Skip to content

Commit 65a5510

Browse files
wqymiMI
andauthored
fix(transform): sync interleaved handling — exclude openrouter + always echo reasoning field (#1819)
* fix(transform): sync interleaved handling — exclude openrouter + always echo reasoning field Sync two upstream behavioral refinements to the interleaved-thinking transform (the capability system already existed): 1. Exclude openrouter from the interleaved field injection block — openrouter has its own reasoning handling via @openrouter/ai-sdk-provider. 2. Always set the providerOptions.openaiCompatible[field] even when reasoningText is empty — some providers (e.g. DeepSeek) return empty reasoning_content which still needs to be echoed back in subsequent requests; gating on non-empty dropped it. * fix(schema): add 'reasoning' to ProviderInterleaved Literals Sync with upstream anomalyco/opencode origin/dev which has THREE values in the ProviderInterleaved field Literals: ['reasoning', 'reasoning_content', 'reasoning_details']. This repo was missing 'reasoning', which would cause schema validation failures for providers declaring interleaved.field = 'reasoning'. Also cast plugin models call to any to bridge SDK ModelV2 type mismatch. * fix(sdk): regenerate SDK types to include 'reasoning' interleaved field The interleaved `field` literal was extended to include 'reasoning' in the provider/config Model schemas, but the checked-in SDK types were not regenerated. This left `SDK.Model` out of sync, breaking share-next.ts's discriminated union typecheck (TS2739 at share-next.ts:216). Regenerating the SDK restores type parity and makes typecheck pass. --------- Co-authored-by: MI <mi@MIdeMacBook-Pro.local>
1 parent 0d7d115 commit 65a5510

7 files changed

Lines changed: 124 additions & 22 deletions

File tree

packages/opencode/src/config/provider.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ export const Model = Schema.Struct({
1717
Schema.Union([
1818
Schema.Literal(true),
1919
Schema.Struct({
20-
field: Schema.Literals(["reasoning_content", "reasoning_details"]),
20+
field: Schema.Literals(["reasoning", "reasoning_content", "reasoning_details"]),
2121
}),
2222
]),
2323
),

packages/opencode/src/provider/models.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ export const Model = z.object({
5656
z.literal(true),
5757
z
5858
.object({
59-
field: z.enum(["reasoning_content", "reasoning_details"]),
59+
field: z.enum(["reasoning", "reasoning_content", "reasoning_details"]),
6060
})
6161
.strict(),
6262
])

packages/opencode/src/provider/provider.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -849,7 +849,7 @@ const ProviderModalities = Schema.Struct({
849849
const ProviderInterleaved = Schema.Union([
850850
Schema.Boolean,
851851
Schema.Struct({
852-
field: Schema.Literals(["reasoning_content", "reasoning_details"]),
852+
field: Schema.Literals(["reasoning", "reasoning_content", "reasoning_details"]),
853853
}),
854854
])
855855

@@ -1371,7 +1371,7 @@ const layer: Layer.Layer<
13711371
const pluginAuth = yield* auth.get(providerID).pipe(Effect.orDie)
13721372

13731373
provider.models = yield* Effect.promise(async () => {
1374-
const next = await models(provider, { auth: pluginAuth })
1374+
const next = await models(provider as any, { auth: pluginAuth })
13751375
return Object.fromEntries(
13761376
Object.entries(next).map(([id, model]) => [
13771377
id,

packages/opencode/src/provider/transform.ts

Lines changed: 14 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,11 @@ function normalizeMessages(
177177
return result
178178
}
179179

180-
if (typeof model.capabilities.interleaved === "object" && model.capabilities.interleaved.field) {
180+
if (
181+
typeof model.capabilities.interleaved === "object" &&
182+
model.capabilities.interleaved.field &&
183+
model.api.npm !== "@openrouter/ai-sdk-provider"
184+
) {
181185
const field = model.capabilities.interleaved.field
182186
return msgs.map((msg) => {
183187
if (msg.role === "assistant" && Array.isArray(msg.content)) {
@@ -187,24 +191,18 @@ function normalizeMessages(
187191
// Filter out reasoning parts from content
188192
const filteredContent = msg.content.filter((part: any) => part.type !== "reasoning")
189193

190-
// Include reasoning_content | reasoning_details directly on the message for all assistant messages
191-
if (reasoningText) {
192-
return {
193-
...msg,
194-
content: filteredContent,
195-
providerOptions: {
196-
...msg.providerOptions,
197-
openaiCompatible: {
198-
...msg.providerOptions?.openaiCompatible,
199-
[field]: reasoningText,
200-
},
201-
},
202-
}
203-
}
204-
194+
// Always set the field even when empty — some providers (e.g. DeepSeek) may return empty
195+
// reasoning_content which still needs to be sent back in subsequent requests.
205196
return {
206197
...msg,
207198
content: filteredContent,
199+
providerOptions: {
200+
...msg.providerOptions,
201+
openaiCompatible: {
202+
...msg.providerOptions?.openaiCompatible,
203+
[field]: reasoningText,
204+
},
205+
},
208206
}
209207
}
210208

packages/opencode/test/provider/provider.test.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -424,6 +424,10 @@ test("custom DeepSeek openai-compatible model defaults interleaved reasoning fie
424424
name: "DeepSeek Details",
425425
interleaved: { field: "reasoning_details" },
426426
},
427+
"deepseek-reasoning": {
428+
name: "DeepSeek Reasoning",
429+
interleaved: { field: "reasoning" },
430+
},
427431
"custom-model": {
428432
name: "Custom Model",
429433
},
@@ -457,6 +461,7 @@ test("custom DeepSeek openai-compatible model defaults interleaved reasoning fie
457461
const provider = providers[ProviderID.make("custom-provider")]
458462
expect(provider.models["deepseek-r1"].capabilities.interleaved).toEqual({ field: "reasoning_content" })
459463
expect(provider.models["deepseek-details"].capabilities.interleaved).toEqual({ field: "reasoning_details" })
464+
expect(provider.models["deepseek-reasoning"].capabilities.interleaved).toEqual({ field: "reasoning" })
460465
expect(provider.models["custom-model"].capabilities.interleaved).toBe(false)
461466
expect(
462467
providers[ProviderID.make("custom-anthropic-provider")].models["deepseek-r1"].capabilities.interleaved,

packages/opencode/test/provider/transform.test.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4410,3 +4410,101 @@ describe("ProviderTransform.message - non-array content guard (j.map is not a fu
44104410
expect(() => ProviderTransform.message(msgs, genericModel, {})).not.toThrow()
44114411
})
44124412
})
4413+
4414+
describe("ProviderTransform.message - interleaved field: openrouter exclusion", () => {
4415+
const openrouterModel = {
4416+
id: "openrouter/anthropic/claude-sonnet-4",
4417+
providerID: "openrouter",
4418+
api: {
4419+
id: "anthropic/claude-sonnet-4",
4420+
url: "https://openrouter.ai/api",
4421+
npm: "@openrouter/ai-sdk-provider",
4422+
},
4423+
name: "Claude Sonnet 4",
4424+
capabilities: {
4425+
temperature: true,
4426+
reasoning: true,
4427+
attachment: true,
4428+
toolcall: true,
4429+
input: { text: true, audio: false, image: true, video: false, pdf: false },
4430+
output: { text: true, audio: false, image: false, video: false, pdf: false },
4431+
interleaved: { field: "reasoning_content" },
4432+
},
4433+
cost: { input: 0.001, output: 0.002, cache: { read: 0.0001, write: 0.0002 } },
4434+
limit: { context: 200000, output: 8192 },
4435+
status: "active",
4436+
options: {},
4437+
headers: {},
4438+
} as any
4439+
4440+
test("openrouter is excluded from interleaved field injection", () => {
4441+
const msgs = [
4442+
{
4443+
role: "assistant",
4444+
content: [
4445+
{ type: "reasoning", text: "Thinking..." },
4446+
{ type: "text", text: "Answer" },
4447+
],
4448+
},
4449+
{ role: "user", content: [{ type: "text", text: "next" }] },
4450+
] as any[]
4451+
4452+
const result = ProviderTransform.message(msgs, openrouterModel, {})
4453+
4454+
// Reasoning parts should be LEFT IN content (not extracted to providerOptions.openaiCompatible)
4455+
const assistantContent = result[0].content as any[]
4456+
expect(assistantContent).toHaveLength(2)
4457+
expect(assistantContent[0].type).toBe("reasoning")
4458+
expect(assistantContent[0].text).toBe("Thinking...")
4459+
expect(assistantContent[1].type).toBe("text")
4460+
expect(assistantContent[1].text).toBe("Answer")
4461+
// The interleaved field must NOT be set on the message (openrouter excluded)
4462+
expect(result[0].providerOptions?.openaiCompatible?.reasoning_content).toBeUndefined()
4463+
})
4464+
})
4465+
4466+
describe("ProviderTransform.message - interleaved field: empty reasoning still sets the field", () => {
4467+
test("empty reasoning_content is echoed back (DeepSeek-style)", () => {
4468+
const deepseekModel = {
4469+
id: "deepseek/deepseek-chat",
4470+
providerID: "deepseek",
4471+
api: {
4472+
id: "deepseek-chat",
4473+
url: "https://api.deepseek.com",
4474+
npm: "@ai-sdk/openai-compatible",
4475+
},
4476+
name: "DeepSeek Chat",
4477+
capabilities: {
4478+
temperature: true,
4479+
reasoning: true,
4480+
attachment: false,
4481+
toolcall: true,
4482+
input: { text: true, audio: false, image: false, video: false, pdf: false },
4483+
output: { text: true, audio: false, image: false, video: false, pdf: false },
4484+
interleaved: { field: "reasoning_content" },
4485+
},
4486+
cost: { input: 0.001, output: 0.002, cache: { read: 0.0001, write: 0.0002 } },
4487+
limit: { context: 128000, output: 8192 },
4488+
status: "active",
4489+
options: {},
4490+
headers: {},
4491+
} as any
4492+
4493+
const msgs = [
4494+
{
4495+
role: "assistant",
4496+
content: [
4497+
{ type: "reasoning", text: "" },
4498+
{ type: "text", text: "Hello" },
4499+
],
4500+
},
4501+
{ role: "user", content: [{ type: "text", text: "next" }] },
4502+
] as any[]
4503+
4504+
const result = ProviderTransform.message(msgs, deepseekModel, {})
4505+
4506+
expect(result[0].content).toEqual([{ type: "text", text: "Hello" }])
4507+
// The field MUST be set even when reasoningText is empty
4508+
expect(result[0].providerOptions?.openaiCompatible?.reasoning_content).toBe("")
4509+
})
4510+
})

packages/sdk/js/src/v2/gen/types.gen.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1820,7 +1820,7 @@ export type ProviderConfig = {
18201820
interleaved?:
18211821
| true
18221822
| {
1823-
field: "reasoning_content" | "reasoning_details"
1823+
field: "reasoning" | "reasoning_content" | "reasoning_details"
18241824
}
18251825
cost?: {
18261826
input: number
@@ -2487,7 +2487,7 @@ export type Model = {
24872487
interleaved:
24882488
| boolean
24892489
| {
2490-
field: "reasoning_content" | "reasoning_details"
2490+
field: "reasoning" | "reasoning_content" | "reasoning_details"
24912491
}
24922492
}
24932493
cost: {
@@ -6778,6 +6778,7 @@ export type AppSkillsResponses = {
67786778
200: Array<{
67796779
name: string
67806780
description: string
6781+
aliases?: Array<string>
67816782
location: string
67826783
content: string
67836784
hidden?: boolean

0 commit comments

Comments
 (0)