diff --git a/packages/core/src/model.ts b/packages/core/src/model.ts index 52fff98733..293954ecb2 100644 --- a/packages/core/src/model.ts +++ b/packages/core/src/model.ts @@ -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,7 +10,6 @@ export type ID = typeof ID.Type export const VariantID = Model.VariantID export type VariantID = typeof VariantID.Type -// Grouping of models, eg claude opus, claude sonnet export const Family = Model.Family export type Family = Model.Family @@ -20,14 +21,89 @@ 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 {} +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, "api"> & { - api: ProviderV2.MutableApi +export type MutableInfo = Omit, "api"> & { api: ProviderV2.MutableApi } + +export function flatten(info: Info): Model.Info { + return Model.Info.make({ + id: info.id, + modelID: info.api.id === info.id ? undefined : info.api.id, + providerID: info.providerID, + family: info.family, + name: info.name, + package: info.api.type === "aisdk" ? `aisdk:${info.api.package}` : undefined, + settings: { + ...info.api.settings, + ...(info.api.url === undefined ? {} : { baseURL: info.api.url }), + }, + headers: info.request.headers, + body: info.request.body, + capabilities: info.capabilities, + variants: info.variants.map((variant) => ({ + id: variant.id, + headers: variant.headers, + body: variant.body, + })), + time: info.time, + cost: info.cost, + status: info.status, + enabled: info.enabled, + limit: info.limit, + }) } export function parse(input: string): { providerID: ProviderV2.ID; modelID: ID } { diff --git a/packages/core/src/provider.ts b/packages/core/src/provider.ts index 03f7d7eef3..40f1f357d6 100644 --- a/packages/core/src/provider.ts +++ b/packages/core/src/provider.ts @@ -1,25 +1,77 @@ 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 AISDK = Provider.AISDK +// Temporary runtime schema until core catalog consumers migrate to the flat +// package identity in @opencode-ai/schema. +export interface AISDK extends Schema.Schema.Type {} +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 Native = Provider.Native +export interface Native extends Schema.Schema.Type {} +export const Native = Schema.Struct({ + type: Schema.Literal("native"), + url: Schema.String.pipe(Schema.optional), + settings: Schema.Record(Schema.String, Schema.Unknown), +}) -export const Api = Provider.Api -export type Api = Provider.Api -export type MutableApi = T extends Api - ? Omit, "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 {} +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, "api"> & { api: MutableApi } +export type MutableApi = T extends Api + ? Omit, "settings"> & (undefined extends T["settings"] ? { settings?: any } : { settings: any }) + : never + +export type MutableInfo = Omit, "api"> & { api: MutableApi } + +export function flatten(info: Info): Provider.Info { + return Provider.Info.make({ + id: info.id, + integrationID: info.integrationID, + name: info.name, + disabled: info.disabled, + package: info.api.type === "aisdk" ? `aisdk:${info.api.package}` : "", + settings: { + ...info.api.settings, + ...(info.api.url === undefined ? {} : { baseURL: info.api.url }), + }, + headers: info.request.headers, + body: info.request.body, + }) +} diff --git a/packages/core/test/shared-schema.test.ts b/packages/core/test/shared-schema.test.ts index 81b813a878..1d4cebb660 100644 --- a/packages/core/test/shared-schema.test.ts +++ b/packages/core/test/shared-schema.test.ts @@ -100,14 +100,8 @@ test("Core reuses the canonical shared schemas", async () => { [ModelV2.Family, Model.Family], [ModelV2.Capabilities, Model.Capabilities], [ModelV2.Cost, Model.Cost], - [ModelV2.Api, Model.Api], - [ModelV2.Info, Model.Info], [ProviderV2.ID, Provider.ID], - [ProviderV2.AISDK, Provider.AISDK], - [ProviderV2.Native, Provider.Native], - [ProviderV2.Api, Provider.Api], [ProviderV2.Request, Provider.Request], - [ProviderV2.Info, Provider.Info], [corePermission.Effect, Permission.Effect], [corePermission.Rule, Permission.Rule], [corePermission.Ruleset, Permission.Ruleset], @@ -155,15 +149,50 @@ test("Core reuses the canonical shared schemas", async () => { for (const [core, shared] of schemas) expect(core).toBe(shared) expect(Agent.Info.empty(Agent.ID.make("test"))).toEqual(AgentV2.Info.empty(AgentV2.ID.make("test"))) - expect(Model.Info.empty(Provider.ID.make("test"), Model.ID.make("model"))).toEqual( - ModelV2.Info.empty(ProviderV2.ID.make("test"), ModelV2.ID.make("model")), - ) - expect(Provider.Info.empty(Provider.ID.make("test"))).toEqual(ProviderV2.Info.empty(ProviderV2.ID.make("test"))) expect(Skill.Source.key(Skill.DirectorySource.make({ type: "directory", path: AbsolutePath.make("/tmp") }))).toBe( "directory:/tmp", ) }) +test("shared provider schemas use flat package identity", () => { + expect( + Schema.decodeUnknownSync(Provider.Info)({ + id: "openai", + name: "OpenAI", + package: "@opencode-ai/llm/providers/openai", + settings: { baseURL: "https://api.openai.com/v1" }, + headers: { "x-application": "opencode" }, + body: { service_tier: "priority" }, + }), + ).toMatchObject({ package: "@opencode-ai/llm/providers/openai" }) + expect( + Schema.decodeUnknownSync(Provider.Info)({ + id: "legacy", + name: "Legacy", + package: "aisdk:@ai-sdk/openai", + }), + ).toMatchObject({ package: "aisdk:@ai-sdk/openai" }) + expect( + Schema.decodeUnknownSync(Model.Info)({ + id: "claude-sonnet", + modelID: "us.anthropic.claude-sonnet-4-6", + providerID: "amazon-bedrock", + name: "Claude Sonnet", + package: "@opencode-ai/llm/providers/amazon-bedrock", + settings: { region: "us-west-2" }, + headers: {}, + body: { additionalModelRequestFields: { reasoning_config: { type: "enabled" } } }, + capabilities: { tools: true, input: ["text"], output: ["text"] }, + variants: [{ id: "max", settings: {}, body: { budget_tokens: 31999 } }], + time: { released: 0 }, + cost: [], + status: "active", + enabled: true, + limit: { context: 200000, output: 64000 }, + }), + ).toMatchObject({ id: "claude-sonnet", modelID: "us.anthropic.claude-sonnet-4-6" }) +}) + test("shared record schemas construct and decode plain objects", () => { const made = Prompt.make({ text: "hello" }) const decoded = Schema.decodeUnknownSync(Prompt)({ text: "hello" }) diff --git a/packages/llm/package.json b/packages/llm/package.json index d5b370c7d4..f78509d247 100644 --- a/packages/llm/package.json +++ b/packages/llm/package.json @@ -15,6 +15,7 @@ "./route": "./src/route/index.ts", "./provider": "./src/provider.ts", "./providers": "./src/providers/index.ts", + "./provider-package": "./src/provider-package.ts", "./providers/amazon-bedrock": "./src/providers/amazon-bedrock.ts", "./providers/anthropic": "./src/providers/anthropic.ts", "./providers/azure": "./src/providers/azure.ts", @@ -22,6 +23,8 @@ "./providers/github-copilot": "./src/providers/github-copilot.ts", "./providers/google": "./src/providers/google.ts", "./providers/openai": "./src/providers/openai.ts", + "./providers/openai/responses": "./src/providers/openai/responses.ts", + "./providers/openai/chat": "./src/providers/openai/chat.ts", "./providers/openai-compatible": "./src/providers/openai-compatible.ts", "./providers/openai-compatible-profile": "./src/providers/openai-compatible-profile.ts", "./providers/openrouter": "./src/providers/openrouter.ts", diff --git a/packages/llm/src/index.ts b/packages/llm/src/index.ts index 735520ff77..843b4536a6 100644 --- a/packages/llm/src/index.ts +++ b/packages/llm/src/index.ts @@ -1,6 +1,7 @@ export { LLMClient } from "./route/client" export { Auth } from "./route/auth" export { Provider } from "./provider" +export { ProviderPackage } from "./provider-package" export { isContextOverflow, isContextOverflowFailure } from "./provider-error" export type { RouteModelInput, @@ -31,3 +32,7 @@ export type { ModelFactory as ProviderModelFactory, ModelOptions as ProviderModelOptions, } from "./provider" +export type { + Definition as ProviderPackageDefinition, + Settings as ProviderPackageSettings, +} from "./provider-package" diff --git a/packages/llm/src/provider-package.ts b/packages/llm/src/provider-package.ts new file mode 100644 index 0000000000..fd878a9d06 --- /dev/null +++ b/packages/llm/src/provider-package.ts @@ -0,0 +1,16 @@ +import type { Model } from "./schema" + +export interface Settings extends Readonly> { + readonly headers?: Readonly> + readonly body?: Readonly> + readonly limits?: { + readonly context: number + readonly output: number + } +} + +export interface Definition { + readonly model: (modelID: string, settings: ProviderSettings) => Model +} + +export * as ProviderPackage from "./provider-package" diff --git a/packages/llm/src/providers/amazon-bedrock.ts b/packages/llm/src/providers/amazon-bedrock.ts index 2f1791e0d6..4e5040c9ec 100644 --- a/packages/llm/src/providers/amazon-bedrock.ts +++ b/packages/llm/src/providers/amazon-bedrock.ts @@ -1,5 +1,6 @@ import type { RouteDefaultsInput } from "../route/client" import { Auth } from "../route/auth" +import type { ProviderPackage } from "../provider-package" import { ProviderID, type ModelID } from "../schema" import * as BedrockConverse from "../protocols/bedrock-converse" import type { BedrockCredentials } from "../protocols/bedrock-converse" @@ -15,6 +16,15 @@ export type Config = RouteDefaultsInput & { /** Override the computed `https://bedrock-runtime..amazonaws.com` URL. */ readonly baseURL?: string } + +export interface Settings extends ProviderPackage.Settings { + readonly apiKey?: string + readonly auth?: "bearer" | "sigv4" + readonly baseURL?: string + readonly credentials?: BedrockCredentials + readonly region?: string + readonly topP?: number +} export const routes = [BedrockConverse.route] const bedrockBaseURL = (region: string) => `https://bedrock-runtime.${region}.amazonaws.com` @@ -40,4 +50,19 @@ export const configure = (input: Config = {}) => { } export const provider = configure() -export const model = provider.model +export const model: ProviderPackage.Definition["model"] = (modelID, settings) => { + if (settings.auth === "bearer" && settings.apiKey === undefined) + throw new Error("Amazon Bedrock bearer auth requires apiKey") + if (settings.auth === "sigv4" && settings.apiKey !== undefined) + throw new Error("Amazon Bedrock SigV4 auth does not accept apiKey") + return configure({ + apiKey: settings.auth === "sigv4" ? undefined : settings.apiKey, + baseURL: settings.baseURL, + credentials: settings.credentials, + generation: settings.topP === undefined ? undefined : { topP: settings.topP }, + headers: settings.headers === undefined ? undefined : { ...settings.headers }, + http: settings.body === undefined ? undefined : { body: { ...settings.body } }, + limits: settings.limits, + region: settings.region, + }).model(modelID) +} diff --git a/packages/llm/src/providers/anthropic.ts b/packages/llm/src/providers/anthropic.ts index 0c9640af5e..1bf8bf569c 100644 --- a/packages/llm/src/providers/anthropic.ts +++ b/packages/llm/src/providers/anthropic.ts @@ -1,6 +1,7 @@ import type { RouteDefaultsInput } from "../route/client" import { Auth } from "../route/auth" import type { ProviderAuthOption } from "../route/auth-options" +import type { ProviderPackage } from "../provider-package" import { ProviderID, type ModelID } from "../schema" import * as AnthropicMessages from "../protocols/anthropic-messages" @@ -10,6 +11,11 @@ export const routes = [AnthropicMessages.route] export type Config = RouteDefaultsInput & ProviderAuthOption<"optional"> & { readonly baseURL?: string } +export interface Settings extends ProviderPackage.Settings { + readonly apiKey?: string + readonly baseURL?: string +} + const auth = (options: ProviderAuthOption<"optional">) => { if ("auth" in options && options.auth) return options.auth return Auth.optional("apiKey" in options ? options.apiKey : undefined, "apiKey") @@ -32,4 +38,11 @@ export const configure = (input: Config = {}) => { } export const provider = configure() -export const model = provider.model +export const model: ProviderPackage.Definition["model"] = (modelID, settings) => + configure({ + apiKey: settings.apiKey, + baseURL: settings.baseURL, + headers: settings.headers === undefined ? undefined : { ...settings.headers }, + http: settings.body === undefined ? undefined : { body: { ...settings.body } }, + limits: settings.limits, + }).model(modelID) diff --git a/packages/llm/src/providers/openai-compatible.ts b/packages/llm/src/providers/openai-compatible.ts index a79f65f6df..8b5d23eed7 100644 --- a/packages/llm/src/providers/openai-compatible.ts +++ b/packages/llm/src/providers/openai-compatible.ts @@ -2,6 +2,7 @@ import { ProviderID, type ModelID } from "../schema" import * as OpenAICompatibleChat from "../protocols/openai-compatible-chat" import type { RouteDefaultsInput } from "../route/client" import { AuthOptions, type ProviderAuthOption } from "../route/auth-options" +import type { ProviderPackage } from "../provider-package" import { profiles, type OpenAICompatibleProfile } from "./openai-compatible-profile" export const id = ProviderID.make("openai-compatible") @@ -12,6 +13,12 @@ type GenericModelOptions = RouteDefaultsInput & readonly baseURL: string } +export interface Settings extends ProviderPackage.Settings { + readonly apiKey?: string + readonly baseURL: string + readonly provider?: string +} + export type FamilyModelOptions = RouteDefaultsInput & ProviderAuthOption<"optional"> & { readonly baseURL?: string @@ -56,6 +63,16 @@ export const provider = { configure, } +export const model: ProviderPackage.Definition["model"] = (modelID, settings) => + configure({ + apiKey: settings.apiKey, + baseURL: settings.baseURL, + headers: settings.headers === undefined ? undefined : { ...settings.headers }, + http: settings.body === undefined ? undefined : { body: { ...settings.body } }, + limits: settings.limits, + provider: settings.provider, + }).model(modelID) + export const baseten = define(profiles.baseten) export const cerebras = define(profiles.cerebras) export const deepinfra = define(profiles.deepinfra) diff --git a/packages/llm/src/providers/openai.ts b/packages/llm/src/providers/openai.ts index 098cad8493..6b49b9def2 100644 --- a/packages/llm/src/providers/openai.ts +++ b/packages/llm/src/providers/openai.ts @@ -1,5 +1,6 @@ import { AuthOptions, type ProviderAuthOption } from "../route/auth-options" import type { Route, RouteDefaultsInput } from "../route/client" +import type { ProviderPackage } from "../provider-package" import { ProviderID, type ModelID } from "../schema" import * as OpenAIChat from "../protocols/openai-chat" import * as OpenAIResponses from "../protocols/openai-responses" @@ -21,6 +22,14 @@ export type Config = RouteDefaultsInput & readonly providerOptions?: OpenAIProviderOptionsInput } +export interface Settings extends ProviderPackage.Settings { + readonly apiKey?: string + readonly baseURL?: string + readonly queryParams?: Readonly> + readonly transport?: "http" | "websocket" + readonly providerOptions?: OpenAIProviderOptionsInput +} + const auth = (options: ProviderAuthOption<"optional">) => AuthOptions.bearer(options, "OPENAI_API_KEY") const defaults = (input: Config) => { @@ -57,7 +66,25 @@ export const configure = (input: Config = {}) => { export const provider = configure() -export const model = provider.model +const config = (settings: Settings): Config => ({ + apiKey: settings.apiKey, + baseURL: settings.baseURL, + headers: settings.headers === undefined ? undefined : { ...settings.headers }, + http: settings.body === undefined ? undefined : { body: { ...settings.body } }, + limits: settings.limits, + providerOptions: settings.providerOptions, + queryParams: settings.queryParams === undefined ? undefined : { ...settings.queryParams }, +}) + +export const model: ProviderPackage.Definition["model"] = (modelID, settings) => { + const configured = configure(config(settings)) + if (settings.transport === undefined || settings.transport === "http") return configured.responses(modelID) + if (settings.transport === "websocket") return configured.responsesWebSocket(modelID) + throw new Error(`Unsupported OpenAI Responses transport: ${String(settings.transport)}`) +} + +export const chatModel: ProviderPackage.Definition["model"] = (modelID, settings) => + configure(config(settings)).chat(modelID) export const responses = provider.responses export const responsesWebSocket = provider.responsesWebSocket export const chat = provider.chat diff --git a/packages/llm/src/providers/openai/chat.ts b/packages/llm/src/providers/openai/chat.ts new file mode 100644 index 0000000000..eb92db8ac5 --- /dev/null +++ b/packages/llm/src/providers/openai/chat.ts @@ -0,0 +1,2 @@ +export { chatModel as model } from "../openai" +export type { Settings } from "../openai" diff --git a/packages/llm/src/providers/openai/responses.ts b/packages/llm/src/providers/openai/responses.ts new file mode 100644 index 0000000000..4db3a232c7 --- /dev/null +++ b/packages/llm/src/providers/openai/responses.ts @@ -0,0 +1,2 @@ +export { model } from "../openai" +export type { Settings } from "../openai" diff --git a/packages/llm/test/exports.test.ts b/packages/llm/test/exports.test.ts index 693a21638b..6ed66a0d8e 100644 --- a/packages/llm/test/exports.test.ts +++ b/packages/llm/test/exports.test.ts @@ -30,7 +30,6 @@ describe("public exports", () => { test("provider barrels expose user-facing facades", () => { expect(OpenAI.model).toBeFunction() - expect(OpenAI.provider.model).toBe(OpenAI.model) expect(OpenAI.provider.responses).toBe(OpenAI.responses) expect(OpenAI.provider.responsesWebSocket).toBe(OpenAI.responsesWebSocket) expect(OpenAI.configure({ apiKey: "fixture" }).responses).toBeFunction() diff --git a/packages/llm/test/provider-package.test.ts b/packages/llm/test/provider-package.test.ts new file mode 100644 index 0000000000..c8cba78eb1 --- /dev/null +++ b/packages/llm/test/provider-package.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, test } from "bun:test" +import { model } from "@opencode-ai/llm/providers/openai" + +describe("provider package entrypoints", () => { + test("semantic API aliases expose the same contract", async () => { + const modules = await Promise.all([ + import("@opencode-ai/llm/providers/openai"), + import("@opencode-ai/llm/providers/openai/responses"), + import("@opencode-ai/llm/providers/openai/chat"), + import("@opencode-ai/llm/providers/anthropic"), + import("@opencode-ai/llm/providers/openai-compatible"), + import("@opencode-ai/llm/providers/amazon-bedrock"), + ]) + + for (const module of modules) expect(module.model).toBeFunction() + expect(modules[0].model).toBe(modules[1].model) + }) + + test("maps package settings onto the executable model", () => { + const selected = model("gpt-5", { + apiKey: "fixture", + baseURL: "https://api.openai.test/v1", + headers: { "x-application": "opencode" }, + body: { service_tier: "priority" }, + limits: { context: 200_000, output: 64_000 }, + unrelatedInheritedSetting: true, + }) + + expect(selected.route.id).toBe("openai-responses") + expect(selected.route.defaults.headers).toEqual({ "x-application": "opencode" }) + expect(selected.route.defaults.http?.body).toEqual({ service_tier: "priority" }) + expect(selected.route.defaults.limits).toEqual({ context: 200_000, output: 64_000 }) + }) + + test("selects transport without changing the semantic API", () => { + expect(model("gpt-5", { apiKey: "fixture" }).route.id).toBe("openai-responses") + expect(model("gpt-5", { apiKey: "fixture", transport: "websocket" }).route.id).toBe( + "openai-responses-websocket", + ) + }) +}) diff --git a/packages/schema/src/model.ts b/packages/schema/src/model.ts index a41b540b4d..4b04efd9f9 100644 --- a/packages/schema/src/model.ts +++ b/packages/schema/src/model.ts @@ -41,34 +41,23 @@ export const Cost = Schema.Struct({ }), }) -export const Api = Schema.Union([ - Schema.Struct({ - id: ID, - ...Provider.AISDK.fields, - }), - Schema.Struct({ - id: ID, - ...Provider.Native.fields, - }), -]).pipe(Schema.toTaggedUnion("type")) -export type Api = typeof Api.Type +export interface Variant extends Schema.Schema.Type {} +export const Variant = Schema.Struct({ + id: VariantID, + ...Provider.Overlays, +}) export interface Info extends Schema.Schema.Type {} export const Info = Schema.Struct({ id: ID, + modelID: ID.pipe(Schema.optional), providerID: Provider.ID, family: Family.pipe(Schema.optional), name: Schema.String, - api: Api, + package: Provider.Package.pipe(Schema.optional), + ...Provider.Overlays, capabilities: Capabilities, - request: Schema.Struct({ - ...Provider.Request.fields, - variant: Schema.String.pipe(Schema.optional), - }), - variants: Schema.Struct({ - id: VariantID, - ...Provider.Request.fields, - }).pipe(Schema.Array, Schema.mutable), + variants: Variant.pipe(Schema.Array, Schema.mutable, Schema.optional), time: Schema.Struct({ released: Schema.Finite, }), @@ -84,15 +73,12 @@ export const Info = Schema.Struct({ .annotate({ identifier: "ModelV2.Info" }) .pipe( withStatics((schema) => ({ - empty: (providerID: Provider.ID, modelID: ID) => + empty: (providerID: Provider.ID, id: ID) => schema.make({ - id: modelID, + id, providerID, - name: modelID, - api: { id: modelID, type: "native", settings: {} }, + name: id, capabilities: { tools: false, input: [], output: [] }, - request: { headers: {}, body: {} }, - variants: [], time: { released: 0 }, cost: [], status: "active", diff --git a/packages/schema/src/provider.ts b/packages/schema/src/provider.ts index d3d5109608..cd2cafaa45 100644 --- a/packages/schema/src/provider.ts +++ b/packages/schema/src/provider.ts @@ -22,23 +22,14 @@ export const ID = Schema.String.pipe( ) export type ID = typeof ID.Type -export interface AISDK extends Schema.Schema.Type {} -export const AISDK = Schema.Struct({ - type: Schema.Literal("aisdk"), - package: Schema.String, - url: Schema.String.pipe(Schema.optional), +export const Package = Schema.String +export type Package = typeof Package.Type + +export const Overlays = { settings: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), -}) - -export interface Native extends Schema.Schema.Type {} -export const Native = Schema.Struct({ - type: Schema.Literal("native"), - url: Schema.String.pipe(Schema.optional), - settings: Schema.Record(Schema.String, Schema.Unknown), -}) - -export const Api = Schema.Union([AISDK, Native]).pipe(Schema.toTaggedUnion("type")) -export type Api = typeof Api.Type + headers: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional), + body: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), +} export interface Request extends Schema.Schema.Type {} export const Request = Schema.Struct({ @@ -52,18 +43,12 @@ export const Info = Schema.Struct({ integrationID: Integration.ID.pipe(Schema.optional), name: Schema.String, disabled: Schema.Boolean.pipe(Schema.optional), - api: Api, - request: Request, + package: Package, + ...Overlays, }) .annotate({ identifier: "ProviderV2.Info" }) .pipe( withStatics((schema) => ({ - empty: (id: ID) => - schema.make({ - id, - name: id, - api: { type: "native", settings: {} }, - request: { headers: {}, body: {} }, - }), + empty: (id: ID) => schema.make({ id, name: id, package: "" }), })), ) diff --git a/packages/server/src/handlers/model.ts b/packages/server/src/handlers/model.ts index 36639ae7b1..a2c2fd4960 100644 --- a/packages/server/src/handlers/model.ts +++ b/packages/server/src/handlers/model.ts @@ -3,6 +3,7 @@ import { Effect } from "effect" import { HttpApiBuilder } from "effect/unstable/httpapi" import { Api } from "../api" import { response } from "../location" +import { ModelV2 } from "@opencode-ai/core/model" export const ModelHandler = HttpApiBuilder.group(Api, "server.model", (handlers) => Effect.gen(function* () { @@ -10,7 +11,7 @@ export const ModelHandler = HttpApiBuilder.group(Api, "server.model", (handlers) "model.list", Effect.fn(function* () { const catalog = yield* Catalog.Service - return yield* response(catalog.model.available()) + return yield* response(catalog.model.available().pipe(Effect.map((models) => models.map(ModelV2.flatten)))) }), ) }), diff --git a/packages/server/src/handlers/provider.ts b/packages/server/src/handlers/provider.ts index c3f25ab0df..9144cde29f 100644 --- a/packages/server/src/handlers/provider.ts +++ b/packages/server/src/handlers/provider.ts @@ -3,6 +3,7 @@ import { Effect } from "effect" import { HttpApiBuilder } from "effect/unstable/httpapi" import { Api } from "../api" import { ProviderNotFoundError } from "@opencode-ai/protocol/errors" +import { ProviderV2 } from "@opencode-ai/core/provider" import { response } from "../location" export const ProviderHandler = HttpApiBuilder.group(Api, "server.provider", (handlers) => @@ -12,7 +13,9 @@ export const ProviderHandler = HttpApiBuilder.group(Api, "server.provider", (han "provider.list", Effect.fn(function* () { const catalog = yield* Catalog.Service - return yield* response(catalog.provider.available()) + return yield* response( + catalog.provider.available().pipe(Effect.map((providers) => providers.map(ProviderV2.flatten))), + ) }), ) .handle( @@ -25,7 +28,7 @@ export const ProviderHandler = HttpApiBuilder.group(Api, "server.provider", (han providerID: ctx.params.providerID, message: `Provider not found: ${ctx.params.providerID}`, }) - return yield* response(Effect.succeed(provider)) + return yield* response(Effect.succeed(ProviderV2.flatten(provider))) }), ) }),