refactor(core): refine provider config schema
This commit is contained in:
@@ -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 }
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}),
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user