diff --git a/README.md b/README.md index 2785112..47d68c7 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ During authentication, you will need a [CoreInfra AI Hub](https://hub.coreinfra. ## Limitations At the moment, OpenCode supports prices in USD only. -Because of that, pricing is not overridden yet: OpenCode shows prices from the original OpenAI/Anthropic API. +The plugin therefore keeps the model provider's API prices. ## Usage @@ -50,6 +50,8 @@ After installation and authentication, models will be available with the `corein ```bash opencode run -m coreinfra/gpt-5.4-nano opencode run -m coreinfra/claude-sonnet-4-20250514 +opencode run -m coreinfra/gemini-3.8-flash +opencode run -m coreinfra/grok-4.7 ``` To see the full list of available models: diff --git a/README.ru.md b/README.ru.md index 4877ebc..cc85dea 100644 --- a/README.ru.md +++ b/README.ru.md @@ -41,7 +41,7 @@ opencode providers login --provider coreinfra ## Ограничения На данный момент OpenCode поддерживает только цены в долларах. -Поэтому стоимость пока не переопределяется: OpenCode показывает цены из исходного API OpenAI/Anthropic. +Поэтому OpenCode показывает цены из API провайдера модели. ## Использование @@ -50,6 +50,8 @@ opencode providers login --provider coreinfra ```bash opencode run -m coreinfra/gpt-5.4-nano opencode run -m coreinfra/claude-sonnet-4-20250514 +opencode run -m coreinfra/gemini-3.8-flash +opencode run -m coreinfra/grok-4.7 ``` Посмотреть список всех доступных моделей: diff --git a/src/models.ts b/src/models.ts index 1e397e6..af33a47 100644 --- a/src/models.ts +++ b/src/models.ts @@ -22,6 +22,7 @@ const DEFAULT_HUB_BASE = "https://hub.coreinfra.ai"; const HUB_URL = `${process.env.COREINFRA_HUB_BASE_URL ?? DEFAULT_HUB_BASE}/hub/api/prices`; const OPENAI_BASE = `${process.env.COREINFRA_HUB_BASE_URL ?? DEFAULT_HUB_BASE}/openai/api/v1`; const ANTHROPIC_BASE = `${process.env.COREINFRA_HUB_BASE_URL ?? DEFAULT_HUB_BASE}/anthropic/api/v1`; +const GOOGLE_BASE = `${process.env.COREINFRA_HUB_BASE_URL ?? DEFAULT_HUB_BASE}/google/api/v1beta`; const ANTHROPIC_BETA_HEADER = "interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14"; @@ -42,24 +43,65 @@ const DEFAULT_COST = { type Protocol = "openai" | "anthropic"; // Providers we explicitly know how to route. Models from any provider not -// listed here are skipped. Adding a provider is a one-line change here. The -// `npm` package selects the wire API: `@ai-sdk/openai` targets OpenAI's -// Responses API (`/responses`), while `@ai-sdk/openai-compatible` targets the -// Chat Completions API (`/chat/completions`). +// listed here are skipped. Adding a provider is one entry here. `protocol` +// picks the Hub endpoint and headers. `v1` is the AI SDK package OpenCode 1 +// loads; it builds our models with the package's languageModel(). `v2` is +// OpenCode 2's built-in package, named explicitly because the provider +// editor's add() does not translate AI SDK package names. `v2Api` overrides +// the endpoint for OpenCode 2. `openai` packages target the Responses API +// (`/responses`), and `openai-compatible` ones the Chat Completions API +// (`/chat/completions`). const PROVIDERS = new Map< string, - { protocol: Protocol; npm: string; temperature?: false } + { + protocol: Protocol; + v1: string; + v2: string; + v2Api?: string; + efforts?: true; + temperature?: false; + } >([ - ["openai", { protocol: "openai", npm: "@ai-sdk/openai" }], - ["anthropic", { protocol: "anthropic", npm: "@ai-sdk/anthropic" }], + [ + "openai", + { + protocol: "openai", + v1: "@ai-sdk/openai", + v2: "@opencode/ai/providers/openai", + }, + ], + [ + "anthropic", + { + protocol: "anthropic", + v1: "@ai-sdk/anthropic", + v2: "@opencode/ai/providers/anthropic", + }, + ], // DeepSeek must use Chat Completions, not the Anthropic protocol: the // Anthropic message format drops reasoning blocks that carry no signature, // and DeepSeek never issues one. A reasoning-only assistant turn then lowers // to `content: []` and the API rejects the whole request with "all messages // must have non-empty content", while `reasoning_content` never survives the // round-trip that DeepSeek's thinking mode requires. - ["deepseek", { protocol: "openai", npm: "@ai-sdk/openai-compatible" }], - ["zai", { protocol: "openai", npm: "@ai-sdk/openai-compatible" }], + [ + "deepseek", + { + protocol: "openai", + v1: "@ai-sdk/openai-compatible", + v2: "@opencode/ai/providers/openai-compatible", + efforts: true, + }, + ], + [ + "zai", + { + protocol: "openai", + v1: "@ai-sdk/openai-compatible", + v2: "@opencode/ai/providers/openai-compatible", + efforts: true, + }, + ], // Moonshot rejects any temperature other than 1 ("only 1 is allowed for this // model"), yet models.dev marks some Kimi models as tunable. OpenCode's title // agent then sends its own temperature and title generation fails, so never @@ -68,10 +110,38 @@ const PROVIDERS = new Map< "moonshotai", { protocol: "openai", - npm: "@ai-sdk/openai-compatible", + v1: "@ai-sdk/openai-compatible", + v2: "@opencode/ai/providers/openai-compatible", + efforts: true, temperature: false, }, ], + // @ai-sdk/xai's languageModel() is its Chat Completions client, which rejects + // efforts such as "xhigh", so OpenCode 1 uses @ai-sdk/openai for the Hub's + // Responses API. OpenCode 2's xAI package defaults to Responses. + [ + "xai", + { + protocol: "openai", + v1: "@ai-sdk/openai", + v2: "@opencode/ai/providers/xai", + efforts: true, + }, + ], + // The Hub serves Gemini natively only over Interactions, which OpenCode 2's + // package speaks. @ai-sdk/google's languageModel() is generateContent, so + // OpenCode 1 uses the Hub's Responses API, which the Hub converts to + // Interactions. + [ + "google", + { + protocol: "openai", + v1: "@ai-sdk/openai", + v2: "@opencode/ai/providers/google/interactions", + v2Api: GOOGLE_BASE, + efforts: true, + }, + ], ]); type ModelDevEntry = { @@ -138,10 +208,11 @@ type ConfigModel = { }; interleaved: boolean | { field: string }; headers: Record; + options?: { forceReasoning: true; reasoningSummary: string }; variants?: Record; }; -// Every effort name OpenCode 1 may generate on its own for a chat model. +// OpenCode 1 may generate these efforts for OpenAI routes. const KNOWN_EFFORTS = [ "none", "minimal", @@ -152,23 +223,20 @@ const KNOWN_EFFORTS = [ "max", ]; -// The reasoning efforts models.dev lists for a model. "none" is left out: on -// chat routes it means turning thinking off, which is not an effort setting. +// Missing reasoning_options leaves efforts unknown; options without an effort +// mean none are supported. "none" toggles thinking off on chat routes. function reasoningEfforts(entry: ModelDevEntry | null) { - const values = entry?.reasoning_options?.find( - (option) => option.type === "effort", - )?.values; - return values?.filter( + const options = entry?.reasoning_options; + if (!options) return undefined; + const values = options.find((option) => option.type === "effort")?.values; + return (values ?? []).filter( (value): value is string => typeof value === "string" && value !== "none", ); } -// OpenCode 1 guesses chat-model efforts from the model ID (e.g. it only offers -// "max" for IDs containing "deepseek-v4"), so a Hub alias like deepseek-flash -// gets the wrong menu. Use the efforts models.dev lists and hide the rest. -function chatVariants(entry: ModelDevEntry | null) { - const efforts = reasoningEfforts(entry); - if (!efforts?.length) return undefined; +// OpenCode 1 guesses efforts from model IDs, which fails for Hub aliases. +function effortVariants(efforts: string[] | undefined) { + if (!efforts) return undefined; return Object.fromEntries( [...new Set([...KNOWN_EFFORTS, ...efforts])].map((effort) => [ effort, @@ -287,12 +355,19 @@ export function buildConfigModels( ); } + const input = entry?.modalities?.input ?? [ + "text", + "image", + "audio", + "video", + "pdf", + ]; models[modelId] = { id: modelId, name: entry?.name ?? hubModel.display_name, provider: { api: anthropic ? ANTHROPIC_BASE : OPENAI_BASE, - npm: known.npm, + npm: known.v1, }, attachment: entry?.attachment ?? DEFAULT_CAPS.attachment, reasoning: entry?.reasoning ?? DEFAULT_CAPS.reasoning, @@ -300,13 +375,12 @@ export function buildConfigModels( known.temperature ?? entry?.temperature ?? DEFAULT_CAPS.temperature, tool_call: entry?.tool_call ?? DEFAULT_CAPS.tool_call, modalities: { - input: entry?.modalities?.input ?? [ - "text", - "image", - "audio", - "video", - "pdf", - ], + // The OpenAI SDK's Responses model rejects files other than images + // and PDFs. OpenCode replaces unsupported attachments with a note. + input: + known.v1 === "@ai-sdk/openai" + ? input.filter((m) => ["text", "image", "pdf"].includes(m)) + : input, output: entry?.modalities?.output ?? ["text"], }, cost: extractCost(entry), @@ -319,10 +393,21 @@ export function buildConfigModels( (anthropic ? { field: "reasoning_content" } : true), headers: anthropic ? { "anthropic-beta": ANTHROPIC_BETA_HEADER } : {}, }; - const variants = - known.npm === "@ai-sdk/openai-compatible" - ? chatVariants(entry) - : undefined; + // The OpenAI SDK drops efforts for unrecognized reasoning model IDs. + // Thought summaries require an explicit request. + const forced = + known.efforts && + known.v1 === "@ai-sdk/openai" && + models[modelId].reasoning; + if (forced) + models[modelId].options = { + forceReasoning: true, + reasoningSummary: "auto", + }; + // Without models.dev efforts, forceReasoning would send OpenCode's guesses. + const variants = known.efforts + ? effortVariants(reasoningEfforts(entry) ?? (forced ? [] : undefined)) + : undefined; if (variants) models[modelId].variants = variants; } } @@ -330,15 +415,6 @@ export function buildConfigModels( return { models, warnings }; } -// OpenCode 2 ships its own native protocol implementations instead of loading -// AI SDK packages. Naming them explicitly matters: a provider registered from a -// plugin is not rewritten from `aisdk:` packages the way config providers are. -const NATIVE_PACKAGES: Record = { - "@ai-sdk/openai": "@opencode/ai/providers/openai", - "@ai-sdk/anthropic": "@opencode/ai/providers/anthropic", - "@ai-sdk/openai-compatible": "@opencode/ai/providers/openai-compatible", -}; - export type HubModel = { provider: string; id: string; @@ -355,16 +431,15 @@ export function listHubModels(hubData: HubResponse): HubModel[] { const models: HubModel[] = []; for (const [provider, providerData] of Object.entries(hubData.providers)) { const known = PROVIDERS.get(provider); - const pkg = known && NATIVE_PACKAGES[known.npm]; - if (!known || !pkg || !providerData?.models) continue; + if (!known || !providerData?.models) continue; const anthropic = known.protocol === "anthropic"; for (const [id, hubModel] of Object.entries(providerData.models)) { models.push({ provider, id, name: hubModel.display_name, - package: pkg, - api: anthropic ? ANTHROPIC_BASE : OPENAI_BASE, + package: known.v2, + api: known.v2Api ?? (anthropic ? ANTHROPIC_BASE : OPENAI_BASE), headers: anthropic ? { "anthropic-beta": ANTHROPIC_BETA_HEADER } : {}, }); } diff --git a/src/v2.ts b/src/v2.ts index 18f82b8..3bb1dcb 100644 --- a/src/v2.ts +++ b/src/v2.ts @@ -29,9 +29,8 @@ const DEFAULT_CAPABILITIES = { output: ["text"], }; -// Variant overlays win over model settings at request time, so only the -// reasoning controls OpenCode generates for the OpenAI and Anthropic routes are -// copied; anything that could redirect the endpoint or auth is dropped. +// Variants override model settings, so copied settings must preserve Hub +// routing and auth. const VARIANT_SETTINGS = new Set([ "reasoningEffort", "reasoningSummary", @@ -81,6 +80,20 @@ function chatVariants(upstream: Model.Info): Model.Variant[] { }); } +// Gemini's generateContent variants nest thinkingLevel in thinkingConfig; +// Interactions requires a top-level thinkingLevel. +function interactionsVariants(upstream: Model.Info): Model.Variant[] { + return upstream.variants.flatMap((variant) => { + const config = variant.settings?.thinkingConfig; + const level = + typeof config === "object" && config !== null && "thinkingLevel" in config + ? config.thinkingLevel + : undefined; + if (typeof level !== "string") return []; + return [{ id: variant.id, settings: { thinkingLevel: level } }]; + }); +} + // Builds a model for the Hub inventory. Metadata comes from OpenCode's own // models.dev record for the upstream provider (including inactive ones); // identity, routing and auth always come from the Hub. @@ -93,10 +106,14 @@ export function toV2Model( source !== undefined && (source.package ?? upstream?.provider.package) === hub.package; const chat = hub.package.endsWith("/openai-compatible"); + const interactions = hub.package.endsWith("/google/interactions"); const reasoningField = chat ? source?.compatibility?.reasoningField : undefined; const id = hub.id as Model.ID; + const capabilities = structuredClone( + source?.capabilities ?? DEFAULT_CAPABILITIES, + ); return { id, @@ -111,15 +128,26 @@ export function toV2Model( // The chat protocol shapes requests (max_tokens, store, tool_stream) // by upstream provider name, as for OpenCode's built-in catalog. ...(chat ? { provider: hub.provider } : {}), + // Gemini returns thought summaries only on request. + ...(interactions ? { thinkingSummaries: "auto" } : {}), }, headers: { ...hub.headers }, - capabilities: structuredClone(source?.capabilities ?? DEFAULT_CAPABILITIES), + capabilities: { + ...capabilities, + // The Hub rejects audio and video on Interactions. OpenCode replaces + // unsupported attachments with a note. + input: interactions + ? capabilities.input.filter((m) => m !== "audio" && m !== "video") + : capabilities.input, + }, variants: chat && source ? chatVariants(source) - : samePackage - ? variants(source) - : [], + : interactions && source + ? interactionsVariants(source) + : samePackage + ? variants(source) + : [], time: { released: source?.time.released ?? 0 }, cost: structuredClone(source?.cost ?? []), status: source?.status ?? "active", diff --git a/test/models.test.ts b/test/models.test.ts index 7cb798e..b84c7ca 100644 --- a/test/models.test.ts +++ b/test/models.test.ts @@ -246,7 +246,7 @@ describe("buildConfigModels", () => { temperature: true, tool_call: true, modalities: { - input: ["text", "image", "audio", "video", "pdf"], + input: ["text", "image", "pdf"], output: ["text"], }, cost: { input: 0, output: 0, cache_read: 0, cache_write: 0 }, @@ -291,9 +291,9 @@ describe("buildConfigModels", () => { it("skips unknown hub providers silently", () => { const hubData = { providers: { - google: { + mistral: { models: { - "gemini-3-pro": { display_name: "Gemini 3 Pro" }, + "mistral-large": { display_name: "Mistral Large" }, }, }, }, @@ -335,9 +335,9 @@ describe("buildConfigModels", () => { "gpt-5.4-nano": { display_name: "GPT-5.4 Nano" }, }, }, - google: { + mistral: { models: { - "gemini-3-pro": { display_name: "Gemini 3 Pro" }, + "mistral-large": { display_name: "Mistral Large" }, }, }, }, @@ -562,6 +562,167 @@ describe("buildConfigModels", () => { expect(models["gpt-x"]?.variants).toBeUndefined(); }); + it("hides efforts when reasoning_options has no effort", () => { + const { models } = buildConfigModels( + { + zai: { + models: { "glm-x": { reasoning_options: [{ type: "toggle" }] } }, + }, + }, + { + providers: { zai: { models: { "glm-x": { display_name: "GLM X" } } } }, + }, + ); + + expect(models["glm-x"]?.variants).toEqual({ + none: { disabled: true }, + minimal: { disabled: true }, + low: { disabled: true }, + medium: { disabled: true }, + high: { disabled: true }, + xhigh: { disabled: true }, + max: { disabled: true }, + }); + }); + + it("routes xAI and Google through the Responses API", () => { + const modelsDevData = { + xai: { + models: { + "grok-4.7": { + name: "Grok 4.7", + reasoning: true, + limit: { context: 500000, output: 500000 }, + reasoning_options: [ + { type: "effort", values: ["low", "medium", "high", "xhigh"] }, + ], + }, + "grok-build-0.1": { + name: "Grok Build 0.1", + reasoning: true, + reasoning_options: [], + }, + "grok-4.20-0309-non-reasoning": { + name: "Grok 4.20 (Non-Reasoning)", + reasoning: false, + }, + }, + }, + google: { + models: { + "gemini-4-argon": { + name: "Gemini 4 Argon", + reasoning: true, + temperature: true, + modalities: { + input: ["text", "image", "video", "audio", "pdf"], + output: ["text"], + }, + reasoning_options: [ + { type: "effort", values: ["low", "medium", "high"] }, + ], + }, + }, + }, + }; + const hubData = { + providers: { + xai: { + models: { + "grok-4.7": { display_name: "Grok 4.7" }, + "grok-build-0.1": { display_name: "Grok Build 0.1" }, + "grok-4.20-0309-non-reasoning": { display_name: "Grok 4.20" }, + }, + }, + google: { + models: { "gemini-4-argon": { display_name: "Gemini 4 Argon" } }, + }, + }, + }; + + const { models, warnings } = buildConfigModels(modelsDevData, hubData); + const responses = (effort: string) => ({ reasoningEffort: effort }); + + expect(warnings).toEqual([]); + for (const id of Object.keys(models)) { + expect(models[id].provider).toEqual({ + api: "https://hub.coreinfra.ai/openai/api/v1", + npm: "@ai-sdk/openai", + }); + expect(models[id].headers).toEqual({}); + } + expect(models["grok-4.7"].options).toEqual({ + forceReasoning: true, + reasoningSummary: "auto", + }); + expect(models["grok-4.7"].limit).toEqual({ + context: 500000, + output: 500000, + }); + expect(models["grok-4.7"].variants).toEqual({ + none: { disabled: true }, + minimal: { disabled: true }, + low: responses("low"), + medium: responses("medium"), + high: responses("high"), + xhigh: responses("xhigh"), + max: { disabled: true }, + }); + expect(models["gemini-4-argon"].options).toEqual({ + forceReasoning: true, + reasoningSummary: "auto", + }); + expect(models["gemini-4-argon"].modalities.input).toEqual([ + "text", + "image", + "pdf", + ]); + expect(models["gemini-4-argon"].variants).toMatchObject({ + minimal: { disabled: true }, + low: responses("low"), + medium: responses("medium"), + high: responses("high"), + xhigh: { disabled: true }, + }); + expect( + Object.values(models["grok-build-0.1"].variants ?? {}).every( + (variant) => "disabled" in variant, + ), + ).toBe(true); + expect(models["grok-4.20-0309-non-reasoning"].options).toBeUndefined(); + expect(models["grok-4.20-0309-non-reasoning"].variants).toBeUndefined(); + }); + + it("forces reasoning for non-OpenAI models", () => { + const { models } = buildConfigModels( + { + openai: { models: { "gpt-x": { reasoning: true } } }, + google: { models: {} }, + }, + { + providers: { + openai: { models: { "gpt-x": { display_name: "GPT X" } } }, + google: { models: { "gemini-x": { display_name: "Gemini X" } } }, + }, + }, + ); + + expect(models["gpt-x"].options).toBeUndefined(); + expect(models["gemini-x"].options).toEqual({ + forceReasoning: true, + reasoningSummary: "auto", + }); + expect(models["gemini-x"].variants).toEqual({ + none: { disabled: true }, + minimal: { disabled: true }, + low: { disabled: true }, + medium: { disabled: true }, + high: { disabled: true }, + xhigh: { disabled: true }, + max: { disabled: true }, + }); + }); + // Moonshot only accepts temperature 1; models.dev marking a Kimi model as // tunable made OpenCode's title agent send another value and fail. it("never lets OpenCode send a temperature to Moonshot", () => { @@ -968,6 +1129,36 @@ describe("listHubModels", () => { ]); }); + it("routes xAI and Google to their own OpenCode 2 packages and Google to /google/api/v1beta", () => { + expect( + listHubModels({ + providers: { + xai: { models: { "grok-4.7": { display_name: "Grok 4.7" } } }, + google: { + models: { "gemini-4-argon": { display_name: "Gemini 4 Argon" } }, + }, + }, + }), + ).toEqual([ + { + provider: "xai", + id: "grok-4.7", + name: "Grok 4.7", + package: "@opencode/ai/providers/xai", + api: "https://hub.coreinfra.ai/openai/api/v1", + headers: {}, + }, + { + provider: "google", + id: "gemini-4-argon", + name: "Gemini 4 Argon", + package: "@opencode/ai/providers/google/interactions", + api: "https://hub.coreinfra.ai/google/api/v1beta", + headers: {}, + }, + ]); + }); + it("skips unknown providers", () => { expect( listHubModels({ diff --git a/test/v2.test.ts b/test/v2.test.ts index 24b6b7a..02e7bbe 100644 --- a/test/v2.test.ts +++ b/test/v2.test.ts @@ -166,6 +166,95 @@ describe("toV2Model", () => { }); }); + it("maps Gemini thinking variants to Interactions options", () => { + const upstream = upstreamModel({ + id: "gemini-4-argon" as Model.ID, + providerID: "google" as Model.Info["providerID"], + name: "Gemini 4 Argon", + package: undefined, + capabilities: { + tools: true, + input: ["text", "image", "video", "audio", "pdf"], + output: ["text"], + }, + variants: [ + { + id: "none", + settings: { + thinkingConfig: { includeThoughts: false, thinkingBudget: 0 }, + }, + }, + { + id: "low", + settings: { + thinkingConfig: { includeThoughts: true, thinkingLevel: "low" }, + baseURL: "https://evil.example", + }, + headers: { "x-upstream": "1" }, + }, + { + id: "high", + settings: { + thinkingConfig: { includeThoughts: true, thinkingLevel: "high" }, + }, + }, + ] as Model.Variant[], + }); + const model = toV2Model( + { + provider: "google", + id: "gemini-4-argon", + name: "Hub Gemini", + package: "@opencode/ai/providers/google/interactions", + api: "https://hub.coreinfra.ai/google/api/v1beta", + headers: {}, + }, + record([upstream], "@opencode/ai/providers/google"), + ); + + expect(model.name).toBe("Gemini 4 Argon"); + expect(model.package).toBe("@opencode/ai/providers/google/interactions"); + expect(model.settings).toEqual({ + baseURL: "https://hub.coreinfra.ai/google/api/v1beta", + thinkingSummaries: "auto", + }); + expect(model.capabilities.input).toEqual(["text", "image", "pdf"]); + expect(model.variants).toEqual([ + { id: "low", settings: { thinkingLevel: "low" } }, + { id: "high", settings: { thinkingLevel: "high" } }, + ]); + }); + + it("keeps native xAI variants", () => { + const upstream = upstreamModel({ + id: "grok-4.7" as Model.ID, + providerID: "xai" as Model.Info["providerID"], + name: "Grok 4.7", + package: undefined, + }); + const model = toV2Model( + { + provider: "xai", + id: "grok-4.7", + name: "Hub Grok", + package: "@opencode/ai/providers/xai", + api: "https://hub.coreinfra.ai/openai/api/v1", + headers: {}, + }, + record([upstream], "@opencode/ai/providers/xai"), + ); + + expect(model.settings).toEqual({ + baseURL: "https://hub.coreinfra.ai/openai/api/v1", + }); + expect(model.variants).toEqual([ + { + id: "high", + settings: { reasoningEffort: "high", reasoningSummary: "auto" }, + }, + ]); + }); + it("does not copy a reasoning field onto non-chat routes", () => { const model = toV2Model( OPENAI_HUB,