diff --git a/packages/types/src/__tests__/mistral.test.ts b/packages/types/src/__tests__/mistral.test.ts new file mode 100644 index 0000000000..a9f9a0fe6d --- /dev/null +++ b/packages/types/src/__tests__/mistral.test.ts @@ -0,0 +1,25 @@ +import type { ModelInfo } from "../model.js" +import { mistralModels } from "../providers/mistral.js" + +const modelEntries: [string, ModelInfo][] = Object.entries(mistralModels) + +describe("mistral registry cache pricing", () => { + it("covers all nine catalog entries", () => { + expect(modelEntries).toHaveLength(9) + }) + + it.each(modelEntries)("%s bills cache reads at 10%% of its input price", (id, info) => { + expect(id).toBeTruthy() + expect(info.inputPrice).toBeGreaterThan(0) + expect(info.cacheReadsPrice).toBeDefined() + // Mistral bills cached prompt tokens at 10% of the standard input price. + expect(info.cacheReadsPrice).toBeCloseTo((info.inputPrice as number) * 0.1, 10) + }) + + it.each(modelEntries)("%s keeps the request-side cache marker off", (id, info) => { + // Mistral takes no cache_control markers in the request body, so the flag + // that gates marker insertion stays false even though reads are reported. + expect(id).toBeTruthy() + expect(info.supportsPromptCache).toBe(false) + }) +}) diff --git a/packages/types/src/providers/mistral.ts b/packages/types/src/providers/mistral.ts index 0b030c80d4..a39fae1c51 100644 --- a/packages/types/src/providers/mistral.ts +++ b/packages/types/src/providers/mistral.ts @@ -3,6 +3,11 @@ import type { ModelInfo } from "../model.js" // https://docs.mistral.ai/getting-started/models/models_overview/ export type MistralModelId = keyof typeof mistralModels +// Mistral bills cached prompt tokens at 10% of the standard input price +// (https://docs.mistral.ai/studio-api/conversations/advanced/prompt-caching), +// so every entry below carries `cacheReadsPrice` at one tenth of its own +// `inputPrice`. Reporting cache reads without this price would bill them at $0 +// instead of 0.1x. export const mistralDefaultModelId: MistralModelId = "codestral-latest" export const mistralModels = { @@ -13,6 +18,7 @@ export const mistralModels = { supportsPromptCache: false, inputPrice: 2.0, outputPrice: 5.0, + cacheReadsPrice: 0.2, }, "devstral-medium-latest": { maxTokens: 8192, @@ -21,6 +27,7 @@ export const mistralModels = { supportsPromptCache: false, inputPrice: 0.4, outputPrice: 2.0, + cacheReadsPrice: 0.04, }, "mistral-medium-latest": { maxTokens: 8192, @@ -29,6 +36,7 @@ export const mistralModels = { supportsPromptCache: false, inputPrice: 0.4, outputPrice: 2.0, + cacheReadsPrice: 0.04, }, "codestral-latest": { maxTokens: 8192, @@ -37,6 +45,7 @@ export const mistralModels = { supportsPromptCache: false, inputPrice: 0.3, outputPrice: 0.9, + cacheReadsPrice: 0.03, }, "mistral-large-latest": { maxTokens: 8192, @@ -45,6 +54,7 @@ export const mistralModels = { supportsPromptCache: false, inputPrice: 2.0, outputPrice: 6.0, + cacheReadsPrice: 0.2, }, "ministral-8b-latest": { maxTokens: 8192, @@ -53,6 +63,7 @@ export const mistralModels = { supportsPromptCache: false, inputPrice: 0.1, outputPrice: 0.1, + cacheReadsPrice: 0.01, }, "ministral-3b-latest": { maxTokens: 8192, @@ -61,6 +72,7 @@ export const mistralModels = { supportsPromptCache: false, inputPrice: 0.04, outputPrice: 0.04, + cacheReadsPrice: 0.004, }, "mistral-small-latest": { maxTokens: 8192, @@ -69,6 +81,7 @@ export const mistralModels = { supportsPromptCache: false, inputPrice: 0.2, outputPrice: 0.6, + cacheReadsPrice: 0.02, }, "pixtral-large-latest": { maxTokens: 8192, @@ -77,6 +90,7 @@ export const mistralModels = { supportsPromptCache: false, inputPrice: 2.0, outputPrice: 6.0, + cacheReadsPrice: 0.2, }, } as const satisfies Record diff --git a/src/api/providers/__tests__/mistral.spec.ts b/src/api/providers/__tests__/mistral.spec.ts index 23de4d6b3d..ed7d147388 100644 --- a/src/api/providers/__tests__/mistral.spec.ts +++ b/src/api/providers/__tests__/mistral.spec.ts @@ -60,6 +60,8 @@ import type { ApiStreamUsageChunk, } from "../../transform/stream" import { makeCreateMessageMetadata } from "../../../test-utils/api" +import { mistralModels } from "@roo-code/types" +import { calculateApiCostOpenAI } from "../../../shared/cost" describe("MistralHandler", () => { let handler: MistralHandler @@ -451,6 +453,216 @@ describe("MistralHandler", () => { expect(results[0]).toEqual({ type: "text", text: "Test response" }) expect(results[1]).toEqual({ type: "usage", inputTokens: 12, outputTokens: 34 }) }) + + it("reports cache reads from prompt_tokens_details.cached_tokens without inflating input tokens", async () => { + // Mistral's documented wire shape: prompt_tokens is the TOTAL input + // count and cached_tokens is the cached subset. The pinned SDK has no + // typed field for the details object, so it arrives under + // additionalProperties after the SDK parse. + mockCreate.mockImplementationOnce(async (_options) => + asyncStreamFrom([ + { + data: { + choices: [{ delta: { content: "Test response" }, index: 0 }], + }, + }, + { + data: { + choices: [], + usage: { + promptTokens: 1013, + completionTokens: 30, + additionalProperties: { prompt_tokens_details: { cached_tokens: 1008 } }, + }, + }, + }, + ]), + ) + + const results = await collectStream(handler.createMessage(systemPrompt, messages)) + + expect(results[1]).toEqual({ + type: "usage", + inputTokens: 1013, + outputTokens: 30, + cacheReadTokens: 1008, + }) + }) + + it("omits cacheReadTokens when the usage carries no cached_tokens detail", async () => { + mockCreate.mockImplementationOnce(async (_options) => + asyncStreamFrom([ + { + data: { + choices: [], + usage: { promptTokens: 12, completionTokens: 34 }, + }, + }, + ]), + ) + + const results = await collectStream(handler.createMessage(systemPrompt, messages)) + + expect(results[0]).toEqual({ type: "usage", inputTokens: 12, outputTokens: 34 }) + }) + + it("omits cacheReadTokens when cached_tokens is zero", async () => { + // Mistral reports 0 (or omits the field) when nothing was served from + // cache; a zero read must not become a reported cache-read chunk field. + mockCreate.mockImplementationOnce(async (_options) => + asyncStreamFrom([ + { + data: { + choices: [], + usage: { + promptTokens: 1013, + completionTokens: 30, + additionalProperties: { prompt_tokens_details: { cached_tokens: 0 } }, + }, + }, + }, + ]), + ) + + const results = await collectStream(handler.createMessage(systemPrompt, messages)) + + expect(results[0]).toEqual({ type: "usage", inputTokens: 1013, outputTokens: 30 }) + }) + + it("ignores a non-integer cached_tokens value", async () => { + mockCreate.mockImplementationOnce(async (_options) => + asyncStreamFrom([ + { + data: { + choices: [], + usage: { + promptTokens: 1013, + completionTokens: 30, + additionalProperties: { prompt_tokens_details: { cached_tokens: 100.5 } }, + }, + }, + }, + ]), + ) + + const results = await collectStream(handler.createMessage(systemPrompt, messages)) + + expect(results[0]).toEqual({ type: "usage", inputTokens: 1013, outputTokens: 30 }) + }) + + it("drops cached_tokens that exceed prompt_tokens instead of billing an impossible read", async () => { + // Subset guard, mirroring the OpenAI sibling that reads the same nested + // field (openai.ts:304-313): a malformed upstream count larger than the + // prompt total must not bill reads at 0.1x or surface cacheReads > tokensIn. + mockCreate.mockImplementationOnce(async (_options) => + asyncStreamFrom([ + { + data: { + choices: [], + usage: { + promptTokens: 100, + completionTokens: 30, + additionalProperties: { prompt_tokens_details: { cached_tokens: 1008 } }, + }, + }, + }, + ]), + ) + + const results = await collectStream(handler.createMessage(systemPrompt, messages)) + + expect(results[0]).toEqual({ type: "usage", inputTokens: 100, outputTokens: 30 }) + }) + + it("keeps a cached_tokens value equal to prompt_tokens (inclusive subset bound)", async () => { + // The sibling guard is `<=`, not `<`: a fully cached prompt is valid. + mockCreate.mockImplementationOnce(async (_options) => + asyncStreamFrom([ + { + data: { + choices: [], + usage: { + promptTokens: 1008, + completionTokens: 30, + additionalProperties: { prompt_tokens_details: { cached_tokens: 1008 } }, + }, + }, + }, + ]), + ) + + const results = await collectStream(handler.createMessage(systemPrompt, messages)) + + expect(results[0]).toEqual({ + type: "usage", + inputTokens: 1008, + outputTokens: 30, + cacheReadTokens: 1008, + }) + }) + + it("bills a cached read at 0.1x and the uncached remainder at full input price", async () => { + // End-to-end cost proof for the coupled change: the reported usage + // chunk from the handler feeds calculateApiCostOpenAI, which subtracts + // the reported reads from prompt_tokens and bills them at cacheReadsPrice. + const info = mistralModels["mistral-large-latest"] + const inputTokens = 1013 + const cachedTokens = 1008 + const outputTokens = 30 + + mockCreate.mockImplementationOnce(async (_options) => + asyncStreamFrom([ + { + data: { + choices: [], + usage: { + promptTokens: inputTokens, + completionTokens: outputTokens, + additionalProperties: { prompt_tokens_details: { cached_tokens: cachedTokens } }, + }, + }, + }, + ]), + ) + + const chunks = await collectStream(handler.createMessage(systemPrompt, messages)) + const usage = chunks.find((chunk) => chunk.type === "usage") + expect(usage).toMatchObject({ type: "usage", inputTokens, outputTokens, cacheReadTokens: cachedTokens }) + + const { totalCost } = calculateApiCostOpenAI( + info, + inputTokens, + outputTokens, + 0, + usage?.type === "usage" ? usage.cacheReadTokens : undefined, + ) + + const uncachedCost = ((inputTokens - cachedTokens) / 1_000_000) * (info.inputPrice as number) + const cachedCost = (cachedTokens / 1_000_000) * (info.cacheReadsPrice as number) + const outputCost = (outputTokens / 1_000_000) * (info.outputPrice as number) + + expect(totalCost).toBeCloseTo(uncachedCost + cachedCost + outputCost, 12) + + // Pin the two wrong variants this PR pairs together, so neither can + // regress alone: reporting reads with no price bills them at $0, and + // billing the full prompt at inputPrice over-reports by 9x on the + // cached fraction. + const mappingWithoutPrice = calculateApiCostOpenAI( + { ...info, cacheReadsPrice: undefined }, + inputTokens, + outputTokens, + 0, + cachedTokens, + ) + expect(mappingWithoutPrice.totalCost).toBeLessThan(totalCost) + + const noMapping = calculateApiCostOpenAI(info, inputTokens, outputTokens, 0, undefined) + expect(noMapping.totalCost).toBeGreaterThan(totalCost) + expect(noMapping.totalCost - totalCost).toBeCloseTo( + (cachedTokens / 1_000_000) * ((info.inputPrice as number) - (info.cacheReadsPrice as number)), + 12, + ) + }) }) it("wraps an Error raised by the stream iterator during for await", async () => { @@ -922,8 +1134,13 @@ describe("MistralHandler", () => { // the request. Both signals are already aborted when completePrompt's catch runs, // so the test really pins the first-cause classification. mockComplete.mockImplementation( - () => + (_options: unknown, requestOptions?: { fetchOptions?: { signal?: AbortSignal } }) => new Promise((_resolve, reject) => { + // Attach a listener the way a real fetch does: Node's composite + // AbortSignal.any materializes its first-cause reason at abort time + // only when something is listening, and reads the live source + // signals otherwise. + requestOptions?.fetchOptions?.signal?.addEventListener("abort", () => {}, { once: true }) rejectRequest = reject }), ) diff --git a/src/api/providers/mistral.ts b/src/api/providers/mistral.ts index eafa987df7..27673895c5 100644 --- a/src/api/providers/mistral.ts +++ b/src/api/providers/mistral.ts @@ -50,6 +50,40 @@ type MistralTool = { } } +// The pinned @mistralai/mistralai (1.15.1) has no typed field for +// `prompt_tokens_details`; its `UsageInfo$inboundSchema` collects unknown keys +// into `additionalProperties`, so the documented cache-read count survives the +// SDK parse but only there. Mistral bills these tokens at 10% of the input +// price, and `prompt_tokens` already includes them, so the count is reported as +// `cacheReadTokens` and never added to `inputTokens`. +// +// The `cachedTokens <= promptTokens` subset guard mirrors the OpenAI sibling +// that reads the identical nested wire field (src/api/providers/openai.ts:304-313, +// `reportedCacheReadTokens <= inputTokens`): a malformed upstream value larger +// than the prompt total is dropped instead of billing reads at 0.1x and +// surfacing as cacheReads > tokensIn. +function readCachedPromptTokens( + usage: { additionalProperties?: { [key: string]: unknown } }, + promptTokens: number, +): number | undefined { + const details = usage.additionalProperties?.["prompt_tokens_details"] + if (typeof details !== "object" || details === null) { + return undefined + } + + const cachedTokens = (details as { cached_tokens?: unknown }).cached_tokens + if ( + typeof cachedTokens !== "number" || + !Number.isInteger(cachedTokens) || + cachedTokens <= 0 || + cachedTokens > promptTokens + ) { + return undefined + } + + return cachedTokens +} + export class MistralHandler extends BaseProvider implements SingleCompletionHandler { protected options: ApiHandlerOptions private client: Mistral @@ -179,10 +213,12 @@ export class MistralHandler extends BaseProvider implements SingleCompletionHand if (event.data.usage) { throwIfAborted(requestSignal) + const promptTokens = event.data.usage.promptTokens || 0 yield { type: "usage", - inputTokens: event.data.usage.promptTokens || 0, + inputTokens: promptTokens, outputTokens: event.data.usage.completionTokens || 0, + cacheReadTokens: readCachedPromptTokens(event.data.usage, promptTokens), } } }