From 26edb304a1f778cf5161ec1591a59a096c1cc534 Mon Sep 17 00:00:00 2001 From: graelo Date: Thu, 4 Dec 2025 10:15:35 +0100 Subject: [PATCH 1/2] feat(mistral): add config for conversation transforms --- packages/opencode/src/config/config.ts | 4 + packages/opencode/src/provider/provider.ts | 3 + packages/opencode/src/provider/transform.ts | 12 +- .../opencode/test/provider/transform.test.ts | 256 ++++++++++++++++++ 4 files changed, 273 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 2c691cedb5f..3c94ba21a83 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -475,6 +475,10 @@ export namespace Config { whitelist: z.array(z.string()).optional(), blacklist: z.array(z.string()).optional(), models: z.record(z.string(), ModelsDev.Model.partial()).optional(), + transforms: z + .enum(["mistral", "deepseek"]) + .optional() + .describe("Apply specific provider transforms for custom providers serving compatible models"), options: z .object({ apiKey: z.string().optional(), diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 54367bcfeb3..873fd7e8c2c 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -302,6 +302,7 @@ export namespace Provider { .object({ id: z.string(), providerID: z.string(), + transforms: z.enum(["mistral", "deepseek"]).optional(), api: z.object({ id: z.string(), url: z.string(), @@ -378,6 +379,7 @@ export namespace Provider { return { id: model.id, providerID: provider.id, + transforms: undefined, name: model.name, api: { id: model.id, @@ -520,6 +522,7 @@ export namespace Provider { status: model.status ?? existing?.status ?? "active", name, providerID, + transforms: provider.transforms ?? existing?.transforms, capabilities: { temperature: model.temperature ?? existing?.capabilities.temperature ?? false, reasoning: model.reasoning ?? existing?.capabilities.reasoning ?? false, diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 09dfd69a317..830642366b6 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -21,7 +21,11 @@ export namespace ProviderTransform { return msg }) } - if (model.providerID === "mistral" || model.api.id.toLowerCase().includes("mistral")) { + const isMistral = + model.transforms === "mistral" || + model.providerID === "mistral" || + /mistral|codestral|devstral|pixtral/i.test(model.api.id) + if (isMistral) { const result: ModelMessage[] = [] for (let i = 0; i < msgs.length; i++) { const msg = msgs[i] @@ -67,7 +71,11 @@ export namespace ProviderTransform { // - With tool calls: Include reasoning_content in providerOptions so model can continue reasoning // - Without tool calls: Strip reasoning (new turn doesn't need previous reasoning) // See: https://api-docs.deepseek.com/guides/thinking_mode - if (model.providerID === "deepseek" || model.api.id.toLowerCase().includes("deepseek")) { + const isDeepSeek = + model.transforms === "deepseek" || + model.providerID === "deepseek" || + model.api.id.toLowerCase().includes("deepseek") + if (isDeepSeek) { return msgs.map((msg) => { if (msg.role === "assistant" && Array.isArray(msg.content)) { const reasoningParts = msg.content.filter((part: any) => part.type === "reasoning") diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index 648f108bd66..36d8882e8d5 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -1,8 +1,44 @@ import { describe, expect, test } from "bun:test" import { ProviderTransform } from "../../src/provider/transform" +import type { Provider } from "../../src/provider/provider" const OUTPUT_TOKEN_MAX = 32000 +function createModel(overrides: Partial): Provider.Model { + return { + id: "test-model", + providerID: "test-provider", + transforms: undefined, + api: { + id: "test-model", + url: "https://api.test.com", + npm: "@ai-sdk/openai-compatible", + }, + name: "Test Model", + capabilities: { + temperature: true, + reasoning: false, + attachment: false, + toolcall: true, + input: { text: true, audio: false, image: false, video: false, pdf: false }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + }, + cost: { + input: 0.001, + output: 0.002, + cache: { read: 0.0001, write: 0.0002 }, + }, + limit: { + context: 128000, + output: 8192, + }, + status: "active", + options: {}, + headers: {}, + ...overrides, + } +} + describe("ProviderTransform.maxOutputTokens", () => { test("returns 32k when modelLimit > 32k", () => { const modelLimit = 100000 @@ -303,3 +339,223 @@ describe("ProviderTransform.message - DeepSeek reasoning content", () => { expect(result[0].providerOptions?.openaiCompatible?.reasoning_content).toBeUndefined() }) }) + +describe("ProviderTransform.message - Mistral transforms", () => { + describe("transform detection", () => { + test("matches providerID === 'mistral'", () => { + const msgs = [ + { + role: "tool", + content: [{ type: "tool-result", toolCallId: "call-abc123def456", result: "done" }], + }, + { role: "user", content: [{ type: "text", text: "next" }] }, + ] as any[] + + const result = ProviderTransform.message( + msgs, + createModel({ providerID: "mistral", api: { id: "mistral-large", url: "https://api.mistral.ai", npm: "@ai-sdk/mistral" } }), + ) + + // Should insert assistant message between tool and user + expect(result).toHaveLength(3) + expect(result[1].role).toBe("assistant") + }) + + test("matches transforms config === 'mistral' for custom provider", () => { + const msgs = [ + { + role: "tool", + content: [{ type: "tool-result", toolCallId: "call-abc123def456", result: "done" }], + }, + { role: "user", content: [{ type: "text", text: "next" }] }, + ] as any[] + + const result = ProviderTransform.message( + msgs, + createModel({ + providerID: "my-local-llm", + transforms: "mistral", + api: { id: "my-codestral", url: "http://localhost:8080/v1", npm: "@ai-sdk/openai-compatible" }, + }), + ) + + // Should insert assistant message between tool and user + expect(result).toHaveLength(3) + expect(result[1].role).toBe("assistant") + }) + + test("matches model name containing 'codestral'", () => { + const msgs = [ + { + role: "tool", + content: [{ type: "tool-result", toolCallId: "call-abc123def456", result: "done" }], + }, + { role: "user", content: [{ type: "text", text: "next" }] }, + ] as any[] + + const result = ProviderTransform.message( + msgs, + createModel({ + providerID: "some-provider", + api: { id: "codestral-latest", url: "https://api.example.com", npm: "@ai-sdk/openai-compatible" }, + }), + ) + + expect(result).toHaveLength(3) + expect(result[1].role).toBe("assistant") + }) + + test("matches model name containing 'devstral'", () => { + const msgs = [ + { + role: "tool", + content: [{ type: "tool-result", toolCallId: "call-abc123def456", result: "done" }], + }, + { role: "user", content: [{ type: "text", text: "next" }] }, + ] as any[] + + const result = ProviderTransform.message( + msgs, + createModel({ + providerID: "some-provider", + api: { id: "devstral-small-2505", url: "https://api.example.com", npm: "@ai-sdk/openai-compatible" }, + }), + ) + + expect(result).toHaveLength(3) + expect(result[1].role).toBe("assistant") + }) + + test("matches model name containing 'pixtral'", () => { + const msgs = [ + { + role: "tool", + content: [{ type: "tool-result", toolCallId: "call-abc123def456", result: "done" }], + }, + { role: "user", content: [{ type: "text", text: "next" }] }, + ] as any[] + + const result = ProviderTransform.message( + msgs, + createModel({ + providerID: "some-provider", + api: { id: "pixtral-large-latest", url: "https://api.example.com", npm: "@ai-sdk/openai-compatible" }, + }), + ) + + expect(result).toHaveLength(3) + expect(result[1].role).toBe("assistant") + }) + + test("does not match unrelated provider", () => { + const msgs = [ + { + role: "tool", + content: [{ type: "tool-result", toolCallId: "call-abc123def456", result: "done" }], + }, + { role: "user", content: [{ type: "text", text: "next" }] }, + ] as any[] + + const result = ProviderTransform.message( + msgs, + createModel({ + providerID: "openai", + api: { id: "gpt-4", url: "https://api.openai.com", npm: "@ai-sdk/openai" }, + }), + ) + + // Should NOT insert assistant message + expect(result).toHaveLength(2) + }) + }) + + describe("tool call ID normalization", () => { + test("normalizes tool call IDs to 9 alphanumeric characters", () => { + const msgs = [ + { + role: "assistant", + content: [{ type: "tool-call", toolCallId: "call_abc-123-def-456", toolName: "bash", input: {} }], + }, + ] as any[] + + const result = ProviderTransform.message(msgs, createModel({ providerID: "mistral" })) + + const content = result[0].content as any[] + expect(content[0].toolCallId).toBe("callabc12") + }) + + test("pads short IDs with zeros", () => { + const msgs = [ + { + role: "assistant", + content: [{ type: "tool-call", toolCallId: "ab", toolName: "bash", input: {} }], + }, + ] as any[] + + const result = ProviderTransform.message(msgs, createModel({ providerID: "mistral" })) + + const content = result[0].content as any[] + expect(content[0].toolCallId).toBe("ab0000000") + }) + + test("normalizes tool result IDs too", () => { + const msgs = [ + { + role: "tool", + content: [{ type: "tool-result", toolCallId: "call_xyz-789-abc", result: "output" }], + }, + ] as any[] + + const result = ProviderTransform.message(msgs, createModel({ providerID: "mistral" })) + + const content = result[0].content as any[] + expect(content[0].toolCallId).toBe("callxyz78") + }) + }) + + describe("message sequence fixing", () => { + test("inserts assistant message between tool and user", () => { + const msgs = [ + { role: "user", content: [{ type: "text", text: "do something" }] }, + { + role: "assistant", + content: [{ type: "tool-call", toolCallId: "abc123def", toolName: "bash", input: {} }], + }, + { + role: "tool", + content: [{ type: "tool-result", toolCallId: "abc123def", result: "done" }], + }, + { role: "user", content: [{ type: "text", text: "thanks" }] }, + ] as any[] + + const result = ProviderTransform.message(msgs, createModel({ providerID: "mistral" })) + + expect(result).toHaveLength(5) + expect(result[0].role).toBe("user") + expect(result[1].role).toBe("assistant") + expect(result[2].role).toBe("tool") + expect(result[3].role).toBe("assistant") + expect(result[3].content).toEqual([{ type: "text", text: "Done." }]) + expect(result[4].role).toBe("user") + }) + + test("does not insert assistant if tool is followed by assistant", () => { + const msgs = [ + { + role: "tool", + content: [{ type: "tool-result", toolCallId: "abc123def", result: "done" }], + }, + { + role: "assistant", + content: [{ type: "text", text: "I see the result" }], + }, + ] as any[] + + const result = ProviderTransform.message(msgs, createModel({ providerID: "mistral" })) + + expect(result).toHaveLength(2) + expect(result[0].role).toBe("tool") + expect(result[1].role).toBe("assistant") + }) + }) +}) From 614230771ed106ecddeccc5843e7ddefa67a73cb Mon Sep 17 00:00:00 2001 From: graelo Date: Thu, 4 Dec 2025 11:45:42 +0100 Subject: [PATCH 2/2] feat(mistral): add auto-detection for ministral --- packages/opencode/src/provider/transform.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 830642366b6..fb43170bbcd 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -24,7 +24,7 @@ export namespace ProviderTransform { const isMistral = model.transforms === "mistral" || model.providerID === "mistral" || - /mistral|codestral|devstral|pixtral/i.test(model.api.id) + /mistral|codestral|devstral|ministral|pixtral/i.test(model.api.id) if (isMistral) { const result: ModelMessage[] = [] for (let i = 0; i < msgs.length; i++) {