From 0d7904c91fc7b35dbd26ccbbb0a3ba61ec4057a4 Mon Sep 17 00:00:00 2001 From: Dax Date: Sat, 15 Aug 2026 12:29:09 -0400 Subject: [PATCH] refactor(core): model provider activation explicitly (#42791) Co-authored-by: Michael Hart --- .../client/src/promise/generated/types.ts | 2 +- packages/core/src/catalog.ts | 4 +- packages/core/src/config/plugin/provider.ts | 1 + packages/core/src/model-resolver.ts | 53 +++++++-- packages/core/src/models-dev.ts | 1 + .../core/src/plugin/provider/llmgateway.ts | 2 +- packages/core/src/plugin/provider/opencode.ts | 5 +- packages/core/test/catalog.test.ts | 31 ++++- packages/core/test/config/provider.test.ts | 2 +- packages/core/test/model-resolver.test.ts | 108 +++++++++++++++++- packages/core/test/models.test.ts | 2 + packages/core/test/plugin/models-dev.test.ts | 4 + .../test/plugin/provider-llmgateway.test.ts | 4 +- .../test/plugin/provider-opencode.test.ts | 2 + packages/schema/src/provider.ts | 7 +- packages/schema/test/contract-hygiene.test.ts | 2 + packages/tui/test/mini/fixture/catalog.ts | 1 + 17 files changed, 209 insertions(+), 22 deletions(-) diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index 166306dde9..1a080b3432 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -192,7 +192,7 @@ export type ProviderInfo = { id: string integrationID?: string name: string - disabled?: boolean + activation: "auto" | "enabled" | "disabled" package: string settings?: { [x: string]: any } headers?: { [x: string]: string } diff --git a/packages/core/src/catalog.ts b/packages/core/src/catalog.ts index b2e6f16715..fd7485ecc8 100644 --- a/packages/core/src/catalog.ts +++ b/packages/core/src/catalog.ts @@ -65,8 +65,8 @@ const layer = Layer.effect( const integrations = yield* Integration.Service const available = (provider: Provider.Info, integration: Integration.Info | undefined) => { - if (provider.disabled) return false - if (typeof provider.settings?.apiKey === "string") return true + if (provider.activation === "disabled") return false + if (provider.activation === "enabled") return true if (integration?.connections.length) return true return provider.integrationID === undefined && !integration } diff --git a/packages/core/src/config/plugin/provider.ts b/packages/core/src/config/plugin/provider.ts index 194e3f5100..6d89cb6b7a 100644 --- a/packages/core/src/config/plugin/provider.ts +++ b/packages/core/src/config/plugin/provider.ts @@ -40,6 +40,7 @@ export const Plugin = define({ for (const [id, item] of configuredProviders(loaded.entries)) { const providerID = id catalog.provider.update(providerID, (provider) => { + provider.activation = "enabled" if (item.name !== undefined) provider.name = item.name if (item.package !== undefined) provider.package = item.package if (item.settings !== undefined) provider.settings = Provider.mergeOverlay(provider.settings, item.settings) diff --git a/packages/core/src/model-resolver.ts b/packages/core/src/model-resolver.ts index 4c2e93ac49..05f8747760 100644 --- a/packages/core/src/model-resolver.ts +++ b/packages/core/src/model-resolver.ts @@ -358,17 +358,21 @@ export const layer = Layer.effect( const connection = yield* integrations.connection.active( provider?.integrationID ?? Integration.ID.make(selected.providerID), ) - const model = yield* resolveModel( - selected, - variant, - connection ? yield* integrations.connection.resolve(connection) : undefined, - { - loadPackage: (specifier) => Provider.loadPackage(specifier, npm), - loadAISDK: (model) => aisdk.model(model), - }, - ) + const credential = connection ? yield* integrations.connection.resolve(connection) : undefined + const runtimeInfo = yield* withVariant(selected, variant) + const model = yield* fromCatalogModel(runtimeInfo, credential, { + loadPackage: (specifier) => Provider.loadPackage(specifier, npm), + loadAISDK: (model) => aisdk.model(model), + }) + const runtime = + provider?.activation === "enabled" && + credential === undefined && + !hasConfiguredAuth(runtimeInfo) && + usesAPIKeyAuth(runtimeInfo.package) + ? LanguageModel.update(model, { route: model.route.with({ auth: Auth.none }) }) + : model return { - model, + model: runtime, ref: Ref.make({ id: selected.id, providerID: selected.providerID, @@ -399,6 +403,35 @@ export const layer = Layer.effect( }), ) +function hasConfiguredAuth(model: Info) { + return [model.settings?.apiKey, model.settings?.authToken, model.settings?.accessToken].some( + (value) => typeof value === "string" && value !== "", + ) +} + +function usesAPIKeyAuth(packageName: string | undefined) { + const name = Provider.packageName(packageName) + return ( + name === "@ai-sdk/openai" || + name === "@ai-sdk/anthropic" || + name === "@ai-sdk/openai-compatible" || + name === "@ai-sdk/google" || + name === "@ai-sdk/xai" || + name === "@openrouter/ai-sdk-provider" || + name === "@ai-sdk/azure" || + name === "@opencode-ai/ai/providers/openai" || + name?.startsWith("@opencode-ai/ai/providers/openai/") === true || + name === "@opencode-ai/ai/providers/anthropic" || + name === "@opencode-ai/ai/providers/anthropic-compatible" || + name === "@opencode-ai/ai/providers/openai-compatible" || + name === "@opencode-ai/ai/providers/google" || + name === "@opencode-ai/ai/providers/xai" || + name === "@opencode-ai/ai/providers/openrouter" || + name === "@opencode-ai/ai/providers/azure" || + name?.startsWith("@opencode-ai/ai/providers/azure/") === true + ) +} + export const node = makeLocationNode({ service: Service, layer, diff --git a/packages/core/src/models-dev.ts b/packages/core/src/models-dev.ts index 5fcdf7d486..126489c96e 100644 --- a/packages/core/src/models-dev.ts +++ b/packages/core/src/models-dev.ts @@ -86,6 +86,7 @@ function normalize(input: Record): readonly Snapshot[] { const info = { id: providerID, name: item.name, + activation: "auto", package: Provider.aisdk(item.npm), ...(item.api ? { settings: { baseURL: item.api } } : {}), } satisfies Provider.Info diff --git a/packages/core/src/plugin/provider/llmgateway.ts b/packages/core/src/plugin/provider/llmgateway.ts index 1e75244c50..2580e88608 100644 --- a/packages/core/src/plugin/provider/llmgateway.ts +++ b/packages/core/src/plugin/provider/llmgateway.ts @@ -10,7 +10,7 @@ export const LLMGatewayPlugin = define({ const configured = new Set((yield* integrations.list()).map((integration) => integration.id)) yield* ctx.catalog.transform((evt) => { for (const item of evt.provider.list()) { - if (item.provider.disabled) continue + if (item.provider.activation === "disabled") continue if (!Provider.isAISDK(item.provider.package)) continue if (Provider.packageName(item.provider.package) !== "@ai-sdk/openai-compatible") continue if (item.provider.settings?.baseURL !== "https://api.llmgateway.io/v1") continue diff --git a/packages/core/src/plugin/provider/opencode.ts b/packages/core/src/plugin/provider/opencode.ts index ede10cc32b..2c1744be88 100644 --- a/packages/core/src/plugin/provider/opencode.ts +++ b/packages/core/src/plugin/provider/opencode.ts @@ -178,7 +178,10 @@ export const OpencodePlugin = define { - if (!hasKey) provider.settings = { ...provider.settings, apiKey: "public" } + if (!hasKey) { + provider.activation = "enabled" + provider.settings = { ...provider.settings, apiKey: "public" } + } }) if (hasKey) return for (const model of item.models.values()) { diff --git a/packages/core/test/catalog.test.ts b/packages/core/test/catalog.test.ts index 8860cd9045..0d0e8a65b0 100644 --- a/packages/core/test/catalog.test.ts +++ b/packages/core/test/catalog.test.ts @@ -102,6 +102,35 @@ describe("Catalog", () => { }).pipe(Effect.provide(localCatalogLayer)) }) + it.effect("makes an explicitly enabled provider available without a connection", () => { + const integrationID = Integration.ID.make("gateway") + const providerID = Provider.ID.make("remote") + const localCatalogLayer = Layer.fresh( + AppNodeBuilder.build(LayerNode.group([Catalog.node, Credential.node, Integration.node]), [ + [Location.node, locationLayer], + ]), + ) + + return Effect.gen(function* () { + const catalog = yield* Catalog.Service + yield* (yield* Integration.Service).transform((editor) => editor.update(integrationID, () => {})) + yield* catalog.transform((editor) => + editor.provider.update(providerID, (provider) => { + provider.integrationID = integrationID + provider.settings = { baseURL: "https://gateway.example.com/v1" } + }), + ) + expect(yield* catalog.provider.available()).toEqual([]) + + yield* catalog.transform((editor) => + editor.provider.update(providerID, (provider) => { + provider.activation = "enabled" + }), + ) + expect((yield* catalog.provider.available()).map((provider) => provider.id)).toEqual([providerID]) + }).pipe(Effect.provide(localCatalogLayer)) + }) + it.effect("projects environment connections without a catalog plugin", () => Effect.acquireUseRelease( Effect.sync(() => { @@ -278,7 +307,7 @@ describe("Catalog", () => { const fallbackModel = Model.ID.make("fallback") yield* catalog.transform((catalog) => { catalog.provider.update(disabledProvider, (provider) => { - provider.disabled = true + provider.activation = "disabled" }) catalog.model.update(disabledProvider, disabledModel, () => {}) catalog.provider.update(enabledProvider, () => {}) diff --git a/packages/core/test/config/provider.test.ts b/packages/core/test/config/provider.test.ts index cf28a78336..d69881656e 100644 --- a/packages/core/test/config/provider.test.ts +++ b/packages/core/test/config/provider.test.ts @@ -342,7 +342,7 @@ describe("ConfigProviderPlugin.Plugin", () => { names: ["CUSTOM_API_KEY"], }) expect((yield* integrations.get(Integration.ID.make("custom")))?.name).toBe("Renamed") - expect(provider.disabled).toBeUndefined() + expect(provider.activation).toBe("enabled") expect(provider.package).toBe("aisdk:custom-sdk") expect(provider.settings).toEqual({ baseURL: "https://example.test" }) expect(provider.headers).toEqual({ first: "first", shared: "last", last: "last" }) diff --git a/packages/core/test/model-resolver.test.ts b/packages/core/test/model-resolver.test.ts index 83e1ed6188..e29e29195e 100644 --- a/packages/core/test/model-resolver.test.ts +++ b/packages/core/test/model-resolver.test.ts @@ -2,13 +2,16 @@ import { describe, expect } from "bun:test" import { LLM, LanguageModel } from "@opencode-ai/ai" import { OpenAIChat } from "@opencode-ai/ai/protocols" import { compileRequest } from "@opencode-ai/ai/route/client" -import { Effect } from "effect" +import { Effect, Layer } from "effect" import { Headers } from "effect/unstable/http" import { Credential } from "@opencode-ai/core/credential" import { Integration } from "@opencode-ai/core/integration" import { Compatibility, ID, Info, VariantID } from "@opencode-ai/core/model" import { Provider } from "@opencode-ai/core/provider" import { ModelResolver } from "@opencode-ai/core/model-resolver" +import { Catalog } from "@opencode-ai/core/catalog" +import { AISDK } from "@opencode-ai/core/aisdk" +import { Npm } from "@opencode-ai/util/npm" import { it } from "./lib/effect" interface ModelOptions { @@ -269,6 +272,109 @@ describe("ModelResolver", () => { }), ) + it.effect("uses no native API-key auth for an explicitly enabled provider without credentials", () => { + const selected = model(Provider.aisdk("@ai-sdk/google"), { + providerID: Provider.ID.make("gateway"), + settings: { baseURL: "https://gateway.example.com/v1" }, + headers: { "cf-access-token": "access-token" }, + }) + const provider = Provider.Info.make({ + ...Provider.Info.empty(selected.providerID), + activation: "enabled", + package: selected.package ?? "", + settings: selected.settings, + headers: selected.headers, + }) + const catalog = Layer.mock(Catalog.Service, { + provider: { + get: () => Effect.succeed(provider), + all: () => Effect.die("unused"), + available: () => Effect.die("unused"), + }, + model: { + get: () => Effect.succeed(selected), + all: () => Effect.die("unused"), + available: () => Effect.die("unused"), + default: () => Effect.die("unused"), + small: () => Effect.die("unused"), + }, + }) + const integrations = Layer.mock(Integration.Service, { + connection: { + active: () => Effect.succeed(undefined), + resolve: () => Effect.die("unused"), + key: () => Effect.die("unused"), + update: () => Effect.die("unused"), + remove: () => Effect.die("unused"), + }, + oauth: { + connect: () => Effect.die("unused"), + status: () => Effect.die("unused"), + complete: () => Effect.die("unused"), + cancel: () => Effect.die("unused"), + }, + command: { + connect: () => Effect.die("unused"), + status: () => Effect.die("unused"), + cancel: () => Effect.die("unused"), + }, + }) + const npm = Layer.mock(Npm.Service, { + add: () => Effect.die("unused"), + which: () => Effect.die("unused"), + }) + const aisdk = Layer.mock(AISDK.Service, { + hook: { + sdk: () => Effect.die("unused"), + language: () => Effect.die("unused"), + }, + model: () => Effect.die("unused"), + }) + const layer = ModelResolver.layer.pipe(Layer.provide(Layer.mergeAll(catalog, integrations, npm, aisdk))) + + return withEnv({ GOOGLE_GENERATIVE_AI_API_KEY: undefined }, () => + Effect.gen(function* () { + const resolver = yield* ModelResolver.Service + const resolved = yield* resolver.resolveModel(selected) + + const headers = yield* resolved.model.route.auth.apply({ + request: LLM.request({ model: resolved.model, prompt: "Hello" }), + method: "POST", + url: "https://gateway.example.com/v1", + body: "{}", + headers: Headers.fromInput(resolved.model.route.defaults.headers), + }) + + expect(headers["cf-access-token"]).toBe("access-token") + expect(headers.authorization).toBeUndefined() + expect(headers["x-goog-api-key"]).toBeUndefined() + }).pipe(Effect.provide(layer)), + ) + }) + + it.effect("keeps native provider environment auth strict when no API key is configured", () => + withEnv({ GOOGLE_GENERATIVE_AI_API_KEY: undefined }, () => + Effect.gen(function* () { + const resolved = yield* ModelResolver.fromCatalogModel( + model(Provider.aisdk("@ai-sdk/google"), { + settings: { baseURL: "https://google.example.com/v1" }, + }), + ) + const exit = yield* Effect.exit( + resolved.route.auth.apply({ + request: LLM.request({ model: resolved, prompt: "Hello" }), + method: "POST", + url: "https://google.example.com/v1", + body: "{}", + headers: Headers.empty, + }), + ) + + expect(exit._tag).toBe("Failure") + }), + ), + ) + it.effect("uses merged API settings for OpenAI-compatible auth and request defaults", () => Effect.gen(function* () { const resolved = yield* ModelResolver.fromCatalogModel( diff --git a/packages/core/test/models.test.ts b/packages/core/test/models.test.ts index 74c7fbc0d3..6a2083d51e 100644 --- a/packages/core/test/models.test.ts +++ b/packages/core/test/models.test.ts @@ -47,6 +47,7 @@ const fixtureSnapshot = [ info: { id: Provider.ID.make("acme"), name: "Acme", + activation: "auto", package: Provider.aisdk("@ai-sdk/openai-compatible"), }, models: [ @@ -109,6 +110,7 @@ const fixture2Snapshot = [ info: { id: Provider.ID.make("beta"), name: "Beta", + activation: "auto", package: Provider.aisdk("@ai-sdk/openai-compatible"), }, models: [ diff --git a/packages/core/test/plugin/models-dev.test.ts b/packages/core/test/plugin/models-dev.test.ts index e864c96be5..3fe32a4b84 100644 --- a/packages/core/test/plugin/models-dev.test.ts +++ b/packages/core/test/plugin/models-dev.test.ts @@ -64,6 +64,7 @@ describe("ModelsDevPlugin", () => { info: { id: providerID, name: "Acme", + activation: "auto", package: Provider.aisdk("@ai-sdk/openai-compatible"), settings: { baseURL: "https://api.acme.test/v1" }, }, @@ -239,6 +240,7 @@ describe("ModelsDevPlugin", () => { info: { id: providerID, name: "Acme", + activation: "auto", package: Provider.aisdk("@ai-sdk/openai-compatible"), }, environment: [], @@ -330,6 +332,7 @@ describe("ModelsDevPlugin", () => { info: { id: providerID, name: "Acme", + activation: "auto", package: Provider.aisdk("@ai-sdk/openai-compatible"), settings: { baseURL: "https://${ACME_HOST}/${UNDECLARED_HOST}/v1" }, }, @@ -385,6 +388,7 @@ describe("ModelsDevPlugin", () => { info: { id: Provider.ID.make(id), name, + activation: "auto", package: Provider.aisdk(packageName), }, environment: id === "azure" ? ["AZURE_RESOURCE_NAME", environment] : [environment], diff --git a/packages/core/test/plugin/provider-llmgateway.test.ts b/packages/core/test/plugin/provider-llmgateway.test.ts index 11455d3b62..8672db044a 100644 --- a/packages/core/test/plugin/provider-llmgateway.test.ts +++ b/packages/core/test/plugin/provider-llmgateway.test.ts @@ -60,14 +60,14 @@ describe("LLMGatewayPlugin", () => { }) yield* catalog.transform((catalog) => { catalog.provider.update(Provider.ID.make("llmgateway"), (provider) => { - provider.disabled = true + provider.activation = "disabled" provider.package = Provider.aisdk("@ai-sdk/openai-compatible") provider.settings = { baseURL: "https://api.llmgateway.io/v1" } }) }) yield* addPlugin() - expect((yield* catalog.provider.get(Provider.ID.make("llmgateway")))?.disabled).toBe(true) + expect((yield* catalog.provider.get(Provider.ID.make("llmgateway")))?.activation).toBe("disabled") expect((yield* catalog.provider.get(Provider.ID.make("llmgateway")))?.headers).toBeUndefined() }), ) diff --git a/packages/core/test/plugin/provider-opencode.test.ts b/packages/core/test/plugin/provider-opencode.test.ts index 7762094fd3..64ce6f4e1e 100644 --- a/packages/core/test/plugin/provider-opencode.test.ts +++ b/packages/core/test/plugin/provider-opencode.test.ts @@ -347,6 +347,8 @@ describe("OpencodePlugin", () => { }) yield* addPlugin() expect(required(yield* catalog.provider.get(Provider.ID.opencode)).settings?.apiKey).toBe("public") + expect(required(yield* catalog.provider.get(Provider.ID.opencode)).activation).toBe("enabled") + expect((yield* catalog.provider.available()).map((provider) => provider.id)).toContain(Provider.ID.opencode) expect(required(yield* catalog.model.get(Provider.ID.opencode, Model.ID.make("free"))).enabled).toBe(true) }), ), diff --git a/packages/schema/src/provider.ts b/packages/schema/src/provider.ts index 69a8d0212f..cb60f69632 100644 --- a/packages/schema/src/provider.ts +++ b/packages/schema/src/provider.ts @@ -25,6 +25,9 @@ export type ID = typeof ID.Type export const Package = Schema.String export type Package = typeof Package.Type +export const Activation = Schema.Literals(["auto", "enabled", "disabled"]) +export type Activation = typeof Activation.Type + export const Overlays = { settings: Schema.Record(Schema.String, Schema.Any).pipe(optional), headers: Schema.Record(Schema.String, Schema.String).pipe(optional), @@ -46,13 +49,13 @@ export const Info = Schema.Struct({ id: ID, integrationID: Integration.ID.pipe(optional), name: Schema.String, - disabled: Schema.Boolean.pipe(optional), + activation: Activation, package: Package, ...Overlays, }) .annotate({ identifier: "Provider.Info" }) .pipe( statics(() => ({ - empty: (id: ID): Info => ({ id, name: id, package: "" }), + empty: (id: ID): Info => ({ id, name: id, activation: "auto", package: "" }), })), ) diff --git a/packages/schema/test/contract-hygiene.test.ts b/packages/schema/test/contract-hygiene.test.ts index 7f9627df53..5e32bbbc81 100644 --- a/packages/schema/test/contract-hygiene.test.ts +++ b/packages/schema/test/contract-hygiene.test.ts @@ -120,10 +120,12 @@ describe("contract hygiene", () => { test("model defaults and provider overlays preserve public invariants", () => { const id = Model.ID.make("model") expect(Model.Info.default(Provider.ID.make("provider"), id)).toMatchObject({ modelID: id, variants: [] }) + expect(Provider.Info.empty(Provider.ID.make("provider")).activation).toBe("auto") expect( Schema.decodeUnknownSync(Provider.Info)({ id: "provider", name: "Provider", + activation: "auto", package: "native", settings: { arbitrary: 1n }, }).settings, diff --git a/packages/tui/test/mini/fixture/catalog.ts b/packages/tui/test/mini/fixture/catalog.ts index ff9db7a505..39ac1383ee 100644 --- a/packages/tui/test/mini/fixture/catalog.ts +++ b/packages/tui/test/mini/fixture/catalog.ts @@ -5,6 +5,7 @@ export function catalogProvider(id: string, name: string): ProviderListOutput["d return { id, name, + activation: "auto", package: "", } }