mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-20 06:53:27 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 48ea84109e |
+10
-5
@@ -71,7 +71,7 @@ export const route = Route.make({
|
||||
})
|
||||
```
|
||||
|
||||
Route defaults are request-shaping defaults such as `headers`, `limits`, `generation`, `providerOptions`, and `http`. Endpoint host/query belongs on the route endpoint. Selected `LanguageModel` values carry only model id, provider id, and the configured route value. Model capability/catalog metadata lives outside this package; protocol support is enforced by request lowering and typed `AIError`s.
|
||||
Route defaults are request-shaping defaults such as `headers`, `limits`, `generation`, `providerOptions`, and `http`. Endpoint host/query belongs on the route endpoint. Selected `LanguageModel` values carry model identity and the configured route; low-level callers may also attach model-specific defaults and compatibility metadata. Model capability/catalog metadata lives outside this package; protocol support is enforced by request lowering and typed `AIError`s.
|
||||
|
||||
The four-axis decomposition is the reason DeepSeek, TogetherAI, Cerebras, Baseten, Fireworks, and DeepInfra all reuse `OpenAIChat.protocol` verbatim — each provider deployment is a 5-15 line `Route.make(...)` call instead of a 300-400 line route clone. Bug fixes in one protocol propagate to every consumer of that protocol in a single commit.
|
||||
|
||||
@@ -88,7 +88,7 @@ For providers where the URL is derived from typed inputs (Azure resource name, B
|
||||
Provider-facing APIs are configured facades over route values. Endpoint/auth/resource/API-version setup happens before model selection, and model selectors accept only a model or deployment id:
|
||||
|
||||
```ts
|
||||
const openai = OpenAI.configure({ apiKey, baseURL })
|
||||
const openai = OpenAI.configure({ apiKey, baseURL, store: false })
|
||||
const model = openai.responses("gpt-4o-mini")
|
||||
|
||||
const azure = Azure.configure({ resourceName, apiKey, apiVersion: "v1" })
|
||||
@@ -108,17 +108,22 @@ Keep provider facades small and explicit:
|
||||
- Resolve `apiKey` → `Auth` with `AuthOptions.bearer(options, "<PROVIDER>_API_KEY")` (it honors an explicit `auth` override and falls back to `Auth.config(envVar)` so missing keys surface a typed `Authentication` error rather than a runtime crash).
|
||||
- Use separate top-level facades for products with different required setup, such as `CloudflareAIGateway` and `CloudflareWorkersAI`.
|
||||
|
||||
Provider facades and model-derived `LLMRequest.providerOptions` are provider-specific, so expose typed native options flat at those boundaries. Provider package settings keep deployment configuration separate from their typed `providerOptions` field, except facades such as OpenAI whose settings are already unambiguous when flat. The selected `LanguageModel<Options>` carries request-option typing; the route decodes the flat runtime record. Keep provider metadata namespaced because replay may contain metadata from multiple layers.
|
||||
|
||||
`Provider.make(...)` remains available for simple static provider definitions, but new built-in providers should prefer plain configured facades unless a helper removes real duplication without adding runtime behavior.
|
||||
|
||||
### Provider Package Entrypoints
|
||||
|
||||
Catalog-selected native providers use package-like export paths from `@opencode-ai/ai`. They are internal entrypoints in one npm package, not separately published provider packages. Every entrypoint implements `ProviderPackage.Definition` and exposes `model(modelID, settings)`, where settings are serializable provider configuration plus common `headers`, `body`, and `limits` overlays.
|
||||
Catalog-selected native providers use package-like export paths from `@opencode-ai/ai`. They are internal entrypoints in one npm package, not separately published provider packages. Every entrypoint implements `ProviderPackage.Definition` and exposes `model({ id, settings, credential, defaults })`. Core selects and refreshes the optional `key | oauth` credential; the provider package interprets it as route authentication. Serializable provider settings remain separate from common `headers`, `body`, and `limits` defaults.
|
||||
|
||||
```ts
|
||||
import { model } from "@opencode-ai/ai/providers/openai/responses"
|
||||
|
||||
const selected = model("gpt-5", {
|
||||
apiKey,
|
||||
const selected = model({
|
||||
id: "gpt-5",
|
||||
settings: {},
|
||||
credential: { type: "key", value: apiKey },
|
||||
defaults: {},
|
||||
})
|
||||
```
|
||||
|
||||
|
||||
+56
-12
@@ -305,19 +305,26 @@ const gateway = CloudflareAIGateway.configure({
|
||||
}).model("workers-ai/@cf/meta/llama-3.1-8b-instruct")
|
||||
```
|
||||
|
||||
Included providers: OpenAI, Anthropic, Google (Gemini), Google Vertex Gemini and Anthropic, Amazon Bedrock, Azure OpenAI, Cloudflare AI Gateway, Cloudflare Workers AI, GitHub Copilot, OpenRouter, xAI, Z.ai, plus generic OpenAI-compatible Chat and Responses entrypoints and an Anthropic Messages-compatible entrypoint.
|
||||
Included providers: OpenAI, Anthropic, Google (Gemini), Google Vertex Gemini and Anthropic, Amazon Bedrock, Azure OpenAI, Cloudflare AI Gateway, Cloudflare Workers AI, OpenRouter, xAI, Z.ai, plus generic OpenAI-compatible Chat and Responses entrypoints and an Anthropic Messages-compatible entrypoint. GitHub Copilot remains a Core-owned AI SDK integration rather than an AI-package provider.
|
||||
|
||||
### Package-like entrypoints
|
||||
|
||||
Native catalog integrations load provider behavior through package-like entrypoints. These are export paths from the same `@opencode-ai/ai` npm package, not independently published packages. Each entrypoint exports the same `model(modelID, settings)` contract, and `settings` contains serializable provider configuration plus common `headers`, `body`, and `limits` overlays.
|
||||
Native catalog integrations load provider behavior through package-like entrypoints. These are export paths from the same `@opencode-ai/ai` npm package, not independently published packages. Each entrypoint exports the same `model({ id, settings, credential, defaults })` contract. Core selects and refreshes the optional `key | oauth` credential, while the provider package interprets it as route authentication. Serializable provider settings remain separate from common `headers`, `body`, and `limits` defaults.
|
||||
|
||||
```ts
|
||||
import { model } from "@opencode-ai/ai/providers/openai/responses"
|
||||
|
||||
const selected = model("gpt-5", {
|
||||
apiKey: process.env.OPENAI_API_KEY,
|
||||
headers: { "x-application": "opencode" },
|
||||
limits: { context: 200_000, output: 64_000 },
|
||||
const apiKey = process.env.OPENAI_API_KEY
|
||||
if (!apiKey) throw new Error("OPENAI_API_KEY is required")
|
||||
|
||||
const selected = model({
|
||||
id: "gpt-5",
|
||||
settings: {},
|
||||
credential: { type: "key", value: apiKey },
|
||||
defaults: {
|
||||
headers: { "x-application": "opencode" },
|
||||
limits: { context: 200_000, output: 64_000 },
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
@@ -341,30 +348,57 @@ Tuned Vertex Gemini deployments use model ids shaped like `endpoints/1234567890`
|
||||
```ts
|
||||
import { model } from "@opencode-ai/ai/providers/google-vertex/gemini"
|
||||
|
||||
model("gemini-3.5-flash", { project: "my-project", location: "global" })
|
||||
model({
|
||||
id: "gemini-3.5-flash",
|
||||
settings: { project: "my-project", location: "global" },
|
||||
defaults: {},
|
||||
})
|
||||
```
|
||||
|
||||
```ts
|
||||
import { model } from "@opencode-ai/ai/providers/google-vertex/chat"
|
||||
|
||||
model("deepseek-ai/deepseek-v3.2-maas", { project: "my-project", location: "global" })
|
||||
model({
|
||||
id: "deepseek-ai/deepseek-v3.2-maas",
|
||||
settings: { project: "my-project", location: "global" },
|
||||
defaults: {},
|
||||
})
|
||||
```
|
||||
|
||||
```ts
|
||||
import { model } from "@opencode-ai/ai/providers/google-vertex/responses"
|
||||
|
||||
model("xai/grok-4.20-reasoning", { project: "my-project", location: "global" })
|
||||
model({
|
||||
id: "xai/grok-4.20-reasoning",
|
||||
settings: { project: "my-project", location: "global" },
|
||||
defaults: {},
|
||||
})
|
||||
```
|
||||
|
||||
```ts
|
||||
import { model } from "@opencode-ai/ai/providers/google-vertex/messages"
|
||||
|
||||
model("claude-sonnet-4-6", { project: "my-project", location: "global" })
|
||||
model({
|
||||
id: "claude-sonnet-4-6",
|
||||
settings: { project: "my-project", location: "global" },
|
||||
defaults: {},
|
||||
})
|
||||
```
|
||||
|
||||
Provider facades such as `OpenAI.configure(...).responses(...)` remain the direct application API. Package-like entrypoints are the self-similar loading contract used when a catalog selects behavior by export path.
|
||||
Provider facades such as `OpenAI.configure(...).responses(...)` remain the direct application API. Package-like entrypoints are the self-similar loading contract used when a catalog selects behavior by export path. The entrypoints listed above implement that contract and are covered by `test/provider-package.test.ts`.
|
||||
|
||||
Other provider exports listed above remain direct facades until they explicitly implement the package-like contract. Exporting a provider facade does not implicitly make it a catalog-loadable provider package.
|
||||
## How OpenCode uses this package
|
||||
|
||||
OpenCode does not call provider facades directly from the CLI or server. Core owns the integration:
|
||||
|
||||
1. `packages/core/src/model-resolver.ts` resolves catalog metadata and an active integration credential into a `LanguageModel`. Native package entrypoints expose `model({ id, settings, credential, defaults })`; catalog packages without a native mapping fall back through Core's AI SDK adapter.
|
||||
2. `packages/core/src/session/model-request.ts` lowers Session state, instructions, tools, and plugin hooks into one canonical `LLMRequest`.
|
||||
3. `packages/core/src/session/runner/llm.ts` calls the yielded `LLMClient.Service` once per physical attempt and persists provider-neutral `LLMEvent`s.
|
||||
4. Core owns retries, continuation, compaction, permissions, durable tool execution, and Session history. None of that orchestration belongs in this package.
|
||||
|
||||
Title generation, compaction, standalone generation, and transient Session generation also build `LLMRequest`s and use the same `LLMClient.Service`. Core's `AISDK` adapter wraps remaining Vercel AI SDK models in executable routes so native and fallback providers present the same request and event model to callers.
|
||||
|
||||
This separation is intentional: `@opencode-ai/ai` owns one model call, provider protocols, and transport; Core owns the durable agent runtime.
|
||||
|
||||
## Provider options & HTTP overlays
|
||||
|
||||
@@ -377,6 +411,16 @@ Request options in order of stability:
|
||||
|
||||
Route/provider defaults are overridden by request-level values for each axis.
|
||||
|
||||
Provider-specific facades accept their own options directly because the provider is already known:
|
||||
|
||||
```ts
|
||||
const model = OpenAI.configure({
|
||||
apiKey,
|
||||
store: false,
|
||||
reasoningEffort: "high",
|
||||
}).responses("gpt-5")
|
||||
```
|
||||
|
||||
The selected model supplies the provider-specific option type, so per-request overrides stay flat while the canonical runtime request remains provider-neutral:
|
||||
|
||||
```ts
|
||||
|
||||
@@ -17,13 +17,12 @@ import { OpenAI } from "@opencode-ai/ai/providers"
|
||||
const apiKey = Config.redacted("OPENAI_API_KEY")
|
||||
|
||||
// 1. Pick a model. The provider helper records provider identity, protocol
|
||||
// choice, capabilities, deployment options, authentication, and defaults.
|
||||
// choice, deployment options, authentication, and defaults. Catalog capabilities
|
||||
// remain application-owned and are not part of LanguageModel.
|
||||
const model = OpenAI.configure({
|
||||
apiKey,
|
||||
generation: { maxTokens: 160 },
|
||||
providerOptions: {
|
||||
store: false,
|
||||
},
|
||||
store: false,
|
||||
}).model("gpt-4o-mini")
|
||||
|
||||
// 2. Build a provider-neutral request. This is useful when reusing one request
|
||||
@@ -74,8 +73,8 @@ const streamText = LLM.stream(request).pipe(
|
||||
Stream.runDrain,
|
||||
)
|
||||
|
||||
// 5. Tools are typed with Effect Schema. Provider turns remain explicit:
|
||||
// advertise definitions on the request, stream one turn, dispatch local calls,
|
||||
// 5. Tools are typed with Effect Schema. Model calls remain explicit:
|
||||
// advertise definitions on the request, stream one call, dispatch local calls,
|
||||
// then persist/build follow-up history in the enclosing product flow.
|
||||
const tools = {
|
||||
get_weather: Tool.make({
|
||||
@@ -102,7 +101,7 @@ const streamWithTools = Effect.gen(function* () {
|
||||
console.log("tool result", event.name, dispatched.result)
|
||||
|
||||
// A durable agent would persist these messages before starting another
|
||||
// raw model turn. This tutorial keeps the boundary visible instead.
|
||||
// model call. This tutorial keeps the boundary visible instead.
|
||||
const followUp = LLMRequest.update(request, {
|
||||
messages: [
|
||||
...request.messages,
|
||||
|
||||
@@ -37,6 +37,9 @@ export type {
|
||||
LanguageModelOptions as ProviderLanguageModelOptions,
|
||||
} from "./provider.js"
|
||||
export type {
|
||||
Credential as ProviderPackageCredential,
|
||||
Defaults as ProviderPackageDefaults,
|
||||
Definition as ProviderPackageDefinition,
|
||||
ModelInput as ProviderPackageModelInput,
|
||||
Settings as ProviderPackageSettings,
|
||||
} from "./provider-package.js"
|
||||
|
||||
@@ -1,7 +1,21 @@
|
||||
import { Auth } from "./route/auth.js"
|
||||
import type { AuthOverride, RequiredApiKeyAuth } from "./route/auth-options.js"
|
||||
import type { LanguageModel, ProviderOptions } from "./schema/index.js"
|
||||
|
||||
export interface Settings extends Readonly<Record<string, unknown>> {
|
||||
readonly baseURL?: string
|
||||
export interface Settings {}
|
||||
|
||||
export type Credential =
|
||||
| {
|
||||
readonly type: "key"
|
||||
readonly value: string
|
||||
readonly configuration?: Readonly<Record<string, unknown>>
|
||||
}
|
||||
| {
|
||||
readonly type: "oauth"
|
||||
readonly accessToken: string
|
||||
}
|
||||
|
||||
export interface Defaults {
|
||||
readonly headers?: Readonly<Record<string, string>>
|
||||
readonly body?: Readonly<Record<string, unknown>>
|
||||
readonly limits?: {
|
||||
@@ -11,11 +25,38 @@ export interface Settings extends Readonly<Record<string, unknown>> {
|
||||
}
|
||||
}
|
||||
|
||||
export interface ModelInput<ProviderSettings extends Settings = Settings> {
|
||||
readonly id: string
|
||||
readonly settings: ProviderSettings
|
||||
readonly credential?: Credential
|
||||
readonly defaults: Defaults
|
||||
}
|
||||
|
||||
export const routeDefaults = (input: Defaults) => ({
|
||||
headers: input.headers,
|
||||
http: input.body === undefined ? undefined : { body: input.body },
|
||||
limits: input.limits,
|
||||
})
|
||||
|
||||
export const bearerCredentialValue = (input: Credential) => (input.type === "key" ? input.value : input.accessToken)
|
||||
|
||||
export const bearerAuthOption = (input: Credential): AuthOverride => ({
|
||||
auth: Auth.bearer(bearerCredentialValue(input)),
|
||||
})
|
||||
|
||||
export const apiKeyOrBearerAuthOption = (
|
||||
input: Credential,
|
||||
competingKeyHeader: string,
|
||||
): RequiredApiKeyAuth | AuthOverride =>
|
||||
input.type === "key"
|
||||
? { apiKey: input.value }
|
||||
: { auth: Auth.remove(competingKeyHeader).andThen(Auth.bearer(input.accessToken)) }
|
||||
|
||||
export interface Definition<
|
||||
ProviderSettings extends Settings = Settings,
|
||||
Options extends ProviderOptions = ProviderOptions,
|
||||
> {
|
||||
readonly model: (modelID: string, settings: ProviderSettings) => LanguageModel<Options>
|
||||
readonly model: (input: ModelInput<ProviderSettings>) => LanguageModel<Options>
|
||||
}
|
||||
|
||||
export * as ProviderPackage from "./provider-package.js"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Auth } from "../route/auth.js"
|
||||
import type { Route as RouteDef, RouteDefaultsInput } from "../route/client.js"
|
||||
import type { ProviderPackage } from "../provider-package.js"
|
||||
import { ProviderPackage } from "../provider-package.js"
|
||||
import { OpenAIChat } from "../protocols/openai-chat.js"
|
||||
import { OpenAIResponses } from "../protocols/openai-responses.js"
|
||||
import { BedrockAuth, type Credentials } from "../protocols/utils/bedrock-auth.js"
|
||||
@@ -79,29 +79,27 @@ export const configure = (input: Config = {}) => {
|
||||
|
||||
export const provider = configure()
|
||||
|
||||
const config = (settings: Settings): Config => {
|
||||
if (settings.auth === "bearer" && settings.apiKey === undefined)
|
||||
const config = (input: ProviderPackage.ModelInput<Settings>): Config => {
|
||||
if (!input.credential && input.settings.auth === "bearer" && input.settings.apiKey === undefined)
|
||||
throw new Error("Amazon Bedrock Mantle bearer auth requires apiKey")
|
||||
if (settings.auth === "sigv4" && settings.apiKey !== undefined)
|
||||
if (!input.credential && input.settings.auth === "sigv4" && input.settings.apiKey !== undefined)
|
||||
throw new Error("Amazon Bedrock Mantle SigV4 auth does not accept apiKey")
|
||||
return {
|
||||
apiKey: settings.auth === "sigv4" ? undefined : settings.apiKey,
|
||||
baseURL: settings.baseURL,
|
||||
credentials: settings.credentials,
|
||||
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
limits: settings.limits,
|
||||
providerOptions: settings.providerOptions,
|
||||
region: settings.region,
|
||||
...ProviderPackage.routeDefaults(input.defaults),
|
||||
apiKey: input.credential
|
||||
? ProviderPackage.bearerCredentialValue(input.credential)
|
||||
: input.settings.auth === "sigv4"
|
||||
? undefined
|
||||
: input.settings.apiKey,
|
||||
baseURL: input.settings.baseURL,
|
||||
credentials: input.settings.credentials,
|
||||
providerOptions: input.settings.providerOptions,
|
||||
region: input.settings.region,
|
||||
}
|
||||
}
|
||||
|
||||
export const chatModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
|
||||
modelID,
|
||||
settings,
|
||||
) => configure(config(settings)).chat(modelID)
|
||||
export const responsesModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
|
||||
modelID,
|
||||
settings,
|
||||
) => configure(config(settings)).responses(modelID)
|
||||
export const chatModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (input) =>
|
||||
configure(config(input)).chat(input.id)
|
||||
export const responsesModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (input) =>
|
||||
configure(config(input)).responses(input.id)
|
||||
export const model = chatModel
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { RouteDefaultsInput } from "../route/client.js"
|
||||
import { Auth } from "../route/auth.js"
|
||||
import type { ProviderPackage } from "../provider-package.js"
|
||||
import { ProviderPackage } from "../provider-package.js"
|
||||
import { ProviderID, type ModelID } from "../schema/index.js"
|
||||
import * as BedrockConverse from "../protocols/bedrock-converse.js"
|
||||
import type { BedrockCredentials } from "../protocols/bedrock-converse.js"
|
||||
@@ -50,19 +50,21 @@ export const configure = (input: Config = {}) => {
|
||||
}
|
||||
|
||||
export const provider = configure()
|
||||
export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) => {
|
||||
if (settings.auth === "bearer" && settings.apiKey === undefined)
|
||||
export const model: ProviderPackage.Definition<Settings>["model"] = (input) => {
|
||||
if (!input.credential && input.settings.auth === "bearer" && input.settings.apiKey === undefined)
|
||||
throw new Error("Amazon Bedrock bearer auth requires apiKey")
|
||||
if (settings.auth === "sigv4" && settings.apiKey !== undefined)
|
||||
if (!input.credential && input.settings.auth === "sigv4" && input.settings.apiKey !== undefined)
|
||||
throw new Error("Amazon Bedrock SigV4 auth does not accept apiKey")
|
||||
return configure({
|
||||
apiKey: settings.auth === "sigv4" ? undefined : settings.apiKey,
|
||||
baseURL: settings.baseURL,
|
||||
credentials: settings.credentials,
|
||||
generation: settings.topP === undefined ? undefined : { topP: settings.topP },
|
||||
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
limits: settings.limits,
|
||||
region: settings.region,
|
||||
}).model(modelID)
|
||||
...ProviderPackage.routeDefaults(input.defaults),
|
||||
apiKey: input.credential
|
||||
? ProviderPackage.bearerCredentialValue(input.credential)
|
||||
: input.settings.auth === "sigv4"
|
||||
? undefined
|
||||
: input.settings.apiKey,
|
||||
baseURL: input.settings.baseURL,
|
||||
credentials: input.settings.credentials,
|
||||
generation: input.settings.topP === undefined ? undefined : { topP: input.settings.topP },
|
||||
region: input.settings.region,
|
||||
}).model(input.id)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ProviderPackage } from "../provider-package.js"
|
||||
import { ProviderPackage } from "../provider-package.js"
|
||||
import { AnthropicMessages } from "../protocols/anthropic-messages.js"
|
||||
import { Auth } from "../route/auth.js"
|
||||
import type { ProviderAuthOption } from "../route/auth-options.js"
|
||||
@@ -32,7 +32,9 @@ export const routes = [AnthropicMessages.route]
|
||||
|
||||
const auth = (input: ProviderAuthOption<"optional">) => {
|
||||
if ("auth" in input && input.auth) return input.auth
|
||||
return Auth.optional("apiKey" in input ? input.apiKey : undefined, "apiKey").pipe(Auth.header("x-api-key"))
|
||||
return Auth.remove("authorization").andThen(
|
||||
Auth.optional("apiKey" in input ? input.apiKey : undefined, "apiKey").pipe(Auth.header("x-api-key")),
|
||||
)
|
||||
}
|
||||
|
||||
export const configure = (input: Config) => {
|
||||
@@ -57,21 +59,20 @@ export const provider = {
|
||||
configure,
|
||||
}
|
||||
|
||||
export const model: ProviderPackage.Definition<Settings, AnthropicMessages.ProviderOptionsInput>["model"] = (
|
||||
modelID,
|
||||
settings,
|
||||
) => {
|
||||
if (settings.apiKey !== undefined && settings.authToken !== undefined)
|
||||
export const model: ProviderPackage.Definition<Settings, AnthropicMessages.ProviderOptionsInput>["model"] = (input) => {
|
||||
if (!input.credential && input.settings.apiKey !== undefined && input.settings.authToken !== undefined)
|
||||
throw new Error("Anthropic-compatible apiKey cannot be combined with authToken")
|
||||
return configure({
|
||||
...(settings.authToken === undefined ? { apiKey: settings.apiKey } : { auth: Auth.bearer(settings.authToken) }),
|
||||
baseURL: settings.baseURL,
|
||||
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
limits: settings.limits,
|
||||
provider: settings.provider,
|
||||
providerOptions: settings.providerOptions,
|
||||
}).model(modelID)
|
||||
...ProviderPackage.routeDefaults(input.defaults),
|
||||
...(input.credential
|
||||
? ProviderPackage.apiKeyOrBearerAuthOption(input.credential, "x-api-key")
|
||||
: input.settings.authToken === undefined
|
||||
? { apiKey: input.settings.apiKey }
|
||||
: { auth: Auth.bearer(input.settings.authToken) }),
|
||||
baseURL: input.settings.baseURL,
|
||||
provider: input.settings.provider,
|
||||
providerOptions: input.settings.providerOptions,
|
||||
}).model(input.id)
|
||||
}
|
||||
|
||||
export * as AnthropicCompatible from "./anthropic-compatible.js"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { RouteDefaultsInput } from "../route/client.js"
|
||||
import { Auth } from "../route/auth.js"
|
||||
import type { ProviderAuthOption } from "../route/auth-options.js"
|
||||
import type { ProviderPackage } from "../provider-package.js"
|
||||
import { ProviderPackage } from "../provider-package.js"
|
||||
import { ProviderID, type ModelID } from "../schema/index.js"
|
||||
import { AnthropicMessages } from "../protocols/anthropic-messages.js"
|
||||
import { AnthropicCompatible } from "./anthropic-compatible.js"
|
||||
@@ -31,9 +31,11 @@ export type Settings = ProviderPackage.Settings &
|
||||
|
||||
const auth = (options: ProviderAuthOption<"optional">) => {
|
||||
if ("auth" in options && options.auth) return options.auth
|
||||
return Auth.optional("apiKey" in options ? options.apiKey : undefined, "apiKey")
|
||||
.orElse(Auth.config("ANTHROPIC_API_KEY"))
|
||||
.pipe(Auth.header("x-api-key"))
|
||||
return Auth.remove("authorization").andThen(
|
||||
Auth.optional("apiKey" in options ? options.apiKey : undefined, "apiKey")
|
||||
.orElse(Auth.config("ANTHROPIC_API_KEY"))
|
||||
.pipe(Auth.header("x-api-key")),
|
||||
)
|
||||
}
|
||||
|
||||
export const configure = (input: Config = {}) => {
|
||||
@@ -52,18 +54,17 @@ export const configure = (input: Config = {}) => {
|
||||
}
|
||||
|
||||
export const provider = configure()
|
||||
export const model: ProviderPackage.Definition<Settings, AnthropicMessages.ProviderOptionsInput>["model"] = (
|
||||
modelID,
|
||||
settings,
|
||||
) => {
|
||||
if (settings.apiKey !== undefined && settings.authToken !== undefined)
|
||||
export const model: ProviderPackage.Definition<Settings, AnthropicMessages.ProviderOptionsInput>["model"] = (input) => {
|
||||
if (!input.credential && input.settings.apiKey !== undefined && input.settings.authToken !== undefined)
|
||||
throw new Error("Anthropic apiKey cannot be combined with authToken")
|
||||
return configure({
|
||||
...(settings.authToken === undefined ? { apiKey: settings.apiKey } : { auth: Auth.bearer(settings.authToken) }),
|
||||
baseURL: settings.baseURL,
|
||||
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
limits: settings.limits,
|
||||
providerOptions: settings.providerOptions,
|
||||
}).model(modelID)
|
||||
...ProviderPackage.routeDefaults(input.defaults),
|
||||
...(input.credential
|
||||
? ProviderPackage.apiKeyOrBearerAuthOption(input.credential, "x-api-key")
|
||||
: input.settings.authToken === undefined
|
||||
? { apiKey: input.settings.apiKey }
|
||||
: { auth: Auth.bearer(input.settings.authToken) }),
|
||||
baseURL: input.settings.baseURL,
|
||||
providerOptions: input.settings.providerOptions,
|
||||
}).model(input.id)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Auth } from "../route/auth.js"
|
||||
import { type AtLeastOne, type ProviderAuthOption } from "../route/auth-options.js"
|
||||
import type { Route as RouteDef, RouteDefaultsInput } from "../route/client.js"
|
||||
import type { ProviderPackage } from "../provider-package.js"
|
||||
import { ProviderPackage } from "../provider-package.js"
|
||||
import { ProviderID, type ModelID } from "../schema/index.js"
|
||||
import * as OpenAIChat from "../protocols/openai-chat.js"
|
||||
import * as OpenAIResponses from "../protocols/openai-responses.js"
|
||||
@@ -120,28 +120,29 @@ export const provider = {
|
||||
configure,
|
||||
}
|
||||
|
||||
const config = (settings: Settings): Config => {
|
||||
const config = (input: ProviderPackage.ModelInput<Settings>): Config => {
|
||||
const settings = input.settings
|
||||
const configuration = input.credential?.type === "key" ? input.credential.configuration : undefined
|
||||
const baseURL = settings.baseURL ?? (typeof configuration?.baseURL === "string" ? configuration.baseURL : undefined)
|
||||
const resourceName =
|
||||
settings.resourceName ?? (typeof configuration?.resourceName === "string" ? configuration.resourceName : undefined)
|
||||
const common = {
|
||||
apiKey: settings.apiKey,
|
||||
...ProviderPackage.routeDefaults(input.defaults),
|
||||
...(input.credential
|
||||
? ProviderPackage.apiKeyOrBearerAuthOption(input.credential, "api-key")
|
||||
: { apiKey: settings.apiKey }),
|
||||
apiVersion: settings.apiVersion,
|
||||
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
limits: settings.limits,
|
||||
providerOptions: settings.providerOptions,
|
||||
queryParams: settings.queryParams === undefined ? undefined : { ...settings.queryParams },
|
||||
useDeploymentBasedUrls: settings.useDeploymentBasedUrls,
|
||||
}
|
||||
if (settings.baseURL !== undefined) return { ...common, baseURL: settings.baseURL }
|
||||
if (settings.resourceName !== undefined) return { ...common, resourceName: settings.resourceName }
|
||||
if (baseURL !== undefined) return { ...common, baseURL }
|
||||
if (resourceName !== undefined) return { ...common, resourceName }
|
||||
throw new Error("Azure requires resourceName or baseURL")
|
||||
}
|
||||
|
||||
export const responsesModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
|
||||
modelID,
|
||||
settings,
|
||||
) => configure(config(settings)).responses(modelID)
|
||||
export const chatModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
|
||||
modelID,
|
||||
settings,
|
||||
) => configure(config(settings)).chat(modelID)
|
||||
export const responsesModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (input) =>
|
||||
configure(config(input)).responses(input.id)
|
||||
export const chatModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (input) =>
|
||||
configure(config(input)).chat(input.id)
|
||||
export const model = responsesModel
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ProviderPackage } from "../provider-package.js"
|
||||
import { ProviderPackage } from "../provider-package.js"
|
||||
import { OpenAICompatibleChat } from "../protocols/openai-compatible-chat.js"
|
||||
import type { RouteDefaultsInput } from "../route/client.js"
|
||||
import { ProviderID, type ModelID } from "../schema/index.js"
|
||||
@@ -68,16 +68,15 @@ export const provider = {
|
||||
configure,
|
||||
}
|
||||
|
||||
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) => {
|
||||
if (settings.apiKey !== undefined) throw new Error("Google Vertex Chat does not support API keys")
|
||||
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (input) => {
|
||||
if (input.credential?.type === "key" || (!input.credential && input.settings.apiKey !== undefined))
|
||||
throw new Error("Google Vertex Chat does not support API keys")
|
||||
return configure({
|
||||
accessToken: settings.accessToken,
|
||||
baseURL: settings.baseURL,
|
||||
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
limits: settings.limits,
|
||||
location: settings.location,
|
||||
project: settings.project,
|
||||
providerOptions: settings.providerOptions,
|
||||
}).model(modelID)
|
||||
...ProviderPackage.routeDefaults(input.defaults),
|
||||
accessToken: input.credential?.type === "oauth" ? input.credential.accessToken : input.settings.accessToken,
|
||||
baseURL: input.settings.baseURL,
|
||||
location: input.settings.location,
|
||||
project: input.settings.project,
|
||||
providerOptions: input.settings.providerOptions,
|
||||
}).model(input.id)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Effect, Schema, Struct } from "effect"
|
||||
import type { ProviderPackage } from "../provider-package.js"
|
||||
import { ProviderPackage } from "../provider-package.js"
|
||||
import { AnthropicMessages } from "../protocols/anthropic-messages.js"
|
||||
import { Auth } from "../route/auth.js"
|
||||
import { Route, type RouteDefaultsInput } from "../route/client.js"
|
||||
@@ -100,19 +100,15 @@ export const provider = {
|
||||
configure,
|
||||
}
|
||||
|
||||
export const model: ProviderPackage.Definition<Settings, AnthropicMessages.ProviderOptionsInput>["model"] = (
|
||||
modelID,
|
||||
settings,
|
||||
) => {
|
||||
if (settings.apiKey !== undefined) throw new Error("Google Vertex Messages does not support API keys")
|
||||
export const model: ProviderPackage.Definition<Settings, AnthropicMessages.ProviderOptionsInput>["model"] = (input) => {
|
||||
if (input.credential?.type === "key" || (!input.credential && input.settings.apiKey !== undefined))
|
||||
throw new Error("Google Vertex Messages does not support API keys")
|
||||
return configure({
|
||||
accessToken: settings.accessToken,
|
||||
baseURL: settings.baseURL,
|
||||
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
limits: settings.limits,
|
||||
location: settings.location,
|
||||
project: settings.project,
|
||||
providerOptions: settings.providerOptions,
|
||||
}).model(modelID)
|
||||
...ProviderPackage.routeDefaults(input.defaults),
|
||||
accessToken: input.credential?.type === "oauth" ? input.credential.accessToken : input.settings.accessToken,
|
||||
baseURL: input.settings.baseURL,
|
||||
location: input.settings.location,
|
||||
project: input.settings.project,
|
||||
providerOptions: input.settings.providerOptions,
|
||||
}).model(input.id)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ProviderPackage } from "../provider-package.js"
|
||||
import { ProviderPackage } from "../provider-package.js"
|
||||
import { OpenAICompatibleResponses } from "../protocols/openai-compatible-responses.js"
|
||||
import type { RouteDefaultsInput } from "../route/client.js"
|
||||
import { ProviderID, type ModelID } from "../schema/index.js"
|
||||
@@ -70,19 +70,15 @@ export const provider = {
|
||||
configure,
|
||||
}
|
||||
|
||||
export const model: ProviderPackage.Definition<Settings, OpenResponsesProviderOptionsInput>["model"] = (
|
||||
modelID,
|
||||
settings,
|
||||
) => {
|
||||
if (settings.apiKey !== undefined) throw new Error("Google Vertex Responses does not support API keys")
|
||||
export const model: ProviderPackage.Definition<Settings, OpenResponsesProviderOptionsInput>["model"] = (input) => {
|
||||
if (input.credential?.type === "key" || (!input.credential && input.settings.apiKey !== undefined))
|
||||
throw new Error("Google Vertex Responses does not support API keys")
|
||||
return configure({
|
||||
accessToken: settings.accessToken,
|
||||
baseURL: settings.baseURL,
|
||||
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
limits: settings.limits,
|
||||
location: settings.location,
|
||||
project: settings.project,
|
||||
providerOptions: settings.providerOptions,
|
||||
}).model(modelID)
|
||||
...ProviderPackage.routeDefaults(input.defaults),
|
||||
accessToken: input.credential?.type === "oauth" ? input.credential.accessToken : input.settings.accessToken,
|
||||
baseURL: input.settings.baseURL,
|
||||
location: input.settings.location,
|
||||
project: input.settings.project,
|
||||
providerOptions: input.settings.providerOptions,
|
||||
}).model(input.id)
|
||||
}
|
||||
|
||||
@@ -69,9 +69,8 @@ const adc = (project?: string) => {
|
||||
export const oauth = (input: OAuthOptions, project?: string) => {
|
||||
if (input.accessToken !== undefined && input.auth !== undefined)
|
||||
throw new Error("Google Vertex accessToken cannot be combined with auth")
|
||||
if (input.auth) return input.auth
|
||||
if (input.accessToken !== undefined) return Auth.bearer(input.accessToken)
|
||||
return adc(project)
|
||||
const auth = input.auth ?? (input.accessToken !== undefined ? Auth.bearer(input.accessToken) : adc(project))
|
||||
return Auth.remove("x-goog-api-key").andThen(auth)
|
||||
}
|
||||
|
||||
export * as GoogleVertexShared from "./google-vertex-shared.js"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Effect } from "effect"
|
||||
import type { ProviderPackage } from "../provider-package.js"
|
||||
import { ProviderPackage } from "../provider-package.js"
|
||||
import { Gemini } from "../protocols/gemini.js"
|
||||
import { ProviderShared } from "../protocols/shared.js"
|
||||
import { Auth } from "../route/auth.js"
|
||||
@@ -94,7 +94,10 @@ const configuredRoute = (input: Config, modelID: string | ModelID) => {
|
||||
return route.with({
|
||||
...rest,
|
||||
endpoint: { baseURL: endpoint },
|
||||
auth: apiKey === undefined ? GoogleVertexShared.oauth(input, project) : Auth.header("x-goog-api-key", apiKey),
|
||||
auth:
|
||||
apiKey === undefined
|
||||
? GoogleVertexShared.oauth(input, project)
|
||||
: Auth.remove("authorization").andThen(Auth.header("x-goog-api-key", apiKey)),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -111,17 +114,21 @@ export const provider = {
|
||||
id,
|
||||
configure,
|
||||
}
|
||||
export const model: ProviderPackage.Definition<Settings, GeminiProviderOptionsInput>["model"] = (modelID, settings) => {
|
||||
if (settings.apiKey !== undefined && settings.accessToken !== undefined)
|
||||
export const model: ProviderPackage.Definition<Settings, GeminiProviderOptionsInput>["model"] = (input) => {
|
||||
if (!input.credential && input.settings.apiKey !== undefined && input.settings.accessToken !== undefined)
|
||||
throw new Error("Google Vertex apiKey cannot be combined with accessToken or auth")
|
||||
return configure({
|
||||
...(settings.apiKey === undefined ? { accessToken: settings.accessToken } : { apiKey: settings.apiKey }),
|
||||
baseURL: settings.baseURL,
|
||||
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
limits: settings.limits,
|
||||
location: settings.location,
|
||||
project: settings.project,
|
||||
providerOptions: settings.providerOptions,
|
||||
}).model(modelID)
|
||||
...ProviderPackage.routeDefaults(input.defaults),
|
||||
...(input.credential
|
||||
? input.credential.type === "key"
|
||||
? { apiKey: input.credential.value }
|
||||
: { accessToken: input.credential.accessToken }
|
||||
: input.settings.apiKey === undefined
|
||||
? { accessToken: input.settings.accessToken }
|
||||
: { apiKey: input.settings.apiKey }),
|
||||
baseURL: input.settings.baseURL,
|
||||
location: input.settings.location,
|
||||
project: input.settings.project,
|
||||
providerOptions: input.settings.providerOptions,
|
||||
}).model(input.id)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { RouteDefaultsInput } from "../route/client.js"
|
||||
import { Auth } from "../route/auth.js"
|
||||
import type { ProviderAuthOption } from "../route/auth-options.js"
|
||||
import type { ProviderPackage } from "../provider-package.js"
|
||||
import { ProviderPackage } from "../provider-package.js"
|
||||
import { HttpOptions, ProviderID, mergeHttpOptions, type ModelID } from "../schema/index.js"
|
||||
import { Gemini } from "../protocols/gemini.js"
|
||||
import { GoogleImages } from "../protocols/google-images.js"
|
||||
@@ -28,9 +28,11 @@ export interface Settings extends ProviderPackage.Settings {
|
||||
|
||||
const auth = (options: ProviderAuthOption<"optional">) => {
|
||||
if ("auth" in options && options.auth) return options.auth
|
||||
return Auth.optional("apiKey" in options ? options.apiKey : undefined, "apiKey")
|
||||
.orElse(Auth.config("GOOGLE_GENERATIVE_AI_API_KEY"))
|
||||
.pipe(Auth.header("x-goog-api-key"))
|
||||
return Auth.remove("authorization").andThen(
|
||||
Auth.optional("apiKey" in options ? options.apiKey : undefined, "apiKey")
|
||||
.orElse(Auth.config("GOOGLE_GENERATIVE_AI_API_KEY"))
|
||||
.pipe(Auth.header("x-goog-api-key")),
|
||||
)
|
||||
}
|
||||
|
||||
const configuredRoute = (input: Config) => {
|
||||
@@ -57,14 +59,14 @@ export const configure = (input: Config = {}) => {
|
||||
}
|
||||
|
||||
export const provider = configure()
|
||||
export const model: ProviderPackage.Definition<Settings, Gemini.ProviderOptionsInput>["model"] = (modelID, settings) =>
|
||||
export const model: ProviderPackage.Definition<Settings, Gemini.ProviderOptionsInput>["model"] = (input) =>
|
||||
configure({
|
||||
apiKey: settings.apiKey,
|
||||
baseURL: settings.baseURL,
|
||||
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
limits: settings.limits,
|
||||
providerOptions: settings.providerOptions,
|
||||
}).model(modelID)
|
||||
...ProviderPackage.routeDefaults(input.defaults),
|
||||
...(input.credential
|
||||
? ProviderPackage.apiKeyOrBearerAuthOption(input.credential, "x-goog-api-key")
|
||||
: { apiKey: input.settings.apiKey }),
|
||||
baseURL: input.settings.baseURL,
|
||||
providerOptions: input.settings.providerOptions,
|
||||
}).model(input.id)
|
||||
|
||||
export const image = provider.image
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ProviderPackage } from "../provider-package.js"
|
||||
import { ProviderPackage } from "../provider-package.js"
|
||||
import { OpenAICompatibleResponses } from "../protocols/openai-compatible-responses.js"
|
||||
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
|
||||
import type { RouteDefaultsInput } from "../route/client.js"
|
||||
@@ -46,16 +46,11 @@ export const provider = {
|
||||
configure,
|
||||
}
|
||||
|
||||
export const model: ProviderPackage.Definition<Settings, OpenResponsesProviderOptionsInput>["model"] = (
|
||||
modelID,
|
||||
settings,
|
||||
) =>
|
||||
export const model: ProviderPackage.Definition<Settings, OpenResponsesProviderOptionsInput>["model"] = (input) =>
|
||||
configure({
|
||||
apiKey: settings.apiKey,
|
||||
baseURL: settings.baseURL,
|
||||
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
limits: settings.limits,
|
||||
provider: settings.provider,
|
||||
providerOptions: settings.providerOptions,
|
||||
}).model(modelID)
|
||||
...ProviderPackage.routeDefaults(input.defaults),
|
||||
...(input.credential ? ProviderPackage.bearerAuthOption(input.credential) : { apiKey: input.settings.apiKey }),
|
||||
baseURL: input.settings.baseURL,
|
||||
provider: input.settings.provider,
|
||||
providerOptions: input.settings.providerOptions,
|
||||
}).model(input.id)
|
||||
|
||||
@@ -2,7 +2,7 @@ import { ProviderID, type ModelID } from "../schema/index.js"
|
||||
import * as OpenAICompatibleChat from "../protocols/openai-compatible-chat.js"
|
||||
import type { RouteDefaultsInput } from "../route/client.js"
|
||||
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
|
||||
import type { ProviderPackage } from "../provider-package.js"
|
||||
import { ProviderPackage } from "../provider-package.js"
|
||||
import { profiles, type OpenAICompatibleProfile } from "./openai-compatible-profile.js"
|
||||
import type { OpenAIProviderOptionsInput } from "./openai-options.js"
|
||||
|
||||
@@ -68,16 +68,14 @@ export const provider = {
|
||||
configure,
|
||||
}
|
||||
|
||||
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) =>
|
||||
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (input) =>
|
||||
configure({
|
||||
apiKey: settings.apiKey,
|
||||
baseURL: settings.baseURL,
|
||||
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
limits: settings.limits,
|
||||
provider: settings.provider,
|
||||
providerOptions: settings.providerOptions,
|
||||
}).model(modelID)
|
||||
...ProviderPackage.routeDefaults(input.defaults),
|
||||
...(input.credential ? ProviderPackage.bearerAuthOption(input.credential) : { apiKey: input.settings.apiKey }),
|
||||
baseURL: input.settings.baseURL,
|
||||
provider: input.settings.provider,
|
||||
providerOptions: input.settings.providerOptions,
|
||||
}).model(input.id)
|
||||
|
||||
export const baseten = define(profiles.baseten)
|
||||
export const cerebras = define(profiles.cerebras)
|
||||
|
||||
@@ -1,37 +1,21 @@
|
||||
import { mergeProviderOptions, type ProviderOptions } from "../schema/index.js"
|
||||
import type { OpenResponsesOptionsInput } from "./open-responses-options.js"
|
||||
import type { Options } from "../protocols/utils/open-responses-options.js"
|
||||
|
||||
export type { OpenAIResponseIncludable, OpenAIServiceTier } from "../protocols/utils/openai-options.js"
|
||||
|
||||
export type OpenAIOptionsInput = OpenResponsesOptionsInput
|
||||
export type OpenAIConfigOptions = Options
|
||||
|
||||
export type OpenAIProviderOptionsInput = OpenAIOptionsInput
|
||||
|
||||
const definedEntries = (input: Record<string, unknown>) =>
|
||||
Object.entries(input).filter((entry) => entry[1] !== undefined)
|
||||
|
||||
const openAIProviderOptions = (options: OpenAIOptionsInput | undefined): ProviderOptions | undefined => {
|
||||
const result = Object.fromEntries(
|
||||
definedEntries({
|
||||
store: options?.store,
|
||||
reasoningEffort: options?.reasoningEffort,
|
||||
reasoningSummary: options?.reasoningSummary,
|
||||
include: options?.include,
|
||||
textVerbosity: options?.textVerbosity,
|
||||
serviceTier: options?.serviceTier,
|
||||
}),
|
||||
)
|
||||
if (Object.keys(result).length === 0) return undefined
|
||||
return result
|
||||
}
|
||||
|
||||
export const gpt5DefaultOptions = (
|
||||
modelID: string,
|
||||
options: { readonly textVerbosity?: boolean } = {},
|
||||
): ProviderOptions | undefined => {
|
||||
const id = modelID.toLowerCase()
|
||||
if (!id.includes("gpt-5") || id.includes("gpt-5-chat") || id.includes("gpt-5-pro")) return undefined
|
||||
return openAIProviderOptions({
|
||||
return {
|
||||
reasoningEffort: "medium",
|
||||
reasoningSummary: "auto",
|
||||
// GPT-5 reasoning models are configured stateless (`store: false`) by
|
||||
@@ -44,14 +28,13 @@ export const gpt5DefaultOptions = (
|
||||
options.textVerbosity === true && id.includes("gpt-5.") && !id.includes("codex") && !id.includes("-chat")
|
||||
? "low"
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const openAIDefaultOptions = (
|
||||
modelID: string,
|
||||
options: { readonly textVerbosity?: boolean } = {},
|
||||
): ProviderOptions | undefined =>
|
||||
mergeProviderOptions(openAIProviderOptions({ store: false }), gpt5DefaultOptions(modelID, options))
|
||||
): ProviderOptions | undefined => mergeProviderOptions({ store: false }, gpt5DefaultOptions(modelID, options))
|
||||
|
||||
export const withOpenAIOptions = <Options extends { readonly providerOptions?: OpenAIProviderOptionsInput }>(
|
||||
modelID: string,
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
|
||||
import type { Route, RouteDefaultsInput } from "../route/client.js"
|
||||
import type { ProviderPackage } from "../provider-package.js"
|
||||
import { ProviderPackage } from "../provider-package.js"
|
||||
import { HttpOptions, ProviderID, ToolDefinition, mergeHttpOptions, type ModelID } from "../schema/index.js"
|
||||
import * as OpenAIChat from "../protocols/openai-chat.js"
|
||||
import * as OpenAIResponses from "../protocols/openai-responses.js"
|
||||
import { withOpenAIOptions, type OpenAIProviderOptionsInput } from "./openai-options.js"
|
||||
import { withOpenAIOptions, type OpenAIConfigOptions, type OpenAIProviderOptionsInput } from "./openai-options.js"
|
||||
import { OpenAIImages, type OpenAIImageString } from "../protocols/openai-images.js"
|
||||
|
||||
export type { OpenAIOptionsInput, OpenAIResponseIncludable } from "./openai-options.js"
|
||||
@@ -17,11 +17,11 @@ export const routes = [OpenAIResponses.route, OpenAIChat.route]
|
||||
// This provider facade wraps the lower-level Responses and Chat model factories
|
||||
// with OpenAI-specific conveniences: typed options, API-key sugar, env fallback,
|
||||
// and default option normalization.
|
||||
export type Config = RouteDefaultsInput &
|
||||
export type Config = Omit<RouteDefaultsInput, "providerOptions"> &
|
||||
OpenAIConfigOptions &
|
||||
ProviderAuthOption<"optional"> & {
|
||||
readonly baseURL?: string
|
||||
readonly queryParams?: Record<string, string>
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
|
||||
export interface ImageGenerationOptions {
|
||||
@@ -57,13 +57,12 @@ export const imageGeneration = (options: ImageGenerationOptions = {}) =>
|
||||
},
|
||||
})
|
||||
|
||||
export interface Settings extends ProviderPackage.Settings {
|
||||
export interface Settings extends ProviderPackage.Settings, OpenAIConfigOptions {
|
||||
readonly apiKey?: string
|
||||
readonly baseURL?: string
|
||||
readonly organization?: string
|
||||
readonly project?: string
|
||||
readonly queryParams?: Readonly<Record<string, string>>
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
|
||||
const auth = (options: ProviderAuthOption<"optional">) => AuthOptions.bearer(options, "OPENAI_API_KEY")
|
||||
@@ -73,6 +72,39 @@ const defaults = (input: Config) => {
|
||||
return rest
|
||||
}
|
||||
|
||||
const splitConfigOptions = <Input extends OpenAIConfigOptions>(input: Input) => {
|
||||
const {
|
||||
instructions,
|
||||
store,
|
||||
reasoningEffort,
|
||||
reasoningSummary,
|
||||
include,
|
||||
textVerbosity,
|
||||
serviceTier,
|
||||
truncation,
|
||||
allowedTools,
|
||||
maxToolCalls,
|
||||
parallelToolCalls,
|
||||
...rest
|
||||
} = input
|
||||
return {
|
||||
options: {
|
||||
instructions,
|
||||
store,
|
||||
reasoningEffort,
|
||||
reasoningSummary,
|
||||
include,
|
||||
textVerbosity,
|
||||
serviceTier,
|
||||
truncation,
|
||||
allowedTools,
|
||||
maxToolCalls,
|
||||
parallelToolCalls,
|
||||
},
|
||||
rest,
|
||||
}
|
||||
}
|
||||
|
||||
const configuredRoute = <Body, Prepared>(route: Route<Body, Prepared>, input: Config) =>
|
||||
route.with({
|
||||
auth: auth(input),
|
||||
@@ -82,7 +114,8 @@ const configuredRoute = <Body, Prepared>(route: Route<Body, Prepared>, input: Co
|
||||
export const configure = (input: Config = {}) => {
|
||||
const responsesRoute = configuredRoute(OpenAIResponses.route, input)
|
||||
const chatRoute = configuredRoute(OpenAIChat.route, input)
|
||||
const modelDefaults = defaults(input)
|
||||
const split = splitConfigOptions(defaults(input))
|
||||
const modelDefaults = { ...split.rest, providerOptions: split.options }
|
||||
const responses = (id: string | ModelID) =>
|
||||
responsesRoute
|
||||
.with(withOpenAIOptions(id, modelDefaults, { textVerbosity: true }))
|
||||
@@ -113,31 +146,30 @@ export const configure = (input: Config = {}) => {
|
||||
|
||||
export const provider = configure()
|
||||
|
||||
const config = (settings: Settings): Config => {
|
||||
const config = (input: ProviderPackage.ModelInput<Settings>): Config => {
|
||||
const settings = input.settings
|
||||
const options = splitConfigOptions(settings).options
|
||||
const headers = {
|
||||
...(settings.organization === undefined ? {} : { "OpenAI-Organization": settings.organization }),
|
||||
...(settings.project === undefined ? {} : { "OpenAI-Project": settings.project }),
|
||||
...settings.headers,
|
||||
...input.defaults.headers,
|
||||
}
|
||||
return {
|
||||
apiKey: settings.apiKey,
|
||||
...ProviderPackage.routeDefaults(input.defaults),
|
||||
...(input.credential ? ProviderPackage.bearerAuthOption(input.credential) : { apiKey: settings.apiKey }),
|
||||
baseURL: settings.baseURL,
|
||||
headers: Object.keys(headers).length === 0 ? undefined : headers,
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
limits: settings.limits,
|
||||
providerOptions: settings.providerOptions,
|
||||
queryParams: settings.queryParams === undefined ? undefined : { ...settings.queryParams },
|
||||
...options,
|
||||
}
|
||||
}
|
||||
|
||||
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) => {
|
||||
return configure(config(settings)).responses(modelID)
|
||||
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (input) => {
|
||||
return configure(config(input)).responses(input.id)
|
||||
}
|
||||
|
||||
export const chatModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
|
||||
modelID,
|
||||
settings,
|
||||
) => configure(config(settings)).chat(modelID)
|
||||
export const chatModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (input) =>
|
||||
configure(config(input)).chat(input.id)
|
||||
export const responses = provider.responses
|
||||
export const chat = provider.chat
|
||||
export const image = provider.image
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Framing } from "../route/framing.js"
|
||||
import { Protocol } from "../route/protocol.js"
|
||||
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
|
||||
import { ProviderID, type CacheHint, type ModelID } from "../schema/index.js"
|
||||
import type { ProviderPackage } from "../provider-package.js"
|
||||
import { ProviderPackage } from "../provider-package.js"
|
||||
import * as OpenAICompatibleProfiles from "./openai-compatible-profile.js"
|
||||
import * as OpenAIChat from "../protocols/openai-chat.js"
|
||||
import { newBreakpoints, ttlBucket } from "../protocols/utils/cache.js"
|
||||
@@ -191,15 +191,10 @@ export const configure = (input: LanguageModelOptions = {}) => {
|
||||
}
|
||||
|
||||
export const provider = configure()
|
||||
export const model: ProviderPackage.Definition<Settings, OpenRouterProviderOptionsInput>["model"] = (
|
||||
modelID,
|
||||
settings,
|
||||
) =>
|
||||
export const model: ProviderPackage.Definition<Settings, OpenRouterProviderOptionsInput>["model"] = (input) =>
|
||||
configure({
|
||||
apiKey: settings.apiKey,
|
||||
baseURL: settings.baseURL,
|
||||
headers: settings.headers,
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
limits: settings.limits,
|
||||
providerOptions: settings.providerOptions,
|
||||
}).model(modelID)
|
||||
...ProviderPackage.routeDefaults(input.defaults),
|
||||
...(input.credential ? ProviderPackage.bearerAuthOption(input.credential) : { apiKey: input.settings.apiKey }),
|
||||
baseURL: input.settings.baseURL,
|
||||
providerOptions: input.settings.providerOptions,
|
||||
}).model(input.id)
|
||||
|
||||
@@ -8,7 +8,7 @@ import * as OpenAIChat from "../protocols/openai-chat.js"
|
||||
import * as OpenAIResponses from "../protocols/openai-responses.js"
|
||||
import { XAIImages } from "../protocols/xai-images.js"
|
||||
import type { OpenAIOptionsInput } from "./openai-options.js"
|
||||
import type { ProviderPackage } from "../provider-package.js"
|
||||
import { ProviderPackage } from "../provider-package.js"
|
||||
|
||||
export const id = ProviderID.make("xai")
|
||||
|
||||
@@ -95,15 +95,13 @@ export const configure = (input: LanguageModelOptions = {}) => {
|
||||
}
|
||||
|
||||
export const provider = configure()
|
||||
export const model: ProviderPackage.Definition<Settings, XAIProviderOptionsInput>["model"] = (modelID, settings) =>
|
||||
export const model: ProviderPackage.Definition<Settings, XAIProviderOptionsInput>["model"] = (input) =>
|
||||
configure({
|
||||
apiKey: settings.apiKey,
|
||||
baseURL: settings.baseURL,
|
||||
headers: settings.headers,
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
limits: settings.limits,
|
||||
providerOptions: settings.providerOptions,
|
||||
}).model(modelID)
|
||||
...ProviderPackage.routeDefaults(input.defaults),
|
||||
...(input.credential ? ProviderPackage.bearerAuthOption(input.credential) : { apiKey: input.settings.apiKey }),
|
||||
baseURL: input.settings.baseURL,
|
||||
providerOptions: input.settings.providerOptions,
|
||||
}).model(input.id)
|
||||
export const responses = provider.responses
|
||||
export const chat = provider.chat
|
||||
export const image = provider.image
|
||||
|
||||
@@ -81,8 +81,22 @@ OpenAI.configure({
|
||||
}).responses("gpt-4.1-mini")
|
||||
OpenAI.configure({
|
||||
generation: { maxTokens: 100 },
|
||||
providerOptions: { store: false },
|
||||
store: false,
|
||||
}).responses("gpt-4.1-mini")
|
||||
OpenAI.model({
|
||||
id: "gpt-5",
|
||||
settings: {},
|
||||
credential: { type: "key", value: "sk-test" },
|
||||
defaults: { headers: { "x-test": "value" } },
|
||||
})
|
||||
OpenAI.model({
|
||||
id: "gpt-5",
|
||||
settings: {
|
||||
// @ts-expect-error Common request defaults belong under input.defaults.
|
||||
headers: { "x-test": "value" },
|
||||
},
|
||||
defaults: {},
|
||||
})
|
||||
|
||||
// @ts-expect-error OpenAI model selectors only accept model ids.
|
||||
OpenAI.configure({ apiKey: "sk-test" }).responses("gpt-4.1-mini", {})
|
||||
@@ -97,7 +111,7 @@ OpenAI.configure({ bogus: true })
|
||||
OpenAI.configure({ generation: { maxTokens: "many" } })
|
||||
|
||||
// @ts-expect-error provider-native options remain typed.
|
||||
OpenAI.configure({ providerOptions: { store: "false" } })
|
||||
OpenAI.configure({ store: "false" })
|
||||
|
||||
// @ts-expect-error auth is an override, so OpenAI rejects apiKey with auth.
|
||||
OpenAI.configure({ apiKey: "sk-test", auth: Auth.bearer("oauth-token") })
|
||||
@@ -145,8 +159,12 @@ Anthropic.configure({
|
||||
}).model("claude-haiku")
|
||||
// @ts-expect-error Anthropic model selectors only accept model ids.
|
||||
Anthropic.configure({ apiKey: "anthropic-key" }).model("claude-haiku", {})
|
||||
// @ts-expect-error Anthropic package settings accept only one auth source.
|
||||
Anthropic.model("claude-sonnet-4-6", { apiKey: "anthropic-key", authToken: "anthropic-token" })
|
||||
Anthropic.model({
|
||||
id: "claude-sonnet-4-6",
|
||||
// @ts-expect-error Anthropic package settings accept only one auth source.
|
||||
settings: { apiKey: "anthropic-key", authToken: "anthropic-token" },
|
||||
defaults: {},
|
||||
})
|
||||
// @ts-expect-error Enabled Anthropic thinking requires a token budget.
|
||||
Anthropic.configure({ providerOptions: { thinking: { type: "enabled" } } })
|
||||
// @ts-expect-error Anthropic thinking budgets must be numbers.
|
||||
@@ -162,11 +180,15 @@ AnthropicCompatible.configure({
|
||||
AnthropicCompatible.configure({ apiKey: "messages-key" })
|
||||
// @ts-expect-error Anthropic-compatible model selectors only accept model ids.
|
||||
AnthropicCompatible.configure({ baseURL: "https://messages.example.com/v1" }).model("compatible-model", {})
|
||||
// @ts-expect-error Anthropic-compatible package settings accept only one auth source.
|
||||
AnthropicCompatible.model("compatible-model", {
|
||||
apiKey: "messages-key",
|
||||
authToken: "messages-token",
|
||||
baseURL: "https://messages.example.com/v1",
|
||||
AnthropicCompatible.model({
|
||||
id: "compatible-model",
|
||||
// @ts-expect-error Anthropic-compatible package settings accept only one auth source.
|
||||
settings: {
|
||||
apiKey: "messages-key",
|
||||
authToken: "messages-token",
|
||||
baseURL: "https://messages.example.com/v1",
|
||||
},
|
||||
defaults: {},
|
||||
})
|
||||
|
||||
Google.configure({ apiKey: "google-key" }).model("gemini-2.5-flash")
|
||||
@@ -189,15 +211,23 @@ GoogleVertex.configure({ auth: Auth.bearer("vertex-token"), project: "project" }
|
||||
GoogleVertex.configure({ apiKey: "vertex-key" }).model("gemini-3.5-flash", {})
|
||||
// @ts-expect-error Vertex Gemini config accepts only one auth source.
|
||||
GoogleVertex.configure({ accessToken: "vertex-token", apiKey: "vertex-key", project: "project" })
|
||||
// @ts-expect-error Vertex Gemini package settings accept only one auth source.
|
||||
GoogleVertex.model("gemini-3.5-flash", { accessToken: "vertex-token", apiKey: "vertex-key", project: "project" })
|
||||
GoogleVertex.model({
|
||||
id: "gemini-3.5-flash",
|
||||
// @ts-expect-error Vertex Gemini package settings accept only one auth source.
|
||||
settings: { accessToken: "vertex-token", apiKey: "vertex-key", project: "project" },
|
||||
defaults: {},
|
||||
})
|
||||
|
||||
GoogleVertexChat.configure({ accessToken: "vertex-token", project: "project" }).model("deepseek-ai/deepseek-v3.2-maas")
|
||||
GoogleVertexChat.configure({ auth: Auth.bearer("vertex-token"), project: "project" }).model(
|
||||
"deepseek-ai/deepseek-v3.2-maas",
|
||||
)
|
||||
// @ts-expect-error Vertex Chat package settings do not accept API keys.
|
||||
GoogleVertexChat.model("deepseek-ai/deepseek-v3.2-maas", { apiKey: "vertex-key", project: "project" })
|
||||
GoogleVertexChat.model({
|
||||
id: "deepseek-ai/deepseek-v3.2-maas",
|
||||
// @ts-expect-error Vertex Chat package settings do not accept API keys.
|
||||
settings: { apiKey: "vertex-key", project: "project" },
|
||||
defaults: {},
|
||||
})
|
||||
GoogleVertexChat.configure({ accessToken: "vertex-token", project: "project" }).model(
|
||||
"deepseek-ai/deepseek-v3.2-maas",
|
||||
// @ts-expect-error Vertex Chat model selectors only accept model ids.
|
||||
@@ -214,8 +244,12 @@ GoogleVertexResponses.configure({ accessToken: "vertex-token", project: "project
|
||||
GoogleVertexResponses.configure({ auth: Auth.bearer("vertex-token"), project: "project" }).model(
|
||||
"xai/grok-4.20-reasoning",
|
||||
)
|
||||
// @ts-expect-error Vertex Responses package settings do not accept API keys.
|
||||
GoogleVertexResponses.model("xai/grok-4.20-reasoning", { apiKey: "vertex-key", project: "project" })
|
||||
GoogleVertexResponses.model({
|
||||
id: "xai/grok-4.20-reasoning",
|
||||
// @ts-expect-error Vertex Responses package settings do not accept API keys.
|
||||
settings: { apiKey: "vertex-key", project: "project" },
|
||||
defaults: {},
|
||||
})
|
||||
GoogleVertexResponses.configure({ accessToken: "vertex-token", project: "project" }).model(
|
||||
"xai/grok-4.20-reasoning",
|
||||
// @ts-expect-error Vertex Responses model selectors only accept model ids.
|
||||
@@ -233,8 +267,12 @@ GoogleVertexMessages.configure({
|
||||
project: "project",
|
||||
providerOptions: { thinking: { type: "adaptive", display: "omitted" }, effort: "low" },
|
||||
}).model("claude-sonnet-4-6")
|
||||
// @ts-expect-error Vertex Messages package settings do not accept API keys.
|
||||
GoogleVertexMessages.model("claude-sonnet-4-6", { apiKey: "vertex-key", project: "project" })
|
||||
GoogleVertexMessages.model({
|
||||
id: "claude-sonnet-4-6",
|
||||
// @ts-expect-error Vertex Messages package settings do not accept API keys.
|
||||
settings: { apiKey: "vertex-key", project: "project" },
|
||||
defaults: {},
|
||||
})
|
||||
GoogleVertexMessages.configure({ auth: Auth.bearer("vertex-token"), project: "project" }).model("claude-sonnet-4-6")
|
||||
GoogleVertexMessages.configure({ accessToken: "vertex-token", project: "project" }).model(
|
||||
"claude-sonnet-4-6",
|
||||
|
||||
@@ -1,6 +1,72 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { ConfigProvider, Effect } from "effect"
|
||||
import { Headers } from "effect/unstable/http"
|
||||
import { LLM, ProviderPackage } from "@opencode-ai/ai"
|
||||
import { model } from "@opencode-ai/ai/providers/openai"
|
||||
|
||||
const packageInput = <Input extends Record<string, unknown>>(id: string, input: Input) => {
|
||||
const { headers, body, limits, ...settings } = input
|
||||
return { id, settings, defaults: { headers, body, limits } }
|
||||
}
|
||||
|
||||
const authHeaders = (
|
||||
selected: ReturnType<typeof model>,
|
||||
headers: Record<string, string> = {},
|
||||
env: Record<string, string> = {},
|
||||
) =>
|
||||
Effect.runPromise(
|
||||
selected.route.auth
|
||||
.apply({
|
||||
request: LLM.request({ model: selected, prompt: "hello" }),
|
||||
method: "POST",
|
||||
url: "https://example.test/v1",
|
||||
body: "{}",
|
||||
headers: Headers.fromInput(headers),
|
||||
})
|
||||
.pipe(Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env })))),
|
||||
)
|
||||
|
||||
const applyAuth = (
|
||||
option: ReturnType<typeof ProviderPackage.bearerAuthOption>,
|
||||
headers: Record<string, string> = {},
|
||||
) => {
|
||||
const selected = model(packageInput("gpt-5", { apiKey: "fixture" }))
|
||||
return Effect.runPromise(
|
||||
option.auth.apply({
|
||||
request: LLM.request({ model: selected, prompt: "hello" }),
|
||||
method: "POST",
|
||||
url: "https://example.test/v1",
|
||||
body: "{}",
|
||||
headers: Headers.fromInput(headers),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
describe("provider package credential lowering", () => {
|
||||
test("intentionally renders keys and OAuth credentials as bearer auth", async () => {
|
||||
const key = await applyAuth(ProviderPackage.bearerAuthOption({ type: "key", value: "provider-key" }))
|
||||
const oauth = await applyAuth(ProviderPackage.bearerAuthOption({ type: "oauth", accessToken: "provider-token" }))
|
||||
|
||||
expect(key.authorization).toBe("Bearer provider-key")
|
||||
expect(oauth.authorization).toBe("Bearer provider-token")
|
||||
})
|
||||
|
||||
test("keeps key-header credentials configurable and removes stale keys for OAuth", async () => {
|
||||
expect(ProviderPackage.apiKeyOrBearerAuthOption({ type: "key", value: "provider-key" }, "x-api-key")).toEqual({
|
||||
apiKey: "provider-key",
|
||||
})
|
||||
const oauth = ProviderPackage.apiKeyOrBearerAuthOption(
|
||||
{ type: "oauth", accessToken: "provider-token" },
|
||||
"x-api-key",
|
||||
)
|
||||
if (!("auth" in oauth)) throw new Error("Expected OAuth credential to lower to auth")
|
||||
const headers = await applyAuth(oauth, { "x-api-key": "stale" })
|
||||
|
||||
expect(headers.authorization).toBe("Bearer provider-token")
|
||||
expect(headers["x-api-key"]).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("provider package entrypoints", () => {
|
||||
test("semantic API aliases expose the same contract", async () => {
|
||||
const modules = await Promise.all([
|
||||
@@ -45,14 +111,18 @@ describe("provider package entrypoints", () => {
|
||||
body: { service_tier: "priority" },
|
||||
limits: { context: 200_000, output: 64_000 },
|
||||
}
|
||||
const openrouter = OpenRouter.model("anthropic/claude-sonnet-4", {
|
||||
...settings,
|
||||
providerOptions: { usage: true },
|
||||
})
|
||||
const xai = XAI.model("grok-4", {
|
||||
...settings,
|
||||
providerOptions: { reasoningEffort: "high" },
|
||||
})
|
||||
const openrouter = OpenRouter.model(
|
||||
packageInput("anthropic/claude-sonnet-4", {
|
||||
...settings,
|
||||
providerOptions: { usage: true },
|
||||
}),
|
||||
)
|
||||
const xai = XAI.model(
|
||||
packageInput("grok-4", {
|
||||
...settings,
|
||||
providerOptions: { reasoningEffort: "high" },
|
||||
}),
|
||||
)
|
||||
|
||||
for (const selected of [openrouter, xai]) {
|
||||
expect(selected.route.endpoint.baseURL).toBe(settings.baseURL)
|
||||
@@ -65,32 +135,151 @@ describe("provider package entrypoints", () => {
|
||||
})
|
||||
|
||||
test("maps package settings onto the executable model", () => {
|
||||
const selected = model("gpt-5", {
|
||||
apiKey: "fixture",
|
||||
baseURL: "https://api.openai.test/v1",
|
||||
headers: { "x-application": "opencode" },
|
||||
body: { service_tier: "priority" },
|
||||
limits: { context: 200_000, output: 64_000 },
|
||||
unrelatedInheritedSetting: true,
|
||||
})
|
||||
const selected = model(
|
||||
packageInput("gpt-5", {
|
||||
apiKey: "fixture",
|
||||
baseURL: "https://api.openai.test/v1",
|
||||
headers: { "x-application": "opencode" },
|
||||
body: { service_tier: "priority" },
|
||||
limits: { context: 200_000, output: 64_000 },
|
||||
reasoningEffort: "high",
|
||||
unrelatedInheritedSetting: true,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(selected.route.id).toBe("openai-responses")
|
||||
expect(selected.route.defaults.headers).toEqual({ "x-application": "opencode" })
|
||||
expect(selected.route.defaults.http?.body).toEqual({ service_tier: "priority" })
|
||||
expect(selected.route.defaults.limits).toEqual({ context: 200_000, output: 64_000 })
|
||||
expect(selected.route.defaults.providerOptions).toEqual({
|
||||
store: false,
|
||||
reasoningEffort: "high",
|
||||
reasoningSummary: "auto",
|
||||
include: ["reasoning.encrypted_content"],
|
||||
})
|
||||
})
|
||||
|
||||
test("lets provider packages interpret resolved credentials", async () => {
|
||||
const Anthropic = await import("@opencode-ai/ai/providers/anthropic")
|
||||
const Azure = await import("@opencode-ai/ai/providers/azure")
|
||||
const Google = await import("@opencode-ai/ai/providers/google")
|
||||
const GoogleVertex = await import("@opencode-ai/ai/providers/google-vertex")
|
||||
const GoogleVertexChat = await import("@opencode-ai/ai/providers/google-vertex/chat")
|
||||
const openai = model({
|
||||
id: "gpt-5",
|
||||
settings: {},
|
||||
credential: { type: "oauth", accessToken: "openai-token" },
|
||||
defaults: {},
|
||||
})
|
||||
const anthropicKey = Anthropic.model({
|
||||
id: "claude-sonnet-4-6",
|
||||
settings: {},
|
||||
credential: { type: "key", value: "anthropic-key" },
|
||||
defaults: {},
|
||||
})
|
||||
const anthropicOAuth = Anthropic.model({
|
||||
id: "claude-sonnet-4-6",
|
||||
settings: {},
|
||||
credential: { type: "oauth", accessToken: "anthropic-token" },
|
||||
defaults: {},
|
||||
})
|
||||
const anthropicEmptyKey = Anthropic.model({
|
||||
id: "claude-sonnet-4-6",
|
||||
settings: {},
|
||||
credential: { type: "key", value: "" },
|
||||
defaults: {},
|
||||
})
|
||||
const azureKey = Azure.model({
|
||||
id: "deployment",
|
||||
settings: { resourceName: "opencode-test" },
|
||||
credential: { type: "key", value: "azure-key" },
|
||||
defaults: {},
|
||||
})
|
||||
const azureOAuth = Azure.model({
|
||||
id: "deployment",
|
||||
settings: { resourceName: "opencode-test" },
|
||||
credential: { type: "oauth", accessToken: "azure-token" },
|
||||
defaults: {},
|
||||
})
|
||||
const googleKey = Google.model({
|
||||
id: "gemini-2.5-flash",
|
||||
settings: {},
|
||||
credential: { type: "key", value: "google-key" },
|
||||
defaults: {},
|
||||
})
|
||||
const googleOAuth = Google.model({
|
||||
id: "gemini-2.5-flash",
|
||||
settings: {},
|
||||
credential: { type: "oauth", accessToken: "google-token" },
|
||||
defaults: {},
|
||||
})
|
||||
const vertexKey = GoogleVertex.model({
|
||||
id: "gemini-3.5-flash",
|
||||
settings: {},
|
||||
credential: { type: "key", value: "vertex-key" },
|
||||
defaults: {},
|
||||
})
|
||||
const vertexOAuth = GoogleVertex.model({
|
||||
id: "gemini-3.5-flash",
|
||||
settings: { project: "vertex-project" },
|
||||
credential: { type: "oauth", accessToken: "vertex-token" },
|
||||
defaults: {},
|
||||
})
|
||||
const vertexChatOAuth = GoogleVertexChat.model({
|
||||
id: "deepseek-ai/deepseek-v3.2-maas",
|
||||
settings: { apiKey: "configured-key", project: "vertex-project" },
|
||||
credential: { type: "oauth", accessToken: "vertex-chat-token" },
|
||||
defaults: {},
|
||||
})
|
||||
|
||||
expect((await authHeaders(openai)).authorization).toBe("Bearer openai-token")
|
||||
const anthropicKeyHeaders = await authHeaders(anthropicKey, { authorization: "Bearer stale" })
|
||||
const anthropicOAuthHeaders = await authHeaders(anthropicOAuth, { "x-api-key": "stale" })
|
||||
const anthropicEmptyKeyHeaders = await authHeaders(
|
||||
anthropicEmptyKey,
|
||||
{ authorization: "Bearer stale" },
|
||||
{ ANTHROPIC_API_KEY: "environment-key" },
|
||||
)
|
||||
const azureKeyHeaders = await authHeaders(azureKey, { authorization: "Bearer stale" })
|
||||
const azureOAuthHeaders = await authHeaders(azureOAuth, { "api-key": "stale" })
|
||||
const googleKeyHeaders = await authHeaders(googleKey, { authorization: "Bearer stale" })
|
||||
const googleOAuthHeaders = await authHeaders(googleOAuth, { "x-goog-api-key": "stale" })
|
||||
const vertexKeyHeaders = await authHeaders(vertexKey, { authorization: "Bearer stale" })
|
||||
const vertexOAuthHeaders = await authHeaders(vertexOAuth, { "x-goog-api-key": "stale" })
|
||||
expect(anthropicKeyHeaders["x-api-key"]).toBe("anthropic-key")
|
||||
expect(anthropicKeyHeaders.authorization).toBeUndefined()
|
||||
expect(anthropicOAuthHeaders.authorization).toBe("Bearer anthropic-token")
|
||||
expect(anthropicOAuthHeaders["x-api-key"]).toBeUndefined()
|
||||
expect(anthropicEmptyKeyHeaders["x-api-key"]).toBe("environment-key")
|
||||
expect(anthropicEmptyKeyHeaders.authorization).toBeUndefined()
|
||||
expect(azureKeyHeaders["api-key"]).toBe("azure-key")
|
||||
expect(azureKeyHeaders.authorization).toBeUndefined()
|
||||
expect(azureOAuthHeaders.authorization).toBe("Bearer azure-token")
|
||||
expect(azureOAuthHeaders["api-key"]).toBeUndefined()
|
||||
expect(googleKeyHeaders["x-goog-api-key"]).toBe("google-key")
|
||||
expect(googleKeyHeaders.authorization).toBeUndefined()
|
||||
expect(googleOAuthHeaders.authorization).toBe("Bearer google-token")
|
||||
expect(googleOAuthHeaders["x-goog-api-key"]).toBeUndefined()
|
||||
expect(vertexKeyHeaders["x-goog-api-key"]).toBe("vertex-key")
|
||||
expect(vertexKeyHeaders.authorization).toBeUndefined()
|
||||
expect(vertexOAuthHeaders.authorization).toBe("Bearer vertex-token")
|
||||
expect(vertexOAuthHeaders["x-goog-api-key"]).toBeUndefined()
|
||||
expect((await authHeaders(vertexChatOAuth)).authorization).toBe("Bearer vertex-chat-token")
|
||||
})
|
||||
|
||||
test("maps OpenAI-compatible Responses settings onto the executable model", async () => {
|
||||
const OpenAICompatibleResponses = await import("@opencode-ai/ai/providers/openai-compatible/responses")
|
||||
const selected = OpenAICompatibleResponses.model("custom-model", {
|
||||
apiKey: "fixture",
|
||||
baseURL: "https://responses.example.test/v1",
|
||||
provider: "example",
|
||||
headers: { "x-application": "opencode" },
|
||||
body: { service_tier: "priority" },
|
||||
limits: { context: 200_000, output: 64_000 },
|
||||
providerOptions: { reasoningEffort: "low", store: true },
|
||||
})
|
||||
const selected = OpenAICompatibleResponses.model(
|
||||
packageInput("custom-model", {
|
||||
apiKey: "fixture",
|
||||
baseURL: "https://responses.example.test/v1",
|
||||
provider: "example",
|
||||
headers: { "x-application": "opencode" },
|
||||
body: { service_tier: "priority" },
|
||||
limits: { context: 200_000, output: 64_000 },
|
||||
providerOptions: { reasoningEffort: "low", store: true },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(String(selected.provider)).toBe("example")
|
||||
expect(selected.route.id).toBe("openai-compatible-responses")
|
||||
@@ -106,15 +295,17 @@ describe("provider package entrypoints", () => {
|
||||
|
||||
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", {
|
||||
apiKey: "fixture",
|
||||
baseURL: "https://messages.example.test/v1",
|
||||
provider: "example",
|
||||
headers: { "x-application": "opencode" },
|
||||
body: { metadata: { user_id: "user_1" } },
|
||||
limits: { context: 200_000, output: 64_000 },
|
||||
providerOptions: { effort: "low" },
|
||||
})
|
||||
const selected = AnthropicCompatible.model(
|
||||
packageInput("compatible-model", {
|
||||
apiKey: "fixture",
|
||||
baseURL: "https://messages.example.test/v1",
|
||||
provider: "example",
|
||||
headers: { "x-application": "opencode" },
|
||||
body: { metadata: { user_id: "user_1" } },
|
||||
limits: { context: 200_000, output: 64_000 },
|
||||
providerOptions: { effort: "low" },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(String(selected.provider)).toBe("example")
|
||||
expect(selected.route.id).toBe("anthropic-messages")
|
||||
@@ -130,10 +321,12 @@ describe("provider package entrypoints", () => {
|
||||
|
||||
test("maps Anthropic provider options onto the executable model", async () => {
|
||||
const Anthropic = await import("@opencode-ai/ai/providers/anthropic")
|
||||
const selected = Anthropic.model("claude-sonnet-4-6", {
|
||||
apiKey: "fixture",
|
||||
providerOptions: { thinking: { type: "adaptive" } },
|
||||
})
|
||||
const selected = Anthropic.model(
|
||||
packageInput("claude-sonnet-4-6", {
|
||||
apiKey: "fixture",
|
||||
providerOptions: { thinking: { type: "adaptive" } },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(selected.route.defaults.providerOptions).toEqual({ thinking: { type: "adaptive" } })
|
||||
})
|
||||
@@ -141,7 +334,7 @@ describe("provider package entrypoints", () => {
|
||||
test("requires an Anthropic-compatible base URL at runtime", async () => {
|
||||
const AnthropicCompatible = await import("@opencode-ai/ai/providers/anthropic-compatible")
|
||||
expect(() =>
|
||||
Reflect.apply(AnthropicCompatible.model, undefined, ["compatible-model", { apiKey: "fixture" }]),
|
||||
Reflect.apply(AnthropicCompatible.model, undefined, [packageInput("compatible-model", { apiKey: "fixture" })]),
|
||||
).toThrow("Anthropic-compatible providers require a baseURL")
|
||||
})
|
||||
|
||||
@@ -150,25 +343,28 @@ describe("provider package entrypoints", () => {
|
||||
const AnthropicCompatible = await import("@opencode-ai/ai/providers/anthropic-compatible")
|
||||
expect(() =>
|
||||
Reflect.apply(AnthropicCompatible.model, undefined, [
|
||||
"compatible-model",
|
||||
{
|
||||
packageInput("compatible-model", {
|
||||
apiKey: "fixture",
|
||||
authToken: "token",
|
||||
baseURL: "https://messages.example.test/v1",
|
||||
},
|
||||
}),
|
||||
]),
|
||||
).toThrow("Anthropic-compatible apiKey cannot be combined with authToken")
|
||||
expect(() =>
|
||||
Reflect.apply(Anthropic.model, undefined, ["claude-sonnet-4-6", { apiKey: "fixture", authToken: "token" }]),
|
||||
Reflect.apply(Anthropic.model, undefined, [
|
||||
packageInput("claude-sonnet-4-6", { apiKey: "fixture", authToken: "token" }),
|
||||
]),
|
||||
).toThrow("Anthropic apiKey cannot be combined with authToken")
|
||||
})
|
||||
|
||||
test("maps legacy OpenAI organization and project settings to headers", () => {
|
||||
const selected = model("gpt-5", {
|
||||
apiKey: "fixture",
|
||||
organization: "org_123",
|
||||
project: "proj_123",
|
||||
})
|
||||
const selected = model(
|
||||
packageInput("gpt-5", {
|
||||
apiKey: "fixture",
|
||||
organization: "org_123",
|
||||
project: "proj_123",
|
||||
}),
|
||||
)
|
||||
|
||||
expect(selected.route.defaults.headers).toMatchObject({
|
||||
"OpenAI-Organization": "org_123",
|
||||
@@ -188,10 +384,10 @@ describe("provider package entrypoints", () => {
|
||||
limits: { context: 200_000, output: 64_000 },
|
||||
}
|
||||
|
||||
const responses = AzureResponses.model("deployment", settings)
|
||||
const chat = AzureChat.model("deployment", settings)
|
||||
const responses = AzureResponses.model(packageInput("deployment", settings))
|
||||
const chat = AzureChat.model(packageInput("deployment", settings))
|
||||
|
||||
expect(Azure.model("deployment", settings).route.id).toBe("azure-openai-responses")
|
||||
expect(Azure.model(packageInput("deployment", settings)).route.id).toBe("azure-openai-responses")
|
||||
expect(responses.route.id).toBe("azure-openai-responses")
|
||||
expect(responses.route.endpoint.baseURL).toBe("https://opencode-test.openai.azure.com/openai/v1")
|
||||
expect(responses.route.defaults.headers).toEqual({ "x-application": "opencode" })
|
||||
@@ -202,16 +398,20 @@ describe("provider package entrypoints", () => {
|
||||
|
||||
test("constructs Azure deployment URLs and preserves custom gateway URLs", async () => {
|
||||
const Azure = await import("@opencode-ai/ai/providers/azure")
|
||||
const deployment = Azure.model("custom-deployment", {
|
||||
apiKey: "fixture",
|
||||
resourceName: "opencode-test",
|
||||
apiVersion: "2025-01-01-preview",
|
||||
useDeploymentBasedUrls: true,
|
||||
})
|
||||
const gateway = Azure.model("gateway-model", {
|
||||
apiKey: "fixture",
|
||||
baseURL: "https://gateway.example/azure/",
|
||||
})
|
||||
const deployment = Azure.model(
|
||||
packageInput("custom-deployment", {
|
||||
apiKey: "fixture",
|
||||
resourceName: "opencode-test",
|
||||
apiVersion: "2025-01-01-preview",
|
||||
useDeploymentBasedUrls: true,
|
||||
}),
|
||||
)
|
||||
const gateway = Azure.model(
|
||||
packageInput("gateway-model", {
|
||||
apiKey: "fixture",
|
||||
baseURL: "https://gateway.example/azure/",
|
||||
}),
|
||||
)
|
||||
|
||||
expect(deployment.route.endpoint).toMatchObject({
|
||||
baseURL: "https://opencode-test.openai.azure.com/openai/deployments/custom-deployment",
|
||||
@@ -223,14 +423,16 @@ describe("provider package entrypoints", () => {
|
||||
|
||||
test("maps Google package settings onto the Gemini model", async () => {
|
||||
const Google = await import("@opencode-ai/ai/providers/google")
|
||||
const selected = Google.model("gemini-2.5-flash", {
|
||||
apiKey: "fixture",
|
||||
baseURL: "https://generativelanguage.test/v1beta",
|
||||
headers: { "x-application": "opencode" },
|
||||
body: { safetySettings: [] },
|
||||
limits: { context: 1_000_000, output: 65_536 },
|
||||
providerOptions: { thinkingConfig: { thinkingBudget: 1_024 } },
|
||||
})
|
||||
const selected = Google.model(
|
||||
packageInput("gemini-2.5-flash", {
|
||||
apiKey: "fixture",
|
||||
baseURL: "https://generativelanguage.test/v1beta",
|
||||
headers: { "x-application": "opencode" },
|
||||
body: { safetySettings: [] },
|
||||
limits: { context: 1_000_000, output: 65_536 },
|
||||
providerOptions: { thinkingConfig: { thinkingBudget: 1_024 } },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(selected.route.id).toBe("gemini")
|
||||
expect(selected.route.endpoint.baseURL).toBe("https://generativelanguage.test/v1beta")
|
||||
@@ -246,27 +448,35 @@ describe("provider package entrypoints", () => {
|
||||
const GoogleVertexChat = await import("@opencode-ai/ai/providers/google-vertex/chat")
|
||||
const GoogleVertexResponses = await import("@opencode-ai/ai/providers/google-vertex/responses")
|
||||
const GoogleVertexMessages = await import("@opencode-ai/ai/providers/google-vertex/messages")
|
||||
const gemini = GoogleVertex.model("gemini-3.5-flash", {
|
||||
apiKey: "fixture",
|
||||
headers: { "x-application": "opencode" },
|
||||
body: { safetySettings: [] },
|
||||
limits: { context: 1_000_000, output: 65_536 },
|
||||
})
|
||||
const messages = GoogleVertexMessages.model("claude-sonnet-4-6", {
|
||||
accessToken: "fixture",
|
||||
location: "global",
|
||||
project: "vertex-project",
|
||||
})
|
||||
const chat = GoogleVertexChat.model("deepseek-ai/deepseek-v3.2-maas", {
|
||||
accessToken: "fixture",
|
||||
location: "global",
|
||||
project: "vertex-project",
|
||||
})
|
||||
const responses = GoogleVertexResponses.model("xai/grok-4.20-reasoning", {
|
||||
accessToken: "fixture",
|
||||
location: "global",
|
||||
project: "vertex-project",
|
||||
})
|
||||
const gemini = GoogleVertex.model(
|
||||
packageInput("gemini-3.5-flash", {
|
||||
apiKey: "fixture",
|
||||
headers: { "x-application": "opencode" },
|
||||
body: { safetySettings: [] },
|
||||
limits: { context: 1_000_000, output: 65_536 },
|
||||
}),
|
||||
)
|
||||
const messages = GoogleVertexMessages.model(
|
||||
packageInput("claude-sonnet-4-6", {
|
||||
accessToken: "fixture",
|
||||
location: "global",
|
||||
project: "vertex-project",
|
||||
}),
|
||||
)
|
||||
const chat = GoogleVertexChat.model(
|
||||
packageInput("deepseek-ai/deepseek-v3.2-maas", {
|
||||
accessToken: "fixture",
|
||||
location: "global",
|
||||
project: "vertex-project",
|
||||
}),
|
||||
)
|
||||
const responses = GoogleVertexResponses.model(
|
||||
packageInput("xai/grok-4.20-reasoning", {
|
||||
accessToken: "fixture",
|
||||
location: "global",
|
||||
project: "vertex-project",
|
||||
}),
|
||||
)
|
||||
|
||||
expect(GoogleVertexGemini.model).toBe(GoogleVertex.model)
|
||||
expect(gemini.route.id).toBe("google-vertex-gemini")
|
||||
@@ -276,11 +486,13 @@ describe("provider package entrypoints", () => {
|
||||
expect(gemini.route.defaults.http?.body).toEqual({ safetySettings: [] })
|
||||
expect(gemini.route.defaults.limits).toEqual({ context: 1_000_000, output: 65_536 })
|
||||
expect(
|
||||
GoogleVertex.model("gemini-3.5-flash", {
|
||||
accessToken: "fixture",
|
||||
location: "eu",
|
||||
project: "vertex-project",
|
||||
}).route.endpoint.baseURL,
|
||||
GoogleVertex.model(
|
||||
packageInput("gemini-3.5-flash", {
|
||||
accessToken: "fixture",
|
||||
location: "eu",
|
||||
project: "vertex-project",
|
||||
}),
|
||||
).route.endpoint.baseURL,
|
||||
).toBe("https://aiplatform.eu.rep.googleapis.com/v1beta1/projects/vertex-project/locations/eu/publishers/google")
|
||||
expect(messages.route.id).toBe("google-vertex-messages")
|
||||
expect(messages.route.protocol).toBe("anthropic-messages")
|
||||
@@ -310,8 +522,11 @@ describe("provider package entrypoints", () => {
|
||||
const Providers = await import("@opencode-ai/ai/providers")
|
||||
expect(() =>
|
||||
Reflect.apply(GoogleVertex.model, undefined, [
|
||||
"gemini-3.5-flash",
|
||||
{ accessToken: "token", apiKey: "fixture", project: "vertex-project" },
|
||||
packageInput("gemini-3.5-flash", {
|
||||
accessToken: "token",
|
||||
apiKey: "fixture",
|
||||
project: "vertex-project",
|
||||
}),
|
||||
]),
|
||||
).toThrow("Google Vertex apiKey cannot be combined with accessToken or auth")
|
||||
const configured = Reflect.apply(GoogleVertex.configure, undefined, [
|
||||
@@ -320,8 +535,7 @@ describe("provider package entrypoints", () => {
|
||||
expect(() => configured.model("gemini-3.5-flash")).toThrow("Google Vertex accessToken cannot be combined with auth")
|
||||
expect(() =>
|
||||
Reflect.apply(GoogleVertexMessages.model, undefined, [
|
||||
"claude-sonnet-4-6",
|
||||
{ apiKey: "fixture", project: "vertex-project" },
|
||||
packageInput("claude-sonnet-4-6", { apiKey: "fixture", project: "vertex-project" }),
|
||||
]),
|
||||
).toThrow("Google Vertex Messages does not support API keys")
|
||||
expect(() =>
|
||||
@@ -331,8 +545,7 @@ describe("provider package entrypoints", () => {
|
||||
).toThrow("Google Vertex Messages does not support API keys")
|
||||
expect(() =>
|
||||
Reflect.apply(GoogleVertexChat.model, undefined, [
|
||||
"deepseek-ai/deepseek-v3.2-maas",
|
||||
{ apiKey: "fixture", project: "vertex-project" },
|
||||
packageInput("deepseek-ai/deepseek-v3.2-maas", { apiKey: "fixture", project: "vertex-project" }),
|
||||
]),
|
||||
).toThrow("Google Vertex Chat does not support API keys")
|
||||
expect(() =>
|
||||
@@ -342,8 +555,7 @@ describe("provider package entrypoints", () => {
|
||||
).toThrow("Google Vertex Chat does not support API keys")
|
||||
expect(() =>
|
||||
Reflect.apply(GoogleVertexResponses.model, undefined, [
|
||||
"xai/grok-4.20-reasoning",
|
||||
{ apiKey: "fixture", project: "vertex-project" },
|
||||
packageInput("xai/grok-4.20-reasoning", { apiKey: "fixture", project: "vertex-project" }),
|
||||
]),
|
||||
).toThrow("Google Vertex Responses does not support API keys")
|
||||
expect(() =>
|
||||
|
||||
@@ -20,16 +20,48 @@ export interface MapInput {
|
||||
export function map(input: MapInput): Mapping | undefined {
|
||||
const baseSettings = mapBaseSettings(input.settings)
|
||||
switch (input.packageName) {
|
||||
case "@ai-sdk/anthropic":
|
||||
case "@ai-sdk/openai":
|
||||
return {
|
||||
package: "@opencode-ai/ai/providers/openai",
|
||||
settings: {
|
||||
...baseSettings,
|
||||
...mapAPIKey(input.settings),
|
||||
...(typeof input.settings.organization === "string" ? { organization: input.settings.organization } : {}),
|
||||
...(typeof input.settings.project === "string" ? { project: input.settings.project } : {}),
|
||||
...(isStringRecord(input.settings.queryParams) ? { queryParams: input.settings.queryParams } : {}),
|
||||
...openAIOptions(input.settings),
|
||||
},
|
||||
...(isStringRecord(input.settings.headers) ? { headers: input.settings.headers } : {}),
|
||||
}
|
||||
case "@ai-sdk/anthropic": {
|
||||
const providerOptions = {
|
||||
...(isRecord(input.settings.thinking) ? { thinking: input.settings.thinking } : {}),
|
||||
...(typeof input.settings.effort === "string" ? { effort: input.settings.effort } : {}),
|
||||
}
|
||||
return {
|
||||
package: "@opencode-ai/ai/providers/anthropic",
|
||||
settings: {
|
||||
...baseSettings,
|
||||
...mapAPIKey(input.settings),
|
||||
...(typeof input.settings.authToken === "string" ? { authToken: input.settings.authToken } : {}),
|
||||
...mapProviderOptions(input.settings, ["apiKey", "authToken", "baseURL"]),
|
||||
...(Object.keys(providerOptions).length === 0 ? {} : { providerOptions }),
|
||||
},
|
||||
...(isStringRecord(input.settings.headers) ? { headers: input.settings.headers } : {}),
|
||||
}
|
||||
}
|
||||
case "@ai-sdk/openai-compatible":
|
||||
return typeof input.settings.baseURL !== "string"
|
||||
? undefined
|
||||
: {
|
||||
package: "@opencode-ai/ai/providers/openai-compatible",
|
||||
settings: {
|
||||
...baseSettings,
|
||||
...mapAPIKey(input.settings),
|
||||
provider: input.providerID,
|
||||
...mapOpenAIOptions(input.settings),
|
||||
},
|
||||
...(isStringRecord(input.settings.headers) ? { headers: input.settings.headers } : {}),
|
||||
}
|
||||
case "@ai-sdk/amazon-bedrock":
|
||||
return {
|
||||
package: "@opencode-ai/ai/providers/amazon-bedrock",
|
||||
@@ -71,10 +103,7 @@ export function map(input: MapInput): Mapping | undefined {
|
||||
...mapAPIKey(input.settings),
|
||||
...(typeof input.settings.location === "string" ? { location: input.settings.location } : {}),
|
||||
...(typeof input.settings.project === "string" ? { project: input.settings.project } : {}),
|
||||
...mapGoogleOptions(
|
||||
input.settings,
|
||||
isStringRecord(input.settings.labels) ? { labels: input.settings.labels } : {},
|
||||
),
|
||||
...mapGoogleOptions(input.settings),
|
||||
},
|
||||
...(isStringRecord(input.settings.headers) ? { headers: input.settings.headers } : {}),
|
||||
}
|
||||
@@ -97,29 +126,6 @@ export function map(input: MapInput): Mapping | undefined {
|
||||
},
|
||||
...(isStringRecord(input.settings.headers) ? { headers: input.settings.headers } : {}),
|
||||
}
|
||||
case "@ai-sdk/openai":
|
||||
return {
|
||||
package: "@opencode-ai/ai/providers/openai",
|
||||
settings: {
|
||||
...baseSettings,
|
||||
...mapAPIKey(input.settings),
|
||||
...(typeof input.settings.organization === "string" ? { organization: input.settings.organization } : {}),
|
||||
...(typeof input.settings.project === "string" ? { project: input.settings.project } : {}),
|
||||
...(isStringRecord(input.settings.queryParams) ? { queryParams: input.settings.queryParams } : {}),
|
||||
...mapProviderOptions(input.settings, ["apiKey", "baseURL", "organization", "project", "queryParams"]),
|
||||
},
|
||||
}
|
||||
case "@ai-sdk/openai-compatible":
|
||||
if (typeof input.settings.baseURL !== "string") return
|
||||
return {
|
||||
package: "@opencode-ai/ai/providers/openai-compatible",
|
||||
settings: {
|
||||
...baseSettings,
|
||||
...mapAPIKey(input.settings),
|
||||
provider: input.providerID,
|
||||
...mapProviderOptions(input.settings, ["apiKey", "baseURL"]),
|
||||
},
|
||||
}
|
||||
case "@openrouter/ai-sdk-provider":
|
||||
return mapOpenRouter(input.settings, baseSettings)
|
||||
case "@ai-sdk/xai":
|
||||
@@ -134,12 +140,6 @@ export function map(input: MapInput): Mapping | undefined {
|
||||
}
|
||||
}
|
||||
|
||||
function mapProviderOptions(settings: Readonly<Record<string, unknown>>, excluded: ReadonlyArray<string>) {
|
||||
const options = Object.fromEntries(Object.entries(settings).filter(([name]) => !excluded.includes(name)))
|
||||
if (Object.keys(options).length === 0) return {}
|
||||
return { providerOptions: options }
|
||||
}
|
||||
|
||||
function mapBedrockMantle(input: MapInput, baseSettings: Readonly<Record<string, unknown>>): Mapping | undefined {
|
||||
const settings = input.settings
|
||||
const chat = input.modelID === "openai.gpt-oss-safeguard-20b" || input.modelID === "openai.gpt-oss-safeguard-120b"
|
||||
@@ -260,17 +260,26 @@ function bedrockRegion(settings: Readonly<Record<string, unknown>>) {
|
||||
}
|
||||
|
||||
function mapOpenAIOptions(settings: Readonly<Record<string, unknown>>) {
|
||||
const options = openAIOptions(settings)
|
||||
if (Object.keys(options).length === 0) return {}
|
||||
return { providerOptions: options }
|
||||
}
|
||||
|
||||
function openAIOptions(settings: Readonly<Record<string, unknown>>) {
|
||||
const options = {
|
||||
...(typeof settings.instructions === "string" ? { instructions: settings.instructions } : {}),
|
||||
...(typeof settings.reasoningEffort === "string" ? { reasoningEffort: settings.reasoningEffort } : {}),
|
||||
...(typeof settings.reasoningSummary === "string" ? { reasoningSummary: settings.reasoningSummary } : {}),
|
||||
...(Array.isArray(settings.include) ? { include: settings.include } : {}),
|
||||
...(typeof settings.store === "boolean" ? { store: settings.store } : {}),
|
||||
...(typeof settings.promptCacheKey === "string" ? { promptCacheKey: settings.promptCacheKey } : {}),
|
||||
...(typeof settings.textVerbosity === "string" ? { textVerbosity: settings.textVerbosity } : {}),
|
||||
...(typeof settings.serviceTier === "string" ? { serviceTier: settings.serviceTier } : {}),
|
||||
...(typeof settings.truncation === "string" ? { truncation: settings.truncation } : {}),
|
||||
...(isRecord(settings.allowedTools) ? { allowedTools: settings.allowedTools } : {}),
|
||||
...(typeof settings.maxToolCalls === "number" ? { maxToolCalls: settings.maxToolCalls } : {}),
|
||||
...(typeof settings.parallelToolCalls === "boolean" ? { parallelToolCalls: settings.parallelToolCalls } : {}),
|
||||
}
|
||||
if (Object.keys(options).length === 0) return {}
|
||||
return { providerOptions: options }
|
||||
return options
|
||||
}
|
||||
|
||||
function mapBaseSettings(settings: Readonly<Record<string, unknown>>) {
|
||||
@@ -283,7 +292,7 @@ function mapAPIKey(settings: Readonly<Record<string, unknown>>) {
|
||||
return typeof settings.apiKey === "string" ? { apiKey: settings.apiKey } : {}
|
||||
}
|
||||
|
||||
function mapGoogleOptions(settings: Readonly<Record<string, unknown>>, extra: Readonly<Record<string, unknown>> = {}) {
|
||||
function mapGoogleOptions(settings: Readonly<Record<string, unknown>>) {
|
||||
const input = settings.thinkingConfig
|
||||
const thinkingConfig = {
|
||||
...(isRecord(input) && typeof input.thinkingBudget === "number" ? { thinkingBudget: input.thinkingBudget } : {}),
|
||||
@@ -298,7 +307,6 @@ function mapGoogleOptions(settings: Readonly<Record<string, unknown>>, extra: Re
|
||||
...(Array.isArray(settings.safetySettings) ? { safetySettings: settings.safetySettings } : {}),
|
||||
...(typeof settings.serviceTier === "string" ? { serviceTier: settings.serviceTier } : {}),
|
||||
...(Object.keys(thinkingConfig).length > 0 ? { thinkingConfig } : {}),
|
||||
...extra,
|
||||
}
|
||||
if (Object.keys(options).length === 0) return {}
|
||||
return { providerOptions: options }
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export * as ModelResolver from "./model-resolver.js"
|
||||
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { LanguageModel } from "@opencode-ai/ai"
|
||||
import { LanguageModel, ProviderPackage } from "@opencode-ai/ai"
|
||||
import { Auth } from "@opencode-ai/ai/route"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { produce } from "immer"
|
||||
@@ -124,7 +124,7 @@ const resolveCatalogModel = Effect.fn("ModelResolver.resolveCatalogModel")(funct
|
||||
const resolved = prepareRuntimeModel(model, credential)
|
||||
const packageName = Provider.packageName(resolved.package)
|
||||
const configuration = credential?.type === "key" ? credential.configuration : undefined
|
||||
const configured = { ...resolved.settings, ...credential?.metadata, ...configuration }
|
||||
const configured = Provider.mergeOverlay(resolved.settings, configuration) ?? {}
|
||||
const mapping = Provider.isAISDK(resolved.package)
|
||||
? AISDKNative.map({
|
||||
packageName,
|
||||
@@ -140,7 +140,7 @@ const resolveCatalogModel = Effect.fn("ModelResolver.resolveCatalogModel")(funct
|
||||
const settings = yield* prepareProviderSettings(
|
||||
resolved,
|
||||
Provider.mergeOverlay(resolved.settings, {
|
||||
...nativeCredentialSettings(resolved.package ?? "", credential),
|
||||
...legacyCredentialSettings(credential),
|
||||
...credential?.metadata,
|
||||
...configuration,
|
||||
}) ?? {},
|
||||
@@ -157,16 +157,18 @@ const resolveCatalogModel = Effect.fn("ModelResolver.resolveCatalogModel")(funct
|
||||
const module = yield* (dependencies?.loadPackage ?? Provider.loadPackage)(specifier).pipe(
|
||||
Effect.mapError(() => unsupported(resolved)),
|
||||
)
|
||||
const settings = {
|
||||
...(credential ? withoutNativeAuthSettings(mapped) : mapped),
|
||||
...nativeCredentialSettings(specifier, credential),
|
||||
headers: Provider.mergeHeaders(mapping?.headers, resolved.headers),
|
||||
body: Provider.mergeOverlay(mapping?.body, resolved.body),
|
||||
limits: { context: resolved.limit.context, input: resolved.limit.input, output: resolved.limit.output },
|
||||
}
|
||||
return yield* Effect.try({
|
||||
try: () => {
|
||||
const runtime = module.model(resolved.modelID ?? resolved.id, settings)
|
||||
const runtime = module.model({
|
||||
id: resolved.modelID ?? resolved.id,
|
||||
settings: mapped,
|
||||
credential: providerCredential(credential),
|
||||
defaults: {
|
||||
headers: Provider.mergeHeaders(mapping?.headers, resolved.headers),
|
||||
body: Provider.mergeOverlay(mapping?.body, resolved.body),
|
||||
limits: { context: resolved.limit.context, input: resolved.limit.input, output: resolved.limit.output },
|
||||
},
|
||||
})
|
||||
return LanguageModel.update(runtime, {
|
||||
provider: resolved.providerID,
|
||||
compatibility: resolved.compatibility
|
||||
@@ -225,25 +227,23 @@ function unresolvedProviderVariables(model: Info, baseURL: string) {
|
||||
})
|
||||
}
|
||||
|
||||
const nativeCredentialSettings = (specifier: string, credential: Credential.Value | undefined) => {
|
||||
const legacyCredentialSettings = (credential: Credential.Value | undefined) => {
|
||||
if (!credential) return {}
|
||||
if (credential.type === "key") return { apiKey: credential.key }
|
||||
if (
|
||||
specifier === "@opencode-ai/ai/providers/anthropic" ||
|
||||
specifier === "@opencode-ai/ai/providers/anthropic-compatible"
|
||||
)
|
||||
return { authToken: credential.access }
|
||||
if (
|
||||
specifier === "@opencode-ai/ai/providers/google-vertex" ||
|
||||
specifier.startsWith("@opencode-ai/ai/providers/google-vertex/")
|
||||
)
|
||||
return { accessToken: credential.access }
|
||||
return { apiKey: credential.access }
|
||||
}
|
||||
|
||||
const withoutNativeAuthSettings = (settings: Record<string, unknown>) => {
|
||||
const { accessToken: _accessToken, apiKey: _apiKey, authToken: _authToken, ...rest } = settings
|
||||
return rest
|
||||
const providerCredential = (credential: Credential.Value | undefined): ProviderPackage.Credential | undefined => {
|
||||
if (!credential) return undefined
|
||||
if (credential.type === "key" && credential.key.length === 0) return undefined
|
||||
if (credential.type === "oauth" && credential.access.length === 0) return undefined
|
||||
if (credential.type === "key")
|
||||
return {
|
||||
type: "key",
|
||||
value: credential.key,
|
||||
configuration: credential.configuration,
|
||||
}
|
||||
return { type: "oauth", accessToken: credential.access }
|
||||
}
|
||||
|
||||
const unsupported = (model: Info) =>
|
||||
|
||||
@@ -171,7 +171,7 @@ const importPackage = Effect.fn("Provider.importPackage")(function* (
|
||||
if (typeof module !== "object" || module === null || typeof (module as { model?: unknown }).model !== "function") {
|
||||
return yield* new LoadError({
|
||||
package: specifier,
|
||||
cause: new Error(`Provider package ${specifier} does not export model(modelID, settings)`),
|
||||
cause: new Error(`Provider package ${specifier} does not export model(input)`),
|
||||
})
|
||||
}
|
||||
return module as ProviderPackageDefinition
|
||||
|
||||
@@ -22,14 +22,12 @@ describe("AISDKNative", () => {
|
||||
settings: {
|
||||
apiKey: "secret",
|
||||
baseURL: "https://api.meta.ai/v1",
|
||||
providerOptions: {
|
||||
reasoningEffort: "xhigh",
|
||||
reasoningSummary: "auto",
|
||||
include: ["reasoning.encrypted_content"],
|
||||
instructions: "Follow the repository instructions.",
|
||||
truncation: "auto",
|
||||
},
|
||||
organization: "org",
|
||||
reasoningEffort: "xhigh",
|
||||
reasoningSummary: "auto",
|
||||
include: ["reasoning.encrypted_content"],
|
||||
instructions: "Follow the repository instructions.",
|
||||
truncation: "auto",
|
||||
},
|
||||
})
|
||||
expect(map("@ai-sdk/openai-compatible", { baseURL: "https://example.com/v1", reasoningEffort: "high" })).toEqual({
|
||||
|
||||
@@ -66,10 +66,6 @@ function withEnv<A, E, R>(variables: Record<string, string | undefined>, effect:
|
||||
)
|
||||
}
|
||||
|
||||
function withConfigEnv<A, E, R>(env: Record<string, string>, effect: () => Effect.Effect<A, E, R>) {
|
||||
return effect().pipe(Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env }))))
|
||||
}
|
||||
|
||||
describe("ModelResolver", () => {
|
||||
it.effect("constructs native Azure requests with deployment IDs and projected resource URLs", () =>
|
||||
Effect.gen(function* () {
|
||||
@@ -81,6 +77,22 @@ describe("ModelResolver", () => {
|
||||
}),
|
||||
Credential.Key.make({ type: "key", key: "secret" }),
|
||||
)
|
||||
const configuredCredential = yield* ModelResolver.fromCatalogModel(
|
||||
model(Provider.aisdk("@ai-sdk/azure"), {
|
||||
providerID: Provider.ID.azure,
|
||||
modelID: "configured-deployment",
|
||||
settings: { resourceName: "catalog-resource", apiVersion: "catalog-version" },
|
||||
}),
|
||||
Credential.Key.make({
|
||||
type: "key",
|
||||
key: "secret",
|
||||
configuration: {
|
||||
resourceName: "configured-resource",
|
||||
apiVersion: "configured-version",
|
||||
useDeploymentBasedUrls: true,
|
||||
},
|
||||
}),
|
||||
)
|
||||
const chat = yield* ModelResolver.fromCatalogModel(
|
||||
model(Provider.aisdk("@ai-sdk/azure"), {
|
||||
providerID: Provider.ID.azure,
|
||||
@@ -118,6 +130,10 @@ describe("ModelResolver", () => {
|
||||
query: { "api-version": "2025-01-01-preview" },
|
||||
},
|
||||
})
|
||||
expect(configuredCredential.route.endpoint).toMatchObject({
|
||||
baseURL: "https://configured-resource.openai.azure.com/openai/deployments/configured-deployment",
|
||||
query: { "api-version": "configured-version" },
|
||||
})
|
||||
expect(chat).toMatchObject({ id: "chat-deployment", provider: "azure" })
|
||||
expect(chat.route.id).toBe("azure-openai-chat")
|
||||
expect(deployment).toMatchObject({ id: "legacy-url-deployment", provider: "azure" })
|
||||
@@ -139,11 +155,22 @@ describe("ModelResolver", () => {
|
||||
settings: { baseURL: "https://${AZURE_HOST}/openai" },
|
||||
}),
|
||||
)
|
||||
const configured = yield* ModelResolver.fromCatalogModel(
|
||||
model(Provider.aisdk("@ai-sdk/azure"), {
|
||||
providerID: Provider.ID.azure,
|
||||
}),
|
||||
Credential.Key.make({
|
||||
type: "key",
|
||||
key: "secret",
|
||||
configuration: { baseURL: "https://${AZURE_HOST}/openai" },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(resolved.route.endpoint).toMatchObject({
|
||||
baseURL: "https://resource.openai.azure.com/openai/v1",
|
||||
query: { "api-version": "v1" },
|
||||
})
|
||||
expect(configured.route.endpoint.baseURL).toBe("https://resource.openai.azure.com/openai/v1")
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -260,25 +287,33 @@ describe("ModelResolver", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("treats an empty configured API key as omitted", () =>
|
||||
withConfigEnv({ OPENAI_API_KEY: "environment-key" }, () =>
|
||||
Effect.gen(function* () {
|
||||
const resolved = yield* ModelResolver.fromCatalogModel(
|
||||
model(Provider.aisdk("@ai-sdk/openai"), {
|
||||
settings: { apiKey: "", baseURL: "https://openai.example/v1" },
|
||||
}),
|
||||
)
|
||||
const headers = yield* resolved.route.auth.apply({
|
||||
request: LLM.request({ model: resolved, prompt: "Hello" }),
|
||||
it.effect("treats empty configured and selected API keys as omitted", () =>
|
||||
Effect.gen(function* () {
|
||||
const resolved = yield* ModelResolver.fromCatalogModel(
|
||||
model(Provider.aisdk("@ai-sdk/openai"), {
|
||||
settings: { apiKey: "", baseURL: "https://openai.example/v1" },
|
||||
}),
|
||||
)
|
||||
const selected = yield* ModelResolver.fromCatalogModel(
|
||||
model(Provider.aisdk("@ai-sdk/openai"), {
|
||||
settings: { baseURL: "https://openai.example/v1" },
|
||||
}),
|
||||
Credential.Key.make({ type: "key", key: "" }),
|
||||
)
|
||||
const headers = yield* Effect.forEach([resolved, selected], (model) =>
|
||||
model.route.auth.apply({
|
||||
request: LLM.request({ model, prompt: "Hello" }),
|
||||
method: "POST",
|
||||
url: "https://openai.example/v1/responses",
|
||||
body: "{}",
|
||||
headers: Headers.empty,
|
||||
})
|
||||
}),
|
||||
).pipe(
|
||||
Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: { OPENAI_API_KEY: "environment-key" } }))),
|
||||
)
|
||||
|
||||
expect(headers.authorization).toBe("Bearer environment-key")
|
||||
}),
|
||||
),
|
||||
expect(headers.map((item) => item.authorization)).toEqual(["Bearer environment-key", "Bearer environment-key"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("uses no native API-key auth for an explicitly enabled provider without credentials", () => {
|
||||
@@ -341,7 +376,7 @@ describe("ModelResolver", () => {
|
||||
})
|
||||
const layer = ModelResolver.layer.pipe(Layer.provide(Layer.mergeAll(catalog, integrations, npm, aisdk)))
|
||||
|
||||
return withConfigEnv({}, () =>
|
||||
return withEnv({ GOOGLE_GENERATIVE_AI_API_KEY: undefined }, () =>
|
||||
Effect.gen(function* () {
|
||||
const resolver = yield* ModelResolver.Service
|
||||
const resolved = yield* resolver.resolveModel(selected)
|
||||
@@ -362,7 +397,7 @@ describe("ModelResolver", () => {
|
||||
})
|
||||
|
||||
it.effect("keeps native provider environment auth strict when no API key is configured", () =>
|
||||
withConfigEnv({}, () =>
|
||||
withEnv({ GOOGLE_GENERATIVE_AI_API_KEY: undefined }, () =>
|
||||
Effect.gen(function* () {
|
||||
const resolved = yield* ModelResolver.fromCatalogModel(
|
||||
model(Provider.aisdk("@ai-sdk/google"), {
|
||||
@@ -588,6 +623,43 @@ describe("ModelResolver", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("lets the native Anthropic package distinguish key and OAuth credentials", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = model(Provider.aisdk("@ai-sdk/anthropic"), {
|
||||
settings: { baseURL: "https://anthropic.example/v1" },
|
||||
})
|
||||
const key = yield* ModelResolver.fromCatalogModel(
|
||||
catalog,
|
||||
Credential.Key.make({ type: "key", key: "anthropic-key" }),
|
||||
)
|
||||
const oauth = yield* ModelResolver.fromCatalogModel(
|
||||
catalog,
|
||||
Credential.OAuth.make({
|
||||
type: "oauth",
|
||||
methodID: Integration.MethodID.make("device"),
|
||||
access: "anthropic-token",
|
||||
refresh: "refresh",
|
||||
expires: Date.now() + 60_000,
|
||||
}),
|
||||
)
|
||||
const input = (resolved: LanguageModel) => ({
|
||||
request: LLM.request({ model: resolved, prompt: "Hello" }),
|
||||
method: "POST" as const,
|
||||
url: "https://anthropic.example/v1/messages",
|
||||
body: "{}",
|
||||
headers: Headers.empty,
|
||||
})
|
||||
|
||||
const keyHeaders = yield* key.route.auth.apply(input(key))
|
||||
const oauthHeaders = yield* oauth.route.auth.apply(input(oauth))
|
||||
|
||||
expect(keyHeaders["x-api-key"]).toBe("anthropic-key")
|
||||
expect(keyHeaders.authorization).toBeUndefined()
|
||||
expect(oauthHeaders.authorization).toBe("Bearer anthropic-token")
|
||||
expect(oauthHeaders["x-api-key"]).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("uses resolved credentials for bearer auth", () =>
|
||||
Effect.gen(function* () {
|
||||
const resolved = yield* ModelResolver.fromCatalogModel(
|
||||
@@ -692,6 +764,24 @@ describe("ModelResolver", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("maps flat native OpenAI settings into provider options", () =>
|
||||
Effect.gen(function* () {
|
||||
const resolved = yield* ModelResolver.fromCatalogModel(
|
||||
model("@opencode-ai/ai/providers/openai", {
|
||||
modelID: "gpt-5",
|
||||
settings: { reasoningEffort: "high", store: true },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(resolved.route.defaults.providerOptions).toEqual({
|
||||
store: true,
|
||||
reasoningEffort: "high",
|
||||
reasoningSummary: "auto",
|
||||
include: ["reasoning.encrypted_content"],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not route native OpenAI-compatible packages to the codex backend", () =>
|
||||
Effect.gen(function* () {
|
||||
const resolved = yield* ModelResolver.fromCatalogModel(
|
||||
@@ -778,15 +868,18 @@ describe("ModelResolver", () => {
|
||||
loadPackage: (specifier) => {
|
||||
expect(specifier).toBe("@opencode-ai/ai/providers/custom")
|
||||
return Effect.succeed({
|
||||
model: (modelID, settings) => {
|
||||
expect(modelID).toBe("api-test-model")
|
||||
expect(settings).toEqual({
|
||||
region: "test",
|
||||
headers: { "x-package": "header" },
|
||||
body: { custom: true },
|
||||
limits: { context: 100, output: 20 },
|
||||
model: (input) => {
|
||||
expect(input).toEqual({
|
||||
id: "api-test-model",
|
||||
settings: { region: "test" },
|
||||
credential: undefined,
|
||||
defaults: {
|
||||
headers: { "x-package": "header" },
|
||||
body: { custom: true },
|
||||
limits: { context: 100, output: 20 },
|
||||
},
|
||||
})
|
||||
return LanguageModel.make({ id: modelID, provider: "package-provider", route: native.route })
|
||||
return LanguageModel.make({ id: input.id, provider: "package-provider", route: native.route })
|
||||
},
|
||||
})
|
||||
},
|
||||
@@ -797,7 +890,7 @@ describe("ModelResolver", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("maps OAuth credentials to native provider auth settings", () =>
|
||||
it.effect("passes OAuth credentials to native provider packages without interpreting them", () =>
|
||||
Effect.gen(function* () {
|
||||
const native = yield* ModelResolver.fromCatalogModel(
|
||||
model(Provider.aisdk("@ai-sdk/openai"), {
|
||||
@@ -812,23 +905,23 @@ describe("ModelResolver", () => {
|
||||
expires: Date.now() + 60_000,
|
||||
})
|
||||
const packages = [
|
||||
["@opencode-ai/ai/providers/google-vertex", "accessToken"],
|
||||
["@opencode-ai/ai/providers/google-vertex/gemini", "accessToken"],
|
||||
["@opencode-ai/ai/providers/google-vertex/chat", "accessToken"],
|
||||
["@opencode-ai/ai/providers/google-vertex/responses", "accessToken"],
|
||||
["@opencode-ai/ai/providers/google-vertex/messages", "accessToken"],
|
||||
["@opencode-ai/ai/providers/anthropic", "authToken"],
|
||||
["@opencode-ai/ai/providers/anthropic-compatible", "authToken"],
|
||||
"@opencode-ai/ai/providers/google-vertex",
|
||||
"@opencode-ai/ai/providers/google-vertex/gemini",
|
||||
"@opencode-ai/ai/providers/google-vertex/chat",
|
||||
"@opencode-ai/ai/providers/google-vertex/responses",
|
||||
"@opencode-ai/ai/providers/google-vertex/messages",
|
||||
"@opencode-ai/ai/providers/anthropic",
|
||||
"@opencode-ai/ai/providers/anthropic-compatible",
|
||||
] as const
|
||||
|
||||
yield* Effect.forEach(packages, ([specifier, key]) =>
|
||||
yield* Effect.forEach(packages, (specifier) =>
|
||||
ModelResolver.fromCatalogModel(model(specifier, { settings: { apiKey: "configured-key" } }), credential, {
|
||||
loadPackage: () =>
|
||||
Effect.succeed({
|
||||
model: (modelID, settings) => {
|
||||
expect(settings).toMatchObject({ [key]: "oauth-token" })
|
||||
expect(settings).not.toHaveProperty("apiKey")
|
||||
return LanguageModel.make({ id: modelID, provider: "package-provider", route: native.route })
|
||||
model: (input) => {
|
||||
expect(input.settings).toEqual({ apiKey: "configured-key" })
|
||||
expect(input.credential).toEqual({ type: "oauth", accessToken: "oauth-token" })
|
||||
return LanguageModel.make({ id: input.id, provider: "package-provider", route: native.route })
|
||||
},
|
||||
}),
|
||||
}),
|
||||
@@ -858,41 +951,41 @@ describe("ModelResolver", () => {
|
||||
"@ai-sdk/anthropic",
|
||||
"@opencode-ai/ai/providers/anthropic",
|
||||
{ thinking: { type: "adaptive", display: "summarized" }, effort: "high" },
|
||||
{ thinking: { type: "adaptive", display: "summarized" }, effort: "high" },
|
||||
{ providerOptions: { thinking: { type: "adaptive", display: "summarized" }, effort: "high" } },
|
||||
],
|
||||
[
|
||||
"@ai-sdk/openai-compatible",
|
||||
"@opencode-ai/ai/providers/openai-compatible",
|
||||
{ reasoningEffort: "high" },
|
||||
{ reasoningEffort: "high" },
|
||||
{ provider: "test-provider", providerOptions: { reasoningEffort: "high" } },
|
||||
],
|
||||
[
|
||||
"@ai-sdk/google",
|
||||
"@opencode-ai/ai/providers/google",
|
||||
{ thinkingConfig: { thinkingLevel: "high" } },
|
||||
{ thinkingConfig: { thinkingLevel: "high" } },
|
||||
{ providerOptions: { thinkingConfig: { thinkingLevel: "high" } } },
|
||||
],
|
||||
[
|
||||
"@ai-sdk/google-vertex",
|
||||
"@opencode-ai/ai/providers/google-vertex",
|
||||
{ thinkingConfig: { thinkingLevel: "high" } },
|
||||
{ thinkingConfig: { thinkingLevel: "high" } },
|
||||
{ providerOptions: { thinkingConfig: { thinkingLevel: "high" } } },
|
||||
],
|
||||
[
|
||||
"@openrouter/ai-sdk-provider",
|
||||
"@opencode-ai/ai/providers/openrouter",
|
||||
{ reasoning: { effort: "high" } },
|
||||
{ reasoning: { effort: "high" } },
|
||||
{ providerOptions: { reasoning: { effort: "high" } } },
|
||||
],
|
||||
[
|
||||
"@ai-sdk/xai",
|
||||
"@opencode-ai/ai/providers/xai",
|
||||
{ reasoningEffort: "high" },
|
||||
{ reasoningEffort: "high" },
|
||||
{ providerOptions: { reasoningEffort: "high" } },
|
||||
],
|
||||
] as const
|
||||
|
||||
yield* Effect.forEach(packages, ([catalogPackage, nativePackage, sourceOptions, providerOptions]) =>
|
||||
yield* Effect.forEach(packages, ([catalogPackage, nativePackage, sourceOptions, mappedSettings]) =>
|
||||
ModelResolver.fromCatalogModel(
|
||||
model(Provider.aisdk(catalogPackage), {
|
||||
modelID: "api-model",
|
||||
@@ -905,17 +998,19 @@ describe("ModelResolver", () => {
|
||||
loadPackage: (specifier) => {
|
||||
expect(specifier).toBe(nativePackage)
|
||||
return Effect.succeed({
|
||||
model: (modelID, settings) => {
|
||||
expect(modelID).toBe("api-model")
|
||||
expect(settings).toMatchObject({
|
||||
apiKey: "secret",
|
||||
model: (input) => {
|
||||
expect(input.id).toBe("api-model")
|
||||
expect(input.settings).toMatchObject({
|
||||
baseURL: "https://provider.example/v1",
|
||||
...mappedSettings,
|
||||
})
|
||||
expect(input.credential).toEqual({ type: "key", value: "secret" })
|
||||
expect(input.defaults).toEqual({
|
||||
headers: { "x-provider": "header" },
|
||||
body: { custom: true },
|
||||
limits: { context: 100, output: 20 },
|
||||
providerOptions,
|
||||
})
|
||||
return LanguageModel.make({ id: modelID, provider: "native-provider", route: native.route })
|
||||
return LanguageModel.make({ id: input.id, provider: "native-provider", route: native.route })
|
||||
},
|
||||
})
|
||||
},
|
||||
@@ -939,11 +1034,7 @@ describe("ModelResolver", () => {
|
||||
["@ai-sdk/azure", "@opencode-ai/ai/providers/azure/responses", "api-model"],
|
||||
["@ai-sdk/google", "@opencode-ai/ai/providers/google", "api-model"],
|
||||
["@ai-sdk/google-vertex", "@opencode-ai/ai/providers/google-vertex", "api-model"],
|
||||
[
|
||||
"@ai-sdk/google-vertex/anthropic",
|
||||
"@opencode-ai/ai/providers/google-vertex/messages",
|
||||
"claude-sonnet-4-6",
|
||||
],
|
||||
["@ai-sdk/google-vertex/anthropic", "@opencode-ai/ai/providers/google-vertex/messages", "claude-sonnet-4-6"],
|
||||
["@ai-sdk/openai", "@opencode-ai/ai/providers/openai", "api-model"],
|
||||
["@ai-sdk/openai-compatible", "@opencode-ai/ai/providers/openai-compatible", "api-model"],
|
||||
["@openrouter/ai-sdk-provider", "@opencode-ai/ai/providers/openrouter", "api-model"],
|
||||
@@ -961,7 +1052,8 @@ describe("ModelResolver", () => {
|
||||
loadPackage: (specifier) => {
|
||||
expect(specifier).toBe(nativePackage)
|
||||
return Effect.succeed({
|
||||
model: (id) => LanguageModel.make({ id, provider: "native-provider", route: OpenAIChat.route }),
|
||||
model: (input) =>
|
||||
LanguageModel.make({ id: input.id, provider: "native-provider", route: OpenAIChat.route }),
|
||||
})
|
||||
},
|
||||
loadAISDK: () => Effect.die(`AI SDK loader called for ${catalogPackage}`),
|
||||
@@ -997,10 +1089,9 @@ describe("ModelResolver", () => {
|
||||
loadPackage: (specifier) => {
|
||||
expect(specifier).toBe("@opencode-ai/ai/providers/google-vertex/messages")
|
||||
return Effect.succeed({
|
||||
model: (modelID, settings) => {
|
||||
expect(modelID).toBe("claude-sonnet-4-6")
|
||||
expect(settings).toMatchObject({
|
||||
accessToken: "vertex-token",
|
||||
model: (input) => {
|
||||
expect(input.id).toBe("claude-sonnet-4-6")
|
||||
expect(input.settings).toMatchObject({
|
||||
location: "eu",
|
||||
project: "vertex-project",
|
||||
providerOptions: {
|
||||
@@ -1008,7 +1099,8 @@ describe("ModelResolver", () => {
|
||||
effort: "high",
|
||||
},
|
||||
})
|
||||
return LanguageModel.make({ id: modelID, provider: "native-provider", route: native.route })
|
||||
expect(input.credential).toEqual({ type: "oauth", accessToken: "vertex-token" })
|
||||
return LanguageModel.make({ id: input.id, provider: "native-provider", route: native.route })
|
||||
},
|
||||
})
|
||||
},
|
||||
@@ -1035,16 +1127,16 @@ describe("ModelResolver", () => {
|
||||
{
|
||||
loadPackage: () =>
|
||||
Effect.succeed({
|
||||
model: (modelID, settings) => {
|
||||
expect(settings.headers).toEqual({
|
||||
model: (input) => {
|
||||
expect(input.defaults.headers).toEqual({
|
||||
"HTTP-Referer": "https://opencode.ai",
|
||||
"X-OpenRouter-Title": "Custom",
|
||||
})
|
||||
expect(settings.body).toEqual({
|
||||
expect(input.defaults.body).toEqual({
|
||||
transforms: ["middle-out"],
|
||||
provider: { sort: "price", only: ["anthropic"] },
|
||||
})
|
||||
return LanguageModel.make({ id: modelID, provider: "openrouter", route: OpenAIChat.route })
|
||||
return LanguageModel.make({ id: input.id, provider: "openrouter", route: OpenAIChat.route })
|
||||
},
|
||||
}),
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user