Skip to content

Commit ebc3f6e

Browse files
fwieselGr33ndevtudstudentwoodsonl
committed
fix(core): respect model availability at configured endpoints
Extend the discovery lifecycle from anomalyco#57161 so native proxy endpoints do not inherit unsupported models from the shared catalog. Preserve catalog metadata and explicit configuration, without coupling availability to routing or request-budget changes. Based on anomalyco#57161; availability and effective-connection handling informed by anomalyco#53713 and anomalyco#54398. Co-authored-by: Gr33nbl00d <G_Rehm@ich-habe-fertig.com> Co-authored-by: J.H.S. Schlepers <lucht_piloot@gmx.net> Co-authored-by: Lance Woodson <65194841+woodsonl@users.noreply.github.com>
1 parent 54b1dfd commit ebc3f6e

5 files changed

Lines changed: 513 additions & 67 deletions

File tree

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
export * as ProviderModels from "./models.js"
2+
3+
import { Effect, Schema } from "effect"
4+
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"
5+
import { Credential } from "../../credential.js"
6+
import { Provider } from "../../provider.js"
7+
8+
const Data = Schema.Struct({
9+
data: Schema.Array(Schema.Struct({ id: Schema.String, mode: Schema.optional(Schema.NullOr(Schema.String)) })),
10+
has_more: Schema.optional(Schema.Boolean),
11+
last_id: Schema.optional(Schema.String),
12+
})
13+
const Gemini = Schema.Struct({
14+
models: Schema.Array(Schema.Struct({ name: Schema.String })),
15+
nextPageToken: Schema.optional(Schema.String),
16+
})
17+
18+
// Select the wire format by adapter, not by the configurable provider name.
19+
export function protocol(pkg: string | undefined) {
20+
if (pkg === "@opencode/ai/providers/anthropic" || pkg === "@opencode/ai/providers/anthropic-compatible")
21+
return "anthropic"
22+
if (pkg === "@opencode/ai/providers/google") return "google"
23+
if (
24+
pkg === "@opencode/ai/providers/openai" ||
25+
pkg === "@opencode/ai/providers/openai/chat" ||
26+
pkg === "@opencode/ai/providers/openai/responses" ||
27+
pkg === "@opencode/ai/providers/openai-compatible"
28+
)
29+
return "openai"
30+
}
31+
32+
export const fetch = Effect.fn("ProviderModels.fetch")(function* (
33+
provider: Provider.Info,
34+
format: NonNullable<ReturnType<typeof protocol>>,
35+
baseURL: string,
36+
credential: Credential.Value | undefined,
37+
) {
38+
const http = HttpClient.filterStatusOk(yield* HttpClient.HttpClient)
39+
const settings = provider.settings
40+
const key =
41+
credential?.type === "key" ? credential.key : credential?.type === "oauth" ? credential.access : settings?.apiKey
42+
const bearer =
43+
format === "openai" ||
44+
(format === "anthropic" &&
45+
(credential?.type === "oauth" || (!credential && typeof settings?.authToken === "string")))
46+
const token =
47+
!credential && format === "anthropic" && typeof settings?.authToken === "string" ? settings.authToken : key
48+
const headers: Record<string, string> = {}
49+
if (format === "anthropic") headers["anthropic-version"] = "2023-06-01"
50+
if (typeof token === "string")
51+
headers[bearer ? "Authorization" : format === "google" ? "x-goog-api-key" : "x-api-key"] = bearer
52+
? `Bearer ${token}`
53+
: token
54+
const request = HttpClientRequest.get(`${baseURL.replace(/\/+$/, "")}/models`).pipe(
55+
HttpClientRequest.setHeaders(Provider.mergeHeaders(headers, provider.headers) ?? headers),
56+
)
57+
const ids = new Set<string>()
58+
const cursors = new Set<string>()
59+
let page: string | undefined
60+
do {
61+
const response = yield* http.execute(
62+
page
63+
? request.pipe(HttpClientRequest.setUrlParam(format === "google" ? "pageToken" : "after_id", page))
64+
: request,
65+
)
66+
if (format === "google") {
67+
const body = yield* HttpClientResponse.schemaBodyJson(Gemini)(response)
68+
body.models.forEach((model) => ids.add(model.name.replace(/^models\//, "")))
69+
page = body.nextPageToken
70+
} else {
71+
const body = yield* HttpClientResponse.schemaBodyJson(Data)(response)
72+
body.data
73+
.filter((model) => model.id.length > 0 && (!model.mode || ["chat", "responses"].includes(model.mode)))
74+
.forEach((model) => ids.add(model.id))
75+
page = body.has_more ? (body.last_id ?? body.data.at(-1)?.id) : undefined
76+
if (body.has_more && !page) return yield* Effect.fail(new Error("Missing model-list cursor"))
77+
}
78+
if (page && cursors.has(page)) return yield* Effect.fail(new Error("Repeated model-list cursor"))
79+
if (page) cursors.add(page)
80+
} while (page)
81+
return ids
82+
})
Lines changed: 73 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -1,84 +1,104 @@
11
import { define } from "@opencode/plugin/effect/plugin"
22
import 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"
55
import { Bus } from "../../bus.js"
66
import { Config } from "../../config.js"
77
import { Credential } from "../../credential.js"
88
import { Provider } from "../../provider.js"
9+
import { IntegrationConnection } from "../../integration/connection.js"
10+
import { ProviderModels } from "./models.js"
911
import type { PluginInternal } from "../internal.js"
1012
import { 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.
2715
const dedicated = new Set(["ollama", "lmstudio", "vllm"])
2816

29-
type Target = ReturnType<typeof targets>[number]
30-
3117
export 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

Comments
 (0)