Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
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|pixtral/i.test(model.api.id)
Comment thread
graelo marked this conversation as resolved.
Outdated
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")
})
})
})