Skip to content

Commit 72fe31e

Browse files
committed
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.
1 parent fdb25ed commit 72fe31e

6 files changed

Lines changed: 449 additions & 62 deletions

File tree

‎README.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ During authentication, you will need a [CoreInfra AI Hub](https://hub.coreinfra.
4141
## Limitations
4242

4343
At the moment, OpenCode supports prices in USD only.
44-
Because of that, pricing is not overridden yet: OpenCode shows prices from the original OpenAI/Anthropic API.
44+
The plugin therefore keeps the model provider's API prices.
4545

4646
## Usage
4747

@@ -50,6 +50,8 @@ After installation and authentication, models will be available with the `corein
5050
```bash
5151
opencode run -m coreinfra/gpt-5.4-nano
5252
opencode run -m coreinfra/claude-sonnet-4-20250514
53+
opencode run -m coreinfra/gemini-3.8-flash
54+
opencode run -m coreinfra/grok-4.7
5355
```
5456

5557
To see the full list of available models:

‎README.ru.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ opencode providers login --provider coreinfra
4141
## Ограничения
4242

4343
На данный момент OpenCode поддерживает только цены в долларах.
44-
Поэтому стоимость пока не переопределяется: OpenCode показывает цены из исходного API OpenAI/Anthropic.
44+
Поэтому OpenCode показывает цены из API провайдера модели.
4545

4646
## Использование
4747

@@ -50,6 +50,8 @@ opencode providers login --provider coreinfra
5050
```bash
5151
opencode run -m coreinfra/gpt-5.4-nano
5252
opencode run -m coreinfra/claude-sonnet-4-20250514
53+
opencode run -m coreinfra/gemini-3.8-flash
54+
opencode run -m coreinfra/grok-4.7
5355
```
5456

5557
Посмотреть список всех доступных моделей:

‎src/models.ts‎

Lines changed: 123 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ const DEFAULT_HUB_BASE = "https://hub.coreinfra.ai";
2222
const HUB_URL = `${process.env.COREINFRA_HUB_BASE_URL ?? DEFAULT_HUB_BASE}/hub/api/prices`;
2323
const OPENAI_BASE = `${process.env.COREINFRA_HUB_BASE_URL ?? DEFAULT_HUB_BASE}/openai/api/v1`;
2424
const ANTHROPIC_BASE = `${process.env.COREINFRA_HUB_BASE_URL ?? DEFAULT_HUB_BASE}/anthropic/api/v1`;
25+
const GOOGLE_BASE = `${process.env.COREINFRA_HUB_BASE_URL ?? DEFAULT_HUB_BASE}/google/api/v1beta`;
2526
const ANTHROPIC_BETA_HEADER =
2627
"interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14";
2728

@@ -42,24 +43,65 @@ const DEFAULT_COST = {
4243
type Protocol = "openai" | "anthropic";
4344

4445
// Providers we explicitly know how to route. Models from any provider not
45-
// listed here are skipped. Adding a provider is a one-line change here. The
46-
// `npm` package selects the wire API: `@ai-sdk/openai` targets OpenAI's
47-
// Responses API (`/responses`), while `@ai-sdk/openai-compatible` targets the
48-
// Chat Completions API (`/chat/completions`).
46+
// listed here are skipped. Adding a provider is one entry here. `protocol`
47+
// picks the Hub endpoint and headers. `v1` is the AI SDK package OpenCode 1
48+
// loads; it builds our models with the package's languageModel(). `v2` is
49+
// OpenCode 2's built-in package, named explicitly because the provider
50+
// editor's add() does not translate AI SDK package names. `v2Api` overrides
51+
// the endpoint for OpenCode 2. `openai` packages target the Responses API
52+
// (`/responses`), and `openai-compatible` ones the Chat Completions API
53+
// (`/chat/completions`).
4954
const PROVIDERS = new Map<
5055
string,
51-
{ protocol: Protocol; npm: string; temperature?: false }
56+
{
57+
protocol: Protocol;
58+
v1: string;
59+
v2: string;
60+
v2Api?: string;
61+
efforts?: true;
62+
temperature?: false;
63+
}
5264
>([
53-
["openai", { protocol: "openai", npm: "@ai-sdk/openai" }],
54-
["anthropic", { protocol: "anthropic", npm: "@ai-sdk/anthropic" }],
65+
[
66+
"openai",
67+
{
68+
protocol: "openai",
69+
v1: "@ai-sdk/openai",
70+
v2: "@opencode/ai/providers/openai",
71+
},
72+
],
73+
[
74+
"anthropic",
75+
{
76+
protocol: "anthropic",
77+
v1: "@ai-sdk/anthropic",
78+
v2: "@opencode/ai/providers/anthropic",
79+
},
80+
],
5581
// DeepSeek must use Chat Completions, not the Anthropic protocol: the
5682
// Anthropic message format drops reasoning blocks that carry no signature,
5783
// and DeepSeek never issues one. A reasoning-only assistant turn then lowers
5884
// to `content: []` and the API rejects the whole request with "all messages
5985
// must have non-empty content", while `reasoning_content` never survives the
6086
// round-trip that DeepSeek's thinking mode requires.
61-
["deepseek", { protocol: "openai", npm: "@ai-sdk/openai-compatible" }],
62-
["zai", { protocol: "openai", npm: "@ai-sdk/openai-compatible" }],
87+
[
88+
"deepseek",
89+
{
90+
protocol: "openai",
91+
v1: "@ai-sdk/openai-compatible",
92+
v2: "@opencode/ai/providers/openai-compatible",
93+
efforts: true,
94+
},
95+
],
96+
[
97+
"zai",
98+
{
99+
protocol: "openai",
100+
v1: "@ai-sdk/openai-compatible",
101+
v2: "@opencode/ai/providers/openai-compatible",
102+
efforts: true,
103+
},
104+
],
63105
// Moonshot rejects any temperature other than 1 ("only 1 is allowed for this
64106
// model"), yet models.dev marks some Kimi models as tunable. OpenCode's title
65107
// agent then sends its own temperature and title generation fails, so never
@@ -68,10 +110,38 @@ const PROVIDERS = new Map<
68110
"moonshotai",
69111
{
70112
protocol: "openai",
71-
npm: "@ai-sdk/openai-compatible",
113+
v1: "@ai-sdk/openai-compatible",
114+
v2: "@opencode/ai/providers/openai-compatible",
115+
efforts: true,
72116
temperature: false,
73117
},
74118
],
119+
// @ai-sdk/xai's languageModel() is its Chat Completions client, which rejects
120+
// efforts such as "xhigh", so OpenCode 1 uses @ai-sdk/openai for the Hub's
121+
// Responses API. OpenCode 2's xAI package defaults to Responses.
122+
[
123+
"xai",
124+
{
125+
protocol: "openai",
126+
v1: "@ai-sdk/openai",
127+
v2: "@opencode/ai/providers/xai",
128+
efforts: true,
129+
},
130+
],
131+
// The Hub serves Gemini natively only over Interactions, which OpenCode 2's
132+
// package speaks. @ai-sdk/google's languageModel() is generateContent, so
133+
// OpenCode 1 uses the Hub's Responses API, which the Hub converts to
134+
// Interactions.
135+
[
136+
"google",
137+
{
138+
protocol: "openai",
139+
v1: "@ai-sdk/openai",
140+
v2: "@opencode/ai/providers/google/interactions",
141+
v2Api: GOOGLE_BASE,
142+
efforts: true,
143+
},
144+
],
75145
]);
76146

77147
type ModelDevEntry = {
@@ -138,10 +208,11 @@ type ConfigModel = {
138208
};
139209
interleaved: boolean | { field: string };
140210
headers: Record<string, string>;
211+
options?: { forceReasoning: true; reasoningSummary: string };
141212
variants?: Record<string, { reasoningEffort: string } | { disabled: true }>;
142213
};
143214

144-
// Every effort name OpenCode 1 may generate on its own for a chat model.
215+
// OpenCode 1 may generate these efforts for OpenAI routes.
145216
const KNOWN_EFFORTS = [
146217
"none",
147218
"minimal",
@@ -152,23 +223,20 @@ const KNOWN_EFFORTS = [
152223
"max",
153224
];
154225

155-
// The reasoning efforts models.dev lists for a model. "none" is left out: on
156-
// chat routes it means turning thinking off, which is not an effort setting.
226+
// Missing reasoning_options leaves efforts unknown; options without an effort
227+
// mean none are supported. "none" toggles thinking off on chat routes.
157228
function reasoningEfforts(entry: ModelDevEntry | null) {
158-
const values = entry?.reasoning_options?.find(
159-
(option) => option.type === "effort",
160-
)?.values;
161-
return values?.filter(
229+
const options = entry?.reasoning_options;
230+
if (!options) return undefined;
231+
const values = options.find((option) => option.type === "effort")?.values;
232+
return (values ?? []).filter(
162233
(value): value is string => typeof value === "string" && value !== "none",
163234
);
164235
}
165236

166-
// OpenCode 1 guesses chat-model efforts from the model ID (e.g. it only offers
167-
// "max" for IDs containing "deepseek-v4"), so a Hub alias like deepseek-flash
168-
// gets the wrong menu. Use the efforts models.dev lists and hide the rest.
169-
function chatVariants(entry: ModelDevEntry | null) {
170-
const efforts = reasoningEfforts(entry);
171-
if (!efforts?.length) return undefined;
237+
// OpenCode 1 guesses efforts from model IDs, which fails for Hub aliases.
238+
function effortVariants(efforts: string[] | undefined) {
239+
if (!efforts) return undefined;
172240
return Object.fromEntries(
173241
[...new Set([...KNOWN_EFFORTS, ...efforts])].map((effort) => [
174242
effort,
@@ -287,26 +355,32 @@ export function buildConfigModels(
287355
);
288356
}
289357

358+
const input = entry?.modalities?.input ?? [
359+
"text",
360+
"image",
361+
"audio",
362+
"video",
363+
"pdf",
364+
];
290365
models[modelId] = {
291366
id: modelId,
292367
name: entry?.name ?? hubModel.display_name,
293368
provider: {
294369
api: anthropic ? ANTHROPIC_BASE : OPENAI_BASE,
295-
npm: known.npm,
370+
npm: known.v1,
296371
},
297372
attachment: entry?.attachment ?? DEFAULT_CAPS.attachment,
298373
reasoning: entry?.reasoning ?? DEFAULT_CAPS.reasoning,
299374
temperature:
300375
known.temperature ?? entry?.temperature ?? DEFAULT_CAPS.temperature,
301376
tool_call: entry?.tool_call ?? DEFAULT_CAPS.tool_call,
302377
modalities: {
303-
input: entry?.modalities?.input ?? [
304-
"text",
305-
"image",
306-
"audio",
307-
"video",
308-
"pdf",
309-
],
378+
// The OpenAI SDK's Responses model rejects files other than images
379+
// and PDFs. OpenCode replaces unsupported attachments with a note.
380+
input:
381+
known.v1 === "@ai-sdk/openai"
382+
? input.filter((m) => ["text", "image", "pdf"].includes(m))
383+
: input,
310384
output: entry?.modalities?.output ?? ["text"],
311385
},
312386
cost: extractCost(entry),
@@ -319,26 +393,28 @@ export function buildConfigModels(
319393
(anthropic ? { field: "reasoning_content" } : true),
320394
headers: anthropic ? { "anthropic-beta": ANTHROPIC_BETA_HEADER } : {},
321395
};
322-
const variants =
323-
known.npm === "@ai-sdk/openai-compatible"
324-
? chatVariants(entry)
325-
: undefined;
396+
// The OpenAI SDK drops efforts for unrecognized reasoning model IDs.
397+
// Thought summaries require an explicit request.
398+
const forced =
399+
known.efforts &&
400+
known.v1 === "@ai-sdk/openai" &&
401+
models[modelId].reasoning;
402+
if (forced)
403+
models[modelId].options = {
404+
forceReasoning: true,
405+
reasoningSummary: "auto",
406+
};
407+
// Without models.dev efforts, forceReasoning would send OpenCode's guesses.
408+
const variants = known.efforts
409+
? effortVariants(reasoningEfforts(entry) ?? (forced ? [] : undefined))
410+
: undefined;
326411
if (variants) models[modelId].variants = variants;
327412
}
328413
}
329414

330415
return { models, warnings };
331416
}
332417

333-
// OpenCode 2 ships its own native protocol implementations instead of loading
334-
// AI SDK packages. Naming them explicitly matters: a provider registered from a
335-
// plugin is not rewritten from `aisdk:` packages the way config providers are.
336-
const NATIVE_PACKAGES: Record<string, string> = {
337-
"@ai-sdk/openai": "@opencode/ai/providers/openai",
338-
"@ai-sdk/anthropic": "@opencode/ai/providers/anthropic",
339-
"@ai-sdk/openai-compatible": "@opencode/ai/providers/openai-compatible",
340-
};
341-
342418
export type HubModel = {
343419
provider: string;
344420
id: string;
@@ -355,16 +431,15 @@ export function listHubModels(hubData: HubResponse): HubModel[] {
355431
const models: HubModel[] = [];
356432
for (const [provider, providerData] of Object.entries(hubData.providers)) {
357433
const known = PROVIDERS.get(provider);
358-
const pkg = known && NATIVE_PACKAGES[known.npm];
359-
if (!known || !pkg || !providerData?.models) continue;
434+
if (!known || !providerData?.models) continue;
360435
const anthropic = known.protocol === "anthropic";
361436
for (const [id, hubModel] of Object.entries(providerData.models)) {
362437
models.push({
363438
provider,
364439
id,
365440
name: hubModel.display_name,
366-
package: pkg,
367-
api: anthropic ? ANTHROPIC_BASE : OPENAI_BASE,
441+
package: known.v2,
442+
api: known.v2Api ?? (anthropic ? ANTHROPIC_BASE : OPENAI_BASE),
368443
headers: anthropic ? { "anthropic-beta": ANTHROPIC_BETA_HEADER } : {},
369444
});
370445
}

‎src/v2.ts‎

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,8 @@ const DEFAULT_CAPABILITIES = {
2929
output: ["text"],
3030
};
3131

32-
// Variant overlays win over model settings at request time, so only the
33-
// reasoning controls OpenCode generates for the OpenAI and Anthropic routes are
34-
// copied; anything that could redirect the endpoint or auth is dropped.
32+
// Variants override model settings, so copied settings must preserve Hub
33+
// routing and auth.
3534
const VARIANT_SETTINGS = new Set([
3635
"reasoningEffort",
3736
"reasoningSummary",
@@ -81,6 +80,20 @@ function chatVariants(upstream: Model.Info): Model.Variant[] {
8180
});
8281
}
8382

83+
// Gemini's generateContent variants nest thinkingLevel in thinkingConfig;
84+
// Interactions requires a top-level thinkingLevel.
85+
function interactionsVariants(upstream: Model.Info): Model.Variant[] {
86+
return upstream.variants.flatMap((variant) => {
87+
const config = variant.settings?.thinkingConfig;
88+
const level =
89+
typeof config === "object" && config !== null && "thinkingLevel" in config
90+
? config.thinkingLevel
91+
: undefined;
92+
if (typeof level !== "string") return [];
93+
return [{ id: variant.id, settings: { thinkingLevel: level } }];
94+
});
95+
}
96+
8497
// Builds a model for the Hub inventory. Metadata comes from OpenCode's own
8598
// models.dev record for the upstream provider (including inactive ones);
8699
// identity, routing and auth always come from the Hub.
@@ -93,10 +106,14 @@ export function toV2Model(
93106
source !== undefined &&
94107
(source.package ?? upstream?.provider.package) === hub.package;
95108
const chat = hub.package.endsWith("/openai-compatible");
109+
const interactions = hub.package.endsWith("/google/interactions");
96110
const reasoningField = chat
97111
? source?.compatibility?.reasoningField
98112
: undefined;
99113
const id = hub.id as Model.ID;
114+
const capabilities = structuredClone(
115+
source?.capabilities ?? DEFAULT_CAPABILITIES,
116+
);
100117

101118
return {
102119
id,
@@ -111,15 +128,26 @@ export function toV2Model(
111128
// The chat protocol shapes requests (max_tokens, store, tool_stream)
112129
// by upstream provider name, as for OpenCode's built-in catalog.
113130
...(chat ? { provider: hub.provider } : {}),
131+
// Gemini returns thought summaries only on request.
132+
...(interactions ? { thinkingSummaries: "auto" } : {}),
114133
},
115134
headers: { ...hub.headers },
116-
capabilities: structuredClone(source?.capabilities ?? DEFAULT_CAPABILITIES),
135+
capabilities: {
136+
...capabilities,
137+
// The Hub rejects audio and video on Interactions. OpenCode replaces
138+
// unsupported attachments with a note.
139+
input: interactions
140+
? capabilities.input.filter((m) => m !== "audio" && m !== "video")
141+
: capabilities.input,
142+
},
117143
variants:
118144
chat && source
119145
? chatVariants(source)
120-
: samePackage
121-
? variants(source)
122-
: [],
146+
: interactions && source
147+
? interactionsVariants(source)
148+
: samePackage
149+
? variants(source)
150+
: [],
123151
time: { released: source?.time.released ?? 0 },
124152
cost: structuredClone(source?.cost ?? []),
125153
status: source?.status ?? "active",

0 commit comments

Comments
 (0)