@@ -22,6 +22,7 @@ const DEFAULT_HUB_BASE = "https://hub.coreinfra.ai";
2222const HUB_URL = `${ process . env . COREINFRA_HUB_BASE_URL ?? DEFAULT_HUB_BASE } /hub/api/prices` ;
2323const OPENAI_BASE = `${ process . env . COREINFRA_HUB_BASE_URL ?? DEFAULT_HUB_BASE } /openai/api/v1` ;
2424const 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` ;
2526const ANTHROPIC_BETA_HEADER =
2627 "interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14" ;
2728
@@ -42,24 +43,65 @@ const DEFAULT_COST = {
4243type 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`).
4954const 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
77147type 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 .
145216const 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 .
157228function 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-
342418export 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 }
0 commit comments