feat(ai): support custom reasoning fields (#38227)
This commit is contained in:
parent
69c05ae3fc
commit
23483ea013
24 changed files with 306 additions and 67 deletions
|
|
@ -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>>) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
]),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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({})
|
||||
}),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue