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
16 changes: 11 additions & 5 deletions packages/opencode/src/provider/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1160,7 +1160,12 @@ function fromModelsDevModel(provider: ModelsDev.Provider, model: ModelsDev.Model
video: model.modalities?.output?.includes("video") ?? false,
pdf: model.modalities?.output?.includes("pdf") ?? false,
},
interleaved: model.interleaved ?? false,
interleaved:
model.interleaved ??
((model.provider?.npm ?? provider.npm ?? "@ai-sdk/openai-compatible") === "@ai-sdk/openai-compatible" &&

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: Kilo-specific logic re-inlined into a shared upstream file instead of the kilocode helper

The first commit on this PR extracted this check into defaultInterleaved() in src/kilocode/provider/provider.ts and called it from here behind a single kilocode_change import. The follow-up commit removed that helper and duplicated the same "openai-compatible npm + id contains step/deepseek" condition inline in two places in this shared upstream file (here and in the layer merge path below), with no kilocode_change marker.

Per the Fork Isolation Rule in packages/opencode/AGENTS.md, Kilo-specific logic touching a shared upstream file should live in src/kilocode/<same/path>.ts and be called from a single marked line, to minimize upstream merge-conflict surface. Restoring the extracted helper (as the first commit had it) would avoid duplicating this logic and keep the diff against upstream provider.ts minimal.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

model.id.includes("step")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: Broad substring match on "step" may false-positive on unrelated model IDs

model.id.includes("step") (and the same check on apiID in the layer merge path below) matches "step" anywhere in the model ID, not just as a StepFun-style prefix/word. The PR's own test asserts this matches ids like "step 3.5 flash", "step.3.5.flash", "step_3_5_flash", which is intentional for StepFun naming, but it would also match any unrelated openai-compatible model whose id happens to contain the substring "step" (e.g. a future model literally named with "step" as part of an unrelated word). Consider anchoring the match more precisely to the StepFun provider/model naming convention (e.g. a prefix check or provider id check) to avoid unintended defaults for unrelated models.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

? { field: "reasoning_content" as const }
: false),
},
release_date: model.release_date ?? "",
variants: {},
Expand Down Expand Up @@ -1397,10 +1402,11 @@ export const layer = Layer.effect(
},
interleaved:
model.interleaved ??
existingModel?.capabilities.interleaved ??
(!existingModel && apiNpm === "@ai-sdk/openai-compatible" && apiID.includes("deepseek")
? { field: "reasoning_content" }
: false),
(existingModel?.capabilities.interleaved

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: Fallback now overrides a previously-computed false even when an existing model is present

Before this change, the fallback here was gated by !existingModel, so a model that already had a computed capabilities.interleaved (even an explicit false) was never re-evaluated. The new ternary treats existingModel?.capabilities.interleaved === false the same as "absent" and falls through to the deepseek/step fallback regardless of whether existingModel exists. This looks intentional here (it's what lets previously-cached false values for step models get upgraded on this fix), but it also means any existingModel whose interleaved capability was legitimately computed as false for a model matching the deepseek/step substring check will now get silently overridden to { field: "reasoning_content" } on the next merge. Worth double-checking this is the desired behavior for all existingModel cases, not just the step upgrade path this PR targets.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

? existingModel.capabilities.interleaved
: apiNpm === "@ai-sdk/openai-compatible" && (apiID.includes("deepseek") || apiID.includes("step"))
? { field: "reasoning_content" as const }
: false),
},
cost: {
input: model?.cost?.input ?? existingModel?.cost?.input ?? 0,
Expand Down
149 changes: 149 additions & 0 deletions packages/opencode/test/kilocode/provider/stepfun-interleaved.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import { expect, test } from "bun:test"
import { Effect } from "effect"
import { ModelsDev } from "@opencode-ai/core/models-dev"
import { Provider } from "../../../src/provider/provider"
import { ProviderID } from "../../../src/provider/schema"
import { testEffect } from "../../lib/effect"

const it = testEffect(Provider.defaultLayer)

const base = (id: string): ModelsDev.Model => ({
id,
name: id,
release_date: "2026-01-01",
attachment: false,
reasoning: true,
temperature: true,
tool_call: true,
limit: {
context: 128000,
output: 8192,
},
})

test("StepFun catalog models default to interleaved reasoning_content", () => {
const provider = Provider.fromModelsDevProvider({
id: "stepfun",
name: "StepFun",
env: ["STEPFUN_API_KEY"],
npm: "@ai-sdk/openai-compatible",
api: "https://api.stepfun.com/v1",
models: {
"step-3.5-flash": base("step-3.5-flash"),
},
})

expect(provider.models["step-3.5-flash"].capabilities.interleaved).toEqual({
field: "reasoning_content",
})
})

test("StepFun provider does not default when model ID does not include step", () => {
const provider = Provider.fromModelsDevProvider({
id: "stepfun",
name: "StepFun",
env: ["STEPFUN_API_KEY"],
npm: "@ai-sdk/openai-compatible",
api: "https://api.stepfun.com/v1",
models: {
custom: {
...base("custom"),
name: "Custom Model",
},
},
})

expect(provider.models["custom"].capabilities.interleaved).toBe(false)
})

test("catalog DeepSeek models do not default to interleaved reasoning_content", () => {
const provider = Provider.fromModelsDevProvider({
id: "custom-provider",
name: "Custom Provider",
env: ["CUSTOM_API_KEY"],
npm: "@ai-sdk/openai-compatible",
api: "https://api.custom.com/v1",
models: {
"deepseek-r1": base("deepseek-r1"),
},
})

expect(provider.models["deepseek-r1"].capabilities.interleaved).toBe(false)
})

test("catalog model IDs containing step default to interleaved reasoning_content", () => {
const provider = Provider.fromModelsDevProvider({
id: "custom-provider",
name: "Custom Provider",
env: ["CUSTOM_API_KEY"],
npm: "@ai-sdk/openai-compatible",
api: "https://api.custom.com/v1",
models: {
space: base("step 3.5 flash"),
hyphen: base("step-3.5-flash"),
dot: base("step.3.5.flash"),
underscore: base("step_3_5_flash"),
},
})

for (const model of Object.values(provider.models)) {
expect(model.capabilities.interleaved).toEqual({
field: "reasoning_content",
})
}
})

test("catalog model IDs containing step do not default for non-openai-compatible providers", () => {
const provider = Provider.fromModelsDevProvider({
id: "custom-provider",
name: "Custom Provider",
env: ["CUSTOM_API_KEY"],
npm: "@kilocode/kilo-gateway",
api: "https://api.custom.com/v1",
models: {
custom: base("step-3.5-flash"),
},
})

expect(provider.models["custom"].capabilities.interleaved).toBe(false)
})

it.instance(
"configured StepFun and step model IDs default to interleaved reasoning_content",
Effect.gen(function* () {
const providers = yield* Provider.use.list()
const stepfun = providers[ProviderID.make("stepfun")].models["step-custom"]
const custom = providers[ProviderID.make("custom-provider")].models["custom-step"]

expect(stepfun.capabilities.interleaved).toEqual({
field: "reasoning_content",
})
expect(custom.capabilities.interleaved).toEqual({
field: "reasoning_content",
})
}),
{
config: {
provider: {
stepfun: {
name: "StepFun",
npm: "@ai-sdk/openai-compatible",
api: "https://api.stepfun.com/v1",
models: {
"step-custom": { name: "Step Custom" },
},
options: { apiKey: "test-key" },
},
"custom-provider": {
name: "Custom Provider",
npm: "@ai-sdk/openai-compatible",
api: "https://api.custom.com/v1",
models: {
"custom-step": { id: "step-3.5-flash", name: "Custom Alias" },
},
options: { apiKey: "test-key" },
},
},
},
},
)