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
11 changes: 7 additions & 4 deletions packages/opencode/src/bus/global.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,14 @@ export type GlobalEvent = {
class GlobalBusEmitter extends EventEmitter<{
event: [GlobalEvent]
}> {
override emit(eventName: "event", event: GlobalEvent): boolean {
if (event.payload && typeof event.payload === "object" && !("id" in event.payload)) {
event.payload.id = event.payload.syncEvent?.id ?? Identifier.create("evt", "ascending")
override emit(eventName: string | symbol, ...args: any[]): boolean {
if (eventName === "event") {
const event = args[0] as GlobalEvent | undefined
if (event?.payload && typeof event.payload === "object" && !("id" in event.payload)) {
event.payload.id = event.payload.syncEvent?.id ?? Identifier.create("evt", "ascending")
}
}
return super.emit(eventName, event)
return super.emit(eventName, ...args)
}
}

Expand Down
31 changes: 24 additions & 7 deletions packages/opencode/src/provider/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,22 @@ function shouldUseCopilotResponsesApi(modelID: string): boolean {
return Number(match[1]) >= 5 && !modelID.startsWith("gpt-5-mini")
}

function defaultOpenAICompatibleInterleaved(
apiNpm: string,
apiID: string,
reasoning: boolean,
): false | { field: "reasoning_content" } {
if (apiNpm !== "@ai-sdk/openai-compatible" || !reasoning) return false

const id = apiID.toLowerCase()
const usesReasoningContent =
id.includes("deepseek") ||
id.includes("kimi") ||
/(^|[/:])glm-(4\.7|5(?:\.1)?|5v)(?:[^a-z0-9]|$)/.test(id)

return usesReasoningContent ? { field: "reasoning_content" } : false
}

function wrapSSE(res: Response, ms: number, ctl: AbortController) {
if (typeof ms !== "number" || ms <= 0) return res
if (!res.body) return res
Expand Down Expand Up @@ -1271,6 +1287,12 @@ const layer = Layer.effect(
if (model.id && model.id !== modelID) return modelID
return existingModel?.name ?? modelID
})
const reasoning = model.reasoning ?? existingModel?.capabilities.reasoning ?? false
const defaultInterleaved = defaultOpenAICompatibleInterleaved(apiNpm, apiID, reasoning)
const existingInterleaved = existingModel?.capabilities.interleaved
const interleaved =
model.interleaved ??
(existingInterleaved ? existingInterleaved : defaultInterleaved)
const parsedModel: Model = {
id: ModelID.make(modelID),
api: {
Expand All @@ -1283,7 +1305,7 @@ const layer = Layer.effect(
providerID: ProviderID.make(providerID),
capabilities: {
temperature: model.temperature ?? existingModel?.capabilities.temperature ?? false,
reasoning: model.reasoning ?? existingModel?.capabilities.reasoning ?? false,
reasoning,
attachment: model.attachment ?? existingModel?.capabilities.attachment ?? false,
toolcall: model.tool_call ?? existingModel?.capabilities.toolcall ?? true,
input: {
Expand All @@ -1303,12 +1325,7 @@ const layer = Layer.effect(
model.modalities?.output?.includes("video") ?? existingModel?.capabilities.output.video ?? false,
pdf: model.modalities?.output?.includes("pdf") ?? existingModel?.capabilities.output.pdf ?? false,
},
interleaved:
model.interleaved ??
existingModel?.capabilities.interleaved ??
(!existingModel && apiNpm === "@ai-sdk/openai-compatible" && apiID.includes("deepseek")
? { field: "reasoning_content" }
: false),
interleaved,
},
cost: {
input: model?.cost?.input ?? existingModel?.cost?.input ?? 0,
Expand Down
105 changes: 104 additions & 1 deletion packages/opencode/src/provider/transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,76 @@ export function sanitizeSurrogates(content: string) {
return content.replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g, "\uFFFD")
}

const REASONING_REPLAY_FIELDS = ["reasoning_content", "reasoning_details"] as const

function isCerebrasCompatibleEndpoint(model: Provider.Model) {
if (model.api.npm === "@ai-sdk/cerebras") return true
const providerID = model.providerID.toLowerCase()
const apiURL = model.api.url.toLowerCase()
return providerID.includes("cerebras") || apiURL.includes("cerebras")
}

function cerebrasReasoningText(text: string, model: Provider.Model) {
return model.api.id.toLowerCase().includes("gpt-oss") ? text : `<think>${text}</think>`
}

function reasoningReplayText(value: unknown): string {
if (typeof value === "string") return value
if (Array.isArray(value)) return value.map(reasoningReplayText).join("")
if (!value || typeof value !== "object") return ""
const entry = value as Record<string, unknown>
if (typeof entry.text === "string") return entry.text
if (typeof entry.content === "string") return entry.content
if (typeof entry.reasoning === "string") return entry.reasoning
return ""
}

function topLevelReasoningReplayText(value: unknown): string {
if (!value || typeof value !== "object" || Array.isArray(value)) return ""
const entry = value as Record<string, unknown>
return REASONING_REPLAY_FIELDS.map((field) => reasoningReplayText(entry[field])).join("")
}

function stripReasoningReplayFields<T>(value: T, model: Provider.Model): T {
if (!value || typeof value !== "object" || Array.isArray(value)) return value
let changed = false
const result: Record<string, any> = { ...(value as Record<string, any>) }

for (const field of REASONING_REPLAY_FIELDS) {
if (field in result) {
delete result[field]
changed = true
}
}

const providerOptions = result.providerOptions
if (!providerOptions) return changed ? (result as T) : value

const optionKeys = unique(["openaiCompatible", "openai-compatible", model.providerID])
const nextProviderOptions: Record<string, any> = { ...providerOptions }

for (const optionKey of optionKeys) {
const options = nextProviderOptions[optionKey]
if (!options || typeof options !== "object" || Array.isArray(options)) continue

const nextOptions: Record<string, unknown> = { ...options }
for (const field of REASONING_REPLAY_FIELDS) {
if (field in nextOptions) {
delete nextOptions[field]
changed = true
}
}

if (Object.keys(nextOptions).length === 0) delete nextProviderOptions[optionKey]
else nextProviderOptions[optionKey] = nextOptions
}

if (!changed) return value
if (Object.keys(nextProviderOptions).length === 0) delete result.providerOptions
else result.providerOptions = nextProviderOptions
return result as T
}

// Maps npm package to the key the AI SDK expects for providerOptions
function sdkKey(npm: string): string | undefined {
switch (npm) {
Expand Down Expand Up @@ -283,6 +353,38 @@ function normalizeMessages(
return result
}

if (isCerebrasCompatibleEndpoint(model)) {
// Cerebras-compatible endpoints reject OpenAI-compatible reasoning replay
// fields on prior assistant messages. Fold useful reasoning back into
// content and strip replay providerOptions before the SDK serializes them.
msgs = msgs.map((msg) => {
if (msg.role !== "assistant") return msg
const replayText = topLevelReasoningReplayText(msg)
const stripped = stripReasoningReplayFields(msg, model)
if (!Array.isArray(stripped.content)) {
if (!replayText) return stripped
const folded = cerebrasReasoningText(replayText, model)
const existing = typeof stripped.content === "string" ? stripped.content : ""
return {
...stripped,
content: existing.trim() ? `${folded}\n${existing}` : folded,
} as ModelMessage
}
const content = stripped.content.flatMap((part: any) => {
if (part.type !== "reasoning") return [stripReasoningReplayFields(part, model)]
if (part.text.length === 0) return []
return [{ type: "text" as const, text: cerebrasReasoningText(part.text, model) }]
})
if (replayText) {
content.unshift({ type: "text" as const, text: cerebrasReasoningText(replayText, model) })
}
return {
...stripped,
content,
} as ModelMessage
})
}

// Deepseek requires all assistant messages to have reasoning on them
if (model.api.id.toLowerCase().includes("deepseek")) {
msgs = msgs.map((msg) => {
Expand All @@ -304,7 +406,8 @@ function normalizeMessages(
if (
typeof model.capabilities.interleaved === "object" &&
model.capabilities.interleaved.field &&
model.api.npm !== "@openrouter/ai-sdk-provider"
model.api.npm !== "@openrouter/ai-sdk-provider" &&
!isCerebrasCompatibleEndpoint(model)
) {
const field = model.capabilities.interleaved.field
return msgs.map((msg) => {
Expand Down
1 change: 0 additions & 1 deletion packages/opencode/src/pty/pty.node.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
/** @ts-expect-error */
import * as pty from "@lydell/node-pty"
import type { Opts, Proc } from "./pty"

Expand Down
11 changes: 7 additions & 4 deletions packages/opencode/src/session/llm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -395,11 +395,14 @@ const live: Layer.Layer<
{
specificationVersion: "v3" as const,
async transformParams(args) {
if (args.type === "stream") {
// @ts-expect-error
args.params.prompt = ProviderTransform.message(args.params.prompt, input.model, options)
const params = args.params as any
if (Array.isArray(params.prompt)) {
params.prompt = ProviderTransform.message(params.prompt, input.model, options)
}
return args.params
if (Array.isArray(params.messages)) {
params.messages = ProviderTransform.message(params.messages, input.model, options)
}
return params
},
},
],
Expand Down
38 changes: 37 additions & 1 deletion packages/opencode/test/provider/provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -305,7 +305,7 @@ test("custom provider with npm package", async () => {
})
})

test("custom DeepSeek openai-compatible model defaults interleaved reasoning field", async () => {
test("custom OpenAI-compatible reasoning_content models default interleaved reasoning field", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
Expand All @@ -320,14 +320,43 @@ test("custom DeepSeek openai-compatible model defaults interleaved reasoning fie
models: {
"deepseek-r1": {
name: "DeepSeek R1",
reasoning: true,
},
"deepseek-details": {
name: "DeepSeek Details",
reasoning: true,
interleaved: { field: "reasoning_details" },
},
"kimi-k2.5": {
name: "Kimi K2.5",
reasoning: true,
},
"kimi-k2.6": {
name: "Kimi K2.6",
reasoning: true,
},
"kimi-k2-thinking": {
name: "Kimi K2 Thinking",
reasoning: true,
},
"glm-5": {
name: "GLM 5",
reasoning: true,
},
"glm-5.1": {
name: "GLM 5.1",
reasoning: true,
},
"glm-5v-turbo": {
name: "GLM 5V Turbo",
reasoning: true,
},
"custom-model": {
name: "Custom Model",
},
"kimi-k2-turbo-preview": {
name: "Kimi K2 Turbo Preview",
},
},
options: {
apiKey: "custom-key",
Expand Down Expand Up @@ -358,7 +387,14 @@ test("custom DeepSeek openai-compatible model defaults interleaved reasoning fie
const provider = providers[ProviderID.make("custom-provider")]
expect(provider.models["deepseek-r1"].capabilities.interleaved).toEqual({ field: "reasoning_content" })
expect(provider.models["deepseek-details"].capabilities.interleaved).toEqual({ field: "reasoning_details" })
expect(provider.models["kimi-k2.5"].capabilities.interleaved).toEqual({ field: "reasoning_content" })
expect(provider.models["kimi-k2.6"].capabilities.interleaved).toEqual({ field: "reasoning_content" })
expect(provider.models["kimi-k2-thinking"].capabilities.interleaved).toEqual({ field: "reasoning_content" })
expect(provider.models["glm-5"].capabilities.interleaved).toEqual({ field: "reasoning_content" })
expect(provider.models["glm-5.1"].capabilities.interleaved).toEqual({ field: "reasoning_content" })
expect(provider.models["glm-5v-turbo"].capabilities.interleaved).toEqual({ field: "reasoning_content" })
expect(provider.models["custom-model"].capabilities.interleaved).toBe(false)
expect(provider.models["kimi-k2-turbo-preview"].capabilities.interleaved).toBe(false)
expect(
providers[ProviderID.make("custom-anthropic-provider")].models["deepseek-r1"].capabilities.interleaved,
).toBe(false)
Expand Down
Loading