From f28cc51f1274c12d45bfa9d19bb0b7c5539d8206 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Sat, 18 Jul 2026 11:11:45 -0500 Subject: [PATCH] feat(core): widen interleaved reasoning fields --- .../client/src/promise/generated/types.ts | 52 ++++++++++++------- packages/core/src/config/plugin/provider.ts | 3 ++ packages/core/src/model.ts | 6 +++ packages/core/src/models-dev.ts | 3 +- packages/core/src/v1/config/migrate.ts | 12 ++++- packages/core/src/v1/config/provider.ts | 10 +--- packages/core/test/config/config.test.ts | 18 +++++++ packages/core/test/config/provider.test.ts | 14 ++++- packages/core/test/models.test.ts | 3 +- packages/httpapi-codegen/src/index.ts | 14 +++-- .../httpapi-codegen/test/generate.test.ts | 13 +++++ packages/schema/src/model.ts | 18 +++++++ packages/schema/test/model.test.ts | 11 ++++ 13 files changed, 140 insertions(+), 37 deletions(-) diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index a22956f74b..b9c16f2100 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -163,7 +163,12 @@ export type SessionMessageProviderState7 = { [x: string]: any } export type EventLogSynced = { type: "log.synced"; aggregateID: string; seq?: number } -export type ModelCapabilities = { tools: boolean; input: Array; output: Array } +export type ModelInterleavedField = + | "reasoning" + | "reasoning_content" + | "reasoning_text" + | "reasoning_details" + | (string & {}) export type ModelVariant = { id: string @@ -1094,7 +1099,7 @@ export type TuiCommandExecute = { | "prompt.clear" | "prompt.submit" | "agent.cycle" - | string + | (string & {}) } } @@ -1401,6 +1406,8 @@ export type SessionToolFailed = { } } +export type ModelInterleaved = true | { field: ModelInterleavedField } + export type ModelCost = { tier?: { type: "context"; size: number } input: MoneyUSDPerMillionTokens @@ -1885,23 +1892,11 @@ export type SessionMessageCompaction = | SessionMessageCompactionCompleted | SessionMessageCompactionFailed -export type ModelInfo = { - id: string - modelID: string - providerID: string - family?: string - name: string - package?: string - settings?: { [x: string]: JsonValue } - headers?: { [x: string]: string } - body?: { [x: string]: JsonValue } - capabilities: ModelCapabilities - variants: Array - time: { released: number } - cost: Array - status: "alpha" | "beta" | "deprecated" | "active" - enabled: boolean - limit: { context: number; input?: number; output: number } +export type ModelCapabilities = { + tools: boolean + input: Array + output: Array + interleaved?: ModelInterleaved } export type IntegrationOAuthMethod = { @@ -2031,6 +2026,25 @@ export type SessionMessageAssistantTool = { time: { created: number; ran?: number; completed?: number } } +export type ModelInfo = { + id: string + modelID: string + providerID: string + family?: string + name: string + package?: string + settings?: { [x: string]: JsonValue } + headers?: { [x: string]: string } + body?: { [x: string]: JsonValue } + capabilities: ModelCapabilities + variants: Array + time: { released: number } + cost: Array + status: "alpha" | "beta" | "deprecated" | "active" + enabled: boolean + limit: { context: number; input?: number; output: number } +} + export type IntegrationMethod = | IntegrationOAuthMethod | IntegrationCommandMethod diff --git a/packages/core/src/config/plugin/provider.ts b/packages/core/src/config/plugin/provider.ts index 1bb05e0fa1..8f249d9331 100644 --- a/packages/core/src/config/plugin/provider.ts +++ b/packages/core/src/config/plugin/provider.ts @@ -69,6 +69,9 @@ export const Plugin = define({ tools: config.capabilities.tools, input: [...config.capabilities.input], output: [...config.capabilities.output], + ...(config.capabilities.interleaved !== undefined + ? { interleaved: config.capabilities.interleaved } + : {}), } } if (config.variants !== undefined) { diff --git a/packages/core/src/model.ts b/packages/core/src/model.ts index 3f264ecd9f..5ee36a9aef 100644 --- a/packages/core/src/model.ts +++ b/packages/core/src/model.ts @@ -12,6 +12,12 @@ export type VariantID = typeof VariantID.Type export const Family = Model.Family export type Family = Model.Family +export const InterleavedField = Model.InterleavedField +export type InterleavedField = Model.InterleavedField + +export const Interleaved = Model.Interleaved +export type Interleaved = Model.Interleaved + export const Capabilities = Model.Capabilities export type Capabilities = Model.Capabilities diff --git a/packages/core/src/models-dev.ts b/packages/core/src/models-dev.ts index 0caf414cce..ede157fc9b 100644 --- a/packages/core/src/models-dev.ts +++ b/packages/core/src/models-dev.ts @@ -46,7 +46,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?: ModelV2.Interleaved readonly cost?: Cost readonly limit: { readonly context: number; readonly input?: number; readonly output: number } readonly modalities?: { readonly input: readonly Modality[]; readonly output: readonly Modality[] } @@ -505,6 +505,7 @@ function modelInfo( tools: model.tool_call, input: [...(model.modalities?.input ?? [])], output: [...(model.modalities?.output ?? [])], + ...(model.interleaved !== undefined ? { interleaved: model.interleaved } : {}), }, variants: [...(input.variants ?? [])], time: { released: released(model.release_date) }, diff --git a/packages/core/src/v1/config/migrate.ts b/packages/core/src/v1/config/migrate.ts index 63348e5480..fde45bea9f 100644 --- a/packages/core/src/v1/config/migrate.ts +++ b/packages/core/src/v1/config/migrate.ts @@ -271,8 +271,16 @@ function migrateModel(info: typeof ConfigProviderV1.Model.Type) { : []), ] const capabilities = - info.tool_call !== undefined || info.modalities?.input !== undefined || info.modalities?.output !== undefined - ? { tools: info.tool_call ?? false, input: info.modalities?.input ?? [], output: info.modalities?.output ?? [] } + info.tool_call !== undefined || + info.modalities?.input !== undefined || + info.modalities?.output !== undefined || + info.interleaved !== undefined + ? { + tools: info.tool_call ?? false, + input: info.modalities?.input ?? [], + output: info.modalities?.output ?? [], + ...(info.interleaved !== undefined ? { interleaved: info.interleaved } : {}), + } : undefined return { modelID: info.id, diff --git a/packages/core/src/v1/config/provider.ts b/packages/core/src/v1/config/provider.ts index d54a3f08f9..b5cc809822 100644 --- a/packages/core/src/v1/config/provider.ts +++ b/packages/core/src/v1/config/provider.ts @@ -1,6 +1,7 @@ export * as ConfigProviderV1 from "./provider" import { Schema } from "effect" +import { Interleaved } from "@opencode-ai/schema/model" import { PositiveInt } from "../../schema" export const ModelStatus = Schema.Literals(["alpha", "beta", "deprecated", "active"]) @@ -14,14 +15,7 @@ export const Model = Schema.Struct({ reasoning: Schema.optional(Schema.Boolean), temperature: Schema.optional(Schema.Boolean), tool_call: Schema.optional(Schema.Boolean), - interleaved: Schema.optional( - Schema.Union([ - Schema.Literal(true), - Schema.Struct({ - field: Schema.Literals(["reasoning", "reasoning_content", "reasoning_details"]), - }), - ]), - ), + interleaved: Schema.optional(Interleaved), cost: Schema.optional( Schema.Struct({ input: Schema.Finite, diff --git a/packages/core/test/config/config.test.ts b/packages/core/test/config/config.test.ts index c452b219ca..e888738e4c 100644 --- a/packages/core/test/config/config.test.ts +++ b/packages/core/test/config/config.test.ts @@ -289,6 +289,24 @@ describe("Config", () => { }), ) + it.effect("migrates provider-specific interleaved reasoning fields", () => + Effect.sync(() => { + const migrated = ConfigMigrateV1.migrate({ + provider: { + custom: { + models: { + chat: { interleaved: { field: "vendor_reasoning" } }, + }, + }, + }, + }) + + expect(migrated.providers?.custom?.models?.chat?.capabilities?.interleaved).toEqual({ + field: "vendor_reasoning", + }) + }), + ) + it.effect("migrates v1 provider lists to policies", () => Effect.sync(() => { expect( diff --git a/packages/core/test/config/provider.test.ts b/packages/core/test/config/provider.test.ts index 0c7c85a1ca..9d8341d998 100644 --- a/packages/core/test/config/provider.test.ts +++ b/packages/core/test/config/provider.test.ts @@ -170,7 +170,12 @@ describe("ConfigProviderPlugin.Plugin", () => { models: { chat: { name: "First", - capabilities: { tools: true, input: ["text"], output: ["text"] }, + capabilities: { + tools: true, + input: ["text"], + output: ["text"], + interleaved: { field: "vendor_reasoning" }, + }, disabled: true, limit: { context: 100, output: 50 }, cost: { input: 1, output: 2 }, @@ -251,7 +256,12 @@ describe("ConfigProviderPlugin.Plugin", () => { expect(model.id).toBe(modelID) expect(model.modelID).toBe(ModelV2.ID.make("api-chat")) expect(model.name).toBe("Last") - expect(model.capabilities).toEqual({ tools: true, input: ["text"], output: ["text"] }) + expect(model.capabilities).toEqual({ + tools: true, + input: ["text"], + output: ["text"], + interleaved: { field: "vendor_reasoning" }, + }) expect(model.enabled).toBe(false) expect(model.limit).toEqual({ context: 100, output: 75 }) expect(model.cost).toEqual([ diff --git a/packages/core/test/models.test.ts b/packages/core/test/models.test.ts index 638cfc463e..774fa52247 100644 --- a/packages/core/test/models.test.ts +++ b/packages/core/test/models.test.ts @@ -47,6 +47,7 @@ const fixture = { reasoning: false, temperature: true, tool_call: true, + interleaved: { field: "vendor_reasoning" }, limit: { context: 128000, output: 8192 }, }, }, @@ -69,7 +70,7 @@ const fixtureSnapshot = [ family: undefined, package: undefined, settings: undefined, - capabilities: { tools: true, input: [], output: [] }, + capabilities: { tools: true, input: [], output: [], interleaved: { field: "vendor_reasoning" } }, variants: [], time: { released: Date.parse("2026-01-01") }, cost: [ diff --git a/packages/httpapi-codegen/src/index.ts b/packages/httpapi-codegen/src/index.ts index 5cefabd59f..f8b9e11db5 100644 --- a/packages/httpapi-codegen/src/index.ts +++ b/packages/httpapi-codegen/src/index.ts @@ -830,7 +830,7 @@ function structuralTypes(schemas: ReadonlyArray, mutable: boolean, r .replaceAll(/ & Brand\.Brand<"[^"]+">/g, "") .replaceAll("Schema.Json", "JsonValue") .replaceAll(/(? render(code.Type)), @@ -870,9 +870,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) { diff --git a/packages/httpapi-codegen/test/generate.test.ts b/packages/httpapi-codegen/test/generate.test.ts index 7797b50953..9dda65b8f0 100644 --- a/packages/httpapi-codegen/test/generate.test.ts +++ b/packages/httpapi-codegen/test/generate.test.ts @@ -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( diff --git a/packages/schema/src/model.ts b/packages/schema/src/model.ts index 623b80a60a..e5a0ba01a8 100644 --- a/packages/schema/src/model.ts +++ b/packages/schema/src/model.ts @@ -41,11 +41,29 @@ export interface Ref extends Schema.Schema.Type {} export const Family = Schema.String.pipe(Schema.brand("Model.Family")) export type Family = typeof Family.Type +export type InterleavedField = + | "reasoning" + | "reasoning_content" + | "reasoning_text" + | "reasoning_details" + | (string & {}) +export const InterleavedField: Schema.Codec = Schema.Union([ + Schema.Literals(["reasoning", "reasoning_content", "reasoning_text", "reasoning_details"]), + Schema.String, +]).annotate({ identifier: "Model.InterleavedField" }) + +export type Interleaved = true | { readonly field: InterleavedField } +export const Interleaved: Schema.Codec = Schema.Union([ + Schema.Literal(true), + Schema.Struct({ field: InterleavedField }), +]).annotate({ identifier: "Model.Interleaved" }) + export interface Capabilities extends Schema.Schema.Type {} export const Capabilities = Schema.Struct({ tools: Schema.Boolean, input: Schema.Array(Schema.String), output: Schema.Array(Schema.String), + interleaved: Interleaved.pipe(optional), }).annotate({ identifier: "Model.Capabilities" }) export interface Cost extends Schema.Schema.Type {} diff --git a/packages/schema/test/model.test.ts b/packages/schema/test/model.test.ts index 1dedf08698..1ac8db993d 100644 --- a/packages/schema/test/model.test.ts +++ b/packages/schema/test/model.test.ts @@ -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,13 @@ describe("Model.Ref", () => { expect(() => Model.Ref.parse("openai/gpt-5#high#extra")).toThrow() }) }) + +describe("Model.Interleaved", () => { + test("accepts known and provider-specific fields", () => { + const decode = Schema.decodeUnknownSync(Model.Interleaved) + const fields = ["reasoning", "reasoning_content", "reasoning_text", "reasoning_details", "vendor_reasoning"] + + for (const field of fields) expect(decode({ field })).toEqual({ field }) + expect(decode(true)).toBe(true) + }) +})