Procházet zdrojové kódy

feat(core): widen interleaved reasoning fields

Aiden Cline před 4 týdny
rodič
revize
f28cc51f12

+ 33 - 19
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<string>; output: Array<string> }
+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<ModelVariant>
-  time: { released: number }
-  cost: Array<ModelCost>
-  status: "alpha" | "beta" | "deprecated" | "active"
-  enabled: boolean
-  limit: { context: number; input?: number; output: number }
+export type ModelCapabilities = {
+  tools: boolean
+  input: Array<string>
+  output: Array<string>
+  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<ModelVariant>
+  time: { released: number }
+  cost: Array<ModelCost>
+  status: "alpha" | "beta" | "deprecated" | "active"
+  enabled: boolean
+  limit: { context: number; input?: number; output: number }
+}
+
 export type IntegrationMethod =
   | IntegrationOAuthMethod
   | IntegrationCommandMethod

+ 3 - 0
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) {

+ 6 - 0
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
 

+ 2 - 1
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) },

+ 10 - 2
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,

+ 2 - 8
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,

+ 18 - 0
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(

+ 12 - 2
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([

+ 2 - 1
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: [

+ 10 - 4
packages/httpapi-codegen/src/index.ts

@@ -830,7 +830,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)),
@@ -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<Group>) {

+ 13 - 0
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(

+ 18 - 0
packages/schema/src/model.ts

@@ -41,11 +41,29 @@ 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 InterleavedField =
+  | "reasoning"
+  | "reasoning_content"
+  | "reasoning_text"
+  | "reasoning_details"
+  | (string & {})
+export const InterleavedField: Schema.Codec<InterleavedField> = 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<Interleaved> = Schema.Union([
+  Schema.Literal(true),
+  Schema.Struct({ field: InterleavedField }),
+]).annotate({ identifier: "Model.Interleaved" })
+
 export interface Capabilities extends Schema.Schema.Type<typeof Capabilities> {}
 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<typeof Cost> {}

+ 11 - 0
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)
+  })
+})