Compare commits

..

6 Commits

Author SHA1 Message Date
Kit Langton ca7f7c215e refactor(ai): centralize credential lowering 2026-08-19 16:15:45 -04:00
Kit Langton 930541010c fix(ai): preserve provider credential behavior 2026-08-19 14:46:54 -04:00
Kit Langton bb4b9b1ed4 test(core): provide native model credentials 2026-08-19 14:34:59 -04:00
Kit Langton f498cf26eb test(core): isolate provider auth config 2026-08-19 14:18:16 -04:00
Kit Langton 5141e60bf7 fix(ai): preserve credential precedence 2026-08-19 14:14:56 -04:00
Kit Langton 0fda7fe231 refactor(ai): simplify provider boundaries 2026-08-19 14:05:07 -04:00
663 changed files with 23008 additions and 23934 deletions
-5
View File
@@ -1,5 +0,0 @@
---
"@opencode-ai/core": patch
---
Prompt and synthetic inbox ID reuse is now idempotent: reusing an ID within the same Session succeeds and returns the first admission, ignoring the retried payload, metadata, and delivery mode. Previously reuse with a differing payload failed with a conflict. Cross-Session and cross-type reuse still fail, and control items keep their operation-specific conflict behavior.
@@ -1,5 +0,0 @@
---
"@opencode-ai/core": patch
---
Apply shared Session model-request preparation to transient generation.
-5
View File
@@ -1,5 +0,0 @@
---
"@opencode-ai/core": patch
---
Simplify interrupt continuation: the steer-scoped resume decision now lives in SessionExecution as a post-cleanup inbox check, and the run coordinator drops its continuation state machine. Wakes arriving during cancellation cleanup now restart a normal full drain, and interrupting an idle session with continue now resumes pending steering input. Recovery-applied moves now end with the same full wake as inbox-admitted moves, retrying any stranded inbox work at the new location. Interrupting with continue now also resumes a next-in-line control item: between-turn manual compaction and moves run under any drain scope, while queued prompts remain parked.
-5
View File
@@ -1,5 +0,0 @@
---
"@opencode-ai/core": patch
---
Title generation and compaction summaries now build their model requests through the shared session request boundary, gaining unsupported-media filtering and image bounds while explicitly opting out of session context hooks: plugins that shape the agent conversation do not observe title or compaction requests. Title requests gain the fork-aware session prompt cache key, and compaction summaries in forked sessions reuse the fork root's prompt cache key instead of the fork's own.
-37
View File
@@ -1,37 +0,0 @@
name: deploy-posts
on:
push:
branches:
- v2
paths:
- packages/posts/**
- bun.lock
- .github/workflows/deploy-posts.yml
workflow_dispatch:
concurrency:
group: deploy-posts-${{ github.ref_name }}
cancel-in-progress: false
permissions:
contents: read
jobs:
deploy:
if: github.repository == 'anomalyco/opencode' && github.ref_name == 'v2'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
- uses: ./.github/actions/setup-bun
- name: Build
working-directory: packages/posts
run: bun run build
- name: Deploy
working-directory: packages/posts
run: bun run deploy
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
+1 -1
View File
@@ -6,7 +6,7 @@ permissions:
on:
workflow_dispatch:
push:
branches: [dev, beta, v2]
branches: [dev, beta]
paths:
- "bun.lock"
- "package.json"
+1 -1
View File
@@ -176,7 +176,7 @@ const table = sqliteTable("session", {
- Keep durable events minimal: record irreducible new facts and do not repeat state derivable by folding the ordered aggregate history. Enrich projections and read models with previous or derived state when consumers need self-contained views.
- Keep durable prompt admission separate from model execution. `Session.prompt(...)` publishes `session.inbox.enqueued`, whose projection inserts one durable `session_inbox` row, before scheduling advisory `SessionExecution.wake(sessionID)` unless `resume: false` requests admit-only behavior. Delivery publishes `session.inbox.delivered`; its projection consumes the inbox row and inserts the visible message in the same transaction. `session_inbox` stores only unconsumed work.
- Reusing a Session ID adopts the existing Session. Reusing a user or synthetic inbox item ID is idempotent when Session and type match: the first admission wins and the retried payload, metadata, and delivery mode are ignored, whether the item is still pending or already delivered (reconciled from the projected message without retained enqueue history). Cross-Session or cross-type reuse fails. Control items keep their operation-specific conflict behavior.
- Reusing a Session ID adopts the existing Session. While a user or synthetic inbox item is pending, reusing its ID reconciles only when Session, type, complete payload, metadata, and delivery match; conflicting reuse fails. Once delivered, retry reconciliation for those message-producing items uses the projected message and does not require retained enqueue history or the original delivery mode. Control items keep their operation-specific conflict behavior.
- Keep `SessionExecution` process-global and Session-ID based. Its local implementation owns the process-local Session coordinator and discovers placement through `SessionStore` plus `LocationServiceMap.get(session.location)` only when a drain starts; no layer should take a Session ID. V2 interruption targets the active process-local ownership chain for that Session; interruption of a known but idle or locally unowned Session is a no-op, while the public API rejects an unknown Session.
- Keep `SessionRunner`, model resolution, tool registry, permissions, and filesystem Location-scoped. Omitted `Location.workspaceID` means implicit-local placement; explicit workspace identity remains reserved for future placement semantics.
- Preserve one explicit `llm.stream(request)` call per Physical Attempt and reload projected history before durable continuation. A logical Step may use generic pre-output retries, one full-context retry after continuation rejection, incomplete-stream continuation, or one overflow-compaction rebuild. Generic retries retain the logical step number and do not consume another agent-step allowance. Do not delegate orchestration to an in-memory tool loop.
+1246 -2400
View File
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-PuNZrtSgh5F3KpXSM+bd+rYQuyzwWd+wCOnMJSDS2Z0=",
"aarch64-linux": "sha256-RYy8ZRf59FE/3+gICjvsZv3ekQvn+DTZaT9jefbK+0g=",
"aarch64-darwin": "sha256-1AsDK8xNj3RlzX2efbuEDEwaOLAgjFYaEvk7EkQkh4w=",
"x86_64-darwin": "sha256-8ONeOu9UmM0GRxVeOO3Uhk1yAOuW6R8tqBYswOVEkME="
"x86_64-linux": "sha256-yYd6nV4YRnTYqf5W5T7oXNo7bSgr1Vzjhy7d5YjA5Vo=",
"aarch64-linux": "sha256-YVjpbil0QswVwi6NtVYFq3xCqpsfveG1chlNVCVI0MU=",
"aarch64-darwin": "sha256-CdL2mI84pawH2H5i9qu8A6IWbkmKOYHlJS+DI/Mafdw=",
"x86_64-darwin": "sha256-NtswwfU5WYv99bEmI4XeLwjhBGcS9ZMYLRo4MQRNtLo="
}
}
+8 -9
View File
@@ -15,7 +15,6 @@
"dev:stats": "bun sst shell --stage=production -- bun run --cwd packages/stats/app dev",
"dev:www": "bun run --cwd packages/www dev",
"dev:storybook": "bun --cwd packages/storybook storybook",
"bench:devex": "bun run --cwd packages/app test:bench:devex",
"lint": "oxlint",
"lint:effect-patterns": "ast-grep scan -c script/ast-grep/sgconfig.yml packages/util/src packages/core/src packages/server/src packages/protocol/src packages/cli/src",
"test:lint-rules": "ast-grep test -c script/ast-grep/sgconfig.yml",
@@ -38,19 +37,19 @@
"packages/slack"
],
"catalog": {
"@effect/opentelemetry": "4.0.0-rc.110",
"@effect/platform-node": "4.0.0-rc.110",
"@effect/platform-node-shared": "4.0.0-rc.110",
"@effect/sql-sqlite-bun": "4.0.0-rc.110",
"@effect/opentelemetry": "4.0.0-beta.107",
"@effect/platform-node": "4.0.0-beta.107",
"@effect/platform-node-shared": "4.0.0-beta.107",
"@effect/sql-sqlite-bun": "4.0.0-beta.107",
"@npmcli/arborist": "9.4.0",
"@types/bun": "1.3.13",
"@types/cross-spawn": "6.0.6",
"@octokit/rest": "22.0.0",
"@hono/standard-validator": "0.2.0",
"@hono/zod-validator": "0.4.2",
"@opentui/core": "0.5.6",
"@opentui/keymap": "0.5.6",
"@opentui/solid": "0.5.6",
"@opentui/core": "0.5.4",
"@opentui/keymap": "0.5.4",
"@opentui/solid": "0.5.4",
"@tanstack/solid-virtual": "3.13.32",
"@shikijs/stream": "4.2.0",
"@standard-schema/spec": "1.1.0",
@@ -72,7 +71,7 @@
"dompurify": "3.3.1",
"drizzle-kit": "1.0.0-rc.2",
"drizzle-orm": "1.0.0-rc.2",
"effect": "4.0.0-rc.110",
"effect": "4.0.0-beta.107",
"ai": "6.0.168",
"cross-spawn": "7.0.6",
"hono": "4.10.7",
+58 -5
View File
@@ -71,7 +71,7 @@ export const route = Route.make({
})
```
Route defaults are request-shaping defaults such as `headers`, `limits`, `generation`, `providerOptions`, and `http`. Endpoint host/query belongs on the route endpoint. Selected `LanguageModel` values carry only model id, provider id, and the configured route value. Model capability/catalog metadata lives outside this package; protocol support is enforced by request lowering and typed `AIError`s.
Route defaults are request-shaping defaults such as `headers`, `limits`, `generation`, `providerOptions`, and `http`. Endpoint host/query belongs on the route endpoint. Selected `LanguageModel` values carry model identity and the configured route; low-level callers may also attach model-specific defaults and compatibility metadata. Model capability/catalog metadata lives outside this package; protocol support is enforced by request lowering and typed `AIError`s.
The four-axis decomposition is the reason DeepSeek, TogetherAI, Cerebras, Baseten, Fireworks, and DeepInfra all reuse `OpenAIChat.protocol` verbatim — each provider deployment is a 5-15 line `Route.make(...)` call instead of a 300-400 line route clone. Bug fixes in one protocol propagate to every consumer of that protocol in a single commit.
@@ -88,7 +88,7 @@ For providers where the URL is derived from typed inputs (Azure resource name, B
Provider-facing APIs are configured facades over route values. Endpoint/auth/resource/API-version setup happens before model selection, and model selectors accept only a model or deployment id:
```ts
const openai = OpenAI.configure({ apiKey, baseURL })
const openai = OpenAI.configure({ apiKey, baseURL, store: false })
const model = openai.responses("gpt-4o-mini")
const azure = Azure.configure({ resourceName, apiKey, apiVersion: "v1" })
@@ -108,17 +108,22 @@ Keep provider facades small and explicit:
- Resolve `apiKey``Auth` with `AuthOptions.bearer(options, "<PROVIDER>_API_KEY")` (it honors an explicit `auth` override and falls back to `Auth.config(envVar)` so missing keys surface a typed `Authentication` error rather than a runtime crash).
- Use separate top-level facades for products with different required setup, such as `CloudflareAIGateway` and `CloudflareWorkersAI`.
Provider facades and model-derived `LLMRequest.providerOptions` are provider-specific, so expose typed native options flat at those boundaries. Provider package settings keep deployment configuration separate from their typed `providerOptions` field, except facades such as OpenAI whose settings are already unambiguous when flat. The selected `LanguageModel<Options>` carries request-option typing; the route decodes the flat runtime record. Keep provider metadata namespaced because replay may contain metadata from multiple layers.
`Provider.make(...)` remains available for simple static provider definitions, but new built-in providers should prefer plain configured facades unless a helper removes real duplication without adding runtime behavior.
### Provider Package Entrypoints
Catalog-selected native providers use package-like export paths from `@opencode-ai/ai`. They are internal entrypoints in one npm package, not separately published provider packages. Every entrypoint implements `ProviderPackage.Definition` and exposes `model(modelID, settings)`, where settings are serializable provider configuration plus common `headers`, `body`, and `limits` overlays.
Catalog-selected native providers use package-like export paths from `@opencode-ai/ai`. They are internal entrypoints in one npm package, not separately published provider packages. Every entrypoint implements `ProviderPackage.Definition` and exposes `model({ id, settings, credential, defaults })`. Core selects and refreshes the optional `key | oauth` credential; the provider package interprets it as route authentication. Serializable provider settings remain separate from common `headers`, `body`, and `limits` defaults.
```ts
import { model } from "@opencode-ai/ai/providers/openai/responses"
const selected = model("gpt-5", {
apiKey,
const selected = model({
id: "gpt-5",
settings: {},
credential: { type: "key", value: apiKey },
defaults: {},
})
```
@@ -126,6 +131,54 @@ Keep semantic APIs as separate entrypoints, such as OpenAI `chat` and `responses
Do not expose `Route` in provider package settings. Route composition stays an implementation detail behind `model(...)`.
### Folder layout
```
packages/ai/src/
schema/ canonical Schema model, split by concern
ids.ts branded IDs, literal types, ProviderMetadata
options.ts Generation/Provider/Http options, Limits, LanguageModel, cache policy
messages.ts content parts, Message, ToolDefinition, LLMRequest
events.ts Usage, individual events, LLMEvent, LLMResponse
errors.ts error reasons, AIError, ToolFailure
index.ts barrel
llm.ts request constructors and convenience helpers
route/
index.ts @opencode-ai/ai/route advanced barrel
client.ts Route.make + LLMClient.stream/generate
executor.ts RequestExecutor service + transport error mapping
protocol.ts Protocol type + Protocol.make
endpoint.ts Endpoint type + Endpoint.path
auth.ts Auth type + Auth.bearer / Auth.header / Auth.none
auth-options.ts ProviderAuthOption shape, AuthOptions.bearer, AtLeastOne helper
framing.ts Framing type + Framing.sse
transport/ transport implementations
index.ts Transport execution types + HttpTransport / WebSocketTransport namespaces
websocket-channel.ts generic sequential channel executor/driver contract
http.ts HttpTransport.httpJson — POST + framing
websocket.ts direct one-request channel executor + raw socket adapter
protocols/
shared.ts ProviderShared toolkit used inside protocol impls
openai-chat.ts protocol + route (compose OpenAIChat.protocol)
open-responses.ts provider-neutral Responses protocol baseline
open-responses-channel.ts provider-neutral Responses WebSocket transport factory
openai-responses.ts OpenAI tools/events and channel policy composed over OpenResponses
anthropic-messages.ts
gemini.ts
bedrock-converse.ts
bedrock-event-stream.ts framing for AWS event-stream binary frames
openai-compatible-chat.ts route that reuses OpenAIChat.protocol, no canonical URL
openai-compatible-responses.ts deployment adapter that reuses OpenResponses.protocol, no canonical URL
utils/ per-protocol helpers (auth, cache, media, tool-stream, ...)
providers/
openai-compatible.ts generic Chat helper + family model helpers
openai-compatible-responses.ts generic Responses helper
openai-compatible-profile.ts family defaults (deepseek, togetherai, ...)
azure.ts / amazon-bedrock.ts / cloudflare.ts / google.ts / xai.ts / openai.ts / anthropic.ts / openrouter.ts
tool.ts typed Tool.make helper
tool-runtime.ts narrow one-call typed tool dispatcher
```
The dependency arrow points down: `providers/*.ts` files import protocol routes and auth-option utilities; protocol modules import `endpoint`, `auth`, `framing`, and transport pieces. Protocols do not import provider facades. Lower-level modules know nothing about provider catalog metadata. `OpenAIResponses` composes the provider-neutral `OpenResponses` protocol; the baseline never imports the OpenAI extension.
### Shared protocol helpers
+64 -13
View File
@@ -34,8 +34,10 @@ Run `LLMClient.stream(request)` instead of `generate` when you want incremental
Use `Image.generate` with an image model for direct asset generation:
```ts
import { Image, ImageInput } from "@opencode-ai/ai"
import { Effect, Layer } from "effect"
import { Image, ImageClient, ImageInput } from "@opencode-ai/ai"
import { OpenAI } from "@opencode-ai/ai/providers"
import { RequestExecutor } from "@opencode-ai/ai/route"
const program = Effect.gen(function* () {
const response = yield* Image.generate({
@@ -52,6 +54,10 @@ const program = Effect.gen(function* () {
return response.images // GeneratedImage[] with owned bytes or a provider URL
})
const imageLayer = ImageClient.layer.pipe(Layer.provide(RequestExecutor.fetchLayer))
await Effect.runPromise(program.pipe(Effect.provide(imageLayer)))
```
Pass ordered image inputs to the same method for editing, composition, or image-conditioned generation:
@@ -199,7 +205,7 @@ The hosted result is represented as a provider-executed tool call and tool resul
- **`LLM.request({...})`** — build a provider-neutral `LLMRequest`. Accepts ergonomic inputs (`system: string`, `prompt: string`) that normalize into the canonical Schema classes.
- **`LLM.generate` / `LLM.stream`** — re-exported from `LLMClient` for one-import use.
- **`Message.user(...)` / `Message.assistant(...)` / `Message.tool(...)`** — message constructors from the canonical schema model.
- **`Message.system(...)` / `Message.user(...)` / `Message.assistant(...)` / `Message.tool(...)`** — message constructors from the canonical schema model. Top-level `request.system` is the initial prompt; a system message in history is a chronological operator update.
- **`LanguageModel.make(...)` / `ToolCallPart.make(...)` / `ToolResultPart.make(...)` / `ToolDefinition.make(...)`** — model and tool-related constructors from the canonical schema model.
- **`LLMEvent.is.*`** — typed guards (`is.textDelta`, `is.toolCall`, `is.finish`, …) for filtering streams.
- **`Image.generate({...})`** — generate images through a provider-neutral image request and response model.
@@ -305,18 +311,26 @@ const gateway = CloudflareAIGateway.configure({
}).model("workers-ai/@cf/meta/llama-3.1-8b-instruct")
```
Included providers: OpenAI, Anthropic, Google (Gemini), Google Vertex Gemini and Anthropic, Amazon Bedrock, Azure OpenAI, Cloudflare AI Gateway, Cloudflare Workers AI, GitHub Copilot, OpenRouter, xAI, Z.ai, plus generic OpenAI-compatible Chat and Responses entrypoints and an Anthropic Messages-compatible entrypoint.
Included providers: OpenAI, Anthropic, Google (Gemini), Google Vertex Gemini and Anthropic, Amazon Bedrock, Azure OpenAI, Cloudflare AI Gateway, Cloudflare Workers AI, OpenRouter, xAI, Z.ai, plus generic OpenAI-compatible Chat and Responses entrypoints and an Anthropic Messages-compatible entrypoint. GitHub Copilot remains a Core-owned AI SDK integration rather than an AI-package provider.
### Package-like entrypoints
Native catalog integrations load provider behavior through package-like entrypoints. These are export paths from the same `@opencode-ai/ai` npm package, not independently published packages. Each entrypoint exports the same `model(modelID, settings)` contract, and `settings` contains serializable provider configuration plus common `headers` and `body` overlays.
Native catalog integrations load provider behavior through package-like entrypoints. These are export paths from the same `@opencode-ai/ai` npm package, not independently published packages. Each entrypoint exports the same `model({ id, settings, credential, defaults })` contract. Core selects and refreshes the optional `key | oauth` credential, while the provider package interprets it as route authentication. Serializable provider settings remain separate from common `headers`, `body`, and `limits` defaults.
```ts
import { model } from "@opencode-ai/ai/providers/openai/responses"
const selected = model("gpt-5", {
apiKey: process.env.OPENAI_API_KEY,
headers: { "x-application": "opencode" },
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 },
},
})
```
@@ -340,30 +354,57 @@ Tuned Vertex Gemini deployments use model ids shaped like `endpoints/1234567890`
```ts
import { model } from "@opencode-ai/ai/providers/google-vertex/gemini"
model("gemini-3.5-flash", { project: "my-project", location: "global" })
model({
id: "gemini-3.5-flash",
settings: { project: "my-project", location: "global" },
defaults: {},
})
```
```ts
import { model } from "@opencode-ai/ai/providers/google-vertex/chat"
model("deepseek-ai/deepseek-v3.2-maas", { project: "my-project", location: "global" })
model({
id: "deepseek-ai/deepseek-v3.2-maas",
settings: { project: "my-project", location: "global" },
defaults: {},
})
```
```ts
import { model } from "@opencode-ai/ai/providers/google-vertex/responses"
model("xai/grok-4.20-reasoning", { project: "my-project", location: "global" })
model({
id: "xai/grok-4.20-reasoning",
settings: { project: "my-project", location: "global" },
defaults: {},
})
```
```ts
import { model } from "@opencode-ai/ai/providers/google-vertex/messages"
model("claude-sonnet-4-6", { project: "my-project", location: "global" })
model({
id: "claude-sonnet-4-6",
settings: { project: "my-project", location: "global" },
defaults: {},
})
```
Provider facades such as `OpenAI.configure(...).responses(...)` remain the direct application API. Package-like entrypoints are the self-similar loading contract used when a catalog selects behavior by export path.
Provider facades such as `OpenAI.configure(...).responses(...)` remain the direct application API. Package-like entrypoints are the self-similar loading contract used when a catalog selects behavior by export path. The entrypoints listed above implement that contract and are covered by `test/provider-package.test.ts`.
Other provider exports listed above remain direct facades until they explicitly implement the package-like contract. Exporting a provider facade does not implicitly make it a catalog-loadable provider package.
## How OpenCode uses this package
OpenCode does not call provider facades directly from the CLI or server. Core owns the integration:
1. `packages/core/src/model-resolver.ts` resolves catalog metadata and an active integration credential into a `LanguageModel`. Native package entrypoints expose `model({ id, settings, credential, defaults })`; catalog packages without a native mapping fall back through Core's AI SDK adapter.
2. `packages/core/src/session/model-request.ts` lowers Session state, instructions, tools, and plugin hooks into one canonical `LLMRequest`.
3. `packages/core/src/session/runner/llm.ts` calls the yielded `LLMClient.Service` once per physical attempt and persists provider-neutral `LLMEvent`s.
4. Core owns retries, continuation, compaction, permissions, durable tool execution, and Session history. None of that orchestration belongs in this package.
Title generation, compaction, standalone generation, and transient Session generation also build `LLMRequest`s and use the same `LLMClient.Service`. Core's `AISDK` adapter wraps remaining Vercel AI SDK models in executable routes so native and fallback providers present the same request and event model to callers.
This separation is intentional: `@opencode-ai/ai` owns one model call, provider protocols, and transport; Core owns the durable agent runtime.
## Provider options & HTTP overlays
@@ -376,6 +417,16 @@ Request options in order of stability:
Route/provider defaults are overridden by request-level values for each axis.
Provider-specific facades accept their own options directly because the provider is already known:
```ts
const model = OpenAI.configure({
apiKey,
store: false,
reasoningEffort: "high",
}).responses("gpt-5")
```
The selected model supplies the provider-specific option type, so per-request overrides stay flat while the canonical runtime request remains provider-neutral:
```ts
+6 -8
View File
@@ -17,13 +17,11 @@ import { OpenAI } from "@opencode-ai/ai/providers"
const apiKey = Config.redacted("OPENAI_API_KEY")
// 1. Pick a model. The provider helper records provider identity, protocol
// choice, capabilities, deployment options, authentication, and defaults.
// choice, deployment options, authentication, and defaults. Catalog capabilities
// remain application-owned and are not part of LanguageModel.
const model = OpenAI.configure({
apiKey,
generation: { maxTokens: 160 },
providerOptions: {
store: false,
},
}).model("gpt-4o-mini")
// 2. Build a provider-neutral request. This is useful when reusing one request
@@ -74,8 +72,8 @@ const streamText = LLM.stream(request).pipe(
Stream.runDrain,
)
// 5. Tools are typed with Effect Schema. Provider turns remain explicit:
// advertise definitions on the request, stream one turn, dispatch local calls,
// 5. Tools are typed with Effect Schema. Model calls remain explicit:
// advertise definitions on the request, stream one call, dispatch local calls,
// then persist/build follow-up history in the enclosing product flow.
const tools = {
get_weather: Tool.make({
@@ -102,7 +100,7 @@ const streamWithTools = Effect.gen(function* () {
console.log("tool result", event.name, dispatched.result)
// A durable agent would persist these messages before starting another
// raw model turn. This tutorial keeps the boundary visible instead.
// model call. This tutorial keeps the boundary visible instead.
const followUp = LLMRequest.update(request, {
messages: [
...request.messages,
@@ -195,7 +193,7 @@ const FakeAdapter = Route.make({
provider: "fake-echo",
protocol: FakeProtocol,
endpoint: Endpoint.path("/v1/echo", { baseURL: "https://fake.local" }),
auth: Auth.passthrough,
auth: Auth.none,
framing: Framing.sse,
})
+3
View File
@@ -37,6 +37,9 @@ export type {
LanguageModelOptions as ProviderLanguageModelOptions,
} from "./provider.js"
export type {
Credential as ProviderPackageCredential,
Defaults as ProviderPackageDefaults,
Definition as ProviderPackageDefinition,
ModelInput as ProviderPackageModelInput,
Settings as ProviderPackageSettings,
} from "./provider-package.js"
@@ -31,7 +31,6 @@ import { ToolStream } from "./utils/tool-stream.js"
const ADAPTER = "anthropic-messages"
export const DEFAULT_BASE_URL = "https://api.anthropic.com/v1"
export const PATH = "/messages"
export const DEFAULT_MAX_TOKENS = 32_000
export type ThinkingInput =
| {
@@ -234,7 +233,7 @@ export type AnthropicMessagesBody = Schema.Schema.Type<typeof AnthropicMessagesB
const AnthropicUsage = Schema.StructWithRest(
Schema.Struct({
input_tokens: optionalNull(Schema.Number),
input_tokens: Schema.optional(Schema.Number),
output_tokens: Schema.optional(Schema.Number),
cache_creation_input_tokens: optionalNull(Schema.Number),
cache_read_input_tokens: optionalNull(Schema.Number),
@@ -625,6 +624,7 @@ const resolveThinking = Effect.fn("AnthropicMessages.resolveThinking")(function*
const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (request: LLMRequest) {
const generation = request.generation
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
const outputLimit = request.model.defaults?.limits?.output ?? request.model.route.defaults.limits?.output ?? 4096
// Allocate the 4-breakpoint budget in invalidation order: tools → system →
// messages. Tools live highest in the cache hierarchy, so when callers
// over-mark we keep their tool hints and shed the message-tail ones first.
@@ -663,7 +663,7 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
tools,
tool_choice: toolChoice,
stream: true as const,
max_tokens: generation?.maxTokens ?? DEFAULT_MAX_TOKENS,
max_tokens: generation?.maxTokens ?? outputLimit,
temperature: generation?.temperature,
top_p: generation?.topP,
top_k: generation?.topK,
@@ -692,7 +692,7 @@ const mapFinishReason = (reason: string | null | undefined): FinishReason => {
// expose that subset through `output_tokens_details.thinking_tokens`.
const mapUsage = (usage: AnthropicUsage | undefined): Usage | undefined => {
if (!usage) return undefined
const nonCached = usage.input_tokens ?? undefined
const nonCached = usage.input_tokens
const cacheRead = usage.cache_read_input_tokens ?? undefined
const cacheWrite = usage.cache_creation_input_tokens ?? undefined
const inputTokens = ProviderShared.sumTokens(nonCached, cacheRead, cacheWrite)
+1 -3
View File
@@ -289,9 +289,7 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
if (message.role === "system") {
const part = yield* ProviderShared.wrappedSystemUpdate("Gemini", message)
const previous = contents.at(-1)
// Gemini rejects a continuation whose function-response turn carries extra
// parts, so an update after a tool result starts its own user turn.
if (previous?.role === "user" && !previous.parts.some((item) => "functionResponse" in item))
if (previous?.role === "user")
contents[contents.length - 1] = { role: "user", parts: [...previous.parts, { text: part.text }] }
else contents.push({ role: "user", parts: [{ text: part.text }] })
continue
+45 -104
View File
@@ -93,8 +93,6 @@ export const InputItem = Schema.Union([
Schema.Struct({ role: Schema.tag("developer"), content: Schema.String }),
Schema.Struct({ role: Schema.tag("user"), content: Schema.Array(OpenResponsesInputContent) }),
Schema.Struct({
type: Schema.tag("message"),
id: Schema.optionalKey(Schema.String),
role: Schema.tag("assistant"),
content: Schema.Array(OpenResponsesOutputText),
phase: Schema.optionalKey(MessagePhase),
@@ -103,7 +101,6 @@ export const InputItem = Schema.Union([
OpenResponsesItemReference,
Schema.Struct({
type: Schema.tag("function_call"),
id: Schema.optionalKey(Schema.String),
call_id: Schema.String,
name: Schema.String,
arguments: Schema.String,
@@ -118,8 +115,6 @@ type OpenResponsesInputItem = Schema.Schema.Type<typeof InputItem>
type LoweredInputItem =
| OpenResponsesInputItem
| {
readonly type: "message"
readonly id?: string
readonly role: "assistant"
readonly content: ReadonlyArray<{ readonly type: "output_text"; readonly text: string }>
readonly phase?: MessagePhase | null
@@ -133,6 +128,8 @@ type OpenResponsesReasoningInput = {
summary: Array<{ type: "summary_text"; text: string }>
encrypted_content?: string | null
}
type OpenResponsesReasoningReplay = Omit<OpenResponsesReasoningInput, "id">
export const Tool = Schema.Struct({
type: Schema.tag("function"),
name: Schema.String,
@@ -162,14 +159,6 @@ export const coreFields = {
tools: optionalArray(Tool),
tool_choice: Schema.optional(ToolChoice),
store: Schema.optional(Schema.Boolean),
metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)),
safety_identifier: Schema.optional(Schema.String),
stream_options: Schema.optional(
Schema.Struct({
include_obfuscation: Schema.optional(Schema.Boolean),
}),
),
top_logprobs: Schema.optional(Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 20 }))),
truncation: Schema.optional(OpenResponsesOptions.TruncationSchema),
service_tier: Schema.optional(OpenResponsesOptions.ServiceTierSchema),
prompt_cache_key: Schema.optional(Schema.String),
@@ -190,8 +179,6 @@ export const coreFields = {
parallel_tool_calls: Schema.optional(Schema.Boolean),
temperature: Schema.optional(Schema.Number),
top_p: Schema.optional(Schema.Number),
presence_penalty: Schema.optional(Schema.Number),
frequency_penalty: Schema.optional(Schema.Number),
}
const OpenResponsesBody = Schema.Struct({
@@ -301,20 +288,6 @@ export const Event = Schema.StructWithRest(
)
export type Event = Schema.Schema.Type<typeof Event>
const RefusalEvent = Schema.Union([
Schema.Struct({
type: Schema.tag("response.refusal.delta"),
item_id: Schema.String,
delta: Schema.String,
}),
Schema.Struct({
type: Schema.tag("response.refusal.done"),
item_id: Schema.String,
refusal: Schema.String,
}),
])
const isRefusalEvent = Schema.is(RefusalEvent)
export interface Extension {
readonly id: string
readonly name: string
@@ -380,42 +353,34 @@ export const lowerToolChoice = (protocolName: string, toolChoice: NonNullable<LL
tool: (toolName) => ({ type: "function" as const, name: toolName }),
})
const itemID = (providerMetadata: ProviderMetadata | undefined, providerMetadataKey: string) => {
const metadata = providerMetadata?.[providerMetadataKey]
return ProviderShared.isRecord(metadata) && typeof metadata.itemId === "string" && metadata.itemId.length > 0
? metadata.itemId
: undefined
}
const lowerToolCall = (part: ToolCallPart, providerMetadataKey: string): OpenResponsesInputItem => {
const id = itemID(part.providerMetadata, providerMetadataKey)
return {
type: "function_call",
...(id ? { id } : {}),
call_id: part.id,
name: part.name,
arguments: ProviderShared.encodeJson(part.input),
}
}
const lowerToolCall = (part: ToolCallPart): OpenResponsesInputItem => ({
type: "function_call",
call_id: part.id,
name: part.name,
arguments: ProviderShared.encodeJson(part.input),
})
const lowerReasoning = (part: ReasoningPart, providerMetadataKey: string): OpenResponsesReasoningInput | undefined => {
const metadata = part.providerMetadata?.[providerMetadataKey]
const id = itemID(part.providerMetadata, providerMetadataKey)
if (!ProviderShared.isRecord(metadata) || !id) return undefined
if (!ProviderShared.isRecord(metadata) || typeof metadata.itemId !== "string" || metadata.itemId.length === 0)
return undefined
const encryptedContent =
typeof metadata.reasoningEncryptedContent === "string" || metadata.reasoningEncryptedContent === null
? metadata.reasoningEncryptedContent
: undefined
return {
type: "reasoning",
id,
id: metadata.itemId,
summary: part.text.length > 0 ? [{ type: "summary_text", text: part.text }] : [],
encrypted_content: encryptedContent,
}
}
const hostedToolItemID = (part: ToolResultPart, providerMetadataKey: string) => {
return itemID(part.providerMetadata, providerMetadataKey)
const metadata = part.providerMetadata?.[providerMetadataKey]
return ProviderShared.isRecord(metadata) && typeof metadata.itemId === "string" && metadata.itemId.length > 0
? metadata.itemId
: undefined
}
const lowerMedia = Effect.fn("OpenResponses.lowerMedia")(function* (
@@ -500,26 +465,24 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
if (message.role === "assistant") {
const content: TextPart[] = []
const reasoningItems: Record<string, OpenResponsesReasoningInput> = {}
const reasoningItems: Record<string, OpenResponsesReasoningReplay> = {}
const reasoningReferences = new Set<string>()
const hostedToolReferences = new Set<string>()
const flushText = () => {
if (content.length === 0) return
const groups = content.reduce<
Array<{ id: string | undefined; phase: MessagePhase | null | undefined; parts: TextPart[] }>
>((groups, part) => {
const metadata = part.providerMetadata?.[providerMetadataKey]
const id = itemID(part.providerMetadata, providerMetadataKey)
const phase = ProviderShared.isRecord(metadata) ? messagePhase(metadata.phase, extension) : undefined
const group = groups.at(-1)
if (group && group.id === id && group.phase === phase) group.parts.push(part)
else groups.push({ id, phase, parts: [part] })
return groups
}, [])
const groups = content.reduce<Array<{ phase: MessagePhase | null | undefined; parts: TextPart[] }>>(
(groups, part) => {
const metadata = part.providerMetadata?.[providerMetadataKey]
const phase = ProviderShared.isRecord(metadata) ? messagePhase(metadata.phase, extension) : undefined
const group = groups.at(-1)
if (group && group.phase === phase) group.parts.push(part)
else groups.push({ phase, parts: [part] })
return groups
},
[],
)
input.push(
...groups.map((group) => ({
type: "message" as const,
...(group.id === undefined ? {} : { id: group.id }),
role: "assistant" as const,
content: group.parts.map((part) => ({ type: "output_text" as const, text: part.text })),
...(group.phase === undefined ? {} : { phase: group.phase }),
@@ -548,14 +511,19 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
existing.encrypted_content = reasoning.encrypted_content
continue
}
reasoningItems[reasoning.id] = reasoning
input.push(reasoning)
const replay = {
type: reasoning.type,
summary: reasoning.summary,
encrypted_content: reasoning.encrypted_content,
}
reasoningItems[reasoning.id] = replay
input.push(replay)
continue
}
if (part.type === "tool-call") {
flushText()
if (part.providerExecuted === true) continue
input.push(lowerToolCall(part, providerMetadataKey))
input.push(lowerToolCall(part))
continue
}
if (part.type === "tool-result" && part.providerExecuted === true) {
@@ -610,12 +578,6 @@ const lowerOptions = (request: LLMRequest) => {
return {
...(options.instructions ? { instructions: options.instructions } : {}),
...(options.store !== undefined ? { store: options.store } : {}),
...(options.metadata ? { metadata: options.metadata } : {}),
...(options.safetyIdentifier ? { safety_identifier: options.safetyIdentifier } : {}),
...(options.streamOptions?.includeObfuscation !== undefined
? { stream_options: { include_obfuscation: options.streamOptions.includeObfuscation } }
: {}),
...(options.topLogprobs !== undefined ? { top_logprobs: options.topLogprobs } : {}),
...(request.promptCacheKey ? { prompt_cache_key: request.promptCacheKey } : {}),
...(options.include ? { include: options.include } : {}),
...(options.reasoningEffort || options.reasoningSummary
@@ -665,8 +627,6 @@ export const fromRequestWithExtension = Effect.fn("OpenResponses.fromRequestWith
max_output_tokens: generation?.maxTokens,
temperature: generation?.temperature,
top_p: generation?.topP,
presence_penalty: generation?.presencePenalty,
frequency_penalty: generation?.frequencyPenalty,
...lowerOptions(request),
}
})
@@ -735,7 +695,7 @@ const onOutputTextDelta = (state: ParserState, event: Event, id: string): StepRe
if (!event.delta) return [state, NO_EVENTS]
const events: LLMEvent[] = []
const phase = state.messagePhases[id]
const metadata = providerMetadata(state, { itemId: id, ...(phase === undefined ? {} : { phase }) })
const metadata = phase === undefined ? undefined : providerMetadata(state, { phase })
const lifecycle = Lifecycle.textStart(state.lifecycle, events, id, metadata)
return [{ ...state, lifecycle: Lifecycle.textDelta(lifecycle, events, id, event.delta) }, events]
}
@@ -964,7 +924,7 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
state.lifecycle,
events,
item.id,
providerMetadata(state, { itemId: item.id, ...(phase === undefined ? {} : { phase }) }),
phase === undefined ? undefined : providerMetadata(state, { phase }),
),
messageItems,
messagePhases,
@@ -977,11 +937,7 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
if (!item.id || !item.call_id || !item.name) return [state, NO_EVENTS] satisfies StepResult
const tools = state.tools[item.id]
? state.tools
: ToolStream.start(state.tools, item.id, {
id: item.call_id,
name: item.name,
providerMetadata: providerMetadata(state, { itemId: item.id }),
})
: ToolStream.start(state.tools, item.id, { id: item.call_id, name: item.name })
const result =
item.arguments === undefined
? yield* ToolStream.finish(state.id, tools, item.id)
@@ -1032,19 +988,11 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
return [state, NO_EVENTS] satisfies StepResult
})
const onResponseFinish = Effect.fn("OpenResponses.onResponseFinish")(function* (state: ParserState, event: Event) {
// Some compatible providers omit output_item.done even after completing the response.
const pending =
event.type === "response.completed"
? yield* ToolStream.finishAll(state.id, state.tools)
: { tools: state.tools, events: NO_EVENTS }
const events: LLMEvent[] = [...pending.events]
const hasFunctionCall =
pending.events.some((event) => LLMEvent.is.toolCall(event) || LLMEvent.is.toolInputError(event)) ||
state.hasFunctionCall
const onResponseFinish = (state: ParserState, event: Event): StepResult => {
const events: LLMEvent[] = []
const lifecycle = Lifecycle.finish(state.lifecycle, events, {
reason: {
normalized: mapFinishReason(event, hasFunctionCall),
normalized: mapFinishReason(event, state.hasFunctionCall),
raw: event.response?.incomplete_details?.reason,
},
usage: mapUsage(event.response?.usage, state.providerMetadataKey),
@@ -1056,8 +1004,8 @@ const onResponseFinish = Effect.fn("OpenResponses.onResponseFinish")(function* (
})
: undefined,
})
return [{ ...state, lifecycle, hasFunctionCall, tools: pending.tools }, events] satisfies StepResult
})
return [{ ...state, lifecycle }, events]
}
// Build a single human-readable message from whatever the provider supplied.
// When both code and message are present, prefix the code so consumers see
@@ -1099,14 +1047,6 @@ export const step = (state: ParserState, event: Event) => {
: onOutputTextDone(state, event, event.item_id),
)
}
if (event.type === "response.refusal.delta" || event.type === "response.refusal.done") {
if (!isRefusalEvent(event)) return ProviderShared.eventError(state.id, `${event.type} is malformed`)
return Effect.succeed(
event.type === "response.refusal.delta"
? onOutputTextDelta(state, event, event.item_id)
: onOutputTextDone(state, { ...event, text: event.refusal }, event.item_id),
)
}
if (event.type === "response.reasoning.delta" || event.type === "response.reasoning_summary_text.delta") {
if (!event.item_id) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
return Effect.succeed(onReasoningDelta(state, event, event.item_id))
@@ -1134,7 +1074,8 @@ export const step = (state: ParserState, event: Event) => {
return ProviderShared.eventError(state.id, `${event.type} message is missing id`)
return onOutputItemDone(state, event)
}
if (event.type === "response.completed" || event.type === "response.incomplete") return onResponseFinish(state, event)
if (event.type === "response.completed" || event.type === "response.incomplete")
return Effect.succeed(onResponseFinish(state, event))
if (event.type === "response.failed") return providerError(state, event, `${state.name} response failed`)
if (event.type === "error")
return decodeKnownErrorEvent(event).pipe(
+2 -14
View File
@@ -28,7 +28,7 @@ import { ToolSchemaProjection } from "./utils/tool-schema.js"
import { ToolStream } from "./utils/tool-stream.js"
const ADAPTER = "openai-chat"
const RESERVED_REASONING_FIELDS = new Set(["role", "content", "refusal", "tool_calls"])
const RESERVED_REASONING_FIELDS = new Set(["role", "content", "tool_calls"])
export const DEFAULT_BASE_URL = "https://api.openai.com/v1"
export const PATH = "/chat/completions"
@@ -194,7 +194,6 @@ type OpenAIChatToolCallDelta = Schema.Schema.Type<typeof OpenAIChatToolCallDelta
const OpenAIChatDelta = Schema.StructWithRest(
Schema.Struct({
content: optionalNull(Schema.String),
refusal: optionalNull(Schema.String),
reasoning_content: optionalNull(Schema.String),
reasoning: optionalNull(Schema.String),
reasoning_text: optionalNull(Schema.String),
@@ -710,7 +709,6 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
const reasoning = reasoningDelta(delta, state.reasoningField)
const hasLateContent =
Boolean(delta?.content) ||
Boolean(delta?.refusal) ||
reasoning !== undefined ||
(Array.isArray(delta?.reasoning_details) && delta.reasoning_details.length > 0) ||
toolDeltas.some((tool) => Boolean(tool.id) || Boolean(tool.function?.name) || Boolean(tool.function?.arguments))
@@ -730,7 +728,7 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
else if (
reasoningDetailsObserved &&
!lifecycle.reasoning.has("reasoning-0") &&
(Boolean(delta?.content) || Boolean(delta?.refusal) || toolDeltas.length > 0)
(Boolean(delta?.content) || toolDeltas.length > 0)
)
lifecycle = Lifecycle.reasoningStart(lifecycle, events, "reasoning-0", deltaMetadata)
const reasoningEmitted = state.reasoningEmitted || lifecycle.reasoning.has("reasoning-0")
@@ -745,16 +743,6 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.content)
}
if (delta?.refusal) {
lifecycle = Lifecycle.reasoningEnd(
lifecycle,
events,
"reasoning-0",
reasoningMetadata(reasoningField, reasoningDetailsObserved ? state.reasoningDetails : undefined),
)
lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.refusal)
}
// Compatible providers may omit indexes. Prefer durable identity, then use
// batch position for parallel deltas or the latest call for sparse chunks.
for (const [position, tool] of toolDeltas.entries()) {
@@ -42,8 +42,6 @@ const OpenAIResponsesToolChoice = Schema.Union([
const OpenAIResponsesInputItem = Schema.Union([
Schema.Struct({
type: Schema.tag("message"),
id: Schema.optionalKey(Schema.String),
role: Schema.tag("assistant"),
content: Schema.Array(Schema.Struct({ type: Schema.tag("output_text"), text: Schema.String })),
phase: Schema.optionalKey(Schema.NullOr(OpenResponses.MessagePhase)),
@@ -1,19 +1,5 @@
import { Option, Schema } from "effect"
import type { LLMRequest } from "../../schema/index.js"
export const ReasoningEfforts = ["none", "minimal", "low", "medium", "high", "xhigh", "max"] as const
export type ReasoningEffort = (typeof ReasoningEfforts)[number] | (string & {})
export const ReasoningEffort = Schema.declare<ReasoningEffort>(
(value): value is ReasoningEffort => typeof value === "string",
{ title: "ReasoningEffort" },
)
export const TextVerbosities = ["low", "medium", "high"] as const
export type TextVerbosity = (typeof TextVerbosities)[number] | (string & {})
export const TextVerbosity = Schema.declare<TextVerbosity>(
(value): value is TextVerbosity => typeof value === "string",
{ title: "TextVerbosity" },
)
import { TextVerbosity, type LLMRequest } from "../../schema/index.js"
export const ResponseIncludables = [
"file_search_call.results",
@@ -33,6 +19,7 @@ export type ServiceTier = (typeof ServiceTiers)[number]
export const Truncations = ["auto", "disabled"] as const
export type Truncation = (typeof Truncations)[number]
export const ReasoningEffort = Schema.String
export const TextVerbositySchema = TextVerbosity
export const ResponseIncludableSchema = Schema.declare<ResponseIncludable>(
(value): value is ResponseIncludable => typeof value === "string",
@@ -47,17 +34,9 @@ export const AllowedTools = Schema.Struct({
})
export type AllowedTools = typeof AllowedTools.Type
export const StreamOptions = Schema.Struct({
includeObfuscation: Schema.optional(Schema.Boolean),
})
export const Options = Schema.Struct({
instructions: Schema.optional(Schema.String),
store: Schema.optional(Schema.Boolean),
metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)),
safetyIdentifier: Schema.optional(Schema.String),
streamOptions: Schema.optional(StreamOptions),
topLogprobs: Schema.optional(Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 20 }))),
reasoningEffort: Schema.optional(ReasoningEffort),
reasoningSummary: Schema.optional(Schema.Literals(["auto", "concise", "detailed"])),
include: Schema.optional(Schema.Array(ResponseIncludableSchema)),
@@ -1,9 +1,8 @@
import { ReasoningEfforts } from "../../schema/index.js"
import { OpenResponsesOptions } from "./open-responses-options.js"
export const OpenAIReasoningEfforts = OpenResponsesOptions.ReasoningEfforts
export type OpenAIReasoningEffort = OpenResponsesOptions.ReasoningEffort
export const OpenAITextVerbosities = OpenResponsesOptions.TextVerbosities
export type OpenAITextVerbosity = OpenResponsesOptions.TextVerbosity
export const OpenAIReasoningEfforts = ReasoningEfforts
export type OpenAIReasoningEffort = string
// Mirrors OpenAI's `ResponseIncludable` union from the official SDK. Keep this
// in lockstep with `openai-node/src/resources/responses/responses.ts`.
@@ -13,7 +12,7 @@ export const OpenAIServiceTiers = OpenResponsesOptions.ServiceTiers
export type OpenAIServiceTier = OpenResponsesOptions.ServiceTier
export const OpenAIReasoningEffort = OpenResponsesOptions.ReasoningEffort
export const OpenAITextVerbosity = OpenResponsesOptions.TextVerbosity
export const OpenAITextVerbosity = OpenResponsesOptions.TextVerbositySchema
export const OpenAIResponseIncludable = OpenResponsesOptions.ResponseIncludableSchema
export const OpenAIServiceTier = OpenResponsesOptions.ServiceTierSchema
+47 -3
View File
@@ -1,16 +1,60 @@
import { Auth } from "./route/auth.js"
import type { AuthOverride, RequiredApiKeyAuth } from "./route/auth-options.js"
import type { LanguageModel, ProviderOptions } from "./schema/index.js"
export interface Settings extends Readonly<Record<string, unknown>> {
readonly baseURL?: string
export interface Settings {}
export type Credential =
| {
readonly type: "key"
readonly value: string
readonly configuration?: Readonly<Record<string, unknown>>
}
| {
readonly type: "oauth"
readonly accessToken: string
}
export interface Defaults {
readonly headers?: Readonly<Record<string, string>>
readonly body?: Readonly<Record<string, unknown>>
readonly limits?: {
readonly context: number
readonly input?: number
readonly output: number
}
}
export interface ModelInput<ProviderSettings extends Settings = Settings> {
readonly id: string
readonly settings: ProviderSettings
readonly credential?: Credential
readonly defaults: Defaults
}
export const routeDefaults = (input: Defaults) => ({
headers: input.headers,
http: input.body === undefined ? undefined : { body: input.body },
limits: input.limits,
})
export const bearerCredentialValue = (input: Credential) => (input.type === "key" ? input.value : input.accessToken)
export const bearerAuthOption = (input: Credential): AuthOverride => ({ auth: Auth.bearer(bearerCredentialValue(input)) })
export const apiKeyOrBearerAuthOption = (
input: Credential,
competingKeyHeader: string,
): RequiredApiKeyAuth | AuthOverride =>
input.type === "key"
? { apiKey: input.value }
: { auth: Auth.remove(competingKeyHeader).andThen(Auth.bearer(input.accessToken)) }
export interface Definition<
ProviderSettings extends Settings = Settings,
Options extends ProviderOptions = ProviderOptions,
> {
readonly model: (modelID: string, settings: ProviderSettings) => LanguageModel<Options>
readonly model: (input: ModelInput<ProviderSettings>) => LanguageModel<Options>
}
export * as ProviderPackage from "./provider-package.js"
@@ -1,6 +1,6 @@
import { Auth } from "../route/auth.js"
import type { Route as RouteDef, RouteDefaultsInput } from "../route/client.js"
import type { ProviderPackage } from "../provider-package.js"
import { ProviderPackage } from "../provider-package.js"
import { OpenAIChat } from "../protocols/openai-chat.js"
import { OpenAIResponses } from "../protocols/openai-responses.js"
import { BedrockAuth, type Credentials } from "../protocols/utils/bedrock-auth.js"
@@ -79,28 +79,27 @@ export const configure = (input: Config = {}) => {
export const provider = configure()
const config = (settings: Settings): Config => {
if (settings.auth === "bearer" && settings.apiKey === undefined)
const config = (input: ProviderPackage.ModelInput<Settings>): Config => {
if (!input.credential && input.settings.auth === "bearer" && input.settings.apiKey === undefined)
throw new Error("Amazon Bedrock Mantle bearer auth requires apiKey")
if (settings.auth === "sigv4" && settings.apiKey !== undefined)
if (!input.credential && input.settings.auth === "sigv4" && input.settings.apiKey !== undefined)
throw new Error("Amazon Bedrock Mantle SigV4 auth does not accept apiKey")
return {
apiKey: settings.auth === "sigv4" ? undefined : settings.apiKey,
baseURL: settings.baseURL,
credentials: settings.credentials,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.providerOptions,
region: settings.region,
...ProviderPackage.routeDefaults(input.defaults),
apiKey: input.credential
? ProviderPackage.bearerCredentialValue(input.credential)
: input.settings.auth === "sigv4"
? undefined
: input.settings.apiKey,
baseURL: input.settings.baseURL,
credentials: input.settings.credentials,
providerOptions: input.settings.providerOptions,
region: input.settings.region,
}
}
export const chatModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
modelID,
settings,
) => configure(config(settings)).chat(modelID)
export const responsesModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
modelID,
settings,
) => configure(config(settings)).responses(modelID)
export const chatModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (input) =>
configure(config(input)).chat(input.id)
export const responsesModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (input) =>
configure(config(input)).responses(input.id)
export const model = chatModel
+15 -12
View File
@@ -1,6 +1,6 @@
import type { RouteDefaultsInput } from "../route/client.js"
import { Auth } from "../route/auth.js"
import type { ProviderPackage } from "../provider-package.js"
import { ProviderPackage } from "../provider-package.js"
import { ProviderID, type ModelID } from "../schema/index.js"
import * as BedrockConverse from "../protocols/bedrock-converse.js"
import type { BedrockCredentials } from "../protocols/bedrock-converse.js"
@@ -50,18 +50,21 @@ export const configure = (input: Config = {}) => {
}
export const provider = configure()
export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) => {
if (settings.auth === "bearer" && settings.apiKey === undefined)
export const model: ProviderPackage.Definition<Settings>["model"] = (input) => {
if (!input.credential && input.settings.auth === "bearer" && input.settings.apiKey === undefined)
throw new Error("Amazon Bedrock bearer auth requires apiKey")
if (settings.auth === "sigv4" && settings.apiKey !== undefined)
if (!input.credential && input.settings.auth === "sigv4" && input.settings.apiKey !== undefined)
throw new Error("Amazon Bedrock SigV4 auth does not accept apiKey")
return configure({
apiKey: settings.auth === "sigv4" ? undefined : settings.apiKey,
baseURL: settings.baseURL,
credentials: settings.credentials,
generation: settings.topP === undefined ? undefined : { topP: settings.topP },
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
region: settings.region,
}).model(modelID)
...ProviderPackage.routeDefaults(input.defaults),
apiKey: input.credential
? ProviderPackage.bearerCredentialValue(input.credential)
: input.settings.auth === "sigv4"
? undefined
: input.settings.apiKey,
baseURL: input.settings.baseURL,
credentials: input.settings.credentials,
generation: input.settings.topP === undefined ? undefined : { topP: input.settings.topP },
region: input.settings.region,
}).model(input.id)
}
@@ -1,4 +1,4 @@
import type { ProviderPackage } from "../provider-package.js"
import { ProviderPackage } from "../provider-package.js"
import { AnthropicMessages } from "../protocols/anthropic-messages.js"
import { Auth } from "../route/auth.js"
import type { ProviderAuthOption } from "../route/auth-options.js"
@@ -32,7 +32,9 @@ export const routes = [AnthropicMessages.route]
const auth = (input: ProviderAuthOption<"optional">) => {
if ("auth" in input && input.auth) return input.auth
return Auth.optional("apiKey" in input ? input.apiKey : undefined, "apiKey").pipe(Auth.header("x-api-key"))
return Auth.remove("authorization").andThen(
Auth.optional("apiKey" in input ? input.apiKey : undefined, "apiKey").pipe(Auth.header("x-api-key")),
)
}
export const configure = (input: Config) => {
@@ -57,20 +59,20 @@ export const provider = {
configure,
}
export const model: ProviderPackage.Definition<Settings, AnthropicMessages.ProviderOptionsInput>["model"] = (
modelID,
settings,
) => {
if (settings.apiKey !== undefined && settings.authToken !== undefined)
export const model: ProviderPackage.Definition<Settings, AnthropicMessages.ProviderOptionsInput>["model"] = (input) => {
if (!input.credential && input.settings.apiKey !== undefined && input.settings.authToken !== undefined)
throw new Error("Anthropic-compatible apiKey cannot be combined with authToken")
return configure({
...(settings.authToken === undefined ? { apiKey: settings.apiKey } : { auth: Auth.bearer(settings.authToken) }),
baseURL: settings.baseURL,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
provider: settings.provider,
providerOptions: settings.providerOptions,
}).model(modelID)
...ProviderPackage.routeDefaults(input.defaults),
...(input.credential
? ProviderPackage.apiKeyOrBearerAuthOption(input.credential, "x-api-key")
: input.settings.authToken === undefined
? { apiKey: input.settings.apiKey }
: { auth: Auth.bearer(input.settings.authToken) }),
baseURL: input.settings.baseURL,
provider: input.settings.provider,
providerOptions: input.settings.providerOptions,
}).model(input.id)
}
export * as AnthropicCompatible from "./anthropic-compatible.js"
+17 -15
View File
@@ -1,7 +1,7 @@
import type { RouteDefaultsInput } from "../route/client.js"
import { Auth } from "../route/auth.js"
import type { ProviderAuthOption } from "../route/auth-options.js"
import type { ProviderPackage } from "../provider-package.js"
import { ProviderPackage } from "../provider-package.js"
import { ProviderID, type ModelID } from "../schema/index.js"
import { AnthropicMessages } from "../protocols/anthropic-messages.js"
import { AnthropicCompatible } from "./anthropic-compatible.js"
@@ -31,9 +31,11 @@ export type Settings = ProviderPackage.Settings &
const auth = (options: ProviderAuthOption<"optional">) => {
if ("auth" in options && options.auth) return options.auth
return Auth.optional("apiKey" in options ? options.apiKey : undefined, "apiKey")
.orElse(Auth.config("ANTHROPIC_API_KEY"))
.pipe(Auth.header("x-api-key"))
return Auth.remove("authorization").andThen(
Auth.optional("apiKey" in options ? options.apiKey : undefined, "apiKey")
.orElse(Auth.config("ANTHROPIC_API_KEY"))
.pipe(Auth.header("x-api-key")),
)
}
export const configure = (input: Config = {}) => {
@@ -52,17 +54,17 @@ export const configure = (input: Config = {}) => {
}
export const provider = configure()
export const model: ProviderPackage.Definition<Settings, AnthropicMessages.ProviderOptionsInput>["model"] = (
modelID,
settings,
) => {
if (settings.apiKey !== undefined && settings.authToken !== undefined)
export const model: ProviderPackage.Definition<Settings, AnthropicMessages.ProviderOptionsInput>["model"] = (input) => {
if (!input.credential && input.settings.apiKey !== undefined && input.settings.authToken !== undefined)
throw new Error("Anthropic apiKey cannot be combined with authToken")
return configure({
...(settings.authToken === undefined ? { apiKey: settings.apiKey } : { auth: Auth.bearer(settings.authToken) }),
baseURL: settings.baseURL,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.providerOptions,
}).model(modelID)
...ProviderPackage.routeDefaults(input.defaults),
...(input.credential
? ProviderPackage.apiKeyOrBearerAuthOption(input.credential, "x-api-key")
: input.settings.authToken === undefined
? { apiKey: input.settings.apiKey }
: { auth: Auth.bearer(input.settings.authToken) }),
baseURL: input.settings.baseURL,
providerOptions: input.settings.providerOptions,
}).model(input.id)
}
+17 -15
View File
@@ -1,7 +1,7 @@
import { Auth } from "../route/auth.js"
import { type AtLeastOne, type ProviderAuthOption } from "../route/auth-options.js"
import type { Route as RouteDef, RouteDefaultsInput } from "../route/client.js"
import type { ProviderPackage } from "../provider-package.js"
import { ProviderPackage } from "../provider-package.js"
import { ProviderID, type ModelID } from "../schema/index.js"
import * as OpenAIChat from "../protocols/openai-chat.js"
import * as OpenAIResponses from "../protocols/openai-responses.js"
@@ -120,27 +120,29 @@ export const provider = {
configure,
}
const config = (settings: Settings): Config => {
const config = (input: ProviderPackage.ModelInput<Settings>): Config => {
const settings = input.settings
const configuration = input.credential?.type === "key" ? input.credential.configuration : undefined
const baseURL = settings.baseURL ?? (typeof configuration?.baseURL === "string" ? configuration.baseURL : undefined)
const resourceName =
settings.resourceName ?? (typeof configuration?.resourceName === "string" ? configuration.resourceName : undefined)
const common = {
apiKey: settings.apiKey,
...ProviderPackage.routeDefaults(input.defaults),
...(input.credential
? ProviderPackage.apiKeyOrBearerAuthOption(input.credential, "api-key")
: { apiKey: settings.apiKey }),
apiVersion: settings.apiVersion,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.providerOptions,
queryParams: settings.queryParams === undefined ? undefined : { ...settings.queryParams },
useDeploymentBasedUrls: settings.useDeploymentBasedUrls,
}
if (settings.baseURL !== undefined) return { ...common, baseURL: settings.baseURL }
if (settings.resourceName !== undefined) return { ...common, resourceName: settings.resourceName }
if (baseURL !== undefined) return { ...common, baseURL }
if (resourceName !== undefined) return { ...common, resourceName }
throw new Error("Azure requires resourceName or baseURL")
}
export const responsesModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
modelID,
settings,
) => configure(config(settings)).responses(modelID)
export const chatModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
modelID,
settings,
) => configure(config(settings)).chat(modelID)
export const responsesModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (input) =>
configure(config(input)).responses(input.id)
export const chatModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (input) =>
configure(config(input)).chat(input.id)
export const model = responsesModel
+11 -11
View File
@@ -1,4 +1,4 @@
import type { ProviderPackage } from "../provider-package.js"
import { ProviderPackage } from "../provider-package.js"
import { OpenAICompatibleChat } from "../protocols/openai-compatible-chat.js"
import type { RouteDefaultsInput } from "../route/client.js"
import { ProviderID, type ModelID } from "../schema/index.js"
@@ -68,15 +68,15 @@ export const provider = {
configure,
}
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) => {
if (settings.apiKey !== undefined) throw new Error("Google Vertex Chat does not support API keys")
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (input) => {
if (input.credential?.type === "key" || (!input.credential && input.settings.apiKey !== undefined))
throw new Error("Google Vertex Chat does not support API keys")
return configure({
accessToken: settings.accessToken,
baseURL: settings.baseURL,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
location: settings.location,
project: settings.project,
providerOptions: settings.providerOptions,
}).model(modelID)
...ProviderPackage.routeDefaults(input.defaults),
accessToken: input.credential?.type === "oauth" ? input.credential.accessToken : input.settings.accessToken,
baseURL: input.settings.baseURL,
location: input.settings.location,
project: input.settings.project,
providerOptions: input.settings.providerOptions,
}).model(input.id)
}
@@ -1,5 +1,5 @@
import { Effect, Schema, Struct } from "effect"
import type { ProviderPackage } from "../provider-package.js"
import { ProviderPackage } from "../provider-package.js"
import { AnthropicMessages } from "../protocols/anthropic-messages.js"
import { Auth } from "../route/auth.js"
import { Route, type RouteDefaultsInput } from "../route/client.js"
@@ -100,18 +100,15 @@ export const provider = {
configure,
}
export const model: ProviderPackage.Definition<Settings, AnthropicMessages.ProviderOptionsInput>["model"] = (
modelID,
settings,
) => {
if (settings.apiKey !== undefined) throw new Error("Google Vertex Messages does not support API keys")
export const model: ProviderPackage.Definition<Settings, AnthropicMessages.ProviderOptionsInput>["model"] = (input) => {
if (input.credential?.type === "key" || (!input.credential && input.settings.apiKey !== undefined))
throw new Error("Google Vertex Messages does not support API keys")
return configure({
accessToken: settings.accessToken,
baseURL: settings.baseURL,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
location: settings.location,
project: settings.project,
providerOptions: settings.providerOptions,
}).model(modelID)
...ProviderPackage.routeDefaults(input.defaults),
accessToken: input.credential?.type === "oauth" ? input.credential.accessToken : input.settings.accessToken,
baseURL: input.settings.baseURL,
location: input.settings.location,
project: input.settings.project,
providerOptions: input.settings.providerOptions,
}).model(input.id)
}
@@ -1,4 +1,4 @@
import type { ProviderPackage } from "../provider-package.js"
import { ProviderPackage } from "../provider-package.js"
import { OpenAICompatibleResponses } from "../protocols/openai-compatible-responses.js"
import type { RouteDefaultsInput } from "../route/client.js"
import { ProviderID, type ModelID } from "../schema/index.js"
@@ -70,18 +70,15 @@ export const provider = {
configure,
}
export const model: ProviderPackage.Definition<Settings, OpenResponsesProviderOptionsInput>["model"] = (
modelID,
settings,
) => {
if (settings.apiKey !== undefined) throw new Error("Google Vertex Responses does not support API keys")
export const model: ProviderPackage.Definition<Settings, OpenResponsesProviderOptionsInput>["model"] = (input) => {
if (input.credential?.type === "key" || (!input.credential && input.settings.apiKey !== undefined))
throw new Error("Google Vertex Responses does not support API keys")
return configure({
accessToken: settings.accessToken,
baseURL: settings.baseURL,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
location: settings.location,
project: settings.project,
providerOptions: settings.providerOptions,
}).model(modelID)
...ProviderPackage.routeDefaults(input.defaults),
accessToken: input.credential?.type === "oauth" ? input.credential.accessToken : input.settings.accessToken,
baseURL: input.settings.baseURL,
location: input.settings.location,
project: input.settings.project,
providerOptions: input.settings.providerOptions,
}).model(input.id)
}
@@ -69,9 +69,8 @@ const adc = (project?: string) => {
export const oauth = (input: OAuthOptions, project?: string) => {
if (input.accessToken !== undefined && input.auth !== undefined)
throw new Error("Google Vertex accessToken cannot be combined with auth")
if (input.auth) return input.auth
if (input.accessToken !== undefined) return Auth.bearer(input.accessToken)
return adc(project)
const auth = input.auth ?? (input.accessToken !== undefined ? Auth.bearer(input.accessToken) : adc(project))
return Auth.remove("x-goog-api-key").andThen(auth)
}
export * as GoogleVertexShared from "./google-vertex-shared.js"
+20 -12
View File
@@ -1,5 +1,5 @@
import { Effect } from "effect"
import type { ProviderPackage } from "../provider-package.js"
import { ProviderPackage } from "../provider-package.js"
import { Gemini } from "../protocols/gemini.js"
import { ProviderShared } from "../protocols/shared.js"
import { Auth } from "../route/auth.js"
@@ -94,7 +94,10 @@ const configuredRoute = (input: Config, modelID: string | ModelID) => {
return route.with({
...rest,
endpoint: { baseURL: endpoint },
auth: apiKey === undefined ? GoogleVertexShared.oauth(input, project) : Auth.header("x-goog-api-key", apiKey),
auth:
apiKey === undefined
? GoogleVertexShared.oauth(input, project)
: Auth.remove("authorization").andThen(Auth.header("x-goog-api-key", apiKey)),
})
}
@@ -111,16 +114,21 @@ export const provider = {
id,
configure,
}
export const model: ProviderPackage.Definition<Settings, GeminiProviderOptionsInput>["model"] = (modelID, settings) => {
if (settings.apiKey !== undefined && settings.accessToken !== undefined)
export const model: ProviderPackage.Definition<Settings, GeminiProviderOptionsInput>["model"] = (input) => {
if (!input.credential && input.settings.apiKey !== undefined && input.settings.accessToken !== undefined)
throw new Error("Google Vertex apiKey cannot be combined with accessToken or auth")
return configure({
...(settings.apiKey === undefined ? { accessToken: settings.accessToken } : { apiKey: settings.apiKey }),
baseURL: settings.baseURL,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
location: settings.location,
project: settings.project,
providerOptions: settings.providerOptions,
}).model(modelID)
...ProviderPackage.routeDefaults(input.defaults),
...(input.credential
? input.credential.type === "key"
? { apiKey: input.credential.value }
: { accessToken: input.credential.accessToken }
: input.settings.apiKey === undefined
? { accessToken: input.settings.accessToken }
: { apiKey: input.settings.apiKey }),
baseURL: input.settings.baseURL,
location: input.settings.location,
project: input.settings.project,
providerOptions: input.settings.providerOptions,
}).model(input.id)
}
+14 -11
View File
@@ -1,7 +1,7 @@
import type { RouteDefaultsInput } from "../route/client.js"
import { Auth } from "../route/auth.js"
import type { ProviderAuthOption } from "../route/auth-options.js"
import type { ProviderPackage } from "../provider-package.js"
import { ProviderPackage } from "../provider-package.js"
import { HttpOptions, ProviderID, mergeHttpOptions, type ModelID } from "../schema/index.js"
import { Gemini } from "../protocols/gemini.js"
import { GoogleImages } from "../protocols/google-images.js"
@@ -28,9 +28,11 @@ export interface Settings extends ProviderPackage.Settings {
const auth = (options: ProviderAuthOption<"optional">) => {
if ("auth" in options && options.auth) return options.auth
return Auth.optional("apiKey" in options ? options.apiKey : undefined, "apiKey")
.orElse(Auth.config("GOOGLE_GENERATIVE_AI_API_KEY"))
.pipe(Auth.header("x-goog-api-key"))
return Auth.remove("authorization").andThen(
Auth.optional("apiKey" in options ? options.apiKey : undefined, "apiKey")
.orElse(Auth.config("GOOGLE_GENERATIVE_AI_API_KEY"))
.pipe(Auth.header("x-goog-api-key")),
)
}
const configuredRoute = (input: Config) => {
@@ -57,13 +59,14 @@ export const configure = (input: Config = {}) => {
}
export const provider = configure()
export const model: ProviderPackage.Definition<Settings, Gemini.ProviderOptionsInput>["model"] = (modelID, settings) =>
export const model: ProviderPackage.Definition<Settings, Gemini.ProviderOptionsInput>["model"] = (input) =>
configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.providerOptions,
}).model(modelID)
...ProviderPackage.routeDefaults(input.defaults),
...(input.credential
? ProviderPackage.apiKeyOrBearerAuthOption(input.credential, "x-goog-api-key")
: { apiKey: input.settings.apiKey }),
baseURL: input.settings.baseURL,
providerOptions: input.settings.providerOptions,
}).model(input.id)
export const image = provider.image
@@ -1,4 +1,4 @@
import type { ProviderPackage } from "../provider-package.js"
import { ProviderPackage } from "../provider-package.js"
import { OpenAICompatibleResponses } from "../protocols/openai-compatible-responses.js"
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
import type { RouteDefaultsInput } from "../route/client.js"
@@ -46,15 +46,13 @@ export const provider = {
configure,
}
export const model: ProviderPackage.Definition<Settings, OpenResponsesProviderOptionsInput>["model"] = (
modelID,
settings,
) =>
export const model: ProviderPackage.Definition<Settings, OpenResponsesProviderOptionsInput>["model"] = (input) =>
configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
provider: settings.provider,
providerOptions: settings.providerOptions,
}).model(modelID)
...ProviderPackage.routeDefaults(input.defaults),
...(input.credential
? ProviderPackage.bearerAuthOption(input.credential)
: { apiKey: input.settings.apiKey }),
baseURL: input.settings.baseURL,
provider: input.settings.provider,
providerOptions: input.settings.providerOptions,
}).model(input.id)
+10 -9
View File
@@ -2,7 +2,7 @@ import { ProviderID, type ModelID } from "../schema/index.js"
import * as OpenAICompatibleChat from "../protocols/openai-compatible-chat.js"
import type { RouteDefaultsInput } from "../route/client.js"
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
import type { ProviderPackage } from "../provider-package.js"
import { ProviderPackage } from "../provider-package.js"
import { profiles, type OpenAICompatibleProfile } from "./openai-compatible-profile.js"
import type { OpenAIProviderOptionsInput } from "./openai-options.js"
@@ -68,15 +68,16 @@ export const provider = {
configure,
}
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) =>
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (input) =>
configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
provider: settings.provider,
providerOptions: settings.providerOptions,
}).model(modelID)
...ProviderPackage.routeDefaults(input.defaults),
...(input.credential
? ProviderPackage.bearerAuthOption(input.credential)
: { apiKey: input.settings.apiKey }),
baseURL: input.settings.baseURL,
provider: input.settings.provider,
providerOptions: input.settings.providerOptions,
}).model(input.id)
export const baseten = define(profiles.baseten)
export const cerebras = define(profiles.cerebras)
+5 -22
View File
@@ -1,37 +1,21 @@
import { mergeProviderOptions, type ProviderOptions } from "../schema/index.js"
import type { OpenResponsesOptionsInput } from "./open-responses-options.js"
import type { Options } from "../protocols/utils/open-responses-options.js"
export type { OpenAIResponseIncludable, OpenAIServiceTier } from "../protocols/utils/openai-options.js"
export type OpenAIOptionsInput = OpenResponsesOptionsInput
export type OpenAIConfigOptions = Options
export type OpenAIProviderOptionsInput = OpenAIOptionsInput
const definedEntries = (input: Record<string, unknown>) =>
Object.entries(input).filter((entry) => entry[1] !== undefined)
const openAIProviderOptions = (options: OpenAIOptionsInput | undefined): ProviderOptions | undefined => {
const result = Object.fromEntries(
definedEntries({
store: options?.store,
reasoningEffort: options?.reasoningEffort,
reasoningSummary: options?.reasoningSummary,
include: options?.include,
textVerbosity: options?.textVerbosity,
serviceTier: options?.serviceTier,
}),
)
if (Object.keys(result).length === 0) return undefined
return result
}
export const gpt5DefaultOptions = (
modelID: string,
options: { readonly textVerbosity?: boolean } = {},
): ProviderOptions | undefined => {
const id = modelID.toLowerCase()
if (!id.includes("gpt-5") || id.includes("gpt-5-chat") || id.includes("gpt-5-pro")) return undefined
return openAIProviderOptions({
return {
reasoningEffort: "medium",
reasoningSummary: "auto",
// GPT-5 reasoning models are configured stateless (`store: false`) by
@@ -44,14 +28,13 @@ export const gpt5DefaultOptions = (
options.textVerbosity === true && id.includes("gpt-5.") && !id.includes("codex") && !id.includes("-chat")
? "low"
: undefined,
})
}
}
export const openAIDefaultOptions = (
modelID: string,
options: { readonly textVerbosity?: boolean } = {},
): ProviderOptions | undefined =>
mergeProviderOptions(openAIProviderOptions({ store: false }), gpt5DefaultOptions(modelID, options))
): ProviderOptions | undefined => mergeProviderOptions({ store: false }, gpt5DefaultOptions(modelID, options))
export const withOpenAIOptions = <Options extends { readonly providerOptions?: OpenAIProviderOptionsInput }>(
modelID: string,
+53 -18
View File
@@ -1,10 +1,10 @@
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
import type { Route, RouteDefaultsInput } from "../route/client.js"
import type { ProviderPackage } from "../provider-package.js"
import { ProviderPackage } from "../provider-package.js"
import { HttpOptions, ProviderID, ToolDefinition, mergeHttpOptions, type ModelID } from "../schema/index.js"
import * as OpenAIChat from "../protocols/openai-chat.js"
import * as OpenAIResponses from "../protocols/openai-responses.js"
import { withOpenAIOptions, type OpenAIProviderOptionsInput } from "./openai-options.js"
import { withOpenAIOptions, type OpenAIConfigOptions, type OpenAIProviderOptionsInput } from "./openai-options.js"
import { OpenAIImages, type OpenAIImageString } from "../protocols/openai-images.js"
export type { OpenAIOptionsInput, OpenAIResponseIncludable } from "./openai-options.js"
@@ -17,11 +17,11 @@ export const routes = [OpenAIResponses.route, OpenAIChat.route]
// This provider facade wraps the lower-level Responses and Chat model factories
// with OpenAI-specific conveniences: typed options, API-key sugar, env fallback,
// and default option normalization.
export type Config = RouteDefaultsInput &
export type Config = Omit<RouteDefaultsInput, "providerOptions"> &
OpenAIConfigOptions &
ProviderAuthOption<"optional"> & {
readonly baseURL?: string
readonly queryParams?: Record<string, string>
readonly providerOptions?: OpenAIProviderOptionsInput
}
export interface ImageGenerationOptions {
@@ -57,13 +57,12 @@ export const imageGeneration = (options: ImageGenerationOptions = {}) =>
},
})
export interface Settings extends ProviderPackage.Settings {
export interface Settings extends ProviderPackage.Settings, OpenAIConfigOptions {
readonly apiKey?: string
readonly baseURL?: string
readonly organization?: string
readonly project?: string
readonly queryParams?: Readonly<Record<string, string>>
readonly providerOptions?: OpenAIProviderOptionsInput
}
const auth = (options: ProviderAuthOption<"optional">) => AuthOptions.bearer(options, "OPENAI_API_KEY")
@@ -73,6 +72,39 @@ const defaults = (input: Config) => {
return rest
}
const splitConfigOptions = <Input extends OpenAIConfigOptions>(input: Input) => {
const {
instructions,
store,
reasoningEffort,
reasoningSummary,
include,
textVerbosity,
serviceTier,
truncation,
allowedTools,
maxToolCalls,
parallelToolCalls,
...rest
} = input
return {
options: {
instructions,
store,
reasoningEffort,
reasoningSummary,
include,
textVerbosity,
serviceTier,
truncation,
allowedTools,
maxToolCalls,
parallelToolCalls,
},
rest,
}
}
const configuredRoute = <Body, Prepared>(route: Route<Body, Prepared>, input: Config) =>
route.with({
auth: auth(input),
@@ -82,7 +114,8 @@ const configuredRoute = <Body, Prepared>(route: Route<Body, Prepared>, input: Co
export const configure = (input: Config = {}) => {
const responsesRoute = configuredRoute(OpenAIResponses.route, input)
const chatRoute = configuredRoute(OpenAIChat.route, input)
const modelDefaults = defaults(input)
const split = splitConfigOptions(defaults(input))
const modelDefaults = { ...split.rest, providerOptions: split.options }
const responses = (id: string | ModelID) =>
responsesRoute
.with(withOpenAIOptions(id, modelDefaults, { textVerbosity: true }))
@@ -113,30 +146,32 @@ export const configure = (input: Config = {}) => {
export const provider = configure()
const config = (settings: Settings): Config => {
const config = (input: ProviderPackage.ModelInput<Settings>): Config => {
const settings = input.settings
const options = splitConfigOptions(settings).options
const headers = {
...(settings.organization === undefined ? {} : { "OpenAI-Organization": settings.organization }),
...(settings.project === undefined ? {} : { "OpenAI-Project": settings.project }),
...settings.headers,
...input.defaults.headers,
}
return {
apiKey: settings.apiKey,
...ProviderPackage.routeDefaults(input.defaults),
...(input.credential
? ProviderPackage.bearerAuthOption(input.credential)
: { apiKey: settings.apiKey }),
baseURL: settings.baseURL,
headers: Object.keys(headers).length === 0 ? undefined : headers,
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.providerOptions,
queryParams: settings.queryParams === undefined ? undefined : { ...settings.queryParams },
...options,
}
}
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) => {
return configure(config(settings)).responses(modelID)
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (input) => {
return configure(config(input)).responses(input.id)
}
export const chatModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
modelID,
settings,
) => configure(config(settings)).chat(modelID)
export const chatModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (input) =>
configure(config(input)).chat(input.id)
export const responses = provider.responses
export const chat = provider.chat
export const image = provider.image
+9 -11
View File
@@ -5,7 +5,7 @@ import { Framing } from "../route/framing.js"
import { Protocol } from "../route/protocol.js"
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
import { ProviderID, type CacheHint, type ModelID } from "../schema/index.js"
import type { ProviderPackage } from "../provider-package.js"
import { ProviderPackage } from "../provider-package.js"
import * as OpenAICompatibleProfiles from "./openai-compatible-profile.js"
import * as OpenAIChat from "../protocols/openai-chat.js"
import { newBreakpoints, ttlBucket } from "../protocols/utils/cache.js"
@@ -191,14 +191,12 @@ export const configure = (input: LanguageModelOptions = {}) => {
}
export const provider = configure()
export const model: ProviderPackage.Definition<Settings, OpenRouterProviderOptionsInput>["model"] = (
modelID,
settings,
) =>
export const model: ProviderPackage.Definition<Settings, OpenRouterProviderOptionsInput>["model"] = (input) =>
configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers,
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.providerOptions,
}).model(modelID)
...ProviderPackage.routeDefaults(input.defaults),
...(input.credential
? ProviderPackage.bearerAuthOption(input.credential)
: { apiKey: input.settings.apiKey }),
baseURL: input.settings.baseURL,
providerOptions: input.settings.providerOptions,
}).model(input.id)
+9 -8
View File
@@ -8,7 +8,7 @@ import * as OpenAIChat from "../protocols/openai-chat.js"
import * as OpenAIResponses from "../protocols/openai-responses.js"
import { XAIImages } from "../protocols/xai-images.js"
import type { OpenAIOptionsInput } from "./openai-options.js"
import type { ProviderPackage } from "../provider-package.js"
import { ProviderPackage } from "../provider-package.js"
export const id = ProviderID.make("xai")
@@ -95,14 +95,15 @@ export const configure = (input: LanguageModelOptions = {}) => {
}
export const provider = configure()
export const model: ProviderPackage.Definition<Settings, XAIProviderOptionsInput>["model"] = (modelID, settings) =>
export const model: ProviderPackage.Definition<Settings, XAIProviderOptionsInput>["model"] = (input) =>
configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers,
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.providerOptions,
}).model(modelID)
...ProviderPackage.routeDefaults(input.defaults),
...(input.credential
? ProviderPackage.bearerAuthOption(input.credential)
: { apiKey: input.settings.apiKey }),
baseURL: input.settings.baseURL,
providerOptions: input.settings.providerOptions,
}).model(input.id)
export const responses = provider.responses
export const chat = provider.chat
export const image = provider.image
+4
View File
@@ -16,6 +16,7 @@ import {
LLMRequest,
LLMResponse,
LanguageModel,
LanguageModelLimits,
LLMEvent,
InvalidProviderOutputReason,
ProviderID,
@@ -73,6 +74,7 @@ export type RouteRoutedLanguageModelInput = Omit<LanguageModel.Input, "route">
export interface RouteDefaults {
readonly headers?: Record<string, string>
readonly limits?: LanguageModelLimits
readonly generation?: GenerationOptions
readonly providerOptions?: ProviderOptions
readonly http?: HttpOptions
@@ -80,6 +82,7 @@ export interface RouteDefaults {
export interface RouteDefaultsInput {
readonly headers?: Record<string, string>
readonly limits?: LanguageModelLimits.Input
readonly generation?: GenerationOptions.Input
readonly providerOptions?: ProviderOptions
readonly http?: HttpOptions.Input
@@ -116,6 +119,7 @@ const mergeRouteDefaults = (base: RouteDefaults | undefined, patch: RouteDefault
...base,
...patch,
headers,
limits: patch.limits === undefined ? base?.limits : LanguageModelLimits.make(patch.limits),
generation: mergeGenerationOptions(generationOptions(base?.generation), generationOptions(patch.generation)),
providerOptions: mergeProviderOptions(base?.providerOptions, patch.providerOptions),
http: mergeHttpOptions(
+1 -2
View File
@@ -1,7 +1,6 @@
import { Schema } from "effect"
import { Tool } from "@opencode-ai/schema/tool"
import { ModelID, ProviderID, RouteID } from "./ids.js"
import { ProviderMetadata } from "./messages.js"
import { ModelID, ProviderID, ProviderMetadata, RouteID } from "./ids.js"
export const ProviderFailureClassification = Schema.Literals(["context-overflow", "payload-too-large"])
export type ProviderFailureClassification = typeof ProviderFailureClassification.Type
+2 -15
View File
@@ -1,21 +1,8 @@
import { Schema } from "effect"
import { LLM } from "@opencode-ai/schema/llm"
import { ContentBlockID, ToolCallID } from "./ids.js"
import {
Message,
ProviderMetadata,
ToolCallPart,
ToolOutput,
ToolResultPart,
ToolResultValue,
type ContentPart,
} from "./messages.js"
import { ContentBlockID, FinishReason, ProviderMetadata, ToolCallID } from "./ids.js"
import { Message, ToolCallPart, ToolOutput, ToolResultPart, ToolResultValue, type ContentPart } from "./messages.js"
import { ProviderFailureClassification } from "./errors.js"
export const FinishReason = LLM.FinishReason
export type FinishReason = Schema.Schema.Type<typeof FinishReason>
export { ProviderMetadata } from "./messages.js"
/**
* Token usage reported by an LLM provider.
*
+20
View File
@@ -1,4 +1,8 @@
import { Schema } from "effect"
import { ProviderMetadata } from "@opencode-ai/schema/ai"
import { LLM } from "@opencode-ai/schema/llm"
export { ProviderMetadata }
/** Stable string identifier for a protocol implementation. */
export const ProtocolID = Schema.String
@@ -22,3 +26,19 @@ export type ContentBlockID = Schema.Schema.Type<typeof ContentBlockID>
export const ToolCallID = Schema.String
export type ToolCallID = Schema.Schema.Type<typeof ToolCallID>
export const ReasoningEfforts = ["none", "minimal", "low", "medium", "high", "xhigh", "max"] as const
export const ReasoningEffort = Schema.String
export type ReasoningEffort = Schema.Schema.Type<typeof ReasoningEffort>
export const TextVerbosity = Schema.Literals(["low", "medium", "high"])
export type TextVerbosity = Schema.Schema.Type<typeof TextVerbosity>
export const MessageRole = Schema.Literals(["system", "user", "assistant", "tool"])
export type MessageRole = Schema.Schema.Type<typeof MessageRole>
export const FinishReason = LLM.FinishReason
export type FinishReason = Schema.Schema.Type<typeof FinishReason>
export const JsonSchema = Schema.Record(Schema.String, Schema.Unknown)
export type JsonSchema = Schema.Schema.Type<typeof JsonSchema>
+1 -9
View File
@@ -1,24 +1,16 @@
import { Schema } from "effect"
import { Tool } from "@opencode-ai/schema/tool"
import { JsonSchema, MessageRole, ProviderMetadata } from "./ids.js"
import {
CacheHint,
CachePolicy,
GenerationOptions,
HttpOptions,
JsonSchema,
LanguageModelSchema,
ProviderOptions,
} from "./options.js"
import { isRecord } from "../utils/record.js"
export const MessageRole = Schema.Literals(["system", "user", "assistant", "tool"])
export type MessageRole = Schema.Schema.Type<typeof MessageRole>
export const ProviderMetadata = Schema.Record(Schema.String, Schema.Record(Schema.String, Schema.Unknown)).annotate({
identifier: "LLM.ProviderMetadata",
})
export type ProviderMetadata = Schema.Schema.Type<typeof ProviderMetadata>
const systemPartSchema = Schema.Struct({
type: Schema.Literal("text"),
text: Schema.String,
+18 -4
View File
@@ -1,11 +1,8 @@
import { Schema } from "effect"
import { ModelID, ProviderID } from "./ids.js"
import { JsonSchema, ModelID, ProviderID } from "./ids.js"
import type { AnyRoute } from "../route/client.js"
import { isRecord } from "../utils/record.js"
export const JsonSchema = Schema.Record(Schema.String, Schema.Unknown)
export type JsonSchema = Schema.Schema.Type<typeof JsonSchema>
export const mergeJsonRecords = (
...items: ReadonlyArray<Record<string, unknown> | undefined>
): Record<string, unknown> | undefined => {
@@ -114,7 +111,22 @@ export const mergeGenerationOptions = (...items: ReadonlyArray<GenerationOptions
return Object.values(result).some((value) => value !== undefined) ? result : undefined
}
export class LanguageModelLimits extends Schema.Class<LanguageModelLimits>("LLM.LanguageModelLimits")({
context: Schema.optional(Schema.Number),
input: Schema.optional(Schema.Number),
output: Schema.optional(Schema.Number),
}) {}
export namespace LanguageModelLimits {
export type Input = LanguageModelLimits | ConstructorParameters<typeof LanguageModelLimits>[0]
/** Normalize model limit input into the canonical `LanguageModelLimits` class. */
export const make = (input: Input | undefined) =>
input instanceof LanguageModelLimits ? input : new LanguageModelLimits(input ?? {})
}
export class LanguageModelDefaults extends Schema.Class<LanguageModelDefaults>("LLM.LanguageModelDefaults")({
limits: Schema.optional(LanguageModelLimits),
generation: Schema.optional(GenerationOptions),
providerOptions: Schema.optional(ProviderOptions),
http: Schema.optional(HttpOptions),
@@ -124,6 +136,7 @@ export namespace LanguageModelDefaults {
export type Input =
| LanguageModelDefaults
| {
readonly limits?: LanguageModelLimits.Input
readonly generation?: GenerationOptions.Input
readonly providerOptions?: ProviderOptions
readonly http?: HttpOptions.Input
@@ -133,6 +146,7 @@ export namespace LanguageModelDefaults {
export const make = (input: Input) => {
if (input instanceof LanguageModelDefaults) return input
return new LanguageModelDefaults({
limits: input.limits === undefined ? undefined : LanguageModelLimits.make(input.limits),
generation: input.generation === undefined ? undefined : GenerationOptions.make(input.generation),
providerOptions: input.providerOptions,
http: input.http === undefined ? undefined : HttpOptions.make(input.http),
+55 -17
View File
@@ -81,8 +81,22 @@ OpenAI.configure({
}).responses("gpt-4.1-mini")
OpenAI.configure({
generation: { maxTokens: 100 },
providerOptions: { store: false },
store: false,
}).responses("gpt-4.1-mini")
OpenAI.model({
id: "gpt-5",
settings: {},
credential: { type: "key", value: "sk-test" },
defaults: { headers: { "x-test": "value" } },
})
OpenAI.model({
id: "gpt-5",
settings: {
// @ts-expect-error Common request defaults belong under input.defaults.
headers: { "x-test": "value" },
},
defaults: {},
})
// @ts-expect-error OpenAI model selectors only accept model ids.
OpenAI.configure({ apiKey: "sk-test" }).responses("gpt-4.1-mini", {})
@@ -97,7 +111,7 @@ OpenAI.configure({ bogus: true })
OpenAI.configure({ generation: { maxTokens: "many" } })
// @ts-expect-error provider-native options remain typed.
OpenAI.configure({ providerOptions: { store: "false" } })
OpenAI.configure({ store: "false" })
// @ts-expect-error auth is an override, so OpenAI rejects apiKey with auth.
OpenAI.configure({ apiKey: "sk-test", auth: Auth.bearer("oauth-token") })
@@ -145,8 +159,12 @@ Anthropic.configure({
}).model("claude-haiku")
// @ts-expect-error Anthropic model selectors only accept model ids.
Anthropic.configure({ apiKey: "anthropic-key" }).model("claude-haiku", {})
// @ts-expect-error Anthropic package settings accept only one auth source.
Anthropic.model("claude-sonnet-4-6", { apiKey: "anthropic-key", authToken: "anthropic-token" })
Anthropic.model({
id: "claude-sonnet-4-6",
// @ts-expect-error Anthropic package settings accept only one auth source.
settings: { apiKey: "anthropic-key", authToken: "anthropic-token" },
defaults: {},
})
// @ts-expect-error Enabled Anthropic thinking requires a token budget.
Anthropic.configure({ providerOptions: { thinking: { type: "enabled" } } })
// @ts-expect-error Anthropic thinking budgets must be numbers.
@@ -162,11 +180,15 @@ AnthropicCompatible.configure({
AnthropicCompatible.configure({ apiKey: "messages-key" })
// @ts-expect-error Anthropic-compatible model selectors only accept model ids.
AnthropicCompatible.configure({ baseURL: "https://messages.example.com/v1" }).model("compatible-model", {})
// @ts-expect-error Anthropic-compatible package settings accept only one auth source.
AnthropicCompatible.model("compatible-model", {
apiKey: "messages-key",
authToken: "messages-token",
baseURL: "https://messages.example.com/v1",
AnthropicCompatible.model({
id: "compatible-model",
// @ts-expect-error Anthropic-compatible package settings accept only one auth source.
settings: {
apiKey: "messages-key",
authToken: "messages-token",
baseURL: "https://messages.example.com/v1",
},
defaults: {},
})
Google.configure({ apiKey: "google-key" }).model("gemini-2.5-flash")
@@ -189,15 +211,23 @@ GoogleVertex.configure({ auth: Auth.bearer("vertex-token"), project: "project" }
GoogleVertex.configure({ apiKey: "vertex-key" }).model("gemini-3.5-flash", {})
// @ts-expect-error Vertex Gemini config accepts only one auth source.
GoogleVertex.configure({ accessToken: "vertex-token", apiKey: "vertex-key", project: "project" })
// @ts-expect-error Vertex Gemini package settings accept only one auth source.
GoogleVertex.model("gemini-3.5-flash", { accessToken: "vertex-token", apiKey: "vertex-key", project: "project" })
GoogleVertex.model({
id: "gemini-3.5-flash",
// @ts-expect-error Vertex Gemini package settings accept only one auth source.
settings: { accessToken: "vertex-token", apiKey: "vertex-key", project: "project" },
defaults: {},
})
GoogleVertexChat.configure({ accessToken: "vertex-token", project: "project" }).model("deepseek-ai/deepseek-v3.2-maas")
GoogleVertexChat.configure({ auth: Auth.bearer("vertex-token"), project: "project" }).model(
"deepseek-ai/deepseek-v3.2-maas",
)
// @ts-expect-error Vertex Chat package settings do not accept API keys.
GoogleVertexChat.model("deepseek-ai/deepseek-v3.2-maas", { apiKey: "vertex-key", project: "project" })
GoogleVertexChat.model({
id: "deepseek-ai/deepseek-v3.2-maas",
// @ts-expect-error Vertex Chat package settings do not accept API keys.
settings: { apiKey: "vertex-key", project: "project" },
defaults: {},
})
GoogleVertexChat.configure({ accessToken: "vertex-token", project: "project" }).model(
"deepseek-ai/deepseek-v3.2-maas",
// @ts-expect-error Vertex Chat model selectors only accept model ids.
@@ -214,8 +244,12 @@ GoogleVertexResponses.configure({ accessToken: "vertex-token", project: "project
GoogleVertexResponses.configure({ auth: Auth.bearer("vertex-token"), project: "project" }).model(
"xai/grok-4.20-reasoning",
)
// @ts-expect-error Vertex Responses package settings do not accept API keys.
GoogleVertexResponses.model("xai/grok-4.20-reasoning", { apiKey: "vertex-key", project: "project" })
GoogleVertexResponses.model({
id: "xai/grok-4.20-reasoning",
// @ts-expect-error Vertex Responses package settings do not accept API keys.
settings: { apiKey: "vertex-key", project: "project" },
defaults: {},
})
GoogleVertexResponses.configure({ accessToken: "vertex-token", project: "project" }).model(
"xai/grok-4.20-reasoning",
// @ts-expect-error Vertex Responses model selectors only accept model ids.
@@ -233,8 +267,12 @@ GoogleVertexMessages.configure({
project: "project",
providerOptions: { thinking: { type: "adaptive", display: "omitted" }, effort: "low" },
}).model("claude-sonnet-4-6")
// @ts-expect-error Vertex Messages package settings do not accept API keys.
GoogleVertexMessages.model("claude-sonnet-4-6", { apiKey: "vertex-key", project: "project" })
GoogleVertexMessages.model({
id: "claude-sonnet-4-6",
// @ts-expect-error Vertex Messages package settings do not accept API keys.
settings: { apiKey: "vertex-key", project: "project" },
defaults: {},
})
GoogleVertexMessages.configure({ auth: Auth.bearer("vertex-token"), project: "project" }).model("claude-sonnet-4-6")
GoogleVertexMessages.configure({ accessToken: "vertex-token", project: "project" }).model(
"claude-sonnet-4-6",
+25 -18
View File
@@ -17,25 +17,31 @@ describe("request option precedence", () => {
test("deep-merges provider option records and replaces arrays, primitives, and null", () => {
const merged = mergeProviderOptions(
{
include: ["route"],
metadata: { route: true, shared: "route" },
nullable: "route",
primitive: "route",
openai: {
include: ["route"],
metadata: { route: true, shared: "route" },
nullable: "route",
primitive: "route",
},
},
{
include: ["model"],
metadata: { model: true, shared: "model" },
nullable: null,
primitive: "model",
openai: {
include: ["model"],
metadata: { model: true, shared: "model" },
nullable: null,
primitive: "model",
},
},
{ metadata: { request: true }, primitive: false },
{ openai: { metadata: { request: true }, primitive: false } },
)
expect(merged).toEqual({
include: ["model"],
metadata: { route: true, model: true, request: true, shared: "model" },
nullable: null,
primitive: false,
openai: {
include: ["model"],
metadata: { route: true, model: true, request: true, shared: "model" },
nullable: null,
primitive: false,
},
})
})
@@ -270,20 +276,21 @@ describe("request option precedence", () => {
),
)
it.effect("uses the Anthropic default before call maxTokens", () =>
it.effect("uses model output limits after route limits and before call maxTokens", () =>
Effect.gen(function* () {
const route = AnthropicMessages.route.with({
endpoint: { baseURL: "https://api.anthropic.test/v1/" },
auth: Auth.header("x-api-key", "test"),
limits: { output: 128 },
})
const model = route.model({ id: "claude-sonnet-4-5" })
const model = route.model({ id: "claude-sonnet-4-5", defaults: { limits: { output: 64 } } })
const withoutMaxTokens = yield* compileRequest(LLM.request({ model, prompt: "Say hello.", cache: "none" }))
const withMaxTokens = yield* compileRequest(
LLM.request({ model, prompt: "Say hello.", cache: "none", generation: { maxTokens: 8_000 } }),
LLM.request({ model, prompt: "Say hello.", cache: "none", generation: { maxTokens: 32 } }),
)
expect(withoutMaxTokens.body.max_tokens).toBe(32_000)
expect(withMaxTokens.body.max_tokens).toBe(8_000)
expect(withoutMaxTokens.body.max_tokens).toBe(64)
expect(withMaxTokens.body.max_tokens).toBe(32)
}),
)
})
File diff suppressed because one or more lines are too long
@@ -1,100 +0,0 @@
{
"version": 1,
"metadata": {
"provider": "openai",
"protocol": "openai-responses",
"transport": "websocket",
"model": "gpt-5.5",
"tags": [
"prefix:openai-responses-websocket",
"provider:openai",
"protocol:openai-responses",
"transport:websocket",
"tool",
"continuation"
],
"name": "openai-responses-websocket/continues-a-tool-call-over-one-socket",
"recordedAt": "2026-08-20T00:00:00.000Z"
},
"interactions": [
{
"transport": "websocket",
"connection": {
"sequence": 0,
"url": "wss://api.openai.com/v1/responses",
"protocols": [],
"close": {
"code": 1000,
"reason": ""
}
},
"events": [
{
"direction": "client",
"kind": "text",
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Call get_weather once, then reply exactly: Paris is sunny.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"max_output_tokens\":50,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.created\",\"response\":{\"id\":\"resp_ws_tool_1\"}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_item.added\",\"item\":{\"type\":\"function_call\",\"id\":\"fc_ws_weather\",\"call_id\":\"call_ws_weather\",\"name\":\"get_weather\",\"arguments\":\"\"}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.function_call_arguments.delta\",\"item_id\":\"fc_ws_weather\",\"delta\":\"{\\\"city\\\":\\\"Paris\\\"}\"}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_item.done\",\"item\":{\"type\":\"function_call\",\"id\":\"fc_ws_weather\",\"call_id\":\"call_ws_weather\",\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.completed\",\"response\":{\"id\":\"resp_ws_tool_1\"}}"
},
{
"direction": "client",
"kind": "text",
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"type\":\"function_call_output\",\"call_id\":\"call_ws_weather\",\"output\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"max_output_tokens\":50,\"previous_response_id\":\"resp_ws_tool_1\",\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.created\",\"response\":{\"id\":\"resp_ws_tool_2\"}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_item.added\",\"item\":{\"type\":\"message\",\"id\":\"msg_ws_tool_2\",\"role\":\"assistant\",\"content\":[]}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_text.delta\",\"item_id\":\"msg_ws_tool_2\",\"delta\":\"Paris is sunny.\"}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_text.done\",\"item_id\":\"msg_ws_tool_2\",\"text\":\"Paris is sunny.\"}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_item.done\",\"item\":{\"type\":\"message\",\"id\":\"msg_ws_tool_2\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Paris is sunny.\"}]}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.completed\",\"response\":{\"id\":\"resp_ws_tool_2\"}}"
}
]
}
]
}
@@ -1,119 +0,0 @@
{
"version": 1,
"metadata": {
"provider": "openai",
"protocol": "openai-responses",
"transport": "websocket",
"model": "gpt-5.5",
"tags": [
"prefix:openai-responses-websocket",
"provider:openai",
"protocol:openai-responses",
"transport:websocket",
"reconnect",
"full-context"
],
"name": "openai-responses-websocket/reconstructs-full-context-after-reconnect",
"recordedAt": "2026-08-20T00:00:00.000Z"
},
"interactions": [
{
"transport": "websocket",
"connection": {
"sequence": 0,
"url": "wss://api.openai.com/v1/responses",
"protocols": [],
"close": {
"code": 1000,
"reason": ""
}
},
"events": [
{
"direction": "client",
"kind": "text",
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Follow the user's exact reply instruction.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Alpha.\"}]}],\"store\":false,\"max_output_tokens\":30,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.created\",\"response\":{\"id\":\"resp_ws_reconnect_1\"}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_item.added\",\"item\":{\"type\":\"message\",\"id\":\"msg_ws_reconnect_1\",\"role\":\"assistant\",\"content\":[]}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_text.delta\",\"item_id\":\"msg_ws_reconnect_1\",\"delta\":\"Alpha.\"}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_text.done\",\"item_id\":\"msg_ws_reconnect_1\",\"text\":\"Alpha.\"}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_item.done\",\"item\":{\"type\":\"message\",\"id\":\"msg_ws_reconnect_1\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Alpha.\"}]}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.completed\",\"response\":{\"id\":\"resp_ws_reconnect_1\"}}"
}
]
},
{
"transport": "websocket",
"connection": {
"sequence": 1,
"url": "wss://api.openai.com/v1/responses",
"protocols": [],
"close": {
"code": 1000,
"reason": ""
}
},
"events": [
{
"direction": "client",
"kind": "text",
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Follow the user's exact reply instruction.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Alpha.\"}]},{\"type\":\"message\",\"id\":\"msg_ws_reconnect_1\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Alpha.\"}]},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Beta.\"}]}],\"store\":false,\"max_output_tokens\":30,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.created\",\"response\":{\"id\":\"resp_ws_reconnect_2\"}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_item.added\",\"item\":{\"type\":\"message\",\"id\":\"msg_ws_reconnect_2\",\"role\":\"assistant\",\"content\":[]}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_text.delta\",\"item_id\":\"msg_ws_reconnect_2\",\"delta\":\"Beta.\"}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_text.done\",\"item_id\":\"msg_ws_reconnect_2\",\"text\":\"Beta.\"}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_item.done\",\"item\":{\"type\":\"message\",\"id\":\"msg_ws_reconnect_2\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Beta.\"}]}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.completed\",\"response\":{\"id\":\"resp_ws_reconnect_2\"}}"
}
]
}
]
}
@@ -1,129 +0,0 @@
{
"version": 1,
"metadata": {
"provider": "openai",
"protocol": "openai-responses",
"transport": "websocket",
"model": "gpt-5.5",
"tags": [
"prefix:openai-responses-websocket",
"provider:openai",
"protocol:openai-responses",
"transport:websocket",
"continuation",
"recovery"
],
"name": "openai-responses-websocket/recovers-from-explicit-continuation-rejection",
"recordedAt": "2026-08-20T00:00:00.000Z"
},
"interactions": [
{
"transport": "websocket",
"connection": {
"sequence": 0,
"url": "wss://api.openai.com/v1/responses",
"protocols": [],
"close": {
"code": 1000,
"reason": ""
}
},
"events": [
{
"direction": "client",
"kind": "text",
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Follow the user's exact reply instruction.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Ready.\"}]}],\"store\":false,\"max_output_tokens\":30,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.created\",\"response\":{\"id\":\"resp_ws_rejection_1\"}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_item.added\",\"item\":{\"type\":\"message\",\"id\":\"msg_ws_rejection_1\",\"role\":\"assistant\",\"content\":[]}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_text.delta\",\"item_id\":\"msg_ws_rejection_1\",\"delta\":\"Ready.\"}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_text.done\",\"item_id\":\"msg_ws_rejection_1\",\"text\":\"Ready.\"}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_item.done\",\"item\":{\"type\":\"message\",\"id\":\"msg_ws_rejection_1\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Ready.\"}]}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.completed\",\"response\":{\"id\":\"resp_ws_rejection_1\"}}"
}
]
},
{
"transport": "websocket",
"connection": {
"sequence": 1,
"url": "wss://api.openai.com/v1/responses",
"protocols": [],
"close": {
"code": 1000,
"reason": ""
}
},
"events": [
{
"direction": "client",
"kind": "text",
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Recovered.\"}]}],\"store\":false,\"max_output_tokens\":30,\"previous_response_id\":\"resp_ws_rejection_1\",\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"error\",\"error\":{\"code\":\"previous_response_not_found\",\"message\":\"Previous response not found\"}}"
},
{
"direction": "client",
"kind": "text",
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Follow the user's exact reply instruction.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Ready.\"}]},{\"type\":\"message\",\"id\":\"msg_ws_rejection_1\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Ready.\"}]},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Recovered.\"}]}],\"store\":false,\"max_output_tokens\":30,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.created\",\"response\":{\"id\":\"resp_ws_rejection_2\"}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_item.added\",\"item\":{\"type\":\"message\",\"id\":\"msg_ws_rejection_2\",\"role\":\"assistant\",\"content\":[]}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_text.delta\",\"item_id\":\"msg_ws_rejection_2\",\"delta\":\"Recovered.\"}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_text.done\",\"item_id\":\"msg_ws_rejection_2\",\"text\":\"Recovered.\"}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_item.done\",\"item\":{\"type\":\"message\",\"id\":\"msg_ws_rejection_2\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Recovered.\"}]}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.completed\",\"response\":{\"id\":\"resp_ws_rejection_2\"}}"
}
]
}
]
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2
View File
@@ -121,6 +121,7 @@ describe("llm constructors", () => {
const model = chatRoute.model({
id: "kimi-k2",
defaults: {
limits: { context: 128_000, output: 8_192 },
generation: { maxTokens: 1_024, stop: ["END"] },
providerOptions: { parallelToolCalls: false },
http: { body: { extra_body: true } },
@@ -129,6 +130,7 @@ describe("llm constructors", () => {
})
const request = LLM.request({ model, prompt: "Say hello." })
expect(request.model.defaults?.limits).toEqual({ context: 128_000, output: 8_192 })
expect(request.model.defaults?.generation).toEqual({ maxTokens: 1_024, stop: ["END"] })
expect(request.model.defaults?.providerOptions).toEqual({ parallelToolCalls: false })
expect(request.model.defaults?.http).toEqual({ body: { extra_body: true } })
@@ -4,11 +4,10 @@ import { GoogleVertexResponses } from "../../src/providers.js"
const model = GoogleVertexResponses.configure({ accessToken: "test", project: "project" }).model("gemini")
LLM.request({ model, prompt: "Hello", providerOptions: { textVerbosity: "high" } })
LLM.request({ model, prompt: "Hello", providerOptions: { textVerbosity: "verbose" } })
LLM.request({
model,
prompt: "Hello",
// @ts-expect-error Vertex Responses verbosity must be a string.
providerOptions: { textVerbosity: 1 },
// @ts-expect-error Vertex Responses verbosity uses the Open Responses union.
providerOptions: { textVerbosity: "verbose" },
})
@@ -4,10 +4,6 @@ import { OpenAICompatibleResponses } from "../../src/providers.js"
const model = OpenAICompatibleResponses.configure({ baseURL: "https://example.com" }).model("model")
LLM.request({ model, prompt: "Hello", providerOptions: { reasoningSummary: "detailed" } })
LLM.request({ model, prompt: "Hello", providerOptions: { reasoningEffort: "high" } })
LLM.request({ model, prompt: "Hello", providerOptions: { reasoningEffort: "experimental" } })
LLM.request({ model, prompt: "Hello", providerOptions: { textVerbosity: "low" } })
LLM.request({ model, prompt: "Hello", providerOptions: { textVerbosity: "verbose" } })
LLM.request({
model,
@@ -2,14 +2,8 @@ import { LLM } from "../../src/index.js"
import { OpenAI } from "../../src/providers.js"
const selected = OpenAI.responses("gpt-5")
const chat = OpenAI.chat("gpt-4o-mini")
LLM.request({ model: selected, prompt: "Hello", providerOptions: { reasoningEffort: "high" } })
LLM.request({ model: selected, prompt: "Hello", providerOptions: { reasoningEffort: "experimental" } })
LLM.request({ model: selected, prompt: "Hello", providerOptions: { textVerbosity: "low" } })
LLM.request({ model: selected, prompt: "Hello", providerOptions: { textVerbosity: "verbose" } })
LLM.request({ model: chat, prompt: "Hello", providerOptions: { reasoningEffort: "max" } })
LLM.request({ model: chat, prompt: "Hello", providerOptions: { reasoningEffort: "experimental" } })
LLM.request({
model: selected,
@@ -4,7 +4,6 @@ import { XAI } from "../../src/providers.js"
const model = XAI.provider.model("grok-4")
LLM.request({ model, prompt: "Hello", providerOptions: { reasoningEffort: "high" } })
LLM.request({ model, prompt: "Hello", providerOptions: { reasoningEffort: "experimental" } })
LLM.request({
model,
+324 -98
View File
@@ -1,6 +1,72 @@
import { describe, expect, test } from "bun:test"
import { ConfigProvider, Effect } from "effect"
import { Headers } from "effect/unstable/http"
import { LLM, ProviderPackage } from "@opencode-ai/ai"
import { model } from "@opencode-ai/ai/providers/openai"
const packageInput = <Input extends Record<string, unknown>>(id: string, input: Input) => {
const { headers, body, limits, ...settings } = input
return { id, settings, defaults: { headers, body, limits } }
}
const authHeaders = (
selected: ReturnType<typeof model>,
headers: Record<string, string> = {},
env: Record<string, string> = {},
) =>
Effect.runPromise(
selected.route.auth.apply({
request: LLM.request({ model: selected, prompt: "hello" }),
method: "POST",
url: "https://example.test/v1",
body: "{}",
headers: Headers.fromInput(headers),
}).pipe(Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env })))),
)
const applyAuth = (
option: ReturnType<typeof ProviderPackage.bearerAuthOption>,
headers: Record<string, string> = {},
) => {
const selected = model(packageInput("gpt-5", { apiKey: "fixture" }))
return Effect.runPromise(
option.auth.apply({
request: LLM.request({ model: selected, prompt: "hello" }),
method: "POST",
url: "https://example.test/v1",
body: "{}",
headers: Headers.fromInput(headers),
}),
)
}
describe("provider package credential lowering", () => {
test("intentionally renders keys and OAuth credentials as bearer auth", async () => {
const key = await applyAuth(ProviderPackage.bearerAuthOption({ type: "key", value: "provider-key" }))
const oauth = await applyAuth(
ProviderPackage.bearerAuthOption({ type: "oauth", accessToken: "provider-token" }),
)
expect(key.authorization).toBe("Bearer provider-key")
expect(oauth.authorization).toBe("Bearer provider-token")
})
test("keeps key-header credentials configurable and removes stale keys for OAuth", async () => {
expect(
ProviderPackage.apiKeyOrBearerAuthOption({ type: "key", value: "provider-key" }, "x-api-key"),
).toEqual({ apiKey: "provider-key" })
const oauth = ProviderPackage.apiKeyOrBearerAuthOption(
{ type: "oauth", accessToken: "provider-token" },
"x-api-key",
)
if (!("auth" in oauth)) throw new Error("Expected OAuth credential to lower to auth")
const headers = await applyAuth(oauth, { "x-api-key": "stale" })
expect(headers.authorization).toBe("Bearer provider-token")
expect(headers["x-api-key"]).toBeUndefined()
})
})
describe("provider package entrypoints", () => {
test("semantic API aliases expose the same contract", async () => {
const modules = await Promise.all([
@@ -43,49 +109,177 @@ describe("provider package entrypoints", () => {
baseURL: "https://provider.example.test/v1",
headers: { "x-application": "opencode" },
body: { service_tier: "priority" },
limits: { context: 200_000, output: 64_000 },
}
const openrouter = OpenRouter.model("anthropic/claude-sonnet-4", {
...settings,
providerOptions: { usage: true },
})
const xai = XAI.model("grok-4", {
...settings,
providerOptions: { reasoningEffort: "high" },
})
const openrouter = OpenRouter.model(
packageInput("anthropic/claude-sonnet-4", {
...settings,
providerOptions: { usage: true },
}),
)
const xai = XAI.model(
packageInput("grok-4", {
...settings,
providerOptions: { reasoningEffort: "high" },
}),
)
for (const selected of [openrouter, xai]) {
expect(selected.route.endpoint.baseURL).toBe(settings.baseURL)
expect(selected.route.defaults.headers).toEqual(settings.headers)
expect(selected.route.defaults.http?.body).toEqual(settings.body)
expect(selected.route.defaults.limits).toEqual(settings.limits)
}
expect(openrouter.route.defaults.providerOptions).toEqual({ usage: true })
expect(xai.route.defaults.providerOptions).toMatchObject({ reasoningEffort: "high", store: false })
})
test("maps package settings onto the executable model", () => {
const selected = model("gpt-5", {
apiKey: "fixture",
baseURL: "https://api.openai.test/v1",
headers: { "x-application": "opencode" },
body: { service_tier: "priority" },
unrelatedInheritedSetting: true,
})
const selected = model(
packageInput("gpt-5", {
apiKey: "fixture",
baseURL: "https://api.openai.test/v1",
headers: { "x-application": "opencode" },
body: { service_tier: "priority" },
limits: { context: 200_000, output: 64_000 },
reasoningEffort: "high",
unrelatedInheritedSetting: true,
}),
)
expect(selected.route.id).toBe("openai-responses")
expect(selected.route.defaults.headers).toEqual({ "x-application": "opencode" })
expect(selected.route.defaults.http?.body).toEqual({ service_tier: "priority" })
expect(selected.route.defaults.limits).toEqual({ context: 200_000, output: 64_000 })
expect(selected.route.defaults.providerOptions).toEqual({
store: false,
reasoningEffort: "high",
reasoningSummary: "auto",
include: ["reasoning.encrypted_content"],
})
})
test("lets provider packages interpret resolved credentials", async () => {
const Anthropic = await import("@opencode-ai/ai/providers/anthropic")
const Azure = await import("@opencode-ai/ai/providers/azure")
const Google = await import("@opencode-ai/ai/providers/google")
const GoogleVertex = await import("@opencode-ai/ai/providers/google-vertex")
const GoogleVertexChat = await import("@opencode-ai/ai/providers/google-vertex/chat")
const openai = model({
id: "gpt-5",
settings: {},
credential: { type: "oauth", accessToken: "openai-token" },
defaults: {},
})
const anthropicKey = Anthropic.model({
id: "claude-sonnet-4-6",
settings: {},
credential: { type: "key", value: "anthropic-key" },
defaults: {},
})
const anthropicOAuth = Anthropic.model({
id: "claude-sonnet-4-6",
settings: {},
credential: { type: "oauth", accessToken: "anthropic-token" },
defaults: {},
})
const anthropicEmptyKey = Anthropic.model({
id: "claude-sonnet-4-6",
settings: {},
credential: { type: "key", value: "" },
defaults: {},
})
const azureKey = Azure.model({
id: "deployment",
settings: { resourceName: "opencode-test" },
credential: { type: "key", value: "azure-key" },
defaults: {},
})
const azureOAuth = Azure.model({
id: "deployment",
settings: { resourceName: "opencode-test" },
credential: { type: "oauth", accessToken: "azure-token" },
defaults: {},
})
const googleKey = Google.model({
id: "gemini-2.5-flash",
settings: {},
credential: { type: "key", value: "google-key" },
defaults: {},
})
const googleOAuth = Google.model({
id: "gemini-2.5-flash",
settings: {},
credential: { type: "oauth", accessToken: "google-token" },
defaults: {},
})
const vertexKey = GoogleVertex.model({
id: "gemini-3.5-flash",
settings: {},
credential: { type: "key", value: "vertex-key" },
defaults: {},
})
const vertexOAuth = GoogleVertex.model({
id: "gemini-3.5-flash",
settings: { project: "vertex-project" },
credential: { type: "oauth", accessToken: "vertex-token" },
defaults: {},
})
const vertexChatOAuth = GoogleVertexChat.model({
id: "deepseek-ai/deepseek-v3.2-maas",
settings: { apiKey: "configured-key", project: "vertex-project" },
credential: { type: "oauth", accessToken: "vertex-chat-token" },
defaults: {},
})
expect((await authHeaders(openai)).authorization).toBe("Bearer openai-token")
const anthropicKeyHeaders = await authHeaders(anthropicKey, { authorization: "Bearer stale" })
const anthropicOAuthHeaders = await authHeaders(anthropicOAuth, { "x-api-key": "stale" })
const anthropicEmptyKeyHeaders = await authHeaders(
anthropicEmptyKey,
{ authorization: "Bearer stale" },
{ ANTHROPIC_API_KEY: "environment-key" },
)
const azureKeyHeaders = await authHeaders(azureKey, { authorization: "Bearer stale" })
const azureOAuthHeaders = await authHeaders(azureOAuth, { "api-key": "stale" })
const googleKeyHeaders = await authHeaders(googleKey, { authorization: "Bearer stale" })
const googleOAuthHeaders = await authHeaders(googleOAuth, { "x-goog-api-key": "stale" })
const vertexKeyHeaders = await authHeaders(vertexKey, { authorization: "Bearer stale" })
const vertexOAuthHeaders = await authHeaders(vertexOAuth, { "x-goog-api-key": "stale" })
expect(anthropicKeyHeaders["x-api-key"]).toBe("anthropic-key")
expect(anthropicKeyHeaders.authorization).toBeUndefined()
expect(anthropicOAuthHeaders.authorization).toBe("Bearer anthropic-token")
expect(anthropicOAuthHeaders["x-api-key"]).toBeUndefined()
expect(anthropicEmptyKeyHeaders["x-api-key"]).toBe("environment-key")
expect(anthropicEmptyKeyHeaders.authorization).toBeUndefined()
expect(azureKeyHeaders["api-key"]).toBe("azure-key")
expect(azureKeyHeaders.authorization).toBeUndefined()
expect(azureOAuthHeaders.authorization).toBe("Bearer azure-token")
expect(azureOAuthHeaders["api-key"]).toBeUndefined()
expect(googleKeyHeaders["x-goog-api-key"]).toBe("google-key")
expect(googleKeyHeaders.authorization).toBeUndefined()
expect(googleOAuthHeaders.authorization).toBe("Bearer google-token")
expect(googleOAuthHeaders["x-goog-api-key"]).toBeUndefined()
expect(vertexKeyHeaders["x-goog-api-key"]).toBe("vertex-key")
expect(vertexKeyHeaders.authorization).toBeUndefined()
expect(vertexOAuthHeaders.authorization).toBe("Bearer vertex-token")
expect(vertexOAuthHeaders["x-goog-api-key"]).toBeUndefined()
expect((await authHeaders(vertexChatOAuth)).authorization).toBe("Bearer vertex-chat-token")
})
test("maps OpenAI-compatible Responses settings onto the executable model", async () => {
const OpenAICompatibleResponses = await import("@opencode-ai/ai/providers/openai-compatible/responses")
const selected = OpenAICompatibleResponses.model("custom-model", {
apiKey: "fixture",
baseURL: "https://responses.example.test/v1",
provider: "example",
headers: { "x-application": "opencode" },
body: { service_tier: "priority" },
providerOptions: { reasoningEffort: "low", store: true },
})
const selected = OpenAICompatibleResponses.model(
packageInput("custom-model", {
apiKey: "fixture",
baseURL: "https://responses.example.test/v1",
provider: "example",
headers: { "x-application": "opencode" },
body: { service_tier: "priority" },
limits: { context: 200_000, output: 64_000 },
providerOptions: { reasoningEffort: "low", store: true },
}),
)
expect(String(selected.provider)).toBe("example")
expect(selected.route.id).toBe("openai-compatible-responses")
@@ -95,19 +289,23 @@ describe("provider package entrypoints", () => {
})
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({ reasoningEffort: "low", store: true })
})
test("maps Anthropic-compatible settings onto the executable model", async () => {
const AnthropicCompatible = await import("@opencode-ai/ai/providers/anthropic-compatible")
const selected = AnthropicCompatible.model("compatible-model", {
apiKey: "fixture",
baseURL: "https://messages.example.test/v1",
provider: "example",
headers: { "x-application": "opencode" },
body: { metadata: { user_id: "user_1" } },
providerOptions: { effort: "low" },
})
const selected = AnthropicCompatible.model(
packageInput("compatible-model", {
apiKey: "fixture",
baseURL: "https://messages.example.test/v1",
provider: "example",
headers: { "x-application": "opencode" },
body: { metadata: { user_id: "user_1" } },
limits: { context: 200_000, output: 64_000 },
providerOptions: { effort: "low" },
}),
)
expect(String(selected.provider)).toBe("example")
expect(selected.route.id).toBe("anthropic-messages")
@@ -117,15 +315,18 @@ describe("provider package entrypoints", () => {
})
expect(selected.route.defaults.headers).toEqual({ "x-application": "opencode" })
expect(selected.route.defaults.http?.body).toEqual({ metadata: { user_id: "user_1" } })
expect(selected.route.defaults.limits).toEqual({ context: 200_000, output: 64_000 })
expect(selected.route.defaults.providerOptions).toEqual({ effort: "low" })
})
test("maps Anthropic provider options onto the executable model", async () => {
const Anthropic = await import("@opencode-ai/ai/providers/anthropic")
const selected = Anthropic.model("claude-sonnet-4-6", {
apiKey: "fixture",
providerOptions: { thinking: { type: "adaptive" } },
})
const selected = Anthropic.model(
packageInput("claude-sonnet-4-6", {
apiKey: "fixture",
providerOptions: { thinking: { type: "adaptive" } },
}),
)
expect(selected.route.defaults.providerOptions).toEqual({ thinking: { type: "adaptive" } })
})
@@ -133,7 +334,7 @@ describe("provider package entrypoints", () => {
test("requires an Anthropic-compatible base URL at runtime", async () => {
const AnthropicCompatible = await import("@opencode-ai/ai/providers/anthropic-compatible")
expect(() =>
Reflect.apply(AnthropicCompatible.model, undefined, ["compatible-model", { apiKey: "fixture" }]),
Reflect.apply(AnthropicCompatible.model, undefined, [packageInput("compatible-model", { apiKey: "fixture" })]),
).toThrow("Anthropic-compatible providers require a baseURL")
})
@@ -142,25 +343,28 @@ describe("provider package entrypoints", () => {
const AnthropicCompatible = await import("@opencode-ai/ai/providers/anthropic-compatible")
expect(() =>
Reflect.apply(AnthropicCompatible.model, undefined, [
"compatible-model",
{
packageInput("compatible-model", {
apiKey: "fixture",
authToken: "token",
baseURL: "https://messages.example.test/v1",
},
}),
]),
).toThrow("Anthropic-compatible apiKey cannot be combined with authToken")
expect(() =>
Reflect.apply(Anthropic.model, undefined, ["claude-sonnet-4-6", { apiKey: "fixture", authToken: "token" }]),
Reflect.apply(Anthropic.model, undefined, [
packageInput("claude-sonnet-4-6", { apiKey: "fixture", authToken: "token" }),
]),
).toThrow("Anthropic apiKey cannot be combined with authToken")
})
test("maps legacy OpenAI organization and project settings to headers", () => {
const selected = model("gpt-5", {
apiKey: "fixture",
organization: "org_123",
project: "proj_123",
})
const selected = model(
packageInput("gpt-5", {
apiKey: "fixture",
organization: "org_123",
project: "proj_123",
}),
)
expect(selected.route.defaults.headers).toMatchObject({
"OpenAI-Organization": "org_123",
@@ -177,31 +381,37 @@ describe("provider package entrypoints", () => {
resourceName: "opencode-test",
headers: { "x-application": "opencode" },
body: { service_tier: "priority" },
limits: { context: 200_000, output: 64_000 },
}
const responses = AzureResponses.model("deployment", settings)
const chat = AzureChat.model("deployment", settings)
const responses = AzureResponses.model(packageInput("deployment", settings))
const chat = AzureChat.model(packageInput("deployment", settings))
expect(Azure.model("deployment", settings).route.id).toBe("azure-openai-responses")
expect(Azure.model(packageInput("deployment", settings)).route.id).toBe("azure-openai-responses")
expect(responses.route.id).toBe("azure-openai-responses")
expect(responses.route.endpoint.baseURL).toBe("https://opencode-test.openai.azure.com/openai/v1")
expect(responses.route.defaults.headers).toEqual({ "x-application": "opencode" })
expect(responses.route.defaults.http?.body).toEqual({ service_tier: "priority" })
expect(responses.route.defaults.limits).toEqual({ context: 200_000, output: 64_000 })
expect(chat.route.id).toBe("azure-openai-chat")
})
test("constructs Azure deployment URLs and preserves custom gateway URLs", async () => {
const Azure = await import("@opencode-ai/ai/providers/azure")
const deployment = Azure.model("custom-deployment", {
apiKey: "fixture",
resourceName: "opencode-test",
apiVersion: "2025-01-01-preview",
useDeploymentBasedUrls: true,
})
const gateway = Azure.model("gateway-model", {
apiKey: "fixture",
baseURL: "https://gateway.example/azure/",
})
const deployment = Azure.model(
packageInput("custom-deployment", {
apiKey: "fixture",
resourceName: "opencode-test",
apiVersion: "2025-01-01-preview",
useDeploymentBasedUrls: true,
}),
)
const gateway = Azure.model(
packageInput("gateway-model", {
apiKey: "fixture",
baseURL: "https://gateway.example/azure/",
}),
)
expect(deployment.route.endpoint).toMatchObject({
baseURL: "https://opencode-test.openai.azure.com/openai/deployments/custom-deployment",
@@ -213,18 +423,22 @@ describe("provider package entrypoints", () => {
test("maps Google package settings onto the Gemini model", async () => {
const Google = await import("@opencode-ai/ai/providers/google")
const selected = Google.model("gemini-2.5-flash", {
apiKey: "fixture",
baseURL: "https://generativelanguage.test/v1beta",
headers: { "x-application": "opencode" },
body: { safetySettings: [] },
providerOptions: { thinkingConfig: { thinkingBudget: 1_024 } },
})
const selected = Google.model(
packageInput("gemini-2.5-flash", {
apiKey: "fixture",
baseURL: "https://generativelanguage.test/v1beta",
headers: { "x-application": "opencode" },
body: { safetySettings: [] },
limits: { context: 1_000_000, output: 65_536 },
providerOptions: { thinkingConfig: { thinkingBudget: 1_024 } },
}),
)
expect(selected.route.id).toBe("gemini")
expect(selected.route.endpoint.baseURL).toBe("https://generativelanguage.test/v1beta")
expect(selected.route.defaults.headers).toEqual({ "x-application": "opencode" })
expect(selected.route.defaults.http?.body).toEqual({ safetySettings: [] })
expect(selected.route.defaults.limits).toEqual({ context: 1_000_000, output: 65_536 })
expect(selected.route.defaults.providerOptions).toEqual({ thinkingConfig: { thinkingBudget: 1_024 } })
})
@@ -234,26 +448,35 @@ describe("provider package entrypoints", () => {
const GoogleVertexChat = await import("@opencode-ai/ai/providers/google-vertex/chat")
const GoogleVertexResponses = await import("@opencode-ai/ai/providers/google-vertex/responses")
const GoogleVertexMessages = await import("@opencode-ai/ai/providers/google-vertex/messages")
const gemini = GoogleVertex.model("gemini-3.5-flash", {
apiKey: "fixture",
headers: { "x-application": "opencode" },
body: { safetySettings: [] },
})
const messages = GoogleVertexMessages.model("claude-sonnet-4-6", {
accessToken: "fixture",
location: "global",
project: "vertex-project",
})
const chat = GoogleVertexChat.model("deepseek-ai/deepseek-v3.2-maas", {
accessToken: "fixture",
location: "global",
project: "vertex-project",
})
const responses = GoogleVertexResponses.model("xai/grok-4.20-reasoning", {
accessToken: "fixture",
location: "global",
project: "vertex-project",
})
const gemini = GoogleVertex.model(
packageInput("gemini-3.5-flash", {
apiKey: "fixture",
headers: { "x-application": "opencode" },
body: { safetySettings: [] },
limits: { context: 1_000_000, output: 65_536 },
}),
)
const messages = GoogleVertexMessages.model(
packageInput("claude-sonnet-4-6", {
accessToken: "fixture",
location: "global",
project: "vertex-project",
}),
)
const chat = GoogleVertexChat.model(
packageInput("deepseek-ai/deepseek-v3.2-maas", {
accessToken: "fixture",
location: "global",
project: "vertex-project",
}),
)
const responses = GoogleVertexResponses.model(
packageInput("xai/grok-4.20-reasoning", {
accessToken: "fixture",
location: "global",
project: "vertex-project",
}),
)
expect(GoogleVertexGemini.model).toBe(GoogleVertex.model)
expect(gemini.route.id).toBe("google-vertex-gemini")
@@ -261,12 +484,15 @@ describe("provider package entrypoints", () => {
expect(gemini.route.endpoint.baseURL).toBe("https://aiplatform.googleapis.com/v1/publishers/google")
expect(gemini.route.defaults.headers).toEqual({ "x-application": "opencode" })
expect(gemini.route.defaults.http?.body).toEqual({ safetySettings: [] })
expect(gemini.route.defaults.limits).toEqual({ context: 1_000_000, output: 65_536 })
expect(
GoogleVertex.model("gemini-3.5-flash", {
accessToken: "fixture",
location: "eu",
project: "vertex-project",
}).route.endpoint.baseURL,
GoogleVertex.model(
packageInput("gemini-3.5-flash", {
accessToken: "fixture",
location: "eu",
project: "vertex-project",
}),
).route.endpoint.baseURL,
).toBe("https://aiplatform.eu.rep.googleapis.com/v1beta1/projects/vertex-project/locations/eu/publishers/google")
expect(messages.route.id).toBe("google-vertex-messages")
expect(messages.route.protocol).toBe("anthropic-messages")
@@ -296,8 +522,11 @@ describe("provider package entrypoints", () => {
const Providers = await import("@opencode-ai/ai/providers")
expect(() =>
Reflect.apply(GoogleVertex.model, undefined, [
"gemini-3.5-flash",
{ accessToken: "token", apiKey: "fixture", project: "vertex-project" },
packageInput("gemini-3.5-flash", {
accessToken: "token",
apiKey: "fixture",
project: "vertex-project",
}),
]),
).toThrow("Google Vertex apiKey cannot be combined with accessToken or auth")
const configured = Reflect.apply(GoogleVertex.configure, undefined, [
@@ -306,8 +535,7 @@ describe("provider package entrypoints", () => {
expect(() => configured.model("gemini-3.5-flash")).toThrow("Google Vertex accessToken cannot be combined with auth")
expect(() =>
Reflect.apply(GoogleVertexMessages.model, undefined, [
"claude-sonnet-4-6",
{ apiKey: "fixture", project: "vertex-project" },
packageInput("claude-sonnet-4-6", { apiKey: "fixture", project: "vertex-project" }),
]),
).toThrow("Google Vertex Messages does not support API keys")
expect(() =>
@@ -317,8 +545,7 @@ describe("provider package entrypoints", () => {
).toThrow("Google Vertex Messages does not support API keys")
expect(() =>
Reflect.apply(GoogleVertexChat.model, undefined, [
"deepseek-ai/deepseek-v3.2-maas",
{ apiKey: "fixture", project: "vertex-project" },
packageInput("deepseek-ai/deepseek-v3.2-maas", { apiKey: "fixture", project: "vertex-project" }),
]),
).toThrow("Google Vertex Chat does not support API keys")
expect(() =>
@@ -328,8 +555,7 @@ describe("provider package entrypoints", () => {
).toThrow("Google Vertex Chat does not support API keys")
expect(() =>
Reflect.apply(GoogleVertexResponses.model, undefined, [
"xai/grok-4.20-reasoning",
{ apiKey: "fixture", project: "vertex-project" },
packageInput("xai/grok-4.20-reasoning", { apiKey: "fixture", project: "vertex-project" }),
]),
).toThrow("Google Vertex Responses does not support API keys")
expect(() =>
@@ -322,7 +322,7 @@ describe("Anthropic Messages route", () => {
{ role: "user", content: [{ type: "tool_result", tool_use_id: "call_1", content: '{"forecast":"sunny"}' }] },
],
stream: true,
max_tokens: 32_000,
max_tokens: 4096,
})
}),
)
@@ -640,7 +640,7 @@ describe("Anthropic Messages route", () => {
}),
)
it.effect("maps nullable input tokens and preserves unknown Anthropic usage fields", () =>
it.effect("maps thinking tokens and preserves unknown Anthropic usage fields", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
@@ -663,7 +663,6 @@ describe("Anthropic Messages route", () => {
type: "message_delta",
delta: { stop_reason: "end_turn" },
usage: {
input_tokens: null,
output_tokens: 8,
server_tool_use: { web_search_requests: 2, terminal_counter: 3 },
output_tokens_details: { terminal_detail: "preserved" },
@@ -683,7 +682,7 @@ describe("Anthropic Messages route", () => {
totalTokens: 15,
providerMetadata: {
anthropic: {
input_tokens: null,
input_tokens: 5,
cache_read_input_tokens: 2,
service_tier: "standard",
cache_creation: { ephemeral_5m_input_tokens: 1 },
-42
View File
@@ -139,48 +139,6 @@ describe("Gemini route", () => {
}),
)
it.effect("keeps system updates separate from function responses", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: { query: "weather" } })]),
Message.tool({ id: "call_1", name: "lookup", result: "done", resultType: "text" }),
Message.system("Update."),
Message.system("Later update."),
],
}),
)
expect(prepared.body.contents).toEqual([
{
role: "model",
parts: [{ functionCall: { id: undefined, name: "lookup", args: { query: "weather" } } }],
},
{
role: "user",
parts: [
{
functionResponse: {
id: undefined,
name: "lookup",
response: { name: "lookup", content: "done" },
},
},
],
},
{
role: "user",
parts: [
{ text: "<system-update>\nUpdate.\n</system-update>" },
{ text: "<system-update>\nLater update.\n</system-update>" },
],
},
])
}),
)
it.effect("prepares multimodal user input and tool history", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
+1 -69
View File
@@ -255,7 +255,7 @@ describe("OpenAI Chat route", () => {
LLMClient.generate(
LLMRequest.update(request, {
model: Azure.configure({
baseURL: "https://opencode-test.openai.azure.com/openai/",
baseURL: "https://opencode-test.openai.azure.com/openai/v1/",
apiKey: "azure-key",
headers: { authorization: "Bearer stale" },
}).chat("gpt-4o-mini"),
@@ -664,74 +664,6 @@ describe("OpenAI Chat route", () => {
}),
)
it.effect("preserves streamed refusals as ordinary assistant text", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
deltaChunk({ role: "assistant", refusal: "I can't" }),
deltaChunk({ refusal: " help with that." }),
deltaChunk({}, "stop"),
),
),
),
)
expect(response.text).toBe("I can't help with that.")
expect(response.finishReason).toEqual({ normalized: "stop", raw: "stop" })
expect(response.message.content).toEqual([{ type: "text", text: "I can't help with that." }])
const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
expect(replay.body.messages).toEqual([{ role: "assistant", content: "I can't help with that." }])
}),
)
it.effect("orders metadata-only reasoning before refusal output", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ choices: [{ delta: { reasoning_details: [] } }] },
deltaChunk({ refusal: "I can't help with that." }),
deltaChunk({}, "stop"),
),
),
),
)
expect(response.message.content).toEqual([
{ type: "reasoning", text: "", providerMetadata: { openai: { reasoningDetails: [] } } },
{
type: "text",
text: "I can't help with that.",
},
])
}),
)
it.effect("joins content and refusal deltas into ordinary assistant text", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
deltaChunk({ refusal: "No." }),
deltaChunk({ content: " Alternative." }),
deltaChunk({ refusal: " Still no." }),
deltaChunk({}, "stop"),
),
),
),
)
expect(response.text).toBe("No. Alternative. Still no.")
expect(response.events.filter(LLMEvent.is.textStart).map((event) => event.id)).toEqual(["text-0"])
expect(response.events.filter(LLMEvent.is.textEnd).map((event) => event.id)).toEqual(["text-0"])
}),
)
it.effect("parses and replays OpenAI-compatible reasoning fields", () =>
Effect.gen(function* () {
const fields = ["reasoning_content", "reasoning", "reasoning_text"] as const
@@ -73,7 +73,7 @@ describe("Open Responses-compatible route", () => {
expect(prepared.body.input).toEqual([
{ role: "user", content: [{ type: "input_text", text: "Before." }] },
{ role: "developer", content: "Operator update." },
{ type: "message", role: "assistant", content: [{ type: "output_text", text: "After." }] },
{ role: "assistant", content: [{ type: "output_text", text: "After." }] },
])
}),
)
@@ -113,67 +113,11 @@ describe("Open Responses-compatible route", () => {
)
expect(prepared.body).toMatchObject({
input: [{ type: "message", role: "assistant", content: [{ type: "output_text", text: "Unclassified." }] }],
input: [{ role: "assistant", content: [{ type: "output_text", text: "Unclassified." }] }],
})
}),
)
it.effect("preserves standard refusal content as ordinary assistant text", () =>
Effect.gen(function* () {
const model = configure({
apiKey: "test-key",
baseURL: "https://responses.example.test/v1",
provider: "example",
}).model("example-model")
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Unsafe request" })).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{
type: "response.output_item.added",
output_index: 0,
item: { type: "message", id: "msg_refusal", content: [] },
},
{
type: "response.refusal.done",
item_id: "msg_refusal",
refusal: "I can't help with that.",
},
{
type: "response.output_item.done",
output_index: 0,
item: {
type: "message",
id: "msg_refusal",
content: [{ type: "refusal", refusal: "I can't help with that." }],
},
},
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.message.content).toEqual([
{
type: "text",
text: "I can't help with that.",
providerMetadata: { openresponses: { itemId: "msg_refusal" } },
},
])
const prepared = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
expect(prepared.body.input).toEqual([
{
type: "message",
id: "msg_refusal",
role: "assistant",
content: [{ type: "output_text", text: "I can't help with that." }],
},
])
}),
)
it.effect("reads standard Open Responses options", () =>
Effect.gen(function* () {
const model = configure({
@@ -182,10 +126,6 @@ describe("Open Responses-compatible route", () => {
providerOptions: {
reasoningEffort: "low",
store: true,
metadata: { environment: "test" },
safetyIdentifier: "user_123",
streamOptions: { includeObfuscation: false },
topLogprobs: 3,
truncation: "auto",
allowedTools: { toolNames: ["lookup"] },
maxToolCalls: 2,
@@ -196,7 +136,6 @@ describe("Open Responses-compatible route", () => {
LLM.request({
model,
prompt: "Think.",
generation: { presencePenalty: 0.2, frequencyPenalty: -0.1 },
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
}),
)
@@ -204,12 +143,6 @@ describe("Open Responses-compatible route", () => {
expect(prepared.body).toMatchObject({
reasoning: { effort: "low" },
store: true,
metadata: { environment: "test" },
safety_identifier: "user_123",
stream_options: { include_obfuscation: false },
top_logprobs: 3,
presence_penalty: 0.2,
frequency_penalty: -0.1,
truncation: "auto",
tool_choice: {
type: "allowed_tools",
@@ -1,216 +0,0 @@
import { describe, expect } from "bun:test"
import { Effect, Stream } from "effect"
import { Socket } from "effect/unstable/socket"
import { LLM, LLMRequest, Message, ToolRuntime } from "../../src/index.js"
import {
LLMClient,
WebSocketTransport,
type ChannelCheckpoint,
type ChannelObservation,
type WebSocketChannelExchange,
type WebSocketChannelExecutor,
type WebSocketConnection,
} from "../../src/route.js"
import { configure } from "../../src/providers/openai.js"
import { decodeJson } from "../../src/protocols/shared.js"
import { weatherRuntimeTool, weatherTool, weatherToolName } from "../recorded-scenarios.js"
import { recordedTests } from "../recorded-test.js"
const model = configure({ apiKey: process.env.OPENAI_API_KEY ?? "fixture" }).responses("gpt-5.5")
const recorded = recordedTests({
prefix: "openai-responses-websocket",
provider: "openai",
protocol: "openai-responses",
requires: ["OPENAI_API_KEY"],
tags: ["transport:websocket"],
metadata: { transport: "websocket", model: model.id },
})
const observationFrame = (observation: ChannelObservation) => {
if (observation.type === "frame" || observation.type === "completed" || observation.type === "incomplete")
return Effect.succeed(observation.frame)
return Effect.fail(observation.error)
}
const terminal = (observation: ChannelObservation) => observation.type !== "frame"
// This deliberately models only sequential test traffic. Core owns production connection pooling and recovery.
const makeChannel = Effect.gen(function* () {
const constructor = yield* Socket.WebSocketConstructor
let connection: WebSocketConnection | undefined
let checkpoint: ChannelCheckpoint | undefined
let pending: ChannelCheckpoint | undefined
let opens = 0
const sent: unknown[] = []
const close = Effect.suspend(() => {
const current = connection
connection = undefined
return current ? current.close : Effect.void
})
yield* Effect.addFinalizer(() => close)
const executor: WebSocketChannelExecutor = {
execute: (exchange: WebSocketChannelExchange) =>
Effect.gen(function* () {
if (!connection) {
connection = yield* WebSocketTransport.open(exchange.connect).pipe(
Effect.provideService(Socket.WebSocketConstructor, constructor),
)
opens += 1
}
const current = connection
const create = yield* exchange.driver.create(checkpoint)
if (create.mode === "full") checkpoint = undefined
pending = undefined
sent.push(decodeJson(create.message))
yield* current.sendText(create.message)
const decoder = new TextDecoder()
return {
frames: current.messages.pipe(
Stream.map((message) => WebSocketTransport.messageText(message, decoder)),
Stream.mapEffect((frame) => exchange.driver.observe(create, frame)),
Stream.tap((observation) =>
Effect.sync(() => {
if (!terminal(observation)) return
pending = observation.type === "completed" ? observation.checkpoint : undefined
if (observation.type !== "completed") checkpoint = undefined
}),
),
Stream.takeUntil(terminal),
Stream.mapEffect(observationFrame),
),
complete: Effect.sync(() => {
checkpoint = pending
pending = undefined
}),
}
}),
}
return {
executor,
sent,
opens: () => opens,
reconnect: (preserveCheckpoint = false) =>
close.pipe(
Effect.andThen(
Effect.sync(() => {
pending = undefined
if (!preserveCheckpoint) checkpoint = undefined
}),
),
),
}
})
describe("OpenAI Responses WebSocket recorded", () => {
recorded.effect.with("continues a tool call over one socket", { tags: ["tool", "continuation"] }, () =>
Effect.gen(function* () {
const channel = yield* makeChannel
const request = LLM.request({
id: "recorded_openai_responses_websocket_tool",
model,
system: "Call get_weather once, then reply exactly: Paris is sunny.",
prompt: "What is the weather in Paris?",
tools: [weatherTool],
generation: { maxTokens: 50 },
cache: "none",
})
const first = yield* LLMClient.generate(request, { webSocket: channel.executor })
const call = first.toolCalls[0]
if (!call) yield* Effect.die("Expected get_weather tool call")
const result = yield* ToolRuntime.dispatch({ [weatherToolName]: weatherRuntimeTool }, call)
const second = yield* LLMClient.generate(
LLMRequest.update(request, {
messages: [
...request.messages,
first.message,
Message.tool({ id: call.id, name: call.name, result: result.result }),
],
}),
{ webSocket: channel.executor },
)
expect(second.text).toBe("Paris is sunny.")
expect(channel.opens()).toBe(1)
expect(channel.sent).toHaveLength(2)
expect(channel.sent[1]).toMatchObject({
previous_response_id: expect.any(String),
input: [{ type: "function_call_output", call_id: call.id, output: expect.any(String) }],
})
}),
)
recorded.effect.with("reconstructs full context after reconnect", { tags: ["reconnect", "full-context"] }, () =>
Effect.gen(function* () {
const channel = yield* makeChannel
const request = LLM.request({
id: "recorded_openai_responses_websocket_reconnect",
model,
system: "Follow the user's exact reply instruction.",
prompt: "Reply exactly: Alpha.",
generation: { maxTokens: 30 },
cache: "none",
})
const first = yield* LLMClient.generate(request, { webSocket: channel.executor })
yield* channel.reconnect()
const second = yield* LLMClient.generate(
LLMRequest.update(request, {
messages: [...request.messages, first.message, Message.user("Reply exactly: Beta.")],
}),
{ webSocket: channel.executor },
)
expect(first.text).toBe("Alpha.")
expect(second.text).toBe("Beta.")
expect(channel.opens()).toBe(2)
expect(channel.sent[1]).not.toHaveProperty("previous_response_id")
expect(channel.sent[1]).toMatchObject({
input: [
{ role: "system", content: "Follow the user's exact reply instruction." },
{ role: "user", content: [{ type: "input_text", text: "Reply exactly: Alpha." }] },
{ role: "assistant", content: [{ type: "output_text", text: "Alpha." }] },
{ role: "user", content: [{ type: "input_text", text: "Reply exactly: Beta." }] },
],
})
}),
)
recorded.effect.with("recovers from explicit continuation rejection", { tags: ["continuation", "recovery"] }, () =>
Effect.gen(function* () {
const channel = yield* makeChannel
const request = LLM.request({
id: "recorded_openai_responses_websocket_rejection",
model,
system: "Follow the user's exact reply instruction.",
prompt: "Reply exactly: Ready.",
generation: { maxTokens: 30 },
cache: "none",
})
const first = yield* LLMClient.generate(request, { webSocket: channel.executor })
const continuation = LLMRequest.update(request, {
messages: [...request.messages, first.message, Message.user("Reply exactly: Recovered.")],
})
yield* channel.reconnect(true)
const rejected = yield* LLMClient.generate(continuation, { webSocket: channel.executor }).pipe(Effect.flip)
const recovered = yield* LLMClient.generate(continuation, { webSocket: channel.executor })
expect(rejected).toMatchObject({
reason: { _tag: "Transport", delivery: "rejected", recovery: "retry-full" },
})
expect(recovered.text).toBe("Recovered.")
expect(channel.opens()).toBe(2)
expect(channel.sent[1]).toHaveProperty("previous_response_id", expect.any(String))
expect(channel.sent[2]).not.toHaveProperty("previous_response_id")
expect(channel.sent[2]).toMatchObject({
input: [
{ role: "system", content: "Follow the user's exact reply instruction." },
{ role: "user", content: [{ type: "input_text", text: "Reply exactly: Ready." }] },
{ role: "assistant", content: [{ type: "output_text", text: "Ready." }] },
{ role: "user", content: [{ type: "input_text", text: "Reply exactly: Recovered." }] },
],
})
}),
)
})
@@ -178,16 +178,6 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("passes through custom OpenAI text verbosity strings", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLMRequest.update(request, { providerOptions: { textVerbosity: "verbose" } }),
)
expect(prepared.body.text).toEqual({ verbosity: "verbose" })
}),
)
it.effect("omits unsupported semantic service tiers", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
@@ -263,7 +253,7 @@ describe("OpenAI Responses route", () => {
expect(prepared.body.input).toEqual([
{ role: "user", content: [{ type: "input_text", text: "Before." }] },
{ role: "developer", content: "Operator update." },
{ type: "message", role: "assistant", content: [{ type: "output_text", text: "After." }] },
{ role: "assistant", content: [{ type: "output_text", text: "After." }] },
])
}),
)
@@ -485,7 +475,7 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("continues store-false reasoning while retaining the output item ID", () =>
it.effect("continues store-false reasoning without replaying the output-only item ID", () =>
Effect.gen(function* () {
const firstInput = [{ role: "user", content: [{ type: "input_text", text: "Think" }] }]
const request = { type: "response.create", model: "gpt-5.2", store: false, input: firstInput }
@@ -515,7 +505,6 @@ describe("OpenAI Responses route", () => {
...firstInput,
{
type: "reasoning",
id: "rs_1",
summary: [{ type: "summary_text", text: "Thought" }],
encrypted_content: "encrypted",
},
@@ -1285,7 +1274,6 @@ describe("OpenAI Responses route", () => {
model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).model("gpt-5.2"),
prompt: "think",
promptCacheKey: "session_123",
generation: { presencePenalty: 0.25, frequencyPenalty: -0.25 },
tools: [
ToolDefinition.make({ name: "read", description: "Read a file", inputSchema: { type: "object" } }),
ToolDefinition.make({ name: "grep", description: "Search files", inputSchema: { type: "object" } }),
@@ -1295,10 +1283,6 @@ describe("OpenAI Responses route", () => {
reasoningEffort: "high",
reasoningSummary: "auto",
include: ["reasoning.encrypted_content"],
metadata: { environment: "test", tenant: "acme" },
safetyIdentifier: "user_123",
streamOptions: { includeObfuscation: false },
topLogprobs: 5,
truncation: "disabled",
allowedTools: { toolNames: ["read", "grep"], mode: "required" },
maxToolCalls: 4,
@@ -1312,12 +1296,6 @@ describe("OpenAI Responses route", () => {
expect(prepared.body.include).toEqual(["reasoning.encrypted_content"])
expect(prepared.body.reasoning).toEqual({ effort: "high", summary: "auto" })
expect(prepared.body.text).toEqual({ verbosity: "low" })
expect(prepared.body.metadata).toEqual({ environment: "test", tenant: "acme" })
expect(prepared.body.safety_identifier).toBe("user_123")
expect(prepared.body.stream_options).toEqual({ include_obfuscation: false })
expect(prepared.body.top_logprobs).toBe(5)
expect(prepared.body.presence_penalty).toBe(0.25)
expect(prepared.body.frequency_penalty).toBe(-0.25)
expect(prepared.body.truncation).toBe("disabled")
expect(prepared.body.tool_choice).toEqual({
type: "allowed_tools",
@@ -1485,7 +1463,7 @@ describe("OpenAI Responses route", () => {
expect(response.text).toBe("Hello!")
expect(response.events).toEqual([
{ type: "step-start", index: 0 },
{ type: "text-start", id: "msg_1", providerMetadata: { openai: { itemId: "msg_1" } } },
{ type: "text-start", id: "msg_1" },
{ type: "text-delta", id: "msg_1", text: "Hello" },
{ type: "text-delta", id: "msg_1", text: "!" },
{ type: "text-end", id: "msg_1" },
@@ -1506,108 +1484,6 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("preserves standard refusal content as ordinary assistant text", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{
type: "response.output_item.added",
output_index: 0,
item: { type: "message", id: "msg_refusal", content: [] },
},
{
type: "response.content_part.added",
item_id: "msg_refusal",
output_index: 0,
content_index: 0,
part: { type: "refusal", refusal: "" },
},
{
type: "response.refusal.delta",
item_id: "msg_refusal",
output_index: 0,
content_index: 0,
delta: "I can't",
},
{
type: "response.refusal.delta",
item_id: "msg_refusal",
output_index: 0,
content_index: 0,
delta: " help with that.",
},
{
type: "response.refusal.done",
item_id: "msg_refusal",
output_index: 0,
content_index: 0,
refusal: "I can't help with that.",
},
{
type: "response.content_part.done",
item_id: "msg_refusal",
output_index: 0,
content_index: 0,
part: { type: "refusal", refusal: "I can't help with that." },
},
{
type: "response.output_item.done",
output_index: 0,
item: {
type: "message",
id: "msg_refusal",
phase: "final_answer",
content: [{ type: "refusal", refusal: "I can't help with that." }],
},
},
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.text).toBe("I can't help with that.")
expect(response.finishReason).toEqual({ normalized: "stop", raw: undefined })
expect(response.message.content).toEqual([
{
type: "text",
text: "I can't help with that.",
providerMetadata: { openai: { itemId: "msg_refusal", phase: "final_answer" } },
},
])
const prepared = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
expect(prepared.body.input).toEqual([
{
type: "message",
id: "msg_refusal",
role: "assistant",
content: [{ type: "output_text", text: "I can't help with that." }],
phase: "final_answer",
},
])
}),
)
it.effect("rejects malformed refusal events", () =>
Effect.gen(function* () {
const events = [
{ type: "response.refusal.delta", output_index: 0, content_index: 0, delta: "missing item" },
{ type: "response.refusal.delta", item_id: "msg_1", output_index: 0, content_index: 0 },
{ type: "response.refusal.done", item_id: "msg_1", output_index: 0, content_index: 0 },
]
for (const event of events) {
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(fixedResponse(sseEvents(event))),
Effect.flip,
)
expect(error.reason._tag).toBe("InvalidProviderOutput")
}
}),
)
it.effect("preserves and replays assistant message phases", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
@@ -1646,39 +1522,33 @@ describe("OpenAI Responses route", () => {
{
type: "text",
text: "Checking.",
providerMetadata: { openai: { itemId: "msg_commentary", phase: "commentary" } },
providerMetadata: { openai: { phase: "commentary" } },
},
{
type: "text",
text: "Finished.",
providerMetadata: { openai: { itemId: "msg_final", phase: "final_answer" } },
providerMetadata: { openai: { phase: "final_answer" } },
},
{
type: "text",
text: "Unclassified.",
providerMetadata: { openai: { itemId: "msg_null", phase: null } },
providerMetadata: { openai: { phase: null } },
},
])
const prepared = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
expect(prepared.body.input).toEqual([
{
type: "message",
id: "msg_commentary",
role: "assistant",
content: [{ type: "output_text", text: "Checking." }],
phase: "commentary",
},
{
type: "message",
id: "msg_final",
role: "assistant",
content: [{ type: "output_text", text: "Finished." }],
phase: "final_answer",
},
{
type: "message",
id: "msg_null",
role: "assistant",
content: [{ type: "output_text", text: "Unclassified." }],
phase: null,
@@ -1772,12 +1642,12 @@ describe("OpenAI Responses route", () => {
)
expect(response.events.filter((event) => event.type.startsWith("text-"))).toEqual([
{ type: "text-start", id: "msg_1", providerMetadata: { openai: { itemId: "msg_1" } } },
{ type: "text-start", id: "msg_1" },
{ type: "text-delta", id: "msg_1", text: "First" },
{ type: "text-end", id: "msg_1", providerMetadata: undefined },
{ type: "text-start", id: "msg_2", providerMetadata: { openai: { itemId: "msg_2" } } },
{ type: "text-end", id: "msg_1" },
{ type: "text-start", id: "msg_2" },
{ type: "text-delta", id: "msg_2", text: "Second" },
{ type: "text-end", id: "msg_2", providerMetadata: { openai: { itemId: "msg_2" } } },
{ type: "text-end", id: "msg_2" },
])
}),
)
@@ -1809,7 +1679,7 @@ describe("OpenAI Responses route", () => {
expect(response.events.filter((event) => event.type === "finish")).toHaveLength(1)
expect(response.message.content).toEqual([
{ type: "reasoning", text: "thinking" },
{ type: "text", text: "Hello", providerMetadata: { openai: { itemId: "msg_1" } } },
{ type: "text", text: "Hello" },
])
}),
)
@@ -1970,7 +1840,6 @@ describe("OpenAI Responses route", () => {
{ role: "user", content: [{ type: "input_text", text: "What changed?" }] },
{
type: "reasoning",
id: "rs_1",
encrypted_content: "encrypted-state",
summary: [{ type: "summary_text", text: "Checked the previous diff." }],
},
@@ -1978,6 +1847,7 @@ describe("OpenAI Responses route", () => {
{ role: "user", content: [{ type: "input_text", text: "Summarize it." }] },
],
})
expect(body.input[1]).not.toHaveProperty("id")
return input.respond(
sseEvents(
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Parser now round-trips reasoning." },
@@ -2021,14 +1891,13 @@ describe("OpenAI Responses route", () => {
)
expect(prepared.body.input).toEqual([
{ type: "message", role: "assistant", content: [{ type: "output_text", text: "Before." }] },
{ role: "assistant", content: [{ type: "output_text", text: "Before." }] },
{
type: "reasoning",
id: "rs_1",
encrypted_content: "encrypted-state",
summary: [{ type: "summary_text", text: "Checked order." }],
},
{ type: "message", role: "assistant", content: [{ type: "output_text", text: "After." }] },
{ role: "assistant", content: [{ type: "output_text", text: "After." }] },
])
}),
)
@@ -2160,7 +2029,6 @@ describe("OpenAI Responses route", () => {
expect(prepared.body.input).toEqual([
{
type: "reasoning",
id: "rs_1",
encrypted_content: "encrypted-state",
summary: [
{ type: "summary_text", text: "First" },
@@ -2293,50 +2161,6 @@ describe("OpenAI Responses route", () => {
usage,
},
])
const prepared = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
expect(prepared.body.input).toEqual([
{
type: "function_call",
id: "item_1",
call_id: "call_1",
name: "lookup",
arguments: '{"query":"weather"}',
},
])
}),
)
it.effect("finalizes a pending function call at response completion", () =>
Effect.gen(function* () {
const body = sseEvents(
{
type: "response.output_item.added",
item: { type: "function_call", id: "item_1", call_id: "call_1", name: "lookup", arguments: "" },
},
{ type: "response.completed", response: { usage: { input_tokens: 5, output_tokens: 1 } } },
)
const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)))
expect(response.events.filter((event) => LLMEvent.is.toolInputEnd(event) || LLMEvent.is.toolCall(event))).toEqual(
[
{
type: "tool-input-end",
id: "call_1",
name: "lookup",
providerMetadata: { openai: { itemId: "item_1" } },
},
{
type: "tool-call",
id: "call_1",
name: "lookup",
input: {},
providerExecuted: undefined,
providerMetadata: { openai: { itemId: "item_1" } },
},
],
)
expect(response.finishReason.normalized).toBe("tool-calls")
}),
)
@@ -2403,35 +2227,6 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("retains function call item metadata when output_item.added is absent", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{
type: "response.output_item.done",
item: {
type: "function_call",
id: "item_1",
call_id: "call_1",
name: "lookup",
arguments: '{"query":"weather"}',
},
},
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.events.find(LLMEvent.is.toolCall)).toMatchObject({
id: "call_1",
providerMetadata: { openai: { itemId: "item_1" } },
})
}),
)
it.effect("decodes web_search_call as provider-executed tool-call + tool-result", () =>
Effect.gen(function* () {
const item = {
+2 -10
View File
@@ -1,7 +1,5 @@
import { HttpRecorder } from "@opencode-ai/http-recorder"
import { NodeSocket } from "@effect/platform-node"
import { Layer } from "effect"
import { Socket } from "effect/unstable/socket"
import * as path from "node:path"
import { fileURLToPath } from "node:url"
import { LLMClient, RequestExecutor } from "../src/route.js"
@@ -18,7 +16,7 @@ import {
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const FIXTURES_DIR = path.resolve(__dirname, "fixtures", "recordings")
type RecordedEnv = RequestExecutorService | LLMClientService | ImageClientService | Socket.WebSocketConstructor
type RecordedEnv = RequestExecutorService | LLMClientService | ImageClientService
type RecordedTestsOptions = RecordedGroupOptions & {
readonly options?: HttpRecorder.RecorderOptions
@@ -71,7 +69,7 @@ export const recordedTests = (options: RecordedTestsOptions) =>
...metadata,
}
if (recording) {
if (process.env.CI !== undefined) throw new Error("Unset CI before recording cassettes")
if (process.env.CI !== undefined) throw new Error("Unset CI before recording HTTP cassettes")
HttpRecorder.removeCassetteSync(cassette, { directory: FIXTURES_DIR })
}
const requestExecutor = RequestExecutor.layer.pipe(
@@ -83,16 +81,10 @@ export const recordedTests = (options: RecordedTestsOptions) =>
}),
),
)
const webSocket = HttpRecorder.layerWebSocketConstructor(cassette, {
...recorderOptions,
directory: FIXTURES_DIR,
metadata: recorderMetadata,
}).pipe(Layer.provide(NodeSocket.layerWebSocketConstructorWS))
return Layer.mergeAll(
requestExecutor,
LLMClient.layer.pipe(Layer.provide(requestExecutor)),
ImageClient.layer.pipe(Layer.provide(requestExecutor)),
webSocket,
)
},
})
-22
View File
@@ -2,34 +2,12 @@
The app's high-volume performance diagnostics live under `packages/app/e2e/performance` and are excluded from normal local and CI Playwright discovery. The benchmark config builds the app and serves the production bundle before running scenarios serially.
The `devex` category is the explicit exception to the production-build rule. It measures development commands from submission through a user-visible ready state and has its own Playwright configuration.
Run the suite explicitly from `packages/app`:
```sh
bun run test:bench
```
Run the desktop development startup benchmark from the repository root:
```sh
bun run bench:devex
```
It runs five serial samples of the exact `bun dev:desktop` command. Each sample uses a fresh desktop profile, database, service configuration, service registration, and service process; the desktop selects an isolated ephemeral loopback endpoint. It removes desktop build output and the desktop Vite cache before every run; dependencies, Bun's package cache, and Electron remain installed. The harness stops only that sample's service; it does not stop or change the elected global OpenCode service. The measured endpoint is a visible Home page whose empty-state controls pass Playwright actionability checks. The command's Electron installation check remains inside the measured interval.
Set `DESKTOP_STARTUP_RUNS` only for focused diagnostics:
```sh
DESKTOP_STARTUP_RUNS=1 bun run bench:devex
```
Set `OPENCODE_PERFORMANCE_TRACE_DIR` to capture the renderer's CDP trace from attachment through actionable Home:
```sh
DESKTOP_STARTUP_RUNS=1 OPENCODE_PERFORMANCE_TRACE_DIR=/tmp/opencode-desktop-traces bun run bench:devex
```
PowerShell:
```powershell
+2 -2
View File
@@ -18,9 +18,9 @@ const categories = [
"disabled-by-default-v8.cpu_profiler",
]
export async function startChromeTrace(page: Page, name: string): Promise<undefined | (() => Promise<string>)> {
export async function startChromeTrace(page: Page, name: string) {
const directory = process.env.OPENCODE_PERFORMANCE_TRACE_DIR
if (!directory) return undefined
if (!directory) return
const selectors = process.env.OPENCODE_PERFORMANCE_SELECTOR_TRACE === "1"
const file = await prepareChromeTrace(directory, name, selectors)
@@ -1,26 +0,0 @@
import { benchmark } from "../benchmark"
import {
desktopBenchmarkContext,
runDesktopStartup,
summarizeDesktopStartup,
type DesktopStartupSample,
} from "./desktop-startup"
benchmark.describe("devex: desktop startup", () => {
benchmark("opens a cold desktop on Home", async ({ report }, testInfo) => {
benchmark.setTimeout(15 * 60_000)
const runs = Number(process.env.DESKTOP_STARTUP_RUNS ?? 5)
if (!Number.isSafeInteger(runs) || runs < 1) throw new Error("DESKTOP_STARTUP_RUNS must be a positive integer")
const samples: DesktopStartupSample[] = []
const context = await desktopBenchmarkContext(runs)
for (let run = 1; run <= runs; run++) {
const sample = await runDesktopStartup(run, testInfo).catch((error) => {
report(samples.length ? { samples, summary: summarizeDesktopStartup(samples) } : { samples }, context)
throw error
})
samples.push(sample)
}
report({ samples, summary: summarizeDesktopStartup(samples) }, context)
})
})
@@ -1,526 +0,0 @@
import { Service } from "@opencode-ai/client/service"
import { chromium, expect, type Browser, type Page, type TestInfo } from "@playwright/test"
import { spawn, spawnSync, type ChildProcess } from "node:child_process"
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"
import { tmpdir } from "node:os"
import { join, resolve } from "node:path"
import { startChromeTrace } from "../chrome-trace"
const repository = resolve(import.meta.dirname, "../../../../..")
const milestones = [
"bunRootScript",
"bunDesktopScript",
"desktopPrepared",
"mainBundleReady",
"preloadBundleReady",
"rendererDevServerReady",
"electronSpawnStarted",
"debugEndpointReady",
"electronStarted",
"serviceEnsureStarted",
"serviceSpawnRequested",
"serviceReady",
"backgroundLoadingReady",
"rendererViteConnected",
"rendererInitializationStarted",
"rendererInitializationReady",
"windowVisible",
"homeReady",
] as const
const phases = [
"desktopPreparation",
"viteMainBundle",
"vitePreloadBundle",
"rendererServerStartup",
"electronStartup",
"serviceSpawnWait",
"serviceProcessStartup",
"rendererStartup",
"visibleWindowToHome",
] as const
type Milestone = (typeof milestones)[number]
type Phase = (typeof phases)[number]
type ServiceInfo = { id: string; version: string; url: string; pid: number }
export type DesktopStartupSample = {
run: number
commandToHomeReadyMs: number
milestonesMs: Record<Milestone, number>
phasesMs: Record<Phase, number>
service: Omit<ServiceInfo, "id">
}
export async function runDesktopStartup(run: number, testInfo: TestInfo) {
const profile = await createColdProfile()
const desktop = await Promise.resolve()
.then(() => startDesktop(profile))
.catch(async (error) => {
await rm(profile.root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })
throw error
})
try {
const page = await desktop.open()
const stopTrace = await startChromeTrace(page, `desktop-startup-${run}`)
try {
await startThemeObservation(page)
await waitForHome(page, desktop.mark)
await requireStableTheme(page)
return await desktop.result(run)
} finally {
await stopTrace?.()
}
} finally {
await desktop.close(testInfo, run)
}
}
export async function desktopBenchmarkContext(runs: number) {
const pkg = JSON.parse(await readFile(join(repository, "packages/desktop/package.json"), "utf8"))
const revision = spawnSync("git", ["rev-parse", "HEAD"], { cwd: repository })
if (revision.status !== 0) throw new Error("Failed to read the benchmark Git revision")
const status = spawnSync("git", ["status", "--porcelain"], { cwd: repository })
if (status.status !== 0) throw new Error("Failed to read the benchmark Git status")
const bun = spawnSync("bun", ["--version"], { cwd: repository })
if (bun.status !== 0) throw new Error("Failed to read the benchmark Bun version")
return {
arch: process.arch,
command: "bun dev:desktop",
runs,
profile: "fresh",
service: "isolated-cold",
install: "complete",
viteCache: "cold",
electronInstall: "present",
bunVersion: bun.stdout.toString().trim(),
electronVersion: pkg.devDependencies.electron,
electronViteVersionRange: pkg.devDependencies["electron-vite"],
gitCommit: revision.stdout.toString().trim(),
gitDirty: status.stdout.length > 0,
trace: Boolean(process.env.OPENCODE_PERFORMANCE_TRACE_DIR),
}
}
export function summarizeDesktopStartup(samples: DesktopStartupSample[]) {
return {
commandToHomeReadyMs: statistics(samples.map((sample) => sample.commandToHomeReadyMs)),
milestonesMs: Object.fromEntries(
milestones.map((name) => [name, statistics(samples.map((sample) => sample.milestonesMs[name]))]),
),
phasesMs: Object.fromEntries(
phases.map((name) => [name, statistics(samples.map((sample) => sample.phasesMs[name]))]),
),
}
}
export function milestoneForLine(line: string): Milestone | undefined {
const text = stripAnsi(line)
return milestonePatterns.find((item) => text.includes(item.text))?.name
}
const milestonePatterns: ReadonlyArray<{ name: Milestone; text: string }> = [
{ name: "bunRootScript", text: "$ bun --cwd packages/desktop dev" },
{ name: "bunDesktopScript", text: "$ bun ./scripts/dev.ts" },
{ name: "desktopPrepared", text: "Copied dev icons from" },
{ name: "mainBundleReady", text: "electron main process built successfully" },
{ name: "preloadBundleReady", text: "electron preload scripts built successfully" },
{ name: "rendererDevServerReady", text: "dev server running for the electron renderer process at:" },
{ name: "electronSpawnStarted", text: "starting electron app..." },
{ name: "debugEndpointReady", text: "DevTools listening on ws://" },
{ name: "electronStarted", text: "app starting" },
{ name: "serviceEnsureStarted", text: "starting v2 background service" },
{ name: "serviceSpawnRequested", text: "v2 CLI background service starting" },
{ name: "serviceReady", text: "v2 CLI background service ready" },
{ name: "backgroundLoadingReady", text: "loading task finished" },
{ name: "rendererViteConnected", text: "[vite] connected." },
{ name: "rendererInitializationStarted", text: "awaiting server ready" },
{ name: "rendererInitializationReady", text: "server ready" },
{ name: "windowVisible", text: "main window visible" },
]
async function createColdProfile() {
await Promise.all(
["packages/desktop/node_modules/.vite", "packages/desktop/out"].map((path) =>
rm(join(repository, path), { recursive: true, force: true }),
),
)
const root = await mkdtemp(join(tmpdir(), "opencode-desktop-startup-"))
return initializeColdProfile(root).catch(async (error) => {
await rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })
throw error
})
}
async function initializeColdProfile(root: string) {
await Promise.all(
["data", "config", "cache", "state", "desktop", "session", "home"].map((dir) =>
mkdir(join(root, dir), { recursive: true }),
),
)
await Promise.all([
writeFile(
join(root, "desktop", "opencode.settings"),
JSON.stringify({ firstLaunchOnboardingComplete: true }),
),
writeFile(join(root, "desktop", "opencode.global.dat"), JSON.stringify({ language: '{"locale":"en"}' })),
])
const registration = join(root, "desktop", "opencode", "service-local.json")
await Service.stop({ file: registration })
return { root, registration }
}
function startDesktop(profile: Awaited<ReturnType<typeof createColdProfile>>) {
const started = performance.now()
const child = spawn("bun", ["dev:desktop"], {
cwd: repository,
detached: process.platform !== "win32",
env: {
...process.env,
OPENCODE_CONFIG_DIR: join(profile.root, "config"),
OPENCODE_DB: join(profile.root, "data", "opencode.db"),
OPENCODE_TEST_HOME: join(profile.root, "home"),
OPENCODE_TEST_ONBOARDING: "0",
OPENCODE_DESKTOP_TEST_ROOT: profile.root,
OPENCODE_DESKTOP_REMOTE_DEBUGGING_PORT: "0",
OPENCODE_DESKTOP_DISABLE_PROTOCOL_REGISTRATION: "1",
},
stdio: ["ignore", "pipe", "pipe"],
})
if (!child.pid || !child.stdout || !child.stderr) throw new Error("Failed to start the desktop command")
const exited = childExit(child)
const observed: Partial<Record<Milestone, number>> = {}
const endpoint = Promise.withResolvers<string>()
const pageErrors: string[] = []
let browser: Browser | undefined
let service: ServiceInfo | undefined
const mark = (name: Milestone) => {
observed[name] ??= elapsed(started)
}
const record = (line: string) => {
const milestone = milestoneForLine(line)
if (milestone) mark(milestone)
const match = stripAnsi(line).match(/DevTools listening on (ws:\/\/\S+)/)
if (match?.[1]) endpoint.resolve(match[1])
}
const stdout = observeOutput(child.stdout, record)
const stderr = observeOutput(child.stderr, record)
return {
mark,
async open() {
const url = await Promise.race([
endpoint.promise,
exited.then((code) => {
throw new Error(`Desktop command exited with code ${code} before opening its debug endpoint`)
}),
sleep(120_000).then(() => {
throw new Error("Timed out waiting for the desktop debug endpoint")
}),
])
browser = await chromium.connectOverCDP(url, { timeout: 120_000 })
const context = browser.contexts()[0]
if (!context) throw new Error("Electron did not expose a browser context")
await expect.poll(() => context.pages().length, { timeout: 120_000 }).toBeGreaterThan(0)
const page = context.pages()[0]
if (!page) throw new Error("Electron did not expose a renderer page")
page.on("pageerror", (error) => pageErrors.push(error.stack ?? error.message))
return page
},
async result(run: number): Promise<DesktopStartupSample> {
if (pageErrors.length) throw new Error(`Desktop renderer reported errors:\n\n${pageErrors.join("\n\n")}`)
service = await readService(profile)
const milestonesMs = requireMilestones(observed)
return {
run,
commandToHomeReadyMs: milestonesMs.homeReady,
milestonesMs,
phasesMs: calculatePhases(milestonesMs),
service: {
version: service.version,
url: service.url,
pid: service.pid,
},
}
},
async close(testInfo: TestInfo, run: number) {
const errors: unknown[] = []
await browser?.close().catch(() => undefined)
await stopProcessTree(child, exited).catch((error) => {
errors.push(error)
child.stdout?.destroy()
child.stderr?.destroy()
})
const [stdoutText, stderrText] = await Promise.all([stdout, stderr]).catch((error) => {
errors.push(error)
return ["", ""]
})
await Promise.all([
testInfo.attach(`desktop-startup-${run}-stdout`, { body: stdoutText, contentType: "text/plain" }),
testInfo.attach(`desktop-startup-${run}-stderr`, { body: stderrText, contentType: "text/plain" }),
pageErrors.length
? testInfo.attach(`desktop-startup-${run}-page-errors`, {
body: pageErrors.join("\n\n"),
contentType: "text/plain",
})
: Promise.resolve(),
]).catch((error) => errors.push(error))
await Service.stop({ file: profile.registration }).catch((error) => errors.push(error))
if (service && processAlive(service.pid))
errors.push(new Error(`Desktop service process ${service.pid} did not stop`))
await rm(profile.root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }).catch((error) =>
errors.push(error),
)
if (errors.length) throw new AggregateError(errors, "Desktop benchmark cleanup failed")
},
}
}
async function waitForHome(page: Page, mark: (name: Milestone) => void) {
await expect.poll(() => page.evaluate(() => document.visibilityState), { timeout: 120_000 }).toBe("visible")
const projects = page.getByRole("complementary", { name: "Projects", exact: true })
const sessions = page.getByRole("region", { name: "Recent sessions", exact: true })
const search = page.getByRole("textbox", { name: "Search sessions", exact: true })
const addProject = projects.locator('button[data-action="home-add-project-row"]')
await expect(projects).toBeVisible({ timeout: 120_000 })
await expect(sessions).toBeVisible()
await expect(search).toBeEditable()
await expect(sessions.getByText("Nothing here yet", { exact: true })).toBeVisible()
await expect(addProject).toHaveCount(1)
await addProject.click({ trial: true })
mark("homeReady")
}
type ThemeWindow = Window & {
__OPENCODE_THEME_STATES__?: string[]
__OPENCODE_THEME_OBSERVER__?: MutationObserver
}
async function startThemeObservation(page: Page) {
await page.addInitScript(installThemeObservation)
await page.evaluate(installThemeObservation)
}
async function requireStableTheme(page: Page) {
const states = await page.evaluate(() => {
const target = window as ThemeWindow
target.__OPENCODE_THEME_OBSERVER__?.disconnect()
return target.__OPENCODE_THEME_STATES__ ?? []
})
if (states.length !== 1) throw new Error(`Desktop theme changed during startup: ${states.join(" -> ")}`)
}
function installThemeObservation() {
const target = window as ThemeWindow
const observeRoot = () => {
const root = document.documentElement
if (!root) return false
const state = () => {
const theme = root.dataset.theme
const scheme = root.dataset.colorScheme
return theme && scheme ? `${theme}:${scheme}` : undefined
}
const initial = state()
target.__OPENCODE_THEME_STATES__ = initial ? [initial] : []
target.__OPENCODE_THEME_OBSERVER__ = new MutationObserver(() => {
const next = state()
if (!next) return
if (target.__OPENCODE_THEME_STATES__?.at(-1) !== next) target.__OPENCODE_THEME_STATES__?.push(next)
})
target.__OPENCODE_THEME_OBSERVER__.observe(root, {
attributes: true,
attributeFilter: ["data-theme", "data-color-scheme"],
})
return true
}
if (observeRoot()) return
const documentObserver = new MutationObserver(() => {
if (!observeRoot()) return
documentObserver.disconnect()
})
target.__OPENCODE_THEME_OBSERVER__ = documentObserver
documentObserver.observe(document, { childList: true })
}
async function observeOutput(stream: NodeJS.ReadableStream, record: (line: string) => void) {
const decoder = new TextDecoder()
const output: string[] = []
let pending = ""
for await (const chunk of stream) {
const text = typeof chunk === "string" ? chunk : decoder.decode(chunk, { stream: true })
output.push(text)
pending += text
const lines = pending.split(/\r?\n/)
pending = lines.pop() ?? ""
lines.forEach(record)
}
const final = decoder.decode()
output.push(final)
pending += final
if (pending) record(pending)
return output.join("")
}
async function readService(profile: Awaited<ReturnType<typeof createColdProfile>>) {
const value: unknown = JSON.parse(await readFile(profile.registration, "utf8"))
if (!isServiceInfo(value)) throw new Error("Desktop service registration is invalid")
const url = new URL(value.url)
const port = Number(url.port)
if (url.hostname !== "127.0.0.1" || !Number.isInteger(port) || port <= 0)
throw new Error(`Desktop service used unexpected endpoint ${value.url}`)
if (!value.version.startsWith("2.0.0-local-"))
throw new Error(`Desktop service used unexpected version ${value.version}`)
return value
}
function isServiceInfo(value: unknown): value is ServiceInfo {
return (
typeof value === "object" &&
value !== null &&
"id" in value &&
typeof value.id === "string" &&
"version" in value &&
typeof value.version === "string" &&
"url" in value &&
typeof value.url === "string" &&
"pid" in value &&
typeof value.pid === "number"
)
}
function requireMilestones(observed: Partial<Record<Milestone, number>>) {
const get = (name: Milestone) => {
const value = observed[name]
if (value === undefined) throw new Error(`Desktop startup did not report milestone: ${name}`)
return round(value)
}
return {
bunRootScript: get("bunRootScript"),
bunDesktopScript: get("bunDesktopScript"),
desktopPrepared: get("desktopPrepared"),
mainBundleReady: get("mainBundleReady"),
preloadBundleReady: get("preloadBundleReady"),
rendererDevServerReady: get("rendererDevServerReady"),
electronSpawnStarted: get("electronSpawnStarted"),
debugEndpointReady: get("debugEndpointReady"),
electronStarted: get("electronStarted"),
serviceEnsureStarted: get("serviceEnsureStarted"),
serviceSpawnRequested: get("serviceSpawnRequested"),
serviceReady: get("serviceReady"),
backgroundLoadingReady: get("backgroundLoadingReady"),
rendererViteConnected: get("rendererViteConnected"),
rendererInitializationStarted: get("rendererInitializationStarted"),
rendererInitializationReady: get("rendererInitializationReady"),
windowVisible: get("windowVisible"),
homeReady: get("homeReady"),
}
}
function calculatePhases(value: Record<Milestone, number>): Record<Phase, number> {
return {
desktopPreparation: value.desktopPrepared,
viteMainBundle: round(value.mainBundleReady - value.desktopPrepared),
vitePreloadBundle: round(value.preloadBundleReady - value.mainBundleReady),
rendererServerStartup: round(value.rendererDevServerReady - value.preloadBundleReady),
electronStartup: round(value.electronStarted - value.electronSpawnStarted),
serviceSpawnWait: round(value.serviceSpawnRequested - value.serviceEnsureStarted),
serviceProcessStartup: round(value.serviceReady - value.serviceSpawnRequested),
rendererStartup: round(value.homeReady - value.rendererViteConnected),
visibleWindowToHome: round(value.homeReady - value.windowVisible),
}
}
function statistics(values: number[]) {
if (!values.length) throw new Error("Cannot summarize an empty benchmark")
const sorted = values.toSorted((left, right) => left - right)
const median = medianOf(sorted)
return {
min: round(sorted[0]),
median: round(median),
max: round(sorted.at(-1)!),
medianAbsoluteDeviation: round(medianOf(sorted.map((value) => Math.abs(value - median)).toSorted((a, b) => a - b))),
}
}
function medianOf(sorted: number[]) {
const middle = Math.floor(sorted.length / 2)
if (sorted.length % 2) return sorted[middle]
return (sorted[middle - 1] + sorted[middle]) / 2
}
async function stopProcessTree(child: ChildProcess, exited: Promise<number | null>) {
if (!child.pid) throw new Error("Desktop command has no process ID")
if (process.platform !== "win32") return stopProcessGroup(child.pid, exited)
if (child.exitCode !== null || (await exitsWithin(child, exited, 2_000))) return
const kill = spawn("taskkill.exe", ["/PID", String(child.pid), "/T", "/F"], {
stdio: "ignore",
})
await childExit(kill)
if (await exitsWithin(child, exited, 10_000)) return
if (!(await exitsWithin(child, exited, 5_000))) throw new Error(`Desktop command process ${child.pid} did not stop`)
}
async function stopProcessGroup(pid: number, exited: Promise<number | null>) {
await Promise.race([exited, sleep(2_000)])
if (!processGroupAlive(pid)) return
process.kill(-pid, "SIGTERM")
if (await processGroupStopsWithin(pid, 10_000)) return
process.kill(-pid, "SIGKILL")
if (!(await processGroupStopsWithin(pid, 5_000))) throw new Error(`Desktop command process group ${pid} did not stop`)
}
async function processGroupStopsWithin(pid: number, timeout: number) {
const deadline = Date.now() + timeout
while (Date.now() < deadline) {
if (!processGroupAlive(pid)) return true
await sleep(50)
}
return !processGroupAlive(pid)
}
function processGroupAlive(pid: number) {
try {
process.kill(-pid, 0)
return true
} catch {
return false
}
}
async function exitsWithin(child: ChildProcess, exited: Promise<number | null>, timeout: number) {
if (child.exitCode !== null) return true
const result = await Promise.race([exited.then(() => true), sleep(timeout).then(() => false)])
return result
}
function childExit(child: ChildProcess) {
return new Promise<number | null>((resolve, reject) => {
child.once("error", reject)
child.once("exit", (code) => resolve(code))
})
}
function sleep(milliseconds: number) {
return new Promise<void>((resolve) => setTimeout(resolve, milliseconds))
}
function processAlive(pid: number) {
try {
process.kill(pid, 0)
return true
} catch {
return false
}
}
function stripAnsi(value: string) {
return value.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "")
}
function elapsed(started: number) {
return round(performance.now() - started)
}
function round(value: number) {
return Math.round(value * 100) / 100
}
@@ -1,17 +0,0 @@
import { defineConfig } from "@playwright/test"
process.env.OPENCODE_PERFORMANCE_RUN_ID ??= `${new Date().toISOString().replace(/[:.]/g, "-")}-${process.pid}`
export default defineConfig({
testDir: ".",
testMatch: "desktop-startup-benchmark.spec.ts",
outputDir: "../../test-results/performance-devex",
timeout: 15 * 60_000,
expect: {
timeout: 120_000,
},
fullyParallel: false,
workers: 1,
reporter: [["html", { outputFolder: "../../playwright-report/performance-devex", open: "never" }], ["line"]],
projects: [{ name: "desktop" }],
})
@@ -7,7 +7,7 @@ process.env.OPENCODE_PERFORMANCE_RUN_ID ??= `${new Date().toISOString().replace(
export default {
...config,
testDir: ".",
testIgnore: ["unit/**", "devex/**"],
testIgnore: "unit/**",
outputDir: "../test-results/performance",
fullyParallel: false,
workers: 1,
@@ -179,7 +179,7 @@ test.describe("timeline adverse visual stability", () => {
userMessage(),
assistantMessage([
shell(shellID, "completed", wideLines(15)),
toolPart(contextIDs[0]!, "read", "completed", { path: "src/a.ts" }),
toolPart(contextIDs[0]!, "read", "completed", { filePath: "src/a.ts" }),
toolPart(contextIDs[1]!, "glob", "completed", { path: ".", pattern: "**/*.ts" }),
textPart(followingID, "Following responsive timeline content that wraps on narrow screens."),
]),
@@ -1,5 +1,4 @@
import { test } from "@playwright/test"
import { createTwoFilesPatch } from "diff"
import {
defineVisualRegions,
reportVisualStability,
@@ -19,20 +18,16 @@ import {
} from "./fixture"
const profiles = [
{
name: "edit",
tool: "edit",
input: { path: "src/edit.ts", oldString: "export const value = 1", newString: "export const value = 2" },
},
{ name: "edit", tool: "edit", input: { filePath: "src/edit.ts" } },
{
name: "multi patch",
tool: "patch",
input: { patchText: "Update generated files" },
tool: "apply_patch",
input: { files: ["src/a.ts", "src/b.ts", "src/old.ts", "src/moved.ts"] },
},
] as const
for (const profile of profiles) {
test(`stabilizes ${profile.name} streaming to completed`, async ({ page }, testInfo) => {
test(`stabilizes ${profile.name} pending to completed`, async ({ page }, testInfo) => {
const partID = `prt_file_matrix_${profiles.indexOf(profile)}`
const followingID = `prt_file_matrix_following_${profiles.indexOf(profile)}`
const timeline = await setupTimeline(page, {
@@ -40,7 +35,7 @@ for (const profile of profiles) {
userMessage(),
assistantMessage(
[
toolPart(partID, profile.tool, "streaming", profile.input),
toolPart(partID, profile.tool, "pending", profile.input),
textPart(followingID, `Following ${profile.name}`),
],
{ completed: false },
@@ -94,27 +89,34 @@ function completedPart(partID: string, profile: (typeof profiles)[number]) {
if (profile.tool === "edit") {
return toolPart(partID, profile.tool, "completed", profile.input, {
metadata: {
files: [patchFile("src/edit.ts", "modified", 50)],
filediff: {
file: "src/edit.ts",
additions: 50,
deletions: 50,
before: source(50, false),
after: source(50, true),
},
},
})
}
const files = [
patchFile("src/a.ts", "modified", 20),
patchFile("src/b.ts", "added", 20),
patchFile("src/old.ts", "deleted", 20),
patchFile("src/a.ts", "update"),
patchFile("src/b.ts", "add"),
patchFile("src/old.ts", "delete"),
{ ...patchFile("src/moved.ts", "move"), move: "src/new-place.ts" },
]
return toolPart(partID, profile.tool, "completed", profile.input, { metadata: { files } })
}
function patchFile(file: string, status: "added" | "modified" | "deleted", lines: number) {
const before = status === "added" ? "" : source(lines, false)
const after = status === "deleted" ? "" : source(lines, true)
function patchFile(filePath: string, type: "add" | "update" | "delete" | "move") {
return {
file,
status,
patch: createTwoFilesPatch(`a/${file}`, `b/${file}`, before, after),
additions: status === "deleted" ? 0 : lines,
deletions: status === "added" ? 0 : lines,
filePath,
relativePath: filePath,
type,
additions: type === "delete" ? 0 : 20,
deletions: type === "add" ? 0 : 20,
before: type === "add" ? undefined : source(20, false),
after: type === "delete" ? undefined : source(20, true),
}
}
@@ -1,5 +1,4 @@
import { expect, test } from "@playwright/test"
import { createTwoFilesPatch } from "diff"
import {
defineVisualRegions,
reportVisualStability,
@@ -21,13 +20,13 @@ import {
test("adds patch files incrementally without resetting outer expansion", async ({ page }, testInfo) => {
const patchID = "prt_incremental_01_patch"
const followingID = "prt_incremental_02_following"
const first = patchFile("src/a.ts", "modified")
const first = patchFile("src/a.ts", "update")
const timeline = await setupTimeline(page, {
messages: [
userMessage(),
assistantMessage(
[
toolPart(patchID, "patch", "running", { patchText: "Update files" }, { metadata: { files: [first] } }),
toolPart(patchID, "apply_patch", "running", { files: [first.filePath] }, { metadata: { files: [first] } }),
textPart(followingID, "Following incremental patch"),
],
{ completed: false },
@@ -56,15 +55,15 @@ test("adds patch files incrementally without resetting outer expansion", async (
},
})
await startVisualProbe(page, regions)
const second = patchFile("src/b.ts", "added")
const third = patchFile("src/old.ts", "deleted")
const second = patchFile("src/b.ts", "add")
const third = patchFile("src/old.ts", "delete")
await timeline.send(
partUpdated(
toolPart(
patchID,
"patch",
"apply_patch",
"running",
{ patchText: "Update files" },
{ files: [first.filePath, second.filePath] },
{ metadata: { files: [first, second] } },
),
),
@@ -74,9 +73,9 @@ test("adds patch files incrementally without resetting outer expansion", async (
partUpdated(
toolPart(
patchID,
"patch",
"apply_patch",
"completed",
{ patchText: "Update files" },
{ files: [first.filePath, second.filePath, third.filePath] },
{ metadata: { files: [first, second, third] } },
),
),
@@ -107,15 +106,15 @@ test("adds patch files incrementally without resetting outer expansion", async (
await expect(page.locator('[data-scope="apply-patch"] [data-type="delete"]')).toBeVisible()
})
function patchFile(file: string, status: "added" | "modified" | "deleted") {
const before = status === "added" ? "" : source(false)
const after = status === "deleted" ? "" : source(true)
function patchFile(filePath: string, type: "add" | "update" | "delete") {
return {
file,
status,
patch: createTwoFilesPatch(`a/${file}`, `b/${file}`, before, after),
additions: status === "deleted" ? 0 : 4,
deletions: status === "added" ? 0 : 3,
filePath,
relativePath: filePath,
type,
additions: type === "delete" ? 0 : 4,
deletions: type === "add" ? 0 : 3,
before: type === "add" ? undefined : source(false),
after: type === "delete" ? undefined : source(true),
}
}
@@ -35,7 +35,7 @@ describe("timeline fixture validation", () => {
userMessage(),
{
...assistantMessage(),
content: [{ type: "tool", id: "call_invalid", name: "shell", state: { status: "completed" } }],
content: [{ type: "tool", id: "call_invalid", name: "bash", state: { status: "completed" } }],
} as never,
]),
).toThrow()
@@ -60,11 +60,12 @@ if (false) {
const userSeed = { id: "prt_type_user", type: "text", text: "typed" } satisfies PartSeed<"user">
userMessage([userSeed])
// @ts-expect-error Tool completion fields are not valid while streaming.
toolPart("prt_invalid_streaming", "shell", "streaming", {}, { output: "impossible" })
toolPart("prt_valid_running", "shell", "running", {}, { output: "progressive output" })
// @ts-expect-error Tool completion fields are not valid while pending.
toolPart("prt_invalid_pending", "bash", "pending", {}, { output: "impossible" })
// @ts-expect-error Tool completion fields are not valid while running.
toolPart("prt_invalid_running", "bash", "running", {}, { output: "impossible" })
// @ts-expect-error Tool error fields are not valid after completion.
toolPart("prt_invalid_completed", "shell", "completed", {}, { error: "impossible" })
toolPart("prt_invalid_completed", "bash", "completed", {}, { error: "impossible" })
assistantMessage([
// @ts-expect-error Agent references belong to user messages, not assistant messages.
@@ -60,17 +60,17 @@ type ReasoningSeed = {
type ToolSeed = {
id: string
type: "tool"
name: string
callID: string
tool: string
messageID?: string
executed?: boolean
providerState?: Record<string, unknown>
providerResultState?: Record<string, unknown>
state:
| { status: "streaming"; input: Record<string, unknown>; raw: string }
| { status: "pending"; input: Record<string, unknown>; raw: string }
| {
status: "running"
input: Record<string, unknown>
output?: string
title?: string
metadata: Record<string, unknown>
time: { start: number }
@@ -100,10 +100,10 @@ export type PartSeed<Owner extends "user" | "assistant"> = Owner extends "user"
? TextSeed | FileSeed | AgentSeed
: TextSeed | ReasoningSeed | ToolSeed
type ToolOptions<State extends ToolStatus> = State extends "streaming"
type ToolOptions<State extends ToolStatus> = State extends "pending"
? { output?: never; title?: never; metadata?: never; error?: never }
: State extends "running"
? { title?: string; metadata?: Record<string, unknown>; output?: string; error?: never }
? { title?: string; metadata?: Record<string, unknown>; output?: never; error?: never }
: State extends "error"
? { error?: string; metadata?: Record<string, unknown>; output?: never; title?: never }
: { output?: string; title?: string; metadata?: Record<string, unknown>; error?: never }
@@ -371,15 +371,6 @@ export function partUpdated(part: PartSeed<"assistant">): readonly OpenCodeEvent
}
if (part.type === "reasoning") {
startedParts.add(part.id)
if (!started && !part.text)
return [
makeEvent("session.reasoning.started", {
sessionID,
assistantMessageID: messageID,
ordinal: ref.ordinal!,
state: jsonRecord(part.metadata),
}),
]
return [
...(started
? []
@@ -551,9 +542,9 @@ export function reasoningPart(id: string, text: string): ReasoningSeed {
export function toolPart(
id: string,
tool: string,
state: "streaming",
state: "pending",
input: Record<string, unknown>,
options?: ToolOptions<"streaming">,
options?: ToolOptions<"pending">,
): ToolSeed
export function toolPart(
id: string,
@@ -583,15 +574,14 @@ export function toolPart(
input: Record<string, unknown>,
options: ToolOptions<ToolStatus> = {},
): ToolSeed {
const base = { id, type: "tool" as const, name: tool }
if (state === "streaming") return { ...base, state: { status: state, input, raw: "" } }
const base = { id, type: "tool" as const, callID: id, tool }
if (state === "pending") return { ...base, state: { status: state, input, raw: "" } }
if (state === "running")
return {
...base,
state: {
status: state,
input,
...(options.output === undefined ? {} : { output: options.output }),
title: options.title,
metadata: options.metadata ?? {},
time: { start: 1700000001000 },
@@ -622,10 +612,12 @@ export function toolPart(
}
export function shell(id: string, state: ToolStatus, output = "", command = `echo ${id}`): ToolSeed {
if (state === "streaming") return toolPart(id, "shell", state, { command })
if (state === "running") return toolPart(id, "shell", state, { command }, { title: command, output })
if (state === "error") return toolPart(id, "shell", state, { command }, { error: output || undefined })
return toolPart(id, "shell", state, { command }, { title: command, output })
if (state === "pending") return toolPart(id, "bash", state, { command })
if (state === "running")
return toolPart(id, "bash", state, { command }, { title: command, metadata: { command, output } })
if (state === "error")
return toolPart(id, "bash", state, { command }, { error: output || undefined, metadata: { command, output } })
return toolPart(id, "bash", state, { command }, { title: command, output, metadata: { command, output } })
}
export function completedAssistantInfo(info: SessionMessageAssistant): SessionMessageAssistant {
@@ -663,7 +655,7 @@ function messageContent(
): SessionMessageAssistant["content"][number] {
if (part.type === "tool") {
partRefs.set(part.id, { messageID, type: part.type })
toolStates.set(part.id, part.state.status)
toolStates.set(part.callID, part.state.status)
} else {
partRefs.set(part.id, { messageID, type: part.type, ordinal: ordinals[part.type]++ })
startedParts.add(part.id)
@@ -683,8 +675,8 @@ function messageContent(
const completed = state.status === "completed" || state.status === "error" ? state.time.end : undefined
const base = {
type: "tool" as const,
id: part.id,
name: part.name,
id: part.callID,
name: part.tool,
time: {
created: time?.start ?? 1700000001000,
...(time?.start === undefined ? {} : { ran: time.start }),
@@ -694,18 +686,11 @@ function messageContent(
...(part.providerState ? { providerState: jsonRecord(part.providerState) } : {}),
...(part.providerResultState ? { providerResultState: jsonRecord(part.providerResultState) } : {}),
}
if (state.status === "streaming") return { ...base, state: { status: "streaming", input: state.raw } }
if (state.status === "pending") return { ...base, state: { status: "streaming", input: state.raw } }
if (state.status === "running")
return {
...base,
state: {
status: "running",
input: jsonRecord(state.input),
metadata: jsonRecord({
...state.metadata,
...(state.output === undefined ? {} : { output: state.output }),
}),
},
state: { status: "running", input: jsonRecord(state.input), metadata: jsonRecord(state.metadata) },
}
if (state.status === "error")
return {
@@ -729,7 +714,7 @@ function messageContent(
}
function toolEvents(part: ToolSeed, messageID: string): readonly OpenCodeEvent[] {
const previous = toolStates.get(part.id)
const previous = toolStates.get(part.callID)
if (previous === "completed" || previous === "error") return []
const events: OpenCodeEvent[] = []
@@ -738,27 +723,27 @@ function toolEvents(part: ToolSeed, messageID: string): readonly OpenCodeEvent[]
makeEvent("session.tool.input.started", {
sessionID,
assistantMessageID: messageID,
id: part.id,
name: part.name,
id: part.callID,
name: part.tool,
}),
)
}
if (part.state.status === "streaming") {
toolStates.set(part.id, part.state.status)
if (part.state.status === "pending") {
toolStates.set(part.callID, part.state.status)
return events
}
if (!previous || previous === "streaming") {
if (!previous || previous === "pending") {
events.push(
makeEvent("session.tool.input.ended", {
sessionID,
assistantMessageID: messageID,
id: part.id,
id: part.callID,
text: JSON.stringify(part.state.input),
}),
makeEvent("session.tool.called", {
sessionID,
assistantMessageID: messageID,
id: part.id,
id: part.callID,
input: part.state.input,
executed: part.executed ?? true,
state: jsonRecord(part.providerState),
@@ -766,20 +751,16 @@ function toolEvents(part: ToolSeed, messageID: string): readonly OpenCodeEvent[]
)
}
if (part.state.status === "running") {
const metadata = {
...part.state.metadata,
...(part.state.output === undefined ? {} : { output: part.state.output }),
}
if (previous === "running" || Object.keys(metadata).length)
if (previous === "running" || Object.keys(part.state.metadata).length)
events.push(
makeEvent("session.tool.progress", {
sessionID,
assistantMessageID: messageID,
id: part.id,
metadata: jsonRecord(metadata),
id: part.callID,
metadata: jsonRecord(part.state.metadata),
}),
)
toolStates.set(part.id, part.state.status)
toolStates.set(part.callID, part.state.status)
return events
}
if (part.state.status === "error") {
@@ -787,28 +768,28 @@ function toolEvents(part: ToolSeed, messageID: string): readonly OpenCodeEvent[]
makeEvent("session.tool.failed", {
sessionID,
assistantMessageID: messageID,
id: part.id,
id: part.callID,
error: { type: "ToolError", message: part.state.error },
metadata: jsonRecord(part.state.metadata),
executed: part.executed ?? true,
resultState: jsonRecord(part.providerResultState),
}),
)
toolStates.set(part.id, part.state.status)
toolStates.set(part.callID, part.state.status)
return events
}
events.push(
makeEvent("session.tool.success", {
sessionID,
assistantMessageID: messageID,
id: part.id,
id: part.callID,
content: [{ type: "text", text: part.state.output }],
metadata: jsonRecord(part.state.metadata),
executed: part.executed ?? true,
resultState: jsonRecord(part.providerResultState),
}),
)
toolStates.set(part.id, part.state.status)
toolStates.set(part.callID, part.state.status)
return events
}
@@ -1,5 +1,4 @@
import { expect, test } from "@playwright/test"
import { createTwoFilesPatch } from "diff"
import {
defineVisualRegions,
reportVisualStability,
@@ -59,14 +58,14 @@ test("expands and collapses a long completed shell without overlap", async ({ pa
await startVisualProbe(page, regions)
await trigger.click()
await expect(trigger).toHaveAttribute("aria-expanded", "true")
await waitForVisualSettle(page, [regions.shell.selector, regions.following.selector])
await page.waitForTimeout(500)
const expanded = await stopVisualProbe<keyof typeof regions>(page)
await reportVisualStability(testInfo, "shell-expand", expanded, plan)
await startVisualProbe(page, regions)
await trigger.click()
await expect(trigger).toHaveAttribute("aria-expanded", "false")
await waitForVisualSettle(page, [regions.shell.selector, regions.following.selector])
await page.waitForTimeout(500)
const collapsed = await stopVisualProbe<keyof typeof regions>(page)
await reportVisualStability(testInfo, "shell-collapse", collapsed, plan)
})
@@ -84,7 +83,7 @@ test("expands and collapses a completed context group without overlap", async ({
messages: [
userMessage(),
assistantMessage([
toolPart(ids[0]!, "read", "completed", { path: "src/a.ts" }),
toolPart(ids[0]!, "read", "completed", { filePath: "src/a.ts" }),
toolPart(ids[1]!, "glob", "completed", { path: ".", pattern: "**/*.ts" }),
toolPart(ids[2]!, "grep", "completed", { path: ".", pattern: "stable" }),
toolPart(ids[3]!, "list", "completed", { path: "src" }),
@@ -111,7 +110,7 @@ test("expands and collapses a completed context group without overlap", async ({
await startVisualProbe(page, regions)
await trigger.click()
await expect(trigger).toHaveAttribute("aria-expanded", String(expanded))
await waitForVisualSettle(page, [regions.context.selector, regions.following.selector])
await page.waitForTimeout(500)
const trace = await stopVisualProbe<keyof typeof regions>(page)
await reportVisualStability(
testInfo,
@@ -143,23 +142,16 @@ test("expands and collapses an edit diff without moving twice", async ({ page },
editID,
"edit",
"completed",
{ path: "src/edit.ts", oldString: "export const value = 1", newString: "export const value = 2" },
{ filePath: "src/edit.ts" },
{
metadata: {
files: [
{
file: "src/edit.ts",
patch: createTwoFilesPatch(
"a/src/edit.ts",
"b/src/edit.ts",
source(40, false),
source(40, true),
),
additions: 40,
deletions: 40,
status: "modified",
},
],
filediff: {
file: "src/edit.ts",
additions: 40,
deletions: 40,
before: source(40, false),
after: source(40, true),
},
},
},
),
@@ -190,7 +182,7 @@ test("expands and collapses an edit diff without moving twice", async ({ page },
await startVisualProbe(page, regions)
await trigger.click()
await expect(trigger).toHaveAttribute("aria-expanded", "true")
await waitForVisualSettle(page, [regions.edit.selector, regions.following.selector])
await page.waitForTimeout(900)
const trace = await stopVisualProbe<keyof typeof regions>(page)
await reportVisualStability(
testInfo,
@@ -17,32 +17,32 @@ import {
userMessage,
} from "./fixture"
test("adds a subagent child-session link without replacing the row", async ({ page }, testInfo) => {
const taskID = "prt_subagent_link"
const childID = "ses_subagent_child"
const input = { description: "Inspect child", agent: "explore", prompt: "Inspect the child Session." }
test("adds a task child-session link without replacing the task row", async ({ page }, testInfo) => {
const taskID = "prt_task_link"
const childID = "ses_task_child"
const input = { description: "Inspect child", subagent_type: "explore" }
const timeline = await setupTimeline(page, {
messages: [userMessage(), assistantMessage([toolPart(taskID, "subagent", "running", input)], { completed: false })],
messages: [userMessage(), assistantMessage([toolPart(taskID, "task", "running", input)], { completed: false })],
sessions: [session(), session({ id: childID, parentID: sessionID, title: "Inspect child" })],
cpuRate: 4,
})
const regions = defineVisualRegions({
subagent: { selector: `[data-timeline-part-id="${renderedPartID(taskID)}"] [data-slot="collapsible-trigger"]` },
task: { selector: `[data-timeline-part-id="${renderedPartID(taskID)}"] [data-slot="collapsible-trigger"]` },
})
await startVisualProbe(page, regions)
await timeline.send(
partUpdated(toolPart(taskID, "subagent", "completed", input, { metadata: { sessionID: childID } })),
partUpdated(toolPart(taskID, "task", "completed", input, { metadata: { sessionId: childID } })),
500,
)
const trace = await stopVisualProbe<keyof typeof regions>(page)
await reportVisualStability(
testInfo,
"subagent-link",
"task-link",
trace,
visualPlan(regions, [
{ type: "required", regions: ["subagent"] },
{ type: "unique", regions: ["subagent"] },
{ type: "stable", regions: ["subagent"] },
{ type: "required", regions: ["task"] },
{ type: "unique", regions: ["task"] },
{ type: "stable", regions: ["task"] },
{ type: "opacity", regions: "all" },
{ type: "continuity", regions: "all" },
{ type: "motion", regions: "all", maxPositionReversals: 0 },
@@ -21,30 +21,24 @@ import {
} from "./fixture"
test.describe("timeline tool state stability", () => {
test("moves lightweight tools through streaming, running, and completed without replacing rows", async ({
test("moves lightweight tools through pending, running, and completed without replacing rows", async ({
page,
}, testInfo) => {
const ids = ["webfetch", "websearch", "subagent", "skill", "custom"] as const
const ids = ["webfetch", "websearch", "task", "skill", "custom"] as const
const inputs = {
webfetch: { url: "https://example.com/docs" },
websearch: { query: "timeline stability" },
subagent: { description: "Inspect timeline", agent: "explore", prompt: "Inspect the timeline." },
task: { description: "Inspect timeline", subagent_type: "explore" },
skill: { name: "stability" },
custom: { target: "timeline", depth: 2 },
}
const names = {
webfetch: "webfetch",
websearch: "websearch",
subagent: "subagent",
skill: "skill",
custom: "mcp_probe",
}
const names = { webfetch: "webfetch", websearch: "websearch", task: "task", skill: "skill", custom: "mcp_probe" }
const questionID = "prt_state_question"
const todoID = "prt_state_todo"
const initial = [
...ids.map((id) => toolPart(`prt_state_${id}`, names[id], "streaming", inputs[id])),
toolPart(questionID, "question", "streaming", questionInput()),
toolPart(todoID, "todowrite", "streaming", { todos: [{ content: "Hidden", status: "pending" }] }),
...ids.map((id) => toolPart(`prt_state_${id}`, names[id], "pending", inputs[id])),
toolPart(questionID, "question", "pending", questionInput()),
toolPart(todoID, "todowrite", "pending", { todos: [{ content: "Hidden", status: "pending" }] }),
textPart("prt_state_following", "Following lightweight tools"),
]
const childID = "ses_timeline_child"
@@ -61,14 +55,14 @@ test.describe("timeline tool state stability", () => {
const regionIDs = [
"prt_state_webfetch",
"prt_state_websearch",
"prt_state_subagent",
"prt_state_task",
"prt_state_skill",
"prt_state_custom",
] as const
const regions = defineVisualRegions({
prt_state_webfetch: toolRegion(regionIDs[0]),
prt_state_websearch: toolRegion(regionIDs[1]),
prt_state_subagent: toolRegion(regionIDs[2]),
prt_state_task: toolRegion(regionIDs[2]),
prt_state_skill: toolRegion(regionIDs[3]),
prt_state_custom: toolRegion(regionIDs[4]),
})
@@ -79,9 +73,9 @@ test.describe("timeline tool state stability", () => {
[80, 240, 100, 360, 140][index],
)
}
for (const [index, id] of ["skill", "webfetch", "custom", "subagent", "websearch"].entries()) {
for (const [index, id] of ["skill", "webfetch", "custom", "task", "websearch"].entries()) {
const key = id as (typeof ids)[number]
const metadata = key === "subagent" ? { sessionID: childID } : key === "websearch" ? { provider: "exa" } : {}
const metadata = key === "task" ? { sessionId: childID } : key === "websearch" ? { provider: "exa" } : {}
const output = key === "websearch" ? "Result https://example.com/result" : "Completed"
await timeline.send(
partUpdated(toolPart(`prt_state_${key}`, names[key], "completed", inputs[key], { metadata, output })),
@@ -127,12 +121,12 @@ test.describe("timeline tool state stability", () => {
const ids = ["prt_ctx_01_read", "prt_ctx_02_glob", "prt_ctx_03_grep", "prt_ctx_04_list"]
const tools = ["read", "glob", "grep", "list"]
const inputs = [
{ path: "src/a.ts", offset: 0, limit: 120 },
{ filePath: "src/a.ts", offset: 0, limit: 120 },
{ path: directory, pattern: "**/*.ts" },
{ path: directory, pattern: "stability", include: "*.ts" },
{ path: "src" },
]
const context = ids.map((id, index) => toolPart(id, tools[index]!, "streaming", inputs[index]!))
const context = ids.map((id, index) => toolPart(id, tools[index]!, "pending", inputs[index]!))
const timeline = await setupTimeline(page, {
messages: [
userMessage(),
@@ -4,7 +4,6 @@ import type { Page } from "@playwright/test"
import { mockOpenCodeServer } from "../../utils/mock-server"
import { expectAppVisible, expectSessionTitle } from "../../utils/waits"
import { expect } from "../benchmark"
import { createTwoFilesPatch } from "diff"
const directory = "C:/OpenCode/TimelineStateRegression"
const projectID = "proj_timeline_state_regression"
@@ -27,29 +26,28 @@ const userMessage = {
const editPart: ToolSeed = {
id: editPartID,
sessionID,
messageID: assistantMessageID,
type: "tool",
name: "edit",
callID: "call_edit_regression",
tool: "edit",
state: {
status: "completed",
input: {
path: "src/regression.ts",
oldString: "export const value = 'before'",
newString: "export const value = 'after'",
},
content: [{ type: "text", text: "Edited src/regression.ts" }],
input: { filePath: "src/regression.ts" },
output: "Edited src/regression.ts",
title: "src/regression.ts",
metadata: {
files: [
currentFile(
"src/regression.ts",
"export const value = 'before'\n",
"export const value = 'after'\n",
1,
1,
),
],
filediff: {
file: "src/regression.ts",
additions: 1,
deletions: 1,
before: "export const value = 'before'\n",
after: "export const value = 'after'\n",
},
diff: "diff --git a/src/regression.ts b/src/regression.ts\n-export const value = 'before'\n+export const value = 'after'\n",
},
time: { start: 1700000001000, end: 1700000002000 },
},
time: { created: 1700000001000, ran: 1700000001000, completed: 1700000002000 },
}
const assistantMessage = {
@@ -206,20 +204,20 @@ function performanceTurn(index: number) {
? [
{
id: `prt_0000_${suffix}_edit`,
sessionID,
messageID: assistantID,
type: "tool",
name: "edit",
callID: `call_0000_${suffix}_edit`,
tool: "edit",
state: {
status: "completed",
input: { path: `src/history-${index}.ts`, oldString: before, newString: after },
content: [{ type: "text", text: `Edited src/history-${index}.ts` }],
input: { filePath: `src/history-${index}.ts` },
output: `Edited src/history-${index}.ts`,
title: `src/history-${index}.ts`,
metadata: {
files: [currentFile(`src/history-${index}.ts`, before, after, 48, 48)],
filediff: { file: `src/history-${index}.ts`, additions: 48, deletions: 48, before, after },
},
},
time: {
created: 1690000001200 + index * 2_000,
ran: 1690000001200 + index * 2_000,
completed: 1690000001400 + index * 2_000,
time: { start: 1690000001200 + index * 2_000, end: 1690000001400 + index * 2_000 },
},
},
]
@@ -228,18 +226,20 @@ function performanceTurn(index: number) {
? [
{
id: `prt_0000_${suffix}_write`,
sessionID,
messageID: assistantID,
type: "tool",
name: "write",
callID: `call_0000_${suffix}_write`,
tool: "write",
state: {
status: "completed",
input: { path: `src/generated-${index}.tsx`, content: after },
content: [{ type: "text", text: `Wrote src/generated-${index}.tsx` }],
metadata: { files: [currentFile(`src/generated-${index}.tsx`, "", after, 32, 0)] },
},
time: {
created: 1690000001400 + index * 2_000,
ran: 1690000001400 + index * 2_000,
completed: 1690000001500 + index * 2_000,
input: { filePath: `src/generated-${index}.tsx`, content: after },
output: `Wrote src/generated-${index}.tsx`,
title: `src/generated-${index}.tsx`,
metadata: {
filediff: { file: `src/generated-${index}.tsx`, additions: 32, deletions: 0, before: "", after },
},
time: { start: 1690000001400 + index * 2_000, end: 1690000001500 + index * 2_000 },
},
},
]
@@ -248,24 +248,31 @@ function performanceTurn(index: number) {
? [
{
id: `prt_0000_${suffix}_patch`,
sessionID,
messageID: assistantID,
type: "tool",
name: "patch",
callID: `call_0000_${suffix}_patch`,
tool: "apply_patch",
state: {
status: "completed",
input: { patchText: realisticPatch(index) },
content: [{ type: "text", text: "Success. Updated src/components/SessionCard.tsx" }],
output: "Success. Updated src/components/SessionCard.tsx",
title: "src/components/SessionCard.tsx",
metadata: {
files: [
{
...currentFile("src/components/SessionCard.tsx", before, after, 8, 3),
filePath: "src/components/SessionCard.tsx",
relativePath: "src/components/SessionCard.tsx",
type: "update",
additions: 8,
deletions: 3,
patch: realisticPatch(index),
before,
after,
},
],
},
},
time: {
created: 1690000001500 + index * 2_000,
ran: 1690000001500 + index * 2_000,
completed: 1690000001700 + index * 2_000,
time: { start: 1690000001500 + index * 2_000, end: 1690000001700 + index * 2_000 },
},
},
]
@@ -302,16 +309,20 @@ function performanceTurn(index: number) {
}
type ToolSeed = {
id: string
id?: string
sessionID?: string
messageID?: string
type: "tool"
name: string
callID: string
tool: string
state: {
status: "completed"
status: string
input: Record<string, unknown>
content: [{ type: "text"; text: string }]
output: string
title?: string
metadata: Record<string, unknown>
time: { start: number; end: number }
}
time: { created: number; ran: number; completed: number }
}
type ContentSeedBase = { id?: string; sessionID?: string; messageID?: string }
@@ -324,13 +335,13 @@ type ContentSeed =
function toolContent(part: ToolSeed): SessionMessageAssistant["content"][number] {
return {
type: "tool",
id: part.id,
name: part.name,
time: part.time,
id: part.callID,
name: part.tool,
time: { created: part.state.time.start, ran: part.state.time.start, completed: part.state.time.end },
state: {
status: "completed",
input: part.state.input as Record<string, JsonValue>,
content: part.state.content,
content: [{ type: "text", text: part.state.output }],
metadata: part.state.metadata as Record<string, JsonValue>,
},
}
@@ -411,16 +422,6 @@ export function MessageSummary(props: { messages: Message[]; locale: string }) {
`
}
function currentFile(file: string, before: string, after: string, additions: number, deletions: number) {
return {
file,
patch: createTwoFilesPatch(`a/${file}`, `b/${file}`, before, after),
additions,
deletions,
status: before ? (after ? "modified" : "deleted") : "added",
}
}
function realisticPatch(index: number) {
return `*** Begin Patch
*** Update File: src/components/SessionCard.tsx
@@ -1,6 +1,4 @@
import type { CDPSession, Page } from "@playwright/test"
import path from "node:path"
import { mkdir, writeFile } from "node:fs/promises"
export async function startTimelineProfile(page: Page, options: { cpuThrottle: number; profileCPU: boolean }) {
const cdp = await page.context().newCDPSession(page)
@@ -14,13 +12,6 @@ export async function startTimelineProfile(page: Page, options: { cpuThrottle: n
async stop() {
if (!options.profileCPU) return
const result = await cdp.send("Profiler.stop")
const directory = process.env.TIMELINE_CPU_PROFILE_DIR
if (directory) {
await mkdir(directory, { recursive: true })
const file = path.join(directory, `${process.env.OPENCODE_PERFORMANCE_RUN_ID ?? "manual"}-timeline.cpuprofile`)
await writeFile(file, JSON.stringify(result.profile))
console.log("timeline cpu profile file", file)
}
const self = new Map<number, number>()
result.profile.samples?.forEach((id, index) => {
const duration = (result.profile.timeDeltas?.[index] ?? 0) / 1_000
@@ -1,5 +1,3 @@
import { createTwoFilesPatch } from "diff"
const words = [
"alpha",
"bravo",
@@ -30,21 +28,22 @@ const directory = "C:/OpenCode/SmokeProject"
const projectID = "proj_smoke_timeline"
const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
type MessagePart =
| { id: string; type: "text"; text: string }
| { id: string; type: "reasoning"; text: string; time?: { start: number; end?: number } }
| {
id: string
type: "tool"
name: string
state: {
status: "completed"
input: Record<string, unknown>
content: [{ type: "text"; text: string }]
metadata: Record<string, unknown>
}
time: { created: number; ran: number; completed: number }
}
type MessagePart = {
id: string
type: "text" | "reasoning" | "tool"
text?: string
time?: { start: number; end?: number }
callID?: string
tool?: string
state?: {
status: "completed"
input: Record<string, unknown>
output: string
title: unknown
metadata: Record<string, unknown>
time: { start: number; end: number }
}
}
function lorem(seed: number, length: number) {
let out = ""
@@ -103,16 +102,17 @@ function messageContent(part: MessagePart): SessionMessageAssistant["content"][n
? { created: part.time.start, ...(part.time.end === undefined ? {} : { completed: part.time.end }) }
: undefined,
}
const state = part.state!
return {
type: "tool",
id: part.id,
name: part.name,
time: part.time,
id: part.callID ?? part.id,
name: part.tool!,
time: { created: state.time.start, ran: state.time.start, completed: state.time.end },
state: {
status: "completed",
input: part.state.input as Record<string, JsonValue>,
content: part.state.content,
metadata: part.state.metadata as Record<string, JsonValue>,
input: state.input as Record<string, JsonValue>,
content: [{ type: "text", text: state.output }],
metadata: state.metadata as Record<string, JsonValue>,
},
}
}
@@ -149,46 +149,43 @@ function toolPart(
): MessagePart {
const metadata =
metadataOverride ??
(tool === "patch"
? {
files: [
patchFile(index, "modified"),
patchFile(index + 1, index % 2 === 0 ? "added" : "deleted"),
],
}
(tool === "apply_patch"
? { files: [patchFile(index, "update"), patchFile(index + 1, index % 2 === 0 ? "add" : "delete")] }
: tool === "edit" || tool === "write"
? { files: [fileDiff(String(input.path ?? `src/generated/file-${index}.ts`), index)] }
? {
filediff: fileDiff(String(input.filePath ?? `src/generated/file-${index}.ts`), index),
diff: patch(index, outputLength),
preview: patch(index + 1, 420),
}
: tool === "question"
? { answers: [["Proceed"], ["Keep sample output"]] }
: {})
return {
id: id(`call_${tool}_${partIndex}`, index),
id: id(`prt_tool_${tool}_${partIndex}`, index),
type: "tool",
name: tool,
callID: id("call", index * 10 + partIndex),
tool,
state: {
status: "completed",
input,
content: [{ type: "text", text: lorem(index * 23 + partIndex, outputLength) }],
output: lorem(index * 23 + partIndex, outputLength),
title: tool === "bash" ? "Verify generated output" : input.filePath || input.path || input.pattern || "completed",
metadata,
},
time: {
created: 1700000000000 + index * 10_000,
ran: 1700000000000 + index * 10_000,
completed: 1700000000000 + index * 10_000 + 400,
time: { start: 1700000000000 + index * 10_000, end: 1700000000000 + index * 10_000 + 400 },
},
}
}
function patchFile(seed: number, status: "added" | "modified" | "deleted") {
const file = `src/generated/patch-${seed}.ts`
const before = status === "added" ? "" : code(seed, 18)
const after = status === "deleted" ? "" : code(seed + 1, 24)
function patchFile(seed: number, type: "add" | "update" | "delete") {
return {
file,
status,
additions: status === "deleted" ? 0 : (seed % 7) + 1,
deletions: status === "added" ? 0 : seed % 4,
patch: createTwoFilesPatch(`a/${file}`, `b/${file}`, before, after),
filePath: `src/generated/patch-${seed}.ts`,
relativePath: `src/generated/patch-${seed}.ts`,
type,
additions: (seed % 7) + 1,
deletions: type === "add" ? 0 : seed % 4,
patch: patch(seed, 520),
before: type === "add" ? undefined : code(seed, 18),
after: type === "delete" ? undefined : code(seed + 1, 24),
}
}
@@ -203,13 +200,17 @@ function fileDiff(file: string, seed: number) {
: before.replace("value4", "updatedValue4").replace("value20", "updatedValue20")
return {
file,
status: "modified" as const,
additions: lines === 300 ? 300 : lines === 2 ? 1 : 2,
deletions: lines === 300 ? 300 : lines === 2 ? 1 : 2,
patch: createTwoFilesPatch(`a/${file}`, `b/${file}`, before, after),
before,
after,
}
}
function patch(seed: number, length: number) {
return `diff --git a/src/generated/file-${seed}.ts b/src/generated/file-${seed}.ts\n+${lorem(seed, length).replace(/\n/g, "\n+")}`
}
function code(seed: number, lines: number, width = 32) {
return Array.from(
{ length: lines },
@@ -224,24 +225,22 @@ function turn(index: number): SessionMessageInfo[] {
...(index % 5 === 0 ? [reasoningPart(index, 0, 420)] : []),
...(index % 3 === 0
? [
toolPart(index, 0, "read", { path: `src/generated/file-${index}.ts`, offset: 0, limit: 80 }, 220),
toolPart(index, 0, "read", { filePath: `src/generated/file-${index}.ts`, offset: 0, limit: 80 }, 220),
toolPart(index, 5, "glob", { path: directory, pattern: `**/*sample-${index}*.ts` }, 140),
toolPart(index, 1, "grep", { path: directory, pattern: `sample-${index}`, include: "*.ts" }, 180),
toolPart(index, 6, "list", { path: `src/generated/${index}` }, 120),
]
: []),
textPart(index, 2, 160 + (index % 6) * 90),
...(index % 4 === 0
? [toolPart(index, 3, "edit", { path: `src/generated/file-${index}.ts`, oldString: "before", newString: "after" }, 700)]
: []),
...(index % 4 === 0 ? [toolPart(index, 3, "edit", { filePath: `src/generated/file-${index}.ts` }, 700)] : []),
...(index % 6 === 0
? [toolPart(index, 7, "write", { path: `src/generated/write-${index}.ts`, content: code(index, 28) }, 560)]
? [toolPart(index, 7, "write", { filePath: `src/generated/write-${index}.ts`, content: code(index, 28) }, 560)]
: []),
...(index % 8 === 0
? [toolPart(index, 8, "patch", { patchText: `Update generated patch ${index}` }, 620)]
? [toolPart(index, 8, "apply_patch", { files: [`src/generated/patch-${index}.ts`] }, 620)]
: []),
...(index % 7 === 0
? [toolPart(index, 4, "shell", { command: "bun typecheck", description: "Verify generated output" }, 620)]
? [toolPart(index, 4, "bash", { command: "bun typecheck", description: "Verify generated output" }, 620)]
: []),
...(index % 10 === 0 ? [toolPart(index, 9, "webfetch", { url: "https://example.com/docs/sample" }, 120)] : []),
...(index % 11 === 0 ? [toolPart(index, 10, "websearch", { query: "sample movement notes" }, 240)] : []),
@@ -257,15 +256,7 @@ function turn(index: number): SessionMessageInfo[] {
]
: []),
...(index % 17 === 0
? [
toolPart(
index,
12,
"subagent",
{ description: "Inspect generated fixture", agent: "explore", prompt: "Inspect the fixture." },
160,
),
]
? [toolPart(index, 12, "task", { description: "Inspect generated fixture", subagent_type: "explore" }, 160)]
: []),
]
return [user, assistantMessage(targetID, index, user.id, parts)]
@@ -281,10 +272,10 @@ const sourceMessages = Array.from({ length: 12 }, (_, index) => [
toolPart(
index + 1000,
1,
"subagent",
{ description: "Inspect child navigation", agent: "explore", prompt: "Inspect child navigation." },
"task",
{ description: "Inspect child navigation", subagent_type: "explore" },
160,
{ sessionID: childID },
{ sessionId: childID },
),
]
: []),
@@ -1,88 +0,0 @@
import { describe, expect, test } from "bun:test"
import { inlineThemePreload } from "../../../vite.js"
import { milestoneForLine, summarizeDesktopStartup, type DesktopStartupSample } from "../devex/desktop-startup"
describe("desktop startup benchmark", () => {
test.each(["/oc-theme-preload.js", "./oc-theme-preload.js"])("inlines %s before the renderer runs", (path) => {
const html = inlineThemePreload(`<script id="oc-theme-preload-script" src="${path}"></script>`)
expect(html).not.toContain(" src=")
expect(html).toContain("opencode-color-scheme")
})
test("recognizes startup milestones in colored output", () => {
const cases = [
["bunRootScript", "$ bun --cwd packages/desktop dev"],
["bunDesktopScript", "$ bun ./scripts/dev.ts"],
["desktopPrepared", "Copied dev icons from"],
["mainBundleReady", "electron main process built successfully"],
["preloadBundleReady", "electron preload scripts built successfully"],
["rendererDevServerReady", "dev server running for the electron renderer process at:"],
["electronSpawnStarted", "starting electron app..."],
["debugEndpointReady", "DevTools listening on ws://"],
["electronStarted", "app starting"],
["serviceEnsureStarted", "starting v2 background service"],
["serviceSpawnRequested", "v2 CLI background service starting"],
["serviceReady", "v2 CLI background service ready"],
["backgroundLoadingReady", "loading task finished"],
["rendererViteConnected", "[vite] connected."],
["rendererInitializationStarted", "awaiting server ready"],
["rendererInitializationReady", "server ready"],
["windowVisible", "main window visible"],
] as const
cases.forEach(([milestone, line]) => {
expect(milestoneForLine(`\u001b[32m${line}\u001b[39m`)).toBe(milestone)
})
expect(milestoneForLine("12:30:00.000 v2 CLI background service ready {")).toBe("serviceReady")
expect(milestoneForLine("unrelated output")).toBeUndefined()
})
test("keeps raw samples and reports median absolute deviation", () => {
const samples = [24, 20, 22, 28, 26].map((commandToHomeReadyMs, index) => sample(index + 1, commandToHomeReadyMs))
expect(summarizeDesktopStartup(samples).commandToHomeReadyMs).toEqual({
min: 20,
median: 24,
max: 28,
medianAbsoluteDeviation: 2,
})
})
})
function sample(run: number, commandToHomeReadyMs: number): DesktopStartupSample {
const milestonesMs = {
bunRootScript: 1,
bunDesktopScript: 2,
desktopPrepared: 3,
mainBundleReady: 4,
preloadBundleReady: 5,
rendererDevServerReady: 6,
electronSpawnStarted: 7,
debugEndpointReady: 8,
electronStarted: 9,
serviceEnsureStarted: 10,
serviceSpawnRequested: 11,
serviceReady: 12,
backgroundLoadingReady: 13,
rendererViteConnected: 14,
rendererInitializationStarted: 15,
rendererInitializationReady: 16,
windowVisible: 17,
homeReady: commandToHomeReadyMs,
}
return {
run,
commandToHomeReadyMs,
milestonesMs,
phasesMs: {
desktopPreparation: 3,
viteMainBundle: 1,
vitePreloadBundle: 1,
rendererServerStartup: 1,
electronStartup: 2,
serviceSpawnWait: 1,
serviceProcessStartup: 1,
rendererStartup: commandToHomeReadyMs - 14,
visibleWindowToHome: commandToHomeReadyMs - 17,
},
service: { version: "2.0.0-local-test", url: "http://127.0.0.1:3000", pid: run },
}
}
@@ -36,9 +36,6 @@ test("preserves the draft when a populated command menu triggers a built-in", as
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
const composer = page.locator('[data-component="prompt-input-v2"]')
const input = composer.locator('[data-component="prompt-input"]')
await expect.poll(() => input.evaluate((element) => getComputedStyle(element, "::before").content)).toBe(
`"${String.fromCodePoint(0x200b)}"`,
)
await expectAppVisible(composer)
await input.fill("keep me")
@@ -19,7 +19,7 @@ test("session settings use the remote server context", async ({ page }) => {
await configureServers(page)
await page.goto(`/server/${base64Encode(serverB)}/session/${sessionB.id}`)
await expect(page.getByRole("heading", { name: sessionB.title, exact: true })).toBeVisible()
await expect(page.getByText(sessionB.title).first()).toBeVisible()
await page.keyboard.press("Control+,")
const dialog = page.locator(".settings-v2-dialog")
@@ -61,7 +61,7 @@ test("auto-accept responds for an unfocused server session", async ({ page }) =>
const hrefB = `/server/${base64Encode(serverB)}/session/${sessionB.id}`
await page.goto(`/server/${base64Encode(serverA)}/session/${sessionA.id}`)
await expect(page.getByRole("heading", { name: sessionA.title, exact: true })).toBeVisible()
await expect(page.getByText(sessionA.title).first()).toBeVisible()
await page.keyboard.press("Control+,")
const autoAccept = page.locator(".settings-v2-dialog").locator('[data-action="settings-auto-accept-permissions"]')
await autoAccept.locator('[data-slot="switch-control"]').click()
@@ -78,7 +78,7 @@ test("auto-accept responds for an unfocused server session", async ({ page }) =>
await page.locator(`[data-titlebar-tab-slot]:has(a[href="${hrefB}"])`).click()
await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
await expect(page.getByRole("heading", { name: sessionB.title, exact: true })).toBeVisible()
await expect(page.getByText(sessionB.title).first()).toBeVisible()
await transport.waitForConnection()
await transport.send({
@@ -89,7 +89,7 @@ test("auto-accept responds for an unfocused server session", async ({ page }) =>
data: {
id: "permission-background-a",
sessionID: sessionA.id,
action: "shell",
action: "bash",
resources: ["git status"],
metadata: {},
save: [],
@@ -116,7 +116,7 @@ test("auto-accept responds for an unfocused server session", async ({ page }) =>
data: {
id: "permission-background-a-child",
sessionID: childSessionA.id,
action: "shell",
action: "bash",
resources: ["git diff"],
metadata: {},
save: [],
@@ -208,7 +208,7 @@ async function mockServers(page: Page, permissionRequests: string[], permissionR
return json(route, [
{
id: remote ? sessionB.projectID : "project-server-a",
canonical: directory,
worktree: directory,
vcs: "git",
time: { created: 1, updated: 1 },
sandboxes: [],
@@ -216,7 +216,7 @@ async function mockServers(page: Page, permissionRequests: string[], permissionR
])
}
if (url.pathname === "/api/project/current")
return json(route, { id: remote ? sessionB.projectID : "project-server-a", directory, canonical: directory })
return json(route, { id: remote ? sessionB.projectID : "project-server-a", directory })
if (url.pathname === "/api/session")
return json(route, { data: sessions.map((session) => currentSession(session)), cursor: {} })
if (url.pathname === "/api/session/active") return json(route, { data: {} })
@@ -237,7 +237,7 @@ function session(id: string, directory: string, title: string) {
id,
slug: id,
projectID: `project-${id}`,
location: { directory },
directory,
title,
version: "dev",
time: { created: 1, updated: 1 },
@@ -76,7 +76,7 @@ test("keeps the review tree and terminal sized when both panels are open", async
}),
})
})
await page.route(/\/api\/pty(?:\?.*)?$/, (route) =>
await page.route("**/pty*", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
@@ -85,7 +85,7 @@ test("shows a pending permission dock", async ({ page }) => {
{
id: "permission-request",
sessionID,
permission: "shell",
permission: "bash",
patterns: ["git status", "git diff"],
metadata: {},
always: [],
@@ -2,7 +2,6 @@ import { expect, test, type Locator, type Page } from "@playwright/test"
import type { JsonValue, OpenCodeEvent, SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client/promise"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectAppVisible, expectSessionTitle } from "../utils/waits"
import { createTwoFilesPatch } from "diff"
const directory = "C:/OpenCode/TimelineStateRegression"
const projectID = "proj_timeline_state_regression"
@@ -41,28 +40,18 @@ const editPart = {
tool: "edit",
state: {
status: "completed",
input: {
path: "src/regression.ts",
oldString: "export const value = 'before'",
newString: "export const value = 'after'",
},
input: { filePath: "src/regression.ts" },
output: "Edited src/regression.ts",
title: "src/regression.ts",
metadata: {
files: [
{
file: "src/regression.ts",
patch: createTwoFilesPatch(
"a/src/regression.ts",
"b/src/regression.ts",
"export const value = 'before'\n",
"export const value = 'after'\n",
),
additions: 1,
deletions: 1,
status: "modified",
},
],
filediff: {
file: "src/regression.ts",
additions: 1,
deletions: 1,
before: "export const value = 'before'\n",
after: "export const value = 'after'\n",
},
diff: "diff --git a/src/regression.ts b/src/regression.ts\n-export const value = 'before'\n+export const value = 'after'\n",
},
time: { start: 1700000001000, end: 1700000002000 },
},
@@ -160,15 +149,13 @@ test.describe("regression: session timeline local row state", () => {
...editPart.state,
metadata: {
...editPart.state.metadata,
files: [
{
file: "src/regression.ts",
patch: createTwoFilesPatch("a/src/regression.ts", "b/src/regression.ts", lines, after),
additions: 5,
deletions: 5,
status: "modified",
},
],
filediff: {
file: "src/regression.ts",
additions: 1,
deletions: 1,
before: lines,
after,
},
},
},
}
@@ -78,7 +78,7 @@ test.describe("regression: session timeline context group resize", () => {
id("msg_assistant", 10),
["read", "glob", "grep", "list"][index]!,
[
{ path: "src/recent-a.ts" },
{ filePath: "src/recent-a.ts" },
{ path: directory, pattern: "**/*.ts" },
{ path: directory, pattern: "Explored" },
{ path: "src" },
@@ -213,7 +213,7 @@ function turn(index: number, target: boolean, status: "running" | "completed" =
contextIDs[0]!,
assistantID,
"read",
{ path: "src/recent-a.ts", offset: 0, limit: 120 },
{ filePath: "src/recent-a.ts", offset: 0, limit: 120 },
status,
),
),
@@ -270,7 +270,7 @@ function contextTool(
status,
input,
output: `Completed ${tool}.\n${"detail line\n".repeat(8)}`,
title: input.path || input.pattern || "completed",
title: input.filePath || input.path || input.pattern || "completed",
metadata: {},
time: { start: 1700000000000, end: 1700000000100 },
},
@@ -10,7 +10,7 @@ import {
test("preserves a collapsed context group through count and status updates", async ({ page }) => {
const ids = ["prt_closed_01_read", "prt_closed_02_glob"]
const inputs = {
read: { path: "src/a.ts", offset: 0, limit: 120 },
read: { filePath: "src/a.ts", offset: 0, limit: 120 },
glob: { path: ".", pattern: "**/*.ts" },
}
const timeline = await setupTimeline(page, {
@@ -7,7 +7,7 @@ test("renders completed write content", async ({ page }) => {
messages: [
userMessage(),
assistantMessage([
toolPart(id, "write", "completed", { path: "src/write.ts", content: "export const written = true\n" }),
toolPart(id, "write", "completed", { filePath: "src/write.ts", content: "export const written = true\n" }),
]),
],
settings: { editToolPartsExpanded: true },
@@ -24,19 +24,20 @@ test("renders a completed single-file patch", async ({ page }) => {
assistantMessage([
toolPart(
id,
"patch",
"apply_patch",
"completed",
{ patchText: "Update src/a.ts" },
{ files: ["src/a.ts"] },
{
metadata: {
files: [
{
file: "src/a.ts",
status: "modified",
patch:
"diff --git a/src/a.ts b/src/a.ts\n--- a/src/a.ts\n+++ b/src/a.ts\n@@ -1 +1 @@\n-export const value = 1\n+export const value = 2\n",
filePath: "src/a.ts",
relativePath: "src/a.ts",
type: "update",
additions: 1,
deletions: 1,
before: "export const value = 1\n",
after: "export const value = 2\n",
},
],
},
@@ -1,19 +1,18 @@
import { expect, test } from "@playwright/test"
import { assistantMessage, setupTimeline, toolPart, userMessage } from "../performance/timeline-stability/fixture"
import { createTwoFilesPatch } from "diff"
test("preserves nested patch file state through outer collapse and reopen", async ({ page }) => {
const patchID = "prt_nested_patch"
const files = [patchFile("src/a.ts", "modified"), patchFile("src/b.ts", "added"), patchFile("src/old.ts", "deleted")]
const files = [patchFile("src/a.ts", "update"), patchFile("src/b.ts", "add"), patchFile("src/old.ts", "delete")]
await setupTimeline(page, {
messages: [
userMessage(),
assistantMessage([
toolPart(
patchID,
"patch",
"apply_patch",
"completed",
{ patchText: "Update three files" },
{ files: files.map((file) => file.filePath) },
{ metadata: { files } },
),
]),
@@ -32,15 +31,15 @@ test("preserves nested patch file state through outer collapse and reopen", asyn
await expect(deleted.getByRole("button")).toHaveAttribute("aria-expanded", "true")
})
function patchFile(file: string, status: "added" | "modified" | "deleted") {
const before = status === "added" ? "" : source(false)
const after = status === "deleted" ? "" : source(true)
function patchFile(filePath: string, type: "add" | "update" | "delete") {
return {
file,
status,
patch: createTwoFilesPatch(`a/${file}`, `b/${file}`, before, after),
additions: status === "deleted" ? 0 : 4,
deletions: status === "added" ? 0 : 3,
filePath,
relativePath: filePath,
type,
additions: type === "delete" ? 0 : 4,
deletions: type === "add" ? 0 : 3,
before: type === "add" ? undefined : source(false),
after: type === "delete" ? undefined : source(true),
}
}
@@ -11,7 +11,7 @@ for (const profile of [
messages: [
userMessage(),
assistantMessage([
toolPart(ids[0]!, "read", "completed", { path: "src/a.ts" }),
toolPart(ids[0]!, "read", "completed", { filePath: "src/a.ts" }),
toolPart(ids[1]!, "glob", "completed", { path: ".", pattern: "**/*.ts" }),
]),
],
@@ -12,7 +12,7 @@ import {
test.describe("session timeline projection", () => {
test("renders every admitted tool family and hides timeline-only exclusions", async ({ page }) => {
const parts = [
toolPart("prt_01_read", "read", "completed", { path: "src/a.ts" }),
toolPart("prt_01_read", "read", "completed", { filePath: "src/a.ts" }),
toolPart("prt_02_glob", "glob", "completed", { path: ".", pattern: "**/*.ts" }),
toolPart("prt_03_grep", "grep", "completed", { path: ".", pattern: "value" }),
toolPart("prt_04_list", "list", "completed", { path: "src" }),
@@ -24,20 +24,16 @@ test.describe("session timeline projection", () => {
{ query: "timeline stability" },
{ output: "https://example.com/result" },
),
toolPart("prt_task", "subagent", "completed", {
description: "Inspect timeline",
agent: "explore",
prompt: "Inspect the timeline implementation.",
}),
toolPart("prt_task", "task", "completed", { description: "Inspect timeline", subagent_type: "explore" }),
toolPart(
"prt_bash",
"shell",
"bash",
"completed",
{ command: "printf stable" },
{ output: "stable", title: "printf stable" },
),
editPart("prt_edit"),
toolPart("prt_write", "write", "completed", { path: "src/new.ts", content: "export const stable = true\n" }),
toolPart("prt_write", "write", "completed", { filePath: "src/new.ts", content: "export const stable = true\n" }),
patchPart("prt_patch"),
toolPart("prt_todo", "todowrite", "completed", { todos: [{ content: "Hidden", status: "pending" }] }),
toolPart(
@@ -179,10 +175,16 @@ function editPart(id: string) {
id,
"edit",
"completed",
{ path: "src/a.ts", oldString: "export const value = 1", newString: "export const value = 2" },
{ filePath: "src/a.ts" },
{
metadata: {
files: [patchFile("src/a.ts", "modified")],
filediff: {
file: "src/a.ts",
additions: 1,
deletions: 1,
before: "export const value = 1\n",
after: "export const value = 2\n",
},
},
},
)
@@ -191,33 +193,31 @@ function editPart(id: string) {
function patchPart(id: string) {
return toolPart(
id,
"patch",
"apply_patch",
"completed",
{ patchText: "Update the projected files" },
{ files: ["src/a.ts", "src/b.ts"] },
{
metadata: {
files: [
patchFile("src/a.ts", "modified"),
patchFile("src/b.ts", "added"),
patchFile("src/old.ts", "deleted"),
patchFile("src/a.ts", "update"),
patchFile("src/b.ts", "add"),
patchFile("src/old.ts", "delete"),
{ ...patchFile("src/moved.ts", "move"), move: "src/new-place.ts" },
],
},
},
)
}
function patchFile(file: string, status: "added" | "modified" | "deleted") {
function patchFile(filePath: string, type: "add" | "update" | "delete" | "move") {
return {
file,
status,
patch:
status === "added"
? "@@ -0,0 +1 @@\n+export const after = true"
: status === "deleted"
? "@@ -1 +0,0 @@\n-export const before = true"
: "@@ -1 +1 @@\n-export const before = true\n+export const after = true",
additions: status === "deleted" ? 0 : 1,
deletions: status === "added" ? 0 : 1,
filePath,
relativePath: filePath,
type,
additions: type === "delete" ? 0 : 1,
deletions: type === "add" ? 0 : 1,
before: type === "add" ? undefined : "export const before = true\n",
after: type === "delete" ? undefined : "export const after = true\n",
}
}
@@ -15,7 +15,7 @@ import {
test("groups singleton and separated context operations at correct boundaries", async ({ page }) => {
const parts = [
toolPart("prt_boundary_01_read", "read", "completed", { path: "src/a.ts" }),
toolPart("prt_boundary_01_read", "read", "completed", { filePath: "src/a.ts" }),
textPart("prt_boundary_02_text", "Boundary text"),
toolPart("prt_boundary_03_glob", "glob", "completed", { path: ".", pattern: "**/*.ts" }),
toolPart("prt_boundary_04_grep", "grep", "completed", { path: ".", pattern: "stable" }),
@@ -67,18 +67,19 @@ for (const deviceScaleFactor of [1.25, 1.5]) {
test("keeps the patch card inside a fractionally short virtual row", async ({ page }) => {
const patchID = "prt_patch_outline"
const file = {
file: "src/outline.ts",
status: "modified",
patch:
"diff --git a/src/outline.ts b/src/outline.ts\n--- a/src/outline.ts\n+++ b/src/outline.ts\n@@ -1 +1 @@\n-const outline = false\n+const outline = true\n",
filePath: "src/outline.ts",
relativePath: "src/outline.ts",
type: "update",
additions: 1,
deletions: 1,
before: "const outline = false\n",
after: "const outline = true\n",
}
const timeline = await setupTimeline(page, {
messages: [
userMessage(),
assistantMessage([
toolPart(patchID, "patch", "completed", { patchText: "Update src/outline.ts" }, { metadata: { files: [file] } }),
toolPart(patchID, "apply_patch", "completed", { files: [file.filePath] }, { metadata: { files: [file] } }),
]),
],
settings: { editToolPartsExpanded: true },
@@ -8,7 +8,7 @@ import {
} from "../performance/timeline-stability/fixture"
test("renders every tool error outcome without leaking hidden tools", async ({ page }) => {
const ordinary = ["shell", "edit", "write", "patch", "webfetch", "websearch", "subagent", "skill", "mcp_probe"]
const ordinary = ["bash", "edit", "write", "apply_patch", "webfetch", "websearch", "task", "skill", "mcp_probe"]
const parts = ordinary.map((tool, index) =>
toolPart(`prt_error_${index}`, tool, "error", errorInput(tool), { error: `${tool} failed visibly` }),
)
@@ -37,8 +37,8 @@ test("transitions shell and question through running error outcomes", async ({ p
userMessage(),
assistantMessage(
[
toolPart(shellID, "shell", "streaming", { command: "exit 1" }),
toolPart(questionID, "question", "streaming", questionInput()),
toolPart(shellID, "bash", "pending", { command: "exit 1" }),
toolPart(questionID, "question", "pending", questionInput()),
],
{ completed: false },
),
@@ -46,11 +46,11 @@ test("transitions shell and question through running error outcomes", async ({ p
})
await timeline.waitForPart(shellID)
await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toHaveCount(0)
await timeline.send(partUpdated(toolPart(shellID, "shell", "running", { command: "exit 1" })), 120)
await timeline.send(partUpdated(toolPart(shellID, "bash", "running", { command: "exit 1" })), 120)
await timeline.send(partUpdated(toolPart(questionID, "question", "running", questionInput())), 180)
await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toHaveCount(0)
await timeline.send(
partUpdated(toolPart(shellID, "shell", "error", { command: "exit 1" }, { error: "Command exited 1" })),
partUpdated(toolPart(shellID, "bash", "error", { command: "exit 1" }, { error: "Command exited 1" })),
180,
)
await timeline.send(
@@ -147,13 +147,12 @@ function questionInput() {
}
function errorInput(tool: string) {
if (tool === "shell") return { command: "exit 1" }
if (["edit", "write"].includes(tool)) return { path: "src/error.ts", content: "" }
if (tool === "patch") return { patchText: "Update src/error.ts" }
if (tool === "bash") return { command: "exit 1" }
if (["edit", "write"].includes(tool)) return { filePath: "src/error.ts", content: "" }
if (tool === "apply_patch") return { files: ["src/error.ts"] }
if (tool === "webfetch") return { url: "https://example.com" }
if (tool === "websearch") return { query: "failure" }
if (tool === "subagent")
return { description: "Fail subagent", agent: "explore", prompt: "Inspect the failure." }
if (tool === "task") return { description: "Fail task", subagent_type: "explore" }
if (tool === "skill") return { name: "failure" }
return { target: "failure" }
}
@@ -167,14 +167,14 @@ function parentMessages(): SessionMessageInfo[] {
content: [
{
type: "tool",
id: "call_subagent_0001",
name: "subagent",
id: "call_task_0001",
name: "task",
time: { created: 1700000001000, ran: 1700000001000, completed: 1700000002000 },
state: {
status: "completed",
input: { description: taskDescription, agent: "explore", prompt: "Inspect the delegated work." },
input: { description: taskDescription, subagent_type: "explore" },
content: [{ type: "text", text: "Subagent finished" }],
metadata: { sessionID: childID },
metadata: { sessionId: childID },
},
},
],
@@ -1,7 +1,7 @@
import { createResource, createSignal, For, onMount, Suspense } from "solid-js"
import { render } from "solid-js/web"
import { createVirtualizer, observeElementOffset, observeElementRect } from "@tanstack/solid-virtual"
import { observeElementOffsetReconnectAware } from "../../../src/session/timeline/observe-element-offset"
import { observeElementOffsetReconnectAware } from "../../../src/pages/session/timeline/observe-element-offset"
const rowCount = 2_000
const rowHeight = 40

Some files were not shown because too many files have changed in this diff Show More