Ver Fonte

refactor(core): route openai-compatible natively

Aiden Cline há 1 semana atrás
pai
commit
24c26dcf66

+ 20 - 5
packages/ai/src/providers/openai-compatible.ts

@@ -1,6 +1,7 @@
-import { ProviderID, type ModelID } from "../schema"
+import { HttpOptions, ProviderID, mergeHttpOptions, type ModelID } from "../schema"
 import * as OpenAICompatibleChat from "../protocols/openai-compatible-chat"
 import * as OpenAICompatibleChat from "../protocols/openai-compatible-chat"
 import type { RouteDefaultsInput } from "../route/client"
 import type { RouteDefaultsInput } from "../route/client"
+import { Auth } from "../route/auth"
 import { AuthOptions, type ProviderAuthOption } from "../route/auth-options"
 import { AuthOptions, type ProviderAuthOption } from "../route/auth-options"
 import type { ProviderPackage } from "../provider-package"
 import type { ProviderPackage } from "../provider-package"
 import { profiles, type OpenAICompatibleProfile } from "./openai-compatible-profile"
 import { profiles, type OpenAICompatibleProfile } from "./openai-compatible-profile"
@@ -19,6 +20,8 @@ export interface Settings extends ProviderPackage.Settings {
   readonly apiKey?: string
   readonly apiKey?: string
   readonly baseURL: string
   readonly baseURL: string
   readonly provider?: string
   readonly provider?: string
+  readonly http?: RouteDefaultsInput["http"]
+  readonly providerOptions?: OpenAIProviderOptionsInput
 }
 }
 
 
 export type FamilyModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
 export type FamilyModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
@@ -31,16 +34,24 @@ export const routes = [OpenAICompatibleChat.route]
 
 
 export const configure = (input: GenericModelOptions) => {
 export const configure = (input: GenericModelOptions) => {
   const provider = input.provider ?? "openai-compatible"
   const provider = input.provider ?? "openai-compatible"
-  const { provider: _, baseURL, apiKey: _apiKey, auth: _auth, ...rest } = input
+  const {
+    provider: _,
+    baseURL,
+    apiKey: _apiKey,
+    auth: _auth,
+    headers,
+    ...rest
+  } = input
   const route = OpenAICompatibleChat.route.with({
   const route = OpenAICompatibleChat.route.with({
     ...rest,
     ...rest,
     provider,
     provider,
     endpoint: { baseURL },
     endpoint: { baseURL },
-    auth: AuthOptions.bearer(input, []),
+    auth: AuthOptions.bearer(input, []).andThen(Auth.headers(headers ?? {})),
   })
   })
   return {
   return {
     id: ProviderID.make(provider),
     id: ProviderID.make(provider),
     model: (modelID: string | ModelID) =>
     model: (modelID: string | ModelID) =>
+      // oxlint-disable-next-line typescript-eslint/no-unnecessary-type-arguments -- preserves provider-option validation at call sites
       route.model<OpenAIProviderOptionsInput>({ id: modelID, provider: ProviderID.make(provider) }),
       route.model<OpenAIProviderOptionsInput>({ id: modelID, provider: ProviderID.make(provider) }),
     configure,
     configure,
   }
   }
@@ -67,14 +78,18 @@ export const provider = {
   configure,
   configure,
 }
 }
 
 
-export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) =>
+export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) =>
   configure({
   configure({
     apiKey: settings.apiKey,
     apiKey: settings.apiKey,
     baseURL: settings.baseURL,
     baseURL: settings.baseURL,
     headers: settings.headers === undefined ? undefined : { ...settings.headers },
     headers: settings.headers === undefined ? undefined : { ...settings.headers },
-    http: settings.body === undefined ? undefined : { body: { ...settings.body } },
+    http: mergeHttpOptions(
+      settings.http === undefined ? undefined : HttpOptions.make(settings.http),
+      settings.body === undefined ? undefined : new HttpOptions({ body: { ...settings.body } }),
+    ),
     limits: settings.limits,
     limits: settings.limits,
     provider: settings.provider,
     provider: settings.provider,
+    providerOptions: settings.providerOptions,
   }).model(modelID)
   }).model(modelID)
 
 
 export const baseten = define(profiles.baseten)
 export const baseten = define(profiles.baseten)

