mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-08 01:59:46 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 24c26dcf66 |
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>>) {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user