Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
179 changes: 179 additions & 0 deletions src/api/providers/__tests__/base-openai-compatible-provider.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,32 @@ class TestOpenAiCompatibleProvider extends BaseOpenAiCompatibleProvider<"test-mo
}
}

// A provider whose catalog row publishes an explicit zero cache-write price,
// the shape friendli and moonshot ship. It exists so the miss-token path can be
// priced end to end rather than only asserted as a token label.
const ZERO_WRITE_PRICE_MODEL_INFO: ModelInfo = {
maxTokens: 4096,
contextWindow: 128000,
supportsImages: false,
supportsPromptCache: true,
inputPrice: 1.4,
outputPrice: 4.4,
cacheWritesPrice: 0,
cacheReadsPrice: 0.26,
}

class ZeroWritePriceProvider extends BaseOpenAiCompatibleProvider<"zero-write"> {
constructor(apiKey: string) {
super({
providerName: "ZeroWriteProvider",
baseURL: "https://test.example.com/v1",
defaultProviderModelId: "zero-write",
providerModels: { "zero-write": ZERO_WRITE_PRICE_MODEL_INFO },
apiKey,
})
}
}

describe("BaseOpenAiCompatibleProvider", () => {
let handler: TestOpenAiCompatibleProvider

Expand Down Expand Up @@ -297,6 +323,159 @@ describe("BaseOpenAiCompatibleProvider", () => {
expect(firstChunk.done).toBe(false)
expect(firstChunk.value).toMatchObject({ type: "usage", inputTokens: 100, outputTokens: 50 })
})

describe("cache accounting", () => {
const usageChunksFor = async (usage: Record<string, unknown>) => {
mockCreate.mockImplementationOnce(() => asyncStreamFrom([{ choices: [{ delta: {} }], usage }]))

const chunks = await collectStream(handler.createMessage("system prompt", []))

return chunks.filter((chunk) => chunk.type === "usage")
}

it("reads the nested shape it already understood", async () => {
expect(
await usageChunksFor({
prompt_tokens: 100,
completion_tokens: 50,
prompt_tokens_details: { cached_tokens: 20, cache_write_tokens: 30 },
}),
).toEqual([
expect.objectContaining({
type: "usage",
inputTokens: 100,
outputTokens: 50,
cacheReadTokens: 20,
cacheWriteTokens: 30,
}),
])
})

it("reads the top-level cache_creation_input_tokens write shape", async () => {
expect(
await usageChunksFor({
prompt_tokens: 100,
completion_tokens: 50,
cache_creation_input_tokens: 30,
}),
).toEqual([
expect.objectContaining({
type: "usage",
inputTokens: 100,
outputTokens: 50,
cacheWriteTokens: 30,
}),
])
})

it("reads the top-level cache_read_input_tokens read shape", async () => {
expect(
await usageChunksFor({
prompt_tokens: 100,
completion_tokens: 50,
cache_read_input_tokens: 20,
}),
).toEqual([
expect.objectContaining({ type: "usage", inputTokens: 100, outputTokens: 50, cacheReadTokens: 20 }),
])
})

it("bills a DeepSeek-shaped miss counter as ordinary input, not as a cache write", async () => {
// The base class serves baseten/fireworks/friendli/sambanova/zai, none of
// which price misses through the cache-write field. A miss is input that
// `prompt_tokens` already covers, so it must stay in the input bucket.
const [chunk] = await usageChunksFor({
prompt_tokens: 100,
completion_tokens: 50,
prompt_cache_hit_tokens: 20,
prompt_cache_miss_tokens: 30,
})

expect(chunk).toMatchObject({
type: "usage",
inputTokens: 100,
outputTokens: 50,
cacheReadTokens: 20,
})
expect(chunk.cacheWriteTokens).toBeUndefined()

// TEST_MODEL_INFO prices input at 0.5 and output at 1.5 with no cache
// prices, so the 80 uncached tokens bill at the input price and the 20
// reads bill at $0: (80 * 0.5 + 50 * 1.5) / 1e6.
expect(chunk.totalCost).toBeCloseTo(0.000115, 9)

// Folding the 30 misses onto the unpriced write bucket would subtract
// them from the input pool and bill them nowhere: 0.0001.
expect(chunk.totalCost).not.toBeCloseTo(0.0001, 9)
})

it("never sums two shapes that report the same quantity", async () => {
// All shapes below describe the same 20 reads and 30 writes. An
// additive reader would bill 60 reads and 90 writes.
expect(
await usageChunksFor({
prompt_tokens: 100,
completion_tokens: 50,
prompt_tokens_details: { cached_tokens: 20, cache_write_tokens: 30 },
prompt_cache_hit_tokens: 20,
prompt_cache_miss_tokens: 30,
cache_read_input_tokens: 20,
cache_creation_input_tokens: 30,
}),
).toEqual([
expect.objectContaining({
type: "usage",
inputTokens: 100,
outputTokens: 50,
cacheReadTokens: 20,
cacheWriteTokens: 30,
}),
])
})

it("bills a miss on an explicit-zero cache-write row at the input price", async () => {
// friendli and moonshot publish `cacheWritesPrice: 0`, so the kernel fix
// alone cannot repair this shape: a miss routed to the write bucket is
// still billed at zero. Only keeping misses out of that bucket bills them.
mockCreate.mockImplementationOnce(() =>
asyncStreamFrom([
{
choices: [{ delta: {} }],
usage: {
prompt_tokens: 1000,
completion_tokens: 500,
prompt_cache_hit_tokens: 100,
prompt_cache_miss_tokens: 400,
},
},
]),
)

const chunks = await collectStream(new ZeroWritePriceProvider("test-api-key").createMessage("s", []))
const [chunk] = chunks.filter((c) => c.type === "usage")

expect(chunk.cacheWriteTokens).toBeUndefined()

// The 400 misses stay inside prompt_tokens, so they are priced as input.
// Uncached input: (1.4 / 1_000_000) * 900 = 0.00126
// Cache reads: (0.26 / 1_000_000) * 100 = 0.000026
// Output cost: (4.4 / 1_000_000) * 500 = 0.0022
// Total: 0.00126 + 0.000026 + 0.0022 = 0.003486
expect(chunk.totalCost).toBeCloseTo(0.003486, 9)

// The defect moved those 400 tokens onto the row's zero write price and
// out of the input pool, billing 0.002926: 16.1% short.
expect(chunk.totalCost).not.toBeCloseTo(0.002926, 9)
})

it("reports no cache counters when the provider reports none", async () => {
const [chunk] = await usageChunksFor({ prompt_tokens: 100, completion_tokens: 50 })

expect(chunk).toMatchObject({ type: "usage", inputTokens: 100, outputTokens: 50 })
expect(chunk.cacheReadTokens).toBeUndefined()
expect(chunk.cacheWriteTokens).toBeUndefined()
})
})
})

describe("getModel", () => {
Expand Down
Loading
Loading