Skip to content
Merged
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
25 changes: 25 additions & 0 deletions packages/types/src/__tests__/mistral.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
14 changes: 14 additions & 0 deletions packages/types/src/providers/mistral.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -13,6 +18,7 @@ export const mistralModels = {
supportsPromptCache: false,
inputPrice: 2.0,
outputPrice: 5.0,
cacheReadsPrice: 0.2,
},
"devstral-medium-latest": {
maxTokens: 8192,
Expand All @@ -21,6 +27,7 @@ export const mistralModels = {
supportsPromptCache: false,
inputPrice: 0.4,
outputPrice: 2.0,
cacheReadsPrice: 0.04,
},
"mistral-medium-latest": {
maxTokens: 8192,
Expand All @@ -29,6 +36,7 @@ export const mistralModels = {
supportsPromptCache: false,
inputPrice: 0.4,
outputPrice: 2.0,
cacheReadsPrice: 0.04,
},
"codestral-latest": {
maxTokens: 8192,
Expand All @@ -37,6 +45,7 @@ export const mistralModels = {
supportsPromptCache: false,
inputPrice: 0.3,
outputPrice: 0.9,
cacheReadsPrice: 0.03,
},
"mistral-large-latest": {
maxTokens: 8192,
Expand All @@ -45,6 +54,7 @@ export const mistralModels = {
supportsPromptCache: false,
inputPrice: 2.0,
outputPrice: 6.0,
cacheReadsPrice: 0.2,
},
"ministral-8b-latest": {
maxTokens: 8192,
Expand All @@ -53,6 +63,7 @@ export const mistralModels = {
supportsPromptCache: false,
inputPrice: 0.1,
outputPrice: 0.1,
cacheReadsPrice: 0.01,
},
"ministral-3b-latest": {
maxTokens: 8192,
Expand All @@ -61,6 +72,7 @@ export const mistralModels = {
supportsPromptCache: false,
inputPrice: 0.04,
outputPrice: 0.04,
cacheReadsPrice: 0.004,
},
"mistral-small-latest": {
maxTokens: 8192,
Expand All @@ -69,6 +81,7 @@ export const mistralModels = {
supportsPromptCache: false,
inputPrice: 0.2,
outputPrice: 0.6,
cacheReadsPrice: 0.02,
},
"pixtral-large-latest": {
maxTokens: 8192,
Expand All @@ -77,6 +90,7 @@ export const mistralModels = {
supportsPromptCache: false,
inputPrice: 2.0,
outputPrice: 6.0,
cacheReadsPrice: 0.2,
},
} as const satisfies Record<string, ModelInfo>

Expand Down
219 changes: 218 additions & 1 deletion src/api/providers/__tests__/mistral.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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<never>((_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
}),
)
Expand Down
Loading
Loading