diff --git a/packages/core/src/catalog.ts b/packages/core/src/catalog.ts index 0b274299e5..9b993391f5 100644 --- a/packages/core/src/catalog.ts +++ b/packages/core/src/catalog.ts @@ -78,12 +78,28 @@ export const layer = Layer.effect( const projectModel = (model: ModelV2.Info, provider: ProviderV2.Info) => { const api = - model.api.type === "native" && !model.api.url && Object.keys(model.api.settings).length === 0 - ? { ...provider.api, id: model.api.id } + model.api.type === "native" && !model.api.package + ? { + ...provider.api, + id: model.api.id, + url: model.api.url ?? provider.api.url, + settings: ModelRequest.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: ModelRequest.mergeRecords(provider.api.settings, model.api.settings), + } : model.api.type === "aisdk" && provider.api.type === "aisdk" && !model.api.url - ? { ...model.api, url: provider.api.url, settings: { ...provider.api.settings, ...model.api.settings } } + ? { + ...model.api, + url: provider.api.url, + settings: ModelRequest.mergeRecords(provider.api.settings, model.api.settings), + } : model.api.type === "aisdk" && provider.api.type === "aisdk" - ? { ...model.api, settings: { ...provider.api.settings, ...model.api.settings } } + ? { ...model.api, settings: ModelRequest.mergeRecords(provider.api.settings, model.api.settings) } : model.api const request = { ...ModelRequest.merge({ ...provider.request, generation: {}, options: {} }, model.request), diff --git a/packages/core/src/config/plugin/provider.ts b/packages/core/src/config/plugin/provider.ts index 6fb13f1c63..92cb0c1d42 100644 --- a/packages/core/src/config/plugin/provider.ts +++ b/packages/core/src/config/plugin/provider.ts @@ -48,26 +48,95 @@ export const Plugin = define({ const model = ModelV2.parse(configuredDefault) catalog.model.default.set(model.providerID, model.modelID) } + const providerAiSDK = new Map() + const modelAiSDK = new Map() 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.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) { + const settings = ModelRequest.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 = ModelRequest.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.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 ?? {} } + } + } + ModelRequest.assign(provider.request, { headers: item.headers, body: item.body }) }) const providerApi = catalog.provider.get(providerID)?.provider.api const providerPackage = providerApi?.type === "aisdk" ? providerApi.package : undefined 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.api !== undefined) model.api = { ...model.api, ...config.api } + 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 = ModelRequest.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 = ModelRequest.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 ?? {} } + } + } const packageName = model.api.type === "aisdk" ? model.api.package : providerPackage + const aiSDK = + modelAiSDK.get(modelKey) ?? providerAiSDK.get(providerID) ?? providerApi?.type === "aisdk" if (config.capabilities !== undefined) { model.capabilities = { tools: config.capabilities.tools, @@ -75,19 +144,22 @@ export const Plugin = define({ output: [...config.capabilities.output], } } - if (config.request !== undefined) { + if (config.headers !== undefined || config.body !== undefined) { ModelRequest.assign(model.request, { - headers: config.request.headers, - ...ModelRequest.normalizeAiSdkOptions(packageName, config.request.body ?? {}), + headers: config.headers, + ...(aiSDK + ? ModelRequest.normalizeAiSdkOptions(packageName, config.body ?? {}) + : { body: config.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: {}, generation: {}, @@ -95,9 +167,12 @@ export const Plugin = define({ } model.variants.push(existing) } + existing.settings = ModelRequest.mergeRecords(existing.settings, variant.settings) ModelRequest.assign(existing, { headers: variant.headers, - ...ModelRequest.normalizeAiSdkOptions(packageName, variant.body ?? {}), + ...(aiSDK + ? ModelRequest.normalizeAiSdkOptions(packageName, variant.body ?? {}) + : { body: variant.body }), }) } } diff --git a/packages/core/src/config/provider.ts b/packages/core/src/config/provider.ts index 1b54757078..665799ec3f 100644 --- a/packages/core/src/config/provider.ts +++ b/packages/core/src/config/provider.ts @@ -1,7 +1,6 @@ export * as ConfigProvider from "./provider" import { Schema } from "effect" -import { ProviderV2 } from "../provider" import { ModelV2 } from "../model" export class Request extends Schema.Class("ConfigV2.Provider.Request")({ @@ -9,6 +8,11 @@ export class Request extends Schema.Class("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("ConfigV2.Model.Cost.Cache")({ read: Schema.Finite.pipe(Schema.optional), write: Schema.Finite.pipe(Schema.optional), @@ -30,32 +34,18 @@ class Limit extends Schema.Class("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("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), - request: Schema.Struct({ - ...Request.fields, - variant: Schema.String.pipe(Schema.optional), - }).pipe(Schema.optional), + variant: Schema.String.pipe(Schema.optional), variants: Schema.Struct({ id: ModelV2.VariantID, - ...Request.fields, + ...Overlays, }).pipe(Schema.Array, Schema.optional), cost: Schema.Union([Cost, Cost.pipe(Schema.Array)]).pipe(Schema.optional), disabled: Schema.Boolean.pipe(Schema.optional), @@ -65,7 +55,8 @@ class Model extends Schema.Class("ConfigV2.Model")({ export class Info extends Schema.Class("ConfigV2.Provider")({ name: Schema.String.pipe(Schema.optional), env: Schema.String.pipe(Schema.Array, Schema.optional), - api: ProviderV2.Api.pipe(Schema.optional), - request: Request.pipe(Schema.optional), + package: Schema.String.pipe(Schema.optional), + aiSDK: Schema.Boolean.pipe(Schema.optional), + ...Overlays, models: Schema.Record(Schema.String, Model).pipe(Schema.optional), }) {} diff --git a/packages/core/src/model-request.ts b/packages/core/src/model-request.ts index 06059a9d73..6779d808ef 100644 --- a/packages/core/src/model-request.ts +++ b/packages/core/src/model-request.ts @@ -67,16 +67,41 @@ const profiles = new Map([ export const namespace = (packageName: string) => profiles.get(packageName)?.namespace +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value) + +export const mergeRecords = (...items: ReadonlyArray> | undefined>) => { + const result: Record = {} + 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> | undefined>) => { + const result = new Map() + 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 merge = (base: Request, override: Partial) => ({ - headers: { ...base.headers, ...override.headers }, - body: { ...base.body, ...override.body }, + headers: mergeHeaders(base.headers, override.headers), + body: mergeRecords(base.body, override.body), generation: { ...base.generation, ...override.generation }, options: { ...base.options, ...override.options }, }) export const assign = (target: MutableRequest, override: Partial) => { - Object.assign(target.headers, override.headers) - Object.assign(target.body, override.body) + const headers = mergeHeaders(target.headers, override.headers) + Object.keys(target.headers).forEach((key) => delete target.headers[key]) + Object.assign(target.headers, headers) + const body = mergeRecords(target.body, override.body) + Object.keys(target.body).forEach((key) => delete target.body[key]) + Object.assign(target.body, body) Object.assign((target.generation ??= {}), override.generation) Object.assign((target.options ??= {}), override.options) } diff --git a/packages/core/src/model.ts b/packages/core/src/model.ts index 52fff98733..bb22033943 100644 --- a/packages/core/src/model.ts +++ b/packages/core/src/model.ts @@ -8,6 +8,9 @@ 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 diff --git a/packages/core/src/provider.ts b/packages/core/src/provider.ts index 03f7d7eef3..95de2c3ebc 100644 --- a/packages/core/src/provider.ts +++ b/packages/core/src/provider.ts @@ -6,6 +6,11 @@ import { Provider } from "@opencode-ai/schema/provider" export const ID = Provider.ID export type ID = typeof ID.Type +export const Overlays = Provider.Overlays + +export const Package = Provider.Package +export type Package = Provider.Package + export const AISDK = Provider.AISDK export const Native = Provider.Native diff --git a/packages/core/src/session/runner/model.ts b/packages/core/src/session/runner/model.ts index 97b862e432..253be42216 100644 --- a/packages/core/src/session/runner/model.ts +++ b/packages/core/src/session/runner/model.ts @@ -122,6 +122,12 @@ const withVariant = ( return Effect.succeed( variant ? produce(model, (draft) => { + if (variant.settings !== undefined) { + draft.api.settings = ModelRequest.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 + } + } ModelRequest.assign(draft.request, variant) }) : model, diff --git a/packages/core/src/v1/config/migrate.ts b/packages/core/src/v1/config/migrate.ts index c474cac51a..67a78cba86 100644 --- a/packages/core/src/v1/config/migrate.ts +++ b/packages/core/src/v1/config/migrate.ts @@ -174,15 +174,14 @@ function migrateProvider(info: ConfigProviderV1.Info) { return { name: info.name, env: info.env, - 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 }, + 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, models: info.models && Object.fromEntries(Object.entries(info.models).map(([name, model]) => [name, migrateModel(model, info.npm)])), @@ -221,22 +220,13 @@ function migrateModel(info: typeof ConfigProviderV1.Model.Type, packageName?: st return { family: info.family, name: info.name, - 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 }, + id: info.id, + package: info.provider?.npm, + aiSDK: info.provider?.npm ? true : undefined, + settings: info.provider?.api ? { baseURL: info.provider.api } : undefined, capabilities, - request: (info.headers || request) && { - headers: info.headers, - body: request, - }, + headers: info.headers, + body: request, variants: info.variants && Object.entries(info.variants).map(([id, options]) => ({ diff --git a/packages/core/test/catalog.test.ts b/packages/core/test/catalog.test.ts index 23a3a0f4aa..a165343c83 100644 --- a/packages/core/test/catalog.test.ts +++ b/packages/core/test/catalog.test.ts @@ -213,6 +213,7 @@ describe("CatalogV2", () => { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://provider.example.com", + settings: {}, }) }), ) diff --git a/packages/core/test/config/config.test.ts b/packages/core/test/config/config.test.ts index 6275d8fed3..552a87da49 100644 --- a/packages/core/test/config/config.test.ts +++ b/packages/core/test/config/config.test.ts @@ -103,13 +103,10 @@ describe("Config", () => { }, }) - expect(migrated.providers?.bedrock?.api).toEqual({ - type: "aisdk", + expect(migrated.providers?.bedrock).toMatchObject({ package: "@ai-sdk/amazon-bedrock", - url: undefined, + aiSDK: true, settings: { region: "us-east-1", profile: "dev" }, - }) - expect(migrated.providers?.bedrock?.request).toEqual({ headers: { "x-test": "1" }, body: { trace: true }, }) @@ -585,34 +582,34 @@ describe("Config", () => { }) expect(documents[0]?.info.attachments).toEqual({ image: { auto_resize: false, max_width: 1200 } }) expect(documents[0]?.info.providers?.custom).toMatchObject({ - request: { body: { apiKey: "secret" } }, + body: { apiKey: "secret" }, models: { model: { - request: { body: { reasoningEffort: "high" } }, + body: { reasoningEffort: "high" }, variants: [{ id: "fast", body: { temperature: 0.2 } }], }, }, }) expect(documents[0]?.info.providers?.openai).toMatchObject({ - api: { settings: {} }, - request: { headers: { Authorization: "Bearer secret", "OpenAI-Organization": "org" } }, + package: "@ai-sdk/openai", + aiSDK: true, + settings: {}, + headers: { Authorization: "Bearer secret", "OpenAI-Organization": "org" }, models: { model: { - request: { - body: { temperature: 0.3, reasoningEffort: "high", serviceTier: "priority" }, - }, + body: { temperature: 0.3, reasoningEffort: "high", serviceTier: "priority" }, variants: [{ id: "high", body: { reasoningEffort: "high", reasoningSummary: "auto" } }], }, }, }) expect(documents[0]?.info.providers?.anthropic).toMatchObject({ + package: "@ai-sdk/anthropic", + aiSDK: true, models: { model: { - request: { - body: { - output_config: { effort: "high", task_budget: 4096 }, - metadata: { user_id: "user-1" }, - }, + body: { + output_config: { effort: "high", task_budget: 4096 }, + metadata: { user_id: "user-1" }, }, }, }, diff --git a/packages/core/test/config/provider.test.ts b/packages/core/test/config/provider.test.ts index 12f4a01c78..f8715b6e68 100644 --- a/packages/core/test/config/provider.test.ts +++ b/packages/core/test/config/provider.test.ts @@ -45,16 +45,90 @@ function withEnv(vars: Record, effect: () = ) } -function request(headers: Record, variant?: string) { - return { - headers, - variant, - } -} - const decode = Schema.decodeUnknownSync(Config.Info) describe("ConfigProviderPlugin.Plugin", () => { + it.effect("merges flat provider and model overlays", () => + Effect.gen(function* () { + const catalog = yield* Catalog.Service + const providerID = ProviderV2.ID.make("custom") + const modelID = ModelV2.ID.make("chat") + const config = Config.Service.of({ + entries: () => + Effect.succeed([ + new Config.Document({ + type: "document", + info: decode({ providers: { custom: { aiSDK: true } } }), + }), + new Config.Document({ + type: "document", + info: decode({ + providers: { + custom: { + package: "custom-provider", + settings: { auth: { type: "token", region: "us-east-1" } }, + headers: { "X-Test": "provider" }, + body: { reasoning: { type: "enabled", budget: 8_000 }, tags: ["provider"] }, + models: { + chat: { + package: "custom-model-provider", + settings: { auth: { region: "us-west-2" } }, + headers: { "x-test": "model" }, + body: { reasoning: { budget: 32_000 }, tags: ["model"] }, + }, + inherit: { + settings: { auth: { region: "eu-west-1" }, baseURL: "https://model.example/v1" }, + }, + clear: { settings: { baseURL: "https://old.example/v1" } }, + }, + }, + }, + }), + }), + new Config.Document({ + type: "document", + info: decode({ + providers: { + custom: { models: { clear: { package: "custom-provider", settings: { baseURL: null } } } }, + }, + }), + }), + ]), + }) + + yield* addPlugin(config) + + const model = required(yield* catalog.model.get(providerID, modelID)) + expect(model.api).toEqual({ + id: modelID, + type: "aisdk", + package: "custom-model-provider", + settings: { auth: { type: "token", region: "us-west-2" } }, + }) + expect(model.request.headers).toEqual({ "x-test": "model" }) + expect(model.request.body).toEqual({ + reasoning: { type: "enabled", budget: 32_000 }, + tags: ["model"], + }) + expect(required(yield* catalog.model.get(providerID, ModelV2.ID.make("inherit"))).api).toEqual({ + id: ModelV2.ID.make("inherit"), + type: "aisdk", + package: "custom-provider", + url: "https://model.example/v1", + settings: { + auth: { type: "token", region: "eu-west-1" }, + baseURL: "https://model.example/v1", + }, + }) + expect(required(yield* catalog.model.get(providerID, ModelV2.ID.make("clear"))).api).toEqual({ + id: ModelV2.ID.make("clear"), + type: "aisdk", + package: "custom-provider", + settings: { auth: { type: "token", region: "us-east-1" }, baseURL: null }, + }) + }), + ) + it.effect("partitions existing model variant bodies without changing config shape", () => Effect.gen(function* () { const catalog = yield* Catalog.Service @@ -68,7 +142,9 @@ describe("ConfigProviderPlugin.Plugin", () => { info: decode({ providers: { opencode: { - api: { type: "aisdk", package: "@ai-sdk/openai", url: "https://opencode.test/v1" }, + package: "@ai-sdk/openai", + aiSDK: true, + settings: { baseURL: "https://opencode.test/v1" }, models: { "alpha-gpt-next": { variants: [ @@ -120,7 +196,9 @@ describe("ConfigProviderPlugin.Plugin", () => { info: decode({ providers: { opencode: { - api: { type: "aisdk", package: "@ai-sdk/openai", url: "https://opencode.test/v1" }, + package: "@ai-sdk/openai", + aiSDK: true, + settings: { baseURL: "https://opencode.test/v1" }, }, }, }), @@ -171,8 +249,8 @@ describe("ConfigProviderPlugin.Plugin", () => { custom: { name: "Configured", env: ["CUSTOM_API_KEY"], - api: { type: "native", settings: {} }, - request: request({ first: "first", shared: "first" }), + package: "custom-native", + headers: { first: "first", shared: "first" }, models: { chat: { name: "First", @@ -180,7 +258,8 @@ describe("ConfigProviderPlugin.Plugin", () => { disabled: true, limit: { context: 100, output: 50 }, cost: { input: 1, output: 2 }, - request: request({ first: "first", shared: "first" }, "retained"), + headers: { first: "first", shared: "first" }, + variant: "retained", variants: [ { id: "fast", @@ -199,17 +278,19 @@ describe("ConfigProviderPlugin.Plugin", () => { model: "custom/default", providers: { custom: { - api: { type: "aisdk", package: "custom-sdk", url: "https://example.test" }, - request: request({ last: "last", shared: "last" }), + package: "custom-sdk", + aiSDK: true, + settings: { baseURL: "https://example.test" }, + headers: { last: "last", shared: "last" }, models: { default: { name: "Default", }, chat: { - api: { id: "api-chat" }, + id: "api-chat", name: "Last", limit: { output: 75 }, - request: request({ last: "last", shared: "last" }), + headers: { last: "last", shared: "last" }, variants: [ { id: "fast", @@ -249,7 +330,12 @@ describe("ConfigProviderPlugin.Plugin", () => { }) expect((yield* integrations.get(Integration.ID.make("custom")))?.name).toBe("Renamed") expect(provider.disabled).toBeUndefined() - expect(provider.api).toEqual({ type: "aisdk", package: "custom-sdk", url: "https://example.test" }) + expect(provider.api).toEqual({ + type: "aisdk", + package: "custom-sdk", + url: "https://example.test", + settings: { baseURL: "https://example.test" }, + }) expect(provider.request.headers).toEqual({ first: "first", shared: "last", last: "last" }) expect(model.api.id).toBe(ModelV2.ID.make("api-chat")) expect(model.name).toBe("Last") diff --git a/packages/core/test/session-runner-model.test.ts b/packages/core/test/session-runner-model.test.ts index efebe60d04..f570dabfc7 100644 --- a/packages/core/test/session-runner-model.test.ts +++ b/packages/core/test/session-runner-model.test.ts @@ -148,6 +148,40 @@ describe("SessionRunnerModel", () => { }), ) + it.effect("applies a selected variant base URL", () => + Effect.gen(function* () { + const base = model( + { type: "aisdk", package: "@ai-sdk/openai", url: "https://default.example/v1" }, + [ + { + id: ModelV2.VariantID.make("regional"), + settings: { baseURL: "https://regional.example/v1" }, + headers: {}, + body: {}, + generation: {}, + options: {}, + }, + ], + ) + const session = SessionV2.Info.make({ + id: SessionV2.ID.make("ses_regional_variant"), + projectID: ProjectV2.ID.global, + title: "test", + model: { id: base.id, providerID: base.providerID, variant: ModelV2.VariantID.make("regional") }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) }, + location: { directory: AbsolutePath.make("/project") }, + }) + const resolved = yield* SessionRunnerModel.resolve( + session, + base, + ) + + expect(resolved.route.endpoint.baseURL).toBe("https://regional.example/v1") + }), + ) + it.effect("lowers selected OpenAI-compatible Session variants into Chat options", () => Effect.gen(function* () { const catalog = model( diff --git a/packages/core/test/shared-schema.test.ts b/packages/core/test/shared-schema.test.ts index a1794b9263..3632c701fe 100644 --- a/packages/core/test/shared-schema.test.ts +++ b/packages/core/test/shared-schema.test.ts @@ -99,6 +99,7 @@ test("Core reuses the canonical shared schemas", async () => { [coreLLM.ToolContent, LLM.ToolContent], [ModelV2.ID, Model.ID], [ModelV2.VariantID, Model.VariantID], + [ModelV2.Package, Model.Package], [ModelV2.Ref, Model.Ref], [ModelV2.Family, Model.Family], [ModelV2.Capabilities, Model.Capabilities], @@ -106,6 +107,7 @@ test("Core reuses the canonical shared schemas", async () => { [ModelV2.Api, Model.Api], [ModelV2.Info, Model.Info], [ProviderV2.ID, Provider.ID], + [ProviderV2.Package, Provider.Package], [ProviderV2.AISDK, Provider.AISDK], [ProviderV2.Native, Provider.Native], [ProviderV2.Api, Provider.Api], diff --git a/packages/schema/src/model.ts b/packages/schema/src/model.ts index 62b76df0fc..3f95afd49c 100644 --- a/packages/schema/src/model.ts +++ b/packages/schema/src/model.ts @@ -11,6 +11,14 @@ export type ID = typeof ID.Type export const VariantID = Schema.String.pipe(Schema.brand("VariantID")) export type VariantID = typeof VariantID.Type +export const Package = Schema.Struct({ + modelID: ID, + package: Schema.String.pipe(Schema.optional), + aiSDK: Schema.Boolean.pipe(Schema.optional), + ...Provider.Overlays, +}) +export type Package = typeof Package.Type + export const Ref = Schema.Struct({ id: ID, providerID: Provider.ID, @@ -68,6 +76,7 @@ export const Info = Schema.Struct({ }), variants: Schema.Struct({ id: VariantID, + settings: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), ...ModelRequest.Request.fields, }).pipe(Schema.Array, Schema.mutable), time: Schema.Struct({ diff --git a/packages/schema/src/provider.ts b/packages/schema/src/provider.ts index d3d5109608..21e24285a2 100644 --- a/packages/schema/src/provider.ts +++ b/packages/schema/src/provider.ts @@ -22,6 +22,19 @@ export const ID = Schema.String.pipe( ) export type ID = typeof ID.Type +export const Overlays = { + settings: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), + headers: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional), + body: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), +} + +export const Package = Schema.Struct({ + package: Schema.String, + aiSDK: Schema.Boolean.pipe(Schema.optional), + ...Overlays, +}) +export type Package = typeof Package.Type + export interface AISDK extends Schema.Schema.Type {} export const AISDK = Schema.Struct({ type: Schema.Literal("aisdk"), @@ -33,6 +46,7 @@ export const AISDK = Schema.Struct({ export interface Native extends Schema.Schema.Type {} export const Native = Schema.Struct({ type: Schema.Literal("native"), + package: Schema.String.pipe(Schema.optional), url: Schema.String.pipe(Schema.optional), settings: Schema.Record(Schema.String, Schema.Unknown), }) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 9f9ddb14c2..107e3a08ce 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -4002,6 +4002,7 @@ export type ModelV2Info = { | { id: string type: "native" + package?: string url?: string settings: { [key: string]: unknown @@ -4036,6 +4037,9 @@ export type ModelV2Info = { } variants: Array<{ id: string + settings?: { + [key: string]: unknown + } headers: { [key: string]: string } @@ -4096,6 +4100,7 @@ export type ProviderV2Info = { } | { type: "native" + package?: string url?: string settings: { [key: string]: unknown