Skip to content

Commit 69c0199

Browse files
committed
fix(providers): close the streaming cache-telemetry gaps; honor the remaining fallback chain on streamed retries
Three P2 hygiene slices from the prompt-cache audit: - AnthropicProvider samples the cache-leak detector on the streaming path (the RC9 blind spot: only generateCompletion sampled, leaving the streamed narrator/companion surfaces invisible to the zero-read and unmarked tripwires that were built after the July regressions). - streamText's fallback recursion passes the REMAINING chain slice like generateText (undefined made the recursion rebuild the default chain, re-trying the failed primary and ignoring explicit chains) and the auto-built chain is now policy-aware (mature streamed turns get the uncensored prefix instead of refuse-happy availability legs). - OpenAIProvider normalizes prompt_tokens_details.cached_tokens (and the Responses API's input_tokens_details) into cacheReadInputTokens, the same field the Anthropic and OpenRouter providers report — automatic caching on the gpt fallback legs stops being invisible.
1 parent 73fd340 commit 69c0199

4 files changed

Lines changed: 176 additions & 6 deletions

File tree

src/api/streamText.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import { hostPolicyToRouteParams, mergeRequiredCapabilities } from './runtime/ho
1515
import { adaptTools } from './runtime/toolAdapter.js';
1616
import { runEmulatedToolLoop, type ToolMode } from './runtime/tool-emulation/index.js';
1717
import {
18-
buildFallbackChain,
18+
buildPolicyAwareFallbackChain,
1919
createPlan,
2020
isRetryableError,
2121
resolveChainOfThought,
@@ -969,9 +969,13 @@ export function streamText(opts: GenerateTextOptions): StreamTextResult {
969969
// call targeting the next available fallback. All parts from the
970970
// fallback stream are yielded transparently to the consumer.
971971
// Resolve fallback chain: caller-supplied wins, undefined triggers
972-
// auto-build from env keys, empty array explicitly opts out.
972+
// auto-build from env keys, empty array explicitly opts out. The
973+
// auto-build is POLICY-AWARE like generateText's (mature tiers get
974+
// the uncensored prefix) — streaming previously used the plain
975+
// availability chain, so a mature streamed turn that lost its
976+
// primary fell onto refuse-happy legs first.
973977
const effectiveFallbacks = opts.fallbackProviders === undefined
974-
? buildFallbackChain(recordedProviderId)
978+
? buildPolicyAwareFallbackChain(opts.policyTier, recordedProviderId)
975979
: opts.fallbackProviders;
976980

977981
if (effectiveFallbacks.length && isRetryableError(error)) {
@@ -1015,7 +1019,15 @@ export function streamText(opts: GenerateTextOptions): StreamTextResult {
10151019
model: fb.model,
10161020
apiKey: undefined,
10171021
baseUrl: undefined,
1018-
fallbackProviders: undefined,
1022+
// Preserve the REMAINING chain (entries AFTER the current fb;
1023+
// `attempt` is 1-indexed so slice(attempt) drops fb and all
1024+
// already-tried entries), mirroring generateText. Passing
1025+
// `undefined` here made the recursion REBUILD the default
1026+
// chain — it could re-try the already-failed primary and
1027+
// ignored explicit frontier-only chains. The final entry
1028+
// passes [] -> explicit opt-out -> the recursion throws
1029+
// instead of looping.
1030+
fallbackProviders: effectiveFallbacks.slice(attempt),
10191031
onFallback: undefined,
10201032
});
10211033

src/core/llm/providers/__tests__/openai-streaming-usage.test.ts

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,3 +176,113 @@ describe('OpenAIProvider streaming usage', () => {
176176
expect(usageChunk!.usage!.totalTokens).toBe(46);
177177
});
178178
});
179+
180+
describe('OpenAIProvider cached-token normalization (automatic prompt caching)', () => {
181+
let provider: OpenAIProvider;
182+
183+
beforeEach(async () => {
184+
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
185+
new Response(
186+
JSON.stringify({
187+
object: 'list',
188+
data: [{ id: 'gpt-4o', object: 'model', created: 1, owned_by: 'openai' }],
189+
}),
190+
{ status: 200, headers: { 'content-type': 'application/json' } },
191+
),
192+
);
193+
provider = new OpenAIProvider();
194+
await provider.initialize({ apiKey: 'sk-test', maxRetries: 1 });
195+
vi.spyOn(globalThis, 'fetch').mockClear();
196+
});
197+
198+
afterEach(() => {
199+
vi.restoreAllMocks();
200+
});
201+
202+
it('normalizes prompt_tokens_details.cached_tokens on non-streaming completions', async () => {
203+
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
204+
new Response(
205+
JSON.stringify({
206+
id: 'chatcmpl-cached',
207+
object: 'chat.completion',
208+
created: 1,
209+
model: 'gpt-4o',
210+
choices: [
211+
{
212+
index: 0,
213+
message: { role: 'assistant', content: 'hello' },
214+
finish_reason: 'stop',
215+
logprobs: null,
216+
},
217+
],
218+
usage: {
219+
prompt_tokens: 1200,
220+
completion_tokens: 40,
221+
total_tokens: 1240,
222+
prompt_tokens_details: { cached_tokens: 1024 },
223+
},
224+
}),
225+
{ status: 200, headers: { 'content-type': 'application/json' } },
226+
),
227+
);
228+
229+
const result = await provider.generateCompletion(
230+
'gpt-4o',
231+
[{ role: 'user', content: 'hi' }],
232+
{},
233+
);
234+
// Cached tokens surface on the same normalized field the Anthropic and
235+
// OpenRouter providers use; prompt_tokens stays INCLUSIVE of them
236+
// (OpenAI accounting), so promptTokens is unchanged.
237+
expect(result.usage?.cacheReadInputTokens).toBe(1024);
238+
expect(result.usage?.promptTokens).toBe(1200);
239+
});
240+
241+
it('normalizes cached_tokens on the trailing streaming usage-only chunk', async () => {
242+
const usageOnly = {
243+
...makeUsageOnlyChunk(500, 20),
244+
usage: {
245+
prompt_tokens: 500,
246+
completion_tokens: 20,
247+
total_tokens: 520,
248+
prompt_tokens_details: { cached_tokens: 384 },
249+
},
250+
};
251+
const sse = [
252+
`data: ${JSON.stringify({
253+
id: 'chatcmpl-2',
254+
object: 'chat.completion.chunk',
255+
created: 1,
256+
model: 'gpt-4o',
257+
choices: [
258+
{ index: 0, delta: { role: 'assistant', content: 'hi' }, finish_reason: null },
259+
],
260+
})}\n\n`,
261+
`data: ${JSON.stringify({
262+
id: 'chatcmpl-2',
263+
object: 'chat.completion.chunk',
264+
created: 1,
265+
model: 'gpt-4o',
266+
choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],
267+
})}\n\n`,
268+
`data: ${JSON.stringify(usageOnly)}\n\n`,
269+
'data: [DONE]\n\n',
270+
].join('');
271+
272+
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
273+
new Response(sse, { status: 200, headers: { 'content-type': 'text/event-stream' } }),
274+
);
275+
276+
const chunks: { isFinal?: boolean; usage?: { cacheReadInputTokens?: number } }[] = [];
277+
for await (const chunk of provider.generateCompletionStream(
278+
'gpt-4o',
279+
[{ role: 'user', content: 'hi' }],
280+
{},
281+
)) {
282+
chunks.push(chunk as { isFinal?: boolean; usage?: { cacheReadInputTokens?: number } });
283+
}
284+
285+
const usageChunk = chunks.find((c) => c.usage && c.isFinal);
286+
expect(usageChunk?.usage?.cacheReadInputTokens).toBe(384);
287+
});
288+
});

