forked from boos/BoosAPI
[codex] fix empty stream success handling and unknown usage logs (#343)
* fix: preserve repeated short responses deltas * fix empty stream success handling and unknown usage logs * fix proxy usage metadata typing * fix stream bookkeeping and explicit zero usage source * fix sticky fast path and usage placeholder detection
This commit is contained in:
@@ -534,4 +534,54 @@ describe('stats proxy logs routes', () => {
|
||||
expect(detailBody.clientAppId).toBe(null);
|
||||
expect(detailBody.clientAppName).toBe(null);
|
||||
});
|
||||
|
||||
it('returns unknown usage source and nullable token fields for logs without recovered usage', async () => {
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'unknown-usage-site',
|
||||
url: 'https://unknown-usage.example.com',
|
||||
platform: 'new-api',
|
||||
}).returning().get();
|
||||
|
||||
const account = await db.insert(schema.accounts).values({
|
||||
siteId: site.id,
|
||||
username: 'unknown-usage-user',
|
||||
accessToken: 'unknown-usage-token',
|
||||
status: 'active',
|
||||
}).returning().get();
|
||||
|
||||
await db.insert(schema.proxyLogs).values({
|
||||
accountId: account.id,
|
||||
modelRequested: 'gpt-5',
|
||||
modelActual: 'gpt-5',
|
||||
status: 'success',
|
||||
promptTokens: null,
|
||||
completionTokens: null,
|
||||
totalTokens: null,
|
||||
estimatedCost: 0,
|
||||
errorMessage: '[downstream:/v1/chat/completions] [upstream:/v1/chat/completions] [usage:unknown]',
|
||||
createdAt: formatUtcSqlDateTime(new Date('2026-03-09T12:30:00.000Z')),
|
||||
}).run();
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/stats/proxy-logs',
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const body = response.json() as {
|
||||
items: Array<{
|
||||
usageSource: string | null;
|
||||
promptTokens: number | null;
|
||||
completionTokens: number | null;
|
||||
totalTokens: number | null;
|
||||
}>;
|
||||
};
|
||||
|
||||
expect(body.items[0]).toMatchObject({
|
||||
usageSource: 'unknown',
|
||||
promptTokens: null,
|
||||
completionTokens: null,
|
||||
totalTokens: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -275,6 +275,14 @@ function resolveProxyLogClientMeta(proxyLog: Record<string, unknown>) {
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeProxyLogUsageSource(value: unknown): 'upstream' | 'self-log' | 'unknown' | null {
|
||||
const normalized = normalizeNullableText(value)?.toLowerCase() || null;
|
||||
if (normalized === 'upstream' || normalized === 'self-log' || normalized === 'unknown') {
|
||||
return normalized;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildProxyLogClientOptions(rows: Array<{
|
||||
clientFamily?: string | null;
|
||||
clientAppId?: string | null;
|
||||
@@ -472,6 +480,7 @@ function mapProxyLogRow(
|
||||
options?: { includeBillingDetails?: boolean },
|
||||
) {
|
||||
const clientMeta = resolveProxyLogClientMeta(row.proxy_logs);
|
||||
const legacyMeta = parseProxyLogMessageMeta(typeof row.proxy_logs.errorMessage === 'string' ? row.proxy_logs.errorMessage : '');
|
||||
return {
|
||||
...row.proxy_logs,
|
||||
...(options?.includeBillingDetails
|
||||
@@ -481,6 +490,7 @@ function mapProxyLogRow(
|
||||
clientAppId: clientMeta.clientAppId,
|
||||
clientAppName: clientMeta.clientAppName,
|
||||
clientConfidence: clientMeta.clientConfidence,
|
||||
usageSource: normalizeProxyLogUsageSource(legacyMeta.usageSource),
|
||||
username: row.accounts?.username || null,
|
||||
siteId: row.sites?.id || null,
|
||||
siteName: row.sites?.name || null,
|
||||
|
||||
@@ -169,6 +169,8 @@ describe('chat proxy stream behavior', () => {
|
||||
expect(response.body).toContain('"chat.completion.chunk"');
|
||||
expect(response.body).toContain('hello from upstream');
|
||||
expect(response.body).toContain('data: [DONE]');
|
||||
expect(recordSuccessMock).toHaveBeenCalledTimes(1);
|
||||
expect(recordFailureMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('decodes zstd-compressed non-stream chat responses before serializing downstream JSON', async () => {
|
||||
@@ -347,6 +349,148 @@ describe('chat proxy stream behavior', () => {
|
||||
expect(recordFailureMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('returns HTTP upstream_error when streamed chat SSE yields only empty deltas before DONE', async () => {
|
||||
config.proxyEmptyContentFailEnabled = true;
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const upstreamBody = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(encoder.encode('data: {"id":"chatcmpl-empty-sse","choices":[{"delta":{}}]}\n\n'));
|
||||
controller.enqueue(encoder.encode('data: [DONE]\n\n'));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
|
||||
fetchMock.mockResolvedValue(new Response(upstreamBody, {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'text/event-stream; charset=utf-8' },
|
||||
}));
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1/chat/completions',
|
||||
payload: {
|
||||
model: 'gpt-4o-mini',
|
||||
stream: true,
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(502);
|
||||
expect(response.headers['content-type']).not.toContain('text/event-stream');
|
||||
expect(response.json()?.error?.type).toBe('upstream_error');
|
||||
expect(response.json()?.error?.message).toContain('empty content');
|
||||
expect(recordSuccessMock).not.toHaveBeenCalled();
|
||||
expect(recordFailureMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('returns HTTP upstream_error when streamed chat SSE carries prompt usage but no assistant output', async () => {
|
||||
config.proxyEmptyContentFailEnabled = true;
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const upstreamBody = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(encoder.encode('data: {"id":"chatcmpl-empty-usage","choices":[{"delta":{}}],"usage":{"prompt_tokens":42203,"completion_tokens":0,"total_tokens":42203}}\n\n'));
|
||||
controller.enqueue(encoder.encode('data: [DONE]\n\n'));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
|
||||
fetchMock.mockResolvedValue(new Response(upstreamBody, {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'text/event-stream; charset=utf-8' },
|
||||
}));
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1/chat/completions',
|
||||
payload: {
|
||||
model: 'gpt-5.4',
|
||||
stream: true,
|
||||
messages: [{ role: 'user', content: 'long prompt' }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(502);
|
||||
expect(response.headers['content-type']).not.toContain('text/event-stream');
|
||||
expect(response.json()?.error?.type).toBe('upstream_error');
|
||||
expect(response.json()?.error?.message).toContain('empty content');
|
||||
expect(recordSuccessMock).not.toHaveBeenCalled();
|
||||
expect(recordFailureMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('returns HTTP upstream_error when streamed chat SSE carries completion usage but no assistant output', async () => {
|
||||
config.proxyEmptyContentFailEnabled = true;
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const upstreamBody = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(encoder.encode('data: {"id":"chatcmpl-empty-completion-usage","choices":[{"delta":{}}],"usage":{"prompt_tokens":12,"completion_tokens":3,"total_tokens":15}}\n\n'));
|
||||
controller.enqueue(encoder.encode('data: [DONE]\n\n'));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
|
||||
fetchMock.mockResolvedValue(new Response(upstreamBody, {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'text/event-stream; charset=utf-8' },
|
||||
}));
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1/chat/completions',
|
||||
payload: {
|
||||
model: 'gpt-5.4',
|
||||
stream: true,
|
||||
messages: [{ role: 'user', content: 'long prompt' }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(502);
|
||||
expect(response.headers['content-type']).not.toContain('text/event-stream');
|
||||
expect(response.json()?.error?.type).toBe('upstream_error');
|
||||
expect(response.json()?.error?.message).toContain('empty content');
|
||||
expect(recordSuccessMock).not.toHaveBeenCalled();
|
||||
expect(recordFailureMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('keeps streamed non-SSE chat fallback successful when the final payload has visible output but zero completion usage', async () => {
|
||||
config.proxyEmptyContentFailEnabled = true;
|
||||
|
||||
fetchMock.mockResolvedValue(new Response(JSON.stringify({
|
||||
id: 'chatcmpl-visible-zero-usage',
|
||||
object: 'chat.completion',
|
||||
created: 1_706_000_000,
|
||||
model: 'upstream-gpt',
|
||||
choices: [{
|
||||
index: 0,
|
||||
message: { role: 'assistant', content: 'visible answer despite zero output usage' },
|
||||
finish_reason: 'stop',
|
||||
}],
|
||||
usage: { prompt_tokens: 11, completion_tokens: 0, total_tokens: 11 },
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}));
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1/chat/completions',
|
||||
payload: {
|
||||
model: 'gpt-5.4',
|
||||
stream: true,
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.headers['content-type']).toContain('text/event-stream');
|
||||
expect(response.body).toContain('visible answer despite zero output usage');
|
||||
expect(response.body).toContain('data: [DONE]');
|
||||
expect(recordSuccessMock).toHaveBeenCalledTimes(1);
|
||||
expect(recordFailureMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns clear 400 when /v1/chat/completions receives responses-style input without messages', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { parseProxyLogMetadata, type ParsedProxyLogMetadata } from '../../../shared/proxyLogMeta.js';
|
||||
import {
|
||||
parseProxyLogMetadata,
|
||||
type ParsedProxyLogMetadata,
|
||||
type ProxyLogUsageSource,
|
||||
} from '../../../shared/proxyLogMeta.js';
|
||||
|
||||
type ComposeProxyLogMessageArgs = {
|
||||
clientKind?: string | null;
|
||||
@@ -6,6 +10,7 @@ type ComposeProxyLogMessageArgs = {
|
||||
traceHint?: string | null;
|
||||
downstreamPath?: string | null;
|
||||
upstreamPath?: string | null;
|
||||
usageSource?: ProxyLogUsageSource;
|
||||
errorMessage?: string | null;
|
||||
};
|
||||
|
||||
@@ -21,6 +26,7 @@ export function composeProxyLogMessage({
|
||||
traceHint,
|
||||
downstreamPath,
|
||||
upstreamPath,
|
||||
usageSource,
|
||||
errorMessage,
|
||||
}: ComposeProxyLogMessageArgs): string | null {
|
||||
const rawMessage = typeof errorMessage === 'string' ? errorMessage.trim() : '';
|
||||
@@ -29,6 +35,7 @@ export function composeProxyLogMessage({
|
||||
const finalSessionId = (sessionId || traceHint || parsed.sessionId || '').trim();
|
||||
const finalDownstreamPath = (downstreamPath || parsed.downstreamPath || '').trim();
|
||||
const finalUpstreamPath = (upstreamPath || parsed.upstreamPath || '').trim();
|
||||
const finalUsageSource = (usageSource || parsed.usageSource || '').trim();
|
||||
const finalMessageText = parsed.messageText.trim();
|
||||
|
||||
const prefixParts: string[] = [];
|
||||
@@ -36,6 +43,7 @@ export function composeProxyLogMessage({
|
||||
if (finalSessionId) prefixParts.push(`[session:${finalSessionId}]`);
|
||||
if (finalDownstreamPath) prefixParts.push(`[downstream:${finalDownstreamPath}]`);
|
||||
if (finalUpstreamPath) prefixParts.push(`[upstream:${finalUpstreamPath}]`);
|
||||
if (finalUsageSource) prefixParts.push(`[usage:${finalUsageSource}]`);
|
||||
|
||||
if (prefixParts.length === 0 && !finalMessageText) return null;
|
||||
if (finalMessageText) return `${prefixParts.join(' ')} ${finalMessageText}`.trim();
|
||||
|
||||
@@ -7,6 +7,7 @@ import { resetCodexHttpSessionQueue } from '../../proxy-core/runtime/codexHttpSe
|
||||
const fetchMock = vi.fn();
|
||||
const selectChannelMock = vi.fn();
|
||||
const selectNextChannelMock = vi.fn();
|
||||
const selectPreferredChannelMock = vi.fn();
|
||||
const recordSuccessMock = vi.fn();
|
||||
const recordFailureMock = vi.fn();
|
||||
const refreshModelsAndRebuildRoutesMock = vi.fn();
|
||||
@@ -41,6 +42,7 @@ vi.mock('../../services/tokenRouter.js', () => ({
|
||||
tokenRouter: {
|
||||
selectChannel: (...args: unknown[]) => selectChannelMock(...args),
|
||||
selectNextChannel: (...args: unknown[]) => selectNextChannelMock(...args),
|
||||
selectPreferredChannel: (...args: unknown[]) => selectPreferredChannelMock(...args),
|
||||
recordSuccess: (...args: unknown[]) => recordSuccessMock(...args),
|
||||
recordFailure: (...args: unknown[]) => recordFailureMock(...args),
|
||||
},
|
||||
@@ -157,6 +159,7 @@ describe('responses proxy codex oauth refresh', () => {
|
||||
fetchMock.mockReset();
|
||||
selectChannelMock.mockReset();
|
||||
selectNextChannelMock.mockReset();
|
||||
selectPreferredChannelMock.mockReset();
|
||||
recordSuccessMock.mockReset();
|
||||
recordFailureMock.mockReset();
|
||||
refreshModelsAndRebuildRoutesMock.mockReset();
|
||||
@@ -188,6 +191,7 @@ describe('responses proxy codex oauth refresh', () => {
|
||||
tokenValue: 'expired-access-token',
|
||||
actualModel: 'gpt-5.2-codex',
|
||||
});
|
||||
selectPreferredChannelMock.mockReturnValue(null);
|
||||
selectNextChannelMock.mockReturnValue(null);
|
||||
refreshOauthAccessTokenSingleflightMock.mockResolvedValue({
|
||||
accessToken: 'fresh-access-token',
|
||||
@@ -724,6 +728,77 @@ describe('responses proxy codex oauth refresh', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('rebinds sticky channels after websocket transport fast-path successes', async () => {
|
||||
config.proxyStickySessionEnabled = true;
|
||||
|
||||
const selected = {
|
||||
channel: { id: 11, routeId: 22 },
|
||||
site: { name: 'codex-site', url: 'https://chatgpt.com/backend-api/codex', platform: 'codex' },
|
||||
account: {
|
||||
id: 33,
|
||||
username: 'codex-user@example.com',
|
||||
extraConfig: JSON.stringify({
|
||||
credentialMode: 'session',
|
||||
oauth: {
|
||||
provider: 'codex',
|
||||
accountId: 'chatgpt-account-123',
|
||||
email: 'codex-user@example.com',
|
||||
planType: 'plus',
|
||||
},
|
||||
}),
|
||||
},
|
||||
tokenName: 'default',
|
||||
tokenValue: 'expired-access-token',
|
||||
actualModel: 'gpt-5.2-codex',
|
||||
};
|
||||
selectChannelMock.mockReturnValue(selected);
|
||||
selectPreferredChannelMock.mockReturnValue(selected);
|
||||
|
||||
const stickyHeaders = {
|
||||
'x-metapi-responses-websocket-transport': '1',
|
||||
session_id: 'session-sticky-fast-path-1',
|
||||
};
|
||||
const ssePayload = createSseResponse([
|
||||
'event: response.created\n',
|
||||
'data: {"type":"response.created","response":{"id":"resp_codex_fast_path","model":"gpt-5.4","created_at":1706000000,"status":"in_progress","output":[]}}\n\n',
|
||||
'event: response.completed\n',
|
||||
'data: {"type":"response.completed","response":{"id":"resp_codex_fast_path","model":"gpt-5.4","status":"completed","usage":{"input_tokens":3,"output_tokens":1,"total_tokens":4}}}\n\n',
|
||||
'data: [DONE]\n\n',
|
||||
]);
|
||||
fetchMock.mockResolvedValue(ssePayload);
|
||||
|
||||
const firstResponse = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1/responses',
|
||||
headers: stickyHeaders,
|
||||
payload: {
|
||||
model: 'gpt-5.4',
|
||||
input: 'hello codex',
|
||||
stream: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(firstResponse.statusCode).toBe(200);
|
||||
expect(firstResponse.body).toContain('event: response.completed');
|
||||
expect(selectPreferredChannelMock).not.toHaveBeenCalled();
|
||||
|
||||
const secondResponse = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1/responses',
|
||||
headers: stickyHeaders,
|
||||
payload: {
|
||||
model: 'gpt-5.4',
|
||||
input: 'hello again',
|
||||
stream: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(secondResponse.statusCode).toBe(200);
|
||||
expect(selectPreferredChannelMock).toHaveBeenCalledTimes(1);
|
||||
expect(selectPreferredChannelMock.mock.calls[0]?.[0]).toBe('gpt-5.4');
|
||||
expect(selectPreferredChannelMock.mock.calls[0]?.[1]).toBe(11);
|
||||
});
|
||||
|
||||
it('decodes zstd-compressed codex responses SSE before relaying native downstream streams', async () => {
|
||||
fetchMock.mockResolvedValue(createCompressedSseResponse([
|
||||
'event: response.created\n',
|
||||
@@ -887,6 +962,39 @@ describe('responses proxy codex oauth refresh', () => {
|
||||
expect(String(insertedProxyLogs.at(-1)?.errorMessage || '')).toContain('empty content');
|
||||
});
|
||||
|
||||
it('does not record success when a native responses stream completes with prompt tokens only and no output', async () => {
|
||||
config.proxyEmptyContentFailEnabled = true;
|
||||
|
||||
fetchMock.mockResolvedValue(createSseResponse([
|
||||
'event: response.created\n',
|
||||
'data: {"type":"response.created","response":{"id":"resp_codex_prompt_only","model":"gpt-5.4","created_at":1706000000,"status":"in_progress","output":[]}}\n\n',
|
||||
'event: response.completed\n',
|
||||
'data: {"type":"response.completed","response":{"id":"resp_codex_prompt_only","model":"gpt-5.4","status":"completed","output":[],"output_text":"","usage":{"input_tokens":5,"output_tokens":0,"total_tokens":5}}}\n\n',
|
||||
'data: [DONE]\n\n',
|
||||
]));
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1/responses',
|
||||
payload: {
|
||||
model: 'gpt-5.4',
|
||||
input: 'hello codex',
|
||||
stream: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.body).toContain('event: response.failed');
|
||||
expect(response.body).not.toContain('event: response.completed');
|
||||
expect(recordSuccessMock).not.toHaveBeenCalled();
|
||||
expect(recordFailureMock).toHaveBeenCalledTimes(1);
|
||||
expect(insertedProxyLogs.at(-1)).toMatchObject({
|
||||
status: 'failed',
|
||||
httpStatus: 200,
|
||||
});
|
||||
expect(String(insertedProxyLogs.at(-1)?.errorMessage || '')).toContain('empty content');
|
||||
});
|
||||
|
||||
it('does not retry or mark failure after converting a non-stream upstream payload into SSE when post-stream usage accounting fails', async () => {
|
||||
resolveProxyUsageWithSelfLogFallbackMock.mockRejectedValueOnce(new Error('usage accounting failed'));
|
||||
fetchMock.mockResolvedValue(new Response(JSON.stringify({
|
||||
|
||||
Reference in New Issue
Block a user