From a747013bf65e0ec1bb9fe16177ddcb6623618a28 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Thu, 28 May 2026 11:14:53 -0400 Subject: [PATCH] refactor(core): refine provider config schema --- packages/core/src/config/agent.ts | 4 +- packages/core/src/config/plugin/provider.ts | 95 ++++++++++++++ packages/core/src/config/provider.ts | 135 ++++++-------------- packages/core/src/plugin/boot.ts | 4 +- packages/core/test/config/config.test.ts | 6 +- packages/core/test/config/provider.test.ts | 28 ++-- specs/v2/config.md | 29 +++++ 7 files changed, 179 insertions(+), 122 deletions(-) create mode 100644 packages/core/src/config/plugin/provider.ts diff --git a/packages/core/src/config/agent.ts b/packages/core/src/config/agent.ts index 0606ac36c..40d2bc94b 100644 --- a/packages/core/src/config/agent.ts +++ b/packages/core/src/config/agent.ts @@ -2,7 +2,7 @@ export * as ConfigAgent from "./agent" import { Schema } from "effect" import { PermissionV2 } from "../permission" -import { ProviderV2 } from "../provider" +import { ConfigProvider } from "./provider" import { PositiveInt } from "../schema" export const Color = Schema.Union([ @@ -13,7 +13,7 @@ export const Color = Schema.Union([ export class Info extends Schema.Class("ConfigV2.Agent")({ model: Schema.String.pipe(Schema.optional), variant: Schema.String.pipe(Schema.optional), - options: ProviderV2.Options.pipe(Schema.optional), + options: ConfigProvider.Options.pipe(Schema.optional), system: Schema.String.pipe(Schema.optional), description: Schema.String.pipe(Schema.optional), mode: Schema.Literals(["subagent", "primary", "all"]).pipe(Schema.optional), diff --git a/packages/core/src/config/plugin/provider.ts b/packages/core/src/config/plugin/provider.ts new file mode 100644 index 000000000..4c50b8096 --- /dev/null +++ b/packages/core/src/config/plugin/provider.ts @@ -0,0 +1,95 @@ +export * as ConfigProviderPlugin from "./provider" + +import { Effect } from "effect" +import { Catalog } from "../../catalog" +import { Config } from "../../config" +import { ModelV2 } from "../../model" +import { PluginV2 } from "../../plugin" +import { ProviderV2 } from "../../provider" + +export const Plugin = PluginV2.define({ + id: PluginV2.ID.make("config-provider"), + effect: Effect.gen(function* () { + const catalog = yield* Catalog.Service + const config = yield* Config.Service + const load = yield* catalog.loader() + const files = yield* config.get() + + yield* load((catalog) => { + for (const file of files) { + for (const [id, item] of Object.entries(file.info.providers ?? {})) { + const providerID = ProviderV2.ID.make(id) + catalog.provider.update(providerID, (provider) => { + if (item.name !== undefined) provider.name = item.name + if (item.env !== undefined) provider.env = [...item.env] + provider.enabled = { via: "custom", data: {} } + if (item.endpoint !== undefined) provider.endpoint = { ...item.endpoint } + if (item.options !== undefined) { + Object.assign(provider.options.headers, item.options.headers ?? {}) + Object.assign(provider.options.body, item.options.body ?? {}) + Object.assign(provider.options.aisdk.provider, item.options.aisdk?.provider ?? {}) + Object.assign(provider.options.aisdk.request, item.options.aisdk?.request ?? {}) + } + }) + + for (const [id, config] of Object.entries(item.models ?? {})) { + catalog.model.update(providerID, ModelV2.ID.make(id), (model) => { + if (config.api_id !== undefined) model.apiID = config.api_id + if (config.family !== undefined) model.family = config.family + if (config.name !== undefined) model.name = config.name + if (config.endpoint !== undefined) model.endpoint = { ...config.endpoint } + if (config.capabilities !== undefined) { + model.capabilities = { + tools: config.capabilities.tools, + input: [...config.capabilities.input], + output: [...config.capabilities.output], + } + } + if (config.options !== undefined) { + Object.assign(model.options.headers, config.options.headers ?? {}) + Object.assign(model.options.body, config.options.body ?? {}) + Object.assign(model.options.aisdk.provider, config.options.aisdk?.provider ?? {}) + Object.assign(model.options.aisdk.request, config.options.aisdk?.request ?? {}) + if (config.options.variant !== undefined) model.options.variant = config.options.variant + } + if (config.variants !== undefined) { + for (const variant of config.variants) { + let existing = model.variants.find((item) => item.id === variant.id) + if (!existing) { + existing = { + id: variant.id, + headers: {}, + body: {}, + aisdk: { + provider: {}, + request: {}, + }, + } + model.variants.push(existing) + } + Object.assign(existing.headers, variant.headers ?? {}) + Object.assign(existing.body, variant.body ?? {}) + Object.assign(existing.aisdk.provider, variant.aisdk?.provider ?? {}) + Object.assign(existing.aisdk.request, variant.aisdk?.request ?? {}) + } + } + 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 ?? 0, + write: cost.cache?.write ?? 0, + }, + })) + } + if (config.disabled !== undefined) model.enabled = !config.disabled + if (config.limit !== undefined) model.limit = { ...model.limit, ...config.limit } + }) + } + } + } + }) + }), +}) diff --git a/packages/core/src/config/provider.ts b/packages/core/src/config/provider.ts index 4e0c85799..fbb0e1c3e 100644 --- a/packages/core/src/config/provider.ts +++ b/packages/core/src/config/provider.ts @@ -1,121 +1,62 @@ export * as ConfigProvider from "./provider" -import { Effect, Schema } from "effect" -import { Catalog } from "../catalog" -import { Config } from "../config" +import { Schema } from "effect" import { ProviderV2 } from "../provider" import { ModelV2 } from "../model" -import { PluginV2 } from "../plugin" + +export class Options extends Schema.Class("ConfigV2.Provider.Options")({ + headers: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional), + body: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), + aisdk: Schema.Struct({ + provider: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), + request: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), + }).pipe(Schema.optional), +}) {} + +class Cache extends Schema.Class("ConfigV2.Model.Cost.Cache")({ + read: Schema.Finite.pipe(Schema.optional), + write: Schema.Finite.pipe(Schema.optional), +}) {} + +class Cost extends Schema.Class("ConfigV2.Model.Cost")({ + tier: Schema.Struct({ + type: Schema.Literal("context"), + size: Schema.Int, + }).pipe(Schema.optional), + input: Schema.Finite, + output: Schema.Finite, + cache: Cache.pipe(Schema.optional), +}) {} + +class Limit extends Schema.Class("ConfigV2.Model.Limit")({ + context: Schema.Int.pipe(Schema.optional), + input: Schema.Int.pipe(Schema.optional), + output: Schema.Int.pipe(Schema.optional), +}) {} class Model extends Schema.Class("ConfigV2.Model")({ - apiID: ModelV2.ID.pipe(Schema.optional), + api_id: ModelV2.ID.pipe(Schema.optional), family: ModelV2.Family.pipe(Schema.optional), name: Schema.String.pipe(Schema.optional), endpoint: ProviderV2.Endpoint.pipe(Schema.optional), capabilities: ModelV2.Capabilities.pipe(Schema.optional), options: Schema.Struct({ - ...ProviderV2.Options.fields, + ...Options.fields, variant: Schema.String.pipe(Schema.optional), }).pipe(Schema.optional), variants: Schema.Struct({ id: ModelV2.VariantID, - ...ProviderV2.Options.fields, + ...Options.fields, }).pipe(Schema.Array, Schema.optional), - cost: ModelV2.Cost.pipe(Schema.Array).pipe(Schema.optional), + cost: Schema.Union([Cost, Cost.pipe(Schema.Array)]).pipe(Schema.optional), disabled: Schema.Boolean.pipe(Schema.optional), - limit: Schema.Struct({ - context: Schema.Int, - input: Schema.Int.pipe(Schema.optional), - output: Schema.Int, - }).pipe(Schema.optional), + limit: Limit.pipe(Schema.optional), }) {} export class Info extends Schema.Class("ConfigV2.Provider")({ name: Schema.String.pipe(Schema.optional), + env: Schema.String.pipe(Schema.Array, Schema.optional), endpoint: ProviderV2.Endpoint.pipe(Schema.optional), - options: ProviderV2.Options.pipe(Schema.optional), + options: Options.pipe(Schema.optional), models: Schema.Record(Schema.String, Model).pipe(Schema.optional), }) {} - -export const Plugin = PluginV2.define({ - id: PluginV2.ID.make("config-provider"), - effect: Effect.gen(function* () { - const catalog = yield* Catalog.Service - const config = yield* Config.Service - const load = yield* catalog.loader() - const files = yield* config.get() - - yield* load((catalog) => { - for (const file of files) { - for (const [id, item] of Object.entries(file.info.providers ?? {})) { - const providerID = ProviderV2.ID.make(id) - catalog.provider.update(providerID, (provider) => { - if (item.name !== undefined) provider.name = item.name - provider.enabled = { via: "custom", data: {} } - if (item.endpoint !== undefined) provider.endpoint = { ...item.endpoint } - if (item.options !== undefined) { - Object.assign(provider.options.headers, item.options.headers) - Object.assign(provider.options.body, item.options.body) - Object.assign(provider.options.aisdk.provider, item.options.aisdk.provider) - Object.assign(provider.options.aisdk.request, item.options.aisdk.request) - } - }) - - for (const [id, config] of Object.entries(item.models ?? {})) { - catalog.model.update(providerID, ModelV2.ID.make(id), (model) => { - if (config.apiID !== undefined) model.apiID = config.apiID - if (config.family !== undefined) model.family = config.family - if (config.name !== undefined) model.name = config.name - if (config.endpoint !== undefined) model.endpoint = { ...config.endpoint } - if (config.capabilities !== undefined) { - model.capabilities = { - tools: config.capabilities.tools, - input: [...config.capabilities.input], - output: [...config.capabilities.output], - } - } - if (config.options !== undefined) { - Object.assign(model.options.headers, config.options.headers) - Object.assign(model.options.body, config.options.body) - Object.assign(model.options.aisdk.provider, config.options.aisdk.provider) - Object.assign(model.options.aisdk.request, config.options.aisdk.request) - if (config.options.variant !== undefined) model.options.variant = config.options.variant - } - if (config.variants !== undefined) { - for (const variant of config.variants) { - let existing = model.variants.find((item) => item.id === variant.id) - if (!existing) { - existing = { - id: variant.id, - headers: {}, - body: {}, - aisdk: { - provider: {}, - request: {}, - }, - } - model.variants.push(existing) - } - Object.assign(existing.headers, variant.headers) - Object.assign(existing.body, variant.body) - Object.assign(existing.aisdk.provider, variant.aisdk.provider) - Object.assign(existing.aisdk.request, variant.aisdk.request) - } - } - if (config.cost !== undefined) { - model.cost = config.cost.map((cost) => ({ - tier: cost.tier && { ...cost.tier }, - input: cost.input, - output: cost.output, - cache: { ...cost.cache }, - })) - } - if (config.disabled !== undefined) model.enabled = !config.disabled - if (config.limit !== undefined) model.limit = { ...config.limit } - }) - } - } - } - }) - }), -}) diff --git a/packages/core/src/plugin/boot.ts b/packages/core/src/plugin/boot.ts index c8c8b4061..aac7011be 100644 --- a/packages/core/src/plugin/boot.ts +++ b/packages/core/src/plugin/boot.ts @@ -4,11 +4,11 @@ import { Context, Deferred, Effect, Layer } from "effect" import { AccountV2 } from "../account" import { Catalog } from "../catalog" import { Config } from "../config" -import { ConfigProvider } from "../config/provider" import { EventV2 } from "../event" import { Npm } from "../npm" import { PluginV2 } from "../plugin" import { AccountPlugin } from "./account" +import { ConfigProviderPlugin } from "../config/plugin/provider" import { EnvPlugin } from "./env" import { ModelsDevPlugin } from "./models-dev" import { ProviderPlugins } from "./provider" @@ -58,7 +58,7 @@ export const layer = Layer.effect( yield* add(item) } yield* add(ModelsDevPlugin) - yield* add(ConfigProvider.Plugin) + yield* add(ConfigProviderPlugin.Plugin) }).pipe(Effect.withSpan("PluginBoot.boot")) yield* boot.pipe( diff --git a/packages/core/test/config/config.test.ts b/packages/core/test/config/config.test.ts index 2bf3784b5..53c4eb22a 100644 --- a/packages/core/test/config/config.test.ts +++ b/packages/core/test/config/config.test.ts @@ -181,8 +181,7 @@ describe("Config", () => { variant: "high", options: { headers: { "x-agent": "reviewer" }, - body: {}, - aisdk: { provider: {}, request: { reasoningEffort: "high" } }, + aisdk: { request: { reasoningEffort: "high" } }, }, description: "Review changes for correctness", system: "Find regressions.", @@ -260,8 +259,7 @@ describe("Config", () => { variant: "high", options: { headers: { "x-agent": "reviewer" }, - body: {}, - aisdk: { provider: {}, request: { reasoningEffort: "high" } }, + aisdk: { request: { reasoningEffort: "high" } }, }, description: "Review changes for correctness", system: "Find regressions.", diff --git a/packages/core/test/config/provider.test.ts b/packages/core/test/config/provider.test.ts index b95261e05..5fa487470 100644 --- a/packages/core/test/config/provider.test.ts +++ b/packages/core/test/config/provider.test.ts @@ -2,7 +2,7 @@ import { describe, expect } from "bun:test" import { Effect, Schema } from "effect" import { Catalog } from "@opencode-ai/core/catalog" import { Config } from "@opencode-ai/core/config" -import { ConfigProvider } from "@opencode-ai/core/config/provider" +import { ConfigProviderPlugin } from "@opencode-ai/core/config/plugin/provider" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" import { ProviderV2 } from "@opencode-ai/core/provider" @@ -11,18 +11,13 @@ import { it } from "../plugin/provider-helper" function options(headers: Record, variant?: string) { return { headers, - body: {}, - aisdk: { - provider: {}, - request: {}, - }, variant, } } const decode = Schema.decodeUnknownSync(Config.Info) -describe("ConfigProvider.Plugin", () => { +describe("ConfigProviderPlugin.Plugin", () => { it.effect("loads configured providers and applies later model overrides", () => Effect.gen(function* () { const catalog = yield* Catalog.Service @@ -39,6 +34,7 @@ describe("ConfigProvider.Plugin", () => { providers: { custom: { name: "Configured", + env: ["CUSTOM_API_KEY"], endpoint: { type: "unknown" }, options: options({ first: "first", shared: "first" }), models: { @@ -47,13 +43,12 @@ describe("ConfigProvider.Plugin", () => { capabilities: { tools: true, input: ["text"], output: ["text"] }, disabled: true, limit: { context: 100, output: 50 }, + cost: { input: 1, output: 2 }, options: options({ first: "first", shared: "first" }, "retained"), variants: [ { id: "fast", headers: { first: "first", shared: "first" }, - body: {}, - aisdk: { provider: {}, request: {} }, }, ], }, @@ -71,21 +66,18 @@ describe("ConfigProvider.Plugin", () => { options: options({ last: "last", shared: "last" }), models: { chat: { - apiID: "api-chat", + api_id: "api-chat", name: "Last", + limit: { output: 75 }, options: options({ last: "last", shared: "last" }), variants: [ { id: "fast", headers: { last: "last", shared: "last" }, - body: {}, - aisdk: { provider: {}, request: {} }, }, { id: "slow", headers: { slow: "slow" }, - body: {}, - aisdk: { provider: {}, request: {} }, }, ], }, @@ -106,8 +98,8 @@ describe("ConfigProvider.Plugin", () => { }) yield* plugin.add({ - ...ConfigProvider.Plugin, - effect: ConfigProvider.Plugin.effect.pipe( + ...ConfigProviderPlugin.Plugin, + effect: ConfigProviderPlugin.Plugin.effect.pipe( Effect.provideService(Config.Service, config), Effect.provideService(Catalog.Service, catalog), ), @@ -116,6 +108,7 @@ describe("ConfigProvider.Plugin", () => { const provider = yield* catalog.provider.get(providerID) const model = yield* catalog.model.get(providerID, modelID) expect(provider.name).toBe("Renamed") + expect(provider.env).toEqual(["CUSTOM_API_KEY"]) expect(provider.enabled).toEqual({ via: "custom", data: {} }) expect(provider.endpoint).toEqual({ type: "aisdk", package: "custom-sdk", url: "https://example.test" }) expect(provider.options.headers).toEqual({ first: "first", shared: "last", last: "last" }) @@ -123,7 +116,8 @@ describe("ConfigProvider.Plugin", () => { expect(model.name).toBe("Last") expect(model.capabilities).toEqual({ tools: true, input: ["text"], output: ["text"] }) expect(model.enabled).toBe(false) - expect(model.limit).toEqual({ context: 100, output: 50 }) + expect(model.limit).toEqual({ context: 100, output: 75 }) + expect(model.cost).toEqual([{ input: 1, output: 2, cache: { read: 0, write: 0 }, tier: undefined }]) expect(model.options.headers).toEqual({ first: "first", shared: "last", last: "last" }) expect(model.options.variant).toBe("retained") expect(model.variants.map((variant) => variant.id)).toEqual([ diff --git a/specs/v2/config.md b/specs/v2/config.md index 269dd46df..55caf2e66 100644 --- a/specs/v2/config.md +++ b/specs/v2/config.md @@ -202,6 +202,35 @@ Keep `model` as the default model fallback. It is application-wide behavior used Do not port `small_model`. In the current runtime it is only consulted while generating a session title: the `title` agent model wins first, then `small_model`, then automatic/current-model fallback. In v2, users who need a specific title model should configure the `title` agent directly rather than use a separate top-level model setting. +Provider, model, variant, and provisional agent `options` are authored as partial patches rather than fully materialized runtime option records. Users should be able to set only the override they need, such as a header or an AI SDK request option; catalog state supplies empty defaults and merges patches in configuration order. + +Keep provider `env` as an authored list of recognized credential environment variable names. Built-in catalog providers already carry this metadata for automatic environment-backed availability, and configured providers may need to declare the same source. For a configured provider this is additive metadata, not a requirement that one of the variables exists: the provider may instead be usable through configured options, a stored account, or an endpoint that needs no credential. + +Within configured models, rename legacy upstream model identifier `id` to `api_id` rather than exposing camelCase runtime `apiID`. Model `limit` is an authored patch, so an override may change only `context`, `input`, or `output`. Model `cost` accepts one simple pricing object or an array of tiered pricing entries; omitted cache prices default to zero. + +Do not port legacy provider model `reasoning`, `temperature`, or `interleaved` flags as first-class config fields; provider/request behavior belongs in structured `options` or model variants. Do not port `release_date`, `status`, `experimental`, `whitelist`, or `blacklist` in this v2 surface. + +```jsonc +{ + "providers": { + "internal": { + "env": ["INTERNAL_LLM_API_KEY"], + "options": { "headers": { "Authorization": "Bearer {env:API_KEY}" } }, + "models": { + "chat": { + "api_id": "upstream-chat-model", + "limit": { "output": 32768 }, + "cost": { "input": 1.25, "output": 10 }, + "variants": [ + { "id": "high", "aisdk": { "request": { "reasoningEffort": "high" } } }, + ], + }, + }, + }, + }, +} +``` + ## Group 8: Agents And Permissions Agent behavior and tool-access policy. Review together because agent configuration can contain permissions and model choices.