mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-20 08:36:03 +00:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
530af4394f | ||
|
|
5f8f439b00 | ||
|
|
f5a3769867 | ||
|
|
42e447400e |
+5
-10
@@ -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 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.
|
||||
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.
|
||||
|
||||
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, store: false })
|
||||
const openai = OpenAI.configure({ apiKey, baseURL })
|
||||
const model = openai.responses("gpt-4o-mini")
|
||||
|
||||
const azure = Azure.configure({ resourceName, apiKey, apiVersion: "v1" })
|
||||
@@ -108,22 +108,17 @@ 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({ 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.
|
||||
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.
|
||||
|
||||
```ts
|
||||
import { model } from "@opencode-ai/ai/providers/openai/responses"
|
||||
|
||||
const selected = model({
|
||||
id: "gpt-5",
|
||||
settings: {},
|
||||
credential: { type: "key", value: apiKey },
|
||||
defaults: {},
|
||||
const selected = model("gpt-5", {
|
||||
apiKey,
|
||||
})
|
||||
```
|
||||
|
||||
|
||||
+12
-56
@@ -305,26 +305,19 @@ 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, 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.
|
||||
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.
|
||||
|
||||
### 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({ 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.
|
||||
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.
|
||||
|
||||
```ts
|
||||
import { model } from "@opencode-ai/ai/providers/openai/responses"
|
||||
|
||||
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 },
|
||||
},
|
||||
const selected = model("gpt-5", {
|
||||
apiKey: process.env.OPENAI_API_KEY,
|
||||
headers: { "x-application": "opencode" },
|
||||
limits: { context: 200_000, output: 64_000 },
|
||||
})
|
||||
```
|
||||
|
||||
@@ -348,57 +341,30 @@ Tuned Vertex Gemini deployments use model ids shaped like `endpoints/1234567890`
|
||||
```ts
|
||||
import { model } from "@opencode-ai/ai/providers/google-vertex/gemini"
|
||||
|
||||
model({
|
||||
id: "gemini-3.5-flash",
|
||||
settings: { project: "my-project", location: "global" },
|
||||
defaults: {},
|
||||
})
|
||||
model("gemini-3.5-flash", { project: "my-project", location: "global" })
|
||||
```
|
||||
|
||||
```ts
|
||||
import { model } from "@opencode-ai/ai/providers/google-vertex/chat"
|
||||
|
||||
model({
|
||||
id: "deepseek-ai/deepseek-v3.2-maas",
|
||||
settings: { project: "my-project", location: "global" },
|
||||
defaults: {},
|
||||
})
|
||||
model("deepseek-ai/deepseek-v3.2-maas", { project: "my-project", location: "global" })
|
||||
```
|
||||
|
||||
```ts
|
||||
import { model } from "@opencode-ai/ai/providers/google-vertex/responses"
|
||||
|
||||
model({
|
||||
id: "xai/grok-4.20-reasoning",
|
||||
settings: { project: "my-project", location: "global" },
|
||||
defaults: {},
|
||||
})
|
||||
model("xai/grok-4.20-reasoning", { project: "my-project", location: "global" })
|
||||
```
|
||||
|
||||
```ts
|
||||
import { model } from "@opencode-ai/ai/providers/google-vertex/messages"
|
||||
|
||||
model({
|
||||
id: "claude-sonnet-4-6",
|
||||
settings: { project: "my-project", location: "global" },
|
||||
defaults: {},
|
||||
})
|
||||
model("claude-sonnet-4-6", { project: "my-project", location: "global" })
|
||||
```
|
||||
|
||||
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`.
|
||||
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.
|
||||
|
||||
## 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.
|
||||
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.
|
||||
|
||||
## Provider options & HTTP overlays
|
||||
|
||||
@@ -411,16 +377,6 @@ 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,12 +17,13 @@ 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, deployment options, authentication, and defaults. Catalog capabilities
|
||||
// remain application-owned and are not part of LanguageModel.
|
||||
// choice, capabilities, deployment options, authentication, and defaults.
|
||||
const model = OpenAI.configure({
|
||||
apiKey,
|
||||
generation: { maxTokens: 160 },
|
||||
store: false,
|
||||
providerOptions: {
|
||||
store: false,
|
||||
},
|
||||
}).model("gpt-4o-mini")
|
||||
|
||||
// 2. Build a provider-neutral request. This is useful when reusing one request
|
||||
@@ -73,8 +74,8 @@ const streamText = LLM.stream(request).pipe(
|
||||
Stream.runDrain,
|
||||
)
|
||||
|
||||
// 5. Tools are typed with Effect Schema. Model calls remain explicit:
|
||||
// advertise definitions on the request, stream one call, dispatch local calls,
|
||||
// 5. Tools are typed with Effect Schema. Provider turns remain explicit:
|
||||
// advertise definitions on the request, stream one turn, dispatch local calls,
|
||||
// then persist/build follow-up history in the enclosing product flow.
|
||||
const tools = {
|
||||
get_weather: Tool.make({
|
||||
@@ -101,7 +102,7 @@ const streamWithTools = Effect.gen(function* () {
|
||||
console.log("tool result", event.name, dispatched.result)
|
||||
|
||||
// A durable agent would persist these messages before starting another
|
||||
// model call. This tutorial keeps the boundary visible instead.
|
||||
// raw model turn. This tutorial keeps the boundary visible instead.
|
||||
const followUp = LLMRequest.update(request, {
|
||||
messages: [
|
||||
...request.messages,
|
||||
|
||||
@@ -37,9 +37,6 @@ 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,21 +1,7 @@
|
||||
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 {}
|
||||
|
||||
export type Credential =
|
||||
| {
|
||||
readonly type: "key"
|
||||
readonly value: string
|
||||
readonly configuration?: Readonly<Record<string, unknown>>
|
||||
}
|
||||
| {
|
||||
readonly type: "oauth"
|
||||
readonly accessToken: string
|
||||
}
|
||||
|
||||
export interface Defaults {
|
||||
export interface Settings extends Readonly<Record<string, unknown>> {
|
||||
readonly baseURL?: string
|
||||
readonly headers?: Readonly<Record<string, string>>
|
||||
readonly body?: Readonly<Record<string, unknown>>
|
||||
readonly limits?: {
|
||||
@@ -25,38 +11,11 @@ export interface Defaults {
|
||||
}
|
||||
}
|
||||
|
||||
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: (input: ModelInput<ProviderSettings>) => LanguageModel<Options>
|
||||
readonly model: (modelID: string, settings: 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 { ProviderPackage } from "../provider-package.js"
|
||||
import type { 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,27 +79,29 @@ export const configure = (input: Config = {}) => {
|
||||
|
||||
export const provider = configure()
|
||||
|
||||
const config = (input: ProviderPackage.ModelInput<Settings>): Config => {
|
||||
if (!input.credential && input.settings.auth === "bearer" && input.settings.apiKey === undefined)
|
||||
const config = (settings: Settings): Config => {
|
||||
if (settings.auth === "bearer" && settings.apiKey === undefined)
|
||||
throw new Error("Amazon Bedrock Mantle bearer auth requires apiKey")
|
||||
if (!input.credential && input.settings.auth === "sigv4" && input.settings.apiKey !== undefined)
|
||||
if (settings.auth === "sigv4" && settings.apiKey !== undefined)
|
||||
throw new Error("Amazon Bedrock Mantle SigV4 auth does not accept apiKey")
|
||||
return {
|
||||
...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,
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
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 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 model = chatModel
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { RouteDefaultsInput } from "../route/client.js"
|
||||
import { Auth } from "../route/auth.js"
|
||||
import { ProviderPackage } from "../provider-package.js"
|
||||
import type { 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,21 +50,19 @@ export const configure = (input: Config = {}) => {
|
||||
}
|
||||
|
||||
export const provider = configure()
|
||||
export const model: ProviderPackage.Definition<Settings>["model"] = (input) => {
|
||||
if (!input.credential && input.settings.auth === "bearer" && input.settings.apiKey === undefined)
|
||||
export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) => {
|
||||
if (settings.auth === "bearer" && settings.apiKey === undefined)
|
||||
throw new Error("Amazon Bedrock bearer auth requires apiKey")
|
||||
if (!input.credential && input.settings.auth === "sigv4" && input.settings.apiKey !== undefined)
|
||||
if (settings.auth === "sigv4" && settings.apiKey !== undefined)
|
||||
throw new Error("Amazon Bedrock SigV4 auth does not accept apiKey")
|
||||
return configure({
|
||||
...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)
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ProviderPackage } from "../provider-package.js"
|
||||
import type { 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,9 +32,7 @@ export const routes = [AnthropicMessages.route]
|
||||
|
||||
const auth = (input: ProviderAuthOption<"optional">) => {
|
||||
if ("auth" in input && input.auth) return input.auth
|
||||
return Auth.remove("authorization").andThen(
|
||||
Auth.optional("apiKey" in input ? input.apiKey : undefined, "apiKey").pipe(Auth.header("x-api-key")),
|
||||
)
|
||||
return Auth.optional("apiKey" in input ? input.apiKey : undefined, "apiKey").pipe(Auth.header("x-api-key"))
|
||||
}
|
||||
|
||||
export const configure = (input: Config) => {
|
||||
@@ -59,20 +57,21 @@ export const provider = {
|
||||
configure,
|
||||
}
|
||||
|
||||
export const model: ProviderPackage.Definition<Settings, AnthropicMessages.ProviderOptionsInput>["model"] = (input) => {
|
||||
if (!input.credential && input.settings.apiKey !== undefined && input.settings.authToken !== undefined)
|
||||
export const model: ProviderPackage.Definition<Settings, AnthropicMessages.ProviderOptionsInput>["model"] = (
|
||||
modelID,
|
||||
settings,
|
||||
) => {
|
||||
if (settings.apiKey !== undefined && settings.authToken !== undefined)
|
||||
throw new Error("Anthropic-compatible apiKey cannot be combined with authToken")
|
||||
return configure({
|
||||
...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)
|
||||
...(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)
|
||||
}
|
||||
|
||||
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 { ProviderPackage } from "../provider-package.js"
|
||||
import type { 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,11 +31,9 @@ export type Settings = ProviderPackage.Settings &
|
||||
|
||||
const auth = (options: ProviderAuthOption<"optional">) => {
|
||||
if ("auth" in options && options.auth) return options.auth
|
||||
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")),
|
||||
)
|
||||
return 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 = {}) => {
|
||||
@@ -54,17 +52,18 @@ export const configure = (input: Config = {}) => {
|
||||
}
|
||||
|
||||
export const provider = configure()
|
||||
export const model: ProviderPackage.Definition<Settings, AnthropicMessages.ProviderOptionsInput>["model"] = (input) => {
|
||||
if (!input.credential && input.settings.apiKey !== undefined && input.settings.authToken !== undefined)
|
||||
export const model: ProviderPackage.Definition<Settings, AnthropicMessages.ProviderOptionsInput>["model"] = (
|
||||
modelID,
|
||||
settings,
|
||||
) => {
|
||||
if (settings.apiKey !== undefined && settings.authToken !== undefined)
|
||||
throw new Error("Anthropic apiKey cannot be combined with authToken")
|
||||
return configure({
|
||||
...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)
|
||||
...(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)
|
||||
}
|
||||
|
||||
@@ -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 { ProviderPackage } from "../provider-package.js"
|
||||
import type { 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,29 +120,28 @@ export const provider = {
|
||||
configure,
|
||||
}
|
||||
|
||||
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 config = (settings: Settings): Config => {
|
||||
const common = {
|
||||
...ProviderPackage.routeDefaults(input.defaults),
|
||||
...(input.credential
|
||||
? ProviderPackage.apiKeyOrBearerAuthOption(input.credential, "api-key")
|
||||
: { apiKey: settings.apiKey }),
|
||||
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 (baseURL !== undefined) return { ...common, baseURL }
|
||||
if (resourceName !== undefined) return { ...common, resourceName }
|
||||
if (settings.baseURL !== undefined) return { ...common, baseURL: settings.baseURL }
|
||||
if (settings.resourceName !== undefined) return { ...common, resourceName: settings.resourceName }
|
||||
throw new Error("Azure requires resourceName or baseURL")
|
||||
}
|
||||
|
||||
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 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 model = responsesModel
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ProviderPackage } from "../provider-package.js"
|
||||
import type { 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,15 +68,16 @@ export const provider = {
|
||||
configure,
|
||||
}
|
||||
|
||||
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")
|
||||
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")
|
||||
return configure({
|
||||
...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)
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Effect, Schema, Struct } from "effect"
|
||||
import { ProviderPackage } from "../provider-package.js"
|
||||
import type { 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,15 +100,19 @@ export const provider = {
|
||||
configure,
|
||||
}
|
||||
|
||||
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")
|
||||
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")
|
||||
return configure({
|
||||
...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)
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ProviderPackage } from "../provider-package.js"
|
||||
import type { 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,15 +70,19 @@ export const provider = {
|
||||
configure,
|
||||
}
|
||||
|
||||
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")
|
||||
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")
|
||||
return configure({
|
||||
...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)
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -69,8 +69,9 @@ 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")
|
||||
const auth = input.auth ?? (input.accessToken !== undefined ? Auth.bearer(input.accessToken) : adc(project))
|
||||
return Auth.remove("x-goog-api-key").andThen(auth)
|
||||
if (input.auth) return input.auth
|
||||
if (input.accessToken !== undefined) return Auth.bearer(input.accessToken)
|
||||
return adc(project)
|
||||
}
|
||||
|
||||
export * as GoogleVertexShared from "./google-vertex-shared.js"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Effect } from "effect"
|
||||
import { ProviderPackage } from "../provider-package.js"
|
||||
import type { ProviderPackage } from "../provider-package.js"
|
||||
import { Gemini } from "../protocols/gemini.js"
|
||||
import { ProviderShared } from "../protocols/shared.js"
|
||||
import { Auth } from "../route/auth.js"
|
||||
@@ -94,10 +94,7 @@ const configuredRoute = (input: Config, modelID: string | ModelID) => {
|
||||
return route.with({
|
||||
...rest,
|
||||
endpoint: { baseURL: endpoint },
|
||||
auth:
|
||||
apiKey === undefined
|
||||
? GoogleVertexShared.oauth(input, project)
|
||||
: Auth.remove("authorization").andThen(Auth.header("x-goog-api-key", apiKey)),
|
||||
auth: apiKey === undefined ? GoogleVertexShared.oauth(input, project) : Auth.header("x-goog-api-key", apiKey),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -114,21 +111,17 @@ export const provider = {
|
||||
id,
|
||||
configure,
|
||||
}
|
||||
export const model: ProviderPackage.Definition<Settings, GeminiProviderOptionsInput>["model"] = (input) => {
|
||||
if (!input.credential && input.settings.apiKey !== undefined && input.settings.accessToken !== undefined)
|
||||
export const model: ProviderPackage.Definition<Settings, GeminiProviderOptionsInput>["model"] = (modelID, settings) => {
|
||||
if (settings.apiKey !== undefined && settings.accessToken !== undefined)
|
||||
throw new Error("Google Vertex apiKey cannot be combined with accessToken or auth")
|
||||
return configure({
|
||||
...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)
|
||||
...(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)
|
||||
}
|
||||
|
||||
@@ -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 { ProviderPackage } from "../provider-package.js"
|
||||
import type { 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,11 +28,9 @@ export interface Settings extends ProviderPackage.Settings {
|
||||
|
||||
const auth = (options: ProviderAuthOption<"optional">) => {
|
||||
if ("auth" in options && options.auth) return options.auth
|
||||
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")),
|
||||
)
|
||||
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"))
|
||||
}
|
||||
|
||||
const configuredRoute = (input: Config) => {
|
||||
@@ -59,14 +57,14 @@ export const configure = (input: Config = {}) => {
|
||||
}
|
||||
|
||||
export const provider = configure()
|
||||
export const model: ProviderPackage.Definition<Settings, Gemini.ProviderOptionsInput>["model"] = (input) =>
|
||||
export const model: ProviderPackage.Definition<Settings, Gemini.ProviderOptionsInput>["model"] = (modelID, settings) =>
|
||||
configure({
|
||||
...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)
|
||||
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)
|
||||
|
||||
export const image = provider.image
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ProviderPackage } from "../provider-package.js"
|
||||
import type { 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,11 +46,16 @@ export const provider = {
|
||||
configure,
|
||||
}
|
||||
|
||||
export const model: ProviderPackage.Definition<Settings, OpenResponsesProviderOptionsInput>["model"] = (input) =>
|
||||
export const model: ProviderPackage.Definition<Settings, OpenResponsesProviderOptionsInput>["model"] = (
|
||||
modelID,
|
||||
settings,
|
||||
) =>
|
||||
configure({
|
||||
...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)
|
||||
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)
|
||||
|
||||
@@ -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 { ProviderPackage } from "../provider-package.js"
|
||||
import type { ProviderPackage } from "../provider-package.js"
|
||||
import { profiles, type OpenAICompatibleProfile } from "./openai-compatible-profile.js"
|
||||
import type { OpenAIProviderOptionsInput } from "./openai-options.js"
|
||||
|
||||
@@ -68,14 +68,16 @@ export const provider = {
|
||||
configure,
|
||||
}
|
||||
|
||||
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (input) =>
|
||||
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) =>
|
||||
configure({
|
||||
...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)
|
||||
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)
|
||||
|
||||
export const baseten = define(profiles.baseten)
|
||||
export const cerebras = define(profiles.cerebras)
|
||||
|
||||
@@ -1,21 +1,37 @@
|
||||
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 {
|
||||
return openAIProviderOptions({
|
||||
reasoningEffort: "medium",
|
||||
reasoningSummary: "auto",
|
||||
// GPT-5 reasoning models are configured stateless (`store: false`) by
|
||||
@@ -28,13 +44,14 @@ 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({ store: false }, gpt5DefaultOptions(modelID, options))
|
||||
): ProviderOptions | undefined =>
|
||||
mergeProviderOptions(openAIProviderOptions({ 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 { ProviderPackage } from "../provider-package.js"
|
||||
import type { 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 OpenAIConfigOptions, type OpenAIProviderOptionsInput } from "./openai-options.js"
|
||||
import { withOpenAIOptions, 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 = Omit<RouteDefaultsInput, "providerOptions"> &
|
||||
OpenAIConfigOptions &
|
||||
export type Config = RouteDefaultsInput &
|
||||
ProviderAuthOption<"optional"> & {
|
||||
readonly baseURL?: string
|
||||
readonly queryParams?: Record<string, string>
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
|
||||
export interface ImageGenerationOptions {
|
||||
@@ -57,12 +57,13 @@ export const imageGeneration = (options: ImageGenerationOptions = {}) =>
|
||||
},
|
||||
})
|
||||
|
||||
export interface Settings extends ProviderPackage.Settings, OpenAIConfigOptions {
|
||||
export interface Settings extends ProviderPackage.Settings {
|
||||
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")
|
||||
@@ -72,39 +73,6 @@ 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),
|
||||
@@ -114,8 +82,7 @@ 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 split = splitConfigOptions(defaults(input))
|
||||
const modelDefaults = { ...split.rest, providerOptions: split.options }
|
||||
const modelDefaults = defaults(input)
|
||||
const responses = (id: string | ModelID) =>
|
||||
responsesRoute
|
||||
.with(withOpenAIOptions(id, modelDefaults, { textVerbosity: true }))
|
||||
@@ -146,30 +113,31 @@ export const configure = (input: Config = {}) => {
|
||||
|
||||
export const provider = configure()
|
||||
|
||||
const config = (input: ProviderPackage.ModelInput<Settings>): Config => {
|
||||
const settings = input.settings
|
||||
const options = splitConfigOptions(settings).options
|
||||
const config = (settings: Settings): Config => {
|
||||
const headers = {
|
||||
...(settings.organization === undefined ? {} : { "OpenAI-Organization": settings.organization }),
|
||||
...(settings.project === undefined ? {} : { "OpenAI-Project": settings.project }),
|
||||
...input.defaults.headers,
|
||||
...settings.headers,
|
||||
}
|
||||
return {
|
||||
...ProviderPackage.routeDefaults(input.defaults),
|
||||
...(input.credential ? ProviderPackage.bearerAuthOption(input.credential) : { apiKey: settings.apiKey }),
|
||||
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"] = (input) => {
|
||||
return configure(config(input)).responses(input.id)
|
||||
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) => {
|
||||
return configure(config(settings)).responses(modelID)
|
||||
}
|
||||
|
||||
export const chatModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (input) =>
|
||||
configure(config(input)).chat(input.id)
|
||||
export const chatModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
|
||||
modelID,
|
||||
settings,
|
||||
) => configure(config(settings)).chat(modelID)
|
||||
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 { ProviderPackage } from "../provider-package.js"
|
||||
import type { 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,10 +191,15 @@ export const configure = (input: LanguageModelOptions = {}) => {
|
||||
}
|
||||
|
||||
export const provider = configure()
|
||||
export const model: ProviderPackage.Definition<Settings, OpenRouterProviderOptionsInput>["model"] = (input) =>
|
||||
export const model: ProviderPackage.Definition<Settings, OpenRouterProviderOptionsInput>["model"] = (
|
||||
modelID,
|
||||
settings,
|
||||
) =>
|
||||
configure({
|
||||
...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)
|
||||
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)
|
||||
|
||||
@@ -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 { ProviderPackage } from "../provider-package.js"
|
||||
import type { ProviderPackage } from "../provider-package.js"
|
||||
|
||||
export const id = ProviderID.make("xai")
|
||||
|
||||
@@ -95,13 +95,15 @@ export const configure = (input: LanguageModelOptions = {}) => {
|
||||
}
|
||||
|
||||
export const provider = configure()
|
||||
export const model: ProviderPackage.Definition<Settings, XAIProviderOptionsInput>["model"] = (input) =>
|
||||
export const model: ProviderPackage.Definition<Settings, XAIProviderOptionsInput>["model"] = (modelID, settings) =>
|
||||
configure({
|
||||
...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)
|
||||
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)
|
||||
export const responses = provider.responses
|
||||
export const chat = provider.chat
|
||||
export const image = provider.image
|
||||
|
||||
@@ -81,22 +81,8 @@ OpenAI.configure({
|
||||
}).responses("gpt-4.1-mini")
|
||||
OpenAI.configure({
|
||||
generation: { maxTokens: 100 },
|
||||
store: false,
|
||||
providerOptions: { 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", {})
|
||||
@@ -111,7 +97,7 @@ OpenAI.configure({ bogus: true })
|
||||
OpenAI.configure({ generation: { maxTokens: "many" } })
|
||||
|
||||
// @ts-expect-error provider-native options remain typed.
|
||||
OpenAI.configure({ store: "false" })
|
||||
OpenAI.configure({ providerOptions: { 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") })
|
||||
@@ -159,12 +145,8 @@ Anthropic.configure({
|
||||
}).model("claude-haiku")
|
||||
// @ts-expect-error Anthropic model selectors only accept model ids.
|
||||
Anthropic.configure({ apiKey: "anthropic-key" }).model("claude-haiku", {})
|
||||
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 Anthropic package settings accept only one auth source.
|
||||
Anthropic.model("claude-sonnet-4-6", { apiKey: "anthropic-key", authToken: "anthropic-token" })
|
||||
// @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.
|
||||
@@ -180,15 +162,11 @@ 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", {})
|
||||
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: {},
|
||||
// @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",
|
||||
})
|
||||
|
||||
Google.configure({ apiKey: "google-key" }).model("gemini-2.5-flash")
|
||||
@@ -211,23 +189,15 @@ 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" })
|
||||
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: {},
|
||||
})
|
||||
// @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" })
|
||||
|
||||
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",
|
||||
)
|
||||
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: {},
|
||||
})
|
||||
// @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.configure({ accessToken: "vertex-token", project: "project" }).model(
|
||||
"deepseek-ai/deepseek-v3.2-maas",
|
||||
// @ts-expect-error Vertex Chat model selectors only accept model ids.
|
||||
@@ -244,12 +214,8 @@ GoogleVertexResponses.configure({ accessToken: "vertex-token", project: "project
|
||||
GoogleVertexResponses.configure({ auth: Auth.bearer("vertex-token"), project: "project" }).model(
|
||||
"xai/grok-4.20-reasoning",
|
||||
)
|
||||
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: {},
|
||||
})
|
||||
// @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.configure({ accessToken: "vertex-token", project: "project" }).model(
|
||||
"xai/grok-4.20-reasoning",
|
||||
// @ts-expect-error Vertex Responses model selectors only accept model ids.
|
||||
@@ -267,12 +233,8 @@ GoogleVertexMessages.configure({
|
||||
project: "project",
|
||||
providerOptions: { thinking: { type: "adaptive", display: "omitted" }, effort: "low" },
|
||||
}).model("claude-sonnet-4-6")
|
||||
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: {},
|
||||
})
|
||||
// @ts-expect-error Vertex Messages package settings do not accept API keys.
|
||||
GoogleVertexMessages.model("claude-sonnet-4-6", { apiKey: "vertex-key", project: "project" })
|
||||
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,72 +1,6 @@
|
||||
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([
|
||||
@@ -111,18 +45,14 @@ describe("provider package entrypoints", () => {
|
||||
body: { service_tier: "priority" },
|
||||
limits: { context: 200_000, output: 64_000 },
|
||||
}
|
||||
const openrouter = OpenRouter.model(
|
||||
packageInput("anthropic/claude-sonnet-4", {
|
||||
...settings,
|
||||
providerOptions: { usage: true },
|
||||
}),
|
||||
)
|
||||
const xai = XAI.model(
|
||||
packageInput("grok-4", {
|
||||
...settings,
|
||||
providerOptions: { reasoningEffort: "high" },
|
||||
}),
|
||||
)
|
||||
const openrouter = OpenRouter.model("anthropic/claude-sonnet-4", {
|
||||
...settings,
|
||||
providerOptions: { usage: true },
|
||||
})
|
||||
const xai = XAI.model("grok-4", {
|
||||
...settings,
|
||||
providerOptions: { reasoningEffort: "high" },
|
||||
})
|
||||
|
||||
for (const selected of [openrouter, xai]) {
|
||||
expect(selected.route.endpoint.baseURL).toBe(settings.baseURL)
|
||||
@@ -135,151 +65,32 @@ describe("provider package entrypoints", () => {
|
||||
})
|
||||
|
||||
test("maps package settings onto the executable model", () => {
|
||||
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,
|
||||
}),
|
||||
)
|
||||
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,
|
||||
})
|
||||
|
||||
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(
|
||||
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 },
|
||||
}),
|
||||
)
|
||||
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 },
|
||||
})
|
||||
|
||||
expect(String(selected.provider)).toBe("example")
|
||||
expect(selected.route.id).toBe("openai-compatible-responses")
|
||||
@@ -295,17 +106,15 @@ 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(
|
||||
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" },
|
||||
}),
|
||||
)
|
||||
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" },
|
||||
})
|
||||
|
||||
expect(String(selected.provider)).toBe("example")
|
||||
expect(selected.route.id).toBe("anthropic-messages")
|
||||
@@ -321,12 +130,10 @@ 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(
|
||||
packageInput("claude-sonnet-4-6", {
|
||||
apiKey: "fixture",
|
||||
providerOptions: { thinking: { type: "adaptive" } },
|
||||
}),
|
||||
)
|
||||
const selected = Anthropic.model("claude-sonnet-4-6", {
|
||||
apiKey: "fixture",
|
||||
providerOptions: { thinking: { type: "adaptive" } },
|
||||
})
|
||||
|
||||
expect(selected.route.defaults.providerOptions).toEqual({ thinking: { type: "adaptive" } })
|
||||
})
|
||||
@@ -334,7 +141,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, [packageInput("compatible-model", { apiKey: "fixture" })]),
|
||||
Reflect.apply(AnthropicCompatible.model, undefined, ["compatible-model", { apiKey: "fixture" }]),
|
||||
).toThrow("Anthropic-compatible providers require a baseURL")
|
||||
})
|
||||
|
||||
@@ -343,28 +150,25 @@ describe("provider package entrypoints", () => {
|
||||
const AnthropicCompatible = await import("@opencode-ai/ai/providers/anthropic-compatible")
|
||||
expect(() =>
|
||||
Reflect.apply(AnthropicCompatible.model, undefined, [
|
||||
packageInput("compatible-model", {
|
||||
"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, [
|
||||
packageInput("claude-sonnet-4-6", { apiKey: "fixture", authToken: "token" }),
|
||||
]),
|
||||
Reflect.apply(Anthropic.model, undefined, ["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(
|
||||
packageInput("gpt-5", {
|
||||
apiKey: "fixture",
|
||||
organization: "org_123",
|
||||
project: "proj_123",
|
||||
}),
|
||||
)
|
||||
const selected = model("gpt-5", {
|
||||
apiKey: "fixture",
|
||||
organization: "org_123",
|
||||
project: "proj_123",
|
||||
})
|
||||
|
||||
expect(selected.route.defaults.headers).toMatchObject({
|
||||
"OpenAI-Organization": "org_123",
|
||||
@@ -384,10 +188,10 @@ describe("provider package entrypoints", () => {
|
||||
limits: { context: 200_000, output: 64_000 },
|
||||
}
|
||||
|
||||
const responses = AzureResponses.model(packageInput("deployment", settings))
|
||||
const chat = AzureChat.model(packageInput("deployment", settings))
|
||||
const responses = AzureResponses.model("deployment", settings)
|
||||
const chat = AzureChat.model("deployment", settings)
|
||||
|
||||
expect(Azure.model(packageInput("deployment", settings)).route.id).toBe("azure-openai-responses")
|
||||
expect(Azure.model("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" })
|
||||
@@ -398,20 +202,16 @@ 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(
|
||||
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/",
|
||||
}),
|
||||
)
|
||||
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/",
|
||||
})
|
||||
|
||||
expect(deployment.route.endpoint).toMatchObject({
|
||||
baseURL: "https://opencode-test.openai.azure.com/openai/deployments/custom-deployment",
|
||||
@@ -423,16 +223,14 @@ 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(
|
||||
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 } },
|
||||
}),
|
||||
)
|
||||
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 } },
|
||||
})
|
||||
|
||||
expect(selected.route.id).toBe("gemini")
|
||||
expect(selected.route.endpoint.baseURL).toBe("https://generativelanguage.test/v1beta")
|
||||
@@ -448,35 +246,27 @@ 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(
|
||||
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",
|
||||
}),
|
||||
)
|
||||
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",
|
||||
})
|
||||
|
||||
expect(GoogleVertexGemini.model).toBe(GoogleVertex.model)
|
||||
expect(gemini.route.id).toBe("google-vertex-gemini")
|
||||
@@ -486,13 +276,11 @@ 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(
|
||||
packageInput("gemini-3.5-flash", {
|
||||
accessToken: "fixture",
|
||||
location: "eu",
|
||||
project: "vertex-project",
|
||||
}),
|
||||
).route.endpoint.baseURL,
|
||||
GoogleVertex.model("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")
|
||||
@@ -522,11 +310,8 @@ describe("provider package entrypoints", () => {
|
||||
const Providers = await import("@opencode-ai/ai/providers")
|
||||
expect(() =>
|
||||
Reflect.apply(GoogleVertex.model, undefined, [
|
||||
packageInput("gemini-3.5-flash", {
|
||||
accessToken: "token",
|
||||
apiKey: "fixture",
|
||||
project: "vertex-project",
|
||||
}),
|
||||
"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, [
|
||||
@@ -535,7 +320,8 @@ 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, [
|
||||
packageInput("claude-sonnet-4-6", { apiKey: "fixture", project: "vertex-project" }),
|
||||
"claude-sonnet-4-6",
|
||||
{ apiKey: "fixture", project: "vertex-project" },
|
||||
]),
|
||||
).toThrow("Google Vertex Messages does not support API keys")
|
||||
expect(() =>
|
||||
@@ -545,7 +331,8 @@ describe("provider package entrypoints", () => {
|
||||
).toThrow("Google Vertex Messages does not support API keys")
|
||||
expect(() =>
|
||||
Reflect.apply(GoogleVertexChat.model, undefined, [
|
||||
packageInput("deepseek-ai/deepseek-v3.2-maas", { apiKey: "fixture", project: "vertex-project" }),
|
||||
"deepseek-ai/deepseek-v3.2-maas",
|
||||
{ apiKey: "fixture", project: "vertex-project" },
|
||||
]),
|
||||
).toThrow("Google Vertex Chat does not support API keys")
|
||||
expect(() =>
|
||||
@@ -555,7 +342,8 @@ describe("provider package entrypoints", () => {
|
||||
).toThrow("Google Vertex Chat does not support API keys")
|
||||
expect(() =>
|
||||
Reflect.apply(GoogleVertexResponses.model, undefined, [
|
||||
packageInput("xai/grok-4.20-reasoning", { apiKey: "fixture", project: "vertex-project" }),
|
||||
"xai/grok-4.20-reasoning",
|
||||
{ apiKey: "fixture", project: "vertex-project" },
|
||||
]),
|
||||
).toThrow("Google Vertex Responses does not support API keys")
|
||||
expect(() =>
|
||||
|
||||
@@ -105,4 +105,16 @@ describe("extractPromptFromMessage", () => {
|
||||
|
||||
expect(extractPromptFromMessage(message)[0]).toMatchObject({ type: "text", content: "model text" })
|
||||
})
|
||||
|
||||
test("restores command invocation text", () => {
|
||||
const message = {
|
||||
id: "msg_1",
|
||||
type: "user",
|
||||
text: "expanded command template",
|
||||
command: { name: "command", arguments: "input" },
|
||||
time: { created: 1 },
|
||||
} satisfies SessionMessageUser
|
||||
|
||||
expect(extractPromptFromMessage(message)[0]).toMatchObject({ type: "text", content: "/command input" })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -44,7 +44,9 @@ export function extractPromptFromMessage(
|
||||
message: SessionMessageUser,
|
||||
opts?: { directory?: string; attachmentName?: string },
|
||||
): Prompt {
|
||||
const text = readPromptPresentation(message.metadata)?.displayText ?? message.text
|
||||
const text = message.command
|
||||
? `/${message.command.name}${message.command.arguments ? ` ${message.command.arguments}` : ""}`
|
||||
: (readPromptPresentation(message.metadata)?.displayText ?? message.text)
|
||||
const directory = opts?.directory
|
||||
const attachmentName = opts?.attachmentName ?? "attachment"
|
||||
const toRelative = (path: string) => {
|
||||
|
||||
@@ -50,6 +50,18 @@ describe("session message presentation", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("projects command invocation text", () => {
|
||||
const message = {
|
||||
id: "msg_user",
|
||||
type: "user",
|
||||
text: "expanded command template",
|
||||
command: { name: "command", arguments: "input" },
|
||||
time: { created: 1 },
|
||||
} satisfies SessionMessageUser
|
||||
|
||||
expect(presentUserParts("ses_1", message)[0]).toMatchObject({ type: "text", text: "/command input" })
|
||||
})
|
||||
|
||||
test("projects current assistant content for existing DOM tools", () => {
|
||||
const message = {
|
||||
id: "msg_assistant",
|
||||
|
||||
@@ -57,7 +57,9 @@ export function presentUserMessage(
|
||||
|
||||
export function presentUserParts(sessionID: string, message: SessionMessageUser): Part[] {
|
||||
const presentation = readPromptPresentation(message.metadata)
|
||||
const text = presentation?.displayText ?? message.text
|
||||
const text = message.command
|
||||
? `/${message.command.name}${message.command.arguments ? ` ${message.command.arguments}` : ""}`
|
||||
: (presentation?.displayText ?? message.text)
|
||||
return [
|
||||
...(text ? [textPart(sessionID, message.id, 0, text)] : []),
|
||||
...(message.files ?? []).map(
|
||||
|
||||
@@ -30,6 +30,8 @@ export type FileDiffInfo = {
|
||||
status: "added" | "deleted" | "modified"
|
||||
}
|
||||
|
||||
export type PromptCommandInvocation = { name: string; arguments: string }
|
||||
|
||||
export type PromptBase64 = string
|
||||
|
||||
export type PromptFileSource = { type: "inline" } | { type: "uri"; uri: string }
|
||||
@@ -1684,6 +1686,7 @@ export type SessionMessageUser = {
|
||||
metadata?: { [x: string]: JsonValue }
|
||||
time: { created: number }
|
||||
text: string
|
||||
command?: PromptCommandInvocation
|
||||
files?: Array<PromptFileAttachment>
|
||||
agents?: Array<PromptAgentAttachment>
|
||||
skills?: Array<PromptSkillAttachment>
|
||||
@@ -1692,6 +1695,7 @@ export type SessionMessageUser = {
|
||||
|
||||
export type SessionInboxUserPayload = {
|
||||
text: string
|
||||
command?: PromptCommandInvocation
|
||||
files?: Array<PromptFileAttachment>
|
||||
agents?: Array<PromptAgentAttachment>
|
||||
skills?: Array<PromptSkillAttachment>
|
||||
@@ -1700,6 +1704,7 @@ export type SessionInboxUserPayload = {
|
||||
|
||||
export type SessionInboxUserPayload1 = {
|
||||
text: string
|
||||
command?: PromptCommandInvocation
|
||||
files?: Array<PromptFileAttachment>
|
||||
agents?: Array<PromptAgentAttachment>
|
||||
skills?: Array<PromptSkillAttachment>
|
||||
@@ -2552,6 +2557,7 @@ export type SessionImportInput = {
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly time: { readonly created: number }
|
||||
readonly text: string
|
||||
readonly command?: { readonly name: string; readonly arguments: string }
|
||||
readonly files?: ReadonlyArray<{
|
||||
readonly data: string
|
||||
readonly mime: string
|
||||
@@ -2821,6 +2827,7 @@ export type SessionImportInput = {
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly time: { readonly created: number }
|
||||
readonly text: string
|
||||
readonly command?: { readonly name: string; readonly arguments: string }
|
||||
readonly files?: ReadonlyArray<{
|
||||
readonly data: string
|
||||
readonly mime: string
|
||||
@@ -3090,6 +3097,7 @@ export type SessionImportInput = {
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly time: { readonly created: number }
|
||||
readonly text: string
|
||||
readonly command?: { readonly name: string; readonly arguments: string }
|
||||
readonly files?: ReadonlyArray<{
|
||||
readonly data: string
|
||||
readonly mime: string
|
||||
|
||||
@@ -20,48 +20,16 @@ export interface MapInput {
|
||||
export function map(input: MapInput): Mapping | undefined {
|
||||
const baseSettings = mapBaseSettings(input.settings)
|
||||
switch (input.packageName) {
|
||||
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 } : {}),
|
||||
}
|
||||
case "@ai-sdk/anthropic":
|
||||
return {
|
||||
package: "@opencode-ai/ai/providers/anthropic",
|
||||
settings: {
|
||||
...baseSettings,
|
||||
...mapAPIKey(input.settings),
|
||||
...(typeof input.settings.authToken === "string" ? { authToken: input.settings.authToken } : {}),
|
||||
...(Object.keys(providerOptions).length === 0 ? {} : { providerOptions }),
|
||||
...mapProviderOptions(input.settings, ["apiKey", "authToken", "baseURL"]),
|
||||
},
|
||||
...(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",
|
||||
@@ -103,7 +71,10 @@ 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),
|
||||
...mapGoogleOptions(
|
||||
input.settings,
|
||||
isStringRecord(input.settings.labels) ? { labels: input.settings.labels } : {},
|
||||
),
|
||||
},
|
||||
...(isStringRecord(input.settings.headers) ? { headers: input.settings.headers } : {}),
|
||||
}
|
||||
@@ -126,6 +97,29 @@ 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":
|
||||
@@ -140,6 +134,12 @@ 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,26 +260,17 @@ 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 } : {}),
|
||||
}
|
||||
return options
|
||||
if (Object.keys(options).length === 0) return {}
|
||||
return { providerOptions: options }
|
||||
}
|
||||
|
||||
function mapBaseSettings(settings: Readonly<Record<string, unknown>>) {
|
||||
@@ -292,7 +283,7 @@ function mapAPIKey(settings: Readonly<Record<string, unknown>>) {
|
||||
return typeof settings.apiKey === "string" ? { apiKey: settings.apiKey } : {}
|
||||
}
|
||||
|
||||
function mapGoogleOptions(settings: Readonly<Record<string, unknown>>) {
|
||||
function mapGoogleOptions(settings: Readonly<Record<string, unknown>>, extra: Readonly<Record<string, unknown>> = {}) {
|
||||
const input = settings.thinkingConfig
|
||||
const thinkingConfig = {
|
||||
...(isRecord(input) && typeof input.thinkingBudget === "number" ? { thinkingBudget: input.thinkingBudget } : {}),
|
||||
@@ -307,6 +298,7 @@ function mapGoogleOptions(settings: Readonly<Record<string, unknown>>) {
|
||||
...(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, ProviderPackage } from "@opencode-ai/ai"
|
||||
import { LanguageModel } 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 = Provider.mergeOverlay(resolved.settings, configuration) ?? {}
|
||||
const configured = { ...resolved.settings, ...credential?.metadata, ...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, {
|
||||
...legacyCredentialSettings(credential),
|
||||
...nativeCredentialSettings(resolved.package ?? "", credential),
|
||||
...credential?.metadata,
|
||||
...configuration,
|
||||
}) ?? {},
|
||||
@@ -157,18 +157,16 @@ 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({
|
||||
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 },
|
||||
},
|
||||
})
|
||||
const runtime = module.model(resolved.modelID ?? resolved.id, settings)
|
||||
return LanguageModel.update(runtime, {
|
||||
provider: resolved.providerID,
|
||||
compatibility: resolved.compatibility
|
||||
@@ -227,23 +225,25 @@ function unresolvedProviderVariables(model: Info, baseURL: string) {
|
||||
})
|
||||
}
|
||||
|
||||
const legacyCredentialSettings = (credential: Credential.Value | undefined) => {
|
||||
const nativeCredentialSettings = (specifier: string, 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 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 withoutNativeAuthSettings = (settings: Record<string, unknown>) => {
|
||||
const { accessToken: _accessToken, apiKey: _apiKey, authToken: _authToken, ...rest } = settings
|
||||
return rest
|
||||
}
|
||||
|
||||
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(input)`),
|
||||
cause: new Error(`Provider package ${specifier} does not export model(modelID, settings)`),
|
||||
})
|
||||
}
|
||||
return module as ProviderPackageDefinition
|
||||
|
||||
@@ -222,6 +222,7 @@ export interface Interface {
|
||||
id?: SessionMessage.ID
|
||||
sessionID: SessionSchema.ID
|
||||
text: string
|
||||
command?: Prompt["command"]
|
||||
files?: PromptInput.Prompt["files"]
|
||||
agents?: PromptInput.Prompt["agents"]
|
||||
skills?: PromptInput.Prompt["skills"]
|
||||
@@ -586,11 +587,7 @@ const layer = Layer.effect(
|
||||
return yield* Image.Service
|
||||
}).pipe(Effect.provide(locations.get(session.location)))
|
||||
const skills = Skill.Service.pipe(Effect.provide(locations.get(session.location)))
|
||||
const prompt = yield* resolvePrompt(
|
||||
{ text: input.text, files: input.files, agents: input.agents, skills: input.skills },
|
||||
image,
|
||||
skills,
|
||||
).pipe(Effect.provideService(FSUtil.Service, fs))
|
||||
const prompt = yield* resolvePrompt(input, image, skills).pipe(Effect.provideService(FSUtil.Service, fs))
|
||||
const messageID = input.id ?? SessionMessage.ID.create()
|
||||
const admittedInput = SessionInbox.Item.make({
|
||||
type: "user",
|
||||
@@ -657,6 +654,7 @@ const layer = Layer.effect(
|
||||
id: input.id,
|
||||
sessionID: input.sessionID,
|
||||
text: evaluated.text,
|
||||
command: { name: input.command, arguments: input.arguments ?? "" },
|
||||
files: input.files,
|
||||
agents: input.agents,
|
||||
skills: input.skills,
|
||||
@@ -964,7 +962,7 @@ function synthesizeTerminalShellInfo(started: ShellSchema.Info): ShellSchema.Inf
|
||||
}
|
||||
|
||||
const resolvePrompt = Effect.fn("Session.resolvePrompt")(function* (
|
||||
input: PromptInput.Prompt,
|
||||
input: PromptInput.Prompt & Pick<Prompt, "command">,
|
||||
image: Effect.Effect<Image.Interface>,
|
||||
skills: Effect.Effect<Skill.Interface>,
|
||||
) {
|
||||
@@ -987,7 +985,13 @@ const resolvePrompt = Effect.fn("Session.resolvePrompt")(function* (
|
||||
})
|
||||
})
|
||||
})
|
||||
return Prompt.make({ text: input.text, agents: input.agents, files, skills: selected?.length ? selected : undefined })
|
||||
return Prompt.fromUserMessage({
|
||||
text: input.text,
|
||||
command: input.command,
|
||||
agents: input.agents,
|
||||
files,
|
||||
skills: selected?.length ? selected : undefined,
|
||||
})
|
||||
})
|
||||
|
||||
const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024
|
||||
|
||||
@@ -20,6 +20,7 @@ import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { Worktree } from "@opencode-ai/schema/worktree"
|
||||
import { Project } from "@opencode-ai/schema/project"
|
||||
import { Prompt } from "@opencode-ai/schema/prompt"
|
||||
import { AbsolutePath, RelativePath } from "../schema.js"
|
||||
import type { SessionSchema } from "./schema.js"
|
||||
|
||||
@@ -526,17 +527,14 @@ const layer = Layer.effectDiscard(
|
||||
yield* insertMessage(
|
||||
db,
|
||||
event,
|
||||
input.type === "user"
|
||||
? {
|
||||
id: input.id,
|
||||
type: "user",
|
||||
metadata: input.payload.metadata,
|
||||
text: input.payload.text,
|
||||
files: input.payload.files,
|
||||
agents: input.payload.agents,
|
||||
skills: input.payload.skills,
|
||||
time: { created: DateTime.makeUnsafe(event.created) },
|
||||
}
|
||||
input.type === "user"
|
||||
? {
|
||||
...Prompt.fromUserMessage(input.payload),
|
||||
id: input.id,
|
||||
type: "user",
|
||||
metadata: input.payload.metadata,
|
||||
time: { created: DateTime.makeUnsafe(event.created) },
|
||||
}
|
||||
: {
|
||||
id: input.id,
|
||||
type: "synthetic",
|
||||
|
||||
@@ -22,12 +22,14 @@ 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,6 +66,10 @@ 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* () {
|
||||
@@ -77,22 +81,6 @@ 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,
|
||||
@@ -130,10 +118,6 @@ 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" })
|
||||
@@ -155,22 +139,11 @@ 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")
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -287,33 +260,25 @@ describe("ModelResolver", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
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" }),
|
||||
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" }),
|
||||
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.map((item) => item.authorization)).toEqual(["Bearer environment-key", "Bearer environment-key"])
|
||||
}),
|
||||
expect(headers.authorization).toBe("Bearer environment-key")
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("uses no native API-key auth for an explicitly enabled provider without credentials", () => {
|
||||
@@ -376,7 +341,7 @@ describe("ModelResolver", () => {
|
||||
})
|
||||
const layer = ModelResolver.layer.pipe(Layer.provide(Layer.mergeAll(catalog, integrations, npm, aisdk)))
|
||||
|
||||
return withEnv({ GOOGLE_GENERATIVE_AI_API_KEY: undefined }, () =>
|
||||
return withConfigEnv({}, () =>
|
||||
Effect.gen(function* () {
|
||||
const resolver = yield* ModelResolver.Service
|
||||
const resolved = yield* resolver.resolveModel(selected)
|
||||
@@ -397,7 +362,7 @@ describe("ModelResolver", () => {
|
||||
})
|
||||
|
||||
it.effect("keeps native provider environment auth strict when no API key is configured", () =>
|
||||
withEnv({ GOOGLE_GENERATIVE_AI_API_KEY: undefined }, () =>
|
||||
withConfigEnv({}, () =>
|
||||
Effect.gen(function* () {
|
||||
const resolved = yield* ModelResolver.fromCatalogModel(
|
||||
model(Provider.aisdk("@ai-sdk/google"), {
|
||||
@@ -623,43 +588,6 @@ 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(
|
||||
@@ -764,24 +692,6 @@ 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(
|
||||
@@ -868,18 +778,15 @@ describe("ModelResolver", () => {
|
||||
loadPackage: (specifier) => {
|
||||
expect(specifier).toBe("@opencode-ai/ai/providers/custom")
|
||||
return Effect.succeed({
|
||||
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 },
|
||||
},
|
||||
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 },
|
||||
})
|
||||
return LanguageModel.make({ id: input.id, provider: "package-provider", route: native.route })
|
||||
return LanguageModel.make({ id: modelID, provider: "package-provider", route: native.route })
|
||||
},
|
||||
})
|
||||
},
|
||||
@@ -890,7 +797,7 @@ describe("ModelResolver", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("passes OAuth credentials to native provider packages without interpreting them", () =>
|
||||
it.effect("maps OAuth credentials to native provider auth settings", () =>
|
||||
Effect.gen(function* () {
|
||||
const native = yield* ModelResolver.fromCatalogModel(
|
||||
model(Provider.aisdk("@ai-sdk/openai"), {
|
||||
@@ -905,23 +812,23 @@ describe("ModelResolver", () => {
|
||||
expires: Date.now() + 60_000,
|
||||
})
|
||||
const packages = [
|
||||
"@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",
|
||||
["@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"],
|
||||
] as const
|
||||
|
||||
yield* Effect.forEach(packages, (specifier) =>
|
||||
yield* Effect.forEach(packages, ([specifier, key]) =>
|
||||
ModelResolver.fromCatalogModel(model(specifier, { settings: { apiKey: "configured-key" } }), credential, {
|
||||
loadPackage: () =>
|
||||
Effect.succeed({
|
||||
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 })
|
||||
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 })
|
||||
},
|
||||
}),
|
||||
}),
|
||||
@@ -951,41 +858,41 @@ describe("ModelResolver", () => {
|
||||
"@ai-sdk/anthropic",
|
||||
"@opencode-ai/ai/providers/anthropic",
|
||||
{ thinking: { type: "adaptive", display: "summarized" }, effort: "high" },
|
||||
{ providerOptions: { thinking: { type: "adaptive", display: "summarized" }, effort: "high" } },
|
||||
{ thinking: { type: "adaptive", display: "summarized" }, effort: "high" },
|
||||
],
|
||||
[
|
||||
"@ai-sdk/openai-compatible",
|
||||
"@opencode-ai/ai/providers/openai-compatible",
|
||||
{ reasoningEffort: "high" },
|
||||
{ provider: "test-provider", providerOptions: { reasoningEffort: "high" } },
|
||||
{ reasoningEffort: "high" },
|
||||
],
|
||||
[
|
||||
"@ai-sdk/google",
|
||||
"@opencode-ai/ai/providers/google",
|
||||
{ thinkingConfig: { thinkingLevel: "high" } },
|
||||
{ providerOptions: { thinkingConfig: { thinkingLevel: "high" } } },
|
||||
{ thinkingConfig: { thinkingLevel: "high" } },
|
||||
],
|
||||
[
|
||||
"@ai-sdk/google-vertex",
|
||||
"@opencode-ai/ai/providers/google-vertex",
|
||||
{ thinkingConfig: { thinkingLevel: "high" } },
|
||||
{ providerOptions: { thinkingConfig: { thinkingLevel: "high" } } },
|
||||
{ thinkingConfig: { thinkingLevel: "high" } },
|
||||
],
|
||||
[
|
||||
"@openrouter/ai-sdk-provider",
|
||||
"@opencode-ai/ai/providers/openrouter",
|
||||
{ reasoning: { effort: "high" } },
|
||||
{ providerOptions: { reasoning: { effort: "high" } } },
|
||||
{ reasoning: { effort: "high" } },
|
||||
],
|
||||
[
|
||||
"@ai-sdk/xai",
|
||||
"@opencode-ai/ai/providers/xai",
|
||||
{ reasoningEffort: "high" },
|
||||
{ providerOptions: { reasoningEffort: "high" } },
|
||||
{ reasoningEffort: "high" },
|
||||
],
|
||||
] as const
|
||||
|
||||
yield* Effect.forEach(packages, ([catalogPackage, nativePackage, sourceOptions, mappedSettings]) =>
|
||||
yield* Effect.forEach(packages, ([catalogPackage, nativePackage, sourceOptions, providerOptions]) =>
|
||||
ModelResolver.fromCatalogModel(
|
||||
model(Provider.aisdk(catalogPackage), {
|
||||
modelID: "api-model",
|
||||
@@ -998,19 +905,17 @@ describe("ModelResolver", () => {
|
||||
loadPackage: (specifier) => {
|
||||
expect(specifier).toBe(nativePackage)
|
||||
return Effect.succeed({
|
||||
model: (input) => {
|
||||
expect(input.id).toBe("api-model")
|
||||
expect(input.settings).toMatchObject({
|
||||
model: (modelID, settings) => {
|
||||
expect(modelID).toBe("api-model")
|
||||
expect(settings).toMatchObject({
|
||||
apiKey: "secret",
|
||||
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: input.id, provider: "native-provider", route: native.route })
|
||||
return LanguageModel.make({ id: modelID, provider: "native-provider", route: native.route })
|
||||
},
|
||||
})
|
||||
},
|
||||
@@ -1034,7 +939,11 @@ 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"],
|
||||
@@ -1052,8 +961,7 @@ describe("ModelResolver", () => {
|
||||
loadPackage: (specifier) => {
|
||||
expect(specifier).toBe(nativePackage)
|
||||
return Effect.succeed({
|
||||
model: (input) =>
|
||||
LanguageModel.make({ id: input.id, provider: "native-provider", route: OpenAIChat.route }),
|
||||
model: (id) => LanguageModel.make({ id, provider: "native-provider", route: OpenAIChat.route }),
|
||||
})
|
||||
},
|
||||
loadAISDK: () => Effect.die(`AI SDK loader called for ${catalogPackage}`),
|
||||
@@ -1089,9 +997,10 @@ describe("ModelResolver", () => {
|
||||
loadPackage: (specifier) => {
|
||||
expect(specifier).toBe("@opencode-ai/ai/providers/google-vertex/messages")
|
||||
return Effect.succeed({
|
||||
model: (input) => {
|
||||
expect(input.id).toBe("claude-sonnet-4-6")
|
||||
expect(input.settings).toMatchObject({
|
||||
model: (modelID, settings) => {
|
||||
expect(modelID).toBe("claude-sonnet-4-6")
|
||||
expect(settings).toMatchObject({
|
||||
accessToken: "vertex-token",
|
||||
location: "eu",
|
||||
project: "vertex-project",
|
||||
providerOptions: {
|
||||
@@ -1099,8 +1008,7 @@ describe("ModelResolver", () => {
|
||||
effort: "high",
|
||||
},
|
||||
})
|
||||
expect(input.credential).toEqual({ type: "oauth", accessToken: "vertex-token" })
|
||||
return LanguageModel.make({ id: input.id, provider: "native-provider", route: native.route })
|
||||
return LanguageModel.make({ id: modelID, provider: "native-provider", route: native.route })
|
||||
},
|
||||
})
|
||||
},
|
||||
@@ -1127,16 +1035,16 @@ describe("ModelResolver", () => {
|
||||
{
|
||||
loadPackage: () =>
|
||||
Effect.succeed({
|
||||
model: (input) => {
|
||||
expect(input.defaults.headers).toEqual({
|
||||
model: (modelID, settings) => {
|
||||
expect(settings.headers).toEqual({
|
||||
"HTTP-Referer": "https://opencode.ai",
|
||||
"X-OpenRouter-Title": "Custom",
|
||||
})
|
||||
expect(input.defaults.body).toEqual({
|
||||
expect(settings.body).toEqual({
|
||||
transforms: ["middle-out"],
|
||||
provider: { sort: "price", only: ["anthropic"] },
|
||||
})
|
||||
return LanguageModel.make({ id: input.id, provider: "openrouter", route: OpenAIChat.route })
|
||||
return LanguageModel.make({ id: modelID, provider: "openrouter", route: OpenAIChat.route })
|
||||
},
|
||||
}),
|
||||
},
|
||||
|
||||
@@ -323,7 +323,11 @@ describe("SessionProjector", () => {
|
||||
const admitted = yield* SessionInbox.admit(db, bus, {
|
||||
id,
|
||||
sessionID,
|
||||
item: { type: "user", payload: { text: "promote me" }, delivery: "steer" },
|
||||
item: {
|
||||
type: "user",
|
||||
payload: { text: "expanded command template", command: { name: "command", arguments: "input" } },
|
||||
delivery: "steer",
|
||||
},
|
||||
})
|
||||
if (!admitted) return yield* Effect.die("Prompt admission failed")
|
||||
|
||||
@@ -337,7 +341,15 @@ describe("SessionProjector", () => {
|
||||
).toBeUndefined()
|
||||
expect(
|
||||
yield* db.select().from(SessionMessageTable).where(eq(SessionMessageTable.id, id)).get().pipe(Effect.orDie),
|
||||
).toMatchObject({ session_id: sessionID, type: "user", seq: event.durable?.seq })
|
||||
).toMatchObject({
|
||||
session_id: sessionID,
|
||||
type: "user",
|
||||
seq: event.durable?.seq,
|
||||
data: {
|
||||
text: "expanded command template",
|
||||
command: { name: "command", arguments: "input" },
|
||||
},
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -235,16 +235,18 @@ describe("Session.prompt", () => {
|
||||
const message = yield* session.prompt({
|
||||
sessionID,
|
||||
text: "Fix the failing tests",
|
||||
command: { name: "fix", arguments: "tests" },
|
||||
resume: false,
|
||||
})
|
||||
|
||||
expect(message.payload.text).toBe("Fix the failing tests")
|
||||
expect(message.payload.command).toEqual({ name: "fix", arguments: "tests" })
|
||||
expect(yield* session.messages({ sessionID })).toEqual([])
|
||||
expect(yield* admitted(message.id)).toMatchObject({
|
||||
id: message.id,
|
||||
sessionID,
|
||||
type: "user",
|
||||
payload: { text: "Fix the failing tests" },
|
||||
payload: { text: "Fix the failing tests", command: { name: "fix", arguments: "tests" } },
|
||||
delivery: "steer",
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -61,9 +61,16 @@ export const SkillAttachment = Schema.Struct({
|
||||
mention: PromptMention.pipe(optional),
|
||||
}).annotate({ identifier: "Prompt.SkillAttachment" })
|
||||
|
||||
export interface CommandInvocation extends Schema.Schema.Type<typeof CommandInvocation> {}
|
||||
export const CommandInvocation = Schema.Struct({
|
||||
name: Schema.String,
|
||||
arguments: Schema.String,
|
||||
}).annotate({ identifier: "Prompt.CommandInvocation" })
|
||||
|
||||
export interface Prompt extends Schema.Schema.Type<typeof Prompt> {}
|
||||
export const Prompt = Schema.Struct({
|
||||
text: Schema.String,
|
||||
command: CommandInvocation.pipe(optional),
|
||||
files: Schema.Array(FileAttachment).pipe(optional),
|
||||
agents: Schema.Array(AgentAttachment).pipe(optional),
|
||||
skills: Schema.Array(SkillAttachment).pipe(optional),
|
||||
@@ -72,9 +79,10 @@ export const Prompt = Schema.Struct({
|
||||
.pipe(
|
||||
statics((schema) => ({
|
||||
equivalence: Schema.toEquivalence(schema),
|
||||
fromUserMessage: (input: Pick<Prompt, "text" | "files" | "agents" | "skills">) =>
|
||||
fromUserMessage: (input: Pick<Prompt, "text" | "command" | "files" | "agents" | "skills">) =>
|
||||
schema.make({
|
||||
text: input.text,
|
||||
...(input.command === undefined ? {} : { command: input.command }),
|
||||
...(input.files === undefined ? {} : { files: input.files }),
|
||||
...(input.agents === undefined ? {} : { agents: input.agents }),
|
||||
...(input.skills === undefined ? {} : { skills: input.skills }),
|
||||
|
||||
@@ -72,10 +72,7 @@ export const LocationSwitched = Schema.Struct({
|
||||
export interface User extends Schema.Schema.Type<typeof User> {}
|
||||
export const User = Schema.Struct({
|
||||
...Base,
|
||||
text: Prompt.fields.text,
|
||||
files: Prompt.fields.files,
|
||||
agents: Prompt.fields.agents,
|
||||
skills: Prompt.fields.skills,
|
||||
...Prompt.fields,
|
||||
type: Schema.tag("user"),
|
||||
}).annotate({ identifier: "Session.Message.User" })
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { StreamCommit } from "./types"
|
||||
import { commandText } from "../util/command"
|
||||
|
||||
export function commandCommit(messageID: string | undefined, command: { name: string; arguments: string }): StreamCommit {
|
||||
return {
|
||||
kind: "system",
|
||||
source: "system",
|
||||
messageID,
|
||||
partID: "command",
|
||||
text: `→ Command "${commandText(command)}"`,
|
||||
phase: "start",
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
import { Locale } from "../util/locale"
|
||||
import { isCompactCommand, isExitCommand, isNewCommand } from "./prompt.shared"
|
||||
import type { FooterApi, FooterEvent, RunDelivery, RunPrompt } from "./types"
|
||||
import { commandCommit } from "./command.shared"
|
||||
|
||||
type Trace = {
|
||||
write(type: string, data?: unknown): void
|
||||
@@ -173,13 +174,16 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
|
||||
}
|
||||
|
||||
if (sent.mode !== "shell") {
|
||||
const commit = {
|
||||
kind: "user",
|
||||
text: sent.text,
|
||||
phase: "start",
|
||||
source: "system",
|
||||
messageID: sent.messageID,
|
||||
} as const
|
||||
const commit =
|
||||
sent.command && sent.command.source !== "skill"
|
||||
? commandCommit(sent.messageID, sent.command)
|
||||
: ({
|
||||
kind: "user",
|
||||
text: sent.text,
|
||||
phase: "start",
|
||||
source: "system",
|
||||
messageID: sent.messageID,
|
||||
} as const)
|
||||
input.trace?.write("ui.commit", commit)
|
||||
input.footer.append(commit)
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
resolveSessionInfo,
|
||||
} from "./runtime.boot"
|
||||
import { createRuntimeLifecycle } from "./runtime.lifecycle"
|
||||
import { commandCommit } from "./command.shared"
|
||||
import { cycleVariant, formatModelLabel, resolveVariant } from "./variant.shared"
|
||||
import type {
|
||||
LocalReplayRow,
|
||||
@@ -903,13 +904,17 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
||||
state.shown = true
|
||||
state.history.push({ ...prompt, delivery: undefined })
|
||||
if (prompt.mode !== "shell" && delivery === "steer") {
|
||||
rememberLocal({
|
||||
kind: "user",
|
||||
text: prompt.text,
|
||||
phase: "start",
|
||||
source: "system",
|
||||
messageID: prompt.messageID,
|
||||
})
|
||||
rememberLocal(
|
||||
prompt.command && prompt.command.source !== "skill"
|
||||
? commandCommit(prompt.messageID, prompt.command)
|
||||
: {
|
||||
kind: "user",
|
||||
text: prompt.text,
|
||||
phase: "start",
|
||||
source: "system",
|
||||
messageID: prompt.messageID,
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
admit: async (prompt, delivery, signal) => {
|
||||
@@ -1044,9 +1049,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
||||
admitted,
|
||||
)
|
||||
if (prompt.messageID) {
|
||||
state.localRows = state.localRows.filter(
|
||||
(row) => row.commit.kind !== "user" || row.commit.messageID !== prompt.messageID,
|
||||
)
|
||||
state.localRows = state.localRows.filter((row) => row.commit.messageID !== prompt.messageID)
|
||||
}
|
||||
// Shell and skill turns never send CLI file attachments; keep them
|
||||
// pending for the next prompt-shaped turn.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { SessionMessageInfo, SessionMessageUser } from "@opencode-ai/client/promise"
|
||||
import { promptCopy, promptSame } from "./prompt.shared"
|
||||
import type { RunInput, RunPrompt } from "./types"
|
||||
import { commandText } from "../util/command"
|
||||
|
||||
const LIMIT = 200
|
||||
|
||||
@@ -22,7 +23,7 @@ export type RunSession = {
|
||||
|
||||
function messagePrompt(message: SessionMessageUser): RunPrompt {
|
||||
return {
|
||||
text: message.text,
|
||||
text: message.command ? commandText(message.command) : message.text,
|
||||
parts: [
|
||||
...(message.files ?? []).map((file) => ({
|
||||
type: "file" as const,
|
||||
|
||||
@@ -17,6 +17,8 @@ import { createFragmentReconciler, fragmentRef, type FragmentReconciler } from "
|
||||
import { createSubagentTracker, toolCommit, toolFinalPhase } from "./stream-v2.subagent"
|
||||
import { normalizeTool, toolOutputText } from "./tool"
|
||||
import { toolDisplayContent } from "../util/tool-display"
|
||||
import { commandCommit } from "./command.shared"
|
||||
import { commandText } from "../util/command"
|
||||
import type {
|
||||
FooterApi,
|
||||
FooterView,
|
||||
@@ -186,7 +188,12 @@ function pendingPrompt(item: SessionInboxInfo): FooterQueuedPrompt | undefined {
|
||||
if (item.type !== "user") return undefined
|
||||
return {
|
||||
messageID: item.id,
|
||||
prompt: { messageID: item.id, text: item.payload.text, parts: [] },
|
||||
prompt: {
|
||||
messageID: item.id,
|
||||
text: item.payload.command ? commandText(item.payload.command) : item.payload.text,
|
||||
parts: [],
|
||||
command: item.payload.command,
|
||||
},
|
||||
delivery: item.delivery,
|
||||
}
|
||||
}
|
||||
@@ -655,9 +662,22 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||
state.messageIDs.add(message.id)
|
||||
if (!render) return
|
||||
if (reuseVisibleWait && waiting) return
|
||||
if (message.command) {
|
||||
write([
|
||||
commandCommit(message.id, message.command),
|
||||
...(message.skills ?? []).map((skill) => skillCommit(message.id, skill.name, skill.id)),
|
||||
])
|
||||
return
|
||||
}
|
||||
write([
|
||||
...(message.skills ?? []).map((skill) => skillCommit(message.id, skill.name, skill.id)),
|
||||
{ kind: "user", source: "system", text: message.text, phase: "start", messageID: message.id },
|
||||
{
|
||||
kind: "user",
|
||||
source: "system",
|
||||
text: message.text,
|
||||
phase: "start",
|
||||
messageID: message.id,
|
||||
},
|
||||
])
|
||||
return
|
||||
}
|
||||
@@ -947,15 +967,19 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||
const visible = state.messageIDs.has(event.data.inboxID)
|
||||
if (waiting || pending) state.messageIDs.add(event.data.inboxID)
|
||||
if (!waiting && pending && !visible) {
|
||||
write([
|
||||
{
|
||||
kind: "user",
|
||||
source: "system",
|
||||
text: pending.prompt.text,
|
||||
phase: "start",
|
||||
messageID: event.data.inboxID,
|
||||
},
|
||||
])
|
||||
write(
|
||||
pending.prompt.command
|
||||
? [commandCommit(event.data.inboxID, pending.prompt.command)]
|
||||
: [
|
||||
{
|
||||
kind: "user",
|
||||
source: "system",
|
||||
text: pending.prompt.text,
|
||||
phase: "start",
|
||||
messageID: event.data.inboxID,
|
||||
},
|
||||
],
|
||||
)
|
||||
}
|
||||
write([], { phase: "running", status: "waiting for assistant" })
|
||||
return
|
||||
@@ -968,15 +992,19 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||
if (event.data.delivery === "queue") return
|
||||
if (state.messageIDs.has(event.data.inboxID)) return
|
||||
state.messageIDs.add(event.data.inboxID)
|
||||
write([
|
||||
{
|
||||
kind: "user",
|
||||
source: "system",
|
||||
text: pending.prompt.text,
|
||||
phase: "start",
|
||||
messageID: event.data.inboxID,
|
||||
},
|
||||
])
|
||||
write(
|
||||
pending.prompt.command
|
||||
? [commandCommit(event.data.inboxID, pending.prompt.command)]
|
||||
: [
|
||||
{
|
||||
kind: "user",
|
||||
source: "system",
|
||||
text: pending.prompt.text,
|
||||
phase: "start",
|
||||
messageID: event.data.inboxID,
|
||||
},
|
||||
],
|
||||
)
|
||||
return
|
||||
}
|
||||
if (event.type === "session.inbox.cancelled") {
|
||||
|
||||
@@ -100,6 +100,7 @@ import {
|
||||
import { switchLabel } from "../../util/model"
|
||||
import { findMessageBoundary, messageNavigationSlack } from "./message-navigation"
|
||||
import { stringWidth } from "../../util/string-width"
|
||||
import { commandText } from "../../util/command"
|
||||
import { useArgs } from "../../context/args"
|
||||
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
|
||||
import { useSessionTabs } from "../../context/session-tabs"
|
||||
@@ -205,7 +206,11 @@ export function Session(props: { verticalTabsWidth: number }) {
|
||||
)
|
||||
const pendingDeliveries = createMemo(() => new Map(pendingUsers().map((item) => [item.id, item.delivery])))
|
||||
const queuedPrompts = createMemo(() =>
|
||||
pendingUsers().flatMap((item) => (item.delivery === "queue" ? [{ id: item.id, text: item.payload.text }] : [])),
|
||||
pendingUsers().flatMap((item) =>
|
||||
item.delivery === "queue"
|
||||
? [{ id: item.id, text: item.payload.command ? commandText(item.payload.command) : item.payload.text }]
|
||||
: [],
|
||||
),
|
||||
)
|
||||
const [composer, setComposer] = createStore({
|
||||
open: false,
|
||||
@@ -2178,7 +2183,29 @@ function UserMessage(props: { message: SessionMessageUser }) {
|
||||
backgroundColor={hover() ? theme.raise(theme.background.default) : theme.background.default}
|
||||
flexShrink={0}
|
||||
>
|
||||
<text fg={theme.text.default}>{props.message.text}</text>
|
||||
<Show when={!props.message.command}>
|
||||
<text fg={theme.text.default}>{props.message.text}</text>
|
||||
</Show>
|
||||
<Show when={props.message.command}>
|
||||
{(command) => (
|
||||
<box flexDirection="row" paddingTop={1} gap={1} flexWrap="wrap">
|
||||
<text fg={theme.text.default}>
|
||||
<span
|
||||
style={{
|
||||
bg: theme.hue.accent[mode() === "light" ? 700 : 200],
|
||||
fg: theme.background.default,
|
||||
bold: true,
|
||||
}}
|
||||
>
|
||||
{" command "}
|
||||
</span>
|
||||
<span style={{ bg: theme.raise(theme.background.default), fg: theme.text.subdued }}>
|
||||
{` ${commandText(command())} `}
|
||||
</span>
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={skills().length}>
|
||||
<box flexDirection="row" paddingTop={1} gap={1} flexWrap="wrap">
|
||||
<For each={skills()}>
|
||||
@@ -3612,7 +3639,10 @@ function recordValue(value: unknown): Record<string, unknown> | undefined {
|
||||
|
||||
function formatSessionTranscript(session: SessionInfo, messages: SessionMessageInfo[], thinking: boolean) {
|
||||
const body = messages.flatMap((message) => {
|
||||
if (message.type === "user") return [`## User\n\n${message.text}`]
|
||||
if (message.type === "user")
|
||||
return [
|
||||
`## User\n\n${message.command ? commandText(message.command) : message.text}`,
|
||||
]
|
||||
if (message.type === "shell")
|
||||
return [`## Shell\n\n\`\`\`\n$ ${message.command}\n${message.output?.output ?? ""}\n\`\`\``]
|
||||
if (message.type !== "assistant") return []
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export function commandText(command: { name: string; arguments: string }) {
|
||||
return `/${command.name}${command.arguments ? ` ${command.arguments}` : ""}`
|
||||
}
|
||||
@@ -103,6 +103,16 @@ describe("run session shared", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("uses presentation text for command history", () => {
|
||||
const out = createSession([
|
||||
userMessage("msg-user-1", "expanded command template", {
|
||||
command: { name: "command", arguments: "input" },
|
||||
}),
|
||||
])
|
||||
|
||||
expect(out.turns[0]?.prompt.text).toBe("/command input")
|
||||
})
|
||||
|
||||
test("dedupes consecutive history entries, drops blanks, and copies prompt parts", () => {
|
||||
const parts = [
|
||||
{
|
||||
|
||||
@@ -667,7 +667,10 @@ describe("V2 mini transport", () => {
|
||||
sessionID: "ses_1",
|
||||
timeCreated: 1,
|
||||
type: "user",
|
||||
payload: { text: "follow up" },
|
||||
payload: {
|
||||
text: "expanded command template",
|
||||
command: { name: "command", arguments: "input" },
|
||||
},
|
||||
delivery: "queue",
|
||||
},
|
||||
{
|
||||
@@ -707,7 +710,7 @@ describe("V2 mini transport", () => {
|
||||
while (!ui.commits.some((item) => item.messageID === "msg_queued")) await Bun.sleep(0)
|
||||
|
||||
expect(ui.commits).toContainEqual(
|
||||
expect.objectContaining({ kind: "user", messageID: "msg_queued", text: "follow up" }),
|
||||
expect.objectContaining({ kind: "system", messageID: "msg_queued", text: '→ Command "/command input"' }),
|
||||
)
|
||||
expect(pending()).toEqual([["msg_cancelled", "queue"]])
|
||||
events.push({
|
||||
|
||||
Reference in New Issue
Block a user