refactor(schema): flatten provider identity

This commit is contained in:
Shoubhit Dash 2026-06-25 19:49:14 +05:30
commit 1a9bd8c1fd
16 changed files with 262 additions and 436 deletions

View file

@ -7,7 +7,6 @@ import { EventV2 } from "./event"
import { Policy } from "./policy"
import { State } from "./state"
import { Integration } from "./integration"
import { ProviderOverlay } from "./provider-overlay"
export type ProviderRecord = {
provider: ProviderV2.MutableInfo
@ -78,32 +77,16 @@ export const layer = Layer.effect(
const projectModel = (model: ModelV2.Info, provider: ProviderV2.Info) => {
const api =
model.api.type === "native" && !model.api.package
? {
...provider.api,
id: model.api.id,
url: model.api.url ?? provider.api.url,
settings: ProviderOverlay.mergeRecords(provider.api.settings, model.api.settings),
}
: model.api.type === "native" && provider.api.type === "native" && !model.api.url
? {
...model.api,
package: model.api.package ?? provider.api.package,
url: provider.api.url,
settings: ProviderOverlay.mergeRecords(provider.api.settings, model.api.settings),
}
model.api.type === "native" && !model.api.url && Object.keys(model.api.settings).length === 0
? { ...provider.api, id: model.api.id }
: model.api.type === "aisdk" && provider.api.type === "aisdk" && !model.api.url
? {
...model.api,
url: provider.api.url,
settings: ProviderOverlay.mergeRecords(provider.api.settings, model.api.settings),
}
? { ...model.api, url: provider.api.url, settings: { ...provider.api.settings, ...model.api.settings } }
: model.api.type === "aisdk" && provider.api.type === "aisdk"
? { ...model.api, settings: ProviderOverlay.mergeRecords(provider.api.settings, model.api.settings) }
? { ...model.api, settings: { ...provider.api.settings, ...model.api.settings } }
: model.api
const request = {
headers: ProviderOverlay.mergeHeaders(provider.request.headers, model.request.headers),
body: ProviderOverlay.mergeRecords(provider.request.body, model.request.body),
headers: { ...provider.request.headers, ...model.request.headers },
body: { ...provider.request.body, ...model.request.body },
variant: model.request.variant,
}
return ModelV2.Info.make({

View file

@ -5,7 +5,6 @@ import { Effect } from "effect"
import { Config } from "../../config"
import { ModelV2 } from "../../model"
import { ProviderV2 } from "../../provider"
import { ProviderOverlay } from "../../provider-overlay"
export const Plugin = define({
id: "config-provider",
@ -48,90 +47,22 @@ export const Plugin = define({
const model = ModelV2.parse(configuredDefault)
catalog.model.default.set(model.providerID, model.modelID)
}
const providerAiSDK = new Map<string, boolean>()
const modelAiSDK = new Map<string, boolean>()
for (const file of files) {
for (const [id, item] of Object.entries(file.info.providers ?? {})) {
const providerID = id
if (item.aiSDK !== undefined) providerAiSDK.set(providerID, item.aiSDK)
catalog.provider.update(providerID, (provider) => {
if (item.name !== undefined) provider.name = item.name
if (item.package !== undefined) {
const settings = ProviderOverlay.mergeRecords(provider.api.settings, item.settings)
const url =
item.settings && Object.hasOwn(item.settings, "baseURL")
? typeof settings.baseURL === "string"
? settings.baseURL
: undefined
: provider.api.url
provider.api = (providerAiSDK.get(providerID) ?? provider.api.type === "aisdk")
? { type: "aisdk", package: item.package, ...(url === undefined ? {} : { url }), settings }
: { type: "native", package: item.package, ...(url === undefined ? {} : { url }), settings }
} else if (item.settings !== undefined) {
provider.api.settings = ProviderOverlay.mergeRecords(provider.api.settings, item.settings)
if (Object.hasOwn(item.settings, "baseURL")) {
provider.api.url =
typeof provider.api.settings.baseURL === "string" ? provider.api.settings.baseURL : undefined
}
if (item.api !== undefined) provider.api = { ...item.api }
if (item.request !== undefined) {
Object.assign(provider.request.headers, item.request.headers)
Object.assign(provider.request.body, item.request.body)
}
if (item.package === undefined && item.aiSDK !== undefined) {
if (item.aiSDK && provider.api.type === "native" && provider.api.package !== undefined) {
provider.api = { ...provider.api, type: "aisdk", package: provider.api.package }
}
if (!item.aiSDK && provider.api.type === "aisdk") {
provider.api = { ...provider.api, type: "native", settings: provider.api.settings ?? {} }
}
}
ProviderOverlay.assign(provider.request, { headers: item.headers, body: item.body })
})
const providerApi = catalog.provider.get(providerID)?.provider.api
for (const [id, config] of Object.entries(item.models ?? {})) {
const modelKey = `${providerID}/${id}`
if (config.aiSDK !== undefined) modelAiSDK.set(modelKey, config.aiSDK)
catalog.model.update(providerID, id, (model) => {
if (config.family !== undefined) model.family = config.family
if (config.name !== undefined) model.name = config.name
if (config.id !== undefined) model.api.id = config.id
if (config.package !== undefined) {
const aiSDK =
modelAiSDK.get(modelKey) ?? providerAiSDK.get(providerID) ?? providerApi?.type === "aisdk"
const settings = ProviderOverlay.mergeRecords(model.api.settings, config.settings)
const url =
config.settings && Object.hasOwn(config.settings, "baseURL")
? typeof settings.baseURL === "string"
? settings.baseURL
: undefined
: model.api.url
model.api = aiSDK
? {
id: model.api.id,
type: "aisdk",
package: config.package,
...(url === undefined ? {} : { url }),
settings,
}
: {
id: model.api.id,
type: "native",
package: config.package,
...(url === undefined ? {} : { url }),
settings,
}
} else if (config.settings !== undefined) {
model.api.settings = ProviderOverlay.mergeRecords(model.api.settings, config.settings)
if (Object.hasOwn(config.settings, "baseURL")) {
model.api.url =
typeof model.api.settings.baseURL === "string" ? model.api.settings.baseURL : undefined
}
}
if (config.package === undefined && config.aiSDK !== undefined) {
if (config.aiSDK && model.api.type === "native" && model.api.package !== undefined) {
model.api = { ...model.api, type: "aisdk", package: model.api.package }
}
if (!config.aiSDK && model.api.type === "aisdk") {
model.api = { ...model.api, type: "native", settings: model.api.settings ?? {} }
}
}
if (config.api !== undefined) model.api = { ...model.api, ...config.api }
if (config.capabilities !== undefined) {
model.capabilities = {
tools: config.capabilities.tools,
@ -139,24 +70,24 @@ export const Plugin = define({
output: [...config.capabilities.output],
}
}
if (config.headers !== undefined || config.body !== undefined) {
ProviderOverlay.assign(model.request, { headers: config.headers, body: config.body })
if (config.request !== undefined) {
Object.assign(model.request.headers, config.request.headers)
Object.assign(model.request.body, config.request.body)
if (config.request.variant !== undefined) model.request.variant = config.request.variant
}
if (config.variant !== undefined) model.request.variant = config.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,
settings: {},
headers: {},
body: {},
}
model.variants.push(existing)
}
existing.settings = ProviderOverlay.mergeRecords(existing.settings, variant.settings)
ProviderOverlay.assign(existing, { headers: variant.headers, body: variant.body })
Object.assign(existing.headers, variant.headers)
Object.assign(existing.body, variant.body)
}
}
if (config.cost !== undefined) {

View file

@ -1,6 +1,7 @@
export * as ConfigProvider from "./provider"
import { Schema } from "effect"
import { ProviderV2 } from "../provider"
import { ModelV2 } from "../model"
export class Request extends Schema.Class<Request>("ConfigV2.Provider.Request")({
@ -8,11 +9,6 @@ export class Request extends Schema.Class<Request>("ConfigV2.Provider.Request")(
body: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
}) {}
const Overlays = {
settings: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
...Request.fields,
}
class Cache extends Schema.Class<Cache>("ConfigV2.Model.Cost.Cache")({
read: Schema.Finite.pipe(Schema.optional),
write: Schema.Finite.pipe(Schema.optional),
@ -34,18 +30,32 @@ class Limit extends Schema.Class<Limit>("ConfigV2.Model.Limit")({
output: Schema.Int.pipe(Schema.optional),
}) {}
const ModelApi = Schema.Union([
Schema.Struct({
id: ModelV2.ID.pipe(Schema.optional),
...ProviderV2.AISDK.fields,
}),
Schema.Struct({
id: ModelV2.ID.pipe(Schema.optional),
...ProviderV2.Native.fields,
}),
Schema.Struct({
id: ModelV2.ID,
}),
])
class Model extends Schema.Class<Model>("ConfigV2.Model")({
id: ModelV2.ID.pipe(Schema.optional),
package: Schema.String.pipe(Schema.optional),
aiSDK: Schema.Boolean.pipe(Schema.optional),
...Overlays,
family: ModelV2.Family.pipe(Schema.optional),
name: Schema.String.pipe(Schema.optional),
api: ModelApi.pipe(Schema.optional),
capabilities: ModelV2.Capabilities.pipe(Schema.optional),
variant: Schema.String.pipe(Schema.optional),
request: Schema.Struct({
...Request.fields,
variant: Schema.String.pipe(Schema.optional),
}).pipe(Schema.optional),
variants: Schema.Struct({
id: ModelV2.VariantID,
...Overlays,
...Request.fields,
}).pipe(Schema.Array, Schema.optional),
cost: Schema.Union([Cost, Cost.pipe(Schema.Array)]).pipe(Schema.optional),
disabled: Schema.Boolean.pipe(Schema.optional),
@ -55,8 +65,7 @@ class Model extends Schema.Class<Model>("ConfigV2.Model")({
export class Info extends Schema.Class<Info>("ConfigV2.Provider")({
name: Schema.String.pipe(Schema.optional),
env: Schema.String.pipe(Schema.Array, Schema.optional),
package: Schema.String.pipe(Schema.optional),
aiSDK: Schema.Boolean.pipe(Schema.optional),
...Overlays,
api: ProviderV2.Api.pipe(Schema.optional),
request: Request.pipe(Schema.optional),
models: Schema.Record(Schema.String, Model).pipe(Schema.optional),
}) {}

View file

@ -1,6 +1,8 @@
import { Types } from "effect"
import { Schema } from "effect"
import { Model } from "@opencode-ai/schema/model"
import { ProviderV2 } from "./provider"
import { withStatics } from "./schema"
import type { DeepMutable } from "./schema"
export const ID = Model.ID
export type ID = typeof ID.Type
@ -8,10 +10,6 @@ export type ID = typeof ID.Type
export const VariantID = Model.VariantID
export type VariantID = typeof VariantID.Type
export const Package = Model.Package
export type Package = Model.Package
// Grouping of models, eg claude opus, claude sonnet
export const Family = Model.Family
export type Family = Model.Family
@ -23,15 +21,62 @@ export const Cost = Model.Cost
export const Ref = Model.Ref
export type Ref = typeof Ref.Type
export const Api = Model.Api
export type Api = Model.Api
// Temporary runtime schema until core catalog consumers migrate to the flat
// package identity in @opencode-ai/schema.
export const Api = Schema.Union([
Schema.Struct({ id: ID, ...ProviderV2.AISDK.fields }),
Schema.Struct({ id: ID, ...ProviderV2.Native.fields }),
]).pipe(Schema.toTaggedUnion("type"))
export type Api = typeof Api.Type
export const Info = Model.Info
export type Info = Model.Info
export interface Info extends Schema.Schema.Type<typeof Info> {}
export const Info = Schema.Struct({
id: ID,
providerID: ProviderV2.ID,
family: Family.pipe(Schema.optional),
name: Schema.String,
api: Api,
capabilities: Capabilities,
request: Schema.Struct({
...ProviderV2.Request.fields,
variant: Schema.String.pipe(Schema.optional),
}),
variants: Schema.Struct({
id: VariantID,
...ProviderV2.Request.fields,
}).pipe(Schema.Array, Schema.mutable),
time: Schema.Struct({ released: Schema.Finite }),
cost: Cost.pipe(Schema.Array, Schema.mutable),
status: Schema.Literals(["alpha", "beta", "deprecated", "active"]),
enabled: Schema.Boolean,
limit: Schema.Struct({
context: Schema.Int,
input: Schema.Int.pipe(Schema.optional),
output: Schema.Int,
}),
})
.annotate({ identifier: "ModelV2.Info" })
.pipe(
withStatics((schema) => ({
empty: (providerID: ProviderV2.ID, modelID: ID) =>
schema.make({
id: modelID,
providerID,
name: modelID,
api: { id: modelID, type: "native", settings: {} },
capabilities: { tools: false, input: [], output: [] },
request: { headers: {}, body: {} },
variants: [],
time: { released: 0 },
cost: [],
status: "active",
enabled: true,
limit: { context: 0, output: 0 },
}),
})),
)
export type MutableInfo = Omit<Types.DeepMutable<Info>, "api"> & {
api: ProviderV2.MutableApi<Api>
}
export type MutableInfo = Omit<DeepMutable<Info>, "api"> & { api: ProviderV2.MutableApi<Api> }
export function parse(input: string): { providerID: ProviderV2.ID; modelID: ID } {
const [providerID, ...modelID] = input.split("/")

View file

@ -1,34 +0,0 @@
export * as ProviderOverlay from "./provider-overlay"
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value)
export const mergeRecords = (...items: ReadonlyArray<Readonly<Record<string, unknown>> | undefined>) => {
const result: Record<string, unknown> = {}
for (const item of items) {
for (const [key, value] of Object.entries(item ?? {})) {
result[key] = isRecord(result[key]) && isRecord(value) ? mergeRecords(result[key], value) : value
}
}
return result
}
export const mergeHeaders = (...items: ReadonlyArray<Readonly<Record<string, string>> | undefined>) => {
const result = new Map<string, readonly [string, string]>()
for (const item of items) {
for (const entry of Object.entries(item ?? {})) result.set(entry[0].toLowerCase(), entry)
}
return Object.fromEntries(result.values())
}
export const assign = (
target: { headers: Record<string, string>; body: Record<string, unknown> },
overlay: { readonly headers?: Readonly<Record<string, string>>; readonly body?: Readonly<Record<string, unknown>> },
) => {
const headers = mergeHeaders(target.headers, overlay.headers)
Object.keys(target.headers).forEach((key) => delete target.headers[key])
Object.assign(target.headers, headers)
const body = mergeRecords(target.body, overlay.body)
Object.keys(target.body).forEach((key) => delete target.body[key])
Object.assign(target.body, body)
}

View file

@ -1,30 +1,61 @@
export * as ProviderV2 from "./provider"
import { Types } from "effect"
import { Schema } from "effect"
import { Provider } from "@opencode-ai/schema/provider"
import { Integration } from "./integration"
import { withStatics } from "./schema"
import type { DeepMutable } from "./schema"
export const ID = Provider.ID
export type ID = typeof ID.Type
export const Overlays = Provider.Overlays
// Temporary runtime schema until core catalog consumers migrate to the flat
// package identity in @opencode-ai/schema.
export interface AISDK extends Schema.Schema.Type<typeof AISDK> {}
export const AISDK = Schema.Struct({
type: Schema.Literal("aisdk"),
package: Schema.String,
url: Schema.String.pipe(Schema.optional),
settings: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
})
export const Package = Provider.Package
export type Package = Provider.Package
export interface Native extends Schema.Schema.Type<typeof Native> {}
export const Native = Schema.Struct({
type: Schema.Literal("native"),
url: Schema.String.pipe(Schema.optional),
settings: Schema.Record(Schema.String, Schema.Unknown),
})
export const AISDK = Provider.AISDK
export const Native = Provider.Native
export const Api = Provider.Api
export type Api = Provider.Api
export type MutableApi<T extends Api = Api> = T extends Api
? Omit<Types.DeepMutable<T>, "settings"> & (undefined extends T["settings"] ? { settings?: any } : { settings: any })
: never
export const Api = Schema.Union([AISDK, Native]).pipe(Schema.toTaggedUnion("type"))
export type Api = typeof Api.Type
export const Request = Provider.Request
export type Request = Provider.Request
export const Info = Provider.Info
export type Info = Provider.Info
export interface Info extends Schema.Schema.Type<typeof Info> {}
export const Info = Schema.Struct({
id: ID,
integrationID: Integration.ID.pipe(Schema.optional),
name: Schema.String,
disabled: Schema.Boolean.pipe(Schema.optional),
api: Api,
request: Request,
})
.annotate({ identifier: "ProviderV2.Info" })
.pipe(
withStatics((schema) => ({
empty: (id: ID) =>
schema.make({
id,
name: id,
api: { type: "native", settings: {} },
request: { headers: {}, body: {} },
}),
})),
)
export type MutableInfo = Omit<Types.DeepMutable<Info>, "api"> & { api: MutableApi }
export type MutableApi<T extends Api = Api> = T extends Api
? Omit<DeepMutable<T>, "settings"> & (undefined extends T["settings"] ? { settings?: any } : { settings: any })
: never
export type MutableInfo = Omit<DeepMutable<Info>, "api"> & { api: MutableApi }

View file

@ -12,7 +12,6 @@ import { Credential } from "../../credential"
import { Integration } from "../../integration"
import { ModelV2 } from "../../model"
import { ProviderV2 } from "../../provider"
import { ProviderOverlay } from "../../provider-overlay"
import { SessionSchema } from "../schema"
export class ModelNotSelectedError extends Schema.TaggedErrorClass<ModelNotSelectedError>()(
@ -118,13 +117,8 @@ const withVariant = (
return Effect.succeed(
variant
? produce(model, (draft) => {
if (variant.settings !== undefined) {
draft.api.settings = ProviderOverlay.mergeRecords(draft.api.settings, variant.settings)
if (Object.hasOwn(variant.settings, "baseURL")) {
draft.api.url = typeof draft.api.settings.baseURL === "string" ? draft.api.settings.baseURL : undefined
}
}
ProviderOverlay.assign(draft.request, variant)
Object.assign(draft.request.headers, variant.headers)
Object.assign(draft.request.body, variant.body)
})
: model,
)

View file

@ -173,14 +173,15 @@ function migrateProvider(info: ConfigProviderV1.Info) {
return {
name: info.name,
env: info.env,
package: info.npm,
aiSDK: info.npm ? true : undefined,
settings: {
...options.settings,
...(info.api ?? options.url ? { baseURL: info.api ?? options.url } : {}),
},
headers: options.headers,
body: options.body,
api: info.npm
? {
type: "aisdk" as const,
package: info.npm,
url: info.api ?? options.url,
settings: options.settings ?? {},
}
: undefined,
request: info.options && { headers: options.headers, body: options.body },
models:
info.models &&
Object.fromEntries(Object.entries(info.models).map(([name, model]) => [name, migrateModel(model, info.npm)])),
@ -215,13 +216,22 @@ function migrateModel(info: typeof ConfigProviderV1.Model.Type, packageName?: st
return {
family: info.family,
name: info.name,
id: info.id,
package: info.provider?.npm,
aiSDK: info.provider?.npm ? true : undefined,
settings: info.provider?.api ? { baseURL: info.provider.api } : undefined,
api: info.provider?.npm
? {
...(info.id === undefined ? {} : { id: info.id }),
type: "aisdk" as const,
package: info.provider.npm,
url: info.provider.api,
settings: {},
}
: info.id === undefined
? undefined
: { id: info.id },
capabilities,
headers: info.headers,
body: request,
request: (info.headers || request) && {
headers: info.headers,
body: request,
},
variants:
info.variants &&
Object.entries(info.variants).map(([id, options]) => ({