feat(ai): support custom reasoning fields (#38227)

This commit is contained in:
Aiden Cline 2026-07-21 23:42:59 -05:00 committed by GitHub
commit 23483ea013
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 306 additions and 67 deletions

View file

@ -344,7 +344,12 @@ function modelFromLanguage(info: ModelV2.Info, language: LanguageModelV3) {
prepareTransport: (body) => Effect.succeed(body),
streamPrepared: (prepared) => streamLanguage(language, prepared as LanguageModelV3CallOptions),
}
return Model.make({ id: info.modelID ?? info.id, provider: info.providerID, route })
return Model.make({
id: info.modelID ?? info.id,
provider: info.providerID,
route,
compatibility: info.compatibility,
})
}
function gatewayProviderOptions(modelID: ModelV2.ID, settings: Readonly<Record<string, unknown>>) {

View file

@ -4,7 +4,6 @@ import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import { Money } from "@opencode-ai/schema/money"
import { Effect, Stream } from "effect"
import { Config } from "../../config"
import { ModelV2 } from "../../model"
import { ProviderV2 } from "../../provider"
export const Plugin = define({
@ -59,6 +58,8 @@ export const Plugin = define({
if (config.family !== undefined) model.family = config.family
if (config.name !== undefined) model.name = config.name
if (config.modelID !== undefined) model.modelID = config.modelID
if (config.compatibility !== undefined)
model.compatibility = { ...model.compatibility, ...config.compatibility }
if (config.package !== undefined) model.package = config.package
if (config.settings !== undefined)
model.settings = ProviderV2.mergeOverlay(model.settings, config.settings)

View file

@ -42,6 +42,7 @@ class Model extends Schema.Class<Model>("ConfigV2.Model")({
modelID: ModelV2.ID.pipe(Schema.optional),
family: ModelV2.Family.pipe(Schema.optional),
name: Schema.String.pipe(Schema.optional),
compatibility: ModelV2.Compatibility.pipe(Schema.optional),
package: Schema.String.pipe(Schema.optional),
...Overlays,
capabilities: ModelV2.Capabilities.pipe(Schema.optional),

View file

@ -12,6 +12,12 @@ export type VariantID = typeof VariantID.Type
export const Family = Model.Family
export type Family = Model.Family
export const ReasoningField = Model.ReasoningField
export type ReasoningField = Model.ReasoningField
export const Compatibility = Model.Compatibility
export type Compatibility = Model.Compatibility
export const Capabilities = Model.Capabilities
export type Capabilities = Model.Capabilities
@ -25,6 +31,12 @@ export type Info = Model.Info
export type MutableInfo = DeepMutable<Info>
export function compatibility(input: unknown): Compatibility | undefined {
if (typeof input === "string") return { reasoningField: input }
if (typeof input !== "object" || input === null || Array.isArray(input) || !("field" in input)) return undefined
return typeof input.field === "string" ? { reasoningField: input.field } : undefined
}
export function parse(input: string): { providerID: ProviderV2.ID; modelID: ID } {
const [providerID, ...modelID] = input.split("/")
return {

View file

@ -43,7 +43,7 @@ type SourceModel = {
readonly reasoning_options?: readonly ReasoningOption[]
readonly temperature?: boolean
readonly tool_call: boolean
readonly interleaved?: true | { readonly field: "reasoning" | "reasoning_content" | "reasoning_details" }
readonly interleaved?: boolean | string | { readonly field: string }
readonly cost?: Cost
readonly limit: { readonly context: number; readonly input?: number; readonly output: number }
readonly modalities?: { readonly input: readonly Modality[]; readonly output: readonly Modality[] }
@ -495,6 +495,7 @@ function modelInfo(
modelID: ModelV2.ID.make(model.id),
providerID,
name: input.name ?? model.name,
compatibility: ModelV2.compatibility(model.interleaved),
family: model.family ? ModelV2.Family.make(model.family) : undefined,
package: model.provider?.npm ? ProviderV2.aisdk(model.provider.npm) : undefined,
settings: model.provider?.api ? { baseURL: model.provider.api } : undefined,

View file

@ -134,6 +134,7 @@ export const OpencodePlugin = define<HttpClient.HttpClient | EventV2.Service | S
if (config.family !== undefined) model.family = config.family
if (config.name !== undefined) model.name = config.name
if (config.id !== undefined) model.modelID = config.id
model.compatibility = ModelV2.compatibility(config.interleaved) ?? model.compatibility
if (config.provider !== undefined) {
model.package = config.provider.npm ? ProviderV2.aisdk(config.provider.npm) : undefined
if (config.provider.api) model.settings = { ...model.settings, baseURL: config.provider.api }

View file

@ -209,14 +209,14 @@ export const fromCatalogModel = (
return Effect.succeed(
withDefaults(resolved, OpenAIResponses.route)
.with({ auth: key === undefined ? Auth.none : Auth.bearer(key) })
.model({ id: resolved.modelID ?? resolved.id }),
.model({ id: resolved.modelID ?? resolved.id, compatibility: resolved.compatibility }),
)
}
if (ProviderV2.isAISDK(resolved.package) && packageName === "@ai-sdk/anthropic") {
return Effect.succeed(
withDefaults(resolved, AnthropicMessages.route)
.with({ auth: key === undefined ? Auth.none : Auth.header("x-api-key", key) })
.model({ id: resolved.modelID ?? resolved.id }),
.model({ id: resolved.modelID ?? resolved.id, compatibility: resolved.compatibility }),
)
}
if (
@ -227,7 +227,7 @@ export const fromCatalogModel = (
return Effect.succeed(
withDefaults(resolved, OpenAICompatibleChat.route)
.with({ auth: key === undefined ? Auth.none : Auth.bearer(key) })
.model({ id: resolved.modelID ?? resolved.id }),
.model({ id: resolved.modelID ?? resolved.id, compatibility: resolved.compatibility }),
)
}
if (ProviderV2.isAISDK(resolved.package)) {
@ -257,8 +257,15 @@ export const fromCatalogModel = (
limits: { context: resolved.limit.context, output: resolved.limit.output },
}
return yield* Effect.try({
try: () =>
Model.update(module.model(resolved.modelID ?? resolved.id, settings), { provider: resolved.providerID }),
try: () => {
const runtime = module.model(resolved.modelID ?? resolved.id, settings)
return Model.update(runtime, {
provider: resolved.providerID,
compatibility: resolved.compatibility
? { ...runtime.compatibility, ...resolved.compatibility }
: runtime.compatibility,
})
},
catch: () => unsupported(resolved),
})
})
@ -302,7 +309,7 @@ const codexModel = (
account === undefined ? Auth.none : Auth.headers({ "chatgpt-account-id": account }),
),
})
.model({ id: model.modelID ?? model.id })
.model({ id: model.modelID ?? model.id, compatibility: model.compatibility })
}
const unsupported = (model: ModelV2.Info) =>

View file

@ -8,6 +8,7 @@ import { ConfigPermissionV1 } from "./permission"
import { ConfigProviderV1 } from "./provider"
import { ConfigProviderOptionsV1 } from "./provider-options"
import { ProviderV2 } from "../../provider"
import { ModelV2 } from "../../model"
const keys = new Set([
"logLevel",
@ -278,6 +279,7 @@ function migrateModel(info: typeof ConfigProviderV1.Model.Type) {
modelID: info.id,
family: info.family,
name: info.name,
compatibility: ModelV2.compatibility(info.interleaved),
package: info.provider?.npm ? ProviderV2.aisdk(info.provider.npm) : undefined,
settings: info.provider?.api ? { ...settings, baseURL: info.provider.api } : settings,
capabilities,

View file

@ -16,9 +16,10 @@ export const Model = Schema.Struct({
tool_call: Schema.optional(Schema.Boolean),
interleaved: Schema.optional(
Schema.Union([
Schema.Literal(true),
Schema.Boolean,
Schema.String,
Schema.Struct({
field: Schema.Literals(["reasoning", "reasoning_content", "reasoning_details"]),
field: Schema.String,
}),
]),
),

View file

@ -419,6 +419,28 @@ describe("Config", () => {
}),
)
it.effect("migrates v1 interleaved fields to compatibility", () =>
Effect.sync(() => {
const migrated = ConfigMigrateV1.migrate({
provider: {
custom: {
models: {
object: { interleaved: { field: "vendor_reasoning" } },
string: { interleaved: "reasoning_text" },
boolean: { interleaved: true },
},
},
},
})
expect(migrated.providers?.custom?.models?.object?.compatibility).toEqual({
reasoningField: "vendor_reasoning",
})
expect(migrated.providers?.custom?.models?.string?.compatibility).toEqual({ reasoningField: "reasoning_text" })
expect(migrated.providers?.custom?.models?.boolean?.compatibility).toBeUndefined()
}),
)
it.effect("migrates v1 command configuration", () =>
Effect.sync(() => {
expect(

View file

@ -170,6 +170,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
models: {
chat: {
name: "First",
compatibility: { reasoningField: "vendor_reasoning" },
capabilities: { tools: true, input: ["text"], output: ["text"] },
disabled: true,
limit: { context: 100, output: 50 },
@ -251,6 +252,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
expect(model.id).toBe(modelID)
expect(model.modelID).toBe(ModelV2.ID.make("api-chat"))
expect(model.name).toBe("Last")
expect(model.compatibility).toEqual({ reasoningField: "vendor_reasoning" })
expect(model.capabilities).toEqual({ tools: true, input: ["text"], output: ["text"] })
expect(model.enabled).toBe(false)
expect(model.limit).toEqual({ context: 100, output: 75 })

View file

@ -1,4 +1,4 @@
import { describe, expect, beforeEach, afterAll } from "bun:test"
import { describe, expect, beforeEach, afterAll, test } from "bun:test"
import { Money } from "@opencode-ai/schema/money"
import { Effect, Layer, Ref } from "effect"
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
@ -15,6 +15,13 @@ import path from "path"
const cacheFile = path.join(Global.Path.cache, "models.json")
test("normalizes permissive interleaved values to compatibility", () => {
expect(ModelV2.compatibility("reasoning_text")).toEqual({ reasoningField: "reasoning_text" })
expect(ModelV2.compatibility({ field: "vendor_reasoning" })).toEqual({ reasoningField: "vendor_reasoning" })
expect(ModelV2.compatibility(true)).toBeUndefined()
expect(ModelV2.compatibility(false)).toBeUndefined()
})
const fixture = {
acme: {
id: "acme",
@ -30,6 +37,7 @@ const fixture = {
reasoning: false,
temperature: true,
tool_call: true,
interleaved: { field: "vendor_reasoning" },
limit: { context: 128000, output: 8192 },
},
},
@ -49,6 +57,7 @@ const fixtureSnapshot = [
modelID: ModelV2.ID.make("acme-1"),
providerID: ProviderV2.ID.make("acme"),
name: "Acme One",
compatibility: { reasoningField: "vendor_reasoning" },
family: undefined,
package: undefined,
settings: undefined,

View file

@ -16,6 +16,7 @@ import { it } from "./lib/effect"
interface ModelOptions {
readonly modelID?: string
readonly compatibility?: ModelV2.Compatibility
readonly settings?: ModelV2.Info["settings"]
readonly headers?: ModelV2.Info["headers"]
readonly body?: ModelV2.Info["body"]
@ -28,6 +29,7 @@ const model = (packageName: string | undefined, options: ModelOptions = {}) =>
modelID: ModelV2.ID.make(options.modelID ?? "api-test-model"),
providerID: ProviderV2.ID.make("test-provider"),
name: "Test model",
compatibility: options.compatibility,
package: packageName,
settings: options.settings ?? {},
headers: options.headers ?? { "x-test": "header" },
@ -101,6 +103,7 @@ describe("SessionRunnerModel", () => {
Effect.gen(function* () {
const resolved = yield* SessionRunnerModel.fromCatalogModel(
model(ProviderV2.aisdk("@ai-sdk/openai-compatible"), {
compatibility: { reasoningField: "vendor_reasoning" },
settings: {
apiKey: "settings-secret",
baseURL: "https://compatible.example/v1",
@ -121,6 +124,7 @@ describe("SessionRunnerModel", () => {
expect(headers.authorization).toBe("Bearer settings-secret")
expect(resolved.route.id).toBe("openai-compatible-chat")
expect(resolved.compatibility?.reasoningField).toBe("vendor_reasoning")
expect(resolved.route.endpoint.baseURL).toBe("https://compatible.example/v1")
expect(resolved.route.defaults.http?.body).toEqual({})
}),