diff --git a/packages/core/src/v1/config/provider.ts b/packages/core/src/v1/config/provider.ts index d54a3f08f926..5dca5d95e604 100644 --- a/packages/core/src/v1/config/provider.ts +++ b/packages/core/src/v1/config/provider.ts @@ -117,5 +117,8 @@ export const Info = Schema.Struct({ ), ), models: Schema.optional(Schema.Record(Schema.String, Model)), + discoverModels: Schema.optional(Schema.Boolean).annotate({ + description: "Whether to discover models from the provider's /v1/models endpoint (default true)", + }), }).annotate({ identifier: "ProviderConfig" }) export type Info = Schema.Schema.Type diff --git a/packages/opencode/src/project/bootstrap.ts b/packages/opencode/src/project/bootstrap.ts index 0bbe3d4abe4f..41acdf2b2a6d 100644 --- a/packages/opencode/src/project/bootstrap.ts +++ b/packages/opencode/src/project/bootstrap.ts @@ -2,6 +2,7 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Plugin } from "../plugin" import { Format } from "../format" import { LSP } from "@/lsp/lsp" +import { Provider } from "@/provider/provider" import { Snapshot } from "../snapshot" import * as Project from "./project" import * as Vcs from "./vcs" @@ -24,6 +25,7 @@ export const layer = Layer.effect( const format = yield* Format.Service const lsp = yield* LSP.Service const plugin = yield* Plugin.Service + const provider = yield* Provider.Service const project = yield* Project.Service const shareNext = yield* ShareNext.Service const snapshot = yield* Snapshot.Service @@ -39,7 +41,7 @@ export const layer = Layer.effect( // Each service self-manages its own slow work via Effect.forkScoped against // its per-instance state scope. We just await materialization here. yield* Effect.forEach( - [lsp, shareNext, format, vcs, snapshot, project], + [lsp, shareNext, format, vcs, snapshot, project, provider], (s) => s.init().pipe(Effect.catchCause((cause) => Effect.logWarning("init failed", { cause }))), { concurrency: "unbounded", discard: true }, ).pipe(Effect.withSpan("InstanceBootstrap.init")) @@ -55,6 +57,7 @@ export const defaultLayer: Layer.Layer = layer.pipe( Format.defaultLayer, LSP.defaultLayer, Plugin.defaultLayer, + Provider.defaultLayer, Project.defaultLayer, ShareNext.defaultLayer, Snapshot.defaultLayer, @@ -67,6 +70,7 @@ export const node = LayerNode.make(layer, [ Format.node, LSP.node, Plugin.node, + Provider.node, Project.node, ShareNext.node, Snapshot.node, diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 63ad8d0d7f61..daa071bfac06 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -18,7 +18,9 @@ import { iife } from "@/util/iife" import { Global } from "@opencode-ai/core/global" import path from "path" import { pathToFileURL } from "url" -import { Effect, Layer, Context, Schema, Types } from "effect" +import { Duration, Effect, Exit, Layer, Context, Option, Schema, Types } from "effect" +import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http" +import { httpClient } from "@opencode-ai/core/effect/layer-node-platform" import { EffectBridge } from "@/effect/bridge" import { InstanceState } from "@/effect/instance-state" import { EffectPromise } from "@/effect/promise" @@ -136,6 +138,19 @@ const BUNDLED_PROVIDERS: Record Promise<(opts: any) => BundledSDK> type CustomModelLoader = (sdk: any, modelID: string, options?: Record, model?: Model) => Promise type CustomVarsLoader = (options: Record) => Record type CustomDiscoverModels = () => Promise> +interface OpenAIModelsResponse { + data: Array<{ + id: string + context_length?: number + max_context_length?: number + max_output_tokens?: number + }> +} + +type DiscoveryResult = + | { action: "delete"; providerID: string } + | { action: "update"; providerID: string; models: Record } + | { action: "skip" } type CustomLoader = (provider: Info) => Effect.Effect<{ autoload: boolean getModel?: CustomModelLoader @@ -1110,6 +1125,7 @@ export type DefaultModelError = ModelNotFoundError | NoProvidersError | NoModels export type Error = ModelNotFoundError | InitError | NoProvidersError | NoModelsError export interface Interface { + readonly init: () => Effect.Effect readonly list: () => Effect.Effect> readonly getProvider: (providerID: ProviderV2.ID) => Effect.Effect readonly getModel: (providerID: ProviderV2.ID, modelID: ModelV2.ID) => Effect.Effect @@ -1129,6 +1145,9 @@ interface State { sdk: Map modelLoaders: Record varsLoaders: Record + discoveryLoaders: Record + discoveryDone: boolean + cfg: ConfigV1.Info } export class Service extends Context.Service()("@opencode/Provider") {} @@ -1292,6 +1311,7 @@ export const layer = Layer.effect( const plugin = yield* Plugin.Service const modelsDevSvc = yield* ModelsDev.Service const runtimeFlags = yield* RuntimeFlags.Service + const http = yield* HttpClient.HttpClient const state = yield* InstanceState.make(() => Effect.gen(function* () { @@ -1543,20 +1563,6 @@ export const layer = Layer.effect( mergeProvider(providerID, partial) } - const gitlab = ProviderV2.ID.make("gitlab") - if (discoveryLoaders[gitlab] && providers[gitlab] && isProviderAllowed(gitlab)) { - yield* Effect.promise(async () => { - try { - const discovered = await discoveryLoaders[gitlab]() - for (const [modelID, model] of Object.entries(discovered)) { - if (!providers[gitlab].models[modelID]) { - providers[gitlab].models[modelID] = model - } - } - } catch (e) {} - }) - } - for (const [id, provider] of Object.entries(providers)) { const providerID = ProviderV2.ID.make(id) if (!isProviderAllowed(providerID)) { @@ -1601,7 +1607,12 @@ export const layer = Layer.effect( } if (Object.keys(provider.models).length === 0) { - delete providers[providerID] + // Don't delete providers that will have models discovered in init() + const hasDiscoveryLoader = discoveryLoaders[providerID] !== undefined + const hasConfigBaseURL = cfg.provider?.[providerID]?.options?.baseURL + if (!hasDiscoveryLoader && !hasConfigBaseURL) { + delete providers[providerID] + } continue } } @@ -1613,12 +1624,244 @@ export const layer = Layer.effect( sdk, modelLoaders, varsLoaders, + discoveryLoaders, + discoveryDone: false, + cfg, } }), ) const list = Effect.fn("Provider.list")(() => InstanceState.use(state, (s) => s.providers)) + const init = Effect.fn("Provider.init")(function* () { + const s = yield* InstanceState.get(state) + if (s.discoveryDone) return + const cfg = s.cfg + + const providersToDiscover: Array<{ + providerID: string + discover?: CustomDiscoverModels + inlineFetch: boolean + }> = [] + + for (const [providerID, discover] of Object.entries(s.discoveryLoaders)) { + providersToDiscover.push({ providerID, discover, inlineFetch: false }) + } + + for (const [providerID, configProvider] of Object.entries(cfg.provider ?? {})) { + if (s.discoveryLoaders[providerID]) continue + const npm = configProvider?.npm ?? "@ai-sdk/openai-compatible" + if (npm !== "@ai-sdk/openai-compatible") continue + const baseURL = configProvider?.options?.baseURL + if (typeof baseURL !== "string" || baseURL === "") continue + if (configProvider?.discoverModels === false) continue + providersToDiscover.push({ providerID, inlineFetch: true }) + } + + const discoveryResults = yield* Effect.forEach( + providersToDiscover, + ({ providerID, discover, inlineFetch }): Effect.Effect => { + const provider = s.providers[ProviderV2.ID.make(providerID)] + if (!provider || provider.source !== "config") { + return Effect.succeed({ action: "skip" }) + } + return discoverProviderModels(providerID, discover, inlineFetch, provider, cfg, s, http, auth) + }, + { concurrency: "unbounded" }, + ) + + // Apply all discovery results atomically + for (const result of discoveryResults) { + if (result.action === "skip") continue + const key = ProviderV2.ID.make(result.providerID) + if (result.action === "delete") { + delete s.providers[key] + } else { + const p = s.providers[key] + if (!p) { + yield* Effect.logWarning("Provider disappeared between discovery and apply", { provider: result.providerID }) + continue + } + s.providers[key] = { ...p, models: result.models } + } + } + s.discoveryDone = true + }) + + function discoverProviderModels( + providerID: string, + discover: CustomDiscoverModels | undefined, + inlineFetch: boolean, + provider: Info, + cfg: ConfigV1.Info, + s: State, + http: HttpClient.HttpClient, + auth: Auth.Interface, + ): Effect.Effect { + return Effect.gen(function* () { + const api = provider.options?.baseURL ?? "" + if (!api) return { action: "skip" } + + const authExit = yield* auth.get(ProviderV2.ID.make(providerID)).pipe(Effect.exit) + const authResult = Exit.isSuccess(authExit) ? authExit.value : undefined + + const headers: Record = {} + if (authResult?.type === "api" && authResult.key) { + headers["Authorization"] = `Bearer ${authResult.key}` + } + if (!headers["Authorization"] && provider.key) { + headers["Authorization"] = `Bearer ${provider.key}` + } + if (!headers["Authorization"] && provider.options?.apiKey) { + headers["Authorization"] = `Bearer ${provider.options.apiKey}` + } + + // Helper: return "delete" if provider has no models, otherwise "skip" + const emptyOrSkip = (): DiscoveryResult => + Object.keys(provider.models).length === 0 + ? { action: "delete", providerID } + : { action: "skip" } + + let discovered: Set + let discoveredLimits: Map | undefined + if (inlineFetch) { + const url = api.endsWith("/v1") ? `${api}/models` : `${api}/v1/models` + const request = HttpClientRequest.get(url).pipe( + HttpClientRequest.setHeaders(headers), + ) + const fetchExit = yield* http.execute(request).pipe( + Effect.timeout("10 seconds"), + Effect.exit, + ) + if (Exit.isFailure(fetchExit)) { + yield* Effect.logWarning("model discovery failed", { provider: providerID, reason: "fetch error or timeout" }) + return emptyOrSkip() + } + + const res = fetchExit.value + if (res.status < 200 || res.status >= 300) { + yield* Effect.logWarning("model discovery failed", { provider: providerID, reason: `non-2xx status ${res.status}` }) + return emptyOrSkip() + } + + const jsonExit = yield* res.json.pipe(Effect.exit) + if (Exit.isFailure(jsonExit)) { + yield* Effect.logWarning("model discovery failed", { provider: providerID, reason: "failed to parse JSON response" }) + return emptyOrSkip() + } + + const json = jsonExit.value as unknown as OpenAIModelsResponse + discovered = new Set( + (json.data ?? []) + .filter((m) => typeof m.id === "string") + .map((m) => m.id), + ) + // Parse context/output limits from API response + discoveredLimits = new Map() + for (const m of json.data ?? []) { + if (typeof m.id !== "string") continue + const context = m.context_length ?? m.max_context_length + const output = m.max_output_tokens + if (typeof context === "number" && typeof output === "number") { + discoveredLimits.set(m.id, { context, output }) + } + } + } else if (discover) { + const discoverExit = yield* Effect.promise(() => discover()).pipe(Effect.exit) + if (Exit.isFailure(discoverExit)) { + yield* Effect.logWarning("model discovery failed", { provider: providerID, reason: "custom discover function failed" }) + return emptyOrSkip() + } + + const result = discoverExit.value + discovered = new Set( + Object.entries(result) + .filter(([_, m]) => m && typeof m.id === "string") + .map(([id]) => id), + ) + } else { + return { action: "skip" } + } + + // Build new models record — never mutate provider.models + const newModels = { ...provider.models } + + // Filter out Models.dev models not returned by discovery (only for inlineFetch) + // Preserve config-defined models (they were explicitly configured by the user) + if (inlineFetch) { + const configProvider = cfg.provider?.[providerID] + if (configProvider) { + const configModelIDs = configProvider.models ? Object.keys(configProvider.models) : [] + for (const modelID of Object.keys(newModels)) { + if (configModelIDs.includes(modelID)) continue + if (!discovered.has(modelID)) delete newModels[modelID] + } + } + } + + // Merge discovered models into newModels + const npm = cfg.provider?.[providerID]?.npm ?? "@ai-sdk/openai-compatible" + const configProvider = cfg.provider?.[providerID] + const configModelIDs = configProvider?.models ? Object.keys(configProvider.models) : [] + for (const modelID of discovered) { + const existing = newModels[modelID] + const limits = discoveredLimits?.get(modelID) + const context = limits?.context ?? 128000 + const output = limits?.output ?? 128000 + + // Update limits for non-config models (config models preserve their limits) + if (existing && !configModelIDs.includes(modelID)) { + newModels[modelID] = { ...existing, limit: { context, output } } + continue + } + if (existing) { + // Warn when config limits exceed discovered limits + if (configModelIDs.includes(modelID) && limits) { + if (existing.limit.context > limits.context || existing.limit.output > limits.output) { + yield* Effect.logWarning("config limits exceed discovered limits", { + provider: providerID, + model: modelID, + configContext: existing.limit.context, + discoveredContext: limits.context, + configOutput: existing.limit.output, + discoveredOutput: limits.output, + }) + } + } + continue + } + + newModels[modelID] = { + id: ModelV2.ID.make(modelID), + providerID: ProviderV2.ID.make(providerID), + name: modelID, + api: { id: modelID, url: api, npm }, + family: "discovered", + status: "active", + headers: {}, + options: {}, + cost: { input: 0, output: 0, cache: { read: 0, write: 0 } }, + limit: { context, output }, + capabilities: { + temperature: false, + reasoning: false, + attachment: false, + toolcall: true, + input: { text: true, audio: false, image: false, video: false, pdf: false }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: false, + }, + release_date: "", + variants: {}, + } + } + + // If no models remain after discovery, signal deletion + if (Object.keys(newModels).length === 0) return { action: "delete", providerID } + return { action: "update", providerID, models: newModels } + }) + } + async function resolveSDK(model: Model, s: State, envs: Record) { try { const provider = s.providers[model.providerID] @@ -1926,9 +2169,18 @@ export const layer = Layer.effect( providerID: provider.id, modelID: model.id, } + }) + + return Service.of({ + init: () => init(), + list, + getProvider, + getModel, + getLanguage, + closest, + getSmallModel, + defaultModel, }) - - return Service.of({ list, getProvider, getModel, getLanguage, closest, getSmallModel, defaultModel }) }), ) @@ -1941,6 +2193,7 @@ export const defaultLayer = Layer.suspend(() => Layer.provide(Plugin.defaultLayer), Layer.provide(ModelsDev.defaultLayer), Layer.provide(RuntimeFlags.defaultLayer), + Layer.provide(FetchHttpClient.layer), ), ) @@ -1970,6 +2223,7 @@ export const node = LayerNode.make(layer, [ Plugin.node, ModelsDev.node, RuntimeFlags.node, + httpClient, ]) export * as Provider from "./provider" diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 92ff8fece835..1bf3ea5aea9e 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -1395,7 +1395,7 @@ export function schema(model: Provider.Model, schema: JSONSchema7): JSONSchema7 } */ - if (model.api.npm === "@ai-sdk/openai" || model.api.npm === "@ai-sdk/azure") { + if (model.api.npm === "@ai-sdk/openai" || model.api.npm === "@ai-sdk/azure" || model.api.npm === "@ai-sdk/openai-compatible") { schema = sanitizeOpenAISchema(schema) as JSONSchema7 // Codex also applies lossy compaction above 4 KB; defer that until OpenCode needs the same schema budget. } diff --git a/packages/opencode/test/fake/provider.ts b/packages/opencode/test/fake/provider.ts index 1dbfa6fa71a6..0ced9a790f70 100644 --- a/packages/opencode/test/fake/provider.ts +++ b/packages/opencode/test/fake/provider.ts @@ -53,6 +53,7 @@ export namespace ProviderTest { layer: Layer.succeed( Provider.Service, Provider.Service.of({ + init: () => Effect.void, list: Effect.fn("TestProvider.list")(() => Effect.succeed({ [row.id]: row })), getProvider: Effect.fn("TestProvider.getProvider")((providerID) => { if (providerID === row.id) return Effect.succeed(row) diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index 6edfc97ca06e..4773fa1e7a4e 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -2,6 +2,7 @@ import { afterEach, expect, test } from "bun:test" import { mkdir, unlink } from "fs/promises" import path from "path" import { Effect, Layer } from "effect" +import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" import { ModelsDev } from "@opencode-ai/core/models-dev" import { FSUtil } from "@opencode-ai/core/fs-util" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" @@ -17,7 +18,7 @@ import { Provider } from "@/provider/provider" import { RuntimeFlags } from "@/effect/runtime-flags" import { Filesystem } from "@/util/filesystem" import { InstanceLayer } from "@/project/instance-layer" -import { testEffect } from "../lib/effect" +import { pollWithTimeout, testEffect } from "../lib/effect" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" @@ -65,6 +66,7 @@ const providerLayer = (flags: Partial = {}) => Layer.provide(Plugin.defaultLayer), Layer.provide(ModelsDev.defaultLayer), Layer.provide(RuntimeFlags.layer(flags)), + Layer.provide(FetchHttpClient.layer), ) const list = Provider.use.list() @@ -78,6 +80,36 @@ const paid = (providers: Record (language as { config: { baseURL: string } }).config.baseURL const it = testEffect(Layer.mergeAll(Provider.defaultLayer, Env.defaultLayer, Plugin.defaultLayer)) + +// Mock HTTP client for discovery tests - replaces globalThis.fetch mocking +const mockHttpClientResponse = (handler: (request: HttpClientRequest.HttpClientRequest) => Response) => { + const client = HttpClient.make( + (request) => Effect.succeed(HttpClientResponse.fromWeb(request, handler(request))), + ) + return Layer.succeed(HttpClient.HttpClient, client) +} + +const jsonResponse = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } }) + +const textResponse = (text: string, status = 200) => + new Response(text, { status, headers: { "Content-Type": "text/html" } }) + +const discoveryTestLayer = (handler: (request: HttpClientRequest.HttpClientRequest) => Response) => + Provider.layer.pipe( + Layer.provide(FSUtil.defaultLayer), + Layer.provide(Env.defaultLayer), + Layer.provide(Config.defaultLayer), + Layer.provide(Auth.defaultLayer), + Layer.provide(Plugin.defaultLayer), + Layer.provide(ModelsDev.defaultLayer), + Layer.provide(RuntimeFlags.layer({})), + Layer.provide(mockHttpClientResponse(handler)), + ) + +const discoveryTestEffect = (handler: (request: HttpClientRequest.HttpClientRequest) => Response) => + testEffect(discoveryTestLayer(handler)) + const experimentalModels = testEffect(providerLayer({ enableExperimentalModels: true })) const alphaProviderConfig = { @@ -1791,3 +1823,391 @@ it.effect("opencode loader keeps paid models when auth exists", () => expect(keyedCount).toBeGreaterThan(0) }).pipe(provideMultiInstance), ) + +discoveryTestEffect(() => + jsonResponse({ + data: [ + { id: "llama-3.1-8b", object: "model" }, + { id: "mistral-7b", object: "model" }, + ], + }), +).instance( + "custom provider with baseURL discovers models from remote /models endpoint", + () => + Effect.gen(function* () { + const provider = yield* Provider.Service + yield* provider.init() + const providers = yield* list + const p = providers[ProviderV2.ID.make("llamacpp-router")] + expect(p).toBeDefined() + yield* pollWithTimeout( + Effect.gen(function* () { + const p = (yield* list)[ProviderV2.ID.make("llamacpp-router")] + return p?.models["llama-3.1-8b"] && p?.models["mistral-7b"] ? (true as const) : undefined + }), + "discovered models not found", + ) + const discoveredProvider = (yield* list)[ProviderV2.ID.make("llamacpp-router")] + expect(Object.keys(discoveredProvider.models)).toContain("llama-3.1-8b") + expect(Object.keys(discoveredProvider.models)).toContain("mistral-7b") + }), + { + config: { + provider: { + "llamacpp-router": { + npm: "@ai-sdk/openai-compatible", + name: "llama.cpp router", + options: { + baseURL: "http://localhost:8080/v1", + timeout: false, + }, + }, + }, + }, + }, +) + +discoveryTestEffect(() => jsonResponse({}, 500)).instance( + "custom provider with failing /models endpoint is removed gracefully", + () => + Effect.gen(function* () { + const provider = yield* Provider.Service + yield* provider.init() + yield* pollWithTimeout( + Effect.gen(function* () { + const p = (yield* list)[ProviderV2.ID.make("failing-provider")] + return p === undefined ? (true as const) : undefined + }), + "provider not removed after failed discovery", + ) + const providers = yield* list + expect(providers[ProviderV2.ID.make("failing-provider")]).toBeUndefined() + }), + { + config: { + provider: { + "failing-provider": { + npm: "@ai-sdk/openai-compatible", + name: "Failing Provider", + options: { + baseURL: "http://localhost:8080/v1", + }, + }, + }, + }, + }, +) + +discoveryTestEffect(() => textResponse("error")).instance( + "custom provider handles non-JSON response gracefully", + () => + Effect.gen(function* () { + const provider = yield* Provider.Service + yield* provider.init() + yield* pollWithTimeout( + Effect.gen(function* () { + const p = (yield* list)[ProviderV2.ID.make("non-json-provider")] + return p === undefined ? (true as const) : undefined + }), + "provider not removed after non-JSON response", + ) + const providers = yield* list + expect(providers[ProviderV2.ID.make("non-json-provider")]).toBeUndefined() + }), + { + config: { + provider: { + "non-json-provider": { + npm: "@ai-sdk/openai-compatible", + name: "Non-JSON Provider", + options: { + baseURL: "http://localhost:8080/v1", + }, + }, + }, + }, + }, +) + +discoveryTestEffect(() => jsonResponse({ data: [] })).instance( + "custom provider handles empty data array gracefully", + () => + Effect.gen(function* () { + const provider = yield* Provider.Service + yield* provider.init() + yield* pollWithTimeout( + Effect.gen(function* () { + const p = (yield* list)[ProviderV2.ID.make("empty-provider")] + return p === undefined ? (true as const) : undefined + }), + "provider not removed after empty discovery", + ) + const providers = yield* list + expect(providers[ProviderV2.ID.make("empty-provider")]).toBeUndefined() + }), + { + config: { + provider: { + "empty-provider": { + npm: "@ai-sdk/openai-compatible", + name: "Empty Provider", + options: { + baseURL: "http://localhost:8080/v1", + }, + }, + }, + }, + }, +) + +discoveryTestEffect(() => + jsonResponse({ + data: [ + { id: "llama-3.1-8b", object: "model" }, + { id: "mistral-7b", object: "model" }, + { id: "gemma-2-9b", object: "model" }, + ], + }), +).instance( + "custom provider discovers additional models while preserving config-defined model settings", + () => + Effect.gen(function* () { + const provider = yield* Provider.Service + yield* provider.init() + const providers = yield* list + const p = providers[ProviderV2.ID.make("llamacpp-router")] + expect(p).toBeDefined() + + // Wait for discovery to complete + yield* pollWithTimeout( + Effect.gen(function* () { + const p = (yield* list)[ProviderV2.ID.make("llamacpp-router")] + return p?.models["mistral-7b"] && p?.models["gemma-2-9b"] ? (true as const) : undefined + }), + "discovered models not found", + ) + + const discoveredProvider = (yield* list)[ProviderV2.ID.make("llamacpp-router")] + + // Config-defined model should preserve custom settings + expect(discoveredProvider.models["llama-3.1-8b"]).toBeDefined() + expect(discoveredProvider.models["llama-3.1-8b"].name).toBe("My Llama") + expect(discoveredProvider.models["llama-3.1-8b"].capabilities.reasoning).toBe(true) + + // Discovered models should be added + expect(discoveredProvider.models["mistral-7b"]).toBeDefined() + expect(discoveredProvider.models["gemma-2-9b"]).toBeDefined() + }), + { + config: { + provider: { + "llamacpp-router": { + npm: "@ai-sdk/openai-compatible", + name: "llama.cpp router", + options: { + baseURL: "http://localhost:8080/v1", + }, + models: { + "llama-3.1-8b": { + name: "My Llama", + reasoning: true, + }, + }, + }, + }, + }, + }, +) + +discoveryTestEffect(() => + jsonResponse({ + data: [ + { id: "llama-3.1-8b", object: "model" }, + ], + }), +).instance( + "discoverModels: false skips discovery and preserves config-defined models", + () => + Effect.gen(function* () { + const provider = yield* Provider.Service + yield* provider.init() + yield* pollWithTimeout( + Effect.gen(function* () { + const p = (yield* list)[ProviderV2.ID.make("no-discovery-provider")] + return p !== undefined ? (true as const) : undefined + }), + "provider disappeared", + ) + const discoveredProvider = (yield* list)[ProviderV2.ID.make("no-discovery-provider")] + + // Provider should exist (not deleted by failed discovery) + expect(discoveredProvider).toBeDefined() + // Config-defined model should be preserved + expect(discoveredProvider.models["config-model"]).toBeDefined() + // Discovered model should NOT be present (discovery was skipped) + expect(discoveredProvider.models["llama-3.1-8b"]).toBeUndefined() + }), + { + config: { + provider: { + "no-discovery-provider": { + npm: "@ai-sdk/openai-compatible", + name: "No Discovery Provider", + discoverModels: false, + options: { + baseURL: "http://localhost:8080/v1", + }, + models: { + "config-model": { + name: "Config Model", + }, + }, + }, + }, + }, + }, +) + +discoveryTestEffect(() => + jsonResponse({ + data: [ + { id: "llama-3.1-8b", object: "model", context_length: 131072, max_output_tokens: 8192 }, + { id: "mistral-7b", object: "model", max_context_length: 32768, max_output_tokens: 4096 }, + ], + }), +).instance( + "discovered models use context_length and max_output_tokens from API response", + () => + Effect.gen(function* () { + const provider = yield* Provider.Service + yield* provider.init() + yield* pollWithTimeout( + Effect.gen(function* () { + const p = (yield* list)[ProviderV2.ID.make("context-provider")] + return p?.models["llama-3.1-8b"] && p?.models["mistral-7b"] ? (true as const) : undefined + }), + "discovered models not found", + ) + const discoveredProvider = (yield* list)[ProviderV2.ID.make("context-provider")] + + // context_length field should be used for context limit + expect(discoveredProvider.models["llama-3.1-8b"].limit.context).toBe(131072) + expect(discoveredProvider.models["llama-3.1-8b"].limit.output).toBe(8192) + + // max_context_length field should also work + expect(discoveredProvider.models["mistral-7b"].limit.context).toBe(32768) + expect(discoveredProvider.models["mistral-7b"].limit.output).toBe(4096) + }), + { + config: { + provider: { + "context-provider": { + npm: "@ai-sdk/openai-compatible", + name: "Context Provider", + options: { + baseURL: "http://localhost:8080/v1", + }, + }, + }, + }, + }, +) + +discoveryTestEffect(() => + jsonResponse({ + data: [ + { id: "config-model", object: "model", context_length: 131072, max_output_tokens: 8192 }, + ], + }), +).instance( + "config-defined model limits take precedence over discovered limits", + () => + Effect.gen(function* () { + const provider = yield* Provider.Service + yield* provider.init() + yield* pollWithTimeout( + Effect.gen(function* () { + const p = (yield* list)[ProviderV2.ID.make("config-limit-provider")] + return p !== undefined ? (true as const) : undefined + }), + "provider disappeared", + ) + const discoveredProvider = (yield* list)[ProviderV2.ID.make("config-limit-provider")] + + // Config-defined limits should be preserved, not overwritten by discovery + expect(discoveredProvider.models["config-model"].limit.context).toBe(65536) + expect(discoveredProvider.models["config-model"].limit.output).toBe(4096) + }), + { + config: { + provider: { + "config-limit-provider": { + npm: "@ai-sdk/openai-compatible", + name: "Config Limit Provider", + options: { + baseURL: "http://localhost:8080/v1", + }, + models: { + "config-model": { + name: "Config Model", + limit: { + context: 65536, + output: 4096, + }, + }, + }, + }, + }, + }, + }, +) + +discoveryTestEffect(() => + jsonResponse({ + data: [ + { id: "overconfigured-model", object: "model", context_length: 32768, max_output_tokens: 2048 }, + ], + }), +).instance( + "config limits exceeding discovered limits are preserved and logged as warning", + () => + Effect.gen(function* () { + const provider = yield* Provider.Service + yield* provider.init() + yield* pollWithTimeout( + Effect.gen(function* () { + const p = (yield* list)[ProviderV2.ID.make("overconfigured-provider")] + return p !== undefined ? (true as const) : undefined + }), + "provider disappeared", + ) + const discoveredProvider = (yield* list)[ProviderV2.ID.make("overconfigured-provider")] + + // Config limits that exceed discovered limits should still be preserved + // (a warning is logged but the user's config wins) + expect(discoveredProvider.models["overconfigured-model"].limit.context).toBe(131072) + expect(discoveredProvider.models["overconfigured-model"].limit.output).toBe(8192) + }), + { + config: { + provider: { + "overconfigured-provider": { + npm: "@ai-sdk/openai-compatible", + name: "Overconfigured Provider", + options: { + baseURL: "http://localhost:8080/v1", + }, + models: { + "overconfigured-model": { + name: "Overconfigured Model", + limit: { + context: 131072, + output: 8192, + }, + }, + }, + }, + }, + }, + }, +) diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index 04d75bfba273..8fc9f78c78bf 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -1324,6 +1324,7 @@ describe("ProviderTransform.schema - openai supported schema subset", () => { ["opencode", "@ai-sdk/openai"], ["custom-openai-compatible", "@ai-sdk/openai"], ["azure", "@ai-sdk/azure"], + ["custom-openai-compatible", "@ai-sdk/openai-compatible"], ])("sanitizes %s models using %s", (providerID, npm) => { expect( ProviderTransform.schema( diff --git a/packages/opencode/test/session/llm-native-recorded.test.ts b/packages/opencode/test/session/llm-native-recorded.test.ts index d17d7f8e5ab7..54b4742d709f 100644 --- a/packages/opencode/test/session/llm-native-recorded.test.ts +++ b/packages/opencode/test/session/llm-native-recorded.test.ts @@ -7,6 +7,9 @@ import { HttpRecorderInternal } from "@opencode-ai/http-recorder/internal" import { describe, expect, test } from "bun:test" import { tool, type ModelMessage, type JSONValue } from "ai" import { Effect, Layer, Option, Schema, Stream } from "effect" +import { FetchHttpClient } from "effect/unstable/http" +import { httpClient } from "@opencode-ai/core/effect/layer-node-platform" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import path from "node:path" import z from "zod" import { Auth } from "@/auth" @@ -270,6 +273,7 @@ function recordedNativeLLMLayer(scenario: RecordedScenario) { Layer.provide(Plugin.defaultLayer), Layer.provide(ModelsDev.defaultLayer), Layer.provide(RuntimeFlags.defaultLayer), + Layer.provide(LayerNode.buildLayer(httpClient)), ) // Only the HTTP client is recorded; RequestExecutor and the opencode LLM stack remain real. const metadata = {