refactor(core): refine provider config schema

This commit is contained in:
Dax Raad
2026-05-28 11:14:53 -04:00
parent 864f54cc46
commit a747013bf6
7 changed files with 179 additions and 122 deletions
+2 -2
View File
@@ -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<Info>("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),
@@ -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 }
})
}
}
}
})
}),
})
+38 -97
View File
@@ -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<Options>("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<Cache>("ConfigV2.Model.Cost.Cache")({
read: Schema.Finite.pipe(Schema.optional),
write: Schema.Finite.pipe(Schema.optional),
}) {}
class Cost extends Schema.Class<Cost>("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<Limit>("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<Model>("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<Info>("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 }
})
}
}
}
})
}),
})
+2 -2
View File
@@ -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(
+2 -4
View File
@@ -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.",
+11 -17
View File
@@ -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<string, string>, 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([
+29
View File
@@ -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.