Skip to content
Closed
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
4 changes: 4 additions & 0 deletions packages/opencode/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
3 changes: 3 additions & 0 deletions packages/opencode/src/provider/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -378,6 +379,7 @@ export namespace Provider {
return {
id: model.id,
providerID: provider.id,
transforms: undefined,
name: model.name,
api: {
id: model.id,
Expand Down Expand Up @@ -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,
Expand Down
12 changes: 10 additions & 2 deletions packages/opencode/src/provider/transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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|ministral|pixtral/i.test(model.api.id)
if (isMistral) {
const result: ModelMessage[] = []
for (let i = 0; i < msgs.length; i++) {
const msg = msgs[i]
Expand Down Expand Up @@ -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")
Expand Down
256 changes: 256 additions & 0 deletions packages/opencode/test/provider/transform.test.ts
Original file line number Diff line number Diff line change
@@ -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>): 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
Expand Down Expand Up @@ -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")
})
})
})