diff --git a/packages/ai/src/providers/xai.ts b/packages/ai/src/providers/xai.ts index 281a106357a..7c50afe00c2 100644 --- a/packages/ai/src/providers/xai.ts +++ b/packages/ai/src/providers/xai.ts @@ -1,38 +1,62 @@ import { AuthOptions, type ProviderAuthOption } from "../route/auth-options" -import type { RouteDefaultsInput } from "../route/client" -import { HttpOptions, ProviderID, type ModelID } from "../schema" +import { Route, type RouteDefaultsInput } from "../route/client" +import { Endpoint } from "../route/endpoint" +import { HttpOptions, ProviderID, type ModelID, type ProviderOptions } from "../schema" import * as OpenAICompatibleProfiles from "./openai-compatible-profile" import * as OpenAICompatibleChat from "../protocols/openai-compatible-chat" +import * as OpenAIChat from "../protocols/openai-chat" import * as OpenAIResponses from "../protocols/openai-responses" import { XAIImages } from "../protocols/xai-images" -import type { OpenAIProviderOptionsInput } from "./openai-options" +import type { OpenAIOptionsInput } from "./openai-options" import type { ProviderPackage } from "../provider-package" export const id = ProviderID.make("xai") +export type XAIProviderOptionsInput = ProviderOptions & { + readonly xai?: OpenAIOptionsInput +} + export type ModelOptions = Omit & ProviderAuthOption<"optional"> & { readonly baseURL?: string - readonly providerOptions?: OpenAIProviderOptionsInput + readonly providerOptions?: XAIProviderOptionsInput } export interface Settings extends ProviderPackage.Settings { readonly apiKey?: string readonly baseURL?: string - readonly providerOptions?: OpenAIProviderOptionsInput + readonly providerOptions?: XAIProviderOptionsInput } export type { XAIImageOptions } from "../protocols/xai-images" -export const routes = [OpenAIResponses.route, OpenAICompatibleChat.route] +const responsesRoute = Route.make({ + id: "openai-responses", + provider: id, + providerMetadataKey: "xai", + protocol: OpenAIResponses.protocol, + endpoint: Endpoint.path("/responses", { baseURL: OpenAICompatibleProfiles.profiles.xai.baseURL }), + transport: OpenAIResponses.httpTransport, + defaults: { providerOptions: { xai: { store: false } } }, +}) + +const chatRoute = Route.make({ + id: "openai-compatible-chat", + provider: id, + providerMetadataKey: "xai", + protocol: OpenAIChat.protocol, + endpoint: Endpoint.path("/chat/completions", { baseURL: OpenAICompatibleProfiles.profiles.xai.baseURL }), + transport: OpenAICompatibleChat.route.transport, +}) + +export const routes = [responsesRoute, chatRoute] const auth = (options: ProviderAuthOption<"optional">) => AuthOptions.bearer(options, "XAI_API_KEY") const configuredResponsesRoute = (input: ModelOptions) => { const { apiKey: _, auth: _auth, baseURL, ...rest } = input - return OpenAIResponses.route.with({ + return responsesRoute.with({ ...rest, - provider: id, endpoint: { baseURL: baseURL ?? OpenAICompatibleProfiles.profiles.xai.baseURL }, auth: auth(input), }) @@ -40,9 +64,8 @@ const configuredResponsesRoute = (input: ModelOptions) => { const configuredChatRoute = (input: ModelOptions) => { const { apiKey: _, auth: _auth, baseURL, ...rest } = input - return OpenAICompatibleChat.route.with({ + return chatRoute.with({ ...rest, - provider: id, endpoint: { baseURL: baseURL ?? OpenAICompatibleProfiles.profiles.xai.baseURL }, auth: auth(input), }) @@ -51,8 +74,8 @@ const configuredChatRoute = (input: ModelOptions) => { export const configure = (input: ModelOptions = {}) => { const responsesRoute = configuredResponsesRoute(input) const chatRoute = configuredChatRoute(input) - const responses = (modelID: string | ModelID) => responsesRoute.model({ id: modelID }) - const chat = (modelID: string | ModelID) => chatRoute.model({ id: modelID }) + const responses = (modelID: string | ModelID) => responsesRoute.model({ id: modelID }) + const chat = (modelID: string | ModelID) => chatRoute.model({ id: modelID }) const image = (modelID: string | ModelID) => XAIImages.model({ id: modelID, @@ -72,7 +95,7 @@ export const configure = (input: ModelOptions = {}) => { } export const provider = configure() -export const model: ProviderPackage.Definition["model"] = (modelID, settings) => +export const model: ProviderPackage.Definition["model"] = (modelID, settings) => configure({ apiKey: settings.apiKey, baseURL: settings.baseURL, diff --git a/packages/ai/test/provider-options/xai.types.ts b/packages/ai/test/provider-options/xai.types.ts index 619f543083c..ab1a99c46bb 100644 --- a/packages/ai/test/provider-options/xai.types.ts +++ b/packages/ai/test/provider-options/xai.types.ts @@ -3,11 +3,11 @@ import { XAI } from "../../src/providers" const model = XAI.provider.model("grok-4") -LLM.request({ model, prompt: "Hello", providerOptions: { openai: { reasoningEffort: "high" } } }) +LLM.request({ model, prompt: "Hello", providerOptions: { xai: { reasoningEffort: "high" } } }) LLM.request({ model, prompt: "Hello", // @ts-expect-error xAI's OpenAI-compatible reasoning effort must be a string. - providerOptions: { openai: { reasoningEffort: true } }, + providerOptions: { xai: { reasoningEffort: true } }, }) diff --git a/packages/ai/test/provider-package.test.ts b/packages/ai/test/provider-package.test.ts index 0bb1b1441b4..9748289b80b 100644 --- a/packages/ai/test/provider-package.test.ts +++ b/packages/ai/test/provider-package.test.ts @@ -47,7 +47,7 @@ describe("provider package entrypoints", () => { }) const xai = XAI.model("grok-4", { ...settings, - providerOptions: { openai: { reasoningEffort: "high" } }, + providerOptions: { xai: { reasoningEffort: "high" } }, }) for (const selected of [openrouter, xai]) { @@ -57,7 +57,7 @@ describe("provider package entrypoints", () => { expect(selected.route.defaults.limits).toEqual(settings.limits) } expect(openrouter.route.defaults.providerOptions).toEqual({ openrouter: { usage: true } }) - expect(xai.route.defaults.providerOptions).toEqual({ openai: { reasoningEffort: "high", store: false } }) + expect(xai.route.defaults.providerOptions).toMatchObject({ xai: { reasoningEffort: "high", store: false } }) }) test("maps package settings onto the executable model", () => { diff --git a/packages/core/src/model-resolver.ts b/packages/core/src/model-resolver.ts index 024ab8b048f..04c737e914b 100644 --- a/packages/core/src/model-resolver.ts +++ b/packages/core/src/model-resolver.ts @@ -182,7 +182,8 @@ export const fromCatalogModel = ( .model({ id: resolved.modelID ?? resolved.id, compatibility: resolved.compatibility }), ) } - if (Provider.isAISDK(resolved.package)) { + const native = Provider.isAISDK(resolved.package) ? nativePackage(packageName) : resolved.package + if (Provider.isAISDK(resolved.package) && !native) { if (!dependencies?.loadAISDK) return Effect.fail(unsupported(resolved)) const runtime = produce(resolved, (draft) => { draft.settings = Provider.mergeOverlay(draft.settings, { @@ -193,20 +194,22 @@ export const fromCatalogModel = ( }) return dependencies.loadAISDK(runtime).pipe(Effect.mapError(() => unsupported(resolved))) } - if (!resolved.package) return Effect.fail(unsupported(resolved)) + if (!native) return Effect.fail(unsupported(resolved)) - const specifier = resolved.package + const specifier = native return Effect.gen(function* () { const module = yield* (dependencies?.loadPackage ?? Provider.loadPackage)(specifier).pipe( Effect.mapError(() => unsupported(resolved)), ) const configured = { ...resolved.settings, ...credential?.metadata } + const providerOptions = nativeProviderOptions(packageName, configured) const settings = { ...(credential ? withoutNativeAuthSettings(configured) : configured), ...nativeCredentialSettings(specifier, credential), headers: resolved.headers, body: resolved.body, limits: { context: resolved.limit.context, output: resolved.limit.output }, + ...(providerOptions ? { providerOptions } : {}), } return yield* Effect.try({ try: () => { @@ -223,6 +226,26 @@ export const fromCatalogModel = ( }) } +const nativePackage = (packageName: string | undefined) => { + if (packageName === "@ai-sdk/google") return "@opencode-ai/ai/providers/google" + if (packageName === "@openrouter/ai-sdk-provider") return "@opencode-ai/ai/providers/openrouter" + if (packageName === "@ai-sdk/xai") return "@opencode-ai/ai/providers/xai" + return undefined +} + +const nativeProviderOptions = (packageName: string | undefined, settings: Readonly>) => { + const values = Object.fromEntries( + Object.entries(settings).filter( + ([key]) => !["apiKey", "authToken", "baseURL", "chunkTimeout", "fetch", "timeout"].includes(key), + ), + ) + if (Object.keys(values).length === 0) return undefined + if (packageName === "@ai-sdk/google") return { gemini: values } + if (packageName === "@openrouter/ai-sdk-provider") return { openrouter: values } + if (packageName === "@ai-sdk/xai") return { xai: values } + return undefined +} + const isNativeOpenAI = (packageName: string | undefined) => packageName === "@opencode-ai/ai/providers/openai" || packageName?.startsWith("@opencode-ai/ai/providers/openai/") === true diff --git a/packages/core/test/generate.test.ts b/packages/core/test/generate.test.ts index f90d83ed1b0..52decda55db 100644 --- a/packages/core/test/generate.test.ts +++ b/packages/core/test/generate.test.ts @@ -15,7 +15,7 @@ import { testEffect } from "./lib/effect" const selected = Info.make({ ...Info.default(Provider.ID.make("test-provider"), ID.make("gemini")), - package: Provider.aisdk("@ai-sdk/google"), + package: Provider.aisdk("@ai-sdk/mistral"), }) const runtime = Model.make({ id: "gemini", provider: "test-provider", route: OpenAIChat.route }) diff --git a/packages/core/test/model-resolver.test.ts b/packages/core/test/model-resolver.test.ts index 273c15a8c62..ea31e93c575 100644 --- a/packages/core/test/model-resolver.test.ts +++ b/packages/core/test/model-resolver.test.ts @@ -546,6 +546,76 @@ describe("ModelResolver", () => { }), ) + it.effect("routes supported AISDK catalog packages through native provider packages", () => + Effect.gen(function* () { + const native = yield* ModelResolver.fromCatalogModel(model(Provider.aisdk("@ai-sdk/openai"))) + const packages = [ + ["@ai-sdk/google", "@opencode-ai/ai/providers/google", "gemini"], + ["@openrouter/ai-sdk-provider", "@opencode-ai/ai/providers/openrouter", "openrouter"], + ["@ai-sdk/xai", "@opencode-ai/ai/providers/xai", "xai"], + ] as const + + yield* Effect.forEach(packages, ([catalogPackage, nativePackage, optionKey]) => + ModelResolver.fromCatalogModel( + model(Provider.aisdk(catalogPackage), { + modelID: "api-model", + settings: { baseURL: "https://provider.example/v1", reasoningEffort: "high" }, + headers: { "x-provider": "header" }, + body: { custom: true }, + }), + Credential.Key.make({ type: "key", key: "secret" }), + { + loadPackage: (specifier) => { + expect(specifier).toBe(nativePackage) + return Effect.succeed({ + model: (modelID, settings) => { + expect(modelID).toBe("api-model") + expect(settings).toMatchObject({ + apiKey: "secret", + baseURL: "https://provider.example/v1", + headers: { "x-provider": "header" }, + body: { custom: true }, + limits: { context: 100, output: 20 }, + providerOptions: { [optionKey]: { reasoningEffort: "high" } }, + }) + return Model.make({ id: modelID, provider: "native-provider", route: native.route }) + }, + }) + }, + loadAISDK: () => Effect.die("AI SDK loader should not be called"), + }, + ), + ) + }), + ) + + it.effect("loads supported AISDK catalog packages as native routes", () => + Effect.gen(function* () { + const google = yield* ModelResolver.fromCatalogModel( + model(Provider.aisdk("@ai-sdk/google"), { settings: { thinkingConfig: { thinkingBudget: 1_024 } } }), + ) + const openrouter = yield* ModelResolver.fromCatalogModel( + model(Provider.aisdk("@openrouter/ai-sdk-provider"), { + settings: { reasoning: { effort: "high" } }, + }), + ) + const xai = yield* ModelResolver.fromCatalogModel( + model(Provider.aisdk("@ai-sdk/xai"), { settings: { reasoningEffort: "high" } }), + ) + + expect(google.route.id).toBe("gemini") + expect(google.route.defaults.providerOptions).toEqual({ + gemini: { thinkingConfig: { thinkingBudget: 1_024 } }, + }) + expect(openrouter.route.id).toBe("openrouter") + expect(openrouter.route.defaults.providerOptions).toEqual({ openrouter: { reasoning: { effort: "high" } } }) + expect(xai.route.id).toBe("openai-responses") + expect(xai.route.defaults.providerOptions).toEqual({ + xai: { reasoningEffort: "high", store: false }, + }) + }), + ) + it.effect("loads arbitrary AISDK packages through the injected AISDK loader", () => Effect.gen(function* () { const native = yield* ModelResolver.fromCatalogModel( @@ -554,8 +624,8 @@ describe("ModelResolver", () => { }), ) const resolved = yield* ModelResolver.fromCatalogModel( - model(Provider.aisdk("@ai-sdk/google"), { - modelID: "gemini-api-model", + model(Provider.aisdk("@ai-sdk/mistral"), { + modelID: "mistral-api-model", settings: { project: "test" }, headers: { "x-aisdk": "header" }, body: { custom: true }, @@ -566,9 +636,9 @@ describe("ModelResolver", () => { Effect.sync(() => { expect(runtime).toMatchObject({ id: "test-model", - modelID: "gemini-api-model", + modelID: "mistral-api-model", providerID: "test-provider", - package: Provider.aisdk("@ai-sdk/google"), + package: Provider.aisdk("@ai-sdk/mistral"), settings: { project: "test", apiKey: "fallback-secret" }, headers: { "x-aisdk": "header" }, body: { custom: true }, @@ -582,15 +652,15 @@ describe("ModelResolver", () => { }, ) - expect(resolved).toMatchObject({ id: "gemini-api-model", provider: "test-provider" }) + expect(resolved).toMatchObject({ id: "mistral-api-model", provider: "test-provider" }) }), ) it.effect("rejects AISDK packages without an available loader", () => Effect.gen(function* () { const failure = yield* ModelResolver.fromCatalogModel( - model(Provider.aisdk("@ai-sdk/google"), { - settings: { baseURL: "https://google.example/v1" }, + model(Provider.aisdk("@ai-sdk/mistral"), { + settings: { baseURL: "https://mistral.example/v1" }, }), ).pipe(Effect.flip) @@ -598,9 +668,9 @@ describe("ModelResolver", () => { _tag: "SessionRunnerModel.UnsupportedPackageError", providerID: "test-provider", modelID: "test-model", - package: "aisdk:@ai-sdk/google", + package: "aisdk:@ai-sdk/mistral", }) - expect(failure.message).toBe("Unsupported package for test-provider/test-model: aisdk:@ai-sdk/google") + expect(failure.message).toBe("Unsupported package for test-provider/test-model: aisdk:@ai-sdk/mistral") }), ) @@ -612,8 +682,8 @@ describe("ModelResolver", () => { }), ) yield* ModelResolver.fromCatalogModel( - model(Provider.aisdk("@ai-sdk/google"), { - settings: { apiKey: "", baseURL: "https://google.example/v1" }, + model(Provider.aisdk("@ai-sdk/mistral"), { + settings: { apiKey: "", baseURL: "https://mistral.example/v1" }, }), undefined, {