+ 16 - 0
packages/ai/test/provider-package.test.ts

@@ -111,6 +111,22 @@ describe("provider package entrypoints", () => {
     })
     })
   })
   })
 
 
+  test("maps OpenAI-compatible Chat settings onto the executable model", async () => {
+    const OpenAICompatible = await import("@opencode-ai/ai/providers/openai-compatible")
+    const selected = OpenAICompatible.model("custom-model", {
+      apiKey: "fixture",
+      baseURL: "https://chat.example.test/v1",
+      provider: "example",
+      http: { query: { tenant: "one" } },
+      providerOptions: { openai: { reasoningEffort: "high" } },
+    })
+
+    expect(String(selected.provider)).toBe("example")
+    expect(selected.route.id).toBe("openai-compatible-chat")
+    expect(selected.route.defaults.http?.query).toEqual({ tenant: "one" })
+    expect(selected.route.defaults.providerOptions).toEqual({ openai: { reasoningEffort: "high" } })
+  })
+
   test("maps Anthropic-compatible settings onto the executable model", async () => {
   test("maps Anthropic-compatible settings onto the executable model", async () => {
     const AnthropicCompatible = await import("@opencode-ai/ai/providers/anthropic-compatible")
     const AnthropicCompatible = await import("@opencode-ai/ai/providers/anthropic-compatible")
     const selected = AnthropicCompatible.model("compatible-model", {
     const selected = AnthropicCompatible.model("compatible-model", {

+ 43 - 0
packages/ai/test/provider/openai-compatible-chat.test.ts

@@ -4,6 +4,7 @@ import { HttpClientRequest } from "effect/unstable/http"
 import { LLM, LLMRequest, Message, ToolCallPart, ToolChoice, ToolDefinition } from "../../src"
 import { LLM, LLMRequest, Message, ToolCallPart, ToolChoice, ToolDefinition } from "../../src"
 import { Auth, LLMClient } from "../../src/route"
 import { Auth, LLMClient } from "../../src/route"
 import { compileRequest } from "../../src/route/client"
 import { compileRequest } from "../../src/route/client"
+import { jsonRequestParts } from "../../src/route/transport/http"
 import * as OpenAICompatible from "../../src/providers/openai-compatible"
 import * as OpenAICompatible from "../../src/providers/openai-compatible"
 import * as OpenAICompatibleChat from "../../src/protocols/openai-compatible-chat"
 import * as OpenAICompatibleChat from "../../src/protocols/openai-compatible-chat"
 import { it } from "../lib/effect"
 import { it } from "../lib/effect"
@@ -144,6 +145,48 @@ describe("OpenAI-compatible Chat route", () => {
     }),
     }),
   )
   )
 
 
+  it.effect("preserves compatible provider URL, usage, options, and body extensions", () =>
+    Effect.gen(function* () {
+      const selected = OpenAICompatible.model("custom-model", {
+        apiKey: "generated-key",
+        baseURL: "https://compatible.example/v1",
+        provider: "custom",
+        headers: { Authorization: "Bearer configured-key" },
+        http: {
+          query: { tenant: "one" },
+          body: {
+            user: "user-1",
+            verbosity: "low",
+            vendor_extension: { enabled: true },
+            custom_boolean: false,
+          },
+        },
+        providerOptions: { openai: { reasoningEffort: "high" } },
+      })
+      const request = LLM.request({ model: selected, prompt: "Hello" })
+      const prepared = yield* compileRequest(request)
+      const parts = yield* jsonRequestParts({
+        endpoint: selected.route.endpoint,
+        auth: selected.route.auth,
+        headers: selected.route.headers,
+        request: LLMRequest.update(request, { http: selected.route.defaults.http }),
+        body: prepared.body,
+        encodeBody: (body) => JSON.stringify(body),
+      })
+
+      expect(parts.url).toBe("https://compatible.example/v1/chat/completions?tenant=one")
+      expect(parts.headers.authorization).toBe("Bearer configured-key")
+      expect(parts.jsonBody).toMatchObject({
+        user: "user-1",
+        reasoning_effort: "high",
+        verbosity: "low",
+        vendor_extension: { enabled: true },
+        custom_boolean: false,
+      })
+      expect(parts.jsonBody).toMatchObject({ stream_options: { include_usage: true } })
+    }),
+  )
+
   it.effect("configures the max tokens request field", () =>
   it.effect("configures the max tokens request field", () =>
     Effect.gen(function* () {
     Effect.gen(function* () {
       const compatible = OpenAICompatibleChat.route
       const compatible = OpenAICompatibleChat.route

+ 31 - 3
packages/core/src/aisdk-native.ts

@@ -51,6 +51,8 @@ export function map(input: MapInput): Mapping | undefined {
           ...mapGoogleOptions(input.settings),
           ...mapGoogleOptions(input.settings),
         },
         },
       }
       }
+    case "@ai-sdk/openai-compatible":
+      return mapOpenAICompatible(input.settings)
     case "@openrouter/ai-sdk-provider":
     case "@openrouter/ai-sdk-provider":
       return mapOpenRouter(input.settings, baseSettings)
       return mapOpenRouter(input.settings, baseSettings)
     case "@ai-sdk/xai":
     case "@ai-sdk/xai":
@@ -63,6 +65,34 @@ export function map(input: MapInput): Mapping | undefined {
         },
         },
       }
       }
   }
   }
+  return undefined
+}
+
+function mapOpenAICompatible(settings: Readonly<Record<string, unknown>>): Mapping | undefined {
+  if (typeof settings.baseURL !== "string") return undefined
+  if (
+    settings.timeout !== undefined ||
+    settings.headerTimeout !== undefined ||
+    settings.chunkTimeout !== undefined ||
+    settings.fetch !== undefined ||
+    settings.transformRequestBody !== undefined ||
+    settings.metadataExtractor !== undefined ||
+    settings.supportsStructuredOutputs === true ||
+    settings.strictJsonSchema !== undefined
+  )
+    return undefined
+  const options = typeof settings.reasoningEffort === "string" ? { reasoningEffort: settings.reasoningEffort } : undefined
+  return {
+    package: "@opencode-ai/ai/providers/openai-compatible",
+    settings: {
+      baseURL: settings.baseURL,
+      ...(typeof settings.name === "string" ? { provider: settings.name } : {}),
+      ...mapAPIKey(settings),
+      ...(isStringRecord(settings.queryParams) ? { http: { query: settings.queryParams } } : {}),
+      ...(options === undefined ? {} : { providerOptions: { openai: options } }),
+    },
+    ...(isStringRecord(settings.headers) ? { headers: settings.headers } : {}),
+  }
 }
 }
 
 
 function mapBedrockMantle(input: MapInput, baseSettings: Readonly<Record<string, unknown>>): Mapping | undefined {
 function mapBedrockMantle(input: MapInput, baseSettings: Readonly<Record<string, unknown>>): Mapping | undefined {
@@ -192,9 +222,7 @@ function mapOpenAIOptions(settings: Readonly<Record<string, unknown>>) {
 }
 }
 
 
 function mapBaseSettings(settings: Readonly<Record<string, unknown>>) {
 function mapBaseSettings(settings: Readonly<Record<string, unknown>>) {
-  return {
-    ...(typeof settings.baseURL === "string" ? { baseURL: settings.baseURL } : {}),
-  }
+  return typeof settings.baseURL === "string" ? { baseURL: settings.baseURL } : {}
 }
 }
 
 
 function mapAPIKey(settings: Readonly<Record<string, unknown>>) {
 function mapAPIKey(settings: Readonly<Record<string, unknown>>) {

+ 0 - 13
packages/core/src/model-resolver.ts

@@ -5,8 +5,6 @@ import { LanguageModel } from "@opencode-ai/ai"
 // ast-grep-ignore: no-star-import
 // ast-grep-ignore: no-star-import
 import * as AnthropicMessages from "@opencode-ai/ai/protocols/anthropic-messages"
 import * as AnthropicMessages from "@opencode-ai/ai/protocols/anthropic-messages"
 // ast-grep-ignore: no-star-import
 // ast-grep-ignore: no-star-import
-import * as OpenAICompatibleChat from "@opencode-ai/ai/protocols/openai-compatible-chat"
-// ast-grep-ignore: no-star-import
 import * as OpenAIResponses from "@opencode-ai/ai/protocols/openai-responses"
 import * as OpenAIResponses from "@opencode-ai/ai/protocols/openai-responses"
 import { Auth, type AnyRoute } from "@opencode-ai/ai/route"
 import { Auth, type AnyRoute } from "@opencode-ai/ai/route"
 import { Context, Effect, Layer, Schema } from "effect"
 import { Context, Effect, Layer, Schema } from "effect"
@@ -164,17 +162,6 @@ export const fromCatalogModel = (
         .model({ id: resolved.modelID ?? resolved.id, compatibility: resolved.compatibility }),
         .model({ id: resolved.modelID ?? resolved.id, compatibility: resolved.compatibility }),
     )
     )
   }
   }
