Skip to content

Commit a56a8fe

Browse files
androidandclaude
andcommitted
feat(provider): discover models from openai-compatible providers
Auto-discover models from any provider using @ai-sdk/openai-compatible with a baseURL set by calling GET /v1/models at startup. Reads id, name, context_length / max_context_length, and max_output_tokens from the response. Manually configured models always win (non-destructive merge). Discovery is skipped when discoverModels:false is set, when the provider has no baseURL, or when it uses a non-compatible npm package. All discovery calls run in parallel (Promise.all) so N offline providers add at most one 2-second timeout to startup rather than N x 2 seconds. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent e15fd0b commit a56a8fe

3 files changed

Lines changed: 473 additions & 0 deletions

File tree

packages/opencode/src/config/provider.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,10 @@ export const Info = Schema.Struct({
7676
npm: Schema.optional(Schema.String),
7777
whitelist: Schema.optional(Schema.mutable(Schema.Array(Schema.String))),
7878
blacklist: Schema.optional(Schema.mutable(Schema.Array(Schema.String))),
79+
discoverModels: Schema.optional(Schema.Boolean).annotate({
80+
description:
81+
"Discover models from the provider's OpenAI-compatible /models endpoint. Defaults to true for OpenAI-compatible providers with baseURL and no configured models.",
82+
}),
7983
options: Schema.optional(
8084
Schema.StructWithRest(
8185
Schema.Struct({

packages/opencode/src/provider/provider.ts

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1127,6 +1127,108 @@ export function fromModelsDevProvider(provider: ModelsDev.Provider): Info {
11271127
}
11281128
}
11291129

1130+
function openAICompatibleDiscoveryEnabled(provider: NonNullable<Config.Info["provider"]>[string]) {
1131+
if (provider.npm && provider.npm !== "@ai-sdk/openai-compatible") return false
1132+
if (!provider.options?.baseURL) return false
1133+
return provider.discoverModels ?? provider.models === undefined
1134+
}
1135+
1136+
async function discoverOpenAICompatibleModels(input: {
1137+
providerID: ProviderID
1138+
provider: NonNullable<Config.Info["provider"]>[string]
1139+
existing: Info | undefined
1140+
}): Promise<Record<string, Model>> {
1141+
const base = String(input.provider.options?.baseURL ?? "").replace(/\/+$/, "")
1142+
if (!base) return {}
1143+
const url = `${base}/models`
1144+
const controller = new AbortController()
1145+
const timer = setTimeout(() => controller.abort(), 2000)
1146+
const apiKey = typeof input.provider.options?.apiKey === "string" ? input.provider.options.apiKey : undefined
1147+
return fetch(url, {
1148+
signal: controller.signal,
1149+
headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : undefined,
1150+
})
1151+
.then((response) => {
1152+
if (!response.ok) return null
1153+
return response.json() as Promise<{ data?: Array<Record<string, unknown>> }>
1154+
})
1155+
.then((body) => {
1156+
if (!body) return {}
1157+
const discovered: Record<string, Model> = {}
1158+
for (const item of body.data ?? []) {
1159+
const rawID = item.id
1160+
if (typeof rawID !== "string" || !rawID.trim()) continue
1161+
const modelID = rawID.trim()
1162+
const existingModel = input.existing?.models[modelID]
1163+
const name =
1164+
typeof item.name === "string" && item.name.trim() ? item.name.trim() : (existingModel?.name ?? modelID)
1165+
const context =
1166+
numberFrom(item.context_length) ?? numberFrom(item.max_context_length) ?? existingModel?.limit.context ?? 0
1167+
const output = numberFrom(item.max_output_tokens) ?? existingModel?.limit.output ?? 0
1168+
discovered[modelID] = {
1169+
id: ModelID.make(modelID),
1170+
providerID: input.providerID,
1171+
name,
1172+
api: {
1173+
id: existingModel?.api.id ?? modelID,
1174+
url: input.provider.api ?? existingModel?.api.url ?? "",
1175+
npm: input.provider.npm ?? existingModel?.api.npm ?? "@ai-sdk/openai-compatible",
1176+
},
1177+
status: existingModel?.status ?? "active",
1178+
headers: existingModel?.headers ?? {},
1179+
options: existingModel?.options ?? {},
1180+
cost: existingModel?.cost ?? { input: 0, output: 0, cache: { read: 0, write: 0 } },
1181+
limit: {
1182+
context,
1183+
input: existingModel?.limit.input,
1184+
output,
1185+
},
1186+
capabilities: {
1187+
temperature: existingModel?.capabilities.temperature ?? true,
1188+
reasoning: existingModel?.capabilities.reasoning ?? false,
1189+
attachment: existingModel?.capabilities.attachment ?? false,
1190+
toolcall: existingModel?.capabilities.toolcall ?? true,
1191+
input: existingModel?.capabilities.input ?? {
1192+
text: true,
1193+
audio: false,
1194+
image: false,
1195+
video: false,
1196+
pdf: false,
1197+
},
1198+
output: existingModel?.capabilities.output ?? {
1199+
text: true,
1200+
audio: false,
1201+
image: false,
1202+
video: false,
1203+
pdf: false,
1204+
},
1205+
interleaved: existingModel?.capabilities.interleaved ?? false,
1206+
},
1207+
family: existingModel?.family ?? "",
1208+
release_date: existingModel?.release_date ?? "",
1209+
variants: existingModel?.variants ?? {},
1210+
}
1211+
}
1212+
return discovered
1213+
})
1214+
.catch((e: unknown) => {
1215+
log.warn("openai-compatible model discovery failed", { providerID: input.providerID, url, error: e })
1216+
return {}
1217+
})
1218+
.finally(() => {
1219+
clearTimeout(timer)
1220+
})
1221+
}
1222+
1223+
function numberFrom(input: unknown) {
1224+
if (typeof input === "number" && Number.isFinite(input) && input >= 0) return input
1225+
if (typeof input === "string") {
1226+
const parsed = Number(input)
1227+
if (Number.isFinite(parsed) && parsed >= 0) return parsed
1228+
}
1229+
return undefined
1230+
}
1231+
11301232
function suggestionModelIDs(provider: Info | undefined) {
11311233
if (!provider) return []
11321234
return Object.keys(provider.models).filter((id) => {
@@ -1423,6 +1525,29 @@ const layer = Layer.effect(
14231525
mergeProvider(providerID, partial)
14241526
}
14251527

1528+
const toDiscover = configProviders.flatMap(([id, provider]) => {
1529+
const providerID = ProviderID.make(id)
1530+
if (!isProviderAllowed(providerID)) return []
1531+
if (!openAICompatibleDiscoveryEnabled(provider)) return []
1532+
const target = providers[providerID]
1533+
if (!target) return []
1534+
return [{ providerID, provider, target }]
1535+
})
1536+
if (toDiscover.length > 0) {
1537+
const results = yield* Effect.promise(() =>
1538+
Promise.all(
1539+
toDiscover.map(({ providerID, provider, target }) =>
1540+
discoverOpenAICompatibleModels({ providerID, provider, existing: target }),
1541+
),
1542+
),
1543+
)
1544+
toDiscover.forEach(({ target }, i) => {
1545+
for (const [modelID, model] of Object.entries(results[i])) {
1546+
if (!target.models[modelID]) target.models[modelID] = model
1547+
}
1548+
})
1549+
}
1550+
14261551
const gitlab = ProviderID.make("gitlab")
14271552
if (discoveryLoaders[gitlab] && providers[gitlab] && isProviderAllowed(gitlab)) {
14281553
yield* Effect.promise(async () => {

0 commit comments

Comments
 (0)