fix(provider): improve prompt caching
This commit is contained in:
parent
0abbcddac2
commit
439a5a5281
19 changed files with 857 additions and 58 deletions
|
|
@ -65,14 +65,14 @@
|
|||
"@ai-sdk/amazon-bedrock": "4.0.112",
|
||||
"@ai-sdk/anthropic": "3.0.82",
|
||||
"@ai-sdk/azure": "3.0.49",
|
||||
"@ai-sdk/cerebras": "2.0.41",
|
||||
"@ai-sdk/cerebras": "2.0.60",
|
||||
"@ai-sdk/cohere": "3.0.27",
|
||||
"@ai-sdk/deepinfra": "2.0.41",
|
||||
"@ai-sdk/gateway": "3.0.104",
|
||||
"@ai-sdk/google": "3.0.73",
|
||||
"@ai-sdk/google-vertex": "4.0.128",
|
||||
"@ai-sdk/groq": "3.0.31",
|
||||
"@ai-sdk/mistral": "3.0.27",
|
||||
"@ai-sdk/mistral": "3.0.34",
|
||||
"@ai-sdk/openai": "3.0.53",
|
||||
"@ai-sdk/openai-compatible": "2.0.41",
|
||||
"@ai-sdk/perplexity": "3.0.26",
|
||||
|
|
|
|||
91
packages/core/test/provider-cache-key.test.ts
Normal file
91
packages/core/test/provider-cache-key.test.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import { createCerebras } from "@ai-sdk/cerebras"
|
||||
import { createDeepInfra } from "@ai-sdk/deepinfra"
|
||||
import { createMistral } from "@ai-sdk/mistral"
|
||||
import { createOpenAI } from "@ai-sdk/openai"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
|
||||
test("Mistral sends promptCacheKey as prompt_cache_key", async () => {
|
||||
let body: Record<string, unknown> | undefined
|
||||
const mockFetch = Object.assign(
|
||||
async (_input: Parameters<typeof fetch>[0], init?: RequestInit) => {
|
||||
body = JSON.parse(String(init?.body))
|
||||
return Response.json({
|
||||
id: "response-1",
|
||||
created: 0,
|
||||
model: "mistral-large-latest",
|
||||
object: "chat.completion",
|
||||
choices: [{ index: 0, message: { role: "assistant", content: "Hello" }, finish_reason: "stop" }],
|
||||
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
|
||||
})
|
||||
},
|
||||
{ preconnect: fetch.preconnect },
|
||||
)
|
||||
const model = createMistral({ apiKey: "test", fetch: mockFetch })("mistral-large-latest")
|
||||
|
||||
await model.doGenerate({
|
||||
prompt: [{ role: "user", content: [{ type: "text", text: "Hello" }] }],
|
||||
providerOptions: { mistral: { promptCacheKey: "session-123" } },
|
||||
})
|
||||
|
||||
expect(body?.prompt_cache_key).toBe("session-123")
|
||||
})
|
||||
|
||||
test("OpenAI Responses sends promptCacheKey as prompt_cache_key", async () => {
|
||||
let body: Record<string, unknown> | undefined
|
||||
const mockFetch = Object.assign(
|
||||
async (_input: Parameters<typeof fetch>[0], init?: RequestInit) => {
|
||||
body = JSON.parse(String(init?.body))
|
||||
return Response.json({
|
||||
id: "response-1",
|
||||
created_at: 0,
|
||||
model: "gpt-5",
|
||||
object: "response",
|
||||
output: [],
|
||||
usage: { input_tokens: 1, output_tokens: 0 },
|
||||
status: "completed",
|
||||
})
|
||||
},
|
||||
{ preconnect: fetch.preconnect },
|
||||
)
|
||||
const model = createOpenAI({ apiKey: "test", fetch: mockFetch }).responses("gpt-5")
|
||||
|
||||
await model.doGenerate({
|
||||
prompt: [{ role: "user", content: [{ type: "text", text: "Hello" }] }],
|
||||
providerOptions: { openai: { promptCacheKey: "session-123" } },
|
||||
})
|
||||
|
||||
expect(body?.prompt_cache_key).toBe("session-123")
|
||||
})
|
||||
|
||||
describe("OpenAI-compatible provider cache keys", () => {
|
||||
for (const provider of [
|
||||
{ name: "Cerebras", create: createCerebras, namespace: "cerebras" },
|
||||
{ name: "DeepInfra", create: createDeepInfra, namespace: "deepinfra" },
|
||||
]) {
|
||||
test(`${provider.name} passes prompt_cache_key through`, async () => {
|
||||
let body: Record<string, unknown> | undefined
|
||||
const mockFetch = Object.assign(
|
||||
async (_input: Parameters<typeof fetch>[0], init?: RequestInit) => {
|
||||
body = JSON.parse(String(init?.body))
|
||||
return Response.json({
|
||||
id: "response-1",
|
||||
created: 0,
|
||||
model: "test-model",
|
||||
object: "chat.completion",
|
||||
choices: [{ index: 0, message: { role: "assistant", content: "Hello" }, finish_reason: "stop" }],
|
||||
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
|
||||
})
|
||||
},
|
||||
{ preconnect: fetch.preconnect },
|
||||
)
|
||||
const model = provider.create({ apiKey: "test", fetch: mockFetch })("test-model")
|
||||
|
||||
await model.doGenerate({
|
||||
prompt: [{ role: "user", content: [{ type: "text", text: "Hello" }] }],
|
||||
providerOptions: { [provider.namespace]: { prompt_cache_key: "session-123" } },
|
||||
})
|
||||
|
||||
expect(body?.prompt_cache_key).toBe("session-123")
|
||||
})
|
||||
}
|
||||
})
|
||||
69
packages/core/test/provider-cache-usage.test.ts
Normal file
69
packages/core/test/provider-cache-usage.test.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import { createCohere } from "@ai-sdk/cohere"
|
||||
import { createGroq } from "@ai-sdk/groq"
|
||||
import { createTogetherAI } from "@ai-sdk/togetherai"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
|
||||
const prompt = [{ role: "user" as const, content: [{ type: "text" as const, text: "Hello" }] }]
|
||||
|
||||
describe("provider cache usage", () => {
|
||||
test("Cohere reports cached input tokens", async () => {
|
||||
const model = createCohere({
|
||||
apiKey: "test",
|
||||
fetch: mockFetch({
|
||||
generation_id: "response-1",
|
||||
message: { role: "assistant", content: [{ type: "text", text: "Hello" }] },
|
||||
finish_reason: "COMPLETE",
|
||||
usage: {
|
||||
billed_units: { input_tokens: 500, output_tokens: 1 },
|
||||
tokens: { input_tokens: 500, output_tokens: 1, cached_tokens: 400 },
|
||||
},
|
||||
}),
|
||||
})("command-r")
|
||||
|
||||
const result = await model.doGenerate({ prompt })
|
||||
expect(result.usage.inputTokens).toEqual({ total: 500, noCache: 100, cacheRead: 400, cacheWrite: undefined })
|
||||
})
|
||||
|
||||
test("Groq reports cached input tokens", async () => {
|
||||
const model = createGroq({
|
||||
apiKey: "test",
|
||||
fetch: mockFetch({
|
||||
id: "response-1",
|
||||
created: 0,
|
||||
model: "openai/gpt-oss-20b",
|
||||
object: "chat.completion",
|
||||
choices: [{ index: 0, message: { role: "assistant", content: "Hello" }, finish_reason: "stop" }],
|
||||
usage: {
|
||||
prompt_tokens: 500,
|
||||
completion_tokens: 1,
|
||||
total_tokens: 501,
|
||||
prompt_tokens_details: { cached_tokens: 400 },
|
||||
},
|
||||
}),
|
||||
})("openai/gpt-oss-20b")
|
||||
|
||||
const result = await model.doGenerate({ prompt })
|
||||
expect(result.usage.inputTokens).toEqual({ total: 500, noCache: 100, cacheRead: 400, cacheWrite: undefined })
|
||||
})
|
||||
|
||||
test("Together AI reports flat cached input tokens", async () => {
|
||||
const model = createTogetherAI({
|
||||
apiKey: "test",
|
||||
fetch: mockFetch({
|
||||
id: "response-1",
|
||||
created: 0,
|
||||
model: "moonshotai/Kimi-K2.6",
|
||||
object: "chat.completion",
|
||||
choices: [{ index: 0, message: { role: "assistant", content: "Hello" }, finish_reason: "stop" }],
|
||||
usage: { prompt_tokens: 500, completion_tokens: 1, total_tokens: 501, cached_tokens: 400 },
|
||||
}),
|
||||
})("moonshotai/Kimi-K2.6")
|
||||
|
||||
const result = await model.doGenerate({ prompt })
|
||||
expect(result.usage.inputTokens).toEqual({ total: 500, noCache: 100, cacheRead: 400, cacheWrite: undefined })
|
||||
})
|
||||
})
|
||||
|
||||
function mockFetch(response: unknown) {
|
||||
return Object.assign(async () => Response.json(response), { preconnect: fetch.preconnect })
|
||||
}
|
||||
|
|
@ -436,21 +436,19 @@ const mapFinishReason = (reason: string): FinishReason => {
|
|||
return "unknown"
|
||||
}
|
||||
|
||||
// AWS Bedrock Converse reports `inputTokens` (inclusive total) with
|
||||
// `cacheReadInputTokens` and `cacheWriteInputTokens` as subsets. Pass
|
||||
// the total through and derive the non-cached breakdown. Bedrock does
|
||||
// not break reasoning out of `outputTokens` for any current model.
|
||||
// AWS reports inputTokens separately from cache reads and writes.
|
||||
// Bedrock does not break reasoning out of outputTokens for current models.
|
||||
const mapUsage = (usage: BedrockUsageSchema | undefined): Usage | undefined => {
|
||||
if (!usage) return undefined
|
||||
const cacheTotal = (usage.cacheReadInputTokens ?? 0) + (usage.cacheWriteInputTokens ?? 0)
|
||||
const nonCached = ProviderShared.subtractTokens(usage.inputTokens, cacheTotal)
|
||||
const inputTokens = usage.inputTokens === undefined ? undefined : usage.inputTokens + cacheTotal
|
||||
return new Usage({
|
||||
inputTokens: usage.inputTokens,
|
||||
inputTokens,
|
||||
outputTokens: usage.outputTokens,
|
||||
nonCachedInputTokens: nonCached,
|
||||
nonCachedInputTokens: usage.inputTokens,
|
||||
cacheReadInputTokens: usage.cacheReadInputTokens,
|
||||
cacheWriteInputTokens: usage.cacheWriteInputTokens,
|
||||
totalTokens: ProviderShared.totalTokens(usage.inputTokens, usage.outputTokens, usage.totalTokens),
|
||||
totalTokens: ProviderShared.totalTokens(inputTokens, usage.outputTokens, usage.totalTokens),
|
||||
providerMetadata: { bedrock: usage },
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -269,6 +269,39 @@ describe("Bedrock Converse route", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
it.effect("adds cache reads and writes to Bedrock input usage", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = eventStreamBody(
|
||||
["messageStart", { role: "assistant" }],
|
||||
["contentBlockDelta", { contentBlockIndex: 0, delta: { text: "Hello" } }],
|
||||
["contentBlockStop", { contentBlockIndex: 0 }],
|
||||
["messageStop", { stopReason: "end_turn" }],
|
||||
[
|
||||
"metadata",
|
||||
{
|
||||
usage: {
|
||||
inputTokens: 5,
|
||||
outputTokens: 2,
|
||||
totalTokens: 12,
|
||||
cacheReadInputTokens: 3,
|
||||
cacheWriteInputTokens: 2,
|
||||
},
|
||||
},
|
||||
],
|
||||
)
|
||||
const response = yield* LLMClient.generate(baseRequest).pipe(Effect.provide(fixedBytes(body)))
|
||||
|
||||
expect(response.usage).toMatchObject({
|
||||
inputTokens: 10,
|
||||
nonCachedInputTokens: 5,
|
||||
cacheReadInputTokens: 3,
|
||||
cacheWriteInputTokens: 2,
|
||||
outputTokens: 2,
|
||||
totalTokens: 12,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("assembles streamed tool call input", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = eventStreamBody(
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@
|
|||
"@ai-sdk/google": "3.0.73",
|
||||
"@ai-sdk/google-vertex": "4.0.128",
|
||||
"@ai-sdk/groq": "3.0.31",
|
||||
"@ai-sdk/mistral": "3.0.27",
|
||||
"@ai-sdk/mistral": "3.0.34",
|
||||
"@ai-sdk/openai": "3.0.53",
|
||||
"@ai-sdk/openai-compatible": "2.0.41",
|
||||
"@ai-sdk/perplexity": "3.0.26",
|
||||
|
|
|
|||
|
|
@ -46,6 +46,28 @@ function sdkKey(npm: string): string | undefined {
|
|||
return "vertex"
|
||||
case "@ai-sdk/google":
|
||||
return "google"
|
||||
case "@ai-sdk/alibaba":
|
||||
return "alibaba"
|
||||
case "@ai-sdk/cerebras":
|
||||
return "cerebras"
|
||||
case "@ai-sdk/cohere":
|
||||
return "cohere"
|
||||
case "@ai-sdk/deepinfra":
|
||||
return "deepinfra"
|
||||
case "@ai-sdk/groq":
|
||||
return "groq"
|
||||
case "@ai-sdk/mistral":
|
||||
return "mistral"
|
||||
case "@ai-sdk/perplexity":
|
||||
return "perplexity"
|
||||
case "@ai-sdk/togetherai":
|
||||
return "togetherai"
|
||||
case "@ai-sdk/vercel":
|
||||
return "vercel"
|
||||
case "@ai-sdk/xai":
|
||||
return "xai"
|
||||
case "venice-ai-sdk-provider":
|
||||
return "venice"
|
||||
case "@ai-sdk/gateway":
|
||||
return "gateway"
|
||||
case "@openrouter/ai-sdk-provider":
|
||||
|
|
@ -430,6 +452,9 @@ function mapProviderOptions(
|
|||
export function message(msgs: ModelMessage[], model: Provider.Model, options: Record<string, unknown>) {
|
||||
msgs = unsupportedParts(msgs, model)
|
||||
msgs = normalizeMessages(msgs, model, options)
|
||||
const usesAnthropicAutomaticCaching =
|
||||
options.cacheControl !== undefined &&
|
||||
(model.api.npm === "@ai-sdk/anthropic" || model.api.npm === "@ai-sdk/google-vertex/anthropic")
|
||||
if (
|
||||
(model.providerID === "anthropic" ||
|
||||
model.providerID === "google-vertex-anthropic" ||
|
||||
|
|
@ -439,7 +464,8 @@ export function message(msgs: ModelMessage[], model: Provider.Model, options: Re
|
|||
model.id.includes("claude") ||
|
||||
model.api.npm === "@ai-sdk/anthropic" ||
|
||||
model.api.npm === "@ai-sdk/alibaba") &&
|
||||
model.api.npm !== "@ai-sdk/gateway"
|
||||
model.api.npm !== "@ai-sdk/gateway" &&
|
||||
!usesAnthropicAutomaticCaching
|
||||
) {
|
||||
msgs = applyCaching(msgs, model)
|
||||
}
|
||||
|
|
@ -1077,7 +1103,7 @@ export function options(input: {
|
|||
sessionID: string
|
||||
providerOptions?: Record<string, any>
|
||||
}): Record<string, any> {
|
||||
const result: Record<string, any> = {}
|
||||
const result: Record<string, any> = cachingOptions(input)
|
||||
|
||||
if (
|
||||
input.model.api.npm === "@ai-sdk/google-vertex/anthropic" ||
|
||||
|
|
@ -1098,7 +1124,6 @@ export function options(input: {
|
|||
|
||||
if (input.model.api.npm === "@ai-sdk/azure") {
|
||||
result["store"] = false
|
||||
result["promptCacheKey"] = input.sessionID
|
||||
}
|
||||
|
||||
if (input.model.api.npm === "@openrouter/ai-sdk-provider" || input.model.api.npm === "@llmgateway/ai-sdk-provider") {
|
||||
|
|
@ -1127,13 +1152,6 @@ export function options(input: {
|
|||
}
|
||||
}
|
||||
|
||||
if (
|
||||
input.providerOptions?.setCacheKey !== false &&
|
||||
(input.model.providerID === "openai" || input.model.api.npm === "@ai-sdk/xai" || input.providerOptions?.setCacheKey)
|
||||
) {
|
||||
result["promptCacheKey"] = input.sessionID
|
||||
}
|
||||
|
||||
if (input.model.api.npm === "@ai-sdk/google" || input.model.api.npm === "@ai-sdk/google-vertex") {
|
||||
if (input.model.capabilities.reasoning) {
|
||||
result["thinkingConfig"] = {
|
||||
|
|
@ -1209,37 +1227,55 @@ export function options(input: {
|
|||
result["textVerbosity"] = "low"
|
||||
}
|
||||
|
||||
if (input.model.providerID.startsWith("opencode")) {
|
||||
if (input.model.providerID.startsWith("opencode") && input.providerOptions?.setCacheKey !== false) {
|
||||
result["promptCacheKey"] = input.sessionID
|
||||
result["include"] = INCLUDE_ENCRYPTED_REASONING
|
||||
result["reasoningSummary"] = "auto"
|
||||
}
|
||||
}
|
||||
|
||||
if (input.model.providerID === "venice") {
|
||||
result["promptCacheKey"] = input.sessionID
|
||||
}
|
||||
|
||||
if (input.model.providerID === "openrouter") {
|
||||
result["prompt_cache_key"] = input.sessionID
|
||||
}
|
||||
if (input.model.api.npm === "@ai-sdk/gateway") {
|
||||
result["gateway"] = {
|
||||
caching: "auto",
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export function smallOptions(model: Provider.Model) {
|
||||
function cachingOptions(input: {
|
||||
model: Provider.Model
|
||||
sessionID: string
|
||||
providerOptions?: Record<string, any>
|
||||
}): Record<string, any> {
|
||||
if (input.providerOptions?.setCacheKey === false) {
|
||||
if (input.model.api.npm === "@ai-sdk/gateway") return { gateway: { caching: "auto" } }
|
||||
return {}
|
||||
}
|
||||
|
||||
if (input.model.api.npm === "@ai-sdk/deepinfra" || input.model.api.npm === "@ai-sdk/cerebras") {
|
||||
return { prompt_cache_key: input.sessionID }
|
||||
}
|
||||
|
||||
if (
|
||||
input.model.api.npm === "@ai-sdk/openai" ||
|
||||
(input.model.providerID === "openai" && input.model.api.npm !== "@ai-sdk/openai-compatible") ||
|
||||
input.model.api.npm === "@ai-sdk/azure" ||
|
||||
input.model.api.npm === "@ai-sdk/xai" ||
|
||||
input.model.api.npm === "@ai-sdk/mistral" ||
|
||||
input.model.api.npm === "venice-ai-sdk-provider" ||
|
||||
input.providerOptions?.setCacheKey === true
|
||||
) {
|
||||
return { promptCacheKey: input.sessionID }
|
||||
}
|
||||
|
||||
if (input.model.api.npm === "@ai-sdk/gateway") return { gateway: { caching: "auto" } }
|
||||
return {}
|
||||
}
|
||||
|
||||
export function smallOptions(model: Provider.Model, sessionID?: string, providerOptions?: Record<string, any>) {
|
||||
const small = Object.values(model.variants ?? {})[0] ?? {}
|
||||
const caching = sessionID ? cachingOptions({ model, sessionID, providerOptions }) : {}
|
||||
if (
|
||||
model.providerID === "openai" ||
|
||||
model.api.npm === "@ai-sdk/openai" ||
|
||||
model.api.npm === "@ai-sdk/github-copilot"
|
||||
) {
|
||||
const base = { store: false }
|
||||
const base = { ...caching, store: false }
|
||||
return mergeDeep(base, small)
|
||||
}
|
||||
if (model.providerID === "openrouter" || model.providerID === "llmgateway") {
|
||||
|
|
@ -1249,11 +1285,11 @@ export function smallOptions(model: Provider.Model) {
|
|||
}
|
||||
|
||||
if (model.providerID === "venice") {
|
||||
if (Object.keys(small).length > 0) return small
|
||||
return { veniceParameters: { disableThinking: true } }
|
||||
if (Object.keys(small).length > 0) return mergeDeep(caching, small)
|
||||
return mergeDeep(caching, { veniceParameters: { disableThinking: true } })
|
||||
}
|
||||
|
||||
return small
|
||||
return mergeDeep(caching, small)
|
||||
}
|
||||
|
||||
// Maps model ID prefix to provider slug used in providerOptions.
|
||||
|
|
|
|||
|
|
@ -82,7 +82,7 @@ export const prepare = Effect.fn("LLMRequestPrep.prepare")(function* (input: Pre
|
|||
? input.model.variants[input.user.model.variant]
|
||||
: {}
|
||||
const base = input.small
|
||||
? ProviderTransform.smallOptions(input.model)
|
||||
? ProviderTransform.smallOptions(input.model, input.sessionID, input.provider.options)
|
||||
: ProviderTransform.options({
|
||||
model: input.model,
|
||||
sessionID: input.sessionID,
|
||||
|
|
|
|||
|
|
@ -387,6 +387,7 @@ export const getUsage = (input: { model: Provider.Model; usage: Usage; metadata?
|
|||
? input.model.cost.experimentalOver200K
|
||||
: input.model.cost)
|
||||
const totalNanoAiu = input.metadata?.["copilot"]?.["totalNanoAiu"]
|
||||
const cacheReadCost = input.model.api.npm === "@ai-sdk/cerebras" ? costInfo?.input : costInfo?.cache?.read
|
||||
return {
|
||||
cost:
|
||||
typeof totalNanoAiu === "number" && Number.isFinite(totalNanoAiu) && totalNanoAiu >= 0
|
||||
|
|
@ -395,7 +396,7 @@ export const getUsage = (input: { model: Provider.Model; usage: Usage; metadata?
|
|||
new Decimal(0)
|
||||
.add(new Decimal(tokens.input).mul(costInfo?.input ?? 0).div(1_000_000))
|
||||
.add(new Decimal(tokens.output).mul(costInfo?.output ?? 0).div(1_000_000))
|
||||
.add(new Decimal(tokens.cache.read).mul(costInfo?.cache?.read ?? 0).div(1_000_000))
|
||||
.add(new Decimal(tokens.cache.read).mul(cacheReadCost ?? 0).div(1_000_000))
|
||||
.add(new Decimal(tokens.cache.write).mul(costInfo?.cache?.write ?? 0).div(1_000_000))
|
||||
// TODO: update models.dev to have better pricing model, for now:
|
||||
// charge reasoning tokens at the same rate as output tokens
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import type * as Provider from "@/provider/provider"
|
|||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
|
||||
type Captured = { url: string; outerBody: unknown }
|
||||
type Captured = { url: string; headers: Headers; outerBody: unknown }
|
||||
type ProviderOptions = Record<string, Record<string, JSONValue>>
|
||||
|
||||
const realFetch = globalThis.fetch
|
||||
|
|
@ -32,7 +32,7 @@ beforeEach(() => {
|
|||
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url
|
||||
if (url.startsWith("https://gateway.ai.cloudflare.com/")) {
|
||||
const bodyText = typeof init?.body === "string" ? init.body : ""
|
||||
captured = { url, outerBody: bodyText ? JSON.parse(bodyText) : null }
|
||||
captured = { url, headers: new Headers(init?.headers), outerBody: bodyText ? JSON.parse(bodyText) : null }
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
id: "chatcmpl-test",
|
||||
|
|
@ -129,4 +129,19 @@ describe("cf-ai-gateway end-to-end (regression: #24432)", () => {
|
|||
})
|
||||
expect(upstream?.reasoning_effort).toBeUndefined()
|
||||
})
|
||||
|
||||
test("uses current Cloudflare cache headers", async () => {
|
||||
const aigateway = createAiGateway({
|
||||
accountId: "test",
|
||||
gateway: "test",
|
||||
apiKey: "test",
|
||||
options: { cacheTtl: 300, skipCache: true },
|
||||
})
|
||||
await generateText({ model: aigateway(createUnified()("openai/gpt-5.4")), prompt: "hi" })
|
||||
|
||||
expect(captured?.headers.get("cf-aig-cache-ttl")).toBe("300")
|
||||
expect(captured?.headers.get("cf-aig-skip-cache")).toBe("true")
|
||||
expect(captured?.headers.get("cf-cache-ttl")).toBeNull()
|
||||
expect(captured?.headers.get("cf-skip-cache")).toBeNull()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -87,6 +87,32 @@ describe("ProviderTransform.options - setCacheKey", () => {
|
|||
expect(result.promptCacheKey).toBe(sessionID)
|
||||
})
|
||||
|
||||
test("should set promptCacheKey for the OpenAI SDK regardless of provider ID", () => {
|
||||
const result = ProviderTransform.options({
|
||||
model: {
|
||||
...mockModel,
|
||||
providerID: "custom-openai",
|
||||
api: { id: "gpt-5", url: "https://example.com", npm: "@ai-sdk/openai" },
|
||||
},
|
||||
sessionID,
|
||||
providerOptions: {},
|
||||
})
|
||||
expect(result.promptCacheKey).toBe(sessionID)
|
||||
})
|
||||
|
||||
test("should not set promptCacheKey for the OpenAI-compatible SDK by provider name", () => {
|
||||
const result = ProviderTransform.options({
|
||||
model: {
|
||||
...mockModel,
|
||||
providerID: "openai",
|
||||
api: { id: "gpt-5", url: "https://example.com", npm: "@ai-sdk/openai-compatible" },
|
||||
},
|
||||
sessionID,
|
||||
providerOptions: {},
|
||||
})
|
||||
expect(result.promptCacheKey).toBeUndefined()
|
||||
})
|
||||
|
||||
test("should not set promptCacheKey for openai when explicitly disabled", () => {
|
||||
const openaiModel = {
|
||||
...mockModel,
|
||||
|
|
@ -171,6 +197,55 @@ describe("ProviderTransform.options - setCacheKey", () => {
|
|||
providerOptions: {},
|
||||
})
|
||||
expect(result.store).toBe(false)
|
||||
expect(result.promptCacheKey).toBe(sessionID)
|
||||
})
|
||||
|
||||
test("should disable the Azure cache key without disabling store=false", () => {
|
||||
const result = ProviderTransform.options({
|
||||
model: {
|
||||
...mockModel,
|
||||
providerID: "azure",
|
||||
api: { id: "gpt-5", url: "https://azure.com", npm: "@ai-sdk/azure" },
|
||||
},
|
||||
sessionID,
|
||||
providerOptions: { setCacheKey: false },
|
||||
})
|
||||
expect(result.store).toBe(false)
|
||||
expect(result.promptCacheKey).toBeUndefined()
|
||||
})
|
||||
|
||||
for (const npm of ["@ai-sdk/deepinfra", "@ai-sdk/cerebras"]) {
|
||||
test(`should set the snake-case cache key for ${npm}`, () => {
|
||||
const result = ProviderTransform.options({
|
||||
model: { ...mockModel, providerID: "custom", api: { ...mockModel.api, npm } },
|
||||
sessionID,
|
||||
providerOptions: {},
|
||||
})
|
||||
expect(result.prompt_cache_key).toBe(sessionID)
|
||||
expect(result.promptCacheKey).toBeUndefined()
|
||||
})
|
||||
}
|
||||
|
||||
test("should set promptCacheKey for the Mistral SDK", () => {
|
||||
const result = ProviderTransform.options({
|
||||
model: { ...mockModel, providerID: "custom", api: { ...mockModel.api, npm: "@ai-sdk/mistral" } },
|
||||
sessionID,
|
||||
providerOptions: {},
|
||||
})
|
||||
expect(result.promptCacheKey).toBe(sessionID)
|
||||
})
|
||||
|
||||
test("should not send an undocumented OpenRouter prompt_cache_key", () => {
|
||||
const result = ProviderTransform.options({
|
||||
model: {
|
||||
...mockModel,
|
||||
providerID: "openrouter",
|
||||
api: { ...mockModel.api, npm: "@openrouter/ai-sdk-provider" },
|
||||
},
|
||||
sessionID,
|
||||
providerOptions: {},
|
||||
})
|
||||
expect(result.prompt_cache_key).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -663,6 +738,17 @@ describe("ProviderTransform.providerOptions", () => {
|
|||
})
|
||||
})
|
||||
|
||||
test("uses canonical sdk key for custom xAI models", () => {
|
||||
const model = createModel({
|
||||
providerID: "my-xai",
|
||||
api: { id: "grok-4", url: "https://api.x.ai", npm: "@ai-sdk/xai" },
|
||||
})
|
||||
|
||||
expect(ProviderTransform.providerOptions(model, { promptCacheKey: "session" })).toEqual({
|
||||
xai: { promptCacheKey: "session" },
|
||||
})
|
||||
})
|
||||
|
||||
test("forces reasoning for custom OpenAI package models with explicit effort", () => {
|
||||
const model = createModel({
|
||||
providerID: "meta",
|
||||
|
|
@ -2924,6 +3010,20 @@ describe("ProviderTransform.message - cache control on gateway", () => {
|
|||
})
|
||||
})
|
||||
|
||||
test("does not add explicit breakpoints when Anthropic automatic caching is enabled", () => {
|
||||
const model = createModel({
|
||||
providerID: "anthropic",
|
||||
api: { id: "claude-sonnet-4", url: "https://api.anthropic.com", npm: "@ai-sdk/anthropic" },
|
||||
})
|
||||
const msgs = [
|
||||
{ role: "system", content: "You are a helpful assistant" },
|
||||
{ role: "user", content: "Hello" },
|
||||
] as any[]
|
||||
|
||||
const result = ProviderTransform.message(msgs, model, { cacheControl: { type: "ephemeral" } }) as any[]
|
||||
expect(result.every((message) => message.providerOptions === undefined)).toBe(true)
|
||||
})
|
||||
|
||||
test("google-vertex-anthropic applies cache control", () => {
|
||||
const model = createModel({
|
||||
providerID: "google-vertex-anthropic",
|
||||
|
|
@ -4686,6 +4786,13 @@ describe("ProviderTransform.smallOptions - gpt-5 chat/search", () => {
|
|||
expect(ProviderTransform.smallOptions(createModel(testCase.id))).toEqual(testCase.options)
|
||||
})
|
||||
}
|
||||
|
||||
test("includes the OpenAI cache key in small requests", () => {
|
||||
expect(ProviderTransform.smallOptions(createModel("gpt-5-chat-latest"), "session-123")).toEqual({
|
||||
store: false,
|
||||
promptCacheKey: "session-123",
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test("ProviderTransform.smallOptions preserves the weakest OpenRouter reasoning effort", () => {
|
||||
|
|
|
|||
|
|
@ -1564,6 +1564,25 @@ describe("SessionNs.getUsage", () => {
|
|||
expect(result.tokens.cache.read).toBe(200)
|
||||
})
|
||||
|
||||
test("charges Cerebras cache reads at the normal input rate", () => {
|
||||
const result = SessionNs.getUsage({
|
||||
model: createModel({
|
||||
context: 100_000,
|
||||
output: 32_000,
|
||||
npm: "@ai-sdk/cerebras",
|
||||
cost: { input: 10, output: 0, cache: { read: 0, write: 0 } },
|
||||
}),
|
||||
usage: usage({
|
||||
inputTokens: 1_000_000,
|
||||
outputTokens: 0,
|
||||
totalTokens: 1_000_000,
|
||||
cacheReadInputTokens: 200_000,
|
||||
}),
|
||||
})
|
||||
|
||||
expect(result.cost).toBe(10)
|
||||
})
|
||||
|
||||
test("handles anthropic cache write metadata", () => {
|
||||
const model = createModel({ context: 100_000, output: 32_000 })
|
||||
const result = SessionNs.getUsage({
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue