refactor(core): model provider activation explicitly (#42791)
Co-authored-by: Michael Hart <mhart@cloudflare.com>
This commit is contained in:
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -86,6 +86,7 @@ function normalize(input: Record<string, SourceProvider>): 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -178,7 +178,10 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Scope
|
||||
if (!item) return
|
||||
const hasKey = Boolean(process.env.OPENCODE_API_KEY || connected || item.provider.settings?.apiKey)
|
||||
catalog.provider.update(item.provider.id, (provider) => {
|
||||
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()) {
|
||||
|
||||
@@ -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, () => {})
|
||||
|
||||
@@ -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" })
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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()
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -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: "" }),
|
||||
})),
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -5,6 +5,7 @@ export function catalogProvider(id: string, name: string): ProviderListOutput["d
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
activation: "auto",
|
||||
package: "",
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user