From dcc7a4db9c63b6234c19a3b173066874281a708a Mon Sep 17 00:00:00 2001 From: Sergei Razmetov Date: Mon, 26 Oct 2026 16:03:59 +0300 Subject: [PATCH] feat: support Google and xAI models Use OpenCode 2's native xAI Responses and Google Interactions packages. Convert Gemini thinking variants and request thought summaries. Use @ai-sdk/openai Responses on OpenCode 1. Set forceReasoning and request summaries for reasoning models. Use models.dev efforts to avoid OpenCode's guesses; disable efforts without a list. Hide efforts when models.dev lists reasoning options without an effort. Exclude audio and video input on OpenAI SDK and Gemini Interactions routes. Verified end to end on OpenCode 2.1.4, 2.0.28, 1.19.4, and 1.4.0. --- README.md | 4 +- README.ru.md | 4 +- src/models.ts | 171 ++++++++++++++++++++++++++----------- src/v2.ts | 42 +++++++-- test/models.test.ts | 201 ++++++++++++++++++++++++++++++++++++++++++-- test/v2.test.ts | 89 ++++++++++++++++++++ 6 files changed, 449 insertions(+), 62 deletions(-) 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,