src/core/llm/providers/implementations/AnthropicProvider.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -638,7 +638,7 @@ export class AnthropicProvider implements IProvider {
638638
private recordCacheLeakSample(
639639
modelId: string,
640640
payload: Record<string, unknown>,
641-
apiResponse: AnthropicMessagesResponse,
641+
apiResponse: Pick<AnthropicMessagesResponse, 'usage'>,
642642
): void {
643643
try {
644644
const system = payload.system as
@@ -1139,6 +1139,24 @@ export class AnthropicProvider implements IProvider {
11391139
}),
11401140
};
11411141

1142+
// Sample the leak detector on the STREAMING path too. This was
1143+
// the RC9 blind spot: only generateCompletion sampled, so the
1144+
// streamed conversation surfaces (narrator, companion) — where
1145+
// the 2026-07 history-caching regressions actually lived — were
1146+
// invisible to the zero-read / unmarked tripwires.
1147+
this.recordCacheLeakSample(modelId, payload, {
1148+
usage: {
1149+
input_tokens: inputTokens,
1150+
output_tokens: outputTokens,
1151+
...(cacheCreationTokens !== undefined && {
1152+
cache_creation_input_tokens: cacheCreationTokens,
1153+
}),
1154+
...(cacheReadTokens !== undefined && {
1155+
cache_read_input_tokens: cacheReadTokens,
1156+
}),
1157+
},
1158+
});
1159+
11421160
yield {
11431161
id: responseId,
11441162
object: 'chat.completion.chunk',

src/core/llm/providers/implementations/OpenAIProvider.ts

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,14 @@ namespace OpenAIAPITypes {
5555
prompt_tokens: number;
5656
completion_tokens: number;
5757
total_tokens: number;
58+
/**
59+
* OpenAI automatic prompt caching (gpt-4o and newer): prompt tokens
60+
* served from cache at the discounted rate. Normalized into
61+
* {@link ModelUsage.cacheReadInputTokens} so the gpt-5.5 / gpt-4o
62+
* fallback legs' caching is visible platform-wide (OpenRouterProvider
63+
* already normalizes the same field).
64+
*/
65+
prompt_tokens_details?: { cached_tokens?: number };
5866
}
5967
/** Complete tool call as returned on a non-streaming message. */
6068
export interface ToolCall {
@@ -144,6 +152,8 @@ namespace OpenAIAPITypes {
144152
input_tokens: number;
145153
output_tokens: number;
146154
total_tokens: number;
155+
/** Responses-API spelling of the cached-prompt-token detail. */
156+
input_tokens_details?: { cached_tokens?: number };
147157
}
148158
export interface ResponsesOutputContentPart {
149159
type: string; // 'output_text' | …
@@ -1119,6 +1129,11 @@ export class OpenAIProvider implements IProvider {
11191129
prompt_tokens: apiResponse.usage.input_tokens,
11201130
completion_tokens: apiResponse.usage.output_tokens,
11211131
total_tokens: apiResponse.usage.total_tokens,
1132+
// Responses API spells the cached detail input_tokens_details;
1133+
// re-key to the chat spelling the shared mapper reads.
1134+
...(apiResponse.usage.input_tokens_details
1135+
? { prompt_tokens_details: { cached_tokens: apiResponse.usage.input_tokens_details.cached_tokens } }
1136+
: {}),
11221137
},
11231138
apiResponse.model ?? modelId
11241139
)
@@ -1307,14 +1322,29 @@ export class OpenAIProvider implements IProvider {
13071322
* @private
13081323
*/
13091324
private calculateUsage(
1310-
usage: { prompt_tokens: number; completion_tokens: number; total_tokens: number },
1325+
usage: {
1326+
prompt_tokens: number;
1327+
completion_tokens: number;
1328+
total_tokens: number;
1329+
prompt_tokens_details?: { cached_tokens?: number };
1330+
},
13111331
modelId: string
13121332
): ModelUsage {
1333+
// OpenAI automatic caching reports cached prompt tokens as a detail
1334+
// field (they remain INCLUDED in prompt_tokens, unlike Anthropic's
1335+
// exclusive accounting). Normalize into cacheReadInputTokens so the
1336+
// fallback legs' caching stops being invisible in platform telemetry;
1337+
// cost stays computed off the full prompt_tokens (the discount is a
1338+
// billing-side rate, not a token-count change).
1339+
const cachedTokens = usage.prompt_tokens_details?.cached_tokens;
13131340
return {
13141341
promptTokens: usage.prompt_tokens,
13151342
completionTokens: usage.completion_tokens,
13161343
totalTokens: usage.total_tokens,
13171344
costUSD: this.calculateCost(usage.prompt_tokens, usage.completion_tokens, modelId),
1345+
...(typeof cachedTokens === 'number' && cachedTokens >= 0
1346+
? { cacheReadInputTokens: cachedTokens }
1347+
: {}),
13181348
};
13191349
}
13201350

0 commit comments

Comments
 (0)