forked from boos/BoosAPI
[codex] fix empty stream success handling and unknown usage logs (#343)
* fix: preserve repeated short responses deltas * fix empty stream success handling and unknown usage logs * fix proxy usage metadata typing * fix stream bookkeeping and explicit zero usage source * fix sticky fast path and usage placeholder detection
This commit is contained in:
@@ -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 () => {
|
||||
hasProxyLogClientColumnsMock.mockResolvedValue(true);
|
||||
dbInsertRunMock
|
||||
|
||||
@@ -214,9 +214,9 @@ export async function insertProxyLog(input: ProxyLogInsertInput): Promise<void>
|
||||
status: input.status ?? null,
|
||||
httpStatus: input.httpStatus ?? null,
|
||||
latencyMs: input.latencyMs ?? null,
|
||||
promptTokens: input.promptTokens ?? 0,
|
||||
completionTokens: input.completionTokens ?? 0,
|
||||
totalTokens: input.totalTokens ?? 0,
|
||||
promptTokens: input.promptTokens ?? null,
|
||||
completionTokens: input.completionTokens ?? null,
|
||||
totalTokens: input.totalTokens ?? null,
|
||||
estimatedCost: input.estimatedCost ?? 0,
|
||||
errorMessage: input.errorMessage ?? null,
|
||||
retryCount: input.retryCount ?? 0,
|
||||
|
||||
@@ -129,4 +129,36 @@ describe('proxyUsageFallbackService sub2api', () => {
|
||||
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;
|
||||
requestEndedAtMs: number;
|
||||
localLatencyMs: number;
|
||||
upstreamUsagePresent?: boolean;
|
||||
usage: ProxyUsage;
|
||||
}
|
||||
|
||||
@@ -51,6 +52,7 @@ interface ProxyUsageFallbackResult extends ProxyUsage {
|
||||
recoveredFromSelfLog: boolean;
|
||||
estimatedCostFromQuota: number;
|
||||
selfLogBillingMeta: SelfLogBillingMeta | null;
|
||||
usageSource: 'upstream' | 'self-log' | 'unknown';
|
||||
}
|
||||
|
||||
export interface SelfLogBillingMeta {
|
||||
@@ -149,11 +151,14 @@ function isUsageMissing(usage: ProxyUsage): boolean {
|
||||
export function shouldLookupSelfLog(
|
||||
platform: string,
|
||||
usage: ProxyUsage,
|
||||
upstreamUsagePresent?: boolean,
|
||||
): boolean {
|
||||
const normalizedPlatform = String(platform || '').toLowerCase();
|
||||
if (!SUPPORTED_USAGE_FALLBACK_PLATFORMS.has(normalizedPlatform)) return false;
|
||||
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 {
|
||||
@@ -534,15 +539,18 @@ export async function resolveProxyUsageWithSelfLogFallback(
|
||||
input: ProxyUsageFallbackInput,
|
||||
): Promise<ProxyUsageFallbackResult> {
|
||||
const normalizedUsage = normalizeUsage(input.usage);
|
||||
const hasUpstreamUsage = input.upstreamUsagePresent ?? !isUsageMissing(normalizedUsage);
|
||||
const fallbackUsageSource = hasUpstreamUsage ? 'upstream' : 'unknown';
|
||||
const fallback: ProxyUsageFallbackResult = {
|
||||
...normalizedUsage,
|
||||
recoveredFromSelfLog: false,
|
||||
estimatedCostFromQuota: 0,
|
||||
selfLogBillingMeta: null,
|
||||
usageSource: fallbackUsageSource,
|
||||
};
|
||||
|
||||
const platform = String(input.site.platform || '').toLowerCase();
|
||||
if (!shouldLookupSelfLog(platform, normalizedUsage)) {
|
||||
if (!shouldLookupSelfLog(platform, normalizedUsage, hasUpstreamUsage)) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
@@ -577,6 +585,7 @@ export async function resolveProxyUsageWithSelfLogFallback(
|
||||
recoveredFromSelfLog: true,
|
||||
estimatedCostFromQuota: toRecoveredCost(matched),
|
||||
selfLogBillingMeta: matched.billingMeta,
|
||||
usageSource: useMatchedTokens ? 'self-log' : fallbackUsageSource,
|
||||
};
|
||||
} catch {
|
||||
return fallback;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
hasProxyUsagePayload,
|
||||
mergeProxyUsage,
|
||||
parseProxyUsage,
|
||||
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', () => {
|
||||
const first = pullSseDataEvents('data: {"a":1}\n\ndata: {"b":');
|
||||
expect(first.events).toEqual(['{"a":1}']);
|
||||
|
||||
@@ -16,6 +16,60 @@ const ZERO_USAGE: ParsedProxyUsage = {
|
||||
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 {
|
||||
const n = typeof value === 'number' ? value : Number(value);
|
||||
if (!Number.isFinite(n)) return 0;
|
||||
@@ -26,6 +80,24 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
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>> {
|
||||
const candidates: Array<Record<string, unknown>> = [];
|
||||
const visited = new Set<object>();
|
||||
@@ -253,6 +325,15 @@ export function parseProxyUsage(payload: unknown): ParsedProxyUsage {
|
||||
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 {
|
||||
const normalizeUsage = (usage: ParsedProxyUsage): ParsedProxyUsage => ({
|
||||
promptTokens: toPositiveInt(usage.promptTokens),
|
||||
|
||||
Reference in New Issue
Block a user