diff --git a/packages/cli/src/mini/catalog.shared.ts b/packages/cli/src/mini/catalog.shared.ts index d810c47077..535147e651 100644 --- a/packages/cli/src/mini/catalog.shared.ts +++ b/packages/cli/src/mini/catalog.shared.ts @@ -84,7 +84,7 @@ export function runProviders(providers: CurrentProvider[], models: CurrentModel[ cost: defaultCost(model), limit: model.limit, status: model.status, - variants: Object.fromEntries(model.variants.map((variant) => [variant.id, {}])), + variants: Object.fromEntries((model.variants ?? []).map((variant) => [variant.id, {}])), } grouped.set(provider.id, provider) } @@ -141,10 +141,7 @@ export async function loadRunCommands(sdk: OpenCodeClient, directory: string): P sdk.command.list(location(directory)), sdk.skill.list(location(directory)), ]) - return [ - ...commands.data.map(runCommand), - ...skills.data.filter((skill) => skill.slash !== false).map(runSkill), - ] + return [...commands.data.map(runCommand), ...skills.data.filter((skill) => skill.slash !== false).map(runSkill)] } export async function loadRunReferences(sdk: OpenCodeClient, directory: string): Promise { diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index afe02c11c0..5b3d6c165f 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -2009,39 +2009,24 @@ export type ModelListOutput = { } readonly data: ReadonlyArray<{ readonly id: string + readonly modelID: string readonly providerID: string readonly family?: string readonly name: string - readonly api: - | { - readonly id: string - readonly type: "aisdk" - readonly package: string - readonly url?: string - readonly settings?: { readonly [x: string]: JsonValue } - } - | { - readonly id: string - readonly type: "native" - readonly url?: string - readonly settings: { readonly [x: string]: JsonValue } - } + readonly package?: string + readonly settings?: { readonly [x: string]: JsonValue } + readonly headers?: { readonly [x: string]: string } + readonly body?: { readonly [x: string]: JsonValue } readonly capabilities: { readonly tools: boolean readonly input: ReadonlyArray readonly output: ReadonlyArray } - readonly request: { - readonly settings: { readonly [x: string]: JsonValue } - readonly headers: { readonly [x: string]: string } - readonly body: { readonly [x: string]: JsonValue } - readonly variant?: string - } readonly variants: ReadonlyArray<{ readonly id: string - readonly settings: { readonly [x: string]: JsonValue } - readonly headers: { readonly [x: string]: string } - readonly body: { readonly [x: string]: JsonValue } + readonly settings?: { readonly [x: string]: JsonValue } + readonly headers?: { readonly [x: string]: string } + readonly body?: { readonly [x: string]: JsonValue } }> readonly time: { readonly released: number } readonly cost: ReadonlyArray<{ @@ -2070,39 +2055,24 @@ export type ModelDefaultOutput = { } readonly data: { readonly id: string + readonly modelID: string readonly providerID: string readonly family?: string readonly name: string - readonly api: - | { - readonly id: string - readonly type: "aisdk" - readonly package: string - readonly url?: string - readonly settings?: { readonly [x: string]: JsonValue } - } - | { - readonly id: string - readonly type: "native" - readonly url?: string - readonly settings: { readonly [x: string]: JsonValue } - } + readonly package?: string + readonly settings?: { readonly [x: string]: JsonValue } + readonly headers?: { readonly [x: string]: string } + readonly body?: { readonly [x: string]: JsonValue } readonly capabilities: { readonly tools: boolean readonly input: ReadonlyArray readonly output: ReadonlyArray } - readonly request: { - readonly settings: { readonly [x: string]: JsonValue } - readonly headers: { readonly [x: string]: string } - readonly body: { readonly [x: string]: JsonValue } - readonly variant?: string - } readonly variants: ReadonlyArray<{ readonly id: string - readonly settings: { readonly [x: string]: JsonValue } - readonly headers: { readonly [x: string]: string } - readonly body: { readonly [x: string]: JsonValue } + readonly settings?: { readonly [x: string]: JsonValue } + readonly headers?: { readonly [x: string]: string } + readonly body?: { readonly [x: string]: JsonValue } }> readonly time: { readonly released: number } readonly cost: ReadonlyArray<{ @@ -2150,19 +2120,10 @@ export type ProviderListOutput = { readonly integrationID?: string readonly name: string readonly disabled?: boolean - readonly api: - | { - readonly type: "aisdk" - readonly package: string - readonly url?: string - readonly settings?: { readonly [x: string]: JsonValue } - } - | { readonly type: "native"; readonly url?: string; readonly settings: { readonly [x: string]: JsonValue } } - readonly request: { - readonly settings: { readonly [x: string]: JsonValue } - readonly headers: { readonly [x: string]: string } - readonly body: { readonly [x: string]: JsonValue } - } + readonly package: string + readonly settings?: { readonly [x: string]: JsonValue } + readonly headers?: { readonly [x: string]: string } + readonly body?: { readonly [x: string]: JsonValue } }> } @@ -2184,19 +2145,10 @@ export type ProviderGetOutput = { readonly integrationID?: string readonly name: string readonly disabled?: boolean - readonly api: - | { - readonly type: "aisdk" - readonly package: string - readonly url?: string - readonly settings?: { readonly [x: string]: JsonValue } - } - | { readonly type: "native"; readonly url?: string; readonly settings: { readonly [x: string]: JsonValue } } - readonly request: { - readonly settings: { readonly [x: string]: JsonValue } - readonly headers: { readonly [x: string]: string } - readonly body: { readonly [x: string]: JsonValue } - } + readonly package: string + readonly settings?: { readonly [x: string]: JsonValue } + readonly headers?: { readonly [x: string]: string } + readonly body?: { readonly [x: string]: JsonValue } } } diff --git a/packages/core/src/aisdk.ts b/packages/core/src/aisdk.ts index 9215e2473b..f7474b244a 100644 --- a/packages/core/src/aisdk.ts +++ b/packages/core/src/aisdk.ts @@ -1,13 +1,43 @@ export * as AISDK from "./aisdk" import { makeLocationNode } from "./effect/app-node" -import type { LanguageModelV3 } from "@ai-sdk/provider" -import { Cause, Context, Effect, Layer, Schema, Scope } from "effect" +import type { + JSONSchema7, + JSONValue, + LanguageModelV3, + LanguageModelV3CallOptions, + LanguageModelV3FunctionTool, + LanguageModelV3Message, + LanguageModelV3Prompt, + LanguageModelV3StreamPart, + LanguageModelV3ToolChoice, + SharedV3ProviderOptions, +} from "@ai-sdk/provider" +import { + FinishReason, + InvalidProviderOutputReason, + LLMEvent, + LLMError, + Model, + ProviderID, + ProviderMetadata, + ToolResultValue, + UnknownProviderReason, + type ContentPart, + type LLMRequest, + type ToolDefinition, + type UsageInput, +} from "@opencode-ai/llm" +import { Auth, Endpoint, type AnyRoute } from "@opencode-ai/llm/route" +import { Cause, Context, Effect, Layer, Option, Schema, Scope, Stream } from "effect" import { ModelV2 } from "./model" import { ProviderV2 } from "./provider" import { State } from "./state" type SDK = any +type UserContent = Extract["content"] +type AssistantContent = Extract["content"] +type ToolResultContent = Extract export interface SDKEvent { readonly model: ModelV2.Info @@ -74,10 +104,10 @@ function wrapSSE(res: Response, ms: number, ctl: AbortController) { function prepareOptions(model: ModelV2.Info, pkg: string) { const options: Record = { name: model.providerID, - ...(model.api.type === "aisdk" ? (model.api.settings ?? {}) : {}), - ...model.request.body, + ...(model.settings ?? {}), + headers: model.headers, + body: model.body, } - if (model.api.type === "aisdk" && model.api.url) options.baseURL = model.api.url const customFetch = options.fetch const chunkTimeout = options.chunkTimeout @@ -110,6 +140,13 @@ function prepareOptions(model: ModelV2.Info, pkg: string) { } } + if (typeof opts.body === "string" && model.body !== undefined) { + const decoded = Option.getOrUndefined(Schema.decodeUnknownOption(Schema.UnknownFromJsonString)(opts.body)) + if (Schema.is(Schema.Record(Schema.String, Schema.Json))(decoded)) { + opts.body = JSON.stringify(ProviderV2.mergeOverlay(decoded, model.body)) + } + } + const res = await (typeof customFetch === "function" ? customFetch : fetch)(input, { ...opts, timeout: false, @@ -142,17 +179,29 @@ export interface Interface { readonly runSDK: (event: SDKEvent) => Effect.Effect readonly runLanguage: (event: LanguageEvent) => Effect.Effect readonly language: (model: ModelV2.Info) => Effect.Effect + readonly model: (model: ModelV2.Info) => Effect.Effect } export class Service extends Context.Service()("@opencode/v2/AISDK") {} -const layer = Layer.effect( +export const locationLayer = Layer.effect( Service, Effect.gen(function* () { let sdkHooks: ((event: SDKEvent) => Effect.Effect | void)[] = [] let languageHooks: ((event: LanguageEvent) => Effect.Effect | void)[] = [] const languages = new Map() const sdks = new Map() + const functionIDs = new WeakMap() + let nextFunctionID = 0 + const cacheKey = (input: unknown) => + JSON.stringify(input, (_key, value: unknown) => { + if (typeof value !== "function") return value + const existing = functionIDs.get(value) + if (existing !== undefined) return `function:${existing}` + const id = nextFunctionID++ + functionIDs.set(value, id) + return `function:${id}` + }) ?? "" const register = ( hooks: () => ((event: Event) => Effect.Effect | void)[], @@ -196,24 +245,36 @@ const layer = Layer.effect( runSDK: (event) => run(sdkHooks, event), runLanguage: (event) => run(languageHooks, event), language: Effect.fn("AISDK.language")(function* (model) { - const key = `${model.providerID}/${model.id}/${model.request.variant ?? "default"}` + const key = cacheKey({ + providerID: model.providerID, + id: model.id, + modelID: model.modelID, + package: model.package, + settings: model.settings, + headers: model.headers, + body: model.body, + limit: model.limit, + }) const existing = languages.get(key) if (existing) return existing - if (model.api.type !== "aisdk") + if (!ProviderV2.isAISDK(model.package)) return yield* new InitError({ providerID: model.providerID, - cause: new Error(`Unsupported api ${model.api.type}`), + cause: new Error(`Unsupported package ${model.package}`), }) - const options = prepareOptions(model, model.api.package) - const sdkKey = JSON.stringify({ + const packageName = ProviderV2.packageName(model.package) ?? "" + const options = prepareOptions(model, packageName) + const sdkKey = cacheKey({ providerID: model.providerID, - api: model.api, - options, + package: packageName, + settings: model.settings, + headers: model.headers, + body: model.body, }) const sdk = sdks.get(sdkKey) ?? - (yield* service.runSDK({ model, package: model.api.package, options }).pipe(initError(model.providerID))).sdk + (yield* service.runSDK({ model, package: packageName, options }).pipe(initError(model.providerID))).sdk if (!sdk) return yield* new InitError({ providerID: model.providerID, @@ -221,15 +282,396 @@ const layer = Layer.effect( }) sdks.set(sdkKey, sdk) const result = yield* service.runLanguage({ model, sdk, options }).pipe(initError(model.providerID)) - const language = yield* Effect.sync(() => result.language ?? sdk.languageModel(model.api.id)).pipe( + const language = yield* Effect.sync(() => result.language ?? sdk.languageModel(model.modelID ?? model.id)).pipe( initError(model.providerID), ) languages.set(key, language) return language }), + model: Effect.fn("AISDK.model")(function* (model) { + return modelFromLanguage(model, yield* service.language(model)) + }), }) return service }), ) -export const node = makeLocationNode({ service: Service, layer, deps: [] }) +export const defaultLayer = locationLayer + +function modelFromLanguage(info: ModelV2.Info, language: LanguageModelV3) { + const settings = requestSettings(info.settings) + const optionKey = providerOptionKey(ProviderV2.packageName(info.package), info.providerID) + const route: AnyRoute = { + id: `ai-sdk:${ProviderV2.packageName(info.package) ?? "unknown"}`, + provider: ProviderID.make(info.providerID), + protocol: "ai-sdk", + endpoint: Endpoint.path("/", { baseURL: "https://ai-sdk.local" }), + auth: Auth.none, + transport: { + id: "ai-sdk", + prepare: (input) => Effect.succeed(input.body), + frames: () => Stream.empty, + }, + defaults: { + headers: info.headers, + http: + info.body === undefined && info.headers === undefined + ? undefined + : { body: info.body === undefined ? undefined : { ...info.body }, headers: info.headers }, + limits: { context: info.limit.context, output: info.limit.output }, + providerOptions: settings === undefined ? undefined : { [optionKey]: settings }, + }, + body: { + schema: Schema.Unknown, + from: (request) => Effect.succeed(callOptions(request)), + }, + with: () => route, + model: (input) => Model.make({ ...input, provider: "provider" in input ? input.provider : info.providerID, route }), + prepareTransport: (body) => Effect.succeed(body), + streamPrepared: (prepared) => streamLanguage(language, prepared as LanguageModelV3CallOptions), + } + return Model.make({ id: info.modelID ?? info.id, provider: info.providerID, route }) +} + +function providerOptionKey(packageName: string | undefined, providerID: ProviderV2.ID) { + if (packageName === "@ai-sdk/google") return "google" + if (packageName === "@ai-sdk/google-vertex") return "vertex" + if (packageName === "@ai-sdk/google-vertex/anthropic") return "anthropic" + if (packageName === "@ai-sdk/amazon-bedrock" || packageName === "@ai-sdk/amazon-bedrock/mantle") return "bedrock" + if (packageName === "@ai-sdk/azure") return "azure" + if (packageName === "@openrouter/ai-sdk-provider") return "openrouter" + if (packageName?.startsWith("@ai-sdk/")) return packageName.slice("@ai-sdk/".length) + return providerID +} + +function requestSettings(settings: Readonly> | undefined) { + if (settings === undefined) return undefined + const result = Object.fromEntries( + Object.entries(settings).filter( + ([key]) => !["apiKey", "authToken", "baseURL", "chunkTimeout", "fetch", "timeout"].includes(key), + ), + ) + return Object.keys(result).length === 0 ? undefined : result +} + +function callOptions(request: LLMRequest): LanguageModelV3CallOptions { + return { + prompt: prompt(request), + maxOutputTokens: request.generation?.maxTokens ?? request.model.route.defaults.limits?.output, + temperature: request.generation?.temperature, + stopSequences: request.generation?.stop === undefined ? undefined : [...request.generation.stop], + topP: request.generation?.topP, + topK: request.generation?.topK, + presencePenalty: request.generation?.presencePenalty, + frequencyPenalty: request.generation?.frequencyPenalty, + seed: request.generation?.seed, + responseFormat: responseFormat(request), + tools: request.tools.map(tool), + toolChoice: toolChoice(request.toolChoice), + headers: request.http?.headers, + providerOptions: providerOptions(request.providerOptions), + } +} + +function prompt(request: LLMRequest): LanguageModelV3Prompt { + const system = request.system + .map((part) => part.text) + .filter(Boolean) + .join("\n\n") + const messages = request.messages.flatMap(message) + if (!system.length) return messages + return [{ role: "system", content: system }, ...messages] +} + +function message(input: LLMRequest["messages"][number]): LanguageModelV3Message[] { + switch (input.role) { + case "system": + return [{ role: "system", content: input.content.flatMap(text).join("\n\n") }] + case "user": + return [{ role: "user", content: input.content.flatMap(userPart) }] + case "assistant": + return [{ role: "assistant", content: input.content.flatMap(assistantPart) }] + case "tool": { + const content = input.content.flatMap(toolResultPart) + return content.length ? [{ role: "tool", content }] : [] + } + } +} + +function text(part: ContentPart) { + return part.type === "text" ? [part.text] : [] +} + +function userPart(part: ContentPart): UserContent { + if (part.type === "text") return [{ type: "text", text: part.text }] + if (part.type === "media") + return [{ type: "file", mediaType: part.mediaType, data: part.data, filename: part.filename }] + return [] +} + +function assistantPart(part: ContentPart): AssistantContent { + switch (part.type) { + case "text": + return [{ type: "text", text: part.text }] + case "media": + return [{ type: "file", mediaType: part.mediaType, data: part.data, filename: part.filename }] + case "reasoning": + return [{ type: "reasoning", text: part.text }] + case "tool-call": + return [ + { + type: "tool-call", + toolCallId: part.id, + toolName: part.name, + input: part.input, + providerExecuted: part.providerExecuted, + }, + ] + case "tool-result": + return toolResultPart(part) + } +} + +function toolResultPart(part: ContentPart): ToolResultContent[] { + if (part.type !== "tool-result") return [] + return [ + { + type: "tool-result", + toolCallId: part.id, + toolName: part.name, + output: toolOutput(part.result), + }, + ] +} + +function toolOutput(result: ToolResultValue) { + switch (result.type) { + case "text": + case "error": + return { type: "text" as const, value: messageValue(result.value) } + } + return { type: "json" as const, value: jsonValue(result.value) } +} + +function tool(input: ToolDefinition): LanguageModelV3FunctionTool { + return { + type: "function", + name: input.name, + description: input.description, + inputSchema: input.inputSchema as JSONSchema7, + } +} + +function toolChoice(input: LLMRequest["toolChoice"]): LanguageModelV3ToolChoice | undefined { + if (!input) return undefined + if (input.type === "tool") return input.name === undefined ? undefined : { type: "tool", toolName: input.name } + return { type: input.type } +} + +function responseFormat(request: LLMRequest): LanguageModelV3CallOptions["responseFormat"] { + if (request.responseFormat?.type === "json") + return { type: "json", schema: request.responseFormat.schema as JSONSchema7 } + if (request.responseFormat) return { type: "text" } +} + +function providerOptions(input: LLMRequest["providerOptions"]): SharedV3ProviderOptions | undefined { + if (!input) return undefined + return Object.fromEntries(Object.entries(input).map(([key, value]) => [key, jsonObject(value)])) +} + +function streamLanguage(language: LanguageModelV3, options: LanguageModelV3CallOptions) { + const state = { step: 0, toolNames: {} as Record } + return Stream.concat( + Stream.make(LLMEvent.stepStart({ index: state.step })), + Stream.unwrap( + Effect.tryPromise({ + try: () => language.doStream(options), + catch: (error) => llmError("doStream", error), + }).pipe( + Effect.map((result) => + Stream.fromReadableStream({ + evaluate: () => result.stream, + onError: (error) => llmError("readStream", error), + }).pipe( + Stream.mapEffect((event) => streamPartEvents(state, event)), + Stream.flatMap((events) => Stream.fromIterable(events)), + ), + ), + ), + ), + ) +} + +function streamPartEvents( + state: { step: number; toolNames: Record }, + event: LanguageModelV3StreamPart, +): Effect.Effect, LLMError> { + switch (event.type) { + case "stream-start": + case "response-metadata": + case "raw": + case "file": + case "source": + case "tool-approval-request": + return Effect.succeed([]) + case "text-start": + return Effect.succeed([ + LLMEvent.textStart({ id: event.id, providerMetadata: providerMetadata(event.providerMetadata) }), + ]) + case "text-delta": + return Effect.succeed([ + LLMEvent.textDelta({ + id: event.id, + text: event.delta, + providerMetadata: providerMetadata(event.providerMetadata), + }), + ]) + case "text-end": + return Effect.succeed([ + LLMEvent.textEnd({ id: event.id, providerMetadata: providerMetadata(event.providerMetadata) }), + ]) + case "reasoning-start": + return Effect.succeed([ + LLMEvent.reasoningStart({ id: event.id, providerMetadata: providerMetadata(event.providerMetadata) }), + ]) + case "reasoning-delta": + return Effect.succeed([ + LLMEvent.reasoningDelta({ + id: event.id, + text: event.delta, + providerMetadata: providerMetadata(event.providerMetadata), + }), + ]) + case "reasoning-end": + return Effect.succeed([ + LLMEvent.reasoningEnd({ id: event.id, providerMetadata: providerMetadata(event.providerMetadata) }), + ]) + case "tool-input-start": + state.toolNames[event.id] = event.toolName + return Effect.succeed([ + LLMEvent.toolInputStart({ + id: event.id, + name: event.toolName, + providerMetadata: providerMetadata(event.providerMetadata), + }), + ]) + case "tool-input-delta": + return Effect.succeed([ + LLMEvent.toolInputDelta({ id: event.id, name: state.toolNames[event.id] ?? "unknown", text: event.delta }), + ]) + case "tool-input-end": + return Effect.succeed([ + LLMEvent.toolInputEnd({ + id: event.id, + name: state.toolNames[event.id] ?? "unknown", + providerMetadata: providerMetadata(event.providerMetadata), + }), + ]) + case "tool-call": + state.toolNames[event.toolCallId] = event.toolName + return Effect.succeed([ + LLMEvent.toolCall({ + id: event.toolCallId, + name: event.toolName, + input: parseToolInput(event.input), + providerExecuted: event.providerExecuted, + providerMetadata: providerMetadata(event.providerMetadata), + }), + ]) + case "tool-result": + delete state.toolNames[event.toolCallId] + return Effect.succeed([ + LLMEvent.toolResult({ + id: event.toolCallId, + name: event.toolName, + result: ToolResultValue.make(event.result, event.isError ? "error" : "json"), + providerExecuted: true, + providerMetadata: providerMetadata(event.providerMetadata), + }), + ]) + case "finish": + return Effect.succeed([ + LLMEvent.stepFinish({ + index: state.step++, + reason: finishReason(event.finishReason), + usage: usage(event.usage), + providerMetadata: providerMetadata(event.providerMetadata), + }), + LLMEvent.finish({ + reason: finishReason(event.finishReason), + usage: usage(event.usage), + providerMetadata: providerMetadata(event.providerMetadata), + }), + ]) + case "error": + return Effect.fail(llmError("stream", event.error)) + } +} + +function usage(input: Extract["usage"]): UsageInput | undefined { + const output = { + inputTokens: input.inputTokens.total, + nonCachedInputTokens: input.inputTokens.noCache, + cacheReadInputTokens: input.inputTokens.cacheRead, + cacheWriteInputTokens: input.inputTokens.cacheWrite, + outputTokens: input.outputTokens.total, + reasoningTokens: input.outputTokens.reasoning, + totalTokens: + input.inputTokens.total === undefined || input.outputTokens.total === undefined + ? undefined + : input.inputTokens.total + input.outputTokens.total, + } + return Object.values(output).some((value) => value !== undefined) ? output : undefined +} + +function finishReason(value: unknown): FinishReason { + return Schema.is(FinishReason)(value) ? value : "unknown" +} + +function providerMetadata(value: unknown) { + return Schema.is(ProviderMetadata)(value) ? value : undefined +} + +function parseToolInput(value: string) { + try { + return JSON.parse(value) as unknown + } catch { + return value + } +} + +function jsonObject(input: Record) { + return Object.fromEntries(Object.entries(input).map(([key, value]) => [key, jsonValue(value)])) +} + +function jsonValue(input: unknown): JSONValue { + try { + const encoded = JSON.stringify(input) + return encoded === undefined ? null : (JSON.parse(encoded) as JSONValue) + } catch { + return messageValue(input) + } +} + +function messageValue(input: unknown) { + if (typeof input === "string") return input + try { + return JSON.stringify(input) ?? String(input) + } catch { + return String(input) + } +} + +function llmError(method: string, error: unknown) { + const reason = + error instanceof LLMError + ? new InvalidProviderOutputReason({ message: error.message }) + : new UnknownProviderReason({ message: error instanceof Error ? error.message : String(error) }) + return new LLMError({ + module: "AISDK", + method, + reason, + }) +} + +export const node = makeLocationNode({ service: Service, layer: locationLayer, deps: [] }) diff --git a/packages/core/src/catalog.ts b/packages/core/src/catalog.ts index ab34db8ea4..2441f03789 100644 --- a/packages/core/src/catalog.ts +++ b/packages/core/src/catalog.ts @@ -66,39 +66,21 @@ const layer = Layer.effect( const available = (provider: ProviderV2.Info, integration: Integration.Info | undefined) => { if (provider.disabled) return false - if (typeof provider.request.body.apiKey === "string") return true + if (typeof provider.settings?.apiKey === "string") return true if (integration?.connections.length) return true return provider.integrationID === undefined && !integration } 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 === "aisdk" && provider.api.type === "aisdk" && !model.api.url - ? { ...model.api, url: provider.api.url, settings: { ...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 - const request = { - settings: { ...provider.request.settings, ...model.request.settings }, - headers: { ...provider.request.headers, ...model.request.headers }, - body: { ...provider.request.body, ...model.request.body }, - variant: model.request.variant, - } return ModelV2.Info.make({ ...model, - api, - request, + package: model.package ?? provider.package, + settings: ProviderV2.mergeOverlay(provider.settings, model.settings), + headers: ProviderV2.mergeHeaders(provider.headers, model.headers), + body: ProviderV2.mergeOverlay(provider.body, model.body), }) } - const normalizeApi = (item: ProviderV2.MutableInfo | ModelV2.MutableInfo) => { - if (typeof item.request.body.baseURL !== "string") return - item.api.url = item.request.body.baseURL - delete item.request.body.baseURL - } - const state = State.create({ initial: () => ({ providers: new Map() }), draft: (draft) => { @@ -116,7 +98,6 @@ const layer = Layer.effect( draft.providers.set(providerID, current) } fn(current.provider) - normalizeApi(current.provider) }, remove: (providerID) => { draft.providers.delete(providerID) @@ -139,7 +120,6 @@ const layer = Layer.effect( fn(model) model.id = modelID model.providerID = providerID - normalizeApi(model) }, remove: (providerID, modelID) => { draft.providers.get(providerID)?.models.delete(modelID) diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index 4bd33ca240..ae05ddef7d 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -19,6 +19,7 @@ import { ConfigCommand } from "./config/command" import { ConfigFormatter } from "./config/formatter" import { ConfigLSP } from "./config/lsp" import { ConfigMCP } from "./config/mcp" +import { ConfigModel } from "./config/model" import { ConfigPlugin } from "./config/plugin" import { ConfigProvider } from "./config/provider" import { ConfigReference } from "./config/reference" @@ -35,7 +36,7 @@ export class Info extends Schema.Class("Config.Info")({ shell: Schema.String.pipe(Schema.optional).annotate({ description: "Default shell to use for terminal and shell tool execution", }), - model: Schema.String.pipe(Schema.optional).annotate({ + model: ConfigModel.Selection.pipe(Schema.optional).annotate({ description: "Default model to use when no session or agent model is selected", }), default_agent: Schema.String.pipe(Schema.optional).annotate({ diff --git a/packages/core/src/config/agent.ts b/packages/core/src/config/agent.ts index 63df995f85..fc5edf5119 100644 --- a/packages/core/src/config/agent.ts +++ b/packages/core/src/config/agent.ts @@ -3,6 +3,7 @@ export * as ConfigAgent from "./agent" import { Schema } from "effect" import { Permission } from "@opencode-ai/schema/permission" import { ConfigProvider } from "./provider" +import { ConfigModel } from "./model" import { PositiveInt } from "../schema" export const Color = Schema.Union([ @@ -11,8 +12,7 @@ export const Color = Schema.Union([ ]) export class Info extends Schema.Class("ConfigV2.Agent")({ - model: Schema.String.pipe(Schema.optional), - variant: Schema.String.pipe(Schema.optional), + model: ConfigModel.Selection.pipe(Schema.optional), request: ConfigProvider.Request.pipe(Schema.optional), system: Schema.String.pipe(Schema.optional), description: Schema.String.pipe(Schema.optional), diff --git a/packages/core/src/config/command.ts b/packages/core/src/config/command.ts index 394079b1e9..8168107fd1 100644 --- a/packages/core/src/config/command.ts +++ b/packages/core/src/config/command.ts @@ -1,12 +1,12 @@ export * as ConfigCommand from "./command" import { Schema } from "effect" +import { ConfigModel } from "./model" export class Info extends Schema.Class("ConfigV2.Command")({ template: Schema.String, description: Schema.String.pipe(Schema.optional), agent: Schema.String.pipe(Schema.optional), - model: Schema.String.pipe(Schema.optional), - variant: Schema.String.pipe(Schema.optional), + model: ConfigModel.Selection.pipe(Schema.optional), subtask: Schema.Boolean.pipe(Schema.optional), }) {} diff --git a/packages/core/src/config/model.ts b/packages/core/src/config/model.ts new file mode 100644 index 0000000000..a1ee46a5f1 --- /dev/null +++ b/packages/core/src/config/model.ts @@ -0,0 +1,38 @@ +export * as ConfigModel from "./model" + +import { Schema, SchemaGetter } from "effect" +import { Model } from "@opencode-ai/schema/model" +import { Provider } from "@opencode-ai/schema/provider" + +const ProviderID = Provider.ID.check(Schema.isPattern(/^[^/#]+$/)) +const ModelID = Model.ID.check(Schema.isPattern(/^[^#]+$/)) +const VariantID = Model.VariantID.check(Schema.isPattern(/^[^#]+$/)) + +const Explicit = Schema.Struct({ + providerID: ProviderID, + model: ModelID, + variant: VariantID.pipe(Schema.optional), +}) + +const Short = Schema.String.check(Schema.isPattern(/^[^/#]+\/[^#]+(?:#[^#]+)?$/)) + +export interface Selection extends Schema.Schema.Type {} +export const Selection = Schema.Union([Short, Explicit]) + .pipe( + Schema.decodeTo(Explicit, { + decode: SchemaGetter.transform((input) => (typeof input === "string" ? parse(input) : input)), + encode: SchemaGetter.passthrough({ strict: false }), + }), + ) + .annotate({ identifier: "Config.ModelSelection" }) + +function parse(input: string): Selection { + const providerEnd = input.indexOf("/") + const variantStart = input.lastIndexOf("#") + const hasVariant = variantStart > providerEnd + return { + providerID: Provider.ID.make(input.slice(0, providerEnd)), + model: Model.ID.make(input.slice(providerEnd + 1, hasVariant ? variantStart : undefined)), + ...(hasVariant ? { variant: Model.VariantID.make(input.slice(variantStart + 1)) } : {}), + } +} diff --git a/packages/core/src/config/plugin/agent.ts b/packages/core/src/config/plugin/agent.ts index fa8b61ce18..e6ed0b0ed8 100644 --- a/packages/core/src/config/plugin/agent.ts +++ b/packages/core/src/config/plugin/agent.ts @@ -8,7 +8,6 @@ import { Config } from "../../config" import { ConfigAgent } from "../agent" import { ConfigMarkdown } from "../markdown" import { FSUtil } from "../../fs-util" -import { ModelV2 } from "../../model" import { ConfigAgentV1 } from "../../v1/config/agent" import { ConfigMigrateV1 } from "../../v1/config/migrate" @@ -76,13 +75,12 @@ export const Plugin = define({ const exists = draft.get(agentID) !== undefined draft.update(agentID, (agent) => { if (!exists) agent.permissions.push(...global) - if (item.model !== undefined) { - const model = ModelV2.parse(item.model) - agent.model = { id: model.modelID, providerID: model.providerID, variant: agent.model?.variant } - } - if (item.variant !== undefined && agent.model !== undefined) { - agent.model.variant = ModelV2.VariantID.make(item.variant) - } + if (item.model !== undefined) + agent.model = { + id: item.model.model, + providerID: item.model.providerID, + ...(item.model.variant === undefined ? {} : { variant: item.model.variant }), + } if (item.request !== undefined) { Object.assign(agent.request.headers, item.request.headers ?? {}) Object.assign(agent.request.body, item.request.body ?? {}) @@ -134,14 +132,16 @@ function decode(file: { directory: string; filepath: string; primary: boolean }, .replace(/\.md$/, "") const body = markdown.content.trim() const legacy = Object.keys(markdown.data).some((key) => !agentKeys.has(key)) - const agent = Option.getOrUndefined( - legacy - ? Option.map( + const agent = legacy + ? Option.getOrUndefined( + Option.map( decodeLegacyAgent({ name, ...markdown.data, prompt: body }, { errors: "all", propertyOrder: "original" }), ConfigMigrateV1.migrateAgent, - ) - : decodeAgent({ ...markdown.data, system: body }, { errors: "all", propertyOrder: "original" }), - ) + ), + ) + : Option.getOrUndefined( + decodeAgent({ ...markdown.data, system: body }, { errors: "all", propertyOrder: "original" }), + ) if (!agent) return const info = Option.getOrUndefined( decodeConfig({ diff --git a/packages/core/src/config/plugin/command.ts b/packages/core/src/config/plugin/command.ts index a7babbb5aa..b294ab15b9 100644 --- a/packages/core/src/config/plugin/command.ts +++ b/packages/core/src/config/plugin/command.ts @@ -6,7 +6,6 @@ import { Effect, Option, Schema, Stream } from "effect" import { CommandV2 } from "../../command" import { Config } from "../../config" import { FSUtil } from "../../fs-util" -import { ModelV2 } from "../../model" import { ConfigCommand } from "../command" import { ConfigMarkdown } from "../markdown" @@ -35,13 +34,12 @@ export const Plugin = define({ item.template = command.template if (command.description !== undefined) item.description = command.description if (command.agent !== undefined) item.agent = command.agent - if (command.model !== undefined) { - const model = ModelV2.parse(command.model) - item.model = { id: model.modelID, providerID: model.providerID, variant: item.model?.variant } - } - if (command.variant !== undefined && item.model !== undefined) { - item.model.variant = ModelV2.VariantID.make(command.variant) - } + if (command.model !== undefined) + item.model = { + id: command.model.model, + providerID: command.model.providerID, + ...(command.model.variant === undefined ? {} : { variant: command.model.variant }), + } if (command.subtask !== undefined) item.subtask = command.subtask }) } diff --git a/packages/core/src/config/plugin/provider.ts b/packages/core/src/config/plugin/provider.ts index 320895d128..3cfc799d72 100644 --- a/packages/core/src/config/plugin/provider.ts +++ b/packages/core/src/config/plugin/provider.ts @@ -4,6 +4,7 @@ import { define } from "@opencode-ai/plugin/v2/effect/plugin" import { Effect, Stream } from "effect" import { Config } from "../../config" import { ModelV2 } from "../../model" +import { ProviderV2 } from "../../provider" export const Plugin = define({ id: "opencode.config.provider", @@ -39,27 +40,29 @@ export const Plugin = define({ yield* ctx.catalog.transform((catalog) => { const files = loaded.entries.filter((entry): entry is Config.Document => entry.type === "document") const configuredDefault = Config.latest(loaded.entries, "model") - if (configuredDefault !== undefined) { - const model = ModelV2.parse(configuredDefault) - catalog.model.default.set(model.providerID, model.modelID) - } + if (configuredDefault !== undefined) + catalog.model.default.set(configuredDefault.providerID, configuredDefault.model) for (const file of files) { for (const [id, item] of Object.entries(file.info.providers ?? {})) { const providerID = id 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.settings, item.request.settings) - Object.assign(provider.request.headers, item.request.headers) - Object.assign(provider.request.body, item.request.body) - } + if (item.package !== undefined) provider.package = item.package + if (item.settings !== undefined) + provider.settings = ProviderV2.mergeOverlay(provider.settings, item.settings) + if (item.headers !== undefined) provider.headers = ProviderV2.mergeHeaders(provider.headers, item.headers) + if (item.body !== undefined) provider.body = ProviderV2.mergeOverlay(provider.body, item.body) }) for (const [id, config] of Object.entries(item.models ?? {})) { 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.modelID !== undefined) model.modelID = config.modelID + if (config.package !== undefined) model.package = config.package + if (config.settings !== undefined) + model.settings = ProviderV2.mergeOverlay(model.settings, config.settings) + if (config.headers !== undefined) model.headers = ProviderV2.mergeHeaders(model.headers, config.headers) + if (config.body !== undefined) model.body = ProviderV2.mergeOverlay(model.body, config.body) if (config.capabilities !== undefined) { model.capabilities = { tools: config.capabilities.tools, @@ -67,27 +70,19 @@ export const Plugin = define({ output: [...config.capabilities.output], } } - if (config.request !== undefined) { - Object.assign(model.request.settings, config.request.settings) - 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.variants !== undefined) { + model.variants ??= [] 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: {}, - } + existing = { id: variant.id } model.variants.push(existing) } - Object.assign(existing.settings, variant.settings) - Object.assign(existing.headers, variant.headers) - Object.assign(existing.body, variant.body) + if (variant.settings !== undefined) + existing.settings = ProviderV2.mergeOverlay(existing.settings, variant.settings) + if (variant.headers !== undefined) + existing.headers = ProviderV2.mergeHeaders(existing.headers, variant.headers) + if (variant.body !== undefined) existing.body = ProviderV2.mergeOverlay(existing.body, variant.body) } } if (config.cost !== undefined) { diff --git a/packages/core/src/config/provider.ts b/packages/core/src/config/provider.ts index c78d7979ae..ec37182395 100644 --- a/packages/core/src/config/provider.ts +++ b/packages/core/src/config/provider.ts @@ -1,13 +1,19 @@ 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")({ - settings: ProviderV2.Settings.pipe(Schema.optional), +const JsonRecord = Schema.Record(Schema.String, Schema.Json) + +export const Overlays = { + settings: JsonRecord.pipe(Schema.optional), headers: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional), - body: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), + body: JsonRecord.pipe(Schema.optional), +} + +export class Request extends Schema.Class("ConfigV2.Provider.Request")({ + headers: Overlays.headers, + body: Overlays.body, }) {} class Cache extends Schema.Class("ConfigV2.Model.Cost.Cache")({ @@ -31,32 +37,16 @@ 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")({ + modelID: ModelV2.ID.pipe(Schema.optional), family: ModelV2.Family.pipe(Schema.optional), name: Schema.String.pipe(Schema.optional), - api: ModelApi.pipe(Schema.optional), + package: Schema.String.pipe(Schema.optional), + ...Overlays, capabilities: ModelV2.Capabilities.pipe(Schema.optional), - request: Schema.Struct({ - ...Request.fields, - variant: Schema.String.pipe(Schema.optional), - }).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), @@ -66,7 +56,7 @@ 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), + ...Overlays, models: Schema.Record(Schema.String, Model).pipe(Schema.optional), }) {} diff --git a/packages/core/src/model.ts b/packages/core/src/model.ts index 41abbcb2d2..3f264ecd9f 100644 --- a/packages/core/src/model.ts +++ b/packages/core/src/model.ts @@ -1,6 +1,6 @@ -import { Types } from "effect" import { Model } from "@opencode-ai/schema/model" import { ProviderV2 } from "./provider" +import type { DeepMutable } from "./schema" export const ID = Model.ID export type ID = typeof ID.Type @@ -20,20 +20,10 @@ 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 - export const Info = Model.Info export type Info = Model.Info -export type MutableRequest = ProviderV2.MutableRequest & { variant?: string } -export type MutableVariant = ProviderV2.MutableRequest & { id: VariantID } - -export type MutableInfo = Omit, "api" | "request" | "variants"> & { - api: ProviderV2.MutableApi - request: MutableRequest - variants: MutableVariant[] -} +export type MutableInfo = DeepMutable export function parse(input: string): { providerID: ProviderV2.ID; modelID: ID } { const [providerID, ...modelID] = input.split("/") diff --git a/packages/core/src/plugin/models-dev.ts b/packages/core/src/plugin/models-dev.ts index 23a166e267..67cf0069dc 100644 --- a/packages/core/src/plugin/models-dev.ts +++ b/packages/core/src/plugin/models-dev.ts @@ -72,7 +72,7 @@ function mergeCost(base: ModelV2Info["cost"], override: ModelsDev.Model["cost"] const OPENAI_INCLUDE_ENCRYPTED_REASONING = ["reasoning.encrypted_content"] -function reasoningVariants(provider: ModelsDev.Provider, model: ModelsDev.Model): ModelV2Info["variants"] { +function reasoningVariants(provider: ModelsDev.Provider, model: ModelsDev.Model): NonNullable { const npm = model.provider?.npm ?? provider.npm const options = model.reasoning_options ?? [] const effort = options.find((option) => option.type === "effort") @@ -82,7 +82,7 @@ function reasoningVariants(provider: ModelsDev.Provider, model: ModelsDev.Model) const id = raw === null ? "none" : typeof raw === "string" ? raw : undefined if (id === undefined) return [] const settings = settingsForEffort(npm, id) - return settings ? [{ id, settings, headers: {}, body: {} }] : [] + return settings ? [{ id: ModelV2.VariantID.make(id), settings }] : [] }) } @@ -117,15 +117,18 @@ function settingsForEffort(npm: string | undefined, effort: string): ProviderV2. function budgetVariants( npm: string | undefined, option: Extract[number], { type: "budget_tokens" }>, -): ModelV2Info["variants"] { +): NonNullable { const max = option.max - const high = option.max === undefined ? Math.max(option.min ?? 0, 16_000) : Math.min(Math.max(option.min ?? 0, 16_000), option.max) + const high = + option.max === undefined + ? Math.max(option.min ?? 0, 16_000) + : Math.min(Math.max(option.min ?? 0, 16_000), option.max) return [ { id: "high", budget: high }, ...(max === undefined || max === high ? [] : [{ id: "max", budget: max }]), ].flatMap((item) => { const settings = settingsForBudget(npm, item.budget) - return settings ? [{ id: item.id, settings, headers: {}, body: {} }] : [] + return settings ? [{ id: ModelV2.VariantID.make(item.id), settings }] : [] }) } @@ -143,12 +146,13 @@ function modeName(model: ModelsDev.Model, mode: string) { return `${model.name} ${mode.charAt(0).toUpperCase()}${mode.slice(1)}` } -function mergeVariants(model: ModelV2Info, next: ModelV2Info["variants"]) { - const existing = new Map(model.variants.map((variant) => [variant.id, variant])) +function mergeVariants(model: ModelV2Info, next: NonNullable) { + const variants = model.variants ?? [] + const existing = new Map(variants.map((variant) => [variant.id, variant])) const nextIDs = new Set(next.map((variant) => variant.id)) model.variants = [ ...next.map((variant) => existing.get(variant.id) ?? variant), - ...model.variants.filter((variant) => !nextIDs.has(variant.id)), + ...variants.filter((variant) => !nextIDs.has(variant.id)), ] } @@ -159,24 +163,14 @@ function applyModel( readonly name?: string readonly cost?: ModelV2Info["cost"] readonly request?: NonNullable["modes"]>[string]["provider"] - readonly variants?: ModelV2Info["variants"] + readonly variants?: NonNullable } = {}, ) { draft.name = input.name ?? model.name + draft.modelID = model.id draft.family = model.family ? ModelV2.Family.make(model.family) : undefined - draft.api = model.provider?.npm - ? { - id: ModelV2.ID.make(model.id), - type: "aisdk", - package: model.provider.npm, - url: model.provider.api, - } - : { - id: ModelV2.ID.make(model.id), - type: "native", - url: model.provider?.api, - settings: {}, - } + draft.package = model.provider?.npm ? ProviderV2.aisdk(model.provider.npm) : undefined + draft.settings = model.provider?.api ? { ...draft.settings, baseURL: model.provider.api } : draft.settings draft.capabilities = { tools: model.tool_call, input: [...(model.modalities?.input ?? [])], @@ -184,7 +178,11 @@ function applyModel( } mergeVariants(draft, input.variants ?? []) draft.time.released = released(model.release_date) - draft.cost = input.cost ?? cost(model.cost) + draft.cost = (input.cost ?? cost(model.cost)).map((item) => ({ + ...item, + tier: item.tier && { ...item.tier }, + cache: { ...item.cache }, + })) draft.status = model.status ?? "active" draft.enabled = true draft.limit = { @@ -192,8 +190,8 @@ function applyModel( input: model.limit.input, output: model.limit.output, } - Object.assign(draft.request.headers, input.request?.headers ?? {}) - Object.assign(draft.request.body, input.request?.body ?? {}) + draft.headers = { ...draft.headers, ...input.request?.headers } + draft.body = { ...draft.body, ...input.request?.body } } export const ModelsDevPlugin = define({ @@ -222,25 +220,18 @@ export const ModelsDevPlugin = define({ const providerID = ProviderV2.ID.make(item.id) catalog.provider.update(providerID, (provider) => { provider.name = item.name - provider.api = item.npm - ? { - type: "aisdk", - package: item.npm, - url: item.api, - } - : { - type: "native", - url: item.api, - settings: {}, - } + provider.package = item.npm ? ProviderV2.aisdk(item.npm) : "" + provider.settings = item.api ? { ...provider.settings, baseURL: item.api } : provider.settings }) for (const model of Object.values(item.models)) { const baseCost = cost(model.cost) const variants = reasoningVariants(item, model) - catalog.model.update(providerID, model.id, (draft) => applyModel(draft, model, { cost: baseCost, variants })) + catalog.model.update(providerID, ModelV2.ID.make(model.id), (draft) => + applyModel(draft, model, { cost: baseCost, variants }), + ) for (const [mode, options] of Object.entries(model.experimental?.modes ?? {})) { - catalog.model.update(providerID, `${model.id}-${mode}`, (draft) => + catalog.model.update(providerID, ModelV2.ID.make(`${model.id}-${mode}`), (draft) => applyModel(draft, model, { name: modeName(model, mode), cost: mergeCost(baseCost, options.cost), diff --git a/packages/core/src/plugin/provider/amazon-bedrock.ts b/packages/core/src/plugin/provider/amazon-bedrock.ts index 0bc3ced462..5bbe953f80 100644 --- a/packages/core/src/plugin/provider/amazon-bedrock.ts +++ b/packages/core/src/plugin/provider/amazon-bedrock.ts @@ -64,15 +64,14 @@ export const AmazonBedrockPlugin = define({ effect: Effect.fn(function* (ctx) { yield* ctx.catalog.transform((evt) => { for (const item of evt.provider.list()) { - if (item.provider.api.type !== "aisdk") continue - if (item.provider.api.package !== "@ai-sdk/amazon-bedrock") continue + if (!ProviderV2.isAISDK(item.provider.package)) continue + if (ProviderV2.packageName(item.provider.package) !== "@ai-sdk/amazon-bedrock") continue evt.provider.update(item.provider.id, (provider) => { - if (provider.api.type !== "aisdk") return - if (typeof provider.request.body.endpoint !== "string") return + if (typeof provider.settings?.endpoint !== "string") return // The AI SDK expects a base URL, but users configure Bedrock private/VPC // endpoints as `endpoint`; move it into the catalog endpoint URL once. - provider.api.url = provider.request.body.endpoint - delete provider.request.body.endpoint + provider.settings.baseURL = provider.settings.endpoint + delete provider.settings.endpoint }) } }) @@ -112,12 +111,15 @@ export const AmazonBedrockPlugin = define({ yield* ctx.aisdk.language( Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.amazonBedrock) return - if (evt.model.api.type === "aisdk" && evt.model.api.package === "@ai-sdk/amazon-bedrock/mantle") { - evt.language = selectMantleModel(evt.sdk, evt.model.api.id) + if ( + ProviderV2.isAISDK(evt.model.package) && + ProviderV2.packageName(evt.model.package) === "@ai-sdk/amazon-bedrock/mantle" + ) { + evt.language = selectMantleModel(evt.sdk, evt.model.modelID ?? evt.model.id) return } const region = typeof evt.options.region === "string" ? evt.options.region : process.env.AWS_REGION - evt.language = evt.sdk.languageModel(resolveModelID(evt.model.api.id, region)) + evt.language = evt.sdk.languageModel(resolveModelID(evt.model.modelID ?? evt.model.id, region)) }), ) }), diff --git a/packages/core/src/plugin/provider/anthropic.ts b/packages/core/src/plugin/provider/anthropic.ts index 3a8a7c8659..4c5938e51f 100644 --- a/packages/core/src/plugin/provider/anthropic.ts +++ b/packages/core/src/plugin/provider/anthropic.ts @@ -1,16 +1,19 @@ import { Effect } from "effect" import { define } from "@opencode-ai/plugin/v2/effect/plugin" +import { ProviderV2 } from "../../provider" export const AnthropicPlugin = define({ id: "opencode.provider.anthropic", effect: Effect.fn(function* (ctx) { yield* ctx.catalog.transform((evt) => { for (const item of evt.provider.list()) { - if (item.provider.api.type !== "aisdk") continue - if (item.provider.api.package !== "@ai-sdk/anthropic") continue + if (!ProviderV2.isAISDK(item.provider.package)) continue + if (ProviderV2.packageName(item.provider.package) !== "@ai-sdk/anthropic") continue evt.provider.update(item.provider.id, (provider) => { - provider.request.headers["anthropic-beta"] = - "interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14" + provider.headers = { + ...provider.headers, + "anthropic-beta": "interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14", + } }) } }) diff --git a/packages/core/src/plugin/provider/azure.ts b/packages/core/src/plugin/provider/azure.ts index 3269c7d2d9..65a40c023b 100644 --- a/packages/core/src/plugin/provider/azure.ts +++ b/packages/core/src/plugin/provider/azure.ts @@ -15,14 +15,14 @@ export const AzurePlugin = define({ effect: Effect.fn(function* (ctx) { yield* ctx.catalog.transform((evt) => { for (const item of evt.provider.list()) { - if (item.provider.api.type !== "aisdk") continue - if (item.provider.api.package !== "@ai-sdk/azure") continue - const configured = item.provider.request.body.resourceName + if (!ProviderV2.isAISDK(item.provider.package)) continue + if (ProviderV2.packageName(item.provider.package) !== "@ai-sdk/azure") continue + const configured = item.provider.settings?.resourceName const resourceName = typeof configured === "string" && configured.trim() !== "" ? configured : process.env.AZURE_RESOURCE_NAME if (!resourceName) continue evt.provider.update(item.provider.id, (provider) => { - provider.request.body.resourceName = resourceName + provider.settings = { ...provider.settings, resourceName } }) } }) @@ -33,7 +33,7 @@ export const AzurePlugin = define({ if ( !evt.options.resourceName && !evt.options.baseURL && - (evt.model.api.type !== "aisdk" || !evt.model.api.url) + (!ProviderV2.isAISDK(evt.model.package) || typeof evt.model.settings?.baseURL !== "string") ) { throw new Error( "AZURE_RESOURCE_NAME is missing, set it using env var or reconnecting the azure provider and setting it", @@ -47,7 +47,11 @@ export const AzurePlugin = define({ yield* ctx.aisdk.language( Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.azure) return - evt.language = selectLanguage(evt.sdk, evt.model.api.id, Boolean(evt.options.useCompletionUrls)) + evt.language = selectLanguage( + evt.sdk, + evt.model.modelID ?? evt.model.id, + Boolean(evt.options.useCompletionUrls), + ) }), ) }), @@ -60,18 +64,25 @@ export const AzureCognitiveServicesPlugin = define({ const resourceName = process.env.AZURE_COGNITIVE_SERVICES_RESOURCE_NAME if (!resourceName) return for (const item of evt.provider.list()) { - if (item.provider.api.type !== "aisdk") continue - if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue + if (!ProviderV2.isAISDK(item.provider.package)) continue + if (ProviderV2.packageName(item.provider.package) !== "@ai-sdk/openai-compatible") continue if (!item.provider.id.includes("azure-cognitive-services")) continue evt.provider.update(item.provider.id, (provider) => { - provider.request.body.baseURL = `https://${resourceName}.cognitiveservices.azure.com/openai` + provider.settings = { + ...provider.settings, + baseURL: `https://${resourceName}.cognitiveservices.azure.com/openai`, + } }) } }) yield* ctx.aisdk.language( Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.make("azure-cognitive-services")) return - evt.language = selectLanguage(evt.sdk, evt.model.api.id, Boolean(evt.options.useCompletionUrls)) + evt.language = selectLanguage( + evt.sdk, + evt.model.modelID ?? evt.model.id, + Boolean(evt.options.useCompletionUrls), + ) }), ) }), diff --git a/packages/core/src/plugin/provider/cerebras.ts b/packages/core/src/plugin/provider/cerebras.ts index 20e724d707..42fea46550 100644 --- a/packages/core/src/plugin/provider/cerebras.ts +++ b/packages/core/src/plugin/provider/cerebras.ts @@ -1,15 +1,16 @@ import { Effect } from "effect" import { define } from "@opencode-ai/plugin/v2/effect/plugin" +import { ProviderV2 } from "../../provider" export const CerebrasPlugin = define({ id: "opencode.provider.cerebras", effect: Effect.fn(function* (ctx) { yield* ctx.catalog.transform((evt) => { for (const item of evt.provider.list()) { - if (item.provider.api.type !== "aisdk") continue - if (item.provider.api.package !== "@ai-sdk/cerebras") continue + if (!ProviderV2.isAISDK(item.provider.package)) continue + if (ProviderV2.packageName(item.provider.package) !== "@ai-sdk/cerebras") continue evt.provider.update(item.provider.id, (provider) => { - provider.request.headers["X-Cerebras-3rd-Party-Integration"] = "opencode" + provider.headers = { ...provider.headers, "X-Cerebras-3rd-Party-Integration": "opencode" } }) } }) diff --git a/packages/core/src/plugin/provider/cloudflare-workers-ai.ts b/packages/core/src/plugin/provider/cloudflare-workers-ai.ts index b2db49e4db..9b4558b5d1 100644 --- a/packages/core/src/plugin/provider/cloudflare-workers-ai.ts +++ b/packages/core/src/plugin/provider/cloudflare-workers-ai.ts @@ -13,10 +13,10 @@ export const CloudflareWorkersAIPlugin = define({ const item = evt.provider.get(providerID) if (!item) return evt.provider.update(item.provider.id, (provider) => { - if (provider.api.type !== "aisdk") return - if (provider.api.url) return - const accountId = resolveAccountId(provider.request.body) - if (accountId) provider.api.url = workersEndpoint(accountId) + if (!ProviderV2.isAISDK(provider.package)) return + if (typeof provider.settings?.baseURL === "string") return + const accountId = resolveAccountId(provider.settings ?? {}) + if (accountId) provider.settings = { ...provider.settings, baseURL: workersEndpoint(accountId) } }) }) yield* ctx.aisdk.sdk( @@ -25,7 +25,7 @@ export const CloudflareWorkersAIPlugin = define({ if (evt.package !== "@ai-sdk/openai-compatible") return const accountId = resolveAccountId(evt.options) - if (!hasWorkersEndpoint(evt.model.api) && !accountId) return + if (!hasWorkersEndpoint(evt.model) && !accountId) return const mod = yield* Effect.promise(() => import("@ai-sdk/openai-compatible")) evt.sdk = mod.createOpenAICompatible( sdkOptions({ @@ -38,7 +38,7 @@ export const CloudflareWorkersAIPlugin = define({ yield* ctx.aisdk.language( Effect.fn(function* (evt) { if (evt.model.providerID !== providerID) return - evt.language = evt.sdk.languageModel(evt.model.api.id) + evt.language = evt.sdk.languageModel(evt.model.modelID ?? evt.model.id) }), ) }), @@ -52,8 +52,11 @@ function workersEndpoint(accountId: string) { return `https://api.cloudflare.com/client/v4/accounts/${accountId}/ai/v1` } -function hasWorkersEndpoint(api: ProviderV2.Api) { - return api.type === "aisdk" && Boolean(api.url) +function hasWorkersEndpoint(model: { + readonly package?: string + readonly settings?: Readonly> +}) { + return ProviderV2.isAISDK(model.package) && typeof model.settings?.baseURL === "string" } function sdkOptions(options: Record) { diff --git a/packages/core/src/plugin/provider/github-copilot.ts b/packages/core/src/plugin/provider/github-copilot.ts index 2a4881edc0..d9a50ab097 100644 --- a/packages/core/src/plugin/provider/github-copilot.ts +++ b/packages/core/src/plugin/provider/github-copilot.ts @@ -34,12 +34,11 @@ export const GithubCopilotPlugin = define({ Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.githubCopilot) return if (evt.sdk.responses === undefined && evt.sdk.chat === undefined) { - evt.language = evt.sdk.languageModel(evt.model.api.id) + evt.language = evt.sdk.languageModel(evt.model.modelID ?? evt.model.id) return } - evt.language = shouldUseResponses(evt.model.api.id) - ? evt.sdk.responses(evt.model.api.id) - : evt.sdk.chat(evt.model.api.id) + const id = evt.model.modelID ?? evt.model.id + evt.language = shouldUseResponses(id) ? evt.sdk.responses(id) : evt.sdk.chat(id) }), ) }), diff --git a/packages/core/src/plugin/provider/gitlab.ts b/packages/core/src/plugin/provider/gitlab.ts index 5cd4b94858..1a8a4a1231 100644 --- a/packages/core/src/plugin/provider/gitlab.ts +++ b/packages/core/src/plugin/provider/gitlab.ts @@ -36,26 +36,24 @@ export const GitLabPlugin = define({ if (evt.model.providerID !== ProviderV2.ID.gitlab) return const featureFlags = typeof evt.options.featureFlags === "object" && evt.options.featureFlags ? evt.options.featureFlags : {} - if (evt.model.api.id.startsWith("duo-workflow-")) { + const id = evt.model.modelID ?? evt.model.id + if (id.startsWith("duo-workflow-")) { const gitlab = yield* Effect.promise(() => import("gitlab-ai-provider")).pipe(Effect.orDie) const workflowRef = - typeof evt.model.request.body.workflowRef === "string" ? evt.model.request.body.workflowRef : undefined + typeof evt.model.settings?.workflowRef === "string" ? evt.model.settings.workflowRef : undefined const workflowDefinition = - typeof evt.model.request.body.workflowDefinition === "string" - ? evt.model.request.body.workflowDefinition + typeof evt.model.settings?.workflowDefinition === "string" + ? evt.model.settings.workflowDefinition : undefined - const language = evt.sdk.workflowChat( - gitlab.isWorkflowModel(evt.model.api.id) ? evt.model.api.id : "duo-workflow", - { - featureFlags, - workflowDefinition, - }, - ) + const language = evt.sdk.workflowChat(gitlab.isWorkflowModel(id) ? id : "duo-workflow", { + featureFlags, + workflowDefinition, + }) if (workflowRef) language.selectedModelRef = workflowRef evt.language = language return } - evt.language = evt.sdk.agenticChat(evt.model.api.id, { + evt.language = evt.sdk.agenticChat(id, { aiGatewayHeaders: evt.options.aiGatewayHeaders, featureFlags, }) diff --git a/packages/core/src/plugin/provider/google-vertex.ts b/packages/core/src/plugin/provider/google-vertex.ts index 630e353ef0..2c307a8725 100644 --- a/packages/core/src/plugin/provider/google-vertex.ts +++ b/packages/core/src/plugin/provider/google-vertex.ts @@ -59,25 +59,28 @@ export const GoogleVertexPlugin = define({ effect: Effect.fn(function* (ctx) { yield* ctx.catalog.transform((evt) => { for (const item of evt.provider.list()) { - if (item.provider.api.type !== "aisdk") continue + if (!ProviderV2.isAISDK(item.provider.package)) continue if ( - item.provider.api.package !== "@ai-sdk/google-vertex" && + ProviderV2.packageName(item.provider.package) !== "@ai-sdk/google-vertex" && !( item.provider.id === ProviderV2.ID.googleVertex && - item.provider.api.package.includes("@ai-sdk/openai-compatible") + ProviderV2.packageName(item.provider.package)?.includes("@ai-sdk/openai-compatible") ) ) continue - const project = resolveProject(item.provider.request.body) - const location = String(resolveLocation(item.provider.request.body)) + const project = resolveProject(item.provider.settings ?? {}) + const location = String(resolveLocation(item.provider.settings ?? {})) evt.provider.update(item.provider.id, (provider) => { - if (project) provider.request.body.project = project - provider.request.body.location = location - if (provider.api.type === "aisdk" && provider.api.url) { - provider.api.url = replaceVertexVars(provider.api.url, project, location) - } - if (provider.api.type === "aisdk" && provider.api.package.includes("@ai-sdk/openai-compatible")) { - provider.request.body.fetch = authFetch(provider.request.body.fetch) + provider.settings = { + ...provider.settings, + ...(project ? { project } : {}), + location, + ...(typeof provider.settings?.baseURL === "string" + ? { baseURL: replaceVertexVars(provider.settings.baseURL, project, location) } + : {}), + ...(ProviderV2.packageName(provider.package)?.includes("@ai-sdk/openai-compatible") + ? { fetch: authFetch(provider.settings?.fetch) } + : {}), } }) } @@ -104,7 +107,7 @@ export const GoogleVertexPlugin = define({ yield* ctx.aisdk.language( Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.googleVertex) return - evt.language = evt.sdk.languageModel(String(evt.model.api.id).trim()) + evt.language = evt.sdk.languageModel(String(evt.model.modelID ?? evt.model.id).trim()) }), ) }), @@ -115,21 +118,20 @@ export const GoogleVertexAnthropicPlugin = define({ effect: Effect.fn(function* (ctx) { yield* ctx.catalog.transform((evt) => { for (const item of evt.provider.list()) { - if (item.provider.api.type !== "aisdk") continue - if (item.provider.api.package !== "@ai-sdk/google-vertex/anthropic") continue + if (!ProviderV2.isAISDK(item.provider.package)) continue + if (ProviderV2.packageName(item.provider.package) !== "@ai-sdk/google-vertex/anthropic") continue const project = - item.provider.request.body.project ?? + item.provider.settings?.project ?? process.env.GOOGLE_CLOUD_PROJECT ?? process.env.GCP_PROJECT ?? process.env.GCLOUD_PROJECT const location = - item.provider.request.body.location ?? + item.provider.settings?.location ?? process.env.GOOGLE_CLOUD_LOCATION ?? process.env.VERTEX_LOCATION ?? "global" evt.provider.update(item.provider.id, (provider) => { - if (project) provider.request.body.project = project - provider.request.body.location = location + provider.settings = { ...provider.settings, ...(project ? { project } : {}), location } }) } }) @@ -162,7 +164,7 @@ export const GoogleVertexAnthropicPlugin = define({ yield* ctx.aisdk.language( Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.make("google-vertex-anthropic")) return - evt.language = evt.sdk.languageModel(String(evt.model.api.id).trim()) + evt.language = evt.sdk.languageModel(String(evt.model.modelID ?? evt.model.id).trim()) }), ) }), diff --git a/packages/core/src/plugin/provider/kilo.ts b/packages/core/src/plugin/provider/kilo.ts index d901474ef2..b1c53fca90 100644 --- a/packages/core/src/plugin/provider/kilo.ts +++ b/packages/core/src/plugin/provider/kilo.ts @@ -1,17 +1,17 @@ import { Effect } from "effect" import { define } from "@opencode-ai/plugin/v2/effect/plugin" +import { ProviderV2 } from "../../provider" export const KiloPlugin = define({ id: "opencode.provider.kilo", effect: Effect.fn(function* (ctx) { yield* ctx.catalog.transform((evt) => { for (const item of evt.provider.list()) { - if (item.provider.api.type !== "aisdk") continue - if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue - if (item.provider.api.url !== "https://api.kilo.ai/api/gateway") continue + if (!ProviderV2.isAISDK(item.provider.package)) continue + if (ProviderV2.packageName(item.provider.package) !== "@ai-sdk/openai-compatible") continue + if (item.provider.settings?.baseURL !== "https://api.kilo.ai/api/gateway") continue evt.provider.update(item.provider.id, (provider) => { - provider.request.headers["HTTP-Referer"] = "https://opencode.ai/" - provider.request.headers["X-Title"] = "opencode" + provider.headers = { ...provider.headers, "HTTP-Referer": "https://opencode.ai/", "X-Title": "opencode" } }) } }) diff --git a/packages/core/src/plugin/provider/llmgateway.ts b/packages/core/src/plugin/provider/llmgateway.ts index 68c319b673..88a597164b 100644 --- a/packages/core/src/plugin/provider/llmgateway.ts +++ b/packages/core/src/plugin/provider/llmgateway.ts @@ -1,6 +1,7 @@ import { Effect } from "effect" import { define } from "@opencode-ai/plugin/v2/effect/plugin" import { Integration } from "../../integration" +import { ProviderV2 } from "../../provider" export const LLMGatewayPlugin = define({ id: "opencode.provider.llmgateway", @@ -10,14 +11,17 @@ export const LLMGatewayPlugin = define({ yield* ctx.catalog.transform((evt) => { for (const item of evt.provider.list()) { if (item.provider.disabled) continue - if (item.provider.api.type !== "aisdk") continue - if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue - if (item.provider.api.url !== "https://api.llmgateway.io/v1") continue + if (!ProviderV2.isAISDK(item.provider.package)) continue + if (ProviderV2.packageName(item.provider.package) !== "@ai-sdk/openai-compatible") continue + if (item.provider.settings?.baseURL !== "https://api.llmgateway.io/v1") continue if (!configured.has(Integration.ID.make(item.provider.id))) continue evt.provider.update(item.provider.id, (provider) => { - provider.request.headers["HTTP-Referer"] = "https://opencode.ai/" - provider.request.headers["X-Title"] = "opencode" - provider.request.headers["X-Source"] = "opencode" + provider.headers = { + ...provider.headers, + "HTTP-Referer": "https://opencode.ai/", + "X-Title": "opencode", + "X-Source": "opencode", + } }) } }) diff --git a/packages/core/src/plugin/provider/nvidia.ts b/packages/core/src/plugin/provider/nvidia.ts index 4597f3070a..7363e88f52 100644 --- a/packages/core/src/plugin/provider/nvidia.ts +++ b/packages/core/src/plugin/provider/nvidia.ts @@ -1,18 +1,22 @@ import { Effect } from "effect" import { define } from "@opencode-ai/plugin/v2/effect/plugin" +import { ProviderV2 } from "../../provider" export const NvidiaPlugin = define({ id: "opencode.provider.nvidia", effect: Effect.fn(function* (ctx) { yield* ctx.catalog.transform((evt) => { for (const item of evt.provider.list()) { - if (item.provider.api.type !== "aisdk") continue - if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue - if (item.provider.api.url !== "https://integrate.api.nvidia.com/v1") continue + if (!ProviderV2.isAISDK(item.provider.package)) continue + if (ProviderV2.packageName(item.provider.package) !== "@ai-sdk/openai-compatible") continue + if (item.provider.settings?.baseURL !== "https://integrate.api.nvidia.com/v1") continue evt.provider.update(item.provider.id, (provider) => { - provider.request.headers["HTTP-Referer"] = "https://opencode.ai/" - provider.request.headers["X-Title"] = "opencode" - provider.request.headers["X-BILLING-INVOKE-ORIGIN"] ??= "OpenCode" + provider.headers = { + ...provider.headers, + "HTTP-Referer": "https://opencode.ai/", + "X-Title": "opencode", + "X-BILLING-INVOKE-ORIGIN": provider.headers?.["X-BILLING-INVOKE-ORIGIN"] ?? "OpenCode", + } }) } }) diff --git a/packages/core/src/plugin/provider/openai.ts b/packages/core/src/plugin/provider/openai.ts index 1b27511e49..72fe8ed646 100644 --- a/packages/core/src/plugin/provider/openai.ts +++ b/packages/core/src/plugin/provider/openai.ts @@ -178,8 +178,8 @@ export const OpenAIPlugin = define({ }) yield* ctx.catalog.transform((evt) => { for (const item of evt.provider.list()) { - if (item.provider.api.type !== "aisdk") continue - if (item.provider.api.package !== "@ai-sdk/openai") continue + if (!ProviderV2.isAISDK(item.provider.package)) continue + if (ProviderV2.packageName(item.provider.package) !== "@ai-sdk/openai") continue if (!item.models.has(ModelV2.ID.make("gpt-5-chat-latest"))) continue evt.model.update(item.provider.id, ModelV2.ID.make("gpt-5-chat-latest"), (model) => { // OpenAIPlugin sends OpenAI models through Responses; this alias is a @@ -194,7 +194,7 @@ export const OpenAIPlugin = define({ // ChatGPT-plan tokens only authorize codex-eligible models, and the // subscription covers usage, so hide the rest and zero the cost. evt.model.update(item.provider.id, model.id, (draft) => { - if (!OpenAICodex.eligible(draft.api.id)) { + if (!OpenAICodex.eligible(draft.modelID ?? draft.id)) { draft.enabled = false return } @@ -220,7 +220,7 @@ export const OpenAIPlugin = define({ yield* ctx.aisdk.language( Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.openai) return - evt.language = evt.sdk.responses(evt.model.api.id) + evt.language = evt.sdk.responses(evt.model.modelID ?? evt.model.id) }), ) }), diff --git a/packages/core/src/plugin/provider/opencode.ts b/packages/core/src/plugin/provider/opencode.ts index 1316c3e4d0..58690df0b2 100644 --- a/packages/core/src/plugin/provider/opencode.ts +++ b/packages/core/src/plugin/provider/opencode.ts @@ -112,45 +112,43 @@ export const OpencodePlugin = define { provider.integrationID = Integration.ID.make("opencode") if (item.name !== undefined) provider.name = item.name - provider.api = item.npm - ? { type: "aisdk", package: item.npm, url: item.api } - : { type: "native", url: item.api, settings: {} } - Object.assign(provider.request.headers, item.options?.headers) - Object.assign(provider.request.body, withoutCredentials(item.options)) + provider.package = item.npm ? ProviderV2.aisdk(item.npm) : "" + provider.settings = { + ...provider.settings, + ...withoutCredentials(item.options), + ...(item.api ? { baseURL: item.api } : {}), + } + provider.headers = { ...provider.headers, ...item.options?.headers } }) for (const [modelID, config] of Object.entries(item.models ?? {})) { catalog.model.update(providerID, modelID, (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.id !== undefined) model.modelID = config.id if (config.provider !== undefined) { - model.api = config.provider.npm - ? { - id: model.api.id, - type: "aisdk", - package: config.provider.npm, - url: config.provider.api, - } - : { id: model.api.id, type: "native", url: config.provider.api, settings: {} } + model.package = config.provider.npm ? ProviderV2.aisdk(config.provider.npm) : undefined + if (config.provider.api) model.settings = { ...model.settings, baseURL: config.provider.api } } if (config.tool_call !== undefined) model.capabilities.tools = config.tool_call if (config.modalities?.input !== undefined) model.capabilities.input = [...config.modalities.input] if (config.modalities?.output !== undefined) model.capabilities.output = [...config.modalities.output] - const packageName = config.provider?.npm ?? item.npm - const lowerer = ConfigProviderOptionsV1.get(packageName) - Object.assign(model.request.headers, config.headers) - Object.assign(model.request.body, lowerer.request(withoutCredentials(config.options))) + model.headers = { ...model.headers, ...config.headers } + model.settings = { ...model.settings, ...ConfigProviderOptionsV1.model(withoutCredentials(config.options)) } if (config.variants !== undefined) { + model.variants ??= [] for (const [id, options] of Object.entries(config.variants)) { const variantID = ModelV2.VariantID.make(id) let existing = model.variants.find((item) => item.id === variantID) if (!existing) { - existing = { id: variantID, settings: {}, headers: {}, body: {} } + existing = { id: variantID } model.variants.push(existing) } - Object.assign(existing.headers, options.headers) - Object.assign(existing.body, lowerer.request(withoutCredentials(options))) + existing.headers = { ...existing.headers, ...options.headers } + existing.settings = { + ...existing.settings, + ...ConfigProviderOptionsV1.model(withoutCredentials(options)), + } } } if (config.release_date !== undefined) { @@ -169,9 +167,9 @@ export const OpencodePlugin = define { - if (!hasKey) provider.request.body.apiKey = "public" + if (!hasKey) provider.settings = { ...provider.settings, apiKey: "public" } }) if (hasKey) return for (const model of item.models.values()) { diff --git a/packages/core/src/plugin/provider/openrouter.ts b/packages/core/src/plugin/provider/openrouter.ts index a255844fdb..c27cc30207 100644 --- a/packages/core/src/plugin/provider/openrouter.ts +++ b/packages/core/src/plugin/provider/openrouter.ts @@ -1,5 +1,6 @@ import { Effect } from "effect" import { ModelV2 } from "../../model" +import { ProviderV2 } from "../../provider" import { define } from "@opencode-ai/plugin/v2/effect/plugin" export const OpenRouterPlugin = define({ @@ -7,11 +8,10 @@ export const OpenRouterPlugin = define({ effect: Effect.fn(function* (ctx) { yield* ctx.catalog.transform((evt) => { for (const item of evt.provider.list()) { - if (item.provider.api.type !== "aisdk") continue - if (item.provider.api.package !== "@openrouter/ai-sdk-provider") continue + if (!ProviderV2.isAISDK(item.provider.package)) continue + if (ProviderV2.packageName(item.provider.package) !== "@openrouter/ai-sdk-provider") continue evt.provider.update(item.provider.id, (provider) => { - provider.request.headers["HTTP-Referer"] = "https://opencode.ai/" - provider.request.headers["X-Title"] = "opencode" + provider.headers = { ...provider.headers, "HTTP-Referer": "https://opencode.ai/", "X-Title": "opencode" } }) for (const modelID of [ModelV2.ID.make("gpt-5-chat-latest"), ModelV2.ID.make("openai/gpt-5-chat")]) { if (!item.models.has(modelID)) continue diff --git a/packages/core/src/plugin/provider/sap-ai-core.ts b/packages/core/src/plugin/provider/sap-ai-core.ts index ede4e31d40..4a8c26e82a 100644 --- a/packages/core/src/plugin/provider/sap-ai-core.ts +++ b/packages/core/src/plugin/provider/sap-ai-core.ts @@ -40,7 +40,7 @@ export const SapAICorePlugin = define({ yield* ctx.aisdk.language( Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.make("sap-ai-core")) return - evt.language = evt.sdk(evt.model.api.id) + evt.language = evt.sdk(evt.model.modelID ?? evt.model.id) }), ) }), diff --git a/packages/core/src/plugin/provider/vercel.ts b/packages/core/src/plugin/provider/vercel.ts index 0df949b1be..70a27c8f67 100644 --- a/packages/core/src/plugin/provider/vercel.ts +++ b/packages/core/src/plugin/provider/vercel.ts @@ -1,16 +1,16 @@ import { Effect } from "effect" import { define } from "@opencode-ai/plugin/v2/effect/plugin" +import { ProviderV2 } from "../../provider" export const VercelPlugin = define({ id: "opencode.provider.vercel", effect: Effect.fn(function* (ctx) { yield* ctx.catalog.transform((evt) => { for (const item of evt.provider.list()) { - if (item.provider.api.type !== "aisdk") continue - if (item.provider.api.package !== "@ai-sdk/vercel") continue + if (!ProviderV2.isAISDK(item.provider.package)) continue + if (ProviderV2.packageName(item.provider.package) !== "@ai-sdk/vercel") continue evt.provider.update(item.provider.id, (provider) => { - provider.request.headers["http-referer"] = "https://opencode.ai/" - provider.request.headers["x-title"] = "opencode" + provider.headers = { ...provider.headers, "http-referer": "https://opencode.ai/", "x-title": "opencode" } }) } }) diff --git a/packages/core/src/plugin/provider/xai.ts b/packages/core/src/plugin/provider/xai.ts index 76ec1df1a7..fb1d33e86e 100644 --- a/packages/core/src/plugin/provider/xai.ts +++ b/packages/core/src/plugin/provider/xai.ts @@ -15,7 +15,7 @@ export const XAIPlugin = define({ yield* ctx.aisdk.language( Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.make("xai")) return - evt.language = evt.sdk.responses(evt.model.api.id) + evt.language = evt.sdk.responses(evt.model.modelID ?? evt.model.id) }), ) }), diff --git a/packages/core/src/plugin/provider/zenmux.ts b/packages/core/src/plugin/provider/zenmux.ts index 099a3affca..44619a1ac5 100644 --- a/packages/core/src/plugin/provider/zenmux.ts +++ b/packages/core/src/plugin/provider/zenmux.ts @@ -1,17 +1,21 @@ import { Effect } from "effect" import { define } from "@opencode-ai/plugin/v2/effect/plugin" +import { ProviderV2 } from "../../provider" export const ZenmuxPlugin = define({ id: "opencode.provider.zenmux", effect: Effect.fn(function* (ctx) { yield* ctx.catalog.transform((evt) => { for (const item of evt.provider.list()) { - if (item.provider.api.type !== "aisdk") continue - if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue - if (item.provider.api.url !== "https://zenmux.ai/api/v1") continue + if (!ProviderV2.isAISDK(item.provider.package)) continue + if (ProviderV2.packageName(item.provider.package) !== "@ai-sdk/openai-compatible") continue + if (item.provider.settings?.baseURL !== "https://zenmux.ai/api/v1") continue evt.provider.update(item.provider.id, (provider) => { - provider.request.headers["HTTP-Referer"] ??= "https://opencode.ai/" - provider.request.headers["X-Title"] ??= "opencode" + provider.headers = { + "HTTP-Referer": "https://opencode.ai/", + "X-Title": "opencode", + ...provider.headers, + } }) } }) diff --git a/packages/core/src/plugin/variant.ts b/packages/core/src/plugin/variant.ts index 499a2a32bf..55c9667f9e 100644 --- a/packages/core/src/plugin/variant.ts +++ b/packages/core/src/plugin/variant.ts @@ -1,8 +1,9 @@ export * as VariantPlugin from "./variant" -import type { ModelV2Info } from "@opencode-ai/sdk/v2/types" import { Effect } from "effect" import { define } from "@opencode-ai/plugin/v2/effect/plugin" +import { ModelV2 } from "../model" +import { ProviderV2 } from "../provider" export const Plugin = define({ id: "opencode.variant", @@ -11,14 +12,15 @@ export const Plugin = define({ for (const record of catalog.provider.list()) { for (const model of record.models.values()) { catalog.model.update(model.providerID, model.id, (draft) => { - const generated = generate(draft) + const generated = generate(draft, record.provider) if (generated.length === 0) return - const explicit = new Map(draft.variants.map((variant) => [variant.id, variant])) - const generatedIDs = new Set(generated.map((variant) => variant.id)) + const variants = draft.variants ?? [] + const explicit = new Map(variants.map((variant) => [variant.id, variant])) + const generatedIDs = new Set(generated.map((variant) => variant.id)) draft.variants = [ ...generated.map((variant) => explicit.get(variant.id) ?? variant), - ...draft.variants.filter((variant) => !generatedIDs.has(variant.id)), + ...variants.filter((variant) => !generatedIDs.has(variant.id)), ] }) } @@ -27,14 +29,16 @@ export const Plugin = define({ }), }) -export function generate(model: ModelV2Info): ModelV2Info["variants"] { - if (model.api.type !== "aisdk" || model.api.package !== "@ai-sdk/openai-compatible") return [] - const ids = `${model.id} ${model.api.id}`.toLowerCase() +export function generate( + model: { readonly id: string; readonly modelID?: string; readonly package?: string }, + provider?: { readonly package: string }, +): NonNullable { + const packageName = model.package ?? provider?.package + if (!ProviderV2.isAISDK(packageName) || ProviderV2.packageName(packageName) !== "@ai-sdk/openai-compatible") return [] + const ids = `${model.id} ${model.modelID ?? ""}`.toLowerCase() if (!["glm-5.2", "glm-5-2", "glm-5p2"].some((name) => ids.includes(name))) return [] return ["high", "max"].map((id) => ({ - id, + id: ModelV2.VariantID.make(id), settings: { reasoningEffort: id }, - headers: {}, - body: {}, })) } diff --git a/packages/core/src/provider.ts b/packages/core/src/provider.ts index eb443ca710..165ffa516b 100644 --- a/packages/core/src/provider.ts +++ b/packages/core/src/provider.ts @@ -1,20 +1,106 @@ export * as ProviderV2 from "./provider" -import { Types } from "effect" +import { Effect, Schema } from "effect" +import { pathToFileURL } from "url" import { Provider } from "@opencode-ai/schema/provider" +import type { ProviderPackageDefinition } from "@opencode-ai/llm" +import { Npm } from "./npm" +import type { DeepMutable } from "./schema" export const ID = Provider.ID export type ID = typeof ID.Type -export const AISDK = Provider.AISDK +export const AISDK_PREFIX = "aisdk:" +export const isAISDK = (value: string | undefined) => value?.startsWith(AISDK_PREFIX) ?? false +export const aisdk = (value: string) => (isAISDK(value) ? value : `${AISDK_PREFIX}${value}`) +export const packageName = (value: string | undefined) => { + if (value === undefined || !isAISDK(value)) return value + return value.slice(AISDK_PREFIX.length) +} -export const Native = Provider.Native +type Json = Schema.Schema.Type +const JsonRecord = Schema.Record(Schema.String, Schema.Json) +const decodeJsonRecord = Schema.decodeUnknownSync(JsonRecord) -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 class LoadError extends Schema.TaggedErrorClass()("ProviderV2.LoadError", { + package: Schema.String, + cause: Schema.Defect(), +}) {} +export type ProviderPackage = ProviderPackageDefinition + +const packages = new Map>() + +export const loadPackage = Effect.fn("ProviderV2.loadPackage")(function* (specifier: string, npm?: Npm.Interface) { + const resolved = yield* Effect.sync(() => { + if (specifier.startsWith("file://") || specifier.startsWith("@opencode-ai/llm/")) return specifier + try { + return import.meta.resolve(specifier) + } catch { + return undefined + } + }) + if (resolved) return yield* importPackage(specifier, resolved) + if (!npm) { + return yield* new LoadError({ + package: specifier, + cause: new Error(`Provider package ${specifier} is not installed`), + }) + } + const parts = specifier.split("/") + const root = specifier.startsWith("@") ? parts.slice(0, 2).join("/") : (parts[0] ?? specifier) + const installed = yield* npm.add(root).pipe(Effect.mapError((cause) => new LoadError({ package: specifier, cause }))) + const entrypoint = yield* Effect.try({ + try: () => import.meta.resolve(specifier, pathToFileURL(`${installed.directory}/`).href), + catch: (cause) => new LoadError({ package: specifier, cause }), + }) + return yield* importPackage(specifier, entrypoint) +}) + +export function mergeOverlay( + base: Readonly> | undefined, + overlay: Readonly> | undefined, +): Record | undefined { + if (base === undefined) return overlay && decodeJsonRecord({ ...overlay }) + if (overlay === undefined) return decodeJsonRecord({ ...base }) + return decodeJsonRecord( + Object.fromEntries( + new Set([...Object.keys(base), ...Object.keys(overlay)]).values().map((key): [string, unknown] => { + const left = base[key] + const right = overlay[key] + if (right === undefined) return [key, left] + if ( + typeof left === "object" && + left !== null && + !Array.isArray(left) && + typeof right === "object" && + right !== null && + !Array.isArray(right) + ) + return [ + key, + mergeOverlay(left as Readonly>, right as Readonly>) ?? {}, + ] + return [key, right] + }), + ), + ) +} + +export function mergeHeaders( + base: Readonly> | undefined, + overlay: Readonly> | undefined, +) { + if (base === undefined) return overlay && { ...overlay } + if (overlay === undefined) return { ...base } + return Object.fromEntries( + [...Object.entries(base), ...Object.entries(overlay)] + .reduce((result, entry) => { + result.set(entry[0].toLowerCase(), entry) + return result + }, new Map()) + .values(), + ) +} export const Request = Provider.Request export type Request = Provider.Request @@ -25,9 +111,24 @@ export type Settings = Provider.Settings export const Info = Provider.Info export type Info = Provider.Info -export type MutableRequest = Types.DeepMutable +export type MutableInfo = DeepMutable -export type MutableInfo = Omit, "api" | "request"> & { - api: MutableApi - request: MutableRequest -} +const importPackage = Effect.fn("ProviderV2.importPackage")(function* (specifier: string, entrypoint: string) { + const module = yield* Effect.tryPromise({ + try: () => { + const existing = packages.get(entrypoint) + if (existing) return existing + const loaded = import(entrypoint) + packages.set(entrypoint, loaded) + return loaded + }, + catch: (cause) => new LoadError({ package: specifier, cause }), + }) + if (typeof module !== "object" || module === null || typeof (module as { model?: unknown }).model !== "function") { + return yield* new LoadError({ + package: specifier, + cause: new Error(`Provider package ${specifier} does not export model(modelID, settings)`), + }) + } + return module as ProviderPackageDefinition +}) diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index 40d43a7bfd..4befc88331 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -213,7 +213,10 @@ const layer = Layer.effect( ] .filter((part): part is string => part !== undefined && part.length > 0) .map(SystemPart.make), - messages: [...toLLMMessages(context, model), ...(isLastStep ? [Message.assistant(MAX_STEPS_PROMPT)] : [])], + messages: [ + ...toLLMMessages(context, resolved.ref), + ...(isLastStep ? [Message.assistant(MAX_STEPS_PROMPT)] : []), + ], tools: toolMaterialization?.definitions ?? [], toolChoice: isLastStep ? "none" : undefined, }) diff --git a/packages/core/src/session/runner/model.ts b/packages/core/src/session/runner/model.ts index 2b81e609ea..6cc0d0845d 100644 --- a/packages/core/src/session/runner/model.ts +++ b/packages/core/src/session/runner/model.ts @@ -1,7 +1,7 @@ export * as SessionRunnerModel from "./model" import { makeLocationNode } from "../../effect/app-node" -import { type Model } from "@opencode-ai/llm" +import { Model } from "@opencode-ai/llm" // ast-grep-ignore: no-star-import import * as AnthropicMessages from "@opencode-ai/llm/protocols/anthropic-messages" // ast-grep-ignore: no-star-import @@ -11,10 +11,12 @@ import * as OpenAIResponses from "@opencode-ai/llm/protocols/openai-responses" import { Auth, type AnyRoute } from "@opencode-ai/llm/route" import { Context, Effect, Layer, Schema } from "effect" import { produce } from "immer" +import { AISDK } from "../../aisdk" import { Catalog } from "../../catalog" import { Credential } from "../../credential" import { Integration } from "../../integration" import { ModelV2 } from "../../model" +import { Npm } from "../../npm" import { OpenAICodex } from "../../plugin/provider/openai-codex" import { ProviderV2 } from "../../provider" import { SessionSchema } from "../schema" @@ -55,16 +57,16 @@ export class VariantUnavailableError extends Schema.TaggedErrorClass()( - "SessionRunnerModel.UnsupportedApiError", +export class UnsupportedPackageError extends Schema.TaggedErrorClass()( + "SessionRunnerModel.UnsupportedPackageError", { providerID: ProviderV2.ID, modelID: ModelV2.ID, - api: Schema.String, + package: Schema.String, }, ) { override get message() { - return `Unsupported API for ${this.providerID}/${this.modelID}: ${this.api}` + return `Unsupported package for ${this.providerID}/${this.modelID}: ${this.package}` } } @@ -72,7 +74,7 @@ export type Error = | ModelNotSelectedError | ModelUnavailableError | VariantUnavailableError - | UnsupportedApiError + | UnsupportedPackageError | Integration.AuthorizationError export interface Resolved { @@ -104,41 +106,50 @@ export const resolved = (model: Model, variant?: ModelV2.VariantID): Resolved => const apiKey = (model: ModelV2.Info, credential?: Credential.Value) => { if (credential?.type === "key") return Auth.value(credential.key) if (credential?.type === "oauth") return Auth.value(credential.access) - const value = model.request.body.apiKey ?? model.api.settings?.apiKey + const value = model.settings?.apiKey if (typeof value === "string") return Auth.value(value) } -const withDefaults = (model: ModelV2.Info, route: AnyRoute) => { - const body = model.request.body - const httpBody = Object.hasOwn(body, "apiKey") - ? Object.fromEntries(Object.entries(body).filter(([key]) => key !== "apiKey")) - : body - return route.with({ +const withDefaults = (model: ModelV2.Info, route: AnyRoute) => + route.with({ provider: model.providerID, - endpoint: model.api.url === undefined ? undefined : { baseURL: model.api.url }, - headers: model.request.headers, + endpoint: typeof model.settings?.baseURL === "string" ? { baseURL: model.settings.baseURL } : undefined, + headers: providerHeaders(model), providerOptions: providerOptions(model), - http: { body: httpBody }, + http: model.body === undefined ? undefined : { body: model.body }, limits: { context: model.limit.context, output: model.limit.output }, }) + +const providerHeaders = (model: ModelV2.Info) => { + const packageName = ProviderV2.packageName(model.package) + const generated = new Map() + if (packageName === "@ai-sdk/openai" && typeof model.settings?.organization === "string") + generated.set("OpenAI-Organization", model.settings.organization) + if (packageName === "@ai-sdk/openai" && typeof model.settings?.project === "string") + generated.set("OpenAI-Project", model.settings.project) + if (packageName === "@ai-sdk/anthropic" && typeof model.settings?.authToken === "string") + generated.set("Authorization", `Bearer ${model.settings.authToken}`) + return ProviderV2.mergeHeaders(generated.size === 0 ? undefined : Object.fromEntries(generated), model.headers) } const providerOptions = ( model: ModelV2.Info, ): { readonly [key: string]: { readonly [key: string]: unknown } } | undefined => { - if (Object.keys(model.request.settings).length === 0) return undefined - if (model.api.type !== "aisdk") return undefined - if (model.api.package === "@ai-sdk/openai") return { openai: model.request.settings } - if (model.api.package === "@ai-sdk/anthropic") return { anthropic: model.request.settings } - if (model.api.package === "@ai-sdk/openai-compatible") return { openai: model.request.settings } + if (!ProviderV2.isAISDK(model.package) || model.settings === undefined) return undefined + const { apiKey: _, baseURL: _baseURL, ...settings } = model.settings + if (Object.keys(settings).length === 0) return undefined + const packageName = ProviderV2.packageName(model.package) + if (packageName === "@ai-sdk/openai") return { openai: settings } + if (packageName === "@ai-sdk/anthropic") return { anthropic: settings } + if (packageName === "@ai-sdk/openai-compatible") return { openai: settings } } export const withVariant = ( model: ModelV2.Info, variantID: ModelV2.VariantID | undefined, ): Effect.Effect => { - const id = variantID === "default" || variantID === undefined ? model.request.variant : variantID - const variant = model.variants.find((item) => item.id === id) + const id = variantID === "default" ? undefined : variantID + const variant = model.variants?.find((item) => item.id === id) if (!variant && variantID !== undefined && variantID !== "default") return Effect.fail( new VariantUnavailableError({ @@ -150,81 +161,140 @@ export const withVariant = ( return Effect.succeed( variant ? produce(model, (draft) => { - Object.assign(draft.request.settings, variant.settings) - Object.assign(draft.request.headers, variant.headers) - Object.assign(draft.request.body, variant.body) + draft.settings = ProviderV2.mergeOverlay(draft.settings, variant.settings) + draft.headers = ProviderV2.mergeHeaders(draft.headers, variant.headers) + draft.body = ProviderV2.mergeOverlay(draft.body, variant.body) }) : model, ) } -const apiName = (model: ModelV2.Info) => - model.api.type === "aisdk" ? `${model.api.type}:${model.api.package}` : model.api.type +export interface Dependencies { + readonly loadPackage?: (specifier: string) => Effect.Effect + readonly loadAISDK?: (model: ModelV2.Info) => Effect.Effect +} export const fromCatalogModel = ( model: ModelV2.Info, credential?: Credential.Value, -): Effect.Effect => { + dependencies: Dependencies = {}, +): Effect.Effect => { const resolved = credential?.type !== "key" || credential.metadata === undefined ? model : produce(model, (draft) => { - Object.assign(draft.request.body, credential.metadata) + draft.body = ProviderV2.mergeOverlay(draft.body, credential.metadata) }) + const packageName = ProviderV2.packageName(resolved.package) const key = apiKey(resolved, credential) - if (resolved.api.type === "aisdk" && resolved.api.package === "@ai-sdk/openai") { - // ChatGPT-plan OAuth tokens are not API-key credentials: the public API rejects - // them, so requests must target the codex backend with the account header. - if (OpenAICodex.isChatGPT(credential)) { - const account = OpenAICodex.accountID(credential) - return Effect.succeed( - withDefaults(resolved, OpenAIResponses.route) - .with({ - endpoint: { baseURL: OpenAICodex.baseURL }, - auth: (key === undefined ? Auth.none : Auth.bearer(key)).andThen( - account === undefined ? Auth.none : Auth.headers({ "chatgpt-account-id": account }), - ), - }) - .model({ id: resolved.api.id }), - ) - } + + if ( + OpenAICodex.isChatGPT(credential) && + !ProviderV2.isAISDK(resolved.package) && + isNativeOpenAI(resolved.package) + ) { + return Effect.succeed(codexModel(resolved, credential, key)) + } + + if (ProviderV2.isAISDK(resolved.package) && packageName === "@ai-sdk/openai") { + if (OpenAICodex.isChatGPT(credential)) return Effect.succeed(codexModel(resolved, credential, key)) return Effect.succeed( withDefaults(resolved, OpenAIResponses.route) .with({ auth: key === undefined ? Auth.none : Auth.bearer(key) }) - .model({ id: resolved.api.id }), + .model({ id: resolved.modelID ?? resolved.id }), ) } - if (resolved.api.type === "aisdk" && resolved.api.package === "@ai-sdk/anthropic") { + if (ProviderV2.isAISDK(resolved.package) && packageName === "@ai-sdk/anthropic") { return Effect.succeed( withDefaults(resolved, AnthropicMessages.route) .with({ auth: key === undefined ? Auth.none : Auth.header("x-api-key", key) }) - .model({ id: resolved.api.id }), + .model({ id: resolved.modelID ?? resolved.id }), ) } - if (resolved.api.type === "aisdk" && resolved.api.package === "@ai-sdk/openai-compatible" && resolved.api.url) { + if ( + ProviderV2.isAISDK(resolved.package) && + packageName === "@ai-sdk/openai-compatible" && + typeof resolved.settings?.baseURL === "string" + ) { return Effect.succeed( withDefaults(resolved, OpenAICompatibleChat.route) .with({ auth: key === undefined ? Auth.none : Auth.bearer(key) }) - .model({ id: resolved.api.id }), + .model({ id: resolved.modelID ?? resolved.id }), ) } - return Effect.fail( - new UnsupportedApiError({ - providerID: resolved.providerID, - modelID: resolved.id, - api: apiName(resolved), - }), - ) + if (ProviderV2.isAISDK(resolved.package)) { + if (!dependencies.loadAISDK) return Effect.fail(unsupported(resolved)) + const runtime = produce(resolved, (draft) => { + draft.settings = ProviderV2.mergeOverlay(draft.settings, { + ...(credential?.type === "key" ? { apiKey: credential.key } : {}), + ...(credential?.type === "oauth" ? { apiKey: credential.access } : {}), + ...credential?.metadata, + }) + }) + return dependencies.loadAISDK(runtime).pipe(Effect.mapError(() => unsupported(resolved))) + } + if (!resolved.package) return Effect.fail(unsupported(resolved)) + + const specifier = resolved.package + return Effect.gen(function* () { + const module = yield* (dependencies.loadPackage ?? ProviderV2.loadPackage)(specifier).pipe( + Effect.mapError(() => unsupported(resolved)), + ) + const settings = { + ...resolved.settings, + ...(credential?.type === "key" ? { apiKey: credential.key } : {}), + ...(credential?.type === "oauth" ? { apiKey: credential.access } : {}), + ...credential?.metadata, + headers: resolved.headers, + body: resolved.body, + limits: { context: resolved.limit.context, output: resolved.limit.output }, + } + return yield* Effect.try({ + try: () => + Model.update(module.model(resolved.modelID ?? resolved.id, settings), { provider: resolved.providerID }), + catch: () => unsupported(resolved), + }) + }) } -export const resolve = (session: SessionSchema.Info, model: ModelV2.Info, credential?: Credential.Value) => - withVariant(model, session.model?.variant).pipe(Effect.flatMap((model) => fromCatalogModel(model, credential))) +const isNativeOpenAI = (packageName: string | undefined) => + packageName === "@opencode-ai/llm/providers/openai" || + packageName?.startsWith("@opencode-ai/llm/providers/openai/") === true -export const supported = (model: ModelV2.Info) => - model.api.type === "aisdk" && - (model.api.package === "@ai-sdk/openai" || - model.api.package === "@ai-sdk/anthropic" || - (model.api.package === "@ai-sdk/openai-compatible" && model.api.url !== undefined)) +const codexModel = ( + model: ModelV2.Info, + credential: Credential.Value | undefined, + key: ReturnType | undefined, +) => { + const account = OpenAICodex.accountID(credential) + return withDefaults(model, OpenAIResponses.route) + .with({ + endpoint: { baseURL: OpenAICodex.baseURL }, + auth: (key === undefined ? Auth.none : Auth.bearer(key)).andThen( + account === undefined ? Auth.none : Auth.headers({ "chatgpt-account-id": account }), + ), + }) + .model({ id: model.modelID ?? model.id }) +} + +const unsupported = (model: ModelV2.Info) => + new UnsupportedPackageError({ + providerID: model.providerID, + modelID: model.id, + package: model.package ?? "unknown", + }) + +export const resolve = ( + session: SessionSchema.Info, + model: ModelV2.Info, + credential?: Credential.Value, + dependencies?: Dependencies, +) => + withVariant(model, session.model?.variant).pipe( + Effect.flatMap((model) => fromCatalogModel(model, credential, dependencies)), + ) + +export const supported = (model: ModelV2.Info) => Boolean(model.package) /** Resolves models from the catalog belonging to the current Location runtime. */ const layer = Layer.effect( @@ -232,6 +302,8 @@ const layer = Layer.effect( Effect.gen(function* () { const catalog = yield* Catalog.Service const integrations = yield* Integration.Service + const npm = yield* Npm.Service + const aisdk = yield* AISDK.Service return Service.of({ resolve: Effect.fn("SessionRunnerModel.resolve")(function* (session) { // Location plugins populate and filter the catalog asynchronously during layer startup. @@ -257,6 +329,10 @@ const layer = Layer.effect( session, selected, connection ? yield* integrations.connection.resolve(connection) : undefined, + { + loadPackage: (specifier) => ProviderV2.loadPackage(specifier, npm), + loadAISDK: (model) => aisdk.model(model), + }, ) return { model, @@ -271,4 +347,8 @@ const layer = Layer.effect( }), ) -export const node = makeLocationNode({ service: Service, layer, deps: [Catalog.node, Integration.node] }) +export const node = makeLocationNode({ + service: Service, + layer, + deps: [Catalog.node, Integration.node, Npm.node, AISDK.node], +}) diff --git a/packages/core/src/session/runner/to-llm-message.ts b/packages/core/src/session/runner/to-llm-message.ts index d2eaee413b..0663ac8e6d 100644 --- a/packages/core/src/session/runner/to-llm-message.ts +++ b/packages/core/src/session/runner/to-llm-message.ts @@ -4,10 +4,10 @@ import { ToolOutput, ToolResultPart, type ContentPart, - type Model, type ProviderMetadata, } from "@opencode-ai/llm" import { Option, Schema } from "effect" +import type { ModelV2 } from "../../model" import { SessionMessage } from "../message" import type { FileAttachment } from "@opencode-ai/schema/prompt" @@ -88,9 +88,9 @@ const toolResult = (tool: SessionMessage.AssistantTool, providerMetadata: Provid } } -const assistant = (message: SessionMessage.Assistant, model: Model) => { +const assistant = (message: SessionMessage.Assistant, model: ModelV2.Ref) => { const sameModel = - String(message.model.providerID) === String(model.provider) && String(message.model.id) === String(model.id) + String(message.model.providerID) === String(model.providerID) && String(message.model.id) === String(model.id) const reuseProviderMetadata = sameModel && message.error === undefined const content = message.content.flatMap((item): ContentPart[] => { if (item.type === "text") return [{ type: "text", text: item.text }] @@ -133,7 +133,7 @@ const assistant = (message: SessionMessage.Assistant, model: Model) => { ] } -function toLLMMessage(message: SessionMessage.Message, model: Model): Message[] { +function toLLMMessage(message: SessionMessage.Message, model: ModelV2.Ref): Message[] { switch (message.type) { case "agent-switched": case "model-switched": @@ -197,5 +197,5 @@ ${message.recent} } /** Translate projected V2 Session history into canonical @opencode-ai/llm context. */ -export const toLLMMessages = (messages: readonly SessionMessage.Message[], model: Model) => +export const toLLMMessages = (messages: readonly SessionMessage.Message[], model: ModelV2.Ref) => messages.flatMap((message) => toLLMMessage(message, model)) diff --git a/packages/core/src/v1/config/migrate.ts b/packages/core/src/v1/config/migrate.ts index 046f29c431..1e7e3464dc 100644 --- a/packages/core/src/v1/config/migrate.ts +++ b/packages/core/src/v1/config/migrate.ts @@ -2,10 +2,12 @@ export * as ConfigMigrateV1 from "./migrate" import { ConfigV1 } from "./config" import { ConfigAgentV1 } from "./agent" +import { ConfigCommandV1 } from "./command" import { ConfigMCPV1 } from "./mcp" import { ConfigPermissionV1 } from "./permission" import { ConfigProviderV1 } from "./provider" import { ConfigProviderOptionsV1 } from "./provider-options" +import { ProviderV2 } from "../../provider" const keys = new Set([ "logLevel", @@ -48,7 +50,7 @@ export function migrate(info: typeof ConfigV1.Info.Type) { return { $schema: info.$schema, shell: info.shell, - model: info.model, + model: modelSelection(info.model), default_agent: info.default_agent, autoupdate: info.autoupdate, share: info.share ?? (info.autoshare ? "auto" : undefined), @@ -72,7 +74,7 @@ export function migrate(info: typeof ConfigV1.Info.Type) { buffer: info.compaction.reserved, }, skills: info.skills && [...(info.skills.paths ?? []), ...(info.skills.urls ?? [])], - commands: info.command, + commands: commands(info.command), instructions: info.instructions, references: info.references ?? info.reference, plugins: info.plugin?.map((plugin) => @@ -126,8 +128,7 @@ export function migrateAgent(info: ConfigAgentV1.Info) { ...(info.top_p === undefined ? {} : { top_p: info.top_p }), } return { - model: info.model, - variant: info.variant, + model: modelSelection(info.model, info.variant), request: Object.keys(body).length ? { body } : undefined, system: info.prompt, description: info.description, @@ -140,6 +141,32 @@ export function migrateAgent(info: ConfigAgentV1.Info) { } } +function commands(info?: Readonly>) { + if (!info) return undefined + return Object.fromEntries( + Object.entries(info).map(([id, command]) => [ + id, + { + template: command.template, + description: command.description, + agent: command.agent, + model: modelSelection(command.model, command.variant), + subtask: command.subtask, + }, + ]), + ) +} + +function modelSelection(input?: string, variant?: string) { + if (input === undefined || !/^[^/#]+\/[^#]+$/.test(input)) return undefined + const separator = input.indexOf("/") + return { + providerID: input.slice(0, separator), + model: input.slice(separator + 1), + ...(variant === undefined || variant.length === 0 || variant.includes("#") ? {} : { variant }), + } +} + function mcp(info: typeof ConfigV1.Info.Type) { const servers = Object.fromEntries( Object.entries(info.mcp ?? {}).flatMap(([name, server]) => @@ -184,31 +211,22 @@ function providers(info?: Readonly>) { } function migrateProvider(info: ConfigProviderV1.Info) { - const lowerer = ConfigProviderOptionsV1.get(info.npm) - const options = lowerer.provider(info.options ?? {}) - const url = info.api ?? options.url + const options = ConfigProviderOptionsV1.provider(info.options ?? {}) return { name: info.name, env: info.env, - api: info.npm - ? { - type: "aisdk" as const, - package: info.npm, - ...(url === undefined ? {} : { url }), - settings: options.settings ?? {}, - } - : undefined, - request: info.options && { headers: options.headers, body: options.body }, + package: info.npm ? ProviderV2.aisdk(info.npm) : undefined, + settings: info.api ? { ...options.settings, baseURL: info.api } : options.settings, + headers: info.options && options.headers, + body: info.options && options.body, models: info.models && - Object.fromEntries(Object.entries(info.models).map(([name, model]) => [name, migrateModel(model, info.npm)])), + Object.fromEntries(Object.entries(info.models).map(([name, model]) => [name, migrateModel(model)])), } } -function migrateModel(info: typeof ConfigProviderV1.Model.Type, packageName?: string) { - const packageID = info.provider?.npm ?? packageName - const lowerer = ConfigProviderOptionsV1.get(packageID) - const request = info.options && lowerer.request(info.options) +function migrateModel(info: typeof ConfigProviderV1.Model.Type) { + const settings = info.options && ConfigProviderOptionsV1.model(info.options) const costs = info.cost && [ { input: info.cost.input, @@ -231,29 +249,18 @@ function migrateModel(info: typeof ConfigProviderV1.Model.Type, packageName?: st ? { tools: info.tool_call ?? false, input: info.modalities?.input ?? [], output: info.modalities?.output ?? [] } : undefined return { + modelID: info.id, family: info.family, name: info.name, - api: info.provider?.npm - ? { - ...(info.id === undefined ? {} : { id: info.id }), - type: "aisdk" as const, - package: info.provider.npm, - ...(info.provider.api === undefined ? {} : { url: info.provider.api }), - settings: {}, - } - : info.id === undefined - ? undefined - : { id: info.id }, + package: info.provider?.npm ? ProviderV2.aisdk(info.provider.npm) : undefined, + settings: info.provider?.api ? { ...settings, baseURL: info.provider.api } : settings, capabilities, - request: (info.headers || request) && { - headers: info.headers, - body: request, - }, + headers: info.headers, variants: info.variants && Object.entries(info.variants).map(([id, options]) => ({ id, - body: lowerer.request(options), + settings: ConfigProviderOptionsV1.model(options), })), cost: costs, disabled: info.status === "deprecated" ? true : undefined, diff --git a/packages/core/src/v1/config/provider-options.ts b/packages/core/src/v1/config/provider-options.ts index 6bf0bd9e1a..82d5bb1d50 100644 --- a/packages/core/src/v1/config/provider-options.ts +++ b/packages/core/src/v1/config/provider-options.ts @@ -3,225 +3,29 @@ export * as ConfigProviderOptionsV1 from "./provider-options" type Options = Readonly> export interface ProviderResult { + readonly settings: Record readonly headers?: Record readonly body?: Record - readonly url?: string - readonly settings?: Record } -export interface Lowerer { - readonly provider: (options: Options) => ProviderResult - readonly request: (options: Options) => Record -} - -export function get(packageName?: string): Lowerer { - const key = packageName ?? "" - return Object.hasOwn(lowerers, key) ? lowerers[key]! : raw -} - -const raw: Lowerer = { - provider(options) { - return { body: clone(options) } - }, - request: clone, -} - -const openai: Lowerer = { - provider(options) { - return { - url: string(options.baseURL), - headers: compact({ - Authorization: bearer(options.apiKey), - "OpenAI-Organization": string(options.organization), - "OpenAI-Project": string(options.project), - ...headers(options.headers), - }), - body: body(options.body), - settings: omit(options, ["apiKey", "baseURL", "organization", "project", "headers", "body"]), - } - }, - request(options) { - const result = snake(options) - if (options.reasoningEffort !== undefined || options.reasoningSummary !== undefined) { - result.reasoning = { - ...(isRecord(result.reasoning) ? result.reasoning : {}), - ...(options.reasoningEffort !== undefined ? { effort: options.reasoningEffort } : {}), - ...(options.reasoningSummary !== undefined ? { summary: options.reasoningSummary } : {}), - } - delete result.reasoning_effort - delete result.reasoning_summary - } - if (options.textVerbosity !== undefined) { - result.text = { ...(isRecord(result.text) ? result.text : {}), verbosity: options.textVerbosity } - delete result.text_verbosity - } - return result - }, -} - -const anthropic: Lowerer = { - provider(options) { - return { - url: string(options.baseURL), - headers: compact({ - "x-api-key": string(options.apiKey), - Authorization: options.authToken ? bearer(options.authToken) : undefined, - ...headers(options.headers), - }), - body: body(options.body), - settings: omit(options, ["apiKey", "authToken", "baseURL", "headers", "body"]), - } - }, - request(options) { - const result = snake(options) - if (options.effort !== undefined || options.taskBudget !== undefined) { - result.output_config = compactUnknown({ effort: options.effort, task_budget: options.taskBudget }) - delete result.effort - delete result.task_budget - } - if (isRecord(options.metadata) && options.metadata.userId !== undefined) { - result.metadata = { ...(isRecord(result.metadata) ? result.metadata : {}), user_id: options.metadata.userId } - } - return result - }, -} - -const google: Lowerer = { - provider(options) { - return { - url: string(options.baseURL), - headers: compact({ "x-goog-api-key": string(options.apiKey), ...headers(options.headers) }), - body: body(options.body), - settings: omit(options, ["apiKey", "baseURL", "headers", "body"]), - } - }, - request(options) { - const generationConfig = pick(options, ["thinkingConfig", "responseModalities", "mediaResolution", "imageConfig"]) - return { - ...omit(options, ["thinkingConfig", "responseModalities", "mediaResolution", "imageConfig"]), - ...(Object.keys(generationConfig).length ? { generationConfig } : {}), - } - }, -} - -const azure: Lowerer = { - provider(options) { - return { - url: string(options.baseURL), - headers: compact({ "api-key": string(options.apiKey), ...headers(options.headers) }), - body: body(options.body), - settings: omit(options, ["apiKey", "baseURL", "headers", "body"]), - } - }, - request: openai.request, -} - -const bedrock: Lowerer = { - provider(options) { - return direct(options) - }, - request(options) { - return { additionalModelRequestFields: clone(options) } - }, -} - -const openaiCompatible: Lowerer = { - provider(options) { - return { ...direct(options, ["baseURL"]), url: string(options.baseURL) } - }, - request(options) { - const result = clone(options) - if (options.reasoningEffort !== undefined) { - result.reasoning_effort = options.reasoningEffort - delete result.reasoningEffort - } - return result - }, -} - -const lowerers: Readonly> = { - "@ai-sdk/openai": openai, - "@ai-sdk/anthropic": anthropic, - "@ai-sdk/google-vertex/anthropic": anthropic, - "@ai-sdk/google": google, - "@ai-sdk/google-vertex": google, - "@ai-sdk/azure": azure, - "@ai-sdk/amazon-bedrock": bedrock, - "@ai-sdk/openai-compatible": openaiCompatible, - "@ai-sdk/cerebras": openaiCompatible, - "@ai-sdk/deepinfra": openaiCompatible, - "@ai-sdk/groq": openaiCompatible, - "@ai-sdk/mistral": openaiCompatible, - "@ai-sdk/togetherai": openaiCompatible, - "@ai-sdk/xai": openaiCompatible, - "@openrouter/ai-sdk-provider": openaiCompatible, - "ai-gateway-provider": openaiCompatible, - "venice-ai-sdk-provider": openaiCompatible, -} - -function direct(options: Options, extraKeys: ReadonlyArray = []): ProviderResult { +export function provider(options: Options): ProviderResult { + const headers = options.headers + const body = options.body + const settings = Object.fromEntries(Object.entries(options).filter(([key]) => key !== "headers" && key !== "body")) + const headerOverlay = + typeof headers === "object" && headers !== null && !Array.isArray(headers) + ? Object.fromEntries( + Object.entries(headers).filter((entry): entry is [string, string] => typeof entry[1] === "string"), + ) + : undefined + const bodyOverlay = typeof body === "object" && body !== null && !Array.isArray(body) ? { ...body } : undefined return { - headers: headers(options.headers), - body: body(options.body), - settings: omit(options, ["headers", "body", ...extraKeys]), + settings, + headers: headerOverlay, + body: bodyOverlay, } } -function body(input: unknown) { - if (!isRecord(input)) return undefined - return { ...input } -} - -function snake(options: Options) { - return Object.fromEntries(Object.entries(options).map(([key, value]) => [snakeKey(key), snakeValue(value)])) -} - -function snakeValue(value: unknown): unknown { - if (Array.isArray(value)) return value.map(snakeValue) - if (!isRecord(value)) return value - return Object.fromEntries(Object.entries(value).map(([key, value]) => [snakeKey(key), snakeValue(value)])) -} - -function snakeKey(key: string) { - return key.replace(/[A-Z]/g, (match) => "_" + match.toLowerCase()) -} - -function clone(options: Options) { +export function model(options: Options) { return { ...options } } - -function omit(options: Options, keys: ReadonlyArray) { - return Object.fromEntries(Object.entries(options).filter(([key]) => !keys.includes(key))) -} - -function pick(options: Options, keys: ReadonlyArray) { - return Object.fromEntries(Object.entries(options).filter(([key]) => keys.includes(key))) -} - -function headers(input: unknown) { - if (!isRecord(input)) return undefined - return Object.fromEntries( - Object.entries(input).filter((entry): entry is [string, string] => typeof entry[1] === "string"), - ) -} - -function compact(input: Record) { - const entries = Object.entries(input).filter((entry): entry is [string, string] => entry[1] !== undefined) - return entries.length ? Object.fromEntries(entries) : undefined -} - -function compactUnknown(input: Record) { - return Object.fromEntries(Object.entries(input).filter((entry) => entry[1] !== undefined)) -} - -function string(input: unknown) { - return typeof input === "string" && input ? input : undefined -} - -function bearer(input: unknown) { - return typeof input === "string" && input ? `Bearer ${input}` : undefined -} - -function isRecord(input: unknown): input is Record { - return typeof input === "object" && input !== null && !Array.isArray(input) -} diff --git a/packages/core/test/aisdk.test.ts b/packages/core/test/aisdk.test.ts new file mode 100644 index 0000000000..ecc9481a83 --- /dev/null +++ b/packages/core/test/aisdk.test.ts @@ -0,0 +1,85 @@ +import type { LanguageModelV3CallOptions } from "@ai-sdk/provider" +import { AISDK } from "@opencode-ai/core/aisdk" +import { ModelV2 } from "@opencode-ai/core/model" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { LLM } from "@opencode-ai/llm" +import { LLMClient } from "@opencode-ai/llm/route" +import { expect } from "bun:test" +import { Effect } from "effect" +import { testEffect } from "./lib/effect" + +const it = testEffect(AISDK.locationLayer) +type Fetch = (input: Parameters[0], init?: RequestInit) => Promise + +const model = (packageName: string, settings: Record = {}) => + ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("test-provider"), ModelV2.ID.make("catalog-model")), + modelID: ModelV2.ID.make("api-model"), + package: ProviderV2.aisdk(packageName), + settings, + limit: { context: 100, output: 20 }, + }) + +it.effect("keys language models by package and flattened overlays", () => + Effect.gen(function* () { + const aisdk = yield* AISDK.Service + const loaded: string[] = [] + yield* aisdk.hook.sdk((event) => { + loaded.push(event.package) + event.sdk = { languageModel: () => ({ package: event.package }) } + }) + + const first = yield* aisdk.language(model("first", { region: "us-east-1" })) + const second = yield* aisdk.language(model("second", { region: "us-east-1" })) + const third = yield* aisdk.language( + model("second", { region: "us-east-1", fetch: async () => new Response("ok") }), + ) + + expect(first).not.toBe(second) + expect(second).not.toBe(third) + expect(loaded).toEqual(["first", "second", "second"]) + }), +) + +it.effect("projects request settings, headers, and raw body overlays", () => + Effect.gen(function* () { + const aisdk = yield* AISDK.Service + let wrappedFetch: Fetch | undefined + let body: unknown + const customFetch: Fetch = async (_input, init) => { + body = init?.body + return new Response("ok") + } + yield* aisdk.hook.sdk((event) => { + wrappedFetch = event.options.fetch + event.sdk = { languageModel: () => ({ provider: event.model.providerID }) } + }) + + const resolved = yield* aisdk.model( + ModelV2.Info.make({ + ...model("@ai-sdk/google", { + apiKey: "secret", + fetch: customFetch, + thinkingConfig: { thinkingBudget: 1024 }, + }), + headers: { "x-test": "header" }, + body: { safety_setting: "strict" }, + }), + ) + const prepared = yield* LLMClient.prepare( + LLM.request({ model: resolved, prompt: "Hello" }), + ) + + expect(prepared.body.providerOptions).toEqual({ + google: { thinkingConfig: { thinkingBudget: 1024 } }, + }) + expect(prepared.body.headers).toEqual({ "x-test": "header" }) + expect(wrappedFetch).toBeFunction() + if (wrappedFetch === undefined) return yield* Effect.die("Expected wrapped fetch") + const fetchRequest = wrappedFetch + yield* Effect.promise(() => + fetchRequest("https://provider.example", { method: "POST", body: JSON.stringify({ model: "api-model" }) }), + ) + expect(JSON.parse(String(body))).toEqual({ model: "api-model", safety_setting: "strict" }) + }), +) diff --git a/packages/core/test/catalog.test.ts b/packages/core/test/catalog.test.ts index 04bc006888..83f71c3398 100644 --- a/packages/core/test/catalog.test.ts +++ b/packages/core/test/catalog.test.ts @@ -61,14 +61,14 @@ describe("CatalogV2", () => { }) expect((yield* catalog.provider.available()).map((provider) => provider.id)).toEqual([ProviderV2.ID.make("test")]) - expect(required(yield* catalog.provider.get(ProviderV2.ID.make("test"))).request.body).toEqual({}) + expect(required(yield* catalog.provider.get(ProviderV2.ID.make("test"))).body).toBeUndefined() yield* credentials.create({ integrationID, label: "Second", value: Credential.Key.make({ type: "key", key: "second", metadata: { tenant: "two" } }), }) expect((yield* catalog.provider.available()).map((provider) => provider.id)).toEqual([ProviderV2.ID.make("test")]) - expect(required(yield* catalog.provider.get(ProviderV2.ID.make("test"))).request.body).toEqual({}) + expect(required(yield* catalog.provider.get(ProviderV2.ID.make("test"))).body).toBeUndefined() }).pipe(Effect.provide(localCatalogLayer)) }) @@ -130,111 +130,91 @@ describe("CatalogV2", () => { ), ) - it.effect("normalizes provider baseURL into api url", () => + it.effect("stores provider package settings", () => Effect.gen(function* () { const catalog = yield* Catalog.Service const providerID = ProviderV2.ID.make("test") yield* catalog.transform((catalog) => catalog.provider.update(providerID, (provider) => { - provider.api = { - type: "aisdk", - package: "@ai-sdk/openai-compatible", - url: "https://default.example.com", - } - provider.request.body.baseURL = "https://override.example.com" + provider.package = ProviderV2.aisdk("@ai-sdk/openai-compatible") + provider.settings = { baseURL: "https://override.example.com" } }), ) - expect(required(yield* catalog.provider.get(providerID)).api).toEqual({ - type: "aisdk", - package: "@ai-sdk/openai-compatible", - url: "https://override.example.com", + expect(required(yield* catalog.provider.get(providerID))).toMatchObject({ + package: ProviderV2.aisdk("@ai-sdk/openai-compatible"), + settings: { baseURL: "https://override.example.com" }, }) }), ) - it.effect("normalizes model baseURL into api url", () => + it.effect("uses model package settings over provider settings", () => Effect.gen(function* () { const catalog = yield* Catalog.Service const providerID = ProviderV2.ID.make("test") const modelID = ModelV2.ID.make("model") yield* catalog.transform((catalog) => { catalog.provider.update(providerID, (provider) => { - provider.api = { - type: "aisdk", - package: "@ai-sdk/openai-compatible", - url: "https://provider.example.com", - } + provider.package = ProviderV2.aisdk("@ai-sdk/openai-compatible") + provider.settings = { baseURL: "https://provider.example.com" } }) catalog.model.update(providerID, modelID, (model) => { - model.api = { - id: modelID, - type: "aisdk", - package: "@ai-sdk/openai-compatible", - url: "https://model.example.com", - } - model.request.body.baseURL = "https://override.example.com" + model.modelID = ModelV2.ID.make("upstream-model") + model.package = ProviderV2.aisdk("@ai-sdk/openai-compatible") + model.settings = { baseURL: "https://override.example.com" } }) }) - expect(required(yield* catalog.model.get(providerID, modelID)).api).toEqual({ - id: modelID, - type: "aisdk", - package: "@ai-sdk/openai-compatible", - url: "https://override.example.com", - settings: {}, + expect(required(yield* catalog.model.get(providerID, modelID))).toMatchObject({ + modelID: ModelV2.ID.make("upstream-model"), + package: ProviderV2.aisdk("@ai-sdk/openai-compatible"), + settings: { baseURL: "https://override.example.com" }, }) }), ) - it.effect("resolves default model api from provider api", () => + it.effect("resolves default model package settings from the provider", () => Effect.gen(function* () { const catalog = yield* Catalog.Service const providerID = ProviderV2.ID.make("test") const modelID = ModelV2.ID.make("model") yield* catalog.transform((catalog) => { catalog.provider.update(providerID, (provider) => { - provider.api = { - type: "aisdk", - package: "@ai-sdk/openai-compatible", - url: "https://provider.example.com", - } + provider.package = ProviderV2.aisdk("@ai-sdk/openai-compatible") + provider.settings = { baseURL: "https://provider.example.com" } }) catalog.model.update(providerID, modelID, () => {}) }) - expect(required(yield* catalog.model.get(providerID, modelID)).api).toEqual({ - id: modelID, - type: "aisdk", - package: "@ai-sdk/openai-compatible", - url: "https://provider.example.com", + expect(required(yield* catalog.model.get(providerID, modelID))).toMatchObject({ + package: ProviderV2.aisdk("@ai-sdk/openai-compatible"), + settings: { baseURL: "https://provider.example.com" }, }) }), ) - it.effect("resolves provider and model request merges", () => + it.effect("resolves provider and model overlay merges", () => Effect.gen(function* () { const catalog = yield* Catalog.Service const providerID = ProviderV2.ID.make("test") const modelID = ModelV2.ID.make("model") yield* catalog.transform((catalog) => { catalog.provider.update(providerID, (provider) => { - provider.request.headers.provider = "provider" - provider.request.headers.shared = "provider" - provider.request.body.provider = true + provider.settings = { provider: true, shared: "provider" } + provider.headers = { provider: "provider", shared: "provider" } + provider.body = { provider: true, shared: "provider" } }) catalog.model.update(providerID, modelID, (model) => { - model.request.headers.model = "model" - model.request.headers.shared = "model" - model.request.body.model = true - model.request.body.request = true - model.request.body.shared = "model" + model.settings = { model: true, shared: "model" } + model.headers = { model: "model", shared: "model" } + model.body = { model: true, shared: "model" } }) }) const model = required(yield* catalog.model.get(providerID, modelID)) - expect(model.request.headers).toEqual({ provider: "provider", shared: "model", model: "model" }) - expect(model.request.body).toEqual({ provider: true, model: true, request: true, shared: "model" }) + expect(model.settings).toEqual({ provider: true, shared: "model", model: true }) + expect(model.headers).toEqual({ provider: "provider", shared: "model", model: "model" }) + expect(model.body).toEqual({ provider: true, shared: "model", model: true }) }), ) diff --git a/packages/core/test/config/agent.test.ts b/packages/core/test/config/agent.test.ts index 7f1d7ff053..5537add3db 100644 --- a/packages/core/test/config/agent.test.ts +++ b/packages/core/test/config/agent.test.ts @@ -62,7 +62,7 @@ describe("ConfigAgentPlugin.Plugin", () => { info: decode({ permissions: [{ action: "read", resource: "*", effect: "allow" }], agents: { - reviewer: { variant: "high", hidden: true }, + reviewer: { model: "openrouter/openai/gpt-5#high", hidden: true }, removed: { disabled: true }, late: { permissions: [{ action: "edit", resource: "*", effect: "allow" }], @@ -170,7 +170,7 @@ describe("ConfigAgentPlugin.Plugin", () => { hidden: true, color: "warning", steps: 12, - model: { providerID: "anthropic", id: "claude-sonnet", variant: undefined }, + model: { providerID: "anthropic", id: "claude-sonnet" }, }) expect(reviewer.request).toEqual({ settings: {}, diff --git a/packages/core/test/config/command.test.ts b/packages/core/test/config/command.test.ts index a8126d00f6..f371e19543 100644 --- a/packages/core/test/config/command.test.ts +++ b/packages/core/test/config/command.test.ts @@ -44,8 +44,7 @@ describe("ConfigCommandPlugin.Plugin", () => { `--- description: File review agent: reviewer -model: anthropic/claude -variant: high +model: anthropic/claude#high subtask: true --- Review files`, diff --git a/packages/core/test/config/config.test.ts b/packages/core/test/config/config.test.ts index edf18d5add..ae7322292c 100644 --- a/packages/core/test/config/config.test.ts +++ b/packages/core/test/config/config.test.ts @@ -4,6 +4,7 @@ import { describe, expect } from "bun:test" import { Effect, Fiber, Layer, PubSub, Schema, Stream } from "effect" import { FastCheck } from "effect/testing" import { Config } from "@opencode-ai/core/config" +import { ConfigModel } from "@opencode-ai/core/config/model" import { Config as ConfigSchema } from "@opencode-ai/schema/config" import { ConfigProvider } from "@opencode-ai/core/config/provider" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" @@ -16,12 +17,14 @@ import { EventV2 } from "@opencode-ai/core/event" import { Global } from "@opencode-ai/core/global" import { Location } from "@opencode-ai/core/location" import { Project } from "@opencode-ai/core/project" +import { ProviderV2 } from "@opencode-ai/core/provider" import { AbsolutePath } from "@opencode-ai/core/schema" import { location } from "../fixture/location" import { tmpdir } from "../fixture/tmpdir" import { testEffect } from "../lib/effect" const it = testEffect(Layer.empty) +const selection = Schema.decodeUnknownSync(ConfigModel.Selection) function testLayer( directory: string, @@ -47,11 +50,10 @@ function testLayer( } const provider = { - api: { type: "native", settings: {} }, - request: { - headers: {}, - body: {}, - }, + package: "native", + settings: {}, + headers: {}, + body: {}, models: {}, } @@ -105,13 +107,19 @@ describe("Config", () => { it.effect("returns the latest defined scalar from priority-ordered documents", () => Effect.sync(() => { const entries = [ - new Config.Document({ type: "document", info: new Config.Info({ model: "openrouter/openai/gpt-5" }) }), + new Config.Document({ + type: "document", + info: new Config.Info({ model: selection("openrouter/openai/gpt-5") }), + }), new Config.Directory({ type: "directory", path: AbsolutePath.make("/skills") }), new Config.Document({ type: "document", info: new Config.Info({}) }), - new Config.Document({ type: "document", info: new Config.Info({ model: "openrouter/openai/gpt-5.5" }) }), + new Config.Document({ + type: "document", + info: new Config.Info({ model: selection("openrouter/openai/gpt-5.5") }), + }), ] - expect(Config.latest(entries, "model")).toBe("openrouter/openai/gpt-5.5") + expect(Config.latest(entries, "model")).toEqual(selection("openrouter/openai/gpt-5.5")) expect(Config.latest(entries, "default_agent")).toBeUndefined() }), ) @@ -142,7 +150,12 @@ describe("Config", () => { Effect.sync(() => { FastCheck.assert( FastCheck.property(Schema.toArbitrary(ConfigV1.Info), (info) => { - Schema.decodeUnknownSync(Config.Info)(ConfigMigrateV1.migrate(info), { errors: "all" }) + const parsed = Schema.decodeUnknownSync(ConfigV1.Info)( + Schema.decodeUnknownSync(Schema.UnknownFromJsonString)( + Schema.encodeUnknownSync(Schema.UnknownFromJsonString)(info), + ), + ) + Schema.decodeUnknownSync(Config.Info)(ConfigMigrateV1.migrate(parsed), { errors: "all" }) }), { numRuns: 100 }, ) @@ -165,12 +178,9 @@ describe("Config", () => { }, }) - expect(migrated.providers?.bedrock?.api).toEqual({ - type: "aisdk", - package: "@ai-sdk/amazon-bedrock", + expect(migrated.providers?.bedrock).toMatchObject({ + package: ProviderV2.aisdk("@ai-sdk/amazon-bedrock"), settings: { region: "us-east-1", profile: "dev" }, - }) - expect(migrated.providers?.bedrock?.request).toEqual({ headers: { "x-test": "1" }, body: { trace: true }, }) @@ -197,8 +207,7 @@ describe("Config", () => { template: "Review changes", description: "Review code", agent: "reviewer", - model: "anthropic/claude", - variant: "high", + model: { providerID: "anthropic", model: "claude", variant: "high" }, subtask: true, }, }) @@ -431,8 +440,7 @@ describe("Config", () => { ], agents: { reviewer: { - model: "openrouter/openai/gpt-5", - variant: "high", + model: "openrouter/openai/gpt-5#high", request: { headers: { "x-agent": "reviewer" }, body: { reasoningEffort: "high" }, @@ -505,7 +513,7 @@ describe("Config", () => { expect(documents).toHaveLength(1) expect(documents[0]?.info.shell).toBe("/bin/bash") - expect(documents[0]?.info.model).toBe("anthropic/claude") + expect(documents[0]?.info.model).toEqual(selection("anthropic/claude")) expect(documents[0]?.info.default_agent).toBe("reviewer") expect(documents[0]?.info.autoupdate).toBe("notify") expect(documents[0]?.info.share).toBe("disabled") @@ -516,8 +524,7 @@ describe("Config", () => { { action: "bash", resource: "git status", effect: "allow" }, ]) const reviewer = documents[0]?.info.agents?.reviewer - expect(reviewer?.model).toBe("openrouter/openai/gpt-5") - expect(reviewer?.variant).toBe("high") + expect(reviewer?.model).toEqual(selection("openrouter/openai/gpt-5#high")) expect(reviewer?.request).toEqual({ headers: { "x-agent": "reviewer" }, body: { reasoningEffort: "high" }, @@ -748,34 +755,32 @@ 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" } }, + settings: { apiKey: "secret" }, models: { model: { - request: { body: { reasoningEffort: "high" } }, - variants: [{ id: "fast", body: { temperature: 0.2 } }], + settings: { reasoningEffort: "high" }, + variants: [{ id: "fast", settings: { temperature: 0.2 } }], }, }, }) expect(documents[0]?.info.providers?.openai).toMatchObject({ - api: { settings: {} }, - request: { headers: { Authorization: "Bearer secret", "OpenAI-Organization": "org" } }, + package: ProviderV2.aisdk("@ai-sdk/openai"), + settings: { apiKey: "secret", organization: "org" }, models: { model: { - request: { - body: { temperature: 0.3, reasoning: { effort: "high" }, service_tier: "priority" }, - }, - variants: [{ id: "high", body: { reasoning: { effort: "high", summary: "auto" } } }], + settings: { temperature: 0.3, reasoningEffort: "high", serviceTier: "priority" }, + variants: [{ id: "high", settings: { reasoningEffort: "high", reasoningSummary: "auto" } }], }, }, }) expect(documents[0]?.info.providers?.anthropic).toMatchObject({ + package: ProviderV2.aisdk("@ai-sdk/anthropic"), models: { model: { - request: { - body: { - output_config: { effort: "high", task_budget: 4096 }, - metadata: { user_id: "user-1" }, - }, + settings: { + effort: "high", + taskBudget: 4096, + metadata: { userId: "user-1" }, }, }, }, diff --git a/packages/core/test/config/model.test.ts b/packages/core/test/config/model.test.ts new file mode 100644 index 0000000000..6ef8b2ac75 --- /dev/null +++ b/packages/core/test/config/model.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, test } from "bun:test" +import { ConfigModel } from "@opencode-ai/core/config/model" +import { Model } from "@opencode-ai/schema/model" +import { Provider } from "@opencode-ai/schema/provider" +import { Schema } from "effect" + +const decode = Schema.decodeUnknownSync(ConfigModel.Selection) + +describe("ConfigModel.Selection", () => { + test("normalizes short and explicit model selections", () => { + expect(decode("openrouter/openai/gpt-5#high")).toEqual({ + providerID: Provider.ID.make("openrouter"), + model: Model.ID.make("openai/gpt-5"), + variant: Model.VariantID.make("high"), + }) + expect(decode({ providerID: "anthropic", model: "claude-sonnet", variant: "high" })).toEqual({ + providerID: Provider.ID.make("anthropic"), + model: Model.ID.make("claude-sonnet"), + variant: Model.VariantID.make("high"), + }) + }) + + test("rejects malformed selections and reserved fragments", () => { + expect(() => decode("gpt-5")).toThrow() + expect(() => decode("openai/gpt-5#")).toThrow() + expect(() => decode({ providerID: "openai", model: "gpt-5#high" })).toThrow() + }) +}) diff --git a/packages/core/test/config/provider-options.test.ts b/packages/core/test/config/provider-options.test.ts index 44a1c0c9db..ac746e4441 100644 --- a/packages/core/test/config/provider-options.test.ts +++ b/packages/core/test/config/provider-options.test.ts @@ -2,223 +2,47 @@ import { describe, expect, test } from "bun:test" import { ConfigProviderOptionsV1 } from "@opencode-ai/core/v1/config/provider-options" describe("ConfigProviderOptionsV1", () => { - test("keeps raw provider and request options unchanged", () => { - const lowerer = ConfigProviderOptionsV1.get("custom-provider") - - expect(lowerer.provider({ apiKey: "secret", headers: { "x-test": "1" }, nested: { camelCase: true } })).toEqual({ - body: { apiKey: "secret", headers: { "x-test": "1" }, nested: { camelCase: true } }, - }) - expect(lowerer.request({ nested: { camelCase: true } })).toEqual({ nested: { camelCase: true } }) - }) - - test("falls back to raw lowering for prototype property package names", () => { - expect(ConfigProviderOptionsV1.get("toString").provider({ enabled: true })).toEqual({ body: { enabled: true } }) - }) - - test("lowers OpenAI provider and request options", () => { - const lowerer = ConfigProviderOptionsV1.get("@ai-sdk/openai") - + test("splits provider overlays without changing package settings", () => { expect( - lowerer.provider({ + ConfigProviderOptionsV1.provider({ apiKey: "secret", baseURL: "https://openai.example/v1", organization: "org", - project: "project", - headers: { "x-test": "1" }, + headers: { "x-test": "1", invalid: true }, body: { store: true }, - timeout: 1000, + nested: { camelCase: true }, }), ).toEqual({ - url: "https://openai.example/v1", - headers: { - Authorization: "Bearer secret", - "OpenAI-Organization": "org", - "OpenAI-Project": "project", - "x-test": "1", + settings: { + apiKey: "secret", + baseURL: "https://openai.example/v1", + organization: "org", + nested: { camelCase: true }, }, + headers: { "x-test": "1" }, body: { store: true }, - settings: { timeout: 1000 }, }) + }) + + test("keeps model and variant options unchanged", () => { expect( - lowerer.request({ + ConfigProviderOptionsV1.model({ reasoningEffort: "high", - reasoningSummary: "auto", - reasoning: { encryptedContent: true }, - textVerbosity: "low", - text: { outputFormat: "plain" }, - nestedValue: { camelCase: true }, - }), - ).toEqual({ - reasoning: { encrypted_content: true, effort: "high", summary: "auto" }, - text: { output_format: "plain", verbosity: "low" }, - nested_value: { camel_case: true }, - }) - }) - - test("lowers Anthropic provider and request options", () => { - const lowerer = ConfigProviderOptionsV1.get("@ai-sdk/anthropic") - - expect( - lowerer.provider({ - apiKey: "secret", - authToken: "token", - baseURL: "https://anthropic.example", - headers: { "x-test": "1" }, - body: { beta: true }, - generateId: "custom", - }), - ).toEqual({ - url: "https://anthropic.example", - headers: { "x-api-key": "secret", Authorization: "Bearer token", "x-test": "1" }, - body: { beta: true }, - settings: { generateId: "custom" }, - }) - expect( - lowerer.request({ - effort: "high", taskBudget: 1024, - metadata: { userId: "user", traceId: "trace" }, - nestedValue: { camelCase: true }, + metadata: { userId: "user" }, }), ).toEqual({ - output_config: { effort: "high", task_budget: 1024 }, - metadata: { user_id: "user", trace_id: "trace" }, - nested_value: { camel_case: true }, + reasoningEffort: "high", + taskBudget: 1024, + metadata: { userId: "user" }, }) }) - test("lowers Google provider and request options", () => { - const lowerer = ConfigProviderOptionsV1.get("@ai-sdk/google") - - expect( - lowerer.provider({ - apiKey: "secret", - baseURL: "https://google.example", - headers: { "x-test": "1" }, - body: { trace: true }, - project: "project", - }), - ).toEqual({ - url: "https://google.example", - headers: { "x-goog-api-key": "secret", "x-test": "1" }, - body: { trace: true }, - settings: { project: "project" }, - }) - expect( - lowerer.request({ - thinkingConfig: { thinkingBudget: 1024 }, - responseModalities: ["TEXT"], - mediaResolution: "high", - imageConfig: { aspectRatio: "16:9" }, - safetySettings: ["safe"], - }), - ).toEqual({ - safetySettings: ["safe"], - generationConfig: { - thinkingConfig: { thinkingBudget: 1024 }, - responseModalities: ["TEXT"], - mediaResolution: "high", - imageConfig: { aspectRatio: "16:9" }, - }, - }) - }) - - test("lowers Azure provider options and uses OpenAI request lowering", () => { - const lowerer = ConfigProviderOptionsV1.get("@ai-sdk/azure") - - expect( - lowerer.provider({ - apiKey: "secret", - baseURL: "https://azure.example", - headers: { "x-test": "1" }, - body: { trace: true }, - resourceName: "resource", - }), - ).toEqual({ - url: "https://azure.example", - headers: { "api-key": "secret", "x-test": "1" }, - body: { trace: true }, - settings: { resourceName: "resource" }, - }) - expect(lowerer.request({ reasoningEffort: "high", reasoningSummary: "auto", textVerbosity: "low" })).toEqual({ - reasoning: { effort: "high", summary: "auto" }, - text: { verbosity: "low" }, - }) - }) - - test("lowers Amazon Bedrock provider and request options", () => { - const lowerer = ConfigProviderOptionsV1.get("@ai-sdk/amazon-bedrock") - - expect( - lowerer.provider({ - headers: { "x-test": "1" }, - body: { trace: true }, - region: "us-east-1", - profile: "dev", - }), - ).toEqual({ - headers: { "x-test": "1" }, - body: { trace: true }, - settings: { region: "us-east-1", profile: "dev" }, - }) - expect(lowerer.request({ temperature: 0.2 })).toEqual({ - additionalModelRequestFields: { temperature: 0.2 }, - }) - }) - - test("lowers OpenAI-compatible provider and request options", () => { - const lowerer = ConfigProviderOptionsV1.get("@ai-sdk/openai-compatible") - - expect( - lowerer.provider({ - baseURL: "https://compatible.example/v1", - headers: { "x-test": "1" }, - body: { trace: true }, - apiKey: "secret", - }), - ).toEqual({ - url: "https://compatible.example/v1", - headers: { "x-test": "1" }, - body: { trace: true }, - settings: { apiKey: "secret" }, - }) - expect(lowerer.request({ reasoningEffort: "high", serviceTier: "priority" })).toEqual({ - reasoning_effort: "high", - serviceTier: "priority", - }) - }) - - test.each([ - "@ai-sdk/cerebras", - "@ai-sdk/deepinfra", - "@ai-sdk/groq", - "@ai-sdk/mistral", - "@ai-sdk/togetherai", - "@ai-sdk/xai", - "@openrouter/ai-sdk-provider", - "ai-gateway-provider", - "venice-ai-sdk-provider", - ])("uses OpenAI-compatible lowering for %s", (packageName) => { - const lowerer = ConfigProviderOptionsV1.get(packageName) - - expect(lowerer.provider({ baseURL: "https://example.test", apiKey: "secret" })).toEqual({ - url: "https://example.test", + test("uses mechanical lowering for custom provider options", () => { + expect(ConfigProviderOptionsV1.provider({ enabled: true })).toEqual({ + settings: { enabled: true }, headers: undefined, body: undefined, - settings: { apiKey: "secret" }, }) - expect(lowerer.request({ reasoningEffort: "high" })).toEqual({ reasoning_effort: "high" }) }) - - test.each(["@ai-sdk/google-vertex", "@ai-sdk/google-vertex/anthropic"])( - "uses provider family lowering for %s", - (packageName) => { - const lowerer = ConfigProviderOptionsV1.get(packageName) - - expect(lowerer.provider({ baseURL: "https://example.test", profile: "dev" })).toMatchObject({ - url: "https://example.test", - settings: { profile: "dev" }, - }) - }, - ) }) diff --git a/packages/core/test/config/provider.test.ts b/packages/core/test/config/provider.test.ts index 605bcd63cf..d1d27dc6e9 100644 --- a/packages/core/test/config/provider.test.ts +++ b/packages/core/test/config/provider.test.ts @@ -45,13 +45,6 @@ function withEnv(vars: Record, effect: () = ) } -function request(headers: Record, variant?: string) { - return { - headers, - variant, - } -} - const decode = Schema.decodeUnknownSync(Config.Info) describe("ConfigProviderPlugin.Plugin", () => { @@ -68,7 +61,8 @@ describe("ConfigProviderPlugin.Plugin", () => { info: decode({ providers: { opencode: { - api: { type: "aisdk", package: "@ai-sdk/openai", url: "https://opencode.test/v1" }, + package: "aisdk:@ai-sdk/openai", + settings: { baseURL: "https://opencode.test/v1" }, models: { "alpha-gpt-next": { variants: [ @@ -119,7 +113,8 @@ describe("ConfigProviderPlugin.Plugin", () => { info: decode({ providers: { opencode: { - api: { type: "aisdk", package: "@ai-sdk/openai", url: "https://opencode.test/v1" }, + package: "aisdk:@ai-sdk/openai", + settings: { baseURL: "https://opencode.test/v1" }, }, }, }), @@ -144,7 +139,7 @@ describe("ConfigProviderPlugin.Plugin", () => { yield* addPlugin(config) const model = required(yield* catalog.model.get(providerID, modelID)) - expect(model.variants[0]).toMatchObject({ + expect(model.variants?.[0]).toMatchObject({ id: "high", body: { reasoningEffort: "high" }, }) @@ -169,8 +164,8 @@ describe("ConfigProviderPlugin.Plugin", () => { custom: { name: "Configured", env: ["CUSTOM_API_KEY"], - api: { type: "native", settings: {} }, - request: request({ first: "first", shared: "first" }), + package: "native", + headers: { first: "first", shared: "first" }, models: { chat: { name: "First", @@ -178,7 +173,8 @@ describe("ConfigProviderPlugin.Plugin", () => { disabled: true, limit: { context: 100, output: 50 }, cost: { input: 1, output: 2 }, - request: request({ first: "first", shared: "first" }, "retained"), + settings: { retained: true }, + headers: { first: "first", shared: "first" }, variants: [ { id: "fast", @@ -197,17 +193,18 @@ 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: "aisdk:custom-sdk", + settings: { baseURL: "https://example.test" }, + headers: { last: "last", shared: "last" }, models: { default: { name: "Default", }, chat: { - api: { id: "api-chat" }, + modelID: "api-chat", name: "Last", limit: { output: 75 }, - request: request({ last: "last", shared: "last" }), + headers: { last: "last", shared: "last" }, variants: [ { id: "fast", @@ -247,22 +244,24 @@ 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.request.headers).toEqual({ first: "first", shared: "last", last: "last" }) - expect(model.api.id).toBe(ModelV2.ID.make("api-chat")) + expect(provider.package).toBe("aisdk:custom-sdk") + expect(provider.settings).toEqual({ baseURL: "https://example.test" }) + expect(provider.headers).toEqual({ first: "first", shared: "last", last: "last" }) + expect(model.id).toBe(modelID) + expect(model.modelID).toBe(ModelV2.ID.make("api-chat")) 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: 75 }) expect(model.cost).toEqual([{ input: 1, output: 2, cache: { read: 0, write: 0 }, tier: undefined }]) - expect(model.request.headers).toEqual({ first: "first", shared: "last", last: "last" }) - expect(model.request.variant).toBe("retained") - expect(model.variants.map((variant) => variant.id)).toEqual([ + expect(model.settings).toEqual({ baseURL: "https://example.test", retained: true }) + expect(model.headers).toEqual({ first: "first", shared: "last", last: "last" }) + expect(model.variants?.map((variant) => variant.id)).toEqual([ ModelV2.VariantID.make("fast"), ModelV2.VariantID.make("slow"), ]) - expect(model.variants[0]?.headers).toEqual({ first: "first", shared: "last", last: "last" }) - expect(model.variants[1]?.headers).toEqual({ slow: "slow" }) + expect(model.variants?.[0]?.headers).toEqual({ first: "first", shared: "last", last: "last" }) + expect(model.variants?.[1]?.headers).toEqual({ slow: "slow" }) }), ), ) diff --git a/packages/core/test/location-layer.test.ts b/packages/core/test/location-layer.test.ts index f0bef9fd32..f945cb2265 100644 --- a/packages/core/test/location-layer.test.ts +++ b/packages/core/test/location-layer.test.ts @@ -271,7 +271,7 @@ describe("LocationServiceMap", () => { providers: { unavailable: { name: "Unavailable", - api: { type: "native", settings: {} }, + package: "test-provider", models: { chat: { disabled: true } }, }, }, @@ -306,7 +306,7 @@ describe("LocationServiceMap", () => { ), ) - it.live("preserves the selected catalog identity when the api model id differs", () => + it.live("preserves the selected catalog identity when the package model id differs", () => Effect.acquireRelease( Effect.promise(() => tmpdir()), (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()), @@ -318,12 +318,12 @@ describe("LocationServiceMap", () => { const catalog = yield* Catalog.Service yield* catalog.transform((editor) => { editor.provider.update(ProviderV2.ID.make("aliased"), (provider) => { - provider.api = { type: "aisdk", package: "@ai-sdk/openai", settings: {} } + provider.package = ProviderV2.aisdk("@ai-sdk/openai") }) editor.model.update(ProviderV2.ID.make("aliased"), ModelV2.ID.make("fast"), (model) => { - // Catalog id and provider API id intentionally differ, like gpt-5.5-fast -> gpt-5.5. - model.api = { ...model.api, id: ModelV2.ID.make("base") } - model.variants.push({ id: ModelV2.VariantID.make("high"), settings: {}, headers: {}, body: {} }) + // Catalog id and package model id intentionally differ, like gpt-5.5-fast -> gpt-5.5. + model.modelID = ModelV2.ID.make("base") + model.variants = [{ id: ModelV2.VariantID.make("high") }] }) }) const models = yield* SessionRunnerModel.Service diff --git a/packages/core/test/plugin/fixtures/variant-source-plugin.ts b/packages/core/test/plugin/fixtures/variant-source-plugin.ts index 087a714227..f799c96888 100644 --- a/packages/core/test/plugin/fixtures/variant-source-plugin.ts +++ b/packages/core/test/plugin/fixtures/variant-source-plugin.ts @@ -1,4 +1,5 @@ import { define } from "@opencode-ai/plugin/v2/effect" +import { ProviderV2 } from "@opencode-ai/core/provider" import { Effect } from "effect" export default define({ @@ -7,14 +8,11 @@ export default define({ ctx.catalog .transform((catalog) => { catalog.provider.update("configured", (provider) => { - provider.api = { type: "aisdk", package: "@ai-sdk/openai-compatible" } + provider.package = ProviderV2.aisdk("@ai-sdk/openai-compatible") }) catalog.model.update("configured", "glm-5.2", (model) => { - model.api = { - id: "glm-5.2", - type: "aisdk", - package: "@ai-sdk/openai-compatible", - } + model.modelID = "glm-5.2" + model.package = ProviderV2.aisdk("@ai-sdk/openai-compatible") model.variants = [ { id: "high", diff --git a/packages/core/test/plugin/host.ts b/packages/core/test/plugin/host.ts index 1f37dab47b..2aa8789548 100644 --- a/packages/core/test/plugin/host.ts +++ b/packages/core/test/plugin/host.ts @@ -162,7 +162,7 @@ export function catalogHost(catalog: Catalog.Interface): PluginContext["catalog" id: ModelV2.ID.make(current.id), providerID: ProviderV2.ID.make(current.providerID), family: current.family === undefined ? undefined : ModelV2.Family.make(current.family), - variants: current.variants.map((variant) => ({ + variants: current.variants?.map((variant) => ({ ...variant, id: ModelV2.VariantID.make(variant.id), })), @@ -327,35 +327,28 @@ function agentInfo(value: AgentV2.Info) { function providerInfo(value: ProviderV2.MutableInfo) { return { ...value, - api: { ...value.api, settings: value.api.settings && { ...value.api.settings } }, - request: { - settings: { ...value.request.settings }, - headers: { ...value.request.headers }, - body: { ...value.request.body }, - }, + settings: value.settings && { ...value.settings }, + headers: value.headers && { ...value.headers }, + body: value.body && { ...value.body }, } } function modelInfo(value: ModelV2.Info | ModelV2.MutableInfo) { return { ...value, - api: { ...value.api, settings: value.api.settings && { ...value.api.settings } }, + settings: value.settings && { ...value.settings }, + headers: value.headers && { ...value.headers }, + body: value.body && { ...value.body }, capabilities: { ...value.capabilities, input: [...value.capabilities.input], output: [...value.capabilities.output], }, - request: { - ...value.request, - settings: { ...value.request.settings }, - headers: { ...value.request.headers }, - body: { ...value.request.body }, - }, - variants: value.variants.map((variant) => ({ + variants: value.variants?.map((variant) => ({ ...variant, - settings: { ...variant.settings }, - headers: { ...variant.headers }, - body: { ...variant.body }, + settings: variant.settings && { ...variant.settings }, + headers: variant.headers && { ...variant.headers }, + body: variant.body && { ...variant.body }, })), time: { ...value.time }, cost: value.cost.map((cost) => ({ ...cost, tier: cost.tier && { ...cost.tier }, cache: { ...cost.cache } })), diff --git a/packages/core/test/plugin/models-dev.test.ts b/packages/core/test/plugin/models-dev.test.ts index b3dfe275ba..545e09e0c3 100644 --- a/packages/core/test/plugin/models-dev.test.ts +++ b/packages/core/test/plugin/models-dev.test.ts @@ -94,16 +94,16 @@ describe("ModelsDevPlugin", () => { const fast = yield* catalog.model.get(providerID, ModelV2.ID.make("gpt-5.4-fast")) expect(base?.variants).toEqual([]) - expect(base?.request.body).toEqual({}) + expect(base?.body).toEqual({}) expect(fast).toMatchObject({ id: "gpt-5.4-fast", + modelID: "gpt-5.4", providerID: "acme", name: "GPT-5.4 Fast", - api: { id: "gpt-5.4" }, - request: { - headers: { "x-mode": "fast" }, - body: { service_tier: "priority" }, - }, + package: ProviderV2.aisdk("@ai-sdk/openai-compatible"), + settings: { baseURL: "https://api.acme.test/v1" }, + headers: { "x-mode": "fast" }, + body: { service_tier: "priority" }, variants: [], }) expect(fast?.cost).toEqual([ @@ -191,7 +191,7 @@ describe("ModelsDevPlugin", () => { ) const model = yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-reasoning")) - expect(model?.variants.map((variant) => variant.id)).toEqual([ + expect(model?.variants?.map((variant) => variant.id)).toEqual([ ModelV2.VariantID.make("low"), ModelV2.VariantID.make("high"), ]) @@ -202,8 +202,6 @@ describe("ModelsDevPlugin", () => { reasoningSummary: "auto", include: ["reasoning.encrypted_content"], }, - headers: {}, - body: {}, }) expect(model?.variants).toContainEqual({ id: ModelV2.VariantID.make("high"), @@ -212,20 +210,16 @@ describe("ModelsDevPlugin", () => { reasoningSummary: "auto", include: ["reasoning.encrypted_content"], }, - headers: {}, - body: {}, }) const mode = yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-reasoning-high")) expect(mode).toMatchObject({ id: "gpt-reasoning-high", name: "GPT Reasoning High", - request: { - headers: { "x-mode": "high" }, - body: { service_tier: "priority" }, - }, + headers: { "x-mode": "high" }, + body: { service_tier: "priority" }, }) - expect(mode?.variants.map((variant) => variant.id)).toEqual([ + expect(mode?.variants?.map((variant) => variant.id)).toEqual([ ModelV2.VariantID.make("low"), ModelV2.VariantID.make("high"), ]) @@ -234,22 +228,19 @@ describe("ModelsDevPlugin", () => { expect(budgetModel?.variants).toContainEqual({ id: ModelV2.VariantID.make("high"), settings: { thinking: { type: "enabled", budgetTokens: 16000 } }, - headers: {}, - body: {}, }) expect(budgetModel?.variants).toContainEqual({ id: ModelV2.VariantID.make("max"), settings: { thinking: { type: "enabled", budgetTokens: 64000 } }, - headers: {}, - body: {}, }) - const anthropicEffortModel = yield* catalog.model.get(ProviderV2.ID.anthropic, ModelV2.ID.make("claude-effort")) + const anthropicEffortModel = yield* catalog.model.get( + ProviderV2.ID.anthropic, + ModelV2.ID.make("claude-effort"), + ) expect(anthropicEffortModel?.variants).toContainEqual({ id: ModelV2.VariantID.make("low"), settings: { thinking: { type: "adaptive", display: "summarized" }, effort: "low" }, - headers: {}, - body: {}, }) }).pipe(Effect.provide(AppNodeBuilder.build(ModelsDev.node))), (previous) => diff --git a/packages/core/test/plugin/provider-alibaba.test.ts b/packages/core/test/plugin/provider-alibaba.test.ts index 25d46bf83c..bb7f922e52 100644 --- a/packages/core/test/plugin/provider-alibaba.test.ts +++ b/packages/core/test/plugin/provider-alibaba.test.ts @@ -28,7 +28,8 @@ describe("AlibabaPlugin", () => { const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("alibaba"), ModelV2.ID.make("qwen")), - api: { id: ModelV2.ID.make("qwen"), type: "aisdk", package: "test-provider" }, + modelID: ModelV2.ID.make("qwen"), + package: "aisdk:test-provider", }), package: "@ai-sdk/alibaba", options: { name: "alibaba" }, @@ -45,7 +46,8 @@ describe("AlibabaPlugin", () => { const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("alibaba"), ModelV2.ID.make("qwen")), - api: { id: ModelV2.ID.make("qwen"), type: "aisdk", package: "test-provider" }, + modelID: ModelV2.ID.make("qwen"), + package: "aisdk:test-provider", }), package: "@ai-sdk/openai-compatible", options: { name: "alibaba" }, @@ -62,7 +64,8 @@ describe("AlibabaPlugin", () => { const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("custom-alibaba"), ModelV2.ID.make("qwen")), - api: { id: ModelV2.ID.make("qwen"), type: "aisdk", package: "test-provider" }, + modelID: ModelV2.ID.make("qwen"), + package: "aisdk:test-provider", }), package: "@ai-sdk/alibaba", options: { name: "custom-alibaba", apiKey: "test" }, @@ -74,17 +77,18 @@ describe("AlibabaPlugin", () => { }), ) - it.effect("uses the old default languageModel(api.id) behavior", () => + it.effect("uses the default languageModel(modelID) behavior", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service const aisdk = yield* AISDK.Service yield* addPlugin() const item = ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("alibaba"), ModelV2.ID.make("alias")), - api: { id: ModelV2.ID.make("qwen-plus"), type: "aisdk", package: "test-provider" }, + modelID: ModelV2.ID.make("qwen-plus"), + package: "aisdk:test-provider", }) const result = yield* aisdk.runSDK({ model: item, package: "@ai-sdk/alibaba", options: {} }) - const language = result.sdk?.languageModel(item.api.id) + const language = result.sdk?.languageModel(item.modelID ?? item.id) expect(language?.modelId).toBe("qwen-plus") expect(language?.provider).toBe("alibaba.chat") }), diff --git a/packages/core/test/plugin/provider-amazon-bedrock.test.ts b/packages/core/test/plugin/provider-amazon-bedrock.test.ts index 31065c2e3d..db63910c33 100644 --- a/packages/core/test/plugin/provider-amazon-bedrock.test.ts +++ b/packages/core/test/plugin/provider-amazon-bedrock.test.ts @@ -79,31 +79,24 @@ function openAIUrl(language: unknown, path: string, modelId: string) { } describe("AmazonBedrockPlugin", () => { - it.effect("moves endpoint option to api URL", () => + it.effect("moves endpoint setting to baseURL", () => Effect.gen(function* () { const catalog = yield* Catalog.Service yield* catalog.transform((catalog) => { const bedrock = ProviderV2.Info.make({ ...ProviderV2.Info.empty(ProviderV2.ID.amazonBedrock), - api: { type: "aisdk", package: "@ai-sdk/amazon-bedrock" }, - request: { - headers: {}, - body: { endpoint: "https://bedrock.example" }, - }, + package: ProviderV2.aisdk("@ai-sdk/amazon-bedrock"), + settings: { endpoint: "https://bedrock.example" }, }) catalog.provider.update(bedrock.id, (item) => { - item.api = bedrock.api - item.request = { settings: {}, headers: {}, body: { endpoint: "https://bedrock.example" } } + item.package = bedrock.package + item.settings = { endpoint: "https://bedrock.example" } }) }) yield* addPlugin() const result = required(yield* catalog.provider.get(ProviderV2.ID.amazonBedrock)) - expect(result.api).toEqual({ - type: "aisdk", - package: "@ai-sdk/amazon-bedrock", - url: "https://bedrock.example", - }) - expect(result.request.body.endpoint).toBeUndefined() + expect(result.package).toBe(ProviderV2.aisdk("@ai-sdk/amazon-bedrock")) + expect(result.settings).toEqual({ baseURL: "https://bedrock.example" }) }), ) @@ -116,7 +109,8 @@ describe("AmazonBedrockPlugin", () => { const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), - api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + modelID: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), + package: ProviderV2.aisdk("test-provider"), }), package: "@ai-sdk/amazon-bedrock", options: { @@ -141,7 +135,8 @@ describe("AmazonBedrockPlugin", () => { const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), - api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + modelID: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), + package: ProviderV2.aisdk("test-provider"), }), package: "@ai-sdk/amazon-bedrock", options: { @@ -175,11 +170,8 @@ describe("AmazonBedrockPlugin", () => { const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), - api: { - id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), - type: "aisdk", - package: "test-provider", - }, + modelID: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), + package: ProviderV2.aisdk("test-provider"), }), package: "@ai-sdk/amazon-bedrock", options: { name: "amazon-bedrock" }, @@ -199,7 +191,8 @@ describe("AmazonBedrockPlugin", () => { const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), - api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + modelID: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), + package: ProviderV2.aisdk("test-provider"), }), package: "@ai-sdk/amazon-bedrock", options: { name: "amazon-bedrock", region: "eu-west-1" }, @@ -218,7 +211,8 @@ describe("AmazonBedrockPlugin", () => { const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), - api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + modelID: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), + package: ProviderV2.aisdk("test-provider"), }), package: "@ai-sdk/amazon-bedrock", options: { name: "amazon-bedrock" }, @@ -237,7 +231,8 @@ describe("AmazonBedrockPlugin", () => { const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), - api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + modelID: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), + package: ProviderV2.aisdk("test-provider"), }), package: "@ai-sdk/amazon-bedrock", options: { name: "amazon-bedrock" }, @@ -257,7 +252,8 @@ describe("AmazonBedrockPlugin", () => { const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), - api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + modelID: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), + package: ProviderV2.aisdk("test-provider"), }), package: "@ai-sdk/amazon-bedrock", options: { @@ -286,7 +282,8 @@ describe("AmazonBedrockPlugin", () => { const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), - api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + modelID: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), + package: ProviderV2.aisdk("test-provider"), }), package: "@ai-sdk/amazon-bedrock", options: { @@ -314,11 +311,8 @@ describe("AmazonBedrockPlugin", () => { const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("openai.gpt-5.5")), - api: { - id: ModelV2.ID.make("openai.gpt-5.5"), - type: "aisdk", - package: "@ai-sdk/amazon-bedrock/mantle", - }, + modelID: ModelV2.ID.make("openai.gpt-5.5"), + package: ProviderV2.aisdk("@ai-sdk/amazon-bedrock/mantle"), }), package: "@ai-sdk/amazon-bedrock/mantle", options: { @@ -345,11 +339,8 @@ describe("AmazonBedrockPlugin", () => { yield* aisdk.runLanguage({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("openai.gpt-5.5")), - api: { - id: ModelV2.ID.make("openai.gpt-5.5"), - type: "aisdk", - package: "@ai-sdk/amazon-bedrock/mantle", - }, + modelID: ModelV2.ID.make("openai.gpt-5.5"), + package: ProviderV2.aisdk("@ai-sdk/amazon-bedrock/mantle"), }), sdk: fakeSelectorSdk(calls), options: { baseURL: "https://bedrock-mantle.us-east-2.api.aws/openai/v1", region: "us-east-2" }, @@ -357,11 +348,8 @@ describe("AmazonBedrockPlugin", () => { yield* aisdk.runLanguage({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("openai.gpt-oss-safeguard-120b")), - api: { - id: ModelV2.ID.make("openai.gpt-oss-safeguard-120b"), - type: "aisdk", - package: "@ai-sdk/amazon-bedrock/mantle", - }, + modelID: ModelV2.ID.make("openai.gpt-oss-safeguard-120b"), + package: ProviderV2.aisdk("@ai-sdk/amazon-bedrock/mantle"), }), sdk: fakeSelectorSdk(calls), options: { region: "us-east-1" }, @@ -378,11 +366,8 @@ describe("AmazonBedrockPlugin", () => { const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), - api: { - id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), - type: "aisdk", - package: "@ai-sdk/amazon-bedrock/anthropic", - }, + modelID: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), + package: ProviderV2.aisdk("@ai-sdk/amazon-bedrock/anthropic"), }), package: "@ai-sdk/amazon-bedrock/anthropic", options: { name: "amazon-bedrock" }, @@ -409,11 +394,8 @@ describe("AmazonBedrockPlugin", () => { const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), - api: { - id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), - type: "aisdk", - package: "test-provider", - }, + modelID: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), + package: ProviderV2.aisdk("test-provider"), }), package: "@ai-sdk/amazon-bedrock", options: { @@ -444,7 +426,8 @@ describe("AmazonBedrockPlugin", () => { yield* aisdk.runLanguage({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), - api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + modelID: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), + package: ProviderV2.aisdk("test-provider"), }), sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, options: {}, @@ -452,7 +435,8 @@ describe("AmazonBedrockPlugin", () => { yield* aisdk.runLanguage({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), - api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + modelID: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), + package: ProviderV2.aisdk("test-provider"), }), sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, options: { region: "eu-west-1" }, @@ -460,11 +444,8 @@ describe("AmazonBedrockPlugin", () => { yield* aisdk.runLanguage({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("global.anthropic.claude-sonnet-4-5")), - api: { - id: ModelV2.ID.make("global.anthropic.claude-sonnet-4-5"), - type: "aisdk", - package: "test-provider", - }, + modelID: ModelV2.ID.make("global.anthropic.claude-sonnet-4-5"), + package: ProviderV2.aisdk("test-provider"), }), sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, options: { region: "eu-west-1" }, @@ -472,7 +453,8 @@ describe("AmazonBedrockPlugin", () => { yield* aisdk.runLanguage({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), - api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + modelID: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), + package: ProviderV2.aisdk("test-provider"), }), sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, options: { region: "ap-northeast-1" }, @@ -480,7 +462,8 @@ describe("AmazonBedrockPlugin", () => { yield* aisdk.runLanguage({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), - api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + modelID: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), + package: ProviderV2.aisdk("test-provider"), }), sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, options: { region: "ap-southeast-2" }, @@ -505,7 +488,8 @@ describe("AmazonBedrockPlugin", () => { yield* aisdk.runLanguage({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), - api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + modelID: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), + package: ProviderV2.aisdk("test-provider"), }), sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, options: {}, @@ -591,7 +575,8 @@ describe("AmazonBedrockPlugin", () => { yield* aisdk.runLanguage({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make(item.modelID)), - api: { id: ModelV2.ID.make(item.modelID), type: "aisdk", package: "test-provider" }, + modelID: ModelV2.ID.make(item.modelID), + package: ProviderV2.aisdk("test-provider"), }), sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, options: { region: item.region }, @@ -610,7 +595,8 @@ describe("AmazonBedrockPlugin", () => { const result = yield* aisdk.runLanguage({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.openai, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), - api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + modelID: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), + package: ProviderV2.aisdk("test-provider"), }), sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, options: { region: "eu-west-1" }, diff --git a/packages/core/test/plugin/provider-anthropic.test.ts b/packages/core/test/plugin/provider-anthropic.test.ts index df05a07f75..af7b80dc67 100644 --- a/packages/core/test/plugin/provider-anthropic.test.ts +++ b/packages/core/test/plugin/provider-anthropic.test.ts @@ -31,19 +31,19 @@ describe("AnthropicPlugin", () => { yield* catalog.transform((catalog) => { const item = ProviderV2.Info.make({ ...ProviderV2.Info.empty(ProviderV2.ID.anthropic), - api: { type: "aisdk", package: "@ai-sdk/anthropic" }, - request: { headers: { Existing: "1" }, body: {} }, + package: ProviderV2.aisdk("@ai-sdk/anthropic"), + headers: { Existing: "1" }, }) catalog.provider.update(item.id, (draft) => { - draft.api = item.api - draft.request = { settings: {}, headers: { Existing: "1" }, body: {} } + draft.package = item.package + draft.headers = { Existing: "1" } }) }) yield* addPlugin() - expect(required(yield* catalog.provider.get(ProviderV2.ID.anthropic)).request.headers["anthropic-beta"]).toBe( + expect(required(yield* catalog.provider.get(ProviderV2.ID.anthropic)).headers?.["anthropic-beta"]).toBe( "interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14", ) - expect(required(yield* catalog.provider.get(ProviderV2.ID.anthropic)).request.headers.Existing).toBe("1") + expect(required(yield* catalog.provider.get(ProviderV2.ID.anthropic)).headers?.Existing).toBe("1") }), ) @@ -52,9 +52,7 @@ describe("AnthropicPlugin", () => { const catalog = yield* Catalog.Service yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.openai, () => {})) yield* addPlugin() - expect( - required(yield* catalog.provider.get(ProviderV2.ID.openai)).request.headers["anthropic-beta"], - ).toBeUndefined() + expect(required(yield* catalog.provider.get(ProviderV2.ID.openai)).headers?.["anthropic-beta"]).toBeUndefined() }), ) @@ -66,7 +64,8 @@ describe("AnthropicPlugin", () => { const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("custom-anthropic"), ModelV2.ID.make("claude-sonnet-4-5")), - api: { id: ModelV2.ID.make("claude-sonnet-4-5"), type: "aisdk", package: "@ai-sdk/anthropic" }, + modelID: ModelV2.ID.make("claude-sonnet-4-5"), + package: ProviderV2.aisdk("@ai-sdk/anthropic"), }), package: "@ai-sdk/anthropic", options: { name: "custom-anthropic", apiKey: "test" }, @@ -83,7 +82,8 @@ describe("AnthropicPlugin", () => { const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.anthropic, ModelV2.ID.make("claude-sonnet-4-5")), - api: { id: ModelV2.ID.make("claude-sonnet-4-5"), type: "aisdk", package: "@ai-sdk/anthropic" }, + modelID: ModelV2.ID.make("claude-sonnet-4-5"), + package: ProviderV2.aisdk("@ai-sdk/anthropic"), }), package: "@ai-sdk/anthropic", options: { name: "anthropic", apiKey: "test" }, diff --git a/packages/core/test/plugin/provider-azure-cognitive-services.test.ts b/packages/core/test/plugin/provider-azure-cognitive-services.test.ts index 6b3f6c6a38..f52f013105 100644 --- a/packages/core/test/plugin/provider-azure-cognitive-services.test.ts +++ b/packages/core/test/plugin/provider-azure-cognitive-services.test.ts @@ -66,18 +66,16 @@ describe("AzureCognitiveServicesPlugin", () => { const catalog = yield* Catalog.Service yield* catalog.transform((catalog) => { catalog.provider.update(ProviderV2.ID.make("azure-cognitive-services"), (item) => { - item.api = { type: "aisdk", package: "@ai-sdk/openai-compatible" } + item.package = ProviderV2.aisdk("@ai-sdk/openai-compatible") }) }) yield* addPlugin() const result = required(yield* catalog.provider.get(ProviderV2.ID.make("azure-cognitive-services"))) - expect(result.api).toEqual({ - type: "aisdk", - package: "@ai-sdk/openai-compatible", - url: "https://cognitive.cognitiveservices.azure.com/openai", + expect(result).toMatchObject({ + package: "aisdk:@ai-sdk/openai-compatible", + settings: { baseURL: "https://cognitive.cognitiveservices.azure.com/openai" }, }) - expect(result.request.body.baseURL).toBeUndefined() - expect(result.request.body.resourceName).toBeUndefined() + expect(result.settings?.resourceName).toBeUndefined() }), ), ) @@ -89,26 +87,28 @@ describe("AzureCognitiveServicesPlugin", () => { yield* catalog.transform((catalog) => { const azure = ProviderV2.Info.make({ ...ProviderV2.Info.empty(ProviderV2.ID.make("azure-cognitive-services")), - api: { type: "aisdk", package: "@ai-sdk/openai-compatible" }, + package: "aisdk:@ai-sdk/openai-compatible", }) const openai = ProviderV2.Info.make({ ...ProviderV2.Info.empty(ProviderV2.ID.openai), - api: { type: "aisdk", package: "test-provider" }, + package: "aisdk:test-provider", }) catalog.provider.update(azure.id, (item) => { - item.api = azure.api + item.package = azure.package + item.package = azure.package }) catalog.provider.update(openai.id, (item) => { - item.api = openai.api + item.package = openai.package + item.package = openai.package }) }) yield* addPlugin() const azure = required(yield* catalog.provider.get(ProviderV2.ID.make("azure-cognitive-services"))) const openai = required(yield* catalog.provider.get(ProviderV2.ID.openai)) - expect(azure.request.body.baseURL).toBeUndefined() - expect(azure.api).toEqual({ type: "aisdk", package: "@ai-sdk/openai-compatible" }) - expect(openai.request.body.baseURL).toBeUndefined() - expect(openai.api).toEqual({ type: "aisdk", package: "test-provider" }) + expect(azure.settings?.baseURL).toBeUndefined() + expect(azure).toMatchObject({ package: "aisdk:@ai-sdk/openai-compatible" }) + expect(openai.settings?.baseURL).toBeUndefined() + expect(openai).toMatchObject({ package: "aisdk:test-provider" }) }), ), ) @@ -122,7 +122,8 @@ describe("AzureCognitiveServicesPlugin", () => { yield* aisdk.runLanguage({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("azure-cognitive-services"), ModelV2.ID.make("deployment")), - api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" }, + modelID: ModelV2.ID.make("deployment"), + package: "aisdk:test-provider", }), sdk: fakeSelectorSdk(calls), options: { useCompletionUrls: true }, @@ -140,7 +141,8 @@ describe("AzureCognitiveServicesPlugin", () => { yield* aisdk.runLanguage({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("azure-cognitive-services"), ModelV2.ID.make("deployment")), - api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" }, + modelID: ModelV2.ID.make("deployment"), + package: "aisdk:test-provider", }), sdk: fakeSelectorSdk(calls), options: {}, @@ -148,7 +150,8 @@ describe("AzureCognitiveServicesPlugin", () => { const ignored = yield* aisdk.runLanguage({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.openai, ModelV2.ID.make("deployment")), - api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" }, + modelID: ModelV2.ID.make("deployment"), + package: "aisdk:test-provider", }), sdk: fakeSelectorSdk(calls), options: {}, @@ -168,7 +171,8 @@ describe("AzureCognitiveServicesPlugin", () => { yield* aisdk.runLanguage({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("azure-cognitive-services"), ModelV2.ID.make("messages-deployment")), - api: { id: ModelV2.ID.make("messages-deployment"), type: "aisdk", package: "test-provider" }, + modelID: ModelV2.ID.make("messages-deployment"), + package: "aisdk:test-provider", }), sdk: { messages: sdk.messages, chat: sdk.chat, languageModel: sdk.languageModel }, options: {}, @@ -176,7 +180,8 @@ describe("AzureCognitiveServicesPlugin", () => { yield* aisdk.runLanguage({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("azure-cognitive-services"), ModelV2.ID.make("chat-deployment")), - api: { id: ModelV2.ID.make("chat-deployment"), type: "aisdk", package: "test-provider" }, + modelID: ModelV2.ID.make("chat-deployment"), + package: "aisdk:test-provider", }), sdk: { chat: sdk.chat, languageModel: sdk.languageModel }, options: {}, @@ -184,7 +189,8 @@ describe("AzureCognitiveServicesPlugin", () => { yield* aisdk.runLanguage({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("azure-cognitive-services"), ModelV2.ID.make("language-deployment")), - api: { id: ModelV2.ID.make("language-deployment"), type: "aisdk", package: "test-provider" }, + modelID: ModelV2.ID.make("language-deployment"), + package: "aisdk:test-provider", }), sdk: { languageModel: sdk.languageModel }, options: {}, diff --git a/packages/core/test/plugin/provider-azure.test.ts b/packages/core/test/plugin/provider-azure.test.ts index 42af292e15..6cf515c0c6 100644 --- a/packages/core/test/plugin/provider-azure.test.ts +++ b/packages/core/test/plugin/provider-azure.test.ts @@ -66,11 +66,11 @@ describe("AzurePlugin", () => { const catalog = yield* Catalog.Service yield* catalog.transform((catalog) => { catalog.provider.update(ProviderV2.ID.azure, (item) => { - item.api = { type: "aisdk", package: "@ai-sdk/azure" } + item.package = ProviderV2.aisdk("@ai-sdk/azure") }) }) yield* addPlugin() - expect(required(yield* catalog.provider.get(ProviderV2.ID.azure)).request.body.resourceName).toBe("from-env") + expect(required(yield* catalog.provider.get(ProviderV2.ID.azure)).settings?.resourceName).toBe("from-env") }), ), ) @@ -82,18 +82,18 @@ describe("AzurePlugin", () => { yield* catalog.transform((catalog) => { const azure = ProviderV2.Info.make({ ...ProviderV2.Info.empty(ProviderV2.ID.azure), - api: { type: "aisdk", package: "@ai-sdk/azure" }, - request: { headers: {}, body: { resourceName: "from-config" } }, + package: ProviderV2.aisdk("@ai-sdk/azure"), + settings: { resourceName: "from-config" }, }) catalog.provider.update(azure.id, (item) => { - item.api = azure.api - item.request = { settings: {}, headers: {}, body: { resourceName: "from-config" } } + item.package = azure.package + item.settings = { resourceName: "from-config" } }) catalog.provider.update(ProviderV2.ID.openai, () => {}) }) yield* addPlugin() - expect(required(yield* catalog.provider.get(ProviderV2.ID.azure)).request.body.resourceName).toBe("from-config") - expect(required(yield* catalog.provider.get(ProviderV2.ID.openai)).request.body.resourceName).toBeUndefined() + expect(required(yield* catalog.provider.get(ProviderV2.ID.azure)).settings?.resourceName).toBe("from-config") + expect(required(yield* catalog.provider.get(ProviderV2.ID.openai)).settings?.resourceName).toBeUndefined() }), ), ) @@ -105,16 +105,16 @@ describe("AzurePlugin", () => { yield* catalog.transform((catalog) => { const azure = ProviderV2.Info.make({ ...ProviderV2.Info.empty(ProviderV2.ID.azure), - api: { type: "aisdk", package: "@ai-sdk/azure" }, - request: { headers: {}, body: { resourceName: "" } }, + package: ProviderV2.aisdk("@ai-sdk/azure"), + settings: { resourceName: "" }, }) catalog.provider.update(azure.id, (item) => { - item.api = azure.api - item.request = { settings: {}, headers: {}, body: { resourceName: "" } } + item.package = azure.package + item.settings = { resourceName: "" } }) }) yield* addPlugin() - expect(required(yield* catalog.provider.get(ProviderV2.ID.azure)).request.body.resourceName).toBe("from-env") + expect(required(yield* catalog.provider.get(ProviderV2.ID.azure)).settings?.resourceName).toBe("from-env") }), ), ) @@ -126,16 +126,16 @@ describe("AzurePlugin", () => { yield* catalog.transform((catalog) => { const azure = ProviderV2.Info.make({ ...ProviderV2.Info.empty(ProviderV2.ID.azure), - api: { type: "aisdk", package: "@ai-sdk/azure" }, - request: { headers: {}, body: { resourceName: " " } }, + package: ProviderV2.aisdk("@ai-sdk/azure"), + settings: { resourceName: " " }, }) catalog.provider.update(azure.id, (item) => { - item.api = azure.api - item.request = { settings: {}, headers: {}, body: { resourceName: " " } } + item.package = azure.package + item.settings = { resourceName: " " } }) }) yield* addPlugin() - expect(required(yield* catalog.provider.get(ProviderV2.ID.azure)).request.body.resourceName).toBe("from-env") + expect(required(yield* catalog.provider.get(ProviderV2.ID.azure)).settings?.resourceName).toBe("from-env") }), ), ) @@ -149,7 +149,8 @@ describe("AzurePlugin", () => { const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("deployment")), - api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" }, + modelID: ModelV2.ID.make("deployment"), + package: ProviderV2.aisdk("test-provider"), }), package: "@ai-sdk/azure", options: { name: "azure", baseURL: "https://proxy.example.com/openai" }, @@ -168,7 +169,8 @@ describe("AzurePlugin", () => { .runSDK({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("deployment")), - api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" }, + modelID: ModelV2.ID.make("deployment"), + package: ProviderV2.aisdk("test-provider"), }), package: "@ai-sdk/azure", options: { name: "azure" }, @@ -188,7 +190,8 @@ describe("AzurePlugin", () => { yield* aisdk.runLanguage({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("deployment")), - api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" }, + modelID: ModelV2.ID.make("deployment"), + package: ProviderV2.aisdk("test-provider"), }), sdk: fakeSelectorSdk(calls), options: { useCompletionUrls: true }, @@ -206,7 +209,8 @@ describe("AzurePlugin", () => { yield* aisdk.runLanguage({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("deployment")), - api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" }, + modelID: ModelV2.ID.make("deployment"), + package: ProviderV2.aisdk("test-provider"), }), sdk: fakeSelectorSdk(calls), options: { useCompletionUrls: true }, @@ -224,8 +228,9 @@ describe("AzurePlugin", () => { yield* aisdk.runLanguage({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("deployment")), - api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" }, - request: { headers: {}, body: { useCompletionUrls: true } }, + modelID: ModelV2.ID.make("deployment"), + package: ProviderV2.aisdk("test-provider"), + body: { useCompletionUrls: true }, }), sdk: fakeSelectorSdk(calls), options: {}, @@ -243,7 +248,8 @@ describe("AzurePlugin", () => { yield* aisdk.runLanguage({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("deployment")), - api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" }, + modelID: ModelV2.ID.make("deployment"), + package: ProviderV2.aisdk("test-provider"), }), sdk: fakeSelectorSdk(calls), options: {}, @@ -251,7 +257,8 @@ describe("AzurePlugin", () => { const ignored = yield* aisdk.runLanguage({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.openai, ModelV2.ID.make("deployment")), - api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" }, + modelID: ModelV2.ID.make("deployment"), + package: ProviderV2.aisdk("test-provider"), }), sdk: fakeSelectorSdk(calls), options: {}, @@ -274,7 +281,8 @@ describe("AzurePlugin", () => { yield* aisdk.runLanguage({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("messages-deployment")), - api: { id: ModelV2.ID.make("messages-deployment"), type: "aisdk", package: "test-provider" }, + modelID: ModelV2.ID.make("messages-deployment"), + package: ProviderV2.aisdk("test-provider"), }), sdk: { messages: make("messages"), chat: make("chat"), languageModel: make("languageModel") }, options: {}, @@ -282,7 +290,8 @@ describe("AzurePlugin", () => { yield* aisdk.runLanguage({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("language-deployment")), - api: { id: ModelV2.ID.make("language-deployment"), type: "aisdk", package: "test-provider" }, + modelID: ModelV2.ID.make("language-deployment"), + package: ProviderV2.aisdk("test-provider"), }), sdk: { languageModel: make("languageModel") }, options: {}, diff --git a/packages/core/test/plugin/provider-cerebras.test.ts b/packages/core/test/plugin/provider-cerebras.test.ts index adcc1779b0..eb5c4ec1bf 100644 --- a/packages/core/test/plugin/provider-cerebras.test.ts +++ b/packages/core/test/plugin/provider-cerebras.test.ts @@ -36,12 +36,12 @@ describe("CerebrasPlugin", () => { const catalog = yield* Catalog.Service yield* catalog.transform((catalog) => { catalog.provider.update(ProviderV2.ID.make("cerebras"), (item) => { - item.api = { type: "aisdk", package: "@ai-sdk/cerebras" } - item.request.headers.Existing = "1" + item.package = ProviderV2.aisdk("@ai-sdk/cerebras") + item.headers = { ...item.headers, Existing: "1" } }) }) yield* addPlugin() - expect((yield* catalog.provider.get(ProviderV2.ID.make("cerebras")))?.request.headers).toEqual({ + expect((yield* catalog.provider.get(ProviderV2.ID.make("cerebras")))?.headers).toEqual({ Existing: "1", "X-Cerebras-3rd-Party-Integration": "opencode", }) @@ -53,7 +53,7 @@ describe("CerebrasPlugin", () => { const catalog = yield* Catalog.Service yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.make("groq"), () => {})) yield* addPlugin() - expect((yield* catalog.provider.get(ProviderV2.ID.make("groq")))?.request.headers).toEqual({}) + expect((yield* catalog.provider.get(ProviderV2.ID.make("groq")))?.headers).toBeUndefined() }), ) @@ -69,11 +69,8 @@ describe("CerebrasPlugin", () => { ProviderV2.ID.make("custom-cerebras"), ModelV2.ID.make("llama-4-scout-17b-16e-instruct"), ), - api: { - id: ModelV2.ID.make("llama-4-scout-17b-16e-instruct"), - type: "aisdk", - package: "test-provider", - }, + modelID: ModelV2.ID.make("llama-4-scout-17b-16e-instruct"), + package: "aisdk:test-provider", }), package: "@ai-sdk/cerebras", options: { name: "custom-cerebras", apiKey: "test" }, @@ -95,11 +92,8 @@ describe("CerebrasPlugin", () => { ProviderV2.ID.make("custom-cerebras"), ModelV2.ID.make("llama-4-scout-17b-16e-instruct"), ), - api: { - id: ModelV2.ID.make("llama-4-scout-17b-16e-instruct"), - type: "aisdk", - package: "test-provider", - }, + modelID: ModelV2.ID.make("llama-4-scout-17b-16e-instruct"), + package: "aisdk:test-provider", }), package: "@ai-sdk/cerebras", options: { name: "configured-cerebras", apiKey: "test" }, @@ -120,11 +114,8 @@ describe("CerebrasPlugin", () => { ProviderV2.ID.make("custom-cerebras"), ModelV2.ID.make("llama-4-scout-17b-16e-instruct"), ), - api: { - id: ModelV2.ID.make("llama-4-scout-17b-16e-instruct"), - type: "aisdk", - package: "test-provider", - }, + modelID: ModelV2.ID.make("llama-4-scout-17b-16e-instruct"), + package: "aisdk:test-provider", }), package: "@ai-sdk/groq", options: { name: "custom-cerebras", apiKey: "test" }, diff --git a/packages/core/test/plugin/provider-cloudflare-ai-gateway.test.ts b/packages/core/test/plugin/provider-cloudflare-ai-gateway.test.ts index 008b9a46a3..cbd2b95c9e 100644 --- a/packages/core/test/plugin/provider-cloudflare-ai-gateway.test.ts +++ b/packages/core/test/plugin/provider-cloudflare-ai-gateway.test.ts @@ -118,7 +118,8 @@ describe("CloudflareAIGatewayPlugin", () => { const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), - api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" }, + modelID: ModelV2.ID.make("openai/gpt-5"), + package: "aisdk:test-provider", }), package: "ai-gateway-provider", options: { name: "cloudflare-ai-gateway" }, @@ -139,7 +140,8 @@ describe("CloudflareAIGatewayPlugin", () => { yield* aisdk.runSDK({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), - api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" }, + modelID: ModelV2.ID.make("openai/gpt-5"), + package: "aisdk:test-provider", }), package: "ai-gateway-provider", options: { @@ -183,7 +185,8 @@ describe("CloudflareAIGatewayPlugin", () => { yield* aisdk.runSDK({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), - api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" }, + modelID: ModelV2.ID.make("openai/gpt-5"), + package: "aisdk:test-provider", }), package: "ai-gateway-provider", options: { @@ -212,7 +215,8 @@ describe("CloudflareAIGatewayPlugin", () => { yield* aisdk.runSDK({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), - api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" }, + modelID: ModelV2.ID.make("openai/gpt-5"), + package: "aisdk:test-provider", }), package: "ai-gateway-provider", options: { @@ -249,7 +253,8 @@ describe("CloudflareAIGatewayPlugin", () => { yield* aisdk.runSDK({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), - api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" }, + modelID: ModelV2.ID.make("openai/gpt-5"), + package: "aisdk:test-provider", }), package: "ai-gateway-provider", options: { @@ -280,7 +285,8 @@ describe("CloudflareAIGatewayPlugin", () => { yield* aisdk.runSDK({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), - api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" }, + modelID: ModelV2.ID.make("openai/gpt-5"), + package: "aisdk:test-provider", }), package: "ai-gateway-provider", options: { name: "cloudflare-ai-gateway" }, @@ -302,7 +308,8 @@ describe("CloudflareAIGatewayPlugin", () => { const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), - api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" }, + modelID: ModelV2.ID.make("openai/gpt-5"), + package: "aisdk:test-provider", }), package: "ai-gateway-provider", options: { name: "cloudflare-ai-gateway" }, @@ -325,7 +332,8 @@ describe("CloudflareAIGatewayPlugin", () => { const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), - api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" }, + modelID: ModelV2.ID.make("openai/gpt-5"), + package: "aisdk:test-provider", }), package: "ai-gateway-provider", options: { name: "cloudflare-ai-gateway" }, @@ -354,7 +362,8 @@ describe("CloudflareAIGatewayPlugin", () => { const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), - api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" }, + modelID: ModelV2.ID.make("openai/gpt-5"), + package: "aisdk:test-provider", }), package: "ai-gateway-provider", options: { name: "cloudflare-ai-gateway", baseURL: "https://proxy.example/v1" }, @@ -380,11 +389,8 @@ describe("CloudflareAIGatewayPlugin", () => { ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("anthropic/claude-sonnet-4-5"), ), - api: { - id: ModelV2.ID.make("anthropic/claude-sonnet-4-5"), - type: "aisdk", - package: "test-provider", - }, + modelID: ModelV2.ID.make("anthropic/claude-sonnet-4-5"), + package: "aisdk:test-provider", }), package: "ai-gateway-provider", options: { name: "cloudflare-ai-gateway" }, @@ -412,7 +418,8 @@ describe("CloudflareAIGatewayPlugin", () => { const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), - api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" }, + modelID: ModelV2.ID.make("openai/gpt-5"), + package: "aisdk:test-provider", }), package: "@ai-sdk/openai-compatible", options: { name: "cloudflare-ai-gateway" }, diff --git a/packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts b/packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts index d1eeaa581e..d67b8d91cc 100644 --- a/packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts +++ b/packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts @@ -87,7 +87,7 @@ describe("CloudflareWorkersAIPlugin", () => { const catalog = yield* Catalog.Service yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.make("cloudflare-workers-ai"), (provider) => { - provider.api = { type: "aisdk", package: "test-provider" } + provider.package = ProviderV2.aisdk("test-provider") }), ) yield* addPlugin() @@ -95,15 +95,16 @@ describe("CloudflareWorkersAIPlugin", () => { const sdk = yield* aisdk.runSDK({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("@cf/model")), - api: { id: ModelV2.ID.make("@cf/model"), ...provider.api }, + modelID: ModelV2.ID.make("@cf/model"), + package: provider.package, + settings: provider.settings, }), package: "@ai-sdk/openai-compatible", options: { name: "cloudflare-workers-ai", headers: { custom: "header" } }, }) - expect(provider.api).toEqual({ - type: "aisdk", - package: "test-provider", - url: "https://api.cloudflare.com/client/v4/accounts/acct/ai/v1", + expect(provider).toMatchObject({ + package: "aisdk:test-provider", + settings: { baseURL: "https://api.cloudflare.com/client/v4/accounts/acct/ai/v1" }, }) expect(sdk.sdk).toBeDefined() }), @@ -116,14 +117,14 @@ describe("CloudflareWorkersAIPlugin", () => { const catalog = yield* Catalog.Service yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.make("cloudflare-workers-ai"), (provider) => { - provider.api = { type: "aisdk", package: "test-provider", url: "https://proxy.example/v1" } + provider.package = ProviderV2.aisdk("test-provider") + provider.settings = { ...provider.settings, baseURL: "https://proxy.example/v1" } }), ) yield* addPlugin() - expect(required(yield* catalog.provider.get(ProviderV2.ID.make("cloudflare-workers-ai"))).api).toEqual({ - type: "aisdk", - package: "test-provider", - url: "https://proxy.example/v1", + expect(required(yield* catalog.provider.get(ProviderV2.ID.make("cloudflare-workers-ai")))).toMatchObject({ + package: "aisdk:test-provider", + settings: { baseURL: "https://proxy.example/v1" }, }) }), ), @@ -138,12 +139,9 @@ describe("CloudflareWorkersAIPlugin", () => { const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("@cf/model")), - api: { - id: ModelV2.ID.make("@cf/model"), - type: "aisdk", - package: "@ai-sdk/openai-compatible", - url: "https://proxy.example/v1", - }, + modelID: ModelV2.ID.make("@cf/model"), + package: "aisdk:@ai-sdk/openai-compatible", + settings: { baseURL: "https://proxy.example/v1" }, }), package: "@ai-sdk/openai-compatible", options: { name: "cloudflare-workers-ai", baseURL: "https://proxy.example/v1" }, @@ -159,15 +157,14 @@ describe("CloudflareWorkersAIPlugin", () => { const catalog = yield* Catalog.Service yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.make("cloudflare-workers-ai"), (provider) => { - provider.api = { type: "aisdk", package: "test-provider" } - provider.request.body.accountId = "configured-acct" + provider.package = ProviderV2.aisdk("test-provider") + provider.settings = { ...provider.settings, accountId: "configured-acct" } }), ) yield* addPlugin() - expect(required(yield* catalog.provider.get(ProviderV2.ID.make("cloudflare-workers-ai"))).api).toEqual({ - type: "aisdk", - package: "test-provider", - url: "https://api.cloudflare.com/client/v4/accounts/env-acct/ai/v1", + expect(required(yield* catalog.provider.get(ProviderV2.ID.make("cloudflare-workers-ai")))).toMatchObject({ + package: "aisdk:test-provider", + settings: { baseURL: "https://api.cloudflare.com/client/v4/accounts/env-acct/ai/v1" }, }) }), ), @@ -182,12 +179,9 @@ describe("CloudflareWorkersAIPlugin", () => { const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("@cf/model")), - api: { - id: ModelV2.ID.make("@cf/model"), - type: "aisdk", - package: "@ai-sdk/openai-compatible", - url: "https://proxy.example/v1", - }, + modelID: ModelV2.ID.make("@cf/model"), + package: "aisdk:@ai-sdk/openai-compatible", + settings: { baseURL: "https://proxy.example/v1" }, }), package: "@ai-sdk/openai-compatible", options: { @@ -214,12 +208,9 @@ describe("CloudflareWorkersAIPlugin", () => { const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("@cf/model")), - api: { - id: ModelV2.ID.make("@cf/model"), - type: "aisdk", - package: "@ai-sdk/openai-compatible", - url: "https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/ai/v1", - }, + modelID: ModelV2.ID.make("@cf/model"), + package: "aisdk:@ai-sdk/openai-compatible", + settings: { baseURL: "https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/ai/v1" }, }), package: "@ai-sdk/openai-compatible", options: { @@ -243,7 +234,8 @@ describe("CloudflareWorkersAIPlugin", () => { const result = yield* aisdk.runLanguage({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("alias")), - api: { id: ModelV2.ID.make("@cf/api-model"), type: "aisdk", package: "test-provider" }, + modelID: ModelV2.ID.make("@cf/api-model"), + package: "aisdk:test-provider", }), sdk: fakeSelectorSdk(calls), options: {}, @@ -262,12 +254,9 @@ describe("CloudflareWorkersAIPlugin", () => { const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("@cf/model")), - api: { - id: ModelV2.ID.make("@cf/model"), - type: "aisdk", - package: "@ai-sdk/anthropic", - url: "https://proxy.example/v1", - }, + modelID: ModelV2.ID.make("@cf/model"), + package: "aisdk:@ai-sdk/anthropic", + settings: { baseURL: "https://proxy.example/v1" }, }), package: "@ai-sdk/anthropic", options: { name: "cloudflare-workers-ai" }, diff --git a/packages/core/test/plugin/provider-cohere.test.ts b/packages/core/test/plugin/provider-cohere.test.ts index c001ad935f..a4109f74bd 100644 --- a/packages/core/test/plugin/provider-cohere.test.ts +++ b/packages/core/test/plugin/provider-cohere.test.ts @@ -56,7 +56,8 @@ describe("CoherePlugin", () => { const ignored = yield* aisdk.runSDK({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("cohere"), ModelV2.ID.make("command")), - api: { id: ModelV2.ID.make("command"), type: "aisdk", package: "test-provider" }, + modelID: ModelV2.ID.make("command"), + package: "aisdk:test-provider", }), package: "@ai-sdk/openai-compatible", options: { name: "cohere" }, @@ -66,7 +67,8 @@ describe("CoherePlugin", () => { const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("cohere"), ModelV2.ID.make("command")), - api: { id: ModelV2.ID.make("command"), type: "aisdk", package: "test-provider" }, + modelID: ModelV2.ID.make("command"), + package: "aisdk:test-provider", }), package: "@ai-sdk/cohere", options: { name: "cohere" }, @@ -83,7 +85,8 @@ describe("CoherePlugin", () => { const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("custom-cohere"), ModelV2.ID.make("command-r-plus")), - api: { id: ModelV2.ID.make("command-r-plus"), type: "aisdk", package: "test-provider" }, + modelID: ModelV2.ID.make("command-r-plus"), + package: "aisdk:test-provider", }), package: "@ai-sdk/cohere", options: { name: "custom-cohere", apiKey: "test", baseURL: "https://cohere.example" }, @@ -108,7 +111,8 @@ describe("CoherePlugin", () => { const result = yield* aisdk.runLanguage({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("cohere"), ModelV2.ID.make("alias")), - api: { id: ModelV2.ID.make("command-r-plus"), type: "aisdk", package: "test-provider" }, + modelID: ModelV2.ID.make("command-r-plus"), + package: "aisdk:test-provider", }), sdk, options: {}, diff --git a/packages/core/test/plugin/provider-deepinfra.test.ts b/packages/core/test/plugin/provider-deepinfra.test.ts index 9cb9a4866c..14c41e550d 100644 --- a/packages/core/test/plugin/provider-deepinfra.test.ts +++ b/packages/core/test/plugin/provider-deepinfra.test.ts @@ -48,7 +48,8 @@ describe("DeepInfraPlugin", () => { const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("deepinfra"), ModelV2.ID.make("model")), - api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "@ai-sdk/deepinfra" }, + modelID: ModelV2.ID.make("model"), + package: "aisdk:@ai-sdk/deepinfra", }), package: "@ai-sdk/deepinfra", options: { name: "deepinfra" }, @@ -66,7 +67,8 @@ describe("DeepInfraPlugin", () => { const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("custom-deepinfra"), ModelV2.ID.make("model")), - api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "@ai-sdk/deepinfra" }, + modelID: ModelV2.ID.make("model"), + package: "aisdk:@ai-sdk/deepinfra", }), package: "@ai-sdk/deepinfra", options: { name: "custom-deepinfra", apiKey: "test" }, @@ -85,7 +87,8 @@ describe("DeepInfraPlugin", () => { const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("deepinfra"), ModelV2.ID.make("model")), - api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "@ai-sdk/deepinfra" }, + modelID: ModelV2.ID.make("model"), + package: "aisdk:@ai-sdk/deepinfra", }), package: "@ai-sdk/deepinfra", options: { name: "deepinfra", apiKey: "test" }, @@ -111,7 +114,8 @@ describe("DeepInfraPlugin", () => { const ignored = yield* aisdk.runSDK({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("deepinfra"), ModelV2.ID.make("model")), - api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "@ai-sdk/deepinfra" }, + modelID: ModelV2.ID.make("model"), + package: "aisdk:@ai-sdk/deepinfra", }), package: item, options: { name: "deepinfra" }, @@ -122,7 +126,8 @@ describe("DeepInfraPlugin", () => { const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("deepinfra"), ModelV2.ID.make("model")), - api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "@ai-sdk/deepinfra" }, + modelID: ModelV2.ID.make("model"), + package: "aisdk:@ai-sdk/deepinfra", }), package: "@ai-sdk/deepinfra", options: { name: "deepinfra" }, @@ -141,17 +146,14 @@ describe("DeepInfraPlugin", () => { const sdkEvent = yield* aisdk.runSDK({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("deepinfra"), ModelV2.ID.make("meta-llama/Llama-3.3-70B-Instruct")), - api: { - id: ModelV2.ID.make("meta-llama/Llama-3.3-70B-Instruct"), - type: "aisdk", - package: "@ai-sdk/deepinfra", - }, + modelID: ModelV2.ID.make("meta-llama/Llama-3.3-70B-Instruct"), + package: "aisdk:@ai-sdk/deepinfra", }), package: "@ai-sdk/deepinfra", options: { name: "deepinfra" }, }) const result = yield* aisdk.runLanguage({ model: sdkEvent.model, sdk: sdkEvent.sdk, options: sdkEvent.options }) - const language = result.language ?? result.sdk.languageModel(result.model.api.id) + const language = result.language ?? result.sdk.languageModel(result.model.modelID ?? result.model.id) expect(language.provider).toBe("deepinfra.chat") expect(deepinfraLanguageModels).toEqual(["meta-llama/Llama-3.3-70B-Instruct"]) }), diff --git a/packages/core/test/plugin/provider-dynamic.test.ts b/packages/core/test/plugin/provider-dynamic.test.ts index 9eda035143..c5a598a870 100644 --- a/packages/core/test/plugin/provider-dynamic.test.ts +++ b/packages/core/test/plugin/provider-dynamic.test.ts @@ -54,7 +54,8 @@ describe("DynamicProviderPlugin", () => { const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("test-model")), - api: { id: ModelV2.ID.make("test-model"), type: "aisdk", package: fixtureProvider }, + modelID: ModelV2.ID.make("test-model"), + package: ProviderV2.aisdk(fixtureProvider), }), package: fixtureProvider, options: { name: "custom", marker: "dynamic" }, @@ -72,7 +73,8 @@ describe("DynamicProviderPlugin", () => { const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("test-model")), - api: { id: ModelV2.ID.make("test-model"), type: "aisdk", package: fixtureProvider }, + modelID: ModelV2.ID.make("test-model"), + package: ProviderV2.aisdk(fixtureProvider), }), package: fixtureProvider, options: { name: "custom", marker: "dynamic" }, @@ -89,7 +91,8 @@ describe("DynamicProviderPlugin", () => { const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("custom-provider"), ModelV2.ID.make("test-model")), - api: { id: ModelV2.ID.make("test-model"), type: "aisdk", package: fixtureProvider }, + modelID: ModelV2.ID.make("test-model"), + package: ProviderV2.aisdk(fixtureProvider), }), package: fixtureProvider, options: { name: "custom-provider", marker: "dynamic" }, @@ -105,7 +108,8 @@ describe("DynamicProviderPlugin", () => { const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("npm-provider"), ModelV2.ID.make("test-model")), - api: { id: ModelV2.ID.make("test-model"), type: "aisdk", package: "fixture-provider" }, + modelID: ModelV2.ID.make("test-model"), + package: "aisdk:fixture-provider", }), package: "fixture-provider", options: { name: "npm-provider", marker: "npm" }, @@ -122,7 +126,8 @@ describe("DynamicProviderPlugin", () => { .language( ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("missing-entrypoint"), ModelV2.ID.make("alias")), - api: { id: ModelV2.ID.make("alias"), type: "aisdk", package: "fixture-provider" }, + modelID: ModelV2.ID.make("alias"), + package: "aisdk:fixture-provider", }), ) .pipe(Effect.exit) @@ -139,7 +144,8 @@ describe("DynamicProviderPlugin", () => { .language( ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("bad-import"), ModelV2.ID.make("alias")), - api: { id: ModelV2.ID.make("alias"), type: "aisdk", package: "file:///missing/provider-factory.js" }, + modelID: ModelV2.ID.make("alias"), + package: "aisdk:file:///missing/provider-factory.js", }), ) .pipe(Effect.exit) @@ -158,7 +164,8 @@ describe("DynamicProviderPlugin", () => { .language( ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("missing-factory"), ModelV2.ID.make("alias")), - api: { id: ModelV2.ID.make("alias"), type: "aisdk", package: "fixture-provider" }, + modelID: ModelV2.ID.make("alias"), + package: "aisdk:fixture-provider", }), ) .pipe(Effect.exit) @@ -167,7 +174,7 @@ describe("DynamicProviderPlugin", () => { }), ) - itWithAISDK.effect("uses the model api.id for the default language model", () => + itWithAISDK.effect("uses the model modelID for the default language model", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service const aisdk = yield* AISDK.Service @@ -175,7 +182,8 @@ describe("DynamicProviderPlugin", () => { const language = yield* aisdk.language( ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("alias")), - api: { id: ModelV2.ID.make("test-model-api"), type: "aisdk", package: fixtureProvider }, + modelID: ModelV2.ID.make("test-model-api"), + package: ProviderV2.aisdk(fixtureProvider), }), ) expect(language).toMatchObject({ modelID: "test-model-api", options: { name: "custom" } }) diff --git a/packages/core/test/plugin/provider-gateway.test.ts b/packages/core/test/plugin/provider-gateway.test.ts index c6a0427cdb..722bbde9b6 100644 --- a/packages/core/test/plugin/provider-gateway.test.ts +++ b/packages/core/test/plugin/provider-gateway.test.ts @@ -45,7 +45,8 @@ describe("GatewayPlugin", () => { const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("gateway"), ModelV2.ID.make("model")), - api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, + modelID: ModelV2.ID.make("model"), + package: "aisdk:test-provider", }), package: "@ai-sdk/gateway", options: { name: "gateway" }, @@ -65,11 +66,8 @@ describe("GatewayPlugin", () => { const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("vercel"), ModelV2.ID.make("anthropic/claude-sonnet-4")), - api: { - id: ModelV2.ID.make("anthropic/claude-sonnet-4"), - type: "aisdk", - package: "test-provider", - }, + modelID: ModelV2.ID.make("anthropic/claude-sonnet-4"), + package: "aisdk:test-provider", }), package: "@ai-sdk/gateway", options: { name: "vercel", apiKey: "test-key" }, @@ -91,7 +89,8 @@ describe("GatewayPlugin", () => { const ignored = yield* aisdk.runSDK({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("vercel"), ModelV2.ID.make(modelID)), - api: { id: ModelV2.ID.make(modelID), type: "aisdk", package: "test-provider" }, + modelID: ModelV2.ID.make(modelID), + package: "aisdk:test-provider", }), package: "@ai-sdk/vercel", options: { name: "vercel" }, @@ -101,7 +100,8 @@ describe("GatewayPlugin", () => { const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("vercel"), ModelV2.ID.make(modelID)), - api: { id: ModelV2.ID.make(modelID), type: "aisdk", package: "test-provider" }, + modelID: ModelV2.ID.make(modelID), + package: "aisdk:test-provider", }), package: "@ai-sdk/gateway", options: { name: "vercel" }, diff --git a/packages/core/test/plugin/provider-github-copilot.test.ts b/packages/core/test/plugin/provider-github-copilot.test.ts index beef89ae8c..4c5a3b1160 100644 --- a/packages/core/test/plugin/provider-github-copilot.test.ts +++ b/packages/core/test/plugin/provider-github-copilot.test.ts @@ -47,7 +47,8 @@ describe("GithubCopilotPlugin", () => { const ignored = yield* aisdk.runSDK({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5")), - api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" }, + modelID: ModelV2.ID.make("gpt-5"), + package: "aisdk:test-provider", }), package: "@ai-sdk/openai-compatible", options: { name: "github-copilot" }, @@ -55,7 +56,8 @@ describe("GithubCopilotPlugin", () => { const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5")), - api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" }, + modelID: ModelV2.ID.make("gpt-5"), + package: "aisdk:test-provider", }), package: "@ai-sdk/github-copilot", options: { name: "github-copilot" }, @@ -74,7 +76,8 @@ describe("GithubCopilotPlugin", () => { yield* aisdk.runLanguage({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("claude-sonnet-4")), - api: { id: ModelV2.ID.make("claude-sonnet-4"), type: "aisdk", package: "test-provider" }, + modelID: ModelV2.ID.make("claude-sonnet-4"), + package: "aisdk:test-provider", }), sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, options: {}, @@ -92,7 +95,8 @@ describe("GithubCopilotPlugin", () => { yield* aisdk.runLanguage({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("alias")), - api: { id: ModelV2.ID.make("claude-sonnet-4"), type: "aisdk", package: "test-provider" }, + modelID: ModelV2.ID.make("claude-sonnet-4"), + package: "aisdk:test-provider", }), sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, options: {}, @@ -110,7 +114,8 @@ describe("GithubCopilotPlugin", () => { yield* aisdk.runLanguage({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5")), - api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" }, + modelID: ModelV2.ID.make("gpt-5"), + package: "aisdk:test-provider", }), sdk: fakeSelectorSdk(calls), options: {}, @@ -118,7 +123,8 @@ describe("GithubCopilotPlugin", () => { yield* aisdk.runLanguage({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5.1-codex")), - api: { id: ModelV2.ID.make("gpt-5.1-codex"), type: "aisdk", package: "test-provider" }, + modelID: ModelV2.ID.make("gpt-5.1-codex"), + package: "aisdk:test-provider", }), sdk: fakeSelectorSdk(calls), options: {}, @@ -126,7 +132,8 @@ describe("GithubCopilotPlugin", () => { yield* aisdk.runLanguage({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-4o")), - api: { id: ModelV2.ID.make("gpt-4o"), type: "aisdk", package: "test-provider" }, + modelID: ModelV2.ID.make("gpt-4o"), + package: "aisdk:test-provider", }), sdk: fakeSelectorSdk(calls), options: {}, @@ -134,7 +141,8 @@ describe("GithubCopilotPlugin", () => { yield* aisdk.runLanguage({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5-mini")), - api: { id: ModelV2.ID.make("gpt-5-mini"), type: "aisdk", package: "test-provider" }, + modelID: ModelV2.ID.make("gpt-5-mini"), + package: "aisdk:test-provider", }), sdk: fakeSelectorSdk(calls), options: {}, @@ -142,7 +150,8 @@ describe("GithubCopilotPlugin", () => { yield* aisdk.runLanguage({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5-mini-2025-08-07")), - api: { id: ModelV2.ID.make("gpt-5-mini-2025-08-07"), type: "aisdk", package: "test-provider" }, + modelID: ModelV2.ID.make("gpt-5-mini-2025-08-07"), + package: "aisdk:test-provider", }), sdk: fakeSelectorSdk(calls), options: {}, @@ -166,7 +175,8 @@ describe("GithubCopilotPlugin", () => { yield* aisdk.runLanguage({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("default")), - api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" }, + modelID: ModelV2.ID.make("gpt-5"), + package: "aisdk:test-provider", }), sdk: fakeSelectorSdk(calls), options: {}, @@ -174,7 +184,8 @@ describe("GithubCopilotPlugin", () => { yield* aisdk.runLanguage({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("small")), - api: { id: ModelV2.ID.make("gpt-5-mini"), type: "aisdk", package: "test-provider" }, + modelID: ModelV2.ID.make("gpt-5-mini"), + package: "aisdk:test-provider", }), sdk: fakeSelectorSdk(calls), options: {}, @@ -182,7 +193,8 @@ describe("GithubCopilotPlugin", () => { yield* aisdk.runLanguage({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("sonnet")), - api: { id: ModelV2.ID.make("claude-sonnet-4"), type: "aisdk", package: "test-provider" }, + modelID: ModelV2.ID.make("claude-sonnet-4"), + package: "aisdk:test-provider", }), sdk: fakeSelectorSdk(calls), options: {}, @@ -230,7 +242,8 @@ describe("GithubCopilotPlugin", () => { const result = yield* aisdk.runLanguage({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("openai"), ModelV2.ID.make("gpt-5")), - api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" }, + modelID: ModelV2.ID.make("gpt-5"), + package: "aisdk:test-provider", }), sdk: fakeSelectorSdk(calls), options: {}, diff --git a/packages/core/test/plugin/provider-gitlab.test.ts b/packages/core/test/plugin/provider-gitlab.test.ts index f9880844d3..ae66a61aa8 100644 --- a/packages/core/test/plugin/provider-gitlab.test.ts +++ b/packages/core/test/plugin/provider-gitlab.test.ts @@ -70,7 +70,8 @@ describe("GitLabPlugin", () => { yield* aisdk.runSDK({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("claude")), - api: { id: ModelV2.ID.make("claude"), type: "aisdk", package: "test-provider" }, + modelID: ModelV2.ID.make("claude"), + package: "aisdk:test-provider", }), package: "gitlab-ai-provider", options: { name: "gitlab" }, @@ -107,7 +108,8 @@ describe("GitLabPlugin", () => { yield* aisdk.runSDK({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("claude")), - api: { id: ModelV2.ID.make("claude"), type: "aisdk", package: "test-provider" }, + modelID: ModelV2.ID.make("claude"), + package: "aisdk:test-provider", }), package: "gitlab-ai-provider", options: { name: "gitlab" }, @@ -132,7 +134,8 @@ describe("GitLabPlugin", () => { yield* aisdk.runSDK({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("claude")), - api: { id: ModelV2.ID.make("claude"), type: "aisdk", package: "test-provider" }, + modelID: ModelV2.ID.make("claude"), + package: "aisdk:test-provider", }), package: "gitlab-ai-provider", options: { @@ -173,7 +176,8 @@ describe("GitLabPlugin", () => { const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("claude")), - api: { id: ModelV2.ID.make("claude"), type: "aisdk", package: "test-provider" }, + modelID: ModelV2.ID.make("claude"), + package: "aisdk:test-provider", }), package: "@ai-sdk/openai", options: { name: "gitlab" }, @@ -192,11 +196,10 @@ describe("GitLabPlugin", () => { const result = yield* aisdk.runLanguage({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("duo-workflow-custom")), - api: { id: ModelV2.ID.make("duo-workflow-custom"), type: "aisdk", package: "test-provider" }, - request: { - headers: {}, - body: { workflowRef: "ref", workflowDefinition: "definition" }, - }, + modelID: ModelV2.ID.make("duo-workflow-custom"), + package: "aisdk:test-provider", + headers: {}, + settings: { workflowRef: "ref", workflowDefinition: "definition" }, }), sdk: { workflowChat: (id: string, options: unknown) => { @@ -227,7 +230,8 @@ describe("GitLabPlugin", () => { const result = yield* aisdk.runLanguage({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("duo-workflow-exact")), - api: { id: ModelV2.ID.make("duo-workflow-exact"), type: "aisdk", package: "test-provider" }, + modelID: ModelV2.ID.make("duo-workflow-exact"), + package: "aisdk:test-provider", }), sdk: { workflowChat: (id: string, options: unknown) => { @@ -245,7 +249,7 @@ describe("GitLabPlugin", () => { }), ) - it.effect("uses provider feature flags instead of request feature flags", () => + it.effect("uses provider feature flags instead of model settings feature flags", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service const aisdk = yield* AISDK.Service @@ -254,11 +258,10 @@ describe("GitLabPlugin", () => { yield* aisdk.runLanguage({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("duo-workflow-custom")), - api: { id: ModelV2.ID.make("duo-workflow-custom"), type: "aisdk", package: "test-provider" }, - request: { - headers: {}, - body: { featureFlags: { request_flag: true } }, - }, + modelID: ModelV2.ID.make("duo-workflow-custom"), + package: "aisdk:test-provider", + headers: {}, + settings: { featureFlags: { request_flag: true } }, }), sdk: { workflowChat: (id: string, options: unknown) => { @@ -282,8 +285,10 @@ describe("GitLabPlugin", () => { yield* aisdk.runLanguage({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("claude")), - api: { id: ModelV2.ID.make("claude"), type: "aisdk", package: "test-provider" }, - request: { headers: { h: "v" }, body: {} }, + modelID: ModelV2.ID.make("claude"), + package: "aisdk:test-provider", + headers: { h: "v" }, + settings: {}, }), sdk: { workflowChat: () => undefined, diff --git a/packages/core/test/plugin/provider-google-vertex-anthropic.test.ts b/packages/core/test/plugin/provider-google-vertex-anthropic.test.ts index d94321f3bd..090abb945c 100644 --- a/packages/core/test/plugin/provider-google-vertex-anthropic.test.ts +++ b/packages/core/test/plugin/provider-google-vertex-anthropic.test.ts @@ -64,16 +64,16 @@ describe("GoogleVertexAnthropicPlugin", () => { const catalog = yield* Catalog.Service yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.make("google-vertex-anthropic"), (provider) => { - provider.api = { type: "aisdk", package: "@ai-sdk/google-vertex/anthropic" } + provider.package = ProviderV2.aisdk("@ai-sdk/google-vertex/anthropic") }), ) yield* addPlugin(GoogleVertexAnthropicPlugin) - expect( - (yield* catalog.provider.get(ProviderV2.ID.make("google-vertex-anthropic")))?.request.body.project, - ).toBe("cloud-project") - expect( - (yield* catalog.provider.get(ProviderV2.ID.make("google-vertex-anthropic")))?.request.body.location, - ).toBe("cloud-location") + expect((yield* catalog.provider.get(ProviderV2.ID.make("google-vertex-anthropic")))?.settings?.project).toBe( + "cloud-project", + ) + expect((yield* catalog.provider.get(ProviderV2.ID.make("google-vertex-anthropic")))?.settings?.location).toBe( + "cloud-location", + ) }), ), ) @@ -84,18 +84,17 @@ describe("GoogleVertexAnthropicPlugin", () => { const catalog = yield* Catalog.Service yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.make("google-vertex-anthropic"), (provider) => { - provider.api = { type: "aisdk", package: "@ai-sdk/google-vertex/anthropic" } - provider.request.body.project = "configured-project" - provider.request.body.location = "configured-location" + provider.package = ProviderV2.aisdk("@ai-sdk/google-vertex/anthropic") + provider.settings = { ...provider.settings, project: "configured-project", location: "configured-location" } }), ) yield* addPlugin(GoogleVertexAnthropicPlugin) - expect((yield* catalog.provider.get(ProviderV2.ID.make("google-vertex-anthropic")))?.request.body.project).toBe( + expect((yield* catalog.provider.get(ProviderV2.ID.make("google-vertex-anthropic")))?.settings?.project).toBe( "configured-project", ) - expect( - (yield* catalog.provider.get(ProviderV2.ID.make("google-vertex-anthropic")))?.request.body.location, - ).toBe("configured-location") + expect((yield* catalog.provider.get(ProviderV2.ID.make("google-vertex-anthropic")))?.settings?.location).toBe( + "configured-location", + ) }), ), ) @@ -121,7 +120,8 @@ describe("GoogleVertexAnthropicPlugin", () => { ProviderV2.ID.make("google-vertex-anthropic"), ModelV2.ID.make("claude-sonnet-4-5"), ), - api: { id: ModelV2.ID.make("claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + modelID: ModelV2.ID.make("claude-sonnet-4-5"), + package: "aisdk:test-provider", }), package: "@ai-sdk/google-vertex/anthropic", options: { name: "google-vertex-anthropic" }, @@ -147,7 +147,8 @@ describe("GoogleVertexAnthropicPlugin", () => { ProviderV2.ID.make("google-vertex-anthropic"), ModelV2.ID.make("claude-sonnet-4-5"), ), - api: { id: ModelV2.ID.make("claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + modelID: ModelV2.ID.make("claude-sonnet-4-5"), + package: "aisdk:test-provider", }), package: "@ai-sdk/google-vertex/anthropic", options: { name: "google-vertex-anthropic" }, @@ -167,7 +168,8 @@ describe("GoogleVertexAnthropicPlugin", () => { const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("claude-sonnet-4-5")), - api: { id: ModelV2.ID.make("claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + modelID: ModelV2.ID.make("claude-sonnet-4-5"), + package: "aisdk:test-provider", }), package: "@ai-sdk/google-vertex/anthropic", options: { name: "google-vertex", project: "project", location: "eu" }, @@ -186,7 +188,8 @@ describe("GoogleVertexAnthropicPlugin", () => { const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("claude-sonnet-4-5")), - api: { id: ModelV2.ID.make("claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + modelID: ModelV2.ID.make("claude-sonnet-4-5"), + package: "aisdk:test-provider", }), package: "@ai-sdk/google-vertex/anthropic", options: { name: "google-vertex", project: "project", location: "eu", baseURL: "https://proxy.example/v1" }, @@ -204,7 +207,8 @@ describe("GoogleVertexAnthropicPlugin", () => { const sdkResult = yield* aisdk.runSDK({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make(" claude-sonnet-4-5 ")), - api: { id: ModelV2.ID.make(" claude-sonnet-4-5 "), type: "aisdk", package: "test-provider" }, + modelID: ModelV2.ID.make(" claude-sonnet-4-5 "), + package: "aisdk:test-provider", }), package: "@ai-sdk/google-vertex/anthropic", options: { name: "google-vertex", project: "project", location: "us" }, @@ -212,7 +216,8 @@ describe("GoogleVertexAnthropicPlugin", () => { const languageResult = yield* aisdk.runLanguage({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make(" claude-sonnet-4-5 ")), - api: { id: ModelV2.ID.make(" claude-sonnet-4-5 "), type: "aisdk", package: "test-provider" }, + modelID: ModelV2.ID.make(" claude-sonnet-4-5 "), + package: "aisdk:test-provider", }), sdk: sdkResult.sdk, options: {}, @@ -234,7 +239,8 @@ describe("GoogleVertexAnthropicPlugin", () => { yield* aisdk.runLanguage({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex-anthropic"), ModelV2.ID.make(" claude-sonnet-4-5 ")), - api: { id: ModelV2.ID.make(" claude-sonnet-4-5 "), type: "aisdk", package: "test-provider" }, + modelID: ModelV2.ID.make(" claude-sonnet-4-5 "), + package: "aisdk:test-provider", }), sdk: { languageModel: selector(calls) }, options: {}, @@ -252,7 +258,8 @@ describe("GoogleVertexAnthropicPlugin", () => { const result = yield* aisdk.runLanguage({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("claude-sonnet-4-5")), - api: { id: ModelV2.ID.make("claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + modelID: ModelV2.ID.make("claude-sonnet-4-5"), + package: "aisdk:test-provider", }), sdk: { languageModel: selector(calls) }, options: {}, diff --git a/packages/core/test/plugin/provider-google-vertex.test.ts b/packages/core/test/plugin/provider-google-vertex.test.ts index 388528bd65..635beba3c1 100644 --- a/packages/core/test/plugin/provider-google-vertex.test.ts +++ b/packages/core/test/plugin/provider-google-vertex.test.ts @@ -92,17 +92,14 @@ describe("GoogleVertexPlugin", () => { const catalog = yield* Catalog.Service yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.opencode, (provider) => { - provider.api = { - type: "aisdk", - package: "@ai-sdk/openai-compatible", - url: "https://opencode.ai/zen/v1", - } + provider.package = ProviderV2.aisdk("@ai-sdk/openai-compatible") + provider.settings = { ...provider.settings, baseURL: "https://opencode.ai/zen/v1" } }), ) yield* addPlugin() const provider = required(yield* catalog.provider.get(ProviderV2.ID.opencode)) - expect(provider.request.body).toEqual({}) + expect(provider.settings).toEqual({ baseURL: "https://opencode.ai/zen/v1" }) }), ) @@ -121,21 +118,24 @@ describe("GoogleVertexPlugin", () => { const catalog = yield* Catalog.Service yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => { - provider.api = { - type: "aisdk", - package: "@ai-sdk/openai-compatible", - url: "https://${GOOGLE_VERTEX_ENDPOINT}/v1/projects/${GOOGLE_VERTEX_PROJECT}/locations/${GOOGLE_VERTEX_LOCATION}", + provider.package = ProviderV2.aisdk("@ai-sdk/openai-compatible") + provider.settings = { + ...provider.settings, + baseURL: + "https://${GOOGLE_VERTEX_ENDPOINT}/v1/projects/${GOOGLE_VERTEX_PROJECT}/locations/${GOOGLE_VERTEX_LOCATION}", } }), ) yield* addPlugin() const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("google-vertex"))) - expect(provider.request.body.project).toBe("google-cloud-project") - expect(provider.request.body.location).toBe("google-vertex-location") - expect(provider.api).toEqual({ - type: "aisdk", - package: "@ai-sdk/openai-compatible", - url: "https://google-vertex-location-aiplatform.googleapis.com/v1/projects/google-cloud-project/locations/google-vertex-location", + expect(provider.settings?.project).toBe("google-cloud-project") + expect(provider.settings?.location).toBe("google-vertex-location") + expect(provider).toMatchObject({ + package: "aisdk:@ai-sdk/openai-compatible", + settings: { + baseURL: + "https://google-vertex-location-aiplatform.googleapis.com/v1/projects/google-cloud-project/locations/google-vertex-location", + }, }) }), ), @@ -160,10 +160,11 @@ describe("GoogleVertexPlugin", () => { const catalog = yield* Catalog.Service yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => { - provider.api = { - type: "aisdk", - package: "@ai-sdk/openai-compatible", - url: "https://${GOOGLE_VERTEX_ENDPOINT}/v1/projects/${GOOGLE_VERTEX_PROJECT}/locations/${GOOGLE_VERTEX_LOCATION}", + provider.package = ProviderV2.aisdk("@ai-sdk/openai-compatible") + provider.settings = { + ...provider.settings, + baseURL: + "https://${GOOGLE_VERTEX_ENDPOINT}/v1/projects/${GOOGLE_VERTEX_PROJECT}/locations/${GOOGLE_VERTEX_LOCATION}", } }), ) @@ -172,21 +173,20 @@ describe("GoogleVertexPlugin", () => { yield* aisdk.runSDK({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("gemini")), - api: { - id: ModelV2.ID.make("gemini"), - type: "aisdk", - package: "@ai-sdk/google-vertex", - }, + modelID: ModelV2.ID.make("gemini"), + package: "aisdk:@ai-sdk/google-vertex", }), package: "@ai-sdk/google-vertex", options: { name: "google-vertex" }, }) - expect(provider.request.body.project).toBe("vertex-project") - expect(provider.api).toEqual({ - type: "aisdk", - package: "@ai-sdk/openai-compatible", - url: "https://europe-west4-aiplatform.googleapis.com/v1/projects/vertex-project/locations/europe-west4", + expect(provider.settings?.project).toBe("vertex-project") + expect(provider).toMatchObject({ + package: "aisdk:@ai-sdk/openai-compatible", + settings: { + baseURL: + "https://europe-west4-aiplatform.googleapis.com/v1/projects/vertex-project/locations/europe-west4", + }, }) expect(vertexOptions[0].project).toBe("vertex-project") expect(vertexOptions[0].location).toBe("europe-west4") @@ -209,23 +209,22 @@ describe("GoogleVertexPlugin", () => { const catalog = yield* Catalog.Service yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => { - provider.api = { - type: "aisdk", - package: "@ai-sdk/openai-compatible", - url: "https://${GOOGLE_VERTEX_ENDPOINT}/v1/projects/${GOOGLE_VERTEX_PROJECT}/locations/${GOOGLE_VERTEX_LOCATION}", + provider.package = ProviderV2.aisdk("@ai-sdk/openai-compatible") + provider.settings = { + ...provider.settings, + baseURL: + "https://${GOOGLE_VERTEX_ENDPOINT}/v1/projects/${GOOGLE_VERTEX_PROJECT}/locations/${GOOGLE_VERTEX_LOCATION}", } - provider.request.body.project = "config-project" - provider.request.body.location = "global" + provider.settings = { ...provider.settings, project: "config-project", location: "global" } }), ) yield* addPlugin() const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("google-vertex"))) - expect(provider.request.body.project).toBe("config-project") - expect(provider.request.body.location).toBe("global") - expect(provider.api).toEqual({ - type: "aisdk", - package: "@ai-sdk/openai-compatible", - url: "https://aiplatform.googleapis.com/v1/projects/config-project/locations/global", + expect(provider.settings?.project).toBe("config-project") + expect(provider.settings?.location).toBe("global") + expect(provider).toMatchObject({ + package: "aisdk:@ai-sdk/openai-compatible", + settings: { baseURL: "https://aiplatform.googleapis.com/v1/projects/config-project/locations/global" }, }) }), ), @@ -236,21 +235,20 @@ describe("GoogleVertexPlugin", () => { const catalog = yield* Catalog.Service yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => { - provider.api = { - type: "aisdk", - package: "@ai-sdk/openai-compatible", - url: "https://${GOOGLE_VERTEX_ENDPOINT}/v1/projects/${GOOGLE_VERTEX_PROJECT}/locations/${GOOGLE_VERTEX_LOCATION}", + provider.package = ProviderV2.aisdk("@ai-sdk/openai-compatible") + provider.settings = { + ...provider.settings, + baseURL: + "https://${GOOGLE_VERTEX_ENDPOINT}/v1/projects/${GOOGLE_VERTEX_PROJECT}/locations/${GOOGLE_VERTEX_LOCATION}", } - provider.request.body.project = "config-project" - provider.request.body.location = "eu" + provider.settings = { ...provider.settings, project: "config-project", location: "eu" } }), ) yield* addPlugin() const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("google-vertex"))) - expect(provider.api).toEqual({ - type: "aisdk", - package: "@ai-sdk/openai-compatible", - url: "https://eu-aiplatform.googleapis.com/v1/projects/config-project/locations/eu", + expect(provider).toMatchObject({ + package: "aisdk:@ai-sdk/openai-compatible", + settings: { baseURL: "https://eu-aiplatform.googleapis.com/v1/projects/config-project/locations/eu" }, }) }), ) @@ -270,14 +268,14 @@ describe("GoogleVertexPlugin", () => { const catalog = yield* Catalog.Service yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => { - provider.api = { type: "aisdk", package: "@ai-sdk/google-vertex" } - provider.request.body.project = "config-project" + provider.package = ProviderV2.aisdk("@ai-sdk/google-vertex") + provider.settings = { ...provider.settings, project: "config-project" } }), ) yield* addPlugin() const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("google-vertex"))) - expect(provider.request.body.project).toBe("config-project") - expect(provider.request.body.location).toBe("us-central1") + expect(provider.settings?.project).toBe("config-project") + expect(provider.settings?.location).toBe("us-central1") }), ), ) @@ -297,11 +295,8 @@ describe("GoogleVertexPlugin", () => { yield* aisdk.runSDK({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("gemini")), - api: { - id: ModelV2.ID.make("gemini"), - type: "aisdk", - package: "@ai-sdk/google-vertex", - }, + modelID: ModelV2.ID.make("gemini"), + package: "aisdk:@ai-sdk/google-vertex", }), package: "@ai-sdk/google-vertex", options: { name: "google-vertex" }, @@ -345,11 +340,8 @@ describe("GoogleVertexPlugin", () => { aisdk.runSDK({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("gemini")), - api: { - id: ModelV2.ID.make("gemini"), - type: "aisdk", - package: "@ai-sdk/openai-compatible", - }, + modelID: ModelV2.ID.make("gemini"), + package: "aisdk:@ai-sdk/openai-compatible", }), package: "@ai-sdk/openai-compatible", options: { name: "google-vertex" }, @@ -359,11 +351,11 @@ describe("GoogleVertexPlugin", () => { ;(globalThis as typeof globalThis & { fetch: typeof fetch }).fetch = originalFetch }), ) - expect(fetchCalls).toHaveLength(1) + const vertexCalls = fetchCalls.filter((call) => call.input === "https://vertex.example") + expect(vertexCalls).toHaveLength(1) expect(googleAuthOptions).toEqual([{ scopes: ["https://www.googleapis.com/auth/cloud-platform"] }]) - expect(fetchCalls[0].input).toBe("https://vertex.example") - expect(new Headers(fetchCalls[0].init?.headers).get("authorization")).toBe("Bearer vertex-token") - expect(new Headers(fetchCalls[0].init?.headers).get("x-test")).toBe("1") + expect(new Headers(vertexCalls[0].init?.headers).get("authorization")).toBe("Bearer vertex-token") + expect(new Headers(vertexCalls[0].init?.headers).get("x-test")).toBe("1") }), ) @@ -376,7 +368,8 @@ describe("GoogleVertexPlugin", () => { yield* aisdk.runLanguage({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make(" gemini-2.5-pro ")), - api: { id: ModelV2.ID.make(" gemini-2.5-pro "), type: "aisdk", package: "test-provider" }, + modelID: ModelV2.ID.make(" gemini-2.5-pro "), + package: "aisdk:test-provider", }), sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, options: {}, diff --git a/packages/core/test/plugin/provider-google.test.ts b/packages/core/test/plugin/provider-google.test.ts index 8c10689ca1..d04e00e3b3 100644 --- a/packages/core/test/plugin/provider-google.test.ts +++ b/packages/core/test/plugin/provider-google.test.ts @@ -27,7 +27,8 @@ describe("GooglePlugin", () => { const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("custom-google"), ModelV2.ID.make("gemini")), - api: { id: ModelV2.ID.make("gemini"), type: "aisdk", package: "@ai-sdk/google" }, + modelID: ModelV2.ID.make("gemini"), + package: "aisdk:@ai-sdk/google", }), package: "@ai-sdk/google", options: { name: "custom-google", apiKey: "test" }, @@ -45,7 +46,8 @@ describe("GooglePlugin", () => { const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("google"), ModelV2.ID.make("gemini")), - api: { id: ModelV2.ID.make("gemini"), type: "aisdk", package: "@ai-sdk/google" }, + modelID: ModelV2.ID.make("gemini"), + package: "aisdk:@ai-sdk/google", }), package: "@ai-sdk/google-vertex", options: { name: "google" }, @@ -62,7 +64,8 @@ describe("GooglePlugin", () => { const sdkEvent = yield* aisdk.runSDK({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("custom-google"), ModelV2.ID.make("alias")), - api: { id: ModelV2.ID.make("gemini-api"), type: "aisdk", package: "@ai-sdk/google" }, + modelID: ModelV2.ID.make("gemini-api"), + package: "aisdk:@ai-sdk/google", }), package: "@ai-sdk/google", options: { name: "custom-google", apiKey: "test" }, @@ -72,9 +75,29 @@ describe("GooglePlugin", () => { sdk: sdkEvent.sdk, options: sdkEvent.options, }) - const language = result.language ?? result.sdk.languageModel(result.model.api.id) + const language = result.language ?? result.sdk.languageModel(result.model.modelID ?? result.model.id) expect(language.modelId).toBe("gemini-api") expect(language.provider).toBe("custom-google") }), ) + + it.effect("wraps AI SDK language models for the native runner", () => + Effect.gen(function* () { + const aisdk = yield* AISDK.Service + yield* addPlugin() + + const resolved = yield* aisdk.model( + ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom-google"), ModelV2.ID.make("alias")), + modelID: ModelV2.ID.make("gemini-api"), + package: "aisdk:@ai-sdk/google", + settings: { apiKey: "test" }, + }), + ) + + expect(String(resolved.id)).toBe("gemini-api") + expect(String(resolved.provider)).toBe("custom-google") + expect(resolved.route.id).toBe("ai-sdk:@ai-sdk/google") + }), + ) }) diff --git a/packages/core/test/plugin/provider-groq.test.ts b/packages/core/test/plugin/provider-groq.test.ts index ac96ea98a3..b8900384b7 100644 --- a/packages/core/test/plugin/provider-groq.test.ts +++ b/packages/core/test/plugin/provider-groq.test.ts @@ -28,7 +28,8 @@ describe("GroqPlugin", () => { const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("groq"), ModelV2.ID.make("llama")), - api: { id: ModelV2.ID.make("llama"), type: "aisdk", package: "@ai-sdk/groq" }, + modelID: ModelV2.ID.make("llama"), + package: "aisdk:@ai-sdk/groq", }), package: "@ai-sdk/groq", options: { name: "groq" }, @@ -45,7 +46,8 @@ describe("GroqPlugin", () => { const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("groq"), ModelV2.ID.make("llama")), - api: { id: ModelV2.ID.make("llama"), type: "aisdk", package: "@ai-sdk/groq" }, + modelID: ModelV2.ID.make("llama"), + package: "aisdk:@ai-sdk/groq", }), package: "@ai-sdk/openai-compatible", options: { name: "groq" }, @@ -62,7 +64,8 @@ describe("GroqPlugin", () => { const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("groq"), ModelV2.ID.make("llama")), - api: { id: ModelV2.ID.make("llama"), type: "aisdk", package: "@ai-sdk/groq" }, + modelID: ModelV2.ID.make("llama"), + package: "aisdk:@ai-sdk/groq", }), package: "@ai-sdk/groq/compat", options: { name: "groq" }, @@ -79,7 +82,8 @@ describe("GroqPlugin", () => { const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("custom-groq"), ModelV2.ID.make("llama")), - api: { id: ModelV2.ID.make("llama"), type: "aisdk", package: "@ai-sdk/groq" }, + modelID: ModelV2.ID.make("llama"), + package: "aisdk:@ai-sdk/groq", }), package: "@ai-sdk/groq", options: { name: "custom-groq", apiKey: "test" }, @@ -93,7 +97,7 @@ describe("GroqPlugin", () => { }), ) - it.effect("uses the default languageModel(api.id) behavior", () => + it.effect("uses the default languageModel(modelID) behavior", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service const aisdk = yield* AISDK.Service @@ -104,16 +108,13 @@ describe("GroqPlugin", () => { const result = yield* aisdk.runLanguage({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("groq"), ModelV2.ID.make("alias")), - api: { - id: ModelV2.ID.make("llama-api"), - type: "aisdk", - package: "@ai-sdk/groq", - }, + modelID: ModelV2.ID.make("llama-api"), + package: "aisdk:@ai-sdk/groq", }), sdk, options: { name: "groq", apiKey: "test" }, }) - const language = result.language ?? sdk.languageModel(result.model.api.id) + const language = result.language ?? sdk.languageModel(result.model.modelID ?? result.model.id) expect(language.modelId).toBe("llama-api") expect(language.provider).toBe("groq.chat") }), diff --git a/packages/core/test/plugin/provider-kilo.test.ts b/packages/core/test/plugin/provider-kilo.test.ts index 1ff356523c..e4af57cda6 100644 --- a/packages/core/test/plugin/provider-kilo.test.ts +++ b/packages/core/test/plugin/provider-kilo.test.ts @@ -22,27 +22,24 @@ describe("KiloPlugin", () => { Effect.sync(() => expect(ProviderPlugins.map((item) => item.id)).toContain("opencode.provider.kilo")), ) - it.effect("applies legacy referer headers only to kilo", () => + it.effect("applies legacy referer headers only to Kilo endpoints", () => Effect.gen(function* () { const catalog = yield* Catalog.Service yield* catalog.transform((catalog) => { catalog.provider.update(ProviderV2.ID.make("kilo"), (provider) => { - provider.api = { - type: "aisdk", - package: "@ai-sdk/openai-compatible", - url: "https://api.kilo.ai/api/gateway", - } - provider.request = { settings: {}, headers: { Existing: "value" }, body: {} } + provider.package = ProviderV2.aisdk("@ai-sdk/openai-compatible") + provider.settings = { baseURL: "https://api.kilo.ai/api/gateway" } + provider.headers = { Existing: "value" } }) catalog.provider.update(ProviderV2.ID.openrouter, () => {}) }) yield* addPlugin() - expect((yield* catalog.provider.get(ProviderV2.ID.make("kilo")))?.request.headers).toEqual({ + expect((yield* catalog.provider.get(ProviderV2.ID.make("kilo")))?.headers).toEqual({ Existing: "value", "HTTP-Referer": "https://opencode.ai/", "X-Title": "opencode", }) - expect((yield* catalog.provider.get(ProviderV2.ID.openrouter))?.request.headers).toEqual({}) + expect((yield* catalog.provider.get(ProviderV2.ID.openrouter))?.headers).toBeUndefined() }), ) @@ -51,49 +48,41 @@ describe("KiloPlugin", () => { const catalog = yield* Catalog.Service yield* catalog.transform((catalog) => { catalog.provider.update(ProviderV2.ID.make("kilo"), (provider) => { - provider.api = { - type: "aisdk", - package: "@ai-sdk/openai-compatible", - url: "https://api.kilo.ai/api/gateway", - } + provider.package = ProviderV2.aisdk("@ai-sdk/openai-compatible") + provider.settings = { baseURL: "https://api.kilo.ai/api/gateway" } }) }) yield* addPlugin() - expect((yield* catalog.provider.get(ProviderV2.ID.make("kilo")))?.request.headers).toEqual({ + expect((yield* catalog.provider.get(ProviderV2.ID.make("kilo")))?.headers).toEqual({ "HTTP-Referer": "https://opencode.ai/", "X-Title": "opencode", }) - expect((yield* catalog.provider.get(ProviderV2.ID.make("kilo")))?.request.headers).not.toHaveProperty( - "http-referer", - ) - expect((yield* catalog.provider.get(ProviderV2.ID.make("kilo")))?.request.headers).not.toHaveProperty("x-title") - expect((yield* catalog.provider.get(ProviderV2.ID.make("kilo")))?.request.headers).not.toHaveProperty("X-Source") + expect((yield* catalog.provider.get(ProviderV2.ID.make("kilo")))?.headers).not.toHaveProperty("http-referer") + expect((yield* catalog.provider.get(ProviderV2.ID.make("kilo")))?.headers).not.toHaveProperty("x-title") + expect((yield* catalog.provider.get(ProviderV2.ID.make("kilo")))?.headers).not.toHaveProperty("X-Source") }), ) - it.effect("uses the legacy provider-id guard instead of endpoint package matching", () => + it.effect("uses endpoint package matching instead of a provider ID guard", () => Effect.gen(function* () { const catalog = yield* Catalog.Service yield* catalog.transform((catalog) => { catalog.provider.update(ProviderV2.ID.make("kilo"), (provider) => { - provider.api = { - type: "aisdk", - package: "@ai-sdk/openai-compatible", - url: "https://api.kilo.ai/api/gateway", - } + provider.package = ProviderV2.aisdk("kilo") }) catalog.provider.update(ProviderV2.ID.make("custom-kilo"), (provider) => { - provider.api = { type: "aisdk", package: "kilo" } + provider.package = ProviderV2.aisdk("@ai-sdk/openai-compatible") + provider.settings = { baseURL: "https://api.kilo.ai/api/gateway" } }) }) yield* addPlugin() - expect((yield* catalog.provider.get(ProviderV2.ID.make("kilo")))?.request.headers).toEqual({ + expect((yield* catalog.provider.get(ProviderV2.ID.make("kilo")))?.headers).toBeUndefined() + expect((yield* catalog.provider.get(ProviderV2.ID.make("custom-kilo")))?.headers).toEqual({ "HTTP-Referer": "https://opencode.ai/", "X-Title": "opencode", }) - expect((yield* catalog.provider.get(ProviderV2.ID.make("custom-kilo")))?.request.headers).toEqual({}) }), ) }) diff --git a/packages/core/test/plugin/provider-llmgateway.test.ts b/packages/core/test/plugin/provider-llmgateway.test.ts index 8f4af57889..9ff6d2566e 100644 --- a/packages/core/test/plugin/provider-llmgateway.test.ts +++ b/packages/core/test/plugin/provider-llmgateway.test.ts @@ -34,23 +34,20 @@ describe("LLMGatewayPlugin", () => { }) yield* catalog.transform((catalog) => { catalog.provider.update(ProviderV2.ID.make("llmgateway"), (provider) => { - provider.api = { - type: "aisdk", - package: "@ai-sdk/openai-compatible", - url: "https://api.llmgateway.io/v1", - } - provider.request = { settings: {}, headers: { Existing: "value" }, body: {} } + provider.package = ProviderV2.aisdk("@ai-sdk/openai-compatible") + provider.settings = { baseURL: "https://api.llmgateway.io/v1" } + provider.headers = { Existing: "value" } }) catalog.provider.update(ProviderV2.ID.openrouter, () => {}) }) yield* addPlugin() - expect((yield* catalog.provider.get(ProviderV2.ID.make("llmgateway")))?.request.headers).toEqual({ + expect((yield* catalog.provider.get(ProviderV2.ID.make("llmgateway")))?.headers).toEqual({ Existing: "value", "HTTP-Referer": "https://opencode.ai/", "X-Title": "opencode", "X-Source": "opencode", }) - expect((yield* catalog.provider.get(ProviderV2.ID.openrouter))?.request.headers).toEqual({}) + expect((yield* catalog.provider.get(ProviderV2.ID.openrouter))?.headers).toBeUndefined() }), ) @@ -64,17 +61,14 @@ describe("LLMGatewayPlugin", () => { yield* catalog.transform((catalog) => { catalog.provider.update(ProviderV2.ID.make("llmgateway"), (provider) => { provider.disabled = true - provider.api = { - type: "aisdk", - package: "@ai-sdk/openai-compatible", - url: "https://api.llmgateway.io/v1", - } + provider.package = ProviderV2.aisdk("@ai-sdk/openai-compatible") + provider.settings = { baseURL: "https://api.llmgateway.io/v1" } }) }) yield* addPlugin() expect((yield* catalog.provider.get(ProviderV2.ID.make("llmgateway")))?.disabled).toBe(true) - expect((yield* catalog.provider.get(ProviderV2.ID.make("llmgateway")))?.request.headers).toEqual({}) + expect((yield* catalog.provider.get(ProviderV2.ID.make("llmgateway")))?.headers).toBeUndefined() }), ) }) diff --git a/packages/core/test/plugin/provider-mistral.test.ts b/packages/core/test/plugin/provider-mistral.test.ts index b1b1bc0638..182873482c 100644 --- a/packages/core/test/plugin/provider-mistral.test.ts +++ b/packages/core/test/plugin/provider-mistral.test.ts @@ -28,7 +28,8 @@ describe("MistralPlugin", () => { const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("mistral"), ModelV2.ID.make("mistral-large")), - api: { id: ModelV2.ID.make("mistral-large"), type: "aisdk", package: "test-provider" }, + modelID: ModelV2.ID.make("mistral-large"), + package: "aisdk:test-provider", }), package: "@ai-sdk/mistral", options: { name: "mistral" }, @@ -45,7 +46,8 @@ describe("MistralPlugin", () => { const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("mistral"), ModelV2.ID.make("mistral-large")), - api: { id: ModelV2.ID.make("mistral-large"), type: "aisdk", package: "test-provider" }, + modelID: ModelV2.ID.make("mistral-large"), + package: "aisdk:test-provider", }), package: "@ai-sdk/openai-compatible", options: { name: "mistral" }, @@ -68,7 +70,8 @@ describe("MistralPlugin", () => { const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("mistral"), ModelV2.ID.make("mistral-large")), - api: { id: ModelV2.ID.make("mistral-large"), type: "aisdk", package: "test-provider" }, + modelID: ModelV2.ID.make("mistral-large"), + package: "aisdk:test-provider", }), package: "@ai-sdk/mistral", options: { name: "mistral" }, @@ -92,7 +95,8 @@ describe("MistralPlugin", () => { yield* aisdk.runSDK({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("custom-mistral"), ModelV2.ID.make("mistral-large")), - api: { id: ModelV2.ID.make("mistral-large"), type: "aisdk", package: "test-provider" }, + modelID: ModelV2.ID.make("mistral-large"), + package: "aisdk:test-provider", }), package: "@ai-sdk/mistral", options: { name: "custom-mistral" }, @@ -101,7 +105,7 @@ describe("MistralPlugin", () => { }), ) - it.effect("leaves Mistral language selection on the default sdk.languageModel(api.id) path", () => + it.effect("leaves Mistral language selection on the default sdk.languageModel(modelID) path", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service const aisdk = yield* AISDK.Service @@ -116,12 +120,13 @@ describe("MistralPlugin", () => { const result = yield* aisdk.runLanguage({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("mistral"), ModelV2.ID.make("alias")), - api: { id: ModelV2.ID.make("mistral-large"), type: "aisdk", package: "test-provider" }, + modelID: ModelV2.ID.make("mistral-large"), + package: "aisdk:test-provider", }), sdk, options: {}, }) - const language = result.language ?? sdk.languageModel(result.model.api.id) + const language = result.language ?? sdk.languageModel(result.model.modelID ?? result.model.id) expect(calls).toEqual(["languageModel:mistral-large"]) expect(language).toBeDefined() }), diff --git a/packages/core/test/plugin/provider-nvidia.test.ts b/packages/core/test/plugin/provider-nvidia.test.ts index c176cef4ac..47f78fd022 100644 --- a/packages/core/test/plugin/provider-nvidia.test.ts +++ b/packages/core/test/plugin/provider-nvidia.test.ts @@ -27,23 +27,20 @@ describe("NvidiaPlugin", () => { const catalog = yield* Catalog.Service yield* catalog.transform((catalog) => { catalog.provider.update(ProviderV2.ID.make("nvidia"), (provider) => { - provider.api = { - type: "aisdk", - package: "@ai-sdk/openai-compatible", - url: "https://integrate.api.nvidia.com/v1", - } - provider.request = { settings: {}, headers: { Existing: "value" }, body: {} } + provider.package = ProviderV2.aisdk("@ai-sdk/openai-compatible") + provider.settings = { baseURL: "https://integrate.api.nvidia.com/v1" } + provider.headers = { Existing: "value" } }) catalog.provider.update(ProviderV2.ID.openrouter, () => {}) }) yield* addPlugin() - expect((yield* catalog.provider.get(ProviderV2.ID.make("nvidia")))?.request.headers).toEqual({ + expect((yield* catalog.provider.get(ProviderV2.ID.make("nvidia")))?.headers).toEqual({ Existing: "value", "HTTP-Referer": "https://opencode.ai/", "X-Title": "opencode", "X-BILLING-INVOKE-ORIGIN": "OpenCode", }) - expect((yield* catalog.provider.get(ProviderV2.ID.openrouter))?.request.headers).toEqual({}) + expect((yield* catalog.provider.get(ProviderV2.ID.openrouter))?.headers).toBeUndefined() }), ) @@ -52,16 +49,13 @@ describe("NvidiaPlugin", () => { const catalog = yield* Catalog.Service yield* catalog.transform((catalog) => { catalog.provider.update(ProviderV2.ID.make("nvidia"), (provider) => { - provider.api = { - type: "aisdk", - package: "@ai-sdk/openai-compatible", - url: "https://integrate.api.nvidia.com/v1", - } + provider.package = ProviderV2.aisdk("@ai-sdk/openai-compatible") + provider.settings = { baseURL: "https://integrate.api.nvidia.com/v1" } }) }) yield* addPlugin() - expect((yield* catalog.provider.get(ProviderV2.ID.make("nvidia")))?.request.headers).toEqual({ + expect((yield* catalog.provider.get(ProviderV2.ID.make("nvidia")))?.headers).toEqual({ "HTTP-Referer": "https://opencode.ai/", "X-Title": "opencode", "X-BILLING-INVOKE-ORIGIN": "OpenCode", @@ -74,21 +68,14 @@ describe("NvidiaPlugin", () => { const catalog = yield* Catalog.Service yield* catalog.transform((catalog) => { catalog.provider.update(ProviderV2.ID.make("nvidia"), (provider) => { - provider.api = { - type: "aisdk", - package: "@ai-sdk/openai-compatible", - url: "https://integrate.api.nvidia.com/v1", - } - provider.request = { - settings: {}, - headers: { "X-BILLING-INVOKE-ORIGIN": "CustomOrigin" }, - body: { baseURL: "https://integrate.api.nvidia.com/v1" }, - } + provider.package = ProviderV2.aisdk("@ai-sdk/openai-compatible") + provider.settings = { baseURL: "https://integrate.api.nvidia.com/v1" } + provider.headers = { "X-BILLING-INVOKE-ORIGIN": "CustomOrigin" } }) }) yield* addPlugin() - expect((yield* catalog.provider.get(ProviderV2.ID.make("nvidia")))?.request.headers).toEqual({ + expect((yield* catalog.provider.get(ProviderV2.ID.make("nvidia")))?.headers).toEqual({ "HTTP-Referer": "https://opencode.ai/", "X-Title": "opencode", "X-BILLING-INVOKE-ORIGIN": "CustomOrigin", diff --git a/packages/core/test/plugin/provider-openai-compatible.test.ts b/packages/core/test/plugin/provider-openai-compatible.test.ts index 4e74a37125..a954af9f00 100644 --- a/packages/core/test/plugin/provider-openai-compatible.test.ts +++ b/packages/core/test/plugin/provider-openai-compatible.test.ts @@ -27,7 +27,8 @@ describe("OpenAICompatiblePlugin", () => { const defaulted = yield* aisdk.runSDK({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("model")), - api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, + modelID: ModelV2.ID.make("model"), + package: "aisdk:test-provider", }), package: "@ai-sdk/openai-compatible", options: { name: "custom" }, @@ -35,7 +36,8 @@ describe("OpenAICompatiblePlugin", () => { const disabled = yield* aisdk.runSDK({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("model")), - api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, + modelID: ModelV2.ID.make("model"), + package: "aisdk:test-provider", }), package: "@ai-sdk/openai-compatible", options: { name: "custom", includeUsage: false }, @@ -53,7 +55,8 @@ describe("OpenAICompatiblePlugin", () => { const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("model")), - api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, + modelID: ModelV2.ID.make("model"), + package: "aisdk:test-provider", }), package: "file:///tmp/@ai-sdk/openai-compatible-provider.js", options: { name: "custom" }, @@ -76,7 +79,8 @@ describe("OpenAICompatiblePlugin", () => { yield* aisdk.runSDK({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("custom-provider"), ModelV2.ID.make("model")), - api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, + modelID: ModelV2.ID.make("model"), + package: "aisdk:test-provider", }), package: "@ai-sdk/openai-compatible", options: { name: "custom-provider", baseURL: "https://example.com/v1" }, @@ -96,7 +100,8 @@ describe("OpenAICompatiblePlugin", () => { const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("model")), - api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, + modelID: ModelV2.ID.make("model"), + package: "aisdk:test-provider", }), package: "@ai-sdk/openai-compatible", options: { name: "cloudflare-workers-ai" }, diff --git a/packages/core/test/plugin/provider-openai.test.ts b/packages/core/test/plugin/provider-openai.test.ts index 44941a4aa1..f20091d514 100644 --- a/packages/core/test/plugin/provider-openai.test.ts +++ b/packages/core/test/plugin/provider-openai.test.ts @@ -82,7 +82,8 @@ describe("OpenAIPlugin", () => { const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("custom-openai"), ModelV2.ID.make("gpt-5")), - api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" }, + modelID: ModelV2.ID.make("gpt-5"), + package: ProviderV2.aisdk("test-provider"), }), package: "@ai-sdk/openai", options: { name: "custom-openai", apiKey: "test" }, @@ -99,7 +100,8 @@ describe("OpenAIPlugin", () => { const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5")), - api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" }, + modelID: ModelV2.ID.make("gpt-5"), + package: ProviderV2.aisdk("test-provider"), }), package: "@ai-sdk/openai-compatible", options: { name: "openai" }, @@ -117,7 +119,8 @@ describe("OpenAIPlugin", () => { const result = yield* aisdk.runLanguage({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.openai, ModelV2.ID.make("alias")), - api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" }, + modelID: ModelV2.ID.make("gpt-5"), + package: ProviderV2.aisdk("test-provider"), }), sdk: fakeSelectorSdk(calls), options: {}, @@ -136,7 +139,8 @@ describe("OpenAIPlugin", () => { const result = yield* aisdk.runLanguage({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.anthropic, ModelV2.ID.make("gpt-5")), - api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" }, + modelID: ModelV2.ID.make("gpt-5"), + package: ProviderV2.aisdk("test-provider"), }), sdk: fakeSelectorSdk(calls), options: {}, @@ -152,10 +156,10 @@ describe("OpenAIPlugin", () => { yield* catalog.transform((catalog) => { const item = ProviderV2.Info.make({ ...ProviderV2.Info.empty(ProviderV2.ID.openai), - api: { type: "aisdk", package: "@ai-sdk/openai" }, + package: ProviderV2.aisdk("@ai-sdk/openai"), }) catalog.provider.update(item.id, (draft) => { - draft.api = item.api + draft.package = item.package }) catalog.model.update(item.id, ModelV2.ID.make("gpt-5"), () => {}) catalog.model.update(item.id, ModelV2.ID.make("gpt-5-chat-latest"), () => {}) @@ -175,10 +179,10 @@ describe("OpenAIPlugin", () => { yield* catalog.transform((catalog) => { const item = ProviderV2.Info.make({ ...ProviderV2.Info.empty(ProviderV2.ID.openai), - api: { type: "aisdk", package: "@ai-sdk/openai" }, + package: ProviderV2.aisdk("@ai-sdk/openai"), }) catalog.provider.update(item.id, (draft) => { - draft.api = item.api + draft.package = item.package }) catalog.model.update(item.id, ModelV2.ID.make("gpt-5.5"), (model) => { model.cost = [{ input: 1, output: 2, cache: { read: 0.1, write: 0 } }] @@ -220,10 +224,10 @@ describe("OpenAIPlugin", () => { yield* catalog.transform((catalog) => { const item = ProviderV2.Info.make({ ...ProviderV2.Info.empty(ProviderV2.ID.openai), - api: { type: "aisdk", package: "@ai-sdk/openai" }, + package: ProviderV2.aisdk("@ai-sdk/openai"), }) catalog.provider.update(item.id, (draft) => { - draft.api = item.api + draft.package = item.package }) catalog.model.update(item.id, ModelV2.ID.make("gpt-5.5"), () => {}) catalog.model.update(item.id, ModelV2.ID.make("gpt-4.1"), () => {}) @@ -248,10 +252,10 @@ describe("OpenAIPlugin", () => { yield* catalog.transform((catalog) => { const item = ProviderV2.Info.make({ ...ProviderV2.Info.empty(ProviderV2.ID.make("custom-openai")), - api: { type: "aisdk", package: "test-provider" }, + package: ProviderV2.aisdk("test-provider"), }) catalog.provider.update(item.id, (draft) => { - draft.api = item.api + draft.package = item.package }) catalog.model.update(item.id, ModelV2.ID.make("gpt-5-chat-latest"), () => {}) }) diff --git a/packages/core/test/plugin/provider-opencode.test.ts b/packages/core/test/plugin/provider-opencode.test.ts index d2b1f2bdae..c8b3757d3d 100644 --- a/packages/core/test/plugin/provider-opencode.test.ts +++ b/packages/core/test/plugin/provider-opencode.test.ts @@ -172,13 +172,10 @@ describe("OpencodePlugin", () => { expect(provider).toMatchObject({ name: "Remote", integrationID: "opencode", - api: { - type: "aisdk", - package: "@ai-sdk/openai-compatible", - url: `${server.url.origin}/v1`, - }, + package: ProviderV2.aisdk("@ai-sdk/openai-compatible"), + settings: { baseURL: `${server.url.origin}/v1`, custom: "value" }, + headers: { "x-org-id": "org" }, }) - expect(provider.request).toEqual({ settings: {}, headers: { "x-org-id": "org" }, body: { custom: "value" } }) expect(yield* (yield* Integration.Service).get(Integration.ID.make("remote"))).toBeUndefined() const model = required(yield* catalog.model.get(ProviderV2.ID.make("remote"), ModelV2.ID.make("model"))) @@ -188,8 +185,10 @@ describe("OpencodePlugin", () => { capabilities: { tools: true, input: ["text", "image"], output: ["text"] }, cost: [{ input: 1, output: 2, cache: { read: 0.1, write: 0 } }], limit: { context: 1000, output: 100 }, + package: ProviderV2.aisdk("@ai-sdk/openai-compatible"), + settings: { baseURL: `${server.url.origin}/v1`, custom: "value", temperature: 0.5 }, + headers: { "x-org-id": "org" }, }) - expect(model.request.body).toEqual({ custom: "value", temperature: 0.5 }) expect(model.variants).toEqual([ { id: ModelV2.VariantID.make("custom"), @@ -199,9 +198,8 @@ describe("OpencodePlugin", () => { }, { id: ModelV2.VariantID.make("high"), - settings: {}, + settings: { temperature: 0.2 }, headers: {}, - body: { temperature: 0.2 }, }, ]) expect( @@ -221,11 +219,12 @@ describe("OpencodePlugin", () => { yield* catalog.transform((catalog) => { const provider = ProviderV2.Info.make({ ...ProviderV2.Info.empty(ProviderV2.ID.opencode), - api: { type: "aisdk", package: "test-provider" }, + package: ProviderV2.aisdk("test-provider"), }) const model = ModelV2.Info.make({ ...ModelV2.Info.empty(provider.id, ModelV2.ID.make("paid")), - api: { id: ModelV2.ID.make("paid"), type: "aisdk", package: "test-provider" }, + modelID: ModelV2.ID.make("paid"), + package: ProviderV2.aisdk("test-provider"), cost: cost(1), }) catalog.provider.update(provider.id, () => {}) @@ -234,7 +233,7 @@ describe("OpencodePlugin", () => { }) }) yield* addPlugin() - expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBe("public") + expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).settings?.apiKey).toBe("public") expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(false) }), ), @@ -247,11 +246,12 @@ describe("OpencodePlugin", () => { yield* catalog.transform((catalog) => { const provider = ProviderV2.Info.make({ ...ProviderV2.Info.empty(ProviderV2.ID.opencode), - api: { type: "aisdk", package: "test-provider" }, + package: ProviderV2.aisdk("test-provider"), }) const model = ModelV2.Info.make({ ...ModelV2.Info.empty(provider.id, ModelV2.ID.make("free")), - api: { id: ModelV2.ID.make("free"), type: "aisdk", package: "test-provider" }, + modelID: ModelV2.ID.make("free"), + package: ProviderV2.aisdk("test-provider"), cost: cost(0), }) catalog.provider.update(provider.id, () => {}) @@ -260,7 +260,7 @@ describe("OpencodePlugin", () => { }) }) yield* addPlugin() - expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBe("public") + expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).settings?.apiKey).toBe("public") expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("free"))).enabled).toBe(true) }), ), @@ -273,11 +273,12 @@ describe("OpencodePlugin", () => { yield* catalog.transform((catalog) => { const provider = ProviderV2.Info.make({ ...ProviderV2.Info.empty(ProviderV2.ID.opencode), - api: { type: "aisdk", package: "test-provider" }, + package: ProviderV2.aisdk("test-provider"), }) const model = ModelV2.Info.make({ ...ModelV2.Info.empty(provider.id, ModelV2.ID.make("output-only")), - api: { id: ModelV2.ID.make("output-only"), type: "aisdk", package: "test-provider" }, + modelID: ModelV2.ID.make("output-only"), + package: ProviderV2.aisdk("test-provider"), cost: cost(0, 1), }) catalog.provider.update(provider.id, () => {}) @@ -286,7 +287,7 @@ describe("OpencodePlugin", () => { }) }) yield* addPlugin() - expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBe("public") + expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).settings?.apiKey).toBe("public") expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("output-only"))).enabled).toBe( true, ) @@ -301,11 +302,12 @@ describe("OpencodePlugin", () => { yield* catalog.transform((catalog) => { const provider = ProviderV2.Info.make({ ...ProviderV2.Info.empty(ProviderV2.ID.opencode), - api: { type: "aisdk", package: "test-provider" }, + package: ProviderV2.aisdk("test-provider"), }) const model = ModelV2.Info.make({ ...ModelV2.Info.empty(provider.id, ModelV2.ID.make("paid")), - api: { id: ModelV2.ID.make("paid"), type: "aisdk", package: "test-provider" }, + modelID: ModelV2.ID.make("paid"), + package: ProviderV2.aisdk("test-provider"), cost: cost(1), }) catalog.provider.update(provider.id, () => {}) @@ -314,7 +316,7 @@ describe("OpencodePlugin", () => { }) }) yield* addPlugin() - expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBeUndefined() + expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).settings?.apiKey).toBeUndefined() expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(true) }), ), @@ -334,11 +336,12 @@ describe("OpencodePlugin", () => { yield* catalog.transform((catalog) => { const provider = ProviderV2.Info.make({ ...ProviderV2.Info.empty(ProviderV2.ID.opencode), - api: { type: "aisdk", package: "test-provider" }, + package: ProviderV2.aisdk("test-provider"), }) const model = ModelV2.Info.make({ ...ModelV2.Info.empty(provider.id, ModelV2.ID.make("paid")), - api: { id: ModelV2.ID.make("paid"), type: "aisdk", package: "test-provider" }, + modelID: ModelV2.ID.make("paid"), + package: ProviderV2.aisdk("test-provider"), cost: cost(1), }) catalog.provider.update(provider.id, () => {}) @@ -347,7 +350,7 @@ describe("OpencodePlugin", () => { }) }) yield* addPlugin() - expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBeUndefined() + expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).settings?.apiKey).toBeUndefined() expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(true) }), ), @@ -360,27 +363,25 @@ describe("OpencodePlugin", () => { yield* catalog.transform((catalog) => { const provider = ProviderV2.Info.make({ ...ProviderV2.Info.empty(ProviderV2.ID.opencode), - api: { type: "aisdk", package: "test-provider" }, - request: { - settings: {}, - headers: {}, - body: { apiKey: "configured" }, - }, + package: ProviderV2.aisdk("test-provider"), + settings: { apiKey: "configured" }, }) const model = ModelV2.Info.make({ ...ModelV2.Info.empty(provider.id, ModelV2.ID.make("paid")), - api: { id: ModelV2.ID.make("paid"), type: "aisdk", package: "test-provider" }, + modelID: ModelV2.ID.make("paid"), + package: ProviderV2.aisdk("test-provider"), cost: cost(1), }) catalog.provider.update(provider.id, (draft) => { - draft.request = { settings: {}, headers: {}, body: { apiKey: "configured" } } + draft.package = provider.package + draft.settings = { apiKey: "configured" } }) catalog.model.update(provider.id, model.id, (draft) => { draft.cost = [...model.cost] }) }) yield* addPlugin() - expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBe("configured") + expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).settings?.apiKey).toBe("configured") expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(true) }), ), @@ -393,11 +394,12 @@ describe("OpencodePlugin", () => { yield* catalog.transform((catalog) => { const provider = ProviderV2.Info.make({ ...ProviderV2.Info.empty(ProviderV2.ID.openai), - api: { type: "aisdk", package: "test-provider" }, + package: ProviderV2.aisdk("test-provider"), }) const model = ModelV2.Info.make({ ...ModelV2.Info.empty(provider.id, ModelV2.ID.make("paid")), - api: { id: ModelV2.ID.make("paid"), type: "aisdk", package: "test-provider" }, + modelID: ModelV2.ID.make("paid"), + package: ProviderV2.aisdk("test-provider"), cost: cost(1), }) catalog.provider.update(provider.id, () => {}) @@ -406,7 +408,7 @@ describe("OpencodePlugin", () => { }) }) yield* addPlugin() - expect(required(yield* catalog.provider.get(ProviderV2.ID.openai)).request.body.apiKey).toBeUndefined() + expect(required(yield* catalog.provider.get(ProviderV2.ID.openai)).settings?.apiKey).toBeUndefined() expect(required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("paid"))).enabled).toBe(true) }), ), diff --git a/packages/core/test/plugin/provider-openrouter.test.ts b/packages/core/test/plugin/provider-openrouter.test.ts index 60dfc67b51..21ee3ae216 100644 --- a/packages/core/test/plugin/provider-openrouter.test.ts +++ b/packages/core/test/plugin/provider-openrouter.test.ts @@ -30,19 +30,19 @@ describe("OpenRouterPlugin", () => { const catalog = yield* Catalog.Service yield* catalog.transform((catalog) => { catalog.provider.update(ProviderV2.ID.openrouter, (provider) => { - provider.api = { type: "aisdk", package: "@openrouter/ai-sdk-provider" } - provider.request = { settings: {}, headers: { Existing: "value" }, body: {} } + provider.package = ProviderV2.aisdk("@openrouter/ai-sdk-provider") + provider.headers = { Existing: "value" } }) catalog.provider.update(ProviderV2.ID.make("nvidia"), () => {}) }) yield* addPlugin() - expect((yield* catalog.provider.get(ProviderV2.ID.openrouter))?.request.headers).toEqual({ + expect((yield* catalog.provider.get(ProviderV2.ID.openrouter))?.headers).toEqual({ Existing: "value", "HTTP-Referer": "https://opencode.ai/", "X-Title": "opencode", }) - expect((yield* catalog.provider.get(ProviderV2.ID.make("nvidia")))?.request.headers).toEqual({}) + expect((yield* catalog.provider.get(ProviderV2.ID.make("nvidia")))?.headers).toBeUndefined() }), ) @@ -55,7 +55,8 @@ describe("OpenRouterPlugin", () => { const ignored = yield* aisdk.runSDK({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.openrouter, ModelV2.ID.make("openai/gpt-5")), - api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" }, + modelID: ModelV2.ID.make("openai/gpt-5"), + package: ProviderV2.aisdk("test-provider"), }), package: "@ai-sdk/openai-compatible", options: { name: "openrouter" }, @@ -65,7 +66,8 @@ describe("OpenRouterPlugin", () => { const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("openai/gpt-5")), - api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" }, + modelID: ModelV2.ID.make("openai/gpt-5"), + package: ProviderV2.aisdk("test-provider"), }), package: "@openrouter/ai-sdk-provider", options: { name: "custom" }, @@ -79,7 +81,7 @@ describe("OpenRouterPlugin", () => { const catalog = yield* Catalog.Service yield* catalog.transform((catalog) => { catalog.provider.update(ProviderV2.ID.openrouter, (provider) => { - provider.api = { type: "aisdk", package: "@openrouter/ai-sdk-provider" } + provider.package = ProviderV2.aisdk("@openrouter/ai-sdk-provider") }) catalog.provider.update(ProviderV2.ID.openai, () => {}) catalog.model.update(ProviderV2.ID.openrouter, ModelV2.ID.make("openai/gpt-5-chat"), () => {}) diff --git a/packages/core/test/plugin/provider-perplexity.test.ts b/packages/core/test/plugin/provider-perplexity.test.ts index a2a3969233..d66f5dd71c 100644 --- a/packages/core/test/plugin/provider-perplexity.test.ts +++ b/packages/core/test/plugin/provider-perplexity.test.ts @@ -41,7 +41,8 @@ describe("PerplexityPlugin", () => { const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("perplexity"), ModelV2.ID.make("sonar")), - api: { id: ModelV2.ID.make("sonar"), type: "aisdk", package: "test-provider" }, + modelID: ModelV2.ID.make("sonar"), + package: "aisdk:test-provider", }), package: "@ai-sdk/perplexity", options: { name: "perplexity" }, @@ -58,7 +59,8 @@ describe("PerplexityPlugin", () => { const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("perplexity"), ModelV2.ID.make("sonar")), - api: { id: ModelV2.ID.make("sonar"), type: "aisdk", package: "test-provider" }, + modelID: ModelV2.ID.make("sonar"), + package: "aisdk:test-provider", }), package: "@ai-sdk/perplexity-compatible", options: { name: "perplexity" }, @@ -75,7 +77,8 @@ describe("PerplexityPlugin", () => { const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("perplexity"), ModelV2.ID.make("sonar")), - api: { id: ModelV2.ID.make("sonar"), type: "aisdk", package: "test-provider" }, + modelID: ModelV2.ID.make("sonar"), + package: "aisdk:test-provider", }), package: "@ai-sdk/perplexity", options: { name: "perplexity" }, @@ -92,7 +95,8 @@ describe("PerplexityPlugin", () => { const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("custom-perplexity"), ModelV2.ID.make("sonar")), - api: { id: ModelV2.ID.make("sonar"), type: "aisdk", package: "test-provider" }, + modelID: ModelV2.ID.make("sonar"), + package: "aisdk:test-provider", }), package: "@ai-sdk/perplexity", options: { name: "custom-perplexity" }, @@ -110,7 +114,8 @@ describe("PerplexityPlugin", () => { const result = yield* aisdk.runLanguage({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("perplexity"), ModelV2.ID.make("alias")), - api: { id: ModelV2.ID.make("sonar"), type: "aisdk", package: "test-provider" }, + modelID: ModelV2.ID.make("sonar"), + package: "aisdk:test-provider", }), sdk: fakeSelectorSdk(calls), options: {}, diff --git a/packages/core/test/plugin/provider-sap-ai-core.test.ts b/packages/core/test/plugin/provider-sap-ai-core.test.ts index 5056c5dbb7..8822866be8 100644 --- a/packages/core/test/plugin/provider-sap-ai-core.test.ts +++ b/packages/core/test/plugin/provider-sap-ai-core.test.ts @@ -49,7 +49,8 @@ function withEnv(vars: Record, effect: () = function model(providerID: string) { return ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make(providerID), ModelV2.ID.make("sap-model")), - api: { id: ModelV2.ID.make("sap-model"), type: "aisdk", package: fixtureProvider }, + modelID: ModelV2.ID.make("sap-model"), + package: ProviderV2.aisdk(fixtureProvider), }) } diff --git a/packages/core/test/plugin/provider-snowflake-cortex.test.ts b/packages/core/test/plugin/provider-snowflake-cortex.test.ts index 4de00105b7..92af623756 100644 --- a/packages/core/test/plugin/provider-snowflake-cortex.test.ts +++ b/packages/core/test/plugin/provider-snowflake-cortex.test.ts @@ -59,7 +59,8 @@ describe("SnowflakeCortexPlugin", () => { const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("openai"), ModelV2.ID.make("gpt-4")), - api: { id: ModelV2.ID.make("gpt-4"), type: "aisdk", package: "test-provider" }, + modelID: ModelV2.ID.make("gpt-4"), + package: "aisdk:test-provider", }), package: "@ai-sdk/openai", options: { name: "openai" }, @@ -77,7 +78,8 @@ describe("SnowflakeCortexPlugin", () => { const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("snowflake-cortex"), ModelV2.ID.make("claude-sonnet-4-6")), - api: { id: ModelV2.ID.make("claude-sonnet-4-6"), type: "aisdk", package: "test-provider" }, + modelID: ModelV2.ID.make("claude-sonnet-4-6"), + package: "aisdk:test-provider", }), package: "@ai-sdk/openai-compatible", options: { name: "snowflake-cortex", baseURL: "https://test.snowflakecomputing.com/api/v2/cortex/v1" }, @@ -96,7 +98,8 @@ describe("SnowflakeCortexPlugin", () => { const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("snowflake-cortex"), ModelV2.ID.make("claude-sonnet-4-6")), - api: { id: ModelV2.ID.make("claude-sonnet-4-6"), type: "aisdk", package: "test-provider" }, + modelID: ModelV2.ID.make("claude-sonnet-4-6"), + package: "aisdk:test-provider", }), package: "@ai-sdk/openai-compatible", options: { @@ -119,7 +122,8 @@ describe("SnowflakeCortexPlugin", () => { const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("snowflake-cortex"), ModelV2.ID.make("claude-sonnet-4-6")), - api: { id: ModelV2.ID.make("claude-sonnet-4-6"), type: "aisdk", package: "test-provider" }, + modelID: ModelV2.ID.make("claude-sonnet-4-6"), + package: "aisdk:test-provider", }), package: "@ai-sdk/openai-compatible", options: { name: "snowflake-cortex", baseURL: "https://test.snowflakecomputing.com/api/v2/cortex/v1" }, @@ -138,7 +142,8 @@ describe("SnowflakeCortexPlugin", () => { const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("snowflake-cortex"), ModelV2.ID.make("claude-sonnet-4-6")), - api: { id: ModelV2.ID.make("claude-sonnet-4-6"), type: "aisdk", package: "test-provider" }, + modelID: ModelV2.ID.make("claude-sonnet-4-6"), + package: "aisdk:test-provider", }), package: "@ai-sdk/openai-compatible", options: { @@ -161,7 +166,8 @@ describe("SnowflakeCortexPlugin", () => { const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("snowflake-cortex"), ModelV2.ID.make("claude-sonnet-4-6")), - api: { id: ModelV2.ID.make("claude-sonnet-4-6"), type: "aisdk", package: "test-provider" }, + modelID: ModelV2.ID.make("claude-sonnet-4-6"), + package: "aisdk:test-provider", }), package: "@ai-sdk/openai-compatible", options: { name: "snowflake-cortex", baseURL: "https://test.snowflakecomputing.com/api/v2/cortex/v1" }, diff --git a/packages/core/test/plugin/provider-togetherai.test.ts b/packages/core/test/plugin/provider-togetherai.test.ts index 56bf7c9642..1fffb03156 100644 --- a/packages/core/test/plugin/provider-togetherai.test.ts +++ b/packages/core/test/plugin/provider-togetherai.test.ts @@ -41,7 +41,8 @@ describe("TogetherAIPlugin", () => { const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("togetherai"), ModelV2.ID.make("model")), - api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, + modelID: ModelV2.ID.make("model"), + package: "aisdk:test-provider", }), package: "@ai-sdk/togetherai", options: { name: "togetherai" }, @@ -59,7 +60,8 @@ describe("TogetherAIPlugin", () => { const ignored = yield* aisdk.runSDK({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("togetherai"), ModelV2.ID.make("model")), - api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, + modelID: ModelV2.ID.make("model"), + package: "aisdk:test-provider", }), package: "file:///tmp/@ai-sdk/togetherai-provider.js", options: { name: "togetherai" }, @@ -69,7 +71,8 @@ describe("TogetherAIPlugin", () => { const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("togetherai"), ModelV2.ID.make("model")), - api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, + modelID: ModelV2.ID.make("model"), + package: "aisdk:test-provider", }), package: "@ai-sdk/togetherai", options: { name: "togetherai" }, @@ -87,7 +90,8 @@ describe("TogetherAIPlugin", () => { const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("custom-togetherai"), ModelV2.ID.make("model")), - api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, + modelID: ModelV2.ID.make("model"), + package: "aisdk:test-provider", }), package: "@ai-sdk/togetherai", options: { name: "custom-togetherai" }, @@ -110,11 +114,8 @@ describe("TogetherAIPlugin", () => { ProviderV2.ID.make("togetherai"), ModelV2.ID.make("meta-llama/Llama-3.3-70B-Instruct-Turbo"), ), - api: { - id: ModelV2.ID.make("meta-llama/Llama-3.3-70B-Instruct-Turbo"), - type: "aisdk", - package: "test-provider", - }, + modelID: ModelV2.ID.make("meta-llama/Llama-3.3-70B-Instruct-Turbo"), + package: "aisdk:test-provider", }), sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, options: {}, @@ -122,7 +123,9 @@ describe("TogetherAIPlugin", () => { expect(result.language).toBeUndefined() expect(calls).toEqual([]) - expect(result.language ?? fakeSelectorSdk(calls).languageModel(result.model.api.id)).toBeDefined() + expect( + result.language ?? fakeSelectorSdk(calls).languageModel(result.model.modelID ?? result.model.id), + ).toBeDefined() expect(calls).toEqual(["languageModel:meta-llama/Llama-3.3-70B-Instruct-Turbo"]) }), ) diff --git a/packages/core/test/plugin/provider-venice.test.ts b/packages/core/test/plugin/provider-venice.test.ts index 3ed6c711c4..057d2c6ded 100644 --- a/packages/core/test/plugin/provider-venice.test.ts +++ b/packages/core/test/plugin/provider-venice.test.ts @@ -41,7 +41,8 @@ describe("VenicePlugin", () => { const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("venice"), ModelV2.ID.make("model")), - api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, + modelID: ModelV2.ID.make("model"), + package: "aisdk:test-provider", }), package: "venice-ai-sdk-provider", options: { name: "venice" }, @@ -58,7 +59,8 @@ describe("VenicePlugin", () => { const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("custom-venice"), ModelV2.ID.make("model")), - api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, + modelID: ModelV2.ID.make("model"), + package: "aisdk:test-provider", }), package: "venice-ai-sdk-provider", options: { name: "custom-venice", apiKey: "test" }, @@ -76,7 +78,8 @@ describe("VenicePlugin", () => { const similar = yield* aisdk.runSDK({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("venice"), ModelV2.ID.make("model")), - api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, + modelID: ModelV2.ID.make("model"), + package: "aisdk:test-provider", }), package: "file:///tmp/venice-ai-sdk-provider.js", options: { name: "venice" }, @@ -84,7 +87,8 @@ describe("VenicePlugin", () => { const other = yield* aisdk.runSDK({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("venice"), ModelV2.ID.make("model")), - api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, + modelID: ModelV2.ID.make("model"), + package: "aisdk:test-provider", }), package: "@ai-sdk/openai-compatible", options: { name: "venice" }, @@ -103,7 +107,8 @@ describe("VenicePlugin", () => { const result = yield* aisdk.runLanguage({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("venice"), ModelV2.ID.make("alias")), - api: { id: ModelV2.ID.make("alias"), type: "aisdk", package: "test-provider" }, + modelID: ModelV2.ID.make("alias"), + package: "aisdk:test-provider", }), sdk: fakeSelectorSdk(calls), options: {}, diff --git a/packages/core/test/plugin/provider-vercel.test.ts b/packages/core/test/plugin/provider-vercel.test.ts index eb87b15132..46d5fe25b8 100644 --- a/packages/core/test/plugin/provider-vercel.test.ts +++ b/packages/core/test/plugin/provider-vercel.test.ts @@ -25,12 +25,12 @@ describe("VercelPlugin", () => { const catalog = yield* Catalog.Service yield* catalog.transform((catalog) => { catalog.provider.update(ProviderV2.ID.make("vercel"), (provider) => { - provider.api = { type: "aisdk", package: "@ai-sdk/vercel" } - provider.request.headers.Existing = "1" + provider.package = ProviderV2.aisdk("@ai-sdk/vercel") + provider.headers = { ...provider.headers, Existing: "1" } }) }) yield* addPlugin() - expect((yield* catalog.provider.get(ProviderV2.ID.make("vercel")))?.request.headers).toEqual({ + expect((yield* catalog.provider.get(ProviderV2.ID.make("vercel")))?.headers).toEqual({ Existing: "1", "http-referer": "https://opencode.ai/", "x-title": "opencode", @@ -43,14 +43,12 @@ describe("VercelPlugin", () => { const catalog = yield* Catalog.Service yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.make("vercel"), (provider) => { - provider.api = { type: "aisdk", package: "@ai-sdk/vercel" } + provider.package = ProviderV2.aisdk("@ai-sdk/vercel") }), ) yield* addPlugin() - expect((yield* catalog.provider.get(ProviderV2.ID.make("vercel")))?.request.headers).not.toHaveProperty( - "HTTP-Referer", - ) - expect((yield* catalog.provider.get(ProviderV2.ID.make("vercel")))?.request.headers).not.toHaveProperty("X-Title") + expect((yield* catalog.provider.get(ProviderV2.ID.make("vercel")))?.headers).not.toHaveProperty("HTTP-Referer") + expect((yield* catalog.provider.get(ProviderV2.ID.make("vercel")))?.headers).not.toHaveProperty("X-Title") }), ) @@ -62,7 +60,8 @@ describe("VercelPlugin", () => { const event = yield* aisdk.runSDK({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("custom-vercel"), ModelV2.ID.make("v0-1.0-md")), - api: { id: ModelV2.ID.make("v0-1.0-md"), type: "aisdk", package: "@ai-sdk/vercel" }, + modelID: ModelV2.ID.make("v0-1.0-md"), + package: "aisdk:@ai-sdk/vercel", }), package: "@ai-sdk/vercel", options: { name: "custom-vercel" }, @@ -77,7 +76,7 @@ describe("VercelPlugin", () => { const catalog = yield* Catalog.Service yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.make("gateway"), () => {})) yield* addPlugin() - expect((yield* catalog.provider.get(ProviderV2.ID.make("gateway")))?.request.headers).toEqual({}) + expect((yield* catalog.provider.get(ProviderV2.ID.make("gateway")))?.headers).toBeUndefined() }), ) }) diff --git a/packages/core/test/plugin/provider-xai.test.ts b/packages/core/test/plugin/provider-xai.test.ts index c4d1a5ece3..94824af339 100644 --- a/packages/core/test/plugin/provider-xai.test.ts +++ b/packages/core/test/plugin/provider-xai.test.ts @@ -42,7 +42,8 @@ describe("XAIPlugin", () => { const ignored = yield* aisdk.runSDK({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("xai"), ModelV2.ID.make("grok-4")), - api: { id: ModelV2.ID.make("grok-4"), type: "aisdk", package: "@ai-sdk/xai" }, + modelID: ModelV2.ID.make("grok-4"), + package: "aisdk:@ai-sdk/xai", }), package: "@ai-sdk/openai-compatible", options: {}, @@ -51,7 +52,8 @@ describe("XAIPlugin", () => { const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("xai"), ModelV2.ID.make("grok-4")), - api: { id: ModelV2.ID.make("grok-4"), type: "aisdk", package: "@ai-sdk/xai" }, + modelID: ModelV2.ID.make("grok-4"), + package: "aisdk:@ai-sdk/xai", }), package: "@ai-sdk/xai", options: {}, @@ -71,7 +73,8 @@ describe("XAIPlugin", () => { const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("custom-xai"), ModelV2.ID.make("grok-4")), - api: { id: ModelV2.ID.make("grok-4"), type: "aisdk", package: "@ai-sdk/xai" }, + modelID: ModelV2.ID.make("grok-4"), + package: "aisdk:@ai-sdk/xai", }), package: "@ai-sdk/xai", options: {}, @@ -81,7 +84,7 @@ describe("XAIPlugin", () => { }), ) - it.effect("uses responses with the model api.id for xAI language models", () => + it.effect("uses responses with the model modelID for xAI language models", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service const aisdk = yield* AISDK.Service @@ -91,7 +94,8 @@ describe("XAIPlugin", () => { const result = yield* aisdk.runLanguage({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.make("xai"), ModelV2.ID.make("alias")), - api: { id: ModelV2.ID.make("grok-4"), type: "aisdk", package: "@ai-sdk/xai" }, + modelID: ModelV2.ID.make("grok-4"), + package: "aisdk:@ai-sdk/xai", }), sdk: fakeSelectorSdk(calls), options: {}, @@ -112,7 +116,8 @@ describe("XAIPlugin", () => { const result = yield* aisdk.runLanguage({ model: ModelV2.Info.make({ ...ModelV2.Info.empty(ProviderV2.ID.openai, ModelV2.ID.make("grok-4")), - api: { id: ModelV2.ID.make("grok-4"), type: "aisdk", package: "@ai-sdk/xai" }, + modelID: ModelV2.ID.make("grok-4"), + package: "aisdk:@ai-sdk/xai", }), sdk: fakeSelectorSdk(calls), options: {}, diff --git a/packages/core/test/plugin/provider-zenmux.test.ts b/packages/core/test/plugin/provider-zenmux.test.ts index 9c44800ebc..8f3bdd343a 100644 --- a/packages/core/test/plugin/provider-zenmux.test.ts +++ b/packages/core/test/plugin/provider-zenmux.test.ts @@ -32,17 +32,14 @@ describe("ZenmuxPlugin", () => { const catalog = yield* Catalog.Service yield* catalog.transform((catalog) => { catalog.provider.update(ProviderV2.ID.make("zenmux"), (provider) => { - provider.api = { - type: "aisdk", - package: "@ai-sdk/openai-compatible", - url: "https://zenmux.ai/api/v1", - } + provider.package = ProviderV2.aisdk("@ai-sdk/openai-compatible") + provider.settings = { ...provider.settings, baseURL: "https://zenmux.ai/api/v1" } }) }) yield* addPlugin() const result = required(yield* catalog.provider.get(ProviderV2.ID.make("zenmux"))) - expect(result.request.headers).toEqual({ "HTTP-Referer": "https://opencode.ai/", "X-Title": "opencode" }) - expect(Object.keys(result.request.headers).sort()).toEqual(["HTTP-Referer", "X-Title"]) + expect(result.headers).toEqual({ "HTTP-Referer": "https://opencode.ai/", "X-Title": "opencode" }) + expect(Object.keys(required(result.headers)).sort()).toEqual(["HTTP-Referer", "X-Title"]) }), ) @@ -51,17 +48,14 @@ describe("ZenmuxPlugin", () => { const catalog = yield* Catalog.Service yield* catalog.transform((catalog) => { catalog.provider.update(ProviderV2.ID.make("zenmux"), (provider) => { - provider.api = { - type: "aisdk", - package: "@ai-sdk/openai-compatible", - url: "https://zenmux.ai/api/v1", - } - provider.request.headers.Existing = "value" + provider.package = ProviderV2.aisdk("@ai-sdk/openai-compatible") + provider.settings = { ...provider.settings, baseURL: "https://zenmux.ai/api/v1" } + provider.headers = { ...provider.headers, Existing: "value" } }) }) yield* addPlugin() - expect(required(yield* catalog.provider.get(ProviderV2.ID.make("zenmux"))).request.headers).toEqual({ + expect(required(yield* catalog.provider.get(ProviderV2.ID.make("zenmux"))).headers).toEqual({ Existing: "value", "HTTP-Referer": "https://opencode.ai/", "X-Title": "opencode", @@ -74,17 +68,14 @@ describe("ZenmuxPlugin", () => { const catalog = yield* Catalog.Service yield* catalog.transform((catalog) => { catalog.provider.update(ProviderV2.ID.make("zenmux"), (provider) => { - provider.api = { - type: "aisdk", - package: "@ai-sdk/openai-compatible", - url: "https://zenmux.ai/api/v1", - } - provider.request.headers = { "HTTP-Referer": "https://example.com/", "X-Title": "custom-title" } + provider.package = ProviderV2.aisdk("@ai-sdk/openai-compatible") + provider.settings = { ...provider.settings, baseURL: "https://zenmux.ai/api/v1" } + provider.headers = { "HTTP-Referer": "https://example.com/", "X-Title": "custom-title" } }) }) yield* addPlugin() - expect(required(yield* catalog.provider.get(ProviderV2.ID.make("zenmux"))).request.headers).toEqual({ + expect(required(yield* catalog.provider.get(ProviderV2.ID.make("zenmux"))).headers).toEqual({ "HTTP-Referer": "https://example.com/", "X-Title": "custom-title", }) @@ -96,12 +87,12 @@ describe("ZenmuxPlugin", () => { const catalog = yield* Catalog.Service yield* catalog.transform((catalog) => { catalog.provider.update(ProviderV2.ID.openrouter, (provider) => { - provider.request.headers = { "HTTP-Referer": "https://example.com/", "X-Title": "custom-title" } + provider.headers = { "HTTP-Referer": "https://example.com/", "X-Title": "custom-title" } }) }) yield* addPlugin() - expect(required(yield* catalog.provider.get(ProviderV2.ID.openrouter)).request.headers).toEqual({ + expect(required(yield* catalog.provider.get(ProviderV2.ID.openrouter)).headers).toEqual({ "HTTP-Referer": "https://example.com/", "X-Title": "custom-title", }) diff --git a/packages/core/test/plugin/variant.test.ts b/packages/core/test/plugin/variant.test.ts index 61f392caf8..5f3dafd209 100644 --- a/packages/core/test/plugin/variant.test.ts +++ b/packages/core/test/plugin/variant.test.ts @@ -24,14 +24,11 @@ describe("VariantPlugin", () => { const service = yield* Catalog.Service yield* service.transform((catalog) => { catalog.provider.update(ProviderV2.ID.opencode, (provider) => { - provider.api = { type: "aisdk", package: "@ai-sdk/openai-compatible" } + provider.package = ProviderV2.aisdk("@ai-sdk/openai-compatible") }) catalog.model.update(ProviderV2.ID.opencode, ModelV2.ID.make("glm-5.2"), (model) => { - model.api = { - id: ModelV2.ID.make("glm-5.2"), - type: "aisdk", - package: "@ai-sdk/openai-compatible", - } + model.modelID = ModelV2.ID.make("glm-5.2") + model.package = ProviderV2.aisdk("@ai-sdk/openai-compatible") }) }) yield* VariantPlugin.Plugin.effect(host({ catalog: catalogHost(service) })) @@ -48,11 +45,8 @@ describe("VariantPlugin", () => { const service = yield* Catalog.Service yield* service.transform((catalog) => { catalog.model.update(ProviderV2.ID.opencode, ModelV2.ID.make("glm-5.2"), (model) => { - model.api = { - id: ModelV2.ID.make("glm-5.2"), - type: "aisdk", - package: "@ai-sdk/openai-compatible", - } + model.modelID = ModelV2.ID.make("glm-5.2") + model.package = ProviderV2.aisdk("@ai-sdk/openai-compatible") model.variants = [{ id: ModelV2.VariantID.make("high"), settings: {}, headers: { custom: "true" }, body: {} }] }) }) diff --git a/packages/core/test/session-instructions.test.ts b/packages/core/test/session-instructions.test.ts index 19e717c836..854cd73d4b 100644 --- a/packages/core/test/session-instructions.test.ts +++ b/packages/core/test/session-instructions.test.ts @@ -2,8 +2,7 @@ import { describe, expect, test } from "bun:test" import fs from "fs/promises" import path from "path" import { DateTime, Effect, Layer } from "effect" -import { Message, Model } from "@opencode-ai/llm" -import * as OpenAIChat from "@opencode-ai/llm/protocols/openai-chat" +import { Message } from "@opencode-ai/llm" import { AgentV2 } from "@opencode-ai/core/agent" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/core/effect/layer-node" @@ -300,7 +299,7 @@ describe("SessionInstructions", () => { test("toLLMMessages does not forward synthetic metadata to the provider", () => { const created = DateTime.makeUnsafe(0) - const model = Model.make({ id: "model", provider: "provider", route: OpenAIChat.route }) + const model = ModelV2.Ref.make({ id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") }) const synthetic = SessionMessage.Synthetic.make({ id: SessionMessage.ID.make("msg_synthetic"), type: "synthetic", diff --git a/packages/core/test/session-runner-message.test.ts b/packages/core/test/session-runner-message.test.ts index 469d5e0360..f415614cf5 100644 --- a/packages/core/test/session-runner-message.test.ts +++ b/packages/core/test/session-runner-message.test.ts @@ -1,6 +1,5 @@ import { describe, expect, test } from "bun:test" -import { Message, Model } from "@opencode-ai/llm" -import * as OpenAIChat from "@opencode-ai/llm/protocols/openai-chat" +import { Message } from "@opencode-ai/llm" import { ModelV2 } from "@opencode-ai/core/model" import { ProviderV2 } from "@opencode-ai/core/provider" import { SessionMessage } from "@opencode-ai/core/session/message" @@ -12,7 +11,7 @@ import { DateTime } from "effect" const created = DateTime.makeUnsafe(0) const id = (value: string) => SessionMessage.ID.make(`msg_${value}`) -const model = Model.make({ id: "model", provider: "provider", route: OpenAIChat.route }) +const model = ModelV2.Ref.make({ id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") }) describe("toLLMMessages", () => { test("omits empty assistant turns", () => { @@ -609,4 +608,35 @@ Recent work }, ]) }) + + test("preserves provider metadata for a catalog alias with a different API model ID", () => { + const messages = toLLMMessages( + [ + SessionMessage.Assistant.make({ + id: id("assistant-alias"), + type: "assistant", + agent: "build", + model: { id: ModelV2.ID.make("fast"), providerID: ProviderV2.ID.make("provider") }, + content: [ + SessionMessage.AssistantReasoning.make({ + type: "reasoning", + id: "reasoning-alias", + text: "Visible thought", + providerMetadata: { openai: { reasoningEncryptedContent: "encrypted" } }, + }), + ], + time: { created, completed: created }, + }), + ], + ModelV2.Ref.make({ id: ModelV2.ID.make("fast"), providerID: ProviderV2.ID.make("provider") }), + ) + + expect(messages[0]?.content).toEqual([ + { + type: "reasoning", + text: "Visible thought", + providerMetadata: { openai: { reasoningEncryptedContent: "encrypted" } }, + }, + ]) + }) }) diff --git a/packages/core/test/session-runner-model.test.ts b/packages/core/test/session-runner-model.test.ts index 6d11bd877a..14bfc37bbe 100644 --- a/packages/core/test/session-runner-model.test.ts +++ b/packages/core/test/session-runner-model.test.ts @@ -1,5 +1,5 @@ import { describe, expect } from "bun:test" -import { LLM } from "@opencode-ai/llm" +import { LLM, Model } from "@opencode-ai/llm" import { LLMClient } from "@opencode-ai/llm/route" import { DateTime, Effect } from "effect" import { Headers } from "effect/unstable/http" @@ -13,28 +13,26 @@ import { SessionV2 } from "@opencode-ai/core/session" import { AbsolutePath } from "@opencode-ai/core/schema" import { it } from "./lib/effect" -type Api = - | { - readonly type: "aisdk" - readonly package: string - readonly url?: string - readonly settings?: Record - } - | { readonly type: "native"; readonly url?: string; readonly settings: Record } +interface ModelOptions { + readonly modelID?: string + readonly settings?: ModelV2.Info["settings"] + readonly headers?: ModelV2.Info["headers"] + readonly body?: ModelV2.Info["body"] + readonly variants?: ModelV2.Info["variants"] +} -const model = (api: Api, variants: ModelV2.Info["variants"] = []) => +const model = (packageName: string | undefined, options: ModelOptions = {}) => ModelV2.Info.make({ id: ModelV2.ID.make("test-model"), + modelID: ModelV2.ID.make(options.modelID ?? "api-test-model"), providerID: ProviderV2.ID.make("test-provider"), name: "Test model", - api: { id: ModelV2.ID.make("api-test-model"), ...api }, + package: packageName, + settings: options.settings ?? {}, + headers: options.headers ?? { "x-test": "header" }, + body: options.body ?? { custom_extension: { enabled: true } }, capabilities: { tools: true, input: ["text"], output: ["text"] }, - request: { - settings: {}, - headers: { "x-test": "header" }, - body: { apiKey: "secret", custom_extension: { enabled: true } }, - }, - variants, + variants: options.variants ?? [], time: { released: 0 }, cost: [], status: "active", @@ -43,12 +41,14 @@ const model = (api: Api, variants: ModelV2.Info["variants"] = []) => }) describe("SessionRunnerModel", () => { - it.effect("maps catalog OpenAI AI SDK models into native Responses routes", () => + it.effect("uses the API modelID instead of the catalog ID for native OpenAI routes", () => Effect.gen(function* () { - const resolved = yield* SessionRunnerModel.fromCatalogModel( - model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }), - ) + const catalog = model(ProviderV2.aisdk("@ai-sdk/openai"), { + settings: { baseURL: "https://openai.example/v1" }, + }) + const resolved = yield* SessionRunnerModel.fromCatalogModel(catalog) + expect(catalog.id).toBe(ModelV2.ID.make("test-model")) expect(resolved).toMatchObject({ id: "api-test-model", provider: "test-provider" }) expect(resolved.route).toMatchObject({ id: "openai-responses", @@ -65,7 +65,9 @@ describe("SessionRunnerModel", () => { it.effect("keeps catalog apiKey credentials out of provider JSON", () => Effect.gen(function* () { const resolved = yield* SessionRunnerModel.fromCatalogModel( - model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }), + model(ProviderV2.aisdk("@ai-sdk/openai"), { + settings: { apiKey: "secret", baseURL: "https://openai.example/v1" }, + }), ) const prepared = yield* LLMClient.prepare(LLM.request({ model: resolved, prompt: "Hello" })) @@ -77,14 +79,14 @@ describe("SessionRunnerModel", () => { it.effect("uses merged API settings for OpenAI-compatible auth and request defaults", () => Effect.gen(function* () { const resolved = yield* SessionRunnerModel.fromCatalogModel( - ModelV2.Info.make({ - ...model({ - type: "aisdk", - package: "@ai-sdk/openai-compatible", - url: "https://compatible.example/v1", - settings: { apiKey: "settings-secret", compatibility: "strict" }, - }), - request: { settings: {}, headers: {}, body: {} }, + model(ProviderV2.aisdk("@ai-sdk/openai-compatible"), { + settings: { + apiKey: "settings-secret", + baseURL: "https://compatible.example/v1", + compatibility: "strict", + }, + headers: {}, + body: {}, }), ) const request = LLM.request({ model: resolved, prompt: "Hello" }) @@ -97,24 +99,29 @@ describe("SessionRunnerModel", () => { }) expect(headers.authorization).toBe("Bearer settings-secret") + expect(resolved.route.id).toBe("openai-compatible-chat") + expect(resolved.route.endpoint.baseURL).toBe("https://compatible.example/v1") expect(resolved.route.defaults.http?.body).toEqual({}) }), ) it.effect("overlays selected OpenAI Session variant settings and bodies", () => Effect.gen(function* () { - const catalog = model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }, [ - { - id: ModelV2.VariantID.make("high"), - settings: { reasoningEffort: "high" }, - headers: { "x-variant": "high" }, - body: { - store: false, - service_tier: "priority", - temperature: 0.2, + const catalog = model(ProviderV2.aisdk("@ai-sdk/openai"), { + settings: { baseURL: "https://openai.example/v1" }, + variants: [ + { + id: ModelV2.VariantID.make("high"), + settings: { reasoningEffort: "high" }, + headers: { "x-variant": "high" }, + body: { + store: false, + service_tier: "priority", + temperature: 0.2, + }, }, - }, - ]) + ], + }) const session = SessionV2.Info.make({ id: SessionV2.ID.make("ses_model_variant"), projectID: ProjectV2.ID.global, @@ -147,9 +154,9 @@ describe("SessionRunnerModel", () => { it.effect("overlays selected OpenAI-compatible Session variant bodies", () => Effect.gen(function* () { - const catalog = model( - { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://compatible.example/v1" }, - [ + const catalog = model(ProviderV2.aisdk("@ai-sdk/openai-compatible"), { + settings: { baseURL: "https://compatible.example/v1" }, + variants: [ { id: ModelV2.VariantID.make("high"), settings: {}, @@ -157,7 +164,7 @@ describe("SessionRunnerModel", () => { body: { store: false, reasoning_effort: "high" }, }, ], - ) + }) const session = SessionV2.Info.make({ id: SessionV2.ID.make("ses_compatible_variant"), projectID: ProjectV2.ID.global, @@ -181,7 +188,9 @@ describe("SessionRunnerModel", () => { it.effect("rejects an explicit unavailable Session variant during model resolution", () => Effect.gen(function* () { - const catalog = model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }) + const catalog = model(ProviderV2.aisdk("@ai-sdk/openai"), { + settings: { baseURL: "https://openai.example/v1" }, + }) const session = SessionV2.Info.make({ id: SessionV2.ID.make("ses_model_variant_unavailable"), projectID: ProjectV2.ID.global, @@ -211,14 +220,17 @@ describe("SessionRunnerModel", () => { it.effect("overlays selected Anthropic Session variant settings", () => Effect.gen(function* () { - const catalog = model({ type: "aisdk", package: "@ai-sdk/anthropic", url: "https://anthropic.example/v1" }, [ - { - id: ModelV2.VariantID.make("high"), - settings: { thinking: { type: "enabled", budgetTokens: 12000 } }, - headers: {}, - body: {}, - }, - ]) + const catalog = model(ProviderV2.aisdk("@ai-sdk/anthropic"), { + settings: { baseURL: "https://anthropic.example/v1" }, + variants: [ + { + id: ModelV2.VariantID.make("high"), + settings: { thinking: { type: "enabled", budgetTokens: 12000 } }, + headers: {}, + body: {}, + }, + ], + }) const session = SessionV2.Info.make({ id: SessionV2.ID.make("ses_anthropic_variant"), projectID: ProjectV2.ID.global, @@ -244,7 +256,9 @@ describe("SessionRunnerModel", () => { it.effect("maps catalog Anthropic AI SDK models into native routes", () => Effect.gen(function* () { const resolved = yield* SessionRunnerModel.fromCatalogModel( - model({ type: "aisdk", package: "@ai-sdk/anthropic", url: "https://anthropic.example/v1" }), + model(ProviderV2.aisdk("@ai-sdk/anthropic"), { + settings: { baseURL: "https://anthropic.example/v1" }, + }), ) expect(resolved.route).toMatchObject({ @@ -257,9 +271,10 @@ describe("SessionRunnerModel", () => { it.effect("uses resolved credentials for bearer auth", () => Effect.gen(function* () { const resolved = yield* SessionRunnerModel.fromCatalogModel( - ModelV2.Info.make({ - ...model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }), - request: { settings: {}, headers: {}, body: {} }, + model(ProviderV2.aisdk("@ai-sdk/openai"), { + settings: { baseURL: "https://openai.example/v1" }, + headers: {}, + body: {}, }), Credential.Key.make({ type: "key", key: "secret" }), ) @@ -280,9 +295,10 @@ describe("SessionRunnerModel", () => { Effect.gen(function* () { const credential = Credential.Key.make({ type: "key", key: "stored-secret", metadata: { tenant: "work" } }) const resolved = yield* SessionRunnerModel.fromCatalogModel( - ModelV2.Info.make({ - ...model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }), - request: { settings: {}, headers: {}, body: { apiKey: "configured-secret" } }, + model(ProviderV2.aisdk("@ai-sdk/openai"), { + settings: { apiKey: "configured-secret", baseURL: "https://openai.example/v1" }, + headers: {}, + body: {}, }), credential, ) @@ -302,9 +318,10 @@ describe("SessionRunnerModel", () => { it.effect("does not project OAuth account metadata into the request body", () => Effect.gen(function* () { const resolved = yield* SessionRunnerModel.fromCatalogModel( - ModelV2.Info.make({ - ...model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }), - request: { settings: {}, headers: {}, body: {} }, + model(ProviderV2.aisdk("@ai-sdk/openai"), { + settings: { baseURL: "https://openai.example/v1" }, + headers: {}, + body: {}, }), Credential.OAuth.make({ type: "oauth", @@ -323,9 +340,10 @@ describe("SessionRunnerModel", () => { it.effect("routes ChatGPT OAuth credentials to the codex backend", () => Effect.gen(function* () { const resolved = yield* SessionRunnerModel.fromCatalogModel( - ModelV2.Info.make({ - ...model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }), - request: { headers: {}, body: {} }, + model(ProviderV2.aisdk("@ai-sdk/openai"), { + settings: { baseURL: "https://openai.example/v1" }, + headers: {}, + body: {}, }), Credential.OAuth.make({ type: "oauth", @@ -354,12 +372,78 @@ describe("SessionRunnerModel", () => { }), ) + it.effect("routes native OpenAI provider packages with ChatGPT credentials to the codex backend", () => + Effect.gen(function* () { + const resolved = yield* SessionRunnerModel.fromCatalogModel( + model("@opencode-ai/llm/providers/openai", { + settings: { baseURL: "https://openai.example/v1" }, + }), + Credential.OAuth.make({ + type: "oauth", + methodID: Integration.MethodID.make("chatgpt-browser"), + access: "chatgpt-token", + refresh: "refresh", + expires: Date.now() + 60_000, + metadata: { accountID: "acct_123" }, + }), + ) + const headers = yield* resolved.route.auth.apply({ + request: LLM.request({ model: resolved, prompt: "Hello" }), + method: "POST", + url: "https://chatgpt.com/backend-api/codex/responses", + body: "{}", + headers: Headers.empty, + }) + + expect(resolved.route.endpoint.baseURL).toBe("https://chatgpt.com/backend-api/codex") + expect(headers.authorization).toBe("Bearer chatgpt-token") + expect(headers["chatgpt-account-id"]).toBe("acct_123") + }), + ) + + it.effect("does not route native OpenAI-compatible packages to the codex backend", () => + Effect.gen(function* () { + const resolved = yield* SessionRunnerModel.fromCatalogModel( + model("@opencode-ai/llm/providers/openai-compatible", { + settings: { baseURL: "https://compatible.example/v1" }, + }), + Credential.OAuth.make({ + type: "oauth", + methodID: Integration.MethodID.make("chatgpt-browser"), + access: "chatgpt-token", + refresh: "refresh", + expires: Date.now() + 60_000, + metadata: { accountID: "acct_123" }, + }), + ) + + expect(resolved.route.id).toBe("openai-compatible-chat") + expect(resolved.route.endpoint.baseURL).toBe("https://compatible.example/v1") + }), + ) + + it.effect("maps legacy OpenAI organization and project settings to headers", () => + Effect.gen(function* () { + const resolved = yield* SessionRunnerModel.fromCatalogModel( + model(ProviderV2.aisdk("@ai-sdk/openai"), { + settings: { organization: "org_123", project: "proj_123" }, + }), + ) + + expect(resolved.route.defaults.headers).toMatchObject({ + "OpenAI-Organization": "org_123", + "OpenAI-Project": "proj_123", + }) + }), + ) + it.effect("routes ChatGPT OAuth credentials without an account id to the codex backend", () => Effect.gen(function* () { const resolved = yield* SessionRunnerModel.fromCatalogModel( - ModelV2.Info.make({ - ...model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }), - request: { headers: {}, body: {} }, + model(ProviderV2.aisdk("@ai-sdk/openai"), { + settings: { baseURL: "https://openai.example/v1" }, + headers: {}, + body: {}, }), Credential.OAuth.make({ type: "oauth", @@ -387,9 +471,10 @@ describe("SessionRunnerModel", () => { it.effect("keeps non-ChatGPT OAuth credentials on the configured endpoint", () => Effect.gen(function* () { const resolved = yield* SessionRunnerModel.fromCatalogModel( - ModelV2.Info.make({ - ...model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }), - request: { headers: {}, body: {} }, + model(ProviderV2.aisdk("@ai-sdk/openai"), { + settings: { baseURL: "https://openai.example/v1" }, + headers: {}, + body: {}, }), Credential.OAuth.make({ type: "oauth", @@ -415,35 +500,106 @@ describe("SessionRunnerModel", () => { }), ) - it.effect("rejects catalog APIs without a native route", () => + it.effect("loads dynamic native provider packages through the injected package loader", () => + Effect.gen(function* () { + const native = yield* SessionRunnerModel.fromCatalogModel( + model(ProviderV2.aisdk("@ai-sdk/openai"), { + settings: { baseURL: "https://openai.example/v1" }, + }), + ) + const resolved = yield* SessionRunnerModel.fromCatalogModel( + model("@opencode-ai/llm/providers/custom", { + settings: { region: "test" }, + headers: { "x-package": "header" }, + body: { custom: true }, + }), + undefined, + { + loadPackage: (specifier) => { + expect(specifier).toBe("@opencode-ai/llm/providers/custom") + return Effect.succeed({ + model: (modelID, settings) => { + expect(modelID).toBe("api-test-model") + expect(settings).toEqual({ + region: "test", + headers: { "x-package": "header" }, + body: { custom: true }, + limits: { context: 100, output: 20 }, + }) + return Model.make({ id: modelID, provider: "package-provider", route: native.route }) + }, + }) + }, + }, + ) + + expect(resolved).toMatchObject({ id: "api-test-model", provider: "test-provider" }) + }), + ) + + it.effect("loads arbitrary AISDK packages through the injected AISDK loader", () => + Effect.gen(function* () { + const native = yield* SessionRunnerModel.fromCatalogModel( + model(ProviderV2.aisdk("@ai-sdk/openai"), { + settings: { baseURL: "https://openai.example/v1" }, + }), + ) + const resolved = yield* SessionRunnerModel.fromCatalogModel( + model(ProviderV2.aisdk("@ai-sdk/google"), { + modelID: "gemini-api-model", + settings: { project: "test" }, + headers: { "x-aisdk": "header" }, + body: { custom: true }, + }), + Credential.Key.make({ type: "key", key: "fallback-secret" }), + { + loadAISDK: (runtime) => + Effect.sync(() => { + expect(runtime).toMatchObject({ + id: "test-model", + modelID: "gemini-api-model", + providerID: "test-provider", + package: ProviderV2.aisdk("@ai-sdk/google"), + settings: { project: "test", apiKey: "fallback-secret" }, + headers: { "x-aisdk": "header" }, + body: { custom: true }, + }) + return Model.make({ + id: runtime.modelID ?? runtime.id, + provider: runtime.providerID, + route: native.route, + }) + }), + }, + ) + + expect(resolved).toMatchObject({ id: "gemini-api-model", provider: "test-provider" }) + }), + ) + + it.effect("rejects AISDK packages without an available loader", () => Effect.gen(function* () { const failure = yield* SessionRunnerModel.fromCatalogModel( - model({ type: "aisdk", package: "@ai-sdk/google", url: "https://google.example/v1" }), + model(ProviderV2.aisdk("@ai-sdk/google"), { + settings: { baseURL: "https://google.example/v1" }, + }), ).pipe(Effect.flip) expect(failure).toMatchObject({ - _tag: "SessionRunnerModel.UnsupportedApiError", + _tag: "SessionRunnerModel.UnsupportedPackageError", providerID: "test-provider", modelID: "test-model", - api: "aisdk:@ai-sdk/google", + package: "aisdk:@ai-sdk/google", }) - expect(failure.message).toBe("Unsupported API for test-provider/test-model: aisdk:@ai-sdk/google") + expect(failure.message).toBe("Unsupported package for test-provider/test-model: aisdk:@ai-sdk/google") }), ) - it.effect("reports whether a catalog model has a supported native route", () => + it.effect("reports whether a catalog model declares a provider package", () => Effect.sync(() => { - expect( - SessionRunnerModel.supported( - model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }), - ), - ).toBe(true) - expect( - SessionRunnerModel.supported( - model({ type: "aisdk", package: "@ai-sdk/google", url: "https://google.example/v1" }), - ), - ).toBe(false) - expect(SessionRunnerModel.supported(model({ type: "native", settings: {} }))).toBe(false) + expect(SessionRunnerModel.supported(model(ProviderV2.aisdk("@ai-sdk/openai")))).toBe(true) + expect(SessionRunnerModel.supported(model("@opencode-ai/llm/providers/custom"))).toBe(true) + expect(SessionRunnerModel.supported(model(undefined))).toBe(false) }), ) }) diff --git a/packages/core/test/shared-schema.test.ts b/packages/core/test/shared-schema.test.ts index d96f6e5bd5..9e48790dd0 100644 --- a/packages/core/test/shared-schema.test.ts +++ b/packages/core/test/shared-schema.test.ts @@ -112,12 +112,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], diff --git a/packages/llm/package.json b/packages/llm/package.json index 0b4cd12e25..f9c2ba821c 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..83edee7155 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,4 @@ 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..84684e7d83 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,12 @@ export const routes = [AnthropicMessages.route] export type Config = RouteDefaultsInput & ProviderAuthOption<"optional"> & { readonly baseURL?: string } +export interface Settings extends ProviderPackage.Settings { + readonly apiKey?: string + readonly authToken?: 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 +39,11 @@ export const configure = (input: Config = {}) => { } export const provider = configure() -export const model = provider.model +export const model: ProviderPackage.Definition["model"] = (modelID, settings) => + configure({ + ...(settings.authToken === undefined ? { apiKey: settings.apiKey } : { auth: Auth.bearer(settings.authToken) }), + 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..73819eaf47 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,16 @@ export type Config = RouteDefaultsInput & readonly providerOptions?: OpenAIProviderOptionsInput } +export interface Settings extends ProviderPackage.Settings { + readonly apiKey?: string + readonly baseURL?: string + readonly organization?: string + readonly project?: 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 +68,32 @@ export const configure = (input: Config = {}) => { export const provider = configure() -export const model = provider.model +const config = (settings: Settings): Config => { + const headers = { + ...(settings.organization === undefined ? {} : { "OpenAI-Organization": settings.organization }), + ...(settings.project === undefined ? {} : { "OpenAI-Project": settings.project }), + ...settings.headers, + } + return { + apiKey: settings.apiKey, + baseURL: settings.baseURL, + headers: Object.keys(headers).length === 0 ? undefined : 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..0d3ddc811c --- /dev/null +++ b/packages/llm/test/provider-package.test.ts @@ -0,0 +1,52 @@ +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") + }) + + test("maps legacy OpenAI organization and project settings to headers", () => { + const selected = model("gpt-5", { + apiKey: "fixture", + organization: "org_123", + project: "proj_123", + }) + + expect(selected.route.defaults.headers).toMatchObject({ + "OpenAI-Organization": "org_123", + "OpenAI-Project": "proj_123", + }) + }) +}) diff --git a/packages/opencode/test/cli/run/catalog.shared.test.ts b/packages/opencode/test/cli/run/catalog.shared.test.ts index 0f5ef225bf..d5487cdfa9 100644 --- a/packages/opencode/test/cli/run/catalog.shared.test.ts +++ b/packages/opencode/test/cli/run/catalog.shared.test.ts @@ -59,34 +59,21 @@ describe("run catalog shared", () => { { id: "openai", name: "OpenAI", - api: { type: "native", settings: {} }, - request: { settings: {}, headers: {}, body: {} }, + package: "", }, ], [ { id: "gpt-5", + modelID: "openai", providerID: "openai", name: "Little Frank", - api: { id: "openai", type: "native", settings: {} }, capabilities: { tools: true, input: ["text"], output: ["text"], }, - request: { - settings: {}, - headers: {}, - body: {}, - }, - variants: [ - { - id: "high", - settings: {}, - headers: {}, - body: {}, - }, - ], + variants: [{ id: "high" }], time: { released: 1, }, diff --git a/packages/schema/src/model.ts b/packages/schema/src/model.ts index 19276b6d00..fc6b64712f 100644 --- a/packages/schema/src/model.ts +++ b/packages/schema/src/model.ts @@ -1,9 +1,8 @@ export * as Model from "./model.js" import { Schema } from "effect" -import { optional } from "./schema.js" +import { optional, statics } from "./schema.js" import { Provider } from "./provider.js" -import { statics } from "./schema.js" export const ID = Schema.String.pipe(Schema.brand("ModelV2.ID")) export type ID = typeof ID.Type @@ -42,36 +41,23 @@ export const Cost = Schema.Struct({ }), }).annotate({ identifier: "Model.Cost" }) -export const Api = Schema.Union([ - Schema.Struct({ - id: ID, - ...Provider.AISDK.fields, - }), - Schema.Struct({ - id: ID, - ...Provider.Native.fields, - }), -]) - .pipe(Schema.toTaggedUnion("type")) - .annotate({ identifier: "Model.Api" }) -export type Api = typeof Api.Type +export interface Variant extends Schema.Schema.Type {} +export const Variant = Schema.Struct({ + id: VariantID, + ...Provider.Overlays, +}).annotate({ identifier: "Model.Variant" }) export interface Info extends Schema.Schema.Type {} export const Info = Schema.Struct({ id: ID, + modelID: ID, providerID: Provider.ID, family: Family.pipe(optional), name: Schema.String, - api: Api, + package: Provider.Package.pipe(optional), + ...Provider.Overlays, capabilities: Capabilities, - request: Schema.Struct({ - ...Provider.Request.fields, - variant: Schema.String.pipe(optional), - }), - variants: Schema.Struct({ - id: VariantID, - ...Provider.Request.fields, - }).pipe(Schema.Array), + variants: Schema.Array(Variant), time: Schema.Struct({ released: Schema.Finite, }), @@ -87,14 +73,13 @@ export const Info = Schema.Struct({ .annotate({ identifier: "ModelV2.Info" }) .pipe( statics((schema) => ({ - empty: (providerID: Provider.ID, modelID: ID) => + empty: (providerID: Provider.ID, id: ID) => schema.make({ - id: modelID, + id, + modelID: id, providerID, - name: modelID, - api: { id: modelID, type: "native", settings: {} }, + name: id, capabilities: { tools: false, input: [], output: [] }, - request: { settings: {}, headers: {}, body: {} }, variants: [], time: { released: 0 }, cost: [], diff --git a/packages/schema/src/provider.ts b/packages/schema/src/provider.ts index daa53f8430..f426a19798 100644 --- a/packages/schema/src/provider.ts +++ b/packages/schema/src/provider.ts @@ -1,9 +1,8 @@ export * as Provider from "./provider.js" import { Effect, Schema } from "effect" -import { optional } from "./schema.js" import { Integration } from "./integration.js" -import { statics } from "./schema.js" +import { optional, statics } from "./schema.js" export const ID = Schema.String.pipe( Schema.brand("ProviderV2.ID"), @@ -23,27 +22,16 @@ 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(optional), - settings: Schema.Record(Schema.String, Schema.Unknown).pipe(optional), -}).annotate({ identifier: "Provider.AISDK" }) +export const Package = Schema.String +export type Package = typeof Package.Type -export interface Native extends Schema.Schema.Type {} -export const Native = Schema.Struct({ - type: Schema.Literal("native"), - url: Schema.String.pipe(optional), - settings: Schema.Record(Schema.String, Schema.Unknown), -}).annotate({ identifier: "Provider.Native" }) +export const Overlays = { + settings: Schema.Record(Schema.String, Schema.Json).pipe(optional), + headers: Schema.Record(Schema.String, Schema.String).pipe(optional), + body: Schema.Record(Schema.String, Schema.Json).pipe(optional), +} -export const Api = Schema.Union([AISDK, Native]) - .pipe(Schema.toTaggedUnion("type")) - .annotate({ identifier: "Provider.Api" }) -export type Api = typeof Api.Type - -export const Settings = Schema.Record(Schema.String, Schema.Unknown).annotate({ identifier: "Provider.Settings" }) +export const Settings = Schema.Record(Schema.String, Schema.Json).annotate({ identifier: "Provider.Settings" }) export type Settings = typeof Settings.Type export interface Request extends Schema.Schema.Type {} @@ -59,18 +47,12 @@ export const Info = Schema.Struct({ integrationID: Integration.ID.pipe(optional), name: Schema.String, disabled: Schema.Boolean.pipe(optional), - api: Api, - request: Request, + package: Package, + ...Overlays, }) .annotate({ identifier: "ProviderV2.Info" }) .pipe( statics((schema) => ({ - empty: (id: ID) => - schema.make({ - id, - name: id, - api: { type: "native", settings: {} }, - request: { settings: {}, headers: {}, body: {} }, - }), + empty: (id: ID) => schema.make({ id, name: id, package: "" }), })), ) diff --git a/packages/schema/test/contract-hygiene.test.ts b/packages/schema/test/contract-hygiene.test.ts index edad66725e..58ba6ba856 100644 --- a/packages/schema/test/contract-hygiene.test.ts +++ b/packages/schema/test/contract-hygiene.test.ts @@ -4,6 +4,7 @@ import { Agent } from "../src/agent.js" import { FileSystem } from "../src/filesystem.js" import { Model } from "../src/model.js" import { Project } from "../src/project.js" +import { Provider } from "../src/provider.js" import { Pty } from "../src/pty.js" import { Question } from "../src/question.js" import { Session } from "../src/session.js" @@ -18,6 +19,19 @@ describe("contract hygiene", () => { expect(Schema.encodeSync(Value)({ value: undefined })).toEqual({}) }) + test("model defaults and provider overlays preserve public invariants", () => { + const id = Model.ID.make("model") + expect(Model.Info.empty(Provider.ID.make("provider"), id)).toMatchObject({ modelID: id, variants: [] }) + expect(() => + Schema.decodeUnknownSync(Provider.Info)({ + id: "provider", + name: "Provider", + package: "native", + settings: { invalid: 1n }, + }), + ).toThrow() + }) + test("todo status and priority preserve arbitrary strings", () => { const decode = Schema.decodeUnknownSync(SessionTodo.Info) expect(decode({ content: "ship", status: "waiting", priority: "urgent" })).toEqual({ @@ -39,7 +53,7 @@ describe("contract hygiene", () => { Model.Ref, Model.Capabilities, Model.Cost, - Model.Api, + Model.Variant, Project.Current, Project.Directory, Project.DirectoriesInput, diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index f1215bf713..70b85f2e0b 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -5219,31 +5219,25 @@ export type EventLogSynced = { seq?: number } -export type ModelApi = - | { - id: string - type: "aisdk" - package: string - url?: string - settings?: { - [key: string]: unknown - } - } - | { - id: string - type: "native" - url?: string - settings: { - [key: string]: unknown - } - } - export type ModelCapabilities = { tools: boolean input: Array output: Array } +export type ModelVariant = { + id: string + settings?: { + [key: string]: unknown + } + headers?: { + [key: string]: string + } + body?: { + [key: string]: unknown + } +} + export type ModelCost = { tier?: { type: "context" @@ -5259,31 +5253,22 @@ export type ModelCost = { export type ModelV2Info = { id: string + modelID: string providerID: string family?: string name: string - api: ModelApi - capabilities: ModelCapabilities - request: { - settings: ProviderSettings - headers: { - [key: string]: string - } - body: { - [key: string]: unknown - } - variant?: string + package?: string + settings?: { + [key: string]: unknown } - variants: Array<{ - id: string - settings: ProviderSettings - headers: { - [key: string]: string - } - body: { - [key: string]: unknown - } - }> + headers?: { + [key: string]: string + } + body?: { + [key: string]: unknown + } + capabilities: ModelCapabilities + variants: Array time: { released: number } @@ -5297,32 +5282,21 @@ export type ModelV2Info = { } } -export type ProviderAisdk = { - type: "aisdk" - package: string - url?: string - settings?: { - [key: string]: unknown - } -} - -export type ProviderNative = { - type: "native" - url?: string - settings: { - [key: string]: unknown - } -} - -export type ProviderApi = ProviderAisdk | ProviderNative - export type ProviderV2Info = { id: string integrationID?: string name: string disabled?: boolean - api: ProviderApi - request: ProviderRequest + package: string + settings?: { + [key: string]: unknown + } + headers?: { + [key: string]: string + } + body?: { + [key: string]: unknown + } } export type IntegrationWhen = { @@ -9061,31 +9035,25 @@ export type SessionMessagesResponseV2 = { } } -export type ModelApi2 = - | { - id: string - type: "aisdk" - package: string - url?: string - settings?: { - [key: string]: unknown - } - } - | { - id: string - type: "native" - url?: string - settings: { - [key: string]: unknown - } - } - export type ModelCapabilities2 = { tools: boolean input: Array output: Array } +export type ModelVariant2 = { + id: string + settings?: { + [key: string]: unknown + } + headers?: { + [key: string]: string + } + body?: { + [key: string]: unknown + } +} + export type ModelCost2 = { tier?: { type: "context" @@ -9101,31 +9069,22 @@ export type ModelCost2 = { export type ModelV2Info2 = { id: string + modelID: string providerID: string family?: string name: string - api: ModelApi2 - capabilities: ModelCapabilities2 - request: { - settings: ProviderSettings2 - headers: { - [key: string]: string - } - body: { - [key: string]: unknown - } - variant?: string + package?: string + settings?: { + [key: string]: unknown } - variants: Array<{ - id: string - settings: ProviderSettings2 - headers: { - [key: string]: string - } - body: { - [key: string]: unknown - } - }> + headers?: { + [key: string]: string + } + body?: { + [key: string]: unknown + } + capabilities: ModelCapabilities2 + variants: Array time: { released: number } @@ -9145,32 +9104,21 @@ export type GenerateTextResponseV2 = { } } -export type ProviderAisdk2 = { - type: "aisdk" - package: string - url?: string - settings?: { - [key: string]: unknown - } -} - -export type ProviderNative2 = { - type: "native" - url?: string - settings: { - [key: string]: unknown - } -} - -export type ProviderApi2 = ProviderAisdk2 | ProviderNative2 - export type ProviderV2Info2 = { id: string integrationID?: string name: string disabled?: boolean - api: ProviderApi2 - request: ProviderRequest2 + package: string + settings?: { + [key: string]: unknown + } + headers?: { + [key: string]: string + } + body?: { + [key: string]: unknown + } } export type ProviderNotFoundErrorV2 = { diff --git a/packages/tui/src/context/local.tsx b/packages/tui/src/context/local.tsx index 06427d60f6..efcaa8a01d 100644 --- a/packages/tui/src/context/local.tsx +++ b/packages/tui/src/context/local.tsx @@ -272,7 +272,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ return { provider: provider?.name ?? value.providerID, model: info?.name ?? value.modelID, - reasoning: info?.variants.length !== 0, + reasoning: (info?.variants?.length ?? 0) !== 0, } }), cycle(direction: 1 | -1) { @@ -373,12 +373,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ const v = this.selected() if (!v) return undefined if (v !== "default" && this.list().includes(v)) return v - const m = currentModel()! - return ( - data.location.model - .list() - ?.find((item) => item.providerID === m.providerID && item.id === m.modelID)?.request.variant ?? "default" - ) + return "default" }, list() { const m = currentModel() @@ -386,7 +381,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ const info = data.location.model .list() ?.find((item) => item.providerID === m.providerID && item.id === m.modelID) - return info?.variants.map((variant) => variant.id) ?? [] + return info?.variants?.map((variant) => variant.id) ?? [] }, set(value: string | undefined) { const m = currentModel()