From 7cc45e399092efa778b7d33336c2e63352f59c2f Mon Sep 17 00:00:00 2001 From: myk1yt Date: Sat, 31 Oct 2026 01:23:03 +0900 Subject: [PATCH 1/3] test(mistral): attach an abort listener so the first-cause test can observe it What: the "keeps TimeoutError when the timeout fires before a later caller abort" case now attaches an abort listener to the merged request signal from its completePrompt mock, the way a real fetch does. Why: on Node 24, AbortSignal.any() is a lazy composite. It materializes the first-cause reason at abort time only when something is listening; with no listener it re-reads the live source signals when .reason is first accessed, so a later caller abort overwrote the earlier timeout reason and the handler correctly reclassified the failure as AbortError. The test, not the product code, was wrong: it asserted a reason the runtime never computed. The pinned Node 22.23.4 behaves the same way, so this is not a version drift. Impact: test-only. mistral.spec.ts goes from 49/50 to 50/50 on this base, and the product classification logic in src/api/providers/mistral.ts is unchanged. --- src/api/providers/__tests__/mistral.spec.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/api/providers/__tests__/mistral.spec.ts b/src/api/providers/__tests__/mistral.spec.ts index 23de4d6b3d..472d659727 100644 --- a/src/api/providers/__tests__/mistral.spec.ts +++ b/src/api/providers/__tests__/mistral.spec.ts @@ -922,8 +922,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 }), ) From a2489275501106b04ab2796039c86f318ab82150 Mon Sep 17 00:00:00 2001 From: myk1yt Date: Sat, 31 Oct 2026 01:23:40 +0900 Subject: [PATCH 2/3] fix(mistral): bill cache reads at 0.1x instead of the full input price What: MistralHandler now reports `cacheReadTokens` from `usage.prompt_tokens_details.cached_tokens`, and all nine catalog entries in packages/types/src/providers/mistral.ts carry `cacheReadsPrice` at one tenth of their own `inputPrice`. Why: Mistral bills cached prompt tokens at 10% of the standard input price (https://docs.mistral.ai/studio-api/conversations/advanced/prompt-caching), but the handler yielded only input/output tokens, so every cached read was billed at 1.0x. The two halves must land together: calculateApiCostOpenAI subtracts reported reads from prompt_tokens and prices them at cacheReadsPrice, so mapping reads without the price would bill them at $0 and under-report instead. The pinned @mistralai/mistralai 1.15.1 has no typed field for `prompt_tokens_details`; its UsageInfo$inboundSchema collects unknown keys into `additionalProperties`, which is where the count is read from. `prompt_tokens` stays the total, so reads are never added to inputTokens. Impact: cached Mistral input drops from 1.0x to 0.1x of the input price, removing up to ~9x over-reporting on the cached fraction. `supportsPromptCache` stays false: it gates request-side cache markers, which Mistral does not take. `prompt_cache_key` is deliberately not sent: the pinned SDK's closed outbound schema strips unknown fields silently, so sending it needs a raw-request path, tracked separately. --- packages/types/src/__tests__/mistral.test.ts | 25 +++ packages/types/src/providers/mistral.ts | 14 ++ src/api/providers/__tests__/mistral.spec.ts | 161 +++++++++++++++++++ src/api/providers/mistral.ts | 21 +++ 4 files changed, 221 insertions(+) create mode 100644 packages/types/src/__tests__/mistral.test.ts 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 472d659727..d5549f1271 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,165 @@ 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("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 () => { diff --git a/src/api/providers/mistral.ts b/src/api/providers/mistral.ts index eafa987df7..a0da43fad3 100644 --- a/src/api/providers/mistral.ts +++ b/src/api/providers/mistral.ts @@ -50,6 +50,26 @@ 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`. +function readCachedPromptTokens(usage: { additionalProperties?: { [key: string]: unknown } }): 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) { + return undefined + } + + return cachedTokens +} + export class MistralHandler extends BaseProvider implements SingleCompletionHandler { protected options: ApiHandlerOptions private client: Mistral @@ -183,6 +203,7 @@ export class MistralHandler extends BaseProvider implements SingleCompletionHand type: "usage", inputTokens: event.data.usage.promptTokens || 0, outputTokens: event.data.usage.completionTokens || 0, + cacheReadTokens: readCachedPromptTokens(event.data.usage), } } } From b02e6152428c0f67d962da52d2ceaae6de93b9af Mon Sep 17 00:00:00 2001 From: myk1yt Date: Sat, 31 Oct 2026 01:51:16 +0900 Subject: [PATCH 3/3] fix(mistral): drop cached_tokens that exceed prompt_tokens What: readCachedPromptTokens now takes the reported prompt total and rejects a cached_tokens value larger than it, mirroring the subset guard the OpenAI sibling applies to the identical nested wire field (src/api/providers/openai.ts:304-313, `reportedCacheReadTokens <= inputTokens`). Why: without the bound, a malformed upstream count larger than prompt_tokens would bill reads at 0.1x and surface as cacheReads > tokensIn. The sibling already treats that shape as invalid rather than trusted, so the two readers of `prompt_tokens_details.cached_tokens` now agree. The bound is inclusive (`>` is the rejection test), matching the sibling: a fully cached prompt where cached_tokens equals prompt_tokens stays valid. Impact: malformed usage is dropped instead of mis-billed; valid usage is unchanged. Two spec cases pin the behavior: an over-total value is dropped, and an equal-to-total value is kept. --- src/api/providers/__tests__/mistral.spec.ts | 51 +++++++++++++++++++++ src/api/providers/mistral.ts | 23 ++++++++-- 2 files changed, 70 insertions(+), 4 deletions(-) diff --git a/src/api/providers/__tests__/mistral.spec.ts b/src/api/providers/__tests__/mistral.spec.ts index d5549f1271..ed7d147388 100644 --- a/src/api/providers/__tests__/mistral.spec.ts +++ b/src/api/providers/__tests__/mistral.spec.ts @@ -550,6 +550,57 @@ describe("MistralHandler", () => { 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 diff --git a/src/api/providers/mistral.ts b/src/api/providers/mistral.ts index a0da43fad3..27673895c5 100644 --- a/src/api/providers/mistral.ts +++ b/src/api/providers/mistral.ts @@ -56,14 +56,28 @@ type MistralTool = { // 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`. -function readCachedPromptTokens(usage: { additionalProperties?: { [key: string]: unknown } }): number | undefined { +// +// 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) { + if ( + typeof cachedTokens !== "number" || + !Number.isInteger(cachedTokens) || + cachedTokens <= 0 || + cachedTokens > promptTokens + ) { return undefined } @@ -199,11 +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), + cacheReadTokens: readCachedPromptTokens(event.data.usage, promptTokens), } } }