From 48cf07d32aac4b237c684b8c8765dc7d7ce651e1 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Tue, 2 Dec 2025 00:18:26 -0500 Subject: [PATCH 01/13] core: refactor model ID system to use target field for provider calls Changed model identification from using model.id to model.target when calling providers, allowing users to specify alternate model IDs while maintaining internal references. This enables more flexible provider configurations and better model mapping. --- packages/opencode/src/config/config.ts | 10 +- packages/opencode/src/provider/models.ts | 1 + packages/opencode/src/provider/provider.ts | 115 ++++------ packages/opencode/src/provider/transform.ts | 47 ++-- packages/opencode/src/server/server.ts | 4 +- packages/opencode/src/session/compaction.ts | 126 +++++------ packages/opencode/src/session/processor.ts | 15 +- packages/opencode/src/session/prompt.ts | 239 +++++++++----------- packages/opencode/src/session/summary.ts | 4 +- packages/opencode/src/session/system.ts | 14 +- packages/opencode/src/tool/batch.ts | 2 +- packages/opencode/src/tool/registry.ts | 8 +- packages/sdk/js/src/gen/types.gen.ts | 2 + 13 files changed, 286 insertions(+), 301 deletions(-) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 2bdbbca5b0..6ca8fe55be 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -542,7 +542,15 @@ export namespace Config { .extend({ whitelist: z.array(z.string()).optional(), blacklist: z.array(z.string()).optional(), - models: z.record(z.string(), ModelsDev.Model.partial()).optional(), + models: z + .record( + z.string(), + ModelsDev.Model.partial().refine( + (input) => input.id === undefined, + "The model.id field can no longer be specified. Use model.target to specify an alternate model id to use when calling the provider.", + ), + ) + .optional(), options: z .object({ apiKey: z.string().optional(), diff --git a/packages/opencode/src/provider/models.ts b/packages/opencode/src/provider/models.ts index 676837e152..f8ff2e86a4 100644 --- a/packages/opencode/src/provider/models.ts +++ b/packages/opencode/src/provider/models.ts @@ -13,6 +13,7 @@ export namespace ModelsDev { .object({ id: z.string(), name: z.string(), + target: z.string(), release_date: z.string(), attachment: z.boolean(), reasoning: z.boolean(), diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 1123e6bbed..32750221eb 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -280,7 +280,7 @@ export namespace Provider { project, location, }, - async getModel(sdk: any, modelID: string) { + async getModel(sdk, modelID) { const id = String(modelID).trim() return sdk.languageModel(id) }, @@ -299,6 +299,14 @@ export namespace Provider { }, } + export type Model = { + providerID: string + modelID: string + language: LanguageModel + info: ModelsDev.Model + npm: string + } + const state = Instance.state(async () => { using _ = log.time("state") const config = await Config.get() @@ -321,19 +329,8 @@ export namespace Provider { options: Record } } = {} - const models = new Map< - string, - { - providerID: string - modelID: string - info: ModelsDev.Model - language: LanguageModel - npm?: string - } - >() + const models = new Map() const sdk = new Map() - // Maps `${provider}/${key}` to the provider’s actual model ID for custom aliases. - const realIdByKey = new Map() log.info("init") @@ -395,6 +392,7 @@ export namespace Provider { }) const parsedModel: ModelsDev.Model = { id: modelID, + target: model.target ?? existing?.target ?? modelID, name, release_date: model.release_date ?? existing?.release_date, attachment: model.attachment ?? existing?.attachment ?? false, @@ -432,9 +430,6 @@ export namespace Provider { headers: model.headers, provider: model.provider ?? existing?.provider, } - if (model.id && model.id !== modelID) { - realIdByKey.set(`${providerID}/${modelID}`, model.id) - } parsed.models[modelID] = parsedModel } @@ -528,31 +523,22 @@ export namespace Provider { } const configProvider = config.provider?.[providerID] - const filteredModels = Object.fromEntries( - Object.entries(provider.info.models) - // Filter out blacklisted models - .filter( - ([modelID]) => - modelID !== "gpt-5-chat-latest" && !(providerID === "openrouter" && modelID === "openai/gpt-5-chat"), - ) - // Filter out experimental models - .filter( - ([, model]) => - ((!model.experimental && model.status !== "alpha") || Flag.OPENCODE_ENABLE_EXPERIMENTAL_MODELS) && - model.status !== "deprecated", - ) - // Filter by provider's whitelist/blacklist from config - .filter(([modelID]) => { - if (!configProvider) return true - return ( - (!configProvider.blacklist || !configProvider.blacklist.includes(modelID)) && - (!configProvider.whitelist || configProvider.whitelist.includes(modelID)) - ) - }), - ) - - provider.info.models = filteredModels + for (const [modelID, model] of Object.entries(provider.info.models)) { + model.target = model.target ?? model.id ?? modelID + if (modelID === "gpt-5-chat-latest" || (providerID === "openrouter" && modelID === "openai/gpt-5-chat")) + delete provider.info.models[modelID] + if ( + ((model.status === "alpha" || model.experimental) && !Flag.OPENCODE_ENABLE_EXPERIMENTAL_MODELS) || + model.status === "deprecated" + ) + delete provider.info.models[modelID] + if ( + (configProvider?.blacklist && configProvider.blacklist.includes(modelID)) || + (configProvider?.whitelist && !configProvider.whitelist.includes(modelID)) + ) + delete provider.info.models[modelID] + } if (Object.keys(provider.info.models).length === 0) { delete providers[providerID] @@ -566,7 +552,6 @@ export namespace Provider { models, providers, sdk, - realIdByKey, } }) @@ -574,19 +559,18 @@ export namespace Provider { return state().then((state) => state.providers) } - async function getSDK(provider: ModelsDev.Provider, model: ModelsDev.Model) { - return (async () => { + async function getSDK(npm: string, providerID: string) { + try { using _ = log.time("getSDK", { - providerID: provider.id, + providerID, }) const s = await state() - const pkg = model.provider?.npm ?? provider.npm ?? provider.id - const options = { ...s.providers[provider.id]?.options } - if (pkg.includes("@ai-sdk/openai-compatible") && options["includeUsage"] === undefined) { + const options = { ...s.providers[providerID]?.options } + if (npm.includes("@ai-sdk/openai-compatible") && options["includeUsage"] !== false) { options["includeUsage"] = true } - const key = Bun.hash.xxHash32(JSON.stringify({ pkg, options })) + const key = Bun.hash.xxHash32(JSON.stringify({ pkg: npm, options })) const existing = s.sdk.get(key) if (existing) return existing @@ -615,12 +599,12 @@ export namespace Provider { } // Special case: google-vertex-anthropic uses a subpath import - const bundledKey = provider.id === "google-vertex-anthropic" ? "@ai-sdk/google-vertex/anthropic" : pkg + const bundledKey = providerID === "google-vertex-anthropic" ? "@ai-sdk/google-vertex/anthropic" : npm const bundledFn = BUNDLED_PROVIDERS[bundledKey] if (bundledFn) { - log.info("using bundled provider", { providerID: provider.id, pkg: bundledKey }) + log.info("using bundled provider", { providerID, pkg: bundledKey }) const loaded = bundledFn({ - name: provider.id, + name: providerID, ...options, }) s.sdk.set(key, loaded) @@ -628,25 +612,25 @@ export namespace Provider { } let installedPath: string - if (!pkg.startsWith("file://")) { - installedPath = await BunProc.install(pkg, "latest") + if (!npm.startsWith("file://")) { + installedPath = await BunProc.install(npm, "latest") } else { - log.info("loading local provider", { pkg }) - installedPath = pkg + log.info("loading local provider", { pkg: npm }) + installedPath = npm } const mod = await import(installedPath) const fn = mod[Object.keys(mod).find((key) => key.startsWith("create"))!] const loaded = fn({ - name: provider.id, + name: providerID, ...options, }) s.sdk.set(key, loaded) return loaded as SDK - })().catch((e) => { - throw new InitError({ providerID: provider.id }, { cause: e }) - }) + } catch (e) { + throw new InitError({ providerID }, { cause: e }) + } } export async function getProvider(providerID: string) { @@ -679,28 +663,27 @@ export namespace Provider { throw new ModelNotFoundError({ providerID, modelID, suggestions }) } - const sdk = await getSDK(provider.info, info) + const npm = info.provider?.npm ?? provider.info.npm ?? info.id + const sdk = await getSDK(npm, providerID) try { - const keyReal = `${providerID}/${modelID}` - const realID = s.realIdByKey.get(keyReal) ?? info.id const language = provider.getModel - ? await provider.getModel(sdk, realID, provider.options) - : sdk.languageModel(realID) + ? await provider.getModel(sdk, info.target, provider.options) + : sdk.languageModel(info.target) log.info("found", { providerID, modelID }) s.models.set(key, { providerID, modelID, info, language, - npm: info.provider?.npm ?? provider.info.npm, + npm, }) return { modelID, providerID, info, language, - npm: info.provider?.npm ?? provider.info.npm, + npm, } } catch (e) { if (e instanceof NoSuchModelError) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index abe269d5d0..d3b30575ad 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -1,10 +1,11 @@ import type { APICallError, ModelMessage } from "ai" import { unique } from "remeda" import type { JSONSchema } from "zod/v4/core" +import type { ModelsDev } from "./models" export namespace ProviderTransform { - function normalizeMessages(msgs: ModelMessage[], providerID: string, modelID: string): ModelMessage[] { - if (modelID.includes("claude")) { + function normalizeMessages(msgs: ModelMessage[], providerID: string, model: ModelsDev.Model): ModelMessage[] { + if (model.target.includes("claude")) { return msgs.map((msg) => { if ((msg.role === "assistant" || msg.role === "tool") && Array.isArray(msg.content)) { msg.content = msg.content.map((part) => { @@ -20,7 +21,7 @@ export namespace ProviderTransform { return msg }) } - if (providerID === "mistral" || modelID.toLowerCase().includes("mistral")) { + if (providerID === "mistral" || model.target.toLowerCase().includes("mistral")) { const result: ModelMessage[] = [] for (let i = 0; i < msgs.length; i++) { const msg = msgs[i] @@ -107,30 +108,30 @@ export namespace ProviderTransform { return msgs } - export function message(msgs: ModelMessage[], providerID: string, modelID: string) { - msgs = normalizeMessages(msgs, providerID, modelID) - if (providerID === "anthropic" || modelID.includes("anthropic") || modelID.includes("claude")) { + export function message(msgs: ModelMessage[], providerID: string, model: ModelsDev.Model) { + msgs = normalizeMessages(msgs, providerID, model) + if (providerID === "anthropic" || model.target.includes("anthropic") || model.target.includes("claude")) { msgs = applyCaching(msgs, providerID) } return msgs } - export function temperature(_providerID: string, modelID: string) { - if (modelID.toLowerCase().includes("qwen")) return 0.55 - if (modelID.toLowerCase().includes("claude")) return undefined - if (modelID.toLowerCase().includes("gemini-3-pro")) return 1.0 + export function temperature(model: ModelsDev.Model) { + if (model.target.toLowerCase().includes("qwen")) return 0.55 + if (model.target.toLowerCase().includes("claude")) return undefined + if (model.target.toLowerCase().includes("gemini-3-pro")) return 1.0 return 0 } - export function topP(_providerID: string, modelID: string) { - if (modelID.toLowerCase().includes("qwen")) return 1 + export function topP(model: ModelsDev.Model) { + if (model.target.toLowerCase().includes("qwen")) return 1 return undefined } export function options( providerID: string, - modelID: string, + model: ModelsDev.Model, npm: string, sessionID: string, providerOptions?: Record, @@ -148,22 +149,22 @@ export namespace ProviderTransform { result["promptCacheKey"] = sessionID } - if (providerID === "google" || (providerID.startsWith("opencode") && modelID.includes("gemini-3"))) { + if (providerID === "google" || (providerID.startsWith("opencode") && model.target.includes("gemini-3"))) { result["thinkingConfig"] = { includeThoughts: true, } } - if (modelID.includes("gpt-5") && !modelID.includes("gpt-5-chat")) { - if (modelID.includes("codex")) { + if (model.target.includes("gpt-5") && !model.target.includes("gpt-5-chat")) { + if (model.target.includes("codex")) { result["store"] = false } - if (!modelID.includes("codex") && !modelID.includes("gpt-5-pro")) { + if (!model.target.includes("codex") && !model.target.includes("gpt-5-pro")) { result["reasoningEffort"] = "medium" } - if (modelID.endsWith("gpt-5.1") && providerID !== "azure") { + if (model.target.endsWith("gpt-5.1") && providerID !== "azure") { result["textVerbosity"] = "low" } @@ -176,11 +177,11 @@ export namespace ProviderTransform { return result } - export function smallOptions(input: { providerID: string; modelID: string }) { + export function smallOptions(input: { providerID: string; model: ModelsDev.Model }) { const options: Record = {} - if (input.providerID === "openai" || input.modelID.includes("gpt-5")) { - if (input.modelID.includes("5.1")) { + if (input.providerID === "openai" || input.model.target.includes("gpt-5")) { + if (input.model.target.includes("5.1")) { options["reasoningEffort"] = "low" } else { options["reasoningEffort"] = "minimal" @@ -254,7 +255,7 @@ export namespace ProviderTransform { return standardLimit } - export function schema(providerID: string, modelID: string, schema: JSONSchema.BaseSchema) { + export function schema(providerID: string, model: ModelsDev.Model, schema: JSONSchema.BaseSchema) { /* if (["openai", "azure"].includes(providerID)) { if (schema.type === "object" && schema.properties) { @@ -274,7 +275,7 @@ export namespace ProviderTransform { */ // Convert integer enums to string enums for Google/Gemini - if (providerID === "google" || modelID.includes("gemini")) { + if (providerID === "google" || model.target.includes("gemini")) { const sanitizeGemini = (obj: any): any => { if (obj === null || typeof obj !== "object") { return obj diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index fe4ad195aa..4dfd3ac743 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -296,8 +296,8 @@ export namespace Server { }), ), async (c) => { - const { provider, model } = c.req.valid("query") - const tools = await ToolRegistry.tools(provider, model) + const { provider } = c.req.valid("query") + const tools = await ToolRegistry.tools(provider) return c.json( tools.map((t) => ({ id: t.id, diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index a6b71edcef..d4c9eb99a3 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -1,4 +1,4 @@ -import { streamText, wrapLanguageModel, type ModelMessage } from "ai" +import { wrapLanguageModel, type ModelMessage } from "ai" import { Session } from "." import { Identifier } from "../id/id" import { Instance } from "../project/instance" @@ -130,75 +130,73 @@ export namespace SessionCompaction { model: model.info, abort: input.abort, }) - const result = await processor.process(() => - streamText({ - onError(error) { - log.error("stream error", { - error, - }) - }, - // set to 0, we handle loop - maxRetries: 0, - providerOptions: ProviderTransform.providerOptions( - model.npm, - model.providerID, - pipe( - {}, - mergeDeep(ProviderTransform.options(model.providerID, model.modelID, model.npm ?? "", input.sessionID)), - mergeDeep(model.info.options), - ), + const result = await processor.process({ + onError(error) { + log.error("stream error", { + error, + }) + }, + // set to 0, we handle loop + maxRetries: 0, + providerOptions: ProviderTransform.providerOptions( + model.npm, + model.providerID, + pipe( + {}, + mergeDeep(ProviderTransform.options(model.providerID, model.info, model.npm ?? "", input.sessionID)), + mergeDeep(model.info.options), ), - headers: model.info.headers, - abortSignal: input.abort, - tools: model.info.tool_call ? {} : undefined, - messages: [ - ...system.map( - (x): ModelMessage => ({ - role: "system", - content: x, - }), - ), - ...MessageV2.toModelMessage( - input.messages.filter((m) => { - if (m.info.role !== "assistant" || m.info.error === undefined) { - return true - } - if ( - MessageV2.AbortedError.isInstance(m.info.error) && - m.parts.some((part) => part.type !== "step-start" && part.type !== "reasoning") - ) { - return true - } + ), + headers: model.info.headers, + abortSignal: input.abort, + tools: model.info.tool_call ? {} : undefined, + messages: [ + ...system.map( + (x): ModelMessage => ({ + role: "system", + content: x, + }), + ), + ...MessageV2.toModelMessage( + input.messages.filter((m) => { + if (m.info.role !== "assistant" || m.info.error === undefined) { + return true + } + if ( + MessageV2.AbortedError.isInstance(m.info.error) && + m.parts.some((part) => part.type !== "step-start" && part.type !== "reasoning") + ) { + return true + } - return false - }), - ), - { - role: "user", - content: [ - { - type: "text", - text: "Summarize our conversation above. This summary will be the only context available when the conversation continues, so preserve critical information including: what was accomplished, current work in progress, files involved, next steps, and any key user requests or constraints. Be concise but detailed enough that work can continue seamlessly.", - }, - ], - }, - ], - model: wrapLanguageModel({ - model: model.language, - middleware: [ + return false + }), + ), + { + role: "user", + content: [ { - async transformParams(args) { - if (args.type === "stream") { - // @ts-expect-error - args.params.prompt = ProviderTransform.message(args.params.prompt, model.providerID, model.modelID) - } - return args.params - }, + type: "text", + text: "Summarize our conversation above. This summary will be the only context available when the conversation continues, so preserve critical information including: what was accomplished, current work in progress, files involved, next steps, and any key user requests or constraints. Be concise but detailed enough that work can continue seamlessly.", }, ], - }), + }, + ], + model: wrapLanguageModel({ + model: model.language, + middleware: [ + { + async transformParams(args) { + if (args.type === "stream") { + // @ts-expect-error + args.params.prompt = ProviderTransform.message(args.params.prompt, model.providerID, model.modelID) + } + return args.params + }, + }, + ], }), - ) + }) if (result === "continue" && input.auto) { const continueMsg = await Session.updateMessage({ id: Identifier.ascending("message"), diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 8655781d5e..5823d6191c 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -1,6 +1,6 @@ import type { ModelsDev } from "@/provider/models" import { MessageV2 } from "./message-v2" -import { type StreamTextResult, type Tool as AITool, APICallError } from "ai" +import { streamText } from "ai" import { Log } from "@/util/log" import { Identifier } from "@/id/id" import { Session } from "." @@ -19,6 +19,15 @@ export namespace SessionProcessor { export type Info = Awaited> export type Result = Awaited> + export type StreamInput = Parameters[0] + + export type TBD = { + model: { + modelID: string + providerID: string + } + } + export function create(input: { assistantMessage: MessageV2.Assistant sessionID: string @@ -38,13 +47,13 @@ export namespace SessionProcessor { partFromToolCall(toolCallID: string) { return toolcalls[toolCallID] }, - async process(fn: () => StreamTextResult, never>) { + async process(streamInput: StreamInput) { log.info("process") while (true) { try { let currentText: MessageV2.TextPart | undefined let reasoningMap: Record = {} - const stream = fn() + const stream = streamText(streamInput) for await (const value of stream.fullStream) { input.abort.throwIfAborted() diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index ee58bb3380..2433c582b4 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -11,7 +11,6 @@ import { Agent } from "../agent/agent" import { Provider } from "../provider/provider" import { generateText, - streamText, type ModelMessage, type Tool as AITool, tool, @@ -48,6 +47,7 @@ import { fn } from "@/util/fn" import { SessionProcessor } from "./processor" import { TaskTool } from "@/tool/task" import { SessionStatus } from "./status" +import type { ModelsDev } from "@/provider/models" // @ts-ignore globalThis.AI_SDK_LOG_WARNINGS = false @@ -469,14 +469,15 @@ export namespace SessionPrompt { }) const system = await resolveSystemPrompt({ providerID: model.providerID, - modelID: model.info.id, + model: model.info, agent, system: lastUser.system, }) const tools = await resolveTools({ agent, sessionID, - model: lastUser.model, + providerID: model.providerID, + model: model.info, tools: lastUser.tools, processor, }) @@ -492,14 +493,12 @@ export namespace SessionPrompt { }, { temperature: model.info.temperature - ? (agent.temperature ?? ProviderTransform.temperature(model.providerID, model.modelID)) + ? (agent.temperature ?? ProviderTransform.temperature(model.info)) : undefined, - topP: agent.topP ?? ProviderTransform.topP(model.providerID, model.modelID), + topP: agent.topP ?? ProviderTransform.topP(model.info), options: pipe( {}, - mergeDeep( - ProviderTransform.options(model.providerID, model.modelID, model.npm ?? "", sessionID, provider?.options), - ), + mergeDeep(ProviderTransform.options(model.providerID, model.info, model.npm, sessionID, provider?.options)), mergeDeep(model.info.options), mergeDeep(agent.options), ), @@ -513,113 +512,111 @@ export namespace SessionPrompt { }) } - const result = await processor.process(() => - streamText({ - onError(error) { - log.error("stream error", { - error, + const result = await processor.process({ + onError(error) { + log.error("stream error", { + error, + }) + }, + async experimental_repairToolCall(input) { + const lower = input.toolCall.toolName.toLowerCase() + if (lower !== input.toolCall.toolName && tools[lower]) { + log.info("repairing tool call", { + tool: input.toolCall.toolName, + repaired: lower, }) - }, - async experimental_repairToolCall(input) { - const lower = input.toolCall.toolName.toLowerCase() - if (lower !== input.toolCall.toolName && tools[lower]) { - log.info("repairing tool call", { - tool: input.toolCall.toolName, - repaired: lower, - }) - return { - ...input.toolCall, - toolName: lower, - } - } return { ...input.toolCall, - input: JSON.stringify({ - tool: input.toolCall.toolName, - error: input.error.message, - }), - toolName: "invalid", + toolName: lower, } - }, - headers: { - ...(model.providerID.startsWith("opencode") - ? { - "x-opencode-project": Instance.project.id, - "x-opencode-session": sessionID, - "x-opencode-request": lastUser.id, - } - : undefined), - ...model.info.headers, - }, - // set to 0, we handle loop - maxRetries: 0, - activeTools: Object.keys(tools).filter((x) => x !== "invalid"), - maxOutputTokens: ProviderTransform.maxOutputTokens( - model.providerID, - params.options, - model.info.limit.output, - OUTPUT_TOKEN_MAX, + } + return { + ...input.toolCall, + input: JSON.stringify({ + tool: input.toolCall.toolName, + error: input.error.message, + }), + toolName: "invalid", + } + }, + headers: { + ...(model.providerID.startsWith("opencode") + ? { + "x-opencode-project": Instance.project.id, + "x-opencode-session": sessionID, + "x-opencode-request": lastUser.id, + } + : undefined), + ...model.info.headers, + }, + // set to 0, we handle loop + maxRetries: 0, + activeTools: Object.keys(tools).filter((x) => x !== "invalid"), + maxOutputTokens: ProviderTransform.maxOutputTokens( + model.providerID, + params.options, + model.info.limit.output, + OUTPUT_TOKEN_MAX, + ), + abortSignal: abort, + providerOptions: ProviderTransform.providerOptions(model.npm, model.providerID, params.options), + stopWhen: stepCountIs(1), + temperature: params.temperature, + topP: params.topP, + messages: [ + ...system.map( + (x): ModelMessage => ({ + role: "system", + content: x, + }), ), - abortSignal: abort, - providerOptions: ProviderTransform.providerOptions(model.npm, model.providerID, params.options), - stopWhen: stepCountIs(1), - temperature: params.temperature, - topP: params.topP, - messages: [ - ...system.map( - (x): ModelMessage => ({ - role: "system", - content: x, - }), - ), - ...MessageV2.toModelMessage( - msgs.filter((m) => { - if (m.info.role !== "assistant" || m.info.error === undefined) { - return true - } - if ( - MessageV2.AbortedError.isInstance(m.info.error) && - m.parts.some((part) => part.type !== "step-start" && part.type !== "reasoning") - ) { - return true - } + ...MessageV2.toModelMessage( + msgs.filter((m) => { + if (m.info.role !== "assistant" || m.info.error === undefined) { + return true + } + if ( + MessageV2.AbortedError.isInstance(m.info.error) && + m.parts.some((part) => part.type !== "step-start" && part.type !== "reasoning") + ) { + return true + } - return false - }), - ), - ], - tools: model.info.tool_call === false ? undefined : tools, - model: wrapLanguageModel({ - model: model.language, - middleware: [ - { - async transformParams(args) { - if (args.type === "stream") { - // @ts-expect-error - args.params.prompt = ProviderTransform.message(args.params.prompt, model.providerID, model.modelID) - } - // Transform tool schemas for provider compatibility - if (args.params.tools && Array.isArray(args.params.tools)) { - args.params.tools = args.params.tools.map((tool: any) => { - // Tools at middleware level have inputSchema, not parameters - if (tool.inputSchema && typeof tool.inputSchema === "object") { - // Transform the inputSchema for provider compatibility - return { - ...tool, - inputSchema: ProviderTransform.schema(model.providerID, model.modelID, tool.inputSchema), - } + return false + }), + ), + ], + tools: model.info.tool_call === false ? undefined : tools, + model: wrapLanguageModel({ + model: model.language, + middleware: [ + { + async transformParams(args) { + if (args.type === "stream") { + // @ts-expect-error + args.params.prompt = ProviderTransform.message(args.params.prompt, model.providerID, model.info) + } + // Transform tool schemas for provider compatibility + if (args.params.tools && Array.isArray(args.params.tools)) { + args.params.tools = args.params.tools.map((tool: any) => { + // Tools at middleware level have inputSchema, not parameters + if (tool.inputSchema && typeof tool.inputSchema === "object") { + // Transform the inputSchema for provider compatibility + return { + ...tool, + inputSchema: ProviderTransform.schema(model.providerID, model.info, tool.inputSchema), } - // If no inputSchema, return tool unchanged - return tool - }) - } - return args.params - }, + } + // If no inputSchema, return tool unchanged + return tool + }) + } + return args.params }, - ], - }), + }, + ], }), - ) + }) if (result === "stop") break continue } @@ -646,14 +643,14 @@ export namespace SessionPrompt { system?: string agent: Agent.Info providerID: string - modelID: string + model: ModelsDev.Model }) { let system = SystemPrompt.header(input.providerID) system.push( ...(() => { if (input.system) return [input.system] if (input.agent.prompt) return [input.agent.prompt] - return SystemPrompt.provider(input.modelID) + return SystemPrompt.provider(input.model) })(), ) system.push(...(await SystemPrompt.environment())) @@ -666,10 +663,8 @@ export namespace SessionPrompt { async function resolveTools(input: { agent: Agent.Info - model: { - providerID: string - modelID: string - } + providerID: string + model: ModelsDev.Model sessionID: string tools?: Record processor: SessionProcessor.Info @@ -677,16 +672,12 @@ export namespace SessionPrompt { const tools: Record = {} const enabledTools = pipe( input.agent.tools, - mergeDeep(await ToolRegistry.enabled(input.model.providerID, input.model.modelID, input.agent)), + mergeDeep(await ToolRegistry.enabled(input.agent)), mergeDeep(input.tools ?? {}), ) - for (const item of await ToolRegistry.tools(input.model.providerID, input.model.modelID)) { + for (const item of await ToolRegistry.tools(input.providerID)) { if (Wildcard.all(item.id, enabledTools) === false) continue - const schema = ProviderTransform.schema( - input.model.providerID, - input.model.modelID, - z.toJSONSchema(item.parameters), - ) + const schema = ProviderTransform.schema(input.providerID, input.model, z.toJSONSchema(item.parameters)) tools[item.id] = tool({ id: item.id as any, description: item.description, @@ -1441,15 +1432,9 @@ export namespace SessionPrompt { const options = pipe( {}, mergeDeep( - ProviderTransform.options( - small.providerID, - small.modelID, - small.npm ?? "", - input.session.id, - provider?.options, - ), + ProviderTransform.options(small.providerID, small.info, small.npm ?? "", input.session.id, provider?.options), ), - mergeDeep(ProviderTransform.smallOptions({ providerID: small.providerID, modelID: small.modelID })), + mergeDeep(ProviderTransform.smallOptions({ providerID: small.providerID, model: small.info })), mergeDeep(small.info.options), ) await generateText({ diff --git a/packages/opencode/src/session/summary.ts b/packages/opencode/src/session/summary.ts index d9247f182d..9f56b084e0 100644 --- a/packages/opencode/src/session/summary.ts +++ b/packages/opencode/src/session/summary.ts @@ -79,8 +79,8 @@ export namespace SessionSummary { const options = pipe( {}, - mergeDeep(ProviderTransform.options(small.providerID, small.modelID, small.npm ?? "", assistantMsg.sessionID)), - mergeDeep(ProviderTransform.smallOptions({ providerID: small.providerID, modelID: small.modelID })), + mergeDeep(ProviderTransform.options(small.providerID, small.info, small.npm ?? "", assistantMsg.sessionID)), + mergeDeep(ProviderTransform.smallOptions({ providerID: small.providerID, model: small.info })), mergeDeep(small.info.options), ) diff --git a/packages/opencode/src/session/system.ts b/packages/opencode/src/session/system.ts index 399cad8cde..42b398948d 100644 --- a/packages/opencode/src/session/system.ts +++ b/packages/opencode/src/session/system.ts @@ -17,6 +17,7 @@ import PROMPT_COMPACTION from "./prompt/compaction.txt" import PROMPT_SUMMARIZE from "./prompt/summarize.txt" import PROMPT_TITLE from "./prompt/title.txt" import PROMPT_CODEX from "./prompt/codex.txt" +import type { ModelsDev } from "@/provider/models" export namespace SystemPrompt { export function header(providerID: string) { @@ -24,12 +25,13 @@ export namespace SystemPrompt { return [] } - export function provider(modelID: string) { - if (modelID.includes("gpt-5")) return [PROMPT_CODEX] - if (modelID.includes("gpt-") || modelID.includes("o1") || modelID.includes("o3")) return [PROMPT_BEAST] - if (modelID.includes("gemini-")) return [PROMPT_GEMINI] - if (modelID.includes("claude")) return [PROMPT_ANTHROPIC] - if (modelID.includes("polaris-alpha")) return [PROMPT_POLARIS] + export function provider(model: ModelsDev.Model) { + if (model.target.includes("gpt-5")) return [PROMPT_CODEX] + if (model.target.includes("gpt-") || model.target.includes("o1") || model.target.includes("o3")) + return [PROMPT_BEAST] + if (model.target.includes("gemini-")) return [PROMPT_GEMINI] + if (model.target.includes("claude")) return [PROMPT_ANTHROPIC] + if (model.target.includes("polaris-alpha")) return [PROMPT_POLARIS] return [PROMPT_ANTHROPIC_WITHOUT_TODO] } diff --git a/packages/opencode/src/tool/batch.ts b/packages/opencode/src/tool/batch.ts index 7d6449e7dc..cc61b090aa 100644 --- a/packages/opencode/src/tool/batch.ts +++ b/packages/opencode/src/tool/batch.ts @@ -37,7 +37,7 @@ export const BatchTool = Tool.define("batch", async () => { const discardedCalls = params.tool_calls.slice(10) const { ToolRegistry } = await import("./registry") - const availableTools = await ToolRegistry.tools("", "") + const availableTools = await ToolRegistry.tools("") const toolMap = new Map(availableTools.map((t) => [t.id, t])) const executeCall = async (call: (typeof toolCalls)[0]) => { diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 26b6ea9fcf..33a54675ff 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -108,7 +108,7 @@ export namespace ToolRegistry { return all().then((x) => x.map((t) => t.id)) } - export async function tools(providerID: string, _modelID: string) { + export async function tools(providerID: string) { const tools = await all() const result = await Promise.all( tools @@ -124,11 +124,7 @@ export namespace ToolRegistry { return result } - export async function enabled( - _providerID: string, - _modelID: string, - agent: Agent.Info, - ): Promise> { + export async function enabled(agent: Agent.Info): Promise> { const result: Record = {} if (agent.permission.edit === "deny") { diff --git a/packages/sdk/js/src/gen/types.gen.ts b/packages/sdk/js/src/gen/types.gen.ts index 80348fb9ad..fcf04444ed 100644 --- a/packages/sdk/js/src/gen/types.gen.ts +++ b/packages/sdk/js/src/gen/types.gen.ts @@ -1110,6 +1110,7 @@ export type Config = { [key: string]: { id?: string name?: string + target?: string release_date?: string attachment?: boolean reasoning?: boolean @@ -1355,6 +1356,7 @@ export type Command = { export type Model = { id: string name: string + target: string release_date: string attachment: boolean reasoning: boolean From a844eb2429351261c623eb0639ee70bcec96aedc Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Wed, 3 Dec 2025 12:51:37 -0500 Subject: [PATCH 02/13] core: convert Model type to Zod schema for better type safety and validation --- packages/opencode/src/provider/provider.ts | 362 ++++++++++++++------- 1 file changed, 252 insertions(+), 110 deletions(-) diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 32750221eb..c7443b2cd0 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -1,7 +1,7 @@ import z from "zod" import fuzzysort from "fuzzysort" import { Config } from "../config/config" -import { mergeDeep, sortBy } from "remeda" +import { entries, mapValues, mergeDeep, pipe, sortBy } from "remeda" import { NoSuchModelError, type LanguageModel, type Provider as SDK } from "ai" import { Log } from "../util/log" import { BunProc } from "../bun" @@ -43,7 +43,7 @@ export namespace Provider { "@ai-sdk/github-copilot": createGitHubCopilotOpenAICompatible, } - type CustomLoader = (provider: ModelsDev.Provider) => Promise<{ + type CustomLoader = (provider: Info) => Promise<{ autoload: boolean getModel?: (sdk: any, modelID: string, options?: Record) => Promise options?: Record @@ -299,18 +299,155 @@ export namespace Provider { }, } - export type Model = { - providerID: string - modelID: string + export const Model = z + .object({ + id: z.string(), + providerID: z.string(), + api: z.object({ + id: z.string(), + url: z.string(), + npm: z.string(), + }), + name: z.string(), + capabilities: z.object({ + temperature: z.boolean(), + reasoning: z.boolean(), + attachment: z.boolean(), + toolcall: z.boolean(), + input: { + text: z.boolean(), + audio: z.boolean(), + image: z.boolean(), + video: z.boolean(), + pdf: z.boolean(), + }, + output: { + text: z.boolean(), + audio: z.boolean(), + image: z.boolean(), + video: z.boolean(), + pdf: z.boolean(), + }, + }), + cost: z.object({ + input: z.number(), + output: z.number(), + cache: z.object({ + read: z.number(), + write: z.number(), + }), + experimentalOver200K: z + .object({ + input: z.number(), + output: z.number(), + cache: z.object({ + read: z.number(), + write: z.number(), + }), + }) + .optional(), + }), + limit: z.object({ + context: z.number(), + output: z.number(), + }), + status: z.enum(["alpha", "beta", "deprecated", "active"]), + options: z.record(z.string(), z.any()), + headers: z.record(z.string(), z.string()), + }) + .meta({ + ref: "Model", + }) + export type Model = z.infer + + export const Info = z.object({ + id: z.string(), + name: z.string(), + source: z.enum(["env", "config", "custom", "api"]), + env: z.string().array(), + key: z.string().optional(), + options: z.record(z.string(), z.any()), + models: z.record(z.string(), Model), + }) + export type Info = z.infer + + function fromModelsDevModel(provider: ModelsDev.Provider, model: ModelsDev.Model): Model { + return { + id: model.id, + name: model.name, + api: { + id: model.id, + url: provider.api!, + npm: model.provider?.npm ?? provider.npm ?? provider.id, + }, + status: model.status ?? "active", + headers: model.headers ?? {}, + options: model.options ?? {}, + cost: { + input: model.cost.input, + output: model.cost.output, + cache: { + read: model.cost.cache_read ?? 0, + write: model.cost.cache_write ?? 0, + }, + experimentalOver200K: model.cost.context_over_200k + ? { + cache: { + read: model.cost.context_over_200k.cache_read ?? 0, + write: model.cost.context_over_200k.cache_write ?? 0, + }, + input: model.cost.context_over_200k.input, + output: model.cost.context_over_200k.output, + } + : undefined, + }, + limit: { + context: model.limit.context, + output: model.limit.output, + }, + capabilities: { + temperature: model.temperature, + reasoning: model.reasoning, + attachment: model.attachment, + toolcall: model.tool_call, + input: { + text: model.modalities?.input?.includes("text") ?? false, + audio: model.modalities?.input?.includes("audio") ?? false, + image: model.modalities?.input?.includes("image") ?? false, + video: model.modalities?.input?.includes("video") ?? false, + pdf: model.modalities?.input?.includes("pdf") ?? false, + }, + output: { + text: model.modalities?.output?.includes("text") ?? false, + audio: model.modalities?.output?.includes("audio") ?? false, + image: model.modalities?.output?.includes("image") ?? false, + video: model.modalities?.output?.includes("video") ?? false, + pdf: model.modalities?.output?.includes("pdf") ?? false, + }, + }, + } + } + + function fromModelsDevProvider(provider: ModelsDev.Provider): Info { + return { + id: provider.id, + source: "custom", + name: provider.name, + env: provider.env ?? [], + options: {}, + models: mapValues(provider.models, (model) => fromModelsDevModel(provider, model)), + } + } + + export type ModelWithStuff = { language: LanguageModel - info: ModelsDev.Model - npm: string + info: Model } const state = Instance.state(async () => { using _ = log.time("state") const config = await Config.get() - const database = await ModelsDev.get() + const database = mapValues(await ModelsDev.get(), fromModelsDevProvider) const disabled = new Set(config.disabled_providers ?? []) const enabled = config.enabled_providers ? new Set(config.enabled_providers) : null @@ -321,43 +458,12 @@ export namespace Provider { return true } - const providers: { - [providerID: string]: { - source: Source - info: ModelsDev.Provider - getModel?: (sdk: any, modelID: string, options?: Record) => Promise - options: Record - } - } = {} - const models = new Map() + const providers: { [providerID: string]: Info } = {} + const models = new Map() const sdk = new Map() log.info("init") - function mergeProvider( - id: string, - options: Record, - source: Source, - getModel?: (sdk: any, modelID: string, options?: Record) => Promise, - ) { - const provider = providers[id] - if (!provider) { - const info = database[id] - if (!info) return - if (info.api && !options["baseURL"]) options["baseURL"] = info.api - providers[id] = { - source, - info, - options, - getModel, - } - return - } - provider.options = mergeDeep(provider.options, options) - provider.source = source - provider.getModel = getModel ?? provider.getModel - } - const configProviders = Object.entries(config.provider ?? {}) // Add GitHub Copilot Enterprise provider that inherits from GitHub Copilot @@ -367,11 +473,17 @@ export namespace Provider { ...githubCopilot, id: "github-copilot-enterprise", name: "GitHub Copilot Enterprise", - // Enterprise uses a different API endpoint - will be set dynamically based on auth - api: undefined, } } + function mergeProvider(providerID: string, provider: Partial) { + const match = database[providerID] + if (!match) return + // @ts-expect-error + providers[providerID] = mergeDeep(match, provider) + } + + // TODO: load config for (const [providerID, provider] of configProviders) { const existing = database[providerID] const parsed: ModelsDev.Provider = { @@ -390,29 +502,27 @@ export namespace Provider { if (model.id && model.id !== modelID) return modelID return existing?.name ?? modelID }) - const parsedModel: ModelsDev.Model = { + const parsedModel: Model = { id: modelID, - target: model.target ?? existing?.target ?? modelID, + apiID: model.target ?? existing?.target ?? modelID, + status: model.status ?? existing?.status ?? "alpha", name, - release_date: model.release_date ?? existing?.release_date, - attachment: model.attachment ?? existing?.attachment ?? false, - reasoning: model.reasoning ?? existing?.reasoning ?? false, - temperature: model.temperature ?? existing?.temperature ?? false, - tool_call: model.tool_call ?? existing?.tool_call ?? true, - cost: - !model.cost && !existing?.cost - ? { - input: 0, - output: 0, - cache_read: 0, - cache_write: 0, - } - : { - cache_read: 0, - cache_write: 0, - ...existing?.cost, - ...model.cost, - }, + providerID, + npm: model.provider?.npm ?? existing?.provider?.npm ?? provider.npm ?? providerID, + support: { + temperature: model.temperature ?? existing?.temperature ?? false, + reasoning: model.reasoning ?? existing?.reasoning ?? false, + attachment: model.attachment ?? existing?.attachment ?? false, + toolcall: model.tool_call ?? existing?.tool_call ?? true, + }, + cost: { + input: model?.cost?.input ?? existing?.cost?.input ?? 0, + output: model?.cost?.output ?? existing?.cost?.output ?? 0, + cache: { + read: model?.cost?.cache_read ?? existing?.cost?.cache_read ?? 0, + write: model?.cost?.cache_write ?? existing?.cost?.cache_write ?? 0, + }, + }, options: { ...existing?.options, ...model.options, @@ -427,8 +537,7 @@ export namespace Provider { input: ["text"], output: ["text"], }, - headers: model.headers, - provider: model.provider ?? existing?.provider, + headers: model.headers ?? {}, } parsed.models[modelID] = parsedModel } @@ -442,19 +551,20 @@ export namespace Provider { if (disabled.has(providerID)) continue const apiKey = provider.env.map((item) => env[item]).find(Boolean) if (!apiKey) continue - mergeProvider( - providerID, - // only include apiKey if there's only one potential option - provider.env.length === 1 ? { apiKey } : {}, - "env", - ) + mergeProvider(providerID, { + source: "env", + key: provider.env.length === 1 ? apiKey : undefined, + }) } // load apikeys for (const [providerID, provider] of Object.entries(await Auth.all())) { if (disabled.has(providerID)) continue if (provider.type === "api") { - mergeProvider(providerID, { apiKey: provider.key }, "api") + mergeProvider(providerID, { + source: "api", + key: provider.key, + }) } } @@ -480,7 +590,10 @@ export namespace Provider { // Load for the main provider if auth exists if (auth) { const options = await plugin.auth.loader(() => Auth.get(providerID) as any, database[plugin.auth.provider]) - mergeProvider(plugin.auth.provider, options ?? {}, "custom") + mergeProvider(plugin.auth.provider, { + source: "custom", + options: options, + }) } // If this is github-copilot plugin, also register for github-copilot-enterprise if auth exists @@ -493,7 +606,10 @@ export namespace Provider { () => Auth.get(enterpriseProviderID) as any, database[enterpriseProviderID], ) - mergeProvider(enterpriseProviderID, enterpriseOptions ?? {}, "custom") + mergeProvider(enterpriseProviderID, { + source: "custom", + options: enterpriseOptions, + }) } } } @@ -503,13 +619,22 @@ export namespace Provider { if (disabled.has(providerID)) continue const result = await fn(database[providerID]) if (result && (result.autoload || providers[providerID])) { - mergeProvider(providerID, result.options ?? {}, "custom", result.getModel) + mergeProvider(providerID, { + source: "custom", + options: result.options, + }) } } // load config for (const [providerID, provider] of configProviders) { - mergeProvider(providerID, provider.options ?? {}, "config") + mergeProvider(providerID, { + source: "config", + env: provider.env, + name: provider.name, + options: provider.options, + // TODO: merge models + }) } for (const [providerID, provider] of Object.entries(providers)) { @@ -519,33 +644,36 @@ export namespace Provider { } if (providerID === "github-copilot" || providerID === "github-copilot-enterprise") { - provider.info.npm = "@ai-sdk/github-copilot" + provider.models = mapValues(provider.models, (model) => ({ + ...model, + api: { + ...model.api, + npm: "@ai-sdk/github-copilot", + }, + })) } const configProvider = config.provider?.[providerID] - for (const [modelID, model] of Object.entries(provider.info.models)) { - model.target = model.target ?? model.id ?? modelID + for (const [modelID, model] of Object.entries(provider.models)) { + model.api.id = model.api.id ?? model.id ?? modelID if (modelID === "gpt-5-chat-latest" || (providerID === "openrouter" && modelID === "openai/gpt-5-chat")) - delete provider.info.models[modelID] - if ( - ((model.status === "alpha" || model.experimental) && !Flag.OPENCODE_ENABLE_EXPERIMENTAL_MODELS) || - model.status === "deprecated" - ) - delete provider.info.models[modelID] + delete provider.models[modelID] + if ((model.status === "alpha" && !Flag.OPENCODE_ENABLE_EXPERIMENTAL_MODELS) || model.status === "deprecated") + delete provider.models[modelID] if ( (configProvider?.blacklist && configProvider.blacklist.includes(modelID)) || (configProvider?.whitelist && !configProvider.whitelist.includes(modelID)) ) - delete provider.info.models[modelID] + delete provider.models[modelID] } - if (Object.keys(provider.info.models).length === 0) { + if (Object.keys(provider.models).length === 0) { delete providers[providerID] continue } - log.info("found", { providerID, npm: provider.info.npm }) + log.info("found", { providerID }) } return { @@ -559,18 +687,28 @@ export namespace Provider { return state().then((state) => state.providers) } - async function getSDK(npm: string, providerID: string) { + async function getSDK(model: Model) { try { using _ = log.time("getSDK", { - providerID, + providerID: model.providerID, }) const s = await state() - const options = { ...s.providers[providerID]?.options } - if (npm.includes("@ai-sdk/openai-compatible") && options["includeUsage"] !== false) { + const provider = s.providers[model.providerID] + const options = { ...provider.options } + + if (model.api.npm.includes("@ai-sdk/openai-compatible") && options["includeUsage"] !== false) { options["includeUsage"] = true } - const key = Bun.hash.xxHash32(JSON.stringify({ pkg: npm, options })) + if (!options["baseURL"]) options["baseURL"] = model.api.url + if (!options["apiKey"]) options["apiKey"] = provider.key + if (model.headers) + options["headers"] = { + ...options["headers"], + ...model.headers, + } + + const key = Bun.hash.xxHash32(JSON.stringify({ npm: model.api.npm, options })) const existing = s.sdk.get(key) if (existing) return existing @@ -599,12 +737,13 @@ export namespace Provider { } // Special case: google-vertex-anthropic uses a subpath import - const bundledKey = providerID === "google-vertex-anthropic" ? "@ai-sdk/google-vertex/anthropic" : npm + const bundledKey = + model.providerID === "google-vertex-anthropic" ? "@ai-sdk/google-vertex/anthropic" : model.api.npm const bundledFn = BUNDLED_PROVIDERS[bundledKey] if (bundledFn) { - log.info("using bundled provider", { providerID, pkg: bundledKey }) + log.info("using bundled provider", { providerID: model.providerID, pkg: bundledKey }) const loaded = bundledFn({ - name: providerID, + name: model.providerID, ...options, }) s.sdk.set(key, loaded) @@ -612,24 +751,24 @@ export namespace Provider { } let installedPath: string - if (!npm.startsWith("file://")) { - installedPath = await BunProc.install(npm, "latest") + if (!model.api.npm.startsWith("file://")) { + installedPath = await BunProc.install(model.api.npm, "latest") } else { - log.info("loading local provider", { pkg: npm }) - installedPath = npm + log.info("loading local provider", { pkg: model.api.npm }) + installedPath = model.api.npm } const mod = await import(installedPath) const fn = mod[Object.keys(mod).find((key) => key.startsWith("create"))!] const loaded = fn({ - name: providerID, + name: model.providerID, ...options, }) s.sdk.set(key, loaded) return loaded as SDK } catch (e) { - throw new InitError({ providerID }, { cause: e }) + throw new InitError({ providerID: model.providerID }, { cause: e }) } } @@ -655,22 +794,25 @@ export namespace Provider { throw new ModelNotFoundError({ providerID, modelID, suggestions }) } - const info = provider.info.models[modelID] + const info = provider.models[modelID] if (!info) { - const availableModels = Object.keys(provider.info.models) + const availableModels = Object.keys(provider.models) const matches = fuzzysort.go(modelID, availableModels, { limit: 3, threshold: -10000 }) const suggestions = matches.map((m) => m.target) throw new ModelNotFoundError({ providerID, modelID, suggestions }) } - const npm = info.provider?.npm ?? provider.info.npm ?? info.id - const sdk = await getSDK(npm, providerID) + const sdk = await getSDK(info) try { const language = provider.getModel - ? await provider.getModel(sdk, info.target, provider.options) - : sdk.languageModel(info.target) + ? await provider.getModel(sdk, info.api.id, provider.options) + : sdk.languageModel(info.api.id) log.info("found", { providerID, modelID }) + const cached: ModelWithStuff = { + info, + language, + } s.models.set(key, { providerID, modelID, @@ -755,7 +897,7 @@ export namespace Provider { } const priority = ["gpt-5", "claude-sonnet-4", "big-pickle", "gemini-3-pro"] - export function sort(models: ModelsDev.Model[]) { + export function sort(models: Model[]) { return sortBy( models, [(model) => priority.findIndex((filter) => model.id.includes(filter)), "desc"], From 8b1c55f9fa53fe83624a50840a52635e44dce7fb Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Wed, 3 Dec 2025 13:42:55 -0500 Subject: [PATCH 03/13] sync --- packages/opencode/src/config/config.ts | 90 ++++++++++----------- packages/opencode/src/provider/provider.ts | 92 ++++++++++++---------- 2 files changed, 97 insertions(+), 85 deletions(-) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 6ca8fe55be..1f355b5036 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -470,6 +470,50 @@ export namespace Config { }) export type Layout = z.infer + export const Provider = ModelsDev.Provider.partial() + .extend({ + whitelist: z.array(z.string()).optional(), + blacklist: z.array(z.string()).optional(), + models: z + .record( + z.string(), + ModelsDev.Model.partial().refine( + (input) => input.id === undefined, + "The model.id field can no longer be specified. Use model.target to specify an alternate model id to use when calling the provider.", + ), + ) + .optional(), + options: z + .object({ + apiKey: z.string().optional(), + baseURL: z.string().optional(), + enterpriseUrl: z.string().optional().describe("GitHub Enterprise URL for copilot authentication"), + setCacheKey: z.boolean().optional().describe("Enable promptCacheKey for this provider (default false)"), + timeout: z + .union([ + z + .number() + .int() + .positive() + .describe( + "Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout.", + ), + z.literal(false).describe("Disable timeout for this provider entirely."), + ]) + .optional() + .describe( + "Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout.", + ), + }) + .catchall(z.any()) + .optional(), + }) + .strict() + .meta({ + ref: "ProviderConfig", + }) + export type Provider = z.infer + export const Info = z .object({ $schema: z.string().optional().describe("JSON schema reference for configuration validation"), @@ -536,51 +580,7 @@ export namespace Config { .optional() .describe("Agent configuration, see https://opencode.ai/docs/agent"), provider: z - .record( - z.string(), - ModelsDev.Provider.partial() - .extend({ - whitelist: z.array(z.string()).optional(), - blacklist: z.array(z.string()).optional(), - models: z - .record( - z.string(), - ModelsDev.Model.partial().refine( - (input) => input.id === undefined, - "The model.id field can no longer be specified. Use model.target to specify an alternate model id to use when calling the provider.", - ), - ) - .optional(), - options: z - .object({ - apiKey: z.string().optional(), - baseURL: z.string().optional(), - enterpriseUrl: z.string().optional().describe("GitHub Enterprise URL for copilot authentication"), - setCacheKey: z - .boolean() - .optional() - .describe("Enable promptCacheKey for this provider (default false)"), - timeout: z - .union([ - z - .number() - .int() - .positive() - .describe( - "Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout.", - ), - z.literal(false).describe("Disable timeout for this provider entirely."), - ]) - .optional() - .describe( - "Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout.", - ), - }) - .catchall(z.any()) - .optional(), - }) - .strict(), - ) + .record(z.string(), Provider) .optional() .describe("Custom provider configurations and model overrides"), mcp: z.record(z.string(), Mcp).optional().describe("MCP (Model Context Protocol) server configurations"), diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index c7443b2cd0..8341faf3d3 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -1,7 +1,7 @@ import z from "zod" import fuzzysort from "fuzzysort" import { Config } from "../config/config" -import { entries, mapValues, mergeDeep, pipe, sortBy } from "remeda" +import { mapValues, mergeDeep, sortBy } from "remeda" import { NoSuchModelError, type LanguageModel, type Provider as SDK } from "ai" import { Log } from "../util/log" import { BunProc } from "../bun" @@ -360,20 +360,25 @@ export namespace Provider { }) export type Model = z.infer - export const Info = z.object({ - id: z.string(), - name: z.string(), - source: z.enum(["env", "config", "custom", "api"]), - env: z.string().array(), - key: z.string().optional(), - options: z.record(z.string(), z.any()), - models: z.record(z.string(), Model), - }) + export const Info = z + .object({ + id: z.string(), + name: z.string(), + source: z.enum(["env", "config", "custom", "api"]), + env: z.string().array(), + key: z.string().optional(), + options: z.record(z.string(), z.any()), + models: z.record(z.string(), Model), + }) + .meta({ + ref: "Provider", + }) export type Info = z.infer function fromModelsDevModel(provider: ModelsDev.Provider, model: ModelsDev.Model): Model { return { id: model.id, + providerID: provider.id, name: model.name, api: { id: model.id, @@ -483,16 +488,16 @@ export namespace Provider { providers[providerID] = mergeDeep(match, provider) } - // TODO: load config + // extend database from config for (const [providerID, provider] of configProviders) { const existing = database[providerID] - const parsed: ModelsDev.Provider = { + const parsed: Info = { id: providerID, - npm: provider.npm ?? existing?.npm, name: provider.name ?? existing?.name ?? providerID, env: provider.env ?? existing?.env ?? [], - api: provider.api ?? existing?.api, - models: existing?.models ?? {}, + options: mergeDeep(existing?.options ?? {}, provider.options ?? {}), + source: "config", + models: {}, } for (const [modelID, model] of Object.entries(provider.models ?? {})) { @@ -504,44 +509,51 @@ export namespace Provider { }) const parsedModel: Model = { id: modelID, - apiID: model.target ?? existing?.target ?? modelID, - status: model.status ?? existing?.status ?? "alpha", + api: { + id: model.id ?? existing?.api.id ?? modelID, + npm: model.provider?.npm ?? provider.npm ?? existing?.api.npm ?? providerID, + url: provider?.api ?? existing?.api.url, + }, + status: model.status ?? existing?.status ?? "active", name, providerID, - npm: model.provider?.npm ?? existing?.provider?.npm ?? provider.npm ?? providerID, - support: { - temperature: model.temperature ?? existing?.temperature ?? false, - reasoning: model.reasoning ?? existing?.reasoning ?? false, - attachment: model.attachment ?? existing?.attachment ?? false, - toolcall: model.tool_call ?? existing?.tool_call ?? true, + capabilities: { + temperature: model.temperature ?? existing?.capabilities.temperature ?? false, + reasoning: model.reasoning ?? existing?.capabilities.reasoning ?? false, + attachment: model.attachment ?? existing?.capabilities.attachment ?? false, + toolcall: model.tool_call ?? existing?.capabilities.toolcall ?? true, + input: { + text: model.modalities?.input?.includes("text") ?? false, + audio: model.modalities?.input?.includes("audio") ?? false, + image: model.modalities?.input?.includes("image") ?? false, + video: model.modalities?.input?.includes("video") ?? false, + pdf: model.modalities?.input?.includes("pdf") ?? false, + }, + output: { + text: model.modalities?.output?.includes("text") ?? false, + audio: model.modalities?.output?.includes("audio") ?? false, + image: model.modalities?.output?.includes("image") ?? false, + video: model.modalities?.output?.includes("video") ?? false, + pdf: model.modalities?.output?.includes("pdf") ?? false, + }, }, cost: { input: model?.cost?.input ?? existing?.cost?.input ?? 0, output: model?.cost?.output ?? existing?.cost?.output ?? 0, cache: { - read: model?.cost?.cache_read ?? existing?.cost?.cache_read ?? 0, - write: model?.cost?.cache_write ?? existing?.cost?.cache_write ?? 0, + read: model?.cost?.cache_read ?? existing?.cost?.cache.read ?? 0, + write: model?.cost?.cache_write ?? existing?.cost?.cache.write ?? 0, }, }, - options: { - ...existing?.options, - ...model.options, + options: mergeDeep(existing?.options ?? {}, model.options ?? {}), + limit: { + context: model.limit?.context ?? existing?.limit?.context ?? 0, + output: model.limit?.output ?? existing?.limit?.output ?? 0, }, - limit: model.limit ?? - existing?.limit ?? { - context: 0, - output: 0, - }, - modalities: model.modalities ?? - existing?.modalities ?? { - input: ["text"], - output: ["text"], - }, - headers: model.headers ?? {}, + headers: mergeDeep(existing?.headers ?? {}, model.headers ?? {}), } parsed.models[modelID] = parsedModel } - database[providerID] = parsed } From 2dbb029472a6e4fc9c154eb02e20cabbb712db8c Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Wed, 3 Dec 2025 15:14:30 -0500 Subject: [PATCH 04/13] sync --- .../desktop/src/components/prompt-input.tsx | 4 +- packages/opencode/src/agent/agent.ts | 3 +- packages/opencode/src/cli/cmd/models.ts | 2 +- packages/opencode/src/provider/models.ts | 96 +++---- packages/opencode/src/provider/provider.ts | 93 +++---- packages/opencode/src/provider/transform.ts | 63 ++--- packages/opencode/src/server/server.ts | 13 +- packages/opencode/src/session/compaction.ts | 21 +- packages/opencode/src/session/index.ts | 12 +- packages/opencode/src/session/processor.ts | 7 +- packages/opencode/src/session/prompt.ts | 80 +++--- packages/opencode/src/session/summary.ts | 21 +- packages/opencode/src/session/system.ts | 14 +- packages/opencode/src/share/share-next.ts | 7 +- packages/opencode/src/tool/read.ts | 2 +- packages/sdk/js/src/gen/types.gen.ts | 260 +++++++++++------- 16 files changed, 365 insertions(+), 333 deletions(-) diff --git a/packages/desktop/src/components/prompt-input.tsx b/packages/desktop/src/components/prompt-input.tsx index 9769242237..a311ae7638 100644 --- a/packages/desktop/src/components/prompt-input.tsx +++ b/packages/desktop/src/components/prompt-input.tsx @@ -456,9 +456,9 @@ export const PromptInput: Component = (props) => {
{i.name} - + - {DateTime.fromFormat(i.release_date, "yyyy-MM-dd").toFormat("LLL yyyy")} + {DateTime.fromFormat("unknown", "yyyy-MM-dd").toFormat("LLL yyyy")}
diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index b901b95c2f..0e7a7c5d3b 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -224,6 +224,7 @@ export namespace Agent { export async function generate(input: { description: string }) { const defaultModel = await Provider.defaultModel() const model = await Provider.getModel(defaultModel.providerID, defaultModel.modelID) + const language = await Provider.getLanguage(model) const system = SystemPrompt.header(defaultModel.providerID) system.push(PROMPT_GENERATE) const existing = await list() @@ -241,7 +242,7 @@ export namespace Agent { content: `Create an agent configuration based on this request: \"${input.description}\".\n\nIMPORTANT: The following identifiers already exist and must NOT be used: ${existing.map((i) => i.name).join(", ")}\n Return ONLY the JSON object, no other text, do not wrap in backticks`, }, ], - model: model.language, + model: language, schema: z.object({ identifier: z.string(), whenToUse: z.string(), diff --git a/packages/opencode/src/cli/cmd/models.ts b/packages/opencode/src/cli/cmd/models.ts index 1ae4ae12ca..156dae91c6 100644 --- a/packages/opencode/src/cli/cmd/models.ts +++ b/packages/opencode/src/cli/cmd/models.ts @@ -38,7 +38,7 @@ export const ModelsCommand = cmd({ function printModels(providerID: string, verbose?: boolean) { const provider = providers[providerID] - const sortedModels = Object.entries(provider.info.models).sort(([a], [b]) => a.localeCompare(b)) + const sortedModels = Object.entries(provider.models).sort(([a], [b]) => a.localeCompare(b)) for (const [modelID, model] of sortedModels) { process.stdout.write(`${providerID}/${modelID}`) process.stdout.write(EOL) diff --git a/packages/opencode/src/provider/models.ts b/packages/opencode/src/provider/models.ts index f8ff2e86a4..cedeed69a2 100644 --- a/packages/opencode/src/provider/models.ts +++ b/packages/opencode/src/provider/models.ts @@ -9,63 +9,55 @@ export namespace ModelsDev { const log = Log.create({ service: "models.dev" }) const filepath = path.join(Global.Path.cache, "models.json") - export const Model = z - .object({ - id: z.string(), - name: z.string(), - target: z.string(), - release_date: z.string(), - attachment: z.boolean(), - reasoning: z.boolean(), - temperature: z.boolean(), - tool_call: z.boolean(), - cost: z.object({ - input: z.number(), - output: z.number(), - cache_read: z.number().optional(), - cache_write: z.number().optional(), - context_over_200k: z - .object({ - input: z.number(), - output: z.number(), - cache_read: z.number().optional(), - cache_write: z.number().optional(), - }) - .optional(), - }), - limit: z.object({ - context: z.number(), - output: z.number(), - }), - modalities: z + export const Model = z.object({ + id: z.string(), + name: z.string(), + target: z.string(), + release_date: z.string(), + attachment: z.boolean(), + reasoning: z.boolean(), + temperature: z.boolean(), + tool_call: z.boolean(), + cost: z.object({ + input: z.number(), + output: z.number(), + cache_read: z.number().optional(), + cache_write: z.number().optional(), + context_over_200k: z .object({ - input: z.array(z.enum(["text", "audio", "image", "video", "pdf"])), - output: z.array(z.enum(["text", "audio", "image", "video", "pdf"])), + input: z.number(), + output: z.number(), + cache_read: z.number().optional(), + cache_write: z.number().optional(), }) .optional(), - experimental: z.boolean().optional(), - status: z.enum(["alpha", "beta", "deprecated"]).optional(), - options: z.record(z.string(), z.any()), - headers: z.record(z.string(), z.string()).optional(), - provider: z.object({ npm: z.string() }).optional(), - }) - .meta({ - ref: "Model", - }) + }), + limit: z.object({ + context: z.number(), + output: z.number(), + }), + modalities: z + .object({ + input: z.array(z.enum(["text", "audio", "image", "video", "pdf"])), + output: z.array(z.enum(["text", "audio", "image", "video", "pdf"])), + }) + .optional(), + experimental: z.boolean().optional(), + status: z.enum(["alpha", "beta", "deprecated"]).optional(), + options: z.record(z.string(), z.any()), + headers: z.record(z.string(), z.string()).optional(), + provider: z.object({ npm: z.string() }).optional(), + }) export type Model = z.infer - export const Provider = z - .object({ - api: z.string().optional(), - name: z.string(), - env: z.array(z.string()), - id: z.string(), - npm: z.string().optional(), - models: z.record(z.string(), Model), - }) - .meta({ - ref: "Provider", - }) + export const Provider = z.object({ + api: z.string().optional(), + name: z.string(), + env: z.array(z.string()), + id: z.string(), + npm: z.string().optional(), + models: z.record(z.string(), Model), + }) export type Provider = z.infer diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 8341faf3d3..3a29655a02 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -23,7 +23,7 @@ import { createVertex } from "@ai-sdk/google-vertex" import { createVertexAnthropic } from "@ai-sdk/google-vertex/anthropic" import { createOpenAI } from "@ai-sdk/openai" import { createOpenAICompatible } from "@ai-sdk/openai-compatible" -import { createOpenRouter } from "@openrouter/ai-sdk-provider" +import { createOpenRouter, type LanguageModelV2 } from "@openrouter/ai-sdk-provider" import { createOpenaiCompatible as createGitHubCopilotOpenAICompatible } from "./sdk/openai-compatible/src" export namespace Provider { @@ -43,14 +43,13 @@ export namespace Provider { "@ai-sdk/github-copilot": createGitHubCopilotOpenAICompatible, } + type CustomModelLoader = (sdk: any, modelID: string, options?: Record) => Promise type CustomLoader = (provider: Info) => Promise<{ autoload: boolean - getModel?: (sdk: any, modelID: string, options?: Record) => Promise + getModel?: CustomModelLoader options?: Record }> - type Source = "env" | "config" | "custom" | "api" - const CUSTOM_LOADERS: Record = { async anthropic() { return { @@ -314,20 +313,20 @@ export namespace Provider { reasoning: z.boolean(), attachment: z.boolean(), toolcall: z.boolean(), - input: { + input: z.object({ text: z.boolean(), audio: z.boolean(), image: z.boolean(), video: z.boolean(), pdf: z.boolean(), - }, - output: { + }), + output: z.object({ text: z.boolean(), audio: z.boolean(), image: z.boolean(), video: z.boolean(), pdf: z.boolean(), - }, + }), }), cost: z.object({ input: z.number(), @@ -433,7 +432,7 @@ export namespace Provider { } } - function fromModelsDevProvider(provider: ModelsDev.Provider): Info { + export function fromModelsDevProvider(provider: ModelsDev.Provider): Info { return { id: provider.id, source: "custom", @@ -444,11 +443,6 @@ export namespace Provider { } } - export type ModelWithStuff = { - language: LanguageModel - info: Model - } - const state = Instance.state(async () => { using _ = log.time("state") const config = await Config.get() @@ -464,7 +458,10 @@ export namespace Provider { } const providers: { [providerID: string]: Info } = {} - const models = new Map() + const languages = new Map() + const modelLoaders: { + [providerID: string]: CustomModelLoader + } = {} const sdk = new Map() log.info("init") @@ -631,6 +628,7 @@ export namespace Provider { if (disabled.has(providerID)) continue const result = await fn(database[providerID]) if (result && (result.autoload || providers[providerID])) { + if (result.getModel) modelLoaders[providerID] = result.getModel mergeProvider(providerID, { source: "custom", options: result.options, @@ -645,7 +643,6 @@ export namespace Provider { env: provider.env, name: provider.name, options: provider.options, - // TODO: merge models }) } @@ -689,9 +686,10 @@ export namespace Provider { } return { - models, + models: languages, providers, sdk, + modelLoaders, } }) @@ -789,15 +787,7 @@ export namespace Provider { } export async function getModel(providerID: string, modelID: string) { - const key = `${providerID}/${modelID}` const s = await state() - if (s.models.has(key)) return s.models.get(key)! - - log.info("getModel", { - providerID, - modelID, - }) - const provider = s.providers[providerID] if (!provider) { const availableProviders = Object.keys(s.providers) @@ -813,38 +803,29 @@ export namespace Provider { const suggestions = matches.map((m) => m.target) throw new ModelNotFoundError({ providerID, modelID, suggestions }) } + return info + } - const sdk = await getSDK(info) + export async function getLanguage(model: Model) { + const s = await state() + const key = `${model.providerID}/${model.id}` + if (s.models.has(key)) return s.models.get(key)! + + const provider = s.providers[model.providerID] + const sdk = await getSDK(model) try { - const language = provider.getModel - ? await provider.getModel(sdk, info.api.id, provider.options) - : sdk.languageModel(info.api.id) - log.info("found", { providerID, modelID }) - const cached: ModelWithStuff = { - info, - language, - } - s.models.set(key, { - providerID, - modelID, - info, - language, - npm, - }) - return { - modelID, - providerID, - info, - language, - npm, - } + const language = s.modelLoaders[model.providerID] + ? await s.modelLoaders[model.providerID](sdk, model.api.id, provider.options) + : sdk.languageModel(model.api.id) + s.models.set(key, language) + return language } catch (e) { if (e instanceof NoSuchModelError) throw new ModelNotFoundError( { - modelID: modelID, - providerID, + modelID: model.id, + providerID: model.providerID, }, { cause: e }, ) @@ -857,7 +838,7 @@ export namespace Provider { const provider = s.providers[providerID] if (!provider) return undefined for (const item of query) { - for (const modelID of Object.keys(provider.info.models)) { + for (const modelID of Object.keys(provider.models)) { if (modelID.includes(item)) return { providerID, @@ -893,7 +874,7 @@ export namespace Provider { priority = ["gpt-5-nano"] } for (const item of priority) { - for (const model of Object.keys(provider.info.models)) { + for (const model of Object.keys(provider.models)) { if (model.includes(item)) return getModel(providerID, model) } } @@ -901,7 +882,7 @@ export namespace Provider { // Check if opencode provider is available before using it const opencodeProvider = await state().then((state) => state.providers["opencode"]) - if (opencodeProvider && opencodeProvider.info.models["gpt-5-nano"]) { + if (opencodeProvider && opencodeProvider.models["gpt-5-nano"]) { return getModel("opencode", "gpt-5-nano") } @@ -924,12 +905,12 @@ export namespace Provider { const provider = await list() .then((val) => Object.values(val)) - .then((x) => x.find((p) => !cfg.provider || Object.keys(cfg.provider).includes(p.info.id))) + .then((x) => x.find((p) => !cfg.provider || Object.keys(cfg.provider).includes(p.id))) if (!provider) throw new Error("no providers found") - const [model] = sort(Object.values(provider.info.models)) + const [model] = sort(Object.values(provider.models)) if (!model) throw new Error("no models found") return { - providerID: provider.info.id, + providerID: provider.id, modelID: model.id, } } diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index d3b30575ad..8afac3a65e 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -1,11 +1,11 @@ import type { APICallError, ModelMessage } from "ai" import { unique } from "remeda" import type { JSONSchema } from "zod/v4/core" -import type { ModelsDev } from "./models" +import type { Provider } from "./provider" export namespace ProviderTransform { - function normalizeMessages(msgs: ModelMessage[], providerID: string, model: ModelsDev.Model): ModelMessage[] { - if (model.target.includes("claude")) { + function normalizeMessages(msgs: ModelMessage[], model: Provider.Model): ModelMessage[] { + if (model.api.id.includes("claude")) { return msgs.map((msg) => { if ((msg.role === "assistant" || msg.role === "tool") && Array.isArray(msg.content)) { msg.content = msg.content.map((part) => { @@ -21,7 +21,7 @@ export namespace ProviderTransform { return msg }) } - if (providerID === "mistral" || model.target.toLowerCase().includes("mistral")) { + if (model.providerID === "mistral" || model.api.id.toLowerCase().includes("mistral")) { const result: ModelMessage[] = [] for (let i = 0; i < msgs.length; i++) { const msg = msgs[i] @@ -108,67 +108,68 @@ export namespace ProviderTransform { return msgs } - export function message(msgs: ModelMessage[], providerID: string, model: ModelsDev.Model) { - msgs = normalizeMessages(msgs, providerID, model) - if (providerID === "anthropic" || model.target.includes("anthropic") || model.target.includes("claude")) { - msgs = applyCaching(msgs, providerID) + export function message(msgs: ModelMessage[], model: Provider.Model) { + msgs = normalizeMessages(msgs, model) + if (model.providerID === "anthropic" || model.api.id.includes("anthropic") || model.api.id.includes("claude")) { + msgs = applyCaching(msgs, model.providerID) } return msgs } - export function temperature(model: ModelsDev.Model) { - if (model.target.toLowerCase().includes("qwen")) return 0.55 - if (model.target.toLowerCase().includes("claude")) return undefined - if (model.target.toLowerCase().includes("gemini-3-pro")) return 1.0 + export function temperature(model: Provider.Model) { + if (model.api.id.toLowerCase().includes("qwen")) return 0.55 + if (model.api.id.toLowerCase().includes("claude")) return undefined + if (model.api.id.toLowerCase().includes("gemini-3-pro")) return 1.0 return 0 } - export function topP(model: ModelsDev.Model) { - if (model.target.toLowerCase().includes("qwen")) return 1 + export function topP(model: Provider.Model) { + if (model.api.id.toLowerCase().includes("qwen")) return 1 return undefined } export function options( - providerID: string, - model: ModelsDev.Model, - npm: string, + model: Provider.Model, sessionID: string, providerOptions?: Record, ): Record { const result: Record = {} // switch to providerID later, for now use this - if (npm === "@openrouter/ai-sdk-provider") { + if (model.api.npm === "@openrouter/ai-sdk-provider") { result["usage"] = { include: true, } } - if (providerID === "openai" || providerOptions?.setCacheKey) { + if (model.providerID === "openai" || providerOptions?.setCacheKey) { result["promptCacheKey"] = sessionID } - if (providerID === "google" || (providerID.startsWith("opencode") && model.target.includes("gemini-3"))) { + if ( + model.providerID === "google" || + (model.providerID.startsWith("opencode") && model.api.id.includes("gemini-3")) + ) { result["thinkingConfig"] = { includeThoughts: true, } } - if (model.target.includes("gpt-5") && !model.target.includes("gpt-5-chat")) { - if (model.target.includes("codex")) { + if (model.providerID.includes("gpt-5") && !model.api.id.includes("gpt-5-chat")) { + if (model.providerID.includes("codex")) { result["store"] = false } - if (!model.target.includes("codex") && !model.target.includes("gpt-5-pro")) { + if (!model.api.id.includes("codex") && !model.api.id.includes("gpt-5-pro")) { result["reasoningEffort"] = "medium" } - if (model.target.endsWith("gpt-5.1") && providerID !== "azure") { + if (model.api.id.endsWith("gpt-5.1") && model.providerID !== "azure") { result["textVerbosity"] = "low" } - if (providerID.startsWith("opencode")) { + if (model.providerID.startsWith("opencode")) { result["promptCacheKey"] = sessionID result["include"] = ["reasoning.encrypted_content"] result["reasoningSummary"] = "auto" @@ -177,17 +178,17 @@ export namespace ProviderTransform { return result } - export function smallOptions(input: { providerID: string; model: ModelsDev.Model }) { + export function smallOptions(model: Provider.Model) { const options: Record = {} - if (input.providerID === "openai" || input.model.target.includes("gpt-5")) { - if (input.model.target.includes("5.1")) { + if (model.providerID === "openai" || model.api.id.includes("gpt-5")) { + if (model.api.id.includes("5.1")) { options["reasoningEffort"] = "low" } else { options["reasoningEffort"] = "minimal" } } - if (input.providerID === "google") { + if (model.providerID === "google") { options["thinkingConfig"] = { thinkingBudget: 0, } @@ -255,7 +256,7 @@ export namespace ProviderTransform { return standardLimit } - export function schema(providerID: string, model: ModelsDev.Model, schema: JSONSchema.BaseSchema) { + export function schema(model: Provider.Model, schema: JSONSchema.BaseSchema) { /* if (["openai", "azure"].includes(providerID)) { if (schema.type === "object" && schema.properties) { @@ -275,7 +276,7 @@ export namespace ProviderTransform { */ // Convert integer enums to string enums for Google/Gemini - if (providerID === "google" || model.target.includes("gemini")) { + if (model.providerID === "google" || model.api.id.includes("gemini")) { const sanitizeGemini = (obj: any): any => { if (obj === null || typeof obj !== "object") { return obj diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 4dfd3ac743..31d0822762 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -8,7 +8,7 @@ import { proxy } from "hono/proxy" import { Session } from "../session" import z from "zod" import { Provider } from "../provider/provider" -import { mapValues } from "remeda" +import { mapValues, pipe } from "remeda" import { NamedError } from "@opencode-ai/util/error" import { ModelsDev } from "../provider/models" import { Ripgrep } from "../file/ripgrep" @@ -1025,7 +1025,7 @@ export namespace Server { async (c) => { c.status(204) c.header("Content-Type", "application/json") - return stream(c, async (stream) => { + return stream(c, async () => { const sessionID = c.req.valid("param").id const body = c.req.valid("json") SessionPrompt.prompt({ ...body, sessionID }) @@ -1231,7 +1231,7 @@ export namespace Server { "application/json": { schema: resolver( z.object({ - providers: ModelsDev.Provider.array(), + providers: Provider.Info.array(), default: z.record(z.string(), z.string()), }), ), @@ -1242,7 +1242,7 @@ export namespace Server { }), async (c) => { using _ = log.time("providers") - const providers = await Provider.list().then((x) => mapValues(x, (item) => item.info)) + const providers = await Provider.list().then((x) => mapValues(x, (item) => item)) return c.json({ providers: Object.values(providers), default: mapValues(providers, (item) => Provider.sort(Object.values(item.models))[0].id), @@ -1272,7 +1272,10 @@ export namespace Server { }, }), async (c) => { - const providers = await ModelsDev.get() + const providers = pipe( + await ModelsDev.get(), + mapValues((x) => Provider.fromModelsDevProvider(x)), + ) const connected = await Provider.list().then((x) => Object.keys(x)) return c.json({ all: Object.values(providers), diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index d4c9eb99a3..b83adafbe3 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -7,7 +7,6 @@ import { MessageV2 } from "./message-v2" import { SystemPrompt } from "./system" import { Bus } from "../bus" import z from "zod" -import type { ModelsDev } from "../provider/models" import { SessionPrompt } from "./prompt" import { Flag } from "../flag/flag" import { Token } from "../util/token" @@ -29,7 +28,7 @@ export namespace SessionCompaction { ), } - export function isOverflow(input: { tokens: MessageV2.Assistant["tokens"]; model: ModelsDev.Model }) { + export function isOverflow(input: { tokens: MessageV2.Assistant["tokens"]; model: Provider.Model }) { if (Flag.OPENCODE_DISABLE_AUTOCOMPACT) return false const context = input.model.limit.context if (context === 0) return false @@ -98,6 +97,7 @@ export namespace SessionCompaction { auto: boolean }) { const model = await Provider.getModel(input.model.providerID, input.model.modelID) + const language = await Provider.getLanguage(model) const system = [...SystemPrompt.compaction(model.providerID)] const msg = (await Session.updateMessage({ id: Identifier.ascending("message"), @@ -126,8 +126,7 @@ export namespace SessionCompaction { const processor = SessionProcessor.create({ assistantMessage: msg, sessionID: input.sessionID, - providerID: input.model.providerID, - model: model.info, + model: model, abort: input.abort, }) const result = await processor.process({ @@ -139,17 +138,13 @@ export namespace SessionCompaction { // set to 0, we handle loop maxRetries: 0, providerOptions: ProviderTransform.providerOptions( - model.npm, + model.api.npm, model.providerID, - pipe( - {}, - mergeDeep(ProviderTransform.options(model.providerID, model.info, model.npm ?? "", input.sessionID)), - mergeDeep(model.info.options), - ), + pipe({}, mergeDeep(ProviderTransform.options(model, input.sessionID)), mergeDeep(model.options)), ), - headers: model.info.headers, + headers: model.headers, abortSignal: input.abort, - tools: model.info.tool_call ? {} : undefined, + tools: model.capabilities.toolcall ? {} : undefined, messages: [ ...system.map( (x): ModelMessage => ({ @@ -183,7 +178,7 @@ export namespace SessionCompaction { }, ], model: wrapLanguageModel({ - model: model.language, + model: language, middleware: [ { async transformParams(args) { diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index f09818caa2..1e69f7644e 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -6,7 +6,6 @@ import { Config } from "../config/config" import { Flag } from "../flag/flag" import { Identifier } from "../id/id" import { Installation } from "../installation" -import type { ModelsDev } from "../provider/models" import { Share } from "../share/share" import { Storage } from "../storage/storage" import { Log } from "../util/log" @@ -17,6 +16,7 @@ import { fn } from "@/util/fn" import { Command } from "../command" import { Snapshot } from "@/snapshot" import { ShareNext } from "@/share/share-next" +import { Provider } from "@/provider/provider" export namespace Session { const log = Log.create({ service: "session" }) @@ -389,7 +389,7 @@ export namespace Session { export const getUsage = fn( z.object({ - model: z.custom(), + model: Provider.Model, usage: z.custom(), metadata: z.custom().optional(), }), @@ -420,16 +420,16 @@ export namespace Session { } const costInfo = - input.model.cost?.context_over_200k && tokens.input + tokens.cache.read > 200_000 - ? input.model.cost.context_over_200k + input.model.cost?.experimentalOver200K && tokens.input + tokens.cache.read > 200_000 + ? input.model.cost.experimentalOver200K : input.model.cost return { cost: safe( new Decimal(0) .add(new Decimal(tokens.input).mul(costInfo?.input ?? 0).div(1_000_000)) .add(new Decimal(tokens.output).mul(costInfo?.output ?? 0).div(1_000_000)) - .add(new Decimal(tokens.cache.read).mul(costInfo?.cache_read ?? 0).div(1_000_000)) - .add(new Decimal(tokens.cache.write).mul(costInfo?.cache_write ?? 0).div(1_000_000)) + .add(new Decimal(tokens.cache.read).mul(costInfo?.cache.read ?? 0).div(1_000_000)) + .add(new Decimal(tokens.cache.write).mul(costInfo?.cache.write ?? 0).div(1_000_000)) // TODO: update models.dev to have better pricing model, for now: // charge reasoning tokens at the same rate as output tokens .add(new Decimal(tokens.reasoning).mul(costInfo?.output ?? 0).div(1_000_000)) diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 5823d6191c..21d50abe9d 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -1,4 +1,3 @@ -import type { ModelsDev } from "@/provider/models" import { MessageV2 } from "./message-v2" import { streamText } from "ai" import { Log } from "@/util/log" @@ -11,6 +10,7 @@ import { SessionSummary } from "./summary" import { Bus } from "@/bus" import { SessionRetry } from "./retry" import { SessionStatus } from "./status" +import type { Provider } from "@/provider/provider" export namespace SessionProcessor { const DOOM_LOOP_THRESHOLD = 3 @@ -31,8 +31,7 @@ export namespace SessionProcessor { export function create(input: { assistantMessage: MessageV2.Assistant sessionID: string - providerID: string - model: ModelsDev.Model + model: Provider.Model abort: AbortSignal }) { const toolcalls: Record = {} @@ -341,7 +340,7 @@ export namespace SessionProcessor { log.error("process", { error: e, }) - const error = MessageV2.fromError(e, { providerID: input.providerID }) + const error = MessageV2.fromError(e, { providerID: input.sessionID }) const retry = SessionRetry.retryable(error) if (retry !== undefined) { attempt++ diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 2433c582b4..17981e4993 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -47,7 +47,6 @@ import { fn } from "@/util/fn" import { SessionProcessor } from "./processor" import { TaskTool } from "@/tool/task" import { SessionStatus } from "./status" -import type { ModelsDev } from "@/provider/models" // @ts-ignore globalThis.AI_SDK_LOG_WARNINGS = false @@ -288,6 +287,7 @@ export namespace SessionPrompt { }) const model = await Provider.getModel(lastUser.model.providerID, lastUser.model.modelID) + const language = await Provider.getLanguage(model) const task = tasks.pop() // pending subtask @@ -311,7 +311,7 @@ export namespace SessionPrompt { reasoning: 0, cache: { read: 0, write: 0 }, }, - modelID: model.modelID, + modelID: model.id, providerID: model.providerID, time: { created: Date.now(), @@ -408,7 +408,7 @@ export namespace SessionPrompt { agent: lastUser.agent, model: { providerID: model.providerID, - modelID: model.modelID, + modelID: model.id, }, sessionID, auto: task.auto, @@ -421,7 +421,7 @@ export namespace SessionPrompt { if ( lastFinished && lastFinished.summary !== true && - SessionCompaction.isOverflow({ tokens: lastFinished.tokens, model: model.info }) + SessionCompaction.isOverflow({ tokens: lastFinished.tokens, model }) ) { await SessionCompaction.create({ sessionID, @@ -455,7 +455,7 @@ export namespace SessionPrompt { reasoning: 0, cache: { read: 0, write: 0 }, }, - modelID: model.modelID, + modelID: model.id, providerID: model.providerID, time: { created: Date.now(), @@ -463,21 +463,18 @@ export namespace SessionPrompt { sessionID, })) as MessageV2.Assistant, sessionID: sessionID, - model: model.info, - providerID: model.providerID, + model, abort, }) const system = await resolveSystemPrompt({ - providerID: model.providerID, - model: model.info, + model, agent, system: lastUser.system, }) const tools = await resolveTools({ agent, sessionID, - providerID: model.providerID, - model: model.info, + model, tools: lastUser.tools, processor, }) @@ -487,19 +484,19 @@ export namespace SessionPrompt { { sessionID: sessionID, agent: lastUser.agent, - model: model.info, + model: model, provider, message: lastUser, }, { - temperature: model.info.temperature - ? (agent.temperature ?? ProviderTransform.temperature(model.info)) + temperature: model.capabilities.temperature + ? (agent.temperature ?? ProviderTransform.temperature(model)) : undefined, - topP: agent.topP ?? ProviderTransform.topP(model.info), + topP: agent.topP ?? ProviderTransform.topP(model), options: pipe( {}, - mergeDeep(ProviderTransform.options(model.providerID, model.info, model.npm, sessionID, provider?.options)), - mergeDeep(model.info.options), + mergeDeep(ProviderTransform.options(model, sessionID, provider?.options)), + mergeDeep(model.options), mergeDeep(agent.options), ), }, @@ -547,19 +544,19 @@ export namespace SessionPrompt { "x-opencode-request": lastUser.id, } : undefined), - ...model.info.headers, + ...model.headers, }, // set to 0, we handle loop maxRetries: 0, activeTools: Object.keys(tools).filter((x) => x !== "invalid"), maxOutputTokens: ProviderTransform.maxOutputTokens( - model.providerID, + model.api.npm, params.options, - model.info.limit.output, + model.limit.output, OUTPUT_TOKEN_MAX, ), abortSignal: abort, - providerOptions: ProviderTransform.providerOptions(model.npm, model.providerID, params.options), + providerOptions: ProviderTransform.providerOptions(model.api.npm, model.providerID, params.options), stopWhen: stepCountIs(1), temperature: params.temperature, topP: params.topP, @@ -586,9 +583,9 @@ export namespace SessionPrompt { }), ), ], - tools: model.info.tool_call === false ? undefined : tools, + tools: model.capabilities.toolcall === false ? undefined : tools, model: wrapLanguageModel({ - model: model.language, + model: language, middleware: [ { async transformParams(args) { @@ -604,7 +601,7 @@ export namespace SessionPrompt { // Transform the inputSchema for provider compatibility return { ...tool, - inputSchema: ProviderTransform.schema(model.providerID, model.info, tool.inputSchema), + inputSchema: ProviderTransform.schema(model, tool.inputSchema), } } // If no inputSchema, return tool unchanged @@ -639,13 +636,8 @@ export namespace SessionPrompt { return Provider.defaultModel() } - async function resolveSystemPrompt(input: { - system?: string - agent: Agent.Info - providerID: string - model: ModelsDev.Model - }) { - let system = SystemPrompt.header(input.providerID) + async function resolveSystemPrompt(input: { system?: string; agent: Agent.Info; model: Provider.Model }) { + let system = SystemPrompt.header(input.model.providerID) system.push( ...(() => { if (input.system) return [input.system] @@ -663,8 +655,7 @@ export namespace SessionPrompt { async function resolveTools(input: { agent: Agent.Info - providerID: string - model: ModelsDev.Model + model: Provider.Model sessionID: string tools?: Record processor: SessionProcessor.Info @@ -675,9 +666,9 @@ export namespace SessionPrompt { mergeDeep(await ToolRegistry.enabled(input.agent)), mergeDeep(input.tools ?? {}), ) - for (const item of await ToolRegistry.tools(input.providerID)) { + for (const item of await ToolRegistry.tools(input.model.providerID)) { if (Wildcard.all(item.id, enabledTools) === false) continue - const schema = ProviderTransform.schema(input.providerID, input.model, z.toJSONSchema(item.parameters)) + const schema = ProviderTransform.schema(input.model, z.toJSONSchema(item.parameters)) tools[item.id] = tool({ id: item.id as any, description: item.description, @@ -1428,19 +1419,18 @@ export namespace SessionPrompt { if (!isFirst) return const small = (await Provider.getSmallModel(input.providerID)) ?? (await Provider.getModel(input.providerID, input.modelID)) + const language = await Provider.getLanguage(small) const provider = await Provider.getProvider(small.providerID) const options = pipe( {}, - mergeDeep( - ProviderTransform.options(small.providerID, small.info, small.npm ?? "", input.session.id, provider?.options), - ), - mergeDeep(ProviderTransform.smallOptions({ providerID: small.providerID, model: small.info })), - mergeDeep(small.info.options), + mergeDeep(ProviderTransform.options(small, input.session.id, provider?.options)), + mergeDeep(ProviderTransform.smallOptions(small)), + mergeDeep(small.options), ) await generateText({ // use higher # for reasoning models since reasoning tokens eat up a lot of the budget - maxOutputTokens: small.info.reasoning ? 3000 : 20, - providerOptions: ProviderTransform.providerOptions(small.npm, small.providerID, options), + maxOutputTokens: small.capabilities.reasoning ? 3000 : 20, + providerOptions: ProviderTransform.providerOptions(small.api.npm, small.providerID, options), messages: [ ...SystemPrompt.title(small.providerID).map( (x): ModelMessage => ({ @@ -1471,8 +1461,8 @@ export namespace SessionPrompt { }, ]), ], - headers: small.info.headers, - model: small.language, + headers: small.headers, + model: language, }) .then((result) => { if (result.text) @@ -1489,7 +1479,7 @@ export namespace SessionPrompt { }) }) .catch((error) => { - log.error("failed to generate title", { error, model: small.info.id }) + log.error("failed to generate title", { error, model: small.id }) }) } } diff --git a/packages/opencode/src/session/summary.ts b/packages/opencode/src/session/summary.ts index 9f56b084e0..8d366e4991 100644 --- a/packages/opencode/src/session/summary.ts +++ b/packages/opencode/src/session/summary.ts @@ -76,19 +76,20 @@ export namespace SessionSummary { const small = (await Provider.getSmallModel(assistantMsg.providerID)) ?? (await Provider.getModel(assistantMsg.providerID, assistantMsg.modelID)) + const language = await Provider.getLanguage(small) const options = pipe( {}, - mergeDeep(ProviderTransform.options(small.providerID, small.info, small.npm ?? "", assistantMsg.sessionID)), - mergeDeep(ProviderTransform.smallOptions({ providerID: small.providerID, model: small.info })), - mergeDeep(small.info.options), + mergeDeep(ProviderTransform.options(small, assistantMsg.sessionID)), + mergeDeep(ProviderTransform.smallOptions(small)), + mergeDeep(small.options), ) const textPart = msgWithParts.parts.find((p) => p.type === "text" && !p.synthetic) as MessageV2.TextPart if (textPart && !userMsg.summary?.title) { const result = await generateText({ - maxOutputTokens: small.info.reasoning ? 1500 : 20, - providerOptions: ProviderTransform.providerOptions(small.npm, small.providerID, options), + maxOutputTokens: small.capabilities.reasoning ? 1500 : 20, + providerOptions: ProviderTransform.providerOptions(small.api.npm, small.providerID, options), messages: [ ...SystemPrompt.title(small.providerID).map( (x): ModelMessage => ({ @@ -106,8 +107,8 @@ export namespace SessionSummary { `, }, ], - headers: small.info.headers, - model: small.language, + headers: small.headers, + model: language, }) log.info("title", { title: result.text }) userMsg.summary.title = result.text @@ -132,9 +133,9 @@ export namespace SessionSummary { } } const result = await generateText({ - model: small.language, + model: language, maxOutputTokens: 100, - providerOptions: ProviderTransform.providerOptions(small.npm, small.providerID, options), + providerOptions: ProviderTransform.providerOptions(small.api.npm, small.providerID, options), messages: [ ...SystemPrompt.summarize(small.providerID).map( (x): ModelMessage => ({ @@ -148,7 +149,7 @@ export namespace SessionSummary { content: `Summarize the above conversation according to your system prompts.`, }, ], - headers: small.info.headers, + headers: small.headers, }).catch(() => {}) if (result) summary = result.text } diff --git a/packages/opencode/src/session/system.ts b/packages/opencode/src/session/system.ts index 42b398948d..3146110cf3 100644 --- a/packages/opencode/src/session/system.ts +++ b/packages/opencode/src/session/system.ts @@ -17,7 +17,7 @@ import PROMPT_COMPACTION from "./prompt/compaction.txt" import PROMPT_SUMMARIZE from "./prompt/summarize.txt" import PROMPT_TITLE from "./prompt/title.txt" import PROMPT_CODEX from "./prompt/codex.txt" -import type { ModelsDev } from "@/provider/models" +import type { Provider } from "@/provider/provider" export namespace SystemPrompt { export function header(providerID: string) { @@ -25,13 +25,13 @@ export namespace SystemPrompt { return [] } - export function provider(model: ModelsDev.Model) { - if (model.target.includes("gpt-5")) return [PROMPT_CODEX] - if (model.target.includes("gpt-") || model.target.includes("o1") || model.target.includes("o3")) + export function provider(model: Provider.Model) { + if (model.api.id.includes("gpt-5")) return [PROMPT_CODEX] + if (model.api.id.includes("gpt-") || model.api.id.includes("o1") || model.api.id.includes("o3")) return [PROMPT_BEAST] - if (model.target.includes("gemini-")) return [PROMPT_GEMINI] - if (model.target.includes("claude")) return [PROMPT_ANTHROPIC] - if (model.target.includes("polaris-alpha")) return [PROMPT_POLARIS] + if (model.api.id.includes("gemini-")) return [PROMPT_GEMINI] + if (model.api.id.includes("claude")) return [PROMPT_ANTHROPIC] + if (model.api.id.includes("polaris-alpha")) return [PROMPT_POLARIS] return [PROMPT_ANTHROPIC_WITHOUT_TODO] } diff --git a/packages/opencode/src/share/share-next.ts b/packages/opencode/src/share/share-next.ts index 9543149a81..996400280d 100644 --- a/packages/opencode/src/share/share-next.ts +++ b/packages/opencode/src/share/share-next.ts @@ -1,7 +1,6 @@ import { Bus } from "@/bus" import { Config } from "@/config/config" import { ulid } from "ulid" -import type { ModelsDev } from "@/provider/models" import { Provider } from "@/provider/provider" import { Session } from "@/session" import { MessageV2 } from "@/session/message-v2" @@ -36,7 +35,7 @@ export namespace ShareNext { type: "model", data: [ await Provider.getModel(evt.properties.info.model.providerID, evt.properties.info.model.modelID).then( - (m) => m.info, + (m) => m, ), ], }, @@ -105,7 +104,7 @@ export namespace ShareNext { } | { type: "model" - data: ModelsDev.Model[] + data: SDK.Model[] } const queue = new Map }>() @@ -171,7 +170,7 @@ export namespace ShareNext { messages .filter((m) => m.info.role === "user") .map((m) => (m.info as SDK.UserMessage).model) - .map((m) => Provider.getModel(m.providerID, m.modelID).then((m) => m.info)), + .map((m) => Provider.getModel(m.providerID, m.modelID).then((m) => m)), ) await sync(sessionID, [ { diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts index cf7b20e8b3..7e01246b53 100644 --- a/packages/opencode/src/tool/read.ts +++ b/packages/opencode/src/tool/read.ts @@ -101,7 +101,7 @@ export const ReadTool = Tool.define("read", { const modelID = ctx.extra["modelID"] as string const model = await Provider.getModel(providerID, modelID).catch(() => undefined) if (!model) return false - return model.info.modalities?.input?.includes("image") ?? false + return model.capabilities.input.image })() if (isImage) { if (!supportsImages) { diff --git a/packages/sdk/js/src/gen/types.gen.ts b/packages/sdk/js/src/gen/types.gen.ts index fcf04444ed..88e3f0f35c 100644 --- a/packages/sdk/js/src/gen/types.gen.ts +++ b/packages/sdk/js/src/gen/types.gen.ts @@ -942,6 +942,76 @@ export type AgentConfig = { | undefined } +export type ProviderConfig = { + api?: string + name?: string + env?: Array + id?: string + npm?: string + models?: { + [key: string]: { + id?: string + name?: string + target?: string + release_date?: string + attachment?: boolean + reasoning?: boolean + temperature?: boolean + tool_call?: boolean + cost?: { + input: number + output: number + cache_read?: number + cache_write?: number + context_over_200k?: { + input: number + output: number + cache_read?: number + cache_write?: number + } + } + limit?: { + context: number + output: number + } + modalities?: { + input: Array<"text" | "audio" | "image" | "video" | "pdf"> + output: Array<"text" | "audio" | "image" | "video" | "pdf"> + } + experimental?: boolean + status?: "alpha" | "beta" | "deprecated" + options?: { + [key: string]: unknown + } + headers?: { + [key: string]: string + } + provider?: { + npm: string + } + } + } + whitelist?: Array + blacklist?: Array + options?: { + apiKey?: string + baseURL?: string + /** + * GitHub Enterprise URL for copilot authentication + */ + enterpriseUrl?: string + /** + * Enable promptCacheKey for this provider (default false) + */ + setCacheKey?: boolean + /** + * Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout. + */ + timeout?: number | false + [key: string]: unknown | string | boolean | (number | false) | undefined + } +} + export type McpLocalConfig = { /** * Type of MCP server connection @@ -1100,75 +1170,7 @@ export type Config = { * Custom provider configurations and model overrides */ provider?: { - [key: string]: { - api?: string - name?: string - env?: Array - id?: string - npm?: string - models?: { - [key: string]: { - id?: string - name?: string - target?: string - release_date?: string - attachment?: boolean - reasoning?: boolean - temperature?: boolean - tool_call?: boolean - cost?: { - input: number - output: number - cache_read?: number - cache_write?: number - context_over_200k?: { - input: number - output: number - cache_read?: number - cache_write?: number - } - } - limit?: { - context: number - output: number - } - modalities?: { - input: Array<"text" | "audio" | "image" | "video" | "pdf"> - output: Array<"text" | "audio" | "image" | "video" | "pdf"> - } - experimental?: boolean - status?: "alpha" | "beta" | "deprecated" - options?: { - [key: string]: unknown - } - headers?: { - [key: string]: string - } - provider?: { - npm: string - } - } - } - whitelist?: Array - blacklist?: Array - options?: { - apiKey?: string - baseURL?: string - /** - * GitHub Enterprise URL for copilot authentication - */ - enterpriseUrl?: string - /** - * Enable promptCacheKey for this provider (default false) - */ - setCacheKey?: boolean - /** - * Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout. - */ - timeout?: number | false - [key: string]: unknown | string | boolean | (number | false) | undefined - } - } + [key: string]: ProviderConfig } /** * MCP (Model Context Protocol) server configurations @@ -1355,52 +1357,71 @@ export type Command = { export type Model = { id: string + providerID: string + api: { + id: string + url: string + npm: string + } name: string - target: string - release_date: string - attachment: boolean - reasoning: boolean - temperature: boolean - tool_call: boolean + capabilities: { + temperature: boolean + reasoning: boolean + attachment: boolean + toolcall: boolean + input: { + text: boolean + audio: boolean + image: boolean + video: boolean + pdf: boolean + } + output: { + text: boolean + audio: boolean + image: boolean + video: boolean + pdf: boolean + } + } cost: { input: number output: number - cache_read?: number - cache_write?: number - context_over_200k?: { + cache: { + read: number + write: number + } + experimentalOver200K?: { input: number output: number - cache_read?: number - cache_write?: number + cache: { + read: number + write: number + } } } limit: { context: number output: number } - modalities?: { - input: Array<"text" | "audio" | "image" | "video" | "pdf"> - output: Array<"text" | "audio" | "image" | "video" | "pdf"> - } - experimental?: boolean - status?: "alpha" | "beta" | "deprecated" + status: "alpha" | "beta" | "deprecated" | "active" options: { [key: string]: unknown } - headers?: { + headers: { [key: string]: string } - provider?: { - npm: string - } } export type Provider = { - api?: string - name: string - env: Array id: string - npm?: string + name: string + source: "env" | "config" | "custom" | "api" + env: Array + key?: string + options: { + [key: string]: unknown + } models: { [key: string]: Model } @@ -2667,7 +2688,56 @@ export type ProviderListResponses = { * List of providers */ 200: { - all: Array + all: Array<{ + api?: string + name: string + env: Array + id: string + npm?: string + models: { + [key: string]: { + id: string + name: string + target: string + release_date: string + attachment: boolean + reasoning: boolean + temperature: boolean + tool_call: boolean + cost: { + input: number + output: number + cache_read?: number + cache_write?: number + context_over_200k?: { + input: number + output: number + cache_read?: number + cache_write?: number + } + } + limit: { + context: number + output: number + } + modalities?: { + input: Array<"text" | "audio" | "image" | "video" | "pdf"> + output: Array<"text" | "audio" | "image" | "video" | "pdf"> + } + experimental?: boolean + status?: "alpha" | "beta" | "deprecated" + options: { + [key: string]: unknown + } + headers?: { + [key: string]: string + } + provider?: { + npm: string + } + } + } + }> default: { [key: string]: string } From c3327fc0e4fac3b127119dd3b928ed82eb8b867f Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Wed, 3 Dec 2025 17:40:10 -0500 Subject: [PATCH 05/13] sync --- packages/opencode/src/provider/models.ts | 30 ++++++++++++---------- packages/opencode/src/provider/provider.ts | 10 ++++---- 2 files changed, 21 insertions(+), 19 deletions(-) diff --git a/packages/opencode/src/provider/models.ts b/packages/opencode/src/provider/models.ts index cedeed69a2..7a34af4f4a 100644 --- a/packages/opencode/src/provider/models.ts +++ b/packages/opencode/src/provider/models.ts @@ -18,20 +18,22 @@ export namespace ModelsDev { reasoning: z.boolean(), temperature: z.boolean(), tool_call: z.boolean(), - cost: z.object({ - input: z.number(), - output: z.number(), - cache_read: z.number().optional(), - cache_write: z.number().optional(), - context_over_200k: z - .object({ - input: z.number(), - output: z.number(), - cache_read: z.number().optional(), - cache_write: z.number().optional(), - }) - .optional(), - }), + cost: z + .object({ + input: z.number(), + output: z.number(), + cache_read: z.number().optional(), + cache_write: z.number().optional(), + context_over_200k: z + .object({ + input: z.number(), + output: z.number(), + cache_read: z.number().optional(), + cache_write: z.number().optional(), + }) + .optional(), + }) + .optional(), limit: z.object({ context: z.number(), output: z.number(), diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 3a29655a02..c83b3ac714 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -388,13 +388,13 @@ export namespace Provider { headers: model.headers ?? {}, options: model.options ?? {}, cost: { - input: model.cost.input, - output: model.cost.output, + input: model.cost?.input ?? 0, + output: model.cost?.output ?? 0, cache: { - read: model.cost.cache_read ?? 0, - write: model.cost.cache_write ?? 0, + read: model.cost?.cache_read ?? 0, + write: model.cost?.cache_write ?? 0, }, - experimentalOver200K: model.cost.context_over_200k + experimentalOver200K: model.cost?.context_over_200k ? { cache: { read: model.cost.context_over_200k.cache_read ?? 0, From c57216f22d6ae19dd96d4d08e8d72df5302de913 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Wed, 3 Dec 2025 18:43:36 -0500 Subject: [PATCH 06/13] sync --- packages/opencode/src/provider/provider.ts | 2 +- packages/opencode/src/session/index.ts | 12 ++- .../opencode/test/provider/provider.test.ts | 89 +++++++++---------- 3 files changed, 53 insertions(+), 50 deletions(-) diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index c83b3ac714..4dff85616c 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -2,7 +2,7 @@ import z from "zod" import fuzzysort from "fuzzysort" import { Config } from "../config/config" import { mapValues, mergeDeep, sortBy } from "remeda" -import { NoSuchModelError, type LanguageModel, type Provider as SDK } from "ai" +import { NoSuchModelError, type Provider as SDK } from "ai" import { Log } from "../util/log" import { BunProc } from "../bun" import { Plugin } from "../plugin" diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index 1e69f7644e..6a148e973a 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -6,7 +6,7 @@ import { Config } from "../config/config" import { Flag } from "../flag/flag" import { Identifier } from "../id/id" import { Installation } from "../installation" -import { Share } from "../share/share" + import { Storage } from "../storage/storage" import { Log } from "../util/log" import { MessageV2 } from "./message-v2" @@ -15,8 +15,8 @@ import { SessionPrompt } from "./prompt" import { fn } from "@/util/fn" import { Command } from "../command" import { Snapshot } from "@/snapshot" -import { ShareNext } from "@/share/share-next" -import { Provider } from "@/provider/provider" + +import type { Provider } from "@/provider/provider" export namespace Session { const log = Log.create({ service: "session" }) @@ -223,6 +223,7 @@ export namespace Session { } if (cfg.enterprise?.url) { + const { ShareNext } = await import("@/share/share-next") const share = await ShareNext.create(id) await update(id, (draft) => { draft.share = { @@ -233,6 +234,7 @@ export namespace Session { const session = await get(id) if (session.share) return session.share + const { Share } = await import("../share/share") const share = await Share.create(id) await update(id, (draft) => { draft.share = { @@ -253,6 +255,7 @@ export namespace Session { export const unshare = fn(Identifier.schema("session"), async (id) => { const cfg = await Config.get() if (cfg.enterprise?.url) { + const { ShareNext } = await import("@/share/share-next") await ShareNext.remove(id) await update(id, (draft) => { draft.share = undefined @@ -264,6 +267,7 @@ export namespace Session { await update(id, (draft) => { draft.share = undefined }) + const { Share } = await import("../share/share") await Share.remove(id, share.secret) }) @@ -389,7 +393,7 @@ export namespace Session { export const getUsage = fn( z.object({ - model: Provider.Model, + model: z.custom(), usage: z.custom(), metadata: z.custom().optional(), }), diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index fa31d9d4f1..103ad7f257 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -132,7 +132,7 @@ test("model whitelist filters models for provider", async () => { fn: async () => { const providers = await Provider.list() expect(providers["anthropic"]).toBeDefined() - const models = Object.keys(providers["anthropic"].info.models) + const models = Object.keys(providers["anthropic"].models) expect(models).toContain("claude-sonnet-4-20250514") expect(models.length).toBe(1) }, @@ -163,7 +163,7 @@ test("model blacklist excludes specific models", async () => { fn: async () => { const providers = await Provider.list() expect(providers["anthropic"]).toBeDefined() - const models = Object.keys(providers["anthropic"].info.models) + const models = Object.keys(providers["anthropic"].models) expect(models).not.toContain("claude-sonnet-4-20250514") }, }) @@ -198,8 +198,8 @@ test("custom model alias via config", async () => { fn: async () => { const providers = await Provider.list() expect(providers["anthropic"]).toBeDefined() - expect(providers["anthropic"].info.models["my-alias"]).toBeDefined() - expect(providers["anthropic"].info.models["my-alias"].name).toBe("My Custom Alias") + expect(providers["anthropic"].models["my-alias"]).toBeDefined() + expect(providers["anthropic"].models["my-alias"].name).toBe("My Custom Alias") }, }) }) @@ -241,8 +241,8 @@ test("custom provider with npm package", async () => { fn: async () => { const providers = await Provider.list() expect(providers["custom-provider"]).toBeDefined() - expect(providers["custom-provider"].info.name).toBe("Custom Provider") - expect(providers["custom-provider"].info.models["custom-model"]).toBeDefined() + expect(providers["custom-provider"].name).toBe("Custom Provider") + expect(providers["custom-provider"].models["custom-model"]).toBeDefined() }, }) }) @@ -299,8 +299,9 @@ test("getModel returns model for valid provider/model", async () => { const model = await Provider.getModel("anthropic", "claude-sonnet-4-20250514") expect(model).toBeDefined() expect(model.providerID).toBe("anthropic") - expect(model.modelID).toBe("claude-sonnet-4-20250514") - expect(model.language).toBeDefined() + expect(model.id).toBe("claude-sonnet-4-20250514") + const language = await Provider.getLanguage(model) + expect(language).toBeDefined() }, }) }) @@ -478,11 +479,11 @@ test("model cost defaults to zero when not specified", async () => { directory: tmp.path, fn: async () => { const providers = await Provider.list() - const model = providers["test-provider"].info.models["test-model"] + const model = providers["test-provider"].models["test-model"] expect(model.cost.input).toBe(0) expect(model.cost.output).toBe(0) - expect(model.cost.cache_read).toBe(0) - expect(model.cost.cache_write).toBe(0) + expect(model.cost.cache.read).toBe(0) + expect(model.cost.cache.write).toBe(0) }, }) }) @@ -516,7 +517,7 @@ test("model options are merged from existing model", async () => { }, fn: async () => { const providers = await Provider.list() - const model = providers["anthropic"].info.models["claude-sonnet-4-20250514"] + const model = providers["anthropic"].models["claude-sonnet-4-20250514"] expect(model.options.customOption).toBe("custom-value") }, }) @@ -623,12 +624,12 @@ test("getModel uses realIdByKey for aliased models", async () => { }, fn: async () => { const providers = await Provider.list() - expect(providers["anthropic"].info.models["my-sonnet"]).toBeDefined() + expect(providers["anthropic"].models["my-sonnet"]).toBeDefined() const model = await Provider.getModel("anthropic", "my-sonnet") expect(model).toBeDefined() - expect(model.modelID).toBe("my-sonnet") - expect(model.info.name).toBe("My Sonnet Alias") + expect(model.id).toBe("my-sonnet") + expect(model.name).toBe("My Sonnet Alias") }, }) }) @@ -737,10 +738,10 @@ test("model inherits properties from existing database model", async () => { }, fn: async () => { const providers = await Provider.list() - const model = providers["anthropic"].info.models["claude-sonnet-4-20250514"] + const model = providers["anthropic"].models["claude-sonnet-4-20250514"] expect(model.name).toBe("Custom Name for Sonnet") - expect(model.tool_call).toBe(true) - expect(model.attachment).toBe(true) + expect(model.capabilities.toolcall).toBe(true) + expect(model.capabilities.attachment).toBe(true) expect(model.limit.context).toBeGreaterThan(0) }, }) @@ -820,7 +821,7 @@ test("whitelist and blacklist can be combined", async () => { fn: async () => { const providers = await Provider.list() expect(providers["anthropic"]).toBeDefined() - const models = Object.keys(providers["anthropic"].info.models) + const models = Object.keys(providers["anthropic"].models) expect(models).toContain("claude-sonnet-4-20250514") expect(models).not.toContain("claude-opus-4-20250514") expect(models.length).toBe(1) @@ -858,11 +859,9 @@ test("model modalities default correctly", async () => { directory: tmp.path, fn: async () => { const providers = await Provider.list() - const model = providers["test-provider"].info.models["test-model"] - expect(model.modalities).toEqual({ - input: ["text"], - output: ["text"], - }) + const model = providers["test-provider"].models["test-model"] + expect(model.capabilities.input.text).toBe(true) + expect(model.capabilities.output.text).toBe(true) }, }) }) @@ -903,11 +902,11 @@ test("model with custom cost values", async () => { directory: tmp.path, fn: async () => { const providers = await Provider.list() - const model = providers["test-provider"].info.models["test-model"] + const model = providers["test-provider"].models["test-model"] expect(model.cost.input).toBe(5) expect(model.cost.output).toBe(15) - expect(model.cost.cache_read).toBe(2.5) - expect(model.cost.cache_write).toBe(7.5) + expect(model.cost.cache.read).toBe(2.5) + expect(model.cost.cache.write).toBe(7.5) }, }) }) @@ -931,7 +930,7 @@ test("getSmallModel returns appropriate small model", async () => { fn: async () => { const model = await Provider.getSmallModel("anthropic") expect(model).toBeDefined() - expect(model?.modelID).toContain("haiku") + expect(model?.id).toContain("haiku") }, }) }) @@ -957,7 +956,7 @@ test("getSmallModel respects config small_model override", async () => { const model = await Provider.getSmallModel("anthropic") expect(model).toBeDefined() expect(model?.providerID).toBe("anthropic") - expect(model?.modelID).toBe("claude-sonnet-4-20250514") + expect(model?.id).toBe("claude-sonnet-4-20250514") }, }) }) @@ -1046,7 +1045,7 @@ test("provider with custom npm package", async () => { fn: async () => { const providers = await Provider.list() expect(providers["local-llm"]).toBeDefined() - expect(providers["local-llm"].info.npm).toBe("@ai-sdk/openai-compatible") + expect(providers["local-llm"].models["llama-3"].api.npm).toBe("@ai-sdk/openai-compatible") expect(providers["local-llm"].options.baseURL).toBe("http://localhost:11434/v1") }, }) @@ -1082,7 +1081,7 @@ test("model alias name defaults to alias key when id differs", async () => { }, fn: async () => { const providers = await Provider.list() - expect(providers["anthropic"].info.models["sonnet"].name).toBe("sonnet") + expect(providers["anthropic"].models["sonnet"].name).toBe("sonnet") }, }) }) @@ -1201,7 +1200,7 @@ test("model cost overrides existing cost values", async () => { }, fn: async () => { const providers = await Provider.list() - const model = providers["anthropic"].info.models["claude-sonnet-4-20250514"] + const model = providers["anthropic"].models["claude-sonnet-4-20250514"] expect(model.cost.input).toBe(999) expect(model.cost.output).toBe(888) }, @@ -1249,11 +1248,11 @@ test("completely new provider not in database can be configured", async () => { fn: async () => { const providers = await Provider.list() expect(providers["brand-new-provider"]).toBeDefined() - expect(providers["brand-new-provider"].info.name).toBe("Brand New") - const model = providers["brand-new-provider"].info.models["new-model"] - expect(model.reasoning).toBe(true) - expect(model.attachment).toBe(true) - expect(model.modalities?.input).toContain("image") + expect(providers["brand-new-provider"].name).toBe("Brand New") + const model = providers["brand-new-provider"].models["new-model"] + expect(model.capabilities.reasoning).toBe(true) + expect(model.capabilities.attachment).toBe(true) + expect(model.capabilities.input.image).toBe(true) }, }) }) @@ -1322,7 +1321,7 @@ test("model with tool_call false", async () => { directory: tmp.path, fn: async () => { const providers = await Provider.list() - expect(providers["no-tools"].info.models["basic-model"].tool_call).toBe(false) + expect(providers["no-tools"].models["basic-model"].capabilities.toolcall).toBe(false) }, }) }) @@ -1357,7 +1356,7 @@ test("model defaults tool_call to true when not specified", async () => { directory: tmp.path, fn: async () => { const providers = await Provider.list() - expect(providers["default-tools"].info.models["model"].tool_call).toBe(true) + expect(providers["default-tools"].models["model"].capabilities.toolcall).toBe(true) }, }) }) @@ -1396,7 +1395,7 @@ test("model headers are preserved", async () => { directory: tmp.path, fn: async () => { const providers = await Provider.list() - const model = providers["headers-provider"].info.models["model"] + const model = providers["headers-provider"].models["model"] expect(model.headers).toEqual({ "X-Custom-Header": "custom-value", Authorization: "Bearer special-token", @@ -1465,8 +1464,8 @@ test("getModel returns consistent results", async () => { const model1 = await Provider.getModel("anthropic", "claude-sonnet-4-20250514") const model2 = await Provider.getModel("anthropic", "claude-sonnet-4-20250514") expect(model1.providerID).toEqual(model2.providerID) - expect(model1.modelID).toEqual(model2.modelID) - expect(model1.info).toEqual(model2.info) + expect(model1.id).toEqual(model2.id) + expect(model1).toEqual(model2) }, }) }) @@ -1501,7 +1500,7 @@ test("provider name defaults to id when not in database", async () => { directory: tmp.path, fn: async () => { const providers = await Provider.list() - expect(providers["my-custom-id"].info.name).toBe("my-custom-id") + expect(providers["my-custom-id"].name).toBe("my-custom-id") }, }) }) @@ -1601,7 +1600,7 @@ test("getProvider returns provider info", async () => { fn: async () => { const provider = await Provider.getProvider("anthropic") expect(provider).toBeDefined() - expect(provider?.info.id).toBe("anthropic") + expect(provider?.id).toBe("anthropic") }, }) }) @@ -1684,7 +1683,7 @@ test("model limit defaults to zero when not specified", async () => { directory: tmp.path, fn: async () => { const providers = await Provider.list() - const model = providers["no-limit"].info.models["model"] + const model = providers["no-limit"].models["model"] expect(model.limit.context).toBe(0) expect(model.limit.output).toBe(0) }, From bbbffbf9285e17a18021035f212d33ba5cacf467 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Wed, 3 Dec 2025 19:00:22 -0500 Subject: [PATCH 07/13] sync --- packages/opencode/src/config/config.ts | 10 +---- packages/opencode/src/provider/provider.ts | 43 +++++++++++-------- .../opencode/test/provider/provider.test.ts | 13 +++--- 3 files changed, 32 insertions(+), 34 deletions(-) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 1f355b5036..2c691cedb5 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -474,15 +474,7 @@ export namespace Config { .extend({ whitelist: z.array(z.string()).optional(), blacklist: z.array(z.string()).optional(), - models: z - .record( - z.string(), - ModelsDev.Model.partial().refine( - (input) => input.id === undefined, - "The model.id field can no longer be specified. Use model.target to specify an alternate model id to use when calling the provider.", - ), - ) - .optional(), + models: z.record(z.string(), ModelsDev.Model.partial()).optional(), options: z .object({ apiKey: z.string().optional(), diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 4dff85616c..871fc94d2a 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -481,8 +481,14 @@ export namespace Provider { function mergeProvider(providerID: string, provider: Partial) { const match = database[providerID] if (!match) return - // @ts-expect-error - providers[providerID] = mergeDeep(match, provider) + const existing = providers[providerID] + if (existing) { + // @ts-expect-error + providers[providerID] = mergeDeep(existing, provider) + } else { + // @ts-expect-error + providers[providerID] = mergeDeep(match, provider) + } } // extend database from config @@ -494,7 +500,7 @@ export namespace Provider { env: provider.env ?? existing?.env ?? [], options: mergeDeep(existing?.options ?? {}, provider.options ?? {}), source: "config", - models: {}, + models: existing?.models ?? {}, } for (const [modelID, model] of Object.entries(provider.models ?? {})) { @@ -520,18 +526,18 @@ export namespace Provider { attachment: model.attachment ?? existing?.capabilities.attachment ?? false, toolcall: model.tool_call ?? existing?.capabilities.toolcall ?? true, input: { - text: model.modalities?.input?.includes("text") ?? false, - audio: model.modalities?.input?.includes("audio") ?? false, - image: model.modalities?.input?.includes("image") ?? false, - video: model.modalities?.input?.includes("video") ?? false, - pdf: model.modalities?.input?.includes("pdf") ?? false, + text: model.modalities?.input?.includes("text") ?? existing?.capabilities.input.text ?? true, + audio: model.modalities?.input?.includes("audio") ?? existing?.capabilities.input.audio ?? false, + image: model.modalities?.input?.includes("image") ?? existing?.capabilities.input.image ?? false, + video: model.modalities?.input?.includes("video") ?? existing?.capabilities.input.video ?? false, + pdf: model.modalities?.input?.includes("pdf") ?? existing?.capabilities.input.pdf ?? false, }, output: { - text: model.modalities?.output?.includes("text") ?? false, - audio: model.modalities?.output?.includes("audio") ?? false, - image: model.modalities?.output?.includes("image") ?? false, - video: model.modalities?.output?.includes("video") ?? false, - pdf: model.modalities?.output?.includes("pdf") ?? false, + text: model.modalities?.output?.includes("text") ?? existing?.capabilities.output.text ?? true, + audio: model.modalities?.output?.includes("audio") ?? existing?.capabilities.output.audio ?? false, + image: model.modalities?.output?.includes("image") ?? existing?.capabilities.output.image ?? false, + video: model.modalities?.output?.includes("video") ?? existing?.capabilities.output.video ?? false, + pdf: model.modalities?.output?.includes("pdf") ?? existing?.capabilities.output.pdf ?? false, }, }, cost: { @@ -638,12 +644,11 @@ export namespace Provider { // load config for (const [providerID, provider] of configProviders) { - mergeProvider(providerID, { - source: "config", - env: provider.env, - name: provider.name, - options: provider.options, - }) + const partial: Partial = { source: "config" } + if (provider.env) partial.env = provider.env + if (provider.name) partial.name = provider.name + if (provider.options) partial.options = provider.options + mergeProvider(providerID, partial) } for (const [providerID, provider] of Object.entries(providers)) { diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index 103ad7f257..698fdddfb4 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -634,7 +634,7 @@ test("getModel uses realIdByKey for aliased models", async () => { }) }) -test("provider api field sets default baseURL", async () => { +test("provider api field sets model api.url", async () => { await using tmp = await tmpdir({ init: async (dir) => { await Bun.write( @@ -667,7 +667,8 @@ test("provider api field sets default baseURL", async () => { directory: tmp.path, fn: async () => { const providers = await Provider.list() - expect(providers["custom-api"].options.baseURL).toBe("https://api.example.com/v1") + // api field is stored on model.api.url, used by getSDK to set baseURL + expect(providers["custom-api"].models["model-1"].api.url).toBe("https://api.example.com/v1") }, }) }) @@ -1122,8 +1123,8 @@ test("provider with multiple env var options only includes apiKey when single en fn: async () => { const providers = await Provider.list() expect(providers["multi-env"]).toBeDefined() - // When multiple env options exist, apiKey should NOT be auto-set - expect(providers["multi-env"].options.apiKey).toBeUndefined() + // When multiple env options exist, key should NOT be auto-set + expect(providers["multi-env"].key).toBeUndefined() }, }) }) @@ -1164,8 +1165,8 @@ test("provider with single env var includes apiKey automatically", async () => { fn: async () => { const providers = await Provider.list() expect(providers["single-env"]).toBeDefined() - // Single env option should auto-set apiKey - expect(providers["single-env"].options.apiKey).toBe("my-api-key") + // Single env option should auto-set key + expect(providers["single-env"].key).toBe("my-api-key") }, }) }) From 19c3b25bea3b32c028063200fc9f5053c5cfbe9d Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Wed, 3 Dec 2025 19:03:14 -0500 Subject: [PATCH 08/13] sync --- packages/opencode/src/session/processor.ts | 3 ++- packages/opencode/src/session/prompt.ts | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 21d50abe9d..8b4faf0265 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -336,9 +336,10 @@ export namespace SessionProcessor { continue } } - } catch (e) { + } catch (e: any) { log.error("process", { error: e, + stack: JSON.stringify(e.stack), }) const error = MessageV2.fromError(e, { providerID: input.sessionID }) const retry = SessionRetry.retryable(error) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 17981e4993..d82cbd718c 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -590,8 +590,8 @@ export namespace SessionPrompt { { async transformParams(args) { if (args.type === "stream") { - // @ts-expect-error - args.params.prompt = ProviderTransform.message(args.params.prompt, model.providerID, model.info) + // @ts-expect-error - prompt types are compatible at runtime + args.params.prompt = ProviderTransform.message(args.params.prompt, model) } // Transform tool schemas for provider compatibility if (args.params.tools && Array.isArray(args.params.tools)) { From 9c898cd958b63d8986dde19a7031e4aa7cab1f2b Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Wed, 3 Dec 2025 19:15:44 -0500 Subject: [PATCH 09/13] core: fix provider options being overwritten when configured from multiple sources --- packages/opencode/src/provider/provider.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 871fc94d2a..2df4bc96b9 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -479,16 +479,16 @@ export namespace Provider { } function mergeProvider(providerID: string, provider: Partial) { - const match = database[providerID] - if (!match) return const existing = providers[providerID] if (existing) { // @ts-expect-error providers[providerID] = mergeDeep(existing, provider) - } else { - // @ts-expect-error - providers[providerID] = mergeDeep(match, provider) + return } + const match = database[providerID] + if (!match) return + // @ts-expect-error + providers[providerID] = mergeDeep(match, provider) } // extend database from config From 42aadadf7afd11941d2490fccf409ee27ad851fd Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Wed, 3 Dec 2025 19:31:02 -0500 Subject: [PATCH 10/13] regen sdk --- packages/sdk/js/src/gen/types.gen.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/sdk/js/src/gen/types.gen.ts b/packages/sdk/js/src/gen/types.gen.ts index 88e3f0f35c..f11d3672e6 100644 --- a/packages/sdk/js/src/gen/types.gen.ts +++ b/packages/sdk/js/src/gen/types.gen.ts @@ -2704,7 +2704,7 @@ export type ProviderListResponses = { reasoning: boolean temperature: boolean tool_call: boolean - cost: { + cost?: { input: number output: number cache_read?: number From 425ec87b7f93b95b40cb47b7d8ff9fde64099fb2 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Thu, 4 Dec 2025 00:54:25 +0000 Subject: [PATCH 11/13] Fixed `target` field usage in model API IDs Co-authored-by: thdxr --- packages/opencode/src/provider/provider.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 2df4bc96b9..0098f9f36a 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -380,7 +380,7 @@ export namespace Provider { providerID: provider.id, name: model.name, api: { - id: model.id, + id: model.target ?? model.id, url: provider.api!, npm: model.provider?.npm ?? provider.npm ?? provider.id, }, @@ -513,7 +513,7 @@ export namespace Provider { const parsedModel: Model = { id: modelID, api: { - id: model.id ?? existing?.api.id ?? modelID, + id: model.target ?? model.id ?? existing?.api.id ?? modelID, npm: model.provider?.npm ?? provider.npm ?? existing?.api.npm ?? providerID, url: provider?.api ?? existing?.api.url, }, From c999c3d9e58e49e2cfa975e272251583f5926eeb Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Wed, 3 Dec 2025 21:04:58 -0500 Subject: [PATCH 12/13] dumb --- packages/opencode/src/provider/models.ts | 1 - packages/opencode/src/provider/provider.ts | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/provider/models.ts b/packages/opencode/src/provider/models.ts index 7a34af4f4a..3d28787c88 100644 --- a/packages/opencode/src/provider/models.ts +++ b/packages/opencode/src/provider/models.ts @@ -12,7 +12,6 @@ export namespace ModelsDev { export const Model = z.object({ id: z.string(), name: z.string(), - target: z.string(), release_date: z.string(), attachment: z.boolean(), reasoning: z.boolean(), diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 0098f9f36a..2df4bc96b9 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -380,7 +380,7 @@ export namespace Provider { providerID: provider.id, name: model.name, api: { - id: model.target ?? model.id, + id: model.id, url: provider.api!, npm: model.provider?.npm ?? provider.npm ?? provider.id, }, @@ -513,7 +513,7 @@ export namespace Provider { const parsedModel: Model = { id: modelID, api: { - id: model.target ?? model.id ?? existing?.api.id ?? modelID, + id: model.id ?? existing?.api.id ?? modelID, npm: model.provider?.npm ?? provider.npm ?? existing?.api.npm ?? providerID, url: provider?.api ?? existing?.api.url, }, From 3bedf95e120982033322f66b688ea4cadb3e1005 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Wed, 3 Dec 2025 21:07:18 -0500 Subject: [PATCH 13/13] sync --- packages/sdk/js/src/gen/types.gen.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/sdk/js/src/gen/types.gen.ts b/packages/sdk/js/src/gen/types.gen.ts index f11d3672e6..6c80f0b7c5 100644 --- a/packages/sdk/js/src/gen/types.gen.ts +++ b/packages/sdk/js/src/gen/types.gen.ts @@ -952,7 +952,6 @@ export type ProviderConfig = { [key: string]: { id?: string name?: string - target?: string release_date?: string attachment?: boolean reasoning?: boolean @@ -2698,7 +2697,6 @@ export type ProviderListResponses = { [key: string]: { id: string name: string - target: string release_date: string attachment: boolean reasoning: boolean