mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-24 21:53:37 +00:00
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
|
|
@ -25,6 +25,7 @@ import { ToolStream } from "./utils/tool-stream"
|
|||
|
||||
const ADAPTER = "openai-chat"
|
||||
const IMAGE_MIMES = new Set<string>(ProviderShared.IMAGE_MIMES)
|
||||
const RESERVED_REASONING_FIELDS = new Set(["role", "content", "tool_calls"])
|
||||
export const DEFAULT_BASE_URL = "https://api.openai.com/v1"
|
||||
export const PATH = "/chat/completions"
|
||||
|
||||
|
|
@ -70,15 +71,18 @@ const OpenAIChatMessage = Schema.Union([
|
|||
role: Schema.Literal("user"),
|
||||
content: Schema.Union([Schema.String, Schema.Array(OpenAIChatUserContent)]),
|
||||
}),
|
||||
Schema.Struct({
|
||||
role: Schema.Literal("assistant"),
|
||||
content: Schema.NullOr(Schema.String),
|
||||
tool_calls: optionalArray(OpenAIChatAssistantToolCall),
|
||||
reasoning_content: Schema.optional(Schema.String),
|
||||
reasoning: Schema.optional(Schema.String),
|
||||
reasoning_text: Schema.optional(Schema.String),
|
||||
reasoning_details: optionalArray(Schema.Unknown),
|
||||
}),
|
||||
Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
role: Schema.Literal("assistant"),
|
||||
content: Schema.NullOr(Schema.String),
|
||||
tool_calls: optionalArray(OpenAIChatAssistantToolCall),
|
||||
reasoning_content: Schema.optional(Schema.String),
|
||||
reasoning: Schema.optional(Schema.String),
|
||||
reasoning_text: Schema.optional(Schema.String),
|
||||
reasoning_details: Schema.optional(Schema.Unknown),
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
),
|
||||
Schema.Struct({ role: Schema.Literal("tool"), tool_call_id: Schema.String, content: Schema.String }),
|
||||
]).pipe(Schema.toTaggedUnion("role"))
|
||||
type OpenAIChatMessage = Schema.Schema.Type<typeof OpenAIChatMessage>
|
||||
|
|
@ -145,14 +149,17 @@ const OpenAIChatToolCallDelta = Schema.Struct({
|
|||
})
|
||||
type OpenAIChatToolCallDelta = Schema.Schema.Type<typeof OpenAIChatToolCallDelta>
|
||||
|
||||
const OpenAIChatDelta = Schema.Struct({
|
||||
content: optionalNull(Schema.String),
|
||||
reasoning_content: optionalNull(Schema.String),
|
||||
reasoning: optionalNull(Schema.String),
|
||||
reasoning_text: optionalNull(Schema.String),
|
||||
reasoning_details: optionalNull(Schema.Array(Schema.Unknown)),
|
||||
tool_calls: optionalNull(Schema.Array(OpenAIChatToolCallDelta)),
|
||||
})
|
||||
const OpenAIChatDelta = Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
content: optionalNull(Schema.String),
|
||||
reasoning_content: optionalNull(Schema.String),
|
||||
reasoning: optionalNull(Schema.String),
|
||||
reasoning_text: optionalNull(Schema.String),
|
||||
reasoning_details: optionalNull(Schema.Unknown),
|
||||
tool_calls: optionalNull(Schema.Array(OpenAIChatToolCallDelta)),
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
)
|
||||
|
||||
const OpenAIChatChoice = Schema.Struct({
|
||||
delta: optionalNull(OpenAIChatDelta),
|
||||
|
|
@ -179,7 +186,7 @@ export interface ParserState {
|
|||
readonly usage?: Usage
|
||||
readonly finishReason?: FinishReason
|
||||
readonly lifecycle: Lifecycle.State
|
||||
readonly reasoningField?: "reasoning" | "reasoning_content" | "reasoning_text"
|
||||
readonly reasoningField?: string
|
||||
readonly reasoningDetails: Array<unknown>
|
||||
readonly reasoningDetailsObserved: boolean
|
||||
readonly reasoningEmitted: boolean
|
||||
|
|
@ -227,7 +234,7 @@ const openAICompatibleReasoningContent = (native: unknown) =>
|
|||
|
||||
const reasoningField = (part: ReasoningPart) => {
|
||||
const field = part.providerMetadata?.openai?.reasoningField
|
||||
if (field === "reasoning" || field === "reasoning_content" || field === "reasoning_text") return field
|
||||
return typeof field === "string" ? field : undefined
|
||||
}
|
||||
|
||||
const reasoningDetails = (parts: ReadonlyArray<ReasoningPart>, native: unknown) => {
|
||||
|
|
@ -259,6 +266,7 @@ const lowerUserMessage = Effect.fn("OpenAIChat.lowerUserMessage")(function* (mes
|
|||
|
||||
const lowerAssistantMessage = Effect.fn("OpenAIChat.lowerAssistantMessage")(function* (
|
||||
message: OpenAIChatRequestMessage,
|
||||
configuredField?: string,
|
||||
) {
|
||||
const content: TextPart[] = []
|
||||
const reasoning: ReasoningPart[] = []
|
||||
|
|
@ -285,24 +293,25 @@ const lowerAssistantMessage = Effect.fn("OpenAIChat.lowerAssistantMessage")(func
|
|||
const nativeReasoning = openAICompatibleReasoningContent(message.native?.openaiCompatible)
|
||||
const fullyStructured = reasoning.every((part) => Array.isArray(part.providerMetadata?.openai?.reasoningDetails))
|
||||
const field = (() => {
|
||||
if (reasoning.length === 0) return
|
||||
if (configuredField !== undefined) return configuredField
|
||||
if (reasoning.length === 0) return undefined
|
||||
if (observedField !== undefined) return observedField
|
||||
if (nativeReasoning !== undefined) return "reasoning_content"
|
||||
if (!fullyStructured) return "reasoning_content"
|
||||
})()
|
||||
const reasoningContent = (() => {
|
||||
const reasoningText = (() => {
|
||||
if (configuredField !== undefined) return reasoning.length === 0 ? (nativeReasoning ?? "") : text
|
||||
if (reasoning.length === 0) return nativeReasoning
|
||||
if (field === "reasoning_content") return text
|
||||
return text
|
||||
})()
|
||||
return {
|
||||
const result = {
|
||||
role: "assistant" as const,
|
||||
content: content.length === 0 ? null : ProviderShared.joinText(content),
|
||||
tool_calls: toolCalls.length === 0 ? undefined : toolCalls,
|
||||
reasoning_content: reasoningContent,
|
||||
reasoning: reasoning.length > 0 && field === "reasoning" ? text : undefined,
|
||||
reasoning_text: reasoning.length > 0 && field === "reasoning_text" ? text : undefined,
|
||||
reasoning_details: details,
|
||||
}
|
||||
if (field === undefined || reasoningText === undefined) return result
|
||||
return { ...result, [field]: reasoningText }
|
||||
})
|
||||
|
||||
const lowerToolMessages = Effect.fn("OpenAIChat.lowerToolMessages")(function* (message: OpenAIChatRequestMessage) {
|
||||
|
|
@ -328,9 +337,12 @@ const lowerToolMessages = Effect.fn("OpenAIChat.lowerToolMessages")(function* (m
|
|||
return { messages, images }
|
||||
})
|
||||
|
||||
const lowerMessage = Effect.fn("OpenAIChat.lowerMessage")(function* (message: OpenAIChatRequestMessage) {
|
||||
const lowerMessage = Effect.fn("OpenAIChat.lowerMessage")(function* (
|
||||
message: OpenAIChatRequestMessage,
|
||||
reasoningField?: string,
|
||||
) {
|
||||
if (message.role === "user") return [yield* lowerUserMessage(message)]
|
||||
if (message.role === "assistant") return [yield* lowerAssistantMessage(message)]
|
||||
if (message.role === "assistant") return [yield* lowerAssistantMessage(message, reasoningField)]
|
||||
return (yield* lowerToolMessages(message)).messages
|
||||
})
|
||||
|
||||
|
|
@ -368,7 +380,7 @@ const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request:
|
|||
continue
|
||||
}
|
||||
flushImages()
|
||||
messages.push(...(yield* lowerMessage(message)))
|
||||
messages.push(...(yield* lowerMessage(message, request.model.compatibility?.reasoningField)))
|
||||
}
|
||||
flushImages()
|
||||
return messages
|
||||
|
|
@ -386,6 +398,11 @@ const lowerOptions = Effect.fn("OpenAIChat.lowerOptions")(function* (request: LL
|
|||
const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* (request: LLMRequest) {
|
||||
// `fromRequest` returns the provider body only. Endpoint, auth, framing,
|
||||
// validation, and HTTP execution are composed by `Route.make`.
|
||||
const reasoningField = request.model.compatibility?.reasoningField
|
||||
if (reasoningField && RESERVED_REASONING_FIELDS.has(reasoningField))
|
||||
return yield* ProviderShared.invalidRequest(
|
||||
`OpenAI Chat reasoning field conflicts with reserved field ${reasoningField}`,
|
||||
)
|
||||
const generation = request.generation
|
||||
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
|
||||
return {
|
||||
|
|
@ -446,10 +463,18 @@ const mapUsage = (usage: OpenAIChatEvent["usage"]): Usage | undefined => {
|
|||
})
|
||||
}
|
||||
|
||||
const reasoningDelta = (delta: Schema.Schema.Type<typeof OpenAIChatDelta> | null | undefined) => {
|
||||
if (delta?.reasoning_content) return { field: "reasoning_content", text: delta.reasoning_content } as const
|
||||
if (delta?.reasoning) return { field: "reasoning", text: delta.reasoning } as const
|
||||
if (delta?.reasoning_text) return { field: "reasoning_text", text: delta.reasoning_text } as const
|
||||
const reasoningDelta = (
|
||||
delta: Schema.Schema.Type<typeof OpenAIChatDelta> | null | undefined,
|
||||
configuredField?: string,
|
||||
) => {
|
||||
if (!delta) return undefined
|
||||
const fields = new Set([configuredField, "reasoning_content", "reasoning", "reasoning_text"])
|
||||
for (const field of fields) {
|
||||
if (field === undefined) continue
|
||||
const text = delta[field]
|
||||
if (typeof text === "string" && text.length > 0) return { field, text }
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
const detailText = (details: ReadonlyArray<unknown>) => {
|
||||
|
|
@ -518,7 +543,7 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
|
|||
|
||||
let lifecycle = state.lifecycle
|
||||
|
||||
const reasoning = reasoningDelta(delta)
|
||||
const reasoning = reasoningDelta(delta, state.reasoningField)
|
||||
const reasoningField = state.reasoningField ?? (!state.lifecycle.text.has("text-0") ? reasoning?.field : undefined)
|
||||
const detailDelta = Array.isArray(delta?.reasoning_details) ? delta.reasoning_details : undefined
|
||||
if (detailDelta !== undefined) appendReasoningDetails(state.reasoningDetails, detailDelta)
|
||||
|
|
@ -635,12 +660,12 @@ export const protocol = Protocol.make({
|
|||
},
|
||||
stream: {
|
||||
event: Protocol.jsonEvent(OpenAIChatEvent),
|
||||
initial: () => ({
|
||||
initial: (request) => ({
|
||||
tools: ToolStream.empty<number>(),
|
||||
pendingTools: {},
|
||||
toolCallEvents: [],
|
||||
lifecycle: Lifecycle.initial(),
|
||||
reasoningField: undefined,
|
||||
reasoningField: request.model.compatibility?.reasoningField,
|
||||
reasoningDetails: [],
|
||||
reasoningDetailsObserved: false,
|
||||
reasoningEmitted: false,
|
||||
|
|
|
|||
|
|
@ -168,6 +168,7 @@ export type ModelToolSchemaCompatibility = Schema.Schema.Type<typeof ModelToolSc
|
|||
|
||||
export class ModelCompatibility extends Schema.Class<ModelCompatibility>("LLM.ModelCompatibility")({
|
||||
toolSchema: Schema.optional(ModelToolSchemaCompatibility),
|
||||
reasoningField: Schema.optional(Schema.String),
|
||||
}) {}
|
||||
|
||||
export namespace ModelCompatibility {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { LLM, LLMEvent, LLMResponse } from "../../src"
|
||||
import { LLM, LLMEvent, LLMResponse, Model } from "../../src"
|
||||
import { OpenAIChat } from "../../src/protocols/openai-chat"
|
||||
import * as OpenAICompatible from "../../src/providers/openai-compatible"
|
||||
import * as OpenRouter from "../../src/providers/openrouter"
|
||||
|
|
@ -11,22 +11,28 @@ import { expectWeatherToolLoop, goldenWeatherToolLoopRequest, runWeatherToolLoop
|
|||
const cases = [
|
||||
{
|
||||
name: "OpenRouter",
|
||||
model: OpenRouter.configure({
|
||||
apiKey: process.env.OPENROUTER_API_KEY ?? "fixture",
|
||||
providerOptions: { openrouter: { reasoning: { max_tokens: 1024 } } },
|
||||
}).model("anthropic/claude-sonnet-4.6"),
|
||||
model: Model.update(
|
||||
OpenRouter.configure({
|
||||
apiKey: process.env.OPENROUTER_API_KEY ?? "fixture",
|
||||
providerOptions: { openrouter: { reasoning: { max_tokens: 1024 } } },
|
||||
}).model("anthropic/claude-sonnet-4.6"),
|
||||
{ compatibility: { reasoningField: "reasoning" } },
|
||||
),
|
||||
requires: ["OPENROUTER_API_KEY"],
|
||||
cassette: "openrouter-reasoning",
|
||||
structured: true,
|
||||
},
|
||||
{
|
||||
name: "Vercel AI Gateway",
|
||||
model: OpenAICompatible.configure({
|
||||
provider: "vercel-ai-gateway",
|
||||
baseURL: "https://ai-gateway.vercel.sh/v1",
|
||||
apiKey: process.env.AI_GATEWAY_API_KEY ?? "fixture",
|
||||
http: { body: { reasoning: { enabled: true, max_tokens: 1024 } } },
|
||||
}).model("anthropic/claude-sonnet-4.6"),
|
||||
model: Model.update(
|
||||
OpenAICompatible.configure({
|
||||
provider: "vercel-ai-gateway",
|
||||
baseURL: "https://ai-gateway.vercel.sh/v1",
|
||||
apiKey: process.env.AI_GATEWAY_API_KEY ?? "fixture",
|
||||
http: { body: { reasoning: { enabled: true, max_tokens: 1024 } } },
|
||||
}).model("anthropic/claude-sonnet-4.6"),
|
||||
{ compatibility: { reasoningField: "reasoning" } },
|
||||
),
|
||||
requires: ["AI_GATEWAY_API_KEY"],
|
||||
cassette: "vercel-ai-gateway-reasoning",
|
||||
structured: true,
|
||||
|
|
|
|||
|
|
@ -92,6 +92,45 @@ describe("OpenAI Chat route", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
it.effect("writes reasoning to a configured custom field on every assistant message", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
|
||||
LLM.request({
|
||||
model: Model.update(model, { compatibility: { reasoningField: "vendor_reasoning" } }),
|
||||
messages: [
|
||||
Message.assistant([
|
||||
{
|
||||
type: "reasoning",
|
||||
text: "thinking",
|
||||
providerMetadata: { openai: { reasoningField: "reasoning" } },
|
||||
},
|
||||
{ type: "text", text: "Hello" },
|
||||
]),
|
||||
Message.assistant("Done"),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.messages).toEqual([
|
||||
{ role: "assistant", content: "Hello", vendor_reasoning: "thinking" },
|
||||
{ role: "assistant", content: "Done", vendor_reasoning: "" },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects reasoning fields that conflict with assistant message fields", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.prepare(
|
||||
LLM.request({
|
||||
model: Model.update(model, { compatibility: { reasoningField: "content" } }),
|
||||
messages: [Message.assistant([{ type: "reasoning", text: "thinking" }])],
|
||||
}),
|
||||
).pipe(Effect.flip)
|
||||
|
||||
expect(error.message).toContain("reserved field content")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("maps OpenAI provider options to Chat options", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
|
||||
|
|
@ -570,6 +609,35 @@ describe("OpenAI Chat route", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
it.effect("parses and replays a configured custom reasoning field", () =>
|
||||
Effect.gen(function* () {
|
||||
const custom = Model.update(model, { compatibility: { reasoningField: "vendor_reasoning" } })
|
||||
const response = yield* LLMClient.generate(LLM.updateRequest(request, { model: custom })).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ choices: [{ delta: { vendor_reasoning: "thinking" } }] },
|
||||
{ choices: [{ delta: { content: "Hello" } }] },
|
||||
{ choices: [{ delta: {}, finish_reason: "stop" }] },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.reasoning).toBe("thinking")
|
||||
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
|
||||
openai: { reasoningField: "vendor_reasoning" },
|
||||
})
|
||||
|
||||
const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
|
||||
LLM.request({ model: custom, messages: [response.message] }),
|
||||
)
|
||||
expect(replay.body.messages).toEqual([
|
||||
{ role: "assistant", content: "Hello", vendor_reasoning: "thinking" },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves and replays reasoning details alongside scalar reasoning", () =>
|
||||
Effect.gen(function* () {
|
||||
const details = [
|
||||
|
|
|
|||
|
|
@ -163,6 +163,8 @@ export type SessionMessageProviderState7 = { [x: string]: any }
|
|||
|
||||
export type EventLogSynced = { type: "log.synced"; aggregateID: string; seq?: number }
|
||||
|
||||
export type ModelReasoningField = "reasoning" | "reasoning_content" | "reasoning_text" | (string & {})
|
||||
|
||||
export type ModelCapabilities = { tools: boolean; input: Array<string>; output: Array<string> }
|
||||
|
||||
export type ModelVariant = {
|
||||
|
|
@ -1094,7 +1096,7 @@ export type TuiCommandExecute = {
|
|||
| "prompt.clear"
|
||||
| "prompt.submit"
|
||||
| "agent.cycle"
|
||||
| string
|
||||
| (string & {})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1403,6 +1405,8 @@ export type SessionToolFailed = {
|
|||
}
|
||||
}
|
||||
|
||||
export type ModelCompatibility = { reasoningField?: ModelReasoningField }
|
||||
|
||||
export type ModelCost = {
|
||||
tier?: { type: "context"; size: number }
|
||||
input: MoneyUSDPerMillionTokens
|
||||
|
|
@ -1893,6 +1897,7 @@ export type ModelInfo = {
|
|||
providerID: string
|
||||
family?: string
|
||||
name: string
|
||||
compatibility?: ModelCompatibility
|
||||
package?: string
|
||||
settings?: { [x: string]: JsonValue }
|
||||
headers?: { [x: string]: string }
|
||||
|
|
|
|||
|
|
@ -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({})
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -98,6 +98,28 @@ Here `openai/coding-default` is the selectable catalog reference, while `gpt-5.2
|
|||
model that is not already in the catalog, set accurate `capabilities` and `limit` values so OpenCode can expose tools and
|
||||
enforce the correct context limits. Set `disabled: true` on a model entry to hide it from the available catalog.
|
||||
|
||||
OpenAI-compatible models that stream reasoning through a custom assistant-message field can set
|
||||
`compatibility.reasoningField`:
|
||||
|
||||
```jsonc title="opencode.jsonc"
|
||||
{
|
||||
"providers": {
|
||||
"local": {
|
||||
"models": {
|
||||
"reasoner": {
|
||||
"compatibility": {
|
||||
"reasoningField": "reasoning_content"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
OpenCode recognizes `reasoning`, `reasoning_content`, and `reasoning_text`, and accepts any provider-specific string. It
|
||||
reads streamed reasoning from this field and includes the field when replaying assistant messages to the model.
|
||||
|
||||
### Custom variants
|
||||
|
||||
Add a variant, or override a catalog variant with the same ID, under the model's `variants` array:
|
||||
|
|
|
|||
|
|
@ -853,7 +853,7 @@ function structuralTypes(schemas: ReadonlyArray<Schema.Top>, mutable: boolean, r
|
|||
.replaceAll(/ & Brand\.Brand<"[^"]+">/g, "")
|
||||
.replaceAll("Schema.Json", "JsonValue")
|
||||
.replaceAll(/(?<!["'])\bunknown\b(?!["'])/g, "any")
|
||||
return mutable ? mutableType(output) : output
|
||||
return mutable ? mutableType(preserveStringSuggestions(output)) : preserveStringSuggestions(output)
|
||||
}
|
||||
return {
|
||||
types: document.codes.map((code) => render(code.Type)),
|
||||
|
|
@ -893,9 +893,15 @@ function structuralType(schema: Schema.Top) {
|
|||
}
|
||||
return type
|
||||
}
|
||||
return expand(document.codes[0].Type)
|
||||
.replaceAll(/ & Brand\.Brand<"[^"]+">/g, "")
|
||||
.replaceAll("Schema.Json", "JsonValue")
|
||||
return preserveStringSuggestions(
|
||||
expand(document.codes[0].Type)
|
||||
.replaceAll(/ & Brand\.Brand<"[^"]+">/g, "")
|
||||
.replaceAll("Schema.Json", "JsonValue"),
|
||||
)
|
||||
}
|
||||
|
||||
function preserveStringSuggestions(type: string) {
|
||||
return type.replaceAll(/((?:"(?:\\.|[^"\\])*"\s*\|\s*)+)string\b/g, "$1(string & {})")
|
||||
}
|
||||
|
||||
function normalizePromiseClientContent(content: string, groups: ReadonlyArray<Group>) {
|
||||
|
|
|
|||
|
|
@ -503,6 +503,19 @@ describe("HttpApiCodegen.generate", () => {
|
|||
expect(types).not.toContain("Brand")
|
||||
})
|
||||
|
||||
test("preserves suggestions for open string unions in Promise wire types", () => {
|
||||
const Field = Schema.Union([Schema.Literals(["reasoning", "reasoning_content"]), Schema.String]).annotate({
|
||||
identifier: "Field",
|
||||
})
|
||||
const output = emitPromise(
|
||||
compileContract(api(HttpApiEndpoint.get("get", "/model", { success: Schema.Struct({ field: Field }) }))),
|
||||
)
|
||||
|
||||
expect(output.files.find((file) => file.path === "types.ts")?.content).toContain(
|
||||
'export type Field = "reasoning" | "reasoning_content" | (string & {})',
|
||||
)
|
||||
})
|
||||
|
||||
test("retains non-recursive references in Promise wire types", () => {
|
||||
const Referenced = Schema.Struct({ value: Schema.String }).annotate({ identifier: "Referenced" })
|
||||
const output = emitPromise(
|
||||
|
|
|
|||
|
|
@ -1,11 +1,14 @@
|
|||
import type { ModelInfo, ProviderV2Info } from "@opencode-ai/sdk/v2/types"
|
||||
import type { CatalogApi } from "@opencode-ai/client/effect/api"
|
||||
import type { Model } from "@opencode-ai/schema/model"
|
||||
import type { Effect } from "effect"
|
||||
import type { Transform } from "./registration.js"
|
||||
|
||||
type CatalogModel = ModelInfo & { compatibility?: Model.Compatibility }
|
||||
|
||||
export interface CatalogProviderRecord {
|
||||
readonly provider: ProviderV2Info
|
||||
readonly models: ReadonlyMap<string, ModelInfo>
|
||||
readonly models: ReadonlyMap<string, CatalogModel>
|
||||
}
|
||||
|
||||
export interface CatalogDraft {
|
||||
|
|
@ -16,8 +19,8 @@ export interface CatalogDraft {
|
|||
remove(providerID: string): void
|
||||
}
|
||||
readonly model: {
|
||||
get(providerID: string, modelID: string): ModelInfo | undefined
|
||||
update(providerID: string, modelID: string, update: (model: ModelInfo) => void): void
|
||||
get(providerID: string, modelID: string): CatalogModel | undefined
|
||||
update(providerID: string, modelID: string, update: (model: CatalogModel) => void): void
|
||||
remove(providerID: string, modelID: string): void
|
||||
readonly default: {
|
||||
get(): { providerID: string; modelID: string } | undefined
|
||||
|
|
|
|||
|
|
@ -41,6 +41,17 @@ export interface Ref extends Schema.Schema.Type<typeof Ref> {}
|
|||
export const Family = Schema.String.pipe(Schema.brand("Model.Family"))
|
||||
export type Family = typeof Family.Type
|
||||
|
||||
export type ReasoningField = "reasoning" | "reasoning_content" | "reasoning_text" | (string & {})
|
||||
export const ReasoningField: Schema.Codec<ReasoningField> = Schema.Union([
|
||||
Schema.Literals(["reasoning", "reasoning_content", "reasoning_text"]),
|
||||
Schema.String,
|
||||
]).annotate({ identifier: "Model.ReasoningField" })
|
||||
|
||||
export interface Compatibility extends Schema.Schema.Type<typeof Compatibility> {}
|
||||
export const Compatibility = Schema.Struct({
|
||||
reasoningField: ReasoningField.pipe(optional),
|
||||
}).annotate({ identifier: "Model.Compatibility" })
|
||||
|
||||
export interface Capabilities extends Schema.Schema.Type<typeof Capabilities> {}
|
||||
export const Capabilities = Schema.Struct({
|
||||
tools: Schema.Boolean,
|
||||
|
|
@ -75,6 +86,7 @@ export const Info = Schema.Struct({
|
|||
providerID: Provider.ID,
|
||||
family: Family.pipe(optional),
|
||||
name: Schema.String,
|
||||
compatibility: Compatibility.pipe(optional),
|
||||
package: Provider.Package.pipe(optional),
|
||||
...Provider.Overlays,
|
||||
capabilities: Capabilities,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import { Schema } from "effect"
|
||||
import { Model } from "../src/model.js"
|
||||
|
||||
describe("Model.Ref", () => {
|
||||
|
|
@ -20,3 +21,12 @@ describe("Model.Ref", () => {
|
|||
expect(() => Model.Ref.parse("openai/gpt-5#high#extra")).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe("Model.ReasoningField", () => {
|
||||
test("accepts suggested and custom fields", () => {
|
||||
const decode = Schema.decodeUnknownSync(Model.ReasoningField)
|
||||
|
||||
for (const field of ["reasoning", "reasoning_content", "reasoning_text", "vendor_reasoning"])
|
||||
expect(decode(field)).toBe(field)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue