11import { define } from "@opencode/plugin/effect/plugin"
22import type { Document , Entry } from "@opencode/schema/config"
3- import { type Duration , Effect , Schedule , Schema , Semaphore , Stream } from "effect"
4- import { HttpClient , HttpClientRequest , HttpClientResponse } from "effect/http"
3+ import { type Duration , Effect , Semaphore , Stream } from "effect"
4+ import { HttpClient } from "effect/http"
55import { Bus } from "../../bus.js"
66import { Config } from "../../config.js"
77import { Credential } from "../../credential.js"
88import { Provider } from "../../provider.js"
9+ import { IntegrationConnection } from "../../integration/connection.js"
10+ import { ProviderModels } from "./models.js"
911import type { PluginInternal } from "../internal.js"
1012import { foldSettings } from "./configured.js"
1113
12- const Models = Schema . Struct ( {
13- data : Schema . Array (
14- Schema . Struct ( {
15- id : Schema . String ,
16- mode : Schema . NullOr ( Schema . String ) . pipe ( Schema . optional ) ,
17- } ) ,
18- ) ,
19- } )
20-
21- // Gateways such as LiteLLM also list embedding, image, and audio models.
22- const chat = new Set ( [ "chat" , "responses" ] )
23-
24- const packages = new Set ( [ "@ai-sdk/openai-compatible" , "@opencode/ai/providers/openai-compatible" ] )
25-
2614// These providers have dedicated discovery plugins that own their whole model list.
2715const dedicated = new Set ( [ "ollama" , "lmstudio" , "vllm" ] )
2816
29- type Target = ReturnType < typeof targets > [ number ]
30-
3117export function make ( interval : Duration . Input = "5 minutes" ) {
3218 return define ( {
3319 id : "opencode.provider.openai-compatible" ,
3420 effect : Effect . fn ( function * ( ctx ) {
3521 const http = HttpClient . filterStatusOk ( yield * HttpClient . HttpClient )
3622 const bus = yield * Bus . Service
3723 const config = yield * Config . Service
24+ const providers = yield * Provider . Service
3825 const loading = Semaphore . makeUnsafe ( 1 )
39- const loaded = { inventory : new Map < Provider . ID , { source : string ; ids : readonly string [ ] } > ( ) , hash : "[]" }
26+ const loaded = {
27+ inventory : new Map <
28+ Provider . ID ,
29+ {
30+ source : string
31+ ids : readonly string [ ]
32+ connection ?: Effect . Success < ReturnType < typeof ctx . integration . connection . active > >
33+ }
34+ > ( ) ,
35+ hash : "[]" ,
36+ }
4037
41- // An empty update creates missing models and leaves configured ones untouched .
38+ // Configuration has already captured alias metadata. Its late model edits retain final precedence .
4239 yield * ctx . provider . transform ( ( providers ) => {
4340 for ( const [ providerID , item ] of loaded . inventory ) {
44- if ( ! providers . get ( providerID ) ) continue
45- for ( const id of item . ids ) providers . models . update ( providerID , id , ( ) => { } )
41+ const current = providers . get ( providerID )
42+ if ( ! current ) continue
43+ providers . add ( {
44+ info : current . provider ,
45+ models : Array . from ( current . models . values ( ) ) . filter ( ( model ) => item . ids . includes ( model . modelID ?? model . id ) ) ,
46+ sourceConnection : item . connection ,
47+ } )
48+ // Retain #57161's additive discovery for generic gateways; native adapters use catalog metadata only.
49+ if ( current . provider . package === "@opencode/ai/providers/openai-compatible" )
50+ for ( const id of item . ids ) providers . models . update ( providerID , id , ( ) => { } )
4651 }
4752 } )
4853
49- const inventory = Effect . fnUntraced ( function * ( target : Target ) {
50- const connection = yield * ctx . integration . connection . active ( target . providerID )
54+ const inventory = Effect . fnUntraced ( function * ( providerID : Provider . ID ) {
55+ const current = yield * providers . get ( providerID )
56+ if ( ! current ) return [ ]
57+ // Config observers run independently; discovery must not read the previous endpoint during a reload.
58+ const entries = yield * config . entries ( )
59+ const configured = entries
60+ . filter ( ( entry ) : entry is Document => entry . type === "document" )
61+ . flatMap ( ( entry ) => entry . info . providers ?. [ providerID ] ?? [ ] )
62+ const provider = {
63+ ...current ,
64+ settings : foldSettings ( entries , providerID , current . settings ) ,
65+ headers : configured . reduce ( ( headers , item ) => Provider . mergeHeaders ( headers , item . headers ) , current . headers ) ,
66+ }
67+ const format = ProviderModels . protocol ( provider . package )
68+ const baseURL = provider . settings ?. baseURL
69+ if ( ! format || typeof baseURL !== "string" || ! URL . canParse ( baseURL ) ) return [ ]
70+ if ( ! [ "http:" , "https:" ] . includes ( new URL ( baseURL ) . protocol ) ) return [ ]
71+ const integrationID = provider . integrationID ?? providerID
72+ const connection = yield * ctx . integration . connection . active ( integrationID )
5173 const credential = connection
5274 ? yield * ctx . integration . connection . resolve ( connection ) . pipe ( Effect . orElseSucceed ( ( ) => undefined ) )
5375 : undefined
5476 // Same precedence as requests: a stored key replaces the configured one.
55- const apiKey = credential ?. type === "key" ? credential . key : target . apiKey
56- const source = JSON . stringify ( [ target . baseURL , apiKey , target . headers ] )
57- const previous = loaded . inventory . get ( target . providerID )
58- const request = HttpClientRequest . get ( `${ target . baseURL } /models` ) . pipe (
59- HttpClientRequest . acceptJson ,
60- HttpClientRequest . setHeaders ( target . headers ?? { } ) ,
61- )
62- const ids = yield * http . execute ( apiKey ? request . pipe ( HttpClientRequest . bearerToken ( apiKey ) ) : request ) . pipe (
63- Effect . flatMap ( HttpClientResponse . schemaBodyJson ( Models ) ) ,
77+ const source = JSON . stringify ( [
78+ provider . package ,
79+ provider . settings ,
80+ provider . headers ,
81+ credential ,
82+ IntegrationConnection . key ( connection ) ,
83+ ] )
84+ const previous = loaded . inventory . get ( providerID )
85+ const ids = yield * ProviderModels . fetch ( provider , format , baseURL , credential ) . pipe (
86+ Effect . provideService ( HttpClient . HttpClient , http ) ,
6487 Effect . timeout ( "5 seconds" ) ,
65- Effect . map ( ( response ) =>
66- response . data
67- . filter ( ( model ) => model . id . length > 0 && ( ! model . mode || chat . has ( model . mode ) ) )
68- . map ( ( model ) => model . id )
69- . toSorted ( ) ,
70- ) ,
71- // Log the message only: the request in the cause carries configured headers, which may hold secrets.
72- Effect . tapError ( ( error ) =>
73- Effect . logDebug ( "openai-compatible model discovery failed" , {
74- providerID : target . providerID ,
75- message : error . message ,
76- } ) ,
88+ Effect . map ( ( ids ) => [ ...ids ] . toSorted ( ) ) ,
89+ // Do not log the HTTP cause: it can contain credentials and configured headers.
90+ Effect . tapError ( ( ) =>
91+ Effect . logWarning ( "Endpoint model discovery failed; retaining fallback" , { providerID } ) ,
7792 ) ,
7893 // Keep the last successful inventory through transient outages while the endpoint and key are unchanged.
7994 Effect . orElseSucceed ( ( ) => ( previous ?. source === source ? previous . ids : undefined ) ) ,
8095 )
81- return ids === undefined ? [ ] : [ [ target . providerID , { source, ids } ] as const ]
96+ if (
97+ IntegrationConnection . key ( connection ) !==
98+ IntegrationConnection . key ( yield * ctx . integration . connection . active ( integrationID ) )
99+ )
100+ return [ ]
101+ return ids === undefined ? [ ] : [ [ providerID , { source, ids, connection } ] as const ]
82102 } )
83103
84104 const load = Effect . fn ( "OpenAICompatiblePlugin.load" ) ( function * ( ) {
@@ -97,11 +117,12 @@ export function make(interval: Duration.Input = "5 minutes") {
97117 Stream . runForEach ( refresh ) ,
98118 Effect . forkScoped ( { startImmediately : true } ) ,
99119 )
100- yield * bus . subscribe ( Credential . Event . Switched ) . pipe (
101- Stream . runForEach ( refresh ) ,
102- Effect . forkScoped ( { startImmediately : true } ) ,
103- )
104- yield * refresh ( ) . pipe ( Effect . ignore , Effect . repeat ( Schedule . spaced ( interval ) ) , Effect . forkScoped )
120+ yield * bus
121+ . subscribe ( Credential . Event . Switched )
122+ . pipe ( Stream . runForEach ( refresh ) , Effect . forkScoped ( { startImmediately : true } ) )
123+ // Settle availability before the first model list is served, then re-discover on the interval.
124+ yield * refresh ( )
125+ yield * Effect . sleep ( interval ) . pipe ( Effect . andThen ( refresh ) , Effect . forever , Effect . ignore , Effect . forkScoped )
105126 } ) ,
106127 } satisfies PluginInternal . InternalPlugin )
107128}
@@ -113,23 +134,9 @@ function targets(entries: readonly Entry[]) {
113134 const ids = new Set ( documents . flatMap ( ( file ) => Object . keys ( file . info . providers ?? { } ) ) )
114135 return [ ...ids ] . flatMap ( ( id ) => {
115136 if ( dedicated . has ( id ) ) return [ ]
116- const configured = documents . flatMap ( ( file ) => file . info . providers ?. [ id ] ?? [ ] )
117- const pkg = configured . findLast ( ( item ) => item . package !== undefined ) ?. package
118- // Migrated V1 configs store the package as `aisdk:@ai-sdk/openai-compatible`.
119- if ( pkg === undefined || ! packages . has ( Provider . packageName ( pkg ) ) ) return [ ]
120137 const settings = foldSettings ( entries , id , undefined )
121138 const baseURL = typeof settings ?. baseURL === "string" ? settings . baseURL . replace ( / \/ + $ / , "" ) : ""
122139 if ( ! URL . canParse ( baseURL ) || ! [ "http:" , "https:" ] . includes ( new URL ( baseURL ) . protocol ) ) return [ ]
123- return [
124- {
125- providerID : Provider . ID . make ( id ) ,
126- baseURL,
127- apiKey : typeof settings ?. apiKey === "string" && settings . apiKey !== "" ? settings . apiKey : undefined ,
128- headers : configured . reduce (
129- ( headers : Record < string , string > | undefined , item ) => Provider . mergeHeaders ( headers , item . headers ) ,
130- undefined ,
131- ) ,
132- } ,
133- ]
140+ return [ Provider . ID . make ( id ) ]
134141 } )
135142}
0 commit comments