Skip to content

Commit 7625cbd

Browse files
authored
feat(core): route providers through native AI (#39615)
1 parent 0ffec67 commit 7625cbd

6 files changed

Lines changed: 148 additions & 32 deletions

File tree

packages/ai/src/providers/xai.ts

Lines changed: 36 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,48 +1,71 @@
11
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options"
2-
import type { RouteDefaultsInput } from "../route/client"
3-
import { HttpOptions, ProviderID, type ModelID } from "../schema"
2+
import { Route, type RouteDefaultsInput } from "../route/client"
3+
import { Endpoint } from "../route/endpoint"
4+
import { HttpOptions, ProviderID, type ModelID, type ProviderOptions } from "../schema"
45
import * as OpenAICompatibleProfiles from "./openai-compatible-profile"
56
import * as OpenAICompatibleChat from "../protocols/openai-compatible-chat"
7+
import * as OpenAIChat from "../protocols/openai-chat"
68
import * as OpenAIResponses from "../protocols/openai-responses"
79
import { XAIImages } from "../protocols/xai-images"
8-
import type { OpenAIProviderOptionsInput } from "./openai-options"
10+
import type { OpenAIOptionsInput } from "./openai-options"
911
import type { ProviderPackage } from "../provider-package"
1012

1113
export const id = ProviderID.make("xai")
1214

15+
export type XAIProviderOptionsInput = ProviderOptions & {
16+
readonly xai?: OpenAIOptionsInput
17+
}
18+
1319
export type ModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
1420
ProviderAuthOption<"optional"> & {
1521
readonly baseURL?: string
16-
readonly providerOptions?: OpenAIProviderOptionsInput
22+
readonly providerOptions?: XAIProviderOptionsInput
1723
}
1824

1925
export interface Settings extends ProviderPackage.Settings {
2026
readonly apiKey?: string
2127
readonly baseURL?: string
22-
readonly providerOptions?: OpenAIProviderOptionsInput
28+
readonly providerOptions?: XAIProviderOptionsInput
2329
}
2430

2531
export type { XAIImageOptions } from "../protocols/xai-images"
2632

27-
export const routes = [OpenAIResponses.route, OpenAICompatibleChat.route]
33+
const responsesRoute = Route.make({
34+
id: "openai-responses",
35+
provider: id,
36+
providerMetadataKey: "xai",
37+
protocol: OpenAIResponses.protocol,
38+
endpoint: Endpoint.path("/responses", { baseURL: OpenAICompatibleProfiles.profiles.xai.baseURL }),
39+
transport: OpenAIResponses.httpTransport,
40+
defaults: { providerOptions: { xai: { store: false } } },
41+
})
42+
43+
const chatRoute = Route.make({
44+
id: "openai-compatible-chat",
45+
provider: id,
46+
providerMetadataKey: "xai",
47+
protocol: OpenAIChat.protocol,
48+
endpoint: Endpoint.path("/chat/completions", { baseURL: OpenAICompatibleProfiles.profiles.xai.baseURL }),
49+
transport: OpenAICompatibleChat.route.transport,
50+
})
51+
52+
export const routes = [responsesRoute, chatRoute]
2853

2954
const auth = (options: ProviderAuthOption<"optional">) => AuthOptions.bearer(options, "XAI_API_KEY")
3055

3156
const configuredResponsesRoute = (input: ModelOptions) => {
3257
const { apiKey: _, auth: _auth, baseURL, ...rest } = input
33-
return OpenAIResponses.route.with({
58+
return responsesRoute.with({
3459
...rest,
35-
provider: id,
3660
endpoint: { baseURL: baseURL ?? OpenAICompatibleProfiles.profiles.xai.baseURL },
3761
auth: auth(input),
3862
})
3963
}
4064

4165
const configuredChatRoute = (input: ModelOptions) => {
4266
const { apiKey: _, auth: _auth, baseURL, ...rest } = input
43-
return OpenAICompatibleChat.route.with({
67+
return chatRoute.with({
4468
...rest,
45-
provider: id,
4669
endpoint: { baseURL: baseURL ?? OpenAICompatibleProfiles.profiles.xai.baseURL },
4770
auth: auth(input),
4871
})
@@ -51,8 +74,8 @@ const configuredChatRoute = (input: ModelOptions) => {
5174
export const configure = (input: ModelOptions = {}) => {
5275
const responsesRoute = configuredResponsesRoute(input)
5376
const chatRoute = configuredChatRoute(input)
54-
const responses = (modelID: string | ModelID) => responsesRoute.model<OpenAIProviderOptionsInput>({ id: modelID })
55-
const chat = (modelID: string | ModelID) => chatRoute.model<OpenAIProviderOptionsInput>({ id: modelID })
77+
const responses = (modelID: string | ModelID) => responsesRoute.model<XAIProviderOptionsInput>({ id: modelID })
78+
const chat = (modelID: string | ModelID) => chatRoute.model<XAIProviderOptionsInput>({ id: modelID })
5679
const image = (modelID: string | ModelID) =>
5780
XAIImages.model({
5881
id: modelID,
@@ -72,7 +95,7 @@ export const configure = (input: ModelOptions = {}) => {
7295
}
7396

7497
export const provider = configure()
75-
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) =>
98+
export const model: ProviderPackage.Definition<Settings, XAIProviderOptionsInput>["model"] = (modelID, settings) =>
7699
configure({
77100
apiKey: settings.apiKey,
78101
baseURL: settings.baseURL,

packages/ai/test/provider-options/xai.types.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,11 @@ import { XAI } from "../../src/providers"
33

44
const model = XAI.provider.model("grok-4")
55

6-
LLM.request({ model, prompt: "Hello", providerOptions: { openai: { reasoningEffort: "high" } } })
6+
LLM.request({ model, prompt: "Hello", providerOptions: { xai: { reasoningEffort: "high" } } })
77

88
LLM.request({
99
model,
1010
prompt: "Hello",
1111
// @ts-expect-error xAI's OpenAI-compatible reasoning effort must be a string.
12-
providerOptions: { openai: { reasoningEffort: true } },
12+
providerOptions: { xai: { reasoningEffort: true } },
1313
})

packages/ai/test/provider-package.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ describe("provider package entrypoints", () => {
4747
})
4848
const xai = XAI.model("grok-4", {
4949
...settings,
50-
providerOptions: { openai: { reasoningEffort: "high" } },
50+
providerOptions: { xai: { reasoningEffort: "high" } },
5151
})
5252

5353
for (const selected of [openrouter, xai]) {
@@ -57,7 +57,7 @@ describe("provider package entrypoints", () => {
5757
expect(selected.route.defaults.limits).toEqual(settings.limits)
5858
}
5959
expect(openrouter.route.defaults.providerOptions).toEqual({ openrouter: { usage: true } })
60-
expect(xai.route.defaults.providerOptions).toEqual({ openai: { reasoningEffort: "high", store: false } })
60+
expect(xai.route.defaults.providerOptions).toMatchObject({ xai: { reasoningEffort: "high", store: false } })
6161
})
6262

6363
test("maps package settings onto the executable model", () => {

packages/core/src/model-resolver.ts

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,8 @@ export const fromCatalogModel = (
182182
.model({ id: resolved.modelID ?? resolved.id, compatibility: resolved.compatibility }),
183183
)
184184
}
185-
if (Provider.isAISDK(resolved.package)) {
185+
const native = Provider.isAISDK(resolved.package) ? nativePackage(packageName) : resolved.package
186+
if (Provider.isAISDK(resolved.package) && !native) {
186187
if (!dependencies?.loadAISDK) return Effect.fail(unsupported(resolved))
187188
const runtime = produce(resolved, (draft) => {
188189
draft.settings = Provider.mergeOverlay(draft.settings, {
@@ -193,20 +194,22 @@ export const fromCatalogModel = (
193194
})
194195
return dependencies.loadAISDK(runtime).pipe(Effect.mapError(() => unsupported(resolved)))
195196
}
196-
if (!resolved.package) return Effect.fail(unsupported(resolved))
197+
if (!native) return Effect.fail(unsupported(resolved))
197198

198-
const specifier = resolved.package
199+
const specifier = native
199200
return Effect.gen(function* () {
200201
const module = yield* (dependencies?.loadPackage ?? Provider.loadPackage)(specifier).pipe(
201202
Effect.mapError(() => unsupported(resolved)),
202203
)
203204
const configured = { ...resolved.settings, ...credential?.metadata }
205+
const providerOptions = nativeProviderOptions(packageName, configured)
204206
const settings = {
205207
...(credential ? withoutNativeAuthSettings(configured) : configured),
206208
...nativeCredentialSettings(specifier, credential),
207209
headers: resolved.headers,
208210
body: resolved.body,
209211
limits: { context: resolved.limit.context, output: resolved.limit.output },
212+
...(providerOptions ? { providerOptions } : {}),
210213
}
211214
return yield* Effect.try({
212215
try: () => {
@@ -223,6 +226,26 @@ export const fromCatalogModel = (
223226
})
224227
}
225228

229+
const nativePackage = (packageName: string | undefined) => {
230+
if (packageName === "@ai-sdk/google") return "@opencode-ai/ai/providers/google"
231+
if (packageName === "@openrouter/ai-sdk-provider") return "@opencode-ai/ai/providers/openrouter"
232+
if (packageName === "@ai-sdk/xai") return "@opencode-ai/ai/providers/xai"
233+
return undefined
234+
}
235+
236+
const nativeProviderOptions = (packageName: string | undefined, settings: Readonly<Record<string, unknown>>) => {
237+
const values = Object.fromEntries(
238+
Object.entries(settings).filter(
239+
([key]) => !["apiKey", "authToken", "baseURL", "chunkTimeout", "fetch", "timeout"].includes(key),
240+
),
241+
)
242+
if (Object.keys(values).length === 0) return undefined
243+
if (packageName === "@ai-sdk/google") return { gemini: values }
244+
if (packageName === "@openrouter/ai-sdk-provider") return { openrouter: values }
245+
if (packageName === "@ai-sdk/xai") return { xai: values }
246+
return undefined
247+
}
248+
226249
const isNativeOpenAI = (packageName: string | undefined) =>
227250
packageName === "@opencode-ai/ai/providers/openai" ||
228251
packageName?.startsWith("@opencode-ai/ai/providers/openai/") === true

packages/core/test/generate.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import { testEffect } from "./lib/effect"
1515

1616
const selected = Info.make({
1717
...Info.default(Provider.ID.make("test-provider"), ID.make("gemini")),
18-
package: Provider.aisdk("@ai-sdk/google"),
18+
package: Provider.aisdk("@ai-sdk/mistral"),
1919
})
2020
const runtime = Model.make({ id: "gemini", provider: "test-provider", route: OpenAIChat.route })
2121

packages/core/test/model-resolver.test.ts

Lines changed: 81 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -546,6 +546,76 @@ describe("ModelResolver", () => {
546546
}),
547547
)
548548

549+
it.effect("routes supported AISDK catalog packages through native provider packages", () =>
550+
Effect.gen(function* () {
551+
const native = yield* ModelResolver.fromCatalogModel(model(Provider.aisdk("@ai-sdk/openai")))
552+
const packages = [
553+
["@ai-sdk/google", "@opencode-ai/ai/providers/google", "gemini"],
554+
["@openrouter/ai-sdk-provider", "@opencode-ai/ai/providers/openrouter", "openrouter"],
555+
["@ai-sdk/xai", "@opencode-ai/ai/providers/xai", "xai"],
556+
] as const
557+
558+
yield* Effect.forEach(packages, ([catalogPackage, nativePackage, optionKey]) =>
559+
ModelResolver.fromCatalogModel(
560+
model(Provider.aisdk(catalogPackage), {
561+
modelID: "api-model",
562+
settings: { baseURL: "https://provider.example/v1", reasoningEffort: "high" },
563+
headers: { "x-provider": "header" },
564+
body: { custom: true },
565+
}),
566+
Credential.Key.make({ type: "key", key: "secret" }),
567+
{
568+
loadPackage: (specifier) => {
569+
expect(specifier).toBe(nativePackage)
570+
return Effect.succeed({
571+
model: (modelID, settings) => {
572+
expect(modelID).toBe("api-model")
573+
expect(settings).toMatchObject({
574+
apiKey: "secret",
575+
baseURL: "https://provider.example/v1",
576+
headers: { "x-provider": "header" },
577+
body: { custom: true },
578+
limits: { context: 100, output: 20 },
579+
providerOptions: { [optionKey]: { reasoningEffort: "high" } },
580+
})
581+
return Model.make({ id: modelID, provider: "native-provider", route: native.route })
582+
},
583+
})
584+
},
585+
loadAISDK: () => Effect.die("AI SDK loader should not be called"),
586+
},
587+
),
588+
)
589+
}),
590+
)
591+
592+
it.effect("loads supported AISDK catalog packages as native routes", () =>
593+
Effect.gen(function* () {
594+
const google = yield* ModelResolver.fromCatalogModel(
595+
model(Provider.aisdk("@ai-sdk/google"), { settings: { thinkingConfig: { thinkingBudget: 1_024 } } }),
596+
)
597+
const openrouter = yield* ModelResolver.fromCatalogModel(
598+
model(Provider.aisdk("@openrouter/ai-sdk-provider"), {
599+
settings: { reasoning: { effort: "high" } },
600+
}),
601+
)
602+
const xai = yield* ModelResolver.fromCatalogModel(
603+
model(Provider.aisdk("@ai-sdk/xai"), { settings: { reasoningEffort: "high" } }),
604+
)
605+
606+
expect(google.route.id).toBe("gemini")
607+
expect(google.route.defaults.providerOptions).toEqual({
608+
gemini: { thinkingConfig: { thinkingBudget: 1_024 } },
609+
})
610+
expect(openrouter.route.id).toBe("openrouter")
611+
expect(openrouter.route.defaults.providerOptions).toEqual({ openrouter: { reasoning: { effort: "high" } } })
612+
expect(xai.route.id).toBe("openai-responses")
613+
expect(xai.route.defaults.providerOptions).toEqual({
614+
xai: { reasoningEffort: "high", store: false },
615+
})
616+
}),
617+
)
618+
549619
it.effect("loads arbitrary AISDK packages through the injected AISDK loader", () =>
550620
Effect.gen(function* () {
551621
const native = yield* ModelResolver.fromCatalogModel(
@@ -554,8 +624,8 @@ describe("ModelResolver", () => {
554624
}),
555625
)
556626
const resolved = yield* ModelResolver.fromCatalogModel(
557-
model(Provider.aisdk("@ai-sdk/google"), {
558-
modelID: "gemini-api-model",
627+
model(Provider.aisdk("@ai-sdk/mistral"), {
628+
modelID: "mistral-api-model",
559629
settings: { project: "test" },
560630
headers: { "x-aisdk": "header" },
561631
body: { custom: true },
@@ -566,9 +636,9 @@ describe("ModelResolver", () => {
566636
Effect.sync(() => {
567637
expect(runtime).toMatchObject({
568638
id: "test-model",
569-
modelID: "gemini-api-model",
639+
modelID: "mistral-api-model",
570640
providerID: "test-provider",
571-
package: Provider.aisdk("@ai-sdk/google"),
641+
package: Provider.aisdk("@ai-sdk/mistral"),
572642
settings: { project: "test", apiKey: "fallback-secret" },
573643
headers: { "x-aisdk": "header" },
574644
body: { custom: true },
@@ -582,25 +652,25 @@ describe("ModelResolver", () => {
582652
},
583653
)
584654

585-
expect(resolved).toMatchObject({ id: "gemini-api-model", provider: "test-provider" })
655+
expect(resolved).toMatchObject({ id: "mistral-api-model", provider: "test-provider" })
586656
}),
587657
)
588658

589659
it.effect("rejects AISDK packages without an available loader", () =>
590660
Effect.gen(function* () {
591661
const failure = yield* ModelResolver.fromCatalogModel(
592-
model(Provider.aisdk("@ai-sdk/google"), {
593-
settings: { baseURL: "https://google.example/v1" },
662+
model(Provider.aisdk("@ai-sdk/mistral"), {
663+
settings: { baseURL: "https://mistral.example/v1" },
594664
}),
595665
).pipe(Effect.flip)
596666

597667
expect(failure).toMatchObject({
598668
_tag: "SessionRunnerModel.UnsupportedPackageError",
599669
providerID: "test-provider",
600670
modelID: "test-model",
601-
package: "aisdk:@ai-sdk/google",
671+
package: "aisdk:@ai-sdk/mistral",
602672
})
603-
expect(failure.message).toBe("Unsupported package for test-provider/test-model: aisdk:@ai-sdk/google")
673+
expect(failure.message).toBe("Unsupported package for test-provider/test-model: aisdk:@ai-sdk/mistral")
604674
}),
605675
)
606676

@@ -612,8 +682,8 @@ describe("ModelResolver", () => {
612682
}),
613683
)
614684
yield* ModelResolver.fromCatalogModel(
615-
model(Provider.aisdk("@ai-sdk/google"), {
616-
settings: { apiKey: "", baseURL: "https://google.example/v1" },
685+
model(Provider.aisdk("@ai-sdk/mistral"), {
686+
settings: { apiKey: "", baseURL: "https://mistral.example/v1" },
617687
}),
618688
undefined,
619689
{

0 commit comments

Comments
 (0)