From b525ee0dfae75f88833b3f466c58a1061e219462 Mon Sep 17 00:00:00 2001 From: vimtor Date: Sat, 29 Aug 2026 14:21:27 +0200 Subject: [PATCH] feat(core): support canonical provider config --- .../client/src/promise/generated/types.ts | 3 + packages/core/src/aisdk.ts | 13 +- packages/core/src/catalog.ts | 1 + packages/core/src/config/plugin/provider.ts | 16 ++ packages/core/src/model-resolver.ts | 5 +- packages/core/src/plugin/provider/opencode.ts | 140 ++++++++---------- packages/core/test/aisdk.test.ts | 37 +++++ packages/core/test/config/provider.test.ts | 15 ++ packages/core/test/model-resolver.test.ts | 13 +- .../test/plugin/provider-opencode.test.ts | 108 +++++++++----- packages/schema/src/config/provider.ts | 2 + packages/schema/src/model.ts | 1 + packages/schema/src/provider.ts | 1 + packages/schema/test/config.test.ts | 12 +- 14 files changed, 246 insertions(+), 121 deletions(-) diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index ac01aa94a7f0..aa90c29d4a84 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -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 package?: string diff --git a/packages/core/src/aisdk.ts b/packages/core/src/aisdk.ts index 22a7d2d1b092..ba1ff2872e1b 100644 --- a/packages/core/src/aisdk.ts +++ b/packages/core/src/aisdk.ts @@ -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 = { - 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, }) diff --git a/packages/core/src/catalog.ts b/packages/core/src/catalog.ts index 8c313ccb414b..ea7ebe169866 100644 --- a/packages/core/src/catalog.ts +++ b/packages/core/src/catalog.ts @@ -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), diff --git a/packages/core/src/config/plugin/provider.ts b/packages/core/src/config/plugin/provider.ts index 8d51e78cfccb..7c65e0b514e9 100644 --- a/packages/core/src/config/plugin/provider.ts +++ b/packages/core/src/config/plugin/provider.ts @@ -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 diff --git a/packages/core/src/model-resolver.ts b/packages/core/src/model-resolver.ts index 6750e7c24898..36d175a039b6 100644 --- a/packages/core/src/model-resolver.ts +++ b/packages/core/src/model-resolver.ts @@ -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, diff --git a/packages/core/src/plugin/provider/opencode.ts b/packages/core/src/plugin/provider/opencode.ts index d875aed629f9..dd6f5524e517 100644 --- a/packages/core/src/plugin/provider/opencode.ts +++ b/packages/core/src/plugin/provider/opencode.ts @@ -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 { 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> | undefined) { - return Object.fromEntries(Object.entries(body ?? {}).filter(([key]) => key !== "apiKey" && key !== "headers")) +function withoutCredentials(body: Readonly> | 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 => Effect.gen(function* () { diff --git a/packages/core/test/aisdk.test.ts b/packages/core/test/aisdk.test.ts index 0de60087a295..30538e2389fe 100644 --- a/packages/core/test/aisdk.test.ts +++ b/packages/core/test/aisdk.test.ts @@ -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 diff --git a/packages/core/test/config/provider.test.ts b/packages/core/test/config/provider.test.ts index aeccd90e1679..e8d512d02d5f 100644 --- a/packages/core/test/config/provider.test.ts +++ b/packages/core/test/config/provider.test.ts @@ -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" }) }), ), diff --git a/packages/core/test/model-resolver.test.ts b/packages/core/test/model-resolver.test.ts index 2552153131df..f185e972af14 100644 --- a/packages/core/test/model-resolver.test.ts +++ b/packages/core/test/model-resolver.test.ts @@ -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") diff --git a/packages/core/test/plugin/provider-opencode.test.ts b/packages/core/test/plugin/provider-opencode.test.ts index 5b902c32cd5c..735f00ba46f5 100644 --- a/packages/core/test/plugin/provider-opencode.test.ts +++ b/packages/core/test/plugin/provider-opencode.test.ts @@ -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 = [] + 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 }, - }, - override: { - name: "Override", - provider: { npm: "@ai-sdk/anthropic", api: `${origin}/anthropic` }, + 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, }, - 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 diff --git a/packages/schema/src/config/provider.ts b/packages/schema/src/config/provider.ts index 4947a1f57571..c90482e6ed40 100644 --- a/packages/schema/src/config/provider.ts +++ b/packages/schema/src/config/provider.ts @@ -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("Config.Model")({ }) {} export class Info extends Schema.Class("Config.Provider")({ + canonical: Provider.ID.pipe(optional), name: Schema.String.pipe(optional), env: Schema.String.pipe(Schema.Array, optional), package: Schema.String.pipe(optional), diff --git a/packages/schema/src/model.ts b/packages/schema/src/model.ts index f4a6d1513233..efe09fa44b7a 100644 --- a/packages/schema/src/model.ts +++ b/packages/schema/src/model.ts @@ -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), diff --git a/packages/schema/src/provider.ts b/packages/schema/src/provider.ts index cb60f6963259..11a8ef414b3c 100644 --- a/packages/schema/src/provider.ts +++ b/packages/schema/src/provider.ts @@ -47,6 +47,7 @@ export const Request = Schema.Struct({ export interface Info extends Schema.Schema.Type {} export const Info = Schema.Struct({ id: ID, + canonical: ID.pipe(optional), integrationID: Integration.ID.pipe(optional), name: Schema.String, activation: Activation, diff --git a/packages/schema/test/config.test.ts b/packages/schema/test/config.test.ts index 9a3cbb5fe7fb..1bf24bb577b2 100644 --- a/packages/schema/test/config.test.ts +++ b/packages/schema/test/config.test.ts @@ -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") }) })