Compare commits

...
Author SHA1 Message Date
vimtor b525ee0dfa feat(core): support canonical provider config 2026-08-29 14:21:27 +02:00
14 changed files with 246 additions and 121 deletions
@@ -210,6 +210,7 @@ export type GenerateTextResponse = { data: { text: string } }
export type ProviderInfo = {
id: string
canonical?: string
integrationID?: string
name: string
activation: "auto" | "enabled" | "disabled"
@@ -1801,6 +1802,7 @@ export type ModelInfo = {
id: string
modelID: string
providerID: string
canonical?: string
family?: string
name: string
compatibility?: ModelCompatibility
@@ -1978,6 +1980,7 @@ export type ConfigEntry =
warming?: boolean | { prompt?: string; interval?: string; duration?: string }
providers?: {
[x: string]: {
canonical?: string
name?: string
env?: Array<string>
package?: string
+8 -5
View File
@@ -119,7 +119,7 @@ function wrapSSE(res: Response, ms: number, ctl: AbortController) {
function prepareOptions(model: Info, pkg: string) {
const projected = mapBodyToProviderOptions(model, pkg)
const options: Record<string, any> = {
name: model.providerID,
name: model.canonical ?? model.providerID,
...(model.settings ?? {}),
headers: model.headers,
body: projected.body,
@@ -249,6 +249,7 @@ export const locationLayer = Layer.effect(
language: Effect.fn("AISDK.language")(function* (model) {
const key = cacheKey({
providerID: model.providerID,
canonical: model.canonical,
id: model.id,
modelID: model.modelID,
package: model.package,
@@ -269,6 +270,7 @@ export const locationLayer = Layer.effect(
const options = prepareOptions(model, packageName)
const sdkKey = cacheKey({
providerID: model.providerID,
canonical: model.canonical,
package: packageName,
settings: model.settings,
headers: model.headers,
@@ -301,10 +303,11 @@ export const locationLayer = Layer.effect(
function modelFromLanguage(info: Info, language: LanguageModelV3) {
const packageName = Provider.packageName(info.package!)
const projected = mapBodyToProviderOptions(info, packageName)
const optionKey = providerOptionKey(packageName, info.providerID)
const providerID = info.canonical ?? info.providerID
const optionKey = providerOptionKey(packageName, providerID)
const route: AnyRoute = {
id: `ai-sdk:${packageName}`,
provider: ProviderID.make(info.providerID),
provider: ProviderID.make(providerID),
providerMetadataKey: optionKey,
protocol: "ai-sdk",
endpoint: Endpoint.path("/", { baseURL: "https://ai-sdk.local" }),
@@ -331,13 +334,13 @@ function modelFromLanguage(info: Info, language: LanguageModelV3) {
},
with: () => route,
model: (input) =>
LanguageModel.make({ ...input, provider: "provider" in input ? input.provider : info.providerID, route }),
LanguageModel.make({ ...input, provider: "provider" in input ? input.provider : providerID, route }),
prepareTransport: (body) => Effect.succeed(body),
streamPrepared: (prepared) => streamLanguage(language, prepared as LanguageModelV3CallOptions),
}
return LanguageModel.make({
id: info.modelID ?? info.id,
provider: info.providerID,
provider: providerID,
route,
compatibility: info.compatibility,
})
+1
View File
@@ -74,6 +74,7 @@ const layer = Layer.effect(
const projectModel = (model: Model.Info, provider: Provider.Info) => {
return {
...model,
...(provider.canonical === undefined ? {} : { canonical: provider.canonical }),
package: model.package ?? provider.package,
settings: Provider.mergeOverlay(provider.settings, model.settings),
headers: Provider.mergeHeaders(provider.headers, model.headers),
@@ -44,8 +44,17 @@ export const Plugin = define({
catalog.model.default.set(configuredDefault.providerID, configuredDefault.model)
for (const [id, item] of configuredProviders(loaded.entries)) {
const providerID = id
const current = catalog.provider.get(providerID)
const source = catalog.provider.get(item.canonical ?? current?.provider.canonical ?? providerID)
const changed = item.canonical !== undefined && item.canonical !== current?.provider.canonical
catalog.provider.update(providerID, (provider) => {
if (changed && source && source.provider !== provider)
Object.assign(provider, structuredClone(source.provider), {
id: provider.id,
integrationID: provider.integrationID,
})
provider.activation = "enabled"
if (item.canonical !== undefined) provider.canonical = item.canonical
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)
@@ -53,7 +62,14 @@ export const Plugin = define({
if (item.body !== undefined) provider.body = Provider.mergeOverlay(provider.body, item.body)
})
for (const [id, config] of Object.entries(item.models ?? {})) {
const base = source?.models.get(config.modelID ?? id) ?? source?.models.get(id)
const inherit = changed || !catalog.model.get(providerID, id)
catalog.model.update(providerID, id, (model) => {
if (inherit && base) {
Object.assign(model, structuredClone(base))
if (item.package !== undefined) model.package = undefined
if (item.settings?.baseURL !== undefined && model.settings) delete model.settings.baseURL
}
if (config.family !== undefined) model.family = config.family
if (config.name !== undefined) model.name = config.name
if (config.modelID !== undefined) model.modelID = config.modelID
+3 -2
View File
@@ -132,7 +132,7 @@ const resolveCatalogModel = Effect.fn("ModelResolver.resolveCatalogModel")(funct
packageName,
settings: configured,
modelID: resolved.modelID ?? resolved.id,
providerID: resolved.providerID,
providerID: resolved.canonical ?? resolved.providerID,
})
: undefined
const native = mapping?.package ?? resolved.package
@@ -161,6 +161,7 @@ const resolveCatalogModel = Effect.fn("ModelResolver.resolveCatalogModel")(funct
)
const settings = {
...(credential ? withoutNativeAuthSettings(mapped) : mapped),
...(resolved.canonical === undefined ? {} : { provider: resolved.canonical }),
...nativeCredentialSettings(specifier, credential),
headers: Provider.mergeHeaders(mapping?.headers, resolved.headers),
body: Provider.mergeOverlay(mapping?.body, resolved.body),
@@ -169,7 +170,7 @@ const resolveCatalogModel = Effect.fn("ModelResolver.resolveCatalogModel")(funct
try: () => {
const runtime = module.model(resolved.modelID ?? resolved.id, settings)
return LanguageModel.update(runtime, {
provider: resolved.providerID,
provider: resolved.canonical ?? resolved.providerID,
compatibility: resolved.compatibility
? Object.assign({}, runtime.compatibility, resolved.compatibility)
: runtime.compatibility,
+63 -77
View File
@@ -6,17 +6,14 @@ import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstab
import { Bus } from "../../bus.js"
import { Credential } from "../../credential.js"
import { Integration } from "../../integration.js"
import { Model } from "../../model.js"
import { Provider } from "../../provider.js"
import { ConfigProviderV1 } from "../../v1/config/provider.js"
import { ConfigProvider } from "@opencode-ai/schema/config/provider"
import { Money } from "@opencode-ai/schema/money"
import { ConfigProviderOptionsV1 } from "../../v1/config/provider-options.js"
import { ConfigV1 } from "../../v1/config/config.js"
const defaultServer = "https://opencode.ai/console"
const clientID = "opencode-cli"
const methodID = Integration.MethodID.make("device")
const RemoteResponse = Schema.Struct({ config: ConfigV1.Info })
const RemoteResponse = Schema.Struct({ providers: Schema.Record(Schema.String, ConfigProvider.Info) })
const Device = Schema.Struct({
device_code: Schema.String,
user_code: Schema.String,
@@ -89,7 +86,7 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Scope
const http = yield* HttpClient.HttpClient
const loading = Semaphore.makeUnsafe(1)
let connected = false
let providers: typeof ConfigV1.Info.Type.provider | undefined
let providers: typeof RemoteResponse.Type.providers | undefined
const load = Effect.fn("OpencodePlugin.load")(function* () {
const connection = yield* ctx.integration.connection.active("opencode")
@@ -117,59 +114,69 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Scope
yield* load()
yield* ctx.catalog.transform((catalog) => {
for (const [providerID, item] of Object.entries(providers ?? {})) {
const source = catalog.provider.get(item.canonical ?? providerID)
catalog.provider.update(providerID, (provider) => {
if (source && source.provider !== provider)
Object.assign(provider, structuredClone(source.provider), { id: provider.id })
provider.integrationID = Integration.ID.make("opencode")
if (item.canonical !== undefined) provider.canonical = item.canonical
if (item.name !== undefined) provider.name = item.name
provider.package = item.npm ? Provider.aisdk(item.npm) : ""
provider.settings = {
...provider.settings,
...withoutCredentials(item.options),
...(item.api ? { baseURL: item.api } : {}),
}
provider.headers = { ...provider.headers, ...item.options?.headers }
provider.package = item.package ?? provider.package
provider.settings = Provider.mergeOverlay(
withoutCredentials(provider.settings),
withoutCredentials(item.settings),
)
provider.headers = Provider.mergeHeaders(provider.headers, item.headers)
provider.body = Provider.mergeOverlay(provider.body, item.body)
})
for (const [modelID, config] of Object.entries(item.models ?? {})) {
const base = source?.models.get(config.modelID ?? modelID) ?? source?.models.get(modelID)
catalog.model.update(providerID, modelID, (model) => {
if (config.family !== undefined) model.family = Model.Family.make(config.family)
Object.assign(model, structuredClone(base ?? model))
if (config.family !== undefined) model.family = config.family
if (config.name !== undefined) model.name = config.name
if (config.id !== undefined) model.modelID = Model.ID.make(config.id)
model.compatibility = Model.compatibility(config.interleaved) ?? model.compatibility
if (config.provider !== undefined) {
model.package = config.provider.npm ? Provider.aisdk(config.provider.npm) : undefined
if (config.provider.api) model.settings = { ...model.settings, baseURL: config.provider.api }
}
if (config.tool_call !== undefined) model.capabilities.tools = config.tool_call
if (config.modalities?.input !== undefined) model.capabilities.input = [...config.modalities.input]
if (config.modalities?.output !== undefined) model.capabilities.output = [...config.modalities.output]
model.headers = { ...model.headers, ...config.headers }
model.settings = { ...model.settings, ...ConfigProviderOptionsV1.model(withoutCredentials(config.options)) }
if (config.variants !== undefined) {
model.variants ??= []
for (const [id, options] of Object.entries(config.variants)) {
const variantID = Model.VariantID.make(id)
let existing = model.variants.find((item) => item.id === variantID)
if (!existing) {
existing = { id: variantID }
model.variants.push(existing)
}
existing.headers = { ...existing.headers, ...options.headers }
existing.settings = {
...existing.settings,
...ConfigProviderOptionsV1.model(withoutCredentials(options)),
}
if (config.modelID !== undefined) model.modelID = config.modelID
if (config.compatibility !== undefined)
model.compatibility = { ...model.compatibility, ...config.compatibility }
model.package = config.package ?? (item.package !== undefined ? undefined : model.package)
if (item.settings?.baseURL !== undefined && model.settings) delete model.settings.baseURL
if (config.capabilities !== undefined)
model.capabilities = {
...config.capabilities,
input: [...config.capabilities.input],
output: [...config.capabilities.output],
}
model.settings = Provider.mergeOverlay(
withoutCredentials(model.settings),
withoutCredentials(config.settings),
)
model.headers = Provider.mergeHeaders(model.headers, config.headers)
model.body = Provider.mergeOverlay(model.body, config.body)
for (const variant of config.variants ?? []) {
let existing = model.variants.find((item) => item.id === variant.id)
if (!existing) {
existing = { id: variant.id }
model.variants.push(existing)
}
if (variant.settings !== undefined)
existing.settings = Provider.mergeOverlay(existing.settings, withoutCredentials(variant.settings))
if (variant.headers !== undefined)
existing.headers = Provider.mergeHeaders(existing.headers, variant.headers)
if (variant.body !== undefined) existing.body = Provider.mergeOverlay(existing.body, variant.body)
}
if (config.release_date !== undefined) {
const released = Date.parse(config.release_date)
model.time.released = Number.isFinite(released) ? released : 0
}
if (config.cost !== undefined) {
model.cost = remoteCost(config.cost)
}
model.status = config.status ?? "active"
model.enabled = config.status !== "deprecated"
if (config.limit !== undefined) model.limit = { ...config.limit }
if (config.cost !== undefined)
model.cost = (Array.isArray(config.cost) ? config.cost : [config.cost]).map((cost) => ({
tier: cost.tier && { ...cost.tier },
input: cost.input,
output: cost.output,
cache: {
read: cost.cache?.read ?? Money.USDPerMillionTokens.zero,
write: cost.cache?.write ?? Money.USDPerMillionTokens.zero,
},
}))
model.enabled = !config.disabled
if (config.limit !== undefined) model.limit = { ...model.limit, ...config.limit }
})
}
}
@@ -208,7 +215,7 @@ function fetchProviders(http: HttpClient.HttpClient, value: Credential.Value) {
const token = value.type === "oauth" ? value.access : value.key
return http
.execute(
HttpClientRequest.get(`${server}/api/config`).pipe(
HttpClientRequest.get(`${server}/api/v2/config`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.bearerToken(token),
HttpClientRequest.setHeaders(orgID ? { "x-org-id": orgID } : {}),
@@ -219,14 +226,17 @@ function fetchProviders(http: HttpClient.HttpClient, value: Credential.Value) {
if (response.status === 404) return Effect.undefined
return HttpClientResponse.filterStatusOk(response).pipe(
Effect.flatMap(HttpClientResponse.schemaBodyJson(RemoteResponse)),
Effect.map((remote) => remote.config.provider),
Effect.map((remote) => remote.providers),
)
}),
)
}
function withoutCredentials(body: Readonly<Record<string, unknown>> | undefined) {
return Object.fromEntries(Object.entries(body ?? {}).filter(([key]) => key !== "apiKey" && key !== "headers"))
function withoutCredentials<Value>(body: Readonly<Record<string, Value>> | undefined) {
return (
body &&
Object.fromEntries(Object.entries(body).filter(([key]) => !["apiKey", "authToken", "accessToken"].includes(key)))
)
}
function normalizeServer(input: unknown) {
@@ -242,30 +252,6 @@ function normalizeServer(input: unknown) {
})
}
function remoteCost(input: NonNullable<(typeof ConfigProviderV1.Model.Type)["cost"]>) {
const base = {
input: Money.USDPerMillionTokens.make(input.input),
output: Money.USDPerMillionTokens.make(input.output),
cache: {
read: Money.USDPerMillionTokens.make(input.cache_read ?? 0),
write: Money.USDPerMillionTokens.make(input.cache_write ?? 0),
},
}
if (!input.context_over_200k) return [base]
return [
base,
{
tier: { type: "context" as const, size: 200_000 },
input: Money.USDPerMillionTokens.make(input.context_over_200k.input),
output: Money.USDPerMillionTokens.make(input.context_over_200k.output),
cache: {
read: Money.USDPerMillionTokens.make(input.context_over_200k.cache_read ?? 0),
write: Money.USDPerMillionTokens.make(input.context_over_200k.cache_write ?? 0),
},
},
]
}
function poll(http: HttpClient.HttpClient, server: string, deviceCode: string, interval: Duration.Duration) {
const loop = (wait: Duration.Duration): Effect.Effect<Credential.OAuth, unknown> =>
Effect.gen(function* () {
+37
View File
@@ -87,6 +87,43 @@ it.effect("keys language models by package and flattened overlays", () =>
}),
)
it.effect("uses canonical names and metadata without merging connection cache partitions", () =>
Effect.gen(function* () {
const aisdk = yield* AISDK.Service
const loaded: string[] = []
yield* aisdk.hook.sdk((event) => {
loaded.push(`${event.model.providerID}:${event.options.name}`)
event.sdk = createOpenAICompatible({
...event.options,
name: String(event.options.name),
baseURL: String(event.options.baseURL),
})
})
const input = {
...model("@ai-sdk/openai-compatible", { baseURL: "https://proxy.example/v1", reasoningEffort: "high" }),
providerID: Provider.ID.make("work"),
canonical: Provider.ID.openai,
}
const first = yield* aisdk.language(input)
const second = yield* aisdk.language({ ...input, providerID: Provider.ID.make("personal") })
const plain = yield* aisdk.language({ ...input, canonical: undefined })
expect(yield* aisdk.language(input)).toBe(first)
expect(first).not.toBe(second)
expect(first).not.toBe(plain)
expect(first).toMatchObject({ modelId: "api-model", provider: "openai.chat" })
expect(plain.provider).toBe("work.chat")
expect(loaded).toEqual(["work:openai", "personal:openai", "work:work"])
const resolved = yield* aisdk.model(input)
expect(resolved).toMatchObject({ id: "api-model", provider: "openai" })
expect(resolved.route).toMatchObject({ provider: "openai", providerMetadataKey: "openai" })
expect(resolved.route.model({ id: "another-model" })).toMatchObject({ provider: "openai" })
const prepared = yield* compileRequest(LLM.request({ model: resolved, prompt: "Hello" }))
expect(prepared.body.providerOptions).toEqual({ openai: { reasoningEffort: "high" } })
}),
)
it.effect("projects request settings, headers, and body overlays", () =>
Effect.gen(function* () {
const aisdk = yield* AISDK.Service
@@ -425,6 +425,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
providers: {
custom: {
name: "Configured",
canonical: "anthropic",
env: ["CUSTOM_API_KEY"],
package: "native",
headers: { first: "first", shared: "first" },
@@ -461,6 +462,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
providers: {
custom: {
package: "aisdk:custom-sdk",
canonical: "anthropic",
settings: { baseURL: "https://example.test" },
headers: { last: "last", shared: "last" },
models: {
@@ -498,12 +500,23 @@ describe("ConfigProviderPlugin.Plugin", () => {
}),
]
yield* catalog.transform((draft) => {
draft.provider.update(Provider.ID.anthropic, (provider) => {
provider.package = "aisdk:@ai-sdk/anthropic"
})
draft.model.update(Provider.ID.anthropic, modelID, (model) => {
model.variants = [{ id: Model.VariantID.make("fast"), settings: { effort: "high" } }]
})
})
yield* addPlugin(entries)
const provider = required(yield* catalog.provider.get(providerID))
const model = required(yield* catalog.model.get(providerID, modelID))
expect((yield* catalog.model.default())?.id).toBe(Model.ID.make("default"))
expect(provider.name).toBe("Renamed")
expect(provider.canonical).toBe(Provider.ID.anthropic)
expect(model.canonical).toBe(Provider.ID.anthropic)
expect(model.providerID).toBe(providerID)
expect((yield* integrations.get(Integration.ID.make("custom")))?.methods).toContainEqual({
type: "env",
names: ["CUSTOM_API_KEY"],
@@ -511,6 +524,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
expect((yield* integrations.get(Integration.ID.make("custom")))?.name).toBe("Renamed")
expect(provider.activation).toBe("enabled")
expect(provider.package).toBe("aisdk:custom-sdk")
expect(model.package).toBe("aisdk:custom-sdk")
expect(provider.settings).toEqual({ baseURL: "https://example.test" })
expect(provider.headers).toEqual({ first: "first", shared: "last", last: "last" })
expect(model.id).toBe(modelID)
@@ -542,6 +556,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
Model.VariantID.make("slow"),
])
expect(model.variants?.[0]?.headers).toEqual({ first: "first", shared: "last", last: "last" })
expect(model.variants?.[0]?.settings).toEqual({ effort: "high" })
expect(model.variants?.[1]?.headers).toEqual({ slow: "slow" })
}),
),
+12 -1
View File
@@ -16,6 +16,7 @@ import { it } from "./lib/effect"
interface ModelOptions {
readonly providerID?: Provider.ID
readonly canonical?: Provider.ID
readonly modelID?: string
readonly compatibility?: Compatibility
readonly settings?: Info["settings"]
@@ -30,6 +31,7 @@ const model = (packageName: string | undefined, options: ModelOptions = {}) =>
id: ID.make("test-model"),
modelID: ID.make(options.modelID ?? "api-test-model"),
providerID: options.providerID ?? Provider.ID.make("test-provider"),
canonical: options.canonical,
name: "Test model",
compatibility: options.compatibility,
package: packageName,
@@ -283,6 +285,7 @@ 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"),
canonical: Provider.ID.google,
settings: { baseURL: "https://gateway.example.com/v1" },
headers: { "cf-access-token": "access-token" },
})
@@ -309,7 +312,10 @@ describe("ModelResolver", () => {
})
const integrations = Layer.mock(Integration.Service, {
connection: {
active: () => Effect.undefined,
active: (id) => {
expect(id).toBe(Integration.ID.make("gateway"))
return Effect.undefined
},
resolve: () => Effect.die("unused"),
key: () => Effect.die("unused"),
activate: () => Effect.die("unused"),
@@ -347,6 +353,8 @@ describe("ModelResolver", () => {
const resolved = yield* resolver.resolveModel(selected)
expect(resolved.limit).toEqual(selected.limit)
expect(resolved.ref.providerID).toBe(selected.providerID)
expect(String(resolved.model.provider)).toBe("google")
const headers = yield* resolved.model.route.auth.apply({
request: LLM.request({ model: resolved.model, prompt: "Hello" }),
method: "POST",
@@ -389,6 +397,7 @@ describe("ModelResolver", () => {
Effect.gen(function* () {
const resolved = yield* ModelResolver.fromCatalogModel(
model(Provider.aisdk("@ai-sdk/openai-compatible"), {
canonical: Provider.ID.make("deepseek"),
compatibility: {
reasoningField: "vendor_reasoning",
requireReasoning: true,
@@ -417,6 +426,8 @@ describe("ModelResolver", () => {
expect(headers.authorization).toBe("Bearer settings-secret")
expect(resolved.route.id).toBe("openai-compatible-chat")
expect(String(resolved.provider)).toBe("deepseek")
expect(resolved.route.providerMetadataKey).toBe("deepseek")
expect(resolved.compatibility?.reasoningField).toBe("vendor_reasoning")
expect(resolved.compatibility?.requireReasoning).toBe(true)
expect(resolved.compatibility?.maxTokensField).toBe("max_completion_tokens")
@@ -210,49 +210,68 @@ describe("OpencodePlugin", () => {
}),
)
it.live("loads providers and models from the connected OpenCode server", () =>
it.live("loads native V2 providers and models from the connected OpenCode server", () =>
Effect.acquireUseRelease(
Effect.sync(() => {
const authorization: Array<string | null> = []
const requests: string[] = []
return {
authorization,
requests,
server: Bun.serve({
port: 0,
fetch: (request) => {
authorization.push(request.headers.get("authorization"))
requests.push(`${request.method} ${new URL(request.url).pathname}`)
const origin = new URL(request.url).origin
return Response.json({
config: {
enterprise: { url: origin },
provider: {
remote: {
name: "Remote",
npm: "@ai-sdk/openai-compatible",
api: `${origin}/v1`,
env: ["REMOTE_API_KEY"],
options: {
apiKey: "{env:REMOTE_API_KEY}",
headers: { "x-org-id": "org" },
custom: "value",
},
models: {
model: {
name: "Remote Model",
family: "remote",
release_date: "2026-01-02",
tool_call: true,
modalities: { input: ["text", "image"], output: ["text"] },
options: { apiKey: "model-secret", temperature: 0.5 },
variants: { high: { apiKey: "variant-secret", temperature: 0.2 } },
cost: { input: 1, output: 2, cache_read: 0.1 },
limit: { context: 1000, output: 100 },
providers: {
remote: {
canonical: "openai",
name: "Remote",
package: "aisdk:@ai-sdk/openai-compatible",
env: ["REMOTE_API_KEY"],
settings: {
baseURL: `${origin}/v1`,
apiKey: "{env:REMOTE_API_KEY}",
authToken: "provider-auth",
accessToken: "provider-access",
custom: "value",
},
headers: { "x-org-id": "org" },
models: {
model: {
modelID: "api-model",
name: "Remote Model",
family: "remote",
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
settings: {
apiKey: "model-secret",
authToken: "model-auth",
accessToken: "model-access",
temperature: 0.5,
},
override: {
name: "Override",
provider: { npm: "@ai-sdk/anthropic", api: `${origin}/anthropic` },
},
disabled: { name: "Disabled", status: "deprecated" },
variants: [
{
id: "high",
settings: {
apiKey: "variant-secret",
authToken: "variant-auth",
accessToken: "variant-access",
temperature: 0.2,
},
headers: { "x-variant": "high" },
},
],
cost: { input: 1, output: 2, cache: { read: 0.1 } },
limit: { context: 1000, output: 100 },
},
override: {
name: "Override",
package: "aisdk:@ai-sdk/anthropic",
settings: { baseURL: `${origin}/anthropic` },
},
disabled: { name: "Disabled", disabled: true },
},
},
},
@@ -261,13 +280,19 @@ describe("OpencodePlugin", () => {
}),
}
}),
({ authorization, server }) =>
({ authorization, requests, server }) =>
Effect.gen(function* () {
const credentials = yield* Credential.Service
const catalog = yield* Catalog.Service
const integrations = yield* Integration.Service
yield* catalog.transform((draft) => {
draft.provider.update(Provider.ID.make("remote"), () => {})
draft.model.update(Provider.ID.make("remote"), Model.ID.make("model"), (model) => {
draft.provider.update(Provider.ID.openai, (provider) => {
provider.package = Provider.aisdk("@ai-sdk/openai")
provider.integrationID = Integration.ID.make("openai")
})
draft.model.update(Provider.ID.openai, Model.ID.make("api-model"), (model) => {
model.package = Provider.aisdk("@ai-sdk/openai")
model.settings = { baseURL: "https://upstream.example/v1" }
model.variants = [
{
id: Model.VariantID.make("custom"),
@@ -290,19 +315,27 @@ describe("OpencodePlugin", () => {
yield* addPlugin()
expect(authorization).toEqual(["Bearer secret"])
expect(requests).toEqual(["GET /api/v2/config"])
const provider = required(yield* catalog.provider.get(Provider.ID.make("remote")))
expect(provider).toMatchObject({
id: "remote",
canonical: "openai",
name: "Remote",
integrationID: "opencode",
package: Provider.aisdk("@ai-sdk/openai-compatible"),
settings: { baseURL: `${server.url.origin}/v1`, custom: "value" },
headers: { "x-org-id": "org" },
})
expect(yield* (yield* Integration.Service).get(Integration.ID.make("remote"))).toBeUndefined()
expect(provider.settings).toEqual({ baseURL: `${server.url.origin}/v1`, custom: "value" })
expect(yield* integrations.get(Integration.ID.make("remote"))).toBeUndefined()
const model = required(yield* catalog.model.get(Provider.ID.make("remote"), Model.ID.make("model")))
expect(model).toMatchObject({
id: "model",
modelID: "api-model",
providerID: "remote",
canonical: "openai",
name: "Remote Model",
family: "remote",
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
@@ -312,6 +345,7 @@ describe("OpencodePlugin", () => {
settings: { baseURL: `${server.url.origin}/v1`, custom: "value", temperature: 0.5 },
headers: { "x-org-id": "org" },
})
expect(model.settings).toEqual({ baseURL: `${server.url.origin}/v1`, custom: "value", temperature: 0.5 })
const override = required(yield* catalog.model.get(Provider.ID.make("remote"), Model.ID.make("override")))
expect(override.package).toBe(Provider.aisdk("@ai-sdk/anthropic"))
expect(override.settings?.baseURL).toBe(`${server.url.origin}/anthropic`)
@@ -325,13 +359,16 @@ describe("OpencodePlugin", () => {
{
id: Model.VariantID.make("high"),
settings: { temperature: 0.2 },
headers: {},
headers: { "x-variant": "high" },
},
])
expect(
required(yield* catalog.model.get(Provider.ID.make("remote"), Model.ID.make("disabled"))).enabled,
).toBe(false)
expect(yield* catalog.model.get(Provider.ID.make("remote"), Model.ID.make("stale"))).toBeDefined()
expect((yield* catalog.model.get(Provider.ID.openai, Model.ID.make("api-model")))?.settings?.baseURL).toBe(
"https://upstream.example/v1",
)
yield* credentials.update(initial.id, { label: "Renamed" })
yield* Effect.yieldNow
@@ -350,6 +387,7 @@ describe("OpencodePlugin", () => {
(count) => count === 2,
)
expect(authorization).toEqual(["Bearer secret", "Bearer replacement"])
expect(requests).toEqual(["GET /api/v2/config", "GET /api/v2/config"])
yield* credentials.remove(initial.id)
yield* Effect.yieldNow
+2
View File
@@ -3,6 +3,7 @@ export * as ConfigProvider from "./provider.js"
import { Schema } from "effect"
import { Money } from "../money.js"
import { Capabilities, Compatibility, Family, ID, VariantID } from "../model.js"
import { Provider } from "../provider.js"
import { optional } from "../schema.js"
const JsonRecord = Schema.Record(Schema.String, Schema.Json)
@@ -57,6 +58,7 @@ class Model extends Schema.Class<Model>("Config.Model")({
}) {}
export class Info extends Schema.Class<Info>("Config.Provider")({
canonical: Provider.ID.pipe(optional),
name: Schema.String.pipe(optional),
env: Schema.String.pipe(Schema.Array, optional),
package: Schema.String.pipe(optional),
+1
View File
@@ -101,6 +101,7 @@ export const Info = Schema.Struct({
id: ID,
modelID: ID,
providerID: Provider.ID,
canonical: Provider.ID.pipe(optional),
family: Family.pipe(optional),
name: Schema.String,
compatibility: Compatibility.pipe(optional),
+1
View File
@@ -47,6 +47,7 @@ export const Request = Schema.Struct({
export interface Info extends Schema.Schema.Type<typeof Info> {}
export const Info = Schema.Struct({
id: ID,
canonical: ID.pipe(optional),
integrationID: Integration.ID.pipe(optional),
name: Schema.String,
activation: Activation,
+11 -1
View File
@@ -5,10 +5,19 @@ import { ConfigAgent } from "../src/config/agent.js"
import { ConfigMCP } from "../src/config/mcp.js"
import { ConfigProvider } from "../src/config/provider.js"
import { Mcp } from "../src/mcp.js"
import { Provider } from "../src/provider.js"
import { AbsolutePath } from "../src/schema.js"
import { WebSearch } from "../src/websearch.js"
describe("Config.Entry", () => {
test("round-trips canonical provider IDs without changing config keys", () => {
const input = { providers: { "console-anthropic": { canonical: "anthropic" } } }
const decoded = Schema.decodeUnknownSync(Config.Info)(input)
expect(decoded.providers?.["console-anthropic"]?.canonical).toBe(Provider.ID.anthropic)
expect(Schema.encodeSync(Config.Info)(decoded)).toEqual(input)
expect(() => Schema.decodeUnknownSync(Config.Info)({ providers: { custom: { canonical: 1 } } })).toThrow()
})
test("accepts disabled, fixed, and random web search selection", () => {
const decode = Schema.decodeUnknownSync(Config.Info)
@@ -70,7 +79,7 @@ describe("Config.Entry", () => {
}),
},
}),
providers: { custom: new ConfigProvider.Info({ headers: undefined }) },
providers: { custom: new ConfigProvider.Info({ canonical: undefined, headers: undefined }) },
}),
})
const encoded = Schema.encodeSync(Config.Entry)(entry)
@@ -85,5 +94,6 @@ describe("Config.Entry", () => {
expect(docs).not.toHaveProperty("headers")
expect(docs.oauth).not.toHaveProperty("client_id")
expect(encoded.info.providers?.custom).not.toHaveProperty("headers")
expect(encoded.info.providers?.custom).not.toHaveProperty("canonical")
})
})