Skip to content
Open
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
53 changes: 53 additions & 0 deletions packages/opencode/src/provider/transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,59 @@ function normalizeMessages(
}
})

// Consolidate leading system messages and lower non-leading system messages to prevent
// Jinja chat template exceptions ("System message must be at the beginning.") on
// OpenAI-compatible, Ollama, vLLM, and local models.
const isAnthropic =
model.api.npm === "@ai-sdk/anthropic" || model.api.npm === "@ai-sdk/google-vertex/anthropic"

const leadingSystem: string[] = []
let inLeadingSystem = true
const processed: ModelMessage[] = []

for (const msg of msgs) {
if (msg.role === "system") {
const content = typeof msg.content === "string" ? msg.content : ""
if (inLeadingSystem) {
if (isAnthropic) {
processed.push(msg)
} else {
if (content.trim().length > 0) {
leadingSystem.push(content)
}
}
} else {
// Non-leading system message in conversation history:
// Lower to user-compatible <system-update> block to preserve chronological ordering
// without violating chat template constraints.
processed.push({
role: "user",
content: `<system-update>\n${content}\n</system-update>`,
})
}
} else {
if (inLeadingSystem) {
inLeadingSystem = false
if (!isAnthropic && leadingSystem.length > 0) {
processed.unshift({
role: "system",
content: leadingSystem.join("\n\n"),
})
}
}
processed.push(msg)
}
}

if (inLeadingSystem && !isAnthropic && leadingSystem.length > 0) {
processed.unshift({
role: "system",
content: leadingSystem.join("\n\n"),
})
}

msgs = processed

// Anthropic rejects messages with empty content - filter out empty string messages
// and remove empty text/reasoning parts from array content
if (model.api.npm === "@ai-sdk/anthropic") {
Expand Down
2 changes: 1 addition & 1 deletion packages/opencode/src/session/llm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -367,7 +367,7 @@ const live: Layer.Layer<
{
specificationVersion: "v3" as const,
async transformParams(args) {
if (args.type === "stream") {
if (args.type === "stream" || args.type === "generate") {
// @ts-expect-error
args.params.prompt = ProviderTransform.message(
args.params.prompt,
Expand Down
108 changes: 108 additions & 0 deletions packages/opencode/test/provider/transform.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4740,3 +4740,111 @@ describe("ProviderTransform.providerOptions - ai-gateway-provider", () => {
expect(result).toEqual({ openaiCompatible: { reasoningEffort: "high" } })
})
})

describe("ProviderTransform.message - system message consolidation and ordering", () => {
const openaiCompatibleModel = {
id: "ollama/qwen3.8-27b",
providerID: "ollama",
api: {
id: "qwen3.8-27b",
npm: "@ai-sdk/openai-compatible",
},
capabilities: { input: { text: true, image: false } },
} as any

const anthropicModel = {
id: "anthropic/claude-sonnet-4-20250514",
providerID: "anthropic",
api: {
id: "claude-sonnet-4-20250514",
npm: "@ai-sdk/anthropic",
},
capabilities: { input: { text: true, image: true } },
} as any

test("consolidates multiple leading system messages into a single system message for OpenAI-compatible providers", () => {
const msgs = [
{ role: "system", content: "You are an AI pentesting agent." },
{ role: "system", content: "<pentest-engagement>\nPhase: recon\n</pentest-engagement>" },
{ role: "user", content: "Scan 10.10.10.5" },
] as any[]

const result = ProviderTransform.message(msgs, openaiCompatibleModel, {})

expect(result).toHaveLength(2)
expect(result[0].role).toBe("system")
expect(result[0].content).toBe(
"You are an AI pentesting agent.\n\n<pentest-engagement>\nPhase: recon\n</pentest-engagement>",
)
expect(result[1].role).toBe("user")
expect(result[1].content).toBe("Scan 10.10.10.5")
})

test("lowers non-leading system messages into user-compatible <system-update> blocks", () => {
const msgs = [
{ role: "system", content: "Base system prompt." },
{ role: "user", content: "First question" },
{ role: "assistant", content: "First answer" },
{ role: "system", content: "Compaction summary or chronological update" },
{ role: "user", content: "Second question" },
] as any[]

const result = ProviderTransform.message(msgs, openaiCompatibleModel, {})

expect(result).toHaveLength(5)
expect(result[0].role).toBe("system")
expect(result[0].content).toBe("Base system prompt.")
expect(result[1].role).toBe("user")
expect(result[2].role).toBe("assistant")
expect(result[3].role).toBe("user")
expect(result[3].content).toBe(
"<system-update>\nCompaction summary or chronological update\n</system-update>",
)
expect(result[4].role).toBe("user")
expect(result[4].content).toBe("Second question")
})

test("preserves distinct leading system blocks for Anthropic prompt caching", () => {
const msgs = [
{ role: "system", content: "Static system prompt" },
{ role: "system", content: "Volatile engagement context" },
{ role: "user", content: "Hello" },
] as any[]

const result = ProviderTransform.message(msgs, anthropicModel, {})

expect(result).toHaveLength(3)
expect(result[0].role).toBe("system")
expect(result[0].content).toBe("Static system prompt")
expect(result[1].role).toBe("system")
expect(result[1].content).toBe("Volatile engagement context")
expect(result[2].role).toBe("user")
})

test("handles single leading system message without duplication", () => {
const msgs = [
{ role: "system", content: "Sole system message" },
{ role: "user", content: "Hello" },
] as any[]

const result = ProviderTransform.message(msgs, openaiCompatibleModel, {})

expect(result).toHaveLength(2)
expect(result[0].role).toBe("system")
expect(result[0].content).toBe("Sole system message")
expect(result[1].role).toBe("user")
})

test("handles message sequence without any system message", () => {
const msgs = [
{ role: "user", content: "Hello" },
{ role: "assistant", content: "Hi" },
] as any[]

const result = ProviderTransform.message(msgs, openaiCompatibleModel, {})

expect(result).toHaveLength(2)
expect(result[0].role).toBe("user")
expect(result[1].role).toBe("assistant")
})
})