Skip to content

Commit 52554d7

Browse files
fix(openai): scope prompt cache key to official endpoint, unify usage mapping, tier ChatGPT model routing (#1300)
Co-authored-by: DavidShawa <DavidShawa@users.noreply.github.com>
2 parents feb76f1 + 540e5fb commit 52554d7

18 files changed

Lines changed: 585 additions & 145 deletions

packages/@ant/model-provider/src/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,3 +67,7 @@ export {
6767
anthropicToolChoiceToOpenAI,
6868
} from './shared/openaiConvertTools.js'
6969
export { adaptOpenAIStreamToAnthropic } from './shared/openaiStreamAdapter.js'
70+
export {
71+
normalizeOpenAIUsage,
72+
type AnthropicUsage,
73+
} from './shared/openaiUsage.js'

packages/@ant/model-provider/src/shared/__tests__/openaiStreamAdapter.test.ts

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,11 +34,15 @@ function makeChunk(
3434
}
3535

3636
/** Collect all emitted Anthropic events from the stream adapter for assertion */
37-
async function collectEvents(chunks: ChatCompletionChunk[]) {
37+
async function collectEvents(
38+
chunks: ChatCompletionChunk[],
39+
options?: { includeCacheWriteTokens?: boolean },
40+
) {
3841
const events: any[] = []
3942
for await (const event of adaptOpenAIStreamToAnthropic(
4043
mockStream(chunks),
4144
'gpt-4o',
45+
options,
4246
)) {
4347
events.push(event)
4448
}
@@ -521,6 +525,60 @@ describe('thinking support (reasoning_content)', () => {
521525
})
522526

523527
describe('prompt caching support', () => {
528+
test('maps official OpenAI cache writes when explicitly enabled', async () => {
529+
const events = await collectEvents(
530+
[
531+
makeChunk({
532+
choices: [
533+
{ index: 0, delta: { content: 'hi' }, finish_reason: null },
534+
],
535+
}),
536+
makeChunk({
537+
choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],
538+
usage: {
539+
prompt_tokens: 1000,
540+
completion_tokens: 50,
541+
total_tokens: 1050,
542+
prompt_tokens_details: {
543+
cached_tokens: 600,
544+
cache_write_tokens: 250,
545+
},
546+
} as any,
547+
}),
548+
],
549+
{ includeCacheWriteTokens: true },
550+
)
551+
552+
const msgDelta = events.find(e => e.type === 'message_delta') as any
553+
expect(msgDelta.usage.input_tokens).toBe(150)
554+
expect(msgDelta.usage.cache_read_input_tokens).toBe(600)
555+
expect(msgDelta.usage.cache_creation_input_tokens).toBe(250)
556+
})
557+
558+
test('ignores cache writes for compatible providers by default', async () => {
559+
const events = await collectEvents([
560+
makeChunk({
561+
choices: [{ index: 0, delta: { content: 'hi' }, finish_reason: null }],
562+
}),
563+
makeChunk({
564+
choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],
565+
usage: {
566+
prompt_tokens: 1000,
567+
completion_tokens: 50,
568+
total_tokens: 1050,
569+
prompt_tokens_details: {
570+
cached_tokens: 600,
571+
cache_write_tokens: 250,
572+
},
573+
} as any,
574+
}),
575+
])
576+
577+
const msgDelta = events.find(e => e.type === 'message_delta') as any
578+
expect(msgDelta.usage.input_tokens).toBe(400)
579+
expect(msgDelta.usage.cache_creation_input_tokens).toBe(0)
580+
})
581+
524582
test('maps cached_tokens to cache_read_input_tokens', async () => {
525583
const events = await collectEvents([
526584
makeChunk({
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { describe, expect, test } from 'bun:test'
2+
import { normalizeOpenAIUsage } from '../openaiUsage.js'
3+
4+
describe('normalizeOpenAIUsage', () => {
5+
test('partitions total input into ordinary, cache-read, and cache-write tokens', () => {
6+
expect(
7+
normalizeOpenAIUsage({
8+
totalInputTokens: 1000,
9+
outputTokens: 50,
10+
cacheReadTokens: 600,
11+
cacheWriteTokens: 250,
12+
}),
13+
).toEqual({
14+
input_tokens: 150,
15+
output_tokens: 50,
16+
cache_creation_input_tokens: 250,
17+
cache_read_input_tokens: 600,
18+
})
19+
})
20+
21+
test('clamps overlapping cache segments to the total input', () => {
22+
expect(
23+
normalizeOpenAIUsage({
24+
totalInputTokens: 5000,
25+
outputTokens: 10,
26+
cacheReadTokens: 4000,
27+
cacheWriteTokens: 4000,
28+
}),
29+
).toEqual({
30+
input_tokens: 0,
31+
output_tokens: 10,
32+
cache_creation_input_tokens: 1000,
33+
cache_read_input_tokens: 4000,
34+
})
35+
})
36+
37+
test('clamps negative provider values to zero', () => {
38+
expect(
39+
normalizeOpenAIUsage({
40+
totalInputTokens: -1,
41+
outputTokens: -2,
42+
cacheReadTokens: -3,
43+
cacheWriteTokens: -4,
44+
}),
45+
).toEqual({
46+
input_tokens: 0,
47+
output_tokens: 0,
48+
cache_creation_input_tokens: 0,
49+
cache_read_input_tokens: 0,
50+
})
51+
})
52+
})

packages/@ant/model-provider/src/shared/openaiStreamAdapter.ts

Lines changed: 36 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { BetaRawMessageStreamEvent } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs'
22
import type { ChatCompletionChunk } from 'openai/resources/chat/completions/completions.mjs'
33
import { randomUUID } from 'crypto'
4+
import { normalizeOpenAIUsage } from './openaiUsage.js'
45

56
/**
67
* Adapt an OpenAI streaming response into Anthropic BetaRawMessageStreamEvent.
@@ -13,10 +14,10 @@ import { randomUUID } from 'crypto'
1314
* finish_reason → message_delta(stop_reason) + message_stop
1415
*
1516
* Usage field mapping (OpenAI → Anthropic):
16-
* prompt_tokens - cached_tokens → input_tokens (non-cached input only)
17+
* prompt_tokens - cached_tokens - cache_write_tokens → input_tokens
1718
* completion_tokens → output_tokens
1819
* prompt_tokens_details.cached_tokens → cache_read_input_tokens
19-
* (no OpenAI equivalent) → cache_creation_input_tokens (always 0)
20+
* prompt_tokens_details.cache_write_tokens → cache_creation_input_tokens
2021
*
2122
* All four fields are emitted in the post-loop message_delta (not message_start)
2223
* so that trailing usage chunks (sent after finish_reason by some
@@ -35,6 +36,7 @@ import { randomUUID } from 'crypto'
3536
export async function* adaptOpenAIStreamToAnthropic(
3637
stream: AsyncIterable<ChatCompletionChunk>,
3738
model: string,
39+
options?: { includeCacheWriteTokens?: boolean },
3840
): AsyncGenerator<BetaRawMessageStreamEvent, void> {
3941
const messageId = `msg_${randomUUID().replace(/-/g, '').slice(0, 24)}`
4042

@@ -53,13 +55,13 @@ export async function* adaptOpenAIStreamToAnthropic(
5355
// Track text block state
5456
let textBlockOpen = false
5557

56-
// Track usage — all four Anthropic fields, populated from OpenAI usage fields:
57-
// rawInputTokens tracks the raw prompt_tokens (OpenAI total, including cached).
58-
// inputTokens is the derived Anthropic value (non-cached only = rawInputTokens - cachedReadTokens).
58+
// Track raw OpenAI usage across chunks. The normalized Anthropic fields are
59+
// disjoint: ordinary input + cache reads + cache writes = total input.
5960
let rawInputTokens = 0
60-
let inputTokens = 0
6161
let outputTokens = 0
62-
let cachedReadTokens = 0
62+
let rawCacheReadTokens = 0
63+
let rawCacheWriteTokens = 0
64+
let usage = normalizeOpenAIUsage({ totalInputTokens: 0, outputTokens: 0 })
6365

6466
// Track all open content block indices (for cleanup)
6567
const openBlockIndices = new Set<number>()
@@ -75,16 +77,32 @@ export async function* adaptOpenAIStreamToAnthropic(
7577
// Extract usage from any chunk that carries it.
7678
if (chunk.usage) {
7779
rawInputTokens = chunk.usage.prompt_tokens ?? rawInputTokens
78-
const rawCached =
79-
((chunk.usage as any).prompt_tokens_details?.cached_tokens as
80-
| number
81-
| undefined) ?? cachedReadTokens
82-
// Anthropic's input_tokens = non-cached input only. OpenAI's prompt_tokens
83-
// includes cached tokens, so subtract. Clamp to 0 in case cached > total
84-
// due to a streaming race.
85-
inputTokens = Math.max(0, rawInputTokens - rawCached)
8680
outputTokens = chunk.usage.completion_tokens ?? outputTokens
87-
cachedReadTokens = rawCached
81+
82+
const usageRecord = chunk.usage as unknown as Record<string, unknown>
83+
const detailsValue = usageRecord.prompt_tokens_details
84+
const details =
85+
detailsValue && typeof detailsValue === 'object'
86+
? (detailsValue as Record<string, unknown>)
87+
: undefined
88+
if (typeof details?.cached_tokens === 'number') {
89+
rawCacheReadTokens = details.cached_tokens
90+
}
91+
if (
92+
options?.includeCacheWriteTokens &&
93+
typeof details?.cache_write_tokens === 'number'
94+
) {
95+
rawCacheWriteTokens = details.cache_write_tokens
96+
} else if (!options?.includeCacheWriteTokens) {
97+
rawCacheWriteTokens = 0
98+
}
99+
100+
usage = normalizeOpenAIUsage({
101+
totalInputTokens: rawInputTokens,
102+
outputTokens,
103+
cacheReadTokens: rawCacheReadTokens,
104+
cacheWriteTokens: rawCacheWriteTokens,
105+
})
88106
}
89107

90108
// Emit message_start on first chunk
@@ -102,10 +120,8 @@ export async function* adaptOpenAIStreamToAnthropic(
102120
stop_reason: null,
103121
stop_sequence: null,
104122
usage: {
105-
input_tokens: inputTokens,
123+
...usage,
106124
output_tokens: 0,
107-
cache_creation_input_tokens: 0,
108-
cache_read_input_tokens: cachedReadTokens,
109125
},
110126
},
111127
} as unknown as BetaRawMessageStreamEvent
@@ -312,12 +328,7 @@ export async function* adaptOpenAIStreamToAnthropic(
312328
stop_reason: stopReason,
313329
stop_sequence: null,
314330
},
315-
usage: {
316-
input_tokens: inputTokens,
317-
output_tokens: outputTokens,
318-
cache_read_input_tokens: cachedReadTokens,
319-
cache_creation_input_tokens: 0,
320-
},
331+
usage,
321332
} as BetaRawMessageStreamEvent
322333

323334
yield {
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
export type AnthropicUsage = {
2+
input_tokens: number
3+
output_tokens: number
4+
cache_creation_input_tokens: number
5+
cache_read_input_tokens: number
6+
}
7+
8+
/**
9+
* Convert OpenAI's total-input usage into Anthropic's disjoint usage fields.
10+
* Cache reads take priority when malformed provider data makes segments overlap.
11+
*/
12+
export function normalizeOpenAIUsage(params: {
13+
totalInputTokens: number
14+
outputTokens: number
15+
cacheReadTokens?: number
16+
cacheWriteTokens?: number
17+
}): AnthropicUsage {
18+
const totalInput = Math.max(0, params.totalInputTokens)
19+
const cacheRead = Math.min(
20+
Math.max(0, params.cacheReadTokens ?? 0),
21+
totalInput,
22+
)
23+
const remainingAfterRead = Math.max(0, totalInput - cacheRead)
24+
const cacheCreation = Math.min(
25+
Math.max(0, params.cacheWriteTokens ?? 0),
26+
remainingAfterRead,
27+
)
28+
29+
return {
30+
input_tokens: Math.max(0, remainingAfterRead - cacheCreation),
31+
output_tokens: Math.max(0, params.outputTokens),
32+
cache_creation_input_tokens: cacheCreation,
33+
cache_read_input_tokens: cacheRead,
34+
}
35+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { describe, expect, test } from 'bun:test'
2+
import {
3+
getOfficialOpenAIPromptCacheKey,
4+
isOfficialOpenAIBaseURL,
5+
} from '../openaiShared.js'
6+
7+
describe('isOfficialOpenAIBaseURL', () => {
8+
test('treats the SDK default endpoint as official OpenAI', () => {
9+
expect(isOfficialOpenAIBaseURL(undefined)).toBe(true)
10+
expect(isOfficialOpenAIBaseURL('')).toBe(true)
11+
})
12+
13+
test('accepts global and regional official OpenAI endpoints', () => {
14+
expect(isOfficialOpenAIBaseURL('https://api.openai.com/v1')).toBe(true)
15+
expect(isOfficialOpenAIBaseURL('https://eu.api.openai.com/v1')).toBe(true)
16+
expect(isOfficialOpenAIBaseURL('https://api.openai.com:443/v1')).toBe(true)
17+
})
18+
19+
test('rejects OpenAI-compatible and spoofed endpoints', () => {
20+
expect(isOfficialOpenAIBaseURL('https://api.deepseek.com/v1')).toBe(false)
21+
expect(isOfficialOpenAIBaseURL('http://api.openai.com/v1')).toBe(false)
22+
expect(isOfficialOpenAIBaseURL('https://api.openai.com.evil.test/v1')).toBe(
23+
false,
24+
)
25+
expect(isOfficialOpenAIBaseURL('https://api.openai.com:8443/v1')).toBe(
26+
false,
27+
)
28+
expect(isOfficialOpenAIBaseURL('not-a-url')).toBe(false)
29+
})
30+
})
31+
32+
describe('getOfficialOpenAIPromptCacheKey', () => {
33+
test('returns a session key for the SDK default and official endpoint', () => {
34+
expect(getOfficialOpenAIPromptCacheKey(undefined, 'session-1')).toBe(
35+
'ccb:session-1',
36+
)
37+
expect(
38+
getOfficialOpenAIPromptCacheKey('https://api.openai.com/v1', 'session-2'),
39+
).toBe('ccb:session-2')
40+
})
41+
42+
test('returns undefined for compatible endpoints', () => {
43+
expect(
44+
getOfficialOpenAIPromptCacheKey(
45+
'https://api.deepseek.com/v1',
46+
'session-1',
47+
),
48+
).toBeUndefined()
49+
})
50+
})

0 commit comments

Comments
 (0)