[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:
@@ -3,7 +3,7 @@ import type { FastifyRequest, FastifyReply } from 'fastify';
|
|||||||
import { config } from '../../config.js';
|
import { config } from '../../config.js';
|
||||||
import { tokenRouter } from '../../services/tokenRouter.js';
|
import { tokenRouter } from '../../services/tokenRouter.js';
|
||||||
import { reportProxyAllFailed } from '../../services/alertService.js';
|
import { reportProxyAllFailed } from '../../services/alertService.js';
|
||||||
import { mergeProxyUsage, parseProxyUsage } from '../../services/proxyUsageParser.js';
|
import { hasProxyUsagePayload, mergeProxyUsage, parseProxyUsage } from '../../services/proxyUsageParser.js';
|
||||||
import { type DownstreamFormat } from '../../transformers/shared/normalized.js';
|
import { type DownstreamFormat } from '../../transformers/shared/normalized.js';
|
||||||
import {
|
import {
|
||||||
buildClaudeCountTokensUpstreamRequest,
|
buildClaudeCountTokensUpstreamRequest,
|
||||||
@@ -489,7 +489,10 @@ export async function handleChatSurfaceRequest(
|
|||||||
|
|
||||||
if (isStream) {
|
if (isStream) {
|
||||||
const upstreamContentType = (upstream.headers.get('content-type') || '').toLowerCase();
|
const upstreamContentType = (upstream.headers.get('content-type') || '').toLowerCase();
|
||||||
|
let streamStarted = false;
|
||||||
const startSseResponse = () => {
|
const startSseResponse = () => {
|
||||||
|
if (streamStarted) return;
|
||||||
|
streamStarted = true;
|
||||||
reply.hijack();
|
reply.hijack();
|
||||||
reply.raw.statusCode = 200;
|
reply.raw.statusCode = 200;
|
||||||
reply.raw.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
|
reply.raw.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
|
||||||
@@ -506,23 +509,54 @@ export async function handleChatSurfaceRequest(
|
|||||||
cacheCreationTokens: 0,
|
cacheCreationTokens: 0,
|
||||||
promptTokensIncludeCache: null,
|
promptTokensIncludeCache: null,
|
||||||
};
|
};
|
||||||
|
let upstreamUsagePresent = false;
|
||||||
|
const recordStreamSuccess = async (latencyMs: number) => {
|
||||||
|
await recordSurfaceSuccess({
|
||||||
|
selected,
|
||||||
|
requestedModel,
|
||||||
|
modelName,
|
||||||
|
parsedUsage,
|
||||||
|
upstreamUsagePresent,
|
||||||
|
requestStartedAtMs: startTime,
|
||||||
|
latencyMs,
|
||||||
|
retryCount,
|
||||||
|
upstreamPath: successfulUpstreamPath,
|
||||||
|
logSuccess: failureToolkit.log,
|
||||||
|
recordDownstreamCost: (estimatedCost) => {
|
||||||
|
recordDownstreamCostUsage(request, estimatedCost);
|
||||||
|
},
|
||||||
|
bestEffortMetrics: {
|
||||||
|
errorLabel: '[proxy/chat] failed to record success metrics',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const writeLines = (lines: string[]) => {
|
const writeLines = (lines: string[]) => {
|
||||||
|
startSseResponse();
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
reply.raw.write(line);
|
reply.raw.write(line);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
const streamResponse = {
|
||||||
|
end() {
|
||||||
|
if (streamStarted) {
|
||||||
|
reply.raw.end();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
const streamSession = openAiChatTransformer.proxyStream.createSession({
|
const streamSession = openAiChatTransformer.proxyStream.createSession({
|
||||||
downstreamFormat,
|
downstreamFormat,
|
||||||
modelName,
|
modelName,
|
||||||
successfulUpstreamPath,
|
successfulUpstreamPath,
|
||||||
onParsedPayload: (payload) => {
|
onParsedPayload: (payload) => {
|
||||||
if (payload && typeof payload === 'object') {
|
if (payload && typeof payload === 'object') {
|
||||||
|
upstreamUsagePresent = upstreamUsagePresent || hasProxyUsagePayload(payload);
|
||||||
parsedUsage = mergeProxyUsage(parsedUsage, parseProxyUsage(payload));
|
parsedUsage = mergeProxyUsage(parsedUsage, parseProxyUsage(payload));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
writeLines,
|
writeLines,
|
||||||
writeRaw: (chunk) => {
|
writeRaw: (chunk) => {
|
||||||
|
startSseResponse();
|
||||||
reply.raw.write(chunk);
|
reply.raw.write(chunk);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -531,10 +565,9 @@ export async function handleChatSurfaceRequest(
|
|||||||
const fallbackText = await readRuntimeResponseText(upstream);
|
const fallbackText = await readRuntimeResponseText(upstream);
|
||||||
rawText = fallbackText;
|
rawText = fallbackText;
|
||||||
if (looksLikeResponsesSseText(fallbackText)) {
|
if (looksLikeResponsesSseText(fallbackText)) {
|
||||||
startSseResponse();
|
|
||||||
const streamResult = await streamSession.run(
|
const streamResult = await streamSession.run(
|
||||||
createSingleChunkStreamReader(fallbackText),
|
createSingleChunkStreamReader(fallbackText),
|
||||||
reply.raw,
|
streamResponse,
|
||||||
);
|
);
|
||||||
const latency = Date.now() - startTime;
|
const latency = Date.now() - startTime;
|
||||||
if (streamResult.status === 'failed') {
|
if (streamResult.status === 'failed') {
|
||||||
@@ -560,8 +593,17 @@ export async function handleChatSurfaceRequest(
|
|||||||
type: 'stream_error',
|
type: 'stream_error',
|
||||||
},
|
},
|
||||||
}, successfulUpstreamPath);
|
}, successfulUpstreamPath);
|
||||||
|
if (!streamStarted) {
|
||||||
|
return reply.code(502).send({
|
||||||
|
error: {
|
||||||
|
message: streamResult.errorMessage,
|
||||||
|
type: 'upstream_error',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
await recordStreamSuccess(latency);
|
||||||
await finalizeDebugSuccess(
|
await finalizeDebugSuccess(
|
||||||
200,
|
200,
|
||||||
successfulUpstreamPath,
|
successfulUpstreamPath,
|
||||||
@@ -588,6 +630,7 @@ export async function handleChatSurfaceRequest(
|
|||||||
if (String(selected.site.platform || '').trim().toLowerCase() === 'gemini-cli') {
|
if (String(selected.site.platform || '').trim().toLowerCase() === 'gemini-cli') {
|
||||||
fallbackData = unwrapGeminiCliPayload(fallbackData);
|
fallbackData = unwrapGeminiCliPayload(fallbackData);
|
||||||
}
|
}
|
||||||
|
upstreamUsagePresent = upstreamUsagePresent || hasProxyUsagePayload(fallbackData);
|
||||||
parsedUsage = mergeProxyUsage(parsedUsage, parseProxyUsage(fallbackData));
|
parsedUsage = mergeProxyUsage(parsedUsage, parseProxyUsage(fallbackData));
|
||||||
const latency = Date.now() - startTime;
|
const latency = Date.now() - startTime;
|
||||||
const failure = detectProxyFailure({ rawText, usage: parsedUsage });
|
const failure = detectProxyFailure({ rawText, usage: parsedUsage });
|
||||||
@@ -620,8 +663,7 @@ export async function handleChatSurfaceRequest(
|
|||||||
return reply.code(failureOutcome.status).send(failureOutcome.payload);
|
return reply.code(failureOutcome.status).send(failureOutcome.payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
startSseResponse();
|
const streamResult = streamSession.consumeUpstreamFinalPayload(fallbackData, fallbackText, streamResponse);
|
||||||
const streamResult = streamSession.consumeUpstreamFinalPayload(fallbackData, fallbackText, reply.raw);
|
|
||||||
if (streamResult.status === 'failed') {
|
if (streamResult.status === 'failed') {
|
||||||
clearSurfaceStickyChannel({
|
clearSurfaceStickyChannel({
|
||||||
stickySessionKey,
|
stickySessionKey,
|
||||||
@@ -646,8 +688,17 @@ export async function handleChatSurfaceRequest(
|
|||||||
type: 'stream_error',
|
type: 'stream_error',
|
||||||
},
|
},
|
||||||
}, successfulUpstreamPath);
|
}, successfulUpstreamPath);
|
||||||
|
if (!streamStarted) {
|
||||||
|
return reply.code(502).send({
|
||||||
|
error: {
|
||||||
|
message: streamResult.errorMessage,
|
||||||
|
type: 'upstream_error',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
await recordStreamSuccess(latency);
|
||||||
await finalizeDebugSuccess(
|
await finalizeDebugSuccess(
|
||||||
200,
|
200,
|
||||||
successfulUpstreamPath,
|
successfulUpstreamPath,
|
||||||
@@ -663,8 +714,8 @@ export async function handleChatSurfaceRequest(
|
|||||||
stickySessionKey,
|
stickySessionKey,
|
||||||
selected,
|
selected,
|
||||||
});
|
});
|
||||||
|
return;
|
||||||
} else {
|
} else {
|
||||||
startSseResponse();
|
|
||||||
const upstreamReader = getRuntimeResponseReader(upstream);
|
const upstreamReader = getRuntimeResponseReader(upstream);
|
||||||
const baseReader = String(selected.site.platform || '').trim().toLowerCase() === 'gemini-cli' && upstreamReader
|
const baseReader = String(selected.site.platform || '').trim().toLowerCase() === 'gemini-cli' && upstreamReader
|
||||||
? createGeminiCliStreamReader(upstreamReader)
|
? createGeminiCliStreamReader(upstreamReader)
|
||||||
@@ -687,7 +738,7 @@ export async function handleChatSurfaceRequest(
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
: baseReader;
|
: baseReader;
|
||||||
const streamResult = await streamSession.run(reader, reply.raw);
|
const streamResult = await streamSession.run(reader, streamResponse);
|
||||||
rawText += decoder.decode();
|
rawText += decoder.decode();
|
||||||
|
|
||||||
const latency = Date.now() - startTime;
|
const latency = Date.now() - startTime;
|
||||||
@@ -715,6 +766,14 @@ export async function handleChatSurfaceRequest(
|
|||||||
type: 'stream_error',
|
type: 'stream_error',
|
||||||
},
|
},
|
||||||
}, successfulUpstreamPath);
|
}, successfulUpstreamPath);
|
||||||
|
if (!streamStarted) {
|
||||||
|
return reply.code(502).send({
|
||||||
|
error: {
|
||||||
|
message: streamResult.errorMessage,
|
||||||
|
type: 'upstream_error',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -725,23 +784,7 @@ export async function handleChatSurfaceRequest(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const latency = Date.now() - startTime;
|
const latency = Date.now() - startTime;
|
||||||
await recordSurfaceSuccess({
|
await recordStreamSuccess(latency);
|
||||||
selected,
|
|
||||||
requestedModel,
|
|
||||||
modelName,
|
|
||||||
parsedUsage,
|
|
||||||
requestStartedAtMs: startTime,
|
|
||||||
latencyMs: latency,
|
|
||||||
retryCount,
|
|
||||||
upstreamPath: successfulUpstreamPath,
|
|
||||||
logSuccess: failureToolkit.log,
|
|
||||||
recordDownstreamCost: (estimatedCost) => {
|
|
||||||
recordDownstreamCostUsage(request, estimatedCost);
|
|
||||||
},
|
|
||||||
bestEffortMetrics: {
|
|
||||||
errorLabel: '[proxy/chat] failed to record success metrics',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await finalizeDebugSuccess(
|
await finalizeDebugSuccess(
|
||||||
200,
|
200,
|
||||||
successfulUpstreamPath,
|
successfulUpstreamPath,
|
||||||
@@ -786,6 +829,7 @@ export async function handleChatSurfaceRequest(
|
|||||||
|
|
||||||
const latency = Date.now() - startTime;
|
const latency = Date.now() - startTime;
|
||||||
const parsedUsage = parseProxyUsage(upstreamData);
|
const parsedUsage = parseProxyUsage(upstreamData);
|
||||||
|
const upstreamUsagePresent = hasProxyUsagePayload(upstreamData);
|
||||||
const failure = detectProxyFailure({ rawText, usage: parsedUsage });
|
const failure = detectProxyFailure({ rawText, usage: parsedUsage });
|
||||||
if (failure) {
|
if (failure) {
|
||||||
clearSurfaceStickyChannel({
|
clearSurfaceStickyChannel({
|
||||||
@@ -823,6 +867,7 @@ export async function handleChatSurfaceRequest(
|
|||||||
requestedModel,
|
requestedModel,
|
||||||
modelName,
|
modelName,
|
||||||
parsedUsage,
|
parsedUsage,
|
||||||
|
upstreamUsagePresent,
|
||||||
requestStartedAtMs: startTime,
|
requestStartedAtMs: startTime,
|
||||||
latencyMs: latency,
|
latencyMs: latency,
|
||||||
retryCount,
|
retryCount,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { TextDecoder } from 'node:util';
|
|||||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||||
import { config } from '../../config.js';
|
import { config } from '../../config.js';
|
||||||
import { reportProxyAllFailed } from '../../services/alertService.js';
|
import { reportProxyAllFailed } from '../../services/alertService.js';
|
||||||
import { mergeProxyUsage, parseProxyUsage } from '../../services/proxyUsageParser.js';
|
import { hasProxyUsagePayload, mergeProxyUsage, parseProxyUsage } from '../../services/proxyUsageParser.js';
|
||||||
import { openAiResponsesTransformer } from '../../transformers/openai/responses/index.js';
|
import { openAiResponsesTransformer } from '../../transformers/openai/responses/index.js';
|
||||||
import {
|
import {
|
||||||
buildUpstreamEndpointRequest,
|
buildUpstreamEndpointRequest,
|
||||||
@@ -604,13 +604,19 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
|||||||
|
|
||||||
const upstream = endpointResult.upstream;
|
const upstream = endpointResult.upstream;
|
||||||
const successfulUpstreamPath = endpointResult.upstreamPath;
|
const successfulUpstreamPath = endpointResult.upstreamPath;
|
||||||
const finalizeStreamSuccess = async (parsedUsage: UsageSummary, latency: number, streamDebugBody: unknown) => {
|
const finalizeStreamSuccess = async (
|
||||||
|
parsedUsage: UsageSummary,
|
||||||
|
latency: number,
|
||||||
|
streamDebugBody: unknown,
|
||||||
|
upstreamUsagePresent: boolean,
|
||||||
|
) => {
|
||||||
try {
|
try {
|
||||||
await recordSurfaceSuccess({
|
await recordSurfaceSuccess({
|
||||||
selected,
|
selected,
|
||||||
requestedModel,
|
requestedModel,
|
||||||
modelName,
|
modelName,
|
||||||
parsedUsage,
|
parsedUsage,
|
||||||
|
upstreamUsagePresent,
|
||||||
requestStartedAtMs: startTime,
|
requestStartedAtMs: startTime,
|
||||||
latencyMs: latency,
|
latencyMs: latency,
|
||||||
retryCount,
|
retryCount,
|
||||||
@@ -653,6 +659,7 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
|||||||
cacheCreationTokens: 0,
|
cacheCreationTokens: 0,
|
||||||
promptTokensIncludeCache: null,
|
promptTokensIncludeCache: null,
|
||||||
};
|
};
|
||||||
|
let upstreamUsagePresent = false;
|
||||||
const writeLines = (lines: string[]) => {
|
const writeLines = (lines: string[]) => {
|
||||||
for (const line of lines) reply.raw.write(line);
|
for (const line of lines) reply.raw.write(line);
|
||||||
};
|
};
|
||||||
@@ -663,6 +670,7 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
|||||||
getUsage: () => parsedUsage,
|
getUsage: () => parsedUsage,
|
||||||
onParsedPayload: (payload) => {
|
onParsedPayload: (payload) => {
|
||||||
if (payload && typeof payload === 'object') {
|
if (payload && typeof payload === 'object') {
|
||||||
|
upstreamUsagePresent = upstreamUsagePresent || hasProxyUsagePayload(payload);
|
||||||
parsedUsage = mergeProxyUsage(parsedUsage, parseProxyUsage(payload));
|
parsedUsage = mergeProxyUsage(parsedUsage, parseProxyUsage(payload));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -710,6 +718,7 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
|||||||
parsedUsage,
|
parsedUsage,
|
||||||
latency,
|
latency,
|
||||||
debugTrace?.options.captureStreamChunks ? rawText : { stream: true, usage: parsedUsage },
|
debugTrace?.options.captureStreamChunks ? rawText : { stream: true, usage: parsedUsage },
|
||||||
|
upstreamUsagePresent,
|
||||||
);
|
);
|
||||||
bindSurfaceStickyChannel({
|
bindSurfaceStickyChannel({
|
||||||
stickySessionKey,
|
stickySessionKey,
|
||||||
@@ -728,6 +737,7 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
|||||||
}
|
}
|
||||||
|
|
||||||
parsedUsage = parseProxyUsage(upstreamData);
|
parsedUsage = parseProxyUsage(upstreamData);
|
||||||
|
upstreamUsagePresent = upstreamUsagePresent || hasProxyUsagePayload(upstreamData);
|
||||||
const latency = Date.now() - startTime;
|
const latency = Date.now() - startTime;
|
||||||
const failure = detectProxyFailure({ rawText, usage: parsedUsage });
|
const failure = detectProxyFailure({ rawText, usage: parsedUsage });
|
||||||
if (failure) {
|
if (failure) {
|
||||||
@@ -792,6 +802,7 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
|||||||
parsedUsage,
|
parsedUsage,
|
||||||
latency,
|
latency,
|
||||||
debugTrace?.options.captureStreamChunks ? rawText : upstreamData,
|
debugTrace?.options.captureStreamChunks ? rawText : upstreamData,
|
||||||
|
upstreamUsagePresent,
|
||||||
);
|
);
|
||||||
bindSurfaceStickyChannel({
|
bindSurfaceStickyChannel({
|
||||||
stickySessionKey,
|
stickySessionKey,
|
||||||
@@ -808,6 +819,7 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
|||||||
if (looksLikeResponsesSseText(rawText)) {
|
if (looksLikeResponsesSseText(rawText)) {
|
||||||
try {
|
try {
|
||||||
const collectedPayload = collectResponsesFinalPayloadFromSseText(rawText, modelName).payload;
|
const collectedPayload = collectResponsesFinalPayloadFromSseText(rawText, modelName).payload;
|
||||||
|
upstreamUsagePresent = upstreamUsagePresent || hasProxyUsagePayload(collectedPayload);
|
||||||
parsedUsage = mergeProxyUsage(parsedUsage, parseProxyUsage(collectedPayload));
|
parsedUsage = mergeProxyUsage(parsedUsage, parseProxyUsage(collectedPayload));
|
||||||
const createdPayload = {
|
const createdPayload = {
|
||||||
...collectedPayload,
|
...collectedPayload,
|
||||||
@@ -829,7 +841,12 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
|||||||
parsedUsage,
|
parsedUsage,
|
||||||
latency,
|
latency,
|
||||||
debugTrace?.options.captureStreamChunks ? rawText : collectedPayload,
|
debugTrace?.options.captureStreamChunks ? rawText : collectedPayload,
|
||||||
|
upstreamUsagePresent,
|
||||||
);
|
);
|
||||||
|
bindSurfaceStickyChannel({
|
||||||
|
stickySessionKey,
|
||||||
|
selected,
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
} catch {
|
} catch {
|
||||||
// Fall through to the generic stream session for response.failed/error terminals.
|
// Fall through to the generic stream session for response.failed/error terminals.
|
||||||
@@ -867,6 +884,7 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
|||||||
parsedUsage,
|
parsedUsage,
|
||||||
latency,
|
latency,
|
||||||
debugTrace?.options.captureStreamChunks ? rawText : { stream: true, usage: parsedUsage },
|
debugTrace?.options.captureStreamChunks ? rawText : { stream: true, usage: parsedUsage },
|
||||||
|
upstreamUsagePresent,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -937,6 +955,7 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
|||||||
parsedUsage,
|
parsedUsage,
|
||||||
latency,
|
latency,
|
||||||
debugTrace?.options.captureStreamChunks ? rawText : { stream: true, usage: parsedUsage },
|
debugTrace?.options.captureStreamChunks ? rawText : { stream: true, usage: parsedUsage },
|
||||||
|
upstreamUsagePresent,
|
||||||
);
|
);
|
||||||
bindSurfaceStickyChannel({
|
bindSurfaceStickyChannel({
|
||||||
stickySessionKey,
|
stickySessionKey,
|
||||||
@@ -976,6 +995,7 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
|||||||
}
|
}
|
||||||
const latency = Date.now() - startTime;
|
const latency = Date.now() - startTime;
|
||||||
const parsedUsage = parseProxyUsage(upstreamData);
|
const parsedUsage = parseProxyUsage(upstreamData);
|
||||||
|
const upstreamUsagePresent = hasProxyUsagePayload(upstreamData);
|
||||||
const failure = detectProxyFailure({ rawText, usage: parsedUsage });
|
const failure = detectProxyFailure({ rawText, usage: parsedUsage });
|
||||||
if (failure) {
|
if (failure) {
|
||||||
clearSurfaceStickyChannel({
|
clearSurfaceStickyChannel({
|
||||||
@@ -1022,6 +1042,7 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
|||||||
requestedModel,
|
requestedModel,
|
||||||
modelName,
|
modelName,
|
||||||
parsedUsage,
|
parsedUsage,
|
||||||
|
upstreamUsagePresent,
|
||||||
requestStartedAtMs: startTime,
|
requestStartedAtMs: startTime,
|
||||||
latencyMs: latency,
|
latencyMs: latency,
|
||||||
retryCount,
|
retryCount,
|
||||||
|
|||||||
@@ -275,6 +275,7 @@ describe('selectSurfaceChannelForAttempt', () => {
|
|||||||
estimatedCost: 0.42,
|
estimatedCost: 0.42,
|
||||||
billingDetails: { source: 'test' },
|
billingDetails: { source: 'test' },
|
||||||
upstreamPath: '/v1/responses',
|
upstreamPath: '/v1/responses',
|
||||||
|
usageSource: 'self-log',
|
||||||
clientContext: {
|
clientContext: {
|
||||||
clientKind: 'codex',
|
clientKind: 'codex',
|
||||||
clientAppId: 'app-id',
|
clientAppId: 'app-id',
|
||||||
@@ -292,6 +293,7 @@ describe('selectSurfaceChannelForAttempt', () => {
|
|||||||
traceHint: 'trace-1',
|
traceHint: 'trace-1',
|
||||||
downstreamPath: '/v1/chat/completions',
|
downstreamPath: '/v1/chat/completions',
|
||||||
upstreamPath: '/v1/responses',
|
upstreamPath: '/v1/responses',
|
||||||
|
usageSource: 'self-log',
|
||||||
errorMessage: 'upstream failed',
|
errorMessage: 'upstream failed',
|
||||||
});
|
});
|
||||||
expect(insertProxyLogMock).toHaveBeenCalledWith({
|
expect(insertProxyLogMock).toHaveBeenCalledWith({
|
||||||
@@ -828,6 +830,10 @@ describe('selectSurfaceChannelForAttempt', () => {
|
|||||||
promptTokens: 20,
|
promptTokens: 20,
|
||||||
completionTokens: 8,
|
completionTokens: 8,
|
||||||
totalTokens: 28,
|
totalTokens: 28,
|
||||||
|
recoveredFromSelfLog: true,
|
||||||
|
estimatedCostFromQuota: 0.42,
|
||||||
|
selfLogBillingMeta: null,
|
||||||
|
usageSource: 'self-log',
|
||||||
});
|
});
|
||||||
resolveProxyLogBillingMock.mockResolvedValue({
|
resolveProxyLogBillingMock.mockResolvedValue({
|
||||||
estimatedCost: 0.42,
|
estimatedCost: 0.42,
|
||||||
@@ -870,6 +876,7 @@ describe('selectSurfaceChannelForAttempt', () => {
|
|||||||
requestStartedAtMs: 1000,
|
requestStartedAtMs: 1000,
|
||||||
requestEndedAtMs: 1250,
|
requestEndedAtMs: 1250,
|
||||||
localLatencyMs: 250,
|
localLatencyMs: 250,
|
||||||
|
upstreamUsagePresent: true,
|
||||||
usage: {
|
usage: {
|
||||||
promptTokens: 10,
|
promptTokens: 10,
|
||||||
completionTokens: 5,
|
completionTokens: 5,
|
||||||
@@ -889,6 +896,10 @@ describe('selectSurfaceChannelForAttempt', () => {
|
|||||||
promptTokens: 20,
|
promptTokens: 20,
|
||||||
completionTokens: 8,
|
completionTokens: 8,
|
||||||
totalTokens: 28,
|
totalTokens: 28,
|
||||||
|
recoveredFromSelfLog: true,
|
||||||
|
estimatedCostFromQuota: 0.42,
|
||||||
|
selfLogBillingMeta: null,
|
||||||
|
usageSource: 'self-log',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(recordSuccessMock).toHaveBeenCalledWith(11, 250, 0.42, 'upstream-model');
|
expect(recordSuccessMock).toHaveBeenCalledWith(11, 250, 0.42, 'upstream-model');
|
||||||
@@ -911,6 +922,7 @@ describe('selectSurfaceChannelForAttempt', () => {
|
|||||||
promptTokens: 20,
|
promptTokens: 20,
|
||||||
completionTokens: 8,
|
completionTokens: 8,
|
||||||
totalTokens: 28,
|
totalTokens: 28,
|
||||||
|
usageSource: 'self-log',
|
||||||
estimatedCost: 0.42,
|
estimatedCost: 0.42,
|
||||||
billingDetails: { source: 'pricing-test' },
|
billingDetails: { source: 'pricing-test' },
|
||||||
upstreamPath: '/v1/responses',
|
upstreamPath: '/v1/responses',
|
||||||
@@ -920,12 +932,67 @@ describe('selectSurfaceChannelForAttempt', () => {
|
|||||||
promptTokens: 20,
|
promptTokens: 20,
|
||||||
completionTokens: 8,
|
completionTokens: 8,
|
||||||
totalTokens: 28,
|
totalTokens: 28,
|
||||||
|
recoveredFromSelfLog: true,
|
||||||
|
estimatedCostFromQuota: 0.42,
|
||||||
|
selfLogBillingMeta: null,
|
||||||
|
usageSource: 'self-log',
|
||||||
},
|
},
|
||||||
estimatedCost: 0.42,
|
estimatedCost: 0.42,
|
||||||
billingDetails: { source: 'pricing-test' },
|
billingDetails: { source: 'pricing-test' },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('logs unknown usage as null tokens while preserving success bookkeeping', async () => {
|
||||||
|
resolveProxyUsageWithSelfLogFallbackMock.mockResolvedValue({
|
||||||
|
promptTokens: 0,
|
||||||
|
completionTokens: 0,
|
||||||
|
totalTokens: 0,
|
||||||
|
recoveredFromSelfLog: false,
|
||||||
|
estimatedCostFromQuota: 0,
|
||||||
|
selfLogBillingMeta: null,
|
||||||
|
usageSource: 'unknown',
|
||||||
|
});
|
||||||
|
resolveProxyLogBillingMock.mockResolvedValue({
|
||||||
|
estimatedCost: 0,
|
||||||
|
billingDetails: null,
|
||||||
|
});
|
||||||
|
const logSuccess = vi.fn().mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const { recordSurfaceSuccess } = await import('./sharedSurface.js');
|
||||||
|
await recordSurfaceSuccess({
|
||||||
|
selected: {
|
||||||
|
channel: { id: 11, routeId: 22 },
|
||||||
|
account: { id: 33, username: 'oauth-user' },
|
||||||
|
site: { id: 44, url: 'https://upstream.example.com', platform: 'new-api', name: 'Upstream' },
|
||||||
|
tokenValue: 'live-token',
|
||||||
|
tokenName: 'default',
|
||||||
|
actualModel: 'upstream-model',
|
||||||
|
},
|
||||||
|
requestedModel: 'gpt-5.2',
|
||||||
|
modelName: 'upstream-model',
|
||||||
|
parsedUsage: {
|
||||||
|
promptTokens: 0,
|
||||||
|
completionTokens: 0,
|
||||||
|
totalTokens: 0,
|
||||||
|
},
|
||||||
|
requestStartedAtMs: 1000,
|
||||||
|
latencyMs: 250,
|
||||||
|
retryCount: 0,
|
||||||
|
upstreamPath: '/v1/chat/completions',
|
||||||
|
logSuccess,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(resolveProxyUsageWithSelfLogFallbackMock).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
upstreamUsagePresent: false,
|
||||||
|
}));
|
||||||
|
expect(logSuccess).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
promptTokens: null,
|
||||||
|
completionTokens: null,
|
||||||
|
totalTokens: null,
|
||||||
|
usageSource: 'unknown',
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
it('treats success metrics as best-effort when requested', async () => {
|
it('treats success metrics as best-effort when requested', async () => {
|
||||||
resolveProxyUsageWithSelfLogFallbackMock.mockRejectedValueOnce(new Error('billing failed'));
|
resolveProxyUsageWithSelfLogFallbackMock.mockRejectedValueOnce(new Error('billing failed'));
|
||||||
const logSuccess = vi.fn().mockResolvedValue(undefined);
|
const logSuccess = vi.fn().mockResolvedValue(undefined);
|
||||||
@@ -980,6 +1047,7 @@ describe('selectSurfaceChannelForAttempt', () => {
|
|||||||
recoveredFromSelfLog: false,
|
recoveredFromSelfLog: false,
|
||||||
estimatedCostFromQuota: 0,
|
estimatedCostFromQuota: 0,
|
||||||
selfLogBillingMeta: null,
|
selfLogBillingMeta: null,
|
||||||
|
usageSource: 'upstream',
|
||||||
},
|
},
|
||||||
estimatedCost: 0,
|
estimatedCost: 0,
|
||||||
billingDetails: null,
|
billingDetails: null,
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ type SurfaceResolvedUsageSummary = {
|
|||||||
recoveredFromSelfLog: boolean;
|
recoveredFromSelfLog: boolean;
|
||||||
estimatedCostFromQuota: number;
|
estimatedCostFromQuota: number;
|
||||||
selfLogBillingMeta: import('../../services/proxyUsageFallbackService.js').SelfLogBillingMeta | null;
|
selfLogBillingMeta: import('../../services/proxyUsageFallbackService.js').SelfLogBillingMeta | null;
|
||||||
|
usageSource: 'upstream' | 'self-log' | 'unknown';
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function selectSurfaceChannelForAttempt(input: {
|
export async function selectSurfaceChannelForAttempt(input: {
|
||||||
@@ -228,12 +229,13 @@ export async function writeSurfaceProxyLog(input: {
|
|||||||
errorMessage: string | null;
|
errorMessage: string | null;
|
||||||
retryCount: number;
|
retryCount: number;
|
||||||
downstreamPath: string;
|
downstreamPath: string;
|
||||||
promptTokens?: number;
|
promptTokens?: number | null;
|
||||||
completionTokens?: number;
|
completionTokens?: number | null;
|
||||||
totalTokens?: number;
|
totalTokens?: number | null;
|
||||||
estimatedCost?: number;
|
estimatedCost?: number;
|
||||||
billingDetails?: unknown;
|
billingDetails?: unknown;
|
||||||
upstreamPath?: string | null;
|
upstreamPath?: string | null;
|
||||||
|
usageSource?: 'upstream' | 'self-log' | 'unknown' | null;
|
||||||
clientContext?: DownstreamClientContext | null;
|
clientContext?: DownstreamClientContext | null;
|
||||||
downstreamApiKeyId?: number | null;
|
downstreamApiKeyId?: number | null;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
@@ -247,6 +249,7 @@ export async function writeSurfaceProxyLog(input: {
|
|||||||
traceHint: input.clientContext?.traceHint || null,
|
traceHint: input.clientContext?.traceHint || null,
|
||||||
downstreamPath: input.downstreamPath,
|
downstreamPath: input.downstreamPath,
|
||||||
upstreamPath: input.upstreamPath || null,
|
upstreamPath: input.upstreamPath || null,
|
||||||
|
usageSource: input.usageSource || null,
|
||||||
errorMessage: input.errorMessage,
|
errorMessage: input.errorMessage,
|
||||||
});
|
});
|
||||||
await insertProxyLog({
|
await insertProxyLog({
|
||||||
@@ -259,9 +262,9 @@ export async function writeSurfaceProxyLog(input: {
|
|||||||
status: input.status,
|
status: input.status,
|
||||||
httpStatus: input.httpStatus,
|
httpStatus: input.httpStatus,
|
||||||
latencyMs: input.latencyMs,
|
latencyMs: input.latencyMs,
|
||||||
promptTokens: input.promptTokens ?? 0,
|
promptTokens: input.promptTokens ?? null,
|
||||||
completionTokens: input.completionTokens ?? 0,
|
completionTokens: input.completionTokens ?? null,
|
||||||
totalTokens: input.totalTokens ?? 0,
|
totalTokens: input.totalTokens ?? null,
|
||||||
estimatedCost: input.estimatedCost ?? 0,
|
estimatedCost: input.estimatedCost ?? 0,
|
||||||
billingDetails: input.billingDetails ?? null,
|
billingDetails: input.billingDetails ?? null,
|
||||||
clientFamily: input.clientContext?.clientKind || null,
|
clientFamily: input.clientContext?.clientKind || null,
|
||||||
@@ -354,6 +357,7 @@ export async function recordSurfaceSuccess(input: {
|
|||||||
requestedModel: string;
|
requestedModel: string;
|
||||||
modelName: string;
|
modelName: string;
|
||||||
parsedUsage: SurfaceUsageSummary;
|
parsedUsage: SurfaceUsageSummary;
|
||||||
|
upstreamUsagePresent?: boolean;
|
||||||
requestStartedAtMs: number;
|
requestStartedAtMs: number;
|
||||||
latencyMs: number;
|
latencyMs: number;
|
||||||
retryCount: number;
|
retryCount: number;
|
||||||
@@ -366,9 +370,10 @@ export async function recordSurfaceSuccess(input: {
|
|||||||
latencyMs: number;
|
latencyMs: number;
|
||||||
errorMessage: string | null;
|
errorMessage: string | null;
|
||||||
retryCount: number;
|
retryCount: number;
|
||||||
promptTokens?: number;
|
promptTokens?: number | null;
|
||||||
completionTokens?: number;
|
completionTokens?: number | null;
|
||||||
totalTokens?: number;
|
totalTokens?: number | null;
|
||||||
|
usageSource?: 'upstream' | 'self-log' | 'unknown';
|
||||||
estimatedCost?: number;
|
estimatedCost?: number;
|
||||||
billingDetails?: unknown;
|
billingDetails?: unknown;
|
||||||
upstreamPath?: string | null;
|
upstreamPath?: string | null;
|
||||||
@@ -382,6 +387,11 @@ export async function recordSurfaceSuccess(input: {
|
|||||||
estimatedCost: number;
|
estimatedCost: number;
|
||||||
billingDetails: unknown;
|
billingDetails: unknown;
|
||||||
}> {
|
}> {
|
||||||
|
const hasUpstreamUsage = input.upstreamUsagePresent ?? (
|
||||||
|
input.parsedUsage.totalTokens > 0
|
||||||
|
|| input.parsedUsage.promptTokens > 0
|
||||||
|
|| input.parsedUsage.completionTokens > 0
|
||||||
|
);
|
||||||
let resolvedUsage: SurfaceResolvedUsageSummary = {
|
let resolvedUsage: SurfaceResolvedUsageSummary = {
|
||||||
promptTokens: input.parsedUsage.promptTokens,
|
promptTokens: input.parsedUsage.promptTokens,
|
||||||
completionTokens: input.parsedUsage.completionTokens,
|
completionTokens: input.parsedUsage.completionTokens,
|
||||||
@@ -389,6 +399,7 @@ export async function recordSurfaceSuccess(input: {
|
|||||||
recoveredFromSelfLog: false,
|
recoveredFromSelfLog: false,
|
||||||
estimatedCostFromQuota: 0,
|
estimatedCostFromQuota: 0,
|
||||||
selfLogBillingMeta: null,
|
selfLogBillingMeta: null,
|
||||||
|
usageSource: hasUpstreamUsage ? 'upstream' : 'unknown',
|
||||||
};
|
};
|
||||||
let estimatedCost = 0;
|
let estimatedCost = 0;
|
||||||
let billingDetails: unknown = null;
|
let billingDetails: unknown = null;
|
||||||
@@ -403,6 +414,7 @@ export async function recordSurfaceSuccess(input: {
|
|||||||
requestStartedAtMs: input.requestStartedAtMs,
|
requestStartedAtMs: input.requestStartedAtMs,
|
||||||
requestEndedAtMs: input.requestStartedAtMs + input.latencyMs,
|
requestEndedAtMs: input.requestStartedAtMs + input.latencyMs,
|
||||||
localLatencyMs: input.latencyMs,
|
localLatencyMs: input.latencyMs,
|
||||||
|
upstreamUsagePresent: hasUpstreamUsage,
|
||||||
usage: {
|
usage: {
|
||||||
promptTokens: input.parsedUsage.promptTokens,
|
promptTokens: input.parsedUsage.promptTokens,
|
||||||
completionTokens: input.parsedUsage.completionTokens,
|
completionTokens: input.parsedUsage.completionTokens,
|
||||||
@@ -432,6 +444,17 @@ export async function recordSurfaceSuccess(input: {
|
|||||||
input.modelName,
|
input.modelName,
|
||||||
);
|
);
|
||||||
input.recordDownstreamCost?.(estimatedCost);
|
input.recordDownstreamCost?.(estimatedCost);
|
||||||
|
const logTokens = resolvedUsage.usageSource === 'unknown'
|
||||||
|
? {
|
||||||
|
promptTokens: null,
|
||||||
|
completionTokens: null,
|
||||||
|
totalTokens: null,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
promptTokens: resolvedUsage.promptTokens,
|
||||||
|
completionTokens: resolvedUsage.completionTokens,
|
||||||
|
totalTokens: resolvedUsage.totalTokens,
|
||||||
|
};
|
||||||
await input.logSuccess({
|
await input.logSuccess({
|
||||||
selected: input.selected,
|
selected: input.selected,
|
||||||
modelRequested: input.requestedModel,
|
modelRequested: input.requestedModel,
|
||||||
@@ -440,9 +463,10 @@ export async function recordSurfaceSuccess(input: {
|
|||||||
latencyMs: input.latencyMs,
|
latencyMs: input.latencyMs,
|
||||||
errorMessage: null,
|
errorMessage: null,
|
||||||
retryCount: input.retryCount,
|
retryCount: input.retryCount,
|
||||||
promptTokens: resolvedUsage.promptTokens,
|
promptTokens: logTokens.promptTokens,
|
||||||
completionTokens: resolvedUsage.completionTokens,
|
completionTokens: logTokens.completionTokens,
|
||||||
totalTokens: resolvedUsage.totalTokens,
|
totalTokens: logTokens.totalTokens,
|
||||||
|
usageSource: resolvedUsage.usageSource,
|
||||||
estimatedCost,
|
estimatedCost,
|
||||||
billingDetails,
|
billingDetails,
|
||||||
upstreamPath: input.upstreamPath,
|
upstreamPath: input.upstreamPath,
|
||||||
@@ -470,9 +494,9 @@ export function createSurfaceFailureToolkit(input: {
|
|||||||
latencyMs: number;
|
latencyMs: number;
|
||||||
errorMessage: string | null;
|
errorMessage: string | null;
|
||||||
retryCount: number;
|
retryCount: number;
|
||||||
promptTokens?: number;
|
promptTokens?: number | null;
|
||||||
completionTokens?: number;
|
completionTokens?: number | null;
|
||||||
totalTokens?: number;
|
totalTokens?: number | null;
|
||||||
estimatedCost?: number;
|
estimatedCost?: number;
|
||||||
billingDetails?: unknown;
|
billingDetails?: unknown;
|
||||||
upstreamPath?: string | null;
|
upstreamPath?: string | null;
|
||||||
@@ -581,9 +605,9 @@ export function createSurfaceFailureToolkit(input: {
|
|||||||
failure: { status: number; reason: string };
|
failure: { status: number; reason: string };
|
||||||
latencyMs: number;
|
latencyMs: number;
|
||||||
retryCount: number;
|
retryCount: number;
|
||||||
promptTokens?: number;
|
promptTokens?: number | null;
|
||||||
completionTokens?: number;
|
completionTokens?: number | null;
|
||||||
totalTokens?: number;
|
totalTokens?: number | null;
|
||||||
upstreamPath?: string | null;
|
upstreamPath?: string | null;
|
||||||
}): Promise<SurfaceFailureOutcome> {
|
}): Promise<SurfaceFailureOutcome> {
|
||||||
await tokenRouter.recordFailure(args.selected.channel.id, {
|
await tokenRouter.recordFailure(args.selected.channel.id, {
|
||||||
@@ -676,9 +700,9 @@ export function createSurfaceFailureToolkit(input: {
|
|||||||
errorMessage: string | null;
|
errorMessage: string | null;
|
||||||
latencyMs: number;
|
latencyMs: number;
|
||||||
retryCount: number;
|
retryCount: number;
|
||||||
promptTokens?: number;
|
promptTokens?: number | null;
|
||||||
completionTokens?: number;
|
completionTokens?: number | null;
|
||||||
totalTokens?: number;
|
totalTokens?: number | null;
|
||||||
upstreamPath?: string | null;
|
upstreamPath?: string | null;
|
||||||
httpStatus?: number;
|
httpStatus?: number;
|
||||||
runtimeFailureStatus?: number | null;
|
runtimeFailureStatus?: number | null;
|
||||||
|
|||||||
@@ -534,4 +534,54 @@ describe('stats proxy logs routes', () => {
|
|||||||
expect(detailBody.clientAppId).toBe(null);
|
expect(detailBody.clientAppId).toBe(null);
|
||||||
expect(detailBody.clientAppName).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<{
|
function buildProxyLogClientOptions(rows: Array<{
|
||||||
clientFamily?: string | null;
|
clientFamily?: string | null;
|
||||||
clientAppId?: string | null;
|
clientAppId?: string | null;
|
||||||
@@ -472,6 +480,7 @@ function mapProxyLogRow(
|
|||||||
options?: { includeBillingDetails?: boolean },
|
options?: { includeBillingDetails?: boolean },
|
||||||
) {
|
) {
|
||||||
const clientMeta = resolveProxyLogClientMeta(row.proxy_logs);
|
const clientMeta = resolveProxyLogClientMeta(row.proxy_logs);
|
||||||
|
const legacyMeta = parseProxyLogMessageMeta(typeof row.proxy_logs.errorMessage === 'string' ? row.proxy_logs.errorMessage : '');
|
||||||
return {
|
return {
|
||||||
...row.proxy_logs,
|
...row.proxy_logs,
|
||||||
...(options?.includeBillingDetails
|
...(options?.includeBillingDetails
|
||||||
@@ -481,6 +490,7 @@ function mapProxyLogRow(
|
|||||||
clientAppId: clientMeta.clientAppId,
|
clientAppId: clientMeta.clientAppId,
|
||||||
clientAppName: clientMeta.clientAppName,
|
clientAppName: clientMeta.clientAppName,
|
||||||
clientConfidence: clientMeta.clientConfidence,
|
clientConfidence: clientMeta.clientConfidence,
|
||||||
|
usageSource: normalizeProxyLogUsageSource(legacyMeta.usageSource),
|
||||||
username: row.accounts?.username || null,
|
username: row.accounts?.username || null,
|
||||||
siteId: row.sites?.id || null,
|
siteId: row.sites?.id || null,
|
||||||
siteName: row.sites?.name || 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('"chat.completion.chunk"');
|
||||||
expect(response.body).toContain('hello from upstream');
|
expect(response.body).toContain('hello from upstream');
|
||||||
expect(response.body).toContain('data: [DONE]');
|
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 () => {
|
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);
|
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 () => {
|
it('returns clear 400 when /v1/chat/completions receives responses-style input without messages', async () => {
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
method: 'POST',
|
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 = {
|
type ComposeProxyLogMessageArgs = {
|
||||||
clientKind?: string | null;
|
clientKind?: string | null;
|
||||||
@@ -6,6 +10,7 @@ type ComposeProxyLogMessageArgs = {
|
|||||||
traceHint?: string | null;
|
traceHint?: string | null;
|
||||||
downstreamPath?: string | null;
|
downstreamPath?: string | null;
|
||||||
upstreamPath?: string | null;
|
upstreamPath?: string | null;
|
||||||
|
usageSource?: ProxyLogUsageSource;
|
||||||
errorMessage?: string | null;
|
errorMessage?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -21,6 +26,7 @@ export function composeProxyLogMessage({
|
|||||||
traceHint,
|
traceHint,
|
||||||
downstreamPath,
|
downstreamPath,
|
||||||
upstreamPath,
|
upstreamPath,
|
||||||
|
usageSource,
|
||||||
errorMessage,
|
errorMessage,
|
||||||
}: ComposeProxyLogMessageArgs): string | null {
|
}: ComposeProxyLogMessageArgs): string | null {
|
||||||
const rawMessage = typeof errorMessage === 'string' ? errorMessage.trim() : '';
|
const rawMessage = typeof errorMessage === 'string' ? errorMessage.trim() : '';
|
||||||
@@ -29,6 +35,7 @@ export function composeProxyLogMessage({
|
|||||||
const finalSessionId = (sessionId || traceHint || parsed.sessionId || '').trim();
|
const finalSessionId = (sessionId || traceHint || parsed.sessionId || '').trim();
|
||||||
const finalDownstreamPath = (downstreamPath || parsed.downstreamPath || '').trim();
|
const finalDownstreamPath = (downstreamPath || parsed.downstreamPath || '').trim();
|
||||||
const finalUpstreamPath = (upstreamPath || parsed.upstreamPath || '').trim();
|
const finalUpstreamPath = (upstreamPath || parsed.upstreamPath || '').trim();
|
||||||
|
const finalUsageSource = (usageSource || parsed.usageSource || '').trim();
|
||||||
const finalMessageText = parsed.messageText.trim();
|
const finalMessageText = parsed.messageText.trim();
|
||||||
|
|
||||||
const prefixParts: string[] = [];
|
const prefixParts: string[] = [];
|
||||||
@@ -36,6 +43,7 @@ export function composeProxyLogMessage({
|
|||||||
if (finalSessionId) prefixParts.push(`[session:${finalSessionId}]`);
|
if (finalSessionId) prefixParts.push(`[session:${finalSessionId}]`);
|
||||||
if (finalDownstreamPath) prefixParts.push(`[downstream:${finalDownstreamPath}]`);
|
if (finalDownstreamPath) prefixParts.push(`[downstream:${finalDownstreamPath}]`);
|
||||||
if (finalUpstreamPath) prefixParts.push(`[upstream:${finalUpstreamPath}]`);
|
if (finalUpstreamPath) prefixParts.push(`[upstream:${finalUpstreamPath}]`);
|
||||||
|
if (finalUsageSource) prefixParts.push(`[usage:${finalUsageSource}]`);
|
||||||
|
|
||||||
if (prefixParts.length === 0 && !finalMessageText) return null;
|
if (prefixParts.length === 0 && !finalMessageText) return null;
|
||||||
if (finalMessageText) return `${prefixParts.join(' ')} ${finalMessageText}`.trim();
|
if (finalMessageText) return `${prefixParts.join(' ')} ${finalMessageText}`.trim();
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { resetCodexHttpSessionQueue } from '../../proxy-core/runtime/codexHttpSe
|
|||||||
const fetchMock = vi.fn();
|
const fetchMock = vi.fn();
|
||||||
const selectChannelMock = vi.fn();
|
const selectChannelMock = vi.fn();
|
||||||
const selectNextChannelMock = vi.fn();
|
const selectNextChannelMock = vi.fn();
|
||||||
|
const selectPreferredChannelMock = vi.fn();
|
||||||
const recordSuccessMock = vi.fn();
|
const recordSuccessMock = vi.fn();
|
||||||
const recordFailureMock = vi.fn();
|
const recordFailureMock = vi.fn();
|
||||||
const refreshModelsAndRebuildRoutesMock = vi.fn();
|
const refreshModelsAndRebuildRoutesMock = vi.fn();
|
||||||
@@ -41,6 +42,7 @@ vi.mock('../../services/tokenRouter.js', () => ({
|
|||||||
tokenRouter: {
|
tokenRouter: {
|
||||||
selectChannel: (...args: unknown[]) => selectChannelMock(...args),
|
selectChannel: (...args: unknown[]) => selectChannelMock(...args),
|
||||||
selectNextChannel: (...args: unknown[]) => selectNextChannelMock(...args),
|
selectNextChannel: (...args: unknown[]) => selectNextChannelMock(...args),
|
||||||
|
selectPreferredChannel: (...args: unknown[]) => selectPreferredChannelMock(...args),
|
||||||
recordSuccess: (...args: unknown[]) => recordSuccessMock(...args),
|
recordSuccess: (...args: unknown[]) => recordSuccessMock(...args),
|
||||||
recordFailure: (...args: unknown[]) => recordFailureMock(...args),
|
recordFailure: (...args: unknown[]) => recordFailureMock(...args),
|
||||||
},
|
},
|
||||||
@@ -157,6 +159,7 @@ describe('responses proxy codex oauth refresh', () => {
|
|||||||
fetchMock.mockReset();
|
fetchMock.mockReset();
|
||||||
selectChannelMock.mockReset();
|
selectChannelMock.mockReset();
|
||||||
selectNextChannelMock.mockReset();
|
selectNextChannelMock.mockReset();
|
||||||
|
selectPreferredChannelMock.mockReset();
|
||||||
recordSuccessMock.mockReset();
|
recordSuccessMock.mockReset();
|
||||||
recordFailureMock.mockReset();
|
recordFailureMock.mockReset();
|
||||||
refreshModelsAndRebuildRoutesMock.mockReset();
|
refreshModelsAndRebuildRoutesMock.mockReset();
|
||||||
@@ -188,6 +191,7 @@ describe('responses proxy codex oauth refresh', () => {
|
|||||||
tokenValue: 'expired-access-token',
|
tokenValue: 'expired-access-token',
|
||||||
actualModel: 'gpt-5.2-codex',
|
actualModel: 'gpt-5.2-codex',
|
||||||
});
|
});
|
||||||
|
selectPreferredChannelMock.mockReturnValue(null);
|
||||||
selectNextChannelMock.mockReturnValue(null);
|
selectNextChannelMock.mockReturnValue(null);
|
||||||
refreshOauthAccessTokenSingleflightMock.mockResolvedValue({
|
refreshOauthAccessTokenSingleflightMock.mockResolvedValue({
|
||||||
accessToken: 'fresh-access-token',
|
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 () => {
|
it('decodes zstd-compressed codex responses SSE before relaying native downstream streams', async () => {
|
||||||
fetchMock.mockResolvedValue(createCompressedSseResponse([
|
fetchMock.mockResolvedValue(createCompressedSseResponse([
|
||||||
'event: response.created\n',
|
'event: response.created\n',
|
||||||
@@ -887,6 +962,39 @@ describe('responses proxy codex oauth refresh', () => {
|
|||||||
expect(String(insertedProxyLogs.at(-1)?.errorMessage || '')).toContain('empty content');
|
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 () => {
|
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'));
|
resolveProxyUsageWithSelfLogFallbackMock.mockRejectedValueOnce(new Error('usage accounting failed'));
|
||||||
fetchMock.mockResolvedValue(new Response(JSON.stringify({
|
fetchMock.mockResolvedValue(new Response(JSON.stringify({
|
||||||
|
|||||||
@@ -173,6 +173,23 @@ describe('proxyLogStore', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('preserves null token fields instead of coercing unknown usage to zero', async () => {
|
||||||
|
await insertProxyLog({
|
||||||
|
modelRequested: 'gpt-5',
|
||||||
|
promptTokens: null,
|
||||||
|
completionTokens: null,
|
||||||
|
totalTokens: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(dbInsertValuesMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(dbInsertValuesMock.mock.calls[0][0]).toMatchObject({
|
||||||
|
modelRequested: 'gpt-5',
|
||||||
|
promptTokens: null,
|
||||||
|
completionTokens: null,
|
||||||
|
totalTokens: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('retries proxy log inserts without structured client fields when those columns are missing', async () => {
|
it('retries proxy log inserts without structured client fields when those columns are missing', async () => {
|
||||||
hasProxyLogClientColumnsMock.mockResolvedValue(true);
|
hasProxyLogClientColumnsMock.mockResolvedValue(true);
|
||||||
dbInsertRunMock
|
dbInsertRunMock
|
||||||
|
|||||||
@@ -214,9 +214,9 @@ export async function insertProxyLog(input: ProxyLogInsertInput): Promise<void>
|
|||||||
status: input.status ?? null,
|
status: input.status ?? null,
|
||||||
httpStatus: input.httpStatus ?? null,
|
httpStatus: input.httpStatus ?? null,
|
||||||
latencyMs: input.latencyMs ?? null,
|
latencyMs: input.latencyMs ?? null,
|
||||||
promptTokens: input.promptTokens ?? 0,
|
promptTokens: input.promptTokens ?? null,
|
||||||
completionTokens: input.completionTokens ?? 0,
|
completionTokens: input.completionTokens ?? null,
|
||||||
totalTokens: input.totalTokens ?? 0,
|
totalTokens: input.totalTokens ?? null,
|
||||||
estimatedCost: input.estimatedCost ?? 0,
|
estimatedCost: input.estimatedCost ?? 0,
|
||||||
errorMessage: input.errorMessage ?? null,
|
errorMessage: input.errorMessage ?? null,
|
||||||
retryCount: input.retryCount ?? 0,
|
retryCount: input.retryCount ?? 0,
|
||||||
|
|||||||
@@ -129,4 +129,36 @@ describe('proxyUsageFallbackService sub2api', () => {
|
|||||||
estimatedCostFromQuota: 0.143004,
|
estimatedCostFromQuota: 0.143004,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('treats explicit zero upstream usage as upstream instead of unknown', async () => {
|
||||||
|
const result = await resolveProxyUsageWithSelfLogFallback({
|
||||||
|
site: {
|
||||||
|
url: 'https://newapi.example.com',
|
||||||
|
platform: 'new-api',
|
||||||
|
},
|
||||||
|
account: {
|
||||||
|
accessToken: 'jwt-access-token',
|
||||||
|
},
|
||||||
|
modelName: 'gpt-5.4',
|
||||||
|
requestStartedAtMs: Date.parse('2026-03-07T06:12:07.000Z'),
|
||||||
|
requestEndedAtMs: Date.parse('2026-03-07T06:12:30.000Z'),
|
||||||
|
localLatencyMs: 23000,
|
||||||
|
upstreamUsagePresent: true,
|
||||||
|
usage: {
|
||||||
|
promptTokens: 0,
|
||||||
|
completionTokens: 0,
|
||||||
|
totalTokens: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
promptTokens: 0,
|
||||||
|
completionTokens: 0,
|
||||||
|
totalTokens: 0,
|
||||||
|
recoveredFromSelfLog: false,
|
||||||
|
usageSource: 'upstream',
|
||||||
|
});
|
||||||
|
expect(fetchMock).not.toHaveBeenCalled();
|
||||||
|
expect(fetchJsonWithShieldCookieRetryMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ interface ProxyUsageFallbackInput {
|
|||||||
requestStartedAtMs: number;
|
requestStartedAtMs: number;
|
||||||
requestEndedAtMs: number;
|
requestEndedAtMs: number;
|
||||||
localLatencyMs: number;
|
localLatencyMs: number;
|
||||||
|
upstreamUsagePresent?: boolean;
|
||||||
usage: ProxyUsage;
|
usage: ProxyUsage;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,6 +52,7 @@ interface ProxyUsageFallbackResult extends ProxyUsage {
|
|||||||
recoveredFromSelfLog: boolean;
|
recoveredFromSelfLog: boolean;
|
||||||
estimatedCostFromQuota: number;
|
estimatedCostFromQuota: number;
|
||||||
selfLogBillingMeta: SelfLogBillingMeta | null;
|
selfLogBillingMeta: SelfLogBillingMeta | null;
|
||||||
|
usageSource: 'upstream' | 'self-log' | 'unknown';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SelfLogBillingMeta {
|
export interface SelfLogBillingMeta {
|
||||||
@@ -149,11 +151,14 @@ function isUsageMissing(usage: ProxyUsage): boolean {
|
|||||||
export function shouldLookupSelfLog(
|
export function shouldLookupSelfLog(
|
||||||
platform: string,
|
platform: string,
|
||||||
usage: ProxyUsage,
|
usage: ProxyUsage,
|
||||||
|
upstreamUsagePresent?: boolean,
|
||||||
): boolean {
|
): boolean {
|
||||||
const normalizedPlatform = String(platform || '').toLowerCase();
|
const normalizedPlatform = String(platform || '').toLowerCase();
|
||||||
if (!SUPPORTED_USAGE_FALLBACK_PLATFORMS.has(normalizedPlatform)) return false;
|
if (!SUPPORTED_USAGE_FALLBACK_PLATFORMS.has(normalizedPlatform)) return false;
|
||||||
if (ALWAYS_LOOKUP_SELF_LOG_PLATFORMS.has(normalizedPlatform)) return true;
|
if (ALWAYS_LOOKUP_SELF_LOG_PLATFORMS.has(normalizedPlatform)) return true;
|
||||||
return isUsageMissing(usage);
|
const normalizedUsage = normalizeUsage(usage);
|
||||||
|
const hasUpstreamUsage = upstreamUsagePresent ?? !isUsageMissing(normalizedUsage);
|
||||||
|
return !hasUpstreamUsage;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeUrl(url: string): string {
|
function normalizeUrl(url: string): string {
|
||||||
@@ -534,15 +539,18 @@ export async function resolveProxyUsageWithSelfLogFallback(
|
|||||||
input: ProxyUsageFallbackInput,
|
input: ProxyUsageFallbackInput,
|
||||||
): Promise<ProxyUsageFallbackResult> {
|
): Promise<ProxyUsageFallbackResult> {
|
||||||
const normalizedUsage = normalizeUsage(input.usage);
|
const normalizedUsage = normalizeUsage(input.usage);
|
||||||
|
const hasUpstreamUsage = input.upstreamUsagePresent ?? !isUsageMissing(normalizedUsage);
|
||||||
|
const fallbackUsageSource = hasUpstreamUsage ? 'upstream' : 'unknown';
|
||||||
const fallback: ProxyUsageFallbackResult = {
|
const fallback: ProxyUsageFallbackResult = {
|
||||||
...normalizedUsage,
|
...normalizedUsage,
|
||||||
recoveredFromSelfLog: false,
|
recoveredFromSelfLog: false,
|
||||||
estimatedCostFromQuota: 0,
|
estimatedCostFromQuota: 0,
|
||||||
selfLogBillingMeta: null,
|
selfLogBillingMeta: null,
|
||||||
|
usageSource: fallbackUsageSource,
|
||||||
};
|
};
|
||||||
|
|
||||||
const platform = String(input.site.platform || '').toLowerCase();
|
const platform = String(input.site.platform || '').toLowerCase();
|
||||||
if (!shouldLookupSelfLog(platform, normalizedUsage)) {
|
if (!shouldLookupSelfLog(platform, normalizedUsage, hasUpstreamUsage)) {
|
||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -577,6 +585,7 @@ export async function resolveProxyUsageWithSelfLogFallback(
|
|||||||
recoveredFromSelfLog: true,
|
recoveredFromSelfLog: true,
|
||||||
estimatedCostFromQuota: toRecoveredCost(matched),
|
estimatedCostFromQuota: toRecoveredCost(matched),
|
||||||
selfLogBillingMeta: matched.billingMeta,
|
selfLogBillingMeta: matched.billingMeta,
|
||||||
|
usageSource: useMatchedTokens ? 'self-log' : fallbackUsageSource,
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return fallback;
|
return fallback;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import {
|
import {
|
||||||
|
hasProxyUsagePayload,
|
||||||
mergeProxyUsage,
|
mergeProxyUsage,
|
||||||
parseProxyUsage,
|
parseProxyUsage,
|
||||||
pullSseDataEvents,
|
pullSseDataEvents,
|
||||||
@@ -160,6 +161,30 @@ describe('proxyUsageParser', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not treat null placeholders as explicit upstream usage', () => {
|
||||||
|
expect(hasProxyUsagePayload({
|
||||||
|
usage: {
|
||||||
|
total_tokens: null,
|
||||||
|
cache_creation: {},
|
||||||
|
},
|
||||||
|
})).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats explicit zero usage values as upstream usage', () => {
|
||||||
|
expect(hasProxyUsagePayload({
|
||||||
|
usage: {
|
||||||
|
total_tokens: 0,
|
||||||
|
},
|
||||||
|
})).toBe(true);
|
||||||
|
expect(hasProxyUsagePayload({
|
||||||
|
usage: {
|
||||||
|
prompt_tokens_details: {
|
||||||
|
cached_tokens: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it('pulls SSE data events across chunk boundaries', () => {
|
it('pulls SSE data events across chunk boundaries', () => {
|
||||||
const first = pullSseDataEvents('data: {"a":1}\n\ndata: {"b":');
|
const first = pullSseDataEvents('data: {"a":1}\n\ndata: {"b":');
|
||||||
expect(first.events).toEqual(['{"a":1}']);
|
expect(first.events).toEqual(['{"a":1}']);
|
||||||
|
|||||||
@@ -16,6 +16,60 @@ const ZERO_USAGE: ParsedProxyUsage = {
|
|||||||
promptTokensIncludeCache: null,
|
promptTokensIncludeCache: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const USAGE_DIRECT_KEYS = [
|
||||||
|
'prompt_tokens',
|
||||||
|
'promptTokens',
|
||||||
|
'prompt_token_count',
|
||||||
|
'promptTokenCount',
|
||||||
|
'input_tokens',
|
||||||
|
'inputTokens',
|
||||||
|
'input_token_count',
|
||||||
|
'inputTokenCount',
|
||||||
|
'completion_tokens',
|
||||||
|
'completionTokens',
|
||||||
|
'completion_token_count',
|
||||||
|
'completionTokenCount',
|
||||||
|
'candidates_token_count',
|
||||||
|
'candidatesTokenCount',
|
||||||
|
'output_tokens',
|
||||||
|
'outputTokens',
|
||||||
|
'output_token_count',
|
||||||
|
'outputTokenCount',
|
||||||
|
'total_tokens',
|
||||||
|
'totalTokens',
|
||||||
|
'total_token_count',
|
||||||
|
'totalTokenCount',
|
||||||
|
'cache_read_input_tokens',
|
||||||
|
'cacheReadInputTokens',
|
||||||
|
'prompt_cache_hit_tokens',
|
||||||
|
'promptCacheHitTokens',
|
||||||
|
'cached_tokens',
|
||||||
|
'cachedTokens',
|
||||||
|
'cache_read_tokens',
|
||||||
|
'cacheReadTokens',
|
||||||
|
'cache_creation_input_tokens',
|
||||||
|
'cacheCreationInputTokens',
|
||||||
|
'cache_creation_tokens',
|
||||||
|
'cacheCreationTokens',
|
||||||
|
'claude_cache_creation_5_m_tokens',
|
||||||
|
'claudeCacheCreation5mTokens',
|
||||||
|
'claude_cache_creation_1_h_tokens',
|
||||||
|
'claudeCacheCreation1hTokens',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const USAGE_DETAIL_KEYS = [
|
||||||
|
'prompt_tokens_details',
|
||||||
|
'promptTokensDetails',
|
||||||
|
'input_tokens_details',
|
||||||
|
'inputTokensDetails',
|
||||||
|
'completion_tokens_details',
|
||||||
|
'completionTokensDetails',
|
||||||
|
'output_tokens_details',
|
||||||
|
'outputTokensDetails',
|
||||||
|
'cache_creation',
|
||||||
|
'cacheCreation',
|
||||||
|
] as const;
|
||||||
|
|
||||||
function toPositiveInt(value: unknown): number {
|
function toPositiveInt(value: unknown): number {
|
||||||
const n = typeof value === 'number' ? value : Number(value);
|
const n = typeof value === 'number' ? value : Number(value);
|
||||||
if (!Number.isFinite(n)) return 0;
|
if (!Number.isFinite(n)) return 0;
|
||||||
@@ -26,6 +80,24 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|||||||
return !!value && typeof value === 'object';
|
return !!value && typeof value === 'object';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasOwn(record: Record<string, unknown>, key: string): boolean {
|
||||||
|
return Object.prototype.hasOwnProperty.call(record, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasExplicitUsageValue(value: unknown): boolean {
|
||||||
|
if (value === null || value === undefined) return false;
|
||||||
|
if (typeof value === 'number') return Number.isFinite(value);
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
return trimmed.length > 0 && Number.isFinite(Number(trimmed));
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.some((entry) => hasExplicitUsageValue(entry));
|
||||||
|
}
|
||||||
|
if (!isRecord(value)) return false;
|
||||||
|
return Object.values(value).some((entry) => hasExplicitUsageValue(entry));
|
||||||
|
}
|
||||||
|
|
||||||
function collectUsageCandidates(payload: unknown): Array<Record<string, unknown>> {
|
function collectUsageCandidates(payload: unknown): Array<Record<string, unknown>> {
|
||||||
const candidates: Array<Record<string, unknown>> = [];
|
const candidates: Array<Record<string, unknown>> = [];
|
||||||
const visited = new Set<object>();
|
const visited = new Set<object>();
|
||||||
@@ -253,6 +325,15 @@ export function parseProxyUsage(payload: unknown): ParsedProxyUsage {
|
|||||||
return best;
|
return best;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function hasProxyUsagePayload(payload: unknown): boolean {
|
||||||
|
if (!payload || typeof payload !== 'object') return false;
|
||||||
|
const candidates = collectUsageCandidates(payload);
|
||||||
|
return candidates.some((candidate) => (
|
||||||
|
USAGE_DIRECT_KEYS.some((key) => hasOwn(candidate, key) && hasExplicitUsageValue(candidate[key]))
|
||||||
|
|| USAGE_DETAIL_KEYS.some((key) => hasOwn(candidate, key) && hasExplicitUsageValue(candidate[key]))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
export function mergeProxyUsage(base: ParsedProxyUsage, incoming: ParsedProxyUsage): ParsedProxyUsage {
|
export function mergeProxyUsage(base: ParsedProxyUsage, incoming: ParsedProxyUsage): ParsedProxyUsage {
|
||||||
const normalizeUsage = (usage: ParsedProxyUsage): ParsedProxyUsage => ({
|
const normalizeUsage = (usage: ParsedProxyUsage): ParsedProxyUsage => ({
|
||||||
promptTokens: toPositiveInt(usage.promptTokens),
|
promptTokens: toPositiveInt(usage.promptTokens),
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { type DownstreamFormat, type ParsedSseEvent } from '../../shared/normali
|
|||||||
import { createOpenAiChatAggregateState, applyOpenAiChatStreamEvent, finalizeOpenAiChatAggregate } from './aggregator.js';
|
import { createOpenAiChatAggregateState, applyOpenAiChatStreamEvent, finalizeOpenAiChatAggregate } from './aggregator.js';
|
||||||
import { openAiChatOutbound } from './outbound.js';
|
import { openAiChatOutbound } from './outbound.js';
|
||||||
import { openAiChatStream } from './stream.js';
|
import { openAiChatStream } from './stream.js';
|
||||||
|
import { config } from '../../../config.js';
|
||||||
|
|
||||||
type StreamReader = {
|
type StreamReader = {
|
||||||
read(): Promise<{ done: boolean; value?: Uint8Array }>;
|
read(): Promise<{ done: boolean; value?: Uint8Array }>;
|
||||||
@@ -49,6 +50,9 @@ export function createChatProxyStreamSession(input: ChatProxyStreamSessionInput)
|
|||||||
status: 'completed',
|
status: 'completed',
|
||||||
errorMessage: null,
|
errorMessage: null,
|
||||||
};
|
};
|
||||||
|
let terminalNormalizedFinal: ReturnType<typeof openAiChatOutbound.normalizeFinal> | null = null;
|
||||||
|
let forwardedDownstreamOutput = false;
|
||||||
|
const pendingWrites: string[] = [];
|
||||||
|
|
||||||
const extractFailureMessage = (payload: unknown, fallback = 'upstream stream failed'): string => {
|
const extractFailureMessage = (payload: unknown, fallback = 'upstream stream failed'): string => {
|
||||||
if (payload && typeof payload === 'object' && !Array.isArray(payload)) {
|
if (payload && typeof payload === 'object' && !Array.isArray(payload)) {
|
||||||
@@ -76,10 +80,116 @@ export function createChatProxyStreamSession(input: ChatProxyStreamSessionInput)
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const hasMeaningfulChatAggregateOutput = (): boolean => {
|
||||||
|
if (input.downstreamFormat !== 'openai' || !chatAggregateState) return false;
|
||||||
|
for (const choice of chatAggregateState.choices.values()) {
|
||||||
|
if (choice.content.length > 0) return true;
|
||||||
|
if (choice.reasoning.length > 0) return true;
|
||||||
|
if (choice.toolCalls.some((item) => item.id || item.name || item.arguments)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasMeaningfulNormalizedFinalOutput = (): boolean => {
|
||||||
|
if (!terminalNormalizedFinal) return false;
|
||||||
|
const choices = Array.isArray(terminalNormalizedFinal.choices)
|
||||||
|
? terminalNormalizedFinal.choices
|
||||||
|
: [];
|
||||||
|
if (choices.some((choice) => (
|
||||||
|
choice.content.length > 0
|
||||||
|
|| choice.reasoningContent.length > 0
|
||||||
|
|| choice.toolCalls.some((toolCall) => toolCall.id || toolCall.name || toolCall.arguments)
|
||||||
|
))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (terminalNormalizedFinal.content.length > 0) return true;
|
||||||
|
if (terminalNormalizedFinal.reasoningContent.length > 0) return true;
|
||||||
|
return terminalNormalizedFinal.toolCalls.some((toolCall) => toolCall.id || toolCall.name || toolCall.arguments);
|
||||||
|
};
|
||||||
|
|
||||||
|
const flushPendingWrites = () => {
|
||||||
|
if (pendingWrites.length <= 0) return;
|
||||||
|
input.writeLines([...pendingWrites]);
|
||||||
|
pendingWrites.length = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const emitLines = (lines: string[], options?: { meaningful?: boolean; force?: boolean }) => {
|
||||||
|
if (lines.length <= 0) return;
|
||||||
|
if (input.downstreamFormat !== 'openai') {
|
||||||
|
input.writeLines(lines);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (forwardedDownstreamOutput) {
|
||||||
|
input.writeLines(lines);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (options?.force) {
|
||||||
|
pendingWrites.length = 0;
|
||||||
|
forwardedDownstreamOutput = true;
|
||||||
|
input.writeLines(lines);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (options?.meaningful) {
|
||||||
|
forwardedDownstreamOutput = true;
|
||||||
|
flushPendingWrites();
|
||||||
|
input.writeLines(lines);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pendingWrites.push(...lines);
|
||||||
|
};
|
||||||
|
|
||||||
|
const emitRaw = (chunk: string, options?: { meaningful?: boolean; force?: boolean }) => {
|
||||||
|
if (!chunk) return;
|
||||||
|
if (input.downstreamFormat !== 'openai') {
|
||||||
|
input.writeRaw(chunk);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (forwardedDownstreamOutput) {
|
||||||
|
input.writeRaw(chunk);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (options?.force) {
|
||||||
|
pendingWrites.length = 0;
|
||||||
|
forwardedDownstreamOutput = true;
|
||||||
|
input.writeRaw(chunk);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (options?.meaningful) {
|
||||||
|
forwardedDownstreamOutput = true;
|
||||||
|
flushPendingWrites();
|
||||||
|
input.writeRaw(chunk);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pendingWrites.push(chunk);
|
||||||
|
};
|
||||||
|
|
||||||
|
const shouldFailEmptyChatCompletion = (): boolean => {
|
||||||
|
if (!config.proxyEmptyContentFailEnabled) return false;
|
||||||
|
if (input.downstreamFormat !== 'openai') return false;
|
||||||
|
if (terminalResult.status === 'failed') return false;
|
||||||
|
if (hasMeaningfulChatAggregateOutput()) return false;
|
||||||
|
if (hasMeaningfulNormalizedFinalOutput()) return false;
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
const finalize = () => {
|
const finalize = () => {
|
||||||
if (finalized) return;
|
if (finalized) return;
|
||||||
finalized = true;
|
finalized = true;
|
||||||
|
|
||||||
|
if (shouldFailEmptyChatCompletion()) {
|
||||||
|
markFailed({
|
||||||
|
error: {
|
||||||
|
message: 'Upstream returned empty content',
|
||||||
|
},
|
||||||
|
}, 'Upstream returned empty content');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.downstreamFormat === 'openai' && !forwardedDownstreamOutput) {
|
||||||
|
forwardedDownstreamOutput = true;
|
||||||
|
flushPendingWrites();
|
||||||
|
}
|
||||||
|
|
||||||
// For native Anthropic streams, EOF without message_stop is not a clean
|
// For native Anthropic streams, EOF without message_stop is not a clean
|
||||||
// completion. Forward the partial stream as-is instead of fabricating an
|
// completion. Forward the partial stream as-is instead of fabricating an
|
||||||
// end_turn/message_stop pair that makes clients think the run finished.
|
// end_turn/message_stop pair that makes clients think the run finished.
|
||||||
@@ -108,12 +218,12 @@ export function createChatProxyStreamSession(input: ChatProxyStreamSessionInput)
|
|||||||
}),
|
}),
|
||||||
).slice(-1)[0];
|
).slice(-1)[0];
|
||||||
if (terminalChunk) {
|
if (terminalChunk) {
|
||||||
input.writeLines([`data: ${JSON.stringify(terminalChunk)}\n\n`]);
|
emitLines([`data: ${JSON.stringify(terminalChunk)}\n\n`], { meaningful: true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
input.writeLines(downstreamTransformer.serializeDone(streamContext, claudeContext));
|
emitLines(downstreamTransformer.serializeDone(streamContext, claudeContext), { meaningful: true });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEventBlock = async (eventBlock: ParsedSseEvent): Promise<boolean> => {
|
const handleEventBlock = async (eventBlock: ParsedSseEvent): Promise<boolean> => {
|
||||||
@@ -153,19 +263,26 @@ export function createChatProxyStreamSession(input: ChatProxyStreamSessionInput)
|
|||||||
const payloadType = typeof (parsedPayload as Record<string, unknown>).type === 'string'
|
const payloadType = typeof (parsedPayload as Record<string, unknown>).type === 'string'
|
||||||
? String((parsedPayload as Record<string, unknown>).type)
|
? String((parsedPayload as Record<string, unknown>).type)
|
||||||
: '';
|
: '';
|
||||||
if (payloadType === 'response.failed' || payloadType === 'error') {
|
const isFailurePayload = payloadType === 'response.failed' || payloadType === 'error';
|
||||||
|
if (isFailurePayload) {
|
||||||
markFailed(parsedPayload);
|
markFailed(parsedPayload);
|
||||||
}
|
}
|
||||||
const normalizedEvent = downstreamTransformer.transformStreamEvent(parsedPayload, streamContext, input.modelName);
|
const normalizedEvent = downstreamTransformer.transformStreamEvent(parsedPayload, streamContext, input.modelName);
|
||||||
if (input.downstreamFormat === 'openai' && chatAggregateState) {
|
if (input.downstreamFormat === 'openai' && chatAggregateState) {
|
||||||
applyOpenAiChatStreamEvent(chatAggregateState, normalizedEvent);
|
applyOpenAiChatStreamEvent(chatAggregateState, normalizedEvent);
|
||||||
}
|
}
|
||||||
input.writeLines(downstreamTransformer.serializeStreamEvent(normalizedEvent, streamContext, claudeContext));
|
emitLines(
|
||||||
|
downstreamTransformer.serializeStreamEvent(normalizedEvent, streamContext, claudeContext),
|
||||||
|
{
|
||||||
|
meaningful: hasMeaningfulChatAggregateOutput(),
|
||||||
|
force: isFailurePayload,
|
||||||
|
},
|
||||||
|
);
|
||||||
return input.downstreamFormat === 'claude' && claudeContext.doneSent;
|
return input.downstreamFormat === 'claude' && claudeContext.doneSent;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (input.downstreamFormat === 'openai') {
|
if (input.downstreamFormat === 'openai') {
|
||||||
input.writeRaw(`data: ${eventBlock.data}\n\n`);
|
emitRaw(`data: ${eventBlock.data}\n\n`, { meaningful: true });
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,16 +307,18 @@ export function createChatProxyStreamSession(input: ChatProxyStreamSessionInput)
|
|||||||
}
|
}
|
||||||
if (input.downstreamFormat === 'openai') {
|
if (input.downstreamFormat === 'openai') {
|
||||||
const normalizedFinal = openAiChatOutbound.normalizeFinal(payload, input.modelName, fallbackText);
|
const normalizedFinal = openAiChatOutbound.normalizeFinal(payload, input.modelName, fallbackText);
|
||||||
|
terminalNormalizedFinal = normalizedFinal;
|
||||||
streamContext.id = normalizedFinal.id;
|
streamContext.id = normalizedFinal.id;
|
||||||
streamContext.model = normalizedFinal.model;
|
streamContext.model = normalizedFinal.model;
|
||||||
streamContext.created = normalizedFinal.created;
|
streamContext.created = normalizedFinal.created;
|
||||||
input.writeLines(
|
emitLines(
|
||||||
openAiChatOutbound
|
openAiChatOutbound
|
||||||
.buildSyntheticChunks(normalizedFinal)
|
.buildSyntheticChunks(normalizedFinal)
|
||||||
.map((chunk) => `data: ${JSON.stringify(chunk)}\n\n`),
|
.map((chunk) => `data: ${JSON.stringify(chunk)}\n\n`),
|
||||||
|
{ meaningful: true },
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
input.writeLines(
|
emitLines(
|
||||||
anthropicMessagesTransformer.serializeUpstreamFinalAsStream(
|
anthropicMessagesTransformer.serializeUpstreamFinalAsStream(
|
||||||
payload,
|
payload,
|
||||||
input.modelName,
|
input.modelName,
|
||||||
@@ -207,6 +326,7 @@ export function createChatProxyStreamSession(input: ChatProxyStreamSessionInput)
|
|||||||
streamContext,
|
streamContext,
|
||||||
claudeContext,
|
claudeContext,
|
||||||
),
|
),
|
||||||
|
{ meaningful: true },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
finalize();
|
finalize();
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ function shouldFailEmptyResponsesCompletion(input: {
|
|||||||
: null;
|
: null;
|
||||||
if (hasMeaningfulAggregateOutput(input.state)) return false;
|
if (hasMeaningfulAggregateOutput(input.state)) return false;
|
||||||
if (responsePayload && hasMeaningfulResponsesPayloadOutput(responsePayload)) return false;
|
if (responsePayload && hasMeaningfulResponsesPayloadOutput(responsePayload)) return false;
|
||||||
return input.usage.completionTokens <= 0 && input.usage.totalTokens <= 0;
|
return input.usage.completionTokens <= 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getResponsesStreamFailureMessage(payload: unknown, fallback = 'upstream stream failed'): string {
|
function getResponsesStreamFailureMessage(payload: unknown, fallback = 'upstream stream failed'): string {
|
||||||
|
|||||||
Vendored
+3
@@ -1,8 +1,11 @@
|
|||||||
|
export type ProxyLogUsageSource = 'upstream' | 'self-log' | 'unknown' | null;
|
||||||
|
|
||||||
export type ParsedProxyLogMetadata = {
|
export type ParsedProxyLogMetadata = {
|
||||||
clientKind: string | null;
|
clientKind: string | null;
|
||||||
sessionId: string | null;
|
sessionId: string | null;
|
||||||
downstreamPath: string | null;
|
downstreamPath: string | null;
|
||||||
upstreamPath: string | null;
|
upstreamPath: string | null;
|
||||||
|
usageSource: ProxyLogUsageSource;
|
||||||
messageText: string;
|
messageText: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,9 @@ export function parseProxyLogMetadata(rawMessage) {
|
|||||||
const sessionMatch = rawMessage.match(/\[session:([^\]]+)\]/i);
|
const sessionMatch = rawMessage.match(/\[session:([^\]]+)\]/i);
|
||||||
const downstreamMatch = rawMessage.match(/\[downstream:([^\]]+)\]/i);
|
const downstreamMatch = rawMessage.match(/\[downstream:([^\]]+)\]/i);
|
||||||
const upstreamMatch = rawMessage.match(/\[upstream:([^\]]+)\]/i);
|
const upstreamMatch = rawMessage.match(/\[upstream:([^\]]+)\]/i);
|
||||||
|
const usageMatch = rawMessage.match(/\[usage:([^\]]+)\]/i);
|
||||||
const messageText = rawMessage.replace(
|
const messageText = rawMessage.replace(
|
||||||
/^\s*(?:\[(?:client|session|downstream|upstream):[^\]]+\]\s*)+/i,
|
/^\s*(?:\[(?:client|session|downstream|upstream|usage):[^\]]+\]\s*)+/i,
|
||||||
'',
|
'',
|
||||||
).trim();
|
).trim();
|
||||||
|
|
||||||
@@ -13,6 +14,7 @@ export function parseProxyLogMetadata(rawMessage) {
|
|||||||
sessionId: sessionMatch?.[1]?.trim() || null,
|
sessionId: sessionMatch?.[1]?.trim() || null,
|
||||||
downstreamPath: downstreamMatch?.[1]?.trim() || null,
|
downstreamPath: downstreamMatch?.[1]?.trim() || null,
|
||||||
upstreamPath: upstreamMatch?.[1]?.trim() || null,
|
upstreamPath: upstreamMatch?.[1]?.trim() || null,
|
||||||
|
usageSource: usageMatch?.[1]?.trim() || null,
|
||||||
messageText,
|
messageText,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,12 +5,13 @@ import { parseProxyLogMetadata } from './proxyLogMeta.js';
|
|||||||
describe('proxyLogMeta', () => {
|
describe('proxyLogMeta', () => {
|
||||||
it('parses all supported proxy log prefixes', () => {
|
it('parses all supported proxy log prefixes', () => {
|
||||||
expect(parseProxyLogMetadata(
|
expect(parseProxyLogMetadata(
|
||||||
'[client:codex] [session:turn-1] [downstream:/v1/responses] [upstream:/responses] boom',
|
'[client:codex] [session:turn-1] [downstream:/v1/responses] [upstream:/responses] [usage:self-log] boom',
|
||||||
)).toEqual({
|
)).toEqual({
|
||||||
clientKind: 'codex',
|
clientKind: 'codex',
|
||||||
sessionId: 'turn-1',
|
sessionId: 'turn-1',
|
||||||
downstreamPath: '/v1/responses',
|
downstreamPath: '/v1/responses',
|
||||||
upstreamPath: '/responses',
|
upstreamPath: '/responses',
|
||||||
|
usageSource: 'self-log',
|
||||||
messageText: 'boom',
|
messageText: 'boom',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -21,6 +22,7 @@ describe('proxyLogMeta', () => {
|
|||||||
sessionId: null,
|
sessionId: null,
|
||||||
downstreamPath: null,
|
downstreamPath: null,
|
||||||
upstreamPath: null,
|
upstreamPath: null,
|
||||||
|
usageSource: null,
|
||||||
messageText: 'network timeout',
|
messageText: 'network timeout',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -31,6 +33,7 @@ describe('proxyLogMeta', () => {
|
|||||||
sessionId: 'turn-1',
|
sessionId: 'turn-1',
|
||||||
downstreamPath: null,
|
downstreamPath: null,
|
||||||
upstreamPath: null,
|
upstreamPath: null,
|
||||||
|
usageSource: null,
|
||||||
messageText: 'boom',
|
messageText: 'boom',
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -39,6 +42,7 @@ describe('proxyLogMeta', () => {
|
|||||||
sessionId: null,
|
sessionId: null,
|
||||||
downstreamPath: null,
|
downstreamPath: null,
|
||||||
upstreamPath: null,
|
upstreamPath: null,
|
||||||
|
usageSource: null,
|
||||||
messageText: 'boom',
|
messageText: 'boom',
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -47,6 +51,7 @@ describe('proxyLogMeta', () => {
|
|||||||
sessionId: '1',
|
sessionId: '1',
|
||||||
downstreamPath: null,
|
downstreamPath: null,
|
||||||
upstreamPath: '/x',
|
upstreamPath: '/x',
|
||||||
|
usageSource: null,
|
||||||
messageText: 'msg',
|
messageText: 'msg',
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -55,6 +60,7 @@ describe('proxyLogMeta', () => {
|
|||||||
sessionId: null,
|
sessionId: null,
|
||||||
downstreamPath: null,
|
downstreamPath: null,
|
||||||
upstreamPath: null,
|
upstreamPath: null,
|
||||||
|
usageSource: null,
|
||||||
messageText: '',
|
messageText: '',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -393,6 +393,7 @@ export type RuntimeSettingsPayload = {
|
|||||||
|
|
||||||
export type ProxyLogStatusFilter = 'all' | 'success' | 'failed';
|
export type ProxyLogStatusFilter = 'all' | 'success' | 'failed';
|
||||||
export type ProxyLogClientConfidence = 'exact' | 'heuristic' | 'unknown' | null;
|
export type ProxyLogClientConfidence = 'exact' | 'heuristic' | 'unknown' | null;
|
||||||
|
export type ProxyLogUsageSource = 'upstream' | 'self-log' | 'unknown' | null;
|
||||||
|
|
||||||
export type ProxyLogBillingDetails = {
|
export type ProxyLogBillingDetails = {
|
||||||
quotaType: number;
|
quotaType: number;
|
||||||
@@ -448,6 +449,7 @@ export type ProxyLogListItem = {
|
|||||||
clientAppId?: string | null;
|
clientAppId?: string | null;
|
||||||
clientAppName?: string | null;
|
clientAppName?: string | null;
|
||||||
clientConfidence?: ProxyLogClientConfidence;
|
clientConfidence?: ProxyLogClientConfidence;
|
||||||
|
usageSource?: ProxyLogUsageSource;
|
||||||
promptTokens?: number | null;
|
promptTokens?: number | null;
|
||||||
completionTokens?: number | null;
|
completionTokens?: number | null;
|
||||||
estimatedCost?: number | null;
|
estimatedCost?: number | null;
|
||||||
|
|||||||
@@ -955,6 +955,66 @@ describe('ProxyLogs server-driven page', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('renders unknown usage as -- instead of 0 in the server-driven table', async () => {
|
||||||
|
apiMock.getProxyLogs.mockResolvedValue(buildListResponse({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 101,
|
||||||
|
createdAt: '2026-03-09 16:00:00',
|
||||||
|
modelRequested: 'gpt-5',
|
||||||
|
modelActual: 'gpt-5',
|
||||||
|
status: 'success',
|
||||||
|
latencyMs: 120,
|
||||||
|
promptTokens: null,
|
||||||
|
completionTokens: null,
|
||||||
|
totalTokens: null,
|
||||||
|
usageSource: 'unknown',
|
||||||
|
retryCount: 0,
|
||||||
|
estimatedCost: 0,
|
||||||
|
errorMessage: '[downstream:/v1/chat/completions] [upstream:/v1/chat/completions] [usage:unknown]',
|
||||||
|
username: 'tester',
|
||||||
|
siteName: 'main-site',
|
||||||
|
siteUrl: 'https://main-site.example.com',
|
||||||
|
clientFamily: 'codex',
|
||||||
|
clientAppId: 'cherry_studio',
|
||||||
|
clientAppName: 'Cherry Studio',
|
||||||
|
clientConfidence: 'heuristic',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
summary: {
|
||||||
|
totalCount: 1,
|
||||||
|
successCount: 1,
|
||||||
|
failedCount: 0,
|
||||||
|
totalCost: 0,
|
||||||
|
totalTokensAll: 0,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
let root!: WebTestRenderer;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await act(async () => {
|
||||||
|
root = create(
|
||||||
|
<MemoryRouter initialEntries={['/logs']}>
|
||||||
|
<ToastProvider>
|
||||||
|
<ProxyLogs />
|
||||||
|
</ToastProvider>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
const row = root!.root.find((node) => (
|
||||||
|
node.type === 'tr' && node.props['data-testid'] === 'proxy-log-row-101'
|
||||||
|
));
|
||||||
|
const rowText = collectText(row);
|
||||||
|
expect(rowText).toContain('--');
|
||||||
|
expect(rowText).not.toContain('输入0');
|
||||||
|
} finally {
|
||||||
|
root?.unmount();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('hydrates site and time filters from the route query', async () => {
|
it('hydrates site and time filters from the route query', async () => {
|
||||||
let root!: WebTestRenderer;
|
let root!: WebTestRenderer;
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
type ProxyLogListItem,
|
type ProxyLogListItem,
|
||||||
type ProxyLogsSummary,
|
type ProxyLogsSummary,
|
||||||
type ProxyLogStatusFilter,
|
type ProxyLogStatusFilter,
|
||||||
|
type ProxyLogUsageSource,
|
||||||
} from '../api.js';
|
} from '../api.js';
|
||||||
import { useToast } from '../components/Toast.js';
|
import { useToast } from '../components/Toast.js';
|
||||||
import { ModelBadge } from '../components/BrandIcon.js';
|
import { ModelBadge } from '../components/BrandIcon.js';
|
||||||
@@ -280,6 +281,17 @@ function formatBillingDetailSummary(log: ProxyLogRenderItem) {
|
|||||||
return `模型倍率 ${formatCompactNumber(detail.pricing.modelRatio)},输出倍率 ${formatCompactNumber(detail.pricing.completionRatio)},缓存倍率 ${formatCompactNumber(detail.pricing.cacheRatio)},缓存创建倍率 ${formatCompactNumber(detail.pricing.cacheCreationRatio)},分组倍率 ${formatCompactNumber(detail.pricing.groupRatio)}`;
|
return `模型倍率 ${formatCompactNumber(detail.pricing.modelRatio)},输出倍率 ${formatCompactNumber(detail.pricing.completionRatio)},缓存倍率 ${formatCompactNumber(detail.pricing.cacheRatio)},缓存创建倍率 ${formatCompactNumber(detail.pricing.cacheCreationRatio)},分组倍率 ${formatCompactNumber(detail.pricing.groupRatio)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatProxyLogUsageSource(source: ProxyLogUsageSource | undefined): string | null {
|
||||||
|
if (source === 'upstream') return '上游返回';
|
||||||
|
if (source === 'self-log') return '站点日志回填';
|
||||||
|
if (source === 'unknown') return '未知';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatProxyLogTokenValue(value: number | null | undefined): string {
|
||||||
|
return typeof value === 'number' ? value.toLocaleString() : '--';
|
||||||
|
}
|
||||||
|
|
||||||
function renderDownstreamKeySummary(log: ProxyLogRenderItem) {
|
function renderDownstreamKeySummary(log: ProxyLogRenderItem) {
|
||||||
const parts = [
|
const parts = [
|
||||||
log.downstreamKeyName ? `下游 Key: ${log.downstreamKeyName}` : null,
|
log.downstreamKeyName ? `下游 Key: ${log.downstreamKeyName}` : null,
|
||||||
@@ -1905,13 +1917,13 @@ export default function ProxyLogs() {
|
|||||||
<div className="mobile-summary-metric-label">用时</div>
|
<div className="mobile-summary-metric-label">用时</div>
|
||||||
<div className="mobile-summary-metric-value">{formatLatency(log.latencyMs)}</div>
|
<div className="mobile-summary-metric-value">{formatLatency(log.latencyMs)}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mobile-summary-metric">
|
<div className="mobile-summary-metric">
|
||||||
<div className="mobile-summary-metric-label">输入</div>
|
<div className="mobile-summary-metric-label">输入</div>
|
||||||
<div className="mobile-summary-metric-value">{log.promptTokens?.toLocaleString() || '-'}</div>
|
<div className="mobile-summary-metric-value">{formatProxyLogTokenValue(log.promptTokens)}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mobile-summary-metric">
|
<div className="mobile-summary-metric">
|
||||||
<div className="mobile-summary-metric-label">输出</div>
|
<div className="mobile-summary-metric-label">输出</div>
|
||||||
<div className="mobile-summary-metric-value">{log.completionTokens?.toLocaleString() || '-'}</div>
|
<div className="mobile-summary-metric-value">{formatProxyLogTokenValue(log.completionTokens)}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mobile-summary-metric">
|
<div className="mobile-summary-metric">
|
||||||
<div className="mobile-summary-metric-label">花费</div>
|
<div className="mobile-summary-metric-label">花费</div>
|
||||||
@@ -1923,6 +1935,7 @@ export default function ProxyLogs() {
|
|||||||
<MobileField label="时间" value={formatDateTimeLocal(log.createdAt)} />
|
<MobileField label="时间" value={formatDateTimeLocal(log.createdAt)} />
|
||||||
<MobileField label="站点" value={<SiteBadgeLink siteId={siteIdByName.get(String(log.siteName || '').trim())} siteName={log.siteName} badgeStyle={{ fontSize: 11 }} />} />
|
<MobileField label="站点" value={<SiteBadgeLink siteId={siteIdByName.get(String(log.siteName || '').trim())} siteName={log.siteName} badgeStyle={{ fontSize: 11 }} />} />
|
||||||
<MobileField label="重试" value={log.retryCount > 0 ? log.retryCount : 0} />
|
<MobileField label="重试" value={log.retryCount > 0 ? log.retryCount : 0} />
|
||||||
|
<MobileField label="用量来源" value={formatProxyLogUsageSource(detailLog.usageSource ?? pathMeta.usageSource) || '--'} />
|
||||||
{detailState?.loading && <div style={{ color: 'var(--color-text-muted)' }}>加载详情中...</div>}
|
{detailState?.loading && <div style={{ color: 'var(--color-text-muted)' }}>加载详情中...</div>}
|
||||||
{detailState?.error && <div style={{ color: 'var(--color-danger)' }}>{detailState.error}</div>}
|
{detailState?.error && <div style={{ color: 'var(--color-danger)' }}>{detailState.error}</div>}
|
||||||
{billingDetailSummary && <div style={{ color: 'var(--color-text-muted)' }}>{billingDetailSummary}</div>}
|
{billingDetailSummary && <div style={{ color: 'var(--color-text-muted)' }}>{billingDetailSummary}</div>}
|
||||||
@@ -2033,10 +2046,10 @@ export default function ProxyLogs() {
|
|||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td style={{ textAlign: 'right', fontSize: 12, fontVariantNumeric: 'tabular-nums', color: 'var(--color-text-secondary)' }}>
|
<td style={{ textAlign: 'right', fontSize: 12, fontVariantNumeric: 'tabular-nums', color: 'var(--color-text-secondary)' }}>
|
||||||
{log.promptTokens?.toLocaleString() || '-'}
|
{formatProxyLogTokenValue(log.promptTokens)}
|
||||||
</td>
|
</td>
|
||||||
<td style={{ textAlign: 'right', fontSize: 12, fontVariantNumeric: 'tabular-nums', color: 'var(--color-text-secondary)' }}>
|
<td style={{ textAlign: 'right', fontSize: 12, fontVariantNumeric: 'tabular-nums', color: 'var(--color-text-secondary)' }}>
|
||||||
{log.completionTokens?.toLocaleString() || '-'}
|
{formatProxyLogTokenValue(log.completionTokens)}
|
||||||
</td>
|
</td>
|
||||||
<td style={{ textAlign: 'right', fontSize: 12, fontVariantNumeric: 'tabular-nums', fontWeight: 500 }}>
|
<td style={{ textAlign: 'right', fontSize: 12, fontVariantNumeric: 'tabular-nums', fontWeight: 500 }}>
|
||||||
{typeof log.estimatedCost === 'number' ? `$${log.estimatedCost.toFixed(6)}` : '-'}
|
{typeof log.estimatedCost === 'number' ? `$${log.estimatedCost.toFixed(6)}` : '-'}
|
||||||
@@ -2084,6 +2097,9 @@ export default function ProxyLogs() {
|
|||||||
{billingDetailSummary && (
|
{billingDetailSummary && (
|
||||||
<div style={{ color: 'var(--color-text-muted)' }}>{billingDetailSummary}</div>
|
<div style={{ color: 'var(--color-text-muted)' }}>{billingDetailSummary}</div>
|
||||||
)}
|
)}
|
||||||
|
<div style={{ color: 'var(--color-text-muted)' }}>
|
||||||
|
用量来源:{formatProxyLogUsageSource(detailLog.usageSource ?? pathMeta.usageSource) || '未知'}
|
||||||
|
</div>
|
||||||
<div style={{ display: 'flex', gap: 6, alignItems: 'flex-start' }}>
|
<div style={{ display: 'flex', gap: 6, alignItems: 'flex-start' }}>
|
||||||
<span style={{ color: 'var(--color-text-muted)', flexShrink: 0 }}>客户端</span>
|
<span style={{ color: 'var(--color-text-muted)', flexShrink: 0 }}>客户端</span>
|
||||||
<div style={{ minWidth: 0 }}>
|
<div style={{ minWidth: 0 }}>
|
||||||
@@ -2121,9 +2137,9 @@ export default function ProxyLogs() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<span>
|
<span>
|
||||||
输入 {detailLog.promptTokens?.toLocaleString() || 0} tokens
|
输入 {formatProxyLogTokenValue(detailLog.promptTokens)} tokens
|
||||||
{' + '}输出 {detailLog.completionTokens?.toLocaleString() || 0} tokens
|
{' + '}输出 {formatProxyLogTokenValue(detailLog.completionTokens)} tokens
|
||||||
{' = '}总计 {detailLog.totalTokens?.toLocaleString() || 0} tokens
|
{' = '}总计 {formatProxyLogTokenValue(detailLog.totalTokens)} tokens
|
||||||
{typeof detailLog.estimatedCost === 'number' && (
|
{typeof detailLog.estimatedCost === 'number' && (
|
||||||
<>,预估费用 <strong style={{ color: 'var(--color-text-primary)' }}>${detailLog.estimatedCost.toFixed(6)}</strong></>
|
<>,预估费用 <strong style={{ color: 'var(--color-text-primary)' }}>${detailLog.estimatedCost.toFixed(6)}</strong></>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { parseProxyLogMetadata } from '../../../shared/proxyLogMeta.js';
|
import { parseProxyLogMetadata, type ParsedProxyLogMetadata } from '../../../shared/proxyLogMeta.js';
|
||||||
|
|
||||||
type ProxyLogPathMeta = {
|
type ProxyLogPathMeta = {
|
||||||
clientFamily: string | null;
|
clientFamily: string | null;
|
||||||
sessionId: string | null;
|
sessionId: string | null;
|
||||||
downstreamPath: string | null;
|
downstreamPath: string | null;
|
||||||
upstreamPath: string | null;
|
upstreamPath: string | null;
|
||||||
|
usageSource: ParsedProxyLogMetadata['usageSource'];
|
||||||
errorMessage: string;
|
errorMessage: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -17,6 +18,7 @@ export function parseProxyLogPathMeta(message?: string): ProxyLogPathMeta {
|
|||||||
sessionId: parsed.sessionId,
|
sessionId: parsed.sessionId,
|
||||||
downstreamPath: parsed.downstreamPath,
|
downstreamPath: parsed.downstreamPath,
|
||||||
upstreamPath: parsed.upstreamPath,
|
upstreamPath: parsed.upstreamPath,
|
||||||
|
usageSource: parsed.usageSource,
|
||||||
errorMessage: parsed.messageText,
|
errorMessage: parsed.messageText,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user