Преглед изворни кода

feat(core): route providers through native AI (#39615)

Aiden Cline пре 1 недеља
родитељ
комит
7625cbdf47

+ 36 - 13
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<RouteDefaultsInput, "providerOptions"> &
   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<OpenAIProviderOptionsInput>({ id: modelID })
-  const chat = (modelID: string | ModelID) => chatRoute.model<OpenAIProviderOptionsInput>({ id: modelID })
+  const responses = (modelID: string | ModelID) => responsesRoute.model<XAIProviderOptionsInput>({ id: modelID })
+  const chat = (modelID: string | ModelID) => chatRoute.model<XAIProviderOptionsInput>({ 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<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) =>
+export const model: ProviderPackage.Definition<Settings, XAIProviderOptionsInput>["model"] = (modelID, settings) =>
   configure({
     apiKey: settings.apiKey,
     baseURL: settings.baseURL,

+ 2 - 2
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 } },
 })

+ 2 - 2
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", () => {

+ 26 - 3
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<Record<string, unknown>>) => {
+  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

+ 1 - 1
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 })
 

+ 81 - 11
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,
         {