Compare commits

...

1 Commits

Author SHA1 Message Date
Aiden Cline 24c26dcf66 refactor(core): route openai-compatible natively 2026-08-07 17:06:21 +00:00
10 changed files with 263 additions and 26 deletions
+20 -5
View File
@@ -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 type { RouteDefaultsInput } from "../route/client"
import { Auth } from "../route/auth"
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options"
import type { ProviderPackage } from "../provider-package"
import { profiles, type OpenAICompatibleProfile } from "./openai-compatible-profile"
@@ -19,6 +20,8 @@ export interface Settings extends ProviderPackage.Settings {
readonly apiKey?: string
readonly baseURL: string
readonly provider?: string
readonly http?: RouteDefaultsInput["http"]
readonly providerOptions?: OpenAIProviderOptionsInput
}
export type FamilyModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
@@ -31,16 +34,24 @@ export const routes = [OpenAICompatibleChat.route]
export const configure = (input: GenericModelOptions) => {
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({
...rest,
provider,
endpoint: { baseURL },
auth: AuthOptions.bearer(input, []),
auth: AuthOptions.bearer(input, []).andThen(Auth.headers(headers ?? {})),
})
return {
id: ProviderID.make(provider),
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) }),
configure,
}
@@ -67,14 +78,18 @@ export const provider = {
configure,
}
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) =>
export const model: ProviderPackage.Definition<Settings>["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 } },
http: mergeHttpOptions(
settings.http === undefined ? undefined : HttpOptions.make(settings.http),
settings.body === undefined ? undefined : new HttpOptions({ body: { ...settings.body } }),
),
limits: settings.limits,
provider: settings.provider,
providerOptions: settings.providerOptions,
}).model(modelID)
export const baseten = define(profiles.baseten)
+16
View File
@@ -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 () => {
const AnthropicCompatible = await import("@opencode-ai/ai/providers/anthropic-compatible")
const selected = AnthropicCompatible.model("compatible-model", {
@@ -4,6 +4,7 @@ import { HttpClientRequest } from "effect/unstable/http"
import { LLM, LLMRequest, Message, ToolCallPart, ToolChoice, ToolDefinition } from "../../src"
import { Auth, LLMClient } from "../../src/route"
import { compileRequest } from "../../src/route/client"
import { jsonRequestParts } from "../../src/route/transport/http"
import * as OpenAICompatible from "../../src/providers/openai-compatible"
import * as OpenAICompatibleChat from "../../src/protocols/openai-compatible-chat"
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", () =>
Effect.gen(function* () {
const compatible = OpenAICompatibleChat.route
+31 -3
View File
@@ -51,6 +51,8 @@ export function map(input: MapInput): Mapping | undefined {
...mapGoogleOptions(input.settings),
},
}
case "@ai-sdk/openai-compatible":
return mapOpenAICompatible(input.settings)
case "@openrouter/ai-sdk-provider":
return mapOpenRouter(input.settings, baseSettings)
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 {
@@ -192,9 +222,7 @@ function mapOpenAIOptions(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>>) {
-13
View File
@@ -5,8 +5,6 @@ import { LanguageModel } from "@opencode-ai/ai"
// ast-grep-ignore: no-star-import
import * as AnthropicMessages from "@opencode-ai/ai/protocols/anthropic-messages"
// 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 { Auth, type AnyRoute } from "@opencode-ai/ai/route"
import { Context, Effect, Layer, Schema } from "effect"
@@ -164,17 +162,6 @@ export const fromCatalogModel = (
.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 mapping = Provider.isAISDK(resolved.package)
? AISDKNative.map({
+7 -5
View File
@@ -248,7 +248,7 @@ function migrateStandardProvider(info: ConfigProviderV1.Info) {
body: info.options && options.body,
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
}
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 && [
{
input: info.cost.input,
@@ -323,14 +324,15 @@ function migrateModel(info: typeof ConfigProviderV1.Model.Type) {
name: info.name,
compatibility: Model.compatibility(info.interleaved),
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,
headers: info.headers,
variants:
info.variants &&
Object.entries(info.variants).map(([id, options]) => ({
id,
settings: ConfigProviderOptionsV1.model(options),
...ConfigProviderOptionsV1.modelOverlays(options, packageName),
})),
cost: costs,
disabled: info.status === "deprecated" ? true : undefined,
@@ -29,3 +29,18 @@ export function provider(options: Options): ProviderResult {
export function model(options: 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
View File
@@ -5,6 +5,49 @@ const map = (packageName: string, settings: Readonly<Record<string, unknown>>, m
AISDKNative.map({ packageName, settings, modelID })
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", () => {
expect(map("@ai-sdk/amazon-bedrock", { region: "us-east-1" })).toEqual({
package: "@opencode-ai/ai/providers/amazon-bedrock",
+66
View File
@@ -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", () =>
Effect.sync(() => {
const migrated = ConfigMigrateV1.migrate({
@@ -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", () => {
expect(ConfigProviderOptionsV1.provider({ enabled: true })).toEqual({
settings: { enabled: true },