-  if (
-    Provider.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.modelID ?? resolved.id, compatibility: resolved.compatibility }),
-    )
-  }
   const configured = { ...resolved.settings, ...credential?.metadata }
   const configured = { ...resolved.settings, ...credential?.metadata }
   const mapping = Provider.isAISDK(resolved.package)
   const mapping = Provider.isAISDK(resolved.package)
     ? AISDKNative.map({
     ? AISDKNative.map({

+ 7 - 5
packages/core/src/v1/config/migrate.ts

@@ -248,7 +248,7 @@ function migrateStandardProvider(info: ConfigProviderV1.Info) {
     body: info.options && options.body,
     body: info.options && options.body,
     models:
     models:
       info.models &&
       info.models &&
-      Object.fromEntries(Object.entries(info.models).map(([name, model]) => [name, migrateModel(model)])),
+      Object.fromEntries(Object.entries(info.models).map(([name, model]) => [name, migrateModel(model, info.npm)])),
   }
   }
 }
 }
 
 
@@ -294,8 +294,9 @@ export function providerID(input: string) {
   return input
   return input
 }
 }
 
 
-function migrateModel(info: typeof ConfigProviderV1.Model.Type) {
-  const settings = info.options && ConfigProviderOptionsV1.model(info.options)
+function migrateModel(info: typeof ConfigProviderV1.Model.Type, inheritedPackage?: string) {
+  const packageName = info.provider?.npm ?? inheritedPackage
+  const overlays = info.options && ConfigProviderOptionsV1.modelOverlays(info.options, packageName)
   const costs = info.cost && [
   const costs = info.cost && [
     {
     {
       input: info.cost.input,
       input: info.cost.input,
@@ -323,14 +324,15 @@ function migrateModel(info: typeof ConfigProviderV1.Model.Type) {
     name: info.name,
     name: info.name,
     compatibility: Model.compatibility(info.interleaved),
     compatibility: Model.compatibility(info.interleaved),
     package: info.provider?.npm ? Provider.aisdk(info.provider.npm) : undefined,
     package: info.provider?.npm ? Provider.aisdk(info.provider.npm) : undefined,
-    settings: info.provider?.api ? { ...settings, baseURL: info.provider.api } : settings,
+    settings: info.provider?.api ? { ...overlays?.settings, baseURL: info.provider.api } : overlays?.settings,
+    body: overlays?.body,
     capabilities,
     capabilities,
     headers: info.headers,
     headers: info.headers,
     variants:
     variants:
       info.variants &&
       info.variants &&
       Object.entries(info.variants).map(([id, options]) => ({
       Object.entries(info.variants).map(([id, options]) => ({
         id,
         id,
-        settings: ConfigProviderOptionsV1.model(options),
+        ...ConfigProviderOptionsV1.modelOverlays(options, packageName),
       })),
       })),
     cost: costs,
     cost: costs,
     disabled: info.status === "deprecated" ? true : undefined,
     disabled: info.status === "deprecated" ? true : undefined,

+ 15 - 0
packages/core/src/v1/config/provider-options.ts

@@ -29,3 +29,18 @@ export function provider(options: Options): ProviderResult {
 export function model(options: Options) {
 export function model(options: Options) {
   return { ...options }
   return { ...options }
 }
 }
+
+export function modelOverlays(options: Options, packageName: string | undefined) {
+  if (packageName !== "@ai-sdk/openai-compatible") return { settings: model(options) }
+  const known = new Set(["reasoningEffort", "strictJsonSchema"])
+  const settings = Object.fromEntries(Object.entries(options).filter(([key]) => known.has(key)))
+  const body = Object.fromEntries(
+    Object.entries(options)
+      .filter(([key]) => !known.has(key))
+      .map(([key, value]) => [key === "textVerbosity" ? "verbosity" : key, value]),
+  )
+  return {
+    settings: Object.keys(settings).length === 0 ? undefined : settings,
+    body: Object.keys(body).length === 0 ? undefined : body,
+  }
+}

+ 43 - 0
packages/core/test/aisdk-native.test.ts

@@ -5,6 +5,49 @@ const map = (packageName: string, settings: Readonly<Record<string, unknown>>, m
   AISDKNative.map({ packageName, settings, modelID })
   AISDKNative.map({ packageName, settings, modelID })
 
 
 describe("AISDKNative", () => {
 describe("AISDKNative", () => {
+  test("maps the generic OpenAI-compatible package to the native provider package", () => {
+    expect(
+      map("@ai-sdk/openai-compatible", {
+        apiKey: "secret",
+        baseURL: "https://compatible.example/v1",
+        name: "example",
+        headers: { "x-test": "value" },
+        queryParams: { tenant: "one" },
+        reasoningEffort: "high",
+      }),
+    ).toEqual({
+      package: "@opencode-ai/ai/providers/openai-compatible",
+      settings: {
+        apiKey: "secret",
+        baseURL: "https://compatible.example/v1",
+        provider: "example",
+        http: { query: { tenant: "one" } },
+        providerOptions: {
+          openai: {
+            reasoningEffort: "high",
+          },
+        },
+      },
+      headers: { "x-test": "value" },
+    })
+    expect(map("@ai-sdk/openai-compatible", {})).toBeUndefined()
+    expect(
+      map("@ai-sdk/openai-compatible", { baseURL: "https://compatible.example/v1", timeout: 30_000 }),
+    ).toBeUndefined()
+    expect(
+      map("@ai-sdk/openai-compatible", {
+        baseURL: "https://compatible.example/v1",
+        supportsStructuredOutputs: true,
+      }),
+    ).toBeUndefined()
+    expect(
+      map("@ai-sdk/openai-compatible", {
+        baseURL: "https://compatible.example/v1",
+        strictJsonSchema: false,
+      }),
+    ).toBeUndefined()
+  })
+
   test("maps both models.dev Bedrock packages to native providers", () => {
   test("maps both models.dev Bedrock packages to native providers", () => {
     expect(map("@ai-sdk/amazon-bedrock", { region: "us-east-1" })).toEqual({
     expect(map("@ai-sdk/amazon-bedrock", { region: "us-east-1" })).toEqual({
       package: "@opencode-ai/ai/providers/amazon-bedrock",
       package: "@opencode-ai/ai/providers/amazon-bedrock",

+ 66 - 0
packages/core/test/config/config.test.ts

@@ -570,6 +570,72 @@ describe("Config", () => {
     }),
     }),
   )
   )
 
 
+  it.effect("preserves serializable OpenAI-compatible options across v1 migration", () =>
+    Effect.sync(() => {
+      const migrated = ConfigMigrateV1.migrate({
+        provider: {
+          acme: {
+            npm: "@ai-sdk/openai-compatible",
+            api: "https://api.example/v1",
+            options: {
+              apiKey: "secret",
+              name: "acme",
+              headers: { "x-provider": "yes" },
+              body: { provider_body_extension: true },
+              queryParams: { tenant: "one" },
+              includeUsage: false,
+              supportsStructuredOutputs: true,
+            },
+            models: {
+              chat: {
+                options: {
+                  user: "user-1",
+                  reasoningEffort: "high",
+                  textVerbosity: "low",
+                  strictJsonSchema: false,
+                  vendor_extension: { enabled: true },
+                },
+                variants: {
+                  strict: { strictJsonSchema: true, variant_extension: "value" },
+                },
+              },
+            },
+          },
+        },
+      })
+
+      expect(migrated.providers?.acme).toMatchObject({
+        package: Provider.aisdk("@ai-sdk/openai-compatible"),
+        settings: {
+          apiKey: "secret",
+          name: "acme",
+          queryParams: { tenant: "one" },
+          includeUsage: false,
+          supportsStructuredOutputs: true,
+          baseURL: "https://api.example/v1",
+        },
+        headers: { "x-provider": "yes" },
+        body: { provider_body_extension: true },
+        models: {
+          chat: {
+            settings: {
+              reasoningEffort: "high",
+              strictJsonSchema: false,
+            },
+            body: { user: "user-1", verbosity: "low", vendor_extension: { enabled: true } },
+            variants: [
+              {
+                id: "strict",
+                settings: { strictJsonSchema: true },
+                body: { variant_extension: "value" },
+              },
+            ],
+          },
+        },
+      })
+    }),
+  )
+
   it.effect("renames old provider IDs while migrating v1 configuration", () =>
   it.effect("renames old provider IDs while migrating v1 configuration", () =>
     Effect.sync(() => {
     Effect.sync(() => {
       const migrated = ConfigMigrateV1.migrate({
       const migrated = ConfigMigrateV1.migrate({

+ 22 - 0
packages/core/test/config/provider-options.test.ts

@@ -38,6 +38,28 @@ describe("ConfigProviderOptionsV1", () => {
     })
     })
   })
   })
 
 
+  test("splits OpenAI-compatible model options into native settings and body extensions", () => {
+    expect(
+      ConfigProviderOptionsV1.modelOverlays(
+        {
+          user: "user-1",
+          reasoningEffort: "high",
+          textVerbosity: "low",
+          strictJsonSchema: false,
+          vendor_extension: { enabled: true },
+          store: false,
+        },
+        "@ai-sdk/openai-compatible",
+      ),
+    ).toEqual({
+      settings: {
+        reasoningEffort: "high",
+        strictJsonSchema: false,
+      },
+      body: { user: "user-1", verbosity: "low", vendor_extension: { enabled: true }, store: false },
+    })
+  })
+
   test("uses mechanical lowering for custom provider options", () => {
   test("uses mechanical lowering for custom provider options", () => {
     expect(ConfigProviderOptionsV1.provider({ enabled: true })).toEqual({
     expect(ConfigProviderOptionsV1.provider({ enabled: true })).toEqual({
       settings: { enabled: true },
       settings: { enabled: true },