diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 69a3a1a2d1..f226d3483a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -68,6 +68,11 @@ jobs: env: OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }} + - name: Run HttpApi exerciser gates + if: runner.os == 'Linux' + working-directory: packages/opencode + run: bun run test:httpapi + - name: Publish unit reports if: always() uses: mikepenz/action-junit-report@v6 diff --git a/.gitignore b/.gitignore index 52a5a04596..19198a7a59 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ node_modules .worktrees .sst .env +.env.local .idea .vscode .codex diff --git a/.gitleaksignore b/.gitleaksignore new file mode 100644 index 0000000000..cc01a286fb --- /dev/null +++ b/.gitleaksignore @@ -0,0 +1,5 @@ +# Fake secret-looking strings used by HTTP recorder redaction tests. +afa57acfda894e0ebf3c637dd710310b705c0a2f:packages/http-recorder/test/record-replay.test.ts:generic-api-key:69 +afa57acfda894e0ebf3c637dd710310b705c0a2f:packages/http-recorder/test/record-replay.test.ts:generic-api-key:92 +afa57acfda894e0ebf3c637dd710310b705c0a2f:packages/http-recorder/test/record-replay.test.ts:generic-api-key:146 +afa57acfda894e0ebf3c637dd710310b705c0a2f:packages/http-recorder/test/record-replay.test.ts:gcp-api-key:71 diff --git a/.opencode/plugins/tui-smoke.tsx b/.opencode/plugins/tui-smoke.tsx index fc890537ec..2d3095a57c 100644 --- a/.opencode/plugins/tui-smoke.tsx +++ b/.opencode/plugins/tui-smoke.tsx @@ -2,87 +2,62 @@ import { useTerminalDimensions, type JSX } from "@opentui/solid" import { useBindings, useKeymapSelector } from "@opentui/keymap/solid" import { RGBA, VignetteEffect, type KeyEvent, type Renderable } from "@opentui/core" -import { resolveBindingSections, type BindingSectionsConfig, type BindingValue } from "@opentui/keymap/extras" -import type { Binding } from "@opentui/keymap" +import { createBindingLookup, type BindingConfig } from "@opentui/keymap/extras" import type { TuiPlugin, TuiPluginApi, TuiPluginMeta, TuiPluginModule, TuiSlotPlugin } from "@opencode-ai/plugin/tui" const tabs = ["overview", "counter", "help"] const command = { - modal: "plugin.smoke.modal", - screen: "plugin.smoke.screen", - alert: "plugin.smoke.alert", - confirm: "plugin.smoke.confirm", - prompt: "plugin.smoke.prompt", - select: "plugin.smoke.select", - host: "plugin.smoke.host", - home: "plugin.smoke.home", - toast: "plugin.smoke.toast", - dialog_close: "plugin.smoke.dialog.close", - local_push: "plugin.smoke.local.push", - local_pop: "plugin.smoke.local.pop", - screen_home: "plugin.smoke.screen.home", - screen_left: "plugin.smoke.screen.left", - screen_right: "plugin.smoke.screen.right", - screen_up: "plugin.smoke.screen.up", - screen_down: "plugin.smoke.screen.down", - screen_modal: "plugin.smoke.screen.modal", - screen_local: "plugin.smoke.screen.local", - screen_host: "plugin.smoke.screen.host", - screen_alert: "plugin.smoke.screen.alert", - screen_confirm: "plugin.smoke.screen.confirm", - screen_prompt: "plugin.smoke.screen.prompt", - screen_select: "plugin.smoke.screen.select", - modal_accept: "plugin.smoke.modal.accept", - modal_close: "plugin.smoke.modal.close", -} as const - -const sectionNames = ["global", "dialog", "local", "screen", "modal"] as const -type SectionName = (typeof sectionNames)[number] -type SectionConfig = Record> -type ResolvedSections = Record[]> -type SmokeKeymap = { - sections?: Partial> + modal: "smoke_modal", + screen: "smoke_screen", + alert: "smoke_alert", + confirm: "smoke_confirm", + prompt: "smoke_prompt", + select: "smoke_select", + host: "smoke_host", + home: "smoke_home", + toast: "smoke_toast", + dialog_close: "smoke_dialog_close", + local_push: "smoke_local_push", + local_pop: "smoke_local_pop", + screen_home: "smoke_screen_home", + screen_left: "smoke_screen_left", + screen_right: "smoke_screen_right", + screen_up: "smoke_screen_up", + screen_down: "smoke_screen_down", + screen_modal: "smoke_screen_modal", + screen_local: "smoke_screen_local", + screen_host: "smoke_screen_host", + screen_alert: "smoke_screen_alert", + screen_confirm: "smoke_screen_confirm", + screen_prompt: "smoke_screen_prompt", + screen_select: "smoke_screen_select", + modal_accept: "smoke_modal_accept", + modal_close: "smoke_modal_close", } -type SmokeOptions = { - enabled?: boolean - label?: unknown - route?: unknown - vignette?: unknown - keymap?: SmokeKeymap -} +type SmokeBindings = BindingConfig const defaultKeymap = { - global: { - [command.modal]: "ctrl+shift+m", - [command.screen]: "ctrl+shift+o", - }, - dialog: { - [command.dialog_close]: "escape", - }, - local: { - [command.local_push]: "enter,return", - [command.local_pop]: "escape,q,backspace", - }, - screen: { - [command.screen_home]: "escape,ctrl+h", - [command.screen_left]: "left,h", - [command.screen_right]: "right,l", - [command.screen_up]: "up,k", - [command.screen_down]: "down,j", - [command.screen_modal]: "ctrl+shift+m", - [command.screen_local]: "x", - [command.screen_host]: "z", - [command.screen_alert]: "a", - [command.screen_confirm]: "c", - [command.screen_prompt]: "p", - [command.screen_select]: "s", - }, - modal: { - [command.modal_accept]: "enter,return", - [command.modal_close]: "escape", - }, -} satisfies Record + [command.modal]: "ctrl+shift+m", + [command.screen]: "ctrl+shift+o", + [command.dialog_close]: "escape", + [command.local_push]: "enter,return", + [command.local_pop]: "escape,q,backspace", + [command.screen_home]: "escape,ctrl+h", + [command.screen_left]: "left,h", + [command.screen_right]: "right,l", + [command.screen_up]: "up,k", + [command.screen_down]: "down,j", + [command.screen_modal]: "ctrl+shift+m", + [command.screen_local]: "x", + [command.screen_host]: "z", + [command.screen_alert]: "a", + [command.screen_confirm]: "c", + [command.screen_prompt]: "p", + [command.screen_select]: "s", + [command.modal_accept]: "enter,return", + [command.modal_close]: "escape", +} const pick = (value: unknown, fallback: string) => { if (typeof value !== "string") return fallback @@ -95,11 +70,14 @@ const num = (value: unknown, fallback: number) => { return value } +const record = (value: unknown): value is Record => + !!value && typeof value === "object" && !Array.isArray(value) + type Cfg = { label: string route: string vignette: number - keymap: SmokeKeymap | undefined + keybinds: SmokeBindings | undefined } type Route = { @@ -116,12 +94,12 @@ type State = { local: number } -const cfg = (options: SmokeOptions | undefined) => { +const cfg = (options: Record | undefined) => { return { label: pick(options?.label, "smoke"), route: pick(options?.route, "workspace-smoke"), vignette: Math.max(0, num(options?.vignette, 0.35)), - keymap: options?.keymap, + keybinds: record(options?.keybinds) ? (options.keybinds as SmokeBindings) : undefined, } } @@ -132,21 +110,8 @@ const names = (input: Cfg) => { } } -function createKeys(input: SmokeKeymap | undefined): { sections: ResolvedSections } { - const sections = resolveBindingSections( - { - global: { ...defaultKeymap.global, ...input?.sections?.global }, - dialog: { ...defaultKeymap.dialog, ...input?.sections?.dialog }, - local: { ...defaultKeymap.local, ...input?.sections?.local }, - screen: { ...defaultKeymap.screen, ...input?.sections?.screen }, - modal: { ...defaultKeymap.modal, ...input?.sections?.modal }, - } satisfies BindingSectionsConfig, - { sections: sectionNames }, - ).sections - - return { - sections, - } +function createKeys(input: SmokeBindings | undefined) { + return createBindingLookup({ ...defaultKeymap, ...input }) } type Keys = ReturnType @@ -376,7 +341,7 @@ const Screen = (props: { }, }, ], - bindings: props.keys.sections.dialog, + bindings: props.keys.gather("smoke.dialog", [command.dialog_close]), })) useBindings(() => ({ @@ -395,7 +360,7 @@ const Screen = (props: { }, }, ], - bindings: props.keys.sections.local, + bindings: props.keys.gather("smoke.local", [command.local_push, command.local_pop]), })) useBindings(() => ({ @@ -478,7 +443,20 @@ const Screen = (props: { }, }, ], - bindings: props.keys.sections.screen, + bindings: props.keys.gather("smoke.screen", [ + command.screen_home, + command.screen_left, + command.screen_right, + command.screen_up, + command.screen_down, + command.screen_modal, + command.screen_local, + command.screen_host, + command.screen_alert, + command.screen_confirm, + command.screen_prompt, + command.screen_select, + ]), })) const shortcuts = useKeymapSelector((keymap) => { const bindings = keymap.getCommandBindings({ @@ -687,7 +665,7 @@ const Modal = (props: { }, }, ], - bindings: props.keys.sections.modal, + bindings: props.keys.gather("smoke.modal", [command.modal_accept, command.modal_close]), })) const shortcuts = useKeymapSelector((keymap) => { const bindings = keymap.getCommandBindings({ @@ -766,25 +744,8 @@ const home = (api: TuiPluginApi, input: Cfg) => ({ }, home_prompt(ctx, value) { const skin = look(ctx.theme.current) - type Prompt = (props: { - workspaceID?: string - visible?: boolean - disabled?: boolean - onSubmit?: () => void - hint?: JSX.Element - right?: JSX.Element - showPlaceholder?: boolean - placeholders?: { - normal?: string[] - shell?: string[] - } - }) => JSX.Element - type Slot = ( - props: { name: string; mode?: unknown; children?: JSX.Element } & Record, - ) => JSX.Element | null - const ui = api.ui as TuiPluginApi["ui"] & { Prompt: Prompt; Slot: Slot } - const Prompt = ui.Prompt - const Slot = ui.Slot + const Prompt = api.ui.Prompt + const Slot = api.ui.Slot const normal = [ `[SMOKE] route check for ${input.label}`, "[SMOKE] confirm home_prompt slot override", @@ -1003,20 +964,29 @@ const reg = (api: TuiPluginApi, input: Cfg, keys: Keys) => { }, }, ], - bindings: keys.sections.global, + bindings: keys.gather("smoke.global", [ + command.modal, + command.screen, + command.alert, + command.confirm, + command.prompt, + command.select, + command.host, + command.home, + command.toast, + ]), }) } const tui: TuiPlugin = async (api, options, meta) => { - const input = options as SmokeOptions | undefined - if (input?.enabled === false) return + if (options?.enabled === false) return await api.theme.install("./smoke-theme.json") api.theme.set("smoke-theme") - const value = cfg(input) + const value = cfg(options) const route = names(value) - const keys = createKeys(value.keymap) + const keys = createKeys(value.keybinds) const fx = new VignetteEffect(value.vignette) const post = fx.apply.bind(fx) api.renderer.addPostProcessFn(post) diff --git a/.opencode/tui.json b/.opencode/tui.json index e795209d9c..b92e58dac2 100644 --- a/.opencode/tui.json +++ b/.opencode/tui.json @@ -6,20 +6,12 @@ { "enabled": false, "label": "workspace", - "keymap": { - "sections": { - "global": { - "plugin.smoke.modal": "ctrl+alt+m", - "plugin.smoke.screen": "ctrl+alt+o" - }, - "screen": { - "plugin.smoke.screen.home": "escape,ctrl+shift+h", - "plugin.smoke.screen.modal": "ctrl+alt+m" - }, - "dialog": { - "plugin.smoke.dialog.close": "escape,q" - } - } + "keybinds": { + "smoke_modal": "ctrl+alt+m", + "smoke_screen": "ctrl+alt+o", + "smoke_screen_home": "escape,ctrl+shift+h", + "smoke_screen_modal": "ctrl+alt+m", + "smoke_dialog_close": "escape,q" } } ] diff --git a/AGENTS.md b/AGENTS.md index 44d08ae955..7913ddabd2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,6 +9,7 @@ ### General Principles - Keep things in one function unless composable or reusable +- Do not extract single-use helpers preemptively. Inline the logic at the call site unless the helper is reused, hides a genuinely complex boundary, or has a clear independent name that improves the caller. - Avoid `try`/`catch` where possible - Avoid using the `any` type - Use Bun APIs when possible, like `Bun.file()` diff --git a/bun.lock b/bun.lock index 093fb53d49..b61d2781dd 100644 --- a/bun.lock +++ b/bun.lock @@ -29,7 +29,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.14.41", + "version": "1.14.48", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/core": "workspace:*", @@ -85,7 +85,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.14.41", + "version": "1.14.48", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -111,6 +111,7 @@ "zod": "catalog:", }, "devDependencies": { + "@types/bun": "catalog:", "@typescript/native-preview": "catalog:", "@webgpu/types": "0.1.54", "typescript": "catalog:", @@ -119,7 +120,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.14.41", + "version": "1.14.48", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -146,7 +147,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.14.41", + "version": "1.14.48", "dependencies": { "@ai-sdk/anthropic": "3.0.64", "@ai-sdk/openai": "3.0.48", @@ -170,7 +171,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.14.41", + "version": "1.14.48", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -194,7 +195,7 @@ }, "packages/core": { "name": "@opencode-ai/core", - "version": "1.14.41", + "version": "1.14.48", "bin": { "opencode": "./bin/opencode", }, @@ -228,7 +229,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.14.41", + "version": "1.14.48", "dependencies": { "drizzle-orm": "catalog:", "effect": "catalog:", @@ -282,7 +283,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.14.41", + "version": "1.14.48", "dependencies": { "@opencode-ai/core": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -302,6 +303,7 @@ "devDependencies": { "@cloudflare/workers-types": "catalog:", "@tailwindcss/vite": "catalog:", + "@types/bun": "catalog:", "@types/luxon": "catalog:", "@typescript/native-preview": "catalog:", "tailwindcss": "catalog:", @@ -311,7 +313,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.14.41", + "version": "1.14.48", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -325,9 +327,40 @@ "typescript": "catalog:", }, }, + "packages/http-recorder": { + "name": "@opencode-ai/http-recorder", + "version": "1.14.48", + "dependencies": { + "@effect/platform-node": "catalog:", + "effect": "catalog:", + }, + "devDependencies": { + "@tsconfig/bun": "catalog:", + "@types/bun": "catalog:", + "@typescript/native-preview": "catalog:", + }, + }, + "packages/llm": { + "name": "@opencode-ai/llm", + "version": "1.14.48", + "dependencies": { + "@smithy/eventstream-codec": "4.2.14", + "@smithy/util-utf8": "4.2.2", + "aws4fetch": "1.0.20", + "effect": "catalog:", + }, + "devDependencies": { + "@clack/prompts": "1.0.0-alpha.1", + "@effect/platform-node": "catalog:", + "@opencode-ai/http-recorder": "workspace:*", + "@tsconfig/bun": "catalog:", + "@types/bun": "catalog:", + "@typescript/native-preview": "catalog:", + }, + }, "packages/opencode": { "name": "opencode", - "version": "1.14.41", + "version": "1.14.48", "bin": { "opencode": "./bin/opencode", }, @@ -360,10 +393,6 @@ "@effect/opentelemetry": "catalog:", "@effect/platform-node": "catalog:", "@gitlab/opencode-gitlab-auth": "1.3.3", - "@hono/node-server": "1.19.11", - "@hono/node-ws": "1.3.0", - "@hono/standard-validator": "0.1.5", - "@hono/zod-validator": "catalog:", "@lydell/node-pty": "catalog:", "@modelcontextprotocol/sdk": "1.27.1", "@octokit/graphql": "9.0.2", @@ -383,6 +412,7 @@ "@opentui/solid": "catalog:", "@parcel/watcher": "2.5.1", "@pierre/diffs": "catalog:", + "@silvia-odwyer/photon-node": "0.3.4", "@solid-primitives/event-bus": "1.1.2", "@solid-primitives/scheduled": "1.5.2", "@standard-schema/spec": "1.0.0", @@ -404,8 +434,6 @@ "glob": "13.0.5", "google-auth-library": "10.5.0", "gray-matter": "4.0.3", - "hono": "catalog:", - "hono-openapi": "catalog:", "ignore": "7.0.5", "immer": "11.1.4", "jsonc-parser": "3.3.1", @@ -432,7 +460,6 @@ "xdg-basedir": "5.1.0", "yargs": "18.0.0", "zod": "catalog:", - "zod-to-json-schema": "3.24.5", }, "devDependencies": { "@babel/core": "7.28.4", @@ -465,12 +492,11 @@ "typescript": "catalog:", "vscode-languageserver-types": "3.17.5", "why-is-node-running": "3.2.2", - "zod-to-json-schema": "3.24.5", }, }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.14.41", + "version": "1.14.48", "dependencies": { "@opencode-ai/sdk": "workspace:*", "effect": "catalog:", @@ -486,9 +512,9 @@ "typescript": "catalog:", }, "peerDependencies": { - "@opentui/core": ">=0.2.5", - "@opentui/keymap": ">=0.2.5", - "@opentui/solid": ">=0.2.5", + "@opentui/core": ">=0.2.6", + "@opentui/keymap": ">=0.2.6", + "@opentui/solid": ">=0.2.6", }, "optionalPeers": [ "@opentui/core", @@ -508,7 +534,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.14.41", + "version": "1.14.48", "dependencies": { "cross-spawn": "catalog:", }, @@ -523,7 +549,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.14.41", + "version": "1.14.48", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -558,7 +584,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.14.41", + "version": "1.14.48", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/core": "workspace:*", @@ -607,7 +633,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.14.41", + "version": "1.14.48", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", @@ -652,6 +678,7 @@ "solid-js@1.9.10": "patches/solid-js@1.9.10.patch", "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", "@npmcli/agent@4.0.0": "patches/@npmcli%2Fagent@4.0.0.patch", + "@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch", }, "overrides": { "@types/bun": "catalog:", @@ -667,9 +694,9 @@ "@npmcli/arborist": "9.4.0", "@octokit/rest": "22.0.0", "@openauthjs/openauth": "0.0.0-20250322224806", - "@opentui/core": "0.2.5", - "@opentui/keymap": "0.2.5", - "@opentui/solid": "0.2.5", + "@opentui/core": "0.2.6", + "@opentui/keymap": "0.2.6", + "@opentui/solid": "0.2.6", "@pierre/diffs": "1.1.0-beta.18", "@playwright/test": "1.59.1", "@sentry/solid": "10.36.0", @@ -1210,8 +1237,6 @@ "@hono/node-server": ["@hono/node-server@1.19.11", "", { "peerDependencies": { "hono": "^4" } }, "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g=="], - "@hono/node-ws": ["@hono/node-ws@1.3.0", "", { "dependencies": { "ws": "^8.17.0" }, "peerDependencies": { "@hono/node-server": "^1.19.2", "hono": "^4.6.0" } }, "sha512-ju25YbbvLuXdqBCmLZLqnNYu1nbHIQjoyUqA8ApZOeL1k4skuiTcw5SW77/5SUYo2Xi2NVBJoVlfQurnKEp03Q=="], - "@hono/standard-validator": ["@hono/standard-validator@0.1.5", "", { "peerDependencies": { "@standard-schema/spec": "1.0.0", "hono": ">=3.9.0" } }, "sha512-EIyZPPwkyLn6XKwFj5NBEWHXhXbgmnVh2ceIFo5GO7gKI9WmzTjPDKnppQB0KrqKeAkq3kpoW4SIbu5X1dgx3w=="], "@hono/zod-validator": ["@hono/zod-validator@0.4.2", "", { "peerDependencies": { "hono": ">=3.9.0", "zod": "^3.19.1" } }, "sha512-1rrlBg+EpDPhzOV4hT9pxr5+xDVmKuz6YJl+la7VCwK6ass5ldyKm5fD+umJdV2zhHD6jROoCCv8NbTwyfhT0g=="], @@ -1558,6 +1583,10 @@ "@opencode-ai/function": ["@opencode-ai/function@workspace:packages/function"], + "@opencode-ai/http-recorder": ["@opencode-ai/http-recorder@workspace:packages/http-recorder"], + + "@opencode-ai/llm": ["@opencode-ai/llm@workspace:packages/llm"], + "@opencode-ai/plugin": ["@opencode-ai/plugin@workspace:packages/plugin"], "@opencode-ai/script": ["@opencode-ai/script@workspace:packages/script"], @@ -1600,23 +1629,23 @@ "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="], - "@opentui/core": ["@opentui/core@0.2.5", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.2.5", "@opentui/core-darwin-x64": "0.2.5", "@opentui/core-linux-arm64": "0.2.5", "@opentui/core-linux-x64": "0.2.5", "@opentui/core-win32-arm64": "0.2.5", "@opentui/core-win32-x64": "0.2.5" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-A5DNOW39S60LtOcBdWYx7fuIGsPcClzbdKz9WuLp+wgy0Bt/jPw5XX6dk3k4dCX4jmhA1nX7x7680+GXLHPL6Q=="], + "@opentui/core": ["@opentui/core@0.2.6", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.2.6", "@opentui/core-darwin-x64": "0.2.6", "@opentui/core-linux-arm64": "0.2.6", "@opentui/core-linux-x64": "0.2.6", "@opentui/core-win32-arm64": "0.2.6", "@opentui/core-win32-x64": "0.2.6" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-dBpMaWVM7wtW2/2TlGPrkPjg6gOL3MVU/5XXk+U1LDJB8L4q4NeYWVdzfAVNcEvgmuuCy/cVqdY2D4ei+e7MMg=="], - "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Jdl8TN7oxV8NTaKZsUAt0B/A4hIYiyUKwXNSe4w1OchNMlgjwF1fx/7RhgHXSvWh1Fcqi1IH5FfhsmO89Aed1A=="], + "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-hR5nsxNj+059utzenTCF0kealUlibON6fLuebFUCGM/5kJnqa+shIh0XbUDFm0+F47vqVUgZufBdUuieQZIbvQ=="], - "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-78sKg0ZvwFHzZZGJCeaSNIVi2dadDxQymHAmrK698zEgnQr4eLVVB+MxNpxJx55/z9Y+YqbfSZaobC6w6Q3y5A=="], + "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-pJ/bH4WC/mbBaakM1YdH6TVo67jhy0KPd61bCz97w0I/PJGr8fmNKvhmMt/AwyFgOQi3FYZiEKLMpGdvUcSsrQ=="], - "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-BtqbOjP64hKQaVd0ApHunt0MjkEEKTvxpaBwk7OhwVCoYakQBDZTZXUQ9zuPXvaHc9IF286z1PnJGLu0t11BAw=="], + "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-9Pnd3kOxig8ii+/IqYheOPEgferylsQA0L6tKBnHQ9jRlCJOcu0Rv65Jepueh212vevdV9DzPURJnhejG06J6g=="], - "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.5", "", { "os": "linux", "cpu": "x64" }, "sha512-c3sEXtmOd1E5R4wfWh/MejplxgApYKqzyJ0AVMTU8pU1MHRAMwD8UFDMSVQhl7rYMTuBYPWok3IoCK2u8a2A4A=="], + "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.6", "", { "os": "linux", "cpu": "x64" }, "sha512-458Mx9tBzEPzfft8cSt5ZaIpEepoxBXBOL6AUVmDTKWaZ3uouraPcEKraGAyvOTDQp2XDI3R8c/2GdaR77FaUQ=="], - "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WlgpYkgmuvMPc2mYGJSwN7c+VGAxiZvMKwZEbS+w9PMj7sJhvY+zFrOJNFpvjbAFw8vS3Kz39km4Nj7GF8JH6w=="], + "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-BDUrdrT1RCcVnQoHJmUut4y811jDBAEtc6GJFB4Gs265Be8SrTjVCus6p2fSQ7j9sZQ1OcjO+5+4NkheSZICDQ=="], - "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.5", "", { "os": "win32", "cpu": "x64" }, "sha512-4X7BHJ7Wztzj7p0E+SsN0d4goUVU7Dy2VnhnD4n65ODgVbW59iqasAvbnPLbX3ghjgKiwQ+2SD+ImCIHE6uCAA=="], + "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.6", "", { "os": "win32", "cpu": "x64" }, "sha512-SUYAzRJ9TSoD2Qt8kn6FJz6dbTrFEPVig5mScB4zFGgGQO/Bbod2/Q31vLS/IQrX+FDb67WaErD+kuMCnMPPLA=="], - "@opentui/keymap": ["@opentui/keymap@0.2.5", "", { "dependencies": { "@opentui/core": "0.2.5" }, "peerDependencies": { "@opentui/react": "0.2.5", "@opentui/solid": "0.2.5", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-/B6Gy9LLRRKhvyDV1rFX0p7BUN8NQOcXwTV8E0xb7ym1yREvVmij+hCRkXXddMme2HW9NmV0+RRHo4kJzJxkNQ=="], + "@opentui/keymap": ["@opentui/keymap@0.2.6", "", { "dependencies": { "@opentui/core": "0.2.6" }, "peerDependencies": { "@opentui/react": "0.2.6", "@opentui/solid": "0.2.6", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-+6OYuedrFCKVo4ryGFNwws++2VOmPcXU3PwpY0mP47gYQY2nvQ+etWIs2Y7r5eMIqUfxVCldkKsrzcEcA4tb/A=="], - "@opentui/solid": ["@opentui/solid@0.2.5", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.5", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-M8MxDYJzjtF8TvxB6Q7656GOSS+QIg89jD0jf/asfF4qeip5TQhNZ3ba+R1v2fVuIkQCyRJzTtOtMZiglzGKPQ=="], + "@opentui/solid": ["@opentui/solid@0.2.6", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.6", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-2y225WlOGi/fCaajkxBmLyVW8Cr+OmhowHdvrYcz5w2kBD15sKbJLIYu1G9DxceirT1uIyasGy2TGzRRcVkTDg=="], "@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="], @@ -2014,6 +2043,8 @@ "@sigstore/verify": ["@sigstore/verify@3.1.0", "", { "dependencies": { "@sigstore/bundle": "^4.0.0", "@sigstore/core": "^3.1.0", "@sigstore/protobuf-specs": "^0.5.0" } }, "sha512-mNe0Iigql08YupSOGv197YdHpPPr+EzDZmfCgMc7RPNaZTw5aLN01nBl6CHJOh3BGtnMIj83EeN4butBchc8Ag=="], + "@silvia-odwyer/photon-node": ["@silvia-odwyer/photon-node@0.3.4", "", {}, "sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA=="], + "@sindresorhus/is": ["@sindresorhus/is@4.6.0", "", {}, "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw=="], "@slack/bolt": ["@slack/bolt@3.22.0", "", { "dependencies": { "@slack/logger": "^4.0.0", "@slack/oauth": "^2.6.3", "@slack/socket-mode": "^1.3.6", "@slack/types": "^2.13.0", "@slack/web-api": "^6.13.0", "@types/express": "^4.16.1", "@types/promise.allsettled": "^1.0.3", "@types/tsscmp": "^1.0.0", "axios": "^1.7.4", "express": "^4.21.0", "path-to-regexp": "^8.1.0", "promise.allsettled": "^1.0.2", "raw-body": "^2.3.3", "tsscmp": "^1.0.6" } }, "sha512-iKDqGPEJDnrVwxSVlFW6OKTkijd7s4qLBeSufoBsTM0reTyfdp/5izIQVkxNfzjHi3o6qjdYbRXkYad5HBsBog=="], @@ -5636,6 +5667,10 @@ "@opencode-ai/desktop/typescript": ["typescript@5.6.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw=="], + "@opencode-ai/llm/@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.14", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.14.1", "@smithy/util-hex-encoding": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-erZq0nOIpzfeZdCyzZjdJb4nVSKLUmSkaQUVkRGQTXs30gyUGeKnrYEg+Xe1W5gE3aReS7IgsvANwVPxSzY6Pw=="], + + "@opencode-ai/llm/@smithy/util-utf8": ["@smithy/util-utf8@4.2.2", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw=="], + "@opencode-ai/ui/@solid-primitives/resize-observer": ["@solid-primitives/resize-observer@2.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-zBLje5E06TgOg93S7rGPldmhDnouNGhvfZVKOp+oG2XU8snA+GoCSSCz1M+jpNAg5Ek2EakU5UVQqL152WmdXQ=="], "@opencode-ai/web/@shikijs/transformers": ["@shikijs/transformers@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/types": "3.20.0" } }, "sha512-PrHHMRr3Q5W1qB/42kJW6laqFyWdhrPF2hNR9qjOm1xcSiAO3hAHo7HaVyHE6pMyevmy3i51O8kuGGXC78uK3g=="], @@ -6712,6 +6747,8 @@ "@opencode-ai/desktop/@actions/artifact/@actions/http-client": ["@actions/http-client@2.2.3", "", { "dependencies": { "tunnel": "^0.0.6", "undici": "^5.25.4" } }, "sha512-mx8hyJi/hjFvbPokCg4uRd4ZX78t+YyRPtnKWwIl+RzNaVuFpQHfmlGVfsKEJN8LwTCvL+DfVgAM04XaHkm6bA=="], + "@opencode-ai/llm/@smithy/eventstream-codec/@smithy/types": ["@smithy/types@4.14.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-59b5HtSVrVR/eYNei3BUj3DCPKD/G7EtDDe7OEJE7i7FtQFugYo6MxbotS8mVJkLNVf8gYaAlEBwwtJ9HzhWSg=="], + "@opencode-ai/web/@shikijs/transformers/@shikijs/core": ["@shikijs/core@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-f2ED7HYV4JEk827mtMDwe/yQ25pRiXZmtHjWF8uzZKuKiEsJR7Ce1nuQ+HhV9FzDcbIo4ObBCD9GPTzNuy9S1g=="], "@opencode-ai/web/@shikijs/transformers/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="], diff --git a/infra/monitoring.ts b/infra/monitoring.ts index 3e101c6fd8..8de01b2752 100644 --- a/infra/monitoring.ts +++ b/infra/monitoring.ts @@ -38,7 +38,10 @@ const modelHttpErrorsQuery = (product: "go" | "zen") => { calculatedFields: [ { name: "is_failed_http_status", - expression: `IF(AND(GTE($status, "400"), NOT(EQUALS($status, "401"))), 1, 0)`, + expression: + product === "go" + ? `IF(AND(GTE($status, "400"), NOT(EQUALS($status, "401")), NOT(EQUALS($status, "429"))), 1, 0)` + : `IF(AND(GTE($status, "400"), NOT(EQUALS($status, "401"))), 1, 0)`, }, ], calculations: [ @@ -66,7 +69,7 @@ const providerHttpErrorsQuery = (product: "go" | "zen") => { }, { name: "is_failed_provider_http_status", - expression: `IF(GTE($llm.error.code, "400"), 1, 0)`, + expression: `IF(GT($llm.error.code, "400"), 1, 0)`, }, ], calculations: [ diff --git a/nix/hashes.json b/nix/hashes.json index 7a6e9c8c4b..4244e0c0e7 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-UxWxALOCC/n6JNFcu/IKjC/B9bySQmcr2riWO1Doc3s=", - "aarch64-linux": "sha256-QLM8fPkPOukwOLR26zgZHhWEbfaEhmIqIJSjoQYOvfg=", - "aarch64-darwin": "sha256-GjgCPpkTzqkeiLsp2P+Awtm0K0XKTJV7v9QJoGC02YU=", - "x86_64-darwin": "sha256-pm7xhKAUBgp+zDh1KzyOlKS2TYJpSdDPnZFqFHrflSA=" + "x86_64-linux": "sha256-baGxh+hk/rPhg0xI/OdMDz6dPwncgercYNBdTPnLX9o=", + "aarch64-linux": "sha256-VTWKq679B3Q4ZnAoQzC4VSCYA09wWecNJ+JajvjNB1U=", + "aarch64-darwin": "sha256-orf2zIBMTiiQrt/6qCzE+o0oKhv6u8zXF9DH1Bo3lbo=", + "x86_64-darwin": "sha256-1MZC1fadRoY4lhkmjlcUQTLYH9Q8pDI1bxd5f94f1xU=" } } diff --git a/package.json b/package.json index 385aa8c0d8..9fb623d72f 100644 --- a/package.json +++ b/package.json @@ -35,9 +35,9 @@ "@types/cross-spawn": "6.0.6", "@octokit/rest": "22.0.0", "@hono/zod-validator": "0.4.2", - "@opentui/core": "0.2.5", - "@opentui/keymap": "0.2.5", - "@opentui/solid": "0.2.5", + "@opentui/core": "0.2.6", + "@opentui/keymap": "0.2.6", + "@opentui/solid": "0.2.6", "ulid": "3.0.1", "@kobalte/core": "0.13.11", "@types/luxon": "3.7.1", @@ -133,6 +133,7 @@ }, "patchedDependencies": { "@npmcli/agent@4.0.0": "patches/@npmcli%2Fagent@4.0.0.patch", + "@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch", "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", "solid-js@1.9.10": "patches/solid-js@1.9.10.patch" } diff --git a/packages/app/package.json b/packages/app/package.json index 600c011b6b..9eb4083725 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "1.14.41", + "version": "1.14.48", "description": "", "type": "module", "exports": { diff --git a/packages/app/src/context/global-sync/child-store.ts b/packages/app/src/context/global-sync/child-store.ts index 0138310cdc..737c6bedc9 100644 --- a/packages/app/src/context/global-sync/child-store.ts +++ b/packages/app/src/context/global-sync/child-store.ts @@ -231,6 +231,7 @@ export function createChildStoreManager(input: { limit: 5, message: {}, part: {}, + part_text_accum_delta: {}, }) children[key] = child disposers.set(key, dispose) diff --git a/packages/app/src/context/global-sync/event-reducer.test.ts b/packages/app/src/context/global-sync/event-reducer.test.ts index 892129788e..f02ac5c7be 100644 --- a/packages/app/src/context/global-sync/event-reducer.test.ts +++ b/packages/app/src/context/global-sync/event-reducer.test.ts @@ -81,6 +81,7 @@ const baseState = (input: Partial = {}) => limit: 10, message: {}, part: {}, + part_text_accum_delta: {}, ...input, }) as State diff --git a/packages/app/src/context/global-sync/event-reducer.ts b/packages/app/src/context/global-sync/event-reducer.ts index 5f43c341bc..13d34ef6c5 100644 --- a/packages/app/src/context/global-sync/event-reducer.ts +++ b/packages/app/src/context/global-sync/event-reducer.ts @@ -211,6 +211,12 @@ export function applyDirectoryEvent(input: { const result = Binary.search(messages, props.messageID, (m) => m.id) if (result.found) messages.splice(result.index, 1) } + const parts = draft.part[props.messageID] + if (parts) { + for (const part of parts) { + delete draft.part_text_accum_delta[part.id] + } + } delete draft.part[props.messageID] }), ) @@ -219,6 +225,11 @@ export function applyDirectoryEvent(input: { case "message.part.updated": { const part = (event.properties as { part: Part }).part if (SKIP_PARTS.has(part.type)) break + input.setStore( + produce((draft) => { + delete draft.part_text_accum_delta[part.id] + }), + ) const parts = input.store.part[part.messageID] if (!parts) { input.setStore("part", part.messageID, [part]) @@ -240,6 +251,11 @@ export function applyDirectoryEvent(input: { } case "message.part.removed": { const props = event.properties as { messageID: string; partID: string } + input.setStore( + produce((draft) => { + delete draft.part_text_accum_delta[props.partID] + }), + ) const parts = input.store.part[props.messageID] if (!parts) break const result = Binary.search(parts, props.partID, (p) => p.id) @@ -263,6 +279,7 @@ export function applyDirectoryEvent(input: { if (!parts) break const result = Binary.search(parts, props.partID, (p) => p.id) if (!result.found) break + input.setStore("part_text_accum_delta", props.partID, (existing) => (existing ?? "") + props.delta) input.setStore( "part", props.messageID, diff --git a/packages/app/src/context/global-sync/session-cache.test.ts b/packages/app/src/context/global-sync/session-cache.test.ts index 472ac219e9..4b2be505ea 100644 --- a/packages/app/src/context/global-sync/session-cache.test.ts +++ b/packages/app/src/context/global-sync/session-cache.test.ts @@ -39,6 +39,7 @@ describe("app session cache", () => { part: Record permission: Record question: Record + part_text_accum_delta: Record } = { session_status: { ses_1: { type: "busy" } as SessionStatus }, session_diff: { ses_1: [] }, @@ -47,12 +48,14 @@ describe("app session cache", () => { part: { msg_1: [part("prt_1", "ses_1", "msg_1")] }, permission: { ses_1: [] as PermissionRequest[] }, question: { ses_1: [] as QuestionRequest[] }, + part_text_accum_delta: { prt_1: "streamed text" }, } dropSessionCaches(store, ["ses_1"]) expect(store.message.ses_1).toBeUndefined() expect(store.part.msg_1).toBeUndefined() + expect(store.part_text_accum_delta.prt_1).toBeUndefined() expect(store.todo.ses_1).toBeUndefined() expect(store.session_diff.ses_1).toBeUndefined() expect(store.session_status.ses_1).toBeUndefined() @@ -70,6 +73,7 @@ describe("app session cache", () => { part: Record permission: Record question: Record + part_text_accum_delta: Record } = { session_status: {}, session_diff: {}, @@ -78,6 +82,7 @@ describe("app session cache", () => { part: { [m.id]: [part("prt_1", "ses_1", m.id)] }, permission: {}, question: {}, + part_text_accum_delta: {}, } dropSessionCaches(store, ["ses_1"]) diff --git a/packages/app/src/context/global-sync/session-cache.ts b/packages/app/src/context/global-sync/session-cache.ts index 6f4d81062b..05cdc84643 100644 --- a/packages/app/src/context/global-sync/session-cache.ts +++ b/packages/app/src/context/global-sync/session-cache.ts @@ -18,6 +18,7 @@ type SessionCache = { part: Record permission: Record question: Record + part_text_accum_delta: Record } export function dropSessionCaches(store: SessionCache, sessionIDs: Iterable) { @@ -27,6 +28,9 @@ export function dropSessionCaches(store: SessionCache, sessionIDs: Iterable stale.has(part?.sessionID ?? ""))) continue + for (const part of parts) { + delete store.part_text_accum_delta[part.id] + } delete store.part[key] } diff --git a/packages/app/src/context/global-sync/types.ts b/packages/app/src/context/global-sync/types.ts index e3ec83c5ee..6bf42a0737 100644 --- a/packages/app/src/context/global-sync/types.ts +++ b/packages/app/src/context/global-sync/types.ts @@ -72,6 +72,9 @@ export type State = { part: { [messageID: string]: Part[] } + part_text_accum_delta: { + [partID: string]: string + } } export type VcsCache = { diff --git a/packages/app/src/pages/session/session-side-panel.tsx b/packages/app/src/pages/session/session-side-panel.tsx index 99197f0a70..66f5269bf9 100644 --- a/packages/app/src/pages/session/session-side-panel.tsx +++ b/packages/app/src/pages/session/session-side-panel.tsx @@ -28,6 +28,12 @@ import { createOpenSessionFileTab, createSessionTabs, getTabReorderIndex, type S import { setSessionHandoff } from "@/pages/session/handoff" import { useSessionLayout } from "@/pages/session/session-layout" +type RenderDiff = (SnapshotFileDiff & { file: string }) | VcsFileDiff + +function renderDiff(value: SnapshotFileDiff | VcsFileDiff): value is RenderDiff { + return typeof value.file === "string" +} + export function SessionSidePanel(props: { canReview: () => boolean diffs: () => (SnapshotFileDiff | VcsFileDiff)[] @@ -70,7 +76,8 @@ export function SessionSidePanel(props: { }) const treeWidth = createMemo(() => (fileOpen() ? `${layout.fileTree.width()}px` : "0px")) - const diffFiles = createMemo(() => props.diffs().map((d) => d.file)) + const diffs = createMemo(() => props.diffs().filter(renderDiff)) + const diffFiles = createMemo(() => diffs().map((d) => d.file)) const kinds = createMemo(() => { const merge = (a: "add" | "del" | "mix" | undefined, b: "add" | "del" | "mix") => { if (!a) return b @@ -81,7 +88,7 @@ export function SessionSidePanel(props: { const normalize = (p: string) => p.replaceAll("\\\\", "/").replace(/\/+$/, "") const out = new Map() - for (const diff of props.diffs()) { + for (const diff of diffs()) { const file = normalize(diff.file) const kind = diff.status === "added" ? "add" : diff.status === "deleted" ? "del" : "mix" diff --git a/packages/console/app/package.json b/packages/console/app/package.json index f2471d2926..7e736ca775 100644 --- a/packages/console/app/package.json +++ b/packages/console/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-app", - "version": "1.14.41", + "version": "1.14.48", "type": "module", "license": "MIT", "scripts": { @@ -35,6 +35,7 @@ "zod": "catalog:" }, "devDependencies": { + "@types/bun": "catalog:", "@typescript/native-preview": "catalog:", "@webgpu/types": "0.1.54", "typescript": "catalog:", diff --git a/packages/console/app/src/routes/workspace/[id]/usage/usage-section.module.css b/packages/console/app/src/routes/workspace/[id]/usage/usage-section.module.css index 00232de88f..866ed9ab5c 100644 --- a/packages/console/app/src/routes/workspace/[id]/usage/usage-section.module.css +++ b/packages/console/app/src/routes/workspace/[id]/usage/usage-section.module.css @@ -67,6 +67,7 @@ display: inline-flex; align-items: center; justify-content: center; + flex-shrink: 0; padding: 0; background: transparent; border: none; @@ -79,6 +80,7 @@ } svg { + flex-shrink: 0; width: 16px; height: 16px; } diff --git a/packages/console/app/src/routes/workspace/[id]/usage/usage-section.tsx b/packages/console/app/src/routes/workspace/[id]/usage/usage-section.tsx index 2cf8ef850a..2075052c7d 100644 --- a/packages/console/app/src/routes/workspace/[id]/usage/usage-section.tsx +++ b/packages/console/app/src/routes/workspace/[id]/usage/usage-section.tsx @@ -53,7 +53,7 @@ export function UsageSection() { } const calculateTotalOutputTokens = (u: Awaited>[0]) => { - return u.outputTokens + (u.reasoningTokens ?? 0) + return u.outputTokens } const goPrev = async () => { diff --git a/packages/console/app/src/routes/zen/util/handler.ts b/packages/console/app/src/routes/zen/util/handler.ts index 278a541610..dad65807d3 100644 --- a/packages/console/app/src/routes/zen/util/handler.ts +++ b/packages/console/app/src/routes/zen/util/handler.ts @@ -889,10 +889,6 @@ export async function handler( const inputCost = modelCost.input * inputTokens * 100 const outputCost = modelCost.output * outputTokens * 100 - const reasoningCost = (() => { - if (!reasoningTokens) return undefined - return modelCost.output * reasoningTokens * 100 - })() const cacheReadCost = (() => { if (!cacheReadTokens) return undefined if (!modelCost.cacheRead) return undefined @@ -909,17 +905,11 @@ export async function handler( return modelCost.cacheWrite1h * cacheWrite1hTokens * 100 })() const totalCostInCent = - inputCost + - outputCost + - (reasoningCost ?? 0) + - (cacheReadCost ?? 0) + - (cacheWrite5mCost ?? 0) + - (cacheWrite1hCost ?? 0) + inputCost + outputCost + (cacheReadCost ?? 0) + (cacheWrite5mCost ?? 0) + (cacheWrite1hCost ?? 0) return { totalCostInCent, inputCost, outputCost, - reasoningCost, cacheReadCost, cacheWrite5mCost, cacheWrite1hCost, @@ -941,8 +931,7 @@ export async function handler( ) { const { inputTokens, outputTokens, reasoningTokens, cacheReadTokens, cacheWrite5mTokens, cacheWrite1hTokens } = usageInfo - const { totalCostInCent, inputCost, outputCost, reasoningCost, cacheReadCost, cacheWrite5mCost, cacheWrite1hCost } = - costInfo + const { totalCostInCent, inputCost, outputCost, cacheReadCost, cacheWrite5mCost, cacheWrite1hCost } = costInfo logger.metric({ "tokens.input": inputTokens, @@ -953,14 +942,12 @@ export async function handler( "tokens.cache_write_1h": cacheWrite1hTokens, "cost.input.microcents": centsToMicroCents(inputCost), "cost.output.microcents": centsToMicroCents(outputCost), - "cost.reasoning.microcents": reasoningCost ? centsToMicroCents(reasoningCost) : undefined, "cost.cache_read.microcents": cacheReadCost ? centsToMicroCents(cacheReadCost) : undefined, "cost.cache_write.microcents": cacheWrite5mCost ? centsToMicroCents(cacheWrite5mCost) : undefined, "cost.total.microcents": centsToMicroCents(totalCostInCent), // deprecated - remove after May 20, 2026 "cost.input": Math.round(inputCost), "cost.output": Math.round(outputCost), - "cost.reasoning": reasoningCost ? Math.round(reasoningCost) : undefined, "cost.cache_read": cacheReadCost ? Math.round(cacheReadCost) : undefined, "cost.cache_write_5m": cacheWrite5mCost ? Math.round(cacheWrite5mCost) : undefined, "cost.cache_write_1h": cacheWrite1hCost ? Math.round(cacheWrite1hCost) : undefined, diff --git a/packages/console/app/src/routes/zen/util/provider/openai.ts b/packages/console/app/src/routes/zen/util/provider/openai.ts index 5d61a903ef..1c5cbdb3c9 100644 --- a/packages/console/app/src/routes/zen/util/provider/openai.ts +++ b/packages/console/app/src/routes/zen/util/provider/openai.ts @@ -50,7 +50,7 @@ export const openaiHelper: ProviderHelper = ({ workspaceID }) => ({ const cacheReadTokens = usage.input_tokens_details?.cached_tokens ?? undefined return { inputTokens: inputTokens - (cacheReadTokens ?? 0), - outputTokens: outputTokens - (reasoningTokens ?? 0), + outputTokens, reasoningTokens, cacheReadTokens, cacheWrite5mTokens: undefined, diff --git a/packages/console/app/tsconfig.json b/packages/console/app/tsconfig.json index e5fb212de5..be7ee43194 100644 --- a/packages/console/app/tsconfig.json +++ b/packages/console/app/tsconfig.json @@ -12,7 +12,7 @@ "allowJs": true, "strict": true, "noEmit": true, - "types": ["vite/client", "@webgpu/types"], + "types": ["vite/client", "@webgpu/types", "bun"], "isolatedModules": true, "paths": { "~/*": ["./src/*"] diff --git a/packages/console/core/package.json b/packages/console/core/package.json index 4ca29eb4c7..986103ece3 100644 --- a/packages/console/core/package.json +++ b/packages/console/core/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/console-core", - "version": "1.14.41", + "version": "1.14.48", "private": true, "type": "module", "license": "MIT", diff --git a/packages/console/function/package.json b/packages/console/function/package.json index 7e1d77d7dc..41487f845a 100644 --- a/packages/console/function/package.json +++ b/packages/console/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-function", - "version": "1.14.41", + "version": "1.14.48", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/console/mail/package.json b/packages/console/mail/package.json index 34ddd073f0..6c101f051e 100644 --- a/packages/console/mail/package.json +++ b/packages/console/mail/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-mail", - "version": "1.14.41", + "version": "1.14.48", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", diff --git a/packages/core/package.json b/packages/core/package.json index 995ab18ee5..e2ffa31d8d 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.14.41", + "version": "1.14.48", "name": "@opencode-ai/core", "type": "module", "license": "MIT", diff --git a/packages/opencode/src/util/effect-zod.ts b/packages/core/src/effect-zod.ts similarity index 99% rename from packages/opencode/src/util/effect-zod.ts rename to packages/core/src/effect-zod.ts index 1c88712d7d..42d89ec7d5 100644 --- a/packages/opencode/src/util/effect-zod.ts +++ b/packages/core/src/effect-zod.ts @@ -36,7 +36,7 @@ export function zod(schema: S): z.ZodType` extends * `object` via the brand and gets walked into the prototype by `DeepPartial`, - * `updateSchema`, etc.), and zod's inference through `z.ZodType` + * mapped-schema helpers, and zod's inference through `z.ZodType` * wrappers also can't reconstruct `T` cleanly. Consumers that care about the * post-`.omit()` shape should cast `c.req.valid(...)` to the expected type. */ diff --git a/packages/core/src/flag/flag.ts b/packages/core/src/flag/flag.ts index f55c14bd05..3fe7655759 100644 --- a/packages/core/src/flag/flag.ts +++ b/packages/core/src/flag/flag.ts @@ -11,9 +11,12 @@ function falsy(key: string) { return value === "false" || value === "0" } -// Channels that default to the new effect-httpapi server backend. The legacy -// hono backend remains the default for stable (`prod`/`latest`) installs. -const HTTPAPI_DEFAULT_ON_CHANNELS = new Set(["dev", "beta", "local"]) +// Channels where new experiments default to ON (unstable / internal users). +// Stable channels (`prod`, `latest`) stay opt-in. +const UNSTABLE_CHANNELS = new Set(["dev", "beta", "local"]) +function unstableDefault(key: string) { + return truthy(key) || (!falsy(key) && UNSTABLE_CHANNELS.has(InstallationChannel)) +} function number(key: string) { const value = process.env[key] @@ -53,6 +56,9 @@ export const Flag = { OPENCODE_DISABLE_CLAUDE_CODE_PROMPT: OPENCODE_DISABLE_CLAUDE_CODE || truthy("OPENCODE_DISABLE_CLAUDE_CODE_PROMPT"), OPENCODE_DISABLE_CLAUDE_CODE_SKILLS, OPENCODE_DISABLE_EXTERNAL_SKILLS: truthy("OPENCODE_DISABLE_EXTERNAL_SKILLS"), + // Default-on for dev/beta/local; opt-in for stable. Set + // OPENCODE_EXPERIMENTAL_CUSTOMIZE_SKILL=false to force off, =true to force on. + OPENCODE_EXPERIMENTAL_CUSTOMIZE_SKILL: unstableDefault("OPENCODE_EXPERIMENTAL_CUSTOMIZE_SKILL"), OPENCODE_FAKE_VCS: process.env["OPENCODE_FAKE_VCS"], OPENCODE_SERVER_PASSWORD: process.env["OPENCODE_SERVER_PASSWORD"], OPENCODE_SERVER_USERNAME: process.env["OPENCODE_SERVER_USERNAME"], @@ -76,6 +82,7 @@ export const Flag = { OPENCODE_EXPERIMENTAL_LSP_TY: truthy("OPENCODE_EXPERIMENTAL_LSP_TY"), OPENCODE_EXPERIMENTAL_LSP_TOOL: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_LSP_TOOL"), OPENCODE_EXPERIMENTAL_PLAN_MODE: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_PLAN_MODE"), + OPENCODE_EXPERIMENTAL_SCOUT: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_SCOUT"), OPENCODE_EXPERIMENTAL_MARKDOWN: !falsy("OPENCODE_EXPERIMENTAL_MARKDOWN"), OPENCODE_ENABLE_PARALLEL: truthy("OPENCODE_ENABLE_PARALLEL") || truthy("OPENCODE_EXPERIMENTAL_PARALLEL"), OPENCODE_MODELS_URL: process.env["OPENCODE_MODELS_URL"], @@ -87,14 +94,6 @@ export const Flag = { OPENCODE_STRICT_CONFIG_DEPS: truthy("OPENCODE_STRICT_CONFIG_DEPS"), OPENCODE_WORKSPACE_ID: process.env["OPENCODE_WORKSPACE_ID"], - // Defaults to true on dev/beta/local channels so internal users exercise the - // new effect-httpapi server backend. Stable (`prod`/`latest`) installs stay - // on the legacy hono backend until the rollout is complete. An explicit env - // var ("true"/"1" or "false"/"0") always wins, providing an opt-in for - // stable users and an escape hatch for dev/beta users. - OPENCODE_EXPERIMENTAL_HTTPAPI: - truthy("OPENCODE_EXPERIMENTAL_HTTPAPI") || - (!falsy("OPENCODE_EXPERIMENTAL_HTTPAPI") && HTTPAPI_DEFAULT_ON_CHANNELS.has(InstallationChannel)), OPENCODE_EXPERIMENTAL_WORKSPACES: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_WORKSPACES"), OPENCODE_EXPERIMENTAL_EVENT_SYSTEM: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_EVENT_SYSTEM"), diff --git a/packages/core/src/global.ts b/packages/core/src/global.ts index 6560d308c1..5f9799c252 100644 --- a/packages/core/src/global.ts +++ b/packages/core/src/global.ts @@ -20,6 +20,7 @@ const paths = { data, bin: path.join(cache, "bin"), log: path.join(data, "log"), + repos: path.join(data, "repos"), cache, config, state, @@ -37,6 +38,7 @@ await Promise.all([ fs.mkdir(Path.tmp, { recursive: true }), fs.mkdir(Path.log, { recursive: true }), fs.mkdir(Path.bin, { recursive: true }), + fs.mkdir(Path.repos, { recursive: true }), ]) export class Service extends Context.Service()("@opencode/Global") {} @@ -50,6 +52,7 @@ export interface Interface { readonly tmp: string readonly bin: string readonly log: string + readonly repos: string } export function make(input: Partial = {}): Interface { @@ -62,6 +65,7 @@ export function make(input: Partial = {}): Interface { tmp: Path.tmp, bin: Path.bin, log: Path.log, + repos: Path.repos, ...input, } } diff --git a/packages/opencode/src/util/schema.ts b/packages/core/src/schema.ts similarity index 100% rename from packages/opencode/src/util/schema.ts rename to packages/core/src/schema.ts diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 49e35c5db8..0dfcd4544b 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop", "private": true, - "version": "1.14.41", + "version": "1.14.48", "type": "module", "license": "MIT", "homepage": "https://opencode.ai", diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json index beccdb6991..88e5406cbf 100644 --- a/packages/enterprise/package.json +++ b/packages/enterprise/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/enterprise", - "version": "1.14.41", + "version": "1.14.48", "private": true, "type": "module", "license": "MIT", @@ -32,6 +32,7 @@ "@cloudflare/workers-types": "catalog:", "@tailwindcss/vite": "catalog:", "@typescript/native-preview": "catalog:", + "@types/bun": "catalog:", "@types/luxon": "catalog:", "tailwindcss": "catalog:", "typescript": "catalog:", diff --git a/packages/enterprise/tsconfig.json b/packages/enterprise/tsconfig.json index af4ce16490..eafea7e4f0 100644 --- a/packages/enterprise/tsconfig.json +++ b/packages/enterprise/tsconfig.json @@ -11,7 +11,7 @@ "allowJs": true, "noEmit": true, "strict": true, - "types": ["@cloudflare/workers-types", "vite/client"], + "types": ["@cloudflare/workers-types", "vite/client", "bun"], "isolatedModules": true, "paths": { "~/*": ["./src/*"] diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index 8b4850c885..9b77cf8b9f 100644 --- a/packages/extensions/zed/extension.toml +++ b/packages/extensions/zed/extension.toml @@ -1,7 +1,7 @@ id = "opencode" name = "OpenCode" description = "The open source coding agent." -version = "1.14.41" +version = "1.14.48" schema_version = 1 authors = ["Anomaly"] repository = "https://github.com/anomalyco/opencode" @@ -11,26 +11,26 @@ name = "OpenCode" icon = "./icons/opencode.svg" [agent_servers.opencode.targets.darwin-aarch64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.41/opencode-darwin-arm64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.48/opencode-darwin-arm64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.darwin-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.41/opencode-darwin-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.48/opencode-darwin-x64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-aarch64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.41/opencode-linux-arm64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.48/opencode-linux-arm64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.41/opencode-linux-x64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.48/opencode-linux-x64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.windows-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.41/opencode-windows-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.48/opencode-windows-x64.zip" cmd = "./opencode.exe" args = ["acp"] diff --git a/packages/function/package.json b/packages/function/package.json index 70812ab10a..b644ca7df5 100644 --- a/packages/function/package.json +++ b/packages/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/function", - "version": "1.14.41", + "version": "1.14.48", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/http-recorder/README.md b/packages/http-recorder/README.md new file mode 100644 index 0000000000..f6aaed4358 --- /dev/null +++ b/packages/http-recorder/README.md @@ -0,0 +1,214 @@ +# @opencode-ai/http-recorder + +Record and replay HTTP and WebSocket traffic for Effect's `HttpClient`. Tests +exercise real request shapes against deterministic, version-controlled +cassettes — no manual mocks, no flakes from upstream drift. + +## Install + +Internal package; depended on as `@opencode-ai/http-recorder` from another +workspace package. + +```ts +import { HttpRecorder } from "@opencode-ai/http-recorder" +``` + +## Quickstart + +Provide `cassetteLayer(name)` in place of (or layered over) your `HttpClient`. +By default the layer records on first run and replays on subsequent runs — +no env-var ternary at the call site, and `CI=true` forces strict replay so +missing cassettes fail loudly in CI rather than silently re-recording. + +```ts +import { Effect } from "effect" +import { HttpClient, HttpClientRequest } from "effect/unstable/http" +import { HttpRecorder } from "@opencode-ai/http-recorder" + +const program = Effect.gen(function* () { + const http = yield* HttpClient.HttpClient + const response = yield* http.execute(HttpClientRequest.get("https://api.example.com/users/1")) + return yield* response.json +}) + +// Records if the cassette is missing, replays if it exists. +// In CI (CI=true) always replays — fails loudly on missing fixtures. +Effect.runPromise(program.pipe(Effect.provide(HttpRecorder.cassetteLayer("users/get-one")))) + +// Force a refresh — always hits upstream and overwrites. +Effect.runPromise(program.pipe(Effect.provide(HttpRecorder.cassetteLayer("users/get-one", { mode: "record" })))) +``` + +## Modes + +| Mode | Behavior | +| ------------- | ----------------------------------------------------------------------------------- | +| `auto` | Default. Replay if the cassette exists; record if missing. `CI=true` forces replay. | +| `replay` | Strict — match the request to a recorded interaction; error if none. | +| `record` | Execute upstream, append the interaction, write the cassette. | +| `passthrough` | Bypass the recorder entirely — just call upstream. | + +## Cassette format + +A cassette is JSON at `test/fixtures/recordings/.json`: + +```json +{ + "version": 1, + "metadata": { "name": "users/get-one", "recordedAt": "2026-05-09T..." }, + "interactions": [ + { + "transport": "http", + "request": { "method": "GET", "url": "...", "headers": {...}, "body": "" }, + "response": { "status": 200, "headers": {...}, "body": "..." } + } + ] +} +``` + +Cassettes are normal source files — review them, diff them, commit them. + +## Request matching + +By default, requests match on canonicalized method, URL, headers, and JSON +body (object keys sorted). Two dispatch strategies are available: + +- **`match`** (default) — find the first recorded interaction whose request + matches the incoming request. Same request twice returns the same response. +- **`sequential`** — return interactions in the order they were recorded, + validating each one matches as the cursor advances. Use for ordered flows + where the same URL is hit multiple times with meaningful state changes + (pagination, retries, polling). + +```ts +HttpRecorder.cassetteLayer("flow/poll-until-done", { dispatch: "sequential" }) +``` + +Supply your own matcher via `match: (incoming, recorded) => boolean` for +custom equivalence (e.g. ignoring a timestamp field in the body). + +## Redaction & secret safety + +Cassettes get checked in, so the recorder is aggressive about not letting +secrets escape. Redaction is configured by composing a `Redactor`: + +```ts +import { HttpRecorder, Redactor } from "@opencode-ai/http-recorder" + +HttpRecorder.cassetteLayer("anthropic/messages", { + redactor: Redactor.defaults({ + requestHeaders: { allow: ["content-type", "anthropic-version"] }, + url: { transform: (url) => url.replace(/\/accounts\/[^/]+/, "/accounts/{account}") }, + body: (parsed) => ({ ...(parsed as object), user_id: "{user}" }), + }), +}) +``` + +`Redactor.defaults({ … })` composes the four built-in redactors with your +overrides. For full control, build the stack yourself: + +```ts +const redactor = Redactor.compose( + Redactor.requestHeaders({ allow: ["content-type", "x-custom"] }), + Redactor.responseHeaders(), + Redactor.url({ query: ["session-id"] }), + Redactor.body((parsed) => /* … */), +) +``` + +What each layer does: + +- **`requestHeaders` / `responseHeaders`** — strip headers to a small + allow-list (request default: `content-type`, `accept`, `openai-beta`; + response default: `content-type`). Sensitive headers within the + allow-list (`authorization`, `cookie`, API-key headers, AWS/GCP tokens, + …) are replaced with `[REDACTED]`. +- **`url`** — query parameters matching common secret names (`api_key`, + `token`, `signature`, AWS signing params, …) are replaced with + `[REDACTED]`. URL user/password are replaced. `transform` runs after + built-in redaction for path-level scrubbing. +- **`body`** — receives the parsed JSON request body and returns a redacted + version. No-op for non-JSON bodies. + +After assembling the cassette, the recorder scans every string for known +secret patterns (Bearer tokens, `sk-…`, `sk-ant-…`, Google `AIza…` keys, +AWS access keys, GitHub tokens, PEM blocks) and for values matching any +environment variable named like a credential. If anything is found, the +cassette is **not written** and the request fails with `UnsafeCassetteError` +listing what was detected. + +## WebSocket recording + +WebSocket support records the open frame plus client/server message +streams. It uses the shared `Cassette.Service`, so HTTP and WS interactions +can live in the same cassette. + +```ts +import { HttpRecorder } from "@opencode-ai/http-recorder" +import { Effect } from "effect" + +const program = Effect.gen(function* () { + const cassette = yield* HttpRecorder.Cassette.Service + const executor = yield* HttpRecorder.makeWebSocketExecutor({ + name: "ws/subscribe", + cassette, + live: liveExecutor, + }) + // use executor.open(...) +}) +``` + +## Inspecting cassettes programmatically + +`Cassette.Service` exposes `read`, `append`, `exists`, and `list`. `read` +returns the recorded interactions for a name; the file format is hidden +behind the seam. Useful for CI checks: + +```ts +import { HttpRecorder } from "@opencode-ai/http-recorder" +import { Effect } from "effect" + +const audit = Effect.gen(function* () { + const cassettes = yield* HttpRecorder.Cassette.Service + const entries = yield* cassettes.list() + const issues = yield* Effect.forEach(entries, (entry) => + cassettes + .read(entry.name) + .pipe(Effect.map((interactions) => ({ name: entry.name, findings: HttpRecorder.secretFindings(interactions) }))), + ) + return issues.filter((i) => i.findings.length > 0) +}) +``` + +`cassetteLayer` is the batteries-included entry point — it provides +`Cassette.fileSystem({ directory })` automatically. If you want to provide +your own `Cassette.Service` (e.g. an in-memory adapter for the recorder's +own unit tests), use `recordingLayer` and supply `Cassette.fileSystem` / +`Cassette.memory` yourself. + +## Options reference + +```ts +type RecordReplayOptions = { + mode?: "auto" | "replay" | "record" | "passthrough" // default: "auto" (CI=true forces "replay") + directory?: string // default: /test/fixtures/recordings + metadata?: Record // merged into cassette.metadata + redactor?: Redactor // default: Redactor.defaults() + dispatch?: "match" | "sequential" // default: "match" + match?: (incoming, recorded) => boolean // custom matcher +} +``` + +## Layout + +| File | Purpose | +| -------------- | -------------------------------------------------------------------------------- | +| `effect.ts` | `cassetteLayer` / `recordingLayer` — the `HttpClient` adapter. | +| `websocket.ts` | `makeWebSocketExecutor` — WebSocket record/replay. | +| `cassette.ts` | `Cassette.Service` — reads/writes cassette files, accumulates state. | +| `recorder.ts` | Shared transport plumbing: `UnsafeCassetteError`, `appendOrFail`, `ReplayState`. | +| `redactor.ts` | Composable `Redactor` — headers, url, body redaction. | +| `redaction.ts` | Lower-level header/URL primitives + secret pattern detection. | +| `schema.ts` | Effect Schema definitions for the cassette JSON format. | +| `storage.ts` | Path resolution, JSON encode/decode, sync existence check. | +| `matching.ts` | Request matcher, canonicalization, dispatch strategies, mismatch diagnostics. | diff --git a/packages/http-recorder/package.json b/packages/http-recorder/package.json new file mode 100644 index 0000000000..18ea8b1759 --- /dev/null +++ b/packages/http-recorder/package.json @@ -0,0 +1,26 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "version": "1.14.48", + "name": "@opencode-ai/http-recorder", + "type": "module", + "license": "MIT", + "private": true, + "scripts": { + "test": "bun test --timeout 30000", + "test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml", + "typecheck": "tsgo --noEmit" + }, + "exports": { + ".": "./src/index.ts", + "./*": "./src/*.ts" + }, + "devDependencies": { + "@tsconfig/bun": "catalog:", + "@types/bun": "catalog:", + "@typescript/native-preview": "catalog:" + }, + "dependencies": { + "@effect/platform-node": "catalog:", + "effect": "catalog:" + } +} diff --git a/packages/http-recorder/src/cassette.ts b/packages/http-recorder/src/cassette.ts new file mode 100644 index 0000000000..3897f0222c --- /dev/null +++ b/packages/http-recorder/src/cassette.ts @@ -0,0 +1,150 @@ +import { Context, Effect, FileSystem, Layer, Schema } from "effect" +import * as fs from "node:fs" +import * as path from "node:path" +import { secretFindings, type SecretFinding } from "./redaction" +import { decodeCassette, encodeCassette, type Cassette, type CassetteMetadata, type Interaction } from "./schema" + +const DEFAULT_RECORDINGS_DIR = path.resolve(process.cwd(), "test", "fixtures", "recordings") + +export class CassetteNotFoundError extends Schema.TaggedErrorClass()("CassetteNotFoundError", { + cassetteName: Schema.String, +}) { + override get message() { + return `Cassette "${this.cassetteName}" not found` + } +} + +export interface AppendResult { + readonly findings: ReadonlyArray +} + +export interface Interface { + readonly read: (name: string) => Effect.Effect, CassetteNotFoundError> + readonly append: (name: string, interaction: Interaction, metadata?: CassetteMetadata) => Effect.Effect + readonly exists: (name: string) => Effect.Effect + readonly list: () => Effect.Effect> +} + +export class Service extends Context.Service()("@opencode-ai/http-recorder/Cassette") {} + +export const hasCassetteSync = (name: string, options: { readonly directory?: string } = {}) => + fs.existsSync(path.join(options.directory ?? DEFAULT_RECORDINGS_DIR, `${name}.json`)) + +const buildCassette = ( + name: string, + interactions: ReadonlyArray, + metadata: CassetteMetadata | undefined, +): Cassette => ({ + version: 1, + metadata: { name, recordedAt: new Date().toISOString(), ...(metadata ?? {}) }, + interactions, +}) + +const formatCassette = (cassette: Cassette) => `${JSON.stringify(encodeCassette(cassette), null, 2)}\n` + +const parseCassette = (raw: string) => decodeCassette(JSON.parse(raw)) + +export const fileSystem = ( + options: { readonly directory?: string } = {}, +): Layer.Layer => + Layer.effect( + Service, + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + const directory = options.directory ?? DEFAULT_RECORDINGS_DIR + const recorded = new Map() + const directoriesEnsured = new Set() + + const cassettePath = (name: string) => path.join(directory, `${name}.json`) + + const ensureDirectory = (name: string) => + Effect.gen(function* () { + const dir = path.dirname(cassettePath(name)) + if (directoriesEnsured.has(dir)) return + yield* fs.makeDirectory(dir, { recursive: true }).pipe(Effect.orDie) + directoriesEnsured.add(dir) + }) + + const walk = (current: string): Effect.Effect> => + Effect.gen(function* () { + const entries = yield* fs.readDirectory(current).pipe(Effect.catch(() => Effect.succeed([] as string[]))) + const nested = yield* Effect.forEach(entries, (entry) => { + const full = path.join(current, entry) + return fs.stat(full).pipe( + Effect.flatMap((stat) => (stat.type === "Directory" ? walk(full) : Effect.succeed([full]))), + Effect.catch(() => Effect.succeed([] as string[])), + ) + }) + return nested.flat() + }) + + return Service.of({ + read: (name) => + fs.readFileString(cassettePath(name)).pipe( + Effect.map((raw) => parseCassette(raw).interactions), + Effect.catch(() => Effect.fail(new CassetteNotFoundError({ cassetteName: name }))), + ), + append: (name, interaction, metadata) => + Effect.gen(function* () { + const entry = recorded.get(name) ?? { interactions: [], findings: [] } + if (!recorded.has(name)) recorded.set(name, entry) + entry.interactions.push(interaction) + entry.findings.push(...secretFindings(interaction)) + const cassette = buildCassette(name, entry.interactions, metadata) + const findings = [...entry.findings, ...secretFindings(cassette.metadata ?? {})] + if (findings.length === 0) { + yield* ensureDirectory(name) + yield* fs.writeFileString(cassettePath(name), formatCassette(cassette)).pipe(Effect.orDie) + } + return { findings } + }), + exists: (name) => + fs.access(cassettePath(name)).pipe( + Effect.as(true), + Effect.catch(() => Effect.succeed(false)), + ), + list: () => + walk(directory).pipe( + Effect.map((files) => + files + .filter((file) => file.endsWith(".json")) + .map((file) => + path + .relative(directory, file) + .replace(/\\/g, "/") + .replace(/\.json$/, ""), + ) + .toSorted((a, b) => a.localeCompare(b)), + ), + ), + }) + }), + ) + +export const memory = (initial: Record> = {}): Layer.Layer => + Layer.sync(Service, () => { + const stored = new Map( + Object.entries(initial).map(([name, interactions]) => [name, [...interactions]]), + ) + const accumulatedFindings = new Map() + + return Service.of({ + read: (name) => + stored.has(name) + ? Effect.succeed(stored.get(name) ?? []) + : Effect.fail(new CassetteNotFoundError({ cassetteName: name })), + append: (name, interaction, metadata) => + Effect.sync(() => { + const existing = stored.get(name) + if (existing) existing.push(interaction) + else stored.set(name, [interaction]) + const findings = accumulatedFindings.get(name) + if (findings) findings.push(...secretFindings(interaction)) + else accumulatedFindings.set(name, [...secretFindings(interaction)]) + if (metadata) accumulatedFindings.get(name)!.push(...secretFindings({ name, ...metadata })) + return { findings: accumulatedFindings.get(name) ?? [] } + }), + exists: (name) => Effect.sync(() => stored.has(name)), + list: () => Effect.sync(() => Array.from(stored.keys()).toSorted()), + }) + }) diff --git a/packages/http-recorder/src/effect.ts b/packages/http-recorder/src/effect.ts new file mode 100644 index 0000000000..e6c3ccbc15 --- /dev/null +++ b/packages/http-recorder/src/effect.ts @@ -0,0 +1,144 @@ +import { NodeFileSystem } from "@effect/platform-node" +import { Effect, Layer, Option } from "effect" +import { + FetchHttpClient, + Headers, + HttpBody, + HttpClient, + HttpClientError, + HttpClientRequest, + HttpClientResponse, + UrlParams, +} from "effect/unstable/http" +import * as CassetteService from "./cassette" +import { defaultMatcher, selectMatch, selectSequential, type RequestMatcher } from "./matching" +import { appendOrFail, makeReplayState, resolveAutoMode } from "./recorder" +import { defaults, type Redactor } from "./redactor" +import { redactUrl } from "./redaction" +import { httpInteractions, type CassetteMetadata, type HttpInteraction, type ResponseSnapshot } from "./schema" + +export type RecordReplayMode = "auto" | "record" | "replay" | "passthrough" + +export interface RecordReplayOptions { + readonly mode?: RecordReplayMode + readonly directory?: string + readonly metadata?: CassetteMetadata + readonly redactor?: Redactor + readonly dispatch?: "match" | "sequential" + readonly match?: RequestMatcher +} + +const BINARY_CONTENT_TYPES: ReadonlyArray = ["vnd.amazon.eventstream", "octet-stream"] + +const isBinaryContentType = (contentType: string | undefined) => + contentType !== undefined && BINARY_CONTENT_TYPES.some((token) => contentType.toLowerCase().includes(token)) + +const captureResponseBody = (response: HttpClientResponse.HttpClientResponse, contentType: string | undefined) => + isBinaryContentType(contentType) + ? response.arrayBuffer.pipe( + Effect.map((bytes) => ({ body: Buffer.from(bytes).toString("base64"), bodyEncoding: "base64" as const })), + ) + : response.text.pipe(Effect.map((body) => ({ body }))) + +const decodeResponseBody = (snapshot: ResponseSnapshot) => + snapshot.bodyEncoding === "base64" ? Buffer.from(snapshot.body, "base64") : snapshot.body + +export const redactedErrorRequest = (request: HttpClientRequest.HttpClientRequest) => + HttpClientRequest.makeWith( + request.method, + redactUrl(request.url), + UrlParams.empty, + Option.none(), + Headers.empty, + HttpBody.empty, + ) + +const transportError = (request: HttpClientRequest.HttpClientRequest, description: string) => + new HttpClientError.HttpClientError({ + reason: new HttpClientError.TransportError({ request: redactedErrorRequest(request), description }), + }) + +export const recordingLayer = ( + name: string, + options: Omit = {}, +): Layer.Layer => + Layer.effect( + HttpClient.HttpClient, + Effect.gen(function* () { + const upstream = yield* HttpClient.HttpClient + const cassetteService = yield* CassetteService.Service + const redactor = options.redactor ?? defaults() + const match = options.match ?? defaultMatcher + const requested = options.mode ?? "auto" + const mode = requested === "auto" ? yield* resolveAutoMode(cassetteService, name) : requested + const sequential = options.dispatch === "sequential" + const replay = yield* makeReplayState(cassetteService, name, httpInteractions) + + const snapshotRequest = (request: HttpClientRequest.HttpClientRequest) => + Effect.gen(function* () { + const web = yield* HttpClientRequest.toWeb(request).pipe(Effect.orDie) + return redactor.request({ + method: web.method, + url: web.url, + headers: Object.fromEntries(web.headers.entries()), + body: yield* Effect.promise(() => web.text()), + }) + }) + + return HttpClient.make((request) => { + if (mode === "passthrough") return upstream.execute(request) + + if (mode === "record") { + return Effect.gen(function* () { + const incoming = yield* snapshotRequest(request) + const response = yield* upstream.execute(request) + const captured = yield* captureResponseBody(response, response.headers["content-type"]) + const interaction: HttpInteraction = { + transport: "http", + request: incoming, + response: redactor.response({ + status: response.status, + headers: response.headers as Record, + ...captured, + }), + } + yield* appendOrFail(cassetteService, name, interaction, options.metadata).pipe( + Effect.catchTag("UnsafeCassetteError", (error) => Effect.fail(transportError(request, error.message))), + ) + return HttpClientResponse.fromWeb( + request, + new Response(decodeResponseBody(interaction.response), interaction.response), + ) + }) + } + + return Effect.gen(function* () { + const incoming = yield* snapshotRequest(request) + const interactions = yield* replay.load.pipe( + Effect.mapError(() => + transportError(request, `Fixture "${name}" not found. Run locally to record it (CI=true forces replay).`), + ), + ) + const result = sequential + ? selectSequential(interactions, incoming, match, yield* replay.cursor) + : selectMatch(interactions, incoming, match) + if (!result.interaction) + return yield* Effect.fail( + transportError(request, `Fixture "${name}" does not match the current request: ${result.detail}.`), + ) + if (sequential) yield* replay.advance + return HttpClientResponse.fromWeb( + request, + new Response(decodeResponseBody(result.interaction.response), result.interaction.response), + ) + }) + }) + }), + ) + +export const cassetteLayer = (name: string, options: RecordReplayOptions = {}): Layer.Layer => + recordingLayer(name, options).pipe( + Layer.provide(CassetteService.fileSystem({ directory: options.directory })), + Layer.provide(FetchHttpClient.layer), + Layer.provide(NodeFileSystem.layer), + ) diff --git a/packages/http-recorder/src/index.ts b/packages/http-recorder/src/index.ts new file mode 100644 index 0000000000..4b47e4513d --- /dev/null +++ b/packages/http-recorder/src/index.ts @@ -0,0 +1,26 @@ +export type { + CassetteMetadata, + HttpInteraction, + Interaction, + RequestSnapshot, + ResponseSnapshot, + WebSocketFrame, + WebSocketInteraction, +} from "./schema" +export { CassetteNotFoundError, hasCassetteSync } from "./cassette" +export { defaultMatcher, type RequestMatcher } from "./matching" +export { redactHeaders, redactUrl, secretFindings, type SecretFinding } from "./redaction" +export { UnsafeCassetteError } from "./recorder" +export { cassetteLayer, recordingLayer, type RecordReplayMode, type RecordReplayOptions } from "./effect" +export { + makeWebSocketExecutor, + type WebSocketConnection, + type WebSocketExecutor, + type WebSocketRecordReplayOptions, + type WebSocketRequest, +} from "./websocket" + +export * as Cassette from "./cassette" +export * as Redactor from "./redactor" + +export * as HttpRecorder from "." diff --git a/packages/http-recorder/src/matching.ts b/packages/http-recorder/src/matching.ts new file mode 100644 index 0000000000..9af85a2f3a --- /dev/null +++ b/packages/http-recorder/src/matching.ts @@ -0,0 +1,124 @@ +import { Option, Schema } from "effect" +import { REDACTED, secretFindings } from "./redaction" +import type { HttpInteraction, RequestSnapshot } from "./schema" + +const JsonValue = Schema.fromJsonString(Schema.Unknown) +export const decodeJson = Schema.decodeUnknownOption(JsonValue) + +const isRecord = (value: unknown): value is Record => + value !== null && typeof value === "object" && !Array.isArray(value) + +export const canonicalizeJson = (value: unknown): unknown => { + if (Array.isArray(value)) return value.map(canonicalizeJson) + if (isRecord(value)) { + return Object.fromEntries( + Object.keys(value) + .toSorted() + .map((key) => [key, canonicalizeJson(value[key])]), + ) + } + return value +} + +export type RequestMatcher = (incoming: RequestSnapshot, recorded: RequestSnapshot) => boolean + +export const canonicalSnapshot = (snapshot: RequestSnapshot): string => + JSON.stringify({ + method: snapshot.method, + url: snapshot.url, + headers: canonicalizeJson(snapshot.headers), + body: Option.match(decodeJson(snapshot.body), { + onNone: () => snapshot.body, + onSome: canonicalizeJson, + }), + }) + +export const defaultMatcher: RequestMatcher = (incoming, recorded) => + canonicalSnapshot(incoming) === canonicalSnapshot(recorded) + +const safeText = (value: unknown) => { + if (value === undefined) return "undefined" + if (secretFindings(value).length > 0) return JSON.stringify(REDACTED) + const text = JSON.stringify(value) + if (!text) return String(value) + return text.length > 300 ? `${text.slice(0, 300)}...` : text +} + +const jsonBody = (body: string) => Option.getOrUndefined(decodeJson(body)) + +const valueDiffs = (expected: unknown, received: unknown, base = "$", limit = 8): ReadonlyArray => { + if (Object.is(expected, received)) return [] + if (isRecord(expected) && isRecord(received)) { + return [...new Set([...Object.keys(expected), ...Object.keys(received)])] + .toSorted() + .flatMap((key) => valueDiffs(expected[key], received[key], `${base}.${key}`, limit)) + .slice(0, limit) + } + if (Array.isArray(expected) && Array.isArray(received)) { + return Array.from({ length: Math.max(expected.length, received.length) }, (_, index) => index) + .flatMap((index) => valueDiffs(expected[index], received[index], `${base}[${index}]`, limit)) + .slice(0, limit) + } + return [`${base} expected ${safeText(expected)}, received ${safeText(received)}`] +} + +const headerDiffs = (expected: Record, received: Record) => + [...new Set([...Object.keys(expected), ...Object.keys(received)])].toSorted().flatMap((key) => { + if (expected[key] === received[key]) return [] + if (expected[key] === undefined) return [` ${key} unexpected ${safeText(received[key])}`] + if (received[key] === undefined) return [` ${key} missing expected ${safeText(expected[key])}`] + return [` ${key} expected ${safeText(expected[key])}, received ${safeText(received[key])}`] + }) + +export const requestDiff = (expected: RequestSnapshot, received: RequestSnapshot): ReadonlyArray => { + const lines: string[] = [] + if (expected.method !== received.method) { + lines.push("method:", ` expected ${expected.method}, received ${received.method}`) + } + if (expected.url !== received.url) { + lines.push("url:", ` expected ${expected.url}`, ` received ${received.url}`) + } + const headers = headerDiffs(expected.headers, received.headers) + if (headers.length > 0) lines.push("headers:", ...headers.slice(0, 8)) + const expectedBody = jsonBody(expected.body) + const receivedBody = jsonBody(received.body) + const body = + expectedBody !== undefined && receivedBody !== undefined + ? valueDiffs(expectedBody, receivedBody).map((line) => ` ${line}`) + : expected.body === received.body + ? [] + : [` expected ${safeText(expected.body)}, received ${safeText(received.body)}`] + if (body.length > 0) lines.push("body:", ...body) + return lines +} + +export const mismatchDetail = (interactions: ReadonlyArray, incoming: RequestSnapshot): string => { + if (interactions.length === 0) return "cassette has no recorded HTTP interactions" + const ranked = interactions + .map((interaction, index) => ({ index, lines: requestDiff(interaction.request, incoming) })) + .toSorted((a, b) => a.lines.length - b.lines.length || a.index - b.index) + const best = ranked[0] + return ["no recorded interaction matched", `closest interaction: #${best.index + 1}`, ...best.lines].join("\n") +} + +export const selectMatch = ( + interactions: ReadonlyArray, + incoming: RequestSnapshot, + match: RequestMatcher, +): { readonly interaction: HttpInteraction | undefined; readonly detail: string } => { + const interaction = interactions.find((candidate) => match(incoming, candidate.request)) + return { interaction, detail: interaction ? "" : mismatchDetail(interactions, incoming) } +} + +export const selectSequential = ( + interactions: ReadonlyArray, + incoming: RequestSnapshot, + match: RequestMatcher, + index: number, +): { readonly interaction: HttpInteraction | undefined; readonly detail: string } => { + const interaction = interactions[index] + if (!interaction) return { interaction, detail: `interaction ${index + 1} of ${interactions.length} not recorded` } + if (!match(incoming, interaction.request)) + return { interaction: undefined, detail: requestDiff(interaction.request, incoming).join("\n") } + return { interaction, detail: "" } +} diff --git a/packages/http-recorder/src/recorder.ts b/packages/http-recorder/src/recorder.ts new file mode 100644 index 0000000000..460b427c2a --- /dev/null +++ b/packages/http-recorder/src/recorder.ts @@ -0,0 +1,73 @@ +import { Effect, Ref, Schema, Scope } from "effect" +import type * as CassetteService from "./cassette" +import type { CassetteNotFoundError } from "./cassette" +import { SecretFindingSchema } from "./redaction" +import type { CassetteMetadata, Interaction } from "./schema" + +export class UnsafeCassetteError extends Schema.TaggedErrorClass()("UnsafeCassetteError", { + cassetteName: Schema.String, + findings: Schema.Array(SecretFindingSchema), +}) { + override get message() { + return `Refusing to write cassette "${this.cassetteName}" because it contains possible secrets: ${this.findings + .map((finding) => `${finding.path} (${finding.reason})`) + .join(", ")}` + } +} + +export type ResolvedMode = "record" | "replay" | "passthrough" + +const isCI = () => { + const value = process.env.CI + return value !== undefined && value !== "" && value !== "false" && value !== "0" +} + +export const resolveAutoMode = (cassette: CassetteService.Interface, name: string): Effect.Effect => + Effect.gen(function* () { + if (isCI()) return "replay" + return (yield* cassette.exists(name)) ? "replay" : "record" + }) + +export const appendOrFail = ( + cassette: CassetteService.Interface, + name: string, + interaction: Interaction, + metadata: CassetteMetadata | undefined, +): Effect.Effect => + cassette + .append(name, interaction, metadata) + .pipe( + Effect.flatMap(({ findings }) => + findings.length === 0 ? Effect.void : Effect.fail(new UnsafeCassetteError({ cassetteName: name, findings })), + ), + ) + +export interface ReplayState { + readonly load: Effect.Effect, CassetteNotFoundError> + readonly cursor: Effect.Effect + readonly advance: Effect.Effect +} + +export const makeReplayState = ( + cassette: CassetteService.Interface, + name: string, + project: (interactions: ReadonlyArray) => ReadonlyArray, +): Effect.Effect, never, Scope.Scope> => + Effect.gen(function* () { + const load = yield* Effect.cached(cassette.read(name).pipe(Effect.map(project))) + const position = yield* Ref.make(0) + + yield* Effect.addFinalizer(() => + Effect.gen(function* () { + const used = yield* Ref.get(position) + if (used === 0) return + const interactions = yield* load.pipe(Effect.orDie) + if (used < interactions.length) + yield* Effect.die( + new Error(`Unused recorded interactions in ${name}: used ${used} of ${interactions.length}`), + ) + }), + ) + + return { load, cursor: Ref.get(position), advance: Ref.update(position, (n) => n + 1) } + }) diff --git a/packages/http-recorder/src/redaction.ts b/packages/http-recorder/src/redaction.ts new file mode 100644 index 0000000000..b6aa8b3b87 --- /dev/null +++ b/packages/http-recorder/src/redaction.ts @@ -0,0 +1,115 @@ +export const REDACTED = "[REDACTED]" + +const DEFAULT_REDACT_HEADERS = [ + "authorization", + "cookie", + "proxy-authorization", + "set-cookie", + "x-api-key", + "x-amz-security-token", + "x-goog-api-key", +] + +const DEFAULT_REDACT_QUERY = [ + "access_token", + "api-key", + "api_key", + "apikey", + "code", + "key", + "signature", + "sig", + "token", + "x-amz-credential", + "x-amz-security-token", + "x-amz-signature", +] + +const SECRET_PATTERNS: ReadonlyArray<{ readonly label: string; readonly pattern: RegExp }> = [ + { label: "bearer token", pattern: /\bBearer\s+[A-Za-z0-9._~+/=-]{16,}\b/i }, + { label: "API key", pattern: /\bsk-[A-Za-z0-9][A-Za-z0-9_-]{20,}\b/ }, + { label: "Anthropic API key", pattern: /\bsk-ant-[A-Za-z0-9_-]{20,}\b/ }, + { label: "Google API key", pattern: /\bAIza[0-9A-Za-z_-]{20,}\b/ }, + { label: "AWS access key", pattern: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/ }, + { label: "GitHub token", pattern: /\bgh[pousr]_[A-Za-z0-9_]{20,}\b/ }, + { label: "private key", pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----/ }, +] + +const ENV_SECRET_NAMES = /(?:API|AUTH|BEARER|CREDENTIAL|KEY|PASSWORD|SECRET|TOKEN)/i +const SAFE_ENV_VALUES = new Set(["fixture", "test", "test-key"]) + +const envSecrets = () => + Object.entries(process.env).flatMap(([name, value]) => { + if (!value) return [] + if (!ENV_SECRET_NAMES.test(name)) return [] + if (value.length < 12) return [] + if (SAFE_ENV_VALUES.has(value.toLowerCase())) return [] + return [{ name, value }] + }) + +const pathFor = (base: string, key: string) => (base ? `${base}.${key}` : key) + +const stringEntries = (value: unknown, base = ""): ReadonlyArray<{ readonly path: string; readonly value: string }> => { + if (typeof value === "string") return [{ path: base, value }] + if (Array.isArray(value)) return value.flatMap((item, index) => stringEntries(item, `${base}[${index}]`)) + if (value && typeof value === "object") { + return Object.entries(value).flatMap(([key, child]) => stringEntries(child, pathFor(base, key))) + } + return [] +} + +const redactionSet = (values: ReadonlyArray | undefined, defaults: ReadonlyArray) => + new Set([...defaults, ...(values ?? [])].map((value) => value.toLowerCase())) + +export type UrlRedactor = (url: string) => string + +export const redactUrl = ( + raw: string, + query: ReadonlyArray = DEFAULT_REDACT_QUERY, + urlRedactor?: UrlRedactor, +) => { + if (!URL.canParse(raw)) return urlRedactor?.(raw) ?? raw + const url = new URL(raw) + if (url.username) url.username = REDACTED + if (url.password) url.password = REDACTED + const redacted = redactionSet(query, DEFAULT_REDACT_QUERY) + for (const key of [...url.searchParams.keys()]) { + if (redacted.has(key.toLowerCase())) url.searchParams.set(key, REDACTED) + } + return urlRedactor?.(url.toString()) ?? url.toString() +} + +export const redactHeaders = ( + headers: Record, + allow: ReadonlyArray, + redact: ReadonlyArray = DEFAULT_REDACT_HEADERS, +) => { + const allowed = new Set(allow.map((name) => name.toLowerCase())) + const redacted = redactionSet(redact, DEFAULT_REDACT_HEADERS) + return Object.fromEntries( + Object.entries(headers) + .map(([name, value]) => [name.toLowerCase(), value] as const) + .filter(([name]) => allowed.has(name)) + .map(([name, value]) => [name, redacted.has(name) ? REDACTED : value] as const) + .toSorted(([a], [b]) => a.localeCompare(b)), + ) +} + +import { Schema } from "effect" + +export const SecretFindingSchema = Schema.Struct({ + path: Schema.String, + reason: Schema.String, +}) +export type SecretFinding = Schema.Schema.Type + +export const secretFindings = (value: unknown): ReadonlyArray => + stringEntries(value).flatMap((entry) => [ + ...SECRET_PATTERNS.filter((item) => item.pattern.test(entry.value)).map((item) => ({ + path: entry.path, + reason: item.label, + })), + ...envSecrets() + .filter((item) => entry.value.includes(item.value)) + .map((item) => ({ path: entry.path, reason: `environment secret ${item.name}` })), + ]) diff --git a/packages/http-recorder/src/redactor.ts b/packages/http-recorder/src/redactor.ts new file mode 100644 index 0000000000..917ab05d09 --- /dev/null +++ b/packages/http-recorder/src/redactor.ts @@ -0,0 +1,76 @@ +import { Option } from "effect" +import { decodeJson } from "./matching" +import { redactHeaders, redactUrl } from "./redaction" +import type { RequestSnapshot, ResponseSnapshot } from "./schema" + +export const DEFAULT_REQUEST_HEADERS: ReadonlyArray = ["content-type", "accept", "openai-beta"] +export const DEFAULT_RESPONSE_HEADERS: ReadonlyArray = ["content-type"] + +const identity = (value: T) => value + +export interface Redactor { + readonly request: (snapshot: RequestSnapshot) => RequestSnapshot + readonly response: (snapshot: ResponseSnapshot) => ResponseSnapshot +} + +export const compose = (...redactors: ReadonlyArray>): Redactor => { + const requests = redactors.map((r) => r.request).filter((fn): fn is Redactor["request"] => fn !== undefined) + const responses = redactors.map((r) => r.response).filter((fn): fn is Redactor["response"] => fn !== undefined) + return { + request: requests.length === 0 ? identity : (snapshot) => requests.reduce((acc, fn) => fn(acc), snapshot), + response: responses.length === 0 ? identity : (snapshot) => responses.reduce((acc, fn) => fn(acc), snapshot), + } +} + +export interface HeaderOptions { + readonly allow?: ReadonlyArray + readonly redact?: ReadonlyArray +} + +export const requestHeaders = (options: HeaderOptions = {}): Partial => ({ + request: (snapshot) => ({ + ...snapshot, + headers: redactHeaders(snapshot.headers, options.allow ?? DEFAULT_REQUEST_HEADERS, options.redact), + }), +}) + +export const responseHeaders = (options: HeaderOptions = {}): Partial => ({ + response: (snapshot) => ({ + ...snapshot, + headers: redactHeaders(snapshot.headers, options.allow ?? DEFAULT_RESPONSE_HEADERS, options.redact), + }), +}) + +export interface UrlOptions { + readonly query?: ReadonlyArray + readonly transform?: (url: string) => string +} + +export const url = (options: UrlOptions = {}): Partial => ({ + request: (snapshot) => ({ ...snapshot, url: redactUrl(snapshot.url, options.query, options.transform) }), +}) + +export const body = (transform: (parsed: unknown) => unknown): Partial => ({ + request: (snapshot) => ({ + ...snapshot, + body: Option.match(decodeJson(snapshot.body), { + onNone: () => snapshot.body, + onSome: (parsed) => JSON.stringify(transform(parsed)), + }), + }), +}) + +export interface DefaultRedactorOverrides { + readonly requestHeaders?: HeaderOptions + readonly responseHeaders?: HeaderOptions + readonly url?: UrlOptions + readonly body?: (parsed: unknown) => unknown +} + +export const defaults = (overrides: DefaultRedactorOverrides = {}): Redactor => + compose( + requestHeaders(overrides.requestHeaders), + responseHeaders(overrides.responseHeaders), + url(overrides.url), + ...(overrides.body ? [body(overrides.body)] : []), + ) diff --git a/packages/http-recorder/src/schema.ts b/packages/http-recorder/src/schema.ts new file mode 100644 index 0000000000..113769c7b7 --- /dev/null +++ b/packages/http-recorder/src/schema.ts @@ -0,0 +1,68 @@ +import { Schema } from "effect" + +export const RequestSnapshotSchema = Schema.Struct({ + method: Schema.String, + url: Schema.String, + headers: Schema.Record(Schema.String, Schema.String), + body: Schema.String, +}) +export type RequestSnapshot = Schema.Schema.Type + +export const ResponseSnapshotSchema = Schema.Struct({ + status: Schema.Number, + headers: Schema.Record(Schema.String, Schema.String), + body: Schema.String, + bodyEncoding: Schema.optional(Schema.Literals(["text", "base64"])), +}) +export type ResponseSnapshot = Schema.Schema.Type + +export const CassetteMetadataSchema = Schema.Record(Schema.String, Schema.Unknown) +export type CassetteMetadata = Schema.Schema.Type + +export const HttpInteractionSchema = Schema.Struct({ + transport: Schema.tag("http"), + request: RequestSnapshotSchema, + response: ResponseSnapshotSchema, +}) +export type HttpInteraction = Schema.Schema.Type + +export const WebSocketFrameSchema = Schema.Union([ + Schema.Struct({ kind: Schema.tag("text"), body: Schema.String }), + Schema.Struct({ kind: Schema.tag("binary"), body: Schema.String, bodyEncoding: Schema.Literal("base64") }), +]) +export type WebSocketFrame = Schema.Schema.Type + +export const WebSocketInteractionSchema = Schema.Struct({ + transport: Schema.tag("websocket"), + open: Schema.Struct({ + url: Schema.String, + headers: Schema.Record(Schema.String, Schema.String), + }), + client: Schema.Array(WebSocketFrameSchema), + server: Schema.Array(WebSocketFrameSchema), +}) +export type WebSocketInteraction = Schema.Schema.Type + +export const InteractionSchema = Schema.Union([HttpInteractionSchema, WebSocketInteractionSchema]).pipe( + Schema.toTaggedUnion("transport"), +) +export type Interaction = Schema.Schema.Type + +export const isHttpInteraction = InteractionSchema.guards.http + +export const isWebSocketInteraction = InteractionSchema.guards.websocket + +export const httpInteractions = (interactions: ReadonlyArray) => interactions.filter(isHttpInteraction) + +export const webSocketInteractions = (interactions: ReadonlyArray) => + interactions.filter(isWebSocketInteraction) + +export const CassetteSchema = Schema.Struct({ + version: Schema.Literal(1), + metadata: Schema.optional(CassetteMetadataSchema), + interactions: Schema.Array(InteractionSchema), +}) +export type Cassette = Schema.Schema.Type + +export const decodeCassette = Schema.decodeUnknownSync(CassetteSchema) +export const encodeCassette = Schema.encodeSync(CassetteSchema) diff --git a/packages/http-recorder/src/websocket.ts b/packages/http-recorder/src/websocket.ts new file mode 100644 index 0000000000..f7529b4888 --- /dev/null +++ b/packages/http-recorder/src/websocket.ts @@ -0,0 +1,159 @@ +import { Effect, Option, Ref, Scope, Stream } from "effect" +import type { Headers } from "effect/unstable/http" +import * as CassetteService from "./cassette" +import { canonicalizeJson, decodeJson } from "./matching" +import { appendOrFail, makeReplayState, resolveAutoMode } from "./recorder" +import type { RecordReplayMode } from "./effect" +import { defaults, type Redactor } from "./redactor" +import { webSocketInteractions, type CassetteMetadata, type WebSocketFrame } from "./schema" + +export interface WebSocketRequest { + readonly url: string + readonly headers: Headers.Headers +} + +export interface WebSocketConnection { + readonly sendText: (message: string) => Effect.Effect + readonly messages: Stream.Stream + readonly close: Effect.Effect +} + +export interface WebSocketExecutor { + readonly open: (request: WebSocketRequest) => Effect.Effect, E> +} + +export interface WebSocketRecordReplayOptions { + readonly name: string + readonly mode?: RecordReplayMode + readonly metadata?: CassetteMetadata + readonly cassette: CassetteService.Interface + readonly live: WebSocketExecutor + readonly redactor?: Redactor + readonly compareClientMessagesAsJson?: boolean +} + +const headersRecord = (headers: Headers.Headers): Record => + Object.fromEntries( + Object.entries(headers as Record).filter( + (entry): entry is [string, string] => typeof entry[1] === "string", + ), + ) + +const encodeFrame = (message: string | Uint8Array): WebSocketFrame => + typeof message === "string" + ? { kind: "text", body: message } + : { kind: "binary", body: Buffer.from(message).toString("base64"), bodyEncoding: "base64" } + +const decodeFrameMessage = (frame: WebSocketFrame): string | Uint8Array => + frame.kind === "text" ? frame.body : new Uint8Array(Buffer.from(frame.body, "base64")) + +const decodeFrameText = (frame: WebSocketFrame) => + frame.kind === "text" ? frame.body : new TextDecoder().decode(Buffer.from(frame.body, "base64")) + +const assertEqual = (message: string, actual: unknown, expected: unknown) => + Effect.sync(() => { + if (JSON.stringify(actual) === JSON.stringify(expected)) return + throw new Error(`${message}: expected ${JSON.stringify(expected)}, received ${JSON.stringify(actual)}`) + }) + +const jsonOrText = (value: string) => Option.match(decodeJson(value), { onNone: () => value, onSome: canonicalizeJson }) + +const compareClientMessage = (actual: string, expected: WebSocketFrame | undefined, index: number, asJson: boolean) => { + if (!expected) + return Effect.sync(() => { + throw new Error(`Unexpected WebSocket client frame ${index + 1}: ${actual}`) + }) + const expectedText = decodeFrameText(expected) + if (!asJson) return assertEqual(`WebSocket client frame ${index + 1}`, actual, expectedText) + return assertEqual(`WebSocket client JSON frame ${index + 1}`, jsonOrText(actual), jsonOrText(expectedText)) +} + +export const makeWebSocketExecutor = ( + options: WebSocketRecordReplayOptions, +): Effect.Effect, never, Scope.Scope> => + Effect.gen(function* () { + const requested = options.mode ?? "auto" + const mode = requested === "auto" ? yield* resolveAutoMode(options.cassette, options.name) : requested + const redactor = options.redactor ?? defaults() + const openSnapshot = (request: WebSocketRequest) => { + const redacted = redactor.request({ + method: "GET", + url: request.url, + headers: headersRecord(request.headers), + body: "", + }) + return { url: redacted.url, headers: redacted.headers } + } + + if (mode === "passthrough") return options.live + + if (mode === "record") { + return { + open: (request) => + Effect.gen(function* () { + const client: WebSocketFrame[] = [] + const server: WebSocketFrame[] = [] + const connection = yield* options.live.open(request) + const closed = yield* Ref.make(false) + const closeOnce = Effect.gen(function* () { + if (yield* Ref.getAndSet(closed, true)) return + yield* connection.close + yield* appendOrFail( + options.cassette, + options.name, + { transport: "websocket", open: openSnapshot(request), client, server }, + options.metadata, + ).pipe(Effect.orDie) + }) + return { + sendText: (message) => + connection + .sendText(message) + .pipe(Effect.tap(() => Effect.sync(() => client.push(encodeFrame(message))))), + messages: connection.messages.pipe( + Stream.map((message) => { + server.push(encodeFrame(message)) + return message + }), + ), + close: closeOnce, + } + }), + } + } + + const replay = yield* makeReplayState(options.cassette, options.name, webSocketInteractions) + + return { + open: (request) => + Effect.gen(function* () { + const interactions = yield* replay.load.pipe(Effect.orDie) + const index = yield* replay.cursor + const interaction = interactions[index] + if (!interaction) return yield* Effect.die(new Error(`No recorded WebSocket interaction for ${request.url}`)) + yield* replay.advance + yield* assertEqual(`WebSocket open frame ${index + 1}`, openSnapshot(request), interaction.open) + const messageIndex = yield* Ref.make(0) + return { + sendText: (message) => + Effect.gen(function* () { + const current = yield* Ref.getAndUpdate(messageIndex, (value) => value + 1) + yield* compareClientMessage( + message, + interaction.client[current], + current, + options.compareClientMessagesAsJson === true, + ) + }), + messages: Stream.fromIterable(interaction.server).pipe(Stream.map(decodeFrameMessage)), + close: Effect.gen(function* () { + yield* assertEqual( + `WebSocket client frame count for interaction ${index + 1}`, + yield* Ref.get(messageIndex), + interaction.client.length, + ) + }), + } + }), + } + }) diff --git a/packages/http-recorder/sst-env.d.ts b/packages/http-recorder/sst-env.d.ts new file mode 100644 index 0000000000..64441936d7 --- /dev/null +++ b/packages/http-recorder/sst-env.d.ts @@ -0,0 +1,10 @@ +/* This file is auto-generated by SST. Do not edit. */ +/* tslint:disable */ +/* eslint-disable */ +/* deno-fmt-ignore-file */ +/* biome-ignore-all lint: auto-generated */ + +/// + +import "sst" +export {} \ No newline at end of file diff --git a/packages/http-recorder/test/fixtures/recordings/record-replay/multi-step.json b/packages/http-recorder/test/fixtures/recordings/record-replay/multi-step.json new file mode 100644 index 0000000000..9953b860cd --- /dev/null +++ b/packages/http-recorder/test/fixtures/recordings/record-replay/multi-step.json @@ -0,0 +1,41 @@ +{ + "version": 1, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://example.test/echo", + "headers": { + "content-type": "application/json" + }, + "body": "{\"step\":1}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "application/json" + }, + "body": "{\"reply\":\"first\"}" + } + }, + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://example.test/echo", + "headers": { + "content-type": "application/json" + }, + "body": "{\"step\":2}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "application/json" + }, + "body": "{\"reply\":\"second\"}" + } + } + ] +} diff --git a/packages/http-recorder/test/fixtures/recordings/record-replay/retry.json b/packages/http-recorder/test/fixtures/recordings/record-replay/retry.json new file mode 100644 index 0000000000..873e5a16c0 --- /dev/null +++ b/packages/http-recorder/test/fixtures/recordings/record-replay/retry.json @@ -0,0 +1,41 @@ +{ + "version": 1, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://example.test/poll", + "headers": { + "content-type": "application/json" + }, + "body": "{\"id\":\"job_1\"}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "application/json" + }, + "body": "{\"status\":\"pending\"}" + } + }, + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://example.test/poll", + "headers": { + "content-type": "application/json" + }, + "body": "{\"id\":\"job_1\"}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "application/json" + }, + "body": "{\"status\":\"complete\"}" + } + } + ] +} diff --git a/packages/http-recorder/test/record-replay.test.ts b/packages/http-recorder/test/record-replay.test.ts new file mode 100644 index 0000000000..7613563fd0 --- /dev/null +++ b/packages/http-recorder/test/record-replay.test.ts @@ -0,0 +1,351 @@ +import { NodeFileSystem } from "@effect/platform-node" +import { describe, expect, test } from "bun:test" +import { Cause, Effect, Exit, Scope, Stream } from "effect" +import { Headers, HttpBody, HttpClient, HttpClientRequest } from "effect/unstable/http" +import * as fs from "node:fs" +import * as os from "node:os" +import * as path from "node:path" +import { HttpRecorder } from "../src" +import { redactedErrorRequest } from "../src/effect" +import type { Interaction } from "../src/schema" + +const seedCassetteDirectory = (directory: string, name: string, interactions: ReadonlyArray) => + Effect.runPromise( + Effect.gen(function* () { + const cassette = yield* HttpRecorder.Cassette.Service + yield* Effect.forEach(interactions, (interaction) => cassette.append(name, interaction)) + }).pipe(Effect.provide(HttpRecorder.Cassette.fileSystem({ directory })), Effect.provide(NodeFileSystem.layer)), + ) + +const post = (url: string, body: object) => + Effect.gen(function* () { + const http = yield* HttpClient.HttpClient + const request = HttpClientRequest.post(url, { + headers: { "content-type": "application/json" }, + body: HttpBody.text(JSON.stringify(body), "application/json"), + }) + const response = yield* http.execute(request) + return yield* response.text + }) + +const run = (effect: Effect.Effect) => + Effect.runPromise(effect.pipe(Effect.provide(HttpRecorder.cassetteLayer("record-replay/multi-step")))) + +const runWith = ( + name: string, + options: HttpRecorder.RecordReplayOptions, + effect: Effect.Effect, +) => Effect.runPromise(effect.pipe(Effect.provide(HttpRecorder.cassetteLayer(name, options)))) + +const runRecorder = (effect: Effect.Effect) => + Effect.runPromise( + Effect.scoped( + effect.pipe( + Effect.provide( + HttpRecorder.Cassette.fileSystem({ directory: fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-")) }), + ), + Effect.provide(NodeFileSystem.layer), + ), + ), + ) + +const failureText = (exit: Exit.Exit) => { + if (Exit.isSuccess(exit)) return "" + return Cause.prettyErrors(exit.cause).join("\n") +} + +describe("http-recorder", () => { + test("redacts sensitive URL query parameters", () => { + expect( + HttpRecorder.redactUrl( + "https://example.test/path?key=secret-google-key&api_key=secret-openai-key&safe=value&X-Amz-Signature=secret-signature", + ), + ).toBe( + "https://example.test/path?key=%5BREDACTED%5D&api_key=%5BREDACTED%5D&safe=value&X-Amz-Signature=%5BREDACTED%5D", + ) + }) + + test("redacts URL credentials", () => { + expect(HttpRecorder.redactUrl("https://user:password@example.test/path?safe=value")).toBe( + "https://%5BREDACTED%5D:%5BREDACTED%5D@example.test/path?safe=value", + ) + }) + + test("applies custom URL redaction after built-in redaction", () => { + expect( + HttpRecorder.redactUrl("https://example.test/accounts/real-account/path?key=secret-key", undefined, (url) => + url.replace("/accounts/real-account/", "/accounts/{account}/"), + ), + ).toBe("https://example.test/accounts/{account}/path?key=%5BREDACTED%5D") + }) + + test("redacts sensitive headers when allow-listed", () => { + expect( + HttpRecorder.redactHeaders( + { + authorization: "Bearer secret-token", + "content-type": "application/json", + "x-custom-token": "custom-secret", + "x-api-key": "secret-key", + "x-goog-api-key": "secret-google-key", + }, + ["authorization", "content-type", "x-api-key", "x-goog-api-key", "x-custom-token"], + ["x-custom-token"], + ), + ).toEqual({ + authorization: "[REDACTED]", + "content-type": "application/json", + "x-api-key": "[REDACTED]", + "x-custom-token": "[REDACTED]", + "x-goog-api-key": "[REDACTED]", + }) + }) + + test("redacts error requests without retaining headers, params, or body", () => { + const request = HttpClientRequest.post("https://example.test/path", { + headers: { authorization: "Bearer super-secret" }, + body: HttpBody.text("super-secret-body", "text/plain"), + }).pipe(HttpClientRequest.setUrlParam("api_key", "super-secret-key")) + + expect(redactedErrorRequest(request).toJSON()).toMatchObject({ + url: "https://example.test/path", + urlParams: { params: [] }, + headers: {}, + body: { _tag: "Empty" }, + }) + }) + + test("detects secret-looking values without returning the secret", () => { + expect( + HttpRecorder.secretFindings({ + version: 1, + interactions: [ + { + transport: "http", + request: { + method: "POST", + url: "https://example.test/path?key=sk-123456789012345678901234", + headers: {}, + body: JSON.stringify({ nested: "AIzaSyDHibiBRvJZLsFnPYPoiTwxY4ztQ55yqCE" }), + }, + response: { + status: 200, + headers: {}, + body: "Bearer abcdefghijklmnopqrstuvwxyz", + }, + }, + ], + }), + ).toEqual([ + { path: "interactions[0].request.url", reason: "API key" }, + { path: "interactions[0].request.body", reason: "Google API key" }, + { path: "interactions[0].response.body", reason: "bearer token" }, + ]) + }) + + test("detects secret-looking values inside metadata", () => { + expect( + HttpRecorder.secretFindings({ + version: 1, + metadata: { token: "sk-123456789012345678901234" }, + interactions: [], + }), + ).toEqual([{ path: "metadata.token", reason: "API key" }]) + }) + + test("replays websocket interactions seeded into the in-memory cassette adapter", async () => { + await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const cassette = yield* HttpRecorder.Cassette.Service + const executor = yield* HttpRecorder.makeWebSocketExecutor({ + name: "websocket/replay", + cassette, + compareClientMessagesAsJson: true, + live: { open: () => Effect.die(new Error("unexpected live WebSocket open")) }, + }) + const connection = yield* executor.open({ + url: "wss://example.test/realtime", + headers: Headers.fromInput({ "content-type": "application/json" }), + }) + yield* connection.sendText(JSON.stringify({ type: "response.create" })) + const messages: Array = [] + yield* connection.messages.pipe(Stream.runForEach((message) => Effect.sync(() => messages.push(message)))) + yield* connection.close + + expect(messages).toEqual([JSON.stringify({ type: "response.completed" })]) + }).pipe( + Effect.provide( + HttpRecorder.Cassette.memory({ + "websocket/replay": [ + { + transport: "websocket", + open: { url: "wss://example.test/realtime", headers: { "content-type": "application/json" } }, + client: [{ kind: "text", body: JSON.stringify({ type: "response.create" }) }], + server: [{ kind: "text", body: JSON.stringify({ type: "response.completed" }) }], + }, + ], + }), + ), + ), + ), + ) + }) + + test("records websocket interactions into the shared cassette service", async () => { + await runRecorder( + Effect.gen(function* () { + const cassette = yield* HttpRecorder.Cassette.Service + const executor = yield* HttpRecorder.makeWebSocketExecutor({ + name: "websocket/record", + mode: "record", + metadata: { provider: "test" }, + cassette, + live: { + open: () => + Effect.succeed({ + sendText: () => Effect.void, + messages: Stream.fromIterable([JSON.stringify({ type: "response.completed" })]), + close: Effect.void, + }), + }, + }) + const connection = yield* executor.open({ + url: "wss://example.test/realtime", + headers: Headers.fromInput({ "content-type": "application/json" }), + }) + yield* connection.sendText(JSON.stringify({ type: "response.create" })) + yield* connection.messages.pipe(Stream.runDrain) + yield* connection.close + + expect(yield* cassette.read("websocket/record")).toMatchObject([ + { + transport: "websocket", + open: { url: "wss://example.test/realtime", headers: { "content-type": "application/json" } }, + client: [{ kind: "text", body: JSON.stringify({ type: "response.create" }) }], + server: [{ kind: "text", body: JSON.stringify({ type: "response.completed" }) }], + }, + ]) + }), + ) + }) + + test("default matcher dispatches multi-interaction cassettes by request shape", async () => { + await run( + Effect.gen(function* () { + expect(yield* post("https://example.test/echo", { step: 2 })).toBe('{"reply":"second"}') + expect(yield* post("https://example.test/echo", { step: 1 })).toBe('{"reply":"first"}') + }), + ) + }) + + test("sequential dispatch returns recorded responses in order for identical requests", async () => { + await runWith( + "record-replay/retry", + { dispatch: "sequential" }, + Effect.gen(function* () { + expect(yield* post("https://example.test/poll", { id: "job_1" })).toBe('{"status":"pending"}') + expect(yield* post("https://example.test/poll", { id: "job_1" })).toBe('{"status":"complete"}') + }), + ) + }) + + test("default matcher returns the first match for identical requests", async () => { + await runWith( + "record-replay/retry", + {}, + Effect.gen(function* () { + expect(yield* post("https://example.test/poll", { id: "job_1" })).toBe('{"status":"pending"}') + expect(yield* post("https://example.test/poll", { id: "job_1" })).toBe('{"status":"pending"}') + }), + ) + }) + + test("sequential dispatch reports cursor exhaustion when more requests are made than recorded", async () => { + await runWith( + "record-replay/multi-step", + { dispatch: "sequential" }, + Effect.gen(function* () { + yield* post("https://example.test/echo", { step: 1 }) + yield* post("https://example.test/echo", { step: 2 }) + const exit = yield* Effect.exit(post("https://example.test/echo", { step: 3 })) + expect(Exit.isFailure(exit)).toBe(true) + }), + ) + }) + + test("sequential dispatch still validates each recorded request", async () => { + await runWith( + "record-replay/multi-step", + { dispatch: "sequential" }, + Effect.gen(function* () { + yield* post("https://example.test/echo", { step: 1 }) + const exit = yield* Effect.exit(post("https://example.test/echo", { step: 3 })) + expect(Exit.isFailure(exit)).toBe(true) + expect(failureText(exit)).toContain("$.step expected 2, received 3") + expect(yield* post("https://example.test/echo", { step: 2 })).toBe('{"reply":"second"}') + }), + ) + }) + + test("auto mode replays when the cassette exists", async () => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-auto-")) + await seedCassetteDirectory(directory, "auto-replay", [ + { + transport: "http", + request: { + method: "POST", + url: "https://example.test/echo", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ step: 1 }), + }, + response: { status: 200, headers: { "content-type": "application/json" }, body: '{"reply":"hi"}' }, + }, + ]) + + const result = await runWith( + "auto-replay", + { directory, mode: "auto" }, + post("https://example.test/echo", { step: 1 }), + ) + expect(result).toBe('{"reply":"hi"}') + }) + + test("auto mode forces replay when CI=true even if cassette is missing", async () => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-auto-ci-")) + const previous = process.env.CI + process.env.CI = "true" + try { + const exit = await Effect.runPromise( + Effect.exit( + post("https://example.test/echo", { step: 1 }).pipe( + Effect.provide(HttpRecorder.cassetteLayer("missing-cassette", { directory, mode: "auto" })), + ), + ), + ) + expect(Exit.isFailure(exit)).toBe(true) + expect(failureText(exit)).toContain('Fixture "missing-cassette" not found') + } finally { + if (previous === undefined) delete process.env.CI + else process.env.CI = previous + } + }) + + test("mismatch diagnostics show closest redacted request differences", async () => { + await run( + Effect.gen(function* () { + const exit = yield* Effect.exit( + post("https://example.test/echo?api_key=secret-value", { step: 3, token: "sk-123456789012345678901234" }), + ) + const message = failureText(exit) + expect(message).toContain("closest interaction: #1") + expect(message).toContain("url:") + expect(message).toContain("https://example.test/echo?api_key=%5BREDACTED%5D") + expect(message).toContain("body:") + expect(message).toContain("$.step expected 1, received 3") + expect(message).toContain('$.token expected undefined, received "[REDACTED]"') + expect(message).not.toContain("sk-123456789012345678901234") + }), + ) + }) +}) diff --git a/packages/http-recorder/tsconfig.json b/packages/http-recorder/tsconfig.json new file mode 100644 index 0000000000..2bc480ffbb --- /dev/null +++ b/packages/http-recorder/tsconfig.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "@tsconfig/bun/tsconfig.json", + "compilerOptions": { + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "noUncheckedIndexedAccess": false, + "plugins": [ + { + "name": "@effect/language-service", + "transform": "@effect/language-service/transform", + "namespaceImportPackages": ["effect", "@effect/*"] + } + ] + } +} diff --git a/packages/llm/AGENTS.md b/packages/llm/AGENTS.md new file mode 100644 index 0000000000..b20847da3b --- /dev/null +++ b/packages/llm/AGENTS.md @@ -0,0 +1,294 @@ +# LLM Package Guide + +## Effect + +- Prefer `HttpClient.HttpClient` / `HttpClientResponse.HttpClientResponse` over web `fetch` / `Response` at package boundaries. +- Use `Stream.Stream` for streaming data flow. Avoid ad hoc async generators or manual web reader loops unless an Effect `Stream` API cannot model the behavior. +- Use Effect Schema codecs for JSON encode/decode (`Schema.fromJsonString(...)`) instead of direct `JSON.parse` / `JSON.stringify` in implementation code. +- In `Effect.gen`, yield yieldable errors directly (`return yield* new MyError(...)`) instead of `Effect.fail(new MyError(...))`. +- Use `Effect.void` instead of `Effect.succeed(undefined)` when the successful value is intentionally void. + +## Tests + +- Use `testEffect(...)` from `test/lib/effect.ts` for tests requiring Effect layers. +- Keep provider tests fixture-first. Live provider calls must stay behind `RECORD=true` and required API-key checks. + +## Architecture + +This package is an Effect Schema-first LLM core. The Schema classes in `src/schema/` are the canonical runtime data model. Convenience functions in `src/llm.ts` are thin constructors that return those same Schema class instances; they should improve callsites without creating a second model. + +### Request Flow + +The intended callsite is: + +```ts +const request = LLM.request({ + model: OpenAI.model("gpt-4o-mini", { apiKey }), + system: "You are concise.", + prompt: "Say hello.", +}) + +const response = yield * LLMClient.generate(request) +``` + +`LLM.request(...)` builds an `LLMRequest`. `LLMClient.generate(...)` selects a registered route by `request.model.route`, builds the provider-native body, asks the route's transport for a real `HttpClientRequest.HttpClientRequest`, sends it through `RequestExecutor.Service`, parses the provider stream into common `LLMEvent`s, and finally returns an `LLMResponse`. + +Use `LLMClient.stream(request)` when callers want incremental `LLMEvent`s. Use `LLMClient.generate(request)` when callers want those same events collected into an `LLMResponse`. Use `LLMClient.prepare(request)` to compile a request through the route pipeline without sending it — the optional `Body` type argument narrows `.body` to the route's native shape (e.g. `prepare(...)` returns a `PreparedRequestOf`). The runtime body is identical; the generic is a type-level assertion. + +Filter or narrow `LLMEvent` streams with `LLMEvent.is.*` (camelCase guards, e.g. `events.filter(LLMEvent.is.toolCall)`). The kebab-case `LLMEvent.guards["tool-call"]` form also works but prefer `is.*` in new code. + +### Routes + +A route is the registered, runnable composition of four orthogonal pieces: + +- **`Protocol`** (`src/route/protocol.ts`) — semantic API contract. Owns request body construction (`body.from`), the body schema (`body.schema`), the streaming-event schema (`stream.event`), and the event-to-`LLMEvent` state machine (`stream.step`). `Route.make(...)` validates and JSON-encodes the body from `body.schema` and decodes frames with `stream.event`. Examples: `OpenAIChat.protocol`, `OpenAIResponses.protocol`, `AnthropicMessages.protocol`, `Gemini.protocol`, `BedrockConverse.protocol`. +- **`Endpoint`** (`src/route/endpoint.ts`) — path construction. The host always lives on `model.baseURL`; the endpoint just supplies the path. `Endpoint.path("/chat/completions")` is the common case; pass a function for paths that embed the model id or a body field (e.g. `Endpoint.path(({ body }) => `/model/${body.modelId}/converse-stream`)`). +- **`Auth`** (`src/route/auth.ts`) — per-request transport authentication. Routes read `model.apiKey` at request time via `Auth.bearer` (the default; sets `Authorization: Bearer `) or `Auth.apiKeyHeader(name)` for providers that use a custom header (Anthropic `x-api-key`, Gemini `x-goog-api-key`). Routes that need per-request signing (Bedrock SigV4, future Vertex IAM, Azure AAD) implement `Auth` as a function that signs the body and merges signed headers into the result. +- **`Framing`** (`src/route/framing.ts`) — bytes → frames. SSE (`Framing.sse`) is shared; Bedrock keeps its AWS event-stream framing as a typed `Framing` value alongside its protocol. + +Compose them via `Route.make(...)`: + +```ts +export const route = Route.make({ + id: "openai-chat", + provider: "openai", + protocol: OpenAIChat.protocol, + transport: HttpTransport.httpJson({ + endpoint: Endpoint.path("/chat/completions"), + auth: Auth.bearer(), + framing: Framing.sse, + encodeBody, + }), + defaults: { + baseURL: "https://api.openai.com/v1", + capabilities: capabilities({ tools: { calls: true, streamingInput: true } }), + }, +}) +``` + +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. + +When a provider ships a non-HTTP transport (OpenAI's WebSocket Responses backend, hypothetical bidirectional streaming APIs), the seam is `Transport` — `WebSocketTransport.json(...)` constructs a transport whose `prepare` builds a WebSocket URL and message and whose `frames` yields decoded text from the socket. Same protocol, different transport. + +### URL Construction + +`model.baseURL` is required; `Endpoint` only carries the path. Each protocol's `Route.make` includes a canonical URL in `defaults.baseURL` (e.g. `https://api.openai.com/v1`); provider helpers can override by passing `baseURL` in their input. Routes that have no canonical URL (OpenAI-compatible Chat, GitHub Copilot) set `baseURL: string` (required) on their input type so TypeScript catches a missing host at the call site. + +For providers where the URL is derived from typed inputs (Azure resource name, Bedrock region), the provider helper computes `baseURL` at model construction time. Use `AtLeastOne` from `route/auth-options.ts` for inputs that accept either of two derivation paths (Azure: `resourceName` or `baseURL`). + +### Provider Definitions + +Provider-facing APIs are defined with `Provider.make(...)` from `src/provider.ts`: + +```ts +export const provider = Provider.make({ + id: ProviderID.make("openai"), + model: responses, + apis: { responses, chat }, +}) + +export const model = provider.model +export const apis = provider.apis +``` + +Keep provider definitions small and explicit: + +- Use only `id`, `model`, and optional `apis` in `Provider.make(...)`. +- Use branded `ProviderID.make(...)` and `ModelID.make(...)` where ids are constructed directly. +- Use `model` for the default API path and `apis` for named provider-native alternatives such as OpenAI `responses` versus `chat`. +- Do not add author-facing `kind`, `version`, or `routes` fields. +- Export lower-level `routes` arrays separately only when advanced internal wiring needs them. +- Prefer `apiKey` as provider-specific sugar and `auth` as the explicit override; keep them mutually exclusive in provider option types with `ProviderAuthOption`. +- Resolve `apiKey` → `Auth` with `AuthOptions.bearer(options, "_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). + +Built-in providers are namespace modules from `src/providers/index.ts`, so aliases like `OpenAI.model(...)`, `OpenAI.responses(...)`, and `OpenAI.apis.chat(...)` are fine. External provider packages should default-export the `Provider.make(...)` result and may add named aliases if useful. + +### Folder layout + +``` +packages/llm/src/ + schema/ canonical Schema model, split by concern + ids.ts branded IDs, literal types, ProviderMetadata + options.ts Generation/Provider/Http options, Capabilities, Limits, ModelRef + messages.ts content parts, Message, ToolDefinition, LLMRequest + events.ts Usage, individual events, LLMEvent, PreparedRequest, LLMResponse + errors.ts error reasons, LLMError, ToolFailure + index.ts barrel + llm.ts request constructors and convenience helpers + route/ + index.ts @opencode-ai/llm/route advanced barrel + client.ts Route.make + LLMClient.prepare/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.apiKeyHeader / Auth.passthrough + auth-options.ts ProviderAuthOption shape, AuthOptions.bearer, AtLeastOne helper + framing.ts Framing type + Framing.sse + transport/ transport implementations + index.ts Transport type + HttpTransport / WebSocketTransport namespaces + http.ts HttpTransport.httpJson — POST + framing + websocket.ts WebSocketTransport.json + WebSocketExecutor service + protocols/ + shared.ts ProviderShared toolkit used inside protocol impls + openai-chat.ts protocol + route (compose OpenAIChat.protocol) + openai-responses.ts + 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 + utils/ per-protocol helpers (auth, cache, media, tool-stream, ...) + providers/ + openai-compatible.ts generic compatible helper + family model helpers + openai-compatible-profile.ts family defaults (deepseek, togetherai, ...) + azure.ts / amazon-bedrock.ts / github-copilot.ts / google.ts / xai.ts / openai.ts / anthropic.ts / openrouter.ts + tool.ts typed tool() helper + tool-runtime.ts implementation helpers for LLMClient tool execution +``` + +The dependency arrow points down: `providers/*.ts` files import `protocols`, `endpoint`, `auth`, and `framing`; protocols do not import provider metadata. Lower-level modules know nothing about specific providers. + +### Shared protocol helpers + +`ProviderShared` exports a small toolkit used inside protocol implementations to keep them focused on provider-native shapes: + +- `joinText(parts)` — joins an array of `TextPart` (or anything with a `.text`) with newlines. Use this anywhere a protocol flattens text content into a single string for a provider field. +- `parseToolInput(route, name, raw)` — Schema-decodes a tool-call argument string with the canonical "Invalid JSON input for `` tool call ``" error message. Treats empty input as `{}`. +- `parseJson(route, raw, message)` — generic JSON-via-Schema decode for non-tool bodies. +- `eventError(route, message, ...)` — typed `InvalidProviderOutput` constructor for stream-time decode failures. +- `validateWith(decoder)` — maps Schema decode errors to `InvalidRequest`. `Route.make(...)` uses this for body validation; lower-level routes can reuse it. +- `matchToolChoice(provider, choice, branches)` — branches over `LLMRequest["toolChoice"]` for provider-specific lowering. + +If you find yourself copying a 3-to-5-line snippet between two protocols, lift it into `ProviderShared` next to these helpers rather than duplicating. + +### Tools + +Tool loops are represented in common messages and events: + +```ts +const call = LLM.toolCall({ id: "call_1", name: "lookup", input: { query: "weather" } }) +const result = LLM.toolMessage({ id: "call_1", name: "lookup", result: { forecast: "sunny" } }) + +const followUp = LLM.request({ + model, + messages: [LLM.user("Weather?"), LLM.assistant([call]), result], +}) +``` + +Routes lower these into provider-native assistant tool-call messages and tool-result messages. Streaming providers should emit `tool-input-delta` events while arguments arrive, then a final `tool-call` event with parsed input. + +### Tool runtime + +`LLM.stream({ request, tools })` executes model-requested tools with full type safety. Plain `LLM.stream(request)` only streams the model; if `request.tools` contains schemas, tool calls are returned for the caller to handle. Use `toolExecution: "none"` to pass executable tool definitions as schemas without invoking handlers. Add `stopWhen` to opt into follow-up model rounds after tool results. + +```ts +const get_weather = tool({ + description: "Get current weather for a city", + parameters: Schema.Struct({ city: Schema.String }), + success: Schema.Struct({ temperature: Schema.Number, condition: Schema.String }), + execute: ({ city }) => + Effect.gen(function* () { + // city: string — typed from parameters Schema + const data = yield* WeatherApi.fetch(city) + return { temperature: data.temp, condition: data.cond } + // return type checked against success Schema + }), +}) + +const events = yield* LLM.stream({ + request, + tools: { get_weather, get_time, ... }, + stopWhen: LLM.stepCountIs(10), +}).pipe(Stream.runCollect) +``` + +The runtime: + +- Adds tool definitions (derived from each tool's `parameters` Schema via `Schema.toJsonSchemaDocument`) onto `request.tools`. +- Streams the model. +- On `tool-call`: looks up the named tool, decodes input against `parameters` Schema, dispatches to the typed `execute`, encodes the result against `success` Schema, emits `tool-result`. +- Emits local `tool-result` events in the same step by default. +- Loops only when `stopWhen` is provided and the step finishes with `tool-calls`, appending the assistant + tool messages. + +Handler dependencies (services, permissions, plugin hooks, abort handling) are closed over by the consumer at tool-construction time. The runtime's only environment requirement is `RequestExecutor.Service`. Build the tools record inside an `Effect.gen` once and reuse it across many runs. + +Errors must be expressed as `ToolFailure`. The runtime catches it and emits a `tool-error` event, then a `tool-result` of `type: "error"`, so the model can self-correct on the next step. Anything that is not a `ToolFailure` is treated as a defect and fails the stream. Three recoverable error paths produce `tool-error` events: + +- The model called an unknown tool name. +- Input failed the `parameters` Schema. +- The handler returned a `ToolFailure`. + +Provider-defined / hosted tools (Anthropic `web_search` / `code_execution` / `web_fetch`, OpenAI Responses `web_search_call` / `file_search_call` / `code_interpreter_call` / `mcp_call` / `local_shell_call` / `image_generation_call` / `computer_use_call`) pass through the runtime untouched: + +- Routes surface the model's call as a `tool-call` event with `providerExecuted: true`, and the provider's result as a matching `tool-result` event with `providerExecuted: true`. +- The runtime detects `providerExecuted` on `tool-call` and **skips client dispatch** — no handler is invoked and no `tool-error` is raised for "unknown tool". The provider already executed it. +- Both events are appended to the assistant message in `assistantContent` so the next round's history carries the call + result for context. Anthropic encodes them back as `server_tool_use` + `web_search_tool_result` (or `code_execution_tool_result` / `web_fetch_tool_result`) blocks; OpenAI Responses callers typically use `previous_response_id` instead of resending hosted-tool items. + +Add provider-defined tools to `request.tools` (no runtime entry needed). The matching route must know how to lower the tool definition into the provider-native shape; right now Anthropic accepts `web_search` / `code_execution` / `web_fetch` and OpenAI Responses accepts the hosted tool names listed above. + +## Protocol File Style + +Protocol files should look self-similar. Provider quirks belong behind named helpers so a new route can be reviewed by comparing the same sections across files. + +### Section order + +Use this order for every protocol module: + +1. Public model input +2. Request body schema +3. Streaming event schema +4. Parser state +5. Request body construction (`fromRequest`) +6. Stream parsing (`step` and per-event handlers) +7. Protocol and route +8. Model helper + +### Rules + +- Keep protocol files focused on the protocol. Move provider-specific projection, signing, media normalization, or other bulky transformations into `src/protocols/utils/*`. +- Use `Effect.fn("Provider.fromRequest")` for request body construction entrypoints. Use `Effect.fn(...)` for event handlers that yield effects; keep purely synchronous handlers as plain functions returning a `StepResult` that the dispatcher lifts via `Effect.succeed(...)`. +- Parser state owns terminal information. The state machine records finish reason, usage, and pending tool calls; emit one terminal `request-finish` (or `provider-error`) when a `terminal` event arrives. If a provider splits reason and usage across events, merge them in parser state before flushing. +- Emit exactly one terminal `request-finish` event for a completed response. Use `stream.terminal` to signal the run is over and have `step` emit the final event. +- Use shared helpers for repeated protocol policy such as text joining, usage totals, JSON parsing, and tool-call accumulation. `ToolStream` (`protocols/utils/tool-stream.ts`) accumulates streamed tool-call arguments uniformly. +- Make intentional provider differences explicit in helper names or comments. If two protocol files differ visually, the reason should be obvious from the names. +- Prefer dispatched per-event handlers (`onMessageStart`, `onContentBlockDelta`, ...) called from a small top-level `step` switch over a long if-chain. The dispatcher keeps the event surface visible at a glance. +- Keep tests in the same conceptual order as the protocol: basic prepare, tools prepare, unsupported lowering, text/usage parsing, tool streaming, finish reasons, provider errors. + +### Review checklist + +- Can the file be skimmed side-by-side with `openai-chat.ts` without hunting for equivalent sections? +- Are provider quirks named, isolated, and covered by focused tests? +- Does request body construction validate unsupported common content at the protocol boundary? +- Does stream parsing emit stable common events without leaking provider event order to callers? +- Does `toolChoice: "none"` behavior read as intentional? + +## Recording Tests + +Recorded tests use one cassette file per scenario. A cassette holds an ordered array of `{ request, response }` interactions, so multi-step flows (tool loops, retries, polling) record into a single file. Use `recordedTests({ prefix, requires })` and let the helper derive cassette names from test names: + +```ts +const recorded = recordedTests({ prefix: "openai-chat", requires: ["OPENAI_API_KEY"] }) + +recorded.effect("streams text", () => + Effect.gen(function* () { + // test body + }), +) +``` + +Replay is the default. `RECORD=true` records fresh cassettes and requires the listed env vars. Cassettes are written as pretty-printed JSON so multi-interaction diffs stay reviewable. + +Pass `provider`, `protocol`, and optional `tags` to `recordedTests(...)` / `recorded.effect.with(...)` so cassettes carry searchable metadata. Use recorded-test filters to replay or record a narrow subset without rewriting a whole file: + +- `RECORDED_PROVIDER=openai` matches tests tagged with `provider:openai`; comma-separated values are allowed. +- `RECORDED_PREFIX=openai-chat` matches cassette groups by `recordedTests({ prefix })`; comma-separated values are allowed. +- `RECORDED_TAGS=tool` requires all listed tags to be present, e.g. `RECORDED_TAGS=provider:togetherai,tool`. +- `RECORDED_TEST="streams text"` matches by test name, kebab-case test id, or cassette path. + +Filters apply in replay and record mode. Combine them with `RECORD=true` when refreshing only one provider or scenario. + +**Binary response bodies.** Most providers stream text (SSE, JSON). AWS Bedrock streams binary AWS event-stream frames whose CRC32 fields would be mangled by a UTF-8 round-trip — those bodies are stored as base64 with `bodyEncoding: "base64"` on the response snapshot. Detection is by `Content-Type` in `@opencode-ai/http-recorder` (currently `application/vnd.amazon.eventstream` and `application/octet-stream`); cassettes for SSE/JSON routes omit the field and decode as text. + +**Matching strategies.** Replay defaults to structural matching, which finds an interaction by comparing method, URL, allow-listed headers, and the canonical JSON body. This is the right choice for tool loops because each round's request differs (the message history grows). For scenarios where successive requests are byte-identical and expect different responses (retries, polling), pass `dispatch: "sequential"` in `RecordReplayOptions` — replay then walks the cassette in record order via an internal cursor. `scriptedResponses` (in `test/lib/http.ts`) is the deterministic counterpart for tests that don't need a live provider; it scripts response bodies in order without reading from disk. + +Do not blanket re-record an entire test file when adding one cassette. `RECORD=true` rewrites every recorded case that runs, and provider streams contain volatile IDs, timestamps, fingerprints, and obfuscation fields. Prefer deleting the one cassette you intend to refresh, or run a focused test pattern that only registers the scenario you want to record. Keep stable existing cassettes unchanged unless their request shape or expected behavior changed. diff --git a/packages/llm/README.md b/packages/llm/README.md new file mode 100644 index 0000000000..321bf715bb --- /dev/null +++ b/packages/llm/README.md @@ -0,0 +1,129 @@ +# @opencode-ai/llm + +Schema-first LLM core for opencode. One typed request, response, event, and tool language; provider quirks live in adapters, not in calling code. + +```ts +import { Effect } from "effect" +import { LLM, LLMClient } from "@opencode-ai/llm" +import { OpenAI } from "@opencode-ai/llm/providers" + +const model = OpenAI.model("gpt-4o-mini", { apiKey: process.env.OPENAI_API_KEY }) + +const request = LLM.request({ + model, + system: "You are concise.", + prompt: "Say hello in one short sentence.", + generation: { maxTokens: 40 }, +}) + +const program = Effect.gen(function* () { + const response = yield* LLMClient.generate(request) + console.log(response.text) +}) +``` + +Run `LLMClient.stream(request)` instead of `generate` when you want incremental `LLMEvent`s. The event stream is provider-neutral — same shape across OpenAI Chat, OpenAI Responses, Anthropic Messages, Gemini, Bedrock Converse, and any OpenAI-compatible deployment. + +## Public API + +- **`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. +- **`LLM.user(...)` / `LLM.assistant(...)` / `LLM.toolMessage(...)`** — message constructors. +- **`LLM.toolCall(...)` / `LLM.toolResult(...)` / `LLM.toolDefinition(...)`** — tool-related parts. +- **`LLMClient.prepare(request)`** — compile a request through protocol body construction, validation, and HTTP preparation without sending. Useful for inspection and testing. +- **`LLMEvent.is.*`** — typed guards (`is.text`, `is.toolCall`, `is.requestFinish`, …) for filtering streams. + +## Caching + +Prompt caching is **on by default**. Every `LLMRequest` resolves to `cache: "auto"` unless the caller opts out with `cache: "none"`. Each protocol translates `CacheHint`s to its wire format (`cache_control` on Anthropic, `cachePoint` on Bedrock; OpenAI and Gemini do implicit caching server-side and don't need inline markers — auto is a no-op there). + +### Auto placement + +`"auto"` places three breakpoints — last tool definition, last system part, latest user message. The last-user-message boundary is the load-bearing detail: in a tool-use loop, a single user turn expands into many assistant/tool round-trips, all sharing that prefix. Caching at that boundary lets every intra-turn API call hit. + +The math justifies the default: Anthropic's 5-minute cache write is 1.25× base, read is 0.1×, so a single reuse within 5 minutes already wins. One-shot completions below the per-model minimum-cacheable-token threshold silently no-op on the wire, so the worst case is harmless. + +### Opting out + +```ts +LLM.request({ + model, + system, + prompt: "one-off question", + cache: "none", +}) +``` + +### Granular policy + +```ts +cache: { + tools?: boolean, + system?: boolean, + messages?: "latest-user-message" | "latest-assistant" | { tail: number }, + ttlSeconds?: number, // ≥ 3600 → 1h on Anthropic/Bedrock; else 5m +} +``` + +### Manual hints + +Inline `CacheHint` on any text / system / tool / tool-result part overrides automatic placement. The auto policy preserves manual hints; it only fills gaps. + +```ts +LLM.request({ + model, + system: [ + { type: "text", text: "stable system prompt", cache: { type: "ephemeral" } }, + ], + ... +}) +``` + +### Provider behavior table + +| Protocol | `cache: "auto"` | +| ----------------------- | ------------------------------------------------------------------------- | +| Anthropic Messages | emits up to 3 `cache_control` markers (4-breakpoint cap enforced) | +| Bedrock Converse | emits up to 3 `cachePoint` blocks (4-breakpoint cap enforced) | +| OpenAI Chat / Responses | no-op (implicit caching above 1024 tokens) | +| Gemini | no-op (implicit caching on 2.5+; explicit `CachedContent` is out-of-band) | + +Normalized cache usage is read back into `response.usage.cacheReadInputTokens` and `cacheWriteInputTokens` across every provider. + +## Providers + +Each provider exports a `model(...)` helper that records identity, protocol, capabilities, auth, and defaults. + +```ts +import { Anthropic } from "@opencode-ai/llm/providers" + +const model = Anthropic.model("claude-sonnet-4-6", { + apiKey: process.env.ANTHROPIC_API_KEY, +}) +``` + +Included providers: OpenAI, Anthropic, Google (Gemini), Amazon Bedrock, Azure OpenAI, Cloudflare, GitHub Copilot, OpenRouter, xAI, plus generic OpenAI-compatible helpers for DeepSeek, Cerebras, Groq, Fireworks, Together, etc. + +## Provider options & HTTP overlays + +Three escape hatches in order of stability: + +1. **`generation`** — portable knobs (`maxTokens`, `temperature`, `topP`, `topK`, penalties, seed, stop). +2. **`providerOptions: { : {...} }`** — typed-at-the-facade provider-specific knobs (OpenAI `promptCacheKey`, Anthropic `thinking`, Gemini `thinkingConfig`, OpenRouter routing). +3. **`http: { body, headers, query }`** — last-resort serializable overlays merged into the final HTTP request. Reach for this only when a stable typed path doesn't yet exist. + +Model-level defaults are overridden by request-level values for each axis. + +## Routes + +Adding a new model or deployment is usually 5–15 lines using `Route.make({ protocol, transport, ... })`. The four orthogonal pieces are protocol (body construction + stream parsing), transport (endpoint + auth + framing + encoding), defaults, and capabilities. See `AGENTS.md` for the architectural detail. + +## Effect + +This package is built on Effect. Public methods return `Effect` or `Stream`; provide `LLMClient.layer` (the default registers every shipped route) for runtime dispatch. The example at `example/tutorial.ts` is a runnable walkthrough. + +## See also + +- `AGENTS.md` — architecture, route construction, contributor guide +- `example/tutorial.ts` — runnable end-to-end walkthrough +- `test/provider/*.test.ts` — fixture-first protocol tests; `*.recorded.test.ts` files cover live cassettes diff --git a/packages/llm/example/tutorial.ts b/packages/llm/example/tutorial.ts new file mode 100644 index 0000000000..a9adecf369 --- /dev/null +++ b/packages/llm/example/tutorial.ts @@ -0,0 +1,242 @@ +import { Config, Effect, Formatter, Layer, Schema, Stream } from "effect" +import { LLM, LLMClient, Provider, ProviderID, Tool, type ProviderModelOptions } from "@opencode-ai/llm" +import { Route, Auth, Endpoint, Framing, Protocol, RequestExecutor } from "@opencode-ai/llm/route" +import { OpenAI } from "@opencode-ai/llm/providers" + +/** + * A runnable walkthrough of the LLM package use-site API. + * + * Run from `packages/llm` with an OpenAI key in the environment: + * + * OPENAI_API_KEY=... bun example/tutorial.ts + * + * The file is intentionally written as a normal TypeScript program. You can + * hover imports and local values to see how the public API is typed. + */ + +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. +const model = OpenAI.model("gpt-4o-mini", { + apiKey, + generation: { maxTokens: 160 }, + providerOptions: { + openai: { store: false }, + }, +}) + +// 2. Build a provider-neutral request. This is useful when reusing one request +// across generate and stream examples. +// +// Options can live on both the model and the request: +// +// - `generation`: common controls such as max tokens, temperature, topP/topK, +// penalties, seed, and stop sequences. +// - `providerOptions`: namespaced provider-native behavior. For example, +// OpenAI cache keys and store behavior, Anthropic thinking, Gemini thinking +// config, or OpenRouter routing/reasoning. +// - `http`: last-resort serializable overlays for final request body, headers, +// and query params. Prefer typed `providerOptions` when a field is stable. +// +// Model options are defaults. Request options override them for this call. +const request = LLM.request({ + model, + system: "You are concise and practical.", + prompt: "Tell me a joke", + generation: { maxTokens: 80, temperature: 0.7 }, + providerOptions: { + openai: { promptCacheKey: "tutorial-joke" }, + }, +}) + +// `http` is intentionally not needed for normal calls. This shows the shape for +// newly released provider fields before they deserve a typed provider option. +const rawOverlayExample = LLM.request({ + model, + prompt: "Show the final HTTP overlay shape.", + http: { + body: { metadata: { example: "tutorial" } }, + headers: { "x-opencode-tutorial": "1" }, + query: { debug: "1" }, + }, +}) + +// 3. `generate` sends the request and collects the event stream into one +// response object. `response.text` is the collected text output. +const generateOnce = Effect.gen(function* () { + const response = yield* LLM.generate(request) + + console.log("\n== generate ==") + console.log("generated text:", response.text) + console.log("usage", Formatter.formatJson(response.usage, { space: 2 })) +}) + +// 4. `stream` exposes provider output as common `LLMEvent`s for UIs that want +// incremental text, reasoning, tool input, usage, or finish events. +const streamText = LLM.stream(request).pipe( + Stream.tap((event) => + Effect.sync(() => { + if (event.type === "text-delta") process.stdout.write(`\ntext: ${event.text}`) + if (event.type === "request-finish") process.stdout.write(`\nfinish: ${event.reason}\n`) + }), + ), + Stream.runDrain, +) + +// 5. Tools are typed with Effect Schema. Passing tools to `LLMClient.stream` +// adds their definitions to the request and dispatches matching tool calls. +// Add `stopWhen` to opt into follow-up model rounds after tool results. +const tools = { + get_weather: Tool.make({ + description: "Get current weather for a city.", + parameters: Schema.Struct({ city: Schema.String }), + success: Schema.Struct({ forecast: Schema.String }), + execute: (input) => Effect.succeed({ forecast: `${input.city}: sunny, 72F` }), + }), +} + +const streamWithTools = LLM.stream({ + request: LLM.request({ + model, + prompt: "Use get_weather for San Francisco, then answer in one sentence.", + generation: { maxTokens: 80, temperature: 0 }, + }), + tools, + stopWhen: LLM.stepCountIs(3), +}).pipe( + Stream.tap((event) => + Effect.sync(() => { + if (event.type === "tool-call") console.log("tool call", event.name, event.input) + if (event.type === "tool-result") console.log("tool result", event.name, event.result) + if (event.type === "text-delta") process.stdout.write(event.text) + }), + ), + Stream.runDrain, +) + +// 6. `generateObject` is the structured-output helper. It forces a synthetic +// tool call internally, so the same call site works across providers instead of +// depending on provider-specific JSON mode flags. +const WeatherReport = Schema.Struct({ + city: Schema.String, + forecast: Schema.String, + highFahrenheit: Schema.Number, +}) + +const generateStructuredObject = Effect.gen(function* () { + const response = yield* LLM.generateObject({ + model, + system: "Return only structured weather data.", + prompt: "Give me today's weather for San Francisco.", + schema: WeatherReport, + generation: { maxTokens: 120, temperature: 0 }, + }) + + console.log("\n== generateObject ==") + console.log(Formatter.formatJson(response.object, { space: 2 })) +}) + +// If the shape is only known at runtime, pass raw JSON Schema instead. The +// `.object` type is `unknown`; callers that need static types should validate it. +const generateDynamicObject = LLM.generateObject({ + model, + prompt: "Extract the city and forecast from: San Francisco is sunny.", + jsonSchema: { + type: "object", + properties: { + city: { type: "string" }, + forecast: { type: "string" }, + }, + required: ["city", "forecast"], + }, +}) + +// ----------------------------------------------------------------------------- +// Part 2: provider composition with a fake provider +// ----------------------------------------------------------------------------- + +// A protocol is the provider-native API shape: common request -> body, response +// frames -> common events. This fake one turns text prompts into a JSON body +// and treats every SSE frame as output text. +const FakeBody = Schema.Struct({ + model: Schema.String, + input: Schema.String, +}) +type FakeBody = Schema.Schema.Type + +const FakeProtocol = Protocol.make({ + // Protocol ids are open strings, so external packages can define their own + // protocols without changing this package. + id: "fake-echo", + body: { + schema: FakeBody, + from: (request) => + Effect.succeed({ + model: request.model.id, + input: request.messages + .flatMap((message) => message.content) + .filter((part) => part.type === "text") + .map((part) => part.text) + .join("\n"), + }), + }, + stream: { + event: Schema.String, + initial: () => undefined, + step: (_, frame) => Effect.succeed([undefined, [{ type: "text-delta", id: "text-0", text: frame }]] as const), + onHalt: () => [{ type: "request-finish", reason: "stop" }], + }, +}) + +// An route is the runnable binding for that protocol. It adds the deployment +// axes that the protocol deliberately does not know: URL, auth, and framing. +const FakeAdapter = Route.make({ + id: "fake-echo", + protocol: FakeProtocol, + endpoint: Endpoint.path("/v1/echo"), + auth: Auth.passthrough, + framing: Framing.sse, +}) + +// A provider module exports a Provider definition. The default `model` helper +// sets provider identity, protocol id, and the route id resolved by the registry. +const fakeEchoModel = Route.model(FakeAdapter, { provider: "fake-echo", baseURL: "https://fake.local" }) +const FakeEcho = Provider.make({ + id: ProviderID.make("fake-echo"), + model: (id: string, options: ProviderModelOptions = {}) => fakeEchoModel({ id, ...options }), +}) + +// `LLMClient.prepare` is the lower-level inspection hook: it compiles through +// body conversion, validation, endpoint, auth, and HTTP construction without +// sending anything over the network. +const inspectFakeProvider = Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + model: FakeEcho.model("tiny-echo"), + prompt: "Show me the provider pipeline.", + }), + ) + + console.log("\n== fake provider prepare ==") + console.log("route:", prepared.route) + console.log("body:", Formatter.formatJson(prepared.body, { space: 2 })) +}) + +// Provide the LLM runtime and the HTTP request executor once. Keep one path +// enabled at a time so the tutorial can demonstrate generate, prepare, stream, +// or tool-loop behavior without spending tokens on every example. +const requestExecutorLayer = RequestExecutor.defaultLayer +const llmClientLayer = LLMClient.layer.pipe(Layer.provide(requestExecutorLayer)) + +const program = Effect.gen(function* () { + // yield* generateOnce + // yield* inspectFakeProvider + // yield* LLMClient.prepare(rawOverlayExample).pipe(Effect.andThen((prepared) => Effect.sync(() => console.log(prepared.body)))) + // yield* streamText + // yield* generateStructuredObject + // yield* generateDynamicObject.pipe(Effect.andThen((response) => Effect.sync(() => console.log(response.object)))) + yield* streamWithTools +}).pipe(Effect.provide(Layer.mergeAll(requestExecutorLayer, llmClientLayer))) + +Effect.runPromise(program) diff --git a/packages/llm/package.json b/packages/llm/package.json new file mode 100644 index 0000000000..4070681cf7 --- /dev/null +++ b/packages/llm/package.json @@ -0,0 +1,51 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "version": "1.14.48", + "name": "@opencode-ai/llm", + "type": "module", + "license": "MIT", + "private": true, + "scripts": { + "setup:recording-env": "bun run script/setup-recording-env.ts", + "test": "bun test --timeout 30000", + "typecheck": "tsgo --noEmit" + }, + "exports": { + ".": "./src/index.ts", + "./route": "./src/route/index.ts", + "./provider": "./src/provider.ts", + "./providers": "./src/providers/index.ts", + "./providers/amazon-bedrock": "./src/providers/amazon-bedrock.ts", + "./providers/anthropic": "./src/providers/anthropic.ts", + "./providers/azure": "./src/providers/azure.ts", + "./providers/cloudflare": "./src/providers/cloudflare.ts", + "./providers/github-copilot": "./src/providers/github-copilot.ts", + "./providers/google": "./src/providers/google.ts", + "./providers/openai": "./src/providers/openai.ts", + "./providers/openai-compatible": "./src/providers/openai-compatible.ts", + "./providers/openai-compatible-profile": "./src/providers/openai-compatible-profile.ts", + "./providers/openrouter": "./src/providers/openrouter.ts", + "./providers/xai": "./src/providers/xai.ts", + "./protocols": "./src/protocols/index.ts", + "./protocols/anthropic-messages": "./src/protocols/anthropic-messages.ts", + "./protocols/bedrock-converse": "./src/protocols/bedrock-converse.ts", + "./protocols/gemini": "./src/protocols/gemini.ts", + "./protocols/openai-chat": "./src/protocols/openai-chat.ts", + "./protocols/openai-compatible-chat": "./src/protocols/openai-compatible-chat.ts", + "./protocols/openai-responses": "./src/protocols/openai-responses.ts" + }, + "devDependencies": { + "@clack/prompts": "1.0.0-alpha.1", + "@effect/platform-node": "catalog:", + "@opencode-ai/http-recorder": "workspace:*", + "@tsconfig/bun": "catalog:", + "@types/bun": "catalog:", + "@typescript/native-preview": "catalog:" + }, + "dependencies": { + "@smithy/eventstream-codec": "4.2.14", + "@smithy/util-utf8": "4.2.2", + "aws4fetch": "1.0.20", + "effect": "catalog:" + } +} diff --git a/packages/llm/script/recording-cost-report.ts b/packages/llm/script/recording-cost-report.ts new file mode 100644 index 0000000000..5b08e72d5c --- /dev/null +++ b/packages/llm/script/recording-cost-report.ts @@ -0,0 +1,250 @@ +import * as fs from "node:fs/promises" +import * as path from "node:path" + +const RECORDINGS_DIR = path.resolve(import.meta.dir, "..", "test", "fixtures", "recordings") +const MODELS_DEV_URL = "https://models.dev/api.json" + +type JsonRecord = Record + +type Pricing = { + readonly input?: number + readonly output?: number + readonly cache_read?: number + readonly cache_write?: number + readonly reasoning?: number +} + +type Usage = { + readonly inputTokens: number + readonly outputTokens: number + readonly cacheReadTokens: number + readonly cacheWriteTokens: number + readonly reasoningTokens: number + readonly reportedCost: number +} + +type Row = Usage & { + readonly cassette: string + readonly provider: string + readonly model: string + readonly estimatedCost: number + readonly pricingSource: string +} + +const isRecord = (value: unknown): value is JsonRecord => + value !== null && typeof value === "object" && !Array.isArray(value) + +const asNumber = (value: unknown) => (typeof value === "number" && Number.isFinite(value) ? value : 0) + +const asString = (value: unknown) => (typeof value === "string" ? value : undefined) + +const readJson = async (file: string) => JSON.parse(await Bun.file(file).text()) as unknown + +const walk = async (dir: string): Promise> => + (await fs.readdir(dir, { withFileTypes: true })) + .flatMap((entry) => { + const file = path.join(dir, entry.name) + return entry.isDirectory() ? [] : [file] + }) + .concat( + ...(await Promise.all( + (await fs.readdir(dir, { withFileTypes: true })) + .filter((entry) => entry.isDirectory()) + .map((entry) => walk(path.join(dir, entry.name))), + )), + ) + +const providerFromUrl = (url: string) => { + if (url.includes("api.openai.com")) return "openai" + if (url.includes("api.anthropic.com")) return "anthropic" + if (url.includes("generativelanguage.googleapis.com")) return "google" + if (url.includes("bedrock")) return "amazon-bedrock" + if (url.includes("openrouter.ai")) return "openrouter" + if (url.includes("api.x.ai")) return "xai" + if (url.includes("api.groq.com")) return "groq" + if (url.includes("api.deepseek.com")) return "deepseek" + if (url.includes("api.together.xyz")) return "togetherai" + return "unknown" +} + +const providerAliases: Record> = { + openai: ["openai"], + anthropic: ["anthropic"], + google: ["google"], + "amazon-bedrock": ["amazon-bedrock"], + openrouter: ["openrouter", "openai", "anthropic", "google"], + xai: ["xai"], + groq: ["groq"], + deepseek: ["deepseek"], + togetherai: ["togetherai"], +} + +const modelAliases = (model: string) => [ + model, + model.replace(/^models\//, ""), + model.replace(/-\d{8}$/, ""), + model.replace(/-\d{4}-\d{2}-\d{2}$/, ""), + model.replace(/-\d{4}-\d{2}-\d{2}$/, "").replace(/-\d{8}$/, ""), + model.replace(/^openai\//, ""), + model.replace(/^anthropic\//, ""), + model.replace(/^google\//, ""), +] + +const pricingFor = (models: JsonRecord, provider: string, model: string) => { + for (const providerID of providerAliases[provider] ?? [provider]) { + const providerEntry = models[providerID] + if (!isRecord(providerEntry) || !isRecord(providerEntry.models)) continue + for (const modelID of modelAliases(model)) { + const modelEntry = providerEntry.models[modelID] + if (isRecord(modelEntry) && isRecord(modelEntry.cost)) + return { pricing: modelEntry.cost as Pricing, source: `${providerID}/${modelID}` } + } + } + return { pricing: undefined, source: "missing" } +} + +const estimateCost = (usage: Usage, pricing: Pricing | undefined) => { + if (!pricing) return 0 + return ( + (usage.inputTokens * (pricing.input ?? 0) + + usage.outputTokens * (pricing.output ?? 0) + + usage.cacheReadTokens * (pricing.cache_read ?? 0) + + usage.cacheWriteTokens * (pricing.cache_write ?? 0) + + usage.reasoningTokens * (pricing.reasoning ?? 0)) / + 1_000_000 + ) +} + +const emptyUsage = (): Usage => ({ + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + reasoningTokens: 0, + reportedCost: 0, +}) + +const addUsage = (a: Usage, b: Usage): Usage => ({ + inputTokens: a.inputTokens + b.inputTokens, + outputTokens: a.outputTokens + b.outputTokens, + cacheReadTokens: a.cacheReadTokens + b.cacheReadTokens, + cacheWriteTokens: a.cacheWriteTokens + b.cacheWriteTokens, + reasoningTokens: a.reasoningTokens + b.reasoningTokens, + reportedCost: a.reportedCost + b.reportedCost, +}) + +const usageFromObject = (usage: unknown): Usage => { + if (!isRecord(usage)) return emptyUsage() + const promptDetails = isRecord(usage.prompt_tokens_details) ? usage.prompt_tokens_details : {} + const completionDetails = isRecord(usage.completion_tokens_details) ? usage.completion_tokens_details : {} + const inputDetails = isRecord(usage.input_tokens_details) ? usage.input_tokens_details : {} + const outputDetails = isRecord(usage.output_tokens_details) ? usage.output_tokens_details : {} + const cacheWriteTokens = asNumber(promptDetails.cache_write_tokens) + asNumber(inputDetails.cache_write_tokens) + return { + inputTokens: asNumber(usage.prompt_tokens) + asNumber(usage.input_tokens), + outputTokens: asNumber(usage.completion_tokens) + asNumber(usage.output_tokens), + cacheReadTokens: asNumber(promptDetails.cached_tokens) + asNumber(inputDetails.cached_tokens), + cacheWriteTokens, + reasoningTokens: asNumber(completionDetails.reasoning_tokens) + asNumber(outputDetails.reasoning_tokens), + reportedCost: asNumber(usage.cost), + } +} + +const jsonPayloads = (body: string) => + body + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.startsWith("data:")) + .map((line) => line.slice("data:".length).trim()) + .filter((line) => line !== "" && line !== "[DONE]") + .flatMap((line) => { + try { + return [JSON.parse(line) as unknown] + } catch { + return [] + } + }) + +const usageFromResponseBody = (body: string) => + jsonPayloads(body).reduce((usage, payload) => { + if (!isRecord(payload)) return usage + return addUsage( + usage, + addUsage( + usageFromObject(payload.usage), + usageFromObject(isRecord(payload.response) ? payload.response.usage : undefined), + ), + ) + }, emptyUsage()) + +const modelFromRequest = (request: unknown) => { + if (!isRecord(request)) return "unknown" + const requestBody = asString(request.body) + if (!requestBody) return "unknown" + try { + const body = JSON.parse(requestBody) as unknown + if (!isRecord(body)) return "unknown" + return asString(body.model) ?? "unknown" + } catch { + return "unknown" + } +} + +const rowFor = (models: JsonRecord, file: string, cassette: unknown): Row | undefined => { + if (!isRecord(cassette) || !Array.isArray(cassette.interactions)) return undefined + const first = cassette.interactions.find(isRecord) + if (!first || !isRecord(first.request)) return undefined + const provider = providerFromUrl(asString(first.request.url) ?? "") + const model = modelFromRequest(first.request) + const usage = cassette.interactions.filter(isRecord).reduce((total, interaction) => { + if (!isRecord(interaction.response)) return total + const responseBody = asString(interaction.response.body) + if (!responseBody) return total + return addUsage(total, usageFromResponseBody(responseBody)) + }, emptyUsage()) + const priced = pricingFor(models, provider, model) + return { + cassette: path.relative(RECORDINGS_DIR, file), + provider, + model, + ...usage, + estimatedCost: estimateCost(usage, priced.pricing), + pricingSource: priced.source, + } +} + +const money = (value: number) => (value === 0 ? "$0.000000" : `$${value.toFixed(6)}`) +const tokens = (value: number) => value.toLocaleString("en-US") + +const models = (await (await fetch(MODELS_DEV_URL)).json()) as JsonRecord +const rows = ( + await Promise.all( + (await walk(RECORDINGS_DIR)) + .filter((file) => file.endsWith(".json")) + .map(async (file) => rowFor(models, file, await readJson(file))), + ) +).filter((row): row is Row => row !== undefined) + +const totals = rows.reduce( + (total, row) => ({ + ...addUsage(total, row), + estimatedCost: total.estimatedCost + row.estimatedCost, + }), + { ...emptyUsage(), estimatedCost: 0 }, +) + +console.log("# Recording Cost Report") +console.log("") +console.log(`Pricing: ${MODELS_DEV_URL}`) +console.log(`Cassettes: ${rows.length}`) +console.log(`Reported cost: ${money(totals.reportedCost)}`) +console.log(`Estimated cost: ${money(totals.estimatedCost)}`) +console.log("") +console.log("| Provider | Model | Input | Output | Reasoning | Reported | Estimated | Pricing | Cassette |") +console.log("|---|---:|---:|---:|---:|---:|---:|---|---|") +for (const row of rows.toSorted((a, b) => b.reportedCost + b.estimatedCost - (a.reportedCost + a.estimatedCost))) { + if (row.inputTokens + row.outputTokens + row.reasoningTokens + row.reportedCost + row.estimatedCost === 0) continue + console.log( + `| ${row.provider} | ${row.model} | ${tokens(row.inputTokens)} | ${tokens(row.outputTokens)} | ${tokens(row.reasoningTokens)} | ${money(row.reportedCost)} | ${money(row.estimatedCost)} | ${row.pricingSource} | ${row.cassette} |`, + ) +} diff --git a/packages/llm/script/setup-recording-env.ts b/packages/llm/script/setup-recording-env.ts new file mode 100644 index 0000000000..d32769b3ce --- /dev/null +++ b/packages/llm/script/setup-recording-env.ts @@ -0,0 +1,542 @@ +#!/usr/bin/env bun + +import { NodeFileSystem } from "@effect/platform-node" +import * as path from "node:path" +import * as prompts from "@clack/prompts" +import { AwsV4Signer } from "aws4fetch" +import { Config, ConfigProvider, Effect, FileSystem, PlatformError, Redacted } from "effect" +import { FetchHttpClient, HttpClient, HttpClientRequest, type HttpClientResponse } from "effect/unstable/http" +import * as ProviderShared from "../src/protocols/shared" +import * as Cloudflare from "../src/providers/cloudflare" + +type Provider = { + readonly id: string + readonly label: string + readonly tier: "core" | "canary" | "compatible" | "optional" + readonly note: string + readonly vars: ReadonlyArray<{ + readonly name: string + readonly label?: string + readonly optional?: boolean + readonly secret?: boolean + }> + readonly validate?: (env: Env) => Effect.Effect +} + +type Env = Record + +const PROVIDERS: ReadonlyArray = [ + { + id: "openai", + label: "OpenAI", + tier: "core", + note: "Native OpenAI Chat / Responses recorded tests", + vars: [{ name: "OPENAI_API_KEY" }], + validate: (env) => validateBearer("https://api.openai.com/v1/models", Redacted.make(env.OPENAI_API_KEY)), + }, + { + id: "anthropic", + label: "Anthropic", + tier: "core", + note: "Native Anthropic Messages recorded tests", + vars: [{ name: "ANTHROPIC_API_KEY" }], + validate: (env) => + HttpClientRequest.get("https://api.anthropic.com/v1/models").pipe( + HttpClientRequest.setHeaders({ + "anthropic-version": "2023-06-01", + "x-api-key": Redacted.value(Redacted.make(env.ANTHROPIC_API_KEY)), + }), + executeRequest, + ), + }, + { + id: "google", + label: "Google Gemini", + tier: "core", + note: "Native Gemini recorded tests", + vars: [{ name: "GOOGLE_GENERATIVE_AI_API_KEY" }], + validate: (env) => + HttpClientRequest.get( + `https://generativelanguage.googleapis.com/v1beta/models?key=${encodeURIComponent(env.GOOGLE_GENERATIVE_AI_API_KEY)}`, + ).pipe(executeRequest), + }, + { + id: "bedrock", + label: "Amazon Bedrock", + tier: "core", + note: "Native Bedrock Converse recorded tests", + vars: [ + { name: "AWS_ACCESS_KEY_ID" }, + { name: "AWS_SECRET_ACCESS_KEY" }, + { name: "AWS_SESSION_TOKEN", optional: true }, + { name: "BEDROCK_RECORDING_REGION", optional: true }, + { name: "BEDROCK_MODEL_ID", optional: true }, + ], + validate: (env) => validateBedrock(env), + }, + { + id: "groq", + label: "Groq", + tier: "canary", + note: "Fast OpenAI-compatible canary for text/tool streaming", + vars: [{ name: "GROQ_API_KEY" }], + validate: (env) => validateBearer("https://api.groq.com/openai/v1/models", Redacted.make(env.GROQ_API_KEY)), + }, + { + id: "openrouter", + label: "OpenRouter", + tier: "canary", + note: "Router canary for OpenAI-compatible text/tool streaming", + vars: [{ name: "OPENROUTER_API_KEY" }], + validate: (env) => + validateChat({ + url: "https://openrouter.ai/api/v1/chat/completions", + token: Redacted.make(env.OPENROUTER_API_KEY), + model: "openai/gpt-4o-mini", + }), + }, + { + id: "xai", + label: "xAI", + tier: "canary", + note: "OpenAI-compatible xAI chat endpoint", + vars: [{ name: "XAI_API_KEY" }], + validate: (env) => validateBearer("https://api.x.ai/v1/models", Redacted.make(env.XAI_API_KEY)), + }, + { + id: "cloudflare-ai-gateway", + label: "Cloudflare AI Gateway", + tier: "canary", + note: "Cloudflare Unified/OpenAI-compatible gateway; supports provider/model ids like workers-ai/@cf/...", + vars: [ + { name: "CLOUDFLARE_ACCOUNT_ID", label: "Cloudflare account ID", secret: false }, + { + name: "CLOUDFLARE_GATEWAY_ID", + label: "Cloudflare AI Gateway ID (defaults to default)", + optional: true, + secret: false, + }, + { name: "CLOUDFLARE_API_TOKEN", label: "Cloudflare AI Gateway token" }, + ], + validate: (env) => + validateChat({ + url: `${Cloudflare.aiGatewayBaseURL({ + accountId: env.CLOUDFLARE_ACCOUNT_ID, + gatewayId: env.CLOUDFLARE_GATEWAY_ID || undefined, + })}/chat/completions`, + token: Redacted.make(envValue(env, Cloudflare.aiGatewayAuthEnvVars)), + tokenHeader: "cf-aig-authorization", + model: "workers-ai/@cf/meta/llama-3.1-8b-instruct", + }), + }, + { + id: "cloudflare-workers-ai", + label: "Cloudflare Workers AI", + tier: "canary", + note: "Direct Workers AI OpenAI-compatible endpoint; supports model ids like @cf/meta/...", + vars: [ + { name: "CLOUDFLARE_ACCOUNT_ID", label: "Cloudflare account ID", secret: false }, + { name: "CLOUDFLARE_API_KEY", label: "Cloudflare Workers AI API token" }, + ], + validate: (env) => + validateChat({ + url: `${Cloudflare.workersAIBaseURL({ accountId: env.CLOUDFLARE_ACCOUNT_ID })}/chat/completions`, + token: Redacted.make(envValue(env, Cloudflare.workersAIAuthEnvVars)), + model: "@cf/meta/llama-3.1-8b-instruct", + }), + }, + { + id: "deepseek", + label: "DeepSeek", + tier: "compatible", + note: "Existing OpenAI-compatible recorded tests", + vars: [{ name: "DEEPSEEK_API_KEY" }], + validate: (env) => validateBearer("https://api.deepseek.com/models", Redacted.make(env.DEEPSEEK_API_KEY)), + }, + { + id: "togetherai", + label: "TogetherAI", + tier: "compatible", + note: "Existing OpenAI-compatible text/tool recorded tests", + vars: [{ name: "TOGETHER_AI_API_KEY" }], + validate: (env) => validateBearer("https://api.together.xyz/v1/models", Redacted.make(env.TOGETHER_AI_API_KEY)), + }, + { + id: "mistral", + label: "Mistral", + tier: "optional", + note: "OpenAI-compatible bridge; native reasoning parity is follow-up work", + vars: [{ name: "MISTRAL_API_KEY" }], + validate: (env) => validateBearer("https://api.mistral.ai/v1/models", Redacted.make(env.MISTRAL_API_KEY)), + }, + { + id: "perplexity", + label: "Perplexity", + tier: "optional", + note: "OpenAI-compatible bridge; citations/search metadata are follow-up work", + vars: [{ name: "PERPLEXITY_API_KEY" }], + validate: (env) => validateBearer("https://api.perplexity.ai/models", Redacted.make(env.PERPLEXITY_API_KEY)), + }, + { + id: "venice", + label: "Venice", + tier: "optional", + note: "OpenAI-compatible bridge", + vars: [{ name: "VENICE_API_KEY" }], + validate: (env) => validateBearer("https://api.venice.ai/api/v1/models", Redacted.make(env.VENICE_API_KEY)), + }, + { + id: "cerebras", + label: "Cerebras", + tier: "optional", + note: "OpenAI-compatible bridge", + vars: [{ name: "CEREBRAS_API_KEY" }], + validate: (env) => validateBearer("https://api.cerebras.ai/v1/models", Redacted.make(env.CEREBRAS_API_KEY)), + }, + { + id: "deepinfra", + label: "DeepInfra", + tier: "optional", + note: "OpenAI-compatible bridge", + vars: [{ name: "DEEPINFRA_API_KEY" }], + validate: (env) => + validateBearer("https://api.deepinfra.com/v1/openai/models", Redacted.make(env.DEEPINFRA_API_KEY)), + }, + { + id: "fireworks", + label: "Fireworks", + tier: "optional", + note: "OpenAI-compatible bridge", + vars: [{ name: "FIREWORKS_API_KEY" }], + validate: (env) => + validateBearer("https://api.fireworks.ai/inference/v1/models", Redacted.make(env.FIREWORKS_API_KEY)), + }, + { + id: "baseten", + label: "Baseten", + tier: "optional", + note: "OpenAI-compatible bridge", + vars: [{ name: "BASETEN_API_KEY" }], + }, +] + +const args = process.argv.slice(2) +const hasFlag = (name: string) => args.includes(name) +const option = (name: string) => { + const index = args.indexOf(name) + if (index === -1) return undefined + return args[index + 1] +} + +const envPath = path.resolve(process.cwd(), option("--env") ?? ".env.local") +const checkOnly = hasFlag("--check") +const providerOption = option("--providers") +const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY) + +const envNames = Array.from(new Set(PROVIDERS.flatMap((provider) => provider.vars.map((item) => item.name)))) + +const providersForOption = (value: string | undefined) => { + if (!value || value === "recommended") + return PROVIDERS.filter((provider) => provider.tier === "core" || provider.tier === "canary") + if (value === "recorded") return PROVIDERS.filter((provider) => provider.tier !== "optional") + if (value === "all") return PROVIDERS + const ids = new Set( + value + .split(",") + .map((item) => item.trim()) + .filter(Boolean), + ) + return PROVIDERS.filter((provider) => ids.has(provider.id)) +} + +const chooseProviders = async () => { + if (providerOption) return providersForOption(providerOption) + return providersForOption("recommended") +} + +const catchMissingFile = (error: PlatformError.PlatformError) => { + if (error.reason._tag === "NotFound") return Effect.succeed("") + return Effect.fail(error) +} + +const readEnvFile = Effect.fn("RecordingEnv.readFile")(function* () { + const fileSystem = yield* FileSystem.FileSystem + return yield* fileSystem.readFileString(envPath).pipe(Effect.catch(catchMissingFile)) +}) + +const readConfigString = (provider: ConfigProvider.ConfigProvider, name: string) => + Config.string(name) + .parse(provider) + .pipe( + Effect.match({ + onFailure: () => undefined, + onSuccess: (value) => value, + }), + ) + +const parseEnv = Effect.fn("RecordingEnv.parseEnv")(function* (contents: string) { + const provider = ConfigProvider.fromDotEnvContents(contents) + return Object.fromEntries( + (yield* Effect.forEach(envNames, (name) => + readConfigString(provider, name).pipe(Effect.map((value) => [name, value] as const)), + )).filter((entry): entry is readonly [string, string] => entry[1] !== undefined), + ) +}) + +const quote = (value: string) => JSON.stringify(value) + +const status = (name: string, fileEnv: Env) => { + if (fileEnv[name]) return "file" + if (process.env[name]) return "shell" + return "missing" +} + +const statusLine = (provider: Provider, fileEnv: Env) => + [ + `${provider.label} (${provider.tier})`, + provider.note, + ...provider.vars.map((item) => { + const value = status(item.name, fileEnv) + const suffix = item.optional ? " optional" : "" + return ` ${value === "missing" ? "missing" : "set"} ${item.name}${suffix}${value === "shell" ? " (shell only)" : ""}` + }), + ].join("\n") + +const printStatus = (providers: ReadonlyArray, fileEnv: Env) => { + prompts.note(providers.map((provider) => statusLine(provider, fileEnv)).join("\n\n"), `Recording env: ${envPath}`) +} + +const exitIfCancel = (value: A | symbol): A => { + if (!prompts.isCancel(value)) return value as A + prompts.cancel("Cancelled") + process.exit(130) +} + +const upsertEnv = (contents: string, values: Env) => { + const names = Object.keys(values) + const seen = new Set() + const lines = contents.split(/\r?\n/).map((line) => { + const match = line.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/) + if (!match || !names.includes(match[1])) return line + seen.add(match[1]) + return `${match[1]}=${quote(values[match[1]])}` + }) + const missing = names.filter((name) => !seen.has(name)) + if (missing.length === 0) return lines.join("\n").replace(/\n*$/, "\n") + const prefix = lines.join("\n").trimEnd() + const block = [ + "", + "# Added by bun run setup:recording-env", + ...missing.map((name) => `${name}=${quote(values[name])}`), + ].join("\n") + return `${prefix}${block}\n` +} + +const providerRequiredStatus = (provider: Provider, fileEnv: Env) => { + const required = requiredVars(provider) + if (required.some((item) => status(item.name, fileEnv) === "missing")) return "missing" + if (required.some((item) => status(item.name, fileEnv) === "shell")) return "set in shell" + return "already added" +} + +const requiredVars = (provider: Provider) => provider.vars.filter((item) => !item.optional) + +const promptVars = (provider: Provider) => provider.vars.filter((item) => !item.optional || item.secret === false) + +const processEnv = (): Env => + Object.fromEntries(Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined)) + +const envValue = (env: Env, names: ReadonlyArray) => names.map((name) => env[name]).find(Boolean) ?? "" + +const envWithValues = (fileEnv: Env, values: Env): Env => ({ + ...processEnv(), + ...fileEnv, + ...values, +}) + +const responseError = Effect.fn("RecordingEnv.responseError")(function* ( + response: HttpClientResponse.HttpClientResponse, +) { + if (response.status >= 200 && response.status < 300) return undefined + const body = yield* response.text.pipe(Effect.catch(() => Effect.succeed(""))) + return `${response.status}${body ? `: ${body.slice(0, 180)}` : ""}` +}) + +const executeRequest = Effect.fn("RecordingEnv.executeRequest")(function* ( + request: HttpClientRequest.HttpClientRequest, +) { + const http = yield* HttpClient.HttpClient + return yield* http.execute(request).pipe(Effect.flatMap(responseError)) +}) + +const validateBearer = (url: string, token: Redacted.Redacted, headers: Record = {}) => + HttpClientRequest.get(url).pipe( + HttpClientRequest.setHeaders({ ...headers, authorization: `Bearer ${Redacted.value(token)}` }), + executeRequest, + ) + +const validateChat = (input: { + readonly url: string + readonly token: Redacted.Redacted + readonly tokenHeader?: string + readonly model: string + readonly headers?: Record +}) => + ProviderShared.jsonPost({ + url: input.url, + headers: { ...input.headers, [input.tokenHeader ?? "authorization"]: `Bearer ${Redacted.value(input.token)}` }, + body: ProviderShared.encodeJson({ + model: input.model, + messages: [{ role: "user", content: "Reply with exactly: ok" }], + max_tokens: 3, + temperature: 0, + }), + }).pipe(executeRequest) + +const validateBedrock = (env: Env) => + Effect.gen(function* () { + const request = yield* Effect.promise(() => + new AwsV4Signer({ + url: `https://bedrock.${env.BEDROCK_RECORDING_REGION || "us-east-1"}.amazonaws.com/foundation-models`, + method: "GET", + service: "bedrock", + region: env.BEDROCK_RECORDING_REGION || "us-east-1", + accessKeyId: env.AWS_ACCESS_KEY_ID, + secretAccessKey: env.AWS_SECRET_ACCESS_KEY, + sessionToken: env.AWS_SESSION_TOKEN || undefined, + }).sign(), + ) + return yield* HttpClientRequest.get(request.url.toString()).pipe( + HttpClientRequest.setHeaders(Object.fromEntries(request.headers.entries())), + executeRequest, + ) + }) + +const validateProvider = Effect.fn("RecordingEnv.validateProvider")(function* (provider: Provider, env: Env) { + return yield* (provider.validate?.(env) ?? Effect.succeed("no lightweight validator")).pipe( + Effect.catch((error) => { + if (error instanceof Error) return Effect.succeed(error.message) + return Effect.succeed(String(error)) + }), + ) +}) + +const validateProviders = Effect.fn("RecordingEnv.validateProviders")(function* ( + providers: ReadonlyArray, + env: Env, +) { + const spinner = prompts.spinner() + spinner.start("Validating credentials") + const results = yield* Effect.forEach( + providers, + (provider) => validateProvider(provider, env).pipe(Effect.map((error) => ({ provider, error }))), + { concurrency: 4 }, + ) + spinner.stop("Validation complete") + prompts.note( + results + .map( + (result) => + `${result.error ? "failed" : "ok"} ${result.provider.label}${result.error ? ` - ${result.error}` : ""}`, + ) + .join("\n"), + "Credential validation", + ) +}) + +const writeEnvFile = Effect.fn("RecordingEnv.writeFile")(function* (contents: string) { + const fileSystem = yield* FileSystem.FileSystem + yield* fileSystem.makeDirectory(path.dirname(envPath), { recursive: true }) + yield* fileSystem.writeFileString(envPath, contents, { mode: 0o600 }) +}) + +const prompt = (run: () => Promise) => Effect.promise(run).pipe(Effect.map(exitIfCancel)) + +const chooseConfigurableProviders = Effect.fn("RecordingEnv.chooseConfigurableProviders")(function* ( + providers: ReadonlyArray, + fileEnv: Env, +) { + const configurable = providers.filter((provider) => requiredVars(provider).length > 0) + const selected = yield* prompt>(() => + prompts.multiselect({ + message: "Select provider credentials to add or override", + options: configurable.map((provider) => ({ + value: provider.id, + label: provider.label, + hint: `${providerRequiredStatus(provider, fileEnv)} - ${requiredVars(provider) + .map((item) => item.name) + .join(", ")}`, + })), + initialValues: configurable + .filter((provider) => providerRequiredStatus(provider, fileEnv) === "missing") + .map((provider) => provider.id), + }), + ) + return configurable.filter((provider) => selected.includes(provider.id)) +}) + +const promptEnvVar = (item: Provider["vars"][number]) => + prompt(() => { + const input = { + message: item.label ?? item.name, + validate: (input: string | undefined) => { + if (item.optional) return undefined + return !input || input.length === 0 ? "Leave blank by pressing Esc/cancel, or paste a value" : undefined + }, + } + return item.secret === false ? prompts.text(input) : prompts.password(input) + }) + +const promptProviderValues = Effect.fn("RecordingEnv.promptProviderValues")(function* ( + providers: ReadonlyArray, +) { + const values: Env = {} + for (const provider of providers) { + prompts.log.info(`${provider.label}: ${provider.note}`) + for (const item of promptVars(provider)) { + if (values[item.name]) continue + const value = yield* promptEnvVar(item) + if (value !== "") values[item.name] = value + } + } + return values +}) + +const main = Effect.fn("RecordingEnv.main")(function* () { + prompts.intro("LLM recording credentials") + const contents = yield* readEnvFile() + const fileEnv = yield* parseEnv(contents) + const providers = yield* Effect.promise(() => chooseProviders()) + printStatus(providers, fileEnv) + if (checkOnly) { + prompts.outro("Check complete") + return + } + if (!interactive) { + prompts.outro("Run this command in a terminal to enter credentials") + return + } + + const selectedProviders = yield* chooseConfigurableProviders(providers, fileEnv) + const values = yield* promptProviderValues(selectedProviders) + + if (Object.keys(values).length === 0) { + prompts.outro("No changes") + return + } + + if ( + interactive && + (yield* prompt(() => prompts.confirm({ message: "Validate credentials before saving?", initialValue: true }))) + ) { + yield* validateProviders(selectedProviders, envWithValues(fileEnv, values)) + } + + yield* writeEnvFile(upsertEnv(contents, values)) + prompts.log.success( + `Saved ${Object.keys(values).length} value${Object.keys(values).length === 1 ? "" : "s"} to ${envPath}`, + ) + prompts.outro("Keep .env.local local. Store shared team credentials in a password manager or vault.") +}) + +await Effect.runPromise(main().pipe(Effect.provide(NodeFileSystem.layer), Effect.provide(FetchHttpClient.layer))) diff --git a/packages/llm/src/cache-policy.ts b/packages/llm/src/cache-policy.ts new file mode 100644 index 0000000000..6ab7a049fe --- /dev/null +++ b/packages/llm/src/cache-policy.ts @@ -0,0 +1,111 @@ +// Apply an `LLMRequest.cache` policy by injecting `CacheHint`s onto the parts +// the policy designates. Runs once at compile time, before the per-protocol +// body builder, so the existing inline-hint lowering path handles the rest. +// +// The default `"auto"` shape places one breakpoint at the last tool definition, +// one at the last system part, and one at the latest user message. This +// matches what production agent harnesses (LangChain's caching middleware, +// kern-ai's 10x cost-reduction playbook) converge on for tool-use loops: the +// latest user message stays put while a single turn explodes into many +// assistant/tool round-trips, so caching at that boundary lets every +// intra-turn API call hit the prefix. +// +// Manual `cache: CacheHint` placements on individual parts are preserved — +// this function only fills gaps the caller left empty. +import { CacheHint, type CachePolicy, type CachePolicyObject } from "./schema/options" +import { LLMRequest, Message, ToolDefinition, type ContentPart } from "./schema/messages" + +const AUTO: CachePolicyObject = { + tools: true, + system: true, + messages: "latest-user-message", +} + +const NONE: CachePolicyObject = {} + +// Resolution rules: +// - undefined → "auto" — caching is on by default. The math favors it: +// Anthropic 5m-cache write is 1.25x base, read is 0.1x, +// so a single reuse within 5 minutes already wins. +// - "auto" → tools + system + latest user msg. +// - "none" → no auto placement; manual `CacheHint`s still flow. +// - object form → exactly what the caller asked for. +const resolve = (policy: CachePolicy | undefined): CachePolicyObject => { + if (policy === undefined || policy === "auto") return AUTO + if (policy === "none") return NONE + return policy +} + +// Protocols whose wire format ignores inline cache markers (OpenAI's implicit +// prefix caching, Gemini's implicit + out-of-band CachedContent). Skip the +// whole policy pass for these — emitting hints would be harmless but pointless. +const RESPECTS_INLINE_HINTS = new Set(["anthropic-messages", "bedrock-converse"]) + +const makeHint = (ttlSeconds: number | undefined): CacheHint => + ttlSeconds !== undefined ? new CacheHint({ type: "ephemeral", ttlSeconds }) : new CacheHint({ type: "ephemeral" }) + +const markLastTool = (tools: ReadonlyArray, hint: CacheHint): ReadonlyArray => { + if (tools.length === 0) return tools + const last = tools.length - 1 + if (tools[last]!.cache) return tools + return tools.map((tool, i) => (i === last ? new ToolDefinition({ ...tool, cache: hint }) : tool)) +} + +const markLastSystem = (system: LLMRequest["system"], hint: CacheHint): LLMRequest["system"] => { + if (system.length === 0) return system + const last = system.length - 1 + if (system[last]!.cache) return system + return system.map((part, i) => (i === last ? { ...part, cache: hint } : part)) +} + +const lastIndexOfRole = (messages: ReadonlyArray, role: Message["role"]): number => + messages.findLastIndex((m) => m.role === role) + +// Mark the last text part of `messages[index]`. If no text part exists, mark +// the last content part regardless of type — that's the breakpoint position +// in tool-result-only messages too. +const markMessageAt = (messages: ReadonlyArray, index: number, hint: CacheHint): ReadonlyArray => { + if (index < 0 || index >= messages.length) return messages + const target = messages[index]! + if (target.content.length === 0) return messages + const lastTextIndex = target.content.findLastIndex((part) => part.type === "text") + const markAt = lastTextIndex >= 0 ? lastTextIndex : target.content.length - 1 + const existing = target.content[markAt]! + if ("cache" in existing && existing.cache) return messages + const nextContent = target.content.map((part, i) => (i === markAt ? ({ ...part, cache: hint } as ContentPart) : part)) + const next = new Message({ ...target, content: nextContent }) + // Single pass over `messages`, substituting the one updated entry. Long + // conversations call this on every request, so avoid `.map()` here — its + // closure dispatch and identity copies show up in profiling. + const result = messages.slice() + result[index] = next + return result +} + +const markMessages = ( + messages: ReadonlyArray, + strategy: NonNullable, + hint: CacheHint, +): ReadonlyArray => { + if (messages.length === 0) return messages + if (strategy === "latest-user-message") return markMessageAt(messages, lastIndexOfRole(messages, "user"), hint) + if (strategy === "latest-assistant") return markMessageAt(messages, lastIndexOfRole(messages, "assistant"), hint) + const start = Math.max(0, messages.length - strategy.tail) + let next = messages + for (let i = start; i < messages.length; i++) next = markMessageAt(next, i, hint) + return next +} + +export const applyCachePolicy = (request: LLMRequest): LLMRequest => { + if (!RESPECTS_INLINE_HINTS.has(request.model.route)) return request + const policy = resolve(request.cache) + if (!policy.tools && !policy.system && !policy.messages) return request + + const hint = makeHint(policy.ttlSeconds) + const tools = policy.tools ? markLastTool(request.tools, hint) : request.tools + const system = policy.system ? markLastSystem(request.system, hint) : request.system + const messages = policy.messages ? markMessages(request.messages, policy.messages, hint) : request.messages + + if (tools === request.tools && system === request.system && messages === request.messages) return request + return LLMRequest.update(request, { tools, system, messages }) +} diff --git a/packages/llm/src/index.ts b/packages/llm/src/index.ts new file mode 100644 index 0000000000..f4adf4859a --- /dev/null +++ b/packages/llm/src/index.ts @@ -0,0 +1,35 @@ +export { LLMClient, modelLimits, modelRef } from "./route/client" +export { Auth } from "./route/auth" +export { Provider } from "./provider" +export type { + RouteModelInput, + RouteRoutedModelInput, + Interface as LLMClientShape, + Service as LLMClientService, + ModelRefInput, +} from "./route/client" +export * from "./schema" +export { Tool, ToolFailure, toDefinitions, tool } from "./tool" +export type { + AnyExecutableTool, + AnyTool, + ExecutableTool, + ExecutableTools, + Tool as ToolShape, + ToolExecute, + Tools, + ToolSchema, +} from "./tool" +export type { + RunOptions as ToolRunOptions, + RuntimeState as ToolRuntimeState, + StopCondition as ToolStopCondition, + ToolExecution, +} from "./tool-runtime" + +export * as LLM from "./llm" +export type { + Definition as ProviderDefinition, + ModelFactory as ProviderModelFactory, + ModelOptions as ProviderModelOptions, +} from "./provider" diff --git a/packages/llm/src/llm.ts b/packages/llm/src/llm.ts new file mode 100644 index 0000000000..bca78c888a --- /dev/null +++ b/packages/llm/src/llm.ts @@ -0,0 +1,219 @@ +import { Effect, JsonSchema, Schema } from "effect" +import { LLMClient, modelLimits, modelRef, type ModelRefInput } from "./route/client" +import { + GenerationOptions, + HttpOptions, + InvalidProviderOutputReason, + LLMError, + LLMEvent, + LLMRequest, + LLMResponse, + Message, + SystemPart, + ToolChoice, + ToolDefinition, + type ContentPart, + ToolCallPart, + ToolResultPart, +} from "./schema" +import { make as makeTool, type ToolSchema } from "./tool" + +export type ModelInput = ModelRefInput + +export type MessageInput = Message.Input + +export type ToolChoiceInput = ToolChoice.Input +export type ToolChoiceMode = ToolChoice.Mode + +export type ToolResultInput = Parameters[0] + +/** Input accepted by `LLM.request`, normalized into the canonical `LLMRequest` class. */ +export type RequestInput = Omit< + ConstructorParameters[0], + "system" | "messages" | "tools" | "toolChoice" | "generation" | "http" | "providerOptions" +> & { + readonly system?: string | SystemPart | ReadonlyArray + readonly prompt?: string | ContentPart | ReadonlyArray + readonly messages?: ReadonlyArray + readonly tools?: ReadonlyArray + readonly toolChoice?: ToolChoiceInput + readonly generation?: GenerationOptions.Input + readonly providerOptions?: ConstructorParameters[0]["providerOptions"] + readonly http?: HttpOptions.Input +} + +export const limits = modelLimits + +export const text = Message.text + +export const system = SystemPart.make + +export const message = Message.make + +export const user = Message.user + +export const assistant = Message.assistant + +export const model = modelRef + +export const toolDefinition = ToolDefinition.make + +export const toolCall = ToolCallPart.make + +export const toolResult = ToolResultPart.make + +export const toolMessage = Message.tool + +export const toolChoiceName = ToolChoice.named + +export const toolChoice = ToolChoice.make + +export const generation = GenerationOptions.make + +export const generate = LLMClient.generate + +export const stream = LLMClient.stream + +export const stepCountIs = LLMClient.stepCountIs + +export const requestInput = (input: LLMRequest): RequestInput => ({ + ...LLMRequest.input(input), +}) + +export const request = (input: RequestInput) => { + const { + system: requestSystem, + prompt, + messages, + tools, + toolChoice: requestToolChoice, + generation: requestGeneration, + providerOptions: requestProviderOptions, + http: requestHttp, + ...rest + } = input + return new LLMRequest({ + ...rest, + system: SystemPart.content(requestSystem), + messages: [...(messages?.map(message) ?? []), ...(prompt === undefined ? [] : [user(prompt)])], + tools: tools?.map(toolDefinition) ?? [], + toolChoice: requestToolChoice ? toolChoice(requestToolChoice) : undefined, + generation: requestGeneration === undefined ? undefined : generation(requestGeneration), + providerOptions: requestProviderOptions, + http: requestHttp === undefined ? undefined : HttpOptions.make(requestHttp), + }) +} + +export const updateRequest = (input: LLMRequest, patch: Partial) => + request({ ...requestInput(input), ...patch }) + +const GENERATE_OBJECT_TOOL_NAME = "generate_object" + +const GENERATE_OBJECT_TOOL_DESCRIPTION = "Return the structured result by calling this tool." + +type GenerateObjectBase = Omit + +export class GenerateObjectResponse { + constructor( + readonly object: T, + readonly response: LLMResponse, + ) {} + + get events() { + return this.response.events + } + + get usage() { + return this.response.usage + } +} + +export interface GenerateObjectOptions> extends GenerateObjectBase { + readonly schema: S +} + +export interface GenerateObjectDynamicOptions extends GenerateObjectBase { + /** Raw JSON Schema object describing the expected output shape. */ + readonly jsonSchema: JsonSchema.JsonSchema +} + +const runGenerateObject = Effect.fn("LLM.generateObject")(function* ( + options: GenerateObjectBase, + tool: ReturnType, +) { + const baseRequest = request(options) + const generateRequest = LLMRequest.update(baseRequest, { + toolChoice: ToolChoice.named(GENERATE_OBJECT_TOOL_NAME), + }) + const response = yield* LLMClient.generate({ + request: generateRequest, + tools: { [GENERATE_OBJECT_TOOL_NAME]: tool }, + toolExecution: "none", + }) + const call = response.toolCalls.find( + (event) => LLMEvent.is.toolCall(event) && event.name === GENERATE_OBJECT_TOOL_NAME, + ) + if (!call || !LLMEvent.is.toolCall(call)) + return yield* new LLMError({ + module: "LLM", + method: "generateObject", + reason: new InvalidProviderOutputReason({ + message: `generateObject: model did not call the forced \`${GENERATE_OBJECT_TOOL_NAME}\` tool`, + }), + }) + const object = yield* tool._decode(call.input).pipe( + Effect.mapError( + (error) => + new LLMError({ + module: "LLM", + method: "generateObject", + reason: new InvalidProviderOutputReason({ + message: `generateObject: tool input failed schema decode: ${error.message}`, + }), + }), + ), + ) + return new GenerateObjectResponse(object, response) +}) + +/** + * Run a model and decode its output against `schema`. Works on every protocol + * because it forces a synthetic tool call internally — provider-native JSON + * modes are intentionally avoided so behaviour is uniform. + * + * Two input modes: + * + * 1. `schema: EffectSchema` — `.object` is decoded and typed as `T`. + * Decode failures surface as `LLMError`. + * 2. `jsonSchema: JsonSchema.JsonSchema` — `.object` is `unknown`. Use when + * the schema is only available at runtime (MCP, plugin manifests). Caller validates. + */ +export function generateObject>( + options: GenerateObjectOptions, +): Effect.Effect>, LLMError> +export function generateObject( + options: GenerateObjectDynamicOptions, +): Effect.Effect, LLMError> +export function generateObject(options: GenerateObjectOptions> | GenerateObjectDynamicOptions) { + if ("schema" in options) { + const { schema, ...rest } = options + return runGenerateObject( + rest, + makeTool({ + description: GENERATE_OBJECT_TOOL_DESCRIPTION, + parameters: schema, + success: Schema.Unknown as ToolSchema, + execute: () => Effect.void, + }), + ) + } + const { jsonSchema, ...rest } = options + return runGenerateObject( + rest, + makeTool({ + description: GENERATE_OBJECT_TOOL_DESCRIPTION, + jsonSchema, + execute: () => Effect.void, + }), + ) +} diff --git a/packages/llm/src/protocols/anthropic-messages.ts b/packages/llm/src/protocols/anthropic-messages.ts new file mode 100644 index 0000000000..d893888fd2 --- /dev/null +++ b/packages/llm/src/protocols/anthropic-messages.ts @@ -0,0 +1,659 @@ +import { Effect, Schema } from "effect" +import { Route } from "../route/client" +import { Auth } from "../route/auth" +import { Endpoint } from "../route/endpoint" +import { Framing } from "../route/framing" +import { Protocol } from "../route/protocol" +import { + LLMEvent, + Usage, + type CacheHint, + type FinishReason, + type LLMRequest, + type ProviderMetadata, + type ToolCallPart, + type ToolDefinition, + type ToolResultPart, +} from "../schema" +import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared" +import * as Cache from "./utils/cache" +import { ToolStream } from "./utils/tool-stream" + +const ADAPTER = "anthropic-messages" +export const DEFAULT_BASE_URL = "https://api.anthropic.com/v1" +export const PATH = "/messages" + +// ============================================================================= +// Request Body Schema +// ============================================================================= +const AnthropicCacheControl = Schema.Struct({ + type: Schema.tag("ephemeral"), + ttl: Schema.optional(Schema.Literals(["5m", "1h"])), +}) + +const AnthropicTextBlock = Schema.Struct({ + type: Schema.tag("text"), + text: Schema.String, + cache_control: Schema.optional(AnthropicCacheControl), +}) +type AnthropicTextBlock = Schema.Schema.Type + +const AnthropicThinkingBlock = Schema.Struct({ + type: Schema.tag("thinking"), + thinking: Schema.String, + signature: Schema.optional(Schema.String), + cache_control: Schema.optional(AnthropicCacheControl), +}) + +const AnthropicToolUseBlock = Schema.Struct({ + type: Schema.tag("tool_use"), + id: Schema.String, + name: Schema.String, + input: Schema.Unknown, + cache_control: Schema.optional(AnthropicCacheControl), +}) +type AnthropicToolUseBlock = Schema.Schema.Type + +const AnthropicServerToolUseBlock = Schema.Struct({ + type: Schema.tag("server_tool_use"), + id: Schema.String, + name: Schema.String, + input: Schema.Unknown, + cache_control: Schema.optional(AnthropicCacheControl), +}) +type AnthropicServerToolUseBlock = Schema.Schema.Type + +// Server tool result blocks: web_search_tool_result, code_execution_tool_result, +// and web_fetch_tool_result. The provider executes the tool and inlines the +// structured result into the assistant turn — there is no client tool_result +// round-trip. We round-trip the structured `content` payload as opaque JSON so +// the next request can echo it back when continuing the conversation. +const AnthropicServerToolResultType = Schema.Literals([ + "web_search_tool_result", + "code_execution_tool_result", + "web_fetch_tool_result", +]) +type AnthropicServerToolResultType = Schema.Schema.Type + +const AnthropicServerToolResultBlock = Schema.Struct({ + type: AnthropicServerToolResultType, + tool_use_id: Schema.String, + content: Schema.Unknown, + cache_control: Schema.optional(AnthropicCacheControl), +}) +type AnthropicServerToolResultBlock = Schema.Schema.Type + +const AnthropicToolResultBlock = Schema.Struct({ + type: Schema.tag("tool_result"), + tool_use_id: Schema.String, + content: Schema.String, + is_error: Schema.optional(Schema.Boolean), + cache_control: Schema.optional(AnthropicCacheControl), +}) + +const AnthropicUserBlock = Schema.Union([AnthropicTextBlock, AnthropicToolResultBlock]) +const AnthropicAssistantBlock = Schema.Union([ + AnthropicTextBlock, + AnthropicThinkingBlock, + AnthropicToolUseBlock, + AnthropicServerToolUseBlock, + AnthropicServerToolResultBlock, +]) +type AnthropicAssistantBlock = Schema.Schema.Type +type AnthropicToolResultBlock = Schema.Schema.Type + +const AnthropicMessage = Schema.Union([ + Schema.Struct({ role: Schema.Literal("user"), content: Schema.Array(AnthropicUserBlock) }), + Schema.Struct({ role: Schema.Literal("assistant"), content: Schema.Array(AnthropicAssistantBlock) }), +]).pipe(Schema.toTaggedUnion("role")) +type AnthropicMessage = Schema.Schema.Type + +const AnthropicTool = Schema.Struct({ + name: Schema.String, + description: Schema.String, + input_schema: JsonObject, + cache_control: Schema.optional(AnthropicCacheControl), +}) +type AnthropicTool = Schema.Schema.Type + +const AnthropicToolChoice = Schema.Union([ + Schema.Struct({ type: Schema.Literals(["auto", "any"]) }), + Schema.Struct({ type: Schema.tag("tool"), name: Schema.String }), +]) + +const AnthropicThinking = Schema.Struct({ + type: Schema.tag("enabled"), + budget_tokens: Schema.Number, +}) + +const AnthropicBodyFields = { + model: Schema.String, + system: optionalArray(AnthropicTextBlock), + messages: Schema.Array(AnthropicMessage), + tools: optionalArray(AnthropicTool), + tool_choice: Schema.optional(AnthropicToolChoice), + stream: Schema.Literal(true), + max_tokens: Schema.Number, + temperature: Schema.optional(Schema.Number), + top_p: Schema.optional(Schema.Number), + top_k: Schema.optional(Schema.Number), + stop_sequences: optionalArray(Schema.String), + thinking: Schema.optional(AnthropicThinking), +} +const AnthropicMessagesBody = Schema.Struct(AnthropicBodyFields) +export type AnthropicMessagesBody = Schema.Schema.Type + +const AnthropicUsage = Schema.Struct({ + 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), +}) +type AnthropicUsage = Schema.Schema.Type + +const AnthropicStreamBlock = Schema.Struct({ + type: Schema.String, + id: Schema.optional(Schema.String), + name: Schema.optional(Schema.String), + text: Schema.optional(Schema.String), + thinking: Schema.optional(Schema.String), + signature: Schema.optional(Schema.String), + input: Schema.optional(Schema.Unknown), + // *_tool_result blocks arrive whole as content_block_start (no streaming + // delta) with the structured payload in `content` and the originating + // server_tool_use id in `tool_use_id`. + tool_use_id: Schema.optional(Schema.String), + content: Schema.optional(Schema.Unknown), +}) + +const AnthropicStreamDelta = Schema.Struct({ + type: Schema.optional(Schema.String), + text: Schema.optional(Schema.String), + thinking: Schema.optional(Schema.String), + partial_json: Schema.optional(Schema.String), + signature: Schema.optional(Schema.String), + stop_reason: optionalNull(Schema.String), + stop_sequence: optionalNull(Schema.String), +}) + +const AnthropicEvent = Schema.Struct({ + type: Schema.String, + index: Schema.optional(Schema.Number), + message: Schema.optional(Schema.Struct({ usage: Schema.optional(AnthropicUsage) })), + content_block: Schema.optional(AnthropicStreamBlock), + delta: Schema.optional(AnthropicStreamDelta), + usage: Schema.optional(AnthropicUsage), + error: Schema.optional(Schema.Struct({ type: Schema.String, message: Schema.String })), +}) +type AnthropicEvent = Schema.Schema.Type + +interface ParserState { + readonly tools: ToolStream.State + readonly usage?: Usage +} + +const invalid = ProviderShared.invalidRequest + +// ============================================================================= +// Request Lowering +// ============================================================================= +// Anthropic accepts at most 4 explicit cache_control breakpoints per request, +// across `tools`, `system`, and `messages`. Beyond the cap the API returns a +// 400 — so the lowering layer counts emitted markers and silently drops any +// that exceed it. +const ANTHROPIC_BREAKPOINT_CAP = 4 + +const EPHEMERAL_5M = { type: "ephemeral" as const } +const EPHEMERAL_1H = { type: "ephemeral" as const, ttl: "1h" as const } + +const cacheControl = (breakpoints: Cache.Breakpoints, cache: CacheHint | undefined) => { + if (cache?.type !== "ephemeral" && cache?.type !== "persistent") return undefined + if (breakpoints.remaining <= 0) { + breakpoints.dropped += 1 + return undefined + } + breakpoints.remaining -= 1 + return Cache.ttlBucket(cache.ttlSeconds) === "1h" ? EPHEMERAL_1H : EPHEMERAL_5M +} + +const anthropicMetadata = (metadata: Record): ProviderMetadata => ({ anthropic: metadata }) + +const signatureFromMetadata = (metadata: ProviderMetadata | undefined): string | undefined => { + const anthropic = metadata?.anthropic + if (!ProviderShared.isRecord(anthropic)) return undefined + return typeof anthropic.signature === "string" ? anthropic.signature : undefined +} + +const lowerTool = (breakpoints: Cache.Breakpoints, tool: ToolDefinition): AnthropicTool => ({ + name: tool.name, + description: tool.description, + input_schema: tool.inputSchema, + cache_control: cacheControl(breakpoints, tool.cache), +}) + +const lowerToolChoice = (toolChoice: NonNullable) => + ProviderShared.matchToolChoice("Anthropic Messages", toolChoice, { + auto: () => ({ type: "auto" as const }), + none: () => undefined, + required: () => ({ type: "any" as const }), + tool: (name) => ({ type: "tool" as const, name }), + }) + +const lowerToolCall = (part: ToolCallPart): AnthropicToolUseBlock => ({ + type: "tool_use", + id: part.id, + name: part.name, + input: part.input, +}) + +const lowerServerToolCall = (part: ToolCallPart): AnthropicServerToolUseBlock => ({ + type: "server_tool_use", + id: part.id, + name: part.name, + input: part.input, +}) + +// Server tool result blocks are typed by name. Anthropic ships three today; +// extend this list when new server tools land. The block content is the +// structured payload returned by the provider, which we round-trip as-is. +const serverToolResultType = (name: string): AnthropicServerToolResultType | undefined => { + if (name === "web_search") return "web_search_tool_result" + if (name === "code_execution") return "code_execution_tool_result" + if (name === "web_fetch") return "web_fetch_tool_result" + return undefined +} + +const lowerServerToolResult = Effect.fn("AnthropicMessages.lowerServerToolResult")(function* (part: ToolResultPart) { + const wireType = serverToolResultType(part.name) + if (!wireType) + return yield* invalid(`Anthropic Messages does not know how to round-trip server tool result for ${part.name}`) + return { type: wireType, tool_use_id: part.id, content: part.result.value } satisfies AnthropicServerToolResultBlock +}) + +const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* ( + request: LLMRequest, + breakpoints: Cache.Breakpoints, +) { + const messages: AnthropicMessage[] = [] + + for (const message of request.messages) { + if (message.role === "user") { + const content: AnthropicTextBlock[] = [] + for (const part of message.content) { + if (!ProviderShared.supportsContent(part, ["text"])) + return yield* ProviderShared.unsupportedContent("Anthropic Messages", "user", ["text"]) + content.push({ type: "text", text: part.text, cache_control: cacheControl(breakpoints, part.cache) }) + } + messages.push({ role: "user", content }) + continue + } + + if (message.role === "assistant") { + const content: AnthropicAssistantBlock[] = [] + for (const part of message.content) { + if (part.type === "text") { + content.push({ type: "text", text: part.text, cache_control: cacheControl(breakpoints, part.cache) }) + continue + } + if (part.type === "reasoning") { + content.push({ + type: "thinking", + thinking: part.text, + signature: part.encrypted ?? signatureFromMetadata(part.providerMetadata), + }) + continue + } + if (part.type === "tool-call") { + content.push(part.providerExecuted ? lowerServerToolCall(part) : lowerToolCall(part)) + continue + } + if (part.type === "tool-result" && part.providerExecuted) { + content.push(yield* lowerServerToolResult(part)) + continue + } + return yield* invalid( + `Anthropic Messages assistant messages only support text, reasoning, and tool-call content for now`, + ) + } + messages.push({ role: "assistant", content }) + continue + } + + const content: AnthropicToolResultBlock[] = [] + for (const part of message.content) { + if (!ProviderShared.supportsContent(part, ["tool-result"])) + return yield* ProviderShared.unsupportedContent("Anthropic Messages", "tool", ["tool-result"]) + content.push({ + type: "tool_result", + tool_use_id: part.id, + content: ProviderShared.toolResultText(part), + is_error: part.result.type === "error" ? true : undefined, + cache_control: cacheControl(breakpoints, part.cache), + }) + } + messages.push({ role: "user", content }) + } + + return messages +}) + +const anthropicOptions = (request: LLMRequest) => request.providerOptions?.anthropic + +const lowerThinking = Effect.fn("AnthropicMessages.lowerThinking")(function* (request: LLMRequest) { + const thinking = anthropicOptions(request)?.thinking + if (!ProviderShared.isRecord(thinking) || thinking.type !== "enabled") return undefined + const budget = + typeof thinking.budgetTokens === "number" + ? thinking.budgetTokens + : typeof thinking.budget_tokens === "number" + ? thinking.budget_tokens + : undefined + if (budget === undefined) return yield* invalid("Anthropic thinking provider option requires budgetTokens") + return { type: "enabled" as const, budget_tokens: budget } +}) + +const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (request: LLMRequest) { + const toolChoice = request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined + const generation = request.generation + // 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. + const breakpoints = Cache.newBreakpoints(ANTHROPIC_BREAKPOINT_CAP) + const tools = + request.tools.length === 0 || request.toolChoice?.type === "none" + ? undefined + : request.tools.map((tool) => lowerTool(breakpoints, tool)) + const system = + request.system.length === 0 + ? undefined + : request.system.map((part) => ({ + type: "text" as const, + text: part.text, + cache_control: cacheControl(breakpoints, part.cache), + })) + const messages = yield* lowerMessages(request, breakpoints) + if (breakpoints.dropped > 0) { + yield* Effect.logWarning( + `Anthropic Messages: dropped ${breakpoints.dropped} cache breakpoint(s); the API allows at most ${ANTHROPIC_BREAKPOINT_CAP} per request.`, + ) + } + return { + model: request.model.id, + system, + messages, + tools, + tool_choice: toolChoice, + stream: true as const, + max_tokens: generation?.maxTokens ?? request.model.limits.output ?? 4096, + temperature: generation?.temperature, + top_p: generation?.topP, + top_k: generation?.topK, + stop_sequences: generation?.stop, + thinking: yield* lowerThinking(request), + } +}) + +// ============================================================================= +// Stream Parsing +// ============================================================================= +const mapFinishReason = (reason: string | null | undefined): FinishReason => { + if (reason === "end_turn" || reason === "stop_sequence" || reason === "pause_turn") return "stop" + if (reason === "max_tokens") return "length" + if (reason === "tool_use") return "tool-calls" + if (reason === "refusal") return "content-filter" + return "unknown" +} + +// Anthropic reports the non-overlapping breakdown natively — its +// `input_tokens` is the *non-cached* count per the Messages API docs, with +// cache reads and writes as separate fields. We sum them to derive the +// inclusive `inputTokens` the rest of the contract expects. Extended +// thinking tokens are *not* broken out by Anthropic — they're billed as +// part of `output_tokens`, so `reasoningTokens` stays `undefined` and +// `outputTokens` carries the combined total. +const mapUsage = (usage: AnthropicUsage | undefined): Usage | undefined => { + if (!usage) return 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) + return new Usage({ + inputTokens, + outputTokens: usage.output_tokens, + nonCachedInputTokens: nonCached, + cacheReadInputTokens: cacheRead, + cacheWriteInputTokens: cacheWrite, + totalTokens: ProviderShared.totalTokens(inputTokens, usage.output_tokens, undefined), + providerMetadata: { anthropic: usage }, + }) +} + +// Anthropic emits usage on `message_start` and again on `message_delta` — the +// final delta carries the authoritative totals. Right-biased merge: each +// field prefers `right` when defined, falls back to `left`. `inputTokens` is +// recomputed from the merged breakdown so the inclusive total stays +// consistent with `nonCached + cacheRead + cacheWrite`. +const mergeUsage = (left: Usage | undefined, right: Usage | undefined) => { + if (!left) return right + if (!right) return left + const nonCachedInputTokens = right.nonCachedInputTokens ?? left.nonCachedInputTokens + const cacheReadInputTokens = right.cacheReadInputTokens ?? left.cacheReadInputTokens + const cacheWriteInputTokens = right.cacheWriteInputTokens ?? left.cacheWriteInputTokens + const inputTokens = ProviderShared.sumTokens(nonCachedInputTokens, cacheReadInputTokens, cacheWriteInputTokens) + const outputTokens = right.outputTokens ?? left.outputTokens + return new Usage({ + inputTokens, + outputTokens, + nonCachedInputTokens, + cacheReadInputTokens, + cacheWriteInputTokens, + totalTokens: ProviderShared.totalTokens(inputTokens, outputTokens, undefined), + providerMetadata: { + anthropic: { + ...(left.providerMetadata?.["anthropic"] ?? {}), + ...(right.providerMetadata?.["anthropic"] ?? {}), + }, + }, + }) +} + +// Server tool result blocks come whole in `content_block_start` (no streaming +// delta sequence). We convert the payload to a `tool-result` event with +// `providerExecuted: true`. The runtime appends it to the assistant message +// for round-trip; downstream consumers can inspect `result.value` for the +// structured payload. +const SERVER_TOOL_RESULT_NAMES: Record = { + web_search_tool_result: "web_search", + code_execution_tool_result: "code_execution", + web_fetch_tool_result: "web_fetch", +} + +const isServerToolResultType = (type: string): type is AnthropicServerToolResultType => type in SERVER_TOOL_RESULT_NAMES + +const serverToolResultEvent = (block: NonNullable): LLMEvent | undefined => { + if (!block.type || !isServerToolResultType(block.type)) return undefined + const errorPayload = + typeof block.content === "object" && block.content !== null && "type" in block.content + ? String((block.content as Record).type) + : "" + const isError = errorPayload.endsWith("_tool_result_error") + return LLMEvent.toolResult({ + id: block.tool_use_id ?? "", + name: SERVER_TOOL_RESULT_NAMES[block.type], + result: isError ? { type: "error", value: block.content } : { type: "json", value: block.content }, + providerExecuted: true, + providerMetadata: anthropicMetadata({ blockType: block.type }), + }) +} + +type StepResult = readonly [ParserState, ReadonlyArray] + +const NO_EVENTS: StepResult["1"] = [] + +const onMessageStart = (state: ParserState, event: AnthropicEvent): StepResult => { + const usage = mapUsage(event.message?.usage) + return [usage ? { ...state, usage: mergeUsage(state.usage, usage) } : state, NO_EVENTS] +} + +const onContentBlockStart = (state: ParserState, event: AnthropicEvent): StepResult => { + const block = event.content_block + if (!block) return [state, NO_EVENTS] + + if ((block.type === "tool_use" || block.type === "server_tool_use") && event.index !== undefined) { + return [ + { + ...state, + tools: ToolStream.start(state.tools, event.index, { + id: block.id ?? String(event.index), + name: block.name ?? "", + providerExecuted: block.type === "server_tool_use", + }), + }, + NO_EVENTS, + ] + } + + if (block.type === "text" && block.text) { + return [state, [LLMEvent.textDelta({ id: `text-${event.index ?? 0}`, text: block.text })]] + } + + if (block.type === "thinking" && block.thinking) { + return [ + state, + [ + LLMEvent.reasoningDelta({ + id: `reasoning-${event.index ?? 0}`, + text: block.thinking, + }), + ], + ] + } + + const result = serverToolResultEvent(block) + return [state, result ? [result] : NO_EVENTS] +} + +const onContentBlockDelta = Effect.fn("AnthropicMessages.onContentBlockDelta")(function* ( + state: ParserState, + event: AnthropicEvent, +) { + const delta = event.delta + + if (delta?.type === "text_delta" && delta.text) { + return [state, [LLMEvent.textDelta({ id: `text-${event.index ?? 0}`, text: delta.text })]] satisfies StepResult + } + + if (delta?.type === "thinking_delta" && delta.thinking) { + return [ + state, + [LLMEvent.reasoningDelta({ id: `reasoning-${event.index ?? 0}`, text: delta.thinking })], + ] satisfies StepResult + } + + if (delta?.type === "signature_delta" && delta.signature) { + return [ + state, + [ + LLMEvent.reasoningEnd({ + id: `reasoning-${event.index ?? 0}`, + providerMetadata: anthropicMetadata({ signature: delta.signature }), + }), + ], + ] satisfies StepResult + } + + if (delta?.type === "input_json_delta" && event.index !== undefined) { + if (!delta.partial_json) return [state, NO_EVENTS] satisfies StepResult + const result = ToolStream.appendExisting( + ADAPTER, + state.tools, + event.index, + delta.partial_json, + "Anthropic Messages tool argument delta is missing its tool call", + ) + if (ToolStream.isError(result)) return yield* result + return [{ ...state, tools: result.tools }, result.event ? [result.event] : NO_EVENTS] satisfies StepResult + } + + return [state, NO_EVENTS] satisfies StepResult +}) + +const onContentBlockStop = Effect.fn("AnthropicMessages.onContentBlockStop")(function* ( + state: ParserState, + event: AnthropicEvent, +) { + if (event.index === undefined) return [state, NO_EVENTS] satisfies StepResult + const result = yield* ToolStream.finish(ADAPTER, state.tools, event.index) + return [{ ...state, tools: result.tools }, result.event ? [result.event] : NO_EVENTS] satisfies StepResult +}) + +const onMessageDelta = (state: ParserState, event: AnthropicEvent): StepResult => { + const usage = mergeUsage(state.usage, mapUsage(event.usage)) + return [ + { ...state, usage }, + [ + LLMEvent.requestFinish({ + reason: mapFinishReason(event.delta?.stop_reason), + usage, + providerMetadata: event.delta?.stop_sequence + ? anthropicMetadata({ stopSequence: event.delta.stop_sequence }) + : undefined, + }), + ], + ] +} + +const onError = (state: ParserState, event: AnthropicEvent): StepResult => [ + state, + [LLMEvent.providerError({ message: event.error?.message ?? "Anthropic Messages stream error" })], +] + +const step = (state: ParserState, event: AnthropicEvent) => { + if (event.type === "message_start") return Effect.succeed(onMessageStart(state, event)) + if (event.type === "content_block_start") return Effect.succeed(onContentBlockStart(state, event)) + if (event.type === "content_block_delta") return onContentBlockDelta(state, event) + if (event.type === "content_block_stop") return onContentBlockStop(state, event) + if (event.type === "message_delta") return Effect.succeed(onMessageDelta(state, event)) + if (event.type === "error") return Effect.succeed(onError(state, event)) + return Effect.succeed([state, NO_EVENTS]) +} + +// ============================================================================= +// Protocol And Anthropic Route +// ============================================================================= +/** + * The Anthropic Messages protocol — request body construction, body schema, + * and the streaming-event state machine. Used by native Anthropic Cloud and + * (once registered) Vertex Anthropic / Bedrock-hosted Anthropic passthrough. + */ +export const protocol = Protocol.make({ + id: ADAPTER, + body: { + schema: AnthropicMessagesBody, + from: fromRequest, + }, + stream: { + event: Protocol.jsonEvent(AnthropicEvent), + initial: () => ({ tools: ToolStream.empty() }), + step, + }, +}) + +export const route = Route.make({ + id: ADAPTER, + protocol, + endpoint: Endpoint.path(PATH), + auth: Auth.apiKeyHeader("x-api-key"), + framing: Framing.sse, + headers: () => ({ "anthropic-version": "2023-06-01" }), +}) + +// ============================================================================= +// Model Helper +// ============================================================================= +export const model = Route.model(route, { + provider: "anthropic", + baseURL: DEFAULT_BASE_URL, +}) + +export * as AnthropicMessages from "./anthropic-messages" diff --git a/packages/llm/src/protocols/bedrock-converse.ts b/packages/llm/src/protocols/bedrock-converse.ts new file mode 100644 index 0000000000..f561a6d7c5 --- /dev/null +++ b/packages/llm/src/protocols/bedrock-converse.ts @@ -0,0 +1,580 @@ +import { Effect, Schema } from "effect" +import { Route, type RouteModelInput } from "../route/client" +import { Endpoint } from "../route/endpoint" +import { Protocol } from "../route/protocol" +import { + LLMEvent, + Usage, + type CacheHint, + type FinishReason, + type LLMRequest, + type ToolCallPart, + type ToolDefinition, + type ToolResultPart, +} from "../schema" +import { BedrockEventStream } from "./bedrock-event-stream" +import { JsonObject, optionalArray, ProviderShared } from "./shared" +import { BedrockAuth, type Credentials as BedrockCredentials } from "./utils/bedrock-auth" +import { BedrockCache } from "./utils/bedrock-cache" +import { BedrockMedia } from "./utils/bedrock-media" +import { ToolStream } from "./utils/tool-stream" + +const ADAPTER = "bedrock-converse" + +export type { Credentials as BedrockCredentials } from "./utils/bedrock-auth" + +// ============================================================================= +// Public Model Input +// ============================================================================= +export type BedrockConverseModelInput = RouteModelInput & { + /** + * Bearer API key (Bedrock's newer API key auth). Sets the `Authorization` + * header and bypasses SigV4 signing. Mutually exclusive with `credentials`. + */ + readonly apiKey?: string + /** + * AWS credentials for SigV4 signing. The route signs each request at + * `toHttp` time using `aws4fetch`. Mutually exclusive with `apiKey`. + */ + readonly credentials?: BedrockCredentials + readonly headers?: Record +} + +// ============================================================================= +// Request Body Schema +// ============================================================================= +const BedrockTextBlock = Schema.Struct({ + text: Schema.String, +}) +type BedrockTextBlock = Schema.Schema.Type + +const BedrockToolUseBlock = Schema.Struct({ + toolUse: Schema.Struct({ + toolUseId: Schema.String, + name: Schema.String, + input: Schema.Unknown, + }), +}) +type BedrockToolUseBlock = Schema.Schema.Type + +const BedrockToolResultContentItem = Schema.Union([ + Schema.Struct({ text: Schema.String }), + Schema.Struct({ json: Schema.Unknown }), +]) + +const BedrockToolResultBlock = Schema.Struct({ + toolResult: Schema.Struct({ + toolUseId: Schema.String, + content: Schema.Array(BedrockToolResultContentItem), + status: Schema.optional(Schema.Literals(["success", "error"])), + }), +}) +type BedrockToolResultBlock = Schema.Schema.Type + +const BedrockReasoningBlock = Schema.Struct({ + reasoningContent: Schema.Struct({ + reasoningText: Schema.optional( + Schema.Struct({ + text: Schema.String, + signature: Schema.optional(Schema.String), + }), + ), + }), +}) + +const BedrockUserBlock = Schema.Union([ + BedrockTextBlock, + BedrockMedia.ImageBlock, + BedrockMedia.DocumentBlock, + BedrockToolResultBlock, + BedrockCache.CachePointBlock, +]) +type BedrockUserBlock = Schema.Schema.Type + +const BedrockAssistantBlock = Schema.Union([ + BedrockTextBlock, + BedrockReasoningBlock, + BedrockToolUseBlock, + BedrockCache.CachePointBlock, +]) +type BedrockAssistantBlock = Schema.Schema.Type + +const BedrockMessage = Schema.Union([ + Schema.Struct({ role: Schema.Literal("user"), content: Schema.Array(BedrockUserBlock) }), + Schema.Struct({ role: Schema.Literal("assistant"), content: Schema.Array(BedrockAssistantBlock) }), +]).pipe(Schema.toTaggedUnion("role")) +type BedrockMessage = Schema.Schema.Type + +const BedrockSystemBlock = Schema.Union([BedrockTextBlock, BedrockCache.CachePointBlock]) +type BedrockSystemBlock = Schema.Schema.Type + +const BedrockToolSpec = Schema.Struct({ + toolSpec: Schema.Struct({ + name: Schema.String, + description: Schema.String, + inputSchema: Schema.Struct({ + json: JsonObject, + }), + }), +}) +type BedrockToolSpec = Schema.Schema.Type + +const BedrockTool = Schema.Union([BedrockToolSpec, BedrockCache.CachePointBlock]) +type BedrockTool = Schema.Schema.Type + +const BedrockToolChoice = Schema.Union([ + Schema.Struct({ auto: Schema.Struct({}) }), + Schema.Struct({ any: Schema.Struct({}) }), + Schema.Struct({ tool: Schema.Struct({ name: Schema.String }) }), +]) + +const BedrockBodyFields = { + modelId: Schema.String, + messages: Schema.Array(BedrockMessage), + system: optionalArray(BedrockSystemBlock), + inferenceConfig: Schema.optional( + Schema.Struct({ + maxTokens: Schema.optional(Schema.Number), + temperature: Schema.optional(Schema.Number), + topP: Schema.optional(Schema.Number), + stopSequences: optionalArray(Schema.String), + }), + ), + toolConfig: Schema.optional( + Schema.Struct({ + tools: Schema.Array(BedrockTool), + toolChoice: Schema.optional(BedrockToolChoice), + }), + ), + additionalModelRequestFields: Schema.optional(JsonObject), +} +const BedrockConverseBody = Schema.Struct(BedrockBodyFields) +export type BedrockConverseBody = Schema.Schema.Type + +const BedrockUsageSchema = Schema.Struct({ + inputTokens: Schema.optional(Schema.Number), + outputTokens: Schema.optional(Schema.Number), + totalTokens: Schema.optional(Schema.Number), + cacheReadInputTokens: Schema.optional(Schema.Number), + cacheWriteInputTokens: Schema.optional(Schema.Number), +}) +type BedrockUsageSchema = Schema.Schema.Type + +// Streaming event shape — the AWS event stream wraps each JSON payload by its +// `:event-type` header (e.g. `messageStart`, `contentBlockDelta`). We +// reconstruct that wrapping in `decodeFrames` below so the event schema can +// stay a plain discriminated record. +const BedrockEvent = Schema.Struct({ + messageStart: Schema.optional(Schema.Struct({ role: Schema.String })), + contentBlockStart: Schema.optional( + Schema.Struct({ + contentBlockIndex: Schema.Number, + start: Schema.optional( + Schema.Struct({ + toolUse: Schema.optional(Schema.Struct({ toolUseId: Schema.String, name: Schema.String })), + }), + ), + }), + ), + contentBlockDelta: Schema.optional( + Schema.Struct({ + contentBlockIndex: Schema.Number, + delta: Schema.optional( + Schema.Struct({ + text: Schema.optional(Schema.String), + toolUse: Schema.optional(Schema.Struct({ input: Schema.String })), + reasoningContent: Schema.optional( + Schema.Struct({ + text: Schema.optional(Schema.String), + signature: Schema.optional(Schema.String), + }), + ), + }), + ), + }), + ), + contentBlockStop: Schema.optional(Schema.Struct({ contentBlockIndex: Schema.Number })), + messageStop: Schema.optional( + Schema.Struct({ + stopReason: Schema.String, + additionalModelResponseFields: Schema.optional(Schema.Unknown), + }), + ), + metadata: Schema.optional( + Schema.Struct({ + usage: Schema.optional(BedrockUsageSchema), + metrics: Schema.optional(Schema.Unknown), + }), + ), + internalServerException: Schema.optional(Schema.Struct({ message: Schema.String })), + modelStreamErrorException: Schema.optional(Schema.Struct({ message: Schema.String })), + validationException: Schema.optional(Schema.Struct({ message: Schema.String })), + throttlingException: Schema.optional(Schema.Struct({ message: Schema.String })), + serviceUnavailableException: Schema.optional(Schema.Struct({ message: Schema.String })), +}) +type BedrockEvent = Schema.Schema.Type + +// ============================================================================= +// Request Lowering +// ============================================================================= +const lowerToolSpec = (tool: ToolDefinition): BedrockToolSpec => ({ + toolSpec: { + name: tool.name, + description: tool.description, + inputSchema: { json: tool.inputSchema }, + }, +}) + +const lowerTools = (breakpoints: BedrockCache.Breakpoints, tools: ReadonlyArray): BedrockTool[] => { + const result: BedrockTool[] = [] + for (const tool of tools) { + result.push(lowerToolSpec(tool)) + const cachePoint = BedrockCache.block(breakpoints, tool.cache) + if (cachePoint) result.push(cachePoint) + } + return result +} + +const textWithCache = ( + breakpoints: BedrockCache.Breakpoints, + text: string, + cache: CacheHint | undefined, +): Array => { + const cachePoint = BedrockCache.block(breakpoints, cache) + return cachePoint ? [{ text }, cachePoint] : [{ text }] +} + +const lowerToolChoice = (toolChoice: NonNullable) => + ProviderShared.matchToolChoice("Bedrock Converse", toolChoice, { + auto: () => ({ auto: {} }) as const, + none: () => undefined, + required: () => ({ any: {} }) as const, + tool: (name) => ({ tool: { name } }) as const, + }) + +const lowerToolCall = (part: ToolCallPart): BedrockToolUseBlock => ({ + toolUse: { + toolUseId: part.id, + name: part.name, + input: part.input, + }, +}) + +const lowerToolResult = (part: ToolResultPart): BedrockToolResultBlock => ({ + toolResult: { + toolUseId: part.id, + content: + part.result.type === "text" || part.result.type === "error" + ? [{ text: ProviderShared.toolResultText(part) }] + : [{ json: part.result.value }], + status: part.result.type === "error" ? "error" : "success", + }, +}) + +const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* ( + request: LLMRequest, + breakpoints: BedrockCache.Breakpoints, +) { + const messages: BedrockMessage[] = [] + + for (const message of request.messages) { + if (message.role === "user") { + const content: BedrockUserBlock[] = [] + for (const part of message.content) { + if (!ProviderShared.supportsContent(part, ["text", "media"])) + return yield* ProviderShared.unsupportedContent("Bedrock Converse", "user", ["text", "media"]) + if (part.type === "text") { + content.push(...textWithCache(breakpoints, part.text, part.cache)) + continue + } + if (part.type === "media") { + content.push(yield* BedrockMedia.lower(part)) + continue + } + } + messages.push({ role: "user", content }) + continue + } + + if (message.role === "assistant") { + const content: BedrockAssistantBlock[] = [] + for (const part of message.content) { + if (!ProviderShared.supportsContent(part, ["text", "reasoning", "tool-call"])) + return yield* ProviderShared.unsupportedContent("Bedrock Converse", "assistant", [ + "text", + "reasoning", + "tool-call", + ]) + if (part.type === "text") { + content.push(...textWithCache(breakpoints, part.text, part.cache)) + continue + } + if (part.type === "reasoning") { + content.push({ + reasoningContent: { + reasoningText: { text: part.text, signature: part.encrypted }, + }, + }) + continue + } + if (part.type === "tool-call") { + content.push(lowerToolCall(part)) + continue + } + } + messages.push({ role: "assistant", content }) + continue + } + + const content: BedrockUserBlock[] = [] + for (const part of message.content) { + if (!ProviderShared.supportsContent(part, ["tool-result"])) + return yield* ProviderShared.unsupportedContent("Bedrock Converse", "tool", ["tool-result"]) + content.push(lowerToolResult(part)) + const cachePoint = BedrockCache.block(breakpoints, part.cache) + if (cachePoint) content.push(cachePoint) + } + messages.push({ role: "user", content }) + } + + return messages +}) + +// System prompts share the cache-point convention: emit the text block, then +// optionally a positional `cachePoint` marker. +const lowerSystem = ( + breakpoints: BedrockCache.Breakpoints, + system: ReadonlyArray, +): BedrockSystemBlock[] => system.flatMap((part) => textWithCache(breakpoints, part.text, part.cache)) + +const fromRequest = Effect.fn("BedrockConverse.fromRequest")(function* (request: LLMRequest) { + const toolChoice = request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined + const generation = request.generation + // Bedrock-Claude shares Anthropic's 4-breakpoint cap. Spend the budget in + // tools → system → messages order to favour the highest-impact prefixes. + const breakpoints = BedrockCache.breakpoints() + const toolConfig = + request.tools.length > 0 && request.toolChoice?.type !== "none" + ? { tools: lowerTools(breakpoints, request.tools), toolChoice } + : undefined + const system = request.system.length === 0 ? undefined : lowerSystem(breakpoints, request.system) + const messages = yield* lowerMessages(request, breakpoints) + if (breakpoints.dropped > 0) { + yield* Effect.logWarning( + `Bedrock Converse: dropped ${breakpoints.dropped} cache breakpoint(s); the API allows at most ${BedrockCache.BEDROCK_BREAKPOINT_CAP} per request.`, + ) + } + return { + modelId: request.model.id, + messages, + system, + inferenceConfig: + generation?.maxTokens === undefined && + generation?.temperature === undefined && + generation?.topP === undefined && + (generation?.stop === undefined || generation.stop.length === 0) + ? undefined + : { + maxTokens: generation?.maxTokens, + temperature: generation?.temperature, + topP: generation?.topP, + stopSequences: generation?.stop, + }, + toolConfig, + } +}) + +// ============================================================================= +// Stream Parsing +// ============================================================================= +const mapFinishReason = (reason: string): FinishReason => { + if (reason === "end_turn" || reason === "stop_sequence") return "stop" + if (reason === "max_tokens") return "length" + if (reason === "tool_use") return "tool-calls" + if (reason === "content_filtered" || reason === "guardrail_intervened") return "content-filter" + return "unknown" +} + +// AWS Bedrock Converse reports `inputTokens` (inclusive total) with +// `cacheReadInputTokens` and `cacheWriteInputTokens` as subsets. Pass +// the total through and derive the non-cached breakdown. Bedrock does +// not break reasoning out of `outputTokens` for any current model. +const mapUsage = (usage: BedrockUsageSchema | undefined): Usage | undefined => { + if (!usage) return undefined + const cacheTotal = (usage.cacheReadInputTokens ?? 0) + (usage.cacheWriteInputTokens ?? 0) + const nonCached = ProviderShared.subtractTokens(usage.inputTokens, cacheTotal) + return new Usage({ + inputTokens: usage.inputTokens, + outputTokens: usage.outputTokens, + nonCachedInputTokens: nonCached, + cacheReadInputTokens: usage.cacheReadInputTokens, + cacheWriteInputTokens: usage.cacheWriteInputTokens, + totalTokens: ProviderShared.totalTokens(usage.inputTokens, usage.outputTokens, usage.totalTokens), + providerMetadata: { bedrock: usage }, + }) +} + +interface ParserState { + readonly tools: ToolStream.State + // Bedrock splits the finish into `messageStop` (carries `stopReason`) and + // `metadata` (carries usage). Hold the terminal event in state so `onHalt` + // can emit exactly one finish after both chunks have had a chance to arrive. + readonly pendingFinish: { readonly reason: FinishReason; readonly usage?: Usage } | undefined +} + +const step = (state: ParserState, event: BedrockEvent) => + Effect.gen(function* () { + if (event.contentBlockStart?.start?.toolUse) { + const index = event.contentBlockStart.contentBlockIndex + return [ + { + ...state, + tools: ToolStream.start(state.tools, index, { + id: event.contentBlockStart.start.toolUse.toolUseId, + name: event.contentBlockStart.start.toolUse.name, + }), + }, + [], + ] as const + } + + if (event.contentBlockDelta?.delta?.text) { + return [ + state, + [ + LLMEvent.textDelta({ + id: `text-${event.contentBlockDelta.contentBlockIndex}`, + text: event.contentBlockDelta.delta.text, + }), + ], + ] as const + } + + if (event.contentBlockDelta?.delta?.reasoningContent?.text) { + return [ + state, + [ + LLMEvent.reasoningDelta({ + id: `reasoning-${event.contentBlockDelta.contentBlockIndex}`, + text: event.contentBlockDelta.delta.reasoningContent.text, + }), + ], + ] as const + } + + if (event.contentBlockDelta?.delta?.toolUse) { + const index = event.contentBlockDelta.contentBlockIndex + const result = ToolStream.appendExisting( + ADAPTER, + state.tools, + index, + event.contentBlockDelta.delta.toolUse.input, + "Bedrock Converse tool delta is missing its tool call", + ) + if (ToolStream.isError(result)) return yield* result + return [{ ...state, tools: result.tools }, result.event ? [result.event] : []] as const + } + + if (event.contentBlockStop) { + const result = yield* ToolStream.finish(ADAPTER, state.tools, event.contentBlockStop.contentBlockIndex) + return [{ ...state, tools: result.tools }, result.event ? [result.event] : []] as const + } + + if (event.messageStop) { + return [ + { + ...state, + pendingFinish: { reason: mapFinishReason(event.messageStop.stopReason), usage: state.pendingFinish?.usage }, + }, + [], + ] as const + } + + if (event.metadata) { + const usage = mapUsage(event.metadata.usage) + return [{ ...state, pendingFinish: { reason: state.pendingFinish?.reason ?? "stop", usage } }, []] as const + } + + if (event.internalServerException || event.modelStreamErrorException || event.serviceUnavailableException) { + const message = + event.internalServerException?.message ?? + event.modelStreamErrorException?.message ?? + event.serviceUnavailableException?.message ?? + "Bedrock Converse stream error" + return [state, [LLMEvent.providerError({ message, retryable: true })]] as const + } + + if (event.validationException || event.throttlingException) { + const message = + event.validationException?.message ?? event.throttlingException?.message ?? "Bedrock Converse error" + return [state, [LLMEvent.providerError({ message, retryable: event.throttlingException !== undefined })]] as const + } + + return [state, []] as const + }) + +const framing = BedrockEventStream.framing(ADAPTER) + +const onHalt = (state: ParserState): ReadonlyArray => + state.pendingFinish + ? [LLMEvent.requestFinish({ reason: state.pendingFinish.reason, usage: state.pendingFinish.usage })] + : [] + +// ============================================================================= +// Protocol And Bedrock Route +// ============================================================================= +/** + * The Bedrock Converse protocol — request body construction, body schema, and + * the streaming-event state machine. + */ +export const protocol = Protocol.make({ + id: ADAPTER, + body: { + schema: BedrockConverseBody, + from: fromRequest, + }, + stream: { + event: BedrockEvent, + initial: () => ({ tools: ToolStream.empty(), pendingFinish: undefined }), + step, + onHalt, + }, +}) + +export const route = Route.make({ + id: ADAPTER, + protocol, + // Bedrock's URL embeds the region in the host (set on `model.baseURL` by + // the provider helper from credentials) and the validated modelId in the + // path. We read the validated body so the URL matches the body that gets + // signed. + endpoint: Endpoint.path( + ({ body }) => `/model/${encodeURIComponent(body.modelId)}/converse-stream`, + ), + auth: BedrockAuth.auth, + framing, +}) + +export const nativeCredentials = BedrockAuth.nativeCredentials + +const bedrockModel = Route.model( + route, + { + provider: "bedrock", + }, + { + mapInput: (input: BedrockConverseModelInput) => { + const { credentials, ...rest } = input + const region = credentials?.region ?? "us-east-1" + return { + ...rest, + baseURL: rest.baseURL ?? `https://bedrock-runtime.${region}.amazonaws.com`, + native: nativeCredentials(input.native, credentials), + } + }, + }, +) + +export const model = bedrockModel + +export * as BedrockConverse from "./bedrock-converse" diff --git a/packages/llm/src/protocols/bedrock-event-stream.ts b/packages/llm/src/protocols/bedrock-event-stream.ts new file mode 100644 index 0000000000..d07d7de475 --- /dev/null +++ b/packages/llm/src/protocols/bedrock-event-stream.ts @@ -0,0 +1,87 @@ +import { EventStreamCodec } from "@smithy/eventstream-codec" +import { fromUtf8, toUtf8 } from "@smithy/util-utf8" +import { Effect, Stream } from "effect" +import type { Framing } from "../route/framing" +import { ProviderShared } from "./shared" + +// Bedrock streams responses using the AWS event stream binary protocol — each +// frame is `[length:4][headers-length:4][prelude-crc:4][headers][payload][crc:4]`. +// We use `@smithy/eventstream-codec` to validate framing and CRCs, then +// reconstruct the JSON wrapping by `:event-type` so the chunk schema can match. +const eventCodec = new EventStreamCodec(toUtf8, fromUtf8) +const utf8 = new TextDecoder() + +// Cursor-tracking buffer state. Bytes accumulate in `buffer`; `offset` is the +// read position. Reading by `subarray` is zero-copy. We only allocate a fresh +// buffer when a new network chunk arrives and we need to append. +interface FrameBufferState { + readonly buffer: Uint8Array + readonly offset: number +} + +const initialFrameBuffer: FrameBufferState = { buffer: new Uint8Array(0), offset: 0 } + +const appendChunk = (state: FrameBufferState, chunk: Uint8Array): FrameBufferState => { + const remaining = state.buffer.length - state.offset + // Compact: drop the consumed prefix and append the new chunk in one alloc. + // This bounds buffer growth to at most one network chunk past the live + // window, regardless of stream length. + const next = new Uint8Array(remaining + chunk.length) + next.set(state.buffer.subarray(state.offset), 0) + next.set(chunk, remaining) + return { buffer: next, offset: 0 } +} + +const consumeFrames = (route: string) => (state: FrameBufferState, chunk: Uint8Array) => + Effect.gen(function* () { + let cursor = appendChunk(state, chunk) + const out: object[] = [] + while (cursor.buffer.length - cursor.offset >= 4) { + const view = cursor.buffer.subarray(cursor.offset) + const totalLength = new DataView(view.buffer, view.byteOffset, view.byteLength).getUint32(0, false) + if (view.length < totalLength) break + + const decoded = yield* Effect.try({ + try: () => eventCodec.decode(view.subarray(0, totalLength)), + catch: (error) => + ProviderShared.eventError( + route, + `Failed to decode Bedrock Converse event-stream frame: ${ + error instanceof Error ? error.message : String(error) + }`, + ), + }) + cursor = { buffer: cursor.buffer, offset: cursor.offset + totalLength } + + if (decoded.headers[":message-type"]?.value !== "event") continue + const eventType = decoded.headers[":event-type"]?.value + if (typeof eventType !== "string") continue + const payload = utf8.decode(decoded.body) + if (!payload) continue + // The AWS event stream pads short payloads with a `p` field. Drop it + // before handing the object to the chunk schema. JSON decode goes + // through the shared Schema-driven codec to satisfy the package rule + // against ad-hoc `JSON.parse` calls. + const parsed = (yield* ProviderShared.parseJson( + route, + payload, + "Failed to parse Bedrock Converse event-stream payload", + )) as Record + delete parsed.p + out.push({ [eventType]: parsed }) + } + return [cursor, out] as const + }) + +/** + * AWS event-stream framing for Bedrock Converse. Each frame is decoded by + * `@smithy/eventstream-codec` (length + header + payload + CRC) and rewrapped + * under its `:event-type` header so the chunk schema can match the JSON + * payload directly. + */ +export const framing = (route: string): Framing => ({ + id: "aws-event-stream", + frame: (bytes) => bytes.pipe(Stream.mapAccumEffect(() => initialFrameBuffer, consumeFrames(route))), +}) + +export * as BedrockEventStream from "./bedrock-event-stream" diff --git a/packages/llm/src/protocols/gemini.ts b/packages/llm/src/protocols/gemini.ts new file mode 100644 index 0000000000..0ee88f3beb --- /dev/null +++ b/packages/llm/src/protocols/gemini.ts @@ -0,0 +1,414 @@ +import { Effect, Schema } from "effect" +import { Route } from "../route/client" +import { Auth } from "../route/auth" +import { Endpoint } from "../route/endpoint" +import { Framing } from "../route/framing" +import { Protocol } from "../route/protocol" +import { + LLMEvent, + Usage, + type FinishReason, + type LLMRequest, + type MediaPart, + type TextPart, + type ToolCallPart, + type ToolDefinition, +} from "../schema" +import { JsonObject, optionalArray, ProviderShared } from "./shared" +import { GeminiToolSchema } from "./utils/gemini-tool-schema" + +const ADAPTER = "gemini" +export const DEFAULT_BASE_URL = "https://generativelanguage.googleapis.com/v1beta" + +// ============================================================================= +// Request Body Schema +// ============================================================================= +const GeminiTextPart = Schema.Struct({ + text: Schema.String, + thought: Schema.optional(Schema.Boolean), + thoughtSignature: Schema.optional(Schema.String), +}) + +const GeminiInlineDataPart = Schema.Struct({ + inlineData: Schema.Struct({ + mimeType: Schema.String, + data: Schema.String, + }), +}) + +const GeminiFunctionCallPart = Schema.Struct({ + functionCall: Schema.Struct({ + name: Schema.String, + args: Schema.Unknown, + }), + thoughtSignature: Schema.optional(Schema.String), +}) + +const GeminiFunctionResponsePart = Schema.Struct({ + functionResponse: Schema.Struct({ + name: Schema.String, + response: Schema.Unknown, + }), +}) + +const GeminiContentPart = Schema.Union([ + GeminiTextPart, + GeminiInlineDataPart, + GeminiFunctionCallPart, + GeminiFunctionResponsePart, +]) + +const GeminiContent = Schema.Struct({ + role: Schema.Literals(["user", "model"]), + parts: Schema.Array(GeminiContentPart), +}) +type GeminiContent = Schema.Schema.Type + +const GeminiSystemInstruction = Schema.Struct({ + parts: Schema.Array(Schema.Struct({ text: Schema.String })), +}) + +const GeminiFunctionDeclaration = Schema.Struct({ + name: Schema.String, + description: Schema.String, + parameters: Schema.optional(JsonObject), +}) + +const GeminiTool = Schema.Struct({ + functionDeclarations: Schema.Array(GeminiFunctionDeclaration), +}) + +const GeminiToolConfig = Schema.Struct({ + functionCallingConfig: Schema.Struct({ + mode: Schema.Literals(["AUTO", "NONE", "ANY"]), + allowedFunctionNames: optionalArray(Schema.String), + }), +}) + +const GeminiThinkingConfig = Schema.Struct({ + thinkingBudget: Schema.optional(Schema.Number), + includeThoughts: Schema.optional(Schema.Boolean), +}) + +const GeminiGenerationConfig = Schema.Struct({ + maxOutputTokens: Schema.optional(Schema.Number), + temperature: Schema.optional(Schema.Number), + topP: Schema.optional(Schema.Number), + topK: Schema.optional(Schema.Number), + stopSequences: optionalArray(Schema.String), + thinkingConfig: Schema.optional(GeminiThinkingConfig), +}) + +const GeminiBodyFields = { + contents: Schema.Array(GeminiContent), + systemInstruction: Schema.optional(GeminiSystemInstruction), + tools: optionalArray(GeminiTool), + toolConfig: Schema.optional(GeminiToolConfig), + generationConfig: Schema.optional(GeminiGenerationConfig), +} +const GeminiBody = Schema.Struct(GeminiBodyFields) +export type GeminiBody = Schema.Schema.Type + +const GeminiUsage = Schema.Struct({ + cachedContentTokenCount: Schema.optional(Schema.Number), + thoughtsTokenCount: Schema.optional(Schema.Number), + promptTokenCount: Schema.optional(Schema.Number), + candidatesTokenCount: Schema.optional(Schema.Number), + totalTokenCount: Schema.optional(Schema.Number), +}) +type GeminiUsage = Schema.Schema.Type + +const GeminiCandidate = Schema.Struct({ + content: Schema.optional(GeminiContent), + finishReason: Schema.optional(Schema.String), +}) + +const GeminiEvent = Schema.Struct({ + candidates: optionalArray(GeminiCandidate), + usageMetadata: Schema.optional(GeminiUsage), +}) +type GeminiEvent = Schema.Schema.Type + +interface ParserState { + readonly finishReason?: string + readonly hasToolCalls: boolean + readonly nextToolCallId: number + readonly usage?: Usage +} + +const invalid = ProviderShared.invalidRequest + +const mediaData = ProviderShared.mediaBytes + +// ============================================================================= +// Tool Schema Conversion +// ============================================================================= +// Tool-schema conversion has two distinct concerns: +// +// 1. Sanitize — fix common authoring mistakes Gemini rejects: integer/number +// enums (must be strings), `required` entries that don't match a property, +// untyped arrays (`items` must be present), and `properties`/`required` +// keys on non-object scalars. Mirrors OpenCode's historical Gemini rules. +// +// 2. Project — lossy mapping from JSON Schema to Gemini's schema dialect: +// drop empty objects, derive `nullable: true` from `type: [..., "null"]`, +// coerce `const` to `[const]` enum, recurse properties/items, propagate +// only an allowlisted set of keys (description, required, format, type, +// properties, items, allOf, anyOf, oneOf, minLength). Anything outside the +// allowlist (e.g. `additionalProperties`, `$ref`) is silently dropped. +// +// Sanitize runs first, then project. The implementation lives in +// `utils/gemini-tool-schema` so this protocol keeps the same shape as the other +// provider protocols. + +// ============================================================================= +// Request Lowering +// ============================================================================= +const lowerTool = (tool: ToolDefinition) => ({ + name: tool.name, + description: tool.description, + parameters: GeminiToolSchema.convert(tool.inputSchema), +}) + +const lowerToolConfig = (toolChoice: NonNullable) => + ProviderShared.matchToolChoice("Gemini", toolChoice, { + auto: () => ({ functionCallingConfig: { mode: "AUTO" as const } }), + none: () => ({ functionCallingConfig: { mode: "NONE" as const } }), + required: () => ({ functionCallingConfig: { mode: "ANY" as const } }), + tool: (name) => ({ functionCallingConfig: { mode: "ANY" as const, allowedFunctionNames: [name] } }), + }) + +const lowerUserPart = (part: TextPart | MediaPart) => + part.type === "text" ? { text: part.text } : { inlineData: { mimeType: part.mediaType, data: mediaData(part) } } + +const lowerToolCall = (part: ToolCallPart) => ({ + functionCall: { name: part.name, args: part.input }, +}) + +const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMRequest) { + const contents: GeminiContent[] = [] + + for (const message of request.messages) { + if (message.role === "user") { + const parts: Array> = [] + for (const part of message.content) { + if (!ProviderShared.supportsContent(part, ["text", "media"])) + return yield* ProviderShared.unsupportedContent("Gemini", "user", ["text", "media"]) + parts.push(lowerUserPart(part)) + } + contents.push({ role: "user", parts }) + continue + } + + if (message.role === "assistant") { + const parts: Array> = [] + for (const part of message.content) { + if (!ProviderShared.supportsContent(part, ["text", "reasoning", "tool-call"])) + return yield* ProviderShared.unsupportedContent("Gemini", "assistant", ["text", "reasoning", "tool-call"]) + if (part.type === "text") { + parts.push({ text: part.text }) + continue + } + if (part.type === "reasoning") { + parts.push({ text: part.text, thought: true }) + continue + } + if (part.type === "tool-call") { + parts.push(lowerToolCall(part)) + continue + } + } + contents.push({ role: "model", parts }) + continue + } + + const parts: Array> = [] + for (const part of message.content) { + if (!ProviderShared.supportsContent(part, ["tool-result"])) + return yield* ProviderShared.unsupportedContent("Gemini", "tool", ["tool-result"]) + parts.push({ + functionResponse: { + name: part.name, + response: { + name: part.name, + content: ProviderShared.toolResultText(part), + }, + }, + }) + } + contents.push({ role: "user", parts }) + } + + return contents +}) + +const geminiOptions = (request: LLMRequest) => request.providerOptions?.gemini + +const thinkingConfig = (request: LLMRequest) => { + const value = geminiOptions(request)?.thinkingConfig + if (!ProviderShared.isRecord(value)) return undefined + const result = { + thinkingBudget: typeof value.thinkingBudget === "number" ? value.thinkingBudget : undefined, + includeThoughts: typeof value.includeThoughts === "boolean" ? value.includeThoughts : undefined, + } + return Object.values(result).some((item) => item !== undefined) ? result : undefined +} + +const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMRequest) { + const toolsEnabled = request.tools.length > 0 && request.toolChoice?.type !== "none" + const generation = request.generation + const generationConfig = { + maxOutputTokens: generation?.maxTokens, + temperature: generation?.temperature, + topP: generation?.topP, + topK: generation?.topK, + stopSequences: generation?.stop, + thinkingConfig: thinkingConfig(request), + } + + return { + contents: yield* lowerMessages(request), + systemInstruction: + request.system.length === 0 ? undefined : { parts: [{ text: ProviderShared.joinText(request.system) }] }, + tools: toolsEnabled ? [{ functionDeclarations: request.tools.map(lowerTool) }] : undefined, + toolConfig: toolsEnabled && request.toolChoice ? yield* lowerToolConfig(request.toolChoice) : undefined, + generationConfig: Object.values(generationConfig).some((value) => value !== undefined) + ? generationConfig + : undefined, + } +}) + +// ============================================================================= +// Stream Parsing +// ============================================================================= +// Gemini reports `promptTokenCount` (inclusive total) with a +// `cachedContentTokenCount` subset. `candidatesTokenCount` is *exclusive* +// of `thoughtsTokenCount` — visible-only, not a total — so we sum the two +// to produce the inclusive `outputTokens` the rest of the contract expects. +const mapUsage = (usage: GeminiUsage | undefined) => { + if (!usage) return undefined + const cached = usage.cachedContentTokenCount + const nonCached = ProviderShared.subtractTokens(usage.promptTokenCount, cached) + // `candidatesTokenCount` is visible-only; sum with thoughts to produce the + // inclusive `outputTokens` the contract expects. Only compute the total + // when the visible component is reported — otherwise we'd fabricate an + // inclusive number from a partial breakdown. + const outputTokens = + usage.candidatesTokenCount !== undefined ? usage.candidatesTokenCount + (usage.thoughtsTokenCount ?? 0) : undefined + return new Usage({ + inputTokens: usage.promptTokenCount, + outputTokens, + nonCachedInputTokens: nonCached, + cacheReadInputTokens: cached, + reasoningTokens: usage.thoughtsTokenCount, + totalTokens: ProviderShared.totalTokens(usage.promptTokenCount, outputTokens, usage.totalTokenCount), + providerMetadata: { google: usage }, + }) +} + +const mapFinishReason = (finishReason: string | undefined, hasToolCalls: boolean): FinishReason => { + if (finishReason === "STOP") return hasToolCalls ? "tool-calls" : "stop" + if (finishReason === "MAX_TOKENS") return "length" + if ( + finishReason === "IMAGE_SAFETY" || + finishReason === "RECITATION" || + finishReason === "SAFETY" || + finishReason === "BLOCKLIST" || + finishReason === "PROHIBITED_CONTENT" || + finishReason === "SPII" + ) + return "content-filter" + if (finishReason === "MALFORMED_FUNCTION_CALL") return "error" + return "unknown" +} + +const finish = (state: ParserState): ReadonlyArray => + state.finishReason || state.usage + ? [LLMEvent.requestFinish({ reason: mapFinishReason(state.finishReason, state.hasToolCalls), usage: state.usage })] + : [] + +const step = (state: ParserState, event: GeminiEvent) => { + const nextState = { + ...state, + usage: event.usageMetadata ? (mapUsage(event.usageMetadata) ?? state.usage) : state.usage, + } + const candidate = event.candidates?.[0] + if (!candidate?.content) + return Effect.succeed([ + { ...nextState, finishReason: candidate?.finishReason ?? nextState.finishReason }, + [], + ] as const) + + const events: LLMEvent[] = [] + let hasToolCalls = nextState.hasToolCalls + let nextToolCallId = nextState.nextToolCallId + + for (const part of candidate.content.parts) { + if ("text" in part && part.text.length > 0) { + events.push( + part.thought + ? LLMEvent.reasoningDelta({ id: "reasoning-0", text: part.text }) + : LLMEvent.textDelta({ id: "text-0", text: part.text }), + ) + continue + } + + if ("functionCall" in part) { + const input = part.functionCall.args + const id = `tool_${nextToolCallId++}` + events.push(LLMEvent.toolCall({ id, name: part.functionCall.name, input })) + hasToolCalls = true + } + } + + return Effect.succeed([ + { + ...nextState, + hasToolCalls, + nextToolCallId, + finishReason: candidate.finishReason ?? nextState.finishReason, + }, + events, + ] as const) +} + +// ============================================================================= +// Protocol And Gemini Route +// ============================================================================= +/** + * The Gemini protocol — request body construction, body schema, and the + * streaming-event state machine. Used by Google AI Studio Gemini and (once + * registered) Vertex Gemini. + */ +export const protocol = Protocol.make({ + id: ADAPTER, + body: { + schema: GeminiBody, + from: fromRequest, + }, + stream: { + event: Protocol.jsonEvent(GeminiEvent), + initial: () => ({ hasToolCalls: false, nextToolCallId: 0 }), + step, + onHalt: finish, + }, +}) + +export const route = Route.make({ + id: ADAPTER, + protocol, + // Gemini's path embeds the model id and pins SSE framing at the URL level. + endpoint: Endpoint.path(({ request }) => `/models/${request.model.id}:streamGenerateContent?alt=sse`), + auth: Auth.apiKeyHeader("x-goog-api-key"), + framing: Framing.sse, +}) + +// ============================================================================= +// Model Helper +// ============================================================================= +export const model = Route.model(route, { + provider: "google", + baseURL: DEFAULT_BASE_URL, +}) + +export * as Gemini from "./gemini" diff --git a/packages/llm/src/protocols/index.ts b/packages/llm/src/protocols/index.ts new file mode 100644 index 0000000000..bd8c8d3d9d --- /dev/null +++ b/packages/llm/src/protocols/index.ts @@ -0,0 +1,6 @@ +export * as AnthropicMessages from "./anthropic-messages" +export * as BedrockConverse from "./bedrock-converse" +export * as Gemini from "./gemini" +export * as OpenAIChat from "./openai-chat" +export * as OpenAICompatibleChat from "./openai-compatible-chat" +export * as OpenAIResponses from "./openai-responses" diff --git a/packages/llm/src/protocols/openai-chat.ts b/packages/llm/src/protocols/openai-chat.ts new file mode 100644 index 0000000000..133adb503b --- /dev/null +++ b/packages/llm/src/protocols/openai-chat.ts @@ -0,0 +1,410 @@ +import { Array as Arr, Effect, Schema } from "effect" +import { Route } from "../route/client" +import { Auth } from "../route/auth" +import { Endpoint } from "../route/endpoint" +import { Framing } from "../route/framing" +import { HttpTransport } from "../route/transport" +import { Protocol } from "../route/protocol" +import { + LLMEvent, + Usage, + type FinishReason, + type LLMRequest, + type TextPart, + type ToolCallPart, + type ToolDefinition, +} from "../schema" +import { isRecord, JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared" +import { OpenAIOptions } from "./utils/openai-options" +import { ToolStream } from "./utils/tool-stream" + +const ADAPTER = "openai-chat" +export const DEFAULT_BASE_URL = "https://api.openai.com/v1" +export const PATH = "/chat/completions" + +// ============================================================================= +// Request Body Schema +// ============================================================================= +// The body schema is the provider-native JSON body. `fromRequest` below builds +// this shape from the common `LLMRequest`, then `Route.make` validates and +// JSON-encodes it before transport. +const OpenAIChatFunction = Schema.Struct({ + name: Schema.String, + description: Schema.String, + parameters: JsonObject, +}) + +const OpenAIChatTool = Schema.Struct({ + type: Schema.tag("function"), + function: OpenAIChatFunction, +}) +type OpenAIChatTool = Schema.Schema.Type + +const OpenAIChatAssistantToolCall = Schema.Struct({ + id: Schema.String, + type: Schema.tag("function"), + function: Schema.Struct({ + name: Schema.String, + arguments: Schema.String, + }), +}) +type OpenAIChatAssistantToolCall = Schema.Schema.Type + +const OpenAIChatMessage = Schema.Union([ + Schema.Struct({ role: Schema.Literal("system"), content: Schema.String }), + Schema.Struct({ role: Schema.Literal("user"), content: Schema.String }), + Schema.Struct({ + role: Schema.Literal("assistant"), + content: Schema.NullOr(Schema.String), + tool_calls: optionalArray(OpenAIChatAssistantToolCall), + reasoning_content: Schema.optional(Schema.String), + }), + Schema.Struct({ role: Schema.Literal("tool"), tool_call_id: Schema.String, content: Schema.String }), +]).pipe(Schema.toTaggedUnion("role")) +type OpenAIChatMessage = Schema.Schema.Type + +const OpenAIChatToolChoice = Schema.Union([ + Schema.Literals(["auto", "none", "required"]), + Schema.Struct({ + type: Schema.tag("function"), + function: Schema.Struct({ name: Schema.String }), + }), +]) + +export const bodyFields = { + model: Schema.String, + messages: Schema.Array(OpenAIChatMessage), + tools: optionalArray(OpenAIChatTool), + tool_choice: Schema.optional(OpenAIChatToolChoice), + stream: Schema.Literal(true), + stream_options: Schema.optional(Schema.Struct({ include_usage: Schema.Boolean })), + store: Schema.optional(Schema.Boolean), + reasoning_effort: Schema.optional(OpenAIOptions.OpenAIReasoningEffort), + max_tokens: Schema.optional(Schema.Number), + temperature: Schema.optional(Schema.Number), + top_p: Schema.optional(Schema.Number), + frequency_penalty: Schema.optional(Schema.Number), + presence_penalty: Schema.optional(Schema.Number), + seed: Schema.optional(Schema.Number), + stop: optionalArray(Schema.String), +} +const OpenAIChatBody = Schema.Struct(bodyFields) +export type OpenAIChatBody = Schema.Schema.Type + +// ============================================================================= +// Streaming Event Schema +// ============================================================================= +// The event schema is one decoded SSE `data:` payload. `Framing.sse` splits the +// byte stream into strings, then `Protocol.jsonEvent` decodes each string into +// this provider-native event shape. +const OpenAIChatUsage = Schema.Struct({ + prompt_tokens: Schema.optional(Schema.Number), + completion_tokens: Schema.optional(Schema.Number), + total_tokens: Schema.optional(Schema.Number), + prompt_tokens_details: optionalNull( + Schema.Struct({ + cached_tokens: Schema.optional(Schema.Number), + }), + ), + completion_tokens_details: optionalNull( + Schema.Struct({ + reasoning_tokens: Schema.optional(Schema.Number), + }), + ), +}) + +const OpenAIChatToolCallDeltaFunction = Schema.Struct({ + name: optionalNull(Schema.String), + arguments: optionalNull(Schema.String), +}) + +const OpenAIChatToolCallDelta = Schema.Struct({ + index: Schema.Number, + id: optionalNull(Schema.String), + function: optionalNull(OpenAIChatToolCallDeltaFunction), +}) +type OpenAIChatToolCallDelta = Schema.Schema.Type + +const OpenAIChatDelta = Schema.Struct({ + content: optionalNull(Schema.String), + tool_calls: optionalNull(Schema.Array(OpenAIChatToolCallDelta)), +}) + +const OpenAIChatChoice = Schema.Struct({ + delta: optionalNull(OpenAIChatDelta), + finish_reason: optionalNull(Schema.String), +}) + +const OpenAIChatEvent = Schema.Struct({ + choices: Schema.Array(OpenAIChatChoice), + usage: optionalNull(OpenAIChatUsage), +}) +type OpenAIChatEvent = Schema.Schema.Type +type OpenAIChatRequestMessage = LLMRequest["messages"][number] + +interface ParserState { + readonly tools: ToolStream.State + readonly toolCallEvents: ReadonlyArray + readonly usage?: Usage + readonly finishReason?: FinishReason +} + +const invalid = ProviderShared.invalidRequest + +// ============================================================================= +// Request Lowering +// ============================================================================= +// Lowering is the only place that knows how common LLM messages map onto the +// OpenAI Chat wire format. Keep provider quirks here instead of leaking native +// fields into `LLMRequest`. +const lowerTool = (tool: ToolDefinition): OpenAIChatTool => ({ + type: "function", + function: { + name: tool.name, + description: tool.description, + parameters: tool.inputSchema, + }, +}) + +const lowerToolChoice = (toolChoice: NonNullable) => + ProviderShared.matchToolChoice("OpenAI Chat", toolChoice, { + auto: () => "auto" as const, + none: () => "none" as const, + required: () => "required" as const, + tool: (name) => ({ type: "function" as const, function: { name } }), + }) + +const lowerToolCall = (part: ToolCallPart): OpenAIChatAssistantToolCall => ({ + id: part.id, + type: "function", + function: { + name: part.name, + arguments: ProviderShared.encodeJson(part.input), + }, +}) + +const openAICompatibleReasoningContent = (native: unknown) => + isRecord(native) && typeof native.reasoning_content === "string" ? native.reasoning_content : undefined + +const lowerUserMessage = Effect.fn("OpenAIChat.lowerUserMessage")(function* (message: OpenAIChatRequestMessage) { + const content: TextPart[] = [] + for (const part of message.content) { + if (!ProviderShared.supportsContent(part, ["text"])) + return yield* ProviderShared.unsupportedContent("OpenAI Chat", "user", ["text"]) + content.push(part) + } + return { role: "user" as const, content: ProviderShared.joinText(content) } +}) + +const lowerAssistantMessage = Effect.fn("OpenAIChat.lowerAssistantMessage")(function* ( + message: OpenAIChatRequestMessage, +) { + const content: TextPart[] = [] + const toolCalls: OpenAIChatAssistantToolCall[] = [] + for (const part of message.content) { + if (!ProviderShared.supportsContent(part, ["text", "tool-call"])) + return yield* ProviderShared.unsupportedContent("OpenAI Chat", "assistant", ["text", "tool-call"]) + if (part.type === "text") { + content.push(part) + continue + } + if (part.type === "tool-call") { + toolCalls.push(lowerToolCall(part)) + continue + } + } + return { + role: "assistant" as const, + content: content.length === 0 ? null : ProviderShared.joinText(content), + tool_calls: toolCalls.length === 0 ? undefined : toolCalls, + reasoning_content: openAICompatibleReasoningContent(message.native?.openaiCompatible), + } +}) + +const lowerToolMessages = Effect.fn("OpenAIChat.lowerToolMessages")(function* (message: OpenAIChatRequestMessage) { + const messages: OpenAIChatMessage[] = [] + for (const part of message.content) { + if (!ProviderShared.supportsContent(part, ["tool-result"])) + return yield* ProviderShared.unsupportedContent("OpenAI Chat", "tool", ["tool-result"]) + messages.push({ role: "tool", tool_call_id: part.id, content: ProviderShared.toolResultText(part) }) + } + return messages +}) + +const lowerMessage = Effect.fn("OpenAIChat.lowerMessage")(function* (message: OpenAIChatRequestMessage) { + if (message.role === "user") return [yield* lowerUserMessage(message)] + if (message.role === "assistant") return [yield* lowerAssistantMessage(message)] + return yield* lowerToolMessages(message) +}) + +const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request: LLMRequest) { + const system: OpenAIChatMessage[] = + request.system.length === 0 ? [] : [{ role: "system", content: ProviderShared.joinText(request.system) }] + return [...system, ...Arr.flatten(yield* Effect.forEach(request.messages, lowerMessage))] +}) + +const lowerOptions = Effect.fn("OpenAIChat.lowerOptions")(function* (request: LLMRequest) { + const store = OpenAIOptions.store(request) + const reasoningEffort = OpenAIOptions.reasoningEffort(request) + if (reasoningEffort && !OpenAIOptions.isReasoningEffort(reasoningEffort)) + return yield* invalid(`OpenAI Chat does not support reasoning effort ${reasoningEffort}`) + return { + ...(store !== undefined ? { store } : {}), + ...(reasoningEffort ? { reasoning_effort: reasoningEffort } : {}), + } +}) + +const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* (request: LLMRequest) { + // `fromRequest` returns the provider body only. Endpoint, auth, framing, + // validation, and HTTP execution are composed by `Route.make`. + const generation = request.generation + return { + model: request.model.id, + messages: yield* lowerMessages(request), + tools: request.tools.length === 0 ? undefined : request.tools.map(lowerTool), + tool_choice: request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined, + stream: true as const, + stream_options: { include_usage: true }, + max_tokens: generation?.maxTokens, + temperature: generation?.temperature, + top_p: generation?.topP, + frequency_penalty: generation?.frequencyPenalty, + presence_penalty: generation?.presencePenalty, + seed: generation?.seed, + stop: generation?.stop, + ...(yield* lowerOptions(request)), + } +}) + +// ============================================================================= +// Stream Parsing +// ============================================================================= +// Streaming parsers are small state machines: every event returns a new state +// plus the common `LLMEvent`s produced by that event. Tool calls are accumulated +// because OpenAI streams JSON arguments across multiple deltas. +const mapFinishReason = (reason: string | null | undefined): FinishReason => { + if (reason === "stop") return "stop" + if (reason === "length") return "length" + if (reason === "content_filter") return "content-filter" + if (reason === "function_call" || reason === "tool_calls") return "tool-calls" + return "unknown" +} + +// OpenAI Chat reports `prompt_tokens` (inclusive total) with a +// `cached_tokens` subset, and `completion_tokens` (inclusive total) with +// a `reasoning_tokens` subset. We pass the inclusive totals through and +// derive the non-cached breakdown so the `LLM.Usage` contract is +// satisfied on both sides. +const mapUsage = (usage: OpenAIChatEvent["usage"]): Usage | undefined => { + if (!usage) return undefined + const cached = usage.prompt_tokens_details?.cached_tokens + const reasoning = usage.completion_tokens_details?.reasoning_tokens + const nonCached = ProviderShared.subtractTokens(usage.prompt_tokens, cached) + return new Usage({ + inputTokens: usage.prompt_tokens, + outputTokens: usage.completion_tokens, + nonCachedInputTokens: nonCached, + cacheReadInputTokens: cached, + reasoningTokens: reasoning, + totalTokens: ProviderShared.totalTokens(usage.prompt_tokens, usage.completion_tokens, usage.total_tokens), + providerMetadata: { openai: usage }, + }) +} + +const step = (state: ParserState, event: OpenAIChatEvent) => + Effect.gen(function* () { + const events: LLMEvent[] = [] + const usage = mapUsage(event.usage) ?? state.usage + const choice = event.choices[0] + const finishReason = choice?.finish_reason ? mapFinishReason(choice.finish_reason) : state.finishReason + const delta = choice?.delta + const toolDeltas = delta?.tool_calls ?? [] + let tools = state.tools + + if (delta?.content) events.push(LLMEvent.textDelta({ id: "text-0", text: delta.content })) + + for (const tool of toolDeltas) { + const result = ToolStream.appendOrStart( + ADAPTER, + tools, + tool.index, + { id: tool.id ?? undefined, name: tool.function?.name ?? undefined, text: tool.function?.arguments ?? "" }, + "OpenAI Chat tool call delta is missing id or name", + ) + if (ToolStream.isError(result)) return yield* result + tools = result.tools + if (result.event) events.push(result.event) + } + + // Finalize accumulated tool inputs eagerly when finish_reason arrives so + // JSON parse failures fail the stream at the boundary rather than at halt. + const finished = + finishReason !== undefined && state.finishReason === undefined && Object.keys(tools).length > 0 + ? yield* ToolStream.finishAll(ADAPTER, tools) + : undefined + + return [ + { + tools: finished?.tools ?? tools, + toolCallEvents: finished?.events ?? state.toolCallEvents, + usage, + finishReason, + }, + events, + ] as const + }) + +const finishEvents = (state: ParserState): ReadonlyArray => { + const hasToolCalls = state.toolCallEvents.length > 0 + const reason = state.finishReason === "stop" && hasToolCalls ? "tool-calls" : state.finishReason + return [...state.toolCallEvents, ...(reason ? [LLMEvent.requestFinish({ reason, usage: state.usage })] : [])] +} + +// ============================================================================= +// Protocol And OpenAI Route +// ============================================================================= +/** + * The OpenAI Chat protocol — request body construction, body schema, and the + * streaming-event state machine. Reused by every route that speaks OpenAI Chat + * over HTTP+SSE: native OpenAI, DeepSeek, TogetherAI, Cerebras, Baseten, + * Fireworks, DeepInfra, and (once added) Azure OpenAI Chat. + */ +export const protocol = Protocol.make({ + id: ADAPTER, + body: { + schema: OpenAIChatBody, + from: fromRequest, + }, + stream: { + event: Protocol.jsonEvent(OpenAIChatEvent), + initial: () => ({ tools: ToolStream.empty(), toolCallEvents: [] }), + step, + onHalt: finishEvents, + }, +}) + +const encodeBody = Schema.encodeSync(Schema.fromJsonString(OpenAIChatBody)) + +export const httpTransport = HttpTransport.httpJson({ + endpoint: Endpoint.path(PATH), + auth: Auth.bearer(), + framing: Framing.sse, + encodeBody, +}) + +export const route = Route.make({ + id: ADAPTER, + provider: "openai", + protocol, + transport: httpTransport, + defaults: { + baseURL: DEFAULT_BASE_URL, + }, +}) + +// ============================================================================= +// Model Helper +// ============================================================================= +export const model = route.model + +export * as OpenAIChat from "./openai-chat" diff --git a/packages/llm/src/protocols/openai-compatible-chat.ts b/packages/llm/src/protocols/openai-compatible-chat.ts new file mode 100644 index 0000000000..76deeac451 --- /dev/null +++ b/packages/llm/src/protocols/openai-compatible-chat.ts @@ -0,0 +1,28 @@ +import { Route, type RouteRoutedModelInput } from "../route/client" +import { Endpoint } from "../route/endpoint" +import { Framing } from "../route/framing" +import * as OpenAIChat from "./openai-chat" + +const ADAPTER = "openai-compatible-chat" + +export type OpenAICompatibleChatModelInput = Omit & { + readonly baseURL: string +} + +/** + * Route for non-OpenAI providers that expose an OpenAI Chat-compatible + * `/chat/completions` endpoint. Reuses `OpenAIChat.protocol` end-to-end and + * overrides only the route id so providers can be resolved per-family without + * colliding with native OpenAI. The model carries the host on `baseURL`, + * supplied by whichever profile/provider helper builds it. + */ +export const route = Route.make({ + id: ADAPTER, + protocol: OpenAIChat.protocol, + endpoint: Endpoint.path("/chat/completions"), + framing: Framing.sse, +}) + +export const model = Route.model(route) + +export * as OpenAICompatibleChat from "./openai-compatible-chat" diff --git a/packages/llm/src/protocols/openai-responses.ts b/packages/llm/src/protocols/openai-responses.ts new file mode 100644 index 0000000000..035cc07713 --- /dev/null +++ b/packages/llm/src/protocols/openai-responses.ts @@ -0,0 +1,569 @@ +import { Effect, Schema } from "effect" +import { Route } from "../route/client" +import { Auth } from "../route/auth" +import { Endpoint } from "../route/endpoint" +import { Framing } from "../route/framing" +import { HttpTransport, WebSocketTransport } from "../route/transport" +import { Protocol } from "../route/protocol" +import { + LLMEvent, + Usage, + type FinishReason, + type LLMRequest, + type ProviderMetadata, + type TextPart, + type ToolCallPart, + type ToolDefinition, +} from "../schema" +import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared" +import { OpenAIOptions } from "./utils/openai-options" +import { ToolStream } from "./utils/tool-stream" + +const ADAPTER = "openai-responses" +export const DEFAULT_BASE_URL = "https://api.openai.com/v1" +export const PATH = "/responses" + +// ============================================================================= +// Request Body Schema +// ============================================================================= +const OpenAIResponsesInputText = Schema.Struct({ + type: Schema.tag("input_text"), + text: Schema.String, +}) + +const OpenAIResponsesOutputText = Schema.Struct({ + type: Schema.tag("output_text"), + text: Schema.String, +}) + +const OpenAIResponsesInputItem = Schema.Union([ + Schema.Struct({ role: Schema.tag("system"), content: Schema.String }), + Schema.Struct({ role: Schema.tag("user"), content: Schema.Array(OpenAIResponsesInputText) }), + Schema.Struct({ role: Schema.tag("assistant"), content: Schema.Array(OpenAIResponsesOutputText) }), + Schema.Struct({ + type: Schema.tag("function_call"), + call_id: Schema.String, + name: Schema.String, + arguments: Schema.String, + }), + Schema.Struct({ + type: Schema.tag("function_call_output"), + call_id: Schema.String, + output: Schema.String, + }), +]) +type OpenAIResponsesInputItem = Schema.Schema.Type + +const OpenAIResponsesTool = Schema.Struct({ + type: Schema.tag("function"), + name: Schema.String, + description: Schema.String, + parameters: JsonObject, + strict: Schema.optional(Schema.Boolean), +}) +type OpenAIResponsesTool = Schema.Schema.Type + +const OpenAIResponsesToolChoice = Schema.Union([ + Schema.Literals(["auto", "none", "required"]), + Schema.Struct({ type: Schema.tag("function"), name: Schema.String }), +]) + +// Fields shared between the HTTP body and the WebSocket `response.create` +// message. The HTTP body adds `stream: true`; the WebSocket message adds +// `type: "response.create"`. Defining the shared shape once keeps the two +// transports in sync without a destructure-and-strip dance. +const OpenAIResponsesCoreFields = { + model: Schema.String, + input: Schema.Array(OpenAIResponsesInputItem), + tools: optionalArray(OpenAIResponsesTool), + tool_choice: Schema.optional(OpenAIResponsesToolChoice), + store: Schema.optional(Schema.Boolean), + prompt_cache_key: Schema.optional(Schema.String), + include: optionalArray(Schema.Literal("reasoning.encrypted_content")), + reasoning: Schema.optional( + Schema.Struct({ + effort: Schema.optional(OpenAIOptions.OpenAIReasoningEffort), + summary: Schema.optional(Schema.Literal("auto")), + }), + ), + text: Schema.optional( + Schema.Struct({ + verbosity: Schema.optional(OpenAIOptions.OpenAITextVerbosity), + }), + ), + max_output_tokens: Schema.optional(Schema.Number), + temperature: Schema.optional(Schema.Number), + top_p: Schema.optional(Schema.Number), +} + +const OpenAIResponsesBody = Schema.Struct({ + ...OpenAIResponsesCoreFields, + stream: Schema.Literal(true), +}) +export type OpenAIResponsesBody = Schema.Schema.Type + +const OpenAIResponsesWebSocketMessage = Schema.StructWithRest( + Schema.Struct({ + type: Schema.tag("response.create"), + ...OpenAIResponsesCoreFields, + }), + [Schema.Record(Schema.String, Schema.Unknown)], +) +type OpenAIResponsesWebSocketMessage = Schema.Schema.Type +const encodeWebSocketMessage = Schema.encodeSync(Schema.fromJsonString(OpenAIResponsesWebSocketMessage)) + +const OpenAIResponsesUsage = Schema.Struct({ + input_tokens: Schema.optional(Schema.Number), + input_tokens_details: optionalNull(Schema.Struct({ cached_tokens: Schema.optional(Schema.Number) })), + output_tokens: Schema.optional(Schema.Number), + output_tokens_details: optionalNull(Schema.Struct({ reasoning_tokens: Schema.optional(Schema.Number) })), + total_tokens: Schema.optional(Schema.Number), +}) +type OpenAIResponsesUsage = Schema.Schema.Type + +const OpenAIResponsesStreamItem = Schema.Struct({ + type: Schema.String, + id: Schema.optional(Schema.String), + call_id: Schema.optional(Schema.String), + name: Schema.optional(Schema.String), + arguments: Schema.optional(Schema.String), + // Hosted (provider-executed) tool fields. Each hosted tool item carries its + // own subset of these — we capture them generically so we can surface the + // call's typed input portion and round-trip the full result payload without + // hand-rolling a per-tool schema. + status: Schema.optional(Schema.String), + action: Schema.optional(Schema.Unknown), + queries: Schema.optional(Schema.Unknown), + results: Schema.optional(Schema.Unknown), + code: Schema.optional(Schema.String), + container_id: Schema.optional(Schema.String), + outputs: Schema.optional(Schema.Unknown), + server_label: Schema.optional(Schema.String), + output: Schema.optional(Schema.Unknown), + error: Schema.optional(Schema.Unknown), +}) +type OpenAIResponsesStreamItem = Schema.Schema.Type + +const OpenAIResponsesEvent = Schema.Struct({ + type: Schema.String, + delta: Schema.optional(Schema.String), + item_id: Schema.optional(Schema.String), + item: Schema.optional(OpenAIResponsesStreamItem), + response: Schema.optional( + Schema.Struct({ + id: Schema.optional(Schema.String), + service_tier: Schema.optional(Schema.String), + incomplete_details: optionalNull(Schema.Struct({ reason: Schema.String })), + usage: optionalNull(OpenAIResponsesUsage), + }), + ), + code: Schema.optional(Schema.String), + message: Schema.optional(Schema.String), +}) +type OpenAIResponsesEvent = Schema.Schema.Type + +interface ParserState { + readonly tools: ToolStream.State + readonly hasFunctionCall: boolean +} + +const invalid = ProviderShared.invalidRequest + +// ============================================================================= +// Request Lowering +// ============================================================================= +const lowerTool = (tool: ToolDefinition): OpenAIResponsesTool => ({ + type: "function", + name: tool.name, + description: tool.description, + parameters: tool.inputSchema, +}) + +const lowerToolChoice = (toolChoice: NonNullable) => + ProviderShared.matchToolChoice("OpenAI Responses", toolChoice, { + auto: () => "auto" as const, + none: () => "none" as const, + required: () => "required" as const, + tool: (name) => ({ type: "function" as const, name }), + }) + +const lowerToolCall = (part: ToolCallPart): OpenAIResponsesInputItem => ({ + type: "function_call", + call_id: part.id, + name: part.name, + arguments: ProviderShared.encodeJson(part.input), +}) + +const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (request: LLMRequest) { + const system: OpenAIResponsesInputItem[] = + request.system.length === 0 ? [] : [{ role: "system", content: ProviderShared.joinText(request.system) }] + const input: OpenAIResponsesInputItem[] = [...system] + + for (const message of request.messages) { + if (message.role === "user") { + const content: TextPart[] = [] + for (const part of message.content) { + if (!ProviderShared.supportsContent(part, ["text"])) + return yield* ProviderShared.unsupportedContent("OpenAI Responses", "user", ["text"]) + content.push(part) + } + input.push({ role: "user", content: content.map((part) => ({ type: "input_text", text: part.text })) }) + continue + } + + if (message.role === "assistant") { + const content: TextPart[] = [] + for (const part of message.content) { + if (!ProviderShared.supportsContent(part, ["text", "tool-call"])) + return yield* ProviderShared.unsupportedContent("OpenAI Responses", "assistant", ["text", "tool-call"]) + if (part.type === "text") { + content.push(part) + continue + } + if (part.type === "tool-call") { + input.push(lowerToolCall(part)) + continue + } + } + if (content.length > 0) + input.push({ role: "assistant", content: content.map((part) => ({ type: "output_text", text: part.text })) }) + continue + } + + for (const part of message.content) { + if (!ProviderShared.supportsContent(part, ["tool-result"])) + return yield* ProviderShared.unsupportedContent("OpenAI Responses", "tool", ["tool-result"]) + input.push({ type: "function_call_output", call_id: part.id, output: ProviderShared.toolResultText(part) }) + } + } + + return input +}) + +const lowerOptions = Effect.fn("OpenAIResponses.lowerOptions")(function* (request: LLMRequest) { + const store = OpenAIOptions.store(request) + const promptCacheKey = OpenAIOptions.promptCacheKey(request) + const effort = OpenAIOptions.reasoningEffort(request) + if (effort && !OpenAIOptions.isReasoningEffort(effort)) + return yield* invalid(`OpenAI Responses does not support reasoning effort ${effort}`) + const summary = OpenAIOptions.reasoningSummary(request) + const encryptedState = OpenAIOptions.encryptedReasoning(request) + const verbosity = OpenAIOptions.textVerbosity(request) + return { + ...(store !== undefined ? { store } : {}), + ...(promptCacheKey ? { prompt_cache_key: promptCacheKey } : {}), + ...(encryptedState ? { include: ["reasoning.encrypted_content"] as const } : {}), + ...(effort || summary ? { reasoning: { effort, summary } } : {}), + ...(verbosity ? { text: { verbosity } } : {}), + } +}) + +const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request: LLMRequest) { + const generation = request.generation + return { + model: request.model.id, + input: yield* lowerMessages(request), + tools: request.tools.length === 0 ? undefined : request.tools.map(lowerTool), + tool_choice: request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined, + stream: true as const, + max_output_tokens: generation?.maxTokens, + temperature: generation?.temperature, + top_p: generation?.topP, + ...(yield* lowerOptions(request)), + } +}) + +// ============================================================================= +// Stream Parsing +// ============================================================================= +// OpenAI Responses reports `input_tokens` (inclusive total) with a +// `cached_tokens` subset, and `output_tokens` (inclusive total) with a +// `reasoning_tokens` subset. Pass the totals through and derive the +// non-cached breakdown. +const mapUsage = (usage: OpenAIResponsesUsage | null | undefined) => { + if (!usage) return undefined + const cached = usage.input_tokens_details?.cached_tokens + const reasoning = usage.output_tokens_details?.reasoning_tokens + const nonCached = ProviderShared.subtractTokens(usage.input_tokens, cached) + return new Usage({ + inputTokens: usage.input_tokens, + outputTokens: usage.output_tokens, + nonCachedInputTokens: nonCached, + cacheReadInputTokens: cached, + reasoningTokens: reasoning, + totalTokens: ProviderShared.totalTokens(usage.input_tokens, usage.output_tokens, usage.total_tokens), + providerMetadata: { openai: usage }, + }) +} + +const mapFinishReason = (event: OpenAIResponsesEvent, hasFunctionCall: boolean): FinishReason => { + const reason = event.response?.incomplete_details?.reason + if (reason === undefined || reason === null) return hasFunctionCall ? "tool-calls" : "stop" + if (reason === "max_output_tokens") return "length" + if (reason === "content_filter") return "content-filter" + return hasFunctionCall ? "tool-calls" : "unknown" +} + +const openaiMetadata = (metadata: Record): ProviderMetadata => ({ openai: metadata }) + +// Hosted tool items (provider-executed) ship their typed input + status + +// result fields all in one item. We expose them as a `tool-call` + +// `tool-result` pair so consumers can treat them uniformly with client tools, +// only differentiated by `providerExecuted: true`. +// +// One record per OpenAI Responses item type that represents a hosted +// (provider-executed) tool call: the common name we surface, plus an `input` +// extractor that picks the fields the model actually populated for that tool. +// Falling back to `{}` when an entry isn't fully typed keeps unknown tools +// observable without rolling a per-tool schema. +const HOSTED_TOOLS = { + web_search_call: { name: "web_search", input: (item) => item.action ?? {} }, + web_search_preview_call: { name: "web_search_preview", input: (item) => item.action ?? {} }, + file_search_call: { name: "file_search", input: (item) => ({ queries: item.queries ?? [] }) }, + code_interpreter_call: { + name: "code_interpreter", + input: (item) => ({ code: item.code, container_id: item.container_id }), + }, + computer_use_call: { name: "computer_use", input: (item) => item.action ?? {} }, + image_generation_call: { name: "image_generation", input: () => ({}) }, + mcp_call: { + name: "mcp", + input: (item) => ({ server_label: item.server_label, name: item.name, arguments: item.arguments }), + }, + local_shell_call: { name: "local_shell", input: (item) => item.action ?? {} }, +} as const satisfies Record< + string, + { readonly name: string; readonly input: (item: OpenAIResponsesStreamItem) => unknown } +> + +type HostedToolType = keyof typeof HOSTED_TOOLS + +const isHostedToolItem = ( + item: OpenAIResponsesStreamItem, +): item is OpenAIResponsesStreamItem & { type: HostedToolType; id: string } => + item.type in HOSTED_TOOLS && typeof item.id === "string" && item.id.length > 0 + +// Round-trip the full item as the structured result so consumers can extract +// outputs / sources / status without re-decoding. +const hostedToolResult = (item: OpenAIResponsesStreamItem) => { + const isError = typeof item.error !== "undefined" && item.error !== null + return isError ? { type: "error" as const, value: item.error } : { type: "json" as const, value: item } +} + +const hostedToolEvents = ( + item: OpenAIResponsesStreamItem & { type: HostedToolType; id: string }, +): ReadonlyArray => { + const tool = HOSTED_TOOLS[item.type] + const providerMetadata = openaiMetadata({ itemId: item.id }) + return [ + LLMEvent.toolCall({ + id: item.id, + name: tool.name, + input: tool.input(item), + providerExecuted: true, + providerMetadata, + }), + LLMEvent.toolResult({ + id: item.id, + name: tool.name, + result: hostedToolResult(item), + providerExecuted: true, + providerMetadata, + }), + ] +} + +type StepResult = readonly [ParserState, ReadonlyArray] + +const NO_EVENTS: StepResult["1"] = [] + +// `response.completed` / `response.incomplete` are clean finishes that emit a +// `request-finish` event; `response.failed` is a hard failure that emits a +// `provider-error`. All three end the stream — kept in one set so `step` and +// the protocol's `terminal` predicate stay in sync. +const TERMINAL_TYPES = new Set(["response.completed", "response.incomplete", "response.failed"]) + +const onOutputTextDelta = (state: ParserState, event: OpenAIResponsesEvent): StepResult => { + if (!event.delta) return [state, NO_EVENTS] + return [state, [LLMEvent.textDelta({ id: event.item_id ?? "text-0", text: event.delta })]] +} + +const onOutputItemAdded = (state: ParserState, event: OpenAIResponsesEvent): StepResult => { + const item = event.item + if (item?.type !== "function_call" || !item.id) return [state, NO_EVENTS] + return [ + { + hasFunctionCall: state.hasFunctionCall, + tools: ToolStream.start(state.tools, item.id, { + id: item.call_id ?? item.id, + name: item.name ?? "", + input: item.arguments ?? "", + providerMetadata: openaiMetadata({ itemId: item.id }), + }), + }, + NO_EVENTS, + ] +} + +const onFunctionCallArgumentsDelta = Effect.fn("OpenAIResponses.onFunctionCallArgumentsDelta")(function* ( + state: ParserState, + event: OpenAIResponsesEvent, +) { + if (!event.item_id || !event.delta) return [state, NO_EVENTS] satisfies StepResult + const result = ToolStream.appendExisting( + ADAPTER, + state.tools, + event.item_id, + event.delta, + "OpenAI Responses tool argument delta is missing its tool call", + ) + if (ToolStream.isError(result)) return yield* result + return [ + { hasFunctionCall: state.hasFunctionCall, tools: result.tools }, + result.event ? [result.event] : NO_EVENTS, + ] satisfies StepResult +}) + +const onOutputItemDone = Effect.fn("OpenAIResponses.onOutputItemDone")(function* ( + state: ParserState, + event: OpenAIResponsesEvent, +) { + const item = event.item + if (!item) return [state, NO_EVENTS] satisfies StepResult + + if (item.type === "function_call") { + 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 }) + const result = + item.arguments === undefined + ? yield* ToolStream.finish(ADAPTER, tools, item.id) + : yield* ToolStream.finishWithInput(ADAPTER, tools, item.id, item.arguments) + return [ + { hasFunctionCall: result.event ? true : state.hasFunctionCall, tools: result.tools }, + result.event ? [result.event] : NO_EVENTS, + ] satisfies StepResult + } + + if (isHostedToolItem(item)) return [state, hostedToolEvents(item)] satisfies StepResult + + return [state, NO_EVENTS] satisfies StepResult +}) + +const onResponseFinish = (state: ParserState, event: OpenAIResponsesEvent): StepResult => [ + state, + [ + LLMEvent.requestFinish({ + reason: mapFinishReason(event, state.hasFunctionCall), + usage: mapUsage(event.response?.usage), + providerMetadata: + event.response?.id || event.response?.service_tier + ? openaiMetadata({ + responseId: event.response.id, + serviceTier: event.response.service_tier, + }) + : undefined, + }), + ], +] + +const onResponseFailed = (state: ParserState, event: OpenAIResponsesEvent): StepResult => [ + state, + [LLMEvent.providerError({ message: event.message ?? event.code ?? "OpenAI Responses response failed" })], +] + +const onError = (state: ParserState, event: OpenAIResponsesEvent): StepResult => [ + state, + [LLMEvent.providerError({ message: event.message ?? event.code ?? "OpenAI Responses stream error" })], +] + +const step = (state: ParserState, event: OpenAIResponsesEvent) => { + if (event.type === "response.output_text.delta") return Effect.succeed(onOutputTextDelta(state, event)) + if (event.type === "response.output_item.added") return Effect.succeed(onOutputItemAdded(state, event)) + if (event.type === "response.function_call_arguments.delta") return onFunctionCallArgumentsDelta(state, event) + if (event.type === "response.output_item.done") return onOutputItemDone(state, event) + if (event.type === "response.completed" || event.type === "response.incomplete") + return Effect.succeed(onResponseFinish(state, event)) + if (event.type === "response.failed") return Effect.succeed(onResponseFailed(state, event)) + if (event.type === "error") return Effect.succeed(onError(state, event)) + return Effect.succeed([state, NO_EVENTS]) +} + +// ============================================================================= +// Protocol And OpenAI Route +// ============================================================================= +/** + * The OpenAI Responses protocol — request body construction, body schema, and + * the streaming-event state machine. Used by native OpenAI and (once + * registered) Azure OpenAI Responses. + */ +export const protocol = Protocol.make({ + id: ADAPTER, + body: { + schema: OpenAIResponsesBody, + from: fromRequest, + }, + stream: { + event: Protocol.jsonEvent(OpenAIResponsesEvent), + initial: () => ({ hasFunctionCall: false, tools: ToolStream.empty() }), + step, + terminal: (event) => TERMINAL_TYPES.has(event.type), + }, +}) + +const encodeBody = Schema.encodeSync(Schema.fromJsonString(OpenAIResponsesBody)) +const transportBase = { + endpoint: Endpoint.path(PATH), + auth: Auth.bearer(), + encodeBody, +} +const routeDefaults = { + baseURL: DEFAULT_BASE_URL, +} + +export const httpTransport = HttpTransport.httpJson({ + ...transportBase, + framing: Framing.sse, +}) + +export const route = Route.make({ + id: ADAPTER, + provider: "openai", + protocol, + transport: httpTransport, + defaults: routeDefaults, +}) + +const decodeWebSocketMessage = ProviderShared.validateWith(Schema.decodeUnknownEffect(OpenAIResponsesWebSocketMessage)) + +const webSocketMessage = (body: OpenAIResponsesBody | Record) => + Effect.gen(function* () { + if (!ProviderShared.isRecord(body)) + return yield* ProviderShared.invalidRequest("OpenAI Responses WebSocket body must be a JSON object") + const { stream: _stream, ...message } = body + return yield* decodeWebSocketMessage({ ...message, type: "response.create" }) + }) + +export const webSocketTransport = WebSocketTransport.json({ + ...transportBase, + toMessage: webSocketMessage, + encodeMessage: encodeWebSocketMessage, +}) + +export const webSocketRoute = Route.make({ + id: `${ADAPTER}-websocket`, + provider: "openai", + protocol, + transport: webSocketTransport, + defaults: routeDefaults, +}) + +// ============================================================================= +// Model Helper +// ============================================================================= +export const model = route.model + +export const webSocketModel = webSocketRoute.model + +export * as OpenAIResponses from "./openai-responses" diff --git a/packages/llm/src/protocols/shared.ts b/packages/llm/src/protocols/shared.ts new file mode 100644 index 0000000000..b8067bbe90 --- /dev/null +++ b/packages/llm/src/protocols/shared.ts @@ -0,0 +1,239 @@ +import { Buffer } from "node:buffer" +import { Effect, Schema, Stream } from "effect" +import * as Sse from "effect/unstable/encoding/Sse" +import { Headers, HttpClientRequest } from "effect/unstable/http" +import { + InvalidProviderOutputReason, + InvalidRequestReason, + LLMError, + type ContentPart, + type LLMRequest, + type MediaPart, + type ToolResultPart, +} from "../schema" + +export const Json = Schema.fromJsonString(Schema.Unknown) +export const decodeJson = Schema.decodeUnknownSync(Json) +export const encodeJson = Schema.encodeSync(Json) +export const JsonObject = Schema.Record(Schema.String, Schema.Unknown) +export const optionalArray = (schema: S) => Schema.optional(Schema.Array(schema)) +export const optionalNull = (schema: S) => Schema.optional(Schema.NullOr(schema)) + +/** + * Plain-record narrowing. Excludes arrays so routes checking nested JSON + * Schema fragments don't accidentally treat a tuple as a key/value bag. + */ +export const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value) + +/** + * Streaming tool-call accumulator. Adapters that build a tool call across + * multiple `tool-input-delta` chunks store the partial JSON input string here + * and finalize it with `parseToolInput` once the call completes. + */ +export interface ToolAccumulator { + readonly id: string + readonly name: string + readonly input: string +} + +/** + * `Usage.totalTokens` policy shared by every route. Honors a provider- + * supplied total; otherwise falls back to `inputTokens + outputTokens` only + * when at least one is defined. Returns `undefined` when neither input nor + * output is known so routes don't publish a misleading `0`. + * + * Under the additive `LLM.Usage` contract, `inputTokens` and `outputTokens` + * are the non-cached input and visible output only. The provider-supplied + * `total` is the source of truth when present; the computed fallback + * under-counts cache and reasoning by design and exists mainly so + * Anthropic-style providers (which don't surface a total) still get a + * sensible aggregate on the input + output axes. + */ +export const totalTokens = ( + inputTokens: number | undefined, + outputTokens: number | undefined, + total: number | undefined, +) => { + if (total !== undefined) return total + if (inputTokens === undefined && outputTokens === undefined) return undefined + return (inputTokens ?? 0) + (outputTokens ?? 0) +} + +/** + * Subtract `subtrahend` from `total`, clamping to zero if the provider + * reports a non-sensical breakdown (e.g. `cached_tokens > prompt_tokens`). + * Used by protocol mappers when deriving a non-overlapping breakdown field + * from a provider's inclusive total — `nonCachedInputTokens` from + * `inputTokens - cacheReadInputTokens - cacheWriteInputTokens`. + * + * If `total` is `undefined`, returns `undefined` (we don't fabricate + * counts). If `subtrahend` is `undefined`, returns `total` unchanged. The + * provider-native breakdown stays available on `Usage.native` for debugging. + */ +export const subtractTokens = (total: number | undefined, subtrahend: number | undefined): number | undefined => { + if (total === undefined) return undefined + if (subtrahend === undefined) return total + return Math.max(0, total - subtrahend) +} + +/** + * Sum a list of optional token counts, returning `undefined` only when + * every value is `undefined` (so we don't fabricate a `0`). Used by + * protocol mappers to derive the inclusive `inputTokens` total from a + * provider that natively reports a non-overlapping breakdown + * (e.g. Anthropic, whose `input_tokens` is already non-cached only). + */ +export const sumTokens = (...values: ReadonlyArray): number | undefined => { + if (values.every((value) => value === undefined)) return undefined + return values.reduce((acc, value) => acc + (value ?? 0), 0) +} + +export const eventError = (route: string, message: string, raw?: string) => + new LLMError({ + module: "ProviderShared", + method: "stream", + reason: new InvalidProviderOutputReason({ route, message, raw }), + }) + +export const parseJson = (route: string, input: string, message: string) => + Effect.try({ + try: () => decodeJson(input), + catch: () => eventError(route, message, input), + }) + +/** + * Join the `text` field of a list of parts with newlines. Used by routes + * that flatten system / message content arrays into a single provider string + * (OpenAI Chat `system` content, OpenAI Responses `system` content, Gemini + * `systemInstruction.parts[].text`). + */ +export const joinText = (parts: ReadonlyArray<{ readonly text: string }>) => parts.map((part) => part.text).join("\n") + +/** + * Parse the streamed JSON input of a tool call. Treats an empty string as + * `"{}"` — providers occasionally finish a tool call without ever emitting + * input deltas (e.g. zero-arg tools). The error message is uniform across + * routes: `Invalid JSON input for tool call `. + */ +export const parseToolInput = (route: string, name: string, raw: string) => + parseJson(route, raw || "{}", `Invalid JSON input for ${route} tool call ${name}`) + +/** + * Encode a `MediaPart`'s raw bytes for inclusion in a JSON request body. + * `data: string` is assumed to already be base64 (matches caller convention + * across Gemini / Bedrock); `data: Uint8Array` is base64-encoded here. Used + * by every route that supports image / document inputs. + */ +export const mediaBytes = (part: MediaPart) => + typeof part.data === "string" ? part.data : Buffer.from(part.data).toString("base64") + +export const trimBaseUrl = (value: string) => value.replace(/\/+$/, "") + +export const toolResultText = (part: ToolResultPart) => { + if (part.result.type === "text" || part.result.type === "error") return String(part.result.value) + return encodeJson(part.result.value) +} + +export const errorText = (error: unknown) => { + if (error instanceof Error) return error.message + if (typeof error === "string") return error + if (typeof error === "number" || typeof error === "boolean" || typeof error === "bigint") return String(error) + if (error === null) return "null" + if (error === undefined) return "undefined" + return "Unknown stream error" +} + +/** + * `framing` step for Server-Sent Events. Decodes UTF-8, runs the SSE channel + * decoder, and drops empty / `[DONE]` keep-alive events so the downstream + * `decodeChunk` sees one JSON string per element. The SSE channel emits a + * `Retry` control event on its error channel; we drop it here (we don't + * implement client-driven retries) so the public error channel stays + * `LLMError`. + */ +export const sseFraming = (bytes: Stream.Stream): Stream.Stream => + bytes.pipe( + Stream.decodeText(), + Stream.pipeThroughChannel(Sse.decode()), + Stream.catchTag("Retry", () => Stream.empty), + Stream.filter((event) => event.data.length > 0 && event.data !== "[DONE]"), + Stream.map((event) => event.data), + ) + +/** + * Canonical invalid-request constructor. Lift one-line `const invalid = + * (message) => invalidRequest(message)` aliases out of every + * route so the error constructor lives in one place. If we ever extend + * `InvalidRequestReason` with route context or trace metadata, the change + * lands here. + */ +export const invalidRequest = (message: string) => + new LLMError({ + module: "ProviderShared", + method: "request", + reason: new InvalidRequestReason({ message }), + }) + +export const matchToolChoice = ( + route: string, + toolChoice: NonNullable, + cases: { + readonly auto: () => Auto + readonly none: () => None + readonly required: () => Required + readonly tool: (name: string) => Tool + }, +) => + Effect.gen(function* () { + if (toolChoice.type === "auto") return cases.auto() + if (toolChoice.type === "none") return cases.none() + if (toolChoice.type === "required") return cases.required() + if (!toolChoice.name) return yield* invalidRequest(`${route} tool choice requires a tool name`) + return cases.tool(toolChoice.name) + }) + +type ContentType = ContentPart["type"] + +const formatContentTypes = (types: ReadonlyArray) => { + if (types.length <= 1) return types[0] ?? "" + if (types.length === 2) return `${types[0]} and ${types[1]}` + return `${types.slice(0, -1).join(", ")}, and ${types.at(-1)}` +} + +export const supportsContent = ( + part: ContentPart, + types: ReadonlyArray, +): part is Extract => (types as ReadonlyArray).includes(part.type) + +export const unsupportedContent = ( + route: string, + role: LLMRequest["messages"][number]["role"], + types: ReadonlyArray, +) => invalidRequest(`${route} ${role} messages only support ${formatContentTypes(types)} content for now`) + +/** + * Build a `validate` step from a Schema decoder. Replaces the per-route + * lambda body `(payload) => decode(payload).pipe(Effect.mapError((e) => + * invalid(e.message)))`. Any decode error is translated into + * `LLMError` carrying the original parse-error message. + */ +export const validateWith = + (decode: (input: I) => Effect.Effect) => + (payload: I) => + decode(payload).pipe(Effect.mapError((error) => invalidRequest(error.message))) + +/** + * Build an HTTP POST with a JSON body. Sets `content-type: application/json` + * automatically after caller-supplied headers so routes cannot accidentally + * send JSON with a stale content type. The body is passed pre-encoded so + * routes can choose between + * `Schema.encodeSync(payload)` and `ProviderShared.encodeJson(payload)`. + */ +export const jsonPost = (input: { readonly url: string; readonly body: string; readonly headers?: Headers.Input }) => + HttpClientRequest.post(input.url).pipe( + HttpClientRequest.setHeaders(Headers.set(Headers.fromInput(input.headers), "content-type", "application/json")), + HttpClientRequest.bodyText(input.body, "application/json"), + ) + +export * as ProviderShared from "./shared" diff --git a/packages/llm/src/protocols/utils/bedrock-auth.ts b/packages/llm/src/protocols/utils/bedrock-auth.ts new file mode 100644 index 0000000000..58d16d95f8 --- /dev/null +++ b/packages/llm/src/protocols/utils/bedrock-auth.ts @@ -0,0 +1,103 @@ +import { AwsV4Signer } from "aws4fetch" +import { Effect, Option, Schema } from "effect" +import { Headers } from "effect/unstable/http" +import { Auth, type AuthInput } from "../../route/auth" +import type { LLMRequest } from "../../schema" +import { ProviderShared } from "../shared" + +/** + * AWS credentials for SigV4 signing. Bedrock also supports Bearer API key auth + * via `model.apiKey`, which bypasses SigV4 signing. STS-vended credentials + * should be refreshed by the consumer (rebuild the model) before they expire; + * the route does not refresh. + */ +export interface Credentials { + readonly region: string + readonly accessKeyId: string + readonly secretAccessKey: string + readonly sessionToken?: string +} + +const NativeCredentials = Schema.Struct({ + accessKeyId: Schema.String, + secretAccessKey: Schema.String, + region: Schema.optional(Schema.String), + sessionToken: Schema.optional(Schema.String), +}) + +const decodeNativeCredentials = Schema.decodeUnknownOption(NativeCredentials) + +export const region = (request: LLMRequest) => { + const fromNative = request.model.native?.aws_region + if (typeof fromNative === "string" && fromNative !== "") return fromNative + return ( + decodeNativeCredentials(request.model.native?.aws_credentials).pipe( + Option.map((credentials) => credentials.region), + Option.getOrUndefined, + ) ?? "us-east-1" + ) +} + +const credentialsFromInput = (request: LLMRequest): Credentials | undefined => + decodeNativeCredentials(request.model.native?.aws_credentials).pipe( + Option.map((creds) => ({ ...creds, region: creds.region ?? region(request) })), + Option.getOrUndefined, + ) + +const signRequest = (input: { + readonly url: string + readonly body: string + readonly headers: Headers.Headers + readonly credentials: Credentials +}) => + Effect.tryPromise({ + try: async () => { + const signed = await new AwsV4Signer({ + url: input.url, + method: "POST", + headers: Object.entries(input.headers), + body: input.body, + region: input.credentials.region, + accessKeyId: input.credentials.accessKeyId, + secretAccessKey: input.credentials.secretAccessKey, + sessionToken: input.credentials.sessionToken, + service: "bedrock", + }).sign() + return Object.fromEntries(signed.headers.entries()) + }, + catch: (error) => + ProviderShared.invalidRequest( + `Bedrock Converse SigV4 signing failed: ${error instanceof Error ? error.message : String(error)}`, + ), + }) + +/** + * Bedrock auth. `model.apiKey` (Bedrock's newer Bearer API key auth) wins if + * set; otherwise sign the exact JSON bytes with SigV4 using credentials from + * `model.native.aws_credentials`. + */ +export const auth = Auth.custom((input: AuthInput) => { + if (input.request.model.apiKey) return Auth.toEffect(Auth.bearer())(input) + return Effect.gen(function* () { + const credentials = credentialsFromInput(input.request) + if (!credentials) { + return yield* ProviderShared.invalidRequest( + "Bedrock Converse requires either model.apiKey or AWS credentials in model.native.aws_credentials", + ) + } + const headersForSigning = Headers.set(input.headers, "content-type", "application/json") + const signed = yield* signRequest({ url: input.url, body: input.body, headers: headersForSigning, credentials }) + return Headers.setAll(headersForSigning, signed) + }) +}) + +export const nativeCredentials = (native: Record | undefined, credentials: Credentials | undefined) => + credentials + ? { + ...native, + aws_credentials: credentials, + aws_region: credentials.region, + } + : native + +export * as BedrockAuth from "./bedrock-auth" diff --git a/packages/llm/src/protocols/utils/bedrock-cache.ts b/packages/llm/src/protocols/utils/bedrock-cache.ts new file mode 100644 index 0000000000..fab4d07b5c --- /dev/null +++ b/packages/llm/src/protocols/utils/bedrock-cache.ts @@ -0,0 +1,37 @@ +import { Schema } from "effect" +import type { CacheHint } from "../../schema" +import { newBreakpoints, ttlBucket, type Breakpoints } from "./cache" + +// Bedrock cache markers are positional: emit a `cachePoint` block immediately +// after the content the caller wants treated as a cacheable prefix. Bedrock +// accepts optional `ttl: "5m" | "1h"` on cachePoint, mirroring Anthropic. +export const CachePointBlock = Schema.Struct({ + cachePoint: Schema.Struct({ + type: Schema.tag("default"), + ttl: Schema.optional(Schema.Literals(["5m", "1h"])), + }), +}) +export type CachePointBlock = Schema.Schema.Type + +// Bedrock-Claude enforces the same 4-breakpoint cap as the Anthropic Messages +// API. Callers pass a shared counter through every `block()` call site so the +// budget is respected across `system`, `messages`, and `tools`. +export const BEDROCK_BREAKPOINT_CAP = 4 + +export type { Breakpoints } from "./cache" +export const breakpoints = () => newBreakpoints(BEDROCK_BREAKPOINT_CAP) + +const DEFAULT_5M: CachePointBlock = { cachePoint: { type: "default" } } +const DEFAULT_1H: CachePointBlock = { cachePoint: { type: "default", ttl: "1h" } } + +export const block = (breakpoints: Breakpoints, cache: CacheHint | undefined): CachePointBlock | undefined => { + if (cache?.type !== "ephemeral" && cache?.type !== "persistent") return undefined + if (breakpoints.remaining <= 0) { + breakpoints.dropped += 1 + return undefined + } + breakpoints.remaining -= 1 + return ttlBucket(cache.ttlSeconds) === "1h" ? DEFAULT_1H : DEFAULT_5M +} + +export * as BedrockCache from "./bedrock-cache" diff --git a/packages/llm/src/protocols/utils/bedrock-media.ts b/packages/llm/src/protocols/utils/bedrock-media.ts new file mode 100644 index 0000000000..0fbb396f96 --- /dev/null +++ b/packages/llm/src/protocols/utils/bedrock-media.ts @@ -0,0 +1,80 @@ +import { Effect, Schema } from "effect" +import type { MediaPart } from "../../schema" +import { ProviderShared } from "../shared" + +// Bedrock Converse accepts image `format` as the file extension and +// `source.bytes` as base64 in the JSON wire format. +export const ImageFormat = Schema.Literals(["png", "jpeg", "gif", "webp"]) +export type ImageFormat = Schema.Schema.Type + +export const ImageBlock = Schema.Struct({ + image: Schema.Struct({ + format: ImageFormat, + source: Schema.Struct({ bytes: Schema.String }), + }), +}) +export type ImageBlock = Schema.Schema.Type + +// Bedrock document blocks require a user-facing name so the model can refer to +// the uploaded document. +export const DocumentFormat = Schema.Literals(["pdf", "csv", "doc", "docx", "xls", "xlsx", "html", "txt", "md"]) +export type DocumentFormat = Schema.Schema.Type + +export const DocumentBlock = Schema.Struct({ + document: Schema.Struct({ + format: DocumentFormat, + name: Schema.String, + source: Schema.Struct({ bytes: Schema.String }), + }), +}) +export type DocumentBlock = Schema.Schema.Type + +const IMAGE_FORMATS = { + "image/png": "png", + "image/jpeg": "jpeg", + "image/jpg": "jpeg", + "image/gif": "gif", + "image/webp": "webp", +} as const satisfies Record + +const DOCUMENT_FORMATS = { + "application/pdf": "pdf", + "text/csv": "csv", + "application/msword": "doc", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document": "docx", + "application/vnd.ms-excel": "xls", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": "xlsx", + "text/html": "html", + "text/plain": "txt", + "text/markdown": "md", +} as const satisfies Record + +const imageBlock = (part: MediaPart, format: ImageFormat): ImageBlock => ({ + image: { format, source: { bytes: ProviderShared.mediaBytes(part) } }, +}) + +const documentBlock = (part: MediaPart, format: DocumentFormat): DocumentBlock => ({ + document: { + format, + name: part.filename ?? `document.${format}`, + source: { bytes: ProviderShared.mediaBytes(part) }, + }, +}) + +// Route by MIME. Known image/document formats lower into a typed block; anything +// else fails with a clear error instead of silently degrading to a malformed +// document block. Image MIME types not in `IMAGE_FORMATS` (e.g. `image/svg+xml`) +// get an image-specific error so the caller knows it's a format-support issue, +// not a kind-detection issue. +export const lower = (part: MediaPart) => { + const mime = part.mediaType.toLowerCase() + const imageFormat = IMAGE_FORMATS[mime as keyof typeof IMAGE_FORMATS] + if (imageFormat) return Effect.succeed(imageBlock(part, imageFormat)) + if (mime.startsWith("image/")) + return ProviderShared.invalidRequest(`Bedrock Converse does not support image media type ${part.mediaType}`) + const documentFormat = DOCUMENT_FORMATS[mime as keyof typeof DOCUMENT_FORMATS] + if (documentFormat) return Effect.succeed(documentBlock(part, documentFormat)) + return ProviderShared.invalidRequest(`Bedrock Converse does not support media type ${part.mediaType}`) +} + +export * as BedrockMedia from "./bedrock-media" diff --git a/packages/llm/src/protocols/utils/cache.ts b/packages/llm/src/protocols/utils/cache.ts new file mode 100644 index 0000000000..dd3e213e0e --- /dev/null +++ b/packages/llm/src/protocols/utils/cache.ts @@ -0,0 +1,16 @@ +// Shared helpers for provider cache-marker lowering. Anthropic and Bedrock +// both enforce a 4-breakpoint cap per request and accept the same `5m`/`1h` +// TTL buckets, so the counter and TTL mapping live here. + +export interface Breakpoints { + remaining: number + dropped: number +} + +export const newBreakpoints = (cap: number): Breakpoints => ({ remaining: cap, dropped: 0 }) + +// Returns `"1h"` for any `ttlSeconds >= 3600`, otherwise `undefined` (the +// provider default 5m). Anthropic & Bedrock both treat anything shorter than +// an hour as 5m. +export const ttlBucket = (ttlSeconds: number | undefined): "1h" | undefined => + ttlSeconds !== undefined && ttlSeconds >= 3600 ? "1h" : undefined diff --git a/packages/llm/src/protocols/utils/gemini-tool-schema.ts b/packages/llm/src/protocols/utils/gemini-tool-schema.ts new file mode 100644 index 0000000000..7690b2e600 --- /dev/null +++ b/packages/llm/src/protocols/utils/gemini-tool-schema.ts @@ -0,0 +1,101 @@ +import { ProviderShared } from "../shared" + +// Gemini accepts a JSON Schema-like dialect for tool parameters, but rejects a +// handful of common JSON Schema shapes. Keep this projection isolated so the +// Gemini protocol file still reads like the other protocol modules. +const SCHEMA_INTENT_KEYS = [ + "type", + "properties", + "items", + "prefixItems", + "enum", + "const", + "$ref", + "additionalProperties", + "patternProperties", + "required", + "not", + "if", + "then", + "else", +] + +const isRecord = ProviderShared.isRecord + +const hasCombiner = (schema: unknown) => + isRecord(schema) && (Array.isArray(schema.anyOf) || Array.isArray(schema.oneOf) || Array.isArray(schema.allOf)) + +const hasSchemaIntent = (schema: unknown) => + isRecord(schema) && (hasCombiner(schema) || SCHEMA_INTENT_KEYS.some((key) => key in schema)) + +const sanitizeNode = (schema: unknown): unknown => { + if (!isRecord(schema)) return Array.isArray(schema) ? schema.map(sanitizeNode) : schema + + const result: Record = Object.fromEntries( + Object.entries(schema).map(([key, value]) => [ + key, + key === "enum" && Array.isArray(value) ? value.map(String) : sanitizeNode(value), + ]), + ) + + if (Array.isArray(result.enum) && (result.type === "integer" || result.type === "number")) result.type = "string" + + const properties = result.properties + if (result.type === "object" && isRecord(properties) && Array.isArray(result.required)) { + result.required = result.required.filter((field) => typeof field === "string" && field in properties) + } + + if (result.type === "array" && !hasCombiner(result)) { + result.items = result.items ?? {} + if (isRecord(result.items) && !hasSchemaIntent(result.items)) result.items = { ...result.items, type: "string" } + } + + if (typeof result.type === "string" && result.type !== "object" && !hasCombiner(result)) { + delete result.properties + delete result.required + } + + return result +} + +const emptyObjectSchema = (schema: Record) => + schema.type === "object" && + (!isRecord(schema.properties) || Object.keys(schema.properties).length === 0) && + !schema.additionalProperties + +const projectNode = (schema: unknown): Record | undefined => { + if (!isRecord(schema)) return undefined + if (emptyObjectSchema(schema)) return undefined + return Object.fromEntries( + [ + ["description", schema.description], + ["required", schema.required], + ["format", schema.format], + ["type", Array.isArray(schema.type) ? schema.type.filter((type) => type !== "null")[0] : schema.type], + ["nullable", Array.isArray(schema.type) && schema.type.includes("null") ? true : undefined], + ["enum", schema.const !== undefined ? [schema.const] : schema.enum], + [ + "properties", + isRecord(schema.properties) + ? Object.fromEntries(Object.entries(schema.properties).map(([key, value]) => [key, projectNode(value)])) + : undefined, + ], + [ + "items", + Array.isArray(schema.items) + ? schema.items.map(projectNode) + : schema.items === undefined + ? undefined + : projectNode(schema.items), + ], + ["allOf", Array.isArray(schema.allOf) ? schema.allOf.map(projectNode) : undefined], + ["anyOf", Array.isArray(schema.anyOf) ? schema.anyOf.map(projectNode) : undefined], + ["oneOf", Array.isArray(schema.oneOf) ? schema.oneOf.map(projectNode) : undefined], + ["minLength", schema.minLength], + ].filter((entry) => entry[1] !== undefined), + ) +} + +export const convert = (schema: unknown) => projectNode(sanitizeNode(schema)) + +export * as GeminiToolSchema from "./gemini-tool-schema" diff --git a/packages/llm/src/protocols/utils/openai-options.ts b/packages/llm/src/protocols/utils/openai-options.ts new file mode 100644 index 0000000000..080ef83f50 --- /dev/null +++ b/packages/llm/src/protocols/utils/openai-options.ts @@ -0,0 +1,55 @@ +import { Schema } from "effect" +import type { LLMRequest, ReasoningEffort, TextVerbosity as TextVerbosityValue } from "../../schema" +import { ReasoningEfforts, TextVerbosity } from "../../schema" + +export const OpenAIReasoningEfforts = ReasoningEfforts.filter( + (effort): effort is Exclude => effort !== "max", +) +export type OpenAIReasoningEffort = (typeof OpenAIReasoningEfforts)[number] + +const REASONING_EFFORTS = new Set(ReasoningEfforts) +const OPENAI_REASONING_EFFORTS = new Set(OpenAIReasoningEfforts) +const TEXT_VERBOSITY = new Set(["low", "medium", "high"]) + +export const OpenAIReasoningEffort = Schema.Literals(OpenAIReasoningEfforts) +export const OpenAITextVerbosity = TextVerbosity + +const isAnyReasoningEffort = (effort: unknown): effort is ReasoningEffort => + typeof effort === "string" && REASONING_EFFORTS.has(effort) + +export const isReasoningEffort = (effort: unknown): effort is OpenAIReasoningEffort => + typeof effort === "string" && OPENAI_REASONING_EFFORTS.has(effort) + +const isTextVerbosity = (value: unknown): value is TextVerbosityValue => + typeof value === "string" && TEXT_VERBOSITY.has(value) + +const options = (request: LLMRequest) => request.providerOptions?.openai + +export const store = (request: LLMRequest): boolean | undefined => { + const value = options(request)?.store + return typeof value === "boolean" ? value : undefined +} + +export const reasoningEffort = (request: LLMRequest): ReasoningEffort | undefined => { + const value = options(request)?.reasoningEffort + return isAnyReasoningEffort(value) ? value : undefined +} + +export const reasoningSummary = (request: LLMRequest): "auto" | undefined => { + return options(request)?.reasoningSummary === "auto" ? "auto" : undefined +} + +export const encryptedReasoning = (request: LLMRequest) => + options(request)?.includeEncryptedReasoning === true ? true : undefined + +export const promptCacheKey = (request: LLMRequest) => { + const value = options(request)?.promptCacheKey + return typeof value === "string" ? value : undefined +} + +export const textVerbosity = (request: LLMRequest) => { + const value = options(request)?.textVerbosity + return isTextVerbosity(value) ? value : undefined +} + +export * as OpenAIOptions from "./openai-options" diff --git a/packages/llm/src/protocols/utils/tool-stream.ts b/packages/llm/src/protocols/utils/tool-stream.ts new file mode 100644 index 0000000000..aa9c70f017 --- /dev/null +++ b/packages/llm/src/protocols/utils/tool-stream.ts @@ -0,0 +1,186 @@ +import { Effect } from "effect" +import { LLMError, LLMEvent, type ProviderMetadata, type ToolCall, type ToolInputDelta } from "../../schema" +import { eventError, parseToolInput, type ToolAccumulator } from "../shared" + +type StreamKey = string | number + +/** + * One pending streamed tool call. Providers emit the tool identity and JSON + * argument text across separate chunks; `input` is the raw JSON string collected + * so far, not the parsed object. + */ +export interface PendingTool extends ToolAccumulator { + readonly providerExecuted?: boolean + readonly providerMetadata?: ProviderMetadata +} + +/** + * Sparse parser state keyed by the provider's stream-local tool identifier. + * + * This key is not the final tool-call id (`call_...`). It is the id/index the + * provider uses while streaming a partial call: OpenAI Chat / Anthropic / + * Bedrock use numeric content indexes, while OpenAI Responses uses string + * `item_id`s. The generic keeps each protocol internally consistent. + */ +export type State = Partial> + +/** + * Result of adding argument text to one pending tool call. It returns both the + * next `tools` state and the updated `tool` because parsers often need the + * current id/name immediately. `event` is present only when new text arrived; + * metadata-only deltas update identity without emitting `tool-input-delta`. + */ +export interface AppendOutcome { + readonly tools: State + readonly tool: PendingTool + readonly event?: ToolInputDelta +} + +/** Create empty accumulator state for one provider stream. */ +export const empty = (): State => ({}) + +const withTool = (tools: State, key: K, tool: PendingTool): State => { + return { ...tools, [key]: tool } +} + +const withoutTool = (tools: State, key: K): State => { + const next = { ...tools } + delete next[key] + return next +} + +const inputDelta = (tool: PendingTool, text: string): ToolInputDelta => + LLMEvent.toolInputDelta({ + id: tool.id, + name: tool.name, + text, + }) + +const toolCall = (route: string, tool: PendingTool, inputOverride?: string) => + parseToolInput(route, tool.name, inputOverride ?? tool.input).pipe( + Effect.map( + (input): ToolCall => + LLMEvent.toolCall({ + id: tool.id, + name: tool.name, + input, + providerExecuted: tool.providerExecuted ? true : undefined, + providerMetadata: tool.providerMetadata, + }), + ), + ) + +/** Store the updated tool and produce the optional public delta event. */ +const appendTool = ( + tools: State, + key: K, + tool: PendingTool, + text: string, +): AppendOutcome => ({ + tools: withTool(tools, key, tool), + tool, + event: text.length === 0 ? undefined : inputDelta(tool, text), +}) + +export const isError = (result: AppendOutcome | LLMError): result is LLMError => + result instanceof LLMError + +/** + * Register a tool call whose start event arrived before any argument deltas. + * Used by Anthropic `content_block_start`, Bedrock `contentBlockStart`, and + * OpenAI Responses `response.output_item.added`. + */ +export const start = ( + tools: State, + key: K, + tool: Omit & { readonly input?: string }, +) => withTool(tools, key, { ...tool, input: tool.input ?? "" }) + +/** + * Append a streamed argument delta, starting the tool if this provider encodes + * identity on the first delta instead of a separate start event. OpenAI Chat has + * this shape: `tool_calls[].index` is the stream key, and `id` / `name` may only + * appear on the first delta for that index. + */ +export const appendOrStart = ( + route: string, + tools: State, + key: K, + delta: { readonly id?: string; readonly name?: string; readonly text: string }, + missingToolMessage: string, +): AppendOutcome | LLMError => { + const current = tools[key] + const id = delta.id ?? current?.id + const name = delta.name ?? current?.name + if (!id || !name) return eventError(route, missingToolMessage) + + const tool = { + id, + name, + input: `${current?.input ?? ""}${delta.text}`, + providerExecuted: current?.providerExecuted, + providerMetadata: current?.providerMetadata, + } + if (current && delta.text.length === 0 && current.id === id && current.name === name) return { tools, tool: current } + return appendTool(tools, key, tool, delta.text) +} + +/** + * Append argument text to a tool that must already have been started. This keeps + * protocols honest when their stream grammar promises a start event before any + * argument delta. + */ +export const appendExisting = ( + route: string, + tools: State, + key: K, + text: string, + missingToolMessage: string, +): AppendOutcome | LLMError => { + const current = tools[key] + if (!current) return eventError(route, missingToolMessage) + if (text.length === 0) return { tools, tool: current } + return appendTool(tools, key, { ...current, input: `${current.input}${text}` }, text) +} + +/** + * Finalize one pending tool call: parse the accumulated raw JSON, remove it + * from state, and return the optional public `tool-call` event. Missing keys are + * a no-op because some providers emit stop events for non-tool content blocks. + */ +export const finish = (route: string, tools: State, key: K) => + Effect.gen(function* () { + const tool = tools[key] + if (!tool) return { tools } + return { tools: withoutTool(tools, key), event: yield* toolCall(route, tool) } + }) + +/** + * Finalize one pending tool call with an authoritative final input string. + * OpenAI Responses can send accumulated deltas and then repeat the completed + * arguments on `response.output_item.done`; the final value wins. + */ +export const finishWithInput = (route: string, tools: State, key: K, input: string) => + Effect.gen(function* () { + const tool = tools[key] + if (!tool) return { tools } + return { tools: withoutTool(tools, key), event: yield* toolCall(route, tool, input) } + }) + +/** + * Finalize every pending tool call at once. OpenAI Chat has this shape: it does + * not emit per-tool stop events, so all accumulated calls finish when the choice + * receives a terminal `finish_reason`. + */ +export const finishAll = (route: string, tools: State) => + Effect.gen(function* () { + const pending = Object.values(tools).filter( + (tool): tool is PendingTool => tool !== undefined, + ) + return { + tools: empty(), + events: yield* Effect.forEach(pending, (tool) => toolCall(route, tool)), + } + }) + +export * as ToolStream from "./tool-stream" diff --git a/packages/llm/src/provider.ts b/packages/llm/src/provider.ts new file mode 100644 index 0000000000..8299b5865c --- /dev/null +++ b/packages/llm/src/provider.ts @@ -0,0 +1,31 @@ +import type { RouteModelInput } from "./route/client" +import type { ModelID, ModelRef, ProviderID } from "./schema" + +export type ModelOptions = Omit + +export type ModelFactory = ( + id: string | ModelID, + options?: Options, +) => ModelRef + +type AnyModelFactory = (...args: never[]) => ModelRef + +export interface Definition { + readonly id: ProviderID + readonly model: Factory + readonly apis?: Record +} + +type DefinitionShape = { + readonly id: ProviderID + readonly model: (...args: never[]) => ModelRef + readonly apis?: Record ModelRef> +} + +type NoExtraFields = Input & Record, never> + +export const make = ( + definition: NoExtraFields, +) => definition + +export * as Provider from "./provider" diff --git a/packages/llm/src/providers/amazon-bedrock.ts b/packages/llm/src/providers/amazon-bedrock.ts new file mode 100644 index 0000000000..82408d514e --- /dev/null +++ b/packages/llm/src/providers/amazon-bedrock.ts @@ -0,0 +1,48 @@ +import { Route, type RouteModelInput } from "../route/client" +import { Provider } from "../provider" +import { ProviderID, type ModelID } from "../schema" +import * as BedrockConverse from "../protocols/bedrock-converse" +import type { BedrockCredentials } from "../protocols/bedrock-converse" + +export const id = ProviderID.make("amazon-bedrock") + +export type ModelOptions = Omit & { + readonly apiKey?: string + readonly headers?: Record + readonly credentials?: BedrockCredentials + /** AWS region. Defaults to `us-east-1` when neither this nor `credentials.region` is set. */ + readonly region?: string + /** Override the computed `https://bedrock-runtime..amazonaws.com` URL. */ + readonly baseURL?: string +} +type ModelInput = ModelOptions & Pick + +export const routes = [BedrockConverse.route] + +const bedrockBaseURL = (region: string) => `https://bedrock-runtime.${region}.amazonaws.com` + +const converseModel = Route.model( + BedrockConverse.route, + { + provider: "amazon-bedrock", + }, + { + mapInput: (input) => { + const { credentials, region, baseURL, ...rest } = input + const resolvedRegion = region ?? credentials?.region ?? "us-east-1" + return { + ...rest, + baseURL: baseURL ?? bedrockBaseURL(resolvedRegion), + native: BedrockConverse.nativeCredentials(input.native, credentials), + } + }, + }, +) + +export const model = (modelID: string | ModelID, options: ModelOptions = {}) => + converseModel({ ...options, id: modelID }) + +export const provider = Provider.make({ + id, + model, +}) diff --git a/packages/llm/src/providers/anthropic.ts b/packages/llm/src/providers/anthropic.ts new file mode 100644 index 0000000000..cca12bf7c2 --- /dev/null +++ b/packages/llm/src/providers/anthropic.ts @@ -0,0 +1,18 @@ +import type { RouteModelInput } from "../route/client" +import { Provider } from "../provider" +import { ProviderID, type ModelID } from "../schema" +import * as AnthropicMessages from "../protocols/anthropic-messages" + +export const id = ProviderID.make("anthropic") + +export const routes = [AnthropicMessages.route] + +export const model = ( + id: string | ModelID, + options: Omit & { readonly baseURL?: string } = {}, +) => AnthropicMessages.model({ ...options, id }) + +export const provider = Provider.make({ + id, + model, +}) diff --git a/packages/llm/src/providers/azure.ts b/packages/llm/src/providers/azure.ts new file mode 100644 index 0000000000..8d60fb6669 --- /dev/null +++ b/packages/llm/src/providers/azure.ts @@ -0,0 +1,83 @@ +import { Auth } from "../route/auth" +import { type AtLeastOne, type ProviderAuthOption } from "../route/auth-options" +import { Route } from "../route/client" +import type { ModelInput } from "../llm" +import { Provider } from "../provider" +import { ProviderID, type ModelID } from "../schema" +import * as OpenAIChat from "../protocols/openai-chat" +import * as OpenAIResponses from "../protocols/openai-responses" +import { withOpenAIOptions, type OpenAIProviderOptionsInput } from "./openai-options" + +export const id = ProviderID.make("azure") +const routeAuth = Auth.remove("authorization").andThen(Auth.apiKeyHeader("api-key")) + +// Azure needs the customer's resource URL; supply either `resourceName` +// (helper builds the URL) or `baseURL` directly. +type AzureURL = AtLeastOne<{ readonly resourceName: string; readonly baseURL: string }> + +export type ModelOptions = AzureURL & + Omit & + ProviderAuthOption<"optional"> & { + readonly apiVersion?: string + readonly useCompletionUrls?: boolean + readonly providerOptions?: OpenAIProviderOptionsInput + } +type AzureModelInput = ModelOptions & Pick + +const resourceBaseURL = (resourceName: string) => `https://${resourceName.trim()}.openai.azure.com/openai/v1` + +const responsesRoute = OpenAIResponses.route.with({ + id: "azure-openai-responses", + provider: id, + transport: OpenAIResponses.httpTransport.with({ auth: routeAuth }), +}) + +const chatRoute = OpenAIChat.route.with({ + id: "azure-openai-chat", + provider: id, + transport: OpenAIChat.httpTransport.with({ auth: routeAuth }), +}) + +export const routes = [responsesRoute, chatRoute] + +const mapInput = (input: AzureModelInput) => { + const { apiKey: _, apiVersion, resourceName, useCompletionUrls, ...rest } = input + return { + ...withOpenAIOptions(input.id, rest), + auth: + "auth" in input && input.auth + ? input.auth + : Auth.remove("authorization").andThen( + Auth.optional("apiKey" in input ? input.apiKey : undefined, "apiKey") + .orElse(Auth.config("AZURE_OPENAI_API_KEY")) + .pipe(Auth.header("api-key")), + ), + // AtLeastOne guarantees at least one is set; baseURL wins if both are. + baseURL: rest.baseURL ?? resourceBaseURL(resourceName!), + queryParams: { + ...rest.queryParams, + "api-version": apiVersion ?? rest.queryParams?.["api-version"] ?? "v1", + }, + } +} + +const chatModel = Route.model(chatRoute, {}, { mapInput }) +const responsesModel = Route.model(responsesRoute, {}, { mapInput }) + +export const responses = (modelID: string | ModelID, options: ModelOptions) => + responsesModel({ ...options, id: modelID }) + +export const chat = (modelID: string | ModelID, options: ModelOptions) => chatModel({ ...options, id: modelID }) + +export const model = (modelID: string | ModelID, options: ModelOptions) => { + if (options.useCompletionUrls === true) return chat(modelID, options) + return responses(modelID, options) +} + +export const provider = Provider.make({ + id, + model, + apis: { responses, chat }, +}) + +export const apis = provider.apis diff --git a/packages/llm/src/providers/cloudflare.ts b/packages/llm/src/providers/cloudflare.ts new file mode 100644 index 0000000000..263595a755 --- /dev/null +++ b/packages/llm/src/providers/cloudflare.ts @@ -0,0 +1,139 @@ +import type { Config, Redacted } from "effect" +import { type ModelInput } from "../llm" +import { Provider } from "../provider" +import * as OpenAICompatibleChat from "../protocols/openai-compatible-chat" +import { Auth } from "../route/auth" +import { AuthOptions, type AtLeastOne, type ProviderAuthOption } from "../route/auth-options" +import { Route } from "../route/client" +import { ProviderID, type ModelID } from "../schema" + +export const aiGatewayID = ProviderID.make("cloudflare-ai-gateway") +export const workersAIID = ProviderID.make("cloudflare-workers-ai") +export const id = aiGatewayID +export const aiGatewayAuthEnvVars = ["CLOUDFLARE_API_TOKEN", "CF_AIG_TOKEN"] as const +export const workersAIAuthEnvVars = ["CLOUDFLARE_API_KEY", "CLOUDFLARE_WORKERS_AI_TOKEN"] as const + +type CloudflareSecret = string | Redacted.Redacted | Config.Config> + +type GatewayURL = AtLeastOne<{ + readonly accountId: string + readonly baseURL: string +}> & { + readonly gatewayId?: string +} + +export type AIGatewayOptions = GatewayURL & + Omit & + ProviderAuthOption<"optional"> & { + /** Cloudflare AI Gateway authentication token. Sent as `cf-aig-authorization`. */ + readonly gatewayApiKey?: CloudflareSecret + } + +type AIGatewayInput = AIGatewayOptions & Pick + +type WorkersAIURL = AtLeastOne<{ + readonly accountId: string + readonly baseURL: string +}> + +export type WorkersAIOptions = WorkersAIURL & + Omit & + ProviderAuthOption<"optional"> + +type WorkersAIInput = WorkersAIOptions & Pick + +export const aiGatewayBaseURL = (input: GatewayURL) => { + if (input.baseURL) return input.baseURL + if (!input.accountId) throw new Error("Cloudflare.aiGateway requires accountId unless baseURL is supplied") + return `https://gateway.ai.cloudflare.com/v1/${encodeURIComponent(input.accountId)}/${encodeURIComponent(input.gatewayId?.trim() || "default")}/compat` +} + +const aiGatewayAuth = (input: AIGatewayInput) => { + if ("auth" in input && input.auth) return input.auth + const gateway = Auth.optional(input.gatewayApiKey, "gatewayApiKey") + .orElse(Auth.config("CLOUDFLARE_API_TOKEN")) + .orElse(Auth.config("CF_AIG_TOKEN")) + .pipe(Auth.bearerHeader("cf-aig-authorization")) + if (!("apiKey" in input) || input.apiKey === undefined) return gateway + if (input.gatewayApiKey === undefined) return Auth.bearer(input.apiKey) + return Auth.bearerHeader("cf-aig-authorization", input.gatewayApiKey).andThen(Auth.bearer(input.apiKey)) +} + +export const workersAIBaseURL = (input: WorkersAIURL) => { + if (input.baseURL) return input.baseURL + if (!input.accountId) throw new Error("Cloudflare.workersAI requires accountId unless baseURL is supplied") + return `https://api.cloudflare.com/client/v4/accounts/${encodeURIComponent(input.accountId)}/ai/v1` +} + +const workersAIAuth = (input: WorkersAIInput) => { + return AuthOptions.bearer(input, workersAIAuthEnvVars) +} + +export const aiGatewayRoute = OpenAICompatibleChat.route.with({ + id: "cloudflare-ai-gateway", + provider: aiGatewayID, +}) + +export const workersAIRoute = OpenAICompatibleChat.route.with({ + id: "cloudflare-workers-ai", + provider: workersAIID, +}) + +export const routes = [aiGatewayRoute, workersAIRoute] + +const aiGatewayModel = Route.model( + aiGatewayRoute, + { + provider: id, + }, + { + mapInput: (input) => { + const { + accountId: _accountId, + gatewayId: _gatewayId, + apiKey: _apiKey, + gatewayApiKey: _gatewayApiKey, + auth: _auth, + ...rest + } = input + return { + ...rest, + auth: aiGatewayAuth(input), + baseURL: aiGatewayBaseURL(input), + } + }, + }, +) + +const workersAIModel = Route.model( + workersAIRoute, + { + provider: workersAIID, + }, + { + mapInput: (input) => { + const { accountId: _accountId, apiKey: _apiKey, auth: _auth, ...rest } = input + return { + ...rest, + auth: workersAIAuth(input), + baseURL: workersAIBaseURL(input), + } + }, + }, +) + +export const aiGateway = (modelID: string | ModelID, options: AIGatewayOptions) => + aiGatewayModel({ ...options, id: modelID }) + +export const workersAI = (modelID: string | ModelID, options: WorkersAIOptions) => + workersAIModel({ ...options, id: modelID }) + +export const model = aiGateway + +export const provider = Provider.make({ + id, + model, + apis: { aiGateway, workersAI }, +}) + +export const apis = provider.apis diff --git a/packages/llm/src/providers/github-copilot.ts b/packages/llm/src/providers/github-copilot.ts new file mode 100644 index 0000000000..5de738a3bf --- /dev/null +++ b/packages/llm/src/providers/github-copilot.ts @@ -0,0 +1,48 @@ +import { Route } from "../route/client" +import type { ModelInput } from "../llm" +import { Provider } from "../provider" +import { ProviderID, type ModelID } from "../schema" +import * as OpenAIChat from "../protocols/openai-chat" +import * as OpenAIResponses from "../protocols/openai-responses" +import { withOpenAIOptions, type OpenAIProviderOptionsInput } from "./openai-options" + +export const id = ProviderID.make("github-copilot") + +// GitHub Copilot has no canonical public URL — callers (opencode, etc.) must +// supply `baseURL` explicitly. +export type ModelOptions = Omit & { + readonly providerOptions?: OpenAIProviderOptionsInput +} +type CopilotModelInput = ModelOptions & Pick + +export const shouldUseResponsesApi = (modelID: string | ModelID) => { + const model = String(modelID) + const match = /^gpt-(\d+)/.exec(model) + if (!match) return false + return Number(match[1]) >= 5 && !model.startsWith("gpt-5-mini") +} + +export const routes = [OpenAIResponses.route, OpenAIChat.route] + +const mapInput = (input: CopilotModelInput) => withOpenAIOptions(input.id, input) + +const chatModel = Route.model(OpenAIChat.route, { provider: id }, { mapInput }) +const responsesModel = Route.model(OpenAIResponses.route, { provider: id }, { mapInput }) + +export const responses = (modelID: string | ModelID, options: ModelOptions) => + responsesModel({ ...options, id: modelID }) + +export const chat = (modelID: string | ModelID, options: ModelOptions) => chatModel({ ...options, id: modelID }) + +export const model = (modelID: string | ModelID, options: ModelOptions) => { + const create = shouldUseResponsesApi(modelID) ? responsesModel : chatModel + return create({ ...options, id: modelID }) +} + +export const provider = Provider.make({ + id, + model, + apis: { responses, chat }, +}) + +export const apis = provider.apis diff --git a/packages/llm/src/providers/google.ts b/packages/llm/src/providers/google.ts new file mode 100644 index 0000000000..c03b9a7c25 --- /dev/null +++ b/packages/llm/src/providers/google.ts @@ -0,0 +1,18 @@ +import type { RouteModelInput } from "../route/client" +import { Provider } from "../provider" +import { ProviderID, type ModelID } from "../schema" +import * as Gemini from "../protocols/gemini" + +export const id = ProviderID.make("google") + +export const routes = [Gemini.route] + +export const model = ( + id: string | ModelID, + options: Omit & { readonly baseURL?: string } = {}, +) => Gemini.model({ ...options, id }) + +export const provider = Provider.make({ + id, + model, +}) diff --git a/packages/llm/src/providers/index.ts b/packages/llm/src/providers/index.ts new file mode 100644 index 0000000000..39adbe25c0 --- /dev/null +++ b/packages/llm/src/providers/index.ts @@ -0,0 +1,10 @@ +export * as Anthropic from "./anthropic" +export * as AmazonBedrock from "./amazon-bedrock" +export * as Azure from "./azure" +export * as Cloudflare from "./cloudflare" +export * as GitHubCopilot from "./github-copilot" +export * as Google from "./google" +export * as OpenAI from "./openai" +export * as OpenAICompatible from "./openai-compatible" +export * as OpenRouter from "./openrouter" +export * as XAI from "./xai" diff --git a/packages/llm/src/providers/openai-compatible-profile.ts b/packages/llm/src/providers/openai-compatible-profile.ts new file mode 100644 index 0000000000..30770c9671 --- /dev/null +++ b/packages/llm/src/providers/openai-compatible-profile.ts @@ -0,0 +1,20 @@ +export interface OpenAICompatibleProfile { + readonly provider: string + readonly baseURL: string +} + +export const profiles = { + baseten: { provider: "baseten", baseURL: "https://inference.baseten.co/v1" }, + cerebras: { provider: "cerebras", baseURL: "https://api.cerebras.ai/v1" }, + deepinfra: { provider: "deepinfra", baseURL: "https://api.deepinfra.com/v1/openai" }, + deepseek: { provider: "deepseek", baseURL: "https://api.deepseek.com/v1" }, + fireworks: { provider: "fireworks", baseURL: "https://api.fireworks.ai/inference/v1" }, + groq: { provider: "groq", baseURL: "https://api.groq.com/openai/v1" }, + openrouter: { provider: "openrouter", baseURL: "https://openrouter.ai/api/v1" }, + togetherai: { provider: "togetherai", baseURL: "https://api.together.xyz/v1" }, + xai: { provider: "xai", baseURL: "https://api.x.ai/v1" }, +} as const satisfies Record + +export const byProvider: Record = Object.fromEntries( + Object.values(profiles).map((profile) => [profile.provider, profile]), +) diff --git a/packages/llm/src/providers/openai-compatible.ts b/packages/llm/src/providers/openai-compatible.ts new file mode 100644 index 0000000000..e37dcb4adf --- /dev/null +++ b/packages/llm/src/providers/openai-compatible.ts @@ -0,0 +1,61 @@ +import { Provider } from "../provider" +import { ProviderID, type ModelID } from "../schema" +import * as OpenAICompatibleChat from "../protocols/openai-compatible-chat" +import type { OpenAICompatibleChatModelInput } from "../protocols/openai-compatible-chat" +import { profiles, type OpenAICompatibleProfile } from "./openai-compatible-profile" + +export const id = ProviderID.make("openai-compatible") + +export type ModelOptions = Omit & { + readonly provider: string +} + +type GenericModelOptions = Omit & { + readonly provider?: string +} + +export type FamilyModelOptions = Omit & { + readonly baseURL?: string +} + +export const routes = [OpenAICompatibleChat.route] + +export const model = (id: string | ModelID, options: ModelOptions) => { + return OpenAICompatibleChat.model({ + ...options, + id, + provider: ProviderID.make(options.provider), + }) +} + +export const profileModel = ( + profile: OpenAICompatibleProfile, + id: string | ModelID, + options: FamilyModelOptions = {}, +) => + OpenAICompatibleChat.model({ + ...options, + id, + provider: profile.provider, + baseURL: options.baseURL ?? profile.baseURL, + }) + +const define = (profile: OpenAICompatibleProfile) => + Provider.make({ + id: ProviderID.make(profile.provider), + model: (id: string | ModelID, options: FamilyModelOptions = {}) => profileModel(profile, id, options), + }) + +export const provider = Provider.make({ + id, + model: (id: string | ModelID, options: GenericModelOptions) => + model(id, { ...options, provider: options.provider ?? "openai-compatible" }), +}) + +export const baseten = define(profiles.baseten) +export const cerebras = define(profiles.cerebras) +export const deepinfra = define(profiles.deepinfra) +export const deepseek = define(profiles.deepseek) +export const fireworks = define(profiles.fireworks) +export const groq = define(profiles.groq) +export const togetherai = define(profiles.togetherai) diff --git a/packages/llm/src/providers/openai-options.ts b/packages/llm/src/providers/openai-options.ts new file mode 100644 index 0000000000..8d3980f609 --- /dev/null +++ b/packages/llm/src/providers/openai-options.ts @@ -0,0 +1,70 @@ +import type { ProviderOptions, ReasoningEffort, TextVerbosity } from "../schema" +import { mergeProviderOptions } from "../schema" + +export interface OpenAIOptionsInput { + readonly [key: string]: unknown + readonly store?: boolean + readonly promptCacheKey?: string + readonly reasoningEffort?: ReasoningEffort + readonly reasoningSummary?: "auto" + readonly includeEncryptedReasoning?: boolean + readonly textVerbosity?: TextVerbosity +} + +export type OpenAIProviderOptionsInput = ProviderOptions & { + readonly openai?: OpenAIOptionsInput +} + +const definedEntries = (input: Record) => + Object.entries(input).filter((entry) => entry[1] !== undefined) + +const openAIProviderOptions = (options: OpenAIOptionsInput | undefined): ProviderOptions | undefined => { + const openai = Object.fromEntries( + definedEntries({ + store: options?.store, + promptCacheKey: options?.promptCacheKey, + reasoningEffort: options?.reasoningEffort, + reasoningSummary: options?.reasoningSummary, + includeEncryptedReasoning: options?.includeEncryptedReasoning, + textVerbosity: options?.textVerbosity, + }), + ) + if (Object.keys(openai).length === 0) return undefined + return { openai } +} + +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({ + reasoningEffort: "medium", + reasoningSummary: "auto", + textVerbosity: + 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)) + +export const withOpenAIOptions = ( + modelID: string, + options: Options, + defaults: { readonly textVerbosity?: boolean } = {}, +): Options & { readonly id: string; readonly providerOptions?: ProviderOptions } => { + return { + ...options, + id: modelID, + providerOptions: mergeProviderOptions(openAIDefaultOptions(modelID, defaults), options.providerOptions), + } +} + +export * as OpenAIProviderOptions from "./openai-options" diff --git a/packages/llm/src/providers/openai.ts b/packages/llm/src/providers/openai.ts new file mode 100644 index 0000000000..cbd9b99522 --- /dev/null +++ b/packages/llm/src/providers/openai.ts @@ -0,0 +1,53 @@ +import { AuthOptions, type ProviderAuthOption } from "../route/auth-options" +import type { RouteModelInput } from "../route/client" +import { Provider } from "../provider" +import { ProviderID, type ModelID } from "../schema" +import * as OpenAIChat from "../protocols/openai-chat" +import * as OpenAIResponses from "../protocols/openai-responses" +import { withOpenAIOptions, type OpenAIProviderOptionsInput } from "./openai-options" + +export type { OpenAIOptionsInput } from "./openai-options" + +export const id = ProviderID.make("openai") + +export const routes = [OpenAIResponses.route, OpenAIResponses.webSocketRoute, 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. +type OpenAIModelInput = Omit & + ProviderAuthOption<"optional"> & { + readonly baseURL?: string + readonly providerOptions?: OpenAIProviderOptionsInput + } + +const auth = (options: ProviderAuthOption<"optional">) => AuthOptions.bearer(options, "OPENAI_API_KEY") + +export const responses = (id: string | ModelID, options: OpenAIModelInput> = {}) => { + const { apiKey: _, ...rest } = options + return OpenAIResponses.model(withOpenAIOptions(id, { ...rest, auth: auth(options) }, { textVerbosity: true })) +} + +export const responsesWebSocket = ( + id: string | ModelID, + options: OpenAIModelInput> = {}, +) => { + const { apiKey: _, ...rest } = options + return OpenAIResponses.webSocketModel( + withOpenAIOptions(id, { ...rest, auth: auth(options) }, { textVerbosity: true }), + ) +} + +export const chat = (id: string | ModelID, options: OpenAIModelInput> = {}) => { + const { apiKey: _, ...rest } = options + return OpenAIChat.model(withOpenAIOptions(id, { ...rest, auth: auth(options) })) +} + +export const provider = Provider.make({ + id, + model: responses, + apis: { responses, responsesWebSocket, chat }, +}) + +export const model = provider.model +export const apis = provider.apis diff --git a/packages/llm/src/providers/openrouter.ts b/packages/llm/src/providers/openrouter.ts new file mode 100644 index 0000000000..4c1a432106 --- /dev/null +++ b/packages/llm/src/providers/openrouter.ts @@ -0,0 +1,88 @@ +import { Effect, Schema } from "effect" +import { Route, type RouteModelInput } from "../route/client" +import { Endpoint } from "../route/endpoint" +import { Framing } from "../route/framing" +import { Provider } from "../provider" +import { Protocol } from "../route/protocol" +import { ProviderID, type ModelID, type ProviderOptions } from "../schema" +import * as OpenAICompatibleProfiles from "./openai-compatible-profile" +import * as OpenAIChat from "../protocols/openai-chat" +import { isRecord } from "../protocols/shared" + +export const profile = OpenAICompatibleProfiles.profiles.openrouter +export const id = ProviderID.make(profile.provider) +const ADAPTER = "openrouter" + +export interface OpenRouterOptions { + readonly [key: string]: unknown + readonly usage?: boolean | Record + readonly reasoning?: Record + readonly promptCacheKey?: string +} + +export type OpenRouterProviderOptionsInput = ProviderOptions & { + readonly openrouter?: OpenRouterOptions +} + +export type ModelOptions = Omit & { + readonly baseURL?: string + readonly providerOptions?: OpenRouterProviderOptionsInput +} +type ModelInput = ModelOptions & Pick + +const OpenRouterBody = Schema.StructWithRest(Schema.Struct(OpenAIChat.bodyFields), [ + Schema.Record(Schema.String, Schema.Any), +]) +export type OpenRouterBody = Schema.Schema.Type + +export const protocol = Protocol.make({ + id: "openrouter-chat", + body: { + schema: OpenRouterBody, + from: (request) => + OpenAIChat.protocol.body.from(request).pipe( + Effect.map( + (body) => + ({ + ...body, + ...bodyOptions(request.providerOptions?.openrouter), + }) as OpenRouterBody, + ), + ), + }, + stream: OpenAIChat.protocol.stream, +}) + +const bodyOptions = (input: unknown) => { + const openrouter = isRecord(input) ? input : {} + return { + ...(openrouter.usage === true + ? { usage: { include: true } } + : isRecord(openrouter.usage) + ? { usage: openrouter.usage } + : {}), + ...(isRecord(openrouter.reasoning) ? { reasoning: openrouter.reasoning } : {}), + ...(typeof openrouter.promptCacheKey === "string" ? { prompt_cache_key: openrouter.promptCacheKey } : {}), + } +} + +export const route = Route.make({ + id: ADAPTER, + protocol, + endpoint: Endpoint.path("/chat/completions"), + framing: Framing.sse, +}) + +export const routes = [route] + +const modelRef = Route.model(route, { + provider: profile.provider, + baseURL: profile.baseURL, +}) + +export const model = (id: string | ModelID, options: ModelOptions = {}) => modelRef({ ...options, id }) + +export const provider = Provider.make({ + id, + model, +}) diff --git a/packages/llm/src/providers/xai.ts b/packages/llm/src/providers/xai.ts new file mode 100644 index 0000000000..089c8c7339 --- /dev/null +++ b/packages/llm/src/providers/xai.ts @@ -0,0 +1,52 @@ +import { AuthOptions, type ProviderAuthOption } from "../route/auth-options" +import { Route } from "../route/client" +import type { RouteModelInput } from "../route/client" +import { Provider } from "../provider" +import { ProviderID, type ModelID } from "../schema" +import * as OpenAICompatibleProfiles from "./openai-compatible-profile" +import * as OpenAICompatibleChat from "../protocols/openai-compatible-chat" +import * as OpenAIResponses from "../protocols/openai-responses" + +export const id = ProviderID.make("xai") + +export type ModelOptions = Omit & + ProviderAuthOption<"optional"> & { + readonly baseURL?: string + } + +export const routes = [OpenAIResponses.route, OpenAICompatibleChat.route] + +const responsesModel = Route.model(OpenAIResponses.route, { provider: id }) +const chatModel = OpenAICompatibleChat.model + +const auth = (options: ProviderAuthOption<"optional">) => AuthOptions.bearer(options, "XAI_API_KEY") + +export const responses = (modelID: string | ModelID, options: ModelOptions = {}) => { + const { apiKey: _, ...rest } = options + return responsesModel({ + ...rest, + auth: auth(options), + id: modelID, + baseURL: options.baseURL ?? OpenAICompatibleProfiles.profiles.xai.baseURL, + }) +} + +export const chat = (modelID: string | ModelID, options: ModelOptions = {}) => { + const { apiKey: _, ...rest } = options + return chatModel({ + ...rest, + auth: auth(options), + id: modelID, + provider: id, + baseURL: options.baseURL ?? OpenAICompatibleProfiles.profiles.xai.baseURL, + }) +} + +export const provider = Provider.make({ + id, + model: responses, + apis: { responses, chat }, +}) + +export const model = provider.model +export const apis = provider.apis diff --git a/packages/llm/src/route/auth-options.ts b/packages/llm/src/route/auth-options.ts new file mode 100644 index 0000000000..7e40aa12a2 --- /dev/null +++ b/packages/llm/src/route/auth-options.ts @@ -0,0 +1,57 @@ +import type { Config, Redacted } from "effect" +import { Auth } from "./auth" + +export type ApiKeyMode = "optional" | "required" + +export type AuthOverride = { + readonly auth: Auth + readonly apiKey?: never +} + +export type OptionalApiKeyAuth = { + readonly apiKey?: string | Redacted.Redacted | Config.Config> + readonly auth?: never +} + +export type RequiredApiKeyAuth = { + readonly apiKey: string | Redacted.Redacted | Config.Config> + readonly auth?: never +} + +export type ProviderAuthOption = + | AuthOverride + | (Mode extends "optional" ? OptionalApiKeyAuth : RequiredApiKeyAuth) + +export type ModelOptions = Omit & ProviderAuthOption + +export type ModelArgs = Mode extends "optional" + ? readonly [options?: ModelOptions] + : readonly [options: ModelOptions] + +export type ModelFactory = (id: string, ...args: ModelArgs) => Model + +/** + * Require at least one of the keys in `T`. Use for option shapes where any + * subset of fields is acceptable but at least one must be present (e.g. Azure + * accepts `resourceName` or `baseURL`). + */ +export type AtLeastOne = { + [K in keyof T]: Required> & Partial> +}[keyof T] + +/** + * Standard bearer-auth resolution for providers: honor an explicit `auth` + * override, otherwise resolve `apiKey` (option > config var) and apply it as + * a bearer token. + */ +export const bearer = (options: ProviderAuthOption<"optional">, envVar: string | ReadonlyArray): Auth => { + if ("auth" in options && options.auth) return options.auth + return (Array.isArray(envVar) ? envVar : [envVar]) + .reduce( + (auth, name) => auth.orElse(Auth.config(name)), + Auth.optional("apiKey" in options ? options.apiKey : undefined, "apiKey"), + ) + .bearer() +} + +export * as AuthOptions from "./auth-options" diff --git a/packages/llm/src/route/auth.ts b/packages/llm/src/route/auth.ts new file mode 100644 index 0000000000..b46e223363 --- /dev/null +++ b/packages/llm/src/route/auth.ts @@ -0,0 +1,197 @@ +import { Config, Effect, Redacted } from "effect" +import { Headers } from "effect/unstable/http" +import { AuthenticationReason, InvalidRequestReason, LLMError, type LLMRequest } from "../schema" + +export class MissingCredentialError extends Error { + readonly _tag = "MissingCredentialError" + + constructor(readonly source: string) { + super(`Missing auth credential: ${source}`) + } +} + +export type CredentialError = MissingCredentialError | Config.ConfigError +export type AuthError = CredentialError | LLMError + +export interface AuthInput { + readonly request: LLMRequest + readonly method: "POST" | "GET" + readonly url: string + readonly body: string + readonly headers: Headers.Headers +} + +export interface Credential { + readonly load: Effect.Effect, CredentialError> + readonly orElse: (that: Credential) => Credential + readonly bearer: () => Auth + readonly header: (name: string) => Auth + readonly pipe: (f: (self: Credential) => A) => A +} + +export interface Auth { + readonly apply: (input: AuthInput) => Effect.Effect + readonly andThen: (that: Auth) => Auth + readonly orElse: (that: Auth) => Auth + readonly pipe: (f: (self: Auth) => A) => A +} + +export const isAuth = (input: unknown): input is Auth => + typeof input === "object" && input !== null && "apply" in input && typeof input.apply === "function" + +const credential = (load: Effect.Effect, CredentialError>): Credential => { + const self: Credential = { + load, + orElse: (that) => credential(load.pipe(Effect.catch(() => that.load))), + bearer: () => fromCredential(self, (secret) => ({ authorization: `Bearer ${secret}` })), + header: (name) => fromCredential(self, (secret) => ({ [name]: secret })), + pipe: (f) => f(self), + } + return self +} + +const auth = (apply: Auth["apply"]): Auth => { + const self: Auth = { + apply, + andThen: (that) => + auth((input) => apply(input).pipe(Effect.flatMap((headers) => that.apply({ ...input, headers })))), + orElse: (that) => auth((input) => apply(input).pipe(Effect.catch(() => that.apply(input)))), + pipe: (f) => f(self), + } + return self +} + +const fromCredential = (source: Credential, render: (secret: string) => Headers.Input) => + auth((input) => + source.load.pipe(Effect.map((secret) => Headers.setAll(input.headers, render(Redacted.value(secret))))), + ) + +const secretEffect = (secret: string | Redacted.Redacted, source: string) => { + const redacted = typeof secret === "string" ? Redacted.make(secret) : secret + if (Redacted.value(redacted) === "") return Effect.fail(new MissingCredentialError(source)) + return Effect.succeed(redacted) +} + +const credentialFromSecret = ( + secret: string | Redacted.Redacted | Config.Config>, + source: string, +) => { + if (typeof secret === "string" || Redacted.isRedacted(secret)) return credential(secretEffect(secret, source)) + return credential( + Effect.gen(function* () { + return yield* secretEffect(yield* secret, source) + }), + ) +} + +export const value = (secret: string, source = "value") => credentialFromSecret(secret, source) + +export const optional = ( + secret: string | Redacted.Redacted | Config.Config> | undefined, + source = "optional value", +) => + secret === undefined + ? credential(Effect.fail(new MissingCredentialError(source))) + : credentialFromSecret(secret, source) + +export const config = (name: string) => credentialFromSecret(Config.redacted(name), name) + +export const effect = (load: Effect.Effect, CredentialError>) => credential(load) + +export const none = auth((input) => Effect.succeed(input.headers)) + +export const headers = (input: Headers.Input) => + auth((inputAuth) => Effect.succeed(Headers.setAll(inputAuth.headers, input))) + +export const remove = (name: string) => auth((input) => Effect.succeed(Headers.remove(input.headers, name))) + +export const custom = (apply: (input: AuthInput) => Effect.Effect) => auth(apply) + +export const passthrough = none + +const fromModelApiKey = (from: (apiKey: string) => Headers.Input) => + auth(({ request, headers }) => { + const key = request.model.apiKey + if (!key) return Effect.succeed(headers) + return Effect.succeed(Headers.setAll(headers, from(key))) + }) + +const credentialInput = ( + source: string | Redacted.Redacted | Config.Config> | Credential, +) => + typeof source === "string" || Redacted.isRedacted(source) || Config.isConfig(source) + ? credentialFromSecret(source, "value") + : source + +export function bearer(): Auth +export function bearer( + source: string | Redacted.Redacted | Config.Config> | Credential, +): Auth +export function bearer( + source?: string | Redacted.Redacted | Config.Config> | Credential, +) { + if (source === undefined) return fromModelApiKey((key) => ({ authorization: `Bearer ${key}` })) + return credentialInput(source).bearer() +} + +export const apiKey = bearer + +export const apiKeyHeader = (name: string) => fromModelApiKey((key) => ({ [name]: key })) + +export function header( + name: string, +): (source: string | Redacted.Redacted | Config.Config> | Credential) => Auth +export function header( + name: string, + source: string | Redacted.Redacted | Config.Config> | Credential, +): Auth +export function header( + name: string, + source?: string | Redacted.Redacted | Config.Config> | Credential, +) { + if (source === undefined) { + return ( + next: string | Redacted.Redacted | Config.Config> | Credential, + ) => credentialInput(next).header(name) + } + return credentialInput(source).header(name) +} + +export function bearerHeader( + name: string, +): (source: string | Redacted.Redacted | Config.Config> | Credential) => Auth +export function bearerHeader( + name: string, + source: string | Redacted.Redacted | Config.Config> | Credential, +): Auth +export function bearerHeader( + name: string, + source?: string | Redacted.Redacted | Config.Config> | Credential, +) { + const render = ( + input: string | Redacted.Redacted | Config.Config> | Credential, + ) => fromCredential(credentialInput(input), (secret) => ({ [name]: `Bearer ${secret}` })) + if (source === undefined) return render + return render(source) +} + +const toLLMError = (error: AuthError): LLMError => { + if (error instanceof MissingCredentialError || error instanceof Config.ConfigError) { + return new LLMError({ + module: "Auth", + method: "apply", + reason: + error instanceof MissingCredentialError + ? new AuthenticationReason({ message: error.message, kind: "missing" }) + : new InvalidRequestReason({ message: `Failed to resolve auth config: ${error.message}` }), + }) + } + return error +} + +export const toEffect = + (input: Auth) => + (authInput: AuthInput): Effect.Effect => + input.apply(authInput).pipe(Effect.mapError(toLLMError)) + +export * as Auth from "./auth" diff --git a/packages/llm/src/route/client.ts b/packages/llm/src/route/client.ts new file mode 100644 index 0000000000..2d9de2fd39 --- /dev/null +++ b/packages/llm/src/route/client.ts @@ -0,0 +1,527 @@ +import { Cause, Context, Effect, Layer, Schema, Stream } from "effect" +import type { Auth as AuthDef } from "./auth" +import type { Endpoint } from "./endpoint" +import { RequestExecutor } from "./executor" +import type { Framing } from "./framing" +import { HttpTransport } from "./transport" +import type { Transport, TransportRuntime } from "./transport" +import { WebSocketExecutor } from "./transport" +import type { Service as WebSocketExecutorService } from "./transport/websocket" +import type { Protocol } from "./protocol" +import { applyCachePolicy } from "../cache-policy" +import * as ProviderShared from "../protocols/shared" +import * as ToolRuntime from "../tool-runtime" +import type { Tools } from "../tool" +import type { LLMError, LLMEvent, PreparedRequestOf, ProtocolID } from "../schema" +import { + GenerationOptions, + HttpOptions, + LLMRequest, + LLMResponse, + ModelID, + ModelLimits, + ModelRef, + LLMError as LLMErrorClass, + NoRouteReason, + PreparedRequest, + ProviderID, + RouteID, + mergeGenerationOptions, + mergeHttpOptions, + mergeProviderOptions, +} from "../schema" + +export interface RouteBody { + /** Schema for the validated provider-native body sent as the JSON request. */ + readonly schema: Schema.Codec + /** Build the provider-native body from a common `LLMRequest`. */ + readonly from: (request: LLMRequest) => Effect.Effect +} + +export interface Route { + readonly id: string + readonly provider?: ProviderID + readonly protocol: ProtocolID + readonly transport: Transport + readonly defaults: RouteDefaults + readonly body: RouteBody + readonly with: (patch: RoutePatch) => Route + readonly model: (input: Input) => ModelRef + readonly prepareTransport: (body: Body, request: LLMRequest) => Effect.Effect + readonly streamPrepared: ( + prepared: Prepared, + request: LLMRequest, + runtime: TransportRuntime, + ) => Stream.Stream +} + +// Route registries intentionally erase body generics after construction. +// Normal call sites use `OpenAIChat.route`; callers only need body types +// when preparing a request with a protocol-specific type assertion. +// oxlint-disable-next-line typescript-eslint/no-explicit-any +export type AnyRoute = Route + +const routeRegistry = new Map() + +// Route lookup is intentionally global: model refs name a route id, and +// importing the provider/protocol/custom-route module registers the runnable +// implementation. Duplicate ids are bugs because model refs cannot disambiguate +// them. +const register = (route: R): R => { + const existing = routeRegistry.get(route.id) + if (existing && existing !== route) throw new Error(`Duplicate LLM route id "${route.id}"`) + routeRegistry.set(route.id, route) + return route +} + +const registeredRoute = (id: string) => routeRegistry.get(id) + +export type HttpOptionsInput = HttpOptions.Input + +export type ModelRefInput = Omit< + ConstructorParameters[0], + "id" | "provider" | "route" | "limits" | "generation" | "http" | "auth" +> & { + readonly id: string | ModelID + readonly provider: string | ProviderID + readonly route: string | RouteID + readonly auth?: AuthDef + readonly limits?: ModelLimits.Input + readonly generation?: GenerationOptions.Input + readonly http?: HttpOptionsInput +} + +// `baseURL` is required on `ModelRefInput` (every materialized `ModelRef` has +// a host) but optional at the route-input layers below. The route's `defaults` +// can supply a canonical URL (e.g. OpenAI/Anthropic) so the user's input may +// omit it. Routes without a canonical URL (OpenAI-compatible, GitHub Copilot) +// re-tighten this in their own input type. +export type RouteModelInput = Omit & { + readonly baseURL?: string +} + +export type RouteModelDefaults = Omit & { + readonly baseURL?: string +} + +export type RouteRoutedModelInput = Omit & { + readonly baseURL?: string +} + +export type RouteRoutedModelDefaults = Partial> + +export type RouteDefaults = Partial> + +export interface RoutePatch extends RouteDefaults { + readonly id: string + readonly provider?: string | ProviderID + readonly transport?: Transport +} + +type RouteMappedModelInput = RouteModelInput | RouteRoutedModelInput + +export interface RouteModelOptions< + Input extends RouteMappedModelInput, + Output extends RouteMappedModelInput = RouteMappedModelInput, +> { + readonly mapInput?: (input: Input) => Output +} + +export interface RouteMappedModelOptions { + readonly mapInput: (input: Input) => Output +} + +const modelWithDefaults = + ( + route: AnyRoute, + defaults: Partial>, + options: { readonly mapInput?: (input: Input) => RouteMappedModelInput }, + ) => + (input: Input) => { + const mapped = options.mapInput === undefined ? (input as RouteMappedModelInput) : options.mapInput(input) + const provider = defaults.provider ?? route.provider ?? ("provider" in mapped ? mapped.provider : undefined) + if (!provider) throw new Error(`Route.model(${route.id}) requires a provider`) + const baseURL = mapped.baseURL ?? defaults.baseURL ?? route.defaults.baseURL + if (!baseURL) + throw new Error(`Route.model(${route.id}) requires a baseURL — supply it via input, defaults, or route defaults`) + const generation = mergeGenerationOptions(route.defaults.generation, defaults.generation) + const providerOptions = mergeProviderOptions(route.defaults.providerOptions, defaults.providerOptions) + const http = mergeHttpOptions(httpOptions(route.defaults.http), httpOptions(defaults.http)) + return modelRef({ + ...route.defaults, + ...defaults, + ...mapped, + baseURL, + provider, + route: route.id, + limits: mapped.limits ?? defaults.limits ?? route.defaults.limits, + generation: mergeGenerationOptions(generation, mapped.generation), + providerOptions: mergeProviderOptions(providerOptions, mapped.providerOptions), + http: mergeHttpOptions(http, httpOptions(mapped.http)), + }) + } + +const mergeRouteDefaults = (base: RouteDefaults | undefined, patch: RouteDefaults): RouteDefaults => ({ + ...base, + ...patch, + limits: patch.limits ?? base?.limits, + generation: mergeGenerationOptions(generationOptions(base?.generation), generationOptions(patch.generation)), + providerOptions: mergeProviderOptions(base?.providerOptions, patch.providerOptions), + http: mergeHttpOptions(httpOptions(base?.http), httpOptions(patch.http)), +}) + +export const modelLimits = ModelLimits.make + +export const generationOptions = (input: GenerationOptions.Input | undefined) => + input === undefined ? undefined : GenerationOptions.make(input) + +export const httpOptions = (input: HttpOptionsInput | undefined) => { + if (input === undefined) return input + return HttpOptions.make(input) +} + +export const modelRef = (input: ModelRefInput) => + new ModelRef({ + ...input, + id: ModelID.make(input.id), + provider: ProviderID.make(input.provider), + route: RouteID.make(input.route), + limits: modelLimits(input.limits), + generation: generationOptions(input.generation), + http: httpOptions(input.http), + }) + +function model( + route: AnyRoute, + defaults: RouteModelDefaults, + options?: RouteModelOptions, +): (input: Input) => ModelRef +function model( + route: AnyRoute, + defaults?: RouteRoutedModelDefaults, + options?: RouteModelOptions, +): (input: Input) => ModelRef +function model( + route: AnyRoute, + defaults: Partial>, + options: RouteMappedModelOptions, +): (input: Input) => ModelRef +function model( + route: AnyRoute, + defaults: Partial> = {}, + options: { readonly mapInput?: (input: Input) => RouteMappedModelInput } = {}, +) { + return modelWithDefaults(route, defaults, options) +} + +export interface Interface { + /** + * Compile a request through protocol body construction, validation, and HTTP + * preparation without sending it. Returns the prepared request including the + * provider-native body. + * + * Pass a `Body` type argument to statically expose the route's body + * shape (e.g. `prepare(...)`) — the runtime body is + * identical, so this is a type-level assertion the caller makes about which + * route the request will resolve to. + */ + readonly prepare: (request: LLMRequest) => Effect.Effect, LLMError> + readonly stream: StreamMethod + readonly generate: GenerateMethod +} + +export interface StreamMethod { + (request: LLMRequest): Stream.Stream + (options: ToolRuntime.RunOptions): Stream.Stream +} + +export interface GenerateMethod { + (request: LLMRequest): Effect.Effect + (options: ToolRuntime.RunOptions): Effect.Effect +} + +export class Service extends Context.Service()("@opencode/LLMClient") {} + +const noRoute = (model: ModelRef) => + new LLMErrorClass({ + module: "LLMClient", + method: "resolveRoute", + reason: new NoRouteReason({ route: model.route, provider: model.provider, model: model.id }), + }) + +const resolveRequestOptions = (request: LLMRequest) => + LLMRequest.update(request, { + generation: mergeGenerationOptions(request.model.generation, request.generation) ?? new GenerationOptions({}), + providerOptions: mergeProviderOptions(request.model.providerOptions, request.providerOptions), + http: mergeHttpOptions(request.model.http, request.http), + }) + +export interface MakeInput { + /** Route id used in registry lookup and error messages. */ + readonly id: string + /** Provider identity for route-owned model construction. */ + readonly provider?: string | ProviderID + /** Semantic API contract — owns body construction, body schema, and parsing. */ + readonly protocol: Protocol + /** Where the request is sent. */ + readonly endpoint: Endpoint + /** Per-request transport auth. Model-level `Auth` overrides this. */ + readonly auth?: AuthDef + /** Stream framing — bytes -> frames before `protocol.stream.event` decoding. */ + readonly framing: Framing + /** Static / per-request headers added before `auth` runs. */ + readonly headers?: (input: { readonly request: LLMRequest }) => Record + /** Model defaults used by the route's `.model(...)` helper. */ + readonly defaults?: RouteDefaults +} + +export interface MakeTransportInput { + /** Route id used in registry lookup and error messages. */ + readonly id: string + /** Provider identity for route-owned model construction. */ + readonly provider?: string | ProviderID + /** Semantic API contract — owns body construction, body schema, and parsing. */ + readonly protocol: Protocol + /** Runnable transport route. */ + readonly transport: Transport + /** Provider/model defaults used by the route's `.model(...)` helper. */ + readonly defaults?: RouteDefaults +} + +const streamError = (route: string, message: string, cause: Cause.Cause) => { + const failed = cause.reasons.find(Cause.isFailReason)?.error + if (failed instanceof LLMErrorClass) return failed + return ProviderShared.eventError(route, message, Cause.pretty(cause)) +} + +function makeFromTransport( + input: MakeTransportInput, +): Route { + const protocol = input.protocol + const decodeEventEffect = Schema.decodeUnknownEffect(protocol.stream.event) + const decodeEvent = (route: string) => (frame: Frame) => + decodeEventEffect(frame).pipe( + Effect.mapError(() => + ProviderShared.eventError( + input.id, + `Invalid ${route} stream event`, + typeof frame === "string" ? frame : ProviderShared.encodeJson(frame), + ), + ), + ) + + const build = (routeInput: MakeTransportInput): Route => { + const route: Route = { + id: routeInput.id, + provider: routeInput.provider === undefined ? undefined : ProviderID.make(routeInput.provider), + protocol: protocol.id, + transport: routeInput.transport, + defaults: routeInput.defaults ?? {}, + body: protocol.body, + with: (patch: RoutePatch) => { + const { id, provider, transport, ...defaults } = patch + if (!id || id === routeInput.id) throw new Error(`Route.with(${routeInput.id}) requires a new route id`) + return build({ + ...routeInput, + id, + provider: provider ?? routeInput.provider, + transport: (transport as Transport | undefined) ?? routeInput.transport, + defaults: mergeRouteDefaults(routeInput.defaults, defaults), + }) + }, + model: (input: RouteModelInput): ModelRef => modelWithDefaults(route, {}, {})(input), + prepareTransport: routeInput.transport.prepare, + streamPrepared: (prepared: Prepared, request: LLMRequest, runtime: TransportRuntime) => { + const route = `${request.model.provider}/${request.model.route}` + const events = routeInput.transport + .frames(prepared, request, runtime) + .pipe( + Stream.mapEffect(decodeEvent(route)), + protocol.stream.terminal ? Stream.takeUntil(protocol.stream.terminal) : (stream) => stream, + ) + return events.pipe( + Stream.mapAccumEffect( + protocol.stream.initial, + protocol.stream.step, + protocol.stream.onHalt ? { onHalt: protocol.stream.onHalt } : undefined, + ), + Stream.catchCause((cause) => Stream.fail(streamError(route, `Failed to read ${route} stream`, cause))), + ) + }, + } satisfies Route + return register(route) + } + + return build(input) +} + +export function make( + input: MakeTransportInput, +): Route +/** + * Build a `Route` by composing the four orthogonal pieces of a deployment: + * + * - `Protocol` — what is the API I'm speaking? + * - `Endpoint` — where do I send the request? + * - `Auth` — how do I authenticate it? + * - `Framing` — how do I cut the response stream into protocol frames? + * + * Plus optional `headers` for cross-cutting deployment concerns (provider + * version pins, per-deployment quirks). + * + * This is the canonical route constructor. If a new route does not fit + * this four-axis model, add a purpose-built constructor rather than widening + * the public surface preemptively. + */ +export function make( + input: MakeInput, +): Route> +export function make( + input: MakeInput | MakeTransportInput, +): Route | Route> { + if ("transport" in input) return makeFromTransport(input) + const protocol = input.protocol + const encodeBody = Schema.encodeSync(Schema.fromJsonString(protocol.body.schema)) + return makeFromTransport({ + id: input.id, + provider: input.provider, + protocol, + transport: HttpTransport.httpJson({ + endpoint: input.endpoint, + auth: input.auth, + framing: input.framing, + encodeBody, + headers: input.headers, + }), + defaults: input.defaults, + }) +} + +// `compile` is the important boundary: it turns a common `LLMRequest` into a +// validated provider body plus transport-private prepared data, but does not +// execute transport. +const compile = Effect.fn("LLM.compile")(function* (request: LLMRequest) { + const resolved = applyCachePolicy(resolveRequestOptions(request)) + const route = registeredRoute(resolved.model.route) + if (!route) return yield* noRoute(resolved.model) + + const body = yield* route.body + .from(resolved) + .pipe(Effect.flatMap(ProviderShared.validateWith(Schema.decodeUnknownEffect(route.body.schema)))) + const prepared = yield* route.prepareTransport(body, resolved) + + return { + request: resolved, + route, + body, + prepared, + } +}) + +const prepareWith = Effect.fn("LLMClient.prepare")(function* (request: LLMRequest) { + const compiled = yield* compile(request) + + return new PreparedRequest({ + id: compiled.request.id ?? "request", + route: compiled.route.id, + protocol: compiled.route.protocol, + model: compiled.request.model, + body: compiled.body, + metadata: { transport: compiled.route.transport.id }, + }) +}) + +const streamRequestWith = (runtime: TransportRuntime) => (request: LLMRequest) => + Stream.unwrap( + Effect.gen(function* () { + const compiled = yield* compile(request) + return compiled.route.streamPrepared(compiled.prepared, compiled.request, runtime) + }), + ) + +const isToolRunOptions = (input: LLMRequest | ToolRuntime.RunOptions): input is ToolRuntime.RunOptions => + "request" in input && "tools" in input + +const streamWith = (streamRequest: (request: LLMRequest) => Stream.Stream): StreamMethod => + ((input: LLMRequest | ToolRuntime.RunOptions) => { + if (isToolRunOptions(input)) return ToolRuntime.stream({ ...input, stream: streamRequest }) + return streamRequest(input) + }) as StreamMethod + +const generateWith = (stream: Interface["stream"]) => + Effect.fn("LLM.generate")(function* (input: LLMRequest | ToolRuntime.RunOptions) { + return new LLMResponse( + yield* stream(input as never).pipe( + Stream.runFold( + () => ({ events: [] as LLMEvent[], usage: undefined as LLMResponse["usage"] }), + (acc, event) => { + acc.events.push(event) + if ("usage" in event && event.usage !== undefined) acc.usage = event.usage + return acc + }, + ), + ), + ) + }) + +export const prepare = (request: LLMRequest) => + prepareWith(request) as Effect.Effect, LLMError> + +export function stream(request: LLMRequest): Stream.Stream +export function stream(options: ToolRuntime.RunOptions): Stream.Stream +export function stream(input: LLMRequest | ToolRuntime.RunOptions) { + return Stream.unwrap( + Effect.gen(function* () { + return (yield* Service).stream(input as never) + }), + ) +} + +export function generate(request: LLMRequest): Effect.Effect +export function generate(options: ToolRuntime.RunOptions): Effect.Effect +export function generate(input: LLMRequest | ToolRuntime.RunOptions) { + return Effect.gen(function* () { + return yield* (yield* Service).generate(input as never) + }) +} + +export const streamRequest = (request: LLMRequest) => + Stream.unwrap( + Effect.gen(function* () { + return (yield* Service).stream(request) + }), + ) + +export const layer: Layer.Layer = Layer.effect( + Service, + Effect.gen(function* () { + const stream = streamWith(streamRequestWith({ http: yield* RequestExecutor.Service })) + return Service.of({ prepare: prepareWith as Interface["prepare"], stream, generate: generateWith(stream) }) + }), +) + +export const layerWithWebSocket: Layer.Layer = + Layer.effect( + Service, + Effect.gen(function* () { + const stream = streamWith( + streamRequestWith({ + http: yield* RequestExecutor.Service, + webSocket: yield* WebSocketExecutor.Service, + }), + ) + return Service.of({ prepare: prepareWith as Interface["prepare"], stream, generate: generateWith(stream) }) + }), + ) + +export const Route = { make, model } as const + +export const LLMClient = { + Service, + layer, + layerWithWebSocket, + prepare, + stream, + generate, + stepCountIs: ToolRuntime.stepCountIs, +} as const diff --git a/packages/llm/src/route/endpoint.ts b/packages/llm/src/route/endpoint.ts new file mode 100644 index 0000000000..361ad508e1 --- /dev/null +++ b/packages/llm/src/route/endpoint.ts @@ -0,0 +1,39 @@ +import type { LLMRequest } from "../schema" +import * as ProviderShared from "../protocols/shared" + +export interface EndpointInput { + readonly request: LLMRequest + readonly body: Body +} + +export type EndpointPart = string | ((input: EndpointInput) => string) + +/** + * Declarative URL construction for one route. + * + * `Endpoint` carries only the path. The host always lives on `model.baseURL`, + * supplied by the provider helper that constructs the model. `render(...)` + * just appends the path (and any `model.queryParams`) to that host. + * + * `path` may be a string or a function of `EndpointInput`, for routes whose + * URL embeds the model id, region, or another body field (e.g. Bedrock, + * Gemini). + */ +export interface Endpoint { + readonly path: EndpointPart +} + +/** Construct an `Endpoint` from a path string or path function. */ +export const path = (value: EndpointPart): Endpoint => ({ path: value }) + +const renderPart = (part: EndpointPart, input: EndpointInput) => + typeof part === "function" ? part(input) : part + +export const render = (endpoint: Endpoint, input: EndpointInput) => { + const url = new URL(`${ProviderShared.trimBaseUrl(input.request.model.baseURL)}${renderPart(endpoint.path, input)}`) + const params = input.request.model.queryParams + if (params) for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value) + return url +} + +export * as Endpoint from "./endpoint" diff --git a/packages/llm/src/route/executor.ts b/packages/llm/src/route/executor.ts new file mode 100644 index 0000000000..815b2c289c --- /dev/null +++ b/packages/llm/src/route/executor.ts @@ -0,0 +1,374 @@ +import { Cause, Context, Effect, Layer, Random } from "effect" +import { + FetchHttpClient, + Headers, + HttpClient, + HttpClientError, + HttpClientRequest, + HttpClientResponse, +} from "effect/unstable/http" +import { + AuthenticationReason, + ContentPolicyReason, + HttpContext, + HttpRateLimitDetails, + HttpRequestDetails, + HttpResponseDetails, + InvalidRequestReason, + LLMError, + ProviderInternalReason, + QuotaExceededReason, + RateLimitReason, + TransportReason, + UnknownProviderReason, +} from "../schema" + +export interface Interface { + readonly execute: ( + request: HttpClientRequest.HttpClientRequest, + ) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/LLM/RequestExecutor") {} + +const BODY_LIMIT = 16_384 +const MAX_RETRIES = 2 +const BASE_DELAY_MS = 500 +const MAX_DELAY_MS = 10_000 +const REDACTED = "" + +// One source of truth for what counts as a sensitive name across headers, +// URL query keys, and field names embedded inside request/response bodies. +// +// `SENSITIVE_NAME` is used as both a substring matcher (for free-form header +// names like `Authorization` / `X-API-Key`) and as the body-field alternation +// list. `SHORT_QUERY_NAME` covers anchored short keys like `?key=…` / `?sig=…` +// that are too generic to redact substring-style without false positives. +const SENSITIVE_NAME_SOURCE = + "authorization|api[-_]?key|access[-_]?token|refresh[-_]?token|id[-_]?token|token|secret|credential|signature|x-amz-signature" +const SENSITIVE_NAME = new RegExp(SENSITIVE_NAME_SOURCE, "i") +const SHORT_QUERY_NAME = /^(key|sig)$/i +const SENSITIVE_BODY_FIELD = new RegExp(`(?:${SENSITIVE_NAME_SOURCE}|key)`, "i") +const REDACT_JSON_FIELD = new RegExp(`("(?:${SENSITIVE_BODY_FIELD.source})"\\s*:\\s*)"[^"]*"`, "gi") +const REDACT_QUERY_FIELD = new RegExp(`((?:${SENSITIVE_BODY_FIELD.source})=)[^&\\s"]+`, "gi") + +const isSensitiveHeaderName = (name: string) => SENSITIVE_NAME.test(name) + +const isSensitiveQueryName = (name: string) => isSensitiveHeaderName(name) || SHORT_QUERY_NAME.test(name) + +const redactHeaders = (headers: Headers.Headers, redactedNames: ReadonlyArray) => + Object.fromEntries( + Object.entries(Headers.redact(headers, [...redactedNames, SENSITIVE_NAME])).map(([name, value]) => [ + name, + String(value), + ]), + ) + +const redactUrl = (value: string) => { + if (!URL.canParse(value)) return REDACTED + const url = new URL(value) + url.searchParams.forEach((_, key) => { + if (isSensitiveQueryName(key)) url.searchParams.set(key, REDACTED) + }) + return url.toString() +} + +const normalizedHeaders = (headers: Headers.Headers) => + Object.fromEntries(Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value])) + +const requestId = (headers: Record) => { + return ( + headers["x-request-id"] ?? + headers["request-id"] ?? + headers["x-amzn-requestid"] ?? + headers["x-amz-request-id"] ?? + headers["x-goog-request-id"] ?? + headers["cf-ray"] + ) +} + +const retryableStatus = (status: number) => status === 429 || status === 503 || status === 504 || status === 529 + +const retryAfterMs = (headers: Record) => { + const millis = Number(headers["retry-after-ms"]) + if (Number.isFinite(millis)) return Math.max(0, millis) + + const value = headers["retry-after"] + if (!value) return undefined + + const seconds = Number(value) + if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000) + + const date = Date.parse(value) + if (!Number.isNaN(date)) return Math.max(0, date - Date.now()) + return undefined +} + +const addRateLimitValue = (target: Record, key: string, value: string) => { + if (key.length > 0) target[key] = value +} + +const rateLimitDetails = (headers: Record, retryAfter: number | undefined) => { + const limit: Record = {} + const remaining: Record = {} + const reset: Record = {} + + Object.entries(headers).forEach(([name, value]) => { + const openaiLimit = /^x-ratelimit-limit-(.+)$/.exec(name)?.[1] + if (openaiLimit) return addRateLimitValue(limit, openaiLimit, value) + + const openaiRemaining = /^x-ratelimit-remaining-(.+)$/.exec(name)?.[1] + if (openaiRemaining) return addRateLimitValue(remaining, openaiRemaining, value) + + const openaiReset = /^x-ratelimit-reset-(.+)$/.exec(name)?.[1] + if (openaiReset) return addRateLimitValue(reset, openaiReset, value) + + const anthropic = /^anthropic-ratelimit-(.+)-(limit|remaining|reset)$/.exec(name) + if (!anthropic) return + if (anthropic[2] === "limit") return addRateLimitValue(limit, anthropic[1], value) + if (anthropic[2] === "remaining") return addRateLimitValue(remaining, anthropic[1], value) + return addRateLimitValue(reset, anthropic[1], value) + }) + + if ( + retryAfter === undefined && + Object.keys(limit).length === 0 && + Object.keys(remaining).length === 0 && + Object.keys(reset).length === 0 + ) + return undefined + + return new HttpRateLimitDetails({ + retryAfterMs: retryAfter, + limit: Object.keys(limit).length === 0 ? undefined : limit, + remaining: Object.keys(remaining).length === 0 ? undefined : remaining, + reset: Object.keys(reset).length === 0 ? undefined : reset, + }) +} + +const requestDetails = (request: HttpClientRequest.HttpClientRequest, redactedNames: ReadonlyArray) => + new HttpRequestDetails({ + method: request.method, + url: redactUrl(request.url), + headers: redactHeaders(request.headers, redactedNames), + }) + +const responseDetails = ( + response: HttpClientResponse.HttpClientResponse, + redactedNames: ReadonlyArray, +) => + new HttpResponseDetails({ + status: response.status, + headers: redactHeaders(response.headers, redactedNames), + }) + +const secretValues = (request: HttpClientRequest.HttpClientRequest) => { + const values = new Set() + const add = (value: string) => { + if (value.length < 4) return + values.add(value) + values.add(encodeURIComponent(value)) + } + + Object.entries(request.headers).forEach(([name, value]) => { + if (!isSensitiveHeaderName(name)) return + add(value) + const bearer = /^Bearer\s+(.+)$/i.exec(value)?.[1] + if (bearer) add(bearer) + }) + + if (!URL.canParse(request.url)) return values + new URL(request.url).searchParams.forEach((value, key) => { + if (isSensitiveQueryName(key)) add(value) + }) + return values +} + +// Two passes: structural (redact `"name": "value"` and `name=value` patterns +// for any field name that looks sensitive) plus literal (replace any actual +// secret values we sent in the request, in case the response echoes one back). +const redactBody = (body: string, request: HttpClientRequest.HttpClientRequest) => + Array.from(secretValues(request)).reduce( + (text, secret) => text.split(secret).join(REDACTED), + body.replace(REDACT_JSON_FIELD, `$1"${REDACTED}"`).replace(REDACT_QUERY_FIELD, `$1${REDACTED}`), + ) + +const responseBody = (body: string | void, request: HttpClientRequest.HttpClientRequest) => { + if (body === undefined) return {} + const redacted = redactBody(body, request) + if (redacted.length <= BODY_LIMIT) return { body: redacted } + return { body: redacted.slice(0, BODY_LIMIT), bodyTruncated: true } +} + +const providerMessage = (status: number, body: { readonly body?: string }) => { + if (body.body && body.body.length <= 500) return `Provider request failed with HTTP ${status}: ${body.body}` + return `Provider request failed with HTTP ${status}` +} + +const responseHttp = (input: { + readonly request: HttpClientRequest.HttpClientRequest + readonly response: HttpClientResponse.HttpClientResponse + readonly redactedNames: ReadonlyArray + readonly body: ReturnType + readonly requestId?: string | undefined + readonly rateLimit?: HttpRateLimitDetails | undefined +}) => + new HttpContext({ + request: requestDetails(input.request, input.redactedNames), + response: responseDetails(input.response, input.redactedNames), + ...input.body, + requestId: input.requestId, + rateLimit: input.rateLimit, + }) + +const statusReason = (input: { + readonly status: number + readonly message: string + readonly retryAfterMs?: number | undefined + readonly rateLimit?: HttpRateLimitDetails | undefined + readonly http: HttpContext +}) => { + const body = input.http.body ?? "" + if (/content[-_\s]?policy|content_filter|safety/i.test(body)) { + return new ContentPolicyReason({ message: input.message, http: input.http }) + } + if (input.status === 401) { + return new AuthenticationReason({ message: input.message, kind: "invalid", http: input.http }) + } + if (input.status === 403) { + return new AuthenticationReason({ message: input.message, kind: "insufficient-permissions", http: input.http }) + } + if (input.status === 429) { + if (/insufficient[-_\s]?quota|quota[-_\s]?exceeded/i.test(body)) { + return new QuotaExceededReason({ message: input.message, http: input.http }) + } + return new RateLimitReason({ + message: input.message, + retryAfterMs: input.retryAfterMs, + rateLimit: input.rateLimit, + http: input.http, + }) + } + if (input.status === 400 || input.status === 404 || input.status === 409 || input.status === 422) { + return new InvalidRequestReason({ message: input.message, http: input.http }) + } + if (input.status >= 500 || retryableStatus(input.status)) { + return new ProviderInternalReason({ + message: input.message, + status: input.status, + retryAfterMs: input.retryAfterMs, + http: input.http, + }) + } + return new UnknownProviderReason({ message: input.message, status: input.status, http: input.http }) +} + +const statusError = + (request: HttpClientRequest.HttpClientRequest, redactedNames: ReadonlyArray) => + (response: HttpClientResponse.HttpClientResponse) => + Effect.gen(function* () { + if (response.status < 400) return response + const body = yield* response.text.pipe(Effect.catch(() => Effect.void)) + const headers = normalizedHeaders(response.headers) + const retryAfter = retryAfterMs(headers) + const rateLimit = rateLimitDetails(headers, retryAfter) + const details = responseBody(body, request) + return yield* new LLMError({ + module: "RequestExecutor", + method: "execute", + reason: statusReason({ + status: response.status, + message: providerMessage(response.status, details), + retryAfterMs: retryAfter, + rateLimit, + http: responseHttp({ + request, + response, + redactedNames, + body: details, + requestId: requestId(headers), + rateLimit, + }), + }), + }) + }) + +const toHttpError = (redactedNames: ReadonlyArray) => (error: unknown) => { + const transportError = (input: { + readonly message: string + readonly kind?: string | undefined + readonly request?: HttpClientRequest.HttpClientRequest | undefined + }) => + new LLMError({ + module: "RequestExecutor", + method: "execute", + reason: new TransportReason({ + message: input.message, + kind: input.kind, + url: input.request ? redactUrl(input.request.url) : undefined, + http: input.request ? new HttpContext({ request: requestDetails(input.request, redactedNames) }) : undefined, + }), + }) + + if (Cause.isTimeoutError(error)) { + return transportError({ message: error.message, kind: "Timeout" }) + } + if (!HttpClientError.isHttpClientError(error)) { + return transportError({ message: "HTTP transport failed" }) + } + const request = "request" in error ? error.request : undefined + if (error.reason._tag === "TransportError") { + return transportError({ + message: error.reason.description ?? "HTTP transport failed", + kind: error.reason._tag, + request, + }) + } + return transportError({ + message: `HTTP transport failed: ${error.reason._tag}`, + kind: error.reason._tag, + request, + }) +} + +const retryDelay = (error: LLMError, attempt: number) => { + if (error.retryAfterMs !== undefined) return Effect.succeed(Math.min(error.retryAfterMs, MAX_DELAY_MS)) + return Random.nextBetween( + Math.min(BASE_DELAY_MS * 2 ** attempt * 0.8, MAX_DELAY_MS), + Math.min(BASE_DELAY_MS * 2 ** attempt * 1.2, MAX_DELAY_MS), + ).pipe(Effect.map((delay) => Math.round(delay))) +} + +const retryStatusFailures = ( + effect: Effect.Effect, + retries = MAX_RETRIES, + attempt = 0, +): Effect.Effect => + Effect.catchTag(effect, "LLM.Error", (error): Effect.Effect => { + if (!error.retryable || retries <= 0) return Effect.fail(error) + return retryDelay(error, attempt).pipe( + Effect.flatMap((delay) => Effect.sleep(delay)), + Effect.flatMap(() => retryStatusFailures(effect, retries - 1, attempt + 1)), + ) + }) + +export const layer: Layer.Layer = Layer.effect( + Service, + Effect.gen(function* () { + const http = yield* HttpClient.HttpClient + const executeOnce = (request: HttpClientRequest.HttpClientRequest) => + Effect.gen(function* () { + const redactedNames = yield* Headers.CurrentRedactedNames + return yield* http + .execute(request) + .pipe(Effect.mapError(toHttpError(redactedNames)), Effect.flatMap(statusError(request, redactedNames))) + }) + return Service.of({ + execute: (request) => retryStatusFailures(executeOnce(request)), + }) + }), +) + +export const defaultLayer = layer.pipe(Layer.provide(FetchHttpClient.layer)) + +export * as RequestExecutor from "./executor" diff --git a/packages/llm/src/route/framing.ts b/packages/llm/src/route/framing.ts new file mode 100644 index 0000000000..ef4855817d --- /dev/null +++ b/packages/llm/src/route/framing.ts @@ -0,0 +1,27 @@ +import type { Stream } from "effect" +import * as ProviderShared from "../protocols/shared" +import type { LLMError } from "../schema" + +/** + * Decode a streaming HTTP response body into provider-protocol frames. + * + * `Framing` is the byte-stream-shaped seam between transport and protocol: + * + * - SSE (`Framing.sse`) — UTF-8 decode the body, run the SSE channel decoder, + * drop empty / `[DONE]` keep-alives. Each emitted frame is the JSON `data:` + * payload of one event. + * - AWS event stream — length-prefixed binary frames with CRC checksums. + * Each emitted frame is one parsed binary event record. + * + * The frame type is opaque to this layer; the protocol's `decode` step turns + * a frame into a typed chunk. + */ +export interface Framing { + readonly id: string + readonly frame: (bytes: Stream.Stream) => Stream.Stream +} + +/** Server-Sent Events framing. Used by every JSON-streaming HTTP provider. */ +export const sse: Framing = { id: "sse", frame: ProviderShared.sseFraming } + +export * as Framing from "./framing" diff --git a/packages/llm/src/route/index.ts b/packages/llm/src/route/index.ts new file mode 100644 index 0000000000..a75dd3e038 --- /dev/null +++ b/packages/llm/src/route/index.ts @@ -0,0 +1,26 @@ +export { Route, LLMClient, modelLimits, modelRef } from "./client" +export type { + Route as RouteShape, + RouteModelDefaults, + RouteModelInput, + RouteRoutedModelDefaults, + RouteRoutedModelInput, + AnyRoute, + Interface as LLMClientShape, + Service as LLMClientService, + ModelRefInput, +} from "./client" +export * from "./executor" +export { Auth } from "./auth" +export { AuthOptions } from "./auth-options" +export { Endpoint } from "./endpoint" +export { Framing } from "./framing" +export { Protocol } from "./protocol" +export { HttpTransport, WebSocketExecutor, WebSocketTransport } from "./transport" +export * as Transport from "./transport" +export type { Auth as AuthShape, AuthInput, Credential, CredentialError } from "./auth" +export type { ApiKeyMode, AuthOverride, ProviderAuthOption } from "./auth-options" +export type { Endpoint as EndpointFn, EndpointInput } from "./endpoint" +export type { Framing as FramingDef } from "./framing" +export type { Protocol as ProtocolDef } from "./protocol" +export type { Transport as TransportDef, TransportRuntime } from "./transport" diff --git a/packages/llm/src/route/protocol.ts b/packages/llm/src/route/protocol.ts new file mode 100644 index 0000000000..3ce0f7827d --- /dev/null +++ b/packages/llm/src/route/protocol.ts @@ -0,0 +1,84 @@ +import { Schema, type Effect } from "effect" +import type { LLMError, LLMEvent, LLMRequest, ProtocolID } from "../schema" + +/** + * The semantic API contract of one model server family. + * + * A `Protocol` owns the parts of a route that are intrinsic to "what does + * this API look like": how a common `LLMRequest` becomes a provider-native + * body, what schema that body must satisfy before it is JSON-encoded, and + * how the streaming response decodes back into common `LLMEvent`s. + * + * Examples: + * + * - `OpenAIChat.protocol` — chat completions style + * - `OpenAIResponses.protocol` — responses API + * - `AnthropicMessages.protocol` — messages API with content blocks + * - `Gemini.protocol` — generateContent + * - `BedrockConverse.protocol` — Converse with binary event-stream framing + * + * A `Protocol` is **not** a deployment. It does not know which URL, which + * headers, or which auth scheme to use. Those are deployment concerns owned + * by `Route.make(...)` along with the chosen `Endpoint`, `Auth`, + * and `Framing`. This separation is what lets DeepSeek, TogetherAI, Cerebras, + * etc. all reuse `OpenAIChat.protocol` without forking 300 lines per provider. + * + * The four type parameters reflect the pipeline: + * + * - `Body` — provider-native request body candidate. `Route.make(...)` + * validates and JSON-encodes it with `body.schema`. + * - `Frame` — one unit of the framed response stream. SSE: a JSON data + * string. AWS event stream: a parsed binary frame. + * - `Event` — schema-decoded provider event produced from one frame. + * - `State` — accumulator threaded through `stream.step` to translate event + * sequences into `LLMEvent` sequences. + */ +export interface Protocol { + /** Stable id for the wire protocol implementation. */ + readonly id: ProtocolID + /** Request side: schema for the provider-native body and how to build it. */ + readonly body: ProtocolBody + /** Response side: streaming state machine. */ + readonly stream: ProtocolStream +} + +export interface ProtocolBody { + /** Schema for the validated provider-native body sent as the JSON request. */ + readonly schema: Schema.Codec + /** Build the provider-native body from a common `LLMRequest`. */ + readonly from: (request: LLMRequest) => Effect.Effect +} + +export interface ProtocolStream { + /** Schema for one decoded streaming event, decoded from a transport frame. */ + readonly event: Schema.Codec + /** Initial parser state. Called once per response. */ + readonly initial: () => State + /** Translate one event into emitted `LLMEvent`s plus the next state. */ + readonly step: (state: State, event: Event) => Effect.Effect], LLMError> + /** Optional request-completion signal for transports that do not end naturally. */ + readonly terminal?: (event: Event) => boolean + /** Optional flush emitted when the framed stream ends. */ + readonly onHalt?: (state: State) => ReadonlyArray +} + +/** + * Construct a `Protocol` from its body and stream pieces: + * + * - `body.schema` infers the provider-native request body shape. + * - `body.from` ties the common `LLMRequest` to the provider body. + * - `stream.event` infers the decoded streaming event and the wire frame. + * - `stream.initial`, `stream.step`, and `stream.onHalt` infer the parser state. + * + * Provider implementations should usually call `Protocol.make({ ... })` + * without explicit type arguments; the schemas and parser functions are the + * source of truth. The constructor remains as the public seam for future + * cross-cutting concerns such as tracing or instrumentation. + */ +export const make = ( + input: Protocol, +): Protocol => input + +export const jsonEvent = (schema: S) => Schema.fromJsonString(schema) + +export * as Protocol from "./protocol" diff --git a/packages/llm/src/route/transport/http.ts b/packages/llm/src/route/transport/http.ts new file mode 100644 index 0000000000..2159ce90b0 --- /dev/null +++ b/packages/llm/src/route/transport/http.ts @@ -0,0 +1,122 @@ +import { Effect, Stream } from "effect" +import { Headers, HttpClientRequest } from "effect/unstable/http" +import { Auth, type Auth as AuthDef } from "../auth" +import { type Endpoint, render as renderEndpoint } from "../endpoint" +import type { Framing } from "../framing" +import type { Transport } from "./index" +import * as ProviderShared from "../../protocols/shared" +import { mergeJsonRecords, type LLMRequest } from "../../schema" + +export interface JsonRequestInput { + readonly body: Body + readonly request: LLMRequest + readonly endpoint: Endpoint + readonly auth: AuthDef + readonly encodeBody: (body: Body) => string + readonly headers?: (input: { readonly request: LLMRequest }) => Record +} + +export interface JsonRequestParts { + readonly url: string + readonly jsonBody: Body | Record + readonly bodyText: string + readonly headers: Headers.Headers +} + +export interface HttpPrepared { + readonly request: HttpClientRequest.HttpClientRequest + readonly framing: Framing +} + +const applyQuery = (url: string, query: Record | undefined) => { + if (!query) return url + const next = new URL(url) + Object.entries(query).forEach(([key, value]) => next.searchParams.set(key, value)) + return next.toString() +} + +const bodyWithOverlay = (body: Body, request: LLMRequest, encodeBody: (body: Body) => string) => + Effect.gen(function* () { + if (request.http?.body === undefined) return { jsonBody: body, bodyText: encodeBody(body) } + if (ProviderShared.isRecord(body)) { + const overlaid = mergeJsonRecords(body, request.http.body) ?? {} + return { jsonBody: overlaid, bodyText: ProviderShared.encodeJson(overlaid) } + } + return yield* ProviderShared.invalidRequest("http.body can only overlay JSON object request bodies") + }) + +export const jsonRequestParts = (input: JsonRequestInput) => + Effect.gen(function* () { + const url = applyQuery( + renderEndpoint(input.endpoint, { request: input.request, body: input.body }).toString(), + input.request.http?.query, + ) + const body = yield* bodyWithOverlay(input.body, input.request, input.encodeBody) + const headers = yield* Auth.toEffect(Auth.isAuth(input.request.model.auth) ? input.request.model.auth : input.auth)( + { + request: input.request, + method: "POST", + url, + body: body.bodyText, + headers: Headers.fromInput({ + ...(input.headers?.({ request: input.request }) ?? {}), + ...input.request.model.headers, + ...input.request.http?.headers, + }), + }, + ) + return { url, jsonBody: body.jsonBody, bodyText: body.bodyText, headers } + }) + +export interface HttpJsonInput { + readonly endpoint: Endpoint + readonly auth?: AuthDef + readonly framing: Framing + readonly encodeBody: (body: Body) => string + readonly headers?: (input: { readonly request: LLMRequest }) => Record +} + +export type HttpJsonPatch = Partial> + +export interface HttpJsonTransport extends Transport, Frame> { + readonly with: (patch: HttpJsonPatch) => HttpJsonTransport +} + +export const httpJson = (input: HttpJsonInput): HttpJsonTransport => ({ + id: "http-json", + with: (patch) => httpJson({ ...input, ...patch }), + prepare: (body, request) => + jsonRequestParts({ + body, + request, + endpoint: input.endpoint, + auth: input.auth ?? Auth.bearer(), + encodeBody: input.encodeBody, + headers: input.headers, + }).pipe( + Effect.map((parts) => ({ + request: ProviderShared.jsonPost({ url: parts.url, body: parts.bodyText, headers: parts.headers }), + framing: input.framing, + })), + ), + frames: (prepared, request, runtime) => + Stream.unwrap( + runtime.http + .execute(prepared.request) + .pipe( + Effect.map((response) => + prepared.framing.frame( + response.stream.pipe( + Stream.mapError((error) => + ProviderShared.eventError( + `${request.model.provider}/${request.model.route}`, + `Failed to read ${request.model.provider}/${request.model.route} stream`, + ProviderShared.errorText(error), + ), + ), + ), + ), + ), + ), + ), +}) diff --git a/packages/llm/src/route/transport/index.ts b/packages/llm/src/route/transport/index.ts new file mode 100644 index 0000000000..f4d5fb29b7 --- /dev/null +++ b/packages/llm/src/route/transport/index.ts @@ -0,0 +1,22 @@ +import type { Effect, Stream } from "effect" +import type { Interface as RequestExecutorInterface } from "../executor" +import type { Interface as WebSocketExecutorInterface } from "./websocket" +import type { LLMError, LLMRequest } from "../../schema" + +export interface TransportRuntime { + readonly http: RequestExecutorInterface + readonly webSocket?: WebSocketExecutorInterface +} + +export interface Transport { + readonly id: string + readonly prepare: (body: Body, request: LLMRequest) => Effect.Effect + readonly frames: ( + prepared: Prepared, + request: LLMRequest, + runtime: TransportRuntime, + ) => Stream.Stream +} + +export * as HttpTransport from "./http" +export { WebSocketExecutor, WebSocketTransport } from "./websocket" diff --git a/packages/llm/src/route/transport/websocket.ts b/packages/llm/src/route/transport/websocket.ts new file mode 100644 index 0000000000..647a6db43d --- /dev/null +++ b/packages/llm/src/route/transport/websocket.ts @@ -0,0 +1,282 @@ +import { Cause, Context, Effect, Queue, Stream } from "effect" +import { Headers } from "effect/unstable/http" +import { Auth, type Auth as AuthDef } from "../auth" +import type { Endpoint } from "../endpoint" +import { LLMError, TransportReason, type LLMRequest } from "../../schema" +import * as HttpTransport from "./http" +import type { Transport } from "./index" + +export interface WebSocketRequest { + readonly url: string + readonly headers: Headers.Headers +} + +export interface WebSocketConnection { + readonly sendText: (message: string) => Effect.Effect + readonly messages: Stream.Stream + readonly close: Effect.Effect +} + +export interface Interface { + readonly open: (input: WebSocketRequest) => Effect.Effect +} + +type WebSocketConstructorWithHeaders = new ( + url: string, + options?: { readonly headers?: Headers.Headers }, +) => globalThis.WebSocket + +export class Service extends Context.Service()("@opencode/LLM/WebSocketExecutor") {} + +const transportError = ( + method: string, + message: string, + input: { readonly url?: string; readonly kind?: string } = {}, +) => + new LLMError({ + module: "WebSocketExecutor", + method, + reason: new TransportReason({ message, url: input.url, kind: input.kind }), + }) + +const eventMessage = (event: Event) => { + if ("message" in event && typeof event.message === "string") return event.message + return event.type +} + +const binaryMessage = (data: unknown) => { + if (data instanceof Uint8Array) return data + if (data instanceof ArrayBuffer) return new Uint8Array(data) + if (ArrayBuffer.isView(data)) return new Uint8Array(data.buffer, data.byteOffset, data.byteLength) + return undefined +} + +const waitOpen = (ws: globalThis.WebSocket, input: WebSocketRequest) => { + if (ws.readyState === globalThis.WebSocket.OPEN) return Effect.void + if (ws.readyState === globalThis.WebSocket.CLOSING || ws.readyState === globalThis.WebSocket.CLOSED) { + return Effect.fail( + transportError("open", `WebSocket closed before opening (state ${ws.readyState})`, { + url: input.url, + kind: "open", + }), + ) + } + return Effect.callback((resume, signal) => { + const cleanup = () => { + ws.removeEventListener("open", onOpen) + ws.removeEventListener("error", onError) + ws.removeEventListener("close", onClose) + signal.removeEventListener("abort", onAbort) + } + const onAbort = () => { + cleanup() + if (ws.readyState !== globalThis.WebSocket.CLOSED && ws.readyState !== globalThis.WebSocket.CLOSING) + ws.close(1000) + } + const onOpen = () => { + cleanup() + resume(Effect.void) + } + const onError = (event: Event) => { + cleanup() + resume( + Effect.fail( + transportError("open", `Failed to open WebSocket: ${eventMessage(event)}`, { url: input.url, kind: "open" }), + ), + ) + } + const onClose = (event: CloseEvent) => { + cleanup() + resume( + Effect.fail( + transportError("open", `WebSocket closed before opening with code ${event.code}`, { + url: input.url, + kind: "open", + }), + ), + ) + } + ws.addEventListener("open", onOpen, { once: true }) + ws.addEventListener("error", onError, { once: true }) + ws.addEventListener("close", onClose, { once: true }) + signal.addEventListener("abort", onAbort, { once: true }) + }) +} + +const webSocketUrl = (value: string) => + Effect.try({ + try: () => { + const url = new URL(value) + if (url.protocol === "https:") { + url.protocol = "wss:" + return url.toString() + } + if (url.protocol === "http:") { + url.protocol = "ws:" + return url.toString() + } + throw new Error(`Unsupported WebSocket URL protocol ${url.protocol}`) + }, + catch: (error) => + transportError("prepare", error instanceof Error ? error.message : "Invalid WebSocket URL", { + url: value, + kind: "websocket", + }), + }) + +export const open = (input: WebSocketRequest) => + Effect.try({ + try: () => + new (globalThis.WebSocket as unknown as WebSocketConstructorWithHeaders)(input.url, { headers: input.headers }), + catch: (error) => + transportError("open", error instanceof Error ? error.message : "Failed to construct WebSocket", { + url: input.url, + kind: "open", + }), + }).pipe(Effect.flatMap((ws) => fromWebSocket(ws, input))) + +export const fromWebSocket = ( + ws: globalThis.WebSocket, + input: WebSocketRequest, +): Effect.Effect => + Effect.gen(function* () { + yield* waitOpen(ws, input) + const messages = yield* Queue.bounded>(128) + + const onMessage = (event: MessageEvent) => { + if (typeof event.data === "string") return Queue.offerUnsafe(messages, event.data) + const binary = binaryMessage(event.data) + if (binary) return Queue.offerUnsafe(messages, binary) + Queue.failCauseUnsafe( + messages, + Cause.fail( + transportError("message", "Unsupported WebSocket message payload", { url: input.url, kind: "message" }), + ), + ) + } + const onError = (event: Event) => { + Queue.failCauseUnsafe( + messages, + Cause.fail( + transportError("message", `WebSocket error: ${eventMessage(event)}`, { url: input.url, kind: "message" }), + ), + ) + } + const onClose = (event: CloseEvent) => { + if (event.code === 1000 || event.code === 1005) return Queue.endUnsafe(messages) + Queue.failCauseUnsafe( + messages, + Cause.fail( + transportError("message", `WebSocket closed with code ${event.code}`, { url: input.url, kind: "close" }), + ), + ) + } + const cleanup = Effect.sync(() => { + ws.removeEventListener("message", onMessage) + ws.removeEventListener("error", onError) + ws.removeEventListener("close", onClose) + }).pipe(Effect.andThen(Queue.shutdown(messages))) + + ws.addEventListener("message", onMessage) + ws.addEventListener("error", onError) + ws.addEventListener("close", onClose) + + return { + sendText: (message) => + Effect.try({ + try: () => ws.send(message), + catch: (error) => + transportError("sendText", error instanceof Error ? error.message : "Failed to send WebSocket message", { + url: input.url, + kind: "write", + }), + }), + messages: Stream.fromQueue(messages), + close: cleanup.pipe( + Effect.andThen( + Effect.sync(() => { + if (ws.readyState === globalThis.WebSocket.CLOSED || ws.readyState === globalThis.WebSocket.CLOSING) return + ws.close(1000) + }), + ), + ), + } + }) + +export const messageText = (message: string | Uint8Array, decoder: TextDecoder) => + typeof message === "string" ? message : decoder.decode(message) + +export interface JsonPrepared { + readonly url: string + readonly headers: Headers.Headers + readonly message: string +} + +export interface JsonInput { + readonly endpoint: Endpoint + readonly auth?: AuthDef + readonly encodeBody: (body: Body) => string + readonly toMessage: (body: Body | Record) => Effect.Effect + readonly encodeMessage: (message: Message) => string + readonly headers?: (input: { readonly request: LLMRequest }) => Record +} + +export type JsonPatch = Partial> + +export interface JsonTransport extends Transport { + readonly with: (patch: JsonPatch) => JsonTransport +} + +export const json = (input: JsonInput): JsonTransport => ({ + id: "websocket-json", + with: (patch) => json({ ...input, ...patch }), + prepare: (body, request) => + Effect.gen(function* () { + const parts = yield* HttpTransport.jsonRequestParts({ + body, + request, + endpoint: input.endpoint, + auth: input.auth ?? Auth.bearer(), + encodeBody: input.encodeBody, + headers: input.headers, + }) + return { + url: yield* webSocketUrl(parts.url), + headers: parts.headers, + message: input.encodeMessage(yield* input.toMessage(parts.jsonBody)), + } + }), + frames: (prepared, _request, runtime) => { + const webSocket = runtime.webSocket + if (!webSocket) { + return Stream.fail( + transportError("json", "WebSocket JSON transport requires WebSocketExecutor.Service", { + url: prepared.url, + kind: "websocket", + }), + ) + } + const decoder = new TextDecoder() + return Stream.unwrap( + Effect.gen(function* () { + const connection = yield* Effect.acquireRelease( + webSocket.open({ url: prepared.url, headers: prepared.headers }), + (connection) => connection.close, + ) + yield* connection.sendText(prepared.message) + return connection.messages.pipe(Stream.map((message) => messageText(message, decoder))) + }), + ) + }, +}) + +export const WebSocketExecutor = { + Service, + open, + fromWebSocket, + messageText, +} as const + +export const WebSocketTransport = { + json, +} as const diff --git a/packages/llm/src/schema/errors.ts b/packages/llm/src/schema/errors.ts new file mode 100644 index 0000000000..9bcc8e1694 --- /dev/null +++ b/packages/llm/src/schema/errors.ts @@ -0,0 +1,202 @@ +import { Schema } from "effect" +import { ModelID, ProviderID, ProviderMetadata, RouteID } from "./ids" + +export class HttpRequestDetails extends Schema.Class("LLM.HttpRequestDetails")({ + method: Schema.String, + url: Schema.String, + headers: Schema.Record(Schema.String, Schema.String), +}) {} + +export class HttpResponseDetails extends Schema.Class("LLM.HttpResponseDetails")({ + status: Schema.Number, + headers: Schema.Record(Schema.String, Schema.String), +}) {} + +export class HttpRateLimitDetails extends Schema.Class("LLM.HttpRateLimitDetails")({ + retryAfterMs: Schema.optional(Schema.Number), + limit: Schema.optional(Schema.Record(Schema.String, Schema.String)), + remaining: Schema.optional(Schema.Record(Schema.String, Schema.String)), + reset: Schema.optional(Schema.Record(Schema.String, Schema.String)), +}) {} + +export class HttpContext extends Schema.Class("LLM.HttpContext")({ + request: HttpRequestDetails, + response: Schema.optional(HttpResponseDetails), + body: Schema.optional(Schema.String), + bodyTruncated: Schema.optional(Schema.Boolean), + requestId: Schema.optional(Schema.String), + rateLimit: Schema.optional(HttpRateLimitDetails), +}) {} + +export class InvalidRequestReason extends Schema.Class("LLM.Error.InvalidRequest")({ + _tag: Schema.tag("InvalidRequest"), + message: Schema.String, + parameter: Schema.optional(Schema.String), + providerMetadata: Schema.optional(ProviderMetadata), + http: Schema.optional(HttpContext), +}) { + get retryable() { + return false + } +} + +export class NoRouteReason extends Schema.Class("LLM.Error.NoRoute")({ + _tag: Schema.tag("NoRoute"), + route: RouteID, + provider: ProviderID, + model: ModelID, +}) { + get retryable() { + return false + } + + get message() { + return `No LLM route for ${this.provider}/${this.model} using ${this.route}` + } +} + +export class AuthenticationReason extends Schema.Class("LLM.Error.Authentication")({ + _tag: Schema.tag("Authentication"), + message: Schema.String, + kind: Schema.Literals(["missing", "invalid", "expired", "insufficient-permissions", "unknown"]), + providerMetadata: Schema.optional(ProviderMetadata), + http: Schema.optional(HttpContext), +}) { + get retryable() { + return false + } +} + +export class RateLimitReason extends Schema.Class("LLM.Error.RateLimit")({ + _tag: Schema.tag("RateLimit"), + message: Schema.String, + retryAfterMs: Schema.optional(Schema.Number), + rateLimit: Schema.optional(HttpRateLimitDetails), + providerMetadata: Schema.optional(ProviderMetadata), + http: Schema.optional(HttpContext), +}) { + get retryable() { + return true + } +} + +export class QuotaExceededReason extends Schema.Class("LLM.Error.QuotaExceeded")({ + _tag: Schema.tag("QuotaExceeded"), + message: Schema.String, + providerMetadata: Schema.optional(ProviderMetadata), + http: Schema.optional(HttpContext), +}) { + get retryable() { + return false + } +} + +export class ContentPolicyReason extends Schema.Class("LLM.Error.ContentPolicy")({ + _tag: Schema.tag("ContentPolicy"), + message: Schema.String, + providerMetadata: Schema.optional(ProviderMetadata), + http: Schema.optional(HttpContext), +}) { + get retryable() { + return false + } +} + +export class ProviderInternalReason extends Schema.Class("LLM.Error.ProviderInternal")({ + _tag: Schema.tag("ProviderInternal"), + message: Schema.String, + status: Schema.Number, + retryAfterMs: Schema.optional(Schema.Number), + providerMetadata: Schema.optional(ProviderMetadata), + http: Schema.optional(HttpContext), +}) { + get retryable() { + return true + } +} + +export class TransportReason extends Schema.Class("LLM.Error.Transport")({ + _tag: Schema.tag("Transport"), + message: Schema.String, + kind: Schema.optional(Schema.String), + url: Schema.optional(Schema.String), + http: Schema.optional(HttpContext), +}) { + get retryable() { + return false + } +} + +export class InvalidProviderOutputReason extends Schema.Class( + "LLM.Error.InvalidProviderOutput", +)({ + _tag: Schema.tag("InvalidProviderOutput"), + message: Schema.String, + route: Schema.optional(Schema.String), + raw: Schema.optional(Schema.String), + providerMetadata: Schema.optional(ProviderMetadata), +}) { + get retryable() { + return false + } +} + +export class UnknownProviderReason extends Schema.Class("LLM.Error.UnknownProvider")({ + _tag: Schema.tag("UnknownProvider"), + message: Schema.String, + status: Schema.optional(Schema.Number), + providerMetadata: Schema.optional(ProviderMetadata), + http: Schema.optional(HttpContext), +}) { + get retryable() { + return false + } +} + +export const LLMErrorReason = Schema.Union([ + InvalidRequestReason, + NoRouteReason, + AuthenticationReason, + RateLimitReason, + QuotaExceededReason, + ContentPolicyReason, + ProviderInternalReason, + TransportReason, + InvalidProviderOutputReason, + UnknownProviderReason, +]).pipe(Schema.toTaggedUnion("_tag")) +export type LLMErrorReason = Schema.Schema.Type + +export class LLMError extends Schema.TaggedErrorClass()("LLM.Error", { + module: Schema.String, + method: Schema.String, + reason: LLMErrorReason, +}) { + override readonly cause = this.reason + + get retryable() { + return this.reason.retryable + } + + get retryAfterMs() { + return "retryAfterMs" in this.reason ? this.reason.retryAfterMs : undefined + } + + override get message() { + return `${this.module}.${this.method}: ${this.reason.message}` + } +} + +/** + * Failure type for tool execute handlers. Handlers must map their internal + * errors to this shape; the runtime catches `ToolFailure`s and surfaces them + * as `tool-error` events plus a `tool-result` of `type: "error"` so the model + * can self-correct. + * + * Anything thrown or yielded by a handler that is not a `ToolFailure` is + * treated as a defect and fails the stream. + */ +export class ToolFailure extends Schema.TaggedErrorClass()("LLM.ToolFailure", { + message: Schema.String, + metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), +}) {} diff --git a/packages/llm/src/schema/events.ts b/packages/llm/src/schema/events.ts new file mode 100644 index 0000000000..6e6bb1541b --- /dev/null +++ b/packages/llm/src/schema/events.ts @@ -0,0 +1,355 @@ +import { Schema } from "effect" +import { ContentBlockID, FinishReason, ProtocolID, ProviderMetadata, ResponseID, RouteID, ToolCallID } from "./ids" +import { ModelRef } from "./options" +import { ToolResultValue } from "./messages" + +/** + * Token usage reported by an LLM provider. + * + * **Inclusive totals** (match AI SDK / OpenAI / LangChain convention — a + * reader from any of those ecosystems sees the number they expect): + * + * - `inputTokens` — total prompt tokens, *including* cached reads/writes. + * - `outputTokens` — total output tokens, *including* reasoning. + * - `totalTokens` — provider-supplied total, or `inputTokens + outputTokens`. + * + * **Non-overlapping breakdown** (every field is independently meaningful; + * consumers never have to subtract): + * + * - `nonCachedInputTokens` — the "fresh" portion of the prompt. + * - `cacheReadInputTokens` — input tokens served from cache. + * - `cacheWriteInputTokens` — input tokens written to cache. + * - `reasoningTokens` — subset of `outputTokens` spent on hidden reasoning. + * + * **Invariant**: `nonCachedInputTokens + cacheReadInputTokens + + * cacheWriteInputTokens = inputTokens`, and `reasoningTokens ≤ outputTokens`. + * Each protocol mapper computes whichever side it doesn't get natively, + * with `Math.max(0, …)` clamping for defense against provider bugs. Because + * every breakdown field is stored independently, downstream consumers can + * read whatever they need (cost-by-category, context-pressure, AI-SDK-style + * inclusive total) without ever subtracting — eliminating the underflow + * class of bug where a clamped difference would silently store the wrong + * value. + * + * **Semantics by provider**: + * + * - OpenAI Chat / Responses / Gemini / Bedrock: provider reports inclusive + * `inputTokens` and an inclusive `outputTokens`; mapper subtracts to + * derive the breakdown. + * - Anthropic: provider reports the breakdown natively (`input_tokens` is + * non-cached only); mapper sums to derive the inclusive `inputTokens`. + * Anthropic does *not* break extended-thinking out of `output_tokens`, so + * `reasoningTokens` is `undefined` and `outputTokens` carries the + * combined total — a documented limitation of the Anthropic API. + * + * `providerMetadata` always carries the provider's raw usage payload — + * keyed by provider name (`{ openai: ... }`, `{ anthropic: ... }`, etc.) + * — for fields we don't normalize and for billing-level audit trails. + * Matches the same escape-hatch field on `LLMEvent`. + */ +export class Usage extends Schema.Class("LLM.Usage")({ + inputTokens: Schema.optional(Schema.Number), + outputTokens: Schema.optional(Schema.Number), + nonCachedInputTokens: Schema.optional(Schema.Number), + cacheReadInputTokens: Schema.optional(Schema.Number), + cacheWriteInputTokens: Schema.optional(Schema.Number), + reasoningTokens: Schema.optional(Schema.Number), + totalTokens: Schema.optional(Schema.Number), + providerMetadata: Schema.optional(ProviderMetadata), +}) { + /** + * Visible output tokens — `outputTokens` minus `reasoningTokens`, clamped + * to zero. The one place subtraction happens in this contract; the clamp + * means a provider reporting `reasoningTokens > outputTokens` produces a + * harmless zero rather than a negative that crashes downstream schemas. + */ + get visibleOutputTokens() { + return Math.max(0, (this.outputTokens ?? 0) - (this.reasoningTokens ?? 0)) + } +} + +export const RequestStart = Schema.Struct({ + type: Schema.tag("request-start"), + id: ResponseID, + model: ModelRef, +}).annotate({ identifier: "LLM.Event.RequestStart" }) +export type RequestStart = Schema.Schema.Type + +export const StepStart = Schema.Struct({ + type: Schema.tag("step-start"), + index: Schema.Number, +}).annotate({ identifier: "LLM.Event.StepStart" }) +export type StepStart = Schema.Schema.Type + +export const TextStart = Schema.Struct({ + type: Schema.tag("text-start"), + id: ContentBlockID, + providerMetadata: Schema.optional(ProviderMetadata), +}).annotate({ identifier: "LLM.Event.TextStart" }) +export type TextStart = Schema.Schema.Type + +export const TextDelta = Schema.Struct({ + type: Schema.tag("text-delta"), + id: ContentBlockID, + text: Schema.String, +}).annotate({ identifier: "LLM.Event.TextDelta" }) +export type TextDelta = Schema.Schema.Type + +export const TextEnd = Schema.Struct({ + type: Schema.tag("text-end"), + id: ContentBlockID, + providerMetadata: Schema.optional(ProviderMetadata), +}).annotate({ identifier: "LLM.Event.TextEnd" }) +export type TextEnd = Schema.Schema.Type + +export const ReasoningStart = Schema.Struct({ + type: Schema.tag("reasoning-start"), + id: ContentBlockID, + providerMetadata: Schema.optional(ProviderMetadata), +}).annotate({ identifier: "LLM.Event.ReasoningStart" }) +export type ReasoningStart = Schema.Schema.Type + +export const ReasoningDelta = Schema.Struct({ + type: Schema.tag("reasoning-delta"), + id: ContentBlockID, + text: Schema.String, +}).annotate({ identifier: "LLM.Event.ReasoningDelta" }) +export type ReasoningDelta = Schema.Schema.Type + +export const ReasoningEnd = Schema.Struct({ + type: Schema.tag("reasoning-end"), + id: ContentBlockID, + providerMetadata: Schema.optional(ProviderMetadata), +}).annotate({ identifier: "LLM.Event.ReasoningEnd" }) +export type ReasoningEnd = Schema.Schema.Type + +export const ToolInputStart = Schema.Struct({ + type: Schema.tag("tool-input-start"), + id: ToolCallID, + name: Schema.String, + providerMetadata: Schema.optional(ProviderMetadata), +}).annotate({ identifier: "LLM.Event.ToolInputStart" }) +export type ToolInputStart = Schema.Schema.Type + +export const ToolInputDelta = Schema.Struct({ + type: Schema.tag("tool-input-delta"), + id: ToolCallID, + name: Schema.String, + text: Schema.String, +}).annotate({ identifier: "LLM.Event.ToolInputDelta" }) +export type ToolInputDelta = Schema.Schema.Type + +export const ToolInputEnd = Schema.Struct({ + type: Schema.tag("tool-input-end"), + id: ToolCallID, + name: Schema.String, + providerMetadata: Schema.optional(ProviderMetadata), +}).annotate({ identifier: "LLM.Event.ToolInputEnd" }) +export type ToolInputEnd = Schema.Schema.Type + +export const ToolCall = Schema.Struct({ + type: Schema.tag("tool-call"), + id: ToolCallID, + name: Schema.String, + input: Schema.Unknown, + providerExecuted: Schema.optional(Schema.Boolean), + providerMetadata: Schema.optional(ProviderMetadata), +}).annotate({ identifier: "LLM.Event.ToolCall" }) +export type ToolCall = Schema.Schema.Type + +export const ToolResult = Schema.Struct({ + type: Schema.tag("tool-result"), + id: ToolCallID, + name: Schema.String, + result: ToolResultValue, + providerExecuted: Schema.optional(Schema.Boolean), + providerMetadata: Schema.optional(ProviderMetadata), +}).annotate({ identifier: "LLM.Event.ToolResult" }) +export type ToolResult = Schema.Schema.Type + +export const ToolError = Schema.Struct({ + type: Schema.tag("tool-error"), + id: ToolCallID, + name: Schema.String, + message: Schema.String, + providerMetadata: Schema.optional(ProviderMetadata), +}).annotate({ identifier: "LLM.Event.ToolError" }) +export type ToolError = Schema.Schema.Type + +export const StepFinish = Schema.Struct({ + type: Schema.tag("step-finish"), + index: Schema.Number, + reason: FinishReason, + usage: Schema.optional(Usage), + providerMetadata: Schema.optional(ProviderMetadata), +}).annotate({ identifier: "LLM.Event.StepFinish" }) +export type StepFinish = Schema.Schema.Type + +export const RequestFinish = Schema.Struct({ + type: Schema.tag("request-finish"), + reason: FinishReason, + usage: Schema.optional(Usage), + providerMetadata: Schema.optional(ProviderMetadata), +}).annotate({ identifier: "LLM.Event.RequestFinish" }) +export type RequestFinish = Schema.Schema.Type + +export const ProviderErrorEvent = Schema.Struct({ + type: Schema.tag("provider-error"), + message: Schema.String, + retryable: Schema.optional(Schema.Boolean), + providerMetadata: Schema.optional(ProviderMetadata), +}).annotate({ identifier: "LLM.Event.ProviderError" }) +export type ProviderErrorEvent = Schema.Schema.Type + +const llmEventTagged = Schema.Union([ + RequestStart, + StepStart, + TextStart, + TextDelta, + TextEnd, + ReasoningStart, + ReasoningDelta, + ReasoningEnd, + ToolInputStart, + ToolInputDelta, + ToolInputEnd, + ToolCall, + ToolResult, + ToolError, + StepFinish, + RequestFinish, + ProviderErrorEvent, +]).pipe(Schema.toTaggedUnion("type")) + +type WithID = Omit & { readonly id: ID | string } + +const responseID = (value: ResponseID | string) => ResponseID.make(value) +const contentBlockID = (value: ContentBlockID | string) => ContentBlockID.make(value) +const toolCallID = (value: ToolCallID | string) => ToolCallID.make(value) + +/** + * camelCase aliases for `LLMEvent.guards` (provided by `Schema.toTaggedUnion`). + * Lets consumers write `events.filter(LLMEvent.is.toolCall)` instead of + * `events.filter(LLMEvent.guards["tool-call"])`. + */ +export const LLMEvent = Object.assign(llmEventTagged, { + requestStart: (input: WithID) => RequestStart.make({ ...input, id: responseID(input.id) }), + stepStart: StepStart.make, + textStart: (input: WithID) => TextStart.make({ ...input, id: contentBlockID(input.id) }), + textDelta: (input: WithID) => TextDelta.make({ ...input, id: contentBlockID(input.id) }), + textEnd: (input: WithID) => TextEnd.make({ ...input, id: contentBlockID(input.id) }), + reasoningStart: (input: WithID) => + ReasoningStart.make({ ...input, id: contentBlockID(input.id) }), + reasoningDelta: (input: WithID) => + ReasoningDelta.make({ ...input, id: contentBlockID(input.id) }), + reasoningEnd: (input: WithID) => + ReasoningEnd.make({ ...input, id: contentBlockID(input.id) }), + toolInputStart: (input: WithID) => + ToolInputStart.make({ ...input, id: toolCallID(input.id) }), + toolInputDelta: (input: WithID) => + ToolInputDelta.make({ ...input, id: toolCallID(input.id) }), + toolInputEnd: (input: WithID) => ToolInputEnd.make({ ...input, id: toolCallID(input.id) }), + toolCall: (input: WithID) => ToolCall.make({ ...input, id: toolCallID(input.id) }), + toolResult: (input: WithID) => ToolResult.make({ ...input, id: toolCallID(input.id) }), + toolError: (input: WithID) => ToolError.make({ ...input, id: toolCallID(input.id) }), + stepFinish: StepFinish.make, + requestFinish: RequestFinish.make, + providerError: ProviderErrorEvent.make, + is: { + requestStart: llmEventTagged.guards["request-start"], + stepStart: llmEventTagged.guards["step-start"], + textStart: llmEventTagged.guards["text-start"], + textDelta: llmEventTagged.guards["text-delta"], + textEnd: llmEventTagged.guards["text-end"], + reasoningStart: llmEventTagged.guards["reasoning-start"], + reasoningDelta: llmEventTagged.guards["reasoning-delta"], + reasoningEnd: llmEventTagged.guards["reasoning-end"], + toolInputStart: llmEventTagged.guards["tool-input-start"], + toolInputDelta: llmEventTagged.guards["tool-input-delta"], + toolInputEnd: llmEventTagged.guards["tool-input-end"], + toolCall: llmEventTagged.guards["tool-call"], + toolResult: llmEventTagged.guards["tool-result"], + toolError: llmEventTagged.guards["tool-error"], + stepFinish: llmEventTagged.guards["step-finish"], + requestFinish: llmEventTagged.guards["request-finish"], + providerError: llmEventTagged.guards["provider-error"], + }, +}) +export type LLMEvent = Schema.Schema.Type + +export class PreparedRequest extends Schema.Class("LLM.PreparedRequest")({ + id: Schema.String, + route: RouteID, + protocol: ProtocolID, + model: ModelRef, + body: Schema.Unknown, + metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), +}) {} + +/** + * A `PreparedRequest` whose `body` is typed as `Body`. Use with the generic + * on `LLMClient.prepare(...)` when the caller knows which route their + * request will resolve to and wants its native shape statically exposed + * (debug UIs, request previews, plan rendering). + * + * The runtime body is identical — the route still emits `body: unknown` — so + * this is a type-level assertion the caller makes about what they expect to + * find. The prepare runtime does not validate the assertion. + */ +export type PreparedRequestOf = Omit & { + readonly body: Body +} + +const responseText = (events: ReadonlyArray) => + events + .filter(LLMEvent.is.textDelta) + .map((event) => event.text) + .join("") + +const responseReasoning = (events: ReadonlyArray) => + events + .filter(LLMEvent.is.reasoningDelta) + .map((event) => event.text) + .join("") + +const responseUsage = (events: ReadonlyArray) => + events.reduce( + (usage, event) => ("usage" in event && event.usage !== undefined ? event.usage : usage), + undefined, + ) + +export class LLMResponse extends Schema.Class("LLM.Response")({ + events: Schema.Array(LLMEvent), + usage: Schema.optional(Usage), +}) { + /** Concatenated assistant text assembled from streamed `text-delta` events. */ + get text() { + return responseText(this.events) + } + + /** Concatenated reasoning text assembled from streamed `reasoning-delta` events. */ + get reasoning() { + return responseReasoning(this.events) + } + + /** Completed tool calls emitted by the provider. */ + get toolCalls() { + return this.events.filter(LLMEvent.is.toolCall) + } +} + +export namespace LLMResponse { + export type Output = LLMResponse | { readonly events: ReadonlyArray; readonly usage?: Usage } + + /** Concatenate assistant text from a response or collected event list. */ + export const text = (response: Output) => responseText(response.events) + + /** Return response usage, falling back to the latest usage-bearing event. */ + export const usage = (response: Output) => response.usage ?? responseUsage(response.events) + + /** Return completed tool calls from a response or collected event list. */ + export const toolCalls = (response: Output) => response.events.filter(LLMEvent.is.toolCall) + + /** Concatenate reasoning text from a response or collected event list. */ + export const reasoning = (response: Output) => responseReasoning(response.events) +} diff --git a/packages/llm/src/schema/ids.ts b/packages/llm/src/schema/ids.ts new file mode 100644 index 0000000000..ada133f0db --- /dev/null +++ b/packages/llm/src/schema/ids.ts @@ -0,0 +1,43 @@ +import { Schema } from "effect" + +/** Stable string identifier for a protocol implementation. */ +export const ProtocolID = Schema.String +export type ProtocolID = Schema.Schema.Type + +/** Stable string identifier for the runnable route. */ +export const RouteID = Schema.String +export type RouteID = Schema.Schema.Type + +export const ModelID = Schema.String.pipe(Schema.brand("LLM.ModelID")) +export type ModelID = typeof ModelID.Type + +export const ProviderID = Schema.String.pipe(Schema.brand("LLM.ProviderID")) +export type ProviderID = typeof ProviderID.Type + +export const ResponseID = Schema.String +export type ResponseID = Schema.Schema.Type + +export const ContentBlockID = Schema.String +export type ContentBlockID = Schema.Schema.Type + +export const ToolCallID = Schema.String +export type ToolCallID = Schema.Schema.Type + +export const ReasoningEfforts = ["none", "minimal", "low", "medium", "high", "xhigh", "max"] as const +export const ReasoningEffort = Schema.Literals(ReasoningEfforts) +export type ReasoningEffort = Schema.Schema.Type + +export const TextVerbosity = Schema.Literals(["low", "medium", "high"]) +export type TextVerbosity = Schema.Schema.Type + +export const MessageRole = Schema.Literals(["user", "assistant", "tool"]) +export type MessageRole = Schema.Schema.Type + +export const FinishReason = Schema.Literals(["stop", "length", "tool-calls", "content-filter", "error", "unknown"]) +export type FinishReason = Schema.Schema.Type + +export const JsonSchema = Schema.Record(Schema.String, Schema.Unknown) +export type JsonSchema = Schema.Schema.Type + +export const ProviderMetadata = Schema.Record(Schema.String, Schema.Record(Schema.String, Schema.Unknown)) +export type ProviderMetadata = Schema.Schema.Type diff --git a/packages/llm/src/schema/index.ts b/packages/llm/src/schema/index.ts new file mode 100644 index 0000000000..0c0fede8fa --- /dev/null +++ b/packages/llm/src/schema/index.ts @@ -0,0 +1,5 @@ +export * from "./ids" +export * from "./options" +export * from "./messages" +export * from "./events" +export * from "./errors" diff --git a/packages/llm/src/schema/messages.ts b/packages/llm/src/schema/messages.ts new file mode 100644 index 0000000000..c38a66d33d --- /dev/null +++ b/packages/llm/src/schema/messages.ts @@ -0,0 +1,239 @@ +import { Schema } from "effect" +import { JsonSchema, MessageRole, ProviderMetadata } from "./ids" +import { CacheHint, CachePolicy, GenerationOptions, HttpOptions, ModelRef, ProviderOptions } from "./options" + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value) + +const systemPartSchema = Schema.Struct({ + type: Schema.Literal("text"), + text: Schema.String, + cache: Schema.optional(CacheHint), + metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), +}).annotate({ identifier: "LLM.SystemPart" }) +export type SystemPart = Schema.Schema.Type + +const makeSystemPart = (text: string): SystemPart => ({ type: "text", text }) + +export const SystemPart = Object.assign(systemPartSchema, { + make: makeSystemPart, + content: (input?: string | SystemPart | ReadonlyArray) => { + if (input === undefined) return [] + return typeof input === "string" ? [makeSystemPart(input)] : Array.isArray(input) ? [...input] : [input] + }, +}) + +export const TextPart = Schema.Struct({ + type: Schema.Literal("text"), + text: Schema.String, + cache: Schema.optional(CacheHint), + metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), + providerMetadata: Schema.optional(ProviderMetadata), +}).annotate({ identifier: "LLM.Content.Text" }) +export type TextPart = Schema.Schema.Type + +export const MediaPart = Schema.Struct({ + type: Schema.Literal("media"), + mediaType: Schema.String, + data: Schema.Union([Schema.String, Schema.Uint8Array]), + filename: Schema.optional(Schema.String), + metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), +}).annotate({ identifier: "LLM.Content.Media" }) +export type MediaPart = Schema.Schema.Type + +const isToolResultValue = (value: unknown): value is ToolResultValue => + isRecord(value) && (value.type === "text" || value.type === "json" || value.type === "error") && "value" in value + +export const ToolResultValue = Object.assign( + Schema.Struct({ + type: Schema.Literals(["json", "text", "error"]), + value: Schema.Unknown, + }).annotate({ identifier: "LLM.ToolResult" }), + { + make: (value: unknown, type: ToolResultValue["type"] = "json"): ToolResultValue => + isToolResultValue(value) ? value : { type, value }, + }, +) +export type ToolResultValue = Schema.Schema.Type + +export const ToolCallPart = Object.assign( + Schema.Struct({ + type: Schema.Literal("tool-call"), + id: Schema.String, + name: Schema.String, + input: Schema.Unknown, + providerExecuted: Schema.optional(Schema.Boolean), + metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), + providerMetadata: Schema.optional(ProviderMetadata), + }).annotate({ identifier: "LLM.Content.ToolCall" }), + { + make: (input: Omit): ToolCallPart => ({ type: "tool-call", ...input }), + }, +) +export type ToolCallPart = Schema.Schema.Type + +export const ToolResultPart = Object.assign( + Schema.Struct({ + type: Schema.Literal("tool-result"), + id: Schema.String, + name: Schema.String, + result: ToolResultValue, + providerExecuted: Schema.optional(Schema.Boolean), + cache: Schema.optional(CacheHint), + metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), + providerMetadata: Schema.optional(ProviderMetadata), + }).annotate({ identifier: "LLM.Content.ToolResult" }), + { + make: ( + input: Omit & { + readonly result: unknown + readonly resultType?: ToolResultValue["type"] + }, + ): ToolResultPart => ({ + type: "tool-result", + id: input.id, + name: input.name, + result: ToolResultValue.make(input.result, input.resultType), + providerExecuted: input.providerExecuted, + cache: input.cache, + metadata: input.metadata, + providerMetadata: input.providerMetadata, + }), + }, +) +export type ToolResultPart = Schema.Schema.Type + +export const ReasoningPart = Schema.Struct({ + type: Schema.Literal("reasoning"), + text: Schema.String, + encrypted: Schema.optional(Schema.String), + metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), + providerMetadata: Schema.optional(ProviderMetadata), +}).annotate({ identifier: "LLM.Content.Reasoning" }) +export type ReasoningPart = Schema.Schema.Type + +export const ContentPart = Schema.Union([TextPart, MediaPart, ToolCallPart, ToolResultPart, ReasoningPart]).pipe( + Schema.toTaggedUnion("type"), +) +export type ContentPart = Schema.Schema.Type + +export class Message extends Schema.Class("LLM.Message")({ + id: Schema.optional(Schema.String), + role: MessageRole, + content: Schema.Array(ContentPart), + metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), + native: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), +}) {} + +export namespace Message { + export type ContentInput = string | ContentPart | ReadonlyArray + export type Input = Omit[0], "content"> & { + readonly content: ContentInput + } + + export const text = (value: string): ContentPart => ({ type: "text", text: value }) + + export const content = (input: ContentInput) => + typeof input === "string" ? [text(input)] : Array.isArray(input) ? [...input] : [input] + + export const make = (input: Message | Input) => { + if (input instanceof Message) return input + return new Message({ ...input, content: content(input.content) }) + } + + export const user = (content: ContentInput) => make({ role: "user", content }) + + export const assistant = (content: ContentInput) => make({ role: "assistant", content }) + + export const tool = (result: ToolResultPart | Parameters[0]) => + make({ role: "tool", content: ["type" in result ? result : ToolResultPart.make(result)] }) +} + +export class ToolDefinition extends Schema.Class("LLM.ToolDefinition")({ + name: Schema.String, + description: Schema.String, + inputSchema: JsonSchema, + cache: Schema.optional(CacheHint), + metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), + native: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), +}) {} + +export namespace ToolDefinition { + export type Input = ToolDefinition | ConstructorParameters[0] + + /** Normalize tool definition input into the canonical `ToolDefinition` class. */ + export const make = (input: Input) => (input instanceof ToolDefinition ? input : new ToolDefinition(input)) +} + +export class ToolChoice extends Schema.Class("LLM.ToolChoice")({ + type: Schema.Literals(["auto", "none", "required", "tool"]), + name: Schema.optional(Schema.String), +}) {} + +export namespace ToolChoice { + export type Mode = Exclude + export type Input = ToolChoice | ConstructorParameters[0] | ToolDefinition | string + + const isMode = (value: string): value is Mode => value === "auto" || value === "none" || value === "required" + + /** Select a specific named tool. */ + export const named = (value: string) => new ToolChoice({ type: "tool", name: value }) + + /** Normalize ergonomic tool-choice inputs into the canonical `ToolChoice` class. */ + export const make = (input: Input) => { + if (input instanceof ToolChoice) return input + if (input instanceof ToolDefinition) return named(input.name) + if (typeof input === "string") return isMode(input) ? new ToolChoice({ type: input }) : named(input) + return new ToolChoice(input) + } +} + +export const ResponseFormat = Schema.Union([ + Schema.Struct({ type: Schema.Literal("text") }), + Schema.Struct({ type: Schema.Literal("json"), schema: JsonSchema }), + Schema.Struct({ type: Schema.Literal("tool"), tool: ToolDefinition }), +]).pipe(Schema.toTaggedUnion("type")) +export type ResponseFormat = Schema.Schema.Type + +export class LLMRequest extends Schema.Class("LLM.Request")({ + id: Schema.optional(Schema.String), + model: ModelRef, + system: Schema.Array(SystemPart), + messages: Schema.Array(Message), + tools: Schema.Array(ToolDefinition), + toolChoice: Schema.optional(ToolChoice), + generation: Schema.optional(GenerationOptions), + providerOptions: Schema.optional(ProviderOptions), + http: Schema.optional(HttpOptions), + responseFormat: Schema.optional(ResponseFormat), + cache: Schema.optional(CachePolicy), + metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), +}) {} + +export namespace LLMRequest { + export type Input = ConstructorParameters[0] + + export const input = (request: LLMRequest): Input => ({ + id: request.id, + model: request.model, + system: request.system, + messages: request.messages, + tools: request.tools, + toolChoice: request.toolChoice, + generation: request.generation, + providerOptions: request.providerOptions, + http: request.http, + responseFormat: request.responseFormat, + cache: request.cache, + metadata: request.metadata, + }) + + export const update = (request: LLMRequest, patch: Partial) => { + if (Object.keys(patch).length === 0) return request + return new LLMRequest({ + ...input(request), + ...patch, + model: patch.model ?? request.model, + }) + } +} diff --git a/packages/llm/src/schema/options.ts b/packages/llm/src/schema/options.ts new file mode 100644 index 0000000000..0f40196f7d --- /dev/null +++ b/packages/llm/src/schema/options.ts @@ -0,0 +1,230 @@ +import { Schema } from "effect" +import { JsonSchema, ModelID, ProviderID, RouteID } from "./ids" + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value) + +export const mergeJsonRecords = ( + ...items: ReadonlyArray | undefined> +): Record | undefined => { + const defined = items.filter((item): item is Record => item !== undefined) + if (defined.length === 0) return undefined + if (defined.length === 1 && Object.values(defined[0]).every((value) => value !== undefined)) return defined[0] + const result: Record = {} + for (const item of defined) { + for (const [key, value] of Object.entries(item)) { + if (value === undefined) continue + result[key] = isRecord(result[key]) && isRecord(value) ? mergeJsonRecords(result[key], value) : value + } + } + return Object.keys(result).length === 0 ? undefined : result +} + +const mergeStringRecords = ( + ...items: ReadonlyArray | undefined> +): Record | undefined => { + const defined = items.filter((item): item is Record => item !== undefined) + if (defined.length === 0) return undefined + if (defined.length === 1) return defined[0] + const result = Object.fromEntries( + defined.flatMap((item) => + Object.entries(item).filter((entry): entry is [string, string] => entry[1] !== undefined), + ), + ) + return Object.keys(result).length === 0 ? undefined : result +} + +export const ProviderOptions = Schema.Record(Schema.String, Schema.Record(Schema.String, Schema.Unknown)) +export type ProviderOptions = Schema.Schema.Type + +export const mergeProviderOptions = ( + ...items: ReadonlyArray +): ProviderOptions | undefined => { + const result: Record> = {} + for (const item of items) { + if (!item) continue + for (const [provider, options] of Object.entries(item)) { + const merged = mergeJsonRecords(result[provider], options) + if (merged) result[provider] = merged + } + } + return Object.keys(result).length === 0 ? undefined : result +} + +export class HttpOptions extends Schema.Class("LLM.HttpOptions")({ + body: Schema.optional(JsonSchema), + headers: Schema.optional(Schema.Record(Schema.String, Schema.String)), + query: Schema.optional(Schema.Record(Schema.String, Schema.String)), +}) {} + +export namespace HttpOptions { + export type Input = HttpOptions | ConstructorParameters[0] + + /** Normalize HTTP option input into the canonical `HttpOptions` class. */ + export const make = (input: Input) => (input instanceof HttpOptions ? input : new HttpOptions(input)) +} + +export const mergeHttpOptions = (...items: ReadonlyArray): HttpOptions | undefined => { + const body = mergeJsonRecords(...items.map((item) => item?.body)) + const headers = mergeStringRecords(...items.map((item) => item?.headers)) + const query = mergeStringRecords(...items.map((item) => item?.query)) + if (!body && !headers && !query) return undefined + return new HttpOptions({ body, headers, query }) +} + +export class GenerationOptions extends Schema.Class("LLM.GenerationOptions")({ + maxTokens: Schema.optional(Schema.Number), + temperature: Schema.optional(Schema.Number), + topP: Schema.optional(Schema.Number), + topK: Schema.optional(Schema.Number), + frequencyPenalty: Schema.optional(Schema.Number), + presencePenalty: Schema.optional(Schema.Number), + seed: Schema.optional(Schema.Number), + stop: Schema.optional(Schema.Array(Schema.String)), +}) {} + +export namespace GenerationOptions { + export type Input = GenerationOptions | ConstructorParameters[0] + + /** Normalize generation option input into the canonical `GenerationOptions` class. */ + export const make = (input: Input = {}) => (input instanceof GenerationOptions ? input : new GenerationOptions(input)) +} + +export type GenerationOptionsFields = { + readonly maxTokens?: number + readonly temperature?: number + readonly topP?: number + readonly topK?: number + readonly frequencyPenalty?: number + readonly presencePenalty?: number + readonly seed?: number + readonly stop?: ReadonlyArray +} + +export type GenerationOptionsInput = GenerationOptions | GenerationOptionsFields + +const latestGeneration = ( + items: ReadonlyArray, + key: Key, +) => items.findLast((item) => item?.[key] !== undefined)?.[key] + +export const mergeGenerationOptions = (...items: ReadonlyArray) => { + const result = new GenerationOptions({ + maxTokens: latestGeneration(items, "maxTokens"), + temperature: latestGeneration(items, "temperature"), + topP: latestGeneration(items, "topP"), + topK: latestGeneration(items, "topK"), + frequencyPenalty: latestGeneration(items, "frequencyPenalty"), + presencePenalty: latestGeneration(items, "presencePenalty"), + seed: latestGeneration(items, "seed"), + stop: latestGeneration(items, "stop"), + }) + return Object.values(result).some((value) => value !== undefined) ? result : undefined +} + +export class ModelLimits extends Schema.Class("LLM.ModelLimits")({ + context: Schema.optional(Schema.Number), + output: Schema.optional(Schema.Number), +}) {} + +export namespace ModelLimits { + export type Input = ModelLimits | ConstructorParameters[0] + + /** Normalize model limit input into the canonical `ModelLimits` class. */ + export const make = (input: Input | undefined) => + input instanceof ModelLimits ? input : new ModelLimits(input ?? {}) +} + +export class ModelRef extends Schema.Class("LLM.ModelRef")({ + id: ModelID, + provider: ProviderID, + route: RouteID, + baseURL: Schema.String, + /** Provider-specific API key convenience. Provider helpers normalize this into `auth`. */ + apiKey: Schema.optional(Schema.String), + /** Optional transport auth policy. Opaque because it may contain functions. */ + auth: Schema.optional(Schema.Any), + headers: Schema.optional(Schema.Record(Schema.String, Schema.String)), + /** + * Query params appended to the request URL by `Endpoint.baseURL`. Used for + * deployment-level URL-scoped settings such as Azure's `api-version` or any + * provider that requires a per-request key in the URL. Generic concern, so + * lives as a typed first-class field instead of `native`. + */ + queryParams: Schema.optional(Schema.Record(Schema.String, Schema.String)), + limits: ModelLimits, + /** Provider-neutral generation defaults. Request-level values override them. */ + generation: Schema.optional(GenerationOptions), + /** Provider-owned typed-at-the-facade options for non-portable knobs. */ + providerOptions: Schema.optional(ProviderOptions), + /** Serializable raw HTTP overlays applied to the final outgoing request. */ + http: Schema.optional(HttpOptions), + /** + * Provider-specific opaque options. Reach for this only when the value is + * genuinely provider-private and does not fit a typed axis (e.g. Bedrock's + * `aws_credentials` / `aws_region` for SigV4). Anything used by more than + * one route should grow into a typed field instead. + */ + native: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), +}) {} + +export namespace ModelRef { + export type Input = ConstructorParameters[0] + + export const input = (model: ModelRef): Input => ({ + id: model.id, + provider: model.provider, + route: model.route, + baseURL: model.baseURL, + apiKey: model.apiKey, + auth: model.auth, + headers: model.headers, + queryParams: model.queryParams, + limits: model.limits, + generation: model.generation, + providerOptions: model.providerOptions, + http: model.http, + native: model.native, + }) + + export const update = (model: ModelRef, patch: Partial) => { + if (Object.keys(patch).length === 0) return model + return new ModelRef({ + ...input(model), + ...patch, + }) + } +} + +export class CacheHint extends Schema.Class("LLM.CacheHint")({ + type: Schema.Literals(["ephemeral", "persistent"]), + ttlSeconds: Schema.optional(Schema.Number), +}) {} + +// Auto-placement policy for prompt caching. The protocol-neutral lowering step +// reads this and injects `CacheHint`s at the configured boundaries; the +// per-protocol body builders then translate those hints into wire markers as +// usual. `"auto"` is the recommended default for agent loops — it places one +// breakpoint at the last tool definition, one at the last system part, and one +// at the latest user message. The combination of provider invalidation +// hierarchy (tools → system → messages) and Anthropic/Bedrock's 20-block +// lookback means three trailing breakpoints reliably cover the static prefix. +// +// Pass `"none"` to opt out entirely (the legacy behavior). Pass the granular +// object form to override individual choices. +export const CachePolicyObject = Schema.Struct({ + tools: Schema.optional(Schema.Boolean), + system: Schema.optional(Schema.Boolean), + messages: Schema.optional( + Schema.Union([ + Schema.Literal("latest-user-message"), + Schema.Literal("latest-assistant"), + Schema.Struct({ tail: Schema.Number }), + ]), + ), + ttlSeconds: Schema.optional(Schema.Number), +}) +export type CachePolicyObject = Schema.Schema.Type + +export const CachePolicy = Schema.Union([Schema.Literal("auto"), Schema.Literal("none"), CachePolicyObject]) +export type CachePolicy = Schema.Schema.Type diff --git a/packages/llm/src/tool-runtime.ts b/packages/llm/src/tool-runtime.ts new file mode 100644 index 0000000000..c6e716d45e --- /dev/null +++ b/packages/llm/src/tool-runtime.ts @@ -0,0 +1,248 @@ +import { Effect, Stream } from "effect" +import type { Concurrency } from "effect/Types" +import { + type ContentPart, + type FinishReason, + type LLMError, + LLMEvent, + LLMRequest, + Message, + type ProviderMetadata, + ToolCallPart, + ToolFailure, + ToolResultPart, + type ToolResultValue, +} from "./schema" +import { type AnyTool, type ExecutableTools, type Tools, toDefinitions } from "./tool" + +export interface RuntimeState { + readonly step: number + readonly request: LLMRequest +} + +export type StopCondition = (state: RuntimeState) => boolean + +export type ToolExecution = "auto" | "none" + +interface RunOptionsBase { + readonly request: LLMRequest + readonly concurrency?: Concurrency + readonly stopWhen?: StopCondition +} + +export type RunOptions = RunOptionsAuto | RunOptionsNone + +export interface RunOptionsAuto extends RunOptionsBase { + readonly request: LLMRequest + readonly tools: T + readonly toolExecution?: "auto" +} + +export interface RunOptionsNone extends RunOptionsBase { + readonly request: LLMRequest + readonly tools: T + /** Advertise tool schemas but leave model-emitted tool calls for the caller. */ + readonly toolExecution: "none" +} + +export type StreamOptions = RunOptions & { + readonly stream: (request: LLMRequest) => Stream.Stream +} + +export const stepCountIs = + (count: number): StopCondition => + (state) => + state.step + 1 >= count + +/** + * Run a model with typed tools. This helper owns tool orchestration, while the + * caller supplies the actual model stream function. It can advertise schemas + * only (`toolExecution: "none"`), execute one step, or continue model rounds + * when `stopWhen` is provided. + */ +export const stream = (options: StreamOptions): Stream.Stream => { + const concurrency = options.concurrency ?? 10 + const tools = options.tools as Tools + const runtimeTools = toDefinitions(tools) + const runtimeToolNames = new Set(runtimeTools.map((tool) => tool.name)) + const initialRequest = + runtimeTools.length === 0 + ? options.request + : LLMRequest.update(options.request, { + tools: [...options.request.tools.filter((tool) => !runtimeToolNames.has(tool.name)), ...runtimeTools], + }) + + const loop = (request: LLMRequest, step: number): Stream.Stream => + Stream.unwrap( + Effect.gen(function* () { + const state: StepState = { assistantContent: [], toolCalls: [], finishReason: undefined } + + const modelStream = options + .stream(request) + .pipe(Stream.tap((event) => Effect.sync(() => accumulate(state, event)))) + + const continuation = Stream.unwrap( + Effect.gen(function* () { + if (state.finishReason !== "tool-calls" || state.toolCalls.length === 0) return Stream.empty + if (options.toolExecution === "none") return Stream.empty + + const dispatched = yield* Effect.forEach( + state.toolCalls, + (call) => dispatch(tools, call).pipe(Effect.map((result) => [call, result] as const)), + { concurrency }, + ) + const resultStream = Stream.fromIterable(dispatched.flatMap(([call, result]) => emitEvents(call, result))) + + if (!options.stopWhen) return resultStream + if (options.stopWhen({ step, request })) return resultStream + + return resultStream.pipe(Stream.concat(loop(followUpRequest(request, state, dispatched), step + 1))) + }), + ) + + return modelStream.pipe(Stream.concat(continuation)) + }), + ) + + return loop(initialRequest, 0) +} + +interface StepState { + assistantContent: ContentPart[] + toolCalls: ToolCallPart[] + finishReason: FinishReason | undefined +} + +const accumulate = (state: StepState, event: LLMEvent) => { + if (event.type === "text-delta") { + appendStreamingText(state, "text", event.text, undefined) + return + } + if (event.type === "reasoning-delta") { + appendStreamingText(state, "reasoning", event.text, undefined) + return + } + if (event.type === "reasoning-end") { + appendStreamingText(state, "reasoning", "", event.providerMetadata) + return + } + if (event.type === "text-end") { + appendStreamingText(state, "text", "", event.providerMetadata) + return + } + if (event.type === "tool-call") { + const part = ToolCallPart.make({ + id: event.id, + name: event.name, + input: event.input, + providerExecuted: event.providerExecuted, + providerMetadata: event.providerMetadata, + }) + state.assistantContent.push(part) + if (!event.providerExecuted) state.toolCalls.push(part) + return + } + if (event.type === "tool-result" && event.providerExecuted) { + state.assistantContent.push( + ToolResultPart.make({ + id: event.id, + name: event.name, + result: event.result, + providerExecuted: true, + providerMetadata: event.providerMetadata, + }), + ) + return + } + if (event.type === "request-finish") { + state.finishReason = event.reason + } +} + +const sameProviderMetadata = (left: ProviderMetadata | undefined, right: ProviderMetadata | undefined) => + left === right || JSON.stringify(left) === JSON.stringify(right) + +const mergeProviderMetadata = (left: ProviderMetadata | undefined, right: ProviderMetadata | undefined) => { + if (!left) return right + if (!right) return left + return Object.fromEntries( + Array.from(new Set([...Object.keys(left), ...Object.keys(right)])).map((provider) => [ + provider, + { ...left[provider], ...right[provider] }, + ]), + ) +} + +const appendStreamingText = ( + state: StepState, + type: "text" | "reasoning", + text: string, + providerMetadata: ProviderMetadata | undefined, +) => { + const last = state.assistantContent.at(-1) + if (last?.type === type && text.length === 0) { + state.assistantContent[state.assistantContent.length - 1] = { + ...last, + providerMetadata: mergeProviderMetadata(last.providerMetadata, providerMetadata), + } + return + } + if (last?.type === type && sameProviderMetadata(last.providerMetadata, providerMetadata)) { + state.assistantContent[state.assistantContent.length - 1] = { ...last, text: `${last.text}${text}` } + return + } + state.assistantContent.push({ type, text, providerMetadata }) +} + +const dispatch = (tools: Tools, call: ToolCallPart): Effect.Effect => { + const tool = tools[call.name] + if (!tool) return Effect.succeed({ type: "error" as const, value: `Unknown tool: ${call.name}` }) + if (!tool.execute) + return Effect.succeed({ type: "error" as const, value: `Tool has no execute handler: ${call.name}` }) + + return decodeAndExecute(tool, call.input).pipe( + Effect.catchTag("LLM.ToolFailure", (failure) => + Effect.succeed({ type: "error" as const, value: failure.message } satisfies ToolResultValue), + ), + ) +} + +const decodeAndExecute = (tool: AnyTool, input: unknown): Effect.Effect => + tool._decode(input).pipe( + Effect.mapError((error) => new ToolFailure({ message: `Invalid tool input: ${error.message}` })), + Effect.flatMap((decoded) => tool.execute!(decoded)), + Effect.flatMap((value) => + tool._encode(value).pipe( + Effect.mapError( + (error) => + new ToolFailure({ + message: `Tool returned an invalid value for its success schema: ${error.message}`, + }), + ), + ), + ), + Effect.map((encoded): ToolResultValue => ({ type: "json", value: encoded })), + ) + +const emitEvents = (call: ToolCallPart, result: ToolResultValue): ReadonlyArray => + result.type === "error" + ? [ + LLMEvent.toolError({ id: call.id, name: call.name, message: String(result.value) }), + LLMEvent.toolResult({ id: call.id, name: call.name, result }), + ] + : [LLMEvent.toolResult({ id: call.id, name: call.name, result })] + +const followUpRequest = ( + request: LLMRequest, + state: StepState, + dispatched: ReadonlyArray, +) => + LLMRequest.update(request, { + messages: [ + ...request.messages, + Message.assistant(state.assistantContent), + ...dispatched.map(([call, result]) => Message.tool({ id: call.id, name: call.name, result })), + ], + }) + +export const ToolRuntime = { stream, stepCountIs } as const diff --git a/packages/llm/src/tool.ts b/packages/llm/src/tool.ts new file mode 100644 index 0000000000..311c8798b6 --- /dev/null +++ b/packages/llm/src/tool.ts @@ -0,0 +1,185 @@ +import { Effect, JsonSchema, Schema } from "effect" +import type { ToolDefinition as ToolDefinitionClass } from "./schema" +import { ToolDefinition, ToolFailure } from "./schema" + +/** + * Schema constraint for tool parameters / success values: no decoding or + * encoding services are allowed. Tools should be self-contained — anything + * beyond pure data conversion belongs in the handler closure. + */ +export type ToolSchema = Schema.Codec + +export type ToolExecute, Success extends ToolSchema> = ( + params: Schema.Schema.Type, +) => Effect.Effect, ToolFailure> + +/** + * A type-safe LLM tool. Each tool bundles its own description, parameter + * Schema and success Schema. The execute handler is optional: omit it when you + * only want to expose a tool schema to the model and handle tool calls outside + * this package. + * + * Errors must be expressed as `ToolFailure`. Unmapped errors and defects fail + * the stream. + * + * Internally each tool also carries memoized codecs and a precomputed + * `ToolDefinition` so the runtime doesn't rebuild them per invocation. + */ +export interface Tool, Success extends ToolSchema> { + readonly description: string + readonly parameters: Parameters + readonly success: Success + readonly execute?: ToolExecute + /** @internal */ + readonly _decode: (input: unknown) => Effect.Effect, Schema.SchemaError> + /** @internal */ + readonly _encode: (value: Schema.Schema.Type) => Effect.Effect + /** @internal */ + readonly _definition: ToolDefinitionClass +} + +export type AnyTool = Tool, ToolSchema> + +export type ExecutableTool, Success extends ToolSchema> = Tool< + Parameters, + Success +> & { + readonly execute: ToolExecute +} + +export type AnyExecutableTool = ExecutableTool, ToolSchema> + +export type ExecutableTools = Record + +type TypedToolConfig = { + readonly description: string + readonly parameters: ToolSchema + readonly success: ToolSchema + readonly execute?: ToolExecute, ToolSchema> +} + +type DynamicToolConfig = { + readonly description: string + readonly jsonSchema: JsonSchema.JsonSchema + readonly execute?: (params: unknown) => Effect.Effect +} + +/** + * Constructs a tool. Two input modes: + * + * 1. **Typed** — pass Effect `parameters` and `success` Schemas; inputs and + * outputs are statically typed and decoded/encoded automatically. + * + * ```ts + * Tool.make({ + * description: "Get current weather", + * parameters: Schema.Struct({ city: Schema.String }), + * success: Schema.Struct({ temperature: Schema.Number }), + * execute: ({ city }) => Effect.succeed({ temperature: 22 }), + * }) + * ``` + * + * 2. **Dynamic** — pass raw JSON Schema as `jsonSchema`. Use this when the + * schema comes from an external source (MCP server, plugin manifest, + * dynamic config) and is not known at compile time. Inputs are typed as + * `unknown`; the handler is responsible for any validation it needs. + * + * ```ts + * Tool.make({ + * description: "Look something up", + * jsonSchema: { type: "object", properties: { ... } }, + * execute: (params) => Effect.succeed(...), + * }) + * ``` + * + * In both modes the produced tool flows through `toDefinitions(...)` and the + * runtime identically. + */ +export function make, Success extends ToolSchema>(config: { + readonly description: string + readonly parameters: Parameters + readonly success: Success + readonly execute: ToolExecute +}): ExecutableTool +export function make, Success extends ToolSchema>(config: { + readonly description: string + readonly parameters: Parameters + readonly success: Success + readonly execute?: undefined +}): Tool +export function make(config: { + readonly description: string + readonly jsonSchema: JsonSchema.JsonSchema + readonly execute: (params: unknown) => Effect.Effect +}): AnyExecutableTool +export function make(config: { + readonly description: string + readonly jsonSchema: JsonSchema.JsonSchema + readonly execute?: undefined +}): AnyTool +export function make(config: TypedToolConfig | DynamicToolConfig): AnyTool { + if ("jsonSchema" in config) { + return { + description: config.description, + parameters: Schema.Unknown as ToolSchema, + success: Schema.Unknown as ToolSchema, + execute: config.execute, + _decode: Effect.succeed, + _encode: Effect.succeed, + _definition: new ToolDefinition({ + name: "", + description: config.description, + inputSchema: config.jsonSchema, + }), + } + } + return { + description: config.description, + parameters: config.parameters, + success: config.success, + execute: config.execute, + _decode: Schema.decodeUnknownEffect(config.parameters), + _encode: Schema.encodeEffect(config.success), + _definition: new ToolDefinition({ + name: "", + description: config.description, + inputSchema: toJsonSchema(config.parameters), + }), + } +} + +export const tool = make + +/** + * A record of named tools. The record key becomes the tool name on the wire. + */ +export type Tools = Record + +/** + * Convert a tools record into the `ToolDefinition[]` shape that + * `LLMRequest.tools` expects. The runtime calls this internally; consumers + * that build `LLMRequest` themselves can use it too. + * + * Tool names come from the record keys, so the per-tool cached + * `_definition` is rebuilt with the correct name here. The JSON Schema body + * is reused. + */ +export const toDefinitions = (tools: Tools): ReadonlyArray => + Object.entries(tools).map( + ([name, item]) => + new ToolDefinition({ + name, + description: item._definition.description, + inputSchema: item._definition.inputSchema, + }), + ) + +const toJsonSchema = (schema: Schema.Top): JsonSchema.JsonSchema => { + const document = Schema.toJsonSchemaDocument(schema) + if (Object.keys(document.definitions).length === 0) return document.schema + return { ...document.schema, $defs: document.definitions } +} + +export { ToolFailure } + +export * as Tool from "./tool" diff --git a/packages/llm/sst-env.d.ts b/packages/llm/sst-env.d.ts new file mode 100644 index 0000000000..64441936d7 --- /dev/null +++ b/packages/llm/sst-env.d.ts @@ -0,0 +1,10 @@ +/* This file is auto-generated by SST. Do not edit. */ +/* tslint:disable */ +/* eslint-disable */ +/* deno-fmt-ignore-file */ +/* biome-ignore-all lint: auto-generated */ + +/// + +import "sst" +export {} \ No newline at end of file diff --git a/packages/llm/test/adapter.test.ts b/packages/llm/test/adapter.test.ts new file mode 100644 index 0000000000..5ac8b9d818 --- /dev/null +++ b/packages/llm/test/adapter.test.ts @@ -0,0 +1,177 @@ +import { describe, expect } from "bun:test" +import { Effect, Schema, Stream } from "effect" +import { LLM } from "../src" +import { Route, Endpoint, LLMClient, Protocol, type RouteModelInput, type FramingDef } from "../src/route" +import { ModelRef } from "../src/schema" +import { testEffect } from "./lib/effect" +import { dynamicResponse } from "./lib/http" + +const updateModel = (model: ModelRef, patch: Partial) => ModelRef.update(model, patch) + +const Json = Schema.fromJsonString(Schema.Unknown) +const encodeJson = Schema.encodeSync(Json) + +type FakeBody = { + readonly body: string +} + +const FakeEvent = Schema.Union([ + Schema.Struct({ type: Schema.Literal("text"), text: Schema.String }), + Schema.Struct({ type: Schema.Literal("finish"), reason: Schema.Literal("stop") }), +]) +type FakeEvent = Schema.Schema.Type +const decodeFakeEvents = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Array(FakeEvent))) + +const fakeFraming: FramingDef = { + id: "fake-json-array", + frame: (bytes) => + Stream.fromEffect( + bytes.pipe( + Stream.decodeText(), + Stream.runFold( + () => "", + (text, event) => text + event, + ), + Effect.flatMap(decodeFakeEvents), + Effect.orDie, + ), + ).pipe(Stream.flatMap(Stream.fromIterable)), +} + +const request = LLM.request({ + id: "req_1", + model: LLM.model({ + id: "fake-model", + provider: "fake-provider", + route: "fake", + baseURL: "https://fake.local", + }), + prompt: "hello", +}) + +const raiseEvent = (event: FakeEvent): import("../src/schema").LLMEvent => + event.type === "finish" + ? { type: "request-finish", reason: event.reason } + : { type: "text-delta", id: "text-0", text: event.text } + +const fakeProtocol = Protocol.make({ + id: "fake", + body: { + schema: Schema.Struct({ + body: Schema.String, + }), + from: (request) => + Effect.succeed({ + body: [ + ...request.messages + .flatMap((message) => message.content) + .filter((part) => part.type === "text") + .map((part) => part.text), + ...request.tools.map((tool) => `tool:${tool.name}:${tool.description}`), + ].join("\n"), + }), + }, + stream: { + event: FakeEvent, + initial: () => undefined, + step: (state, event) => Effect.succeed([state, [raiseEvent(event)]] as const), + }, +}) + +const fake = Route.make({ + id: "fake", + protocol: fakeProtocol, + endpoint: Endpoint.path("/chat"), + framing: fakeFraming, +}) + +const gemini = Route.make({ + id: "gemini-fake", + protocol: fakeProtocol, + endpoint: Endpoint.path("/chat"), + framing: fakeFraming, +}) + +const echoLayer = dynamicResponse(({ text, respond }) => + Effect.succeed( + respond( + encodeJson([ + { type: "text", text: `echo:${text}` }, + { type: "finish", reason: "stop" }, + ]), + ), + ), +) + +const it = testEffect(echoLayer) + +describe("llm route", () => { + it.effect("stream and generate use the route pipeline", () => + Effect.gen(function* () { + const llm = yield* LLMClient.Service + const events = Array.from(yield* llm.stream(request).pipe(Stream.runCollect)) + const response = yield* llm.generate(request) + + expect(events.map((event) => event.type)).toEqual(["text-delta", "request-finish"]) + expect(response.events.map((event) => event.type)).toEqual(["text-delta", "request-finish"]) + }), + ) + + it.effect("selects routes by request route", () => + Effect.gen(function* () { + const llm = yield* LLMClient.Service + const prepared = yield* llm.prepare( + LLM.updateRequest(request, { model: updateModel(request.model, { route: "gemini-fake" }) }), + ) + + expect(prepared.route).toBe("gemini-fake") + }), + ) + + it.effect("maps model input before building refs", () => + Effect.gen(function* () { + const mapped = Route.model( + fake, + { provider: "fake-provider", baseURL: "https://fake.local" }, + { + mapInput: (input) => { + const { region, ...rest } = input + return { ...rest, native: { region } } + }, + }, + ) + + expect(mapped({ id: "fake-model", region: "us-east-1" }).native).toEqual({ region: "us-east-1" }) + }), + ) + + it.effect("rejects duplicate route ids", () => + Effect.gen(function* () { + expect(() => + Route.make({ + id: "fake", + protocol: Protocol.make({ + ...fakeProtocol, + body: { + ...fakeProtocol.body, + from: () => Effect.succeed({ body: "late-default" }), + }, + }), + endpoint: Endpoint.path("/chat"), + framing: fakeFraming, + }), + ).toThrow('Duplicate LLM route id "fake"') + }), + ) + + it.effect("rejects missing route", () => + Effect.gen(function* () { + const llm = yield* LLMClient.Service + const error = yield* llm + .prepare(LLM.updateRequest(request, { model: updateModel(request.model, { route: "missing" }) })) + .pipe(Effect.flip) + + expect(error.message).toContain("No LLM route") + }), + ) +}) diff --git a/packages/llm/test/auth-options.types.ts b/packages/llm/test/auth-options.types.ts new file mode 100644 index 0000000000..a44efa2274 --- /dev/null +++ b/packages/llm/test/auth-options.types.ts @@ -0,0 +1,100 @@ +import { Config } from "effect" +import type { Auth } from "../src/route/auth" +import type { ModelFactory } from "../src/route/auth-options" +import { Auth as RuntimeAuth } from "../src/route/auth" +import * as Azure from "../src/providers/azure" +import * as OpenAI from "../src/providers/openai" + +type BaseOptions = { + readonly baseURL?: string + readonly headers?: Record +} + +type Model = { + readonly id: string +} + +declare const auth: Auth +declare const optionalAuthModel: ModelFactory +declare const requiredAuthModel: ModelFactory +const configApiKey = Config.redacted("OPENAI_API_KEY") + +optionalAuthModel("gpt-4.1-mini") +optionalAuthModel("gpt-4.1-mini", {}) +optionalAuthModel("gpt-4.1-mini", { apiKey: "sk-test" }) +optionalAuthModel("gpt-4.1-mini", { apiKey: configApiKey }) +optionalAuthModel("gpt-4.1-mini", { auth }) +optionalAuthModel("gpt-4.1-mini", { auth, baseURL: "https://gateway.example.com/v1" }) +optionalAuthModel("gpt-4.1-mini", { apiKey: "sk-test", headers: { "x-source": "test" } }) + +// @ts-expect-error auth is an override, so apiKey cannot be supplied with it. +optionalAuthModel("gpt-4.1-mini", { apiKey: "sk-test", auth }) + +requiredAuthModel("custom-model", { apiKey: "key" }) +requiredAuthModel("custom-model", { apiKey: configApiKey }) +requiredAuthModel("custom-model", { auth }) +requiredAuthModel("custom-model", { auth, headers: { "x-tenant-id": "tenant" } }) + +// @ts-expect-error providers without config fallback need apiKey or auth. +requiredAuthModel("custom-model") + +// @ts-expect-error providers without config fallback need apiKey or auth. +requiredAuthModel("custom-model", {}) + +// @ts-expect-error auth is an override, so apiKey cannot be supplied with it. +requiredAuthModel("custom-model", { apiKey: "key", auth }) + +OpenAI.responses("gpt-4.1-mini") +OpenAI.responses("gpt-4.1-mini", {}) +OpenAI.responses("gpt-4.1-mini", { apiKey: "sk-test" }) +OpenAI.responses("gpt-4.1-mini", { apiKey: configApiKey }) +OpenAI.responses("gpt-4.1-mini", { auth: RuntimeAuth.bearer("oauth-token") }) +OpenAI.responses("gpt-4.1-mini", { + auth: RuntimeAuth.headers({ authorization: "Bearer gateway" }), + baseURL: "https://gateway.example.com/v1", +}) +OpenAI.responses("gpt-4.1-mini", { + generation: { maxTokens: 100 }, + providerOptions: { openai: { store: false } }, +}) + +// @ts-expect-error apiKey only accepts string, Redacted, or Config>. +OpenAI.responses("gpt-4.1-mini", { apiKey: 123 }) + +// @ts-expect-error provider helpers reject unknown top-level options. +OpenAI.responses("gpt-4.1-mini", { bogus: true }) + +// @ts-expect-error common generation options remain typed. +OpenAI.responses("gpt-4.1-mini", { generation: { maxTokens: "many" } }) + +// @ts-expect-error provider-native options remain typed. +OpenAI.responses("gpt-4.1-mini", { providerOptions: { openai: { store: "false" } } }) + +// @ts-expect-error auth is an override, so OpenAI rejects apiKey with auth. +OpenAI.responses("gpt-4.1-mini", { apiKey: "sk-test", auth: RuntimeAuth.bearer("oauth-token") }) + +OpenAI.chat("gpt-4.1-mini") +OpenAI.chat("gpt-4.1-mini", { apiKey: "sk-test" }) +OpenAI.chat("gpt-4.1-mini", { apiKey: configApiKey }) +OpenAI.chat("gpt-4.1-mini", { auth: RuntimeAuth.bearer("oauth-token") }) + +// @ts-expect-error auth is an override, so OpenAI Chat rejects apiKey with auth. +OpenAI.chat("gpt-4.1-mini", { apiKey: "sk-test", auth: RuntimeAuth.bearer("oauth-token") }) + +// @ts-expect-error Azure requires at least one of `resourceName` or `baseURL`. +Azure.responses("deployment") +Azure.responses("deployment", { apiKey: "azure-key", resourceName: "resource" }) +Azure.responses("deployment", { apiKey: configApiKey, resourceName: "resource" }) +Azure.responses("deployment", { auth: RuntimeAuth.header("api-key", "azure-key"), resourceName: "resource" }) + +// @ts-expect-error auth is an override, so Azure rejects apiKey with auth. +Azure.responses("deployment", { apiKey: "azure-key", auth: RuntimeAuth.header("api-key", "override") }) + +// @ts-expect-error Azure requires at least one of `resourceName` or `baseURL`. +Azure.chat("deployment") +Azure.chat("deployment", { apiKey: "azure-key", resourceName: "resource" }) +Azure.chat("deployment", { apiKey: configApiKey, resourceName: "resource" }) +Azure.chat("deployment", { auth: RuntimeAuth.header("api-key", "azure-key"), resourceName: "resource" }) + +// @ts-expect-error auth is an override, so Azure Chat rejects apiKey with auth. +Azure.chat("deployment", { apiKey: "azure-key", auth: RuntimeAuth.header("api-key", "override") }) diff --git a/packages/llm/test/auth.test.ts b/packages/llm/test/auth.test.ts new file mode 100644 index 0000000000..6b53f4d5eb --- /dev/null +++ b/packages/llm/test/auth.test.ts @@ -0,0 +1,101 @@ +import { describe, expect } from "bun:test" +import { ConfigProvider, Effect } from "effect" +import { Headers } from "effect/unstable/http" +import { LLM } from "../src" +import { Auth } from "../src/route/auth" +import { it } from "./lib/effect" + +const request = LLM.request({ + id: "req_auth", + model: LLM.model({ id: "fake-model", provider: "fake", route: "fake", baseURL: "https://fake.local" }), + prompt: "hello", +}) + +const input = { + request, + method: "POST" as const, + url: "https://example.test/v1/chat", + body: "{}", + headers: Headers.fromInput({ "x-existing": "yes" }), +} + +const withEnv = (env: Record) => Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env }))) + +describe("Auth", () => { + it.effect("renders a config credential as bearer auth", () => + Effect.gen(function* () { + const headers = yield* Auth.config("OPENAI_API_KEY") + .bearer() + .apply(input) + .pipe(withEnv({ OPENAI_API_KEY: "sk-test" })) + + expect(headers.authorization).toBe("Bearer sk-test") + expect(headers["x-existing"]).toBe("yes") + }), + ) + + it.effect("falls back between credential sources before rendering", () => + Effect.gen(function* () { + const headers = yield* Auth.config("PRIMARY_KEY") + .orElse(Auth.value("fallback-key")) + .pipe(Auth.header("x-api-key")) + .apply(input) + .pipe(withEnv({})) + + expect(headers["x-api-key"]).toBe("fallback-key") + expect(headers["x-existing"]).toBe("yes") + }), + ) + + it.effect("composes header auth in sequence", () => + Effect.gen(function* () { + const headers = yield* Auth.headers({ "x-tenant-id": "tenant-1" }) + .andThen(Auth.bearer("gateway-token")) + .apply(input) + + expect(headers["x-tenant-id"]).toBe("tenant-1") + expect(headers.authorization).toBe("Bearer gateway-token") + expect(headers["x-existing"]).toBe("yes") + }), + ) + + it.effect("renders a direct secret as a custom header", () => + Effect.gen(function* () { + const headers = yield* Auth.header("api-key", "direct-key").apply(input) + + expect(headers["api-key"]).toBe("direct-key") + expect(headers["x-existing"]).toBe("yes") + }), + ) + + it.effect("renders bearer auth into a custom header", () => + Effect.gen(function* () { + const headers = yield* Auth.bearerHeader("cf-aig-authorization", "gateway-token").apply(input) + + expect(headers["cf-aig-authorization"]).toBe("Bearer gateway-token") + expect(headers["x-existing"]).toBe("yes") + }), + ) + + it.effect("falls back between full auth values", () => + Effect.gen(function* () { + const headers = yield* Auth.config("OPENAI_API_KEY") + .bearer() + .orElse(Auth.headers({ authorization: "Bearer supplied" })) + .apply(input) + .pipe(withEnv({})) + + expect(headers.authorization).toBe("Bearer supplied") + expect(headers["x-existing"]).toBe("yes") + }), + ) + + it.effect("can intentionally leave auth untouched", () => + Effect.gen(function* () { + const headers = yield* Auth.none.apply(input) + + expect(headers.authorization).toBeUndefined() + expect(headers["x-existing"]).toBe("yes") + }), + ) +}) diff --git a/packages/llm/test/cache-policy.test.ts b/packages/llm/test/cache-policy.test.ts new file mode 100644 index 0000000000..e742ca5e69 --- /dev/null +++ b/packages/llm/test/cache-policy.test.ts @@ -0,0 +1,262 @@ +import { describe, expect, test } from "bun:test" +import { Effect } from "effect" +import { CacheHint, LLM } from "../src" +import { LLMClient } from "../src/route" +import * as AnthropicMessages from "../src/protocols/anthropic-messages" +import * as BedrockConverse from "../src/protocols/bedrock-converse" +import * as Gemini from "../src/protocols/gemini" +import * as OpenAIChat from "../src/protocols/openai-chat" +import { applyCachePolicy } from "../src/cache-policy" +import { it } from "./lib/effect" + +const anthropicModel = AnthropicMessages.model({ + id: "claude-sonnet-4-5", + baseURL: "https://api.anthropic.test/v1/", + headers: { "x-api-key": "test" }, +}) + +const bedrockModel = BedrockConverse.model({ + id: "anthropic.claude-3-5-sonnet-20241022-v2:0", + credentials: { region: "us-east-1", accessKeyId: "fixture", secretAccessKey: "fixture" }, +}) + +const openaiModel = OpenAIChat.model({ + id: "gpt-4o-mini", + baseURL: "https://api.openai.test/v1/", + headers: { authorization: "Bearer test" }, +}) + +const geminiModel = Gemini.model({ + id: "gemini-2.5-flash", + baseURL: "https://generativelanguage.test/v1beta/", + headers: { "x-goog-api-key": "test" }, +}) + +describe("applyCachePolicy", () => { + it.effect("undefined cache resolves to 'auto' (the recommended default)", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + model: anthropicModel, + system: "You are concise.", + prompt: "hi", + }), + ) + + // No explicit cache field → auto policy fires → last system part + latest + // user message both get cache_control markers. + expect(prepared.body).toMatchObject({ + system: [{ type: "text", text: "You are concise.", cache_control: { type: "ephemeral" } }], + messages: [{ role: "user", content: [{ type: "text", text: "hi", cache_control: { type: "ephemeral" } }] }], + }) + }), + ) + + it.effect("'auto' marks the last tool, last system part, and latest user message on Anthropic", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + model: anthropicModel, + system: "Sys A", + tools: [{ name: "t1", description: "t1", inputSchema: { type: "object", properties: {} } }], + messages: [LLM.user("first user"), LLM.assistant("assistant reply"), LLM.user("latest user message")], + cache: "auto", + }), + ) + + expect(prepared.body).toMatchObject({ + tools: [{ name: "t1", cache_control: { type: "ephemeral" } }], + system: [{ type: "text", text: "Sys A", cache_control: { type: "ephemeral" } }], + messages: [ + { role: "user", content: [{ type: "text", text: "first user" }] }, + { role: "assistant", content: [{ type: "text", text: "assistant reply" }] }, + { + role: "user", + content: [{ type: "text", text: "latest user message", cache_control: { type: "ephemeral" } }], + }, + ], + }) + }), + ) + + it.effect("'auto' is a no-op on OpenAI (implicit caching protocol)", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + model: openaiModel, + system: "Sys", + prompt: "hi", + cache: "auto", + }), + ) + + const body = prepared.body as { messages: Array<{ content: unknown }> } + // OpenAI doesn't accept cache_control on messages — policy must skip. + const flat = JSON.stringify(body) + expect(flat).not.toContain("cache_control") + expect(flat).not.toContain("cachePoint") + }), + ) + + it.effect("'auto' is a no-op on Gemini (out-of-band caching protocol)", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + model: geminiModel, + system: "Sys", + prompt: "hi", + cache: "auto", + }), + ) + + const flat = JSON.stringify(prepared.body) + expect(flat).not.toContain("cache_control") + expect(flat).not.toContain("cachePoint") + }), + ) + + it.effect("'auto' on Bedrock emits cachePoint markers in the right places", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + model: bedrockModel, + system: "Sys", + tools: [{ name: "t1", description: "t1", inputSchema: { type: "object", properties: {} } }], + messages: [LLM.user("first user"), LLM.assistant("reply"), LLM.user("latest user")], + cache: "auto", + }), + ) + + expect(prepared.body).toMatchObject({ + toolConfig: { + tools: [{ toolSpec: { name: "t1" } }, { cachePoint: { type: "default" } }], + }, + system: [{ text: "Sys" }, { cachePoint: { type: "default" } }], + messages: [ + { role: "user", content: [{ text: "first user" }] }, + { role: "assistant", content: [{ text: "reply" }] }, + { role: "user", content: [{ text: "latest user" }, { cachePoint: { type: "default" } }] }, + ], + }) + }), + ) + + it.effect("'none' disables auto placement even when manual hints exist", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + model: anthropicModel, + system: "Sys", + tools: [{ name: "t1", description: "t1", inputSchema: { type: "object", properties: {} } }], + prompt: "hi", + cache: "none", + }), + ) + + expect(prepared.body).toMatchObject({ + tools: [{ name: "t1", cache_control: undefined }], + system: [{ type: "text", text: "Sys", cache_control: undefined }], + }) + }), + ) + + it.effect("granular object form: tools-only marks just tools", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + model: anthropicModel, + system: "Sys", + tools: [{ name: "t1", description: "t1", inputSchema: { type: "object", properties: {} } }], + prompt: "hi", + cache: { tools: true }, + }), + ) + + expect(prepared.body).toMatchObject({ + tools: [{ name: "t1", cache_control: { type: "ephemeral" } }], + system: [{ type: "text", text: "Sys", cache_control: undefined }], + }) + }), + ) + + it.effect("auto policy preserves manual CacheHints on other parts", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + model: anthropicModel, + system: [ + { type: "text", text: "first system", cache: new CacheHint({ type: "ephemeral", ttlSeconds: 3600 }) }, + { type: "text", text: "last system" }, + ], + prompt: "hi", + cache: "auto", + }), + ) + + const body = prepared.body as { system: Array<{ text: string; cache_control?: unknown }> } + expect(body.system[0]?.cache_control).toEqual({ type: "ephemeral", ttl: "1h" }) + expect(body.system[1]?.cache_control).toEqual({ type: "ephemeral" }) + }), + ) + + it.effect("ttlSeconds in the policy flows through to wire markers", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + model: anthropicModel, + system: "Sys", + prompt: "hi", + cache: { system: true, ttlSeconds: 3600 }, + }), + ) + + expect(prepared.body).toMatchObject({ + system: [{ type: "text", text: "Sys", cache_control: { type: "ephemeral", ttl: "1h" } }], + }) + }), + ) + + it.effect("messages: { tail: 2 } marks the last 2 message boundaries", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + model: anthropicModel, + messages: [LLM.user("u1"), LLM.assistant("a1"), LLM.user("u2"), LLM.assistant("a2")], + cache: { messages: { tail: 2 } }, + }), + ) + + const body = prepared.body as { messages: Array<{ content: Array<{ cache_control?: unknown }> }> } + expect(body.messages[0]?.content[0]?.cache_control).toBeUndefined() + expect(body.messages[1]?.content[0]?.cache_control).toBeUndefined() + expect(body.messages[2]?.content[0]?.cache_control).toEqual({ type: "ephemeral" }) + expect(body.messages[3]?.content[0]?.cache_control).toEqual({ type: "ephemeral" }) + }), + ) + + it.effect("'latest-assistant' marks the last assistant message", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + model: anthropicModel, + messages: [LLM.user("u1"), LLM.assistant("a1"), LLM.user("u2")], + cache: { messages: "latest-assistant" }, + }), + ) + + const body = prepared.body as { messages: Array<{ content: Array<{ cache_control?: unknown }> }> } + expect(body.messages[0]?.content[0]?.cache_control).toBeUndefined() + expect(body.messages[1]?.content[0]?.cache_control).toEqual({ type: "ephemeral" }) + expect(body.messages[2]?.content[0]?.cache_control).toBeUndefined() + }), + ) + + test("returns the same request reference when policy is a no-op (pure function)", () => { + const request = LLM.request({ + model: anthropicModel, + prompt: "hi", + cache: "none", + }) + expect(applyCachePolicy(request)).toBe(request) + }) +}) diff --git a/packages/llm/test/endpoint.test.ts b/packages/llm/test/endpoint.test.ts new file mode 100644 index 0000000000..43d2e1c5c4 --- /dev/null +++ b/packages/llm/test/endpoint.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, test } from "bun:test" +import { LLM } from "../src" +import { Endpoint } from "../src/route" + +const request = (input: { readonly baseURL: string; readonly queryParams?: Record }) => + LLM.request({ + model: LLM.model({ + id: "model-1", + provider: "test", + route: "test-route", + baseURL: input.baseURL, + queryParams: input.queryParams, + }), + prompt: "hello", + }) + +describe("Endpoint", () => { + test("appends a static path to the model's baseURL", () => { + const url = Endpoint.render(Endpoint.path("/chat"), { + request: request({ baseURL: "https://api.example.test/v1/" }), + body: {}, + }) + + expect(url.toString()).toBe("https://api.example.test/v1/chat") + }) + + test("model query params are appended to the rendered URL", () => { + const url = Endpoint.render(Endpoint.path("/chat?alt=sse"), { + request: request({ + baseURL: "https://custom.example.test/root/", + queryParams: { "api-version": "2026-01-01", alt: "json" }, + }), + body: {}, + }) + + expect(url.toString()).toBe("https://custom.example.test/root/chat?alt=json&api-version=2026-01-01") + }) + + test("path may be a function of the validated body", () => { + const url = Endpoint.render( + Endpoint.path<{ readonly modelId: string }>( + ({ body }) => `/model/${encodeURIComponent(body.modelId)}/converse-stream`, + ), + { + request: request({ baseURL: "https://bedrock-runtime.us-east-1.amazonaws.com" }), + body: { modelId: "us.amazon.nova-micro-v1:0" }, + }, + ) + + expect(url.toString()).toBe( + "https://bedrock-runtime.us-east-1.amazonaws.com/model/us.amazon.nova-micro-v1%3A0/converse-stream", + ) + }) +}) diff --git a/packages/llm/test/executor.test.ts b/packages/llm/test/executor.test.ts new file mode 100644 index 0000000000..b294606ff3 --- /dev/null +++ b/packages/llm/test/executor.test.ts @@ -0,0 +1,416 @@ +import { describe, expect } from "bun:test" +import { Effect, Fiber, Layer, Random, Ref } from "effect" +import * as TestClock from "effect/testing/TestClock" +import { Headers, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" +import { LLM, LLMError } from "../src" +import { LLMClient, RequestExecutor } from "../src/route" +import * as OpenAIChat from "../src/protocols/openai-chat" +import { dynamicResponse } from "./lib/http" +import { deltaChunk } from "./lib/openai-chunks" +import { sseRaw } from "./lib/sse" +import { it } from "./lib/effect" + +const request = HttpClientRequest.post("https://provider.test/v1/chat?api_key=secret&key=secret&debug=1").pipe( + HttpClientRequest.setHeaders(Headers.fromInput({ authorization: "Bearer secret", "x-safe": "visible" })), +) + +const secretRequest = HttpClientRequest.post("https://provider.test/v1/chat?api_key=query-secret-123&debug=1").pipe( + HttpClientRequest.setHeaders(Headers.fromInput({ authorization: "Bearer header-secret-456" })), +) + +const responsesLayer = (responses: ReadonlyArray) => + RequestExecutor.layer.pipe( + Layer.provide( + Layer.unwrap( + Effect.gen(function* () { + const cursor = yield* Ref.make(0) + return Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.gen(function* () { + const index = yield* Ref.getAndUpdate(cursor, (value) => value + 1) + return HttpClientResponse.fromWeb(request, responses[index] ?? responses[responses.length - 1]) + }), + ), + ) + }), + ), + ), + ) + +const countedResponsesLayer = (attempts: Ref.Ref, responses: ReadonlyArray) => + RequestExecutor.layer.pipe( + Layer.provide( + Layer.unwrap( + Effect.gen(function* () { + const cursor = yield* Ref.make(0) + return Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.gen(function* () { + yield* Ref.update(attempts, (value) => value + 1) + const index = yield* Ref.getAndUpdate(cursor, (value) => value + 1) + return HttpClientResponse.fromWeb(request, responses[index] ?? responses[responses.length - 1]) + }), + ), + ) + }), + ), + ), + ) + +const randomMidpoint = { + nextDoubleUnsafe: () => 0.5, + nextIntUnsafe: () => 0, +} + +const expectLLMError = (error: unknown) => { + expect(error).toBeInstanceOf(LLMError) + if (!(error instanceof LLMError)) throw new Error("expected LLMError") + return error +} + +const errorHttp = (error: LLMError) => ("http" in error.reason ? error.reason.http : undefined) + +describe("RequestExecutor", () => { + it.effect("returns redacted diagnostics for retryable rate limits", () => + Effect.gen(function* () { + const executor = yield* RequestExecutor.Service + const error = yield* executor.execute(request).pipe(Effect.flip) + + expectLLMError(error) + expect(error).toMatchObject({ + retryable: true, + retryAfterMs: 0, + reason: { + _tag: "RateLimit", + rateLimit: { retryAfterMs: 0 }, + http: { + requestId: "req_123", + request: { + method: "POST", + url: "https://provider.test/v1/chat?api_key=%3Credacted%3E&key=%3Credacted%3E&debug=1", + headers: { authorization: "", "x-safe": "visible" }, + }, + response: { + status: 429, + headers: { + "retry-after-ms": "0", + "x-request-id": "req_123", + "x-api-key": "", + }, + }, + }, + }, + }) + expect(errorHttp(error)?.body).toBe("rate limited") + }).pipe( + Effect.provide( + responsesLayer([ + ...Array.from( + { length: 3 }, + () => + new Response("rate limited", { + status: 429, + headers: { "retry-after-ms": "0", "x-request-id": "req_123", "x-api-key": "secret" }, + }), + ), + ]), + ), + ), + ) + + it.effect("honors current redacted header names in diagnostics", () => + Effect.gen(function* () { + const executor = yield* RequestExecutor.Service + const error = yield* executor.execute(request).pipe(Effect.flip) + + expectLLMError(error) + expect(errorHttp(error)?.request.headers["x-safe"]).toBe("") + expect(errorHttp(error)?.response?.headers["x-safe"]).toBe("") + }).pipe( + Effect.provide(responsesLayer([new Response("bad", { status: 400, headers: { "x-safe": "response-secret" } })])), + Effect.provideService(Headers.CurrentRedactedNames, ["x-safe"]), + ), + ) + + it.effect("extracts OpenAI-style rate-limit diagnostics", () => + Effect.gen(function* () { + const executor = yield* RequestExecutor.Service + const error = yield* executor.execute(request).pipe(Effect.flip) + + expectLLMError(error) + expect(error.reason).toMatchObject({ _tag: "RateLimit" }) + expect(error.reason._tag === "RateLimit" ? error.reason.rateLimit : undefined).toEqual({ + retryAfterMs: 0, + limit: { requests: "500", tokens: "30000" }, + remaining: { requests: "499", tokens: "29900" }, + reset: { requests: "1s", tokens: "10s" }, + }) + }).pipe( + Effect.provide( + responsesLayer( + Array.from( + { length: 3 }, + () => + new Response("rate limited", { + status: 429, + headers: { + "retry-after-ms": "0", + "x-ratelimit-limit-requests": "500", + "x-ratelimit-limit-tokens": "30000", + "x-ratelimit-remaining-requests": "499", + "x-ratelimit-remaining-tokens": "29900", + "x-ratelimit-reset-requests": "1s", + "x-ratelimit-reset-tokens": "10s", + }, + }), + ), + ), + ), + ), + ) + + it.effect("extracts Anthropic-style rate-limit diagnostics", () => + Effect.gen(function* () { + const executor = yield* RequestExecutor.Service + const error = yield* executor.execute(request).pipe(Effect.flip) + + expectLLMError(error) + expect(error.reason).toMatchObject({ _tag: "ProviderInternal" }) + expect(errorHttp(error)?.rateLimit).toEqual({ + retryAfterMs: 0, + limit: { requests: "100", "input-tokens": "10000" }, + remaining: { requests: "12", "input-tokens": "9000" }, + reset: { requests: "2026-05-06T12:00:00Z", "input-tokens": "2026-05-06T12:00:10Z" }, + }) + }).pipe( + Effect.provide( + responsesLayer( + Array.from( + { length: 3 }, + () => + new Response("overloaded", { + status: 529, + headers: { + "retry-after-ms": "0", + "anthropic-ratelimit-requests-limit": "100", + "anthropic-ratelimit-requests-remaining": "12", + "anthropic-ratelimit-requests-reset": "2026-05-06T12:00:00Z", + "anthropic-ratelimit-input-tokens-limit": "10000", + "anthropic-ratelimit-input-tokens-remaining": "9000", + "anthropic-ratelimit-input-tokens-reset": "2026-05-06T12:00:10Z", + }, + }), + ), + ), + ), + ), + ) + + it.effect("retries retryable status responses before returning the stream", () => + Effect.gen(function* () { + const executor = yield* RequestExecutor.Service + const response = yield* executor.execute(request) + + expect(response.status).toBe(200) + expect(yield* response.text).toBe("ok") + }).pipe( + Effect.provide( + responsesLayer([ + new Response("busy", { status: 503, headers: { "retry-after-ms": "0" } }), + new Response("ok", { status: 200 }), + ]), + ), + ), + ) + + it.effect("marks 504 and 529 status responses retryable", () => + Effect.gen(function* () { + const failWith = (status: number) => + Effect.gen(function* () { + const executor = yield* RequestExecutor.Service + const error = yield* executor.execute(request).pipe(Effect.flip) + + expectLLMError(error) + expect(error.reason).toMatchObject({ _tag: "ProviderInternal", status }) + expect(error.retryable).toBe(true) + }).pipe( + Effect.provide( + responsesLayer( + Array.from( + { length: 3 }, + () => + new Response("retry", { + status, + headers: { "retry-after-ms": "0" }, + }), + ), + ), + ), + ) + + yield* failWith(504) + yield* failWith(529) + }), + ) + + it.effect("does not retry non-retryable status responses and truncates large bodies", () => + Effect.gen(function* () { + const executor = yield* RequestExecutor.Service + const error = yield* executor.execute(request).pipe(Effect.flip) + + expectLLMError(error) + expect(error.reason).toMatchObject({ _tag: "Authentication" }) + expect(error.retryable).toBe(false) + expect(errorHttp(error)?.bodyTruncated).toBe(true) + expect(errorHttp(error)?.body).toHaveLength(16_384) + }).pipe( + Effect.provide( + responsesLayer([ + new Response("x".repeat(20_000), { status: 401 }), + new Response("should not retry", { status: 200 }), + ]), + ), + ), + ) + + it.effect("redacts common secret fields in response bodies", () => + Effect.gen(function* () { + const executor = yield* RequestExecutor.Service + const error = yield* executor.execute(request).pipe(Effect.flip) + + expectLLMError(error) + expect(errorHttp(error)?.body).toContain('"key":""') + expect(errorHttp(error)?.body).toContain("api_key=") + expect(errorHttp(error)?.body).not.toContain("body-secret") + expect(errorHttp(error)?.body).not.toContain("query-secret") + }).pipe( + Effect.provide( + responsesLayer([ + new Response('{"error":{"message":"bad","key":"body-secret","detail":"api_key=query-secret"}}', { + status: 400, + }), + ]), + ), + ), + ) + + it.effect("redacts echoed request secret values in response bodies", () => + Effect.gen(function* () { + const executor = yield* RequestExecutor.Service + const error = yield* executor.execute(secretRequest).pipe(Effect.flip) + + expectLLMError(error) + expect(errorHttp(error)?.body).toContain("provider echoed ") + expect(errorHttp(error)?.body).toContain("authorization ") + expect(errorHttp(error)?.body).not.toContain("query-secret-123") + expect(errorHttp(error)?.body).not.toContain("header-secret-456") + }).pipe( + Effect.provide( + responsesLayer([ + new Response("provider echoed query-secret-123 and authorization header-secret-456", { status: 400 }), + ]), + ), + ), + ) + + it.effect("honors Retry-After delta seconds before retrying", () => + Effect.gen(function* () { + const attempts = yield* Ref.make(0) + return yield* Effect.gen(function* () { + const executor = yield* RequestExecutor.Service + const fiber = yield* executor.execute(request).pipe(Effect.forkChild) + + yield* Effect.yieldNow + expect(yield* Ref.get(attempts)).toBe(1) + + yield* TestClock.adjust(1_999) + yield* Effect.yieldNow + expect(yield* Ref.get(attempts)).toBe(1) + + yield* TestClock.adjust(1) + const response = yield* Fiber.join(fiber) + + expect(response.status).toBe(200) + expect(yield* Ref.get(attempts)).toBe(2) + }).pipe( + Effect.provide( + countedResponsesLayer(attempts, [ + new Response("busy", { status: 503, headers: { "retry-after": "2" } }), + new Response("ok", { status: 200 }), + ]), + ), + ) + }), + ) + + it.effect("uses exponential jittered delay when retry-after is absent", () => + Effect.gen(function* () { + const attempts = yield* Ref.make(0) + return yield* Effect.gen(function* () { + const executor = yield* RequestExecutor.Service + const fiber = yield* executor.execute(request).pipe(Effect.flip, Effect.forkChild) + + yield* Effect.yieldNow + expect(yield* Ref.get(attempts)).toBe(1) + + yield* TestClock.adjust(499) + yield* Effect.yieldNow + expect(yield* Ref.get(attempts)).toBe(1) + + yield* TestClock.adjust(1) + yield* Effect.yieldNow + expect(yield* Ref.get(attempts)).toBe(2) + + yield* TestClock.adjust(999) + yield* Effect.yieldNow + expect(yield* Ref.get(attempts)).toBe(2) + + yield* TestClock.adjust(1) + const error = yield* Fiber.join(fiber) + + expectLLMError(error) + expect(error.reason).toMatchObject({ _tag: "ProviderInternal" }) + expect(yield* Ref.get(attempts)).toBe(3) + }).pipe( + Effect.provide( + countedResponsesLayer(attempts, [ + new Response("busy", { status: 503 }), + new Response("still busy", { status: 503 }), + new Response("done retrying", { status: 503 }), + ]), + ), + ) + }).pipe(Effect.provideService(Random.Random, randomMidpoint)), + ) + + it.effect("does not retry after a successful response reaches stream parsing", () => + Effect.gen(function* () { + const attempts = yield* Ref.make(0) + const model = OpenAIChat.model({ id: "gpt-4o-mini", baseURL: "https://api.openai.test/v1" }) + const error = yield* LLMClient.generate(LLM.request({ model, prompt: "Say hello." })).pipe( + Effect.provide( + dynamicResponse((input) => + Ref.update(attempts, (value) => value + 1).pipe( + Effect.as( + input.respond( + sseRaw( + `data: ${JSON.stringify(deltaChunk({ role: "assistant", content: "Hello" }))}`, + "data: not-json", + ), + { headers: { "content-type": "text/event-stream" } }, + ), + ), + ), + ), + ), + Effect.flip, + ) + + expectLLMError(error) + expect(error.reason).toMatchObject({ _tag: "InvalidProviderOutput" }) + expect(yield* Ref.get(attempts)).toBe(1) + }), + ) +}) diff --git a/packages/llm/test/exports.test.ts b/packages/llm/test/exports.test.ts new file mode 100644 index 0000000000..237dadb27d --- /dev/null +++ b/packages/llm/test/exports.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, test } from "bun:test" +import { LLM, LLMClient, Provider } from "@opencode-ai/llm" +import { Route, Protocol } from "@opencode-ai/llm/route" +import { Provider as ProviderSubpath } from "@opencode-ai/llm/provider" +import { Cloudflare, OpenAI, OpenAICompatible, OpenRouter, XAI } from "@opencode-ai/llm/providers" +import * as GitHubCopilot from "@opencode-ai/llm/providers/github-copilot" +import { OpenAIChat, OpenAICompatibleChat, OpenAIResponses } from "@opencode-ai/llm/protocols" +import * as AnthropicMessages from "@opencode-ai/llm/protocols/anthropic-messages" + +describe("public exports", () => { + test("root exposes app-facing runtime APIs", () => { + expect(LLM.request).toBeFunction() + expect(LLMClient.Service).toBeFunction() + expect(LLMClient.layer).toBeDefined() + expect(Provider.make).toBeFunction() + expect(ProviderSubpath.make).toBe(Provider.make) + }) + + test("route barrel exposes route-authoring APIs", () => { + expect(Route.make).toBeFunction() + expect(Protocol.make).toBeFunction() + }) + + test("provider barrels expose user-facing facades", () => { + expect(OpenAI.model).toBeFunction() + expect(OpenAI.provider.model).toBe(OpenAI.model) + expect(OpenAI.apis.responses).toBe(OpenAI.responses) + expect(OpenAI.apis.responsesWebSocket).toBe(OpenAI.responsesWebSocket) + expect(OpenAICompatible.deepseek.model).toBeFunction() + expect(Cloudflare.model).toBeFunction() + expect(Cloudflare.provider.model).toBe(Cloudflare.model) + expect(Cloudflare.aiGateway).toBeFunction() + expect(Cloudflare.workersAI).toBeFunction() + expect(OpenRouter.model).toBeFunction() + expect(OpenRouter.provider.model).toBe(OpenRouter.model) + expect(XAI.model).toBeFunction() + expect(XAI.provider.model).toBe(XAI.model) + expect(XAI.apis.responses).toBe(XAI.responses) + expect(XAI.apis.chat).toBe(XAI.chat) + expect(XAI.responses("grok-4.3", { apiKey: "fixture" })).toMatchObject({ + route: "openai-responses", + }) + expect(XAI.chat("grok-4.3", { apiKey: "fixture" })).toMatchObject({ + route: "openai-compatible-chat", + }) + expect(GitHubCopilot.model).toBeFunction() + }) + + test("protocol barrels expose supported low-level routes", () => { + expect(OpenAIChat.route.id).toBe("openai-chat") + expect(OpenAICompatibleChat.route.id).toBe("openai-compatible-chat") + expect(OpenAIResponses.route.id).toBe("openai-responses") + expect(OpenAIResponses.webSocketRoute.id).toBe("openai-responses-websocket") + expect(AnthropicMessages.route.id).toBe("anthropic-messages") + }) +}) diff --git a/packages/llm/test/fixtures/recordings/anthropic-messages-cache/writes-then-reads-cache-control-on-identical-second-call.json b/packages/llm/test/fixtures/recordings/anthropic-messages-cache/writes-then-reads-cache-control-on-identical-second-call.json new file mode 100644 index 0000000000..8cf2be05c1 --- /dev/null +++ b/packages/llm/test/fixtures/recordings/anthropic-messages-cache/writes-then-reads-cache-control-on-identical-second-call.json @@ -0,0 +1,48 @@ +{ + "version": 1, + "metadata": { + "name": "anthropic-messages-cache/writes-then-reads-cache-control-on-identical-second-call", + "recordedAt": "2026-05-11T01:52:54.319Z", + "tags": ["prefix:anthropic-messages-cache", "provider:anthropic", "protocol:anthropic-messages", "cache"] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.anthropic.com/v1/messages", + "headers": { + "anthropic-version": "2023-06-01", + "content-type": "application/json" + }, + "body": "{\"model\":\"claude-haiku-4-5-20251001\",\"system\":[{\"type\":\"text\",\"text\":\"You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. \",\"cache_control\":{\"type\":\"ephemeral\"}}],\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Say hi.\"}]}],\"stream\":true,\"max_tokens\":16,\"temperature\":0}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream; charset=utf-8" + }, + "body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_01NSbhSJdF1R6Uz81RRKxd55\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":9,\"cache_creation_input_tokens\":5752,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":5752,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":2,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}} }\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"} }\n\nevent: ping\ndata: {\"type\": \"ping\"}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"Hi.\"} }\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0 }\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null},\"usage\":{\"input_tokens\":9,\"cache_creation_input_tokens\":5752,\"cache_read_input_tokens\":0,\"output_tokens\":5} }\n\nevent: message_stop\ndata: {\"type\":\"message_stop\" }\n\n" + } + }, + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.anthropic.com/v1/messages", + "headers": { + "anthropic-version": "2023-06-01", + "content-type": "application/json" + }, + "body": "{\"model\":\"claude-haiku-4-5-20251001\",\"system\":[{\"type\":\"text\",\"text\":\"You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. \",\"cache_control\":{\"type\":\"ephemeral\"}}],\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Say hi.\"}]}],\"stream\":true,\"max_tokens\":16,\"temperature\":0}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream; charset=utf-8" + }, + "body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_01W9dNB2vnT7HoPQmDfKyniu\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":9,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":5752,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":2,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}} }\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"} }\n\nevent: ping\ndata: {\"type\": \"ping\"}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"Hi.\"} }\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0 }\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null},\"usage\":{\"input_tokens\":9,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":5752,\"output_tokens\":5} }\n\nevent: message_stop\ndata: {\"type\":\"message_stop\" }\n\n" + } + } + ] +} diff --git a/packages/llm/test/fixtures/recordings/anthropic-messages/accepts-malformed-assistant-tool-order-with-default-patch.json b/packages/llm/test/fixtures/recordings/anthropic-messages/accepts-malformed-assistant-tool-order-with-default-patch.json new file mode 100644 index 0000000000..7730485cb4 --- /dev/null +++ b/packages/llm/test/fixtures/recordings/anthropic-messages/accepts-malformed-assistant-tool-order-with-default-patch.json @@ -0,0 +1,29 @@ +{ + "version": 1, + "metadata": { + "name": "anthropic-messages/accepts-malformed-assistant-tool-order-with-default-patch", + "recordedAt": "2026-05-05T20:09:16.245Z", + "tags": ["prefix:anthropic-messages", "provider:anthropic", "protocol:anthropic-messages", "tool"] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.anthropic.com/v1/messages", + "headers": { + "anthropic-version": "2023-06-01", + "content-type": "application/json" + }, + "body": "{\"model\":\"claude-haiku-4-5-20251001\",\"messages\":[{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"I will check the weather.\"}]},{\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"call_1\",\"name\":\"get_weather\",\"input\":{\"city\":\"Paris\"}}]},{\"role\":\"user\",\"content\":[{\"type\":\"tool_result\",\"tool_use_id\":\"call_1\",\"content\":\"{\\\"temperature\\\":\\\"72F\\\"}\"}]},{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Use that result to answer briefly.\",\"cache_control\":{\"type\":\"ephemeral\"}}]}],\"tools\":[{\"name\":\"get_weather\",\"description\":\"Get weather\",\"input_schema\":{\"type\":\"object\",\"properties\":{}}}],\"stream\":true,\"max_tokens\":4096}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream; charset=utf-8" + }, + "body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_01SikJVFaMR1XLMtavUhvuog\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":638,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":1,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}} }\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"} }\n\nevent: ping\ndata: {\"type\": \"ping\"}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"The\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" weather in Paris is currently 72°F.\"} }\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null},\"usage\":{\"input_tokens\":638,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":14} }\n\nevent: message_stop\ndata: {\"type\":\"message_stop\" }\n\n" + } + } + ] +} diff --git a/packages/llm/test/fixtures/recordings/anthropic-messages/claude-opus-4-7-drives-a-tool-loop.json b/packages/llm/test/fixtures/recordings/anthropic-messages/claude-opus-4-7-drives-a-tool-loop.json new file mode 100644 index 0000000000..316f4308fc --- /dev/null +++ b/packages/llm/test/fixtures/recordings/anthropic-messages/claude-opus-4-7-drives-a-tool-loop.json @@ -0,0 +1,56 @@ +{ + "version": 1, + "metadata": { + "name": "anthropic-messages/claude-opus-4-7-drives-a-tool-loop", + "recordedAt": "2026-05-03T19:59:44.186Z", + "tags": [ + "prefix:anthropic-messages", + "provider:anthropic", + "protocol:anthropic-messages", + "tool", + "tool-loop", + "golden", + "flagship" + ] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.anthropic.com/v1/messages", + "headers": { + "anthropic-version": "2023-06-01", + "content-type": "application/json" + }, + "body": "{\"model\":\"claude-opus-4-7\",\"system\":[{\"type\":\"text\",\"text\":\"Use the get_weather tool, then answer in one short sentence.\"}],\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"What is the weather in Paris?\"}]}],\"tools\":[{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"input_schema\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}],\"stream\":true,\"max_tokens\":80}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream; charset=utf-8" + }, + "body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-opus-4-7\",\"id\":\"msg_01DgAEgLgB1ZhavZon4qGE1t\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":798,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":0,\"service_tier\":\"standard\",\"inference_geo\":\"global\"}} }\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"tool_use\",\"id\":\"toolu_01M8nJQQMxqpv1VaPYuJKT4j\",\"name\":\"get_weather\",\"input\":{},\"caller\":{\"type\":\"direct\"}} }\n\nevent: ping\ndata: {\"type\": \"ping\"}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\\\"city\\\": \"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"\\\"Pa\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"ris\\\"}\"} }\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0 }\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"tool_use\",\"stop_sequence\":null,\"stop_details\":null},\"usage\":{\"input_tokens\":798,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":66} }\n\nevent: message_stop\ndata: {\"type\":\"message_stop\" }\n\n" + } + }, + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.anthropic.com/v1/messages", + "headers": { + "anthropic-version": "2023-06-01", + "content-type": "application/json" + }, + "body": "{\"model\":\"claude-opus-4-7\",\"system\":[{\"type\":\"text\",\"text\":\"Use the get_weather tool, then answer in one short sentence.\"}],\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"What is the weather in Paris?\"}]},{\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"toolu_01M8nJQQMxqpv1VaPYuJKT4j\",\"name\":\"get_weather\",\"input\":{\"city\":\"Paris\"}}]},{\"role\":\"user\",\"content\":[{\"type\":\"tool_result\",\"tool_use_id\":\"toolu_01M8nJQQMxqpv1VaPYuJKT4j\",\"content\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}]}],\"tools\":[{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"input_schema\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}],\"stream\":true,\"max_tokens\":80}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream; charset=utf-8" + }, + "body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-opus-4-7\",\"id\":\"msg_011KJqj32QjkrUAiBFxhmEoG\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":895,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":5,\"service_tier\":\"standard\",\"inference_geo\":\"global\"}} }\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"} }\n\nevent: ping\ndata: {\"type\": \"ping\"}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"Paris is curr\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"ently sunny at 22°C.\"} }\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0 }\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null},\"usage\":{\"input_tokens\":895,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":19}}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n" + } + } + ] +} diff --git a/packages/llm/test/fixtures/recordings/anthropic-messages/rejects-malformed-assistant-tool-order-without-patch.json b/packages/llm/test/fixtures/recordings/anthropic-messages/rejects-malformed-assistant-tool-order-without-patch.json new file mode 100644 index 0000000000..cd0990cec5 --- /dev/null +++ b/packages/llm/test/fixtures/recordings/anthropic-messages/rejects-malformed-assistant-tool-order-without-patch.json @@ -0,0 +1,29 @@ +{ + "version": 1, + "metadata": { + "name": "anthropic-messages/rejects-malformed-assistant-tool-order-without-patch", + "recordedAt": "2026-05-05T20:08:42.597Z", + "tags": ["prefix:anthropic-messages", "provider:anthropic", "protocol:anthropic-messages", "tool", "sad-path"] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.anthropic.com/v1/messages", + "headers": { + "anthropic-version": "2023-06-01", + "content-type": "application/json" + }, + "body": "{\"model\":\"claude-haiku-4-5-20251001\",\"messages\":[{\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"call_1\",\"name\":\"get_weather\",\"input\":{\"city\":\"Paris\"}},{\"type\":\"text\",\"text\":\"I will check the weather.\"}]},{\"role\":\"user\",\"content\":[{\"type\":\"tool_result\",\"tool_use_id\":\"call_1\",\"content\":\"{\\\"temperature\\\":\\\"72F\\\"}\"}]},{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Use that result to answer briefly.\"}]}],\"tools\":[{\"name\":\"get_weather\",\"description\":\"Get weather\",\"input_schema\":{\"type\":\"object\",\"properties\":{}}}],\"stream\":true,\"max_tokens\":4096}" + }, + "response": { + "status": 400, + "headers": { + "content-type": "application/json" + }, + "body": "{\"type\":\"error\",\"error\":{\"type\":\"invalid_request_error\",\"message\":\"messages.1: `tool_use` ids were found without `tool_result` blocks immediately after: call_1. Each `tool_use` block must have a corresponding `tool_result` block in the next message.\"},\"request_id\":\"req_011Cak2XdJgnzxKCY2BC2Beh\"}" + } + } + ] +} diff --git a/packages/llm/test/fixtures/recordings/anthropic-messages/streams-text.json b/packages/llm/test/fixtures/recordings/anthropic-messages/streams-text.json new file mode 100644 index 0000000000..e80a0dac34 --- /dev/null +++ b/packages/llm/test/fixtures/recordings/anthropic-messages/streams-text.json @@ -0,0 +1,29 @@ +{ + "version": 1, + "metadata": { + "name": "anthropic-messages/streams-text", + "recordedAt": "2026-04-28T21:18:45.535Z", + "tags": ["prefix:anthropic-messages", "provider:anthropic", "protocol:anthropic-messages"] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.anthropic.com/v1/messages", + "headers": { + "anthropic-version": "2023-06-01", + "content-type": "application/json" + }, + "body": "{\"model\":\"claude-haiku-4-5-20251001\",\"system\":[{\"type\":\"text\",\"text\":\"You are concise.\"}],\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Reply with exactly: Hello!\"}]}],\"stream\":true,\"max_tokens\":20,\"temperature\":0}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream; charset=utf-8" + }, + "body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_01UodR8c3ezAK8rAfi8HAs8g\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":18,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":2,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}} }\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"} }\n\nevent: ping\ndata: {\"type\": \"ping\"}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"Hello!\"} }\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0 }\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null},\"usage\":{\"input_tokens\":18,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":5} }\n\nevent: message_stop\ndata: {\"type\":\"message_stop\" }\n\n" + } + } + ] +} diff --git a/packages/llm/test/fixtures/recordings/anthropic-messages/streams-tool-call.json b/packages/llm/test/fixtures/recordings/anthropic-messages/streams-tool-call.json new file mode 100644 index 0000000000..ef8f69c21d --- /dev/null +++ b/packages/llm/test/fixtures/recordings/anthropic-messages/streams-tool-call.json @@ -0,0 +1,29 @@ +{ + "version": 1, + "metadata": { + "name": "anthropic-messages/streams-tool-call", + "recordedAt": "2026-04-28T21:18:46.878Z", + "tags": ["prefix:anthropic-messages", "provider:anthropic", "protocol:anthropic-messages", "tool"] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.anthropic.com/v1/messages", + "headers": { + "anthropic-version": "2023-06-01", + "content-type": "application/json" + }, + "body": "{\"model\":\"claude-haiku-4-5-20251001\",\"system\":[{\"type\":\"text\",\"text\":\"Call tools exactly as requested.\"}],\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Call get_weather with city exactly Paris.\"}]}],\"tools\":[{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"input_schema\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}],\"tool_choice\":{\"type\":\"tool\",\"name\":\"get_weather\"},\"stream\":true,\"max_tokens\":80,\"temperature\":0}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream; charset=utf-8" + }, + "body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_01RYgU7NUPMK4B9v8S7gVpCS\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":677,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":16,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}} }\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"tool_use\",\"id\":\"toolu_012rmAruviySvUXSjgCPWVRu\",\"name\":\"get_weather\",\"input\":{},\"caller\":{\"type\":\"direct\"}} }\n\nevent: ping\ndata: {\"type\": \"ping\"}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\\\"city\\\":\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\" \\\"Paris\\\"}\"} }\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0 }\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"tool_use\",\"stop_sequence\":null,\"stop_details\":null},\"usage\":{\"input_tokens\":677,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":33} }\n\nevent: message_stop\ndata: {\"type\":\"message_stop\" }\n\n" + } + } + ] +} diff --git a/packages/llm/test/fixtures/recordings/bedrock-converse/drives-a-tool-loop.json b/packages/llm/test/fixtures/recordings/bedrock-converse/drives-a-tool-loop.json new file mode 100644 index 0000000000..26eca01609 --- /dev/null +++ b/packages/llm/test/fixtures/recordings/bedrock-converse/drives-a-tool-loop.json @@ -0,0 +1,55 @@ +{ + "version": 1, + "metadata": { + "name": "bedrock-converse/drives-a-tool-loop", + "recordedAt": "2026-05-03T20:01:48.334Z", + "tags": [ + "prefix:bedrock-converse", + "provider:amazon-bedrock", + "protocol:bedrock-converse", + "tool", + "tool-loop", + "golden" + ] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://bedrock-runtime.us-east-1.amazonaws.com/model/us.amazon.nova-micro-v1%3A0/converse-stream", + "headers": { + "content-type": "application/json" + }, + "body": "{\"modelId\":\"us.amazon.nova-micro-v1:0\",\"messages\":[{\"role\":\"user\",\"content\":[{\"text\":\"What is the weather in Paris?\"}]}],\"system\":[{\"text\":\"Use the get_weather tool, then answer in one short sentence.\"}],\"inferenceConfig\":{\"maxTokens\":80,\"temperature\":0},\"toolConfig\":{\"tools\":[{\"toolSpec\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"inputSchema\":{\"json\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}}}]}}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "application/vnd.amazon.eventstream" + }, + "body": "AAAAtwAAAFJCoDu1CzpldmVudC10eXBlBwAMbWVzc2FnZVN0YXJ0DTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVowMTIzNDUiLCJyb2xlIjoiYXNzaXN0YW50In1xBrKfAAAA0gAAAFdjGDcHCzpldmVudC10eXBlBwARY29udGVudEJsb2NrRGVsdGENOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJjb250ZW50QmxvY2tJbmRleCI6MCwiZGVsdGEiOnsidGV4dCI6Ijx0aGlua2luZyJ9LCJwIjoiYWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXpBQkNERUZHSElKS0xNTk9QUVJTVFVWIn17Hkd0AAAAuQAAAFeN+nFbCzpldmVudC10eXBlBwARY29udGVudEJsb2NrRGVsdGENOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJjb250ZW50QmxvY2tJbmRleCI6MCwiZGVsdGEiOnsidGV4dCI6Ij4ifSwicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6QUJDREUifXAgJvgAAADMAAAAV7zIHuQLOmV2ZW50LXR5cGUHABFjb250ZW50QmxvY2tEZWx0YQ06Y29udGVudC10eXBlBwAQYXBwbGljYXRpb24vanNvbg06bWVzc2FnZS10eXBlBwAFZXZlbnR7ImNvbnRlbnRCbG9ja0luZGV4IjowLCJkZWx0YSI6eyJ0ZXh0IjoiIFRvIn0sInAiOiJhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ekFCQ0RFRkdISUpLTE1OT1BRUlNUVVYifaOASr0AAACrAAAAV5fatbkLOmV2ZW50LXR5cGUHABFjb250ZW50QmxvY2tEZWx0YQ06Y29udGVudC10eXBlBwAQYXBwbGljYXRpb24vanNvbg06bWVzc2FnZS10eXBlBwAFZXZlbnR7ImNvbnRlbnRCbG9ja0luZGV4IjowLCJkZWx0YSI6eyJ0ZXh0IjoiIGRldGVybWluZSJ9LCJwIjoiYWJjZGVmZ2gifQUyd0MAAADQAAAAVxnYZGcLOmV2ZW50LXR5cGUHABFjb250ZW50QmxvY2tEZWx0YQ06Y29udGVudC10eXBlBwAQYXBwbGljYXRpb24vanNvbg06bWVzc2FnZS10eXBlBwAFZXZlbnR7ImNvbnRlbnRCbG9ja0luZGV4IjowLCJkZWx0YSI6eyJ0ZXh0IjoiIHRoZSJ9LCJwIjoiYWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXpBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZIn0ZHcgRAAAAxwAAAFfLGC/1CzpldmVudC10eXBlBwARY29udGVudEJsb2NrRGVsdGENOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJjb250ZW50QmxvY2tJbmRleCI6MCwiZGVsdGEiOnsidGV4dCI6IiB3ZWF0aGVyIn0sInAiOiJhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ekFCQ0RFRkdISUpLTCJ9QpgceQAAALsAAABX9zoiOws6ZXZlbnQtdHlwZQcAEWNvbnRlbnRCbG9ja0RlbHRhDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsiY29udGVudEJsb2NrSW5kZXgiOjAsImRlbHRhIjp7InRleHQiOiIgaW4ifSwicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6QUJDREUifRLNLa0AAACkAAAAVxWKImgLOmV2ZW50LXR5cGUHABFjb250ZW50QmxvY2tEZWx0YQ06Y29udGVudC10eXBlBwAQYXBwbGljYXRpb24vanNvbg06bWVzc2FnZS10eXBlBwAFZXZlbnR7ImNvbnRlbnRCbG9ja0luZGV4IjowLCJkZWx0YSI6eyJ0ZXh0IjoiIFBhcmlzIn0sInAiOiJhYmNkZSJ9QOSGZQAAAKgAAABX0HrPaQs6ZXZlbnQtdHlwZQcAEWNvbnRlbnRCbG9ja0RlbHRhDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsiY29udGVudEJsb2NrSW5kZXgiOjAsImRlbHRhIjp7InRleHQiOiIsIn0sInAiOiJhYmNkZWZnaGlqa2xtbiJ9bgd/VgAAALAAAABXgOoTKgs6ZXZlbnQtdHlwZQcAEWNvbnRlbnRCbG9ja0RlbHRhDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsiY29udGVudEJsb2NrSW5kZXgiOjAsImRlbHRhIjp7InRleHQiOiIgSSJ9LCJwIjoiYWJjZGVmZ2hpamtsbW5vcHFyc3R1In3RkbiWAAAA0QAAAFckuE3XCzpldmVudC10eXBlBwARY29udGVudEJsb2NrRGVsdGENOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJjb250ZW50QmxvY2tJbmRleCI6MCwiZGVsdGEiOnsidGV4dCI6IiB3aWxsIn0sInAiOiJhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ekFCQ0RFRkdISUpLTE1OT1BRUlNUVVZXWFkifa2kMpYAAACfAAAAV8N7q/8LOmV2ZW50LXR5cGUHABFjb250ZW50QmxvY2tEZWx0YQ06Y29udGVudC10eXBlBwAQYXBwbGljYXRpb24vanNvbg06bWVzc2FnZS10eXBlBwAFZXZlbnR7ImNvbnRlbnRCbG9ja0luZGV4IjowLCJkZWx0YSI6eyJ0ZXh0IjoiIHVzZSJ9LCJwIjoiYWIifWRVyJsAAADFAAAAV7HYfJULOmV2ZW50LXR5cGUHABFjb250ZW50QmxvY2tEZWx0YQ06Y29udGVudC10eXBlBwAQYXBwbGljYXRpb24vanNvbg06bWVzc2FnZS10eXBlBwAFZXZlbnR7ImNvbnRlbnRCbG9ja0luZGV4IjowLCJkZWx0YSI6eyJ0ZXh0IjoiIHRoZSJ9LCJwIjoiYWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXpBQkNERUZHSElKS0xNTiJ99QGTXwAAALwAAABXRRr+Kws6ZXZlbnQtdHlwZQcAEWNvbnRlbnRCbG9ja0RlbHRhDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsiY29udGVudEJsb2NrSW5kZXgiOjAsImRlbHRhIjp7InRleHQiOiIgZ2V0In0sInAiOiJhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ekFCQ0RFIn3A1pHkAAAArAAAAFcl+mmpCzpldmVudC10eXBlBwARY29udGVudEJsb2NrRGVsdGENOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJjb250ZW50QmxvY2tJbmRleCI6MCwiZGVsdGEiOnsidGV4dCI6Il8ifSwicCI6ImFiY2RlZmdoaWprbG1ub3BxciJ9Jl4BhgAAAMwAAABXvMge5As6ZXZlbnQtdHlwZQcAEWNvbnRlbnRCbG9ja0RlbHRhDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsiY29udGVudEJsb2NrSW5kZXgiOjAsImRlbHRhIjp7InRleHQiOiJ3ZWF0aGVyIn0sInAiOiJhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ekFCQ0RFRkdISUpLTE1OT1BRUiJ9zDOXNgAAANMAAABXXngetws6ZXZlbnQtdHlwZQcAEWNvbnRlbnRCbG9ja0RlbHRhDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsiY29udGVudEJsb2NrSW5kZXgiOjAsImRlbHRhIjp7InRleHQiOiIgdG9vbCJ9LCJwIjoiYWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXpBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWjAifYuc7T0AAADXAAAAV6v4uHcLOmV2ZW50LXR5cGUHABFjb250ZW50QmxvY2tEZWx0YQ06Y29udGVudC10eXBlBwAQYXBwbGljYXRpb24vanNvbg06bWVzc2FnZS10eXBlBwAFZXZlbnR7ImNvbnRlbnRCbG9ja0luZGV4IjowLCJkZWx0YSI6eyJ0ZXh0IjoiIGFuZCJ9LCJwIjoiYWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXpBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWjAxMjM0NSJ9Z1WRPAAAANYAAABXlpiRxws6ZXZlbnQtdHlwZQcAEWNvbnRlbnRCbG9ja0RlbHRhDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsiY29udGVudEJsb2NrSW5kZXgiOjAsImRlbHRhIjp7InRleHQiOiIgcHJvdmlkZSJ9LCJwIjoiYWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXpBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWjAifWuffy4AAACiAAAAV5rK18gLOmV2ZW50LXR5cGUHABFjb250ZW50QmxvY2tEZWx0YQ06Y29udGVudC10eXBlBwAQYXBwbGljYXRpb24vanNvbg06bWVzc2FnZS10eXBlBwAFZXZlbnR7ImNvbnRlbnRCbG9ja0luZGV4IjowLCJkZWx0YSI6eyJ0ZXh0IjoiIHRoZSJ9LCJwIjoiYWJjZGUifR59TKYAAADUAAAAV+xYwqcLOmV2ZW50LXR5cGUHABFjb250ZW50QmxvY2tEZWx0YQ06Y29udGVudC10eXBlBwAQYXBwbGljYXRpb24vanNvbg06bWVzc2FnZS10eXBlBwAFZXZlbnR7ImNvbnRlbnRCbG9ja0luZGV4IjowLCJkZWx0YSI6eyJ0ZXh0IjoiIGNpdHkifSwicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVowMSJ9JF6q4AAAANQAAABX7FjCpws6ZXZlbnQtdHlwZQcAEWNvbnRlbnRCbG9ja0RlbHRhDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsiY29udGVudEJsb2NrSW5kZXgiOjAsImRlbHRhIjp7InRleHQiOiIgYXMifSwicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVowMTIzIn3T44iVAAAA1gAAAFeWmJHHCzpldmVudC10eXBlBwARY29udGVudEJsb2NrRGVsdGENOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJjb250ZW50QmxvY2tJbmRleCI6MCwiZGVsdGEiOnsidGV4dCI6IiBcIiJ9LCJwIjoiYWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXpBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWjAxMjM0NSJ9T89b0AAAANkAAABXFMgGFgs6ZXZlbnQtdHlwZQcAEWNvbnRlbnRCbG9ja0RlbHRhDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsiY29udGVudEJsb2NrSW5kZXgiOjAsImRlbHRhIjp7InRleHQiOiJQYXJpcyJ9LCJwIjoiYWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXpBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWjAxMjM0NTYifYX0tNEAAAClAAAAVyjqC9gLOmV2ZW50LXR5cGUHABFjb250ZW50QmxvY2tEZWx0YQ06Y29udGVudC10eXBlBwAQYXBwbGljYXRpb24vanNvbg06bWVzc2FnZS10eXBlBwAFZXZlbnR7ImNvbnRlbnRCbG9ja0luZGV4IjowLCJkZWx0YSI6eyJ0ZXh0IjoiXCIuIn0sInAiOiJhYmNkZWZnaGkifUbVohIAAAC9AAAAV3h615sLOmV2ZW50LXR5cGUHABFjb250ZW50QmxvY2tEZWx0YQ06Y29udGVudC10eXBlBwAQYXBwbGljYXRpb24vanNvbg06bWVzc2FnZS10eXBlBwAFZXZlbnR7ImNvbnRlbnRCbG9ja0luZGV4IjowLCJkZWx0YSI6eyJ0ZXh0IjoiIDwvIn0sInAiOiJhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ekFCQ0RFRkcifU+fapUAAADEAAAAV4y4VSULOmV2ZW50LXR5cGUHABFjb250ZW50QmxvY2tEZWx0YQ06Y29udGVudC10eXBlBwAQYXBwbGljYXRpb24vanNvbg06bWVzc2FnZS10eXBlBwAFZXZlbnR7ImNvbnRlbnRCbG9ja0luZGV4IjowLCJkZWx0YSI6eyJ0ZXh0IjoidGhpbmtpbmcifSwicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6QUJDREVGR0hJIn0npV45AAAAoQAAAFfdaq0YCzpldmVudC10eXBlBwARY29udGVudEJsb2NrRGVsdGENOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJjb250ZW50QmxvY2tJbmRleCI6MCwiZGVsdGEiOnsidGV4dCI6Ij5cbiJ9LCJwIjoiYWJjZGUifXpOZ6MAAACtAAAAVm+dcI8LOmV2ZW50LXR5cGUHABBjb250ZW50QmxvY2tTdG9wDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsiY29udGVudEJsb2NrSW5kZXgiOjAsInAiOiJhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ekFCQ0RFRkdISUpLTE1OTyJ9wp8EHgAAAQwAAABXnoElmgs6ZXZlbnQtdHlwZQcAEWNvbnRlbnRCbG9ja1N0YXJ0DTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsiY29udGVudEJsb2NrSW5kZXgiOjEsInAiOiJhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ekFCQ0RFRkdISUpLTE1OT1BRUlNUVSIsInN0YXJ0Ijp7InRvb2xVc2UiOnsibmFtZSI6ImdldF93ZWF0aGVyIiwidG9vbFVzZUlkIjoidG9vbHVzZV9hOG5sZjJicUdMY1p2YVNvQnBRMXNIIn19fY7FuJUAAADLAAAAVw7owvQLOmV2ZW50LXR5cGUHABFjb250ZW50QmxvY2tEZWx0YQ06Y29udGVudC10eXBlBwAQYXBwbGljYXRpb24vanNvbg06bWVzc2FnZS10eXBlBwAFZXZlbnR7ImNvbnRlbnRCbG9ja0luZGV4IjoxLCJkZWx0YSI6eyJ0b29sVXNlIjp7ImlucHV0Ijoie1wiY2l0eVwiOlwiUGFyaXNcIn0ifX0sInAiOiJhYmNkZWZnaGlqa2xtbm9wcSJ9r3QETwAAALQAAABWAm2FfAs6ZXZlbnQtdHlwZQcAEGNvbnRlbnRCbG9ja1N0b3ANOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJjb250ZW50QmxvY2tJbmRleCI6MSwicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6QUJDREVGR0hJSktMTU5PUFFSU1RVViJ9shQTDgAAAKUAAABRwYmu7Qs6ZXZlbnQtdHlwZQcAC21lc3NhZ2VTdG9wDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6QUJDREVGR0hJSiIsInN0b3BSZWFzb24iOiJ0b29sX3VzZSJ9i4+/2gAAAO4AAABOY6LKQAs6ZXZlbnQtdHlwZQcACG1ldGFkYXRhDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsibWV0cmljcyI6eyJsYXRlbmN5TXMiOjQ5OX0sInAiOiJhYmNkZWZnaGlqa2xtbm9wcXJzdHV2dyIsInVzYWdlIjp7ImlucHV0VG9rZW5zIjo0MjUsIm91dHB1dFRva2VucyI6NDUsInNlcnZlclRvb2xVc2FnZSI6e30sInRvdGFsVG9rZW5zIjo0NzB9fSAjG74=", + "bodyEncoding": "base64" + } + }, + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://bedrock-runtime.us-east-1.amazonaws.com/model/us.amazon.nova-micro-v1%3A0/converse-stream", + "headers": { + "content-type": "application/json" + }, + "body": "{\"modelId\":\"us.amazon.nova-micro-v1:0\",\"messages\":[{\"role\":\"user\",\"content\":[{\"text\":\"What is the weather in Paris?\"}]},{\"role\":\"assistant\",\"content\":[{\"text\":\" To determine the weather in Paris, I will use the get_weather tool and provide the city as \\\"Paris\\\". \\n\"},{\"toolUse\":{\"toolUseId\":\"tooluse_a8nlf2bqGLcZvaSoBpQ1sH\",\"name\":\"get_weather\",\"input\":{\"city\":\"Paris\"}}}]},{\"role\":\"user\",\"content\":[{\"toolResult\":{\"toolUseId\":\"tooluse_a8nlf2bqGLcZvaSoBpQ1sH\",\"content\":[{\"json\":{\"temperature\":22,\"condition\":\"sunny\"}}],\"status\":\"success\"}}]}],\"system\":[{\"text\":\"Use the get_weather tool, then answer in one short sentence.\"}],\"inferenceConfig\":{\"maxTokens\":80,\"temperature\":0},\"toolConfig\":{\"tools\":[{\"toolSpec\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"inputSchema\":{\"json\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}}}]}}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "application/vnd.amazon.eventstream" + }, + "body": "AAAAgQAAAFJswXaTCzpldmVudC10eXBlBwAMbWVzc2FnZVN0YXJ0DTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsicCI6ImFiY2QiLCJyb2xlIjoiYXNzaXN0YW50In31EqAFAAAAoQAAAFfdaq0YCzpldmVudC10eXBlBwARY29udGVudEJsb2NrRGVsdGENOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJjb250ZW50QmxvY2tJbmRleCI6MCwiZGVsdGEiOnsidGV4dCI6IlRoZSJ9LCJwIjoiYWJjZGUifZ8hzYkAAACmAAAAV29KcQgLOmV2ZW50LXR5cGUHABFjb250ZW50QmxvY2tEZWx0YQ06Y29udGVudC10eXBlBwAQYXBwbGljYXRpb24vanNvbg06bWVzc2FnZS10eXBlBwAFZXZlbnR7ImNvbnRlbnRCbG9ja0luZGV4IjowLCJkZWx0YSI6eyJ0ZXh0IjoiIHdlYXRoZXIifSwicCI6ImFiY2RlIn0dzksTAAAAsQAAAFe9ijqaCzpldmVudC10eXBlBwARY29udGVudEJsb2NrRGVsdGENOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJjb250ZW50QmxvY2tJbmRleCI6MCwiZGVsdGEiOnsidGV4dCI6IiBpbiJ9LCJwIjoiYWJjZGVmZ2hpamtsbW5vcHFyc3R1In1AJhvbAAAAqgAAAFequpwJCzpldmVudC10eXBlBwARY29udGVudEJsb2NrRGVsdGENOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJjb250ZW50QmxvY2tJbmRleCI6MCwiZGVsdGEiOnsidGV4dCI6IiBQYXJpcyJ9LCJwIjoiYWJjZGVmZ2hpamsifQpyKMQAAADBAAAAV0RY2lULOmV2ZW50LXR5cGUHABFjb250ZW50QmxvY2tEZWx0YQ06Y29udGVudC10eXBlBwAQYXBwbGljYXRpb24vanNvbg06bWVzc2FnZS10eXBlBwAFZXZlbnR7ImNvbnRlbnRCbG9ja0luZGV4IjowLCJkZWx0YSI6eyJ0ZXh0IjoiIGlzIn0sInAiOiJhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ekFCQ0RFRkdISUpLIn1gvC8JAAAA2QAAAFcUyAYWCzpldmVudC10eXBlBwARY29udGVudEJsb2NrRGVsdGENOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJjb250ZW50QmxvY2tJbmRleCI6MCwiZGVsdGEiOnsidGV4dCI6IiBzdW5ueSJ9LCJwIjoiYWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXpBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWjAxMjM0NSJ9j+j/gQAAAK8AAABXYloTeQs6ZXZlbnQtdHlwZQcAEWNvbnRlbnRCbG9ja0RlbHRhDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsiY29udGVudEJsb2NrSW5kZXgiOjAsImRlbHRhIjp7InRleHQiOiIgd2l0aCJ9LCJwIjoiYWJjZGVmZ2hpamtsbW5vcHEifRRyjnsAAACyAAAAV/oqQEoLOmV2ZW50LXR5cGUHABFjb250ZW50QmxvY2tEZWx0YQ06Y29udGVudC10eXBlBwAQYXBwbGljYXRpb24vanNvbg06bWVzc2FnZS10eXBlBwAFZXZlbnR7ImNvbnRlbnRCbG9ja0luZGV4IjowLCJkZWx0YSI6eyJ0ZXh0IjoiIGEifSwicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3In2kLJI+AAAAuAAAAFewmljrCzpldmVudC10eXBlBwARY29udGVudEJsb2NrRGVsdGENOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJjb250ZW50QmxvY2tJbmRleCI6MCwiZGVsdGEiOnsidGV4dCI6IiB0ZW1wZXJhdHVyZSJ9LCJwIjoiYWJjZGVmZ2hpamtsbW5vcHFycyJ9JuTWEQAAAKEAAABX3WqtGAs6ZXZlbnQtdHlwZQcAEWNvbnRlbnRCbG9ja0RlbHRhDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsiY29udGVudEJsb2NrSW5kZXgiOjAsImRlbHRhIjp7InRleHQiOiIgb2YifSwicCI6ImFiY2RlIn1Uu0Z+AAAAmwAAAFc2+w0/CzpldmVudC10eXBlBwARY29udGVudEJsb2NrRGVsdGENOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJjb250ZW50QmxvY2tJbmRleCI6MCwiZGVsdGEiOnsidGV4dCI6IiJ9LCJwIjoiYWIifaR9kNQAAAC4AAAAV7CaWOsLOmV2ZW50LXR5cGUHABFjb250ZW50QmxvY2tEZWx0YQ06Y29udGVudC10eXBlBwAQYXBwbGljYXRpb24vanNvbg06bWVzc2FnZS10eXBlBwAFZXZlbnR7ImNvbnRlbnRCbG9ja0luZGV4IjowLCJkZWx0YSI6eyJ0ZXh0IjoiIDIifSwicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6QUJDIn04fpEGAAAApQAAAFco6gvYCzpldmVudC10eXBlBwARY29udGVudEJsb2NrRGVsdGENOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJjb250ZW50QmxvY2tJbmRleCI6MCwiZGVsdGEiOnsidGV4dCI6IjIifSwicCI6ImFiY2RlZmdoaWprIn0ws3/UAAAA1gAAAFeWmJHHCzpldmVudC10eXBlBwARY29udGVudEJsb2NrRGVsdGENOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJjb250ZW50QmxvY2tJbmRleCI6MCwiZGVsdGEiOnsidGV4dCI6IiBkZWdyZWVzIn0sInAiOiJhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ekFCQ0RFRkdISUpLTE1OT1BRUlNUVVZXWFlaMCJ9q7xKeQAAAJ8AAABXw3ur/ws6ZXZlbnQtdHlwZQcAEWNvbnRlbnRCbG9ja0RlbHRhDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsiY29udGVudEJsb2NrSW5kZXgiOjAsImRlbHRhIjp7InRleHQiOiIuIn0sInAiOiJhYmNkZSJ9t7YAjQAAAMUAAABXsdh8lQs6ZXZlbnQtdHlwZQcAEWNvbnRlbnRCbG9ja0RlbHRhDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsiY29udGVudEJsb2NrSW5kZXgiOjAsImRlbHRhIjp7InRleHQiOiIifSwicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6QUJDREVGR0hJSktMTU5PUFFSIn1NJJR+AAAAsQAAAFbKjQoMCzpldmVudC10eXBlBwAQY29udGVudEJsb2NrU3RvcA06Y29udGVudC10eXBlBwAQYXBwbGljYXRpb24vanNvbg06bWVzc2FnZS10eXBlBwAFZXZlbnR7ImNvbnRlbnRCbG9ja0luZGV4IjowLCJwIjoiYWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXpBQkNERUZHSElKS0xNTk9QUVJTIn1DzHT/AAAAiAAAAFH42EVYCzpldmVudC10eXBlBwALbWVzc2FnZVN0b3ANOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJwIjoiYWJjZGVmZyIsInN0b3BSZWFzb24iOiJlbmRfdHVybiJ9rwP92gAAAOAAAABO3JJ0IQs6ZXZlbnQtdHlwZQcACG1ldGFkYXRhDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsibWV0cmljcyI6eyJsYXRlbmN5TXMiOjM4MX0sInAiOiJhYmNkZWZnaGkiLCJ1c2FnZSI6eyJpbnB1dFRva2VucyI6NTEwLCJvdXRwdXRUb2tlbnMiOjE2LCJzZXJ2ZXJUb29sVXNhZ2UiOnt9LCJ0b3RhbFRva2VucyI6NTI2fX2ZCNET", + "bodyEncoding": "base64" + } + } + ] +} diff --git a/packages/llm/test/fixtures/recordings/bedrock-converse/streams-a-tool-call.json b/packages/llm/test/fixtures/recordings/bedrock-converse/streams-a-tool-call.json new file mode 100644 index 0000000000..4f22ce22da --- /dev/null +++ b/packages/llm/test/fixtures/recordings/bedrock-converse/streams-a-tool-call.json @@ -0,0 +1,29 @@ +{ + "version": 1, + "metadata": { + "name": "bedrock-converse/streams-a-tool-call", + "recordedAt": "2026-04-28T21:18:46.929Z", + "tags": ["prefix:bedrock-converse", "provider:amazon-bedrock", "protocol:bedrock-converse", "tool"] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://bedrock-runtime.us-east-1.amazonaws.com/model/us.amazon.nova-micro-v1%3A0/converse-stream", + "headers": { + "content-type": "application/json" + }, + "body": "{\"modelId\":\"us.amazon.nova-micro-v1:0\",\"messages\":[{\"role\":\"user\",\"content\":[{\"text\":\"Call get_weather with city exactly Paris.\"}]}],\"system\":[{\"text\":\"Call tools exactly as requested.\"}],\"inferenceConfig\":{\"maxTokens\":80,\"temperature\":0},\"toolConfig\":{\"tools\":[{\"toolSpec\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"inputSchema\":{\"json\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}}}],\"toolChoice\":{\"tool\":{\"name\":\"get_weather\"}}}}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "application/vnd.amazon.eventstream" + }, + "body": "AAAAuQAAAFL9kIXUCzpldmVudC10eXBlBwAMbWVzc2FnZVN0YXJ0DTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVowMTIzNDU2NyIsInJvbGUiOiJhc3Npc3RhbnQifWf51EkAAAEMAAAAV56BJZoLOmV2ZW50LXR5cGUHABFjb250ZW50QmxvY2tTdGFydA06Y29udGVudC10eXBlBwAQYXBwbGljYXRpb24vanNvbg06bWVzc2FnZS10eXBlBwAFZXZlbnR7ImNvbnRlbnRCbG9ja0luZGV4IjowLCJwIjoiYWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXpBQkNERUZHSElKS0xNTk9QUVJTVFUiLCJzdGFydCI6eyJ0b29sVXNlIjp7Im5hbWUiOiJnZXRfd2VhdGhlciIsInRvb2xVc2VJZCI6InRvb2x1c2VfNmExcFB2bmM5OUdMS08zS0drVUEyTiJ9fX2LR7PFAAAA4gAAAFfCOY+BCzpldmVudC10eXBlBwARY29udGVudEJsb2NrRGVsdGENOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJjb250ZW50QmxvY2tJbmRleCI6MCwiZGVsdGEiOnsidG9vbFVzZSI6eyJpbnB1dCI6IntcImNpdHlcIjpcIlBhcmlzXCJ9In19LCJwIjoiYWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXpBQkNERUZHSElKS0xNTiJ9RkW+2gAAAIcAAABW5OxHKgs6ZXZlbnQtdHlwZQcAEGNvbnRlbnRCbG9ja1N0b3ANOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJjb250ZW50QmxvY2tJbmRleCI6MCwicCI6ImFiYyJ9y6nrtwAAAK4AAABRtlmf/As6ZXZlbnQtdHlwZQcAC21lc3NhZ2VTdG9wDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6QUJDREVGR0hJSktMTU5PUFFSUyIsInN0b3BSZWFzb24iOiJ0b29sX3VzZSJ9MTlQawAAAOIAAABOplInQQs6ZXZlbnQtdHlwZQcACG1ldGFkYXRhDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsibWV0cmljcyI6eyJsYXRlbmN5TXMiOjM1NX0sInAiOiJhYmNkZWZnaGlqayIsInVzYWdlIjp7ImlucHV0VG9rZW5zIjo0MTksIm91dHB1dFRva2VucyI6MTYsInNlcnZlclRvb2xVc2FnZSI6e30sInRvdGFsVG9rZW5zIjo0MzV9fU1tVJc=", + "bodyEncoding": "base64" + } + } + ] +} diff --git a/packages/llm/test/fixtures/recordings/bedrock-converse/streams-text.json b/packages/llm/test/fixtures/recordings/bedrock-converse/streams-text.json new file mode 100644 index 0000000000..7eaacec02b --- /dev/null +++ b/packages/llm/test/fixtures/recordings/bedrock-converse/streams-text.json @@ -0,0 +1,29 @@ +{ + "version": 1, + "metadata": { + "name": "bedrock-converse/streams-text", + "recordedAt": "2026-04-28T21:18:46.553Z", + "tags": ["prefix:bedrock-converse", "provider:amazon-bedrock", "protocol:bedrock-converse"] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://bedrock-runtime.us-east-1.amazonaws.com/model/us.amazon.nova-micro-v1%3A0/converse-stream", + "headers": { + "content-type": "application/json" + }, + "body": "{\"modelId\":\"us.amazon.nova-micro-v1:0\",\"messages\":[{\"role\":\"user\",\"content\":[{\"text\":\"Say hello.\"}]}],\"system\":[{\"text\":\"Reply with the single word 'Hello'.\"}],\"inferenceConfig\":{\"maxTokens\":16,\"temperature\":0}}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "application/vnd.amazon.eventstream" + }, + "body": "AAAAmQAAAFI8UarQCzpldmVudC10eXBlBwAMbWVzc2FnZVN0YXJ0DTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6QUIiLCJyb2xlIjoiYXNzaXN0YW50In3SL1jNAAAAvQAAAFd4etebCzpldmVudC10eXBlBwARY29udGVudEJsb2NrRGVsdGENOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJjb250ZW50QmxvY2tJbmRleCI6MCwiZGVsdGEiOnsidGV4dCI6IkhlbGxvIn0sInAiOiJhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ekFCQ0RFIn2B0NR6AAAAxgAAAFf2eAZFCzpldmVudC10eXBlBwARY29udGVudEJsb2NrRGVsdGENOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJjb250ZW50QmxvY2tJbmRleCI6MCwiZGVsdGEiOnsidGV4dCI6IiJ9LCJwIjoiYWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXpBQkNERUZHSElKS0xNTk9QUVJTIn3XaHMvAAAAhwAAAFbk7EcqCzpldmVudC10eXBlBwAQY29udGVudEJsb2NrU3RvcA06Y29udGVudC10eXBlBwAQYXBwbGljYXRpb24vanNvbg06bWVzc2FnZS10eXBlBwAFZXZlbnR7ImNvbnRlbnRCbG9ja0luZGV4IjowLCJwIjoiYWJjIn3Lqeu3AAAAjwAAAFFK+JlICzpldmVudC10eXBlBwALbWVzc2FnZVN0b3ANOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJwIjoiYWJjZGVmZ2hpamtsbW4iLCJzdG9wUmVhc29uIjoiZW5kX3R1cm4ifZ+RQqEAAAECAAAATkXaMzsLOmV2ZW50LXR5cGUHAAhtZXRhZGF0YQ06Y29udGVudC10eXBlBwAQYXBwbGljYXRpb24vanNvbg06bWVzc2FnZS10eXBlBwAFZXZlbnR7Im1ldHJpY3MiOnsibGF0ZW5jeU1zIjozMDZ9LCJwIjoiYWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXpBQkNERUZHSElKS0xNTk9QUVJTVCIsInVzYWdlIjp7ImlucHV0VG9rZW5zIjoxMiwib3V0cHV0VG9rZW5zIjoyLCJzZXJ2ZXJUb29sVXNhZ2UiOnt9LCJ0b3RhbFRva2VucyI6MTR9fSnnkUk=", + "bodyEncoding": "base64" + } + } + ] +} diff --git a/packages/llm/test/fixtures/recordings/cloudflare-ai-gateway/cloudflare-ai-gateway-workers-ai-gpt-oss-20b-tools-tool-call.json b/packages/llm/test/fixtures/recordings/cloudflare-ai-gateway/cloudflare-ai-gateway-workers-ai-gpt-oss-20b-tools-tool-call.json new file mode 100644 index 0000000000..981c14f03e --- /dev/null +++ b/packages/llm/test/fixtures/recordings/cloudflare-ai-gateway/cloudflare-ai-gateway-workers-ai-gpt-oss-20b-tools-tool-call.json @@ -0,0 +1,32 @@ +{ + "version": 1, + "metadata": { + "name": "cloudflare-ai-gateway/cloudflare-ai-gateway-workers-ai-gpt-oss-20b-tools-tool-call", + "recordedAt": "2026-05-08T17:20:08.287Z", + "provider": "cloudflare-ai-gateway", + "route": "cloudflare-ai-gateway", + "transport": "http", + "model": "workers-ai/@cf/openai/gpt-oss-20b", + "tags": ["prefix:cloudflare-ai-gateway", "provider:cloudflare-ai-gateway", "tool", "tool-call", "golden"] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://gateway.ai.cloudflare.com/v1/{account}/{gateway}/compat/chat/completions", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"workers-ai/@cf/openai/gpt-oss-20b\",\"messages\":[{\"role\":\"system\",\"content\":\"Call tools exactly as requested.\"},{\"role\":\"user\",\"content\":\"Call get_weather with city exactly Paris.\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}}],\"tool_choice\":{\"type\":\"function\",\"function\":{\"name\":\"get_weather\"}},\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":120,\"temperature\":0}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream" + }, + "body": "data: {\"id\":\"id-1778260808196\",\"created\":1778260808,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"id-1778260808196\",\"created\":1778260808,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\"\"},\"logprobs\":null,\"finish_reason\":null,\"token_ids\":null}]}\n\ndata: {\"id\":\"id-1778260808196\",\"created\":1778260808,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\"We\"},\"logprobs\":null,\"finish_reason\":null,\"token_ids\":null}]}\n\ndata: {\"id\":\"id-1778260808196\",\"created\":1778260808,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" need\"},\"logprobs\":null,\"finish_reason\":null,\"token_ids\":null}]}\n\ndata: {\"id\":\"id-1778260808196\",\"created\":1778260808,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" to\"},\"logprobs\":null,\"finish_reason\":null,\"token_ids\":null}]}\n\ndata: {\"id\":\"id-1778260808196\",\"created\":1778260808,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" call\"},\"logprobs\":null,\"finish_reason\":null,\"token_ids\":null}]}\n\ndata: {\"id\":\"id-1778260808196\",\"created\":1778260808,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" the\"},\"logprobs\":null,\"finish_reason\":null,\"token_ids\":null}]}\n\ndata: {\"id\":\"id-1778260808196\",\"created\":1778260808,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" function\"},\"logprobs\":null,\"finish_reason\":null,\"token_ids\":null}]}\n\ndata: {\"id\":\"id-1778260808196\",\"created\":1778260808,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" get\"},\"logprobs\":null,\"finish_reason\":null,\"token_ids\":null}]}\n\ndata: {\"id\":\"id-1778260808196\",\"created\":1778260808,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\"_weather\"},\"logprobs\":null,\"finish_reason\":null,\"token_ids\":null}]}\n\ndata: {\"id\":\"id-1778260808196\",\"created\":1778260808,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" with\"},\"logprobs\":null,\"finish_reason\":null,\"token_ids\":null}]}\n\ndata: {\"id\":\"id-1778260808196\",\"created\":1778260808,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" city\"},\"logprobs\":null,\"finish_reason\":null,\"token_ids\":null}]}\n\ndata: {\"id\":\"id-1778260808196\",\"created\":1778260808,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" \\\"\"},\"logprobs\":null,\"finish_reason\":null,\"token_ids\":null}]}\n\ndata: {\"id\":\"id-1778260808196\",\"created\":1778260808,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\"Paris\"},\"logprobs\":null,\"finish_reason\":null,\"token_ids\":null}]}\n\ndata: {\"id\":\"id-1778260808196\",\"created\":1778260808,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\"\\\".\"},\"logprobs\":null,\"finish_reason\":null,\"token_ids\":null}]}\n\ndata: {\"id\":\"id-1778260808196\",\"created\":1778260808,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"id\":\"chatcmpl-tool-b975da5af1f843e095ba7062d8e108ba\",\"type\":\"function\",\"index\":0,\"function\":{\"name\":\"get_weather\",\"arguments\":\"\"}}]},\"logprobs\":null,\"finish_reason\":null,\"token_ids\":null}]}\n\ndata: {\"id\":\"id-1778260808196\",\"created\":1778260808,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"{\\\"\"}}]},\"logprobs\":null,\"finish_reason\":null,\"token_ids\":null}]}\n\ndata: {\"id\":\"id-1778260808196\",\"created\":1778260808,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"city\"}}]},\"logprobs\":null,\"finish_reason\":null,\"token_ids\":null}]}\n\ndata: {\"id\":\"id-1778260808196\",\"created\":1778260808,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"\\\":\\\"\"}}]},\"logprobs\":null,\"finish_reason\":null,\"token_ids\":null}]}\n\ndata: {\"id\":\"id-1778260808196\",\"created\":1778260808,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"Paris\"}}]},\"logprobs\":null,\"finish_reason\":null,\"token_ids\":null}]}\n\ndata: {\"id\":\"id-1778260808196\",\"created\":1778260808,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"\\\"}\"}}]},\"logprobs\":null,\"finish_reason\":null,\"token_ids\":null}]}\n\ndata: {\"id\":\"id-1778260808196\",\"created\":1778260808,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{},\"logprobs\":null,\"finish_reason\":\"tool_calls\",\"stop_reason\":200012,\"token_ids\":null}]}\n\ndata: {\"id\":\"id-1778260808196\",\"object\":\"chat.completion.chunk\",\"created\":1778260808,\"model\":\"@cf/openai/gpt-oss-20b\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"tool_calls\"}],\"usage\":{\"prompt_tokens\":136,\"total_tokens\":173,\"completion_tokens\":37}}\n\ndata: {\"id\":\"id-1778260808196\",\"object\":\"chat.completion.chunk\",\"created\":1778260808,\"model\":\"@cf/openai/gpt-oss-20b\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"tool_calls\"}],\"usage\":{\"prompt_tokens\":136,\"completion_tokens\":37,\"total_tokens\":173,\"prompt_tokens_details\":{\"cached_tokens\":0}}}\n\ndata: [DONE]\n\n" + } + } + ] +} diff --git a/packages/llm/test/fixtures/recordings/cloudflare-ai-gateway/cloudflare-ai-gateway-workers-ai-llama-3-1-8b-text.json b/packages/llm/test/fixtures/recordings/cloudflare-ai-gateway/cloudflare-ai-gateway-workers-ai-llama-3-1-8b-text.json new file mode 100644 index 0000000000..6a8eff09d9 --- /dev/null +++ b/packages/llm/test/fixtures/recordings/cloudflare-ai-gateway/cloudflare-ai-gateway-workers-ai-llama-3-1-8b-text.json @@ -0,0 +1,32 @@ +{ + "version": 1, + "metadata": { + "name": "cloudflare-ai-gateway/cloudflare-ai-gateway-workers-ai-llama-3-1-8b-text", + "recordedAt": "2026-05-08T15:55:48.952Z", + "provider": "cloudflare-ai-gateway", + "route": "cloudflare-ai-gateway", + "transport": "http", + "model": "workers-ai/@cf/meta/llama-3.1-8b-instruct", + "tags": ["prefix:cloudflare-ai-gateway", "provider:cloudflare-ai-gateway", "text", "golden"] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://gateway.ai.cloudflare.com/v1/{account}/{gateway}/compat/chat/completions", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"workers-ai/@cf/meta/llama-3.1-8b-instruct\",\"messages\":[{\"role\":\"system\",\"content\":\"You are concise.\"},{\"role\":\"user\",\"content\":\"Reply exactly with: Hello!\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":40,\"temperature\":0}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream" + }, + "body": "data: {\"id\":\"id-1778255748911\",\"created\":1778255748,\"model\":\"@cf/meta/llama-3.1-8b-instruct\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Hello\"}}]}\n\ndata: {\"id\":\"id-1778255748911\",\"created\":1778255748,\"model\":\"@cf/meta/llama-3.1-8b-instruct\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"!\"}}]}\n\ndata: {\"id\":\"id-1778255748911\",\"object\":\"chat.completion.chunk\",\"created\":1778255748,\"model\":\"@cf/meta/llama-3.1-8b-instruct\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":45,\"completion_tokens\":2,\"total_tokens\":47}}\n\ndata: {\"id\":\"id-1778255748911\",\"object\":\"chat.completion.chunk\",\"created\":1778255748,\"model\":\"@cf/meta/llama-3.1-8b-instruct\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":0,\"completion_tokens\":0,\"total_tokens\":0,\"prompt_tokens_details\":{\"cached_tokens\":0}}}\n\ndata: [DONE]\n\n" + } + } + ] +} diff --git a/packages/llm/test/fixtures/recordings/cloudflare-workers-ai/cloudflare-workers-ai-gpt-oss-20b-tools-tool-call.json b/packages/llm/test/fixtures/recordings/cloudflare-workers-ai/cloudflare-workers-ai-gpt-oss-20b-tools-tool-call.json new file mode 100644 index 0000000000..fa22f1ddb9 --- /dev/null +++ b/packages/llm/test/fixtures/recordings/cloudflare-workers-ai/cloudflare-workers-ai-gpt-oss-20b-tools-tool-call.json @@ -0,0 +1,32 @@ +{ + "version": 1, + "metadata": { + "name": "cloudflare-workers-ai/cloudflare-workers-ai-gpt-oss-20b-tools-tool-call", + "recordedAt": "2026-05-08T17:20:14.106Z", + "provider": "cloudflare-workers-ai", + "route": "cloudflare-workers-ai", + "transport": "http", + "model": "@cf/openai/gpt-oss-20b", + "tags": ["prefix:cloudflare-workers-ai", "provider:cloudflare-workers-ai", "tool", "tool-call", "golden"] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.cloudflare.com/client/v4/accounts/{account}/ai/v1/chat/completions", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"@cf/openai/gpt-oss-20b\",\"messages\":[{\"role\":\"system\",\"content\":\"Call tools exactly as requested.\"},{\"role\":\"user\",\"content\":\"Call get_weather with city exactly Paris.\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}}],\"tool_choice\":{\"type\":\"function\",\"function\":{\"name\":\"get_weather\"}},\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":120,\"temperature\":0}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream" + }, + "body": "data: {\"id\":\"id-1778260814069\",\"created\":1778260814,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"id-1778260814069\",\"created\":1778260814,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\"\"},\"logprobs\":null,\"finish_reason\":null,\"token_ids\":null}]}\n\ndata: {\"id\":\"id-1778260814069\",\"created\":1778260814,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\"We\"},\"logprobs\":null,\"finish_reason\":null,\"token_ids\":null}]}\n\ndata: {\"id\":\"id-1778260814069\",\"created\":1778260814,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" need\"},\"logprobs\":null,\"finish_reason\":null,\"token_ids\":null}]}\n\ndata: {\"id\":\"id-1778260814069\",\"created\":1778260814,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" to\"},\"logprobs\":null,\"finish_reason\":null,\"token_ids\":null}]}\n\ndata: {\"id\":\"id-1778260814069\",\"created\":1778260814,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" call\"},\"logprobs\":null,\"finish_reason\":null,\"token_ids\":null}]}\n\ndata: {\"id\":\"id-1778260814069\",\"created\":1778260814,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" the\"},\"logprobs\":null,\"finish_reason\":null,\"token_ids\":null}]}\n\ndata: {\"id\":\"id-1778260814069\",\"created\":1778260814,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" function\"},\"logprobs\":null,\"finish_reason\":null,\"token_ids\":null}]}\n\ndata: {\"id\":\"id-1778260814069\",\"created\":1778260814,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" get\"},\"logprobs\":null,\"finish_reason\":null,\"token_ids\":null}]}\n\ndata: {\"id\":\"id-1778260814069\",\"created\":1778260814,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\"_weather\"},\"logprobs\":null,\"finish_reason\":null,\"token_ids\":null}]}\n\ndata: {\"id\":\"id-1778260814069\",\"created\":1778260814,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" with\"},\"logprobs\":null,\"finish_reason\":null,\"token_ids\":null}]}\n\ndata: {\"id\":\"id-1778260814069\",\"created\":1778260814,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" city\"},\"logprobs\":null,\"finish_reason\":null,\"token_ids\":null}]}\n\ndata: {\"id\":\"id-1778260814069\",\"created\":1778260814,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" \\\"\"},\"logprobs\":null,\"finish_reason\":null,\"token_ids\":null}]}\n\ndata: {\"id\":\"id-1778260814069\",\"created\":1778260814,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\"Paris\"},\"logprobs\":null,\"finish_reason\":null,\"token_ids\":null}]}\n\ndata: {\"id\":\"id-1778260814069\",\"created\":1778260814,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\"\\\".\"},\"logprobs\":null,\"finish_reason\":null,\"token_ids\":null}]}\n\ndata: {\"id\":\"id-1778260814069\",\"created\":1778260814,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"id\":\"chatcmpl-tool-ed7127682c90443da222d0f8c607b5d5\",\"type\":\"function\",\"index\":0,\"function\":{\"name\":\"get_weather\",\"arguments\":\"\"}}]},\"logprobs\":null,\"finish_reason\":null,\"token_ids\":null}]}\n\ndata: {\"id\":\"id-1778260814069\",\"created\":1778260814,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"{\\\"\"}}]},\"logprobs\":null,\"finish_reason\":null,\"token_ids\":null}]}\n\ndata: {\"id\":\"id-1778260814069\",\"created\":1778260814,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"city\"}}]},\"logprobs\":null,\"finish_reason\":null,\"token_ids\":null}]}\n\ndata: {\"id\":\"id-1778260814069\",\"created\":1778260814,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"\\\":\\\"\"}}]},\"logprobs\":null,\"finish_reason\":null,\"token_ids\":null}]}\n\ndata: {\"id\":\"id-1778260814069\",\"created\":1778260814,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"Paris\"}}]},\"logprobs\":null,\"finish_reason\":null,\"token_ids\":null}]}\n\ndata: {\"id\":\"id-1778260814069\",\"created\":1778260814,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"\\\"}\"}}]},\"logprobs\":null,\"finish_reason\":null,\"token_ids\":null}]}\n\ndata: {\"id\":\"id-1778260814069\",\"created\":1778260814,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{},\"logprobs\":null,\"finish_reason\":null,\"stop_reason\":200012,\"token_ids\":null}]}\n\ndata: {\"id\":\"id-1778260814069\",\"object\":\"chat.completion.chunk\",\"created\":1778260814,\"model\":\"@cf/openai/gpt-oss-20b\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"tool_calls\"}],\"usage\":{\"prompt_tokens\":136,\"total_tokens\":173,\"completion_tokens\":37}}\n\ndata: {\"id\":\"id-1778260814069\",\"object\":\"chat.completion.chunk\",\"created\":1778260814,\"model\":\"@cf/openai/gpt-oss-20b\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"tool_calls\"}],\"usage\":{\"prompt_tokens\":136,\"completion_tokens\":37,\"total_tokens\":173,\"prompt_tokens_details\":{\"cached_tokens\":0}}}\n\ndata: [DONE]\n\n" + } + } + ] +} diff --git a/packages/llm/test/fixtures/recordings/cloudflare-workers-ai/cloudflare-workers-ai-llama-3-1-8b-text.json b/packages/llm/test/fixtures/recordings/cloudflare-workers-ai/cloudflare-workers-ai-llama-3-1-8b-text.json new file mode 100644 index 0000000000..52cc25f86b --- /dev/null +++ b/packages/llm/test/fixtures/recordings/cloudflare-workers-ai/cloudflare-workers-ai-llama-3-1-8b-text.json @@ -0,0 +1,32 @@ +{ + "version": 1, + "metadata": { + "name": "cloudflare-workers-ai/cloudflare-workers-ai-llama-3-1-8b-text", + "recordedAt": "2026-05-08T15:56:18.284Z", + "provider": "cloudflare-workers-ai", + "route": "cloudflare-workers-ai", + "transport": "http", + "model": "@cf/meta/llama-3.1-8b-instruct", + "tags": ["prefix:cloudflare-workers-ai", "provider:cloudflare-workers-ai", "text", "golden"] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.cloudflare.com/client/v4/accounts/{account}/ai/v1/chat/completions", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"@cf/meta/llama-3.1-8b-instruct\",\"messages\":[{\"role\":\"system\",\"content\":\"You are concise.\"},{\"role\":\"user\",\"content\":\"Reply exactly with: Hello!\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":40,\"temperature\":0}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream" + }, + "body": "data: {\"id\":\"id-1778255778230\",\"created\":1778255778,\"model\":\"@cf/meta/llama-3.1-8b-instruct\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Hello\"}}]}\n\ndata: {\"id\":\"id-1778255778230\",\"created\":1778255778,\"model\":\"@cf/meta/llama-3.1-8b-instruct\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"!\"}}]}\n\ndata: {\"id\":\"id-1778255778230\",\"object\":\"chat.completion.chunk\",\"created\":1778255778,\"model\":\"@cf/meta/llama-3.1-8b-instruct\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":45,\"completion_tokens\":2,\"total_tokens\":47}}\n\ndata: {\"id\":\"id-1778255778230\",\"object\":\"chat.completion.chunk\",\"created\":1778255778,\"model\":\"@cf/meta/llama-3.1-8b-instruct\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":0,\"completion_tokens\":0,\"total_tokens\":0,\"prompt_tokens_details\":{\"cached_tokens\":0}}}\n\ndata: [DONE]\n\n" + } + } + ] +} diff --git a/packages/llm/test/fixtures/recordings/gemini-cache/reports-cachedcontenttokencount-on-identical-second-call.json b/packages/llm/test/fixtures/recordings/gemini-cache/reports-cachedcontenttokencount-on-identical-second-call.json new file mode 100644 index 0000000000..0145756887 --- /dev/null +++ b/packages/llm/test/fixtures/recordings/gemini-cache/reports-cachedcontenttokencount-on-identical-second-call.json @@ -0,0 +1,46 @@ +{ + "version": 1, + "metadata": { + "name": "gemini-cache/reports-cachedcontenttokencount-on-identical-second-call", + "recordedAt": "2026-05-11T01:55:40.600Z", + "tags": ["prefix:gemini-cache", "provider:google", "protocol:gemini", "cache"] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse", + "headers": { + "content-type": "application/json" + }, + "body": "{\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"Say hi.\"}]}],\"systemInstruction\":{\"parts\":[{\"text\":\"You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. \"}]},\"generationConfig\":{\"maxOutputTokens\":16,\"temperature\":0}}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream" + }, + "body": "" + } + }, + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse", + "headers": { + "content-type": "application/json" + }, + "body": "{\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"Say hi.\"}]}],\"systemInstruction\":{\"parts\":[{\"text\":\"You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. \"}]},\"generationConfig\":{\"maxOutputTokens\":16,\"temperature\":0}}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream" + }, + "body": "" + } + } + ] +} diff --git a/packages/llm/test/fixtures/recordings/gemini/streams-text.json b/packages/llm/test/fixtures/recordings/gemini/streams-text.json new file mode 100644 index 0000000000..7f0e6b390e --- /dev/null +++ b/packages/llm/test/fixtures/recordings/gemini/streams-text.json @@ -0,0 +1,28 @@ +{ + "version": 1, + "metadata": { + "name": "gemini/streams-text", + "recordedAt": "2026-04-28T21:18:47.483Z", + "tags": ["prefix:gemini", "provider:google", "protocol:gemini"] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse", + "headers": { + "content-type": "application/json" + }, + "body": "{\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"Reply with exactly: Hello!\"}]}],\"systemInstruction\":{\"parts\":[{\"text\":\"You are concise.\"}]},\"generationConfig\":{\"maxOutputTokens\":80,\"temperature\":0}}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream" + }, + "body": "data: {\"candidates\": [{\"content\": {\"parts\": [{\"text\": \"Hello!\"}],\"role\": \"model\"},\"finishReason\": \"STOP\",\"index\": 0}],\"usageMetadata\": {\"promptTokenCount\": 11,\"candidatesTokenCount\": 2,\"totalTokenCount\": 29,\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 11}],\"thoughtsTokenCount\": 16},\"modelVersion\": \"gemini-2.5-flash\",\"responseId\": \"NyTxaczMAZ-b_uMP6u--iQg\"}\r\n\r\n" + } + } + ] +} diff --git a/packages/llm/test/fixtures/recordings/gemini/streams-tool-call.json b/packages/llm/test/fixtures/recordings/gemini/streams-tool-call.json new file mode 100644 index 0000000000..a526910f0d --- /dev/null +++ b/packages/llm/test/fixtures/recordings/gemini/streams-tool-call.json @@ -0,0 +1,28 @@ +{ + "version": 1, + "metadata": { + "name": "gemini/streams-tool-call", + "recordedAt": "2026-04-28T21:18:48.285Z", + "tags": ["prefix:gemini", "provider:google", "protocol:gemini", "tool"] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse", + "headers": { + "content-type": "application/json" + }, + "body": "{\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"Call get_weather with city exactly Paris.\"}]}],\"systemInstruction\":{\"parts\":[{\"text\":\"Call tools exactly as requested.\"}]},\"tools\":[{\"functionDeclarations\":[{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"required\":[\"city\"],\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}}}}]}],\"toolConfig\":{\"functionCallingConfig\":{\"mode\":\"ANY\",\"allowedFunctionNames\":[\"get_weather\"]}},\"generationConfig\":{\"maxOutputTokens\":80,\"temperature\":0}}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream" + }, + "body": "data: {\"candidates\": [{\"content\": {\"parts\": [{\"functionCall\": {\"name\": \"get_weather\",\"args\": {\"city\": \"Paris\"}},\"thoughtSignature\": \"CiQBDDnWx5RcSsS1UMbykQ5HWlrMu6wrxXGUhmZ0uRKLaMhDZaEKXwEMOdbHVoJAlfbOQyKB378pDZ/gkjWr3HP+dWw1us1kMG22g4G3oJvuTq/SrWS+7KYtSlvOxCKhW2l/2/TczpyGyGmANmsusDcxF1SKOYA5/8Hg0nI24MAlT3+91V/MCoUBAQw51seClFLy3E71v2H44F1kpmjgz8FeTRZofrjbaazfrT+w8Yxgdr3UgGagLMY4OadZemQTWckq9IAqRum78hrBg6NGtQvn15SbtfTNqI4PcxX/+qPo4/g4/ZT5kVORDhVqO8BVP/RA5GQ3ce3sRK8hSkvQlXSoXIPpHh6x7hBezIGXzw==\"}],\"role\": \"model\"},\"finishReason\": \"STOP\",\"index\": 0,\"finishMessage\": \"Model generated function call(s).\"}],\"usageMetadata\": {\"promptTokenCount\": 55,\"candidatesTokenCount\": 15,\"totalTokenCount\": 115,\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 55}],\"thoughtsTokenCount\": 45},\"modelVersion\": \"gemini-2.5-flash\",\"responseId\": \"NyTxaYuTJ_OW_uMPgIPKgAg\"}\r\n\r\n" + } + } + ] +} diff --git a/packages/llm/test/fixtures/recordings/openai-chat/continues-after-tool-result.json b/packages/llm/test/fixtures/recordings/openai-chat/continues-after-tool-result.json new file mode 100644 index 0000000000..7c02a93f0b --- /dev/null +++ b/packages/llm/test/fixtures/recordings/openai-chat/continues-after-tool-result.json @@ -0,0 +1,28 @@ +{ + "version": 1, + "metadata": { + "name": "openai-chat/continues-after-tool-result", + "recordedAt": "2026-05-06T01:33:31.878Z", + "tags": ["prefix:openai-chat", "provider:openai", "protocol:openai-chat", "tool"] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.openai.com/v1/chat/completions", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"gpt-4o-mini\",\"messages\":[{\"role\":\"system\",\"content\":\"Answer using only the provided tool result.\"},{\"role\":\"user\",\"content\":\"What is the weather in Paris?\"},{\"role\":\"assistant\",\"content\":null,\"tool_calls\":[{\"id\":\"call_weather\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"}}]},{\"role\":\"tool\",\"tool_call_id\":\"call_weather\",\"content\":\"{\\\"forecast\\\":\\\"sunny\\\",\\\"temperature_c\\\":22}\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":40,\"temperature\":0}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream; charset=utf-8" + }, + "body": "data: {\"id\":\"chatcmpl-DcLQhErGVsn8x3hNFmX5A0yM0T9Km\",\"object\":\"chat.completion.chunk\",\"created\":1778031211,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_57133166c6\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\",\"refusal\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"gJ6VDZ2ZE\"}\n\ndata: {\"id\":\"chatcmpl-DcLQhErGVsn8x3hNFmX5A0yM0T9Km\",\"object\":\"chat.completion.chunk\",\"created\":1778031211,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_57133166c6\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"The\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"B2pU6Neg\"}\n\ndata: {\"id\":\"chatcmpl-DcLQhErGVsn8x3hNFmX5A0yM0T9Km\",\"object\":\"chat.completion.chunk\",\"created\":1778031211,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_57133166c6\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" weather\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"sa2\"}\n\ndata: {\"id\":\"chatcmpl-DcLQhErGVsn8x3hNFmX5A0yM0T9Km\",\"object\":\"chat.completion.chunk\",\"created\":1778031211,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_57133166c6\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" in\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"ENFjAfta\"}\n\ndata: {\"id\":\"chatcmpl-DcLQhErGVsn8x3hNFmX5A0yM0T9Km\",\"object\":\"chat.completion.chunk\",\"created\":1778031211,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_57133166c6\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" Paris\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"E1Kbi\"}\n\ndata: {\"id\":\"chatcmpl-DcLQhErGVsn8x3hNFmX5A0yM0T9Km\",\"object\":\"chat.completion.chunk\",\"created\":1778031211,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_57133166c6\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" is\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"NWj8HasA\"}\n\ndata: {\"id\":\"chatcmpl-DcLQhErGVsn8x3hNFmX5A0yM0T9Km\",\"object\":\"chat.completion.chunk\",\"created\":1778031211,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_57133166c6\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" sunny\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"irmMg\"}\n\ndata: {\"id\":\"chatcmpl-DcLQhErGVsn8x3hNFmX5A0yM0T9Km\",\"object\":\"chat.completion.chunk\",\"created\":1778031211,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_57133166c6\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" with\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"3eCMq6\"}\n\ndata: {\"id\":\"chatcmpl-DcLQhErGVsn8x3hNFmX5A0yM0T9Km\",\"object\":\"chat.completion.chunk\",\"created\":1778031211,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_57133166c6\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" a\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"XKMqPUsnt\"}\n\ndata: {\"id\":\"chatcmpl-DcLQhErGVsn8x3hNFmX5A0yM0T9Km\",\"object\":\"chat.completion.chunk\",\"created\":1778031211,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_57133166c6\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" temperature\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"BFVrBA09z9Y3lAC\"}\n\ndata: {\"id\":\"chatcmpl-DcLQhErGVsn8x3hNFmX5A0yM0T9Km\",\"object\":\"chat.completion.chunk\",\"created\":1778031211,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_57133166c6\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" of\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"AwG4puOX\"}\n\ndata: {\"id\":\"chatcmpl-DcLQhErGVsn8x3hNFmX5A0yM0T9Km\",\"object\":\"chat.completion.chunk\",\"created\":1778031211,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_57133166c6\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" \"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"pKQU39KXN6\"}\n\ndata: {\"id\":\"chatcmpl-DcLQhErGVsn8x3hNFmX5A0yM0T9Km\",\"object\":\"chat.completion.chunk\",\"created\":1778031211,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_57133166c6\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"22\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"xeTNA1JuE\"}\n\ndata: {\"id\":\"chatcmpl-DcLQhErGVsn8x3hNFmX5A0yM0T9Km\",\"object\":\"chat.completion.chunk\",\"created\":1778031211,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_57133166c6\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"°C\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"kNilBK4Nm\"}\n\ndata: {\"id\":\"chatcmpl-DcLQhErGVsn8x3hNFmX5A0yM0T9Km\",\"object\":\"chat.completion.chunk\",\"created\":1778031211,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_57133166c6\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\".\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"BrXQlZOd1Q\"}\n\ndata: {\"id\":\"chatcmpl-DcLQhErGVsn8x3hNFmX5A0yM0T9Km\",\"object\":\"chat.completion.chunk\",\"created\":1778031211,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_57133166c6\",\"choices\":[{\"index\":0,\"delta\":{},\"logprobs\":null,\"finish_reason\":\"stop\"}],\"usage\":null,\"obfuscation\":\"lzLXy\"}\n\ndata: {\"id\":\"chatcmpl-DcLQhErGVsn8x3hNFmX5A0yM0T9Km\",\"object\":\"chat.completion.chunk\",\"created\":1778031211,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_57133166c6\",\"choices\":[],\"usage\":{\"prompt_tokens\":59,\"completion_tokens\":14,\"total_tokens\":73,\"prompt_tokens_details\":{\"cached_tokens\":0,\"audio_tokens\":0},\"completion_tokens_details\":{\"reasoning_tokens\":0,\"audio_tokens\":0,\"accepted_prediction_tokens\":0,\"rejected_prediction_tokens\":0}},\"obfuscation\":\"5z1JJjgtey\"}\n\ndata: [DONE]\n\n" + } + } + ] +} diff --git a/packages/llm/test/fixtures/recordings/openai-chat/drives-a-tool-loop-end-to-end.json b/packages/llm/test/fixtures/recordings/openai-chat/drives-a-tool-loop-end-to-end.json new file mode 100644 index 0000000000..fdc5fa7916 --- /dev/null +++ b/packages/llm/test/fixtures/recordings/openai-chat/drives-a-tool-loop-end-to-end.json @@ -0,0 +1,46 @@ +{ + "version": 1, + "metadata": { + "name": "openai-chat/drives-a-tool-loop-end-to-end", + "recordedAt": "2026-05-06T01:33:29.747Z", + "tags": ["prefix:openai-chat", "provider:openai", "protocol:openai-chat", "tool", "tool-loop"] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.openai.com/v1/chat/completions", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"gpt-4o-mini\",\"messages\":[{\"role\":\"system\",\"content\":\"Use the get_weather tool, then answer in one short sentence.\"},{\"role\":\"user\",\"content\":\"What is the weather in Paris?\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":80,\"temperature\":0}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream; charset=utf-8" + }, + "body": "data: {\"id\":\"chatcmpl-DcLQeieQn9xQe2QqsLPi7rN15bnJF\",\"object\":\"chat.completion.chunk\",\"created\":1778031208,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_99cf176092\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":null,\"tool_calls\":[{\"index\":0,\"id\":\"call_tyZNHs2AudCbG4XJUEmX5Waw\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"\"}}],\"refusal\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"ayQl\"}\n\ndata: {\"id\":\"chatcmpl-DcLQeieQn9xQe2QqsLPi7rN15bnJF\",\"object\":\"chat.completion.chunk\",\"created\":1778031208,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_99cf176092\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"{\\\"\"}}]},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"TWZNUL5mYYtjWu\"}\n\ndata: {\"id\":\"chatcmpl-DcLQeieQn9xQe2QqsLPi7rN15bnJF\",\"object\":\"chat.completion.chunk\",\"created\":1778031208,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_99cf176092\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"city\"}}]},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"QidSCtgZRvDHL\"}\n\ndata: {\"id\":\"chatcmpl-DcLQeieQn9xQe2QqsLPi7rN15bnJF\",\"object\":\"chat.completion.chunk\",\"created\":1778031208,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_99cf176092\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"\\\":\\\"\"}}]},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"nupQO1L4GdWo\"}\n\ndata: {\"id\":\"chatcmpl-DcLQeieQn9xQe2QqsLPi7rN15bnJF\",\"object\":\"chat.completion.chunk\",\"created\":1778031208,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_99cf176092\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"Paris\"}}]},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"3W5B3hzGrFvl\"}\n\ndata: {\"id\":\"chatcmpl-DcLQeieQn9xQe2QqsLPi7rN15bnJF\",\"object\":\"chat.completion.chunk\",\"created\":1778031208,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_99cf176092\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"\\\"}\"}}]},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"JgscYuZR4Lmp5S\"}\n\ndata: {\"id\":\"chatcmpl-DcLQeieQn9xQe2QqsLPi7rN15bnJF\",\"object\":\"chat.completion.chunk\",\"created\":1778031208,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_99cf176092\",\"choices\":[{\"index\":0,\"delta\":{},\"logprobs\":null,\"finish_reason\":\"tool_calls\"}],\"usage\":null,\"obfuscation\":\"BtZF5TaQjX3UwLN\"}\n\ndata: {\"id\":\"chatcmpl-DcLQeieQn9xQe2QqsLPi7rN15bnJF\",\"object\":\"chat.completion.chunk\",\"created\":1778031208,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_99cf176092\",\"choices\":[],\"usage\":{\"prompt_tokens\":64,\"completion_tokens\":14,\"total_tokens\":78,\"prompt_tokens_details\":{\"cached_tokens\":0,\"audio_tokens\":0},\"completion_tokens_details\":{\"reasoning_tokens\":0,\"audio_tokens\":0,\"accepted_prediction_tokens\":0,\"rejected_prediction_tokens\":0}},\"obfuscation\":\"bZ51l7ptxM\"}\n\ndata: [DONE]\n\n" + } + }, + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.openai.com/v1/chat/completions", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"gpt-4o-mini\",\"messages\":[{\"role\":\"system\",\"content\":\"Use the get_weather tool, then answer in one short sentence.\"},{\"role\":\"user\",\"content\":\"What is the weather in Paris?\"},{\"role\":\"assistant\",\"content\":null,\"tool_calls\":[{\"id\":\"call_tyZNHs2AudCbG4XJUEmX5Waw\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"}}]},{\"role\":\"tool\",\"tool_call_id\":\"call_tyZNHs2AudCbG4XJUEmX5Waw\",\"content\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":80,\"temperature\":0}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream; charset=utf-8" + }, + "body": "data: {\"id\":\"chatcmpl-DcLQfUuhXefq7QDmGNhpEN5IqEKMM\",\"object\":\"chat.completion.chunk\",\"created\":1778031209,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_99cf176092\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\",\"refusal\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"SCCu2B8Ri\"}\n\ndata: {\"id\":\"chatcmpl-DcLQfUuhXefq7QDmGNhpEN5IqEKMM\",\"object\":\"chat.completion.chunk\",\"created\":1778031209,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_99cf176092\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"The\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"vuE4h8te\"}\n\ndata: {\"id\":\"chatcmpl-DcLQfUuhXefq7QDmGNhpEN5IqEKMM\",\"object\":\"chat.completion.chunk\",\"created\":1778031209,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_99cf176092\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" weather\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"uzt\"}\n\ndata: {\"id\":\"chatcmpl-DcLQfUuhXefq7QDmGNhpEN5IqEKMM\",\"object\":\"chat.completion.chunk\",\"created\":1778031209,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_99cf176092\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" in\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"4vVdGuJc\"}\n\ndata: {\"id\":\"chatcmpl-DcLQfUuhXefq7QDmGNhpEN5IqEKMM\",\"object\":\"chat.completion.chunk\",\"created\":1778031209,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_99cf176092\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" Paris\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"hAfFt\"}\n\ndata: {\"id\":\"chatcmpl-DcLQfUuhXefq7QDmGNhpEN5IqEKMM\",\"object\":\"chat.completion.chunk\",\"created\":1778031209,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_99cf176092\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" is\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"uuNXNXne\"}\n\ndata: {\"id\":\"chatcmpl-DcLQfUuhXefq7QDmGNhpEN5IqEKMM\",\"object\":\"chat.completion.chunk\",\"created\":1778031209,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_99cf176092\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" sunny\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"HRMlI\"}\n\ndata: {\"id\":\"chatcmpl-DcLQfUuhXefq7QDmGNhpEN5IqEKMM\",\"object\":\"chat.completion.chunk\",\"created\":1778031209,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_99cf176092\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" with\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"Ii1R2u\"}\n\ndata: {\"id\":\"chatcmpl-DcLQfUuhXefq7QDmGNhpEN5IqEKMM\",\"object\":\"chat.completion.chunk\",\"created\":1778031209,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_99cf176092\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" a\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"ay3ddthfT\"}\n\ndata: {\"id\":\"chatcmpl-DcLQfUuhXefq7QDmGNhpEN5IqEKMM\",\"object\":\"chat.completion.chunk\",\"created\":1778031209,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_99cf176092\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" temperature\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"PtxyVsfiluBGiWj\"}\n\ndata: {\"id\":\"chatcmpl-DcLQfUuhXefq7QDmGNhpEN5IqEKMM\",\"object\":\"chat.completion.chunk\",\"created\":1778031209,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_99cf176092\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" of\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"WuI4V7O6\"}\n\ndata: {\"id\":\"chatcmpl-DcLQfUuhXefq7QDmGNhpEN5IqEKMM\",\"object\":\"chat.completion.chunk\",\"created\":1778031209,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_99cf176092\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" \"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"Z5wHwpykrS\"}\n\ndata: {\"id\":\"chatcmpl-DcLQfUuhXefq7QDmGNhpEN5IqEKMM\",\"object\":\"chat.completion.chunk\",\"created\":1778031209,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_99cf176092\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"22\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"Fi66TTzMb\"}\n\ndata: {\"id\":\"chatcmpl-DcLQfUuhXefq7QDmGNhpEN5IqEKMM\",\"object\":\"chat.completion.chunk\",\"created\":1778031209,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_99cf176092\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"°C\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"AFnwTAm2P\"}\n\ndata: {\"id\":\"chatcmpl-DcLQfUuhXefq7QDmGNhpEN5IqEKMM\",\"object\":\"chat.completion.chunk\",\"created\":1778031209,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_99cf176092\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\".\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"xW7U4YToVK\"}\n\ndata: {\"id\":\"chatcmpl-DcLQfUuhXefq7QDmGNhpEN5IqEKMM\",\"object\":\"chat.completion.chunk\",\"created\":1778031209,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_99cf176092\",\"choices\":[{\"index\":0,\"delta\":{},\"logprobs\":null,\"finish_reason\":\"stop\"}],\"usage\":null,\"obfuscation\":\"O0Tks\"}\n\ndata: {\"id\":\"chatcmpl-DcLQfUuhXefq7QDmGNhpEN5IqEKMM\",\"object\":\"chat.completion.chunk\",\"created\":1778031209,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_99cf176092\",\"choices\":[],\"usage\":{\"prompt_tokens\":96,\"completion_tokens\":15,\"total_tokens\":111,\"prompt_tokens_details\":{\"cached_tokens\":0,\"audio_tokens\":0},\"completion_tokens_details\":{\"reasoning_tokens\":0,\"audio_tokens\":0,\"accepted_prediction_tokens\":0,\"rejected_prediction_tokens\":0}},\"obfuscation\":\"advcu5qYJ\"}\n\ndata: [DONE]\n\n" + } + } + ] +} diff --git a/packages/llm/test/fixtures/recordings/openai-chat/streams-text.json b/packages/llm/test/fixtures/recordings/openai-chat/streams-text.json new file mode 100644 index 0000000000..c86a29a462 --- /dev/null +++ b/packages/llm/test/fixtures/recordings/openai-chat/streams-text.json @@ -0,0 +1,28 @@ +{ + "version": 1, + "metadata": { + "name": "openai-chat/streams-text", + "recordedAt": "2026-05-06T01:33:30.542Z", + "tags": ["prefix:openai-chat", "provider:openai", "protocol:openai-chat"] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.openai.com/v1/chat/completions", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"gpt-4o-mini\",\"messages\":[{\"role\":\"system\",\"content\":\"You are concise.\"},{\"role\":\"user\",\"content\":\"Say hello in one short sentence.\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":20,\"temperature\":0}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream; charset=utf-8" + }, + "body": "data: {\"id\":\"chatcmpl-DcLQgbFetadY4JFl0fHK0g7OYsCOL\",\"object\":\"chat.completion.chunk\",\"created\":1778031210,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_57133166c6\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\",\"refusal\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"g9SWm2h6J\"}\n\ndata: {\"id\":\"chatcmpl-DcLQgbFetadY4JFl0fHK0g7OYsCOL\",\"object\":\"chat.completion.chunk\",\"created\":1778031210,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_57133166c6\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Hello\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"lVzwlh\"}\n\ndata: {\"id\":\"chatcmpl-DcLQgbFetadY4JFl0fHK0g7OYsCOL\",\"object\":\"chat.completion.chunk\",\"created\":1778031210,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_57133166c6\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"!\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"onzhziaLGv\"}\n\ndata: {\"id\":\"chatcmpl-DcLQgbFetadY4JFl0fHK0g7OYsCOL\",\"object\":\"chat.completion.chunk\",\"created\":1778031210,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_57133166c6\",\"choices\":[{\"index\":0,\"delta\":{},\"logprobs\":null,\"finish_reason\":\"stop\"}],\"usage\":null,\"obfuscation\":\"LzUj1\"}\n\ndata: {\"id\":\"chatcmpl-DcLQgbFetadY4JFl0fHK0g7OYsCOL\",\"object\":\"chat.completion.chunk\",\"created\":1778031210,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_57133166c6\",\"choices\":[],\"usage\":{\"prompt_tokens\":22,\"completion_tokens\":2,\"total_tokens\":24,\"prompt_tokens_details\":{\"cached_tokens\":0,\"audio_tokens\":0},\"completion_tokens_details\":{\"reasoning_tokens\":0,\"audio_tokens\":0,\"accepted_prediction_tokens\":0,\"rejected_prediction_tokens\":0}},\"obfuscation\":\"emMuPcvvOkI\"}\n\ndata: [DONE]\n\n" + } + } + ] +} diff --git a/packages/llm/test/fixtures/recordings/openai-chat/streams-tool-call.json b/packages/llm/test/fixtures/recordings/openai-chat/streams-tool-call.json new file mode 100644 index 0000000000..fef4d8cd14 --- /dev/null +++ b/packages/llm/test/fixtures/recordings/openai-chat/streams-tool-call.json @@ -0,0 +1,28 @@ +{ + "version": 1, + "metadata": { + "name": "openai-chat/streams-tool-call", + "recordedAt": "2026-05-06T01:33:31.127Z", + "tags": ["prefix:openai-chat", "provider:openai", "protocol:openai-chat", "tool"] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.openai.com/v1/chat/completions", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"gpt-4o-mini\",\"messages\":[{\"role\":\"system\",\"content\":\"Call tools exactly as requested.\"},{\"role\":\"user\",\"content\":\"Call get_weather with city exactly Paris.\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}}],\"tool_choice\":{\"type\":\"function\",\"function\":{\"name\":\"get_weather\"}},\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":80,\"temperature\":0}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream; charset=utf-8" + }, + "body": "data: {\"id\":\"chatcmpl-DcLQgGuIIwnMHqZMRCOwZMLir5SkK\",\"object\":\"chat.completion.chunk\",\"created\":1778031210,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_d0a1738203\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":null,\"tool_calls\":[{\"index\":0,\"id\":\"call_5wBV98AvGPwOyC6a2HtKh85w\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"\"}}],\"refusal\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"hrw8\"}\n\ndata: {\"id\":\"chatcmpl-DcLQgGuIIwnMHqZMRCOwZMLir5SkK\",\"object\":\"chat.completion.chunk\",\"created\":1778031210,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_d0a1738203\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"{\\\"\"}}]},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"MzOlaTohF20Sbb\"}\n\ndata: {\"id\":\"chatcmpl-DcLQgGuIIwnMHqZMRCOwZMLir5SkK\",\"object\":\"chat.completion.chunk\",\"created\":1778031210,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_d0a1738203\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"city\"}}]},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"QuYBQ5vYEUVxR\"}\n\ndata: {\"id\":\"chatcmpl-DcLQgGuIIwnMHqZMRCOwZMLir5SkK\",\"object\":\"chat.completion.chunk\",\"created\":1778031210,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_d0a1738203\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"\\\":\\\"\"}}]},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"spyXlsV2hl6l\"}\n\ndata: {\"id\":\"chatcmpl-DcLQgGuIIwnMHqZMRCOwZMLir5SkK\",\"object\":\"chat.completion.chunk\",\"created\":1778031210,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_d0a1738203\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"Paris\"}}]},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"Db1cjFKa6YAI\"}\n\ndata: {\"id\":\"chatcmpl-DcLQgGuIIwnMHqZMRCOwZMLir5SkK\",\"object\":\"chat.completion.chunk\",\"created\":1778031210,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_d0a1738203\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"\\\"}\"}}]},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"oPu35nrhXcjTL5\"}\n\ndata: {\"id\":\"chatcmpl-DcLQgGuIIwnMHqZMRCOwZMLir5SkK\",\"object\":\"chat.completion.chunk\",\"created\":1778031210,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_d0a1738203\",\"choices\":[{\"index\":0,\"delta\":{},\"logprobs\":null,\"finish_reason\":\"stop\"}],\"usage\":null,\"obfuscation\":\"63TVy\"}\n\ndata: {\"id\":\"chatcmpl-DcLQgGuIIwnMHqZMRCOwZMLir5SkK\",\"object\":\"chat.completion.chunk\",\"created\":1778031210,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_d0a1738203\",\"choices\":[],\"usage\":{\"prompt_tokens\":67,\"completion_tokens\":5,\"total_tokens\":72,\"prompt_tokens_details\":{\"cached_tokens\":0,\"audio_tokens\":0},\"completion_tokens_details\":{\"reasoning_tokens\":0,\"audio_tokens\":0,\"accepted_prediction_tokens\":0,\"rejected_prediction_tokens\":0}},\"obfuscation\":\"NxJjur40z4H\"}\n\ndata: [DONE]\n\n" + } + } + ] +} diff --git a/packages/llm/test/fixtures/recordings/openai-compatible-chat/deepseek-streams-text.json b/packages/llm/test/fixtures/recordings/openai-compatible-chat/deepseek-streams-text.json new file mode 100644 index 0000000000..a71b1121cb --- /dev/null +++ b/packages/llm/test/fixtures/recordings/openai-compatible-chat/deepseek-streams-text.json @@ -0,0 +1,28 @@ +{ + "version": 1, + "metadata": { + "name": "openai-compatible-chat/deepseek-streams-text", + "recordedAt": "2026-04-28T21:18:49.498Z", + "tags": ["prefix:openai-compatible-chat", "protocol:openai-compatible-chat", "provider:deepseek"] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.deepseek.com/v1/chat/completions", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"deepseek-chat\",\"messages\":[{\"role\":\"system\",\"content\":\"You are concise.\"},{\"role\":\"user\",\"content\":\"Reply with exactly: Hello!\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":20,\"temperature\":0}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream; charset=utf-8" + }, + "body": "data: {\"id\":\"0c811926-1e0c-4160-baf8-6e71247c8ad7\",\"object\":\"chat.completion.chunk\",\"created\":1777411128,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_058df29938_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"0c811926-1e0c-4160-baf8-6e71247c8ad7\",\"object\":\"chat.completion.chunk\",\"created\":1777411128,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_058df29938_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Hello\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"0c811926-1e0c-4160-baf8-6e71247c8ad7\",\"object\":\"chat.completion.chunk\",\"created\":1777411128,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_058df29938_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"!\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"0c811926-1e0c-4160-baf8-6e71247c8ad7\",\"object\":\"chat.completion.chunk\",\"created\":1777411128,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_058df29938_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\"},\"logprobs\":null,\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":14,\"completion_tokens\":2,\"total_tokens\":16,\"prompt_tokens_details\":{\"cached_tokens\":0},\"prompt_cache_hit_tokens\":0,\"prompt_cache_miss_tokens\":14}}\n\ndata: [DONE]\n\n" + } + } + ] +} diff --git a/packages/llm/test/fixtures/recordings/openai-compatible-chat/groq-llama-3-3-70b-drives-a-tool-loop.json b/packages/llm/test/fixtures/recordings/openai-compatible-chat/groq-llama-3-3-70b-drives-a-tool-loop.json new file mode 100644 index 0000000000..403260b88b --- /dev/null +++ b/packages/llm/test/fixtures/recordings/openai-compatible-chat/groq-llama-3-3-70b-drives-a-tool-loop.json @@ -0,0 +1,53 @@ +{ + "version": 1, + "metadata": { + "name": "openai-compatible-chat/groq-llama-3-3-70b-drives-a-tool-loop", + "recordedAt": "2026-05-06T01:35:06.032Z", + "tags": [ + "prefix:openai-compatible-chat", + "protocol:openai-compatible-chat", + "provider:groq", + "tool", + "tool-loop", + "golden" + ] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.groq.com/openai/v1/chat/completions", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"llama-3.3-70b-versatile\",\"messages\":[{\"role\":\"system\",\"content\":\"Use the get_weather tool, then answer in one short sentence.\"},{\"role\":\"user\",\"content\":\"What is the weather in Paris?\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":80,\"temperature\":0}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream" + }, + "body": "data: {\"id\":\"chatcmpl-74a8ff95-296e-4c98-8e51-4b23d5d7f261\",\"object\":\"chat.completion.chunk\",\"created\":1778031305,\"model\":\"llama-3.3-70b-versatile\",\"system_fingerprint\":\"fp_43d97c5965\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":null},\"logprobs\":null,\"finish_reason\":null}],\"x_groq\":{\"id\":\"req_01kqxes90afm8r12en80ez1vhw\",\"seed\":1587279809}}\n\ndata: {\"id\":\"chatcmpl-74a8ff95-296e-4c98-8e51-4b23d5d7f261\",\"object\":\"chat.completion.chunk\",\"created\":1778031305,\"model\":\"llama-3.3-70b-versatile\",\"system_fingerprint\":\"fp_43d97c5965\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"id\":\"4vgxtgdfg\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"},\"index\":0}]},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-74a8ff95-296e-4c98-8e51-4b23d5d7f261\",\"object\":\"chat.completion.chunk\",\"created\":1778031305,\"model\":\"llama-3.3-70b-versatile\",\"system_fingerprint\":\"fp_43d97c5965\",\"choices\":[{\"index\":0,\"delta\":{},\"logprobs\":null,\"finish_reason\":\"tool_calls\"}],\"x_groq\":{\"id\":\"req_01kqxes90afm8r12en80ez1vhw\",\"usage\":{\"queue_time\":0.036768035,\"prompt_tokens\":237,\"prompt_time\":0.012356963,\"completion_tokens\":14,\"completion_time\":0.047052437,\"total_tokens\":251,\"total_time\":0.0594094}},\"usage\":{\"queue_time\":0.036768035,\"prompt_tokens\":237,\"prompt_time\":0.012356963,\"completion_tokens\":14,\"completion_time\":0.047052437,\"total_tokens\":251,\"total_time\":0.0594094}}\n\ndata: {\"id\":\"chatcmpl-74a8ff95-296e-4c98-8e51-4b23d5d7f261\",\"object\":\"chat.completion.chunk\",\"created\":1778031305,\"model\":\"llama-3.3-70b-versatile\",\"system_fingerprint\":\"fp_43d97c5965\",\"choices\":[],\"usage\":{\"queue_time\":0.036768035,\"prompt_tokens\":237,\"prompt_time\":0.012356963,\"completion_tokens\":14,\"completion_time\":0.047052437,\"total_tokens\":251,\"total_time\":0.0594094},\"service_tier\":\"on_demand\"}\n\ndata: [DONE]\n\n" + } + }, + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.groq.com/openai/v1/chat/completions", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"llama-3.3-70b-versatile\",\"messages\":[{\"role\":\"system\",\"content\":\"Use the get_weather tool, then answer in one short sentence.\"},{\"role\":\"user\",\"content\":\"What is the weather in Paris?\"},{\"role\":\"assistant\",\"content\":null,\"tool_calls\":[{\"id\":\"4vgxtgdfg\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"}}]},{\"role\":\"tool\",\"tool_call_id\":\"4vgxtgdfg\",\"content\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":80,\"temperature\":0}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream" + }, + "body": "data: {\"id\":\"chatcmpl-52c0acaf-3f4b-45c8-8aa5-93a3b6adb045\",\"object\":\"chat.completion.chunk\",\"created\":1778031305,\"model\":\"llama-3.3-70b-versatile\",\"system_fingerprint\":\"fp_43d97c5965\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\"},\"logprobs\":null,\"finish_reason\":null}],\"x_groq\":{\"id\":\"req_01kqxes966fm8r4q94e70a83gn\",\"seed\":524268521}}\n\ndata: {\"id\":\"chatcmpl-52c0acaf-3f4b-45c8-8aa5-93a3b6adb045\",\"object\":\"chat.completion.chunk\",\"created\":1778031305,\"model\":\"llama-3.3-70b-versatile\",\"system_fingerprint\":\"fp_43d97c5965\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"The\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-52c0acaf-3f4b-45c8-8aa5-93a3b6adb045\",\"object\":\"chat.completion.chunk\",\"created\":1778031305,\"model\":\"llama-3.3-70b-versatile\",\"system_fingerprint\":\"fp_43d97c5965\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" weather\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-52c0acaf-3f4b-45c8-8aa5-93a3b6adb045\",\"object\":\"chat.completion.chunk\",\"created\":1778031305,\"model\":\"llama-3.3-70b-versatile\",\"system_fingerprint\":\"fp_43d97c5965\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" in\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-52c0acaf-3f4b-45c8-8aa5-93a3b6adb045\",\"object\":\"chat.completion.chunk\",\"created\":1778031305,\"model\":\"llama-3.3-70b-versatile\",\"system_fingerprint\":\"fp_43d97c5965\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" Paris\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-52c0acaf-3f4b-45c8-8aa5-93a3b6adb045\",\"object\":\"chat.completion.chunk\",\"created\":1778031305,\"model\":\"llama-3.3-70b-versatile\",\"system_fingerprint\":\"fp_43d97c5965\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" is\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-52c0acaf-3f4b-45c8-8aa5-93a3b6adb045\",\"object\":\"chat.completion.chunk\",\"created\":1778031306,\"model\":\"llama-3.3-70b-versatile\",\"system_fingerprint\":\"fp_43d97c5965\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" sunny\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-52c0acaf-3f4b-45c8-8aa5-93a3b6adb045\",\"object\":\"chat.completion.chunk\",\"created\":1778031306,\"model\":\"llama-3.3-70b-versatile\",\"system_fingerprint\":\"fp_43d97c5965\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" with\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-52c0acaf-3f4b-45c8-8aa5-93a3b6adb045\",\"object\":\"chat.completion.chunk\",\"created\":1778031306,\"model\":\"llama-3.3-70b-versatile\",\"system_fingerprint\":\"fp_43d97c5965\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" a\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-52c0acaf-3f4b-45c8-8aa5-93a3b6adb045\",\"object\":\"chat.completion.chunk\",\"created\":1778031306,\"model\":\"llama-3.3-70b-versatile\",\"system_fingerprint\":\"fp_43d97c5965\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" temperature\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-52c0acaf-3f4b-45c8-8aa5-93a3b6adb045\",\"object\":\"chat.completion.chunk\",\"created\":1778031306,\"model\":\"llama-3.3-70b-versatile\",\"system_fingerprint\":\"fp_43d97c5965\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" of\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-52c0acaf-3f4b-45c8-8aa5-93a3b6adb045\",\"object\":\"chat.completion.chunk\",\"created\":1778031306,\"model\":\"llama-3.3-70b-versatile\",\"system_fingerprint\":\"fp_43d97c5965\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" \"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-52c0acaf-3f4b-45c8-8aa5-93a3b6adb045\",\"object\":\"chat.completion.chunk\",\"created\":1778031306,\"model\":\"llama-3.3-70b-versatile\",\"system_fingerprint\":\"fp_43d97c5965\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"22\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-52c0acaf-3f4b-45c8-8aa5-93a3b6adb045\",\"object\":\"chat.completion.chunk\",\"created\":1778031306,\"model\":\"llama-3.3-70b-versatile\",\"system_fingerprint\":\"fp_43d97c5965\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" degrees\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-52c0acaf-3f4b-45c8-8aa5-93a3b6adb045\",\"object\":\"chat.completion.chunk\",\"created\":1778031306,\"model\":\"llama-3.3-70b-versatile\",\"system_fingerprint\":\"fp_43d97c5965\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\".\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-52c0acaf-3f4b-45c8-8aa5-93a3b6adb045\",\"object\":\"chat.completion.chunk\",\"created\":1778031306,\"model\":\"llama-3.3-70b-versatile\",\"system_fingerprint\":\"fp_43d97c5965\",\"choices\":[{\"index\":0,\"delta\":{},\"logprobs\":null,\"finish_reason\":\"stop\"}],\"x_groq\":{\"id\":\"req_01kqxes966fm8r4q94e70a83gn\",\"usage\":{\"queue_time\":0.036680462,\"prompt_tokens\":270,\"prompt_time\":0.014468555,\"completion_tokens\":15,\"completion_time\":0.057896947,\"total_tokens\":285,\"total_time\":0.072365502}},\"usage\":{\"queue_time\":0.036680462,\"prompt_tokens\":270,\"prompt_time\":0.014468555,\"completion_tokens\":15,\"completion_time\":0.057896947,\"total_tokens\":285,\"total_time\":0.072365502}}\n\ndata: {\"id\":\"chatcmpl-52c0acaf-3f4b-45c8-8aa5-93a3b6adb045\",\"object\":\"chat.completion.chunk\",\"created\":1778031306,\"model\":\"llama-3.3-70b-versatile\",\"system_fingerprint\":\"fp_43d97c5965\",\"choices\":[],\"usage\":{\"queue_time\":0.036680462,\"prompt_tokens\":270,\"prompt_time\":0.014468555,\"completion_tokens\":15,\"completion_time\":0.057896947,\"total_tokens\":285,\"total_time\":0.072365502},\"service_tier\":\"on_demand\"}\n\ndata: [DONE]\n\n" + } + } + ] +} diff --git a/packages/llm/test/fixtures/recordings/openai-compatible-chat/groq-streams-text.json b/packages/llm/test/fixtures/recordings/openai-compatible-chat/groq-streams-text.json new file mode 100644 index 0000000000..561dbfda06 --- /dev/null +++ b/packages/llm/test/fixtures/recordings/openai-compatible-chat/groq-streams-text.json @@ -0,0 +1,28 @@ +{ + "version": 1, + "metadata": { + "name": "openai-compatible-chat/groq-streams-text", + "recordedAt": "2026-05-06T01:35:05.532Z", + "tags": ["prefix:openai-compatible-chat", "protocol:openai-compatible-chat", "provider:groq"] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.groq.com/openai/v1/chat/completions", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"llama-3.3-70b-versatile\",\"messages\":[{\"role\":\"system\",\"content\":\"You are concise.\"},{\"role\":\"user\",\"content\":\"Reply with exactly: Hello!\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":20,\"temperature\":0}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream" + }, + "body": "data: {\"id\":\"chatcmpl-dd5aae9f-7032-44a7-aca8-01027903b4c9\",\"object\":\"chat.completion.chunk\",\"created\":1778031305,\"model\":\"llama-3.3-70b-versatile\",\"system_fingerprint\":\"fp_d42c28f9ce\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\"},\"logprobs\":null,\"finish_reason\":null}],\"x_groq\":{\"id\":\"req_01kqxes8r3fmja0yhxvt665m6h\",\"seed\":687314058}}\n\ndata: {\"id\":\"chatcmpl-dd5aae9f-7032-44a7-aca8-01027903b4c9\",\"object\":\"chat.completion.chunk\",\"created\":1778031305,\"model\":\"llama-3.3-70b-versatile\",\"system_fingerprint\":\"fp_d42c28f9ce\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Hello\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-dd5aae9f-7032-44a7-aca8-01027903b4c9\",\"object\":\"chat.completion.chunk\",\"created\":1778031305,\"model\":\"llama-3.3-70b-versatile\",\"system_fingerprint\":\"fp_d42c28f9ce\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"!\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-dd5aae9f-7032-44a7-aca8-01027903b4c9\",\"object\":\"chat.completion.chunk\",\"created\":1778031305,\"model\":\"llama-3.3-70b-versatile\",\"system_fingerprint\":\"fp_d42c28f9ce\",\"choices\":[{\"index\":0,\"delta\":{},\"logprobs\":null,\"finish_reason\":\"stop\"}],\"x_groq\":{\"id\":\"req_01kqxes8r3fmja0yhxvt665m6h\",\"usage\":{\"queue_time\":0.0381395,\"prompt_tokens\":45,\"prompt_time\":0.003985297,\"completion_tokens\":3,\"completion_time\":0.014171875,\"total_tokens\":48,\"total_time\":0.018157172}},\"usage\":{\"queue_time\":0.0381395,\"prompt_tokens\":45,\"prompt_time\":0.003985297,\"completion_tokens\":3,\"completion_time\":0.014171875,\"total_tokens\":48,\"total_time\":0.018157172}}\n\ndata: {\"id\":\"chatcmpl-dd5aae9f-7032-44a7-aca8-01027903b4c9\",\"object\":\"chat.completion.chunk\",\"created\":1778031305,\"model\":\"llama-3.3-70b-versatile\",\"system_fingerprint\":\"fp_d42c28f9ce\",\"choices\":[],\"usage\":{\"queue_time\":0.0381395,\"prompt_tokens\":45,\"prompt_time\":0.003985297,\"completion_tokens\":3,\"completion_time\":0.014171875,\"total_tokens\":48,\"total_time\":0.018157172},\"service_tier\":\"on_demand\"}\n\ndata: [DONE]\n\n" + } + } + ] +} diff --git a/packages/llm/test/fixtures/recordings/openai-compatible-chat/groq-streams-tool-call.json b/packages/llm/test/fixtures/recordings/openai-compatible-chat/groq-streams-tool-call.json new file mode 100644 index 0000000000..70e9a765d2 --- /dev/null +++ b/packages/llm/test/fixtures/recordings/openai-compatible-chat/groq-streams-tool-call.json @@ -0,0 +1,28 @@ +{ + "version": 1, + "metadata": { + "name": "openai-compatible-chat/groq-streams-tool-call", + "recordedAt": "2026-05-06T01:35:05.706Z", + "tags": ["prefix:openai-compatible-chat", "protocol:openai-compatible-chat", "provider:groq", "tool"] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.groq.com/openai/v1/chat/completions", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"llama-3.3-70b-versatile\",\"messages\":[{\"role\":\"system\",\"content\":\"Call tools exactly as requested.\"},{\"role\":\"user\",\"content\":\"Call get_weather with city exactly Paris.\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}}],\"tool_choice\":{\"type\":\"function\",\"function\":{\"name\":\"get_weather\"}},\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":80,\"temperature\":0}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream" + }, + "body": "data: {\"id\":\"chatcmpl-05380361-f8e4-444a-ae80-296b4d1d46f7\",\"object\":\"chat.completion.chunk\",\"created\":1778031305,\"model\":\"llama-3.3-70b-versatile\",\"system_fingerprint\":\"fp_0761e44d7b\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":null},\"logprobs\":null,\"finish_reason\":null}],\"x_groq\":{\"id\":\"req_01kqxes8v4fm7baf4smt42f0qn\",\"seed\":1846647562}}\n\ndata: {\"id\":\"chatcmpl-05380361-f8e4-444a-ae80-296b4d1d46f7\",\"object\":\"chat.completion.chunk\",\"created\":1778031305,\"model\":\"llama-3.3-70b-versatile\",\"system_fingerprint\":\"fp_0761e44d7b\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"id\":\"mcf2d8nn1\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"},\"index\":0}]},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-05380361-f8e4-444a-ae80-296b4d1d46f7\",\"object\":\"chat.completion.chunk\",\"created\":1778031305,\"model\":\"llama-3.3-70b-versatile\",\"system_fingerprint\":\"fp_0761e44d7b\",\"choices\":[{\"index\":0,\"delta\":{},\"logprobs\":null,\"finish_reason\":\"tool_calls\"}],\"x_groq\":{\"id\":\"req_01kqxes8v4fm7baf4smt42f0qn\",\"usage\":{\"queue_time\":0.07684935,\"prompt_tokens\":249,\"prompt_time\":0.014815006,\"completion_tokens\":10,\"completion_time\":0.036435756,\"total_tokens\":259,\"total_time\":0.051250762}},\"usage\":{\"queue_time\":0.07684935,\"prompt_tokens\":249,\"prompt_time\":0.014815006,\"completion_tokens\":10,\"completion_time\":0.036435756,\"total_tokens\":259,\"total_time\":0.051250762}}\n\ndata: {\"id\":\"chatcmpl-05380361-f8e4-444a-ae80-296b4d1d46f7\",\"object\":\"chat.completion.chunk\",\"created\":1778031305,\"model\":\"llama-3.3-70b-versatile\",\"system_fingerprint\":\"fp_0761e44d7b\",\"choices\":[],\"usage\":{\"queue_time\":0.07684935,\"prompt_tokens\":249,\"prompt_time\":0.014815006,\"completion_tokens\":10,\"completion_time\":0.036435756,\"total_tokens\":259,\"total_time\":0.051250762},\"service_tier\":\"on_demand\"}\n\ndata: [DONE]\n\n" + } + } + ] +} diff --git a/packages/llm/test/fixtures/recordings/openai-compatible-chat/openrouter-claude-opus-4-7-drives-a-tool-loop.json b/packages/llm/test/fixtures/recordings/openai-compatible-chat/openrouter-claude-opus-4-7-drives-a-tool-loop.json new file mode 100644 index 0000000000..e67d280678 --- /dev/null +++ b/packages/llm/test/fixtures/recordings/openai-compatible-chat/openrouter-claude-opus-4-7-drives-a-tool-loop.json @@ -0,0 +1,54 @@ +{ + "version": 1, + "metadata": { + "name": "openai-compatible-chat/openrouter-claude-opus-4-7-drives-a-tool-loop", + "recordedAt": "2026-05-06T01:35:14.282Z", + "tags": [ + "prefix:openai-compatible-chat", + "protocol:openai-compatible-chat", + "provider:openrouter", + "tool", + "tool-loop", + "golden", + "flagship" + ] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://openrouter.ai/api/v1/chat/completions", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"anthropic/claude-opus-4.7\",\"messages\":[{\"role\":\"system\",\"content\":\"Use the get_weather tool exactly once, then answer in one short sentence.\"},{\"role\":\"user\",\"content\":\"What is the weather in Paris?\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":80,\"temperature\":0}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream" + }, + "body": ": OPENROUTER PROCESSING\n\n: OPENROUTER PROCESSING\n\n: OPENROUTER PROCESSING\n\ndata: {\"id\":\"gen-1778031311-S3NlfYGRwAnOoPoNrThK\",\"object\":\"chat.completion.chunk\",\"created\":1778031311,\"model\":\"anthropic/claude-4.7-opus-20260416\",\"provider\":\"Amazon Bedrock\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"id\":\"toolu_bdrk_01AVRkzbigpMbNJ3zjnuQ6ZE\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"\"}}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031311-S3NlfYGRwAnOoPoNrThK\",\"object\":\"chat.completion.chunk\",\"created\":1778031311,\"model\":\"anthropic/claude-4.7-opus-20260416\",\"provider\":\"Amazon Bedrock\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"\"}}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031311-S3NlfYGRwAnOoPoNrThK\",\"object\":\"chat.completion.chunk\",\"created\":1778031311,\"model\":\"anthropic/claude-4.7-opus-20260416\",\"provider\":\"Amazon Bedrock\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"\"}}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031311-S3NlfYGRwAnOoPoNrThK\",\"object\":\"chat.completion.chunk\",\"created\":1778031311,\"model\":\"anthropic/claude-4.7-opus-20260416\",\"provider\":\"Amazon Bedrock\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"{\\\"city\\\": \\\"P\"}}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031311-S3NlfYGRwAnOoPoNrThK\",\"object\":\"chat.completion.chunk\",\"created\":1778031311,\"model\":\"anthropic/claude-4.7-opus-20260416\",\"provider\":\"Amazon Bedrock\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"ari\"}}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031311-S3NlfYGRwAnOoPoNrThK\",\"object\":\"chat.completion.chunk\",\"created\":1778031311,\"model\":\"anthropic/claude-4.7-opus-20260416\",\"provider\":\"Amazon Bedrock\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"s\\\"}\"}}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031311-S3NlfYGRwAnOoPoNrThK\",\"object\":\"chat.completion.chunk\",\"created\":1778031311,\"model\":\"anthropic/claude-4.7-opus-20260416\",\"provider\":\"Amazon Bedrock\",\"service_tier\":\"standard\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\"},\"finish_reason\":\"tool_calls\",\"native_finish_reason\":\"tool_use\"}]}\n\ndata: {\"id\":\"gen-1778031311-S3NlfYGRwAnOoPoNrThK\",\"object\":\"chat.completion.chunk\",\"created\":1778031311,\"model\":\"anthropic/claude-4.7-opus-20260416\",\"provider\":\"Amazon Bedrock\",\"service_tier\":\"standard\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\"},\"finish_reason\":\"tool_calls\",\"native_finish_reason\":\"tool_use\"}],\"usage\":{\"prompt_tokens\":802,\"completion_tokens\":66,\"total_tokens\":868,\"cost\":0.00566,\"is_byok\":false,\"prompt_tokens_details\":{\"cached_tokens\":0,\"cache_write_tokens\":0,\"audio_tokens\":0,\"video_tokens\":0},\"cost_details\":{\"upstream_inference_cost\":0.00566,\"upstream_inference_prompt_cost\":0.00401,\"upstream_inference_completions_cost\":0.00165},\"completion_tokens_details\":{\"reasoning_tokens\":0,\"image_tokens\":0,\"audio_tokens\":0}}}\n\ndata: [DONE]\n\n" + } + }, + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://openrouter.ai/api/v1/chat/completions", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"anthropic/claude-opus-4.7\",\"messages\":[{\"role\":\"system\",\"content\":\"Use the get_weather tool exactly once, then answer in one short sentence.\"},{\"role\":\"user\",\"content\":\"What is the weather in Paris?\"},{\"role\":\"assistant\",\"content\":null,\"tool_calls\":[{\"id\":\"toolu_bdrk_01AVRkzbigpMbNJ3zjnuQ6ZE\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"}}]},{\"role\":\"tool\",\"tool_call_id\":\"toolu_bdrk_01AVRkzbigpMbNJ3zjnuQ6ZE\",\"content\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":80,\"temperature\":0}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream" + }, + "body": ": OPENROUTER PROCESSING\n\ndata: {\"id\":\"gen-1778031313-XM4XZGmFyt6jg3GZ772w\",\"object\":\"chat.completion.chunk\",\"created\":1778031313,\"model\":\"anthropic/claude-4.7-opus-20260416\",\"provider\":\"Amazon Bedrock\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"It\",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031313-XM4XZGmFyt6jg3GZ772w\",\"object\":\"chat.completion.chunk\",\"created\":1778031313,\"model\":\"anthropic/claude-4.7-opus-20260416\",\"provider\":\"Amazon Bedrock\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"'s sunny and\",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031313-XM4XZGmFyt6jg3GZ772w\",\"object\":\"chat.completion.chunk\",\"created\":1778031313,\"model\":\"anthropic/claude-4.7-opus-20260416\",\"provider\":\"Amazon Bedrock\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" 22°C in\",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031313-XM4XZGmFyt6jg3GZ772w\",\"object\":\"chat.completion.chunk\",\"created\":1778031313,\"model\":\"anthropic/claude-4.7-opus-20260416\",\"provider\":\"Amazon Bedrock\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" Paris.\",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031313-XM4XZGmFyt6jg3GZ772w\",\"object\":\"chat.completion.chunk\",\"created\":1778031313,\"model\":\"anthropic/claude-4.7-opus-20260416\",\"provider\":\"Amazon Bedrock\",\"service_tier\":\"standard\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\"},\"finish_reason\":\"stop\",\"native_finish_reason\":\"end_turn\"}]}\n\ndata: {\"id\":\"gen-1778031313-XM4XZGmFyt6jg3GZ772w\",\"object\":\"chat.completion.chunk\",\"created\":1778031313,\"model\":\"anthropic/claude-4.7-opus-20260416\",\"provider\":\"Amazon Bedrock\",\"service_tier\":\"standard\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\"},\"finish_reason\":\"stop\",\"native_finish_reason\":\"end_turn\"}],\"usage\":{\"prompt_tokens\":899,\"completion_tokens\":19,\"total_tokens\":918,\"cost\":0.00497,\"is_byok\":false,\"prompt_tokens_details\":{\"cached_tokens\":0,\"cache_write_tokens\":0,\"audio_tokens\":0,\"video_tokens\":0},\"cost_details\":{\"upstream_inference_cost\":0.00497,\"upstream_inference_prompt_cost\":0.004495,\"upstream_inference_completions_cost\":0.000475},\"completion_tokens_details\":{\"reasoning_tokens\":0,\"image_tokens\":0,\"audio_tokens\":0}}}\n\ndata: [DONE]\n\n" + } + } + ] +} diff --git a/packages/llm/test/fixtures/recordings/openai-compatible-chat/openrouter-gpt-4o-mini-drives-a-tool-loop.json b/packages/llm/test/fixtures/recordings/openai-compatible-chat/openrouter-gpt-4o-mini-drives-a-tool-loop.json new file mode 100644 index 0000000000..7883285e58 --- /dev/null +++ b/packages/llm/test/fixtures/recordings/openai-compatible-chat/openrouter-gpt-4o-mini-drives-a-tool-loop.json @@ -0,0 +1,53 @@ +{ + "version": 1, + "metadata": { + "name": "openai-compatible-chat/openrouter-gpt-4o-mini-drives-a-tool-loop", + "recordedAt": "2026-05-06T01:35:08.922Z", + "tags": [ + "prefix:openai-compatible-chat", + "protocol:openai-compatible-chat", + "provider:openrouter", + "tool", + "tool-loop", + "golden" + ] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://openrouter.ai/api/v1/chat/completions", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"openai/gpt-4o-mini\",\"messages\":[{\"role\":\"system\",\"content\":\"Use the get_weather tool exactly once, then answer in one short sentence.\"},{\"role\":\"user\",\"content\":\"What is the weather in Paris?\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":80,\"temperature\":0}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream" + }, + "body": ": OPENROUTER PROCESSING\n\ndata: {\"id\":\"gen-1778031307-FcHCDYW9unDVyRRL841T\",\"object\":\"chat.completion.chunk\",\"created\":1778031307,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_7e69b4ef44\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"id\":\"call_S63bjYITINemSHZ4Uqns7PIu\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"\"}}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031307-FcHCDYW9unDVyRRL841T\",\"object\":\"chat.completion.chunk\",\"created\":1778031307,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_7e69b4ef44\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"\"}}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031307-FcHCDYW9unDVyRRL841T\",\"object\":\"chat.completion.chunk\",\"created\":1778031307,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_7e69b4ef44\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"{\\\"\"}}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031307-FcHCDYW9unDVyRRL841T\",\"object\":\"chat.completion.chunk\",\"created\":1778031307,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_7e69b4ef44\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"city\"}}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031307-FcHCDYW9unDVyRRL841T\",\"object\":\"chat.completion.chunk\",\"created\":1778031307,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_7e69b4ef44\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"\\\":\\\"\"}}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031307-FcHCDYW9unDVyRRL841T\",\"object\":\"chat.completion.chunk\",\"created\":1778031307,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_7e69b4ef44\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"Paris\"}}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031307-FcHCDYW9unDVyRRL841T\",\"object\":\"chat.completion.chunk\",\"created\":1778031307,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_7e69b4ef44\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"\\\"}\"}}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031307-FcHCDYW9unDVyRRL841T\",\"object\":\"chat.completion.chunk\",\"created\":1778031307,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_7e69b4ef44\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\"},\"finish_reason\":\"tool_calls\",\"native_finish_reason\":\"tool_calls\"}]}\n\ndata: {\"id\":\"gen-1778031307-FcHCDYW9unDVyRRL841T\",\"object\":\"chat.completion.chunk\",\"created\":1778031307,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_7e69b4ef44\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\"},\"finish_reason\":\"tool_calls\",\"native_finish_reason\":\"tool_calls\"}],\"usage\":{\"prompt_tokens\":66,\"completion_tokens\":14,\"total_tokens\":80,\"cost\":0.0000183,\"is_byok\":false,\"prompt_tokens_details\":{\"cached_tokens\":0,\"cache_write_tokens\":0,\"audio_tokens\":0,\"video_tokens\":0},\"cost_details\":{\"upstream_inference_cost\":0.0000183,\"upstream_inference_prompt_cost\":0.0000099,\"upstream_inference_completions_cost\":0.0000084},\"completion_tokens_details\":{\"reasoning_tokens\":0,\"image_tokens\":0,\"audio_tokens\":0}}}\n\ndata: [DONE]\n\n" + } + }, + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://openrouter.ai/api/v1/chat/completions", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"openai/gpt-4o-mini\",\"messages\":[{\"role\":\"system\",\"content\":\"Use the get_weather tool exactly once, then answer in one short sentence.\"},{\"role\":\"user\",\"content\":\"What is the weather in Paris?\"},{\"role\":\"assistant\",\"content\":null,\"tool_calls\":[{\"id\":\"call_S63bjYITINemSHZ4Uqns7PIu\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"}}]},{\"role\":\"tool\",\"tool_call_id\":\"call_S63bjYITINemSHZ4Uqns7PIu\",\"content\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":80,\"temperature\":0}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream" + }, + "body": ": OPENROUTER PROCESSING\n\ndata: {\"id\":\"gen-1778031308-uNHYY6MdDXOs0BYMXXVb\",\"object\":\"chat.completion.chunk\",\"created\":1778031308,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_7e69b4ef44\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"The\",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031308-uNHYY6MdDXOs0BYMXXVb\",\"object\":\"chat.completion.chunk\",\"created\":1778031308,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_7e69b4ef44\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" weather\",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031308-uNHYY6MdDXOs0BYMXXVb\",\"object\":\"chat.completion.chunk\",\"created\":1778031308,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_7e69b4ef44\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" in\",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031308-uNHYY6MdDXOs0BYMXXVb\",\"object\":\"chat.completion.chunk\",\"created\":1778031308,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_7e69b4ef44\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" Paris\",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031308-uNHYY6MdDXOs0BYMXXVb\",\"object\":\"chat.completion.chunk\",\"created\":1778031308,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_7e69b4ef44\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" is\",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031308-uNHYY6MdDXOs0BYMXXVb\",\"object\":\"chat.completion.chunk\",\"created\":1778031308,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_7e69b4ef44\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" sunny\",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031308-uNHYY6MdDXOs0BYMXXVb\",\"object\":\"chat.completion.chunk\",\"created\":1778031308,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_7e69b4ef44\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" with\",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031308-uNHYY6MdDXOs0BYMXXVb\",\"object\":\"chat.completion.chunk\",\"created\":1778031308,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_7e69b4ef44\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" a\",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031308-uNHYY6MdDXOs0BYMXXVb\",\"object\":\"chat.completion.chunk\",\"created\":1778031308,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_7e69b4ef44\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" temperature\",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031308-uNHYY6MdDXOs0BYMXXVb\",\"object\":\"chat.completion.chunk\",\"created\":1778031308,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_7e69b4ef44\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" of\",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031308-uNHYY6MdDXOs0BYMXXVb\",\"object\":\"chat.completion.chunk\",\"created\":1778031308,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_7e69b4ef44\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" \",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031308-uNHYY6MdDXOs0BYMXXVb\",\"object\":\"chat.completion.chunk\",\"created\":1778031308,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_7e69b4ef44\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"22\",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031308-uNHYY6MdDXOs0BYMXXVb\",\"object\":\"chat.completion.chunk\",\"created\":1778031308,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_7e69b4ef44\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"°C\",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031308-uNHYY6MdDXOs0BYMXXVb\",\"object\":\"chat.completion.chunk\",\"created\":1778031308,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_7e69b4ef44\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\".\",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031308-uNHYY6MdDXOs0BYMXXVb\",\"object\":\"chat.completion.chunk\",\"created\":1778031308,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_7e69b4ef44\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\"},\"finish_reason\":\"stop\",\"native_finish_reason\":\"stop\"}]}\n\ndata: {\"id\":\"gen-1778031308-uNHYY6MdDXOs0BYMXXVb\",\"object\":\"chat.completion.chunk\",\"created\":1778031308,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_7e69b4ef44\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\"},\"finish_reason\":\"stop\",\"native_finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":98,\"completion_tokens\":15,\"total_tokens\":113,\"cost\":0.0000237,\"is_byok\":false,\"prompt_tokens_details\":{\"cached_tokens\":0,\"cache_write_tokens\":0,\"audio_tokens\":0,\"video_tokens\":0},\"cost_details\":{\"upstream_inference_cost\":0.0000237,\"upstream_inference_prompt_cost\":0.0000147,\"upstream_inference_completions_cost\":0.000009},\"completion_tokens_details\":{\"reasoning_tokens\":0,\"image_tokens\":0,\"audio_tokens\":0}}}\n\ndata: [DONE]\n\n" + } + } + ] +} diff --git a/packages/llm/test/fixtures/recordings/openai-compatible-chat/openrouter-gpt-5-5-drives-a-tool-loop.json b/packages/llm/test/fixtures/recordings/openai-compatible-chat/openrouter-gpt-5-5-drives-a-tool-loop.json new file mode 100644 index 0000000000..e1cbab70fa --- /dev/null +++ b/packages/llm/test/fixtures/recordings/openai-compatible-chat/openrouter-gpt-5-5-drives-a-tool-loop.json @@ -0,0 +1,54 @@ +{ + "version": 1, + "metadata": { + "name": "openai-compatible-chat/openrouter-gpt-5-5-drives-a-tool-loop", + "recordedAt": "2026-05-06T01:35:11.662Z", + "tags": [ + "prefix:openai-compatible-chat", + "protocol:openai-compatible-chat", + "provider:openrouter", + "tool", + "tool-loop", + "golden", + "flagship" + ] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://openrouter.ai/api/v1/chat/completions", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"openai/gpt-5.5\",\"messages\":[{\"role\":\"system\",\"content\":\"Use the get_weather tool exactly once, then answer in one short sentence.\"},{\"role\":\"user\",\"content\":\"What is the weather in Paris?\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":80,\"temperature\":0}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream" + }, + "body": ": OPENROUTER PROCESSING\n\n: OPENROUTER PROCESSING\n\ndata: {\"id\":\"gen-1778031308-dVa9axcHcOlG9GcilZkz\",\"object\":\"chat.completion.chunk\",\"created\":1778031308,\"model\":\"openai/gpt-5.5-20260423\",\"provider\":\"OpenAI\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"id\":\"call_4A7V7UN36HXCUUn8qAOQaKGw\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"\"}}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031308-dVa9axcHcOlG9GcilZkz\",\"object\":\"chat.completion.chunk\",\"created\":1778031308,\"model\":\"openai/gpt-5.5-20260423\",\"provider\":\"OpenAI\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"\"}}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031308-dVa9axcHcOlG9GcilZkz\",\"object\":\"chat.completion.chunk\",\"created\":1778031308,\"model\":\"openai/gpt-5.5-20260423\",\"provider\":\"OpenAI\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"{\\\"\"}}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031308-dVa9axcHcOlG9GcilZkz\",\"object\":\"chat.completion.chunk\",\"created\":1778031308,\"model\":\"openai/gpt-5.5-20260423\",\"provider\":\"OpenAI\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"city\"}}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031308-dVa9axcHcOlG9GcilZkz\",\"object\":\"chat.completion.chunk\",\"created\":1778031308,\"model\":\"openai/gpt-5.5-20260423\",\"provider\":\"OpenAI\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"\\\":\\\"\"}}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031308-dVa9axcHcOlG9GcilZkz\",\"object\":\"chat.completion.chunk\",\"created\":1778031308,\"model\":\"openai/gpt-5.5-20260423\",\"provider\":\"OpenAI\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"Paris\"}}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031308-dVa9axcHcOlG9GcilZkz\",\"object\":\"chat.completion.chunk\",\"created\":1778031308,\"model\":\"openai/gpt-5.5-20260423\",\"provider\":\"OpenAI\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"\\\"}\"}}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031308-dVa9axcHcOlG9GcilZkz\",\"object\":\"chat.completion.chunk\",\"created\":1778031308,\"model\":\"openai/gpt-5.5-20260423\",\"provider\":\"OpenAI\",\"service_tier\":\"default\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\"},\"finish_reason\":\"tool_calls\",\"native_finish_reason\":\"completed\"}]}\n\ndata: {\"id\":\"gen-1778031308-dVa9axcHcOlG9GcilZkz\",\"object\":\"chat.completion.chunk\",\"created\":1778031308,\"model\":\"openai/gpt-5.5-20260423\",\"provider\":\"OpenAI\",\"service_tier\":\"default\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\"},\"finish_reason\":\"tool_calls\",\"native_finish_reason\":\"completed\"}],\"usage\":{\"prompt_tokens\":69,\"completion_tokens\":18,\"total_tokens\":87,\"cost\":0.000885,\"is_byok\":false,\"prompt_tokens_details\":{\"cached_tokens\":0,\"cache_write_tokens\":0,\"audio_tokens\":0,\"video_tokens\":0},\"cost_details\":{\"upstream_inference_cost\":0.000885,\"upstream_inference_prompt_cost\":0.000345,\"upstream_inference_completions_cost\":0.00054},\"completion_tokens_details\":{\"reasoning_tokens\":0,\"image_tokens\":0,\"audio_tokens\":0}}}\n\ndata: [DONE]\n\n" + } + }, + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://openrouter.ai/api/v1/chat/completions", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"openai/gpt-5.5\",\"messages\":[{\"role\":\"system\",\"content\":\"Use the get_weather tool exactly once, then answer in one short sentence.\"},{\"role\":\"user\",\"content\":\"What is the weather in Paris?\"},{\"role\":\"assistant\",\"content\":null,\"tool_calls\":[{\"id\":\"call_4A7V7UN36HXCUUn8qAOQaKGw\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"}}]},{\"role\":\"tool\",\"tool_call_id\":\"call_4A7V7UN36HXCUUn8qAOQaKGw\",\"content\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":80,\"temperature\":0}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream" + }, + "body": ": OPENROUTER PROCESSING\n\n: OPENROUTER PROCESSING\n\ndata: {\"id\":\"gen-1778031310-JUYfFzDbun699uUYoA4N\",\"object\":\"chat.completion.chunk\",\"created\":1778031310,\"model\":\"openai/gpt-5.5-20260423\",\"provider\":\"OpenAI\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Paris\",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031310-JUYfFzDbun699uUYoA4N\",\"object\":\"chat.completion.chunk\",\"created\":1778031310,\"model\":\"openai/gpt-5.5-20260423\",\"provider\":\"OpenAI\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" is\",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031310-JUYfFzDbun699uUYoA4N\",\"object\":\"chat.completion.chunk\",\"created\":1778031310,\"model\":\"openai/gpt-5.5-20260423\",\"provider\":\"OpenAI\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" sunny\",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031310-JUYfFzDbun699uUYoA4N\",\"object\":\"chat.completion.chunk\",\"created\":1778031310,\"model\":\"openai/gpt-5.5-20260423\",\"provider\":\"OpenAI\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" and\",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031310-JUYfFzDbun699uUYoA4N\",\"object\":\"chat.completion.chunk\",\"created\":1778031310,\"model\":\"openai/gpt-5.5-20260423\",\"provider\":\"OpenAI\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" \",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031310-JUYfFzDbun699uUYoA4N\",\"object\":\"chat.completion.chunk\",\"created\":1778031310,\"model\":\"openai/gpt-5.5-20260423\",\"provider\":\"OpenAI\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"22\",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031310-JUYfFzDbun699uUYoA4N\",\"object\":\"chat.completion.chunk\",\"created\":1778031310,\"model\":\"openai/gpt-5.5-20260423\",\"provider\":\"OpenAI\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"°C\",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031310-JUYfFzDbun699uUYoA4N\",\"object\":\"chat.completion.chunk\",\"created\":1778031310,\"model\":\"openai/gpt-5.5-20260423\",\"provider\":\"OpenAI\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\".\",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031310-JUYfFzDbun699uUYoA4N\",\"object\":\"chat.completion.chunk\",\"created\":1778031310,\"model\":\"openai/gpt-5.5-20260423\",\"provider\":\"OpenAI\",\"service_tier\":\"default\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\"},\"finish_reason\":\"stop\",\"native_finish_reason\":\"completed\"}]}\n\ndata: {\"id\":\"gen-1778031310-JUYfFzDbun699uUYoA4N\",\"object\":\"chat.completion.chunk\",\"created\":1778031310,\"model\":\"openai/gpt-5.5-20260423\",\"provider\":\"OpenAI\",\"service_tier\":\"default\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\"},\"finish_reason\":\"stop\",\"native_finish_reason\":\"completed\"}],\"usage\":{\"prompt_tokens\":108,\"completion_tokens\":12,\"total_tokens\":120,\"cost\":0.0009,\"is_byok\":false,\"prompt_tokens_details\":{\"cached_tokens\":0,\"cache_write_tokens\":0,\"audio_tokens\":0,\"video_tokens\":0},\"cost_details\":{\"upstream_inference_cost\":0.0009,\"upstream_inference_prompt_cost\":0.00054,\"upstream_inference_completions_cost\":0.00036},\"completion_tokens_details\":{\"reasoning_tokens\":0,\"image_tokens\":0,\"audio_tokens\":0}}}\n\ndata: [DONE]\n\n" + } + } + ] +} diff --git a/packages/llm/test/fixtures/recordings/openai-compatible-chat/openrouter-streams-text.json b/packages/llm/test/fixtures/recordings/openai-compatible-chat/openrouter-streams-text.json new file mode 100644 index 0000000000..1a95146931 --- /dev/null +++ b/packages/llm/test/fixtures/recordings/openai-compatible-chat/openrouter-streams-text.json @@ -0,0 +1,28 @@ +{ + "version": 1, + "metadata": { + "name": "openai-compatible-chat/openrouter-streams-text", + "recordedAt": "2026-05-06T01:35:06.767Z", + "tags": ["prefix:openai-compatible-chat", "protocol:openai-compatible-chat", "provider:openrouter"] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://openrouter.ai/api/v1/chat/completions", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"openai/gpt-4o-mini\",\"messages\":[{\"role\":\"system\",\"content\":\"You are concise.\"},{\"role\":\"user\",\"content\":\"Reply with exactly: Hello!\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":20,\"temperature\":0}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream" + }, + "body": ": OPENROUTER PROCESSING\n\ndata: {\"id\":\"gen-1778031306-UD7bR0I1JNCsPvVzlXat\",\"object\":\"chat.completion.chunk\",\"created\":1778031306,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"Azure\",\"system_fingerprint\":\"fp_eb37e061ec\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Hello\",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031306-UD7bR0I1JNCsPvVzlXat\",\"object\":\"chat.completion.chunk\",\"created\":1778031306,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"Azure\",\"system_fingerprint\":\"fp_eb37e061ec\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"!\",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031306-UD7bR0I1JNCsPvVzlXat\",\"object\":\"chat.completion.chunk\",\"created\":1778031306,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"Azure\",\"system_fingerprint\":\"fp_eb37e061ec\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\"},\"finish_reason\":\"stop\",\"native_finish_reason\":\"stop\"}]}\n\ndata: {\"id\":\"gen-1778031306-UD7bR0I1JNCsPvVzlXat\",\"object\":\"chat.completion.chunk\",\"created\":1778031306,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"Azure\",\"system_fingerprint\":\"fp_eb37e061ec\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\"},\"finish_reason\":\"stop\",\"native_finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":21,\"completion_tokens\":3,\"total_tokens\":24,\"cost\":0.00000495,\"is_byok\":false,\"prompt_tokens_details\":{\"cached_tokens\":0,\"cache_write_tokens\":0,\"audio_tokens\":0,\"video_tokens\":0},\"cost_details\":{\"upstream_inference_cost\":0.00000495,\"upstream_inference_prompt_cost\":0.00000315,\"upstream_inference_completions_cost\":0.0000018},\"completion_tokens_details\":{\"reasoning_tokens\":0,\"image_tokens\":0,\"audio_tokens\":0}}}\n\ndata: [DONE]\n\n" + } + } + ] +} diff --git a/packages/llm/test/fixtures/recordings/openai-compatible-chat/openrouter-streams-tool-call.json b/packages/llm/test/fixtures/recordings/openai-compatible-chat/openrouter-streams-tool-call.json new file mode 100644 index 0000000000..36d0ad99c5 --- /dev/null +++ b/packages/llm/test/fixtures/recordings/openai-compatible-chat/openrouter-streams-tool-call.json @@ -0,0 +1,28 @@ +{ + "version": 1, + "metadata": { + "name": "openai-compatible-chat/openrouter-streams-tool-call", + "recordedAt": "2026-05-06T01:35:07.466Z", + "tags": ["prefix:openai-compatible-chat", "protocol:openai-compatible-chat", "provider:openrouter", "tool"] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://openrouter.ai/api/v1/chat/completions", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"openai/gpt-4o-mini\",\"messages\":[{\"role\":\"system\",\"content\":\"Call tools exactly as requested.\"},{\"role\":\"user\",\"content\":\"Call get_weather with city exactly Paris.\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}}],\"tool_choice\":{\"type\":\"function\",\"function\":{\"name\":\"get_weather\"}},\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":80,\"temperature\":0}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream" + }, + "body": ": OPENROUTER PROCESSING\n\ndata: {\"id\":\"gen-1778031306-HYzOq04JIk1hZQ4iaNjD\",\"object\":\"chat.completion.chunk\",\"created\":1778031306,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_b6580bbee1\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"id\":\"call_L7mHMq49ZSUTBHjLJfBIP2eT\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"\"}}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031306-HYzOq04JIk1hZQ4iaNjD\",\"object\":\"chat.completion.chunk\",\"created\":1778031306,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_b6580bbee1\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"\"}}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031306-HYzOq04JIk1hZQ4iaNjD\",\"object\":\"chat.completion.chunk\",\"created\":1778031306,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_b6580bbee1\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"{\\\"\"}}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031306-HYzOq04JIk1hZQ4iaNjD\",\"object\":\"chat.completion.chunk\",\"created\":1778031306,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_b6580bbee1\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"city\"}}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031306-HYzOq04JIk1hZQ4iaNjD\",\"object\":\"chat.completion.chunk\",\"created\":1778031306,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_b6580bbee1\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"\\\":\\\"\"}}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031306-HYzOq04JIk1hZQ4iaNjD\",\"object\":\"chat.completion.chunk\",\"created\":1778031306,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_b6580bbee1\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"Paris\"}}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031306-HYzOq04JIk1hZQ4iaNjD\",\"object\":\"chat.completion.chunk\",\"created\":1778031306,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_b6580bbee1\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"\\\"}\"}}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031306-HYzOq04JIk1hZQ4iaNjD\",\"object\":\"chat.completion.chunk\",\"created\":1778031306,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_b6580bbee1\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\"},\"finish_reason\":\"tool_calls\",\"native_finish_reason\":\"stop\"}]}\n\ndata: {\"id\":\"gen-1778031306-HYzOq04JIk1hZQ4iaNjD\",\"object\":\"chat.completion.chunk\",\"created\":1778031306,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_b6580bbee1\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\"},\"finish_reason\":\"tool_calls\",\"native_finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":67,\"completion_tokens\":5,\"total_tokens\":72,\"cost\":0.00001305,\"is_byok\":false,\"prompt_tokens_details\":{\"cached_tokens\":0,\"cache_write_tokens\":0,\"audio_tokens\":0,\"video_tokens\":0},\"cost_details\":{\"upstream_inference_cost\":0.00001305,\"upstream_inference_prompt_cost\":0.00001005,\"upstream_inference_completions_cost\":0.000003},\"completion_tokens_details\":{\"reasoning_tokens\":0,\"image_tokens\":0,\"audio_tokens\":0}}}\n\ndata: [DONE]\n\n" + } + } + ] +} diff --git a/packages/llm/test/fixtures/recordings/openai-compatible-chat/togetherai-streams-text.json b/packages/llm/test/fixtures/recordings/openai-compatible-chat/togetherai-streams-text.json new file mode 100644 index 0000000000..640565b14f --- /dev/null +++ b/packages/llm/test/fixtures/recordings/openai-compatible-chat/togetherai-streams-text.json @@ -0,0 +1,28 @@ +{ + "version": 1, + "metadata": { + "name": "openai-compatible-chat/togetherai-streams-text", + "recordedAt": "2026-04-28T21:18:55.266Z", + "tags": ["prefix:openai-compatible-chat", "protocol:openai-compatible-chat", "provider:togetherai"] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.together.xyz/v1/chat/completions", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\",\"messages\":[{\"role\":\"system\",\"content\":\"You are concise.\"},{\"role\":\"user\",\"content\":\"Reply with exactly: Hello!\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":20,\"temperature\":0}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream;charset=utf-8" + }, + "body": "data: {\"id\":\"ogzjdpL-6Ng1vN-9f391a08f8af75e1\",\"object\":\"chat.completion.chunk\",\"created\":1777411129,\"choices\":[{\"index\":0,\"text\":\"Hello\",\"logprobs\":null,\"finish_reason\":null,\"seed\":null,\"delta\":{\"token_id\":9906,\"role\":\"assistant\",\"content\":\"Hello\"}}],\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\",\"usage\":null}\n\ndata: {\"id\":\"ogzjdpL-6Ng1vN-9f391a08f8af75e1\",\"object\":\"chat.completion.chunk\",\"created\":1777411129,\"choices\":[{\"index\":0,\"text\":\"!\",\"logprobs\":null,\"finish_reason\":null,\"seed\":null,\"delta\":{\"token_id\":null,\"role\":\"assistant\",\"content\":\"!\"}}],\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\",\"usage\":null}\n\ndata: {\"id\":\"ogzjdpL-6Ng1vN-9f391a08f8af75e1\",\"object\":\"chat.completion.chunk\",\"created\":1777411129,\"choices\":[{\"index\":0,\"text\":\"\",\"logprobs\":null,\"finish_reason\":\"stop\",\"seed\":15924764223251450000,\"delta\":{\"token_id\":128009,\"role\":\"assistant\",\"content\":\"\"}}],\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\",\"usage\":{\"prompt_tokens\":45,\"completion_tokens\":3,\"total_tokens\":48,\"cached_tokens\":0}}\n\ndata: [DONE]\n\n" + } + } + ] +} diff --git a/packages/llm/test/fixtures/recordings/openai-compatible-chat/togetherai-streams-tool-call.json b/packages/llm/test/fixtures/recordings/openai-compatible-chat/togetherai-streams-tool-call.json new file mode 100644 index 0000000000..6c1d9c1a7f --- /dev/null +++ b/packages/llm/test/fixtures/recordings/openai-compatible-chat/togetherai-streams-tool-call.json @@ -0,0 +1,28 @@ +{ + "version": 1, + "metadata": { + "name": "openai-compatible-chat/togetherai-streams-tool-call", + "recordedAt": "2026-04-28T21:18:59.123Z", + "tags": ["prefix:openai-compatible-chat", "protocol:openai-compatible-chat", "provider:togetherai", "tool"] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.together.xyz/v1/chat/completions", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\",\"messages\":[{\"role\":\"system\",\"content\":\"Call tools exactly as requested.\"},{\"role\":\"user\",\"content\":\"Call get_weather with city exactly Paris.\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}}],\"tool_choice\":{\"type\":\"function\",\"function\":{\"name\":\"get_weather\"}},\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":80,\"temperature\":0}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream;charset=utf-8" + }, + "body": "data: {\"id\":\"ogzjfRD-6Ng1vN-9f391a2bb8ca75e1\",\"object\":\"chat.completion.chunk\",\"created\":1777411135,\"choices\":[{\"index\":0,\"role\":\"assistant\",\"text\":\"\",\"logprobs\":null,\"finish_reason\":null,\"delta\":{\"token_id\":null,\"role\":\"assistant\",\"content\":\"\"}}],\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\"}\n\ndata: {\"id\":\"ogzjfRD-6Ng1vN-9f391a2bb8ca75e1\",\"object\":\"chat.completion.chunk\",\"created\":1777411135,\"choices\":[{\"index\":0,\"text\":\"\",\"logprobs\":null,\"finish_reason\":null,\"delta\":{\"token_id\":null,\"role\":\"assistant\",\"content\":\"\",\"tool_calls\":[{\"index\":0,\"id\":\"call_yu1mxtmex7x48nximi9c8jpo\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"\"}}]}}],\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\"}\n\ndata: {\"id\":\"ogzjfRD-6Ng1vN-9f391a2bb8ca75e1\",\"object\":\"chat.completion.chunk\",\"created\":1777411135,\"choices\":[{\"index\":0,\"text\":\"\",\"logprobs\":null,\"finish_reason\":\"tool_calls\",\"delta\":{\"token_id\":null,\"role\":\"assistant\",\"content\":\"\",\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"}}]}}],\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\"}\n\ndata: {\"id\":\"ogzjfRD-6Ng1vN-9f391a2bb8ca75e1\",\"object\":\"chat.completion.chunk\",\"created\":1777411135,\"choices\":[{\"index\":0,\"text\":\"\",\"logprobs\":null,\"finish_reason\":\"tool_calls\",\"seed\":9033012299842426000,\"delta\":{\"token_id\":128009,\"role\":\"assistant\",\"content\":\"\"}}],\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\",\"usage\":{\"prompt_tokens\":194,\"completion_tokens\":19,\"total_tokens\":213,\"cached_tokens\":0}}\n\ndata: [DONE]\n\n" + } + } + ] +} diff --git a/packages/llm/test/fixtures/recordings/openai-responses-cache/reports-cached-tokens-on-identical-second-call.json b/packages/llm/test/fixtures/recordings/openai-responses-cache/reports-cached-tokens-on-identical-second-call.json new file mode 100644 index 0000000000..25b561197c --- /dev/null +++ b/packages/llm/test/fixtures/recordings/openai-responses-cache/reports-cached-tokens-on-identical-second-call.json @@ -0,0 +1,46 @@ +{ + "version": 1, + "metadata": { + "name": "openai-responses-cache/reports-cached-tokens-on-identical-second-call", + "recordedAt": "2026-05-11T01:41:58.951Z", + "tags": ["prefix:openai-responses-cache", "provider:openai", "protocol:openai-responses", "cache"] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.openai.com/v1/responses", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"gpt-4.1-mini\",\"input\":[{\"role\":\"system\",\"content\":\"You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. \"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Say hi.\"}]}],\"prompt_cache_key\":\"recorded-cache-test\",\"max_output_tokens\":16,\"temperature\":0,\"stream\":true}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream; charset=utf-8" + }, + "body": "event: response.created\ndata: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_00b4acfe385b75d6006a0133e252e4819faecb37d96affd4bf\",\"object\":\"response\",\"created_at\":1778463714,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":16,\"max_tool_calls\":null,\"model\":\"gpt-4.1-mini-2025-04-14\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":\"recorded-cache-test\",\"prompt_cache_retention\":\"in_memory\",\"reasoning\":{\"effort\":null,\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":true,\"temperature\":0.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tools\":[],\"top_logprobs\":0,\"top_p\":1.0,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":0}\n\nevent: response.in_progress\ndata: {\"type\":\"response.in_progress\",\"response\":{\"id\":\"resp_00b4acfe385b75d6006a0133e252e4819faecb37d96affd4bf\",\"object\":\"response\",\"created_at\":1778463714,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":16,\"max_tool_calls\":null,\"model\":\"gpt-4.1-mini-2025-04-14\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":\"recorded-cache-test\",\"prompt_cache_retention\":\"in_memory\",\"reasoning\":{\"effort\":null,\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":true,\"temperature\":0.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tools\":[],\"top_logprobs\":0,\"top_p\":1.0,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":1}\n\nevent: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"msg_00b4acfe385b75d6006a0133e42ad8819f83824a88e1160e09\",\"type\":\"message\",\"status\":\"in_progress\",\"content\":[],\"role\":\"assistant\"},\"output_index\":0,\"sequence_number\":2}\n\nevent: response.content_part.added\ndata: {\"type\":\"response.content_part.added\",\"content_index\":0,\"item_id\":\"msg_00b4acfe385b75d6006a0133e42ad8819f83824a88e1160e09\",\"output_index\":0,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"\"},\"sequence_number\":3}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"Hi\",\"item_id\":\"msg_00b4acfe385b75d6006a0133e42ad8819f83824a88e1160e09\",\"logprobs\":[],\"obfuscation\":\"NSLkknb2f6J7MB\",\"output_index\":0,\"sequence_number\":4}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\".\",\"item_id\":\"msg_00b4acfe385b75d6006a0133e42ad8819f83824a88e1160e09\",\"logprobs\":[],\"obfuscation\":\"ywmEAhs1uKOLkln\",\"output_index\":0,\"sequence_number\":5}\n\nevent: response.output_text.done\ndata: {\"type\":\"response.output_text.done\",\"content_index\":0,\"item_id\":\"msg_00b4acfe385b75d6006a0133e42ad8819f83824a88e1160e09\",\"logprobs\":[],\"output_index\":0,\"sequence_number\":6,\"text\":\"Hi.\"}\n\nevent: response.content_part.done\ndata: {\"type\":\"response.content_part.done\",\"content_index\":0,\"item_id\":\"msg_00b4acfe385b75d6006a0133e42ad8819f83824a88e1160e09\",\"output_index\":0,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"Hi.\"},\"sequence_number\":7}\n\nevent: response.output_item.done\ndata: {\"type\":\"response.output_item.done\",\"item\":{\"id\":\"msg_00b4acfe385b75d6006a0133e42ad8819f83824a88e1160e09\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"Hi.\"}],\"role\":\"assistant\"},\"output_index\":0,\"sequence_number\":8}\n\nevent: response.completed\ndata: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_00b4acfe385b75d6006a0133e252e4819faecb37d96affd4bf\",\"object\":\"response\",\"created_at\":1778463714,\"status\":\"completed\",\"background\":false,\"completed_at\":1778463716,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":16,\"max_tool_calls\":null,\"model\":\"gpt-4.1-mini-2025-04-14\",\"moderation\":null,\"output\":[{\"id\":\"msg_00b4acfe385b75d6006a0133e42ad8819f83824a88e1160e09\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"Hi.\"}],\"role\":\"assistant\"}],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":\"recorded-cache-test\",\"prompt_cache_retention\":\"in_memory\",\"reasoning\":{\"effort\":null,\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"default\",\"store\":true,\"temperature\":0.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tools\":[],\"top_logprobs\":0,\"top_p\":1.0,\"truncation\":\"disabled\",\"usage\":{\"input_tokens\":4765,\"input_tokens_details\":{\"cached_tokens\":0},\"output_tokens\":3,\"output_tokens_details\":{\"reasoning_tokens\":0},\"total_tokens\":4768},\"user\":null,\"metadata\":{}},\"sequence_number\":9}\n\n" + } + }, + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.openai.com/v1/responses", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"gpt-4.1-mini\",\"input\":[{\"role\":\"system\",\"content\":\"You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. \"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Say hi.\"}]}],\"prompt_cache_key\":\"recorded-cache-test\",\"max_output_tokens\":16,\"temperature\":0,\"stream\":true}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream; charset=utf-8" + }, + "body": "event: response.created\ndata: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_06a66d5dbf005c28006a0133e48a28819d957163a92a5a56cc\",\"object\":\"response\",\"created_at\":1778463716,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":16,\"max_tool_calls\":null,\"model\":\"gpt-4.1-mini-2025-04-14\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":\"recorded-cache-test\",\"prompt_cache_retention\":\"in_memory\",\"reasoning\":{\"effort\":null,\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":true,\"temperature\":0.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tools\":[],\"top_logprobs\":0,\"top_p\":1.0,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":0}\n\nevent: response.in_progress\ndata: {\"type\":\"response.in_progress\",\"response\":{\"id\":\"resp_06a66d5dbf005c28006a0133e48a28819d957163a92a5a56cc\",\"object\":\"response\",\"created_at\":1778463716,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":16,\"max_tool_calls\":null,\"model\":\"gpt-4.1-mini-2025-04-14\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":\"recorded-cache-test\",\"prompt_cache_retention\":\"in_memory\",\"reasoning\":{\"effort\":null,\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":true,\"temperature\":0.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tools\":[],\"top_logprobs\":0,\"top_p\":1.0,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":1}\n\nevent: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"msg_06a66d5dbf005c28006a0133e6a2b0819d90b31eabe0bb0568\",\"type\":\"message\",\"status\":\"in_progress\",\"content\":[],\"role\":\"assistant\"},\"output_index\":0,\"sequence_number\":2}\n\nevent: response.content_part.added\ndata: {\"type\":\"response.content_part.added\",\"content_index\":0,\"item_id\":\"msg_06a66d5dbf005c28006a0133e6a2b0819d90b31eabe0bb0568\",\"output_index\":0,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"\"},\"sequence_number\":3}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"Hi\",\"item_id\":\"msg_06a66d5dbf005c28006a0133e6a2b0819d90b31eabe0bb0568\",\"logprobs\":[],\"obfuscation\":\"qLgi78ygFGnuw7\",\"output_index\":0,\"sequence_number\":4}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\".\",\"item_id\":\"msg_06a66d5dbf005c28006a0133e6a2b0819d90b31eabe0bb0568\",\"logprobs\":[],\"obfuscation\":\"dyQaYugaXCUfkYH\",\"output_index\":0,\"sequence_number\":5}\n\nevent: response.output_text.done\ndata: {\"type\":\"response.output_text.done\",\"content_index\":0,\"item_id\":\"msg_06a66d5dbf005c28006a0133e6a2b0819d90b31eabe0bb0568\",\"logprobs\":[],\"output_index\":0,\"sequence_number\":6,\"text\":\"Hi.\"}\n\nevent: response.content_part.done\ndata: {\"type\":\"response.content_part.done\",\"content_index\":0,\"item_id\":\"msg_06a66d5dbf005c28006a0133e6a2b0819d90b31eabe0bb0568\",\"output_index\":0,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"Hi.\"},\"sequence_number\":7}\n\nevent: response.output_item.done\ndata: {\"type\":\"response.output_item.done\",\"item\":{\"id\":\"msg_06a66d5dbf005c28006a0133e6a2b0819d90b31eabe0bb0568\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"Hi.\"}],\"role\":\"assistant\"},\"output_index\":0,\"sequence_number\":8}\n\nevent: response.completed\ndata: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_06a66d5dbf005c28006a0133e48a28819d957163a92a5a56cc\",\"object\":\"response\",\"created_at\":1778463716,\"status\":\"completed\",\"background\":false,\"completed_at\":1778463718,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":16,\"max_tool_calls\":null,\"model\":\"gpt-4.1-mini-2025-04-14\",\"moderation\":null,\"output\":[{\"id\":\"msg_06a66d5dbf005c28006a0133e6a2b0819d90b31eabe0bb0568\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"Hi.\"}],\"role\":\"assistant\"}],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":\"recorded-cache-test\",\"prompt_cache_retention\":\"in_memory\",\"reasoning\":{\"effort\":null,\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"default\",\"store\":true,\"temperature\":0.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tools\":[],\"top_logprobs\":0,\"top_p\":1.0,\"truncation\":\"disabled\",\"usage\":{\"input_tokens\":4765,\"input_tokens_details\":{\"cached_tokens\":4608},\"output_tokens\":3,\"output_tokens_details\":{\"reasoning_tokens\":0},\"total_tokens\":4768},\"user\":null,\"metadata\":{}},\"sequence_number\":9}\n\n" + } + } + ] +} diff --git a/packages/llm/test/fixtures/recordings/openai-responses/gpt-5-5-drives-a-tool-loop.json b/packages/llm/test/fixtures/recordings/openai-responses/gpt-5-5-drives-a-tool-loop.json new file mode 100644 index 0000000000..a3f2e014df --- /dev/null +++ b/packages/llm/test/fixtures/recordings/openai-responses/gpt-5-5-drives-a-tool-loop.json @@ -0,0 +1,54 @@ +{ + "version": 1, + "metadata": { + "name": "openai-responses/gpt-5-5-drives-a-tool-loop", + "recordedAt": "2026-05-06T00:26:15.209Z", + "tags": [ + "prefix:openai-responses", + "provider:openai", + "protocol:openai-responses", + "tool", + "tool-loop", + "golden", + "flagship" + ] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.openai.com/v1/responses", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Use the get_weather tool, then answer in one short sentence.\"},{\"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}}],\"stream\":true,\"max_output_tokens\":80}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream; charset=utf-8" + }, + "body": "event: response.created\ndata: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_01394305fdec6fdd0069fa8aa414cc81a1908662495e7c9bd9\",\"object\":\"response\",\"created_at\":1778027172,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":80,\"max_tool_calls\":null,\"model\":\"gpt-5.5-2026-04-23\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"effort\":\"medium\",\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":true,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tools\":[{\"type\":\"function\",\"description\":\"Get current weather for a city.\",\"name\":\"get_weather\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":true}],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":0}\n\nevent: response.in_progress\ndata: {\"type\":\"response.in_progress\",\"response\":{\"id\":\"resp_01394305fdec6fdd0069fa8aa414cc81a1908662495e7c9bd9\",\"object\":\"response\",\"created_at\":1778027172,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":80,\"max_tool_calls\":null,\"model\":\"gpt-5.5-2026-04-23\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"effort\":\"medium\",\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":true,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tools\":[{\"type\":\"function\",\"description\":\"Get current weather for a city.\",\"name\":\"get_weather\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":true}],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":1}\n\nevent: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"fc_01394305fdec6fdd0069fa8aa51a3881a1a2e74c58f5c368d4\",\"type\":\"function_call\",\"status\":\"in_progress\",\"arguments\":\"\",\"call_id\":\"call_JCuVTkQxVB3cCmFWx52adJKZ\",\"name\":\"get_weather\"},\"output_index\":0,\"sequence_number\":2}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"{\\\"\",\"item_id\":\"fc_01394305fdec6fdd0069fa8aa51a3881a1a2e74c58f5c368d4\",\"obfuscation\":\"5DTUG002eUNyAN\",\"output_index\":0,\"sequence_number\":3}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"city\",\"item_id\":\"fc_01394305fdec6fdd0069fa8aa51a3881a1a2e74c58f5c368d4\",\"obfuscation\":\"cbezJUlKOHJ8\",\"output_index\":0,\"sequence_number\":4}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"\\\":\\\"\",\"item_id\":\"fc_01394305fdec6fdd0069fa8aa51a3881a1a2e74c58f5c368d4\",\"obfuscation\":\"Du6y75R0eXTqj\",\"output_index\":0,\"sequence_number\":5}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"Paris\",\"item_id\":\"fc_01394305fdec6fdd0069fa8aa51a3881a1a2e74c58f5c368d4\",\"obfuscation\":\"dHUPwHp6aIB\",\"output_index\":0,\"sequence_number\":6}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"\\\"}\",\"item_id\":\"fc_01394305fdec6fdd0069fa8aa51a3881a1a2e74c58f5c368d4\",\"obfuscation\":\"4A6QSCyeBQa1fC\",\"output_index\":0,\"sequence_number\":7}\n\nevent: response.function_call_arguments.done\ndata: {\"type\":\"response.function_call_arguments.done\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\",\"item_id\":\"fc_01394305fdec6fdd0069fa8aa51a3881a1a2e74c58f5c368d4\",\"output_index\":0,\"sequence_number\":8}\n\nevent: response.output_item.done\ndata: {\"type\":\"response.output_item.done\",\"item\":{\"id\":\"fc_01394305fdec6fdd0069fa8aa51a3881a1a2e74c58f5c368d4\",\"type\":\"function_call\",\"status\":\"completed\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\",\"call_id\":\"call_JCuVTkQxVB3cCmFWx52adJKZ\",\"name\":\"get_weather\"},\"output_index\":0,\"sequence_number\":9}\n\nevent: response.completed\ndata: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_01394305fdec6fdd0069fa8aa414cc81a1908662495e7c9bd9\",\"object\":\"response\",\"created_at\":1778027172,\"status\":\"completed\",\"background\":false,\"completed_at\":1778027173,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":80,\"max_tool_calls\":null,\"model\":\"gpt-5.5-2026-04-23\",\"moderation\":null,\"output\":[{\"id\":\"fc_01394305fdec6fdd0069fa8aa51a3881a1a2e74c58f5c368d4\",\"type\":\"function_call\",\"status\":\"completed\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\",\"call_id\":\"call_JCuVTkQxVB3cCmFWx52adJKZ\",\"name\":\"get_weather\"}],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"effort\":\"medium\",\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"default\",\"store\":true,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tools\":[{\"type\":\"function\",\"description\":\"Get current weather for a city.\",\"name\":\"get_weather\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":true}],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":{\"input_tokens\":67,\"input_tokens_details\":{\"cached_tokens\":0},\"output_tokens\":18,\"output_tokens_details\":{\"reasoning_tokens\":0},\"total_tokens\":85},\"user\":null,\"metadata\":{}},\"sequence_number\":10}\n\n" + } + }, + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.openai.com/v1/responses", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Use the get_weather tool, then answer in one short sentence.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]},{\"type\":\"function_call\",\"call_id\":\"call_JCuVTkQxVB3cCmFWx52adJKZ\",\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"},{\"type\":\"function_call_output\",\"call_id\":\"call_JCuVTkQxVB3cCmFWx52adJKZ\",\"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}}],\"stream\":true,\"max_output_tokens\":80}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream; charset=utf-8" + }, + "body": "event: response.created\ndata: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_00daac70c40e5f4c0069fa8aa5a58c819db01baef7149e9043\",\"object\":\"response\",\"created_at\":1778027173,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":80,\"max_tool_calls\":null,\"model\":\"gpt-5.5-2026-04-23\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"effort\":\"medium\",\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":true,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tools\":[{\"type\":\"function\",\"description\":\"Get current weather for a city.\",\"name\":\"get_weather\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":true}],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":0}\n\nevent: response.in_progress\ndata: {\"type\":\"response.in_progress\",\"response\":{\"id\":\"resp_00daac70c40e5f4c0069fa8aa5a58c819db01baef7149e9043\",\"object\":\"response\",\"created_at\":1778027173,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":80,\"max_tool_calls\":null,\"model\":\"gpt-5.5-2026-04-23\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"effort\":\"medium\",\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":true,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tools\":[{\"type\":\"function\",\"description\":\"Get current weather for a city.\",\"name\":\"get_weather\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":true}],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":1}\n\nevent: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"msg_00daac70c40e5f4c0069fa8aa697a8819daf6660168cb19951\",\"type\":\"message\",\"status\":\"in_progress\",\"content\":[],\"phase\":\"final_answer\",\"role\":\"assistant\"},\"output_index\":0,\"sequence_number\":2}\n\nevent: response.content_part.added\ndata: {\"type\":\"response.content_part.added\",\"content_index\":0,\"item_id\":\"msg_00daac70c40e5f4c0069fa8aa697a8819daf6660168cb19951\",\"output_index\":0,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"\"},\"sequence_number\":3}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"It\",\"item_id\":\"msg_00daac70c40e5f4c0069fa8aa697a8819daf6660168cb19951\",\"logprobs\":[],\"obfuscation\":\"chiK1sgLg8rTyK\",\"output_index\":0,\"sequence_number\":4}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"’s\",\"item_id\":\"msg_00daac70c40e5f4c0069fa8aa697a8819daf6660168cb19951\",\"logprobs\":[],\"obfuscation\":\"ltAaX7wDQM1X8W\",\"output_index\":0,\"sequence_number\":5}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" sunny\",\"item_id\":\"msg_00daac70c40e5f4c0069fa8aa697a8819daf6660168cb19951\",\"logprobs\":[],\"obfuscation\":\"a6nggmY4w0\",\"output_index\":0,\"sequence_number\":6}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" and\",\"item_id\":\"msg_00daac70c40e5f4c0069fa8aa697a8819daf6660168cb19951\",\"logprobs\":[],\"obfuscation\":\"Fm6HNREc68IM\",\"output_index\":0,\"sequence_number\":7}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" \",\"item_id\":\"msg_00daac70c40e5f4c0069fa8aa697a8819daf6660168cb19951\",\"logprobs\":[],\"obfuscation\":\"AvKNavT4eKhSpud\",\"output_index\":0,\"sequence_number\":8}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"22\",\"item_id\":\"msg_00daac70c40e5f4c0069fa8aa697a8819daf6660168cb19951\",\"logprobs\":[],\"obfuscation\":\"xfJpoPh3ZBNXow\",\"output_index\":0,\"sequence_number\":9}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"°C\",\"item_id\":\"msg_00daac70c40e5f4c0069fa8aa697a8819daf6660168cb19951\",\"logprobs\":[],\"obfuscation\":\"PbrlZXftzmtJBV\",\"output_index\":0,\"sequence_number\":10}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" in\",\"item_id\":\"msg_00daac70c40e5f4c0069fa8aa697a8819daf6660168cb19951\",\"logprobs\":[],\"obfuscation\":\"PLrf8voVO2egp\",\"output_index\":0,\"sequence_number\":11}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" Paris\",\"item_id\":\"msg_00daac70c40e5f4c0069fa8aa697a8819daf6660168cb19951\",\"logprobs\":[],\"obfuscation\":\"U4wLv1H29b\",\"output_index\":0,\"sequence_number\":12}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\".\",\"item_id\":\"msg_00daac70c40e5f4c0069fa8aa697a8819daf6660168cb19951\",\"logprobs\":[],\"obfuscation\":\"1n14oh7kAoCuo4f\",\"output_index\":0,\"sequence_number\":13}\n\nevent: response.output_text.done\ndata: {\"type\":\"response.output_text.done\",\"content_index\":0,\"item_id\":\"msg_00daac70c40e5f4c0069fa8aa697a8819daf6660168cb19951\",\"logprobs\":[],\"output_index\":0,\"sequence_number\":14,\"text\":\"It’s sunny and 22°C in Paris.\"}\n\nevent: response.content_part.done\ndata: {\"type\":\"response.content_part.done\",\"content_index\":0,\"item_id\":\"msg_00daac70c40e5f4c0069fa8aa697a8819daf6660168cb19951\",\"output_index\":0,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"It’s sunny and 22°C in Paris.\"},\"sequence_number\":15}\n\nevent: response.output_item.done\ndata: {\"type\":\"response.output_item.done\",\"item\":{\"id\":\"msg_00daac70c40e5f4c0069fa8aa697a8819daf6660168cb19951\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"It’s sunny and 22°C in Paris.\"}],\"phase\":\"final_answer\",\"role\":\"assistant\"},\"output_index\":0,\"sequence_number\":16}\n\nevent: response.completed\ndata: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_00daac70c40e5f4c0069fa8aa5a58c819db01baef7149e9043\",\"object\":\"response\",\"created_at\":1778027173,\"status\":\"completed\",\"background\":false,\"completed_at\":1778027174,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":80,\"max_tool_calls\":null,\"model\":\"gpt-5.5-2026-04-23\",\"moderation\":null,\"output\":[{\"id\":\"msg_00daac70c40e5f4c0069fa8aa697a8819daf6660168cb19951\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"It’s sunny and 22°C in Paris.\"}],\"phase\":\"final_answer\",\"role\":\"assistant\"}],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"effort\":\"medium\",\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"default\",\"store\":true,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tools\":[{\"type\":\"function\",\"description\":\"Get current weather for a city.\",\"name\":\"get_weather\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":true}],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":{\"input_tokens\":106,\"input_tokens_details\":{\"cached_tokens\":0},\"output_tokens\":14,\"output_tokens_details\":{\"reasoning_tokens\":0},\"total_tokens\":120},\"user\":null,\"metadata\":{}},\"sequence_number\":17}\n\n" + } + } + ] +} diff --git a/packages/llm/test/fixtures/recordings/openai-responses/gpt-5-5-streams-text.json b/packages/llm/test/fixtures/recordings/openai-responses/gpt-5-5-streams-text.json new file mode 100644 index 0000000000..92c7b7e0f1 --- /dev/null +++ b/packages/llm/test/fixtures/recordings/openai-responses/gpt-5-5-streams-text.json @@ -0,0 +1,28 @@ +{ + "version": 1, + "metadata": { + "name": "openai-responses/gpt-5-5-streams-text", + "recordedAt": "2026-05-06T00:26:10.447Z", + "tags": ["prefix:openai-responses", "provider:openai", "protocol:openai-responses", "flagship"] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.openai.com/v1/responses", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"You are concise.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply with exactly: Hello!\"}]}],\"stream\":true,\"max_output_tokens\":80}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream; charset=utf-8" + }, + "body": "event: response.created\ndata: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_0ea948e2f42449980069fa8aa0e4b4819ca3395b74c53c13fa\",\"object\":\"response\",\"created_at\":1778027168,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":80,\"max_tool_calls\":null,\"model\":\"gpt-5.5-2026-04-23\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"effort\":\"medium\",\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":true,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tools\":[],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":0}\n\nevent: response.in_progress\ndata: {\"type\":\"response.in_progress\",\"response\":{\"id\":\"resp_0ea948e2f42449980069fa8aa0e4b4819ca3395b74c53c13fa\",\"object\":\"response\",\"created_at\":1778027168,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":80,\"max_tool_calls\":null,\"model\":\"gpt-5.5-2026-04-23\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"effort\":\"medium\",\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":true,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tools\":[],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":1}\n\nevent: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"rs_0ea948e2f42449980069fa8aa1d588819cbbcb9b056624d27c\",\"type\":\"reasoning\",\"summary\":[]},\"output_index\":0,\"sequence_number\":2}\n\nevent: response.output_item.done\ndata: {\"type\":\"response.output_item.done\",\"item\":{\"id\":\"rs_0ea948e2f42449980069fa8aa1d588819cbbcb9b056624d27c\",\"type\":\"reasoning\",\"summary\":[]},\"output_index\":0,\"sequence_number\":3}\n\nevent: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"msg_0ea948e2f42449980069fa8aa20e38819cbf5be70e4d02a1c7\",\"type\":\"message\",\"status\":\"in_progress\",\"content\":[],\"phase\":\"final_answer\",\"role\":\"assistant\"},\"output_index\":1,\"sequence_number\":4}\n\nevent: response.content_part.added\ndata: {\"type\":\"response.content_part.added\",\"content_index\":0,\"item_id\":\"msg_0ea948e2f42449980069fa8aa20e38819cbf5be70e4d02a1c7\",\"output_index\":1,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"\"},\"sequence_number\":5}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"Hello\",\"item_id\":\"msg_0ea948e2f42449980069fa8aa20e38819cbf5be70e4d02a1c7\",\"logprobs\":[],\"obfuscation\":\"VTjmFwAGgIo\",\"output_index\":1,\"sequence_number\":6}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"!\",\"item_id\":\"msg_0ea948e2f42449980069fa8aa20e38819cbf5be70e4d02a1c7\",\"logprobs\":[],\"obfuscation\":\"PfjFymS7MZa7aYf\",\"output_index\":1,\"sequence_number\":7}\n\nevent: response.output_text.done\ndata: {\"type\":\"response.output_text.done\",\"content_index\":0,\"item_id\":\"msg_0ea948e2f42449980069fa8aa20e38819cbf5be70e4d02a1c7\",\"logprobs\":[],\"output_index\":1,\"sequence_number\":8,\"text\":\"Hello!\"}\n\nevent: response.content_part.done\ndata: {\"type\":\"response.content_part.done\",\"content_index\":0,\"item_id\":\"msg_0ea948e2f42449980069fa8aa20e38819cbf5be70e4d02a1c7\",\"output_index\":1,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"Hello!\"},\"sequence_number\":9}\n\nevent: response.output_item.done\ndata: {\"type\":\"response.output_item.done\",\"item\":{\"id\":\"msg_0ea948e2f42449980069fa8aa20e38819cbf5be70e4d02a1c7\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"Hello!\"}],\"phase\":\"final_answer\",\"role\":\"assistant\"},\"output_index\":1,\"sequence_number\":10}\n\nevent: response.completed\ndata: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_0ea948e2f42449980069fa8aa0e4b4819ca3395b74c53c13fa\",\"object\":\"response\",\"created_at\":1778027168,\"status\":\"completed\",\"background\":false,\"completed_at\":1778027170,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":80,\"max_tool_calls\":null,\"model\":\"gpt-5.5-2026-04-23\",\"moderation\":null,\"output\":[{\"id\":\"rs_0ea948e2f42449980069fa8aa1d588819cbbcb9b056624d27c\",\"type\":\"reasoning\",\"summary\":[]},{\"id\":\"msg_0ea948e2f42449980069fa8aa20e38819cbf5be70e4d02a1c7\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"Hello!\"}],\"phase\":\"final_answer\",\"role\":\"assistant\"}],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"effort\":\"medium\",\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"default\",\"store\":true,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tools\":[],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":{\"input_tokens\":20,\"input_tokens_details\":{\"cached_tokens\":0},\"output_tokens\":18,\"output_tokens_details\":{\"reasoning_tokens\":10},\"total_tokens\":38},\"user\":null,\"metadata\":{}},\"sequence_number\":11}\n\n" + } + } + ] +} diff --git a/packages/llm/test/fixtures/recordings/openai-responses/gpt-5-5-streams-tool-call.json b/packages/llm/test/fixtures/recordings/openai-responses/gpt-5-5-streams-tool-call.json new file mode 100644 index 0000000000..172b8407e6 --- /dev/null +++ b/packages/llm/test/fixtures/recordings/openai-responses/gpt-5-5-streams-tool-call.json @@ -0,0 +1,28 @@ +{ + "version": 1, + "metadata": { + "name": "openai-responses/gpt-5-5-streams-tool-call", + "recordedAt": "2026-05-06T00:26:12.011Z", + "tags": ["prefix:openai-responses", "provider:openai", "protocol:openai-responses", "tool", "flagship"] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.openai.com/v1/responses", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Call tools exactly as requested.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Call get_weather with city exactly 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}}],\"tool_choice\":{\"type\":\"function\",\"name\":\"get_weather\"},\"stream\":true,\"max_output_tokens\":80}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream; charset=utf-8" + }, + "body": "event: response.created\ndata: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_05200a06f78f5b310069fa8aa28134819eba958e34eb1db6ae\",\"object\":\"response\",\"created_at\":1778027170,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":80,\"max_tool_calls\":null,\"model\":\"gpt-5.5-2026-04-23\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"effort\":\"medium\",\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":true,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":{\"type\":\"function\",\"name\":\"get_weather\"},\"tools\":[{\"type\":\"function\",\"description\":\"Get current weather for a city.\",\"name\":\"get_weather\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":true}],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":0}\n\nevent: response.in_progress\ndata: {\"type\":\"response.in_progress\",\"response\":{\"id\":\"resp_05200a06f78f5b310069fa8aa28134819eba958e34eb1db6ae\",\"object\":\"response\",\"created_at\":1778027170,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":80,\"max_tool_calls\":null,\"model\":\"gpt-5.5-2026-04-23\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"effort\":\"medium\",\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":true,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":{\"type\":\"function\",\"name\":\"get_weather\"},\"tools\":[{\"type\":\"function\",\"description\":\"Get current weather for a city.\",\"name\":\"get_weather\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":true}],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":1}\n\nevent: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"fc_05200a06f78f5b310069fa8aa37ca8819e9f131e85e47bcff9\",\"type\":\"function_call\",\"status\":\"in_progress\",\"arguments\":\"\",\"call_id\":\"call_ZAbAwsIFeJSyPqz3HaHRXBSn\",\"name\":\"get_weather\"},\"output_index\":0,\"sequence_number\":2}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"{\\\"\",\"item_id\":\"fc_05200a06f78f5b310069fa8aa37ca8819e9f131e85e47bcff9\",\"obfuscation\":\"X7dp3R85iTgHxP\",\"output_index\":0,\"sequence_number\":3}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"city\",\"item_id\":\"fc_05200a06f78f5b310069fa8aa37ca8819e9f131e85e47bcff9\",\"obfuscation\":\"ECfxJgedKWUn\",\"output_index\":0,\"sequence_number\":4}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"\\\":\\\"\",\"item_id\":\"fc_05200a06f78f5b310069fa8aa37ca8819e9f131e85e47bcff9\",\"obfuscation\":\"BYRjhhZxbw5AR\",\"output_index\":0,\"sequence_number\":5}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"Paris\",\"item_id\":\"fc_05200a06f78f5b310069fa8aa37ca8819e9f131e85e47bcff9\",\"obfuscation\":\"lmbnKOW4qyI\",\"output_index\":0,\"sequence_number\":6}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"\\\"}\",\"item_id\":\"fc_05200a06f78f5b310069fa8aa37ca8819e9f131e85e47bcff9\",\"obfuscation\":\"2PHhvsR2H0PNaP\",\"output_index\":0,\"sequence_number\":7}\n\nevent: response.function_call_arguments.done\ndata: {\"type\":\"response.function_call_arguments.done\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\",\"item_id\":\"fc_05200a06f78f5b310069fa8aa37ca8819e9f131e85e47bcff9\",\"output_index\":0,\"sequence_number\":8}\n\nevent: response.output_item.done\ndata: {\"type\":\"response.output_item.done\",\"item\":{\"id\":\"fc_05200a06f78f5b310069fa8aa37ca8819e9f131e85e47bcff9\",\"type\":\"function_call\",\"status\":\"completed\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\",\"call_id\":\"call_ZAbAwsIFeJSyPqz3HaHRXBSn\",\"name\":\"get_weather\"},\"output_index\":0,\"sequence_number\":9}\n\nevent: response.completed\ndata: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_05200a06f78f5b310069fa8aa28134819eba958e34eb1db6ae\",\"object\":\"response\",\"created_at\":1778027170,\"status\":\"completed\",\"background\":false,\"completed_at\":1778027171,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":80,\"max_tool_calls\":null,\"model\":\"gpt-5.5-2026-04-23\",\"moderation\":null,\"output\":[{\"id\":\"fc_05200a06f78f5b310069fa8aa37ca8819e9f131e85e47bcff9\",\"type\":\"function_call\",\"status\":\"completed\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\",\"call_id\":\"call_ZAbAwsIFeJSyPqz3HaHRXBSn\",\"name\":\"get_weather\"}],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"effort\":\"medium\",\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"default\",\"store\":true,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":{\"type\":\"function\",\"name\":\"get_weather\"},\"tools\":[{\"type\":\"function\",\"description\":\"Get current weather for a city.\",\"name\":\"get_weather\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":true}],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":{\"input_tokens\":61,\"input_tokens_details\":{\"cached_tokens\":0},\"output_tokens\":18,\"output_tokens_details\":{\"reasoning_tokens\":0},\"total_tokens\":79},\"user\":null,\"metadata\":{}},\"sequence_number\":10}\n\n" + } + } + ] +} diff --git a/packages/llm/test/generate-object.test.ts b/packages/llm/test/generate-object.test.ts new file mode 100644 index 0000000000..66e39f7770 --- /dev/null +++ b/packages/llm/test/generate-object.test.ts @@ -0,0 +1,185 @@ +import { describe, expect, test } from "bun:test" +import { Effect, Schema } from "effect" +import { LLM } from "../src" +import * as OpenAIChat from "../src/protocols/openai-chat" +import { Tool, toDefinitions } from "../src/tool" +import { it } from "./lib/effect" +import { dynamicResponse } from "./lib/http" +import { finishChunk, toolCallChunk } from "./lib/openai-chunks" +import { sseEvents } from "./lib/sse" + +type OpenAIChatBody = { + readonly tool_choice?: unknown + readonly tools?: ReadonlyArray<{ + readonly function: { + readonly parameters: unknown + } + }> +} + +const model = OpenAIChat.model({ + id: "gpt-4o-mini", + baseURL: "https://api.openai.test/v1/", + headers: { authorization: "Bearer test" }, +}) + +const Json = Schema.fromJsonString(Schema.Unknown) +const decodeJson = Schema.decodeUnknownSync(Json) +const decodeBody = (text: string): OpenAIChatBody => decodeJson(text) as OpenAIChatBody + +describe("Tool.make (dynamic JSON Schema)", () => { + test("forwards JSON Schema and description through toDefinitions", () => { + const jsonSchema = { + type: "object" as const, + properties: { city: { type: "string" } }, + required: ["city"], + } + const lookup = Tool.make({ + description: "Look up something", + jsonSchema, + execute: () => Effect.succeed({ ok: true }), + }) + const [definition] = toDefinitions({ lookup }) + expect(definition?.name).toBe("lookup") + expect(definition?.description).toBe("Look up something") + expect(definition?.inputSchema).toEqual(jsonSchema) + }) + + test("execute receives the raw input untouched", async () => { + const seen: unknown[] = [] + const tool = Tool.make({ + description: "echo", + jsonSchema: { type: "object" }, + execute: (params) => + Effect.sync(() => { + seen.push(params) + return { ok: true } + }), + }) + const result = await Effect.runPromise(tool.execute({ hello: "world" })) + expect(seen).toEqual([{ hello: "world" }]) + expect(result).toEqual({ ok: true }) + }) +}) + +describe("LLM.generateObject", () => { + it.effect("forces a synthetic tool call and decodes the input", () => + Effect.gen(function* () { + const bodies: OpenAIChatBody[] = [] + const layer = dynamicResponse((input) => + Effect.sync(() => { + bodies.push(decodeBody(input.text)) + return input.respond( + sseEvents( + toolCallChunk("call_1", "generate_object", '{"city":"Paris","temp":22}'), + finishChunk("tool_calls"), + ), + { headers: { "content-type": "text/event-stream" } }, + ) + }), + ) + + const response = yield* LLM.generateObject({ + model, + prompt: "Return a structured weather report.", + schema: Schema.Struct({ city: Schema.String, temp: Schema.Number }), + }).pipe(Effect.provide(layer)) + + expect(response.object).toEqual({ city: "Paris", temp: 22 }) + expect(response.response.toolCalls).toHaveLength(1) + expect(bodies).toHaveLength(1) + expect(bodies[0].tool_choice).toEqual({ type: "function", function: { name: "generate_object" } }) + const tool = bodies[0].tools?.[0] + expect(bodies[0].tools).toHaveLength(1) + expect(tool).toMatchObject({ + type: "function", + function: { name: "generate_object" }, + }) + const params = tool?.function.parameters as { + readonly type?: unknown + readonly required?: unknown + readonly properties?: Record + } + expect(params.type).toBe("object") + expect(params.required).toEqual(["city", "temp"]) + expect(params.properties?.city).toMatchObject({ type: "string" }) + expect(params.properties?.temp).toBeDefined() + }), + ) + + it.effect("accepts a raw JSON Schema and returns the input untouched", () => + Effect.gen(function* () { + const bodies: OpenAIChatBody[] = [] + const layer = dynamicResponse((input) => + Effect.sync(() => { + bodies.push(decodeBody(input.text)) + return input.respond( + sseEvents(toolCallChunk("call_1", "generate_object", '{"name":"Ada","age":30}'), finishChunk("tool_calls")), + { headers: { "content-type": "text/event-stream" } }, + ) + }), + ) + + const response = yield* LLM.generateObject({ + model, + prompt: "Extract the user.", + jsonSchema: { + type: "object", + properties: { name: { type: "string" }, age: { type: "number" } }, + required: ["name", "age"], + }, + }).pipe(Effect.provide(layer)) + + expect(response.object).toEqual({ name: "Ada", age: 30 }) + expect(bodies[0].tools?.[0]?.function.parameters).toEqual({ + type: "object", + properties: { name: { type: "string" }, age: { type: "number" } }, + required: ["name", "age"], + }) + }), + ) + + it.effect("fails when the model does not call the synthetic tool", () => + Effect.gen(function* () { + const layer = dynamicResponse((input) => + Effect.sync(() => + input.respond(sseEvents({ id: "x", choices: [{ delta: { content: "no thanks" }, finish_reason: "stop" }] }), { + headers: { "content-type": "text/event-stream" }, + }), + ), + ) + + const exit = yield* LLM.generateObject({ + model, + prompt: "Return a structured value.", + schema: Schema.Struct({ value: Schema.Number }), + }).pipe(Effect.provide(layer), Effect.exit) + + expect(exit._tag).toBe("Failure") + }), + ) + + it.effect("fails with a decode error when the tool input does not match the schema", () => + Effect.gen(function* () { + const layer = dynamicResponse((input) => + Effect.sync(() => + input.respond( + sseEvents( + toolCallChunk("call_1", "generate_object", '{"value":"not-a-number"}'), + finishChunk("tool_calls"), + ), + { headers: { "content-type": "text/event-stream" } }, + ), + ), + ) + + const exit = yield* LLM.generateObject({ + model, + prompt: "Return a structured value.", + schema: Schema.Struct({ value: Schema.Number }), + }).pipe(Effect.provide(layer), Effect.exit) + + expect(exit._tag).toBe("Failure") + }), + ) +}) diff --git a/packages/llm/test/lib/effect.ts b/packages/llm/test/lib/effect.ts new file mode 100644 index 0000000000..05cf017b2b --- /dev/null +++ b/packages/llm/test/lib/effect.ts @@ -0,0 +1,50 @@ +import { test, type TestOptions } from "bun:test" +import { Cause, Effect, Exit, Layer } from "effect" +import type * as Scope from "effect/Scope" +import * as TestClock from "effect/testing/TestClock" +import * as TestConsole from "effect/testing/TestConsole" + +type Body = Effect.Effect | (() => Effect.Effect) + +const body = (value: Body) => Effect.suspend(() => (typeof value === "function" ? value() : value)) + +const run = (value: Body, layer: Layer.Layer) => + Effect.gen(function* () { + const exit = yield* body(value).pipe(Effect.scoped, Effect.provide(layer), Effect.exit) + if (Exit.isFailure(exit)) { + for (const err of Cause.prettyErrors(exit.cause)) { + yield* Effect.logError(err) + } + } + return yield* exit + }).pipe(Effect.runPromise) + +const make = (testLayer: Layer.Layer, liveLayer: Layer.Layer) => { + const effect = (name: string, value: Body, opts?: number | TestOptions) => + test(name, () => run(value, testLayer), opts) + + effect.only = (name: string, value: Body, opts?: number | TestOptions) => + test.only(name, () => run(value, testLayer), opts) + + effect.skip = (name: string, value: Body, opts?: number | TestOptions) => + test.skip(name, () => run(value, testLayer), opts) + + const live = (name: string, value: Body, opts?: number | TestOptions) => + test(name, () => run(value, liveLayer), opts) + + live.only = (name: string, value: Body, opts?: number | TestOptions) => + test.only(name, () => run(value, liveLayer), opts) + + live.skip = (name: string, value: Body, opts?: number | TestOptions) => + test.skip(name, () => run(value, liveLayer), opts) + + return { effect, live } +} + +const testEnv = Layer.mergeAll(TestConsole.layer, TestClock.layer()) +const liveEnv = TestConsole.layer + +export const it = make(testEnv, liveEnv) + +export const testEffect = (layer: Layer.Layer) => + make(Layer.provideMerge(layer, testEnv), Layer.provideMerge(layer, liveEnv)) diff --git a/packages/llm/test/lib/http.ts b/packages/llm/test/lib/http.ts new file mode 100644 index 0000000000..cfe7e6883b --- /dev/null +++ b/packages/llm/test/lib/http.ts @@ -0,0 +1,96 @@ +import { Effect, Layer, Ref } from "effect" +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" +import { LLMClient, RequestExecutor } from "../../src/route" +import type { Service as LLMClientService } from "../../src/route/client" +import type { Service as RequestExecutorService } from "../../src/route/executor" + +export type HandlerInput = { + readonly request: HttpClientRequest.HttpClientRequest + readonly text: string + readonly respond: ( + body: ConstructorParameters[0], + init?: ResponseInit, + ) => HttpClientResponse.HttpClientResponse +} + +export type Handler = (input: HandlerInput) => Effect.Effect + +const handlerLayer = (handler: Handler): Layer.Layer => + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.gen(function* () { + const web = yield* HttpClientRequest.toWeb(request).pipe(Effect.orDie) + const text = yield* Effect.promise(() => web.text()) + return yield* handler({ + request, + text, + respond: (body, init) => HttpClientResponse.fromWeb(request, new Response(body, init)), + }) + }), + ), + ) + +export type RuntimeEnv = RequestExecutorService | LLMClientService + +export const runtimeLayer = (layer: Layer.Layer): Layer.Layer => { + const requestExecutorLayer = RequestExecutor.layer.pipe(Layer.provide(layer)) + const llmClientLayer = LLMClient.layer.pipe(Layer.provide(requestExecutorLayer)) + return Layer.mergeAll(requestExecutorLayer, llmClientLayer) +} + +const SSE_HEADERS = { "content-type": "text/event-stream" } as const + +/** + * Layer that returns a single fixed response body. Use for stream-parser + * fixture tests where the request shape is irrelevant. The body type widens + * to whatever `Response` accepts so binary fixtures (`Uint8Array`, + * `ReadableStream`, etc.) flow through without casts. + */ +export const fixedResponse = ( + body: ConstructorParameters[0], + init: ResponseInit = { headers: SSE_HEADERS }, +) => runtimeLayer(handlerLayer((input) => Effect.succeed(input.respond(body, init)))) + +/** + * Layer that builds a response per request. Useful for echo servers. + */ +export const dynamicResponse = (handler: Handler) => runtimeLayer(handlerLayer(handler)) + +/** + * Layer that emits the supplied SSE chunks and then aborts mid-stream. Used to + * exercise transport errors that surface during parsing. + */ +export const truncatedStream = (chunks: ReadonlyArray) => + dynamicResponse((input) => + Effect.sync(() => { + const encoder = new TextEncoder() + const stream = new ReadableStream({ + start(controller) { + for (const chunk of chunks) controller.enqueue(encoder.encode(chunk)) + controller.error(new Error("connection reset")) + }, + }) + return input.respond(stream, { headers: SSE_HEADERS }) + }), + ) + +/** + * Layer that returns successive bodies on each request. Useful for scripting + * multi-step model exchanges (e.g. tool-call loops). The last body in the + * array is reused if the test makes more requests than scripted. + */ +export const scriptedResponses = (bodies: ReadonlyArray, init: ResponseInit = { headers: SSE_HEADERS }) => { + if (bodies.length === 0) throw new Error("scriptedResponses requires at least one body") + return Layer.unwrap( + Effect.gen(function* () { + const cursor = yield* Ref.make(0) + return dynamicResponse((input) => + Effect.gen(function* () { + const index = yield* Ref.getAndUpdate(cursor, (n) => n + 1) + return input.respond(bodies[index] ?? bodies[bodies.length - 1], init) + }), + ) + }), + ) +} diff --git a/packages/llm/test/lib/openai-chunks.ts b/packages/llm/test/lib/openai-chunks.ts new file mode 100644 index 0000000000..77a7c919e1 --- /dev/null +++ b/packages/llm/test/lib/openai-chunks.ts @@ -0,0 +1,27 @@ +/** + * Shared chunk shapes for OpenAI Chat / OpenAI-compatible Chat fixture tests. + * Multiple test files build the same `{ id, choices: [{ delta, finish_reason }], usage }` + * envelope; consolidating here keeps tool-call event shapes consistent. + */ + +const FIXTURE_ID = "chatcmpl_fixture" + +export const deltaChunk = (delta: object, finishReason: string | null = null) => ({ + id: FIXTURE_ID, + choices: [{ delta, finish_reason: finishReason }], + usage: null, +}) + +export const usageChunk = (usage: object) => ({ + id: FIXTURE_ID, + choices: [], + usage, +}) + +export const finishChunk = (reason: string) => deltaChunk({}, reason) + +export const toolCallChunk = (id: string, name: string, args: string, index = 0) => + deltaChunk({ + role: "assistant", + tool_calls: [{ index, id, function: { name, arguments: args } }], + }) diff --git a/packages/llm/test/lib/sse.ts b/packages/llm/test/lib/sse.ts new file mode 100644 index 0000000000..80b275d296 --- /dev/null +++ b/packages/llm/test/lib/sse.ts @@ -0,0 +1,17 @@ +/** + * Helpers for building deterministic SSE bodies in tests. + * + * Inline template-literal SSE strings are hard to write and review when chunks + * contain JSON; this helper accepts plain values and serializes them, so test + * authors only think about the chunk shapes, not the wire format. + */ +export const sseEvents = (...chunks: ReadonlyArray): string => + `${chunks.map(formatChunk).join("")}data: [DONE]\n\n` + +const formatChunk = (chunk: unknown) => `data: ${typeof chunk === "string" ? chunk : JSON.stringify(chunk)}\n\n` + +/** + * Build an SSE body from already-serialized strings (used when the chunk shape + * itself is part of what's being tested, e.g. malformed chunks). + */ +export const sseRaw = (...lines: ReadonlyArray): string => lines.map((line) => `${line}\n\n`).join("") diff --git a/packages/llm/test/lib/tool-runtime.ts b/packages/llm/test/lib/tool-runtime.ts new file mode 100644 index 0000000000..a12941603a --- /dev/null +++ b/packages/llm/test/lib/tool-runtime.ts @@ -0,0 +1,9 @@ +import { Stream } from "effect" +import { LLMClient } from "../../src/route" +import type { Tools } from "../../src/tool" +import type { RunOptions } from "../../src/tool-runtime" + +type CompatRunOptions = RunOptions & { readonly maxSteps?: number } + +export const runTools = (options: CompatRunOptions) => + LLMClient.stream({ ...options, stopWhen: options.stopWhen ?? LLMClient.stepCountIs(options.maxSteps ?? 10) }) diff --git a/packages/llm/test/llm.test.ts b/packages/llm/test/llm.test.ts new file mode 100644 index 0000000000..e9ef58afa8 --- /dev/null +++ b/packages/llm/test/llm.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, test } from "bun:test" +import { LLM, LLMResponse } from "../src" +import { LLMRequest, Message, ModelRef, ToolChoice, ToolDefinition } from "../src/schema" + +describe("llm constructors", () => { + test("builds canonical schema classes from ergonomic input", () => { + const request = LLM.request({ + id: "req_1", + model: LLM.model({ id: "fake-model", provider: "fake", route: "openai-chat", baseURL: "https://fake.local" }), + system: "You are concise.", + prompt: "Say hello.", + }) + + expect(request).toBeInstanceOf(LLMRequest) + expect(request.model).toBeInstanceOf(ModelRef) + expect(request.messages[0]).toBeInstanceOf(Message) + expect(request.system).toEqual([{ type: "text", text: "You are concise." }]) + expect(request.messages[0]?.content).toEqual([{ type: "text", text: "Say hello." }]) + expect(request.generation).toBeUndefined() + expect(request.tools).toEqual([]) + }) + + test("updates requests without spreading schema class instances", () => { + const base = LLM.request({ + id: "req_1", + model: LLM.model({ id: "fake-model", provider: "fake", route: "openai-chat", baseURL: "https://fake.local" }), + prompt: "Say hello.", + }) + const updated = LLM.updateRequest(base, { + generation: { maxTokens: 20 }, + messages: [...base.messages, LLM.assistant("Hi.")], + }) + + expect(updated).toBeInstanceOf(LLMRequest) + expect(updated.id).toBe("req_1") + expect(updated.model).toEqual(base.model) + expect(updated.generation).toEqual({ maxTokens: 20 }) + expect(updated.messages.map((message) => message.role)).toEqual(["user", "assistant"]) + }) + + test("keeps request options separate from model defaults", () => { + const request = LLM.request({ + model: LLM.model({ + id: "fake-model", + provider: "fake", + route: "openai-chat", + baseURL: "https://fake.local", + generation: { maxTokens: 100, temperature: 1 }, + providerOptions: { openai: { store: false, metadata: { model: true } } }, + http: { body: { metadata: { model: true } }, headers: { "x-shared": "model" }, query: { model: "1" } }, + }), + prompt: "Say hello.", + generation: { temperature: 0 }, + providerOptions: { openai: { store: true, metadata: { request: true } } }, + http: { body: { metadata: { request: true } }, headers: { "x-shared": "request" }, query: { request: "1" } }, + }) + + expect(request.generation).toEqual({ temperature: 0 }) + expect(request.providerOptions).toEqual({ openai: { store: true, metadata: { request: true } } }) + expect(request.http).toEqual({ + body: { metadata: { request: true } }, + headers: { "x-shared": "request" }, + query: { request: "1" }, + }) + }) + + test("updates canonical requests from the request datatype", () => { + const base = LLM.request({ + id: "req_1", + model: LLM.model({ id: "fake-model", provider: "fake", route: "openai-chat", baseURL: "https://fake.local" }), + prompt: "Say hello.", + }) + const updated = LLMRequest.update(base, { messages: [...base.messages, LLM.assistant("Hi.")] }) + + expect(updated).toBeInstanceOf(LLMRequest) + expect(updated.id).toBe("req_1") + expect(LLMRequest.input(updated).id).toBe("req_1") + expect(updated.messages.map((message) => message.role)).toEqual(["user", "assistant"]) + expect(LLMRequest.update(updated, {})).toBe(updated) + }) + + test("updates canonical models from the model datatype", () => { + const base = LLM.model({ id: "fake-model", provider: "fake", route: "openai-chat", baseURL: "https://fake.local" }) + const updated = ModelRef.update(base, { route: "openai-responses" }) + + expect(updated).toBeInstanceOf(ModelRef) + expect(String(updated.id)).toBe("fake-model") + expect(updated.route).toBe("openai-responses") + expect(String(ModelRef.input(updated).provider)).toBe("fake") + expect(ModelRef.update(updated, {})).toBe(updated) + }) + + test("builds tool choices from names and tools", () => { + const tool = LLM.toolDefinition({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }) + + expect(tool).toBeInstanceOf(ToolDefinition) + expect(LLM.toolChoice("lookup")).toEqual(new ToolChoice({ type: "tool", name: "lookup" })) + expect(LLM.toolChoiceName("required")).toEqual(new ToolChoice({ type: "tool", name: "required" })) + expect(LLM.toolChoice(tool)).toEqual(new ToolChoice({ type: "tool", name: "lookup" })) + }) + + test("builds tool choice modes from reserved strings", () => { + expect(LLM.toolChoice("auto")).toEqual(new ToolChoice({ type: "auto" })) + expect(LLM.toolChoice("none")).toEqual(new ToolChoice({ type: "none" })) + expect(LLM.toolChoice("required")).toEqual(new ToolChoice({ type: "required" })) + expect( + LLM.request({ + model: LLM.model({ id: "fake-model", provider: "fake", route: "openai-chat", baseURL: "https://fake.local" }), + prompt: "Use tools if needed.", + toolChoice: "required", + }).toolChoice, + ).toEqual(new ToolChoice({ type: "required" })) + }) + + test("builds assistant tool calls and tool result messages", () => { + const call = LLM.toolCall({ id: "call_1", name: "lookup", input: { query: "weather" } }) + const result = LLM.toolResult({ id: "call_1", name: "lookup", result: { temperature: 72 } }) + + expect(LLM.assistant([call]).content).toEqual([call]) + expect(LLM.toolMessage(result).content).toEqual([ + { type: "tool-result", id: "call_1", name: "lookup", result: { type: "json", value: { temperature: 72 } } }, + ]) + }) + + test("extracts output text from response events", () => { + expect( + LLMResponse.text({ + events: [ + { type: "text-delta", id: "text-0", text: "hi" }, + { type: "request-finish", reason: "stop" }, + ], + }), + ).toBe("hi") + }) +}) diff --git a/packages/llm/test/provider.types.ts b/packages/llm/test/provider.types.ts new file mode 100644 index 0000000000..a04ce8bc60 --- /dev/null +++ b/packages/llm/test/provider.types.ts @@ -0,0 +1,39 @@ +import { Provider } from "../src/provider" +import { ProviderID, type ModelRef } from "../src/schema" + +declare const model: (id: string) => ModelRef +declare const requiredModel: (id: string, options: { readonly baseURL: string }) => ModelRef +declare const chat: (id: string, options: { readonly apiKey: string }) => ModelRef + +Provider.make({ + id: ProviderID.make("example"), + model, +}) + +Provider.make({ + id: ProviderID.make("bad"), + model, + // @ts-expect-error provider definitions should not grow accidental top-level fields. + routes: [], +}) + +const requiredProvider = Provider.make({ + id: ProviderID.make("required"), + model: requiredModel, +}) + +requiredProvider.model("custom", { baseURL: "https://example.com/v1" }) + +// @ts-expect-error Provider.make preserves required model options. +requiredProvider.model("custom") + +const multiApiProvider = Provider.make({ + id: ProviderID.make("multi-api"), + model, + apis: { chat }, +}) + +multiApiProvider.apis.chat("chat-model", { apiKey: "key" }) + +// @ts-expect-error Provider.make preserves API-specific option types. +multiApiProvider.apis.chat("chat-model") diff --git a/packages/llm/test/provider/anthropic-messages-cache.recorded.test.ts b/packages/llm/test/provider/anthropic-messages-cache.recorded.test.ts new file mode 100644 index 0000000000..cb144b1a5d --- /dev/null +++ b/packages/llm/test/provider/anthropic-messages-cache.recorded.test.ts @@ -0,0 +1,56 @@ +import { Redactor } from "@opencode-ai/http-recorder" +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import { CacheHint, LLM } from "../../src" +import { LLMClient } from "../../src/route" +import * as AnthropicMessages from "../../src/protocols/anthropic-messages" +import { LARGE_CACHEABLE_SYSTEM } from "../recorded-scenarios" +import { recordedTests } from "../recorded-test" + +const model = AnthropicMessages.model({ + id: "claude-haiku-4-5-20251001", + apiKey: process.env.ANTHROPIC_API_KEY ?? "fixture", +}) + +// Two identical generations in a row. The first call writes the prefix into +// Anthropic's cache; the second should report a cache read against the same +// prefix. Cassette captures both interactions in order. +const cacheRequest = LLM.request({ + id: "recorded_anthropic_cache", + model, + system: [{ type: "text", text: LARGE_CACHEABLE_SYSTEM, cache: new CacheHint({ type: "ephemeral" }) }], + prompt: "Say hi.", + // Manual hint on the system part is the only marker we want here — skip the + // auto-policy's latest-user-message breakpoint so the cassette body matches. + cache: "none", + generation: { maxTokens: 16, temperature: 0 }, +}) + +const recorded = recordedTests({ + prefix: "anthropic-messages-cache", + provider: "anthropic", + protocol: "anthropic-messages", + requires: ["ANTHROPIC_API_KEY"], + // Two identical requests in one cassette — match by recording order so the + // second call replays the cached-hit interaction. + options: { + dispatch: "sequential", + redactor: Redactor.defaults({ requestHeaders: { allow: ["content-type", "anthropic-version"] } }), + }, +}) + +describe("Anthropic Messages cache recorded", () => { + recorded.effect.with("writes then reads cache_control on identical second call", { tags: ["cache"] }, () => + Effect.gen(function* () { + const first = yield* LLMClient.generate(cacheRequest) + // The first call may write the cache (cacheWriteInputTokens > 0) or it + // may be a fresh miss (both fields 0) depending on whether the prefix is + // already warm on Anthropic's side. The assertion that matters is that + // the SECOND call reports a non-zero cache read. + expect(first.usage?.cacheReadInputTokens ?? 0).toBeGreaterThanOrEqual(0) + + const second = yield* LLMClient.generate(cacheRequest) + expect(second.usage?.cacheReadInputTokens ?? 0).toBeGreaterThan(0) + }), + ) +}) diff --git a/packages/llm/test/provider/anthropic-messages.recorded.test.ts b/packages/llm/test/provider/anthropic-messages.recorded.test.ts new file mode 100644 index 0000000000..aa5b258d3d --- /dev/null +++ b/packages/llm/test/provider/anthropic-messages.recorded.test.ts @@ -0,0 +1,47 @@ +import { Redactor } from "@opencode-ai/http-recorder" +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import { LLM, LLMError } from "../../src" +import { LLMClient } from "../../src/route" +import * as AnthropicMessages from "../../src/protocols/anthropic-messages" +import { weatherToolName } from "../recorded-scenarios" +import { recordedTests } from "../recorded-test" + +const model = AnthropicMessages.model({ + id: "claude-haiku-4-5-20251001", + apiKey: process.env.ANTHROPIC_API_KEY ?? "fixture", +}) + +const malformedToolOrderRequest = LLM.request({ + id: "recorded_anthropic_malformed_tool_order", + model, + messages: [ + LLM.assistant([ + LLM.toolCall({ id: "call_1", name: weatherToolName, input: { city: "Paris" } }), + { type: "text", text: "I will check the weather." }, + ]), + LLM.toolMessage({ id: "call_1", name: weatherToolName, result: { temperature: "72F" } }), + LLM.user("Use that result to answer briefly."), + ], + tools: [{ name: weatherToolName, description: "Get weather", inputSchema: { type: "object", properties: {} } }], +}) + +const recorded = recordedTests({ + prefix: "anthropic-messages", + provider: "anthropic", + protocol: "anthropic-messages", + requires: ["ANTHROPIC_API_KEY"], + options: { redactor: Redactor.defaults({ requestHeaders: { allow: ["content-type", "anthropic-version"] } }) }, +}) + +describe("Anthropic Messages sad-path recorded", () => { + recorded.effect.with("rejects malformed assistant tool order", { tags: ["tool", "sad-path"] }, () => + Effect.gen(function* () { + const error = yield* LLMClient.generate(malformedToolOrderRequest).pipe(Effect.flip) + + expect(error).toBeInstanceOf(LLMError) + expect(error.reason).toMatchObject({ _tag: "InvalidRequest" }) + expect(error.message).toContain("HTTP 400") + }), + ) +}) diff --git a/packages/llm/test/provider/anthropic-messages.test.ts b/packages/llm/test/provider/anthropic-messages.test.ts new file mode 100644 index 0000000000..a867d16591 --- /dev/null +++ b/packages/llm/test/provider/anthropic-messages.test.ts @@ -0,0 +1,518 @@ +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import { CacheHint, LLM, LLMError, Usage } from "../../src" +import { LLMClient } from "../../src/route" +import * as AnthropicMessages from "../../src/protocols/anthropic-messages" +import { it } from "../lib/effect" +import { fixedResponse } from "../lib/http" +import { sseEvents } from "../lib/sse" + +const model = AnthropicMessages.model({ + id: "claude-sonnet-4-5", + baseURL: "https://api.anthropic.test/v1/", + headers: { "x-api-key": "test" }, +}) + +const request = LLM.request({ + id: "req_1", + model, + system: { type: "text", text: "You are concise.", cache: new CacheHint({ type: "ephemeral" }) }, + prompt: "Say hello.", + // This fixture predates the `cache: "auto"` default; pin the policy off so + // existing wire-shape assertions only see the manual hint on the system part. + cache: "none", + generation: { maxTokens: 20, temperature: 0 }, +}) + +describe("Anthropic Messages route", () => { + it.effect("prepares Anthropic Messages target", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare(request) + + expect(prepared.body).toEqual({ + model: "claude-sonnet-4-5", + system: [{ type: "text", text: "You are concise.", cache_control: { type: "ephemeral" } }], + messages: [{ role: "user", content: [{ type: "text", text: "Say hello." }] }], + stream: true, + max_tokens: 20, + temperature: 0, + }) + }), + ) + + it.effect("prepares tool call and tool result messages", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + id: "req_tool_result", + model, + messages: [ + LLM.user("What is the weather?"), + LLM.assistant([LLM.toolCall({ id: "call_1", name: "lookup", input: { query: "weather" } })]), + LLM.toolMessage({ id: "call_1", name: "lookup", result: { forecast: "sunny" } }), + ], + cache: "none", + }), + ) + + expect(prepared.body).toEqual({ + model: "claude-sonnet-4-5", + messages: [ + { role: "user", content: [{ type: "text", text: "What is the weather?" }] }, + { + role: "assistant", + content: [{ type: "tool_use", id: "call_1", name: "lookup", input: { query: "weather" } }], + }, + { role: "user", content: [{ type: "tool_result", tool_use_id: "call_1", content: '{"forecast":"sunny"}' }] }, + ], + stream: true, + max_tokens: 4096, + }) + }), + ) + + it.effect("lowers preserved Anthropic reasoning signature metadata", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + model, + messages: [ + LLM.assistant([ + { type: "reasoning", text: "thinking", providerMetadata: { anthropic: { signature: "sig_1" } } }, + ]), + ], + }), + ) + + expect(prepared.body).toMatchObject({ + messages: [{ role: "assistant", content: [{ type: "thinking", thinking: "thinking", signature: "sig_1" }] }], + }) + }), + ) + + it.effect("parses text, reasoning, and usage stream fixtures", () => + Effect.gen(function* () { + const body = sseEvents( + { type: "message_start", message: { usage: { input_tokens: 5, cache_read_input_tokens: 1 } } }, + { type: "content_block_start", index: 0, content_block: { type: "text", text: "" } }, + { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "Hello" } }, + { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "!" } }, + { type: "content_block_stop", index: 0 }, + { type: "content_block_start", index: 1, content_block: { type: "thinking", thinking: "" } }, + { type: "content_block_delta", index: 1, delta: { type: "thinking_delta", thinking: "thinking" } }, + { type: "content_block_delta", index: 1, delta: { type: "signature_delta", signature: "sig_1" } }, + { type: "content_block_stop", index: 1 }, + { + type: "message_delta", + delta: { stop_reason: "end_turn", stop_sequence: "\n\nHuman:" }, + usage: { output_tokens: 2 }, + }, + { type: "message_stop" }, + ) + const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body))) + + expect(response.text).toBe("Hello!") + expect(response.reasoning).toBe("thinking") + expect(response.usage).toMatchObject({ + inputTokens: 6, + outputTokens: 2, + nonCachedInputTokens: 5, + cacheReadInputTokens: 1, + totalTokens: 8, + }) + expect(response.events.find((event) => event.type === "reasoning-end")).toMatchObject({ + providerMetadata: { anthropic: { signature: "sig_1" } }, + }) + expect(response.events.at(-1)).toMatchObject({ + type: "request-finish", + reason: "stop", + providerMetadata: { anthropic: { stopSequence: "\n\nHuman:" } }, + }) + }), + ) + + it.effect("assembles streamed tool call input", () => + Effect.gen(function* () { + const body = sseEvents( + { type: "message_start", message: { usage: { input_tokens: 5 } } }, + { type: "content_block_start", index: 0, content_block: { type: "tool_use", id: "call_1", name: "lookup" } }, + { type: "content_block_delta", index: 0, delta: { type: "input_json_delta", partial_json: '{"query"' } }, + { type: "content_block_delta", index: 0, delta: { type: "input_json_delta", partial_json: ':"weather"}' } }, + { type: "content_block_stop", index: 0 }, + { type: "message_delta", delta: { stop_reason: "tool_use" }, usage: { output_tokens: 1 } }, + ) + const response = yield* LLMClient.generate( + LLM.updateRequest(request, { + tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }], + }), + ).pipe(Effect.provide(fixedResponse(body))) + + expect(response.toolCalls).toEqual([ + { type: "tool-call", id: "call_1", name: "lookup", input: { query: "weather" } }, + ]) + expect(response.events).toEqual([ + { type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"' }, + { type: "tool-input-delta", id: "call_1", name: "lookup", text: ':"weather"}' }, + { type: "tool-call", id: "call_1", name: "lookup", input: { query: "weather" } }, + { + type: "request-finish", + reason: "tool-calls", + usage: new Usage({ + inputTokens: 5, + outputTokens: 1, + nonCachedInputTokens: 5, + totalTokens: 6, + providerMetadata: { anthropic: { input_tokens: 5, output_tokens: 1 } }, + }), + }, + ]) + }), + ) + + it.effect("emits provider-error events for mid-stream provider errors", () => + Effect.gen(function* () { + const response = yield* LLMClient.generate(request).pipe( + Effect.provide( + fixedResponse(sseEvents({ type: "error", error: { type: "overloaded_error", message: "Overloaded" } })), + ), + ) + + expect(response.events).toEqual([{ type: "provider-error", message: "Overloaded" }]) + }), + ) + + it.effect("fails HTTP provider errors before stream parsing", () => + Effect.gen(function* () { + const error = yield* LLMClient.generate(request).pipe( + Effect.provide( + fixedResponse('{"type":"error","error":{"type":"invalid_request_error","message":"Bad request"}}', { + status: 400, + headers: { "content-type": "application/json" }, + }), + ), + Effect.flip, + ) + + expect(error).toBeInstanceOf(LLMError) + expect(error.reason).toMatchObject({ _tag: "InvalidRequest" }) + expect(error.message).toContain("HTTP 400") + }), + ) + + it.effect("decodes server_tool_use + web_search_tool_result as provider-executed events", () => + Effect.gen(function* () { + const body = sseEvents( + { type: "message_start", message: { usage: { input_tokens: 5 } } }, + { + type: "content_block_start", + index: 0, + content_block: { type: "server_tool_use", id: "srvtoolu_abc", name: "web_search" }, + }, + { + type: "content_block_delta", + index: 0, + delta: { type: "input_json_delta", partial_json: '{"query":"effect 4"}' }, + }, + { type: "content_block_stop", index: 0 }, + { + type: "content_block_start", + index: 1, + content_block: { + type: "web_search_tool_result", + tool_use_id: "srvtoolu_abc", + content: [{ type: "web_search_result", url: "https://example.com", title: "Example" }], + }, + }, + { type: "content_block_stop", index: 1 }, + { type: "content_block_start", index: 2, content_block: { type: "text", text: "" } }, + { type: "content_block_delta", index: 2, delta: { type: "text_delta", text: "Found it." } }, + { type: "content_block_stop", index: 2 }, + { type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 8 } }, + ) + const response = yield* LLMClient.generate( + LLM.updateRequest(request, { + tools: [{ name: "web_search", description: "Web search", inputSchema: { type: "object" } }], + }), + ).pipe(Effect.provide(fixedResponse(body))) + + const toolCall = response.events.find((event) => event.type === "tool-call") + expect(toolCall).toEqual({ + type: "tool-call", + id: "srvtoolu_abc", + name: "web_search", + input: { query: "effect 4" }, + providerExecuted: true, + }) + const toolResult = response.events.find((event) => event.type === "tool-result") + expect(toolResult).toEqual({ + type: "tool-result", + id: "srvtoolu_abc", + name: "web_search", + result: { type: "json", value: [{ type: "web_search_result", url: "https://example.com", title: "Example" }] }, + providerExecuted: true, + providerMetadata: { anthropic: { blockType: "web_search_tool_result" } }, + }) + expect(response.text).toBe("Found it.") + expect(response.events.at(-1)).toMatchObject({ type: "request-finish", reason: "stop" }) + }), + ) + + it.effect("decodes web_search_tool_result_error as provider-executed error result", () => + Effect.gen(function* () { + const body = sseEvents( + { type: "message_start", message: { usage: { input_tokens: 5 } } }, + { + type: "content_block_start", + index: 0, + content_block: { type: "server_tool_use", id: "srvtoolu_x", name: "web_search" }, + }, + { type: "content_block_delta", index: 0, delta: { type: "input_json_delta", partial_json: '{"query":"q"}' } }, + { type: "content_block_stop", index: 0 }, + { + type: "content_block_start", + index: 1, + content_block: { + type: "web_search_tool_result", + tool_use_id: "srvtoolu_x", + content: { type: "web_search_tool_result_error", error_code: "max_uses_exceeded" }, + }, + }, + { type: "content_block_stop", index: 1 }, + { type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 1 } }, + ) + const response = yield* LLMClient.generate( + LLM.updateRequest(request, { + tools: [{ name: "web_search", description: "Web search", inputSchema: { type: "object" } }], + }), + ).pipe(Effect.provide(fixedResponse(body))) + + const toolResult = response.events.find((event) => event.type === "tool-result") + expect(toolResult).toMatchObject({ + type: "tool-result", + id: "srvtoolu_x", + name: "web_search", + result: { type: "error" }, + providerExecuted: true, + }) + }), + ) + + it.effect("round-trips provider-executed assistant content into server tool blocks", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + id: "req_round_trip", + model, + messages: [ + LLM.user("Search for something."), + LLM.assistant([ + { + type: "tool-call", + id: "srvtoolu_abc", + name: "web_search", + input: { query: "effect 4" }, + providerExecuted: true, + }, + { + type: "tool-result", + id: "srvtoolu_abc", + name: "web_search", + result: { type: "json", value: [{ url: "https://example.com" }] }, + providerExecuted: true, + }, + { type: "text", text: "Found it." }, + ]), + LLM.user("Thanks."), + ], + }), + ) + + expect(prepared.body).toMatchObject({ + messages: [ + { role: "user", content: [{ type: "text", text: "Search for something." }] }, + { + role: "assistant", + content: [ + { type: "server_tool_use", id: "srvtoolu_abc", name: "web_search", input: { query: "effect 4" } }, + { + type: "web_search_tool_result", + tool_use_id: "srvtoolu_abc", + content: [{ url: "https://example.com" }], + }, + { type: "text", text: "Found it." }, + ], + }, + { role: "user", content: [{ type: "text", text: "Thanks." }] }, + ], + }) + }), + ) + + it.effect("rejects round-trip for unknown server tool names", () => + Effect.gen(function* () { + const error = yield* LLMClient.prepare( + LLM.request({ + id: "req_unknown_server_tool", + model, + messages: [ + LLM.assistant([ + { + type: "tool-result", + id: "srvtoolu_abc", + name: "future_server_tool", + result: { type: "json", value: {} }, + providerExecuted: true, + }, + ]), + ], + }), + ).pipe(Effect.flip) + + expect(error.message).toContain("future_server_tool") + }), + ) + + it.effect("rejects unsupported user media content", () => + Effect.gen(function* () { + const error = yield* LLMClient.prepare( + LLM.request({ + id: "req_media", + model, + messages: [LLM.user({ type: "media", mediaType: "image/png", data: "AAECAw==" })], + }), + ).pipe(Effect.flip) + + expect(error.message).toContain("Anthropic Messages user messages only support text content for now") + }), + ) + + it.effect("maps ttlSeconds >= 3600 to cache_control ttl: '1h'", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + model, + system: { type: "text", text: "system", cache: new CacheHint({ type: "ephemeral", ttlSeconds: 3600 }) }, + prompt: "hi", + }), + ) + + expect(prepared.body).toMatchObject({ + system: [{ type: "text", text: "system", cache_control: { type: "ephemeral", ttl: "1h" } }], + }) + }), + ) + + it.effect("emits cache_control on tool definitions and tool-result blocks", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + model, + tools: [ + { + name: "lookup", + description: "lookup tool", + inputSchema: { type: "object", properties: {} }, + cache: new CacheHint({ type: "ephemeral" }), + }, + ], + messages: [ + LLM.user("What's the weather?"), + LLM.assistant([LLM.toolCall({ id: "call_1", name: "lookup", input: {} })]), + LLM.toolMessage({ + id: "call_1", + name: "lookup", + result: { temp: 72 }, + cache: new CacheHint({ type: "ephemeral" }), + }), + ], + }), + ) + + expect(prepared.body).toMatchObject({ + tools: [{ name: "lookup", cache_control: { type: "ephemeral" } }], + messages: [ + { role: "user", content: [{ type: "text", text: "What's the weather?" }] }, + { role: "assistant", content: [{ type: "tool_use", id: "call_1", name: "lookup" }] }, + { + role: "user", + content: [{ type: "tool_result", tool_use_id: "call_1", cache_control: { type: "ephemeral" } }], + }, + ], + }) + }), + ) + + it.effect("drops cache_control breakpoints past the 4-per-request cap", () => + Effect.gen(function* () { + const hint = new CacheHint({ type: "ephemeral" }) + const prepared = yield* LLMClient.prepare( + LLM.request({ + model, + system: [ + { type: "text", text: "a", cache: hint }, + { type: "text", text: "b", cache: hint }, + { type: "text", text: "c", cache: hint }, + { type: "text", text: "d", cache: hint }, + { type: "text", text: "e", cache: hint }, + { type: "text", text: "f", cache: hint }, + ], + prompt: "hi", + }), + ) + + const system = (prepared.body as { system: Array<{ cache_control?: unknown }> }).system + const marked = system.filter((part) => part.cache_control !== undefined) + expect(marked).toHaveLength(4) + expect(system[4]?.cache_control).toBeUndefined() + expect(system[5]?.cache_control).toBeUndefined() + }), + ) + + it.effect("spends breakpoint budget on tools before system before messages", () => + Effect.gen(function* () { + const hint = new CacheHint({ type: "ephemeral" }) + const prepared = yield* LLMClient.prepare( + LLM.request({ + model, + tools: [ + { + name: "t1", + description: "t1", + inputSchema: { type: "object", properties: {} }, + cache: hint, + }, + { + name: "t2", + description: "t2", + inputSchema: { type: "object", properties: {} }, + cache: hint, + }, + { + name: "t3", + description: "t3", + inputSchema: { type: "object", properties: {} }, + cache: hint, + }, + { + name: "t4", + description: "t4", + inputSchema: { type: "object", properties: {} }, + cache: hint, + }, + ], + system: [{ type: "text", text: "system-tail", cache: hint }], + messages: [LLM.user([{ type: "text", text: "message-tail", cache: hint }])], + }), + ) + + const body = prepared.body as { + tools: Array<{ cache_control?: unknown }> + system: Array<{ cache_control?: unknown }> + messages: Array<{ content: Array<{ cache_control?: unknown }> }> + } + expect(body.tools.every((t) => t.cache_control !== undefined)).toBe(true) + expect(body.system[0]?.cache_control).toBeUndefined() + expect(body.messages[0]?.content[0]?.cache_control).toBeUndefined() + }), + ) +}) diff --git a/packages/llm/test/provider/bedrock-converse-cache.recorded.test.ts b/packages/llm/test/provider/bedrock-converse-cache.recorded.test.ts new file mode 100644 index 0000000000..16c44099ce --- /dev/null +++ b/packages/llm/test/provider/bedrock-converse-cache.recorded.test.ts @@ -0,0 +1,56 @@ +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import { CacheHint, LLM } from "../../src" +import { LLMClient } from "../../src/route" +import * as BedrockConverse from "../../src/protocols/bedrock-converse" +import { LARGE_CACHEABLE_SYSTEM } from "../recorded-scenarios" +import { recordedTests } from "../recorded-test" + +const RECORDING_REGION = process.env.BEDROCK_RECORDING_REGION ?? "us-east-1" + +// Use a Claude model on Bedrock — Nova has automatic prefix caching that +// doesn't reliably surface `cacheRead`/`cacheWrite` in usage, so the second +// call wouldn't deterministically prove cache mapping works. Override with +// BEDROCK_CACHE_MODEL_ID if your account has access elsewhere. +const model = BedrockConverse.model({ + id: process.env.BEDROCK_CACHE_MODEL_ID ?? "us.anthropic.claude-haiku-4-5-20251001-v1:0", + credentials: { + region: RECORDING_REGION, + accessKeyId: process.env.AWS_ACCESS_KEY_ID ?? "fixture", + secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY ?? "fixture", + sessionToken: process.env.AWS_SESSION_TOKEN, + }, +}) + +const cacheRequest = LLM.request({ + id: "recorded_bedrock_cache", + model, + system: [{ type: "text", text: LARGE_CACHEABLE_SYSTEM, cache: new CacheHint({ type: "ephemeral" }) }], + prompt: "Say hi.", + // Manual hint on the system part is the only marker we want here — skip the + // auto-policy's latest-user-message breakpoint so the cassette body matches. + cache: "none", + generation: { maxTokens: 16, temperature: 0 }, +}) + +const recorded = recordedTests({ + prefix: "bedrock-converse-cache", + provider: "amazon-bedrock", + protocol: "bedrock-converse", + requires: ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"], + // Two identical requests in one cassette — match by recording order so the + // second call replays the cached-hit interaction. + options: { dispatch: "sequential" }, +}) + +describe("Bedrock Converse cache recorded", () => { + recorded.effect.with("writes then reads cachePoint on identical second call", { tags: ["cache"] }, () => + Effect.gen(function* () { + const first = yield* LLMClient.generate(cacheRequest) + expect(first.usage?.cacheReadInputTokens ?? 0).toBeGreaterThanOrEqual(0) + + const second = yield* LLMClient.generate(cacheRequest) + expect(second.usage?.cacheReadInputTokens ?? 0).toBeGreaterThan(0) + }), + ) +}) diff --git a/packages/llm/test/provider/bedrock-converse.test.ts b/packages/llm/test/provider/bedrock-converse.test.ts new file mode 100644 index 0000000000..208b565272 --- /dev/null +++ b/packages/llm/test/provider/bedrock-converse.test.ts @@ -0,0 +1,612 @@ +import { EventStreamCodec } from "@smithy/eventstream-codec" +import { fromUtf8, toUtf8 } from "@smithy/util-utf8" +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import { CacheHint, LLM } from "../../src" +import { LLMClient } from "../../src/route" +import * as BedrockConverse from "../../src/protocols/bedrock-converse" +import { it } from "../lib/effect" +import { fixedResponse } from "../lib/http" +import { + eventSummary, + expectWeatherToolLoop, + runWeatherToolLoop, + weatherTool, + weatherToolLoopRequest, + weatherToolName, +} from "../recorded-scenarios" +import { recordedTests } from "../recorded-test" + +const codec = new EventStreamCodec(toUtf8, fromUtf8) +const utf8Encoder = new TextEncoder() + +// Build a single AWS event-stream frame for a Converse stream event. Each +// frame carries `:message-type=event` + `:event-type=` headers and a +// JSON payload body. +const eventFrame = (type: string, payload: object) => + codec.encode({ + headers: { + ":message-type": { type: "string", value: "event" }, + ":event-type": { type: "string", value: type }, + ":content-type": { type: "string", value: "application/json" }, + }, + body: utf8Encoder.encode(JSON.stringify(payload)), + }) + +const concat = (frames: ReadonlyArray) => { + const total = frames.reduce((sum, frame) => sum + frame.length, 0) + const out = new Uint8Array(total) + let offset = 0 + for (const frame of frames) { + out.set(frame, offset) + offset += frame.length + } + return out +} + +const eventStreamBody = (...payloads: ReadonlyArray) => + concat(payloads.map(([type, payload]) => eventFrame(type, payload))) + +// Override the default SSE content-type with the binary event-stream type so +// the cassette layer treats the body as bytes when recording. +const fixedBytes = (bytes: Uint8Array) => + fixedResponse(bytes.slice().buffer, { headers: { "content-type": "application/vnd.amazon.eventstream" } }) + +const model = BedrockConverse.model({ + id: "anthropic.claude-3-5-sonnet-20240620-v1:0", + baseURL: "https://bedrock-runtime.test", + apiKey: "test-bearer", +}) + +const baseRequest = LLM.request({ + id: "req_1", + model, + system: "You are concise.", + prompt: "Say hello.", + // Wire-shape assertions in this file predate the `cache: "auto"` default; + // pin the policy off so they only exercise the lowering path itself. + cache: "none", + generation: { maxTokens: 64, temperature: 0 }, +}) + +describe("Bedrock Converse route", () => { + it.effect("prepares Converse target with system, inference config, and messages", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare(baseRequest) + + expect(prepared.body).toEqual({ + modelId: "anthropic.claude-3-5-sonnet-20240620-v1:0", + system: [{ text: "You are concise." }], + messages: [{ role: "user", content: [{ text: "Say hello." }] }], + inferenceConfig: { maxTokens: 64, temperature: 0 }, + }) + }), + ) + + it.effect("prepares tool config with toolSpec and toolChoice", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.updateRequest(baseRequest, { + tools: [ + { + name: "lookup", + description: "Lookup data", + inputSchema: { type: "object", properties: { query: { type: "string" } }, required: ["query"] }, + }, + ], + toolChoice: LLM.toolChoice({ type: "required" }), + }), + ) + + expect(prepared.body).toMatchObject({ + toolConfig: { + tools: [ + { + toolSpec: { + name: "lookup", + description: "Lookup data", + inputSchema: { + json: { type: "object", properties: { query: { type: "string" } }, required: ["query"] }, + }, + }, + }, + ], + toolChoice: { any: {} }, + }, + }) + }), + ) + + it.effect("lowers assistant tool-call + tool-result message history", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + id: "req_history", + model, + messages: [ + LLM.user("What is the weather?"), + LLM.assistant([LLM.toolCall({ id: "tool_1", name: "lookup", input: { query: "weather" } })]), + LLM.toolMessage({ id: "tool_1", name: "lookup", result: { forecast: "sunny" } }), + ], + cache: "none", + }), + ) + + expect(prepared.body).toMatchObject({ + messages: [ + { role: "user", content: [{ text: "What is the weather?" }] }, + { + role: "assistant", + content: [{ toolUse: { toolUseId: "tool_1", name: "lookup", input: { query: "weather" } } }], + }, + { + role: "user", + content: [ + { + toolResult: { + toolUseId: "tool_1", + content: [{ json: { forecast: "sunny" } }], + status: "success", + }, + }, + ], + }, + ], + }) + }), + ) + + it.effect("decodes text-delta + messageStop + metadata usage from binary event stream", () => + Effect.gen(function* () { + const body = eventStreamBody( + ["messageStart", { role: "assistant" }], + ["contentBlockDelta", { contentBlockIndex: 0, delta: { text: "Hello" } }], + ["contentBlockDelta", { contentBlockIndex: 0, delta: { text: "!" } }], + ["contentBlockStop", { contentBlockIndex: 0 }], + ["messageStop", { stopReason: "end_turn" }], + ["metadata", { usage: { inputTokens: 5, outputTokens: 2, totalTokens: 7 } }], + ) + const response = yield* LLMClient.generate(baseRequest).pipe(Effect.provide(fixedBytes(body))) + + expect(response.text).toBe("Hello!") + const finishes = response.events.filter((event) => event.type === "request-finish") + // Bedrock splits the finish across `messageStop` (carries reason) and + // `metadata` (carries usage). We consolidate them into a single + // terminal `request-finish` event with both. + expect(finishes).toHaveLength(1) + expect(finishes[0]).toMatchObject({ type: "request-finish", reason: "stop" }) + expect(response.usage).toMatchObject({ + inputTokens: 5, + outputTokens: 2, + totalTokens: 7, + }) + }), + ) + + it.effect("assembles streamed tool call input", () => + Effect.gen(function* () { + const body = eventStreamBody( + ["messageStart", { role: "assistant" }], + [ + "contentBlockStart", + { + contentBlockIndex: 0, + start: { toolUse: { toolUseId: "tool_1", name: "lookup" } }, + }, + ], + ["contentBlockDelta", { contentBlockIndex: 0, delta: { toolUse: { input: '{"query"' } } }], + ["contentBlockDelta", { contentBlockIndex: 0, delta: { toolUse: { input: ':"weather"}' } } }], + ["contentBlockStop", { contentBlockIndex: 0 }], + ["messageStop", { stopReason: "tool_use" }], + ) + const response = yield* LLMClient.generate( + LLM.updateRequest(baseRequest, { + tools: [{ name: "lookup", description: "Lookup", inputSchema: { type: "object" } }], + }), + ).pipe(Effect.provide(fixedBytes(body))) + + expect(response.toolCalls).toEqual([ + { type: "tool-call", id: "tool_1", name: "lookup", input: { query: "weather" } }, + ]) + const events = response.events.filter((event) => event.type === "tool-input-delta") + expect(events).toEqual([ + { type: "tool-input-delta", id: "tool_1", name: "lookup", text: '{"query"' }, + { type: "tool-input-delta", id: "tool_1", name: "lookup", text: ':"weather"}' }, + ]) + expect(response.events.at(-1)).toMatchObject({ type: "request-finish", reason: "tool-calls" }) + }), + ) + + it.effect("decodes reasoning deltas", () => + Effect.gen(function* () { + const body = eventStreamBody( + ["messageStart", { role: "assistant" }], + ["contentBlockDelta", { contentBlockIndex: 0, delta: { reasoningContent: { text: "Let me think." } } }], + ["contentBlockStop", { contentBlockIndex: 0 }], + ["messageStop", { stopReason: "end_turn" }], + ) + const response = yield* LLMClient.generate(baseRequest).pipe(Effect.provide(fixedBytes(body))) + + expect(response.reasoning).toBe("Let me think.") + }), + ) + + it.effect("emits provider-error for throttlingException", () => + Effect.gen(function* () { + const body = eventStreamBody( + ["messageStart", { role: "assistant" }], + ["throttlingException", { message: "Slow down" }], + ) + const response = yield* LLMClient.generate(baseRequest).pipe(Effect.provide(fixedBytes(body))) + + expect(response.events.find((event) => event.type === "provider-error")).toEqual({ + type: "provider-error", + message: "Slow down", + retryable: true, + }) + }), + ) + + it.effect("rejects requests with no auth path", () => + Effect.gen(function* () { + const unsignedModel = BedrockConverse.model({ + id: "anthropic.claude-3-5-sonnet-20240620-v1:0", + baseURL: "https://bedrock-runtime.test", + }) + const error = yield* LLMClient.generate(LLM.updateRequest(baseRequest, { model: unsignedModel })).pipe( + Effect.provide(fixedBytes(eventStreamBody(["messageStop", { stopReason: "end_turn" }]))), + Effect.flip, + ) + + expect(error.message).toContain("Bedrock Converse requires either model.apiKey") + }), + ) + + it.effect("signs requests with SigV4 when AWS credentials are provided (deterministic plumbing check)", () => + Effect.gen(function* () { + const signed = BedrockConverse.model({ + id: "anthropic.claude-3-5-sonnet-20240620-v1:0", + baseURL: "https://bedrock-runtime.test", + credentials: { + region: "us-east-1", + accessKeyId: "AKIAIOSFODNN7EXAMPLE", + secretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + }, + }) + const prepared = yield* LLMClient.prepare(LLM.updateRequest(baseRequest, { model: signed })) + + expect(prepared.route).toBe("bedrock-converse") + // The prepare phase doesn't sign — toHttp does. We assert the credential + // is plumbed onto the model native field for the signer to find. + expect(prepared.model.native).toMatchObject({ + aws_credentials: { region: "us-east-1", accessKeyId: "AKIAIOSFODNN7EXAMPLE" }, + aws_region: "us-east-1", + }) + }), + ) + + it.effect("emits cachePoint markers after system, user-text, and assistant-text with cache hints", () => + Effect.gen(function* () { + const cache = new CacheHint({ type: "ephemeral" }) + const prepared = yield* LLMClient.prepare( + LLM.request({ + id: "req_cache", + model, + system: [{ type: "text", text: "System prefix.", cache }], + messages: [ + LLM.user([{ type: "text", text: "User prefix.", cache }]), + LLM.assistant([{ type: "text", text: "Assistant prefix.", cache }]), + ], + generation: { maxTokens: 16, temperature: 0 }, + }), + ) + + expect(prepared.body).toMatchObject({ + // System: text block followed by cachePoint marker. + system: [{ text: "System prefix." }, { cachePoint: { type: "default" } }], + messages: [ + { + role: "user", + content: [{ text: "User prefix." }, { cachePoint: { type: "default" } }], + }, + { + role: "assistant", + content: [{ text: "Assistant prefix." }, { cachePoint: { type: "default" } }], + }, + ], + }) + }), + ) + + it.effect("does not emit cachePoint when no cache hint is set", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare(baseRequest) + expect(prepared.body).toMatchObject({ + system: [{ text: "You are concise." }], + messages: [{ role: "user", content: [{ text: "Say hello." }] }], + }) + }), + ) + + it.effect("lowers image media into Bedrock image blocks", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + id: "req_image", + model, + messages: [ + LLM.user([ + { type: "text", text: "What is in this image?" }, + { type: "media", mediaType: "image/png", data: "AAAA" }, + { type: "media", mediaType: "image/jpeg", data: "BBBB" }, + { type: "media", mediaType: "image/jpg", data: "CCCC" }, + { type: "media", mediaType: "image/webp", data: "DDDD" }, + ]), + ], + cache: "none", + }), + ) + + expect(prepared.body).toMatchObject({ + messages: [ + { + role: "user", + content: [ + { text: "What is in this image?" }, + { image: { format: "png", source: { bytes: "AAAA" } } }, + { image: { format: "jpeg", source: { bytes: "BBBB" } } }, + // image/jpg is a non-standard alias; we map it to jpeg. + { image: { format: "jpeg", source: { bytes: "CCCC" } } }, + { image: { format: "webp", source: { bytes: "DDDD" } } }, + ], + }, + ], + }) + }), + ) + + it.effect("base64-encodes Uint8Array image bytes", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + id: "req_image_bytes", + model, + messages: [LLM.user([{ type: "media", mediaType: "image/png", data: new Uint8Array([1, 2, 3, 4, 5]) }])], + }), + ) + + // Buffer.from([1,2,3,4,5]).toString("base64") === "AQIDBAU=" + expect(prepared.body).toMatchObject({ + messages: [ + { + role: "user", + content: [{ image: { format: "png", source: { bytes: "AQIDBAU=" } } }], + }, + ], + }) + }), + ) + + it.effect("lowers document media into Bedrock document blocks with format and name", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + id: "req_doc", + model, + messages: [ + LLM.user([ + { type: "media", mediaType: "application/pdf", data: "PDFDATA", filename: "report.pdf" }, + { type: "media", mediaType: "text/csv", data: "CSVDATA" }, + ]), + ], + }), + ) + + expect(prepared.body).toMatchObject({ + messages: [ + { + role: "user", + content: [ + // Filename round-trips when supplied. + { document: { format: "pdf", name: "report.pdf", source: { bytes: "PDFDATA" } } }, + // Falls back to a stable placeholder when filename is missing. + { document: { format: "csv", name: "document.csv", source: { bytes: "CSVDATA" } } }, + ], + }, + ], + }) + }), + ) + + it.effect("rejects unsupported image media types", () => + Effect.gen(function* () { + const error = yield* LLMClient.prepare( + LLM.request({ + id: "req_bad_image", + model, + messages: [LLM.user([{ type: "media", mediaType: "image/svg+xml", data: "x" }])], + }), + ).pipe(Effect.flip) + + expect(error.message).toContain("Bedrock Converse does not support image media type image/svg+xml") + }), + ) + + it.effect("rejects unsupported document media types", () => + Effect.gen(function* () { + const error = yield* LLMClient.prepare( + LLM.request({ + id: "req_bad_doc", + model, + messages: [LLM.user([{ type: "media", mediaType: "application/x-tar", data: "x", filename: "a.tar" }])], + }), + ).pipe(Effect.flip) + + expect(error.message).toContain("Bedrock Converse does not support media type application/x-tar") + }), + ) + + it.effect("maps ttlSeconds >= 3600 to cachePoint ttl: '1h'", () => + Effect.gen(function* () { + const cache = new CacheHint({ type: "ephemeral", ttlSeconds: 3600 }) + const prepared = yield* LLMClient.prepare( + LLM.request({ + model, + system: [{ type: "text", text: "system", cache }], + prompt: "hi", + }), + ) + + expect(prepared.body).toMatchObject({ + system: [{ text: "system" }, { cachePoint: { type: "default", ttl: "1h" } }], + }) + }), + ) + + it.effect("appends cachePoint after marked tool definitions and tool-result blocks", () => + Effect.gen(function* () { + const cache = new CacheHint({ type: "ephemeral" }) + const prepared = yield* LLMClient.prepare( + LLM.request({ + model, + tools: [{ name: "lookup", description: "lookup", inputSchema: { type: "object", properties: {} }, cache }], + messages: [ + LLM.user("What's the weather?"), + LLM.assistant([LLM.toolCall({ id: "call_1", name: "lookup", input: {} })]), + LLM.toolMessage({ id: "call_1", name: "lookup", result: { temp: 72 }, cache }), + ], + cache: "none", + }), + ) + + expect(prepared.body).toMatchObject({ + toolConfig: { + tools: [{ toolSpec: { name: "lookup" } }, { cachePoint: { type: "default" } }], + }, + messages: [ + { role: "user", content: [{ text: "What's the weather?" }] }, + { role: "assistant", content: [{ toolUse: { toolUseId: "call_1" } }] }, + { + role: "user", + content: [{ toolResult: { toolUseId: "call_1" } }, { cachePoint: { type: "default" } }], + }, + ], + }) + }), + ) + + it.effect("drops cachePoint markers past the 4-per-request cap", () => + Effect.gen(function* () { + const cache = new CacheHint({ type: "ephemeral" }) + const prepared = yield* LLMClient.prepare( + LLM.request({ + model, + system: [ + { type: "text", text: "a", cache }, + { type: "text", text: "b", cache }, + { type: "text", text: "c", cache }, + { type: "text", text: "d", cache }, + { type: "text", text: "e", cache }, + { type: "text", text: "f", cache }, + ], + prompt: "hi", + }), + ) + + const system = (prepared.body as { system: Array<{ cachePoint?: unknown }> }).system + expect(system.filter((part) => "cachePoint" in part)).toHaveLength(4) + }), + ) +}) + +// Live recorded integration tests. Run with `RECORD=true AWS_ACCESS_KEY_ID=... +// AWS_SECRET_ACCESS_KEY=... [AWS_SESSION_TOKEN=...] bun run test ...` to refresh +// cassettes; replay is the default and works without credentials. +// +// Region is pinned to us-east-1 in tests so the request URL is stable across +// machines on replay. If you need to record from a different region (e.g. your +// account has access elsewhere), pass `BEDROCK_RECORDING_REGION=eu-west-1` — +// but then commit the resulting cassette and others should record from the +// same region too. +const RECORDING_REGION = process.env.BEDROCK_RECORDING_REGION ?? "us-east-1" + +const recordedModel = () => + BedrockConverse.model({ + // Most newer Anthropic models on Bedrock require a cross-region inference + // profile (`us.` prefix). Nova does not require an Anthropic use-case form + // and is on-demand-throughput accessible by default for most accounts. + id: process.env.BEDROCK_MODEL_ID ?? "us.amazon.nova-micro-v1:0", + credentials: { + region: RECORDING_REGION, + accessKeyId: process.env.AWS_ACCESS_KEY_ID ?? "fixture", + secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY ?? "fixture", + sessionToken: process.env.AWS_SESSION_TOKEN, + }, + }) + +const recorded = recordedTests({ + prefix: "bedrock-converse", + provider: "amazon-bedrock", + protocol: "bedrock-converse", + requires: ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"], +}) + +describe("Bedrock Converse recorded", () => { + recorded.effect("streams text", () => + Effect.gen(function* () { + const llm = yield* LLMClient.Service + const response = yield* llm.generate( + LLM.request({ + id: "recorded_bedrock_text", + model: recordedModel(), + system: "Reply with the single word 'Hello'.", + prompt: "Say hello.", + cache: "none", + generation: { maxTokens: 16, temperature: 0 }, + }), + ) + + expect(eventSummary(response.events)).toEqual([ + { type: "text", value: "Hello" }, + { type: "finish", reason: "stop", usage: { inputTokens: 12, outputTokens: 2, totalTokens: 14 } }, + ]) + }), + ) + + recorded.effect.with("streams a tool call", { tags: ["tool"] }, () => + Effect.gen(function* () { + const llm = yield* LLMClient.Service + const response = yield* llm.generate( + LLM.request({ + id: "recorded_bedrock_tool_call", + model: recordedModel(), + system: "Call tools exactly as requested.", + prompt: "Call get_weather with city exactly Paris.", + tools: [weatherTool], + toolChoice: LLM.toolChoice(weatherTool), + cache: "none", + generation: { maxTokens: 80, temperature: 0 }, + }), + ) + + expect(eventSummary(response.events)).toEqual([ + { type: "tool-call", name: weatherToolName, input: { city: "Paris" } }, + { type: "finish", reason: "tool-calls", usage: { inputTokens: 419, outputTokens: 16, totalTokens: 435 } }, + ]) + }), + ) + + recorded.effect.with("drives a tool loop", { tags: ["tool", "tool-loop", "golden"] }, () => + Effect.gen(function* () { + const llm = yield* LLMClient.Service + expectWeatherToolLoop( + yield* runWeatherToolLoop( + weatherToolLoopRequest({ + id: "recorded_bedrock_tool_loop", + model: recordedModel(), + }), + ), + ) + }), + ) +}) diff --git a/packages/llm/test/provider/cloudflare.test.ts b/packages/llm/test/provider/cloudflare.test.ts new file mode 100644 index 0000000000..125e79bf9e --- /dev/null +++ b/packages/llm/test/provider/cloudflare.test.ts @@ -0,0 +1,230 @@ +import { describe, expect } from "bun:test" +import { ConfigProvider, Effect, Schema } from "effect" +import { HttpClientRequest } from "effect/unstable/http" +import { LLM } from "../../src" +import * as Cloudflare from "../../src/providers/cloudflare" +import { LLMClient } from "../../src/route" +import { it } from "../lib/effect" +import { dynamicResponse } from "../lib/http" +import { sseEvents } from "../lib/sse" + +const Json = Schema.fromJsonString(Schema.Unknown) +const decodeJson = Schema.decodeUnknownSync(Json) +const withEnv = (env: Record) => Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env }))) + +const deltaChunk = (delta: object, finishReason: string | null = null) => ({ + id: "chatcmpl_fixture", + choices: [{ delta, finish_reason: finishReason }], + usage: null, +}) + +describe("Cloudflare", () => { + it.effect("prepares AI Gateway models through the OpenAI-compatible Chat protocol", () => + Effect.gen(function* () { + const model = Cloudflare.aiGateway("workers-ai/@cf/meta/llama-3.3-70b-instruct", { + accountId: "test-account", + gatewayId: "test-gateway", + apiKey: "test-token", + }) + + expect(model).toMatchObject({ + id: "workers-ai/@cf/meta/llama-3.3-70b-instruct", + provider: "cloudflare-ai-gateway", + route: "cloudflare-ai-gateway", + baseURL: "https://gateway.ai.cloudflare.com/v1/test-account/test-gateway/compat", + }) + + const prepared = yield* LLMClient.prepare(LLM.request({ model, prompt: "Say hello." })) + + expect(prepared.route).toBe("cloudflare-ai-gateway") + expect(prepared.body).toMatchObject({ + model: "workers-ai/@cf/meta/llama-3.3-70b-instruct", + messages: [{ role: "user", content: "Say hello." }], + stream: true, + }) + }), + ) + + it.effect("posts to the derived gateway endpoint with bearer auth", () => + Effect.gen(function* () { + const response = yield* LLM.generate( + LLM.request({ + model: Cloudflare.aiGateway("openai/gpt-4o-mini", { + accountId: "test-account", + gatewayId: "test-gateway", + apiKey: "test-token", + }), + prompt: "Say hello.", + }), + ).pipe( + Effect.provide( + dynamicResponse((input) => + Effect.gen(function* () { + const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie) + expect(web.url).toBe( + "https://gateway.ai.cloudflare.com/v1/test-account/test-gateway/compat/chat/completions", + ) + expect(web.headers.get("authorization")).toBe("Bearer test-token") + expect(decodeJson(input.text)).toMatchObject({ + model: "openai/gpt-4o-mini", + stream: true, + messages: [{ role: "user", content: "Say hello." }], + }) + return input.respond( + sseEvents(deltaChunk({ role: "assistant", content: "Hello" }), deltaChunk({}, "stop")), + { headers: { "content-type": "text/event-stream" } }, + ) + }), + ), + ), + ) + + expect(response.text).toBe("Hello") + }), + ) + + it.effect("defaults AI Gateway id to default when omitted or blank", () => + Effect.gen(function* () { + expect( + Cloudflare.aiGateway("workers-ai/@cf/meta/llama-3.3-70b-instruct", { + accountId: "test-account", + gatewayId: "", + gatewayApiKey: "test-token", + }).baseURL, + ).toBe("https://gateway.ai.cloudflare.com/v1/test-account/default/compat") + }), + ) + + it.effect("supports authenticated AI Gateway plus upstream provider auth", () => + Effect.gen(function* () { + yield* LLM.generate( + LLM.request({ + model: Cloudflare.aiGateway("openai/gpt-4o-mini", { + accountId: "test-account", + gatewayApiKey: "gateway-token", + apiKey: "provider-token", + }), + prompt: "Say hello.", + }), + ).pipe( + Effect.provide( + dynamicResponse((input) => + Effect.gen(function* () { + const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie) + expect(web.url).toBe("https://gateway.ai.cloudflare.com/v1/test-account/default/compat/chat/completions") + expect(web.headers.get("cf-aig-authorization")).toBe("Bearer gateway-token") + expect(web.headers.get("authorization")).toBe("Bearer provider-token") + return input.respond( + sseEvents(deltaChunk({ role: "assistant", content: "Hello" }), deltaChunk({}, "stop")), + { headers: { "content-type": "text/event-stream" } }, + ) + }), + ), + ), + ) + }), + ) + + it.effect("allows a fully configured baseURL override", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + model: Cloudflare.aiGateway("openai/gpt-4o-mini", { + baseURL: "https://gateway.proxy.test/v1/custom/compat", + apiKey: "test-token", + }), + prompt: "Say hello.", + }), + ) + + expect(prepared.model.baseURL).toBe("https://gateway.proxy.test/v1/custom/compat") + }), + ) + + it.effect("prepares direct Workers AI models through the OpenAI-compatible Chat protocol", () => + Effect.gen(function* () { + const model = Cloudflare.workersAI("@cf/meta/llama-3.1-8b-instruct", { + accountId: "test-account", + apiKey: "test-token", + }) + + expect(model).toMatchObject({ + id: "@cf/meta/llama-3.1-8b-instruct", + provider: "cloudflare-workers-ai", + route: "cloudflare-workers-ai", + baseURL: "https://api.cloudflare.com/client/v4/accounts/test-account/ai/v1", + }) + + const prepared = yield* LLMClient.prepare(LLM.request({ model, prompt: "Say hello." })) + + expect(prepared.route).toBe("cloudflare-workers-ai") + expect(prepared.body).toMatchObject({ + model: "@cf/meta/llama-3.1-8b-instruct", + messages: [{ role: "user", content: "Say hello." }], + stream: true, + }) + }), + ) + + it.effect("posts direct Workers AI requests to the account endpoint with bearer auth", () => + Effect.gen(function* () { + const response = yield* LLM.generate( + LLM.request({ + model: Cloudflare.workersAI("@cf/meta/llama-3.1-8b-instruct", { + accountId: "test-account", + apiKey: "test-token", + }), + prompt: "Say hello.", + }), + ).pipe( + Effect.provide( + dynamicResponse((input) => + Effect.gen(function* () { + const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie) + expect(web.url).toBe("https://api.cloudflare.com/client/v4/accounts/test-account/ai/v1/chat/completions") + expect(web.headers.get("authorization")).toBe("Bearer test-token") + expect(decodeJson(input.text)).toMatchObject({ + model: "@cf/meta/llama-3.1-8b-instruct", + stream: true, + messages: [{ role: "user", content: "Say hello." }], + }) + return input.respond( + sseEvents(deltaChunk({ role: "assistant", content: "Hello" }), deltaChunk({}, "stop")), + { headers: { "content-type": "text/event-stream" } }, + ) + }), + ), + ), + ) + + expect(response.text).toBe("Hello") + }), + ) + + it.effect("supports direct Workers AI token aliases through auth config", () => + Effect.gen(function* () { + yield* LLM.generate( + LLM.request({ + model: Cloudflare.workersAI("@cf/meta/llama-3.1-8b-instruct", { + accountId: "test-account", + }), + prompt: "Say hello.", + }), + ).pipe( + withEnv({ CLOUDFLARE_WORKERS_AI_TOKEN: "test-token" }), + Effect.provide( + dynamicResponse((input) => + Effect.gen(function* () { + const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie) + expect(web.headers.get("authorization")).toBe("Bearer test-token") + return input.respond( + sseEvents(deltaChunk({ role: "assistant", content: "Hello" }), deltaChunk({}, "stop")), + { headers: { "content-type": "text/event-stream" } }, + ) + }), + ), + ), + ) + }), + ) +}) diff --git a/packages/llm/test/provider/gemini-cache.recorded.test.ts b/packages/llm/test/provider/gemini-cache.recorded.test.ts new file mode 100644 index 0000000000..c3b3e55b36 --- /dev/null +++ b/packages/llm/test/provider/gemini-cache.recorded.test.ts @@ -0,0 +1,50 @@ +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import { LLM } from "../../src" +import { LLMClient } from "../../src/route" +import * as Gemini from "../../src/protocols/gemini" +import { LARGE_CACHEABLE_SYSTEM } from "../recorded-scenarios" +import { recordedTests } from "../recorded-test" + +const model = Gemini.model({ + id: "gemini-2.5-flash", + apiKey: process.env.GOOGLE_GENERATIVE_AI_API_KEY ?? process.env.GEMINI_API_KEY ?? "fixture", +}) + +// Gemini does implicit prefix caching on 2.5+ models above ~1024 tokens. The +// `CacheHint` is currently a no-op for Gemini (the explicit `CachedContent` +// API is out-of-band and intentionally not wired up). This test exists to +// pin the usage-parsing path: `cachedContentTokenCount` should surface as +// `cacheReadInputTokens` on the second identical call. +const cacheRequest = LLM.request({ + id: "recorded_gemini_cache", + model, + system: LARGE_CACHEABLE_SYSTEM, + prompt: "Say hi.", + generation: { maxTokens: 16, temperature: 0 }, +}) + +const recorded = recordedTests({ + prefix: "gemini-cache", + provider: "google", + protocol: "gemini", + requires: ["GOOGLE_GENERATIVE_AI_API_KEY"], + // Two identical requests in one cassette — match by recording order so the + // second call replays the cached-hit interaction. + options: { dispatch: "sequential" }, +}) + +describe("Gemini cache recorded", () => { + recorded.effect.with("reports cachedContentTokenCount on identical second call", { tags: ["cache"] }, () => + Effect.gen(function* () { + const first = yield* LLMClient.generate(cacheRequest) + expect(first.usage?.cacheReadInputTokens ?? 0).toBeGreaterThanOrEqual(0) + + const second = yield* LLMClient.generate(cacheRequest) + // Implicit caching is best-effort on Gemini's side; we assert the field + // is at least populated and non-negative. When re-recording, verify the + // cassette shows > 0 in the second response's usage. + expect(second.usage?.cacheReadInputTokens ?? 0).toBeGreaterThanOrEqual(0) + }), + ) +}) diff --git a/packages/llm/test/provider/gemini.test.ts b/packages/llm/test/provider/gemini.test.ts new file mode 100644 index 0000000000..e0b3864a26 --- /dev/null +++ b/packages/llm/test/provider/gemini.test.ts @@ -0,0 +1,365 @@ +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import { LLM, LLMError, Usage } from "../../src" +import { LLMClient } from "../../src/route" +import * as Gemini from "../../src/protocols/gemini" +import { it } from "../lib/effect" +import { fixedResponse } from "../lib/http" +import { sseEvents, sseRaw } from "../lib/sse" + +const model = Gemini.model({ + id: "gemini-2.5-flash", + baseURL: "https://generativelanguage.test/v1beta/", + headers: { "x-goog-api-key": "test" }, +}) + +const request = LLM.request({ + id: "req_1", + model, + system: "You are concise.", + prompt: "Say hello.", + generation: { maxTokens: 20, temperature: 0 }, +}) + +describe("Gemini route", () => { + it.effect("prepares Gemini target", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare(request) + + expect(prepared.body).toEqual({ + contents: [{ role: "user", parts: [{ text: "Say hello." }] }], + systemInstruction: { parts: [{ text: "You are concise." }] }, + generationConfig: { maxOutputTokens: 20, temperature: 0 }, + }) + }), + ) + + it.effect("prepares multimodal user input and tool history", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + id: "req_tool_result", + model, + tools: [ + { + name: "lookup", + description: "Lookup data", + inputSchema: { type: "object", properties: { query: { type: "string" } } }, + }, + ], + toolChoice: { type: "tool", name: "lookup" }, + messages: [ + LLM.user([ + { type: "text", text: "What is in this image?" }, + { type: "media", mediaType: "image/png", data: "AAECAw==" }, + ]), + LLM.assistant([LLM.toolCall({ id: "call_1", name: "lookup", input: { query: "weather" } })]), + LLM.toolMessage({ id: "call_1", name: "lookup", result: { forecast: "sunny" } }), + ], + }), + ) + + expect(prepared.body).toEqual({ + contents: [ + { + role: "user", + parts: [{ text: "What is in this image?" }, { inlineData: { mimeType: "image/png", data: "AAECAw==" } }], + }, + { + role: "model", + parts: [{ functionCall: { name: "lookup", args: { query: "weather" } } }], + }, + { + role: "user", + parts: [ + { functionResponse: { name: "lookup", response: { name: "lookup", content: '{"forecast":"sunny"}' } } }, + ], + }, + ], + tools: [ + { + functionDeclarations: [ + { + name: "lookup", + description: "Lookup data", + parameters: { type: "object", properties: { query: { type: "string" } } }, + }, + ], + }, + ], + toolConfig: { functionCallingConfig: { mode: "ANY", allowedFunctionNames: ["lookup"] } }, + }) + }), + ) + + it.effect("omits tools when tool choice is none", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + id: "req_no_tools", + model, + prompt: "Say hello.", + tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }], + toolChoice: { type: "none" }, + }), + ) + + expect(prepared.body).toEqual({ + contents: [{ role: "user", parts: [{ text: "Say hello." }] }], + }) + }), + ) + + it.effect("sanitizes integer enums, dangling required, untyped arrays, and scalar object keys", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + id: "req_schema_patch", + model, + prompt: "Use the tool.", + tools: [ + { + name: "lookup", + description: "Lookup data", + inputSchema: { + type: "object", + required: ["status", "missing"], + properties: { + status: { type: "integer", enum: [1, 2] }, + tags: { type: "array" }, + name: { type: "string", properties: { ignored: { type: "string" } }, required: ["ignored"] }, + }, + }, + }, + ], + }), + ) + + expect(prepared.body).toMatchObject({ + tools: [ + { + functionDeclarations: [ + { + parameters: { + type: "object", + required: ["status"], + properties: { + status: { type: "string", enum: ["1", "2"] }, + tags: { type: "array", items: { type: "string" } }, + name: { type: "string" }, + }, + }, + }, + ], + }, + ], + }) + }), + ) + + it.effect("parses text, reasoning, and usage stream fixtures", () => + Effect.gen(function* () { + const body = sseEvents( + { + candidates: [ + { + content: { role: "model", parts: [{ text: "thinking", thought: true }] }, + }, + ], + }, + { + candidates: [ + { + content: { role: "model", parts: [{ text: "Hello" }] }, + }, + ], + }, + { + candidates: [ + { + content: { role: "model", parts: [{ text: "!" }] }, + finishReason: "STOP", + }, + ], + }, + { + usageMetadata: { + promptTokenCount: 5, + candidatesTokenCount: 2, + totalTokenCount: 7, + thoughtsTokenCount: 1, + cachedContentTokenCount: 1, + }, + }, + ) + const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body))) + + expect(response.text).toBe("Hello!") + expect(response.reasoning).toBe("thinking") + expect(response.usage).toMatchObject({ + inputTokens: 5, + outputTokens: 3, + nonCachedInputTokens: 4, + cacheReadInputTokens: 1, + reasoningTokens: 1, + totalTokens: 7, + }) + expect(response.events).toEqual([ + { type: "reasoning-delta", id: "reasoning-0", text: "thinking" }, + { type: "text-delta", id: "text-0", text: "Hello" }, + { type: "text-delta", id: "text-0", text: "!" }, + { + type: "request-finish", + reason: "stop", + usage: new Usage({ + inputTokens: 5, + outputTokens: 3, + nonCachedInputTokens: 4, + cacheReadInputTokens: 1, + reasoningTokens: 1, + totalTokens: 7, + providerMetadata: { + google: { + promptTokenCount: 5, + candidatesTokenCount: 2, + totalTokenCount: 7, + thoughtsTokenCount: 1, + cachedContentTokenCount: 1, + }, + }, + }), + }, + ]) + }), + ) + + it.effect("emits streamed tool calls and maps finish reason", () => + Effect.gen(function* () { + const body = sseEvents({ + candidates: [ + { + content: { + role: "model", + parts: [{ functionCall: { name: "lookup", args: { query: "weather" } } }], + }, + finishReason: "STOP", + }, + ], + usageMetadata: { promptTokenCount: 5, candidatesTokenCount: 1 }, + }) + const response = yield* LLMClient.generate( + LLM.updateRequest(request, { + tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }], + }), + ).pipe(Effect.provide(fixedResponse(body))) + + expect(response.toolCalls).toEqual([ + { type: "tool-call", id: "tool_0", name: "lookup", input: { query: "weather" } }, + ]) + expect(response.events).toEqual([ + { type: "tool-call", id: "tool_0", name: "lookup", input: { query: "weather" } }, + { + type: "request-finish", + reason: "tool-calls", + usage: new Usage({ + inputTokens: 5, + outputTokens: 1, + nonCachedInputTokens: 5, + totalTokens: 6, + providerMetadata: { google: { promptTokenCount: 5, candidatesTokenCount: 1 } }, + }), + }, + ]) + }), + ) + + it.effect("assigns unique ids to multiple streamed tool calls", () => + Effect.gen(function* () { + const body = sseEvents({ + candidates: [ + { + content: { + role: "model", + parts: [ + { functionCall: { name: "lookup", args: { query: "weather" } } }, + { functionCall: { name: "lookup", args: { query: "news" } } }, + ], + }, + finishReason: "STOP", + }, + ], + }) + const response = yield* LLMClient.generate( + LLM.updateRequest(request, { + tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }], + }), + ).pipe(Effect.provide(fixedResponse(body))) + + expect(response.toolCalls).toEqual([ + { type: "tool-call", id: "tool_0", name: "lookup", input: { query: "weather" } }, + { type: "tool-call", id: "tool_1", name: "lookup", input: { query: "news" } }, + ]) + expect(response.events.at(-1)).toMatchObject({ type: "request-finish", reason: "tool-calls" }) + }), + ) + + it.effect("maps length and content-filter finish reasons", () => + Effect.gen(function* () { + const length = yield* LLMClient.generate(request).pipe( + Effect.provide( + fixedResponse( + sseEvents({ candidates: [{ content: { role: "model", parts: [] }, finishReason: "MAX_TOKENS" }] }), + ), + ), + ) + const filtered = yield* LLMClient.generate(request).pipe( + Effect.provide( + fixedResponse(sseEvents({ candidates: [{ content: { role: "model", parts: [] }, finishReason: "SAFETY" }] })), + ), + ) + + expect(length.events).toEqual([{ type: "request-finish", reason: "length" }]) + expect(filtered.events).toEqual([{ type: "request-finish", reason: "content-filter" }]) + }), + ) + + it.effect("leaves total usage undefined when component counts are missing", () => + Effect.gen(function* () { + const response = yield* LLMClient.generate(request).pipe( + Effect.provide(fixedResponse(sseEvents({ usageMetadata: { thoughtsTokenCount: 1 } }))), + ) + + expect(response.usage).toMatchObject({ reasoningTokens: 1 }) + expect(response.usage?.totalTokens).toBeUndefined() + }), + ) + + it.effect("fails invalid stream events", () => + Effect.gen(function* () { + const error = yield* LLMClient.generate(request).pipe( + Effect.provide(fixedResponse(sseRaw("data: {not json}"))), + Effect.flip, + ) + + expect(error).toBeInstanceOf(LLMError) + expect(error.reason).toMatchObject({ _tag: "InvalidProviderOutput" }) + expect(error.message).toContain("Invalid google/gemini stream event") + }), + ) + + it.effect("rejects unsupported assistant media content", () => + Effect.gen(function* () { + const error = yield* LLMClient.prepare( + LLM.request({ + id: "req_media", + model, + messages: [LLM.assistant({ type: "media", mediaType: "image/png", data: "AAECAw==" })], + }), + ).pipe(Effect.flip) + + expect(error.message).toContain( + "Gemini assistant messages only support text, reasoning, and tool-call content for now", + ) + }), + ) +}) diff --git a/packages/llm/test/provider/golden.recorded.test.ts b/packages/llm/test/provider/golden.recorded.test.ts new file mode 100644 index 0000000000..3fa27c706e --- /dev/null +++ b/packages/llm/test/provider/golden.recorded.test.ts @@ -0,0 +1,216 @@ +import { Redactor } from "@opencode-ai/http-recorder" +import * as AnthropicMessages from "../../src/protocols/anthropic-messages" +import * as Gemini from "../../src/protocols/gemini" +import * as OpenAIChat from "../../src/protocols/openai-chat" +import * as OpenAIResponses from "../../src/protocols/openai-responses" +import * as Cloudflare from "../../src/providers/cloudflare" +import * as OpenAI from "../../src/providers/openai" +import * as OpenAICompatible from "../../src/providers/openai-compatible" +import * as OpenRouter from "../../src/providers/openrouter" +import * as XAI from "../../src/providers/xai" +import { describeRecordedGoldenScenarios } from "../recorded-golden" + +const openAIChat = OpenAIChat.model({ id: "gpt-4o-mini", apiKey: process.env.OPENAI_API_KEY ?? "fixture" }) +const openAIResponses = OpenAIResponses.model({ id: "gpt-5.5", apiKey: process.env.OPENAI_API_KEY ?? "fixture" }) +const openAIResponsesWebSocket = OpenAI.responsesWebSocket("gpt-4.1-mini", { + apiKey: process.env.OPENAI_API_KEY ?? "fixture", +}) +const anthropicHaiku = AnthropicMessages.model({ + id: "claude-haiku-4-5-20251001", + apiKey: process.env.ANTHROPIC_API_KEY ?? "fixture", +}) +const anthropicOpus = AnthropicMessages.model({ + id: "claude-opus-4-7", + apiKey: process.env.ANTHROPIC_API_KEY ?? "fixture", +}) +const gemini = Gemini.model({ id: "gemini-2.5-flash", apiKey: process.env.GOOGLE_GENERATIVE_AI_API_KEY ?? "fixture" }) +const xaiBasic = XAI.model("grok-3-mini", { apiKey: process.env.XAI_API_KEY ?? "fixture" }) +const xaiFlagship = XAI.model("grok-4.3", { apiKey: process.env.XAI_API_KEY ?? "fixture" }) +const cloudflareAIGatewayWorkers = Cloudflare.aiGateway("workers-ai/@cf/meta/llama-3.1-8b-instruct", { + accountId: process.env.CLOUDFLARE_ACCOUNT_ID ?? "fixture-account", + gatewayId: + process.env.CLOUDFLARE_GATEWAY_ID && process.env.CLOUDFLARE_GATEWAY_ID !== process.env.CLOUDFLARE_ACCOUNT_ID + ? process.env.CLOUDFLARE_GATEWAY_ID + : undefined, + gatewayApiKey: process.env.CLOUDFLARE_API_TOKEN ?? "fixture", +}) +const cloudflareAIGatewayWorkersTools = Cloudflare.aiGateway("workers-ai/@cf/openai/gpt-oss-20b", { + accountId: process.env.CLOUDFLARE_ACCOUNT_ID ?? "fixture-account", + gatewayId: + process.env.CLOUDFLARE_GATEWAY_ID && process.env.CLOUDFLARE_GATEWAY_ID !== process.env.CLOUDFLARE_ACCOUNT_ID + ? process.env.CLOUDFLARE_GATEWAY_ID + : undefined, + gatewayApiKey: process.env.CLOUDFLARE_API_TOKEN ?? "fixture", +}) +const cloudflareWorkersAI = Cloudflare.workersAI("@cf/meta/llama-3.1-8b-instruct", { + accountId: process.env.CLOUDFLARE_ACCOUNT_ID ?? "fixture-account", + apiKey: process.env.CLOUDFLARE_API_KEY ?? "fixture", +}) +const cloudflareWorkersAITools = Cloudflare.workersAI("@cf/openai/gpt-oss-20b", { + accountId: process.env.CLOUDFLARE_ACCOUNT_ID ?? "fixture-account", + apiKey: process.env.CLOUDFLARE_API_KEY ?? "fixture", +}) +const deepseek = OpenAICompatible.deepseek.model("deepseek-chat", { apiKey: process.env.DEEPSEEK_API_KEY ?? "fixture" }) +const together = OpenAICompatible.togetherai.model("meta-llama/Llama-3.3-70B-Instruct-Turbo", { + apiKey: process.env.TOGETHER_AI_API_KEY ?? "fixture", +}) +const groq = OpenAICompatible.groq.model("llama-3.3-70b-versatile", { apiKey: process.env.GROQ_API_KEY ?? "fixture" }) +const openrouter = OpenRouter.model("openai/gpt-4o-mini", { apiKey: process.env.OPENROUTER_API_KEY ?? "fixture" }) +const openrouterGpt55 = OpenRouter.model("openai/gpt-5.5", { apiKey: process.env.OPENROUTER_API_KEY ?? "fixture" }) +const openrouterOpus = OpenRouter.model("anthropic/claude-opus-4.7", { + apiKey: process.env.OPENROUTER_API_KEY ?? "fixture", +}) + +const redactCloudflareURL = (url: string) => + url + .replace(/\/client\/v4\/accounts\/[^/]+\/ai\/v1\//, "/client/v4/accounts/{account}/ai/v1/") + .replace(/\/v1\/[^/]+\/[^/]+\/compat\//, "/v1/{account}/{gateway}/compat/") + +const cloudflareOptions = { + redactor: Redactor.defaults({ url: { transform: redactCloudflareURL } }), +} + +describeRecordedGoldenScenarios([ + { + name: "OpenAI Chat gpt-4o-mini", + prefix: "openai-chat", + model: openAIChat, + requires: ["OPENAI_API_KEY"], + scenarios: ["text", "tool-call", "tool-loop"], + }, + { + name: "OpenAI Responses gpt-5.5", + prefix: "openai-responses", + model: openAIResponses, + requires: ["OPENAI_API_KEY"], + tags: ["flagship"], + scenarios: [ + { id: "text", temperature: false }, + { id: "tool-call", temperature: false }, + { id: "tool-loop", temperature: false }, + ], + }, + { + name: "OpenAI Responses WebSocket gpt-4.1-mini", + prefix: "openai-responses-websocket", + model: openAIResponsesWebSocket, + transport: "websocket", + requires: ["OPENAI_API_KEY"], + scenarios: ["tool-loop"], + }, + { + name: "Anthropic Haiku 4.5", + prefix: "anthropic-messages", + model: anthropicHaiku, + requires: ["ANTHROPIC_API_KEY"], + options: { redactor: Redactor.defaults({ requestHeaders: { allow: ["content-type", "anthropic-version"] } }) }, + scenarios: ["text", "tool-call"], + }, + { + name: "Anthropic Opus 4.7", + prefix: "anthropic-messages", + model: anthropicOpus, + requires: ["ANTHROPIC_API_KEY"], + tags: ["flagship"], + options: { redactor: Redactor.defaults({ requestHeaders: { allow: ["content-type", "anthropic-version"] } }) }, + scenarios: [{ id: "tool-loop", temperature: false }], + }, + { + name: "Gemini 2.5 Flash", + prefix: "gemini", + model: gemini, + requires: ["GOOGLE_GENERATIVE_AI_API_KEY"], + scenarios: [{ id: "text", maxTokens: 80 }, "tool-call"], + }, + { + name: "xAI Grok 3 Mini", + prefix: "xai", + model: xaiBasic, + requires: ["XAI_API_KEY"], + scenarios: ["text", "tool-call"], + }, + { + name: "xAI Grok 4.3", + prefix: "xai", + model: xaiFlagship, + requires: ["XAI_API_KEY"], + tags: ["flagship"], + scenarios: [{ id: "tool-loop", timeout: 30_000 }], + }, + { + name: "Cloudflare AI Gateway Workers AI Llama 3.1 8B", + prefix: "cloudflare-ai-gateway", + model: cloudflareAIGatewayWorkers, + requires: ["CLOUDFLARE_ACCOUNT_ID", "CLOUDFLARE_API_TOKEN"], + options: cloudflareOptions, + scenarios: ["text"], + }, + { + name: "Cloudflare AI Gateway Workers AI GPT OSS 20B Tools", + prefix: "cloudflare-ai-gateway", + model: cloudflareAIGatewayWorkersTools, + requires: ["CLOUDFLARE_ACCOUNT_ID", "CLOUDFLARE_API_TOKEN"], + options: cloudflareOptions, + scenarios: [{ id: "tool-call", maxTokens: 120 }], + }, + { + name: "Cloudflare Workers AI Llama 3.1 8B", + prefix: "cloudflare-workers-ai", + model: cloudflareWorkersAI, + requires: ["CLOUDFLARE_ACCOUNT_ID", "CLOUDFLARE_API_KEY"], + options: cloudflareOptions, + scenarios: ["text"], + }, + { + name: "Cloudflare Workers AI GPT OSS 20B Tools", + prefix: "cloudflare-workers-ai", + model: cloudflareWorkersAITools, + requires: ["CLOUDFLARE_ACCOUNT_ID", "CLOUDFLARE_API_KEY"], + options: cloudflareOptions, + scenarios: [{ id: "tool-call", maxTokens: 120 }], + }, + { + name: "DeepSeek Chat", + prefix: "openai-compatible-chat", + model: deepseek, + requires: ["DEEPSEEK_API_KEY"], + scenarios: ["text"], + }, + { + name: "TogetherAI Llama 3.3 70B", + prefix: "openai-compatible-chat", + model: together, + requires: ["TOGETHER_AI_API_KEY"], + scenarios: ["text", "tool-call"], + }, + { + name: "Groq Llama 3.3 70B", + prefix: "openai-compatible-chat", + model: groq, + requires: ["GROQ_API_KEY"], + scenarios: ["text", "tool-call", { id: "tool-loop", timeout: 30_000 }], + }, + { + name: "OpenRouter gpt-4o-mini", + prefix: "openai-compatible-chat", + model: openrouter, + requires: ["OPENROUTER_API_KEY"], + scenarios: ["text", "tool-call", "tool-loop"], + }, + { + name: "OpenRouter gpt-5.5", + prefix: "openai-compatible-chat", + model: openrouterGpt55, + requires: ["OPENROUTER_API_KEY"], + tags: ["flagship"], + scenarios: ["tool-loop"], + }, + { + name: "OpenRouter Claude Opus 4.7", + prefix: "openai-compatible-chat", + model: openrouterOpus, + requires: ["OPENROUTER_API_KEY"], + tags: ["flagship"], + scenarios: ["tool-loop"], + }, +]) diff --git a/packages/llm/test/provider/openai-chat.test.ts b/packages/llm/test/provider/openai-chat.test.ts new file mode 100644 index 0000000000..2c692dcd7d --- /dev/null +++ b/packages/llm/test/provider/openai-chat.test.ts @@ -0,0 +1,358 @@ +import { describe, expect } from "bun:test" +import { Effect, Schema, Stream } from "effect" +import { HttpClientRequest } from "effect/unstable/http" +import { LLM, LLMError, Usage } from "../../src" +import * as Azure from "../../src/providers/azure" +import * as OpenAI from "../../src/providers/openai" +import * as OpenAIChat from "../../src/protocols/openai-chat" +import { LLMClient } from "../../src/route" +import { it } from "../lib/effect" +import { dynamicResponse, fixedResponse, truncatedStream } from "../lib/http" +import { deltaChunk, usageChunk } from "../lib/openai-chunks" +import { sseEvents } from "../lib/sse" + +const TargetJson = Schema.fromJsonString(Schema.Unknown) +const encodeJson = Schema.encodeSync(TargetJson) +const decodeJson = Schema.decodeUnknownSync(TargetJson) + +const model = OpenAIChat.model({ + id: "gpt-4o-mini", + baseURL: "https://api.openai.test/v1/", + headers: { authorization: "Bearer test" }, +}) + +const request = LLM.request({ + id: "req_1", + model, + system: "You are concise.", + prompt: "Say hello.", + generation: { maxTokens: 20, temperature: 0 }, +}) + +describe("OpenAI Chat route", () => { + it.effect("prepares OpenAI Chat payload", () => + Effect.gen(function* () { + // Pass the OpenAIChat payload type so `prepared.body` is statically + // typed to the route's native shape — the assertions below read field + // names without `unknown` casts. + const prepared = yield* LLMClient.prepare(request) + const _typed: { readonly model: string; readonly stream: true } = prepared.body + + expect(prepared.body).toEqual({ + model: "gpt-4o-mini", + messages: [ + { role: "system", content: "You are concise." }, + { role: "user", content: "Say hello." }, + ], + stream: true, + stream_options: { include_usage: true }, + max_tokens: 20, + temperature: 0, + }) + }), + ) + + it.effect("maps OpenAI provider options to Chat options", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + model: OpenAI.chat("gpt-4o-mini", { baseURL: "https://api.openai.test/v1/" }), + prompt: "think", + providerOptions: { openai: { reasoningEffort: "low" } }, + }), + ) + + expect(prepared.body.store).toBe(false) + expect(prepared.body.reasoning_effort).toBe("low") + }), + ) + + it.effect("adds native query params to the Chat Completions URL", () => + LLMClient.generate( + LLM.updateRequest(request, { model: OpenAIChat.model({ ...model, queryParams: { "api-version": "v1" } }) }), + ).pipe( + Effect.provide( + dynamicResponse((input) => + Effect.gen(function* () { + const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie) + expect(web.url).toBe("https://api.openai.test/v1/chat/completions?api-version=v1") + return input.respond(sseEvents(deltaChunk({}, "stop")), { + headers: { "content-type": "text/event-stream" }, + }) + }), + ), + ), + ), + ) + + it.effect("uses Azure api-key header for static OpenAI Chat keys", () => + LLMClient.generate( + LLM.updateRequest(request, { + model: Azure.chat("gpt-4o-mini", { + baseURL: "https://opencode-test.openai.azure.com/openai/v1/", + apiKey: "azure-key", + headers: { authorization: "Bearer stale" }, + }), + }), + ).pipe( + Effect.provide( + dynamicResponse((input) => + Effect.gen(function* () { + const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie) + expect(web.headers.get("api-key")).toBe("azure-key") + expect(web.headers.get("authorization")).toBeNull() + return input.respond(sseEvents(deltaChunk({}, "stop")), { + headers: { "content-type": "text/event-stream" }, + }) + }), + ), + ), + ), + ) + + it.effect("applies serializable HTTP overlays after payload lowering", () => + LLMClient.generate( + LLM.updateRequest(request, { + model: OpenAIChat.model({ ...model, apiKey: "fresh-key", headers: { authorization: "Bearer stale" } }), + http: { + body: { metadata: { source: "test" } }, + headers: { authorization: "Bearer request", "x-custom": "yes" }, + query: { debug: "1" }, + }, + }), + ).pipe( + Effect.provide( + dynamicResponse((input) => + Effect.gen(function* () { + const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie) + expect(web.url).toBe("https://api.openai.test/v1/chat/completions?debug=1") + expect(web.headers.get("authorization")).toBe("Bearer fresh-key") + expect(web.headers.get("x-custom")).toBe("yes") + expect(decodeJson(input.text)).toMatchObject({ + stream: true, + stream_options: { include_usage: true }, + metadata: { source: "test" }, + }) + return input.respond(sseEvents(deltaChunk({}, "stop")), { + headers: { "content-type": "text/event-stream" }, + }) + }), + ), + ), + ), + ) + + it.effect("prepares assistant tool-call and tool-result messages", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + id: "req_tool_result", + model, + messages: [ + LLM.user("What is the weather?"), + LLM.assistant([LLM.toolCall({ id: "call_1", name: "lookup", input: { query: "weather" } })]), + LLM.toolMessage({ id: "call_1", name: "lookup", result: { forecast: "sunny" } }), + ], + }), + ) + + expect(prepared.body).toEqual({ + model: "gpt-4o-mini", + messages: [ + { role: "user", content: "What is the weather?" }, + { + role: "assistant", + content: null, + tool_calls: [ + { + id: "call_1", + type: "function", + function: { name: "lookup", arguments: encodeJson({ query: "weather" }) }, + }, + ], + }, + { role: "tool", tool_call_id: "call_1", content: encodeJson({ forecast: "sunny" }) }, + ], + stream: true, + stream_options: { include_usage: true }, + }) + }), + ) + + it.effect("rejects unsupported user media content", () => + Effect.gen(function* () { + const error = yield* LLMClient.prepare( + LLM.request({ + id: "req_media", + model, + messages: [LLM.user({ type: "media", mediaType: "image/png", data: "AAECAw==" })], + }), + ).pipe(Effect.flip) + + expect(error.message).toContain("OpenAI Chat user messages only support text content for now") + }), + ) + + it.effect("rejects unsupported assistant reasoning content", () => + Effect.gen(function* () { + const error = yield* LLMClient.prepare( + LLM.request({ + id: "req_reasoning", + model, + messages: [LLM.assistant({ type: "reasoning", text: "hidden" })], + }), + ).pipe(Effect.flip) + + expect(error.message).toContain("OpenAI Chat assistant messages only support text and tool-call content for now") + }), + ) + + it.effect("parses text and usage stream fixtures", () => + Effect.gen(function* () { + const body = sseEvents( + deltaChunk({ role: "assistant", content: "Hello" }), + deltaChunk({ content: "!" }), + deltaChunk({}, "stop"), + usageChunk({ + prompt_tokens: 5, + completion_tokens: 2, + total_tokens: 7, + prompt_tokens_details: { cached_tokens: 1 }, + completion_tokens_details: { reasoning_tokens: 0 }, + }), + ) + const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body))) + + expect(response.text).toBe("Hello!") + expect(response.events).toEqual([ + { type: "text-delta", id: "text-0", text: "Hello" }, + { type: "text-delta", id: "text-0", text: "!" }, + { + type: "request-finish", + reason: "stop", + usage: new Usage({ + inputTokens: 5, + outputTokens: 2, + nonCachedInputTokens: 4, + cacheReadInputTokens: 1, + reasoningTokens: 0, + totalTokens: 7, + providerMetadata: { + openai: { + prompt_tokens: 5, + completion_tokens: 2, + total_tokens: 7, + prompt_tokens_details: { cached_tokens: 1 }, + completion_tokens_details: { reasoning_tokens: 0 }, + }, + }, + }), + }, + ]) + }), + ) + + it.effect("assembles streamed tool call input", () => + Effect.gen(function* () { + const body = sseEvents( + deltaChunk({ + role: "assistant", + tool_calls: [{ index: 0, id: "call_1", function: { name: "lookup", arguments: '{"query"' } }], + }), + deltaChunk({ tool_calls: [{ index: 0, function: { arguments: ':"weather"}' } }] }), + deltaChunk({}, "tool_calls"), + ) + const response = yield* LLMClient.generate( + LLM.updateRequest(request, { + tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }], + }), + ).pipe(Effect.provide(fixedResponse(body))) + + expect(response.events).toEqual([ + { type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"' }, + { type: "tool-input-delta", id: "call_1", name: "lookup", text: ':"weather"}' }, + { type: "tool-call", id: "call_1", name: "lookup", input: { query: "weather" } }, + { type: "request-finish", reason: "tool-calls", usage: undefined }, + ]) + }), + ) + + it.effect("does not finalize streamed tool calls without a finish reason", () => + Effect.gen(function* () { + const body = sseEvents( + deltaChunk({ + role: "assistant", + tool_calls: [{ index: 0, id: "call_1", function: { name: "lookup", arguments: '{"query"' } }], + }), + deltaChunk({ tool_calls: [{ index: 0, function: { arguments: ':"weather"}' } }] }), + ) + const response = yield* LLMClient.generate( + LLM.updateRequest(request, { + tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }], + }), + ).pipe(Effect.provide(fixedResponse(body))) + + expect(response.events).toEqual([ + { type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"' }, + { type: "tool-input-delta", id: "call_1", name: "lookup", text: ':"weather"}' }, + ]) + expect(response.toolCalls).toEqual([]) + }), + ) + + it.effect("fails on malformed stream events", () => + Effect.gen(function* () { + const body = sseEvents(deltaChunk({ content: 123 })) + const error = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)), Effect.flip) + + expect(error.message).toContain("Invalid openai/openai-chat stream event") + }), + ) + + it.effect("surfaces transport errors that occur mid-stream", () => + Effect.gen(function* () { + const layer = truncatedStream([ + `data: ${JSON.stringify(deltaChunk({ role: "assistant", content: "Hello" }))}\n\n`, + ]) + const error = yield* LLMClient.generate(request).pipe(Effect.provide(layer), Effect.flip) + + expect(error.message).toContain("Failed to read openai/openai-chat stream") + }), + ) + + it.effect("fails HTTP provider errors before stream parsing", () => + Effect.gen(function* () { + const error = yield* LLMClient.generate(request).pipe( + Effect.provide( + fixedResponse('{"error":{"message":"Bad request","type":"invalid_request_error"}}', { + status: 400, + headers: { "content-type": "application/json" }, + }), + ), + Effect.flip, + ) + + expect(error).toBeInstanceOf(LLMError) + expect(error.reason).toMatchObject({ _tag: "InvalidRequest" }) + expect(error.message).toContain("HTTP 400") + }), + ) + + it.effect("short-circuits the upstream stream when the consumer takes a prefix", () => + Effect.gen(function* () { + // The body has more chunks than we'll consume. If `Stream.take(1)` did + // not interrupt the upstream HTTP body the test would hang waiting for + // the rest of the stream to drain. + const body = sseEvents( + deltaChunk({ role: "assistant", content: "Hello" }), + deltaChunk({ content: " world" }), + deltaChunk({}, "stop"), + ) + + const events = Array.from( + yield* LLMClient.stream(request).pipe(Stream.take(1), Stream.runCollect, Effect.provide(fixedResponse(body))), + ) + expect(events.map((event) => event.type)).toEqual(["text-delta"]) + }), + ) +}) diff --git a/packages/llm/test/provider/openai-compatible-chat.test.ts b/packages/llm/test/provider/openai-compatible-chat.test.ts new file mode 100644 index 0000000000..627e6ef4a0 --- /dev/null +++ b/packages/llm/test/provider/openai-compatible-chat.test.ts @@ -0,0 +1,237 @@ +import { describe, expect } from "bun:test" +import { Effect, Schema } from "effect" +import { HttpClientRequest } from "effect/unstable/http" +import { LLM } from "../../src" +import { LLMClient } from "../../src/route" +import * as OpenAICompatible from "../../src/providers/openai-compatible" +import * as OpenAICompatibleChat from "../../src/protocols/openai-compatible-chat" +import { it } from "../lib/effect" +import { dynamicResponse } from "../lib/http" +import { sseEvents } from "../lib/sse" + +const Json = Schema.fromJsonString(Schema.Unknown) +const decodeJson = Schema.decodeUnknownSync(Json) + +const model = OpenAICompatibleChat.model({ + id: "deepseek-chat", + provider: "deepseek", + baseURL: "https://api.deepseek.test/v1/", + apiKey: "test-key", + queryParams: { "api-version": "2026-01-01" }, +}) + +const request = LLM.request({ + id: "req_1", + model, + system: "You are concise.", + prompt: "Say hello.", + generation: { maxTokens: 20, temperature: 0 }, +}) + +const deltaChunk = (delta: object, finishReason: string | null = null) => ({ + id: "chatcmpl_fixture", + choices: [{ delta, finish_reason: finishReason }], + usage: null, +}) + +const usageChunk = (usage: object) => ({ + id: "chatcmpl_fixture", + choices: [], + usage, +}) + +const providerFamilies = [ + ["baseten", OpenAICompatible.baseten, "https://inference.baseten.co/v1"], + ["cerebras", OpenAICompatible.cerebras, "https://api.cerebras.ai/v1"], + ["deepinfra", OpenAICompatible.deepinfra, "https://api.deepinfra.com/v1/openai"], + ["deepseek", OpenAICompatible.deepseek, "https://api.deepseek.com/v1"], + ["fireworks", OpenAICompatible.fireworks, "https://api.fireworks.ai/inference/v1"], + ["togetherai", OpenAICompatible.togetherai, "https://api.together.xyz/v1"], +] as const + +describe("OpenAI-compatible Chat route", () => { + it.effect("prepares generic Chat target", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.updateRequest(request, { + tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }], + toolChoice: { type: "required" }, + }), + ) + + expect(prepared.route).toBe("openai-compatible-chat") + expect(prepared.model).toMatchObject({ + id: "deepseek-chat", + provider: "deepseek", + route: "openai-compatible-chat", + baseURL: "https://api.deepseek.test/v1/", + apiKey: "test-key", + queryParams: { "api-version": "2026-01-01" }, + }) + expect(prepared.body).toEqual({ + model: "deepseek-chat", + messages: [ + { role: "system", content: "You are concise." }, + { role: "user", content: "Say hello." }, + ], + tools: [ + { + type: "function", + function: { name: "lookup", description: "Lookup data", parameters: { type: "object" } }, + }, + ], + tool_choice: "required", + stream: true, + stream_options: { include_usage: true }, + max_tokens: 20, + temperature: 0, + }) + }), + ) + + it.effect("provides model helpers for compatible provider families", () => + Effect.gen(function* () { + expect( + providerFamilies.map(([provider, family]) => { + const model = family.model(`${provider}-model`, { apiKey: "test-key" }) + return { + id: String(model.id), + provider: String(model.provider), + route: model.route, + baseURL: model.baseURL, + apiKey: model.apiKey, + } + }), + ).toEqual( + providerFamilies.map(([provider, _, baseURL]) => ({ + id: `${provider}-model`, + provider, + route: "openai-compatible-chat", + baseURL, + apiKey: "test-key", + })), + ) + + const custom = OpenAICompatible.deepseek.model("deepseek-chat", { + apiKey: "test-key", + baseURL: "https://custom.deepseek.test/v1", + }) + expect(custom).toMatchObject({ + provider: "deepseek", + route: "openai-compatible-chat", + baseURL: "https://custom.deepseek.test/v1", + }) + }), + ) + + it.effect("matches AI SDK compatible basic request body fixture", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare(request) + + expect(prepared.body).toEqual({ + model: "deepseek-chat", + messages: [ + { role: "system", content: "You are concise." }, + { role: "user", content: "Say hello." }, + ], + stream: true, + stream_options: { include_usage: true }, + max_tokens: 20, + temperature: 0, + }) + }), + ) + + it.effect("matches AI SDK compatible tool request body fixture", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + id: "req_tool_parity", + model, + tools: [ + { + name: "lookup", + description: "Lookup data", + inputSchema: { type: "object", properties: { query: { type: "string" } }, required: ["query"] }, + }, + ], + toolChoice: "lookup", + messages: [ + LLM.user("What is the weather?"), + LLM.assistant([LLM.toolCall({ id: "call_1", name: "lookup", input: { query: "weather" } })]), + LLM.toolMessage({ id: "call_1", name: "lookup", result: { forecast: "sunny" } }), + ], + }), + ) + + expect(prepared.body).toEqual({ + model: "deepseek-chat", + messages: [ + { role: "user", content: "What is the weather?" }, + { + role: "assistant", + content: null, + tool_calls: [ + { + id: "call_1", + type: "function", + function: { name: "lookup", arguments: '{"query":"weather"}' }, + }, + ], + }, + { role: "tool", tool_call_id: "call_1", content: '{"forecast":"sunny"}' }, + ], + tools: [ + { + type: "function", + function: { + name: "lookup", + description: "Lookup data", + parameters: { type: "object", properties: { query: { type: "string" } }, required: ["query"] }, + }, + }, + ], + tool_choice: { type: "function", function: { name: "lookup" } }, + stream: true, + stream_options: { include_usage: true }, + }) + }), + ) + + it.effect("posts to the configured compatible endpoint and parses text usage", () => + Effect.gen(function* () { + const response = yield* LLMClient.generate(request).pipe( + Effect.provide( + dynamicResponse((input) => + Effect.gen(function* () { + const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie) + expect(web.url).toBe("https://api.deepseek.test/v1/chat/completions?api-version=2026-01-01") + expect(web.headers.get("authorization")).toBe("Bearer test-key") + expect(decodeJson(input.text)).toMatchObject({ + model: "deepseek-chat", + stream: true, + messages: [ + { role: "system", content: "You are concise." }, + { role: "user", content: "Say hello." }, + ], + }) + return input.respond( + sseEvents( + deltaChunk({ role: "assistant", content: "Hello" }), + deltaChunk({ content: "!" }), + deltaChunk({}, "stop"), + usageChunk({ prompt_tokens: 5, completion_tokens: 2, total_tokens: 7 }), + ), + { headers: { "content-type": "text/event-stream" } }, + ) + }), + ), + ), + ) + + expect(response.text).toBe("Hello!") + expect(response.usage).toMatchObject({ inputTokens: 5, outputTokens: 2, totalTokens: 7 }) + expect(response.events.at(-1)).toMatchObject({ type: "request-finish", reason: "stop" }) + }), + ) +}) diff --git a/packages/llm/test/provider/openai-responses-cache.recorded.test.ts b/packages/llm/test/provider/openai-responses-cache.recorded.test.ts new file mode 100644 index 0000000000..5a38898c0f --- /dev/null +++ b/packages/llm/test/provider/openai-responses-cache.recorded.test.ts @@ -0,0 +1,47 @@ +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import { LLM } from "../../src" +import { LLMClient } from "../../src/route" +import * as OpenAIResponses from "../../src/protocols/openai-responses" +import { LARGE_CACHEABLE_SYSTEM } from "../recorded-scenarios" +import { recordedTests } from "../recorded-test" + +const model = OpenAIResponses.model({ + id: "gpt-4.1-mini", + apiKey: process.env.OPENAI_API_KEY ?? "fixture", +}) + +// OpenAI caches prefixes automatically once they cross the 1024-token threshold; +// `CacheHint` is a no-op for the wire body. The stable signal is the +// `prompt_cache_key` routing hint, which keeps repeated calls on the same shard +// so cache hits are observable. +const cacheRequest = LLM.request({ + id: "recorded_openai_responses_cache", + model, + system: LARGE_CACHEABLE_SYSTEM, + prompt: "Say hi.", + generation: { maxTokens: 16, temperature: 0 }, + providerOptions: { openai: { promptCacheKey: "recorded-cache-test" } }, +}) + +const recorded = recordedTests({ + prefix: "openai-responses-cache", + provider: "openai", + protocol: "openai-responses", + requires: ["OPENAI_API_KEY"], + // Two identical requests in one cassette — match by recording order so the + // second call replays the cached-hit interaction, not the cold-miss one. + options: { dispatch: "sequential" }, +}) + +describe("OpenAI Responses cache recorded", () => { + recorded.effect.with("reports cached_tokens on identical second call", { tags: ["cache"] }, () => + Effect.gen(function* () { + const first = yield* LLMClient.generate(cacheRequest) + expect(first.usage?.cacheReadInputTokens ?? 0).toBeGreaterThanOrEqual(0) + + const second = yield* LLMClient.generate(cacheRequest) + expect(second.usage?.cacheReadInputTokens ?? 0).toBeGreaterThan(0) + }), + ) +}) diff --git a/packages/llm/test/provider/openai-responses.test.ts b/packages/llm/test/provider/openai-responses.test.ts new file mode 100644 index 0000000000..2319857ed1 --- /dev/null +++ b/packages/llm/test/provider/openai-responses.test.ts @@ -0,0 +1,556 @@ +import { describe, expect } from "bun:test" +import { ConfigProvider, Effect, Layer, Stream } from "effect" +import { Headers, HttpClientRequest } from "effect/unstable/http" +import { LLM, LLMError, Usage } from "../../src" +import { Auth, LLMClient, RequestExecutor, WebSocketExecutor } from "../../src/route" +import * as Azure from "../../src/providers/azure" +import * as OpenAI from "../../src/providers/openai" +import * as OpenAIResponses from "../../src/protocols/openai-responses" +import * as ProviderShared from "../../src/protocols/shared" +import { it } from "../lib/effect" +import { dynamicResponse, fixedResponse } from "../lib/http" +import { sseEvents } from "../lib/sse" + +const model = OpenAIResponses.model({ + id: "gpt-4.1-mini", + baseURL: "https://api.openai.test/v1/", + headers: { authorization: "Bearer test" }, +}) + +const request = LLM.request({ + id: "req_1", + model, + system: "You are concise.", + prompt: "Say hello.", + generation: { maxTokens: 20, temperature: 0 }, +}) + +const configEnv = (env: Record) => Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env }))) + +describe("OpenAI Responses route", () => { + it.effect("prepares OpenAI Responses target", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare(request) + + expect(prepared.body).toEqual({ + model: "gpt-4.1-mini", + input: [ + { role: "system", content: "You are concise." }, + { role: "user", content: [{ type: "input_text", text: "Say hello." }] }, + ], + stream: true, + max_output_tokens: 20, + temperature: 0, + }) + }), + ) + + it.effect("prepares OpenAI Responses WebSocket target", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.updateRequest(request, { + model: OpenAI.responsesWebSocket("gpt-4.1-mini", { baseURL: "https://api.openai.test/v1/", apiKey: "test" }), + }), + ) + + expect(prepared.route).toBe("openai-responses-websocket") + expect(prepared.protocol).toBe("openai-responses") + expect(prepared.metadata).toEqual({ transport: "websocket-json" }) + expect(prepared.body).toMatchObject({ model: "gpt-4.1-mini", stream: true }) + }), + ) + + it.effect("streams OpenAI Responses over WebSocket", () => + Effect.gen(function* () { + const sent: string[] = [] + const opened: Array<{ readonly url: string; readonly authorization: string | undefined }> = [] + let closed = false + const deps = Layer.mergeAll( + Layer.succeed( + RequestExecutor.Service, + RequestExecutor.Service.of({ + execute: () => Effect.die("unexpected HTTP request"), + }), + ), + Layer.succeed( + WebSocketExecutor.Service, + WebSocketExecutor.Service.of({ + open: (input) => + Effect.succeed({ + sendText: (message) => + Effect.sync(() => { + opened.push({ url: input.url, authorization: input.headers.authorization }) + sent.push(message) + }), + messages: Stream.fromArray([ + ProviderShared.encodeJson({ type: "response.output_text.delta", item_id: "msg_1", delta: "Hi" }), + ProviderShared.encodeJson({ type: "response.completed", response: { id: "resp_ws" } }), + ]), + close: Effect.sync(() => { + closed = true + }), + }), + }), + ), + ) + const response = yield* LLMClient.generate( + LLM.request({ + model: OpenAI.responsesWebSocket("gpt-4.1-mini", { baseURL: "https://api.openai.test/v1/", apiKey: "test" }), + prompt: "Say hello.", + }), + ).pipe(Effect.provide(LLMClient.layerWithWebSocket.pipe(Layer.provide(deps)))) + + expect(response.text).toBe("Hi") + expect(opened).toEqual([{ url: "wss://api.openai.test/v1/responses", authorization: "Bearer test" }]) + expect(closed).toBe(true) + expect(sent).toHaveLength(1) + expect(JSON.parse(sent[0])).toEqual({ + type: "response.create", + model: "gpt-4.1-mini", + input: [{ role: "user", content: [{ type: "input_text", text: "Say hello." }] }], + store: false, + }) + }), + ) + + it.effect("requires WebSocket runtime for OpenAI Responses WebSocket", () => + Effect.gen(function* () { + const error = yield* LLMClient.generate( + LLM.request({ + model: OpenAI.responsesWebSocket("gpt-4.1-mini", { baseURL: "https://api.openai.test/v1/", apiKey: "test" }), + prompt: "Say hello.", + }), + ).pipe( + Effect.provide( + LLMClient.layer.pipe( + Layer.provide( + Layer.succeed( + RequestExecutor.Service, + RequestExecutor.Service.of({ + execute: () => Effect.die("unexpected HTTP request"), + }), + ), + ), + ), + ), + Effect.flip, + ) + + expect(error.message).toContain("requires WebSocketExecutor.Service") + }), + ) + + it.effect("fails immediately when WebSocket is already closed", () => + Effect.gen(function* () { + const error = yield* WebSocketExecutor.fromWebSocket( + { readyState: globalThis.WebSocket.CLOSED } as globalThis.WebSocket, + { url: "wss://api.openai.test/v1/responses", headers: Headers.empty }, + ).pipe(Effect.flip) + + expect(error.message).toContain("closed before opening") + }), + ) + + it.effect("adds native query params to the Responses URL", () => + Effect.gen(function* () { + yield* LLMClient.generate( + LLM.updateRequest(request, { + model: OpenAIResponses.model({ ...model, queryParams: { "api-version": "v1" } }), + }), + ).pipe( + Effect.provide( + dynamicResponse((input) => + Effect.gen(function* () { + const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie) + expect(web.url).toBe("https://api.openai.test/v1/responses?api-version=v1") + return input.respond(sseEvents({ type: "response.completed", response: {} }), { + headers: { "content-type": "text/event-stream" }, + }) + }), + ), + ), + ) + }), + ) + + it.effect("uses Azure api-key header for static OpenAI Responses keys", () => + Effect.gen(function* () { + yield* LLMClient.generate( + LLM.updateRequest(request, { + model: Azure.responses("gpt-4.1-mini", { + baseURL: "https://opencode-test.openai.azure.com/openai/v1/", + apiKey: "azure-key", + headers: { authorization: "Bearer stale" }, + }), + }), + ).pipe( + Effect.provide( + dynamicResponse((input) => + Effect.gen(function* () { + const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie) + expect(web.headers.get("api-key")).toBe("azure-key") + expect(web.headers.get("authorization")).toBeNull() + return input.respond(sseEvents({ type: "response.completed", response: {} }), { + headers: { "content-type": "text/event-stream" }, + }) + }), + ), + ), + ) + }), + ) + + it.effect("loads OpenAI default auth from Effect Config", () => + LLMClient.generate( + LLM.updateRequest(request, { + model: OpenAI.responses("gpt-4.1-mini", { baseURL: "https://api.openai.test/v1/" }), + }), + ).pipe( + configEnv({ OPENAI_API_KEY: "env-key" }), + Effect.provide( + dynamicResponse((input) => + Effect.gen(function* () { + const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie) + expect(web.headers.get("authorization")).toBe("Bearer env-key") + return input.respond(sseEvents({ type: "response.completed", response: {} }), { + headers: { "content-type": "text/event-stream" }, + }) + }), + ), + ), + ), + ) + + it.effect("lets explicit auth override OpenAI default API key auth", () => + LLMClient.generate( + LLM.updateRequest(request, { + model: OpenAI.responses("gpt-4.1-mini", { + baseURL: "https://api.openai.test/v1/", + auth: Auth.bearer("oauth-token"), + }), + }), + ).pipe( + Effect.provide( + dynamicResponse((input) => + Effect.gen(function* () { + const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie) + expect(web.headers.get("authorization")).toBe("Bearer oauth-token") + return input.respond(sseEvents({ type: "response.completed", response: {} }), { + headers: { "content-type": "text/event-stream" }, + }) + }), + ), + ), + ), + ) + + it.effect("prepares function call and function output input items", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + id: "req_tool_result", + model, + messages: [ + LLM.user("What is the weather?"), + LLM.assistant([LLM.toolCall({ id: "call_1", name: "lookup", input: { query: "weather" } })]), + LLM.toolMessage({ id: "call_1", name: "lookup", result: { forecast: "sunny" } }), + ], + }), + ) + + expect(prepared.body).toEqual({ + model: "gpt-4.1-mini", + input: [ + { role: "user", content: [{ type: "input_text", text: "What is the weather?" }] }, + { type: "function_call", call_id: "call_1", name: "lookup", arguments: '{"query":"weather"}' }, + { type: "function_call_output", call_id: "call_1", output: '{"forecast":"sunny"}' }, + ], + stream: true, + }) + }), + ) + + it.effect("maps OpenAI provider options to Responses options", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + model: OpenAI.model("gpt-5.2", { baseURL: "https://api.openai.test/v1/" }), + prompt: "think", + providerOptions: { + openai: { + promptCacheKey: "session_123", + reasoningEffort: "high", + reasoningSummary: "auto", + includeEncryptedReasoning: true, + }, + }, + }), + ) + + expect(prepared.body.store).toBe(false) + expect(prepared.body.prompt_cache_key).toBe("session_123") + expect(prepared.body.include).toEqual(["reasoning.encrypted_content"]) + expect(prepared.body.reasoning).toEqual({ effort: "high", summary: "auto" }) + expect(prepared.body.text).toEqual({ verbosity: "low" }) + }), + ) + + it.effect("request OpenAI provider options override model defaults", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + model: OpenAI.model("gpt-4.1-mini", { + baseURL: "https://api.openai.test/v1/", + providerOptions: { openai: { promptCacheKey: "model_cache" } }, + }), + prompt: "no cache", + providerOptions: { openai: { promptCacheKey: "request_cache" } }, + }), + ) + + expect(prepared.body.prompt_cache_key).toBe("request_cache") + }), + ) + + it.effect("parses text and usage stream fixtures", () => + Effect.gen(function* () { + const body = sseEvents( + { type: "response.output_text.delta", item_id: "msg_1", delta: "Hello" }, + { type: "response.output_text.delta", item_id: "msg_1", delta: "!" }, + { + type: "response.completed", + response: { + id: "resp_1", + service_tier: "default", + usage: { + input_tokens: 5, + output_tokens: 2, + total_tokens: 7, + input_tokens_details: { cached_tokens: 1 }, + output_tokens_details: { reasoning_tokens: 0 }, + }, + }, + }, + ) + const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body))) + + expect(response.text).toBe("Hello!") + expect(response.events).toEqual([ + { type: "text-delta", id: "msg_1", text: "Hello" }, + { type: "text-delta", id: "msg_1", text: "!" }, + { + type: "request-finish", + reason: "stop", + providerMetadata: { openai: { responseId: "resp_1", serviceTier: "default" } }, + usage: new Usage({ + inputTokens: 5, + outputTokens: 2, + nonCachedInputTokens: 4, + cacheReadInputTokens: 1, + reasoningTokens: 0, + totalTokens: 7, + providerMetadata: { + openai: { + input_tokens: 5, + output_tokens: 2, + total_tokens: 7, + input_tokens_details: { cached_tokens: 1 }, + output_tokens_details: { reasoning_tokens: 0 }, + }, + }, + }), + }, + ]) + }), + ) + + it.effect("assembles streamed function call input", () => + 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.function_call_arguments.delta", item_id: "item_1", delta: '{"query"' }, + { type: "response.function_call_arguments.delta", item_id: "item_1", delta: ':"weather"}' }, + { + 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: { usage: { input_tokens: 5, output_tokens: 1 } } }, + ) + const response = yield* LLMClient.generate( + LLM.updateRequest(request, { + tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }], + }), + ).pipe(Effect.provide(fixedResponse(body))) + + expect(response.events).toEqual([ + { + type: "tool-input-delta", + id: "call_1", + name: "lookup", + text: '{"query"', + }, + { + type: "tool-input-delta", + id: "call_1", + name: "lookup", + text: ':"weather"}', + }, + { + type: "tool-call", + id: "call_1", + name: "lookup", + input: { query: "weather" }, + providerMetadata: { openai: { itemId: "item_1" } }, + }, + { + type: "request-finish", + reason: "tool-calls", + usage: new Usage({ + inputTokens: 5, + outputTokens: 1, + nonCachedInputTokens: 5, + totalTokens: 6, + providerMetadata: { openai: { input_tokens: 5, output_tokens: 1 } }, + }), + }, + ]) + }), + ) + + it.effect("decodes web_search_call as provider-executed tool-call + tool-result", () => + Effect.gen(function* () { + const item = { + type: "web_search_call", + id: "ws_1", + status: "completed", + action: { type: "search", query: "effect 4" }, + } + const body = sseEvents( + { type: "response.output_item.added", item }, + { type: "response.output_item.done", item }, + { type: "response.completed", response: { usage: { input_tokens: 5, output_tokens: 1 } } }, + ) + const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body))) + + const callsAndResults = response.events.filter( + (event) => event.type === "tool-call" || event.type === "tool-result", + ) + expect(callsAndResults).toEqual([ + { + type: "tool-call", + id: "ws_1", + name: "web_search", + input: { type: "search", query: "effect 4" }, + providerExecuted: true, + providerMetadata: { openai: { itemId: "ws_1" } }, + }, + { + type: "tool-result", + id: "ws_1", + name: "web_search", + result: { type: "json", value: item }, + providerExecuted: true, + providerMetadata: { openai: { itemId: "ws_1" } }, + }, + ]) + }), + ) + + it.effect("decodes code_interpreter_call as provider-executed events with code input", () => + Effect.gen(function* () { + const item = { + type: "code_interpreter_call", + id: "ci_1", + status: "completed", + code: "print(1+1)", + container_id: "cnt_xyz", + outputs: [{ type: "logs", logs: "2\n" }], + } + const body = sseEvents( + { type: "response.output_item.done", item }, + { type: "response.completed", response: { usage: { input_tokens: 5, output_tokens: 1 } } }, + ) + const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body))) + + const toolCall = response.events.find((event) => event.type === "tool-call") + expect(toolCall).toEqual({ + type: "tool-call", + id: "ci_1", + name: "code_interpreter", + input: { code: "print(1+1)", container_id: "cnt_xyz" }, + providerExecuted: true, + providerMetadata: { openai: { itemId: "ci_1" } }, + }) + const toolResult = response.events.find((event) => event.type === "tool-result") + expect(toolResult).toEqual({ + type: "tool-result", + id: "ci_1", + name: "code_interpreter", + result: { type: "json", value: item }, + providerExecuted: true, + providerMetadata: { openai: { itemId: "ci_1" } }, + }) + }), + ) + + it.effect("rejects unsupported user media content", () => + Effect.gen(function* () { + const error = yield* LLMClient.prepare( + LLM.request({ + id: "req_media", + model, + messages: [LLM.user({ type: "media", mediaType: "image/png", data: "AAECAw==" })], + }), + ).pipe(Effect.flip) + + expect(error.message).toContain("OpenAI Responses user messages only support text content for now") + }), + ) + + it.effect("emits provider-error events for mid-stream provider errors", () => + Effect.gen(function* () { + const response = yield* LLMClient.generate(request).pipe( + Effect.provide(fixedResponse(sseEvents({ type: "error", code: "rate_limit_exceeded", message: "Slow down" }))), + ) + + expect(response.events).toEqual([{ type: "provider-error", message: "Slow down" }]) + }), + ) + + it.effect("falls back to error code when no message is present", () => + Effect.gen(function* () { + const response = yield* LLMClient.generate(request).pipe( + Effect.provide(fixedResponse(sseEvents({ type: "error", code: "internal_error" }))), + ) + + expect(response.events).toEqual([{ type: "provider-error", message: "internal_error" }]) + }), + ) + + it.effect("fails HTTP provider errors before stream parsing", () => + Effect.gen(function* () { + const error = yield* LLMClient.generate(request).pipe( + Effect.provide( + fixedResponse('{"error":{"type":"invalid_request_error","message":"Bad request"}}', { + status: 400, + headers: { "content-type": "application/json" }, + }), + ), + Effect.flip, + ) + + expect(error).toBeInstanceOf(LLMError) + expect(error.reason).toMatchObject({ _tag: "InvalidRequest" }) + expect(error.message).toContain("HTTP 400") + }), + ) +}) diff --git a/packages/llm/test/provider/openrouter.test.ts b/packages/llm/test/provider/openrouter.test.ts new file mode 100644 index 0000000000..b3fb6bddc7 --- /dev/null +++ b/packages/llm/test/provider/openrouter.test.ts @@ -0,0 +1,56 @@ +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import { LLM } from "../../src" +import { LLMClient } from "../../src/route" +import * as OpenRouter from "../../src/providers/openrouter" +import { it } from "../lib/effect" + +describe("OpenRouter", () => { + it.effect("prepares OpenRouter models through the OpenAI-compatible Chat route", () => + Effect.gen(function* () { + const model = OpenRouter.model("openai/gpt-4o-mini", { apiKey: "test-key" }) + + expect(model).toMatchObject({ + id: "openai/gpt-4o-mini", + provider: "openrouter", + route: "openrouter", + baseURL: "https://openrouter.ai/api/v1", + apiKey: "test-key", + }) + + const prepared = yield* LLMClient.prepare(LLM.request({ model, prompt: "Say hello." })) + + expect(prepared.route).toBe("openrouter") + expect(prepared.body).toMatchObject({ + model: "openai/gpt-4o-mini", + messages: [{ role: "user", content: "Say hello." }], + stream: true, + }) + }), + ) + + it.effect("applies OpenRouter payload options from the model helper", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + model: OpenRouter.model("anthropic/claude-3.7-sonnet:thinking", { + providerOptions: { + openrouter: { + usage: true, + reasoning: { effort: "high" }, + promptCacheKey: "session_123", + }, + }, + }), + prompt: "Think briefly.", + }), + ) + + expect(prepared.body).toMatchObject({ + usage: { include: true }, + reasoning: { effort: "high" }, + prompt_cache_key: "session_123", + }) + }), + ) +}) diff --git a/packages/llm/test/recorded-golden.ts b/packages/llm/test/recorded-golden.ts new file mode 100644 index 0000000000..6a6c8c7ac9 --- /dev/null +++ b/packages/llm/test/recorded-golden.ts @@ -0,0 +1,103 @@ +import type { HttpRecorder } from "@opencode-ai/http-recorder" +import { describe, type TestOptions } from "bun:test" +import { Effect } from "effect" +import type { ModelRef } from "../src" +import { goldenScenarioTags, runGoldenScenario, type GoldenScenarioID } from "./recorded-scenarios" +import { recordedTests } from "./recorded-test" +import { kebab } from "./recorded-utils" + +type Transport = "http" | "websocket" + +type ScenarioInput = + | GoldenScenarioID + | { + readonly id: GoldenScenarioID + readonly name?: string + readonly cassette?: string + readonly tags?: ReadonlyArray + readonly maxTokens?: number + readonly temperature?: number | false + readonly timeout?: number | TestOptions + } + +type TargetInput = { + readonly name: string + readonly model: ModelRef + readonly protocol?: string + readonly requires?: ReadonlyArray + readonly transport?: Transport + readonly prefix?: string + readonly tags?: ReadonlyArray + readonly metadata?: Record + readonly options?: HttpRecorder.RecordReplayOptions + readonly scenarios: ReadonlyArray +} + +const scenarioInput = (input: ScenarioInput) => (typeof input === "string" ? { id: input } : input) + +const scenarioTitle = (id: GoldenScenarioID) => { + if (id === "text") return "streams text" + if (id === "tool-call") return "streams tool call" + return "drives a tool loop" +} + +const defaultPrefix = (target: TargetInput) => { + if (target.prefix) return target.prefix + const transport = target.transport === "websocket" ? "-websocket" : "" + return `${target.model.provider}-${target.protocol ?? target.model.route}${transport}` +} + +const metadata = (target: TargetInput) => ({ + provider: target.model.provider, + protocol: target.protocol, + route: target.model.route, + transport: target.transport ?? "http", + model: target.model.id, + ...target.metadata, +}) + +const tags = (target: TargetInput) => [ + ...(target.transport === "websocket" ? ["transport:websocket"] : []), + ...(target.tags ?? []), +] + +const runTarget = (target: TargetInput) => { + const recorded = recordedTests({ + prefix: defaultPrefix(target), + provider: target.model.provider, + protocol: target.protocol, + requires: target.requires, + tags: tags(target), + metadata: metadata(target), + options: target.options, + }) + + describe(`${target.name} recorded`, () => { + target.scenarios.forEach((raw) => { + const input = scenarioInput(raw) + const name = input.name ?? scenarioTitle(input.id) + recorded.effect.with( + name, + { + cassette: input.cassette, + id: `${kebab(target.name)}-${input.id}`, + tags: [...goldenScenarioTags(input.id), ...(input.tags ?? [])], + }, + () => + Effect.gen(function* () { + yield* runGoldenScenario(input.id, { + id: `recorded_${kebab(target.name).replaceAll("-", "_")}_${input.id.replaceAll("-", "_")}`, + model: target.model, + maxTokens: input.maxTokens, + temperature: input.temperature, + }) + }), + input.timeout, + ) + }) + }) +} + +export const describeRecordedGoldenScenarios = (targets: ReadonlyArray) => { + targets.forEach(runTarget) +} diff --git a/packages/llm/test/recorded-runner.ts b/packages/llm/test/recorded-runner.ts new file mode 100644 index 0000000000..97d9b03f54 --- /dev/null +++ b/packages/llm/test/recorded-runner.ts @@ -0,0 +1,100 @@ +import { test, type TestOptions } from "bun:test" +import { Effect, type Layer } from "effect" +import { testEffect } from "./lib/effect" +import { cassetteName, classifiedTags, matchesSelected, missingEnv, unique } from "./recorded-utils" + +export type RecordedBody = Effect.Effect | (() => Effect.Effect) + +export type RecordedGroupOptions = { + readonly prefix: string + readonly provider?: string + readonly protocol?: string + readonly requires?: ReadonlyArray + readonly tags?: ReadonlyArray + readonly metadata?: Record +} + +export type RecordedCaseOptions = { + readonly cassette?: string + readonly id?: string + readonly provider?: string + readonly protocol?: string + readonly requires?: ReadonlyArray + readonly tags?: ReadonlyArray + readonly metadata?: Record +} + +export const recordedEffectGroup = < + R, + E, + Options extends RecordedGroupOptions, + CaseOptions extends RecordedCaseOptions, +>(input: { + readonly duplicateLabel: string + readonly options: Options + readonly cassetteExists: (cassette: string) => boolean + readonly layer: (input: { + readonly cassette: string + readonly tags: ReadonlyArray + readonly metadata: Record + readonly recording: boolean + readonly options: Options + readonly caseOptions: CaseOptions + }) => Layer.Layer +}) => { + const cassettes = new Set() + + const run = ( + name: string, + caseOptions: CaseOptions, + body: RecordedBody, + testOptions?: number | TestOptions, + ) => { + const cassette = cassetteName(input.options.prefix, name, caseOptions) + if (cassettes.has(cassette)) throw new Error(`Duplicate ${input.duplicateLabel} "${cassette}"`) + cassettes.add(cassette) + const tags = unique([ + ...classifiedTags(input.options), + ...classifiedTags({ + provider: caseOptions.provider, + protocol: caseOptions.protocol, + tags: caseOptions.tags, + }), + ]) + + if (!matchesSelected({ prefix: input.options.prefix, name, cassette, tags })) + return test.skip(name, () => {}, testOptions) + + const recording = process.env.RECORD === "true" + if (recording) { + if (missingEnv([...(input.options.requires ?? []), ...(caseOptions.requires ?? [])]).length > 0) { + return test.skip(name, () => {}, testOptions) + } + } else if (!input.cassetteExists(cassette)) { + return test.skip(name, () => {}, testOptions) + } + + return testEffect( + input.layer({ + cassette, + tags, + metadata: { ...input.options.metadata, ...caseOptions.metadata, tags }, + recording, + options: input.options, + caseOptions, + }), + ).live(name, body, testOptions) + } + + const effect = (name: string, body: RecordedBody, testOptions?: number | TestOptions) => + run(name, {} as CaseOptions, body, testOptions) + + effect.with = ( + name: string, + caseOptions: CaseOptions, + body: RecordedBody, + testOptions?: number | TestOptions, + ) => run(name, caseOptions, body, testOptions) + + return { effect } +} diff --git a/packages/llm/test/recorded-scenarios.ts b/packages/llm/test/recorded-scenarios.ts new file mode 100644 index 0000000000..127a444a16 --- /dev/null +++ b/packages/llm/test/recorded-scenarios.ts @@ -0,0 +1,280 @@ +import { expect } from "bun:test" +import { Effect, Schema, Stream } from "effect" +import { LLM, LLMEvent, LLMResponse, type LLMRequest, type ModelRef } from "../src" +import { LLMClient } from "../src/route" +import { tool } from "../src/tool" + +export const weatherToolName = "get_weather" + +// A deterministic system prompt long enough to clear every supported provider's +// minimum cacheable-prefix threshold (Anthropic Haiku 3.5: 2048 tokens; Anthropic +// Opus/Haiku 4.5: 4096 tokens; OpenAI/Gemini/Bedrock: lower). Built by repeating +// a fixed sentence — the cassette replays bit-for-bit, so the exact text matters +// only when re-recording with `RECORD=true`. +export const LARGE_CACHEABLE_SYSTEM = (() => { + const sentence = "You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. " + // ~100 chars per sentence × 250 repeats ≈ 25,000 chars ≈ 5k+ tokens, safely + // above every provider's threshold. + return sentence.repeat(250) +})() + +export const weatherTool = LLM.toolDefinition({ + name: weatherToolName, + description: "Get current weather for a city.", + inputSchema: { + type: "object", + properties: { city: { type: "string" } }, + required: ["city"], + additionalProperties: false, + }, +}) + +export const weatherRuntimeTool = tool({ + description: weatherTool.description, + parameters: Schema.Struct({ city: Schema.String }), + success: Schema.Struct({ temperature: Schema.Number, condition: Schema.String }), + execute: ({ city }) => + Effect.succeed( + city === "Paris" ? { temperature: 22, condition: "sunny" } : { temperature: 0, condition: "unknown" }, + ), +}) + +export const textRequest = (input: { + readonly id: string + readonly model: ModelRef + readonly prompt?: string + readonly maxTokens?: number + readonly temperature?: number | false +}) => + LLM.request({ + id: input.id, + model: input.model, + system: "You are concise.", + prompt: input.prompt ?? "Reply with exactly: Hello!", + cache: "none", + generation: + input.temperature === false + ? { maxTokens: input.maxTokens ?? 20 } + : { maxTokens: input.maxTokens ?? 20, temperature: input.temperature ?? 0 }, + }) + +export const weatherToolRequest = (input: { + readonly id: string + readonly model: ModelRef + readonly maxTokens?: number + readonly temperature?: number | false +}) => + LLM.request({ + id: input.id, + model: input.model, + system: "Call tools exactly as requested.", + prompt: "Call get_weather with city exactly Paris.", + tools: [weatherTool], + toolChoice: LLM.toolChoice(weatherTool), + cache: "none", + generation: + input.temperature === false + ? { maxTokens: input.maxTokens ?? 80 } + : { maxTokens: input.maxTokens ?? 80, temperature: input.temperature ?? 0 }, + }) + +export const weatherToolLoopRequest = (input: { + readonly id: string + readonly model: ModelRef + readonly system?: string + readonly maxTokens?: number + readonly temperature?: number | false +}) => + LLM.request({ + id: input.id, + model: input.model, + system: input.system ?? "Use the get_weather tool, then answer in one short sentence.", + prompt: "What is the weather in Paris?", + cache: "none", + generation: + input.temperature === false + ? { maxTokens: input.maxTokens ?? 80 } + : { maxTokens: input.maxTokens ?? 80, temperature: input.temperature ?? 0 }, + }) + +export const goldenWeatherToolLoopRequest = (input: { + readonly id: string + readonly model: ModelRef + readonly maxTokens?: number + readonly temperature?: number | false +}) => + weatherToolLoopRequest({ + ...input, + system: "Use the get_weather tool exactly once. After the tool result, reply exactly: Paris is sunny.", + }) + +export const runWeatherToolLoop = (request: LLMRequest) => + LLMClient.stream({ + request, + tools: { [weatherToolName]: weatherRuntimeTool }, + stopWhen: LLMClient.stepCountIs(10), + }).pipe( + Stream.runCollect, + Effect.map((events) => Array.from(events)), + ) + +export const expectFinish = ( + events: ReadonlyArray, + reason: Extract["reason"], +) => expect(events.at(-1)).toMatchObject({ type: "request-finish", reason }) + +export const expectWeatherToolCall = (response: LLMResponse) => + expect(response.toolCalls).toMatchObject([ + { type: "tool-call", id: expect.any(String), name: weatherToolName, input: { city: "Paris" } }, + ]) + +export const expectWeatherToolLoop = (events: ReadonlyArray) => { + const finishes = events.filter(LLMEvent.is.requestFinish) + expect(finishes).toHaveLength(2) + expect(finishes[0]?.reason).toBe("tool-calls") + expect(finishes.at(-1)?.reason).toBe("stop") + + const toolCalls = events.filter(LLMEvent.is.toolCall) + expect(toolCalls).toHaveLength(1) + expect(toolCalls[0]).toMatchObject({ type: "tool-call", name: weatherToolName, input: { city: "Paris" } }) + + const toolResults = events.filter(LLMEvent.is.toolResult) + expect(toolResults).toHaveLength(1) + expect(toolResults[0]).toMatchObject({ + type: "tool-result", + name: weatherToolName, + result: { type: "json", value: { temperature: 22, condition: "sunny" } }, + }) + + const output = LLMResponse.text({ events }) + expect(output).toContain("Paris") + expect(output.trim().length).toBeGreaterThan(0) +} + +export const expectGoldenWeatherToolLoop = (events: ReadonlyArray) => { + expectWeatherToolLoop(events) + expect(LLMResponse.text({ events }).trim()).toMatch(/^Paris is sunny\.?$/) +} + +export type GoldenScenarioID = "text" | "tool-call" | "tool-loop" + +export interface GoldenScenarioContext { + readonly id: string + readonly model: ModelRef + readonly maxTokens?: number + readonly temperature?: number | false +} + +const generate = (request: LLMRequest) => LLMClient.generate(request) + +export const goldenScenarioTags = (id: GoldenScenarioID) => { + if (id === "text") return ["text", "golden"] + if (id === "tool-call") return ["tool", "tool-call", "golden"] + return ["tool", "tool-loop", "golden"] +} + +export const runGoldenScenario = (id: GoldenScenarioID, context: GoldenScenarioContext) => + Effect.gen(function* () { + if (id === "text") { + const response = yield* generate( + textRequest({ + id: context.id, + model: context.model, + prompt: "Reply exactly with: Hello!", + maxTokens: context.maxTokens ?? 40, + temperature: context.temperature, + }), + ) + expect(response.text.trim()).toMatch(/^Hello!?$/) + expectFinish(response.events, "stop") + return + } + + if (id === "tool-call") { + const response = yield* generate( + weatherToolRequest({ + id: context.id, + model: context.model, + maxTokens: context.maxTokens ?? 80, + temperature: context.temperature, + }), + ) + expectWeatherToolCall(response) + expectFinish(response.events, "tool-calls") + return + } + + expectGoldenWeatherToolLoop( + yield* runWeatherToolLoop( + goldenWeatherToolLoopRequest({ + id: context.id, + model: context.model, + maxTokens: context.maxTokens ?? 80, + temperature: context.temperature, + }), + ), + ) + }) + +const usageSummary = (usage: LLMResponse["usage"] | undefined) => { + if (!usage) return undefined + return Object.fromEntries( + [ + ["inputTokens", usage.inputTokens], + ["outputTokens", usage.outputTokens], + ["reasoningTokens", usage.reasoningTokens], + ["cacheReadInputTokens", usage.cacheReadInputTokens], + ["cacheWriteInputTokens", usage.cacheWriteInputTokens], + ["totalTokens", usage.totalTokens], + ].filter((entry) => entry[1] !== undefined), + ) +} + +const pushText = (summary: Array>, type: "text" | "reasoning", value: string) => { + const last = summary.at(-1) + if (last?.type === type) { + last.value = `${last.value ?? ""}${value}` + return + } + summary.push({ type, value }) +} + +export const eventSummary = (events: ReadonlyArray) => { + const summary: Array> = [] + for (const event of events) { + if (event.type === "text-delta") { + pushText(summary, "text", event.text) + continue + } + if (event.type === "reasoning-delta") { + pushText(summary, "reasoning", event.text) + continue + } + if (event.type === "tool-call") { + summary.push({ + type: "tool-call", + name: event.name, + input: event.input, + providerExecuted: event.providerExecuted, + }) + continue + } + if (event.type === "tool-result") { + summary.push({ + type: "tool-result", + name: event.name, + result: event.result, + providerExecuted: event.providerExecuted, + }) + continue + } + if (event.type === "tool-error") { + summary.push({ type: "tool-error", name: event.name, message: event.message }) + continue + } + if (event.type === "request-finish") { + summary.push({ type: "finish", reason: event.reason, usage: usageSummary(event.usage) }) + } + } + return summary.map((item) => Object.fromEntries(Object.entries(item).filter((entry) => entry[1] !== undefined))) +} diff --git a/packages/llm/test/recorded-test.ts b/packages/llm/test/recorded-test.ts new file mode 100644 index 0000000000..62e51337d9 --- /dev/null +++ b/packages/llm/test/recorded-test.ts @@ -0,0 +1,76 @@ +import { NodeFileSystem } from "@effect/platform-node" +import { HttpRecorder } from "@opencode-ai/http-recorder" +import { Layer } from "effect" +import { FetchHttpClient } from "effect/unstable/http" +import * as path from "node:path" +import { fileURLToPath } from "node:url" +import { LLMClient, RequestExecutor } from "../src/route" +import type { Service as LLMClientService } from "../src/route/client" +import type { Service as RequestExecutorService } from "../src/route/executor" +import type { Service as WebSocketExecutorService } from "../src/route/transport/websocket" +import { + recordedEffectGroup, + type RecordedCaseOptions as RunnerCaseOptions, + type RecordedGroupOptions, +} from "./recorded-runner" +import { webSocketCassetteLayer } from "./recorded-websocket" + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const FIXTURES_DIR = path.resolve(__dirname, "fixtures", "recordings") + +type RecordedEnv = RequestExecutorService | WebSocketExecutorService | LLMClientService + +type RecordedTestsOptions = RecordedGroupOptions & { + readonly options?: HttpRecorder.RecordReplayOptions +} + +type RecordedCaseOptions = RunnerCaseOptions & { + readonly options?: HttpRecorder.RecordReplayOptions +} + +const mergeOptions = ( + base: HttpRecorder.RecordReplayOptions | undefined, + override: HttpRecorder.RecordReplayOptions | undefined, +) => { + if (!base) return override + if (!override) return base + return { + ...base, + ...override, + metadata: base.metadata || override.metadata ? { ...base.metadata, ...override.metadata } : undefined, + } +} + +export const recordedTests = (options: RecordedTestsOptions) => + recordedEffectGroup({ + duplicateLabel: "recorded cassette", + options, + cassetteExists: (cassette) => HttpRecorder.hasCassetteSync(cassette, { directory: FIXTURES_DIR }), + layer: ({ cassette, metadata, options, caseOptions, recording }) => { + const recorderOptions = mergeOptions(options.options, caseOptions.options) + const recorderMetadata = { + ...recorderOptions?.metadata, + ...metadata, + } + const mode = recorderOptions?.mode ?? (recording ? "record" : "replay") + const cassetteService = HttpRecorder.Cassette.fileSystem({ directory: FIXTURES_DIR }).pipe( + Layer.provide(NodeFileSystem.layer), + ) + const requestExecutor = RequestExecutor.layer.pipe( + Layer.provide( + HttpRecorder.recordingLayer(cassette, { + ...recorderOptions, + mode, + metadata: recorderMetadata, + }).pipe(Layer.provide(FetchHttpClient.layer)), + ), + ) + const deps = Layer.mergeAll( + requestExecutor, + webSocketCassetteLayer(cassette, { metadata: recorderMetadata, mode }), + ) + return Layer.mergeAll(deps, LLMClient.layerWithWebSocket.pipe(Layer.provide(deps))).pipe( + Layer.provide(cassetteService), + ) + }, + }) diff --git a/packages/llm/test/recorded-utils.ts b/packages/llm/test/recorded-utils.ts new file mode 100644 index 0000000000..513b2f819c --- /dev/null +++ b/packages/llm/test/recorded-utils.ts @@ -0,0 +1,56 @@ +export const kebab = (value: string) => + value + .trim() + .replace(/['"]/g, "") + .replace(/[^a-zA-Z0-9]+/g, "-") + .replace(/^-|-$/g, "") + .toLowerCase() + +export const missingEnv = (names: ReadonlyArray) => names.filter((name) => !process.env[name]) + +export const envList = (name: string) => + (process.env[name] ?? "") + .split(",") + .map((item) => item.trim().toLowerCase()) + .filter((item) => item !== "") + +export const unique = (items: ReadonlyArray) => Array.from(new Set(items)) + +export const classifiedTags = (input: { + readonly prefix?: string + readonly provider?: string + readonly protocol?: string + readonly tags?: ReadonlyArray +}) => + unique([ + ...(input.prefix ? [`prefix:${input.prefix}`] : []), + ...(input.provider ? [`provider:${input.provider}`] : []), + ...(input.protocol ? [`protocol:${input.protocol}`] : []), + ...(input.tags ?? []), + ]) + +export const matchesSelected = (input: { + readonly prefix: string + readonly name: string + readonly cassette: string + readonly tags: ReadonlyArray +}) => { + const prefixes = envList("RECORDED_PREFIX") + const providers = envList("RECORDED_PROVIDER") + const requiredTags = envList("RECORDED_TAGS") + const tests = envList("RECORDED_TEST") + const tags = input.tags.map((tag) => tag.toLowerCase()) + const names = [input.name, kebab(input.name), input.cassette].map((item) => item.toLowerCase()) + + if (prefixes.length > 0 && !prefixes.includes(input.prefix.toLowerCase())) return false + if (providers.length > 0 && !providers.some((provider) => tags.includes(`provider:${provider}`))) return false + if (requiredTags.length > 0 && !requiredTags.every((tag) => tags.includes(tag))) return false + if (tests.length > 0 && !tests.some((test) => names.some((name) => name.includes(test)))) return false + return true +} + +export const cassetteName = ( + prefix: string, + name: string, + options: { readonly cassette?: string; readonly id?: string }, +) => options.cassette ?? `${prefix}/${options.id ?? kebab(name)}` diff --git a/packages/llm/test/recorded-websocket.ts b/packages/llm/test/recorded-websocket.ts new file mode 100644 index 0000000000..b7ad380dad --- /dev/null +++ b/packages/llm/test/recorded-websocket.ts @@ -0,0 +1,26 @@ +import { Cassette, makeWebSocketExecutor, type RecordReplayMode } from "@opencode-ai/http-recorder" +import { Effect, Layer } from "effect" +import { WebSocketExecutor } from "../src/route" +import type { Service as WebSocketExecutorService } from "../src/route/transport/websocket" + +const liveWebSocket = WebSocketExecutor.open + +export const webSocketCassetteLayer = ( + cassette: string, + input: { readonly metadata?: Record; readonly mode: RecordReplayMode }, +): Layer.Layer => + Layer.effect( + WebSocketExecutor.Service, + Effect.gen(function* () { + const cassetteService = yield* Cassette.Service + const executor = yield* makeWebSocketExecutor({ + name: cassette, + mode: input.mode, + metadata: input.metadata, + cassette: cassetteService, + live: { open: liveWebSocket }, + compareClientMessagesAsJson: true, + }) + return WebSocketExecutor.Service.of(executor) + }), + ) diff --git a/packages/llm/test/schema.test.ts b/packages/llm/test/schema.test.ts new file mode 100644 index 0000000000..23bd9fd9bb --- /dev/null +++ b/packages/llm/test/schema.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, test } from "bun:test" +import { Schema } from "effect" +import { ContentPart, LLMEvent, LLMRequest, ModelID, ModelLimits, ModelRef, ProviderID, Usage } from "../src/schema" +import { ProviderShared } from "../src/protocols/shared" + +const model = new ModelRef({ + id: ModelID.make("fake-model"), + provider: ProviderID.make("fake-provider"), + route: "openai-chat", + baseURL: "https://fake.local", + limits: new ModelLimits({}), +}) + +describe("llm schema", () => { + test("decodes a minimal request", () => { + const input: unknown = { + id: "req_1", + model, + system: [{ type: "text", text: "You are terse." }], + messages: [{ role: "user", content: [{ type: "text", text: "hi" }] }], + tools: [], + generation: {}, + } + + const decoded = Schema.decodeUnknownSync(LLMRequest)(input) + + expect(decoded.id).toBe("req_1") + expect(decoded.messages[0]?.content[0]?.type).toBe("text") + }) + + test("accepts custom route ids", () => { + const decoded = Schema.decodeUnknownSync(LLMRequest)({ + model: { ...model, route: "custom-route" }, + system: [], + messages: [], + tools: [], + generation: {}, + }) + + expect(decoded.model.route).toBe("custom-route") + }) + + test("rejects invalid event type", () => { + expect(() => Schema.decodeUnknownSync(LLMEvent)({ type: "bogus" })).toThrow() + }) + + test("content part tagged union exposes guards", () => { + expect(ContentPart.guards.text({ type: "text", text: "hi" })).toBe(true) + expect(ContentPart.guards.media({ type: "text", text: "hi" })).toBe(false) + }) +}) + +describe("LLM.Usage", () => { + test("subtractTokens clamps non-sensical breakdowns to zero", () => { + // Defense against a provider reporting cached_tokens > prompt_tokens or + // reasoning_tokens > completion_tokens — the negative would otherwise + // round-trip through the pipeline and crash strict downstream schemas. + expect(ProviderShared.subtractTokens(5, 3)).toBe(2) + expect(ProviderShared.subtractTokens(5, 10)).toBe(0) + expect(ProviderShared.subtractTokens(5, undefined)).toBe(5) + expect(ProviderShared.subtractTokens(undefined, 3)).toBeUndefined() + expect(ProviderShared.subtractTokens(undefined, undefined)).toBeUndefined() + }) + + test("sumTokens returns undefined only when every input is undefined", () => { + expect(ProviderShared.sumTokens(1, 2, 3)).toBe(6) + expect(ProviderShared.sumTokens(1, undefined, 3)).toBe(4) + expect(ProviderShared.sumTokens(undefined, undefined, undefined)).toBeUndefined() + expect(ProviderShared.sumTokens()).toBeUndefined() + }) + + test("visibleOutputTokens clamps reasoning > output to zero", () => { + expect(new Usage({ outputTokens: 10, reasoningTokens: 4 }).visibleOutputTokens).toBe(6) + expect(new Usage({ outputTokens: 10 }).visibleOutputTokens).toBe(10) + expect(new Usage({ outputTokens: 4, reasoningTokens: 10 }).visibleOutputTokens).toBe(0) + expect(new Usage({}).visibleOutputTokens).toBe(0) + }) +}) diff --git a/packages/llm/test/tool-runtime.test.ts b/packages/llm/test/tool-runtime.test.ts new file mode 100644 index 0000000000..7251dee8af --- /dev/null +++ b/packages/llm/test/tool-runtime.test.ts @@ -0,0 +1,454 @@ +import { describe, expect } from "bun:test" +import { Effect, Schema, Stream } from "effect" +import { LLM, LLMEvent, LLMRequest, LLMResponse } from "../src" +import { LLMClient } from "../src/route" +import * as AnthropicMessages from "../src/protocols/anthropic-messages" +import * as OpenAIChat from "../src/protocols/openai-chat" +import { tool, ToolFailure } from "../src/tool" +import { it } from "./lib/effect" +import * as TestToolRuntime from "./lib/tool-runtime" +import { dynamicResponse, scriptedResponses } from "./lib/http" +import { deltaChunk, finishChunk, toolCallChunk } from "./lib/openai-chunks" +import { sseEvents } from "./lib/sse" + +const model = OpenAIChat.model({ + id: "gpt-4o-mini", + baseURL: "https://api.openai.test/v1/", + headers: { authorization: "Bearer test" }, +}) +const Json = Schema.fromJsonString(Schema.Unknown) +const decodeJson = Schema.decodeUnknownSync(Json) + +const baseRequest = LLM.request({ + id: "req_1", + model, + prompt: "Use the tool.", +}) + +const get_weather = tool({ + description: "Get current weather for a city.", + parameters: Schema.Struct({ city: Schema.String }), + success: Schema.Struct({ temperature: Schema.Number, condition: Schema.String }), + execute: ({ city }) => + Effect.gen(function* () { + if (city === "FAIL") return yield* new ToolFailure({ message: `Weather lookup failed for ${city}` }) + return { temperature: 22, condition: "sunny" } + }), +}) + +const schema_only_weather = tool({ + description: "Get current weather for a city.", + parameters: Schema.Struct({ city: Schema.String }), + success: Schema.Struct({ temperature: Schema.Number, condition: Schema.String }), +}) + +describe("LLMClient tools", () => { + it.effect("uses the registered model route when adding runtime tools", () => + Effect.gen(function* () { + const layer = scriptedResponses([ + sseEvents(deltaChunk({ role: "assistant", content: "Done." }), finishChunk("stop")), + ]) + + const events = Array.from( + yield* TestToolRuntime.runTools({ request: baseRequest, tools: { get_weather } }).pipe( + Stream.runCollect, + Effect.provide(layer), + ), + ) + + expect(LLMResponse.text({ events })).toBe("Done.") + }), + ) + + it.effect("sends tool-call history and request options on the follow-up request", () => + Effect.gen(function* () { + const bodies: unknown[] = [] + const responses = [ + sseEvents(toolCallChunk("call_1", "get_weather", '{"city":"Paris"}'), finishChunk("tool_calls")), + sseEvents(deltaChunk({ role: "assistant", content: "It's sunny in Paris." }), finishChunk("stop")), + ] + const layer = dynamicResponse((input) => + Effect.sync(() => { + bodies.push(decodeJson(input.text)) + return input.respond(responses[bodies.length - 1] ?? responses[responses.length - 1], { + headers: { "content-type": "text/event-stream" }, + }) + }), + ) + + yield* TestToolRuntime.runTools({ + request: LLMRequest.update(baseRequest, { + generation: LLM.generation({ maxTokens: 50 }), + toolChoice: LLM.toolChoice("auto"), + }), + tools: { get_weather }, + }).pipe(Stream.runCollect, Effect.provide(layer)) + + const second = bodies[1] as { + readonly messages?: ReadonlyArray> + readonly tools?: ReadonlyArray + readonly tool_choice?: unknown + readonly max_tokens?: unknown + } + + expect(second.max_tokens).toBe(50) + expect(second.tool_choice).toBe("auto") + expect(second.tools).toHaveLength(1) + expect(second.messages?.map((message) => message.role)).toEqual(["user", "assistant", "tool"]) + expect(second.messages?.[1]).toMatchObject({ + role: "assistant", + content: null, + tool_calls: [{ id: "call_1", type: "function", function: { name: "get_weather" } }], + }) + expect(second.messages?.[2]).toMatchObject({ + role: "tool", + tool_call_id: "call_1", + content: '{"temperature":22,"condition":"sunny"}', + }) + }), + ) + + it.effect("dispatches a tool call, appends results, and resumes streaming", () => + Effect.gen(function* () { + const layer = scriptedResponses([ + sseEvents(toolCallChunk("call_1", "get_weather", '{"city":"Paris"}'), finishChunk("tool_calls")), + sseEvents(deltaChunk({ role: "assistant", content: "It's sunny in Paris." }), finishChunk("stop")), + ]) + + const events = Array.from( + yield* TestToolRuntime.runTools({ request: baseRequest, tools: { get_weather } }).pipe( + Stream.runCollect, + Effect.provide(layer), + ), + ) + + const result = events.find(LLMEvent.is.toolResult) + expect(result).toMatchObject({ + type: "tool-result", + id: "call_1", + name: "get_weather", + result: { type: "json", value: { temperature: 22, condition: "sunny" } }, + }) + expect(events.at(-1)?.type).toBe("request-finish") + expect(LLMResponse.text({ events })).toBe("It's sunny in Paris.") + }), + ) + + it.effect("executes tool calls for one step without looping by default", () => + Effect.gen(function* () { + const layer = scriptedResponses([ + sseEvents(toolCallChunk("call_1", "get_weather", '{"city":"Paris"}'), finishChunk("tool_calls")), + sseEvents(deltaChunk({ role: "assistant", content: "Should not run." }), finishChunk("stop")), + ]) + + const events = Array.from( + yield* LLMClient.stream({ request: baseRequest, tools: { get_weather } }).pipe( + Stream.runCollect, + Effect.provide(layer), + ), + ) + + expect(events.filter(LLMEvent.is.requestFinish)).toHaveLength(1) + expect(events.find(LLMEvent.is.toolResult)).toMatchObject({ type: "tool-result", id: "call_1" }) + }), + ) + + it.effect("can expose tool schemas without executing tool calls", () => + Effect.gen(function* () { + const layer = scriptedResponses([ + sseEvents(toolCallChunk("call_1", "get_weather", '{"city":"Paris"}'), finishChunk("tool_calls")), + ]) + + const events = Array.from( + yield* LLMClient.stream({ + request: baseRequest, + tools: { get_weather: schema_only_weather }, + toolExecution: "none", + }).pipe(Stream.runCollect, Effect.provide(layer)), + ) + + expect(events.find(LLMEvent.is.toolCall)).toMatchObject({ type: "tool-call", id: "call_1" }) + expect(events.find(LLMEvent.is.toolResult)).toBeUndefined() + }), + ) + + it.effect("preserves provider metadata when folding streamed assistant content into follow-up history", () => + Effect.gen(function* () { + const bodies: unknown[] = [] + const layer = dynamicResponse((input) => + Effect.sync(() => { + bodies.push(decodeJson(input.text)) + return input.respond( + bodies.length === 1 + ? sseEvents( + { type: "message_start", message: { usage: { input_tokens: 5 } } }, + { type: "content_block_start", index: 0, content_block: { type: "thinking", thinking: "" } }, + { type: "content_block_delta", index: 0, delta: { type: "thinking_delta", thinking: "thinking" } }, + { type: "content_block_delta", index: 0, delta: { type: "signature_delta", signature: "sig_1" } }, + { type: "content_block_stop", index: 0 }, + { + type: "content_block_start", + index: 1, + content_block: { type: "tool_use", id: "call_1", name: "get_weather" }, + }, + { + type: "content_block_delta", + index: 1, + delta: { type: "input_json_delta", partial_json: '{"city":"Paris"}' }, + }, + { type: "content_block_stop", index: 1 }, + { type: "message_delta", delta: { stop_reason: "tool_use" }, usage: { output_tokens: 5 } }, + ) + : sseEvents( + { type: "message_start", message: { usage: { input_tokens: 5 } } }, + { type: "content_block_start", index: 0, content_block: { type: "text", text: "" } }, + { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "Done." } }, + { type: "content_block_stop", index: 0 }, + { type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 1 } }, + ), + { headers: { "content-type": "text/event-stream" } }, + ) + }), + ) + + yield* TestToolRuntime.runTools({ + request: LLM.updateRequest(baseRequest, { + model: AnthropicMessages.model({ id: "claude-sonnet-4-5", apiKey: "test" }), + }), + tools: { get_weather }, + }).pipe(Stream.runCollect, Effect.provide(layer)) + + expect(bodies[1]).toMatchObject({ + messages: [ + { role: "user" }, + { + role: "assistant", + content: [ + { type: "thinking", thinking: "thinking", signature: "sig_1" }, + { type: "tool_use", id: "call_1", name: "get_weather", input: { city: "Paris" } }, + ], + }, + { role: "user", content: [{ type: "tool_result", tool_use_id: "call_1" }] }, + ], + }) + }), + ) + + it.effect("emits tool-error for unknown tools so the model can self-correct", () => + Effect.gen(function* () { + const layer = scriptedResponses([ + sseEvents(toolCallChunk("call_1", "missing_tool", "{}"), finishChunk("tool_calls")), + sseEvents(deltaChunk({ role: "assistant", content: "Sorry." }), finishChunk("stop")), + ]) + + const events = Array.from( + yield* TestToolRuntime.runTools({ request: baseRequest, tools: { get_weather } }).pipe( + Stream.runCollect, + Effect.provide(layer), + ), + ) + + const toolError = events.find(LLMEvent.is.toolError) + expect(toolError).toMatchObject({ type: "tool-error", id: "call_1", name: "missing_tool" }) + expect(toolError?.message).toContain("Unknown tool") + expect(events.find(LLMEvent.is.toolResult)).toMatchObject({ + type: "tool-result", + id: "call_1", + name: "missing_tool", + result: { type: "error", value: "Unknown tool: missing_tool" }, + }) + }), + ) + + it.effect("emits tool-error when the LLM input fails the parameters schema", () => + Effect.gen(function* () { + const layer = scriptedResponses([ + sseEvents(toolCallChunk("call_1", "get_weather", '{"city":42}'), finishChunk("tool_calls")), + sseEvents(deltaChunk({ role: "assistant", content: "Done." }), finishChunk("stop")), + ]) + + const events = Array.from( + yield* TestToolRuntime.runTools({ request: baseRequest, tools: { get_weather } }).pipe( + Stream.runCollect, + Effect.provide(layer), + ), + ) + + const toolError = events.find(LLMEvent.is.toolError) + expect(toolError).toMatchObject({ type: "tool-error", id: "call_1", name: "get_weather" }) + expect(toolError?.message).toContain("Invalid tool input") + }), + ) + + it.effect("emits tool-error when the handler returns a ToolFailure", () => + Effect.gen(function* () { + const layer = scriptedResponses([ + sseEvents(toolCallChunk("call_1", "get_weather", '{"city":"FAIL"}'), finishChunk("tool_calls")), + sseEvents(deltaChunk({ role: "assistant", content: "Sorry." }), finishChunk("stop")), + ]) + + const events = Array.from( + yield* TestToolRuntime.runTools({ request: baseRequest, tools: { get_weather } }).pipe( + Stream.runCollect, + Effect.provide(layer), + ), + ) + + const toolError = events.find(LLMEvent.is.toolError) + expect(toolError).toMatchObject({ type: "tool-error", id: "call_1", name: "get_weather" }) + expect(toolError?.message).toBe("Weather lookup failed for FAIL") + }), + ) + + it.effect("stops when the model finishes without requesting more tools", () => + Effect.gen(function* () { + const layer = scriptedResponses([ + sseEvents(deltaChunk({ role: "assistant", content: "Done." }), finishChunk("stop")), + ]) + + const events = Array.from( + yield* TestToolRuntime.runTools({ request: baseRequest, tools: { get_weather } }).pipe( + Stream.runCollect, + Effect.provide(layer), + ), + ) + + expect(events.map((event) => event.type)).toEqual(["text-delta", "request-finish"]) + expect(LLMResponse.text({ events })).toBe("Done.") + }), + ) + + it.effect("respects maxSteps and stops the loop", () => + Effect.gen(function* () { + // Every script entry asks for another tool call. With maxSteps: 2 the + // runtime should run at most two model rounds and then exit even though + // the model still wants to keep going. + const toolCallStep = sseEvents( + toolCallChunk("call_x", "get_weather", '{"city":"Paris"}'), + finishChunk("tool_calls"), + ) + const layer = scriptedResponses([toolCallStep, toolCallStep, toolCallStep]) + + const events = Array.from( + yield* TestToolRuntime.runTools({ request: baseRequest, tools: { get_weather }, maxSteps: 2 }).pipe( + Stream.runCollect, + Effect.provide(layer), + ), + ) + + expect(events.filter(LLMEvent.is.requestFinish)).toHaveLength(2) + }), + ) + + it.effect("stops follow-up when stopWhen returns true after the first step", () => + Effect.gen(function* () { + const layer = scriptedResponses([ + sseEvents(toolCallChunk("call_1", "get_weather", '{"city":"Paris"}'), finishChunk("tool_calls")), + sseEvents(deltaChunk({ role: "assistant", content: "Should not run." }), finishChunk("stop")), + ]) + + const events = Array.from( + yield* TestToolRuntime.runTools({ + request: baseRequest, + tools: { get_weather }, + stopWhen: (state) => state.step >= 0, + }).pipe(Stream.runCollect, Effect.provide(layer)), + ) + + expect(events.filter(LLMEvent.is.requestFinish)).toHaveLength(1) + expect(events.find(LLMEvent.is.toolResult)).toMatchObject({ type: "tool-result", id: "call_1" }) + }), + ) + + it.effect("does not dispatch provider-executed tool calls", () => + Effect.gen(function* () { + let streams = 0 + const layer = dynamicResponse((input) => + Effect.sync(() => { + streams++ + return input.respond( + sseEvents( + { type: "message_start", message: { usage: { input_tokens: 5 } } }, + { + type: "content_block_start", + index: 0, + content_block: { type: "server_tool_use", id: "srvtoolu_abc", name: "web_search" }, + }, + { + type: "content_block_delta", + index: 0, + delta: { type: "input_json_delta", partial_json: '{"query":"x"}' }, + }, + { type: "content_block_stop", index: 0 }, + { + type: "content_block_start", + index: 1, + content_block: { + type: "web_search_tool_result", + tool_use_id: "srvtoolu_abc", + content: [{ type: "web_search_result", url: "https://example.com", title: "Example" }], + }, + }, + { type: "content_block_stop", index: 1 }, + { type: "content_block_start", index: 2, content_block: { type: "text", text: "" } }, + { type: "content_block_delta", index: 2, delta: { type: "text_delta", text: "Done." } }, + { type: "content_block_stop", index: 2 }, + { type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 8 } }, + ), + { headers: { "content-type": "text/event-stream" } }, + ) + }), + ) + const events = Array.from( + yield* TestToolRuntime.runTools({ + request: LLM.updateRequest(baseRequest, { + model: AnthropicMessages.model({ id: "claude-sonnet-4-5", apiKey: "test" }), + }), + tools: {}, + }).pipe(Stream.runCollect, Effect.provide(layer)), + ) + + expect(streams).toBe(1) + expect(events.find(LLMEvent.is.toolError)).toBeUndefined() + expect(events.filter(LLMEvent.is.toolCall)).toEqual([ + { + type: "tool-call", + id: "srvtoolu_abc", + name: "web_search", + input: { query: "x" }, + providerExecuted: true, + }, + ]) + expect(LLMResponse.text({ events })).toBe("Done.") + }), + ) + + it.effect("dispatches multiple tool calls in one step concurrently", () => + Effect.gen(function* () { + const layer = scriptedResponses([ + sseEvents( + deltaChunk({ + role: "assistant", + tool_calls: [ + { index: 0, id: "c1", function: { name: "get_weather", arguments: '{"city":"Paris"}' } }, + { index: 1, id: "c2", function: { name: "get_weather", arguments: '{"city":"Tokyo"}' } }, + ], + }), + finishChunk("tool_calls"), + ), + sseEvents(deltaChunk({ role: "assistant", content: "Both done." }), finishChunk("stop")), + ]) + + const events = Array.from( + yield* TestToolRuntime.runTools({ request: baseRequest, tools: { get_weather } }).pipe( + Stream.runCollect, + Effect.provide(layer), + ), + ) + + const results = events.filter(LLMEvent.is.toolResult) + expect(results).toHaveLength(2) + expect(results.map((event) => event.id).toSorted()).toEqual(["c1", "c2"]) + }), + ) +}) diff --git a/packages/llm/test/tool-stream.test.ts b/packages/llm/test/tool-stream.test.ts new file mode 100644 index 0000000000..04a0035c99 --- /dev/null +++ b/packages/llm/test/tool-stream.test.ts @@ -0,0 +1,88 @@ +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import { LLMError } from "../src/schema" +import { ToolStream } from "../src/protocols/utils/tool-stream" +import { it } from "./lib/effect" + +const ADAPTER = "test-route" + +describe("ToolStream", () => { + it.effect("starts from OpenAI-style deltas and finalizes parsed input", () => + Effect.gen(function* () { + const first = ToolStream.appendOrStart( + ADAPTER, + ToolStream.empty(), + 0, + { id: "call_1", name: "lookup", text: '{"query"' }, + "missing tool", + ) + if (ToolStream.isError(first)) return yield* first + const second = ToolStream.appendOrStart(ADAPTER, first.tools, 0, { text: ':"weather"}' }, "missing tool") + if (ToolStream.isError(second)) return yield* second + const finished = yield* ToolStream.finish(ADAPTER, second.tools, 0) + + expect(first.event).toEqual({ type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"' }) + expect(second.event).toEqual({ type: "tool-input-delta", id: "call_1", name: "lookup", text: ':"weather"}' }) + expect(finished).toEqual({ + tools: {}, + event: { type: "tool-call", id: "call_1", name: "lookup", input: { query: "weather" } }, + }) + }), + ) + + it.effect("fails appendExisting when the provider skipped the tool start", () => + Effect.gen(function* () { + const error = ToolStream.appendExisting(ADAPTER, ToolStream.empty(), 0, "{}", "missing tool") + + expect(error).toBeInstanceOf(LLMError) + if (ToolStream.isError(error)) expect(error.reason.message).toBe("missing tool") + }), + ) + + it.effect("uses final input override without losing accumulated deltas", () => + Effect.gen(function* () { + const tools = ToolStream.start(ToolStream.empty(), "item_1", { + id: "call_1", + name: "lookup", + input: '{"query":"partial"}', + }) + const finished = yield* ToolStream.finishWithInput(ADAPTER, tools, "item_1", '{"query":"final"}') + + expect(finished).toEqual({ + tools: {}, + event: { type: "tool-call", id: "call_1", name: "lookup", input: { query: "final" } }, + }) + }), + ) + + it.effect("preserves providerExecuted and clears all tools", () => + Effect.gen(function* () { + const first: ToolStream.State = ToolStream.start(ToolStream.empty(), 0, { + id: "call_1", + name: "lookup", + input: "{}", + }) + const tools = ToolStream.start(first, 1, { + id: "call_2", + name: "web_search", + input: '{"query":"docs"}', + providerExecuted: true, + }) + const finished = yield* ToolStream.finishAll(ADAPTER, tools) + + expect(finished).toEqual({ + tools: {}, + events: [ + { type: "tool-call", id: "call_1", name: "lookup", input: {} }, + { + type: "tool-call", + id: "call_2", + name: "web_search", + input: { query: "docs" }, + providerExecuted: true, + }, + ], + }) + }), + ) +}) diff --git a/packages/llm/test/tool.types.ts b/packages/llm/test/tool.types.ts new file mode 100644 index 0000000000..4ffc30c986 --- /dev/null +++ b/packages/llm/test/tool.types.ts @@ -0,0 +1,29 @@ +import { Effect, Schema } from "effect" +import { LLM } from "../src" +import * as OpenAIChat from "../src/protocols/openai-chat" +import { tool } from "../src/tool" + +const request = LLM.request({ + model: OpenAIChat.model({ id: "gpt-4o-mini", apiKey: "fixture" }), + prompt: "Use the tool.", +}) + +const executable = tool({ + description: "Get weather.", + parameters: Schema.Struct({ city: Schema.String }), + success: Schema.Struct({ forecast: Schema.String }), + execute: (input) => Effect.succeed({ forecast: input.city }), +}) + +const schemaOnly = tool({ + description: "Get weather.", + parameters: Schema.Struct({ city: Schema.String }), + success: Schema.Struct({ forecast: Schema.String }), +}) + +LLM.stream({ request, tools: { executable } }) +LLM.generate({ request, tools: { executable }, stopWhen: LLM.stepCountIs(2) }) +LLM.stream({ request, tools: { schemaOnly }, toolExecution: "none" }) + +// @ts-expect-error Handler-less tools can only be passed with toolExecution: "none". +LLM.stream({ request, tools: { schemaOnly } }) diff --git a/packages/llm/tsconfig.json b/packages/llm/tsconfig.json new file mode 100644 index 0000000000..2bc480ffbb --- /dev/null +++ b/packages/llm/tsconfig.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "@tsconfig/bun/tsconfig.json", + "compilerOptions": { + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "noUncheckedIndexedAccess": false, + "plugins": [ + { + "name": "@effect/language-service", + "transform": "@effect/language-service/transform", + "namespaceImportPackages": ["effect", "@effect/*"] + } + ] + } +} diff --git a/packages/opencode/AGENTS.md b/packages/opencode/AGENTS.md index 2a39b6c144..ec4131a46c 100644 --- a/packages/opencode/AGENTS.md +++ b/packages/opencode/AGENTS.md @@ -9,6 +9,13 @@ - **Output**: creates `migration/_/migration.sql` and `snapshot.json`. - **Tests**: migration tests should read the per-folder layout (no `_journal.json`). +## Development server + +- Running `bun dev` from `packages/opencode` starts the live interactive TUI. Do not run it as a blocking foreground command when you need to inspect the result. +- Start it in `tmux` instead: `tmux new-session -d -s opencode-dev 'bun dev'`. +- Capture the current TUI output with: `tmux capture-pane -pt opencode-dev`. +- Stop the session explicitly when done: `tmux kill-session -t opencode-dev`. + # Module shape Do not use `export namespace Foo { ... }` for module organization. It is not diff --git a/packages/opencode/bin/opencode b/packages/opencode/bin/opencode index a7674ce2f8..a7101f42b0 100755 --- a/packages/opencode/bin/opencode +++ b/packages/opencode/bin/opencode @@ -5,31 +5,51 @@ const fs = require("fs") const path = require("path") const os = require("os") +const forwardedSignals = ["SIGINT", "SIGTERM", "SIGHUP"] + function run(target) { - const result = childProcess.spawnSync(target, process.argv.slice(2), { + const child = childProcess.spawn(target, process.argv.slice(2), { stdio: "inherit", }) - if (result.error) { - console.error(result.error.message) + + child.on("error", (error) => { + console.error(error.message) process.exit(1) + }) + + const forwarders = {} + for (const signal of forwardedSignals) { + forwarders[signal] = () => { + try { + child.kill(signal) + } catch { + // The child may have already exited. + } + } + process.on(signal, forwarders[signal]) } - const code = typeof result.status === "number" ? result.status : 0 - process.exit(code) + + child.on("exit", (code, signal) => { + for (const forwardedSignal of forwardedSignals) { + process.removeListener(forwardedSignal, forwarders[forwardedSignal]) + } + + if (signal) { + process.kill(process.pid, signal) + return + } + + process.exit(typeof code === "number" ? code : 0) + }) } const envPath = process.env.OPENCODE_BIN_PATH -if (envPath) { - run(envPath) -} const scriptPath = fs.realpathSync(__filename) const scriptDir = path.dirname(scriptPath) // const cached = path.join(scriptDir, ".opencode") -if (fs.existsSync(cached)) { - run(cached) -} const platformMap = { darwin: "darwin", @@ -166,7 +186,7 @@ function findBinary(startDir) { } } -const resolved = findBinary(scriptDir) +const resolved = envPath || (fs.existsSync(cached) ? cached : findBinary(scriptDir)) if (!resolved) { console.error( "It seems that your package manager failed to install the right version of the opencode CLI for your platform. You can try manually installing " + diff --git a/packages/opencode/migration/20260507164347_add_workspace_time/migration.sql b/packages/opencode/migration/20260507164347_add_workspace_time/migration.sql new file mode 100644 index 0000000000..c865526a88 --- /dev/null +++ b/packages/opencode/migration/20260507164347_add_workspace_time/migration.sql @@ -0,0 +1 @@ +ALTER TABLE `workspace` ADD `time_used` integer NOT NULL DEFAULT 0; diff --git a/packages/opencode/migration/20260507164347_add_workspace_time/snapshot.json b/packages/opencode/migration/20260507164347_add_workspace_time/snapshot.json new file mode 100644 index 0000000000..57da763bb9 --- /dev/null +++ b/packages/opencode/migration/20260507164347_add_workspace_time/snapshot.json @@ -0,0 +1,1459 @@ +{ + "version": "7", + "dialect": "sqlite", + "id": "630a93f2-c6c6-4191-a351-868d8f3a05d4", + "prevIds": ["27114226-085b-421a-9a40-29b88747e29a"], + "ddl": [ + { + "name": "account_state", + "entityType": "tables" + }, + { + "name": "account", + "entityType": "tables" + }, + { + "name": "control_account", + "entityType": "tables" + }, + { + "name": "workspace", + "entityType": "tables" + }, + { + "name": "project", + "entityType": "tables" + }, + { + "name": "message", + "entityType": "tables" + }, + { + "name": "part", + "entityType": "tables" + }, + { + "name": "permission", + "entityType": "tables" + }, + { + "name": "session_message", + "entityType": "tables" + }, + { + "name": "session", + "entityType": "tables" + }, + { + "name": "todo", + "entityType": "tables" + }, + { + "name": "session_share", + "entityType": "tables" + }, + { + "name": "event_sequence", + "entityType": "tables" + }, + { + "name": "event", + "entityType": "tables" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active_account_id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active_org_id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "access_token", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refresh_token", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token_expiry", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "access_token", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refresh_token", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token_expiry", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "''", + "generated": null, + "name": "name", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "branch", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "directory", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "extra", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_used", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "worktree", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "vcs", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_url", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_url_override", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_color", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_initialized", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "sandboxes", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "commands", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "message_id", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "part" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "part" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "permission" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "permission" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "permission" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "permission" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "parent_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "slug", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "directory", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "path", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "title", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "version", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "share_url", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_additions", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_deletions", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_files", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_diffs", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "revert", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "permission", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "agent", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "model", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_compacting", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_archived", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "content", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "status", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "priority", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "position", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "secret", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "aggregate_id", + "entityType": "columns", + "table": "event_sequence" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seq", + "entityType": "columns", + "table": "event_sequence" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "owner_id", + "entityType": "columns", + "table": "event_sequence" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "aggregate_id", + "entityType": "columns", + "table": "event" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seq", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "event" + }, + { + "columns": ["active_account_id"], + "tableTo": "account", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "nameExplicit": false, + "name": "fk_account_state_active_account_id_account_id_fk", + "entityType": "fks", + "table": "account_state" + }, + { + "columns": ["project_id"], + "tableTo": "project", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_workspace_project_id_project_id_fk", + "entityType": "fks", + "table": "workspace" + }, + { + "columns": ["session_id"], + "tableTo": "session", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_message_session_id_session_id_fk", + "entityType": "fks", + "table": "message" + }, + { + "columns": ["message_id"], + "tableTo": "message", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_part_message_id_message_id_fk", + "entityType": "fks", + "table": "part" + }, + { + "columns": ["project_id"], + "tableTo": "project", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_permission_project_id_project_id_fk", + "entityType": "fks", + "table": "permission" + }, + { + "columns": ["session_id"], + "tableTo": "session", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_message_session_id_session_id_fk", + "entityType": "fks", + "table": "session_message" + }, + { + "columns": ["project_id"], + "tableTo": "project", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_project_id_project_id_fk", + "entityType": "fks", + "table": "session" + }, + { + "columns": ["session_id"], + "tableTo": "session", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_todo_session_id_session_id_fk", + "entityType": "fks", + "table": "todo" + }, + { + "columns": ["session_id"], + "tableTo": "session", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_share_session_id_session_id_fk", + "entityType": "fks", + "table": "session_share" + }, + { + "columns": ["aggregate_id"], + "tableTo": "event_sequence", + "columnsTo": ["aggregate_id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_event_aggregate_id_event_sequence_aggregate_id_fk", + "entityType": "fks", + "table": "event" + }, + { + "columns": ["email", "url"], + "nameExplicit": false, + "name": "control_account_pk", + "entityType": "pks", + "table": "control_account" + }, + { + "columns": ["session_id", "position"], + "nameExplicit": false, + "name": "todo_pk", + "entityType": "pks", + "table": "todo" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "account_state_pk", + "table": "account_state", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "account_pk", + "table": "account", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "workspace_pk", + "table": "workspace", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "project_pk", + "table": "project", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "message_pk", + "table": "message", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "part_pk", + "table": "part", + "entityType": "pks" + }, + { + "columns": ["project_id"], + "nameExplicit": false, + "name": "permission_pk", + "table": "permission", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "session_message_pk", + "table": "session_message", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "session_pk", + "table": "session", + "entityType": "pks" + }, + { + "columns": ["session_id"], + "nameExplicit": false, + "name": "session_share_pk", + "table": "session_share", + "entityType": "pks" + }, + { + "columns": ["aggregate_id"], + "nameExplicit": false, + "name": "event_sequence_pk", + "table": "event_sequence", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "event_pk", + "table": "event", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "time_created", + "isExpression": false + }, + { + "value": "id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "message_session_time_created_id_idx", + "entityType": "indexes", + "table": "message" + }, + { + "columns": [ + { + "value": "message_id", + "isExpression": false + }, + { + "value": "id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "part_message_id_id_idx", + "entityType": "indexes", + "table": "part" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "part_session_idx", + "entityType": "indexes", + "table": "part" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_message_session_idx", + "entityType": "indexes", + "table": "session_message" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "type", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_message_session_type_idx", + "entityType": "indexes", + "table": "session_message" + }, + { + "columns": [ + { + "value": "time_created", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_message_time_created_idx", + "entityType": "indexes", + "table": "session_message" + }, + { + "columns": [ + { + "value": "project_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_project_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_workspace_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "parent_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_parent_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "todo_session_idx", + "entityType": "indexes", + "table": "todo" + } + ], + "renames": [] +} diff --git a/packages/opencode/migration/20260511000411_data_migration_state/migration.sql b/packages/opencode/migration/20260511000411_data_migration_state/migration.sql new file mode 100644 index 0000000000..ba36a7f078 --- /dev/null +++ b/packages/opencode/migration/20260511000411_data_migration_state/migration.sql @@ -0,0 +1,4 @@ +CREATE TABLE `data_migration` ( + `name` text PRIMARY KEY, + `time_completed` integer NOT NULL +); diff --git a/packages/opencode/migration/20260511000411_data_migration_state/snapshot.json b/packages/opencode/migration/20260511000411_data_migration_state/snapshot.json new file mode 100644 index 0000000000..e84aa1a6a1 --- /dev/null +++ b/packages/opencode/migration/20260511000411_data_migration_state/snapshot.json @@ -0,0 +1,1490 @@ +{ + "version": "7", + "dialect": "sqlite", + "id": "fdfcccee-fb3a-481f-b801-b9835fa30d5d", + "prevIds": ["630a93f2-c6c6-4191-a351-868d8f3a05d4"], + "ddl": [ + { + "name": "account_state", + "entityType": "tables" + }, + { + "name": "account", + "entityType": "tables" + }, + { + "name": "control_account", + "entityType": "tables" + }, + { + "name": "workspace", + "entityType": "tables" + }, + { + "name": "data_migration", + "entityType": "tables" + }, + { + "name": "project", + "entityType": "tables" + }, + { + "name": "message", + "entityType": "tables" + }, + { + "name": "part", + "entityType": "tables" + }, + { + "name": "permission", + "entityType": "tables" + }, + { + "name": "session_message", + "entityType": "tables" + }, + { + "name": "session", + "entityType": "tables" + }, + { + "name": "todo", + "entityType": "tables" + }, + { + "name": "session_share", + "entityType": "tables" + }, + { + "name": "event_sequence", + "entityType": "tables" + }, + { + "name": "event", + "entityType": "tables" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active_account_id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active_org_id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "access_token", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refresh_token", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token_expiry", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "access_token", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refresh_token", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token_expiry", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "''", + "generated": null, + "name": "name", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "branch", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "directory", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "extra", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_used", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "data_migration" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_completed", + "entityType": "columns", + "table": "data_migration" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "worktree", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "vcs", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_url", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_url_override", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_color", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_initialized", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "sandboxes", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "commands", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "message_id", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "part" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "part" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "permission" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "permission" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "permission" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "permission" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "parent_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "slug", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "directory", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "path", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "title", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "version", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "share_url", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_additions", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_deletions", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_files", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_diffs", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "revert", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "permission", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "agent", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "model", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_compacting", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_archived", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "content", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "status", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "priority", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "position", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "secret", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "aggregate_id", + "entityType": "columns", + "table": "event_sequence" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seq", + "entityType": "columns", + "table": "event_sequence" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "owner_id", + "entityType": "columns", + "table": "event_sequence" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "aggregate_id", + "entityType": "columns", + "table": "event" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seq", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "event" + }, + { + "columns": ["active_account_id"], + "tableTo": "account", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "nameExplicit": false, + "name": "fk_account_state_active_account_id_account_id_fk", + "entityType": "fks", + "table": "account_state" + }, + { + "columns": ["project_id"], + "tableTo": "project", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_workspace_project_id_project_id_fk", + "entityType": "fks", + "table": "workspace" + }, + { + "columns": ["session_id"], + "tableTo": "session", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_message_session_id_session_id_fk", + "entityType": "fks", + "table": "message" + }, + { + "columns": ["message_id"], + "tableTo": "message", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_part_message_id_message_id_fk", + "entityType": "fks", + "table": "part" + }, + { + "columns": ["project_id"], + "tableTo": "project", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_permission_project_id_project_id_fk", + "entityType": "fks", + "table": "permission" + }, + { + "columns": ["session_id"], + "tableTo": "session", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_message_session_id_session_id_fk", + "entityType": "fks", + "table": "session_message" + }, + { + "columns": ["project_id"], + "tableTo": "project", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_project_id_project_id_fk", + "entityType": "fks", + "table": "session" + }, + { + "columns": ["session_id"], + "tableTo": "session", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_todo_session_id_session_id_fk", + "entityType": "fks", + "table": "todo" + }, + { + "columns": ["session_id"], + "tableTo": "session", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_share_session_id_session_id_fk", + "entityType": "fks", + "table": "session_share" + }, + { + "columns": ["aggregate_id"], + "tableTo": "event_sequence", + "columnsTo": ["aggregate_id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_event_aggregate_id_event_sequence_aggregate_id_fk", + "entityType": "fks", + "table": "event" + }, + { + "columns": ["email", "url"], + "nameExplicit": false, + "name": "control_account_pk", + "entityType": "pks", + "table": "control_account" + }, + { + "columns": ["session_id", "position"], + "nameExplicit": false, + "name": "todo_pk", + "entityType": "pks", + "table": "todo" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "account_state_pk", + "table": "account_state", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "account_pk", + "table": "account", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "workspace_pk", + "table": "workspace", + "entityType": "pks" + }, + { + "columns": ["name"], + "nameExplicit": false, + "name": "data_migration_pk", + "table": "data_migration", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "project_pk", + "table": "project", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "message_pk", + "table": "message", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "part_pk", + "table": "part", + "entityType": "pks" + }, + { + "columns": ["project_id"], + "nameExplicit": false, + "name": "permission_pk", + "table": "permission", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "session_message_pk", + "table": "session_message", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "session_pk", + "table": "session", + "entityType": "pks" + }, + { + "columns": ["session_id"], + "nameExplicit": false, + "name": "session_share_pk", + "table": "session_share", + "entityType": "pks" + }, + { + "columns": ["aggregate_id"], + "nameExplicit": false, + "name": "event_sequence_pk", + "table": "event_sequence", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "event_pk", + "table": "event", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "time_created", + "isExpression": false + }, + { + "value": "id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "message_session_time_created_id_idx", + "entityType": "indexes", + "table": "message" + }, + { + "columns": [ + { + "value": "message_id", + "isExpression": false + }, + { + "value": "id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "part_message_id_id_idx", + "entityType": "indexes", + "table": "part" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "part_session_idx", + "entityType": "indexes", + "table": "part" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_message_session_idx", + "entityType": "indexes", + "table": "session_message" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "type", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_message_session_type_idx", + "entityType": "indexes", + "table": "session_message" + }, + { + "columns": [ + { + "value": "time_created", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_message_time_created_idx", + "entityType": "indexes", + "table": "session_message" + }, + { + "columns": [ + { + "value": "project_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_project_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_workspace_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "parent_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_parent_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "todo_session_idx", + "entityType": "indexes", + "table": "todo" + } + ], + "renames": [] +} diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 06c1ac7371..e9b811fc5e 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.14.41", + "version": "1.14.48", "name": "opencode", "type": "module", "license": "MIT", @@ -9,6 +9,7 @@ "typecheck": "tsgo --noEmit", "test": "bun test --timeout 30000", "test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml", + "test:httpapi": "bun run script/httpapi-exercise.ts --mode coverage --fail-on-missing --fail-on-skip && bun run script/httpapi-exercise.ts --mode auth --fail-on-missing --fail-on-skip && bun run script/httpapi-exercise.ts --mode effect --fail-on-missing --fail-on-skip", "build": "bun run script/build.ts", "fix-node-pty": "bun run script/fix-node-pty.ts", "dev": "bun run --conditions=browser ./src/index.ts", @@ -32,11 +33,6 @@ "node": "./src/pty/pty.node.ts", "default": "./src/pty/pty.bun.ts" }, - "#hono": { - "bun": "./src/server/adapter.bun.ts", - "node": "./src/server/adapter.node.ts", - "default": "./src/server/adapter.bun.ts" - }, "#httpapi-server": { "bun": "./src/server/httpapi-server.node.ts", "node": "./src/server/httpapi-server.node.ts", @@ -73,8 +69,7 @@ "prettier": "3.6.2", "typescript": "catalog:", "vscode-languageserver-types": "3.17.5", - "why-is-node-running": "3.2.2", - "zod-to-json-schema": "3.24.5" + "why-is-node-running": "3.2.2" }, "dependencies": { "@actions/core": "1.11.1", @@ -105,10 +100,6 @@ "@effect/opentelemetry": "catalog:", "@effect/platform-node": "catalog:", "@gitlab/opencode-gitlab-auth": "1.3.3", - "@hono/node-server": "1.19.11", - "@hono/node-ws": "1.3.0", - "@hono/standard-validator": "0.1.5", - "@hono/zod-validator": "catalog:", "@lydell/node-pty": "catalog:", "@modelcontextprotocol/sdk": "1.27.1", "@octokit/graphql": "9.0.2", @@ -128,6 +119,7 @@ "@opentui/solid": "catalog:", "@parcel/watcher": "2.5.1", "@pierre/diffs": "catalog:", + "@silvia-odwyer/photon-node": "0.3.4", "@solid-primitives/event-bus": "1.1.2", "@solid-primitives/scheduled": "1.5.2", "@standard-schema/spec": "1.0.0", @@ -149,8 +141,6 @@ "glob": "13.0.5", "google-auth-library": "10.5.0", "gray-matter": "4.0.3", - "hono": "catalog:", - "hono-openapi": "catalog:", "ignore": "7.0.5", "immer": "11.1.4", "jsonc-parser": "3.3.1", @@ -176,8 +166,7 @@ "which": "6.0.1", "xdg-basedir": "5.1.0", "yargs": "18.0.0", - "zod": "catalog:", - "zod-to-json-schema": "3.24.5" + "zod": "catalog:" }, "overrides": { "drizzle-orm": "catalog:" diff --git a/packages/opencode/script/httpapi-exercise.ts b/packages/opencode/script/httpapi-exercise.ts index 771e1e417e..5395a812f5 100644 --- a/packages/opencode/script/httpapi-exercise.ts +++ b/packages/opencode/script/httpapi-exercise.ts @@ -1,2014 +1 @@ -/** - * End-to-end exerciser for the legacy Hono instance routes and the Effect HttpApi routes. - * - * The goal is not to be a normal unit test file. This is a route-coverage and parity - * harness we can run while deleting Hono: every public route should eventually have a - * small scenario that proves the Effect route decodes requests, uses the right instance - * context, mutates storage when expected, and returns a compatible response shape. - * - * The script intentionally isolates `OPENCODE_DB` before importing modules that touch - * storage. Scenarios may create/delete sessions and reset the database after each run, - * so this must never point at a developer's real session database. - * - * DSL shape: - * - `http.get/post/...` starts a scenario for one OpenAPI route key. - * - `.seeded(...)` creates typed per-scenario state using Effect helpers on `ctx`. - * - `.at(...)` builds the request from that typed state. - * - `.json(...)` / `.jsonEffect(...)` assert response shape and optional side effects. - * - `.mutating()` tells parity mode to run Effect and Hono in separate isolated contexts - * so destructive routes compare equivalent fresh setups instead of sharing one DB. - */ -import { Cause, ConfigProvider, Effect, Layer } from "effect" -import { HttpRouter } from "effect/unstable/http" -import { OpenApi } from "effect/unstable/httpapi" -import { Flag } from "@opencode-ai/core/flag/flag" -import { TestLLMServer } from "../test/lib/llm-server" -import type { Config } from "../src/config/config" -import { MessageID, PartID, type SessionID } from "../src/session/schema" -import { ModelID, ProviderID } from "../src/provider/schema" -import type { MessageV2 } from "../src/session/message-v2" -import type { Worktree } from "../src/worktree" -import type { Project } from "../src/project/project" -import path from "path" - -const preserveExerciseGlobalRoot = !!process.env.OPENCODE_HTTPAPI_EXERCISE_GLOBAL -const exerciseGlobalRoot = - process.env.OPENCODE_HTTPAPI_EXERCISE_GLOBAL ?? - path.join(process.env.TMPDIR ?? "/tmp", `opencode-httpapi-global-${process.pid}`) -process.env.XDG_DATA_HOME = path.join(exerciseGlobalRoot, "data") -process.env.XDG_CONFIG_HOME = path.join(exerciseGlobalRoot, "config") -process.env.XDG_STATE_HOME = path.join(exerciseGlobalRoot, "state") -process.env.XDG_CACHE_HOME = path.join(exerciseGlobalRoot, "cache") -process.env.OPENCODE_DISABLE_SHARE = "true" -const exerciseConfigDirectory = path.join(exerciseGlobalRoot, "config", "opencode") -const exerciseDataDirectory = path.join(exerciseGlobalRoot, "data", "opencode") - -const preserveExerciseDatabase = !!process.env.OPENCODE_HTTPAPI_EXERCISE_DB -const exerciseDatabasePath = - process.env.OPENCODE_HTTPAPI_EXERCISE_DB ?? - path.join(process.env.TMPDIR ?? "/tmp", `opencode-httpapi-exercise-${process.pid}.db`) -process.env.OPENCODE_DB = exerciseDatabasePath -Flag.OPENCODE_DB = exerciseDatabasePath - -void (await import("@opencode-ai/core/util/log")).init({ print: false }) - -const OpenApiMethods = ["get", "post", "put", "delete", "patch"] as const -const Methods = ["GET", "POST", "PUT", "DELETE", "PATCH"] as const -const color = { - dim: "\x1b[2m", - green: "\x1b[32m", - red: "\x1b[31m", - yellow: "\x1b[33m", - cyan: "\x1b[36m", - reset: "\x1b[0m", -} - -type Method = (typeof Methods)[number] -type OpenApiMethod = (typeof OpenApiMethods)[number] -type Mode = "effect" | "parity" | "coverage" -type Backend = "effect" | "legacy" -type Comparison = "none" | "status" | "json" -type CaptureMode = "full" | "stream" -type ProjectOptions = { git?: boolean; config?: Partial; llm?: boolean } -type OpenApiSpec = { paths?: Record>> } -type JsonObject = Record - -type Options = { - mode: Mode - include: string | undefined - failOnMissing: boolean - failOnSkip: boolean -} - -type RequestSpec = { - path: string - headers?: Record - body?: unknown -} - -type CallResult = { - status: number - contentType: string - body: unknown - text: string -} - -type BackendApp = { - request(input: string | URL | Request, init?: RequestInit): Response | Promise -} - -/** Effect-native helpers available while setting up and asserting a scenario. */ -type ScenarioContext = { - directory: string | undefined - headers: (extra?: Record) => Record - file: (name: string, content: string) => Effect.Effect - session: (input?: { title?: string; parentID?: SessionID }) => Effect.Effect - sessionGet: (sessionID: SessionID) => Effect.Effect - project: () => Effect.Effect - message: (sessionID: SessionID, input?: { text?: string }) => Effect.Effect - messages: (sessionID: SessionID) => Effect.Effect - todos: (sessionID: SessionID, todos: TodoInfo[]) => Effect.Effect - worktree: (input?: { name?: string }) => Effect.Effect - worktreeRemove: (directory: string) => Effect.Effect - llmText: (value: string) => Effect.Effect - llmWait: (count: number) => Effect.Effect - tuiRequest: (request: { path: string; body: unknown }) => Effect.Effect -} - -/** Scenario context after `.seeded(...)`; `state` preserves the seed return type in the DSL. */ -type SeededContext = ScenarioContext & { - state: S -} - -type Scenario = ActiveScenario | TodoScenario -type ActiveScenario = { - kind: "active" - method: Method - path: string - name: string - project: ProjectOptions | undefined - seed: (ctx: ScenarioContext) => Effect.Effect - request: (ctx: ScenarioContext, state: unknown) => RequestSpec - expect: (ctx: ScenarioContext, state: unknown, result: CallResult) => Effect.Effect - compare: Comparison - capture: CaptureMode - mutates: boolean - reset: boolean -} - -/** Internal builder state stays generic until `.json(...)` erases it into `ActiveScenario`. */ -type BuilderState = { - method: Method - path: string - name: string - project: ProjectOptions | undefined - seed: (ctx: ScenarioContext) => Effect.Effect - request: (ctx: SeededContext) => RequestSpec - capture: CaptureMode - mutates: boolean - reset: boolean -} -type TodoScenario = { - kind: "todo" - method: Method - path: string - name: string - reason: string -} -type Result = - | { status: "pass"; scenario: ActiveScenario } - | { status: "fail"; scenario: ActiveScenario; message: string } - | { status: "skip"; scenario: TodoScenario } - -type SessionInfo = { id: SessionID; title: string; parentID?: SessionID } -type TodoInfo = { content: string; status: string; priority: string } -type MessageSeed = { info: MessageV2.User; part: MessageV2.TextPart } - -const original = { - OPENCODE_EXPERIMENTAL_HTTPAPI: Flag.OPENCODE_EXPERIMENTAL_HTTPAPI, - OPENCODE_SERVER_PASSWORD: Flag.OPENCODE_SERVER_PASSWORD, - OPENCODE_SERVER_USERNAME: Flag.OPENCODE_SERVER_USERNAME, -} - -type Runtime = { - PublicApi: (typeof import("../src/server/routes/instance/httpapi/public"))["PublicApi"] - ExperimentalHttpApiServer: (typeof import("../src/server/routes/instance/httpapi/server"))["ExperimentalHttpApiServer"] - Server: (typeof import("../src/server/server"))["Server"] - AppLayer: (typeof import("../src/effect/app-runtime"))["AppLayer"] - InstanceRef: (typeof import("../src/effect/instance-ref"))["InstanceRef"] - Instance: (typeof import("../src/project/instance"))["Instance"] - InstanceStore: (typeof import("../src/project/instance-store"))["InstanceStore"] - Session: (typeof import("../src/session/session"))["Session"] - Todo: (typeof import("../src/session/todo"))["Todo"] - Worktree: (typeof import("../src/worktree"))["Worktree"] - Project: (typeof import("../src/project/project"))["Project"] - Tui: typeof import("../src/server/shared/tui-control") - disposeAllInstances: (typeof import("../test/fixture/fixture"))["disposeAllInstances"] - tmpdir: (typeof import("../test/fixture/fixture"))["tmpdir"] - resetDatabase: (typeof import("../test/fixture/db"))["resetDatabase"] -} - -let runtimePromise: Promise | undefined - -function runtime() { - return (runtimePromise ??= (async () => { - const publicApi = await import("../src/server/routes/instance/httpapi/public") - const httpApiServer = await import("../src/server/routes/instance/httpapi/server") - const server = await import("../src/server/server") - const appRuntime = await import("../src/effect/app-runtime") - const instanceRef = await import("../src/effect/instance-ref") - const instance = await import("../src/project/instance") - const instanceStore = await import("../src/project/instance-store") - const session = await import("../src/session/session") - const todo = await import("../src/session/todo") - const worktree = await import("../src/worktree") - const project = await import("../src/project/project") - const tui = await import("../src/server/shared/tui-control") - const fixture = await import("../test/fixture/fixture") - const db = await import("../test/fixture/db") - return { - PublicApi: publicApi.PublicApi, - ExperimentalHttpApiServer: httpApiServer.ExperimentalHttpApiServer, - Server: server.Server, - AppLayer: appRuntime.AppLayer, - InstanceRef: instanceRef.InstanceRef, - Instance: instance.Instance, - InstanceStore: instanceStore.InstanceStore, - Session: session.Session, - Todo: todo.Todo, - Worktree: worktree.Worktree, - Project: project.Project, - Tui: tui, - disposeAllInstances: fixture.disposeAllInstances, - tmpdir: fixture.tmpdir, - resetDatabase: db.resetDatabase, - } - })()) -} - -class ScenarioBuilder { - private readonly state: BuilderState - - constructor(method: Method, path: string, name: string) { - this.state = { - method, - path, - name, - project: { git: true }, - seed: () => Effect.succeed(undefined as S), - request: (ctx) => ({ path, headers: ctx.headers() }), - capture: "full", - mutates: false, - reset: true, - } - } - - global() { - return this.clone({ project: undefined, request: () => ({ path: this.state.path }) }) - } - - inProject(project: ProjectOptions = { git: true }) { - return this.clone({ project }) - } - - withLlm() { - return this.clone({ project: { ...(this.state.project ?? { git: true }), llm: true } }) - } - - at(request: BuilderState["request"]) { - return this.clone({ request }) - } - - mutating() { - return this.clone({ mutates: true }) - } - - preserveDatabase() { - return this.clone({ reset: false }) - } - - stream() { - return this.clone({ capture: "stream" }) - } - - /** Assert a non-JSON or shape-only response. */ - ok(status = 200, compare: Comparison = "status") { - return this.done(compare, (_ctx, result) => - Effect.sync(() => { - if (result.status !== status) throw new Error(`expected ${status}, got ${result.status}: ${result.text}`) - }), - ) - } - - status( - status = 200, - inspect?: (ctx: SeededContext, result: CallResult) => Effect.Effect, - compare: Comparison = "status", - ) { - return this.done(compare, (ctx, result) => - Effect.gen(function* () { - if (result.status !== status) throw new Error(`expected ${status}, got ${result.status}: ${result.text}`) - if (inspect) yield* inspect(ctx, result) - }), - ) - } - - /** Assert JSON status/content-type plus an optional synchronous body check. */ - json(status = 200, inspect?: (body: unknown, ctx: SeededContext) => void, compare: Comparison = "json") { - return this.jsonEffect(status, inspect ? (body, ctx) => Effect.sync(() => inspect(body, ctx)) : undefined, compare) - } - - /** Assert JSON status/content-type plus optional Effect assertions, e.g. DB side effects. */ - jsonEffect( - status = 200, - inspect?: (body: unknown, ctx: SeededContext) => Effect.Effect, - compare: Comparison = "json", - ) { - return this.done(compare, (ctx, result) => - Effect.gen(function* () { - if (result.status !== status) throw new Error(`expected ${status}, got ${result.status}: ${result.text}`) - if (!looksJson(result)) - throw new Error(`expected JSON response, got ${result.contentType || "no content-type"}`) - if (inspect) yield* inspect(result.body, ctx) - }), - ) - } - - private clone(next: Partial>) { - const builder = new ScenarioBuilder(this.state.method, this.state.path, this.state.name) - Object.assign(builder.state, this.state, next) - return builder - } - - /** - * Seed typed state before the HTTP request. The returned value becomes `ctx.state` - * for `.at(...)` and assertions, giving stateful route tests type-safe setup. - */ - seeded(seed: (ctx: ScenarioContext) => Effect.Effect) { - const builder = new ScenarioBuilder(this.state.method, this.state.path, this.state.name) - Object.assign(builder.state, this.state, { seed }) - return builder - } - - private done( - compare: Comparison, - expect: (ctx: SeededContext, result: CallResult) => Effect.Effect, - ): ActiveScenario { - const state = this.state - return { - kind: "active", - method: state.method, - path: state.path, - name: state.name, - project: state.project, - seed: state.seed, - request: (ctx, seeded) => state.request({ ...ctx, state: seeded as S }), - expect: (ctx, seeded, result) => expect({ ...ctx, state: seeded as S }, result), - compare, - capture: state.capture, - mutates: state.mutates, - reset: state.reset, - } - } -} - -const http = { - get: (path: string, name: string) => new ScenarioBuilder("GET", path, name), - post: (path: string, name: string) => new ScenarioBuilder("POST", path, name), - put: (path: string, name: string) => new ScenarioBuilder("PUT", path, name), - patch: (path: string, name: string) => new ScenarioBuilder("PATCH", path, name), - delete: (path: string, name: string) => new ScenarioBuilder("DELETE", path, name), -} - -const pending = (method: Method, path: string, name: string, reason: string): TodoScenario => ({ - kind: "todo", - method, - path, - name, - reason, -}) - -function route(template: string, params: Record) { - return Object.entries(params).reduce( - (next, [key, value]) => next.replaceAll(`{${key}}`, value).replaceAll(`:${key}`, value), - template, - ) -} - -const scenarios: Scenario[] = [ - http - .get("/global/health", "global.health") - .global() - .json(200, (body) => { - object(body) - check(body.healthy === true, "server should report healthy") - }), - http - .get("/global/event", "global.event") - .global() - .stream() - .status( - 200, - (_ctx, result) => - Effect.sync(() => { - check(result.contentType.includes("text/event-stream"), "global event should be an SSE stream") - check(result.text.includes("server.connected"), "global event should emit initial connection event") - }), - "status", - ), - http.get("/global/config", "global.config.get").global().json(), - http - .patch("/global/config", "global.config.update") - .global() - .seeded(() => - Effect.promise(() => - Bun.write( - path.join(exerciseConfigDirectory, "opencode.jsonc"), - JSON.stringify({ username: "httpapi-global" }, null, 2), - ), - ), - ) - .at(() => ({ path: "/global/config", body: { username: "httpapi-global" } })) - .jsonEffect( - 200, - (body) => - Effect.gen(function* () { - object(body) - check(body.username === "httpapi-global", "global config update should return patched config") - const text = yield* Effect.promise(() => - Bun.file(path.join(exerciseConfigDirectory, "opencode.jsonc")).text(), - ) - check(text.includes('"username": "httpapi-global"'), "global config update should write isolated config file") - }), - "status", - ), - http - .post("/global/dispose", "global.dispose") - .global() - .mutating() - .json( - 200, - (body) => { - check(body === true, "global dispose should return true") - }, - "status", - ), - http.get("/path", "path.get").json(200, (body, ctx) => { - object(body) - check(body.directory === ctx.directory, "directory should resolve from x-opencode-directory") - check(body.worktree === ctx.directory, "worktree should resolve from x-opencode-directory") - }), - http.get("/vcs", "vcs.get").json(), - http - .get("/vcs/diff", "vcs.diff") - .at((ctx) => ({ path: "/vcs/diff?mode=git", headers: ctx.headers() })) - .json(200, array), - http.get("/command", "command.list").json(200, array, "status"), - http.get("/agent", "app.agents").json(200, array, "status"), - http.get("/skill", "app.skills").json(200, array, "status"), - http.get("/lsp", "lsp.status").json(200, array), - http.get("/formatter", "formatter.status").json(200, array), - http.get("/config", "config.get").json(200, undefined, "status"), - http - .patch("/config", "config.update") - .mutating() - .at((ctx) => ({ path: "/config", headers: ctx.headers(), body: { username: "httpapi-local" } })) - .json( - 200, - (body) => { - object(body) - check(body.username === "httpapi-local", "local config update should return patched config") - }, - "status", - ), - http - .patch("/config", "config.update.invalid") - .at((ctx) => ({ path: "/config", headers: ctx.headers(), body: { username: 1 } })) - .status(400), - http.get("/config/providers", "config.providers").json(), - http.get("/project", "project.list").json(200, array, "status"), - http.get("/project/current", "project.current").json( - 200, - (body, ctx) => { - object(body) - check(body.worktree === ctx.directory, "current project should resolve from scenario directory") - }, - "status", - ), - http - .patch("/project/{projectID}", "project.update") - .mutating() - .seeded((ctx) => ctx.project()) - .at((ctx) => ({ - path: route("/project/{projectID}", { projectID: ctx.state.id }), - headers: ctx.headers(), - body: { name: "HTTP API Project", commands: { start: "bun --version" } }, - })) - .json( - 200, - (body) => { - object(body) - check(body.name === "HTTP API Project", "project update should return patched name") - check( - isRecord(body.commands) && body.commands.start === "bun --version", - "project update should return patched command", - ) - }, - "status", - ), - http - .post("/project/git/init", "project.initGit") - .mutating() - .inProject({ git: false }) - .json( - 200, - (body, ctx) => { - object(body) - check(body.worktree === ctx.directory, "git init should return current project") - check(body.vcs === "git", "git init should mark the project as git-backed") - }, - "status", - ), - http.get("/provider", "provider.list").json(), - http.get("/provider/auth", "provider.auth").json(), - http - .post("/provider/{providerID}/oauth/authorize", "provider.oauth.authorize") - .at((ctx) => ({ - path: route("/provider/{providerID}/oauth/authorize", { providerID: "httpapi" }), - headers: ctx.headers(), - body: { method: "bad" }, - })) - .status(400), - http - .post("/provider/{providerID}/oauth/callback", "provider.oauth.callback") - .at((ctx) => ({ - path: route("/provider/{providerID}/oauth/callback", { providerID: "httpapi" }), - headers: ctx.headers(), - body: { method: "bad" }, - })) - .status(400), - http.get("/permission", "permission.list").json(200, array), - http - .post("/permission/{requestID}/reply", "permission.reply.invalid") - .at((ctx) => ({ - path: route("/permission/{requestID}/reply", { requestID: "per_httpapi" }), - headers: ctx.headers(), - body: { reply: "bad" }, - })) - .status(400), - http - .post("/permission/{requestID}/reply", "permission.reply") - .at((ctx) => ({ - path: route("/permission/{requestID}/reply", { requestID: "per_httpapi" }), - headers: ctx.headers(), - body: { reply: "once" }, - })) - .json(200, (body) => { - check(body === true, "permission reply should return true even when request is no longer pending") - }), - http.get("/question", "question.list").json(200, array), - http - .post("/question/{requestID}/reply", "question.reply.invalid") - .at((ctx) => ({ - path: route("/question/{requestID}/reply", { requestID: "que_httpapi_reply" }), - headers: ctx.headers(), - body: { answers: "Yes" }, - })) - .status(400), - http - .post("/question/{requestID}/reply", "question.reply") - .at((ctx) => ({ - path: route("/question/{requestID}/reply", { requestID: "que_httpapi_reply" }), - headers: ctx.headers(), - body: { answers: [["Yes"]] }, - })) - .json(200, (body) => { - check(body === true, "question reply should return true even when request is no longer pending") - }), - http - .post("/question/{requestID}/reject", "question.reject") - .at((ctx) => ({ - path: route("/question/{requestID}/reject", { requestID: "que_httpapi_reject" }), - headers: ctx.headers(), - })) - .json(200, (body) => { - check(body === true, "question reject should return true even when request is no longer pending") - }), - http - .get("/file", "file.list") - .seeded((ctx) => ctx.file("hello.txt", "hello\n")) - .at((ctx) => ({ path: `/file?${new URLSearchParams({ path: "." })}`, headers: ctx.headers() })) - .json(200, array), - http - .get("/file/content", "file.read") - .seeded((ctx) => ctx.file("hello.txt", "hello\n")) - .at((ctx) => ({ path: `/file/content?${new URLSearchParams({ path: "hello.txt" })}`, headers: ctx.headers() })) - .json(200, (body) => { - object(body) - check(body.content === "hello", `content should match seeded file: ${JSON.stringify(body)}`) - }), - http - .get("/file/content", "file.read.missing") - .at((ctx) => ({ path: `/file/content?${new URLSearchParams({ path: "missing.txt" })}`, headers: ctx.headers() })) - .json(200, (body) => { - object(body) - check(body.type === "text" && body.content === "", "missing file content should return an empty text result") - }), - http.get("/file/status", "file.status").json(200, array), - http - .get("/find", "find.text") - .seeded((ctx) => ctx.file("hello.txt", "hello\n")) - .at((ctx) => ({ path: `/find?${new URLSearchParams({ pattern: "hello" })}`, headers: ctx.headers() })) - .json(200, array), - http - .get("/find/file", "find.files") - .seeded((ctx) => ctx.file("hello.txt", "hello\n")) - .at((ctx) => ({ - path: `/find/file?${new URLSearchParams({ query: "hello", dirs: "false" })}`, - headers: ctx.headers(), - })) - .json(200, array), - http - .get("/find/symbol", "find.symbols") - .seeded((ctx) => ctx.file("hello.ts", "export const hello = 1\n")) - .at((ctx) => ({ path: `/find/symbol?${new URLSearchParams({ query: "hello" })}`, headers: ctx.headers() })) - .json(200, array), - http - .get("/event", "event.stream") - .stream() - .status( - 200, - (_ctx, result) => - Effect.sync(() => { - check(result.contentType.includes("text/event-stream"), "event should be an SSE stream") - check(result.text.includes("server.connected"), "event should emit initial connection event") - }), - "status", - ), - http.get("/mcp", "mcp.status").json(), - http - .post("/mcp", "mcp.add") - .mutating() - .at((ctx) => ({ - path: "/mcp", - headers: ctx.headers(), - body: { name: "httpapi-disabled", config: { type: "local", command: ["bun", "--version"], enabled: false } }, - })) - .json( - 200, - (body) => { - object(body) - object(body["httpapi-disabled"]) - check(body["httpapi-disabled"].status === "disabled", "disabled MCP server should be added without spawning") - }, - "status", - ), - http - .post("/mcp", "mcp.add.invalid") - .at((ctx) => ({ - path: "/mcp", - headers: ctx.headers(), - body: { name: "httpapi-invalid", config: { type: "invalid" } }, - })) - .status(400), - http - .post("/mcp/{name}/auth", "mcp.auth.start") - .at((ctx) => ({ path: route("/mcp/{name}/auth", { name: "httpapi-missing" }), headers: ctx.headers() })) - .json( - 400, - (body) => { - object(body) - check(typeof body.error === "string", "unsupported MCP OAuth response should include error") - }, - "status", - ), - http - .delete("/mcp/{name}/auth", "mcp.auth.remove") - .mutating() - .at((ctx) => ({ path: route("/mcp/{name}/auth", { name: "httpapi-missing" }), headers: ctx.headers() })) - .json(200, (body) => { - object(body) - check(body.success === true, "MCP auth removal should return success") - }), - http - .post("/mcp/{name}/auth/authenticate", "mcp.auth.authenticate") - .at((ctx) => ({ - path: route("/mcp/{name}/auth/authenticate", { name: "httpapi-missing" }), - headers: ctx.headers(), - })) - .json( - 400, - (body) => { - object(body) - check(typeof body.error === "string", "unsupported MCP OAuth authenticate response should include error") - }, - "status", - ), - http - .post("/mcp/{name}/auth/callback", "mcp.auth.callback") - .at((ctx) => ({ - path: route("/mcp/{name}/auth/callback", { name: "httpapi-missing" }), - headers: ctx.headers(), - body: { code: 1 }, - })) - .status(400), - http - .post("/mcp/{name}/connect", "mcp.connect") - .mutating() - .at((ctx) => ({ path: route("/mcp/{name}/connect", { name: "httpapi-missing" }), headers: ctx.headers() })) - .json(200, (body) => { - check(body === true, "missing MCP connect should remain a no-op success") - }), - http - .post("/mcp/{name}/disconnect", "mcp.disconnect") - .mutating() - .at((ctx) => ({ path: route("/mcp/{name}/disconnect", { name: "httpapi-missing" }), headers: ctx.headers() })) - .json(200, (body) => { - check(body === true, "missing MCP disconnect should remain a no-op success") - }), - http.get("/pty/shells", "pty.shells").json(200, array), - http.get("/pty", "pty.list").json(200, array), - http - .post("/pty", "pty.create") - .mutating() - .at((ctx) => ({ path: "/pty", headers: ctx.headers(), body: controlledPtyInput("HTTP API PTY") })) - .json( - 200, - (body, ctx) => { - object(body) - check(body.title === "HTTP API PTY", "PTY create should return requested title") - check(body.command === "/bin/sh", "PTY create should use controlled shell command") - check(body.cwd === ctx.directory, "PTY create should default cwd to scenario directory") - }, - "status", - ), - http - .post("/pty", "pty.create.invalid") - .at((ctx) => ({ path: "/pty", headers: ctx.headers(), body: { command: 1 } })) - .status(400), - http - .get("/pty/{ptyID}", "pty.get") - .at((ctx) => ({ path: route("/pty/{ptyID}", { ptyID: "pty_httpapi_missing" }), headers: ctx.headers() })) - .status(404), - http - .put("/pty/{ptyID}", "pty.update") - .mutating() - .at((ctx) => ({ - path: route("/pty/{ptyID}", { ptyID: "pty_httpapi_missing" }), - headers: ctx.headers(), - body: { size: { rows: 0, cols: 0 } }, - })) - .status(400), - http - .delete("/pty/{ptyID}", "pty.remove") - .mutating() - .at((ctx) => ({ path: route("/pty/{ptyID}", { ptyID: "pty_httpapi_missing" }), headers: ctx.headers() })) - .json(200, (body) => { - check(body === true, "PTY remove should return true") - }), - http - .get("/pty/{ptyID}/connect", "pty.connect") - .at((ctx) => ({ path: route("/pty/{ptyID}/connect", { ptyID: "pty_httpapi_missing" }), headers: ctx.headers() })) - .status(404, undefined, "none"), - http.get("/experimental/console", "experimental.console.get").json(), - http.get("/experimental/console/orgs", "experimental.console.listOrgs").json(), - http - .post("/experimental/console/switch", "experimental.console.switchOrg") - .at((ctx) => ({ - path: "/experimental/console/switch", - headers: ctx.headers(), - body: { accountID: "httpapi-account", orgID: "httpapi-org" }, - })) - .status(400, undefined, "none"), - http.get("/experimental/workspace/adapter", "experimental.workspace.adapter.list").json(200, array), - http.get("/experimental/workspace", "experimental.workspace.list").json(200, array), - http.get("/experimental/workspace/status", "experimental.workspace.status").json(200, array), - http - .post("/experimental/workspace", "experimental.workspace.create") - .at((ctx) => ({ path: "/experimental/workspace", headers: ctx.headers(), body: {} })) - .status(400), - http - .delete("/experimental/workspace/{id}", "experimental.workspace.remove") - .mutating() - .at((ctx) => ({ - path: route("/experimental/workspace/{id}", { id: "wrk_httpapi_missing" }), - headers: ctx.headers(), - })) - .status(200), - http - .post("/experimental/workspace/warp", "experimental.workspace.warp") - .at((ctx) => ({ - path: "/experimental/workspace/warp", - headers: ctx.headers(), - body: {}, - })) - .status(400), - http - .get("/experimental/tool", "tool.list") - .at((ctx) => ({ - path: `/experimental/tool?${new URLSearchParams({ provider: "opencode", model: "test" })}`, - headers: ctx.headers(), - })) - .json(200, array, "status"), - http.get("/experimental/tool/ids", "tool.ids").json(200, array), - http.get("/experimental/worktree", "worktree.list").json(200, array), - http - .post("/experimental/worktree", "worktree.create") - .mutating() - .at((ctx) => ({ path: "/experimental/worktree", headers: ctx.headers(), body: { name: "api-dsl" } })) - .jsonEffect( - 200, - (body, ctx) => - Effect.gen(function* () { - object(body) - check(typeof body.directory === "string", "created worktree should include directory") - yield* ctx.worktreeRemove(body.directory) - }), - "status", - ), - http - .post("/experimental/worktree", "worktree.create.invalid") - .at((ctx) => ({ path: "/experimental/worktree", headers: ctx.headers(), body: { name: 1 } })) - .status(400), - http - .delete("/experimental/worktree", "worktree.remove") - .mutating() - .seeded((ctx) => ctx.worktree({ name: "api-remove" })) - .at((ctx) => ({ path: "/experimental/worktree", headers: ctx.headers(), body: { directory: ctx.state.directory } })) - .json(200, (body) => { - check(body === true, "worktree remove should return true") - }), - http - .post("/experimental/worktree/reset", "worktree.reset") - .mutating() - .seeded((ctx) => ctx.worktree({ name: "api-reset" })) - .at((ctx) => ({ - path: "/experimental/worktree/reset", - headers: ctx.headers(), - body: { directory: ctx.state.directory }, - })) - .jsonEffect(200, (body, ctx) => - Effect.gen(function* () { - check(body === true, "worktree reset should return true") - yield* ctx.worktreeRemove(ctx.state.directory) - }), - ), - http.get("/experimental/session", "experimental.session.list").json(200, array), - http.get("/experimental/resource", "experimental.resource.list").json(), - http - .post("/sync/history", "sync.history.list") - .at((ctx) => ({ path: "/sync/history", headers: ctx.headers(), body: {} })) - .json(200, array), - http - .post("/sync/replay", "sync.replay") - .at((ctx) => ({ path: "/sync/replay", headers: ctx.headers(), body: { directory: ctx.directory, events: [] } })) - .status(400), - http - .post("/sync/start", "sync.start") - .mutating() - .preserveDatabase() - .json(200, (body) => { - check(body === true, "sync start should return true when no workspace sessions exist") - }), - http - .post("/instance/dispose", "instance.dispose") - .mutating() - .json(200, (body) => { - check(body === true, "instance dispose should return true") - }), - http - .post("/log", "app.log") - .global() - .at(() => ({ path: "/log", body: { service: "httpapi-exercise", level: "info", message: "route coverage" } })) - .json(200, (body) => { - check(body === true, "log route should return true") - }), - http - .put("/auth/{providerID}", "auth.set") - .global() - .at(() => ({ path: route("/auth/{providerID}", { providerID: "test" }), body: { type: "api", key: "test-key" } })) - .jsonEffect(200, (body) => - Effect.gen(function* () { - check(body === true, "auth set should return true") - const auth = yield* Effect.promise(() => Bun.file(path.join(exerciseDataDirectory, "auth.json")).json()) - object(auth) - check(isRecord(auth.test) && auth.test.key === "test-key", "auth set should write isolated auth file") - }), - ), - http - .delete("/auth/{providerID}", "auth.remove") - .global() - .seeded(() => - Effect.promise(() => - Bun.write( - path.join(exerciseDataDirectory, "auth.json"), - JSON.stringify({ test: { type: "api", key: "remove-me" } }), - ), - ), - ) - .at(() => ({ path: route("/auth/{providerID}", { providerID: "test" }) })) - .jsonEffect(200, (body) => - Effect.gen(function* () { - check(body === true, "auth remove should return true") - const auth = yield* Effect.promise(() => Bun.file(path.join(exerciseDataDirectory, "auth.json")).json()) - object(auth) - check(auth.test === undefined, "auth remove should delete provider from isolated auth file") - }), - ), - http - .get("/session", "session.list") - .seeded((ctx) => ctx.session({ title: "List me" })) - .at((ctx) => ({ path: "/session?roots=true", headers: ctx.headers() })) - .json(200, (body, ctx) => { - array(body) - check( - body.some((item) => isRecord(item) && item.id === ctx.state.id && item.title === "List me"), - "seeded session should be listed", - ) - }), - http - .get("/session/status", "session.status") - .seeded((ctx) => ctx.session({ title: "Status session" })) - .json(200, object), - http - .post("/session", "session.create") - .mutating() - .at((ctx) => ({ path: "/session", headers: ctx.headers(), body: { title: "Created session" } })) - .json( - 200, - (body, ctx) => { - object(body) - check(body.title === "Created session", "created session should use requested title") - check(body.directory === ctx.directory, "created session should use scenario directory") - }, - "status", - ), - http - .get("/session/{sessionID}", "session.get") - .seeded((ctx) => ctx.session({ title: "Get me" })) - .at((ctx) => ({ path: route("/session/{sessionID}", { sessionID: ctx.state.id }), headers: ctx.headers() })) - .json(200, (body, ctx) => { - object(body) - check(body.id === ctx.state.id, "should return requested session") - check(body.title === "Get me", "should preserve seeded title") - }), - http - .get("/session/{sessionID}", "session.get.missing") - .at((ctx) => ({ - path: route("/session/{sessionID}", { sessionID: "ses_httpapi_missing" }), - headers: ctx.headers(), - })) - .status(404), - http - .patch("/session/{sessionID}", "session.update") - .mutating() - .seeded((ctx) => ctx.session({ title: "Before rename" })) - .at((ctx) => ({ - path: route("/session/{sessionID}", { sessionID: ctx.state.id }), - headers: ctx.headers(), - body: { title: "After rename" }, - })) - .json( - 200, - (body) => { - object(body) - check(body.title === "After rename", "updated session should use new title") - }, - "status", - ), - http - .patch("/session/{sessionID}", "session.update.invalid") - .mutating() - .at((ctx) => ({ - path: route("/session/{sessionID}", { sessionID: "ses_httpapi_missing" }), - headers: ctx.headers(), - body: { title: 1 }, - })) - .status(400), - http - .delete("/session/{sessionID}", "session.delete") - .mutating() - .seeded((ctx) => ctx.session({ title: "Delete me" })) - .at((ctx) => ({ path: route("/session/{sessionID}", { sessionID: ctx.state.id }), headers: ctx.headers() })) - .jsonEffect(200, (body, ctx) => - Effect.gen(function* () { - check(body === true, "delete should return true") - check((yield* ctx.sessionGet(ctx.state.id)) === undefined, "deleted session should not remain in storage") - }), - ), - http - .get("/session/{sessionID}/children", "session.children") - .seeded((ctx) => - Effect.gen(function* () { - const parent = yield* ctx.session({ title: "Parent" }) - const child = yield* ctx.session({ title: "Child", parentID: parent.id }) - return { parent, child } - }), - ) - .at((ctx) => ({ - path: route("/session/{sessionID}/children", { sessionID: ctx.state.parent.id }), - headers: ctx.headers(), - })) - .json(200, (body, ctx) => { - array(body) - check( - body.some((item) => isRecord(item) && item.id === ctx.state.child.id && item.parentID === ctx.state.parent.id), - "children should include seeded child", - ) - }), - http - .get("/session/{sessionID}/todo", "session.todo") - .seeded((ctx) => - Effect.gen(function* () { - const session = yield* ctx.session({ title: "Todo session" }) - const todos = [{ content: "cover session todo", status: "pending", priority: "high" }] - yield* ctx.todos(session.id, todos) - return { session, todos } - }), - ) - .at((ctx) => ({ - path: route("/session/{sessionID}/todo", { sessionID: ctx.state.session.id }), - headers: ctx.headers(), - })) - .json(200, (body, ctx) => { - check(stable(body) === stable(ctx.state.todos), "todos should match seeded state") - }), - http - .get("/session/{sessionID}/diff", "session.diff") - .seeded((ctx) => ctx.session({ title: "Diff session" })) - .at((ctx) => ({ path: route("/session/{sessionID}/diff", { sessionID: ctx.state.id }), headers: ctx.headers() })) - .json(200, array), - http - .get("/session/{sessionID}/message", "session.messages") - .seeded((ctx) => ctx.session({ title: "Messages session" })) - .at((ctx) => ({ path: route("/session/{sessionID}/message", { sessionID: ctx.state.id }), headers: ctx.headers() })) - .json(200, (body) => { - array(body) - check(body.length === 0, "new session should have no messages") - }), - http - .get("/session/{sessionID}/message/{messageID}", "session.message") - .seeded((ctx) => - Effect.gen(function* () { - const session = yield* ctx.session({ title: "Message get session" }) - const message = yield* ctx.message(session.id, { text: "read me" }) - return { session, message } - }), - ) - .at((ctx) => ({ - path: route("/session/{sessionID}/message/{messageID}", { - sessionID: ctx.state.session.id, - messageID: ctx.state.message.info.id, - }), - headers: ctx.headers(), - })) - .json(200, (body, ctx) => { - object(body) - check(isRecord(body.info) && body.info.id === ctx.state.message.info.id, "should return requested message") - check( - Array.isArray(body.parts) && body.parts.some((part) => isRecord(part) && part.id === ctx.state.message.part.id), - "message should include seeded part", - ) - }), - http - .patch("/session/{sessionID}/message/{messageID}/part/{partID}", "part.update") - .mutating() - .seeded((ctx) => - Effect.gen(function* () { - const session = yield* ctx.session({ title: "Part update session" }) - const message = yield* ctx.message(session.id, { text: "before" }) - return { session, message } - }), - ) - .at((ctx) => ({ - path: route("/session/{sessionID}/message/{messageID}/part/{partID}", { - sessionID: ctx.state.session.id, - messageID: ctx.state.message.info.id, - partID: ctx.state.message.part.id, - }), - headers: ctx.headers(), - body: { ...ctx.state.message.part, text: "after" }, - })) - .json( - 200, - (body) => { - object(body) - check(body.type === "text" && body.text === "after", "updated part should be returned") - }, - "status", - ), - http - .delete("/session/{sessionID}/message/{messageID}/part/{partID}", "part.delete") - .mutating() - .seeded((ctx) => - Effect.gen(function* () { - const session = yield* ctx.session({ title: "Part delete session" }) - const message = yield* ctx.message(session.id, { text: "delete part" }) - return { session, message } - }), - ) - .at((ctx) => ({ - path: route("/session/{sessionID}/message/{messageID}/part/{partID}", { - sessionID: ctx.state.session.id, - messageID: ctx.state.message.info.id, - partID: ctx.state.message.part.id, - }), - headers: ctx.headers(), - })) - .jsonEffect(200, (body, ctx) => - Effect.gen(function* () { - check(body === true, "delete part should return true") - const messages = yield* ctx.messages(ctx.state.session.id) - check(messages[0]?.parts.length === 0, "deleted part should not remain on message") - }), - ), - http - .delete("/session/{sessionID}/message/{messageID}", "session.deleteMessage") - .mutating() - .seeded((ctx) => - Effect.gen(function* () { - const session = yield* ctx.session({ title: "Message delete session" }) - const message = yield* ctx.message(session.id, { text: "delete message" }) - return { session, message } - }), - ) - .at((ctx) => ({ - path: route("/session/{sessionID}/message/{messageID}", { - sessionID: ctx.state.session.id, - messageID: ctx.state.message.info.id, - }), - headers: ctx.headers(), - })) - .jsonEffect(200, (body, ctx) => - Effect.gen(function* () { - check(body === true, "delete message should return true") - check((yield* ctx.messages(ctx.state.session.id)).length === 0, "deleted message should not remain") - }), - ), - http - .post("/session/{sessionID}/fork", "session.fork") - .mutating() - .seeded((ctx) => ctx.session({ title: "Fork source" })) - .at((ctx) => ({ - path: route("/session/{sessionID}/fork", { sessionID: ctx.state.id }), - headers: ctx.headers(), - body: {}, - })) - .json( - 200, - (body) => { - object(body) - check(typeof body.id === "string", "fork should return a session") - }, - "status", - ), - http - .post("/session/{sessionID}/abort", "session.abort") - .mutating() - .seeded((ctx) => ctx.session({ title: "Abort session" })) - .at((ctx) => ({ path: route("/session/{sessionID}/abort", { sessionID: ctx.state.id }), headers: ctx.headers() })) - .json(200, (body) => { - check(body === true, "abort should return true") - }), - http - .post("/session/{sessionID}/abort", "session.abort.missing") - .at((ctx) => ({ - path: route("/session/{sessionID}/abort", { sessionID: "ses_httpapi_missing" }), - headers: ctx.headers(), - })) - .json(200, (body) => { - check(body === true, "missing session abort should remain a no-op success") - }), - http - .post("/session/{sessionID}/init", "session.init") - .preserveDatabase() - .withLlm() - .seeded((ctx) => - Effect.gen(function* () { - const session = yield* ctx.session({ title: "Init session" }) - const message = yield* ctx.message(session.id, { text: "initialize" }) - yield* ctx.llmText("initialized") - yield* ctx.llmText("initialized") - return { session, message } - }), - ) - .at((ctx) => ({ - path: route("/session/{sessionID}/init", { sessionID: ctx.state.session.id }), - headers: ctx.headers(), - body: { providerID: "test", modelID: "test-model", messageID: ctx.state.message.info.id }, - })) - .jsonEffect(200, (body, ctx) => - Effect.gen(function* () { - check(body === true, "init should return true") - yield* ctx.llmWait(1) - }), - ), - http - .post("/session/{sessionID}/message", "session.prompt") - .preserveDatabase() - .withLlm() - .seeded((ctx) => - Effect.gen(function* () { - const session = yield* ctx.session({ title: "LLM prompt session" }) - yield* ctx.llmText("fake assistant") - yield* ctx.llmText("fake assistant") - return session - }), - ) - .at((ctx) => ({ - path: route("/session/{sessionID}/message", { sessionID: ctx.state.id }), - headers: ctx.headers(), - body: { - agent: "build", - model: { providerID: "test", modelID: "test-model" }, - parts: [{ type: "text", text: "hello llm" }], - }, - })) - .jsonEffect( - 200, - (body, ctx) => - Effect.gen(function* () { - object(body) - check(isRecord(body.info) && body.info.role === "assistant", "prompt should return assistant message") - check( - Array.isArray(body.parts) && body.parts.some((part) => isRecord(part) && part.text === "fake assistant"), - "assistant message should use fake LLM text", - ) - yield* ctx.llmWait(1) - }), - "status", - ), - http - .post("/session/{sessionID}/prompt_async", "session.prompt_async") - .preserveDatabase() - .withLlm() - .seeded((ctx) => - Effect.gen(function* () { - const session = yield* ctx.session({ title: "Async prompt session" }) - yield* ctx.llmText("fake async assistant") - yield* ctx.llmText("fake async assistant") - return session - }), - ) - .at((ctx) => ({ - path: route("/session/{sessionID}/prompt_async", { sessionID: ctx.state.id }), - headers: ctx.headers(), - body: { - agent: "build", - model: { providerID: "test", modelID: "test-model" }, - parts: [{ type: "text", text: "hello async" }], - }, - })) - .status(204, (ctx) => - Effect.gen(function* () { - yield* ctx.llmWait(1) - }), - ), - http - .post("/session/{sessionID}/command", "session.command") - .preserveDatabase() - .withLlm() - .seeded((ctx) => - Effect.gen(function* () { - const session = yield* ctx.session({ title: "Command session" }) - yield* ctx.llmText("command done") - yield* ctx.llmText("command done") - return session - }), - ) - .at((ctx) => ({ - path: route("/session/{sessionID}/command", { sessionID: ctx.state.id }), - headers: ctx.headers(), - body: { command: "init", arguments: "", model: "test/test-model" }, - })) - .jsonEffect( - 200, - (body, ctx) => - Effect.gen(function* () { - object(body) - check(isRecord(body.info) && body.info.role === "assistant", "command should return assistant message") - yield* ctx.llmWait(1) - }), - "status", - ), - http - .post("/session/{sessionID}/shell", "session.shell") - .preserveDatabase() - .mutating() - .seeded((ctx) => ctx.session({ title: "Shell session" })) - .at((ctx) => ({ - path: route("/session/{sessionID}/shell", { sessionID: ctx.state.id }), - headers: ctx.headers(), - body: { agent: "build", model: { providerID: "test", modelID: "test-model" }, command: "printf shell-ok" }, - })) - .json( - 200, - (body) => { - object(body) - check(isRecord(body.info) && body.info.role === "assistant", "shell should return assistant message") - check( - Array.isArray(body.parts) && body.parts.some((part) => isRecord(part) && part.type === "tool"), - "shell should return a tool part", - ) - }, - "status", - ), - http - .post("/session/{sessionID}/summarize", "session.summarize") - .preserveDatabase() - .withLlm() - .seeded((ctx) => - Effect.gen(function* () { - const session = yield* ctx.session({ title: "Summarize session" }) - yield* ctx.message(session.id, { text: "summarize this work" }) - const summary = [ - "## Goal", - "- Exercise session summarize.", - "", - "## Constraints & Preferences", - "- Use fake LLM.", - "", - "## Progress", - "### Done", - "- Summary generated.", - "", - "### In Progress", - "- (none)", - "", - "### Blocked", - "- (none)", - "", - "## Key Decisions", - "- Keep route local.", - "", - "## Next Steps", - "- (none)", - "", - "## Critical Context", - "- Test fixture.", - "", - "## Relevant Files", - "- script/httpapi-exercise.ts: scenario", - ].join("\n") - yield* ctx.llmText(summary) - yield* ctx.llmText(summary) - return session - }), - ) - .at((ctx) => ({ - path: route("/session/{sessionID}/summarize", { sessionID: ctx.state.id }), - headers: ctx.headers(), - body: { providerID: "test", modelID: "test-model", auto: false }, - })) - .jsonEffect( - 200, - (body, ctx) => - Effect.gen(function* () { - check(body === true, "summarize should return true") - const messages = yield* ctx.messages(ctx.state.id) - check( - messages.some((message) => message.info.role === "assistant" && message.info.summary === true), - "summarize should create a summary assistant message", - ) - yield* ctx.llmWait(1) - }), - "status", - ), - http - .post("/session/{sessionID}/revert", "session.revert") - .mutating() - .seeded((ctx) => - Effect.gen(function* () { - const session = yield* ctx.session({ title: "Revert session" }) - const message = yield* ctx.message(session.id, { text: "revert me" }) - return { session, message } - }), - ) - .at((ctx) => ({ - path: route("/session/{sessionID}/revert", { sessionID: ctx.state.session.id }), - headers: ctx.headers(), - body: { messageID: ctx.state.message.info.id }, - })) - .json( - 200, - (body, ctx) => { - object(body) - check(body.id === ctx.state.session.id, "revert should return the session") - check( - isRecord(body.revert) && body.revert.messageID === ctx.state.message.info.id, - "revert should record reverted message", - ) - }, - "status", - ), - http - .post("/session/{sessionID}/unrevert", "session.unrevert") - .mutating() - .seeded((ctx) => ctx.session({ title: "Unrevert session" })) - .at((ctx) => ({ - path: route("/session/{sessionID}/unrevert", { sessionID: ctx.state.id }), - headers: ctx.headers(), - })) - .json( - 200, - (body, ctx) => { - object(body) - check(body.id === ctx.state.id, "unrevert should return the session") - }, - "status", - ), - http - .post("/session/{sessionID}/permissions/{permissionID}", "permission.respond") - .seeded((ctx) => ctx.session({ title: "Deprecated permission session" })) - .at((ctx) => ({ - path: route("/session/{sessionID}/permissions/{permissionID}", { - sessionID: ctx.state.id, - permissionID: "per_httpapi_deprecated", - }), - headers: ctx.headers(), - body: { response: "once" }, - })) - .json(200, (body) => { - check(body === true, "deprecated permission response should return true") - }), - http - .post("/session/{sessionID}/share", "session.share") - .mutating() - .seeded((ctx) => ctx.session({ title: "Share session" })) - .at((ctx) => ({ path: route("/session/{sessionID}/share", { sessionID: ctx.state.id }), headers: ctx.headers() })) - .json( - 200, - (body, ctx) => { - object(body) - check(body.id === ctx.state.id, "share should return the session") - }, - "status", - ), - http - .delete("/session/{sessionID}/share", "session.unshare") - .mutating() - .seeded((ctx) => ctx.session({ title: "Unshare session" })) - .at((ctx) => ({ path: route("/session/{sessionID}/share", { sessionID: ctx.state.id }), headers: ctx.headers() })) - .json( - 200, - (body, ctx) => { - object(body) - check(body.id === ctx.state.id, "unshare should return the session") - }, - "status", - ), - http - .post("/tui/append-prompt", "tui.appendPrompt") - .at((ctx) => ({ path: "/tui/append-prompt", headers: ctx.headers(), body: { text: "hello" } })) - .json(200, boolean, "status"), - http - .post("/tui/select-session", "tui.selectSession.invalid") - .at((ctx) => ({ path: "/tui/select-session", headers: ctx.headers(), body: { sessionID: "invalid" } })) - .status(400), - http.post("/tui/open-help", "tui.openHelp").json(200, boolean, "status"), - http.post("/tui/open-sessions", "tui.openSessions").json(200, boolean, "status"), - http.post("/tui/open-themes", "tui.openThemes").json(200, boolean, "status"), - http.post("/tui/open-models", "tui.openModels").json(200, boolean, "status"), - http.post("/tui/submit-prompt", "tui.submitPrompt").json(200, boolean, "status"), - http.post("/tui/clear-prompt", "tui.clearPrompt").json(200, boolean, "status"), - http - .post("/tui/execute-command", "tui.executeCommand") - .at((ctx) => ({ path: "/tui/execute-command", headers: ctx.headers(), body: { command: "agent_cycle" } })) - .json(200, boolean, "status"), - http - .post("/tui/show-toast", "tui.showToast") - .at((ctx) => ({ - path: "/tui/show-toast", - headers: ctx.headers(), - body: { title: "Exercise", message: "covered", variant: "info", duration: 1000 }, - })) - .json(200, boolean, "status"), - http - .post("/tui/publish", "tui.publish") - .at((ctx) => ({ - path: "/tui/publish", - headers: ctx.headers(), - body: { type: "tui.prompt.append", properties: { text: "published" } }, - })) - .json(200, boolean, "status"), - http - .post("/tui/select-session", "tui.selectSession") - .seeded((ctx) => ctx.session({ title: "TUI select" })) - .at((ctx) => ({ path: "/tui/select-session", headers: ctx.headers(), body: { sessionID: ctx.state.id } })) - .json(200, boolean, "status"), - http - .post("/tui/control/response", "tui.control.response") - .at((ctx) => ({ path: "/tui/control/response", headers: ctx.headers(), body: { ok: true } })) - .json(200, boolean, "status"), - http - .get("/tui/control/next", "tui.control.next") - .mutating() - .seeded((ctx) => ctx.tuiRequest({ path: "/tui/exercise", body: { text: "queued" } })) - .json( - 200, - (body) => { - object(body) - check(body.path === "/tui/exercise", "control next should return queued path") - object(body.body) - check(body.body.text === "queued", "control next should return queued body") - }, - "status", - ), - http - .post("/global/upgrade", "global.upgrade") - .global() - .at(() => ({ path: "/global/upgrade", body: { target: 1 } })) - .status(400), -] - -const main = Effect.gen(function* () { - yield* Effect.addFinalizer(() => cleanupExercisePaths) - const options = parseOptions(Bun.argv.slice(2)) - const modules = yield* Effect.promise(() => runtime()) - const effectRoutes = routeKeys(OpenApi.fromApi(modules.PublicApi)) - const honoRoutes = routeKeys(yield* Effect.promise(() => modules.Server.openapiHono())) - const selected = scenarios.filter((scenario) => matches(options, scenario)) - const missing = effectRoutes.filter((route) => !scenarios.some((scenario) => route === routeKey(scenario))) - const extra = scenarios.filter((scenario) => !effectRoutes.includes(routeKey(scenario))) - - printHeader(options, effectRoutes, honoRoutes, selected, missing, extra) - - const results = - options.mode === "coverage" - ? selected.map(coverageResult) - : yield* Effect.forEach(selected, runScenario(options), { concurrency: 1 }) - printResults(results, missing, extra) - - if (results.some((result) => result.status === "fail")) - return yield* Effect.fail(new Error("one or more scenarios failed")) - if (options.failOnSkip && results.some((result) => result.status === "skip")) - return yield* Effect.fail(new Error("one or more scenarios are skipped")) - if (options.failOnMissing && missing.length > 0) - return yield* Effect.fail(new Error("one or more routes have no scenario")) -}) - -function runScenario(options: Options) { - return (scenario: Scenario) => { - if (scenario.kind === "todo") return Effect.succeed({ status: "skip", scenario } as Result) - return runActive(options, scenario).pipe( - Effect.as({ status: "pass", scenario } as Result), - Effect.catchCause((cause) => Effect.succeed({ status: "fail" as const, scenario, message: Cause.pretty(cause) })), - Effect.scoped, - ) - } -} - -function runActive(options: Options, scenario: ActiveScenario) { - if (options.mode === "parity" && scenario.mutates && scenario.compare !== "none") { - return Effect.gen(function* () { - const effect = yield* runBackend("effect", scenario) - const legacy = yield* runBackend("legacy", scenario) - yield* compare(scenario, effect, legacy) - }) - } - - return withContext(scenario, (ctx) => - Effect.gen(function* () { - const effect = yield* call("effect", scenario, ctx) - yield* scenario.expect(ctx, ctx.state, effect) - if (options.mode === "parity" && scenario.compare !== "none") { - const legacy = yield* call("legacy", scenario, ctx) - yield* scenario.expect(ctx, ctx.state, legacy) - yield* compare(scenario, effect, legacy) - } - }), - ) -} - -function runBackend(backend: "effect" | "legacy", scenario: ActiveScenario) { - return withContext(scenario, (ctx) => - Effect.gen(function* () { - const result = yield* call(backend, scenario, ctx) - yield* scenario.expect(ctx, ctx.state, result) - return result - }), - ) -} - -function withContext(scenario: ActiveScenario, use: (ctx: SeededContext) => Effect.Effect) { - return Effect.acquireRelease( - Effect.gen(function* () { - const llm = scenario.project?.llm ? yield* TestLLMServer : undefined - const project = scenario.project - const dir = project - ? yield* Effect.promise(async () => (await runtime()).tmpdir(projectOptions(project, llm?.url))) - : undefined - return { dir, llm } - }), - (ctx) => Effect.promise(async () => void (await ctx.dir?.[Symbol.asyncDispose]())).pipe(Effect.ignore), - ).pipe( - Effect.flatMap((context) => - Effect.gen(function* () { - const modules = yield* Effect.promise(() => runtime()) - const path = context.dir?.path - const instance = path - ? yield* modules.InstanceStore.Service.use((store) => store.load({ directory: path })).pipe( - Effect.provide(modules.AppLayer), - Effect.catchCause((cause) => - Effect.sleep("100 millis").pipe( - Effect.andThen( - modules.InstanceStore.Service.use((store) => store.load({ directory: path })).pipe( - Effect.provide(modules.AppLayer), - ), - ), - Effect.catchCause(() => Effect.failCause(cause)), - ), - ), - ) - : undefined - const run = (effect: Effect.Effect) => - effect.pipe(Effect.provideService(modules.InstanceRef, instance), Effect.provide(modules.AppLayer)) - const directory = () => { - if (!context.dir?.path) throw new Error("scenario needs a project directory") - return context.dir.path - } - const llm = () => { - if (!context.llm) throw new Error("scenario needs fake LLM") - return context.llm - } - const base: ScenarioContext = { - directory: context.dir?.path, - headers: (extra) => ({ - ...(context.dir?.path ? { "x-opencode-directory": context.dir.path } : {}), - ...extra, - }), - file: (name, content) => - Effect.promise(() => { - return Bun.write(`${directory()}/${name}`, content) - }).pipe(Effect.asVoid), - session: (input) => - run(modules.Session.Service.use((svc) => svc.create({ title: input?.title, parentID: input?.parentID }))), - sessionGet: (sessionID) => - run(modules.Session.Service.use((svc) => svc.get(sessionID))).pipe( - Effect.catchCause(() => Effect.succeed(undefined)), - ), - project: () => - Effect.sync(() => { - if (!instance) throw new Error("scenario needs a project directory") - return instance.project - }), - message: (sessionID, input) => - Effect.gen(function* () { - const info: MessageV2.User = { - id: MessageID.ascending(), - sessionID, - role: "user", - time: { created: Date.now() }, - agent: "build", - model: { - providerID: ProviderID.opencode, - modelID: ModelID.make("test"), - }, - } - const part: MessageV2.TextPart = { - id: PartID.ascending(), - sessionID, - messageID: info.id, - type: "text", - text: input?.text ?? "hello", - } - yield* run( - modules.Session.Service.use((svc) => - Effect.gen(function* () { - yield* svc.updateMessage(info) - yield* svc.updatePart(part) - }), - ), - ) - return { info, part } - }), - messages: (sessionID) => run(modules.Session.Service.use((svc) => svc.messages({ sessionID }))), - todos: (sessionID, todos) => run(modules.Todo.Service.use((svc) => svc.update({ sessionID, todos }))), - worktree: (input) => run(modules.Worktree.Service.use((svc) => svc.create(input))), - worktreeRemove: (directory) => - run(modules.Worktree.Service.use((svc) => svc.remove({ directory })).pipe(Effect.ignore)), - llmText: (value) => Effect.suspend(() => llm().text(value)), - llmWait: (count) => Effect.suspend(() => llm().wait(count)), - tuiRequest: (request) => Effect.sync(() => modules.Tui.submitTuiRequest(request)), - } - const state = yield* scenario.seed(base) - return yield* use({ ...base, state }) - }).pipe(Effect.ensuring(context.llm ? context.llm.reset : Effect.void)), - ), - Effect.ensuring(scenario.reset ? resetState : Effect.void), - ) -} - -function projectOptions( - project: ProjectOptions, - llmUrl: string | undefined, -): { git?: boolean; config?: Partial } { - if (!project.llm || !llmUrl) return { git: project.git, config: project.config } - const fake = fakeLlmConfig(llmUrl) - return { - git: project.git, - config: { - ...fake, - ...project.config, - provider: { - ...fake.provider, - ...project.config?.provider, - }, - }, - } -} - -function fakeLlmConfig(url: string): Partial { - return { - model: "test/test-model", - small_model: "test/test-model", - provider: { - test: { - name: "Test", - id: "test", - env: [], - npm: "@ai-sdk/openai-compatible", - models: { - "test-model": { - id: "test-model", - name: "Test Model", - attachment: false, - reasoning: false, - temperature: false, - tool_call: true, - release_date: "2025-01-01", - limit: { context: 100000, output: 10000 }, - cost: { input: 0, output: 0 }, - options: {}, - }, - }, - options: { - apiKey: "test-key", - baseURL: url, - }, - }, - }, - } -} - -function controlledPtyInput(title: string | undefined) { - return { - command: "/bin/sh", - args: ["-c", "sleep 30"], - ...(title ? { title } : {}), - } -} - -function call(backend: Backend, scenario: ActiveScenario, ctx: SeededContext) { - return Effect.promise(async () => - capture(await app(await runtime(), backend).request(toRequest(scenario, ctx)), scenario.capture), - ) -} - -const appCache: Partial> = {} - -function app(modules: Runtime, backend: Backend) { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = backend === "effect" - Flag.OPENCODE_SERVER_PASSWORD = undefined - Flag.OPENCODE_SERVER_USERNAME = undefined - if (appCache[backend]) return appCache[backend] - if (backend === "legacy") { - const legacy = modules.Server.Legacy().app - return (appCache.legacy = { - request: (input, init) => legacy.request(input, init), - }) - } - - const handler = HttpRouter.toWebHandler( - modules.ExperimentalHttpApiServer.routes.pipe( - Layer.provide( - ConfigProvider.layer( - ConfigProvider.fromUnknown({ OPENCODE_SERVER_PASSWORD: undefined, OPENCODE_SERVER_USERNAME: undefined }), - ), - ), - ), - { disableLogger: true }, - ).handler - return (appCache.effect = { - request(input: string | URL | Request, init?: RequestInit) { - return handler( - input instanceof Request ? input : new Request(new URL(input, "http://localhost"), init), - modules.ExperimentalHttpApiServer.context, - ) - }, - }) -} - -function toRequest(scenario: ActiveScenario, ctx: SeededContext) { - const spec = scenario.request(ctx, ctx.state) - return new Request(new URL(spec.path, "http://localhost"), { - method: scenario.method, - headers: spec.body === undefined ? spec.headers : { "content-type": "application/json", ...spec.headers }, - body: spec.body === undefined ? undefined : JSON.stringify(spec.body), - }) -} - -async function capture(response: Response, mode: CaptureMode): Promise { - const text = mode === "stream" ? await captureStream(response) : await response.text() - return { - status: response.status, - contentType: response.headers.get("content-type") ?? "", - text, - body: parse(text), - } -} - -async function captureStream(response: Response) { - if (!response.body) return "" - const reader = response.body.getReader() - const read = reader.read().then( - (result) => ({ result }), - (error: unknown) => ({ error }), - ) - const winner = await Promise.race([read, Bun.sleep(1_000).then(() => ({ timeout: true }))]) - if ("timeout" in winner) { - await reader.cancel("timed out waiting for stream chunk").catch(() => undefined) - throw new Error("timed out waiting for stream chunk") - } - if ("error" in winner) throw winner.error - await reader.cancel().catch(() => undefined) - if (winner.result.done) return "" - return new TextDecoder().decode(winner.result.value) -} - -const cleanupExercisePaths = Effect.promise(async () => { - const fs = await import("fs/promises") - if (!preserveExerciseDatabase) { - await Promise.all( - [exerciseDatabasePath, `${exerciseDatabasePath}-wal`, `${exerciseDatabasePath}-shm`].map((file) => - fs.rm(file, { force: true }).catch(() => undefined), - ), - ) - } - if (!preserveExerciseGlobalRoot) - await fs.rm(exerciseGlobalRoot, { recursive: true, force: true }).catch(() => undefined) -}) - -function compare(scenario: ActiveScenario, effect: CallResult, legacy: CallResult) { - return Effect.sync(() => { - if (effect.status !== legacy.status) - throw new Error(`legacy returned ${legacy.status}, effect returned ${effect.status}`) - if (scenario.compare === "status") return - if (stable(effect.body) !== stable(legacy.body)) - throw new Error(`JSON parity mismatch\nlegacy: ${stable(legacy.body)}\neffect: ${stable(effect.body)}`) - }) -} - -const resetState = Effect.promise(async () => { - const modules = await runtime() - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original.OPENCODE_EXPERIMENTAL_HTTPAPI - Flag.OPENCODE_SERVER_PASSWORD = original.OPENCODE_SERVER_PASSWORD - Flag.OPENCODE_SERVER_USERNAME = original.OPENCODE_SERVER_USERNAME - await modules.disposeAllInstances() - await modules.resetDatabase() - await Bun.sleep(25) -}) - -function routeKeys(spec: OpenApiSpec) { - return Object.entries(spec.paths ?? {}) - .flatMap(([path, item]) => - OpenApiMethods.filter((method) => item[method]).map((method) => `${method.toUpperCase()} ${path}`), - ) - .sort() -} - -function routeKey(scenario: Scenario) { - return `${scenario.method} ${scenario.path}` -} - -function coverageResult(scenario: Scenario): Result { - if (scenario.kind === "todo") return { status: "skip", scenario } - return { status: "pass", scenario } -} - -function parseOptions(args: string[]): Options { - const mode = option(args, "--mode") ?? "effect" - if (mode !== "effect" && mode !== "parity" && mode !== "coverage") throw new Error(`invalid --mode ${mode}`) - return { - mode, - include: option(args, "--include"), - failOnMissing: args.includes("--fail-on-missing"), - failOnSkip: args.includes("--fail-on-skip"), - } -} - -function option(args: string[], name: string) { - const index = args.indexOf(name) - if (index === -1) return undefined - return args[index + 1] -} - -function matches(options: Options, scenario: Scenario) { - if (!options.include) return true - return ( - scenario.name.includes(options.include) || - scenario.path.includes(options.include) || - scenario.method.includes(options.include.toUpperCase()) - ) -} - -function printHeader( - options: Options, - effectRoutes: string[], - honoRoutes: string[], - selected: Scenario[], - missing: string[], - extra: Scenario[], -) { - console.log(`${color.cyan}HttpApi exerciser${color.reset}`) - console.log(`${color.dim}db=${exerciseDatabasePath}${color.reset}`) - console.log(`${color.dim}global=${exerciseGlobalRoot}${color.reset}`) - console.log( - `${color.dim}mode=${options.mode} selected=${selected.length} effectRoutes=${effectRoutes.length} missing=${missing.length} extra=${extra.length} onlyEffect=${effectRoutes.filter((route) => !honoRoutes.includes(route)).length} onlyHono=${honoRoutes.filter((route) => !effectRoutes.includes(route)).length}${color.reset}`, - ) - console.log("") -} - -function printResults(results: Result[], missing: string[], extra: Scenario[]) { - for (const result of results) { - if (result.status === "pass") { - console.log( - `${color.green}PASS${color.reset} ${pad(result.scenario.method, 6)} ${pad(result.scenario.path, 48)} ${result.scenario.name}`, - ) - continue - } - if (result.status === "skip") { - console.log( - `${color.yellow}SKIP${color.reset} ${pad(result.scenario.method, 6)} ${pad(result.scenario.path, 48)} ${result.scenario.name} ${color.dim}${result.scenario.reason}${color.reset}`, - ) - continue - } - console.log( - `${color.red}FAIL${color.reset} ${pad(result.scenario.method, 6)} ${pad(result.scenario.path, 48)} ${result.scenario.name}`, - ) - console.log(`${color.red}${indent(result.message)}${color.reset}`) - } - if (missing.length > 0) { - console.log("\nMissing scenarios") - for (const route of missing) console.log(`${color.red}MISS${color.reset} ${route}`) - } - if (extra.length > 0) { - console.log("\nExtra scenarios") - for (const scenario of extra) - console.log(`${color.yellow}EXTRA${color.reset} ${routeKey(scenario)} ${scenario.name}`) - } - console.log( - `\n${color.dim}summary pass=${results.filter((result) => result.status === "pass").length} fail=${results.filter((result) => result.status === "fail").length} skip=${results.filter((result) => result.status === "skip").length} missing=${missing.length} extra=${extra.length}${color.reset}`, - ) -} - -function parse(text: string): unknown { - if (!text) return undefined - try { - return JSON.parse(text) as unknown - } catch { - return text - } -} - -function looksJson(result: CallResult) { - return result.contentType.includes("application/json") || result.text.startsWith("{") || result.text.startsWith("[") -} - -function stable(value: unknown): string { - return JSON.stringify(sort(value)) -} - -function sort(value: unknown): unknown { - if (Array.isArray(value)) return value.map(sort) - if (!value || typeof value !== "object") return value - return Object.fromEntries( - Object.entries(value) - .sort(([left], [right]) => left.localeCompare(right)) - .map(([key, item]) => [key, sort(item)]), - ) -} - -function array(value: unknown): asserts value is unknown[] { - if (!Array.isArray(value)) throw new Error("expected array") -} - -function object(value: unknown): asserts value is JsonObject { - if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("expected object") -} - -function boolean(value: unknown): asserts value is boolean { - if (typeof value !== "boolean") throw new Error("expected boolean") -} - -function isRecord(value: unknown): value is JsonObject { - return !!value && typeof value === "object" && !Array.isArray(value) -} - -function check(value: boolean, message: string): asserts value { - if (!value) throw new Error(message) -} - -function message(error: unknown) { - if (error instanceof Error) return error.message - return String(error) -} - -function pad(value: string, size: number) { - return value.length >= size ? value : value + " ".repeat(size - value.length) -} - -function indent(value: string) { - return value - .split("\n") - .map((line) => ` ${line}`) - .join("\n") -} - -Effect.runPromise(main.pipe(Effect.provide(TestLLMServer.layer), Effect.scoped)).then( - () => process.exit(0), - (error: unknown) => { - console.error(`${color.red}${message(error)}${color.reset}`) - process.exit(1) - }, -) +await import("../test/server/httpapi-exercise/index") diff --git a/packages/opencode/scripts/diff-sdk-types.sh b/packages/opencode/scripts/diff-sdk-types.sh deleted file mode 100755 index b27a31e8c3..0000000000 --- a/packages/opencode/scripts/diff-sdk-types.sh +++ /dev/null @@ -1,52 +0,0 @@ -#!/bin/bash -# Compare SDK types generated from Hono vs HttpApi specs. -# Sorts types alphabetically so only meaningful body differences show. -# -# Usage: ./scripts/diff-sdk-types.sh # full diff -# ./scripts/diff-sdk-types.sh --stat # summary only -set -euo pipefail - -DIR="$(cd "$(dirname "$0")/.." && pwd)" -SDK="$(cd "$DIR/../sdk/js" && pwd)" - -normalize() { - python3 -c " -import re, sys -content = open(sys.argv[1]).read() -blocks = re.split(r'(?=^export (?:type|function|const) )', content, flags=re.MULTILINE) -header, body = blocks[0], blocks[1:] -body.sort(key=lambda b: m.group(1) if (m := re.match(r'export \w+ (\w+)', b)) else '') -sys.stdout.write(header + ''.join(body)) -" "$1" -} - -echo "Generating Hono SDK..." >&2 -(cd "$SDK" && bun run script/build.ts >/dev/null 2>&1) -normalize "$SDK/src/v2/gen/types.gen.ts" > /tmp/sdk-types-hono.ts -git -C "$SDK" checkout -- src/ 2>/dev/null - -echo "Generating HttpApi SDK..." >&2 -(cd "$SDK" && OPENCODE_SDK_OPENAPI=httpapi bun run script/build.ts >/dev/null 2>&1) -normalize "$SDK/src/v2/gen/types.gen.ts" > /tmp/sdk-types-httpapi.ts -git -C "$SDK" checkout -- src/ 2>/dev/null - -echo "" >&2 -if [[ "${1:-}" == "--stat" ]]; then - diff_output=$(diff /tmp/sdk-types-hono.ts /tmp/sdk-types-httpapi.ts || true) - honly=$(printf "%s\n" "$diff_output" | grep -c '^< export type' || true) - aonly=$(printf "%s\n" "$diff_output" | grep -c '^> export type' || true) - total=$(printf "%s\n" "$diff_output" | wc -l | tr -d ' ') - echo "Hono-only: $honly types HttpApi-only: $aonly types Diff lines: $total" - echo "" - if [[ $honly -gt 0 ]]; then - echo "=== Hono-only types ===" - printf "%s\n" "$diff_output" | grep '^< export type' | sed 's/< export type //' | sed 's/[ =].*//' | sed 's/^/ /' - echo "" - fi - if [[ $aonly -gt 0 ]]; then - echo "=== HttpApi-only types ===" - printf "%s\n" "$diff_output" | grep '^> export type' | sed 's/> export type //' | sed 's/[ =].*//' | sed 's/^/ /' - fi -else - diff /tmp/sdk-types-hono.ts /tmp/sdk-types-httpapi.ts || true -fi diff --git a/packages/opencode/specs/effect/http-api.md b/packages/opencode/specs/effect/http-api.md deleted file mode 100644 index 99b7f1b156..0000000000 --- a/packages/opencode/specs/effect/http-api.md +++ /dev/null @@ -1,401 +0,0 @@ -# HttpApi migration - -Plan for replacing instance Hono route implementations with Effect `HttpApi` while preserving behavior, OpenAPI, and SDK output during the transition. - -## End State - -- JSON route contracts and handlers live in `src/server/routes/instance/httpapi/*`. -- Route modules own their `HttpApiGroup`, schemas, handlers, and route-level middleware. -- `httpapi/server.ts` only composes groups, instance lookup, observability, and the web handler bridge. -- Hono route implementations are deleted once their `HttpApi` replacements are default, tested, and represented in the SDK/OpenAPI pipeline. -- Streaming, SSE, and websocket routes move later through Effect HTTP primitives or another explicit replacement plan; they do not need to fit `HttpApi` if `HttpApi` is the wrong abstraction. - -## Current State - -- `OPENCODE_EXPERIMENTAL_HTTPAPI` selects the backend at server startup. Default is still `hono`. -- `server/backend.ts` picks one of `effect-httpapi` or `hono`; `server.ts` builds either a pure Effect `HttpApi` web handler or the legacy Hono app accordingly. The earlier in-Hono "bridge" model has been replaced by this fork-at-startup. -- Legacy Hono routes remain mounted for the `hono` backend and remain the source for `hono-openapi` SDK generation. -- An Effect `HttpApi` OpenAPI surface exists (`OpenApi.fromApi(PublicApi)` in `cli/cmd/generate.ts --httpapi`, `OPENCODE_SDK_OPENAPI=httpapi` in `packages/sdk/js/script/build.ts`) but is opt-in. The default SDK generation is still Hono. -- `httpapi/public.ts` carries the Hono-compat normalization for the Effect-generated OpenAPI surface (auth scheme strip, request-body required flag, optional `null` arms, `BadRequestError` / `NotFoundError` remap, `$ref` self-cycle fix, `auth_token` query injection). Today's Effect-generated SDK is not byte-identical to the Hono-generated SDK — see Phase 4. -- Auth is centrally configured for the Effect backend via Effect `Config` (`refactor: use Effect config for HttpApi authorization`, `Fix HttpApi raw route authorization`) rather than re-attached in each route module. -- Auth supports Basic auth and the legacy `auth_token` query parameter through `HttpApiSecurity.apiKey`. -- Instance context is provided by `httpapi/server.ts` using `directory`, `workspace`, and `x-opencode-directory`. -- `Observability.layer` is provided in the Effect route layer and deduplicated through the shared `memoMap`. -- CORS middleware is wired into both backends (`feat(httpapi): add CORS middleware to instance routes`). - -## Migration Rules - -- Preserve runtime behavior first. Semantic changes, new error behavior, or route shape changes need separate PRs. -- Migrate one route group, or one coherent subset of a route group, at a time. -- Reuse existing services. Do not re-architect service logic during HTTP boundary migration. -- Effect Schema owns route DTOs. Keep `.zod` only as compatibility for remaining Hono/OpenAPI surfaces. -- Regenerate the SDK after schema or OpenAPI-affecting changes and verify the diff is expected. -- Do not delete a Hono route until the SDK/OpenAPI pipeline no longer depends on its Hono `describeRoute` entry. - -## Route Slice Checklist - -Use this checklist for each small HttpApi migration PR: - -1. Read the legacy Hono route and copy behavior exactly, including default values, headers, operation IDs, response schemas, and status codes. -2. Put the new `HttpApiGroup`, route paths, DTO schemas, and handlers in `src/server/routes/instance/httpapi/*`. -3. Mount the new paths in `src/server/routes/instance/index.ts` only inside the `OPENCODE_EXPERIMENTAL_HTTPAPI` block. -4. Use `InstanceState.context` / `InstanceState.directory` inside HttpApi handlers instead of `Instance.directory`, `Instance.worktree`, or `Instance.project` ALS globals. -5. Reuse existing services directly. If a service returns plain objects, use `Schema.Struct`; use `Schema.Class` only when handlers return actual class instances. -6. Keep legacy Hono routes and `.zod` compatibility in place for SDK/OpenAPI generation. -7. Add tests that hit the Hono-mounted bridge via `InstanceRoutes`, not only the raw `HttpApi` web handler, when the route depends on auth or instance context. -8. Run `bun typecheck` from `packages/opencode`, relevant `bun run test:ci ...` tests from `packages/opencode`, and `./packages/sdk/js/script/build.ts` from the repo root. - -## Hono Deletion Checklist - -Use this checklist before deleting any Hono route implementation. A route being `bridged` is not enough. - -1. `HttpApi` parity is complete for the route path, method, auth behavior, query parameters, request body, response status, response headers, and error status. -2. The route is mounted by default, not only behind `OPENCODE_EXPERIMENTAL_HTTPAPI`. -3. If a fallback flag exists, tests cover both the default `HttpApi` path and the fallback Hono path until the fallback is removed. -4. OpenAPI generation uses the Effect `HttpApi` route as the source for that path. -5. Generated SDK output is unchanged from the Hono-generated contract, or the SDK diff is intentionally reviewed and accepted. -6. The legacy Hono `describeRoute`, validator, and handler for that path are removed. -7. Any duplicate Zod-only DTOs are deleted or kept only as `.zod` compatibility on the canonical Effect Schema. -8. Bridge tests exist for auth, instance selection, success response, and route-specific side effects. -9. Mutation routes prove persisted side effects and cleanup behavior in tests. If the mutation disposes/reloads the active instance, disposal happens through an explicit post-response lifecycle hook rather than inline handler teardown. -10. Streaming, SSE, websocket, and UI bridge routes have a specific non-Hono replacement plan. Do not force them through `HttpApi` if raw Effect HTTP is a better fit. - -Hono can be removed from the instance server only after all mounted Hono route groups meet this checklist and `server/routes/instance/index.ts` no longer depends on Hono routing for default behavior. - -## Experimental Read Slice Guidance - -For the experimental route group, port read-only JSON routes before mutations: - -- Good first batch: `GET /console`, `GET /console/orgs`, `GET /tool/ids`, `GET /resource`. -- Consider `GET /worktree` only if the handler uses `InstanceState.context` instead of `Instance.project`. -- Defer `POST /console/switch`, worktree create/remove/reset, and `GET /session` to separate PRs because they mutate state or have broader pagination/session behavior. -- Preserve response headers such as pagination cursors if a route is ported. -- If SDK generation changes, explain whether it is a semantic contract change or a generator-equivalent type normalization. - -## Schema Notes - -- Use `Schema.Struct(...).annotate({ identifier })` for named OpenAPI refs when handlers return plain objects. -- Use `Schema.Class` only when the handler returns real class instances or the constructor requirement is intentional. -- Keep nested anonymous shapes as `Schema.Struct` unless a named SDK type is useful. -- Avoid parallel hand-written Zod and Effect definitions for the same route boundary. - -## Phases - -### 1. Stabilize The Bridge - -Before porting more routes, cover the bridge behavior that every route depends on. - -- Add tests that hit the Hono-mounted `HttpApi` bridge, not just `HttpApiBuilder.layer` directly. -- Cover auth disabled, Basic auth success, `auth_token` success, missing credentials, and bad credentials. -- Cover `directory` and `x-opencode-directory` instance selection. -- Verify generated SDK output remains unchanged for non-SDK work. -- Fix or remove any implemented-but-unmounted `HttpApi` groups. - -### 2. Complete The Inventory - -Create a route inventory from the actual Hono registrations and classify each route. - -Statuses: - -- `bridged`: served through the `HttpApi` bridge when the flag is on. -- `implemented`: `HttpApi` group exists but is not mounted through Hono. -- `next`: good JSON candidate for near-term porting. -- `later`: portable, but needs schema/service cleanup first. -- `special`: SSE, websocket, streaming, or UI bridge behavior that likely needs raw Effect HTTP rather than `HttpApi`. - -### 3. Finish JSON Route Parity - -Port remaining JSON routes in small batches. - -Good near-term candidates: - -- top-level reads: `GET /path`, `GET /vcs`, `GET /vcs/diff`, `GET /command`, `GET /agent`, `GET /skill`, `GET /lsp`, `GET /formatter` -- simple mutations: `POST /instance/dispose` -- experimental JSON reads: console, tool, worktree list, resource list -- deferred JSON mutations: workspace/worktree create/remove/reset, file search, MCP auth flows - -Keep large or stateful groups for later: - -- `session` -- `sync` -- process-level experimental routes - -### 4. Move OpenAPI And SDK Generation - -Hono routes cannot be deleted while `hono-openapi` is the source of SDK generation. - -Status: the Effect `HttpApi` OpenAPI surface is **implemented and opt-in** (`bun dev generate --httpapi`, `OPENCODE_SDK_OPENAPI=httpapi`). Default SDK generation still uses Hono. `httpapi/public.ts` applies the Hono-compat normalization layer to the Effect output. Diff against the Hono-generated spec still shows real gaps that must be closed before the SDK can flip: - -- Branded-type `pattern` constraints on ID schemas are not propagated to the Effect output (~169 missing). -- Per-property `description` annotations are not propagated through `Schema.Struct` to the Effect output (~107 missing). -- `Event.*` and `SyncEvent.*` component names use dotted form in Hono and PascalCase in Effect (~50 differences, breaks SDK type names). -- Effect's component deduper emits numbered duplicates (`Session9`, `SyncEvent.session.updated.11`) that need a name-collision fix. -- Cosmetic-only diffs (`additionalProperties: false`, `const` vs `enum`, MAX_SAFE_INTEGER `maximum`, `propertyNames`) can be normalized in `public.ts` if they would otherwise change SDK output. - -Required before route deletion: - -- Close the diff above so Effect-generated SDK output matches the Hono-generated SDK output for every retained path. -- Keep operation IDs, schemas, status codes, and SDK type names stable unless the change is intentional. -- Flip `packages/sdk/js/script/build.ts` default to `httpapi` and regenerate. -- Compare generated SDK output against `dev` for every route group deletion. -- Remove Hono OpenAPI stubs only after Effect OpenAPI is the SDK source for those paths. - -V2 cleanup once SDK compatibility no longer needs the legacy Hono contract: - -- Remove `public.ts` compatibility transforms that hide honest `HttpApi` metadata, including auth `securitySchemes`, per-route `security`, and generated `401` responses. -- Stop remapping built-in `HttpApi` error schemas back to legacy Hono `BadRequestError` / `NotFoundError` components if V2 clients can consume the actual Effect error shape. -- Prefer the direct `HttpApi` OpenAPI output for request/response bodies and named component schemas instead of rewriting it to match Hono generator quirks. -- Keep schema fixes that describe the actual wire format, but delete transforms that only preserve legacy SDK type names or inline-vs-ref shape. -- Re-evaluate `auth_token` as an OpenAPI security scheme rather than a hand-injected query parameter once clients can consume the V2 spec. - -### 5. Make HttpApi Default For JSON Routes - -After JSON parity and SDK generation are covered: - -- Flip the bridge default for ported JSON routes. -- Keep a short-lived fallback flag for the old Hono implementation. -- Run the same tests against both the default and fallback path during rollout. -- Stop adding new Hono handlers for JSON routes once the default flips. - -### 6. Delete Hono Route Implementations - -Delete Hono routes group-by-group after each group meets the deletion criteria. - -Deletion criteria: - -- `HttpApi` route is mounted by default. -- Behavior is covered by bridge-level tests. -- OpenAPI/SDK generation comes from Effect for that path. -- SDK diff is zero or explicitly accepted. -- Legacy Hono route is no longer needed as a fallback. - -After deleting a group: - -- Remove its Hono route file or dead endpoints. -- Remove its `.route(...)` registration from `instance/index.ts`. -- Remove duplicate Zod-only route DTOs if Effect Schema now owns the type. -- Regenerate SDK and verify output. - -### 7. Replace Special Routes - -Special routes need explicit designs before Hono can disappear completely. - -- `event`: SSE -- `pty`: websocket -- `tui`: UI/control bridge behavior -- streaming `session` endpoints - -Use raw Effect HTTP routes where `HttpApi` does not fit. The goal is deleting Hono implementations, not forcing every transport shape through `HttpApi`. - -## Current Route Status - -| Area | Status | Notes | -| ------------------------- | ----------------- | -------------------------------------------------------------------------- | -| `question` | `bridged` | `GET /question`, reply, reject | -| `permission` | `bridged` | list and reply | -| `provider` | `bridged` | list, auth, OAuth authorize/callback | -| `config` | `bridged` | read, providers, update | -| `project` | `bridged` | list, current, git init, update | -| `file` | `bridged` partial | find text/file/symbol, list/content/status | -| `mcp` | `bridged` | status, add, OAuth, connect/disconnect | -| `workspace` | `bridged` | adapter/list/status/create/remove/session-restore | -| top-level instance routes | `bridged` | path, vcs, command, agent, skill, lsp, formatter, dispose | -| experimental JSON routes | `bridged` | console, tool, worktree list/mutations, global session list, resource list | -| `session` | `bridged` | read, lifecycle, prompt, message/part mutations, revert, permission reply | -| `sync` | `bridged` | start/replay/history | -| `event` | `bridged` | SSE via raw Effect HTTP | -| `pty` | `special` | websocket | -| `tui` | `special` | UI bridge | - -## Full Route Checklist - -This checklist tracks bridge parity only. Checked routes are available through the experimental `HttpApi` bridge; Hono deletion is tracked separately by the deletion checklist above. - -### Top-Level Instance Routes - -- [x] `POST /instance/dispose` - dispose active instance after response. -- [x] `GET /path` - current directory and worktree paths. -- [x] `GET /vcs` - current VCS status. -- [x] `GET /vcs/diff` - VCS diff summary. -- [x] `GET /command` - command catalog. -- [x] `GET /agent` - agent catalog. -- [x] `GET /skill` - skill catalog. -- [x] `GET /lsp` - LSP status. -- [x] `GET /formatter` - formatter status. - -### Config Routes - -- [x] `GET /config` - read config. -- [x] `PATCH /config` - update config and dispose active instance after response. -- [x] `GET /config/providers` - config provider summary. - -### Project Routes - -- [x] `GET /project` - list projects. -- [x] `GET /project/current` - current project. -- [x] `POST /project/git/init` - initialize git and reload active instance after response. -- [x] `PATCH /project/:projectID` - update project metadata. - -### Provider Routes - -- [x] `GET /provider` - list providers. -- [x] `GET /provider/auth` - list provider auth methods. -- [x] `POST /provider/:providerID/oauth/authorize` - start provider OAuth. -- [x] `POST /provider/:providerID/oauth/callback` - finish provider OAuth. - -### Question Routes - -- [x] `GET /question` - list questions. -- [x] `POST /question/:requestID/reply` - reply to question. -- [x] `POST /question/:requestID/reject` - reject question. - -### Permission Routes - -- [x] `GET /permission` - list permission requests. -- [x] `POST /permission/:requestID/reply` - reply to permission request. - -### File Routes - -- [x] `GET /find` - text search. -- [x] `GET /find/file` - file search. -- [x] `GET /find/symbol` - symbol search. -- [x] `GET /file` - list directory entries. -- [x] `GET /file/content` - read file content. -- [x] `GET /file/status` - file status. - -### MCP Routes - -- [x] `GET /mcp` - MCP status. -- [x] `POST /mcp` - add MCP server at runtime. -- [x] `POST /mcp/:name/auth` - start MCP OAuth. -- [x] `POST /mcp/:name/auth/callback` - finish MCP OAuth callback. -- [x] `POST /mcp/:name/auth/authenticate` - run MCP OAuth authenticate flow. -- [x] `DELETE /mcp/:name/auth` - remove MCP OAuth credentials. -- [x] `POST /mcp/:name/connect` - connect MCP server. -- [x] `POST /mcp/:name/disconnect` - disconnect MCP server. - -### Experimental Routes - -- [x] `GET /experimental/console` - active Console provider metadata. -- [x] `GET /experimental/console/orgs` - switchable Console orgs. -- [x] `POST /experimental/console/switch` - switch active Console org. -- [x] `GET /experimental/tool/ids` - tool IDs. -- [x] `GET /experimental/tool` - tools for provider/model. -- [x] `GET /experimental/worktree` - list worktrees. -- [x] `POST /experimental/worktree` - create worktree. -- [x] `DELETE /experimental/worktree` - remove worktree. -- [x] `POST /experimental/worktree/reset` - reset worktree. -- [x] `GET /experimental/session` - global session list. -- [x] `GET /experimental/resource` - MCP resources. - -### Workspace Routes - -- [x] `GET /experimental/workspace/adapter` - list workspace adapters. -- [x] `POST /experimental/workspace` - create workspace. -- [x] `GET /experimental/workspace` - list workspaces. -- [x] `GET /experimental/workspace/status` - workspace status. -- [x] `DELETE /experimental/workspace/:id` - remove workspace. -- [x] `POST /experimental/workspace/:id/session-restore` - restore session into workspace. - -### Sync Routes - -- [x] `POST /sync/start` - start workspace sync. -- [x] `POST /sync/replay` - replay sync events. -- [x] `POST /sync/history` - list sync event history. - -### Session Routes - -- [x] `GET /session` - list sessions. -- [x] `GET /session/status` - session status map. -- [x] `GET /session/:sessionID` - get session. -- [x] `GET /session/:sessionID/children` - get child sessions. -- [x] `GET /session/:sessionID/todo` - get session todos. -- [x] `POST /session` - create session. -- [x] `DELETE /session/:sessionID` - delete session. -- [x] `PATCH /session/:sessionID` - update session metadata. -- [x] `POST /session/:sessionID/init` - run project init command. -- [x] `POST /session/:sessionID/fork` - fork session. -- [x] `POST /session/:sessionID/abort` - abort session. -- [x] `POST /session/:sessionID/share` - share session. -- [x] `GET /session/:sessionID/diff` - session diff. -- [x] `DELETE /session/:sessionID/share` - unshare session. -- [x] `POST /session/:sessionID/summarize` - summarize session. -- [x] `GET /session/:sessionID/message` - list session messages. -- [x] `GET /session/:sessionID/message/:messageID` - get message. -- [x] `DELETE /session/:sessionID/message/:messageID` - delete message. -- [x] `DELETE /session/:sessionID/message/:messageID/part/:partID` - delete part. -- [x] `PATCH /session/:sessionID/message/:messageID/part/:partID` - update part. -- [x] `POST /session/:sessionID/message` - prompt with streaming response. -- [x] `POST /session/:sessionID/prompt_async` - async prompt. -- [x] `POST /session/:sessionID/command` - run command. -- [x] `POST /session/:sessionID/shell` - run shell command. -- [x] `POST /session/:sessionID/revert` - revert message. -- [x] `POST /session/:sessionID/unrevert` - restore reverted messages. -- [x] `POST /session/:sessionID/permissions/:permissionID` - deprecated permission response route. - -### Event Routes - -- [x] `GET /event` - SSE event stream via raw Effect HTTP. - -### PTY Routes - -- [x] `GET /pty` - list PTY sessions. -- [x] `POST /pty` - create PTY session. -- [x] `GET /pty/:ptyID` - get PTY session. -- [x] `PUT /pty/:ptyID` - update PTY session. -- [x] `DELETE /pty/:ptyID` - remove PTY session. -- [x] `GET /pty/:ptyID/connect` - PTY websocket; replace with raw Effect HTTP/websocket support. - -### TUI Routes - -- [x] `POST /tui/append-prompt` - append prompt. -- [x] `POST /tui/open-help` - open help. -- [x] `POST /tui/open-sessions` - open sessions. -- [x] `POST /tui/open-themes` - open themes. -- [x] `POST /tui/open-models` - open models. -- [x] `POST /tui/submit-prompt` - submit prompt. -- [x] `POST /tui/clear-prompt` - clear prompt. -- [x] `POST /tui/execute-command` - execute command. -- [x] `POST /tui/show-toast` - show toast. -- [x] `POST /tui/publish` - publish TUI event. -- [x] `POST /tui/select-session` - select session. -- [x] `GET /tui/control/next` - get next TUI request. -- [x] `POST /tui/control/response` - submit TUI control response. - -## Remaining PR Plan - -Prefer smaller PRs from here so route behavior and SDK/OpenAPI fallout stays reviewable. - -1. [x] Bridge `PATCH /project/:projectID`. -2. [x] Bridge MCP add/connect/disconnect routes. -3. [x] Bridge MCP OAuth routes: start, callback, authenticate, remove. -4. [x] Bridge experimental console switch and tool list routes. -5. [x] Bridge experimental global session list. -6. [x] Bridge workspace create/remove/session-restore routes. -7. [x] Bridge sync start/replay/history routes. -8. [x] Bridge session read routes: list, status, get, children, todo, diff, messages. -9. [x] Bridge session lifecycle mutation routes: create, delete, update, fork, abort. -10. [x] Bridge remaining session mutation and prompt routes. -11. [ ] Replace event SSE with non-Hono Effect HTTP. The Effect backend has a raw Effect HTTP `httpapi/event.ts`; the Hono backend still uses `hono/streaming` `streamSSE`. Either port Hono `/event` to raw Effect HTTP for the fallback window, or skip and delete it together with Hono in step 15. -12. [x] Replace pty websocket/control routes with non-Hono Effect HTTP for the Effect backend. Hono `pty.ts` remains in the Hono backend. -13. [x] Replace tui bridge routes or explicitly isolate them behind a non-Hono compatibility layer for the Effect backend. Hono `tui.ts` remains in the Hono backend. -14. [ ] Switch OpenAPI/SDK generation to Effect routes and compare SDK output. Effect path is implemented and opt-in via `--httpapi` / `OPENCODE_SDK_OPENAPI=httpapi`. Close the schema-shape gaps in `public.ts` (branded `pattern`, per-property `description`, `Event.*` / `SyncEvent.*` naming, dedup collisions), then flip `packages/sdk/js/script/build.ts` default. -15. [ ] Flip `backend.ts` default from `hono` to `effect-httpapi`, keep `OPENCODE_EXPERIMENTAL_HTTPAPI` (or its inverse) as a short fallback flag, then delete replaced Hono route files. - -## Checklist - -- [x] Add first `HttpApi` JSON route slices. -- [x] Bridge selected `HttpApi` routes behind `OPENCODE_EXPERIMENTAL_HTTPAPI`. (Now backend-fork-at-startup rather than in-Hono path mounting.) -- [x] Reuse existing Effect services in handlers. -- [x] Provide auth, instance lookup, and observability in the Effect route layer. -- [x] Centralize auth via Effect `Config` for the Effect backend. -- [x] Support `auth_token` as a query security scheme. -- [x] Add bridge-level auth and instance tests. -- [x] Complete exact Hono route inventory. -- [x] Resolve implemented-but-unmounted route groups. -- [x] Port remaining top-level JSON reads. -- [x] Implement Effect `HttpApi` OpenAPI generation behind `--httpapi` / `OPENCODE_SDK_OPENAPI=httpapi`. -- [ ] Close Effect-vs-Hono OpenAPI schema-shape gaps and flip the SDK generator default. -- [ ] Flip the runtime backend default from `hono` to `effect-httpapi`, with a short fallback flag. -- [ ] Delete replaced Hono route implementations. -- [ ] Replace SSE/websocket/streaming Hono routes with non-Hono implementations (or remove with the rest of Hono). diff --git a/packages/opencode/specs/effect/migration.md b/packages/opencode/specs/effect/migration.md index 947eef5a15..01af9da6ce 100644 --- a/packages/opencode/specs/effect/migration.md +++ b/packages/opencode/specs/effect/migration.md @@ -59,10 +59,10 @@ Rules: ## Schema → Zod interop -When a service uses Effect Schema internally but needs Zod schemas for the HTTP layer, derive Zod from Schema using the `zod()` helper from `@/util/effect-zod`: +When a service uses Effect Schema internally but needs Zod schemas for the HTTP layer, derive Zod from Schema using the `zod()` helper from `@opencode-ai/core/effect-zod`: ```ts -import { zod } from "@/util/effect-zod" +import { zod } from "@opencode-ai/core/effect-zod" export const ZodInfo = zod(Info) // derives z.ZodType from Schema.Union ``` diff --git a/packages/opencode/specs/effect/schema.md b/packages/opencode/specs/effect/schema.md index e755457e61..e20605c3bc 100644 --- a/packages/opencode/specs/effect/schema.md +++ b/packages/opencode/specs/effect/schema.md @@ -8,7 +8,7 @@ Zod-first definitions to Effect Schema with Zod compatibility shims. Use Effect Schema as the source of truth for domain models, IDs, inputs, outputs, and typed errors. Keep Zod available at existing HTTP, tool, and compatibility boundaries by exposing a `.zod` static derived from the Effect -schema via `@/util/effect-zod`. +schema via `@opencode-ai/core/effect-zod`. The long-term driver is `specs/effect/http-api.md` — once the HTTP server moves to `@effect/platform`, every Schema-first DTO can flow through @@ -97,7 +97,7 @@ creating a parallel schema source of truth. ## Escape hatches -The walker in `@/util/effect-zod` exposes two explicit escape hatches for +The walker in `@opencode-ai/core/effect-zod` exposes two explicit escape hatches for cases the pure-Schema path cannot express. Each one stays in the codebase only as long as its upstream or local dependency requires it — inline comments document when each can be deleted. @@ -389,7 +389,7 @@ piecewise. ## Notes -- Use `@/util/effect-zod` for all Schema → Zod conversion. +- Use `@opencode-ai/core/effect-zod` for all Schema → Zod conversion. - Prefer one canonical schema definition. Avoid maintaining parallel Zod and Effect definitions for the same domain type. - Keep the migration incremental. Converting the domain model first is more diff --git a/packages/opencode/specs/openapi-translation-cleanup.md b/packages/opencode/specs/openapi-translation-cleanup.md new file mode 100644 index 0000000000..255c09644f --- /dev/null +++ b/packages/opencode/specs/openapi-translation-cleanup.md @@ -0,0 +1,204 @@ +# OpenAPI Translation Cleanup Plan + +## Goal + +Trim `packages/opencode/src/server/routes/instance/httpapi/public.ts` until OpenAPI generation is mostly a direct projection of the `HttpApi` route declarations, without breaking the generated SDK surface. + +The main failure mode to eliminate is spec-only behavior: anything that appears in `/doc` or the SDK but is not accepted by runtime `HttpApi` validation. + +## Current Culprit + +`public.ts` exports `PublicApi` with a large `OpenApi.annotations({ transform })` hook. That hook rewrites the generated spec for legacy SDK compatibility. + +The highest-risk rewrite is `InstanceQueryParameters`, which injected `directory` and `workspace` into every instance route in OpenAPI even when the runtime query schema did not accept them. This caused the SDK and `/doc` to advertise calls that could fail with `400` at runtime. + +## Non-Negotiables + +- Do not break the generated JavaScript SDK without an explicit versioned migration plan. +- Runtime route schemas are the source of truth for accepted params, payloads, and responses. +- `/doc`, generated SDK types, and runtime validation must agree for every endpoint. +- Prefer endpoint or schema annotations over post-generation spec surgery. +- Remove one category of rewrite at a time, with focused compatibility checks. + +## PR Checklist + +Status legend: `[x]` done locally, `[~]` in progress locally, `[ ]` not started. + +Current combined PR scope: + +- `[x]` PR 1 drift tests: added OpenAPI/runtime query assertions and a negative fixture in `test/server/httpapi-query-schema-drift.test.ts`. +- `[x]` PR 2 injection removal: removed broad `directory` / `workspace` post-generation injection from `public.ts` and replaced it with explicit runtime query schemas on affected routes. +- `[ ]` PR 3+ cleanup: leave query override, path pattern, error shape, auth, and component-shape rewrites for later PRs. + +### PR 1: Add OpenAPI/Runtime Query Drift Tests + +- `[x]` Add or extend `packages/opencode/test/server/httpapi-query-schema-drift.test.ts`. +- `[x]` Import `OpenApi.fromApi` and `PublicApi`. +- `[x]` Generate the public spec in-process with `OpenApi.fromApi(PublicApi)`. +- `[x]` Add a route inventory for the existing runtime reproducers: `session`, `file`, `experimental`, and `instance` routes. +- `[x]` For each inventory entry, assert every OpenAPI query parameter is declared by the runtime query schema. +- `[x]` Add a negative regression fixture that fails on spec-only `directory` / `workspace` params. +- `[x]` Keep this part test-only. + +Verification: + +- `[x]` `bun test --timeout 5000 test/server/httpapi-query-schema-drift.test.ts` from `packages/opencode`. +- `[x]` `bun typecheck` from `packages/opencode`. + +### PR 2: Delete Spec-Only Workspace Query Injection + +- `[x]` Edit `packages/opencode/src/server/routes/instance/httpapi/public.ts`. +- `[x]` Delete `InstanceQueryParameters`. +- `[x]` Delete the `isInstanceRoute` constant. +- `[x]` Delete the branch that prepends `directory` and `workspace` to every instance operation. +- `[x]` Keep `normalizeParameter(param, route)` for parameters that are actually produced by `HttpApi`. +- `[x]` Add `WorkspaceRoutingQuery` / `WorkspaceRoutingQueryFields` to runtime query schemas for affected routes. +- `[x]` Regenerate SDK and inspect diff. Result: no `directory` / `workspace` request-param removals; generated SDK diff is declaration ordering only. + +Notes: + +- Added `WorkspaceRoutingQuery` in `middleware/workspace-routing.ts` as the canonical runtime schema for middleware-consumed query params. +- Replaced v2 union-query schemas with plain struct query schemas so `OpenApi.fromApi` emits their query params directly. This intentionally exposes the beta `/api/session` pagination/filter params in the SDK; cursor mutual-exclusion rules now live in the handlers, while `directory` / `workspace` remain allowed with cursors for routing. + +Expected code shape: + +```ts +for (const param of operation.parameters ?? []) normalizeParameter(param, `${method.toUpperCase()} ${path}`) +``` + +Verification: + +- `[x]` `bun test --timeout 5000 test/server/httpapi-query-schema-drift.test.ts` from `packages/opencode`. +- `[x]` `bun dev generate > /tmp/opencode-openapi.json` from `packages/opencode`. +- `[x]` `./packages/sdk/js/script/build.ts` from repo root. +- `[x]` Inspect SDK diff for removed `directory` / `workspace` params. Result: none after explicit runtime schemas; v2 list/message now also expose their existing beta pagination/filter query params in the SDK. +- `[x]` `bun typecheck` from `packages/opencode`. + +### PR 3: Replace Broad Query Type Override Sets With Route-Level Helpers + +- Edit `packages/opencode/src/server/routes/instance/httpapi/public.ts`. +- Remove broad name-based assumptions from `QueryNumberParameters` and `QueryBooleanParameters` one field at a time. +- Add shared query schema helpers near route group code if needed, for example in `groups/metadata.ts` or a new `groups/query.ts`. +- Prefer route declarations like `Schema.NumberFromString.check(...)` and boolean string decoders like the existing `QueryBoolean` in `groups/session.ts`. +- Keep only route-specific `QueryParameterSchemas` entries when SDK compatibility requires a public encoded type that Effect OpenAPI cannot emit yet. + +Concrete first targets: + +- `[x]` Consolidate `roots` / `archived` onto an explicit shared route schema helper. Keep `QueryBooleanParameters` until route-level schema metadata can preserve the SDK's `boolean | "true" | "false"` call shape without a global transform. +- `[x]` Replace broad `QueryNumberParameters` reliance for `start` / `cursor` / `limit` with route-specific SDK compatibility schemas. Keep improving route-level constraints where behavior is intentionally stricter. +- Keep `GET /find/file limit`, `GET /session/{sessionID}/diff messageID`, and `GET /session/{sessionID}/message limit` overrides until their route schemas generate identical SDK types directly. + +Verification: + +- Focused HTTP tests for changed query fields. +- `bun dev generate > /tmp/opencode-openapi.json` from `packages/opencode`. +- `./packages/sdk/js/script/build.ts` from repo root. +- Inspect generated SDK request param types before deleting each override. +- `bun typecheck` from `packages/opencode`. + +### PR 4: Move Path Parameter Patterns Into ID Schemas + +- Audit `PathParameterSchemas` and `pathParameterSchema()` in `public.ts`. +- Check source schemas in files like `packages/opencode/src/session/schema.ts`, `packages/opencode/src/permission/schema.ts`, and pty schema definitions. +- Add or fix `ZodOverride` / OpenAPI-compatible annotations on branded ID schemas so generated path params include the same patterns without `public.ts` overrides. +- Delete one path override only after generated OpenAPI is unchanged for that param. + +Concrete first targets: + +- `[x]` `sessionID` +- `[x]` `messageID` +- `[x]` `partID` +- `[x]` `permissionID` +- `[x]` `ptyID` + +- `[x]` Remove ambiguous workspace `id` path overrides once the endpoint source schema emits the `wrk` pattern. + +Verification: + +- `bun dev generate > /tmp/opencode-openapi.json` from `packages/opencode`. +- `./packages/sdk/js/script/build.ts` from repo root. +- Inspect generated path param types and patterns. +- `bun typecheck` from `packages/opencode`. + +### PR 5: Replace Built-In Error Rewrites With Declared API Errors + +- Edit route group files under `packages/opencode/src/server/routes/instance/httpapi/groups/`. +- Replace SDK-visible `HttpApiError.BadRequest` / `HttpApiError.NotFound` with explicit error schemas from `packages/opencode/src/server/routes/instance/httpapi/errors.ts` or add new ones there. +- Update handlers to fail with the declared API errors at the boundary. +- Remove matching cases from `normalizeLegacyErrorResponses()` only after generated OpenAPI remains SDK-compatible. +- Do this group by group, starting with one small route group. + +Concrete first targets: + +- `groups/config.ts` `PATCH /config` bad request. +- `groups/session.ts` endpoints that already translate domain not-found errors. +- `groups/file.ts` if any handler currently relies on built-in error shape. + +Verification: + +- Focused HTTP tests asserting response body shape for changed error paths. +- `bun dev generate > /tmp/opencode-openapi.json` from `packages/opencode`. +- `./packages/sdk/js/script/build.ts` from repo root. +- Inspect SDK error union diff. +- `bun typecheck` from `packages/opencode`. + +### PR 6: Remove Auth/Security Spec Rewrites If SDK Can Tolerate It + +- Audit `delete operation.security`, `delete operation.responses?.["401"]`, and `delete spec.components?.securitySchemes` in `public.ts`. +- Decide whether SDK should expose auth in generated operation metadata. +- If preserving no-auth SDK surface is required, leave this rewrite and document it as intentional compatibility code. +- If removing it, update SDK generation expectations and docs in the same PR. + +Verification: + +- `./packages/sdk/js/script/build.ts` from repo root. +- Inspect generated client call signatures and error unions. +- Do not merge if auth churn changes normal SDK call ergonomics unintentionally. + +### PR 7: Tackle Component Shape Rewrites One At A Time + +- Audit these in `public.ts`: `normalizeComponentNames`, `collapseDuplicateComponents`, `applyLegacySchemaOverrides`, `normalizeComponentDescriptions`, `stripOptionalNull`, `fixSelfReferencingComponents`. +- For each rewrite, make a tiny PR that removes or narrows only that rewrite. +- If generated SDK type names churn broadly, stop and either keep the rewrite or fix `effect-smol` generation first. + +Concrete first targets: + +- Delete cosmetic `normalizeComponentDescriptions` if SDK output does not change materially. +- Narrow `applyLegacySchemaOverrides` entries that correspond to schemas already fixed at the source. +- Keep `stripOptionalNull` until there is an explicit SDK migration plan, because it likely affects many optional fields. + +Verification: + +- `bun dev generate > /tmp/opencode-openapi.json` from `packages/opencode`. +- `./packages/sdk/js/script/build.ts` from repo root. +- Inspect generated SDK type-name and optionality diffs. + +## Upstream Middleware Query Support + +Long-term, `WorkspaceRoutingMiddleware` should declare the query fields it reads once, and `HttpApi` should use that declaration for both runtime validation and OpenAPI generation. + +Target in `effect-smol`: + +- Extend `HttpApiMiddleware.Service` config with optional query schema support, or add a dedicated middleware query annotation. +- Make runtime request decoding include middleware query schemas. +- Make `OpenApi.fromApi` emit middleware query params for endpoints using that middleware. + +Once available, remove `WorkspaceRoutingQueryFields` spreads from route groups and declare `directory` / `workspace` only on `WorkspaceRoutingMiddleware`. + +## Suggested PR Order + +1. Add drift detection tests only. +2. Remove `InstanceQueryParameters` spec injection; rely on `WorkspaceRoutingQueryFields` already present in runtime schemas. +3. Convert query type overrides into route/schema-level helpers where possible. +4. Convert path parameter overrides into schema annotations or upstream fixes. +5. Replace built-in error response rewrites with explicit declared API errors by route group. +6. Tackle component naming/nullability rewrites only after SDK compatibility snapshots are stable. + +## Verification Checklist Per PR + +- Focused HTTP tests for changed routes. +- OpenAPI drift tests. +- `bun dev generate > /tmp/opencode-openapi.json` from `packages/opencode`. +- `./packages/sdk/js/script/build.ts` from repo root. +- Inspect generated SDK diff for public API churn. +- `bun typecheck` from `packages/opencode`. diff --git a/packages/opencode/specs/tui-plugins.md b/packages/opencode/specs/tui-plugins.md index 73927dbf83..c1a9b271c1 100644 --- a/packages/opencode/specs/tui-plugins.md +++ b/packages/opencode/specs/tui-plugins.md @@ -20,6 +20,12 @@ Example: { "$schema": "https://opencode.ai/tui.json", "theme": "smoke-theme", + "leader_timeout": 2000, + "keybinds": { + "leader": "ctrl+x", + "command_list": "ctrl+p", + "session_new": "n" + }, "plugin": ["@acme/opencode-plugin@1.2.3", ["./plugins/demo.tsx", { "label": "demo" }]], "plugin_enabled": { "acme.demo": false @@ -39,6 +45,9 @@ Example: - Internal plugins can declare `enabled: false` to be registered but inactive by default; `plugin_enabled` and runtime KV can still enable them by id. - `plugin_enabled` is merged across config layers. - Runtime enable/disable state is also stored in KV under `plugin_enabled`; that KV state overrides config on startup. +- `leader_timeout` is a top-level TUI setting. +- `keybinds` is a flat object keyed by command id; values are key binding values (`false`, `"none"`, a key string/object, a binding object, or an array of key strings/objects/binding objects). +- `keybinds.leader` sets the key used by `` shortcuts. ## Author package shape @@ -228,14 +237,14 @@ Top-level API groups exposed to `tui(api, options, meta)`: - To surface a command in the host command palette, set `namespace: "palette"` and provide metadata such as `title`, `category`, `desc`, `suggested`, `hidden`, `enabled`, `slashName`, and `slashAliases` on the command. - Use `api.keymap.dispatchCommand(name)` for user-style execution semantics and `api.keymap.runCommand(name)` only for forced programmatic execution. - Disposers returned by `api.keymap` registrations and `acquireResource(...)` are automatically cleaned up when the plugin deactivates. You do not need to add those disposers to `api.lifecycle.onDispose(...)` yourself. -- Built-in which-key shortcuts are resolved from `keymap.sections.which_key`, not plugin options. +- Built-in which-key shortcuts are resolved from flat `keybinds` command ids such as `which_key_toggle`, not plugin options. ### Keys - `api.keys` exposes host-formatted shortcut display helpers for plugin UI. - `formatSequence(parts)` formats parsed key sequence parts using the host's display policy. - `formatBindings(bindings)` formats binding lists and returns `undefined` when there is nothing to show. -- For generic config-to-bindings helpers, import `resolveBindingSections` from `@opencode-ai/plugin/tui`. +- For generic config-to-bindings helpers, import `createBindingLookup` from `@opencode-ai/plugin/tui`. ### Routes diff --git a/packages/opencode/specs/v2/keymappings.md b/packages/opencode/specs/v2/keymappings.md deleted file mode 100644 index 30a298eee4..0000000000 --- a/packages/opencode/specs/v2/keymappings.md +++ /dev/null @@ -1,36 +0,0 @@ -# Keybindings vs. Keymappings - -Make it `keymappings`, closer to neovim. Can be layered like `abc`. Commands don't define their binding, but have an id that a key can be mapped to like - -```ts -{ key: "ctrl+w", cmd: string | function, description } -``` - -_Why_ -Currently its keybindings that have an `id` like `message_redo` and then a command can use that or define it's own binding. While some keybindings are just used with `.match` in arbitrary key handlers and there is no info what the key is used for, except the binding id maybe. It also is unknown in which context/scope what binding is active, so a plugin like `which-key` is nearly impossible to get right. - -## OpenTUI Keymap Migration - -The v2 TUI uses `@opentui/keymap` as the key/cmd engine. The remaining legacy compatibility is config-only and exists to migrate users from `keybinds` to `keymap`: - -- `packages/opencode/src/config/keybinds.ts`: old `keybinds` schema, defaults, and legacy key names. -- `packages/opencode/src/cli/cmd/tui/config/legacy-keymap-transform.ts`: transforms parsed legacy `keybinds` into OpenTUI `keymap` sections. -- `packages/opencode/src/cli/cmd/tui/config/tui-migrate.ts`: migrates legacy TUI keys from `opencode.json` into `tui.json`, including `theme`, `keybinds`, and nested `tui`. -- `packages/opencode/src/cli/cmd/tui/config/tui-schema.ts`: still accepts deprecated `keybinds` via `KeybindOverride` and marks it as deprecated. This file also contains the new `keymap` config schema. -- `packages/opencode/src/cli/cmd/tui/config/tui.ts`: parses legacy `keybinds`, applies the Windows `terminal_suspend`/`input_undo` adjustment, and uses `LegacyKeymapTransform.create(...)` as the fallback when no `keymap` section is configured. -- `packages/plugin/src/tui.ts`: plugin-facing `tuiConfig` still includes `keybinds` through `PluginConfig`; this should be removed when the public plugin API no longer exposes legacy config. - -The transform must stay while users are migrating. It lets users upgrade without first rewriting their existing `keybinds` config. If `keymap` is configured, `keybinds` are ignored for keymap resolution. If `keymap` is missing, `legacy-keymap-transform.ts` turns legacy `keybinds` into the resolved `keymap` consumed by OpenTUI. - -## Removing Legacy Later - -When switching fully to the new config style, remove legacy support with these exact changes: - -- Delete `packages/opencode/src/config/keybinds.ts`. -- Delete `packages/opencode/src/cli/cmd/tui/config/legacy-keymap-transform.ts`. -- Delete `packages/opencode/src/cli/cmd/tui/config/tui-migrate.ts`. -- In `packages/opencode/src/cli/cmd/tui/config/tui-schema.ts`, remove the `ConfigKeybinds` import, remove `KeybindOverride`, and delete the deprecated `keybinds` field from `TuiInfo`. -- In `packages/opencode/src/cli/cmd/tui/config/tui.ts`, remove `migrateTuiConfig(...)`, remove `ConfigKeybinds`, remove the Windows legacy keybind adjustment, remove `LegacyKeymapTransform.create(...)`, and require/default `keymap` through the new config path instead. -- In `packages/opencode/src/cli/cmd/tui/config/tui.ts`, remove `keybinds` from `Resolved`; resolved TUI config should expose `keymap` only. -- In `packages/plugin/src/tui.ts`, remove `keybinds` from plugin-facing `TuiConfigView`. -- Remove or rewrite tests that write or assert `keybinds`, especially in `packages/opencode/test/config/tui.test.ts`, `packages/opencode/test/fixture/tui-runtime.ts`, and TUI plugin loader tests. diff --git a/packages/opencode/specs/v2/tui-command-shim.md b/packages/opencode/specs/v2/tui-command-shim.md new file mode 100644 index 0000000000..5afade2a96 --- /dev/null +++ b/packages/opencode/specs/v2/tui-command-shim.md @@ -0,0 +1,67 @@ +# TUI Command Shim Removal + +Problem: + +- v1 keeps a deprecated `api.command` TUI plugin shim so older plugins do not fail during initialization +- v2 should expose only the keymap command API +- tests and fixtures should not encode legacy command behavior as expected behavior + +## Remove Public Types + +In `packages/plugin/src/tui.ts`, remove: + +- `TuiCommand` +- `TuiCommandApi` +- `TuiPluginApi.command` + +Keep `api.keymap` as the only TUI command registration and execution surface. + +## Remove Runtime Shim + +Delete `packages/opencode/src/cli/cmd/tui/plugin/command-shim.ts`. + +In `packages/opencode/src/cli/cmd/tui/plugin/api.tsx`, remove: + +- the `createCommandShim` import +- the `command: createCommandShim(...)` field from `createTuiApi(...)` + +In `packages/opencode/src/cli/cmd/tui/plugin/runtime.ts`, remove: + +- the `createCommandShim` import +- the `command: createCommandShim(...)` field from `pluginApi(...)` + +## Migration Target + +Plugin authors should replace old calls with keymap calls: + +```ts +api.keymap.registerLayer({ + commands: [ + { + name: "plugin.command", + title: "Plugin Command", + namespace: "palette", + slashName: "plugin", + run() { + api.ui.dialog.clear() + }, + }, + ], + bindings: [{ key: "ctrl+shift+p", cmd: "plugin.command" }], +}) +``` + +Direct replacements: + +- `api.command.register(cb)` -> `api.keymap.registerLayer({ commands, bindings })` +- `api.command.trigger(name)` -> `api.keymap.dispatchCommand(name)` +- `api.command.show()` -> `api.keymap.dispatchCommand("command.palette.show")` +- `onSelect(dialog)` -> use `api.ui.dialog` from the plugin API closure + +## Verification + +After removal, run from package directories: + +- `bun typecheck` in `packages/plugin` +- `bun typecheck` in `packages/opencode` +- TUI plugin loader tests in `packages/opencode` if runtime plugin API wiring changed diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts index ad930680d1..867b830cf2 100644 --- a/packages/opencode/src/acp/agent.ts +++ b/packages/opencode/src/acp/agent.ts @@ -347,33 +347,7 @@ export class Agent implements ACPAgent { this.toolStarts.delete(part.callID) this.shellSnapshots.delete(part.callID) const kind = toToolKind(part.tool) - const content: ToolCallContent[] = [ - { - type: "content", - content: { - type: "text", - text: part.state.output, - }, - }, - ] - - if (kind === "edit") { - const input = part.state.input - const filePath = typeof input["filePath"] === "string" ? input["filePath"] : "" - const oldText = typeof input["oldString"] === "string" ? input["oldString"] : "" - const newText = - typeof input["newString"] === "string" - ? input["newString"] - : typeof input["content"] === "string" - ? input["content"] - : "" - content.push({ - type: "diff", - path: filePath, - oldText, - newText, - }) - } + const content = completedToolContent(part, kind) if (part.tool === "todowrite") { const parsedTodos = decodeTodos(part.state.output) @@ -413,10 +387,7 @@ export class Agent implements ACPAgent { content, title: part.state.title, rawInput: part.state.input, - rawOutput: { - output: part.state.output, - metadata: part.state.metadata, - }, + rawOutput: completedToolRawOutput(part), }, }) .catch((error) => { @@ -860,33 +831,7 @@ export class Agent implements ACPAgent { this.toolStarts.delete(part.callID) this.shellSnapshots.delete(part.callID) const kind = toToolKind(part.tool) - const content: ToolCallContent[] = [ - { - type: "content", - content: { - type: "text", - text: part.state.output, - }, - }, - ] - - if (kind === "edit") { - const input = part.state.input - const filePath = typeof input["filePath"] === "string" ? input["filePath"] : "" - const oldText = typeof input["oldString"] === "string" ? input["oldString"] : "" - const newText = - typeof input["newString"] === "string" - ? input["newString"] - : typeof input["content"] === "string" - ? input["content"] - : "" - content.push({ - type: "diff", - path: filePath, - oldText, - newText, - }) - } + const content = completedToolContent(part, kind) if (part.tool === "todowrite") { const parsedTodos = decodeTodos(part.state.output) @@ -926,10 +871,7 @@ export class Agent implements ACPAgent { content, title: part.state.title, rawInput: part.state.input, - rawOutput: { - output: part.state.output, - metadata: part.state.metadata, - }, + rawOutput: completedToolRawOutput(part), }, }) .catch((err) => { @@ -1619,6 +1561,8 @@ function toToolKind(toolName: string): ToolKind { case "grep": case "glob": + case "repo_clone": + case "repo_overview": case "context7_resolve_library_id": case "context7_get_library_docs": return "search" @@ -1642,6 +1586,10 @@ function toLocations(toolName: string, input: Record): { path: stri case "glob": case "grep": return input["path"] ? [{ path: input["path"] }] : [] + case "repo_clone": + return input["path"] ? [{ path: input["path"] }] : [] + case "repo_overview": + return input["path"] ? [{ path: input["path"] }] : [] case ShellID.ToolID: return [] default: @@ -1649,6 +1597,70 @@ function toLocations(toolName: string, input: Record): { path: stri } } +function completedToolContent(part: ToolPart, kind: ToolKind): ToolCallContent[] { + if (part.state.status !== "completed") return [] + + const content: ToolCallContent[] = [ + { + type: "content", + content: { + type: "text", + text: part.state.output, + }, + }, + ] + + if (kind === "edit") { + const input = part.state.input + const filePath = typeof input["filePath"] === "string" ? input["filePath"] : "" + const oldText = typeof input["oldString"] === "string" ? input["oldString"] : "" + const newText = + typeof input["newString"] === "string" + ? input["newString"] + : typeof input["content"] === "string" + ? input["content"] + : "" + content.push({ + type: "diff", + path: filePath, + oldText, + newText, + }) + } + + content.push(...imageContents(part.state.attachments ?? [])) + return content +} + +function completedToolRawOutput(part: ToolPart) { + if (part.state.status !== "completed") return {} + return { + output: part.state.output, + metadata: part.state.metadata, + ...(part.state.attachments?.length ? { attachments: part.state.attachments } : {}), + } +} + +function imageContents(attachments: Array<{ mime: string; url: string }>): ToolCallContent[] { + return attachments.flatMap((attachment): ToolCallContent[] => { + const match = attachment.url.match(/^data:([^;,]+)(?:;[^,]*)*;base64,(.*)$/) + const mime = match?.[1] ?? attachment.mime + if (!mime.startsWith("image/")) return [] + const data = match?.[2] + if (data === undefined) return [] + return [ + { + type: "content" as const, + content: { + type: "image" as const, + mimeType: mime, + data, + }, + }, + ] + }) +} + async function defaultModel(config: ACPConfig, cwd?: string): Promise<{ providerID: ProviderID; modelID: ModelID }> { const sdk = config.sdk const configured = config.defaultModel diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index b38b0cc5dd..777f6e6d17 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -10,11 +10,13 @@ import { ProviderTransform } from "@/provider/transform" import PROMPT_GENERATE from "./generate.txt" import PROMPT_COMPACTION from "./prompt/compaction.txt" import PROMPT_EXPLORE from "./prompt/explore.txt" +import PROMPT_SCOUT from "./prompt/scout.txt" import PROMPT_SUMMARY from "./prompt/summary.txt" import PROMPT_TITLE from "./prompt/title.txt" import { Permission } from "@/permission" import { mergeDeep, pipe, sortBy, values } from "remeda" import { Global } from "@opencode-ai/core/global" +import { Flag } from "@opencode-ai/core/flag/flag" import path from "path" import { Plugin } from "@/plugin" import { Skill } from "../skill" @@ -22,8 +24,8 @@ import { Effect, Context, Layer, Schema } from "effect" import { InstanceState } from "@/effect/instance-state" import * as Option from "effect/Option" import * as OtelTracer from "@effect/opentelemetry/Tracer" -import { zod } from "@/util/effect-zod" -import { withStatics, type DeepMutable } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { withStatics, type DeepMutable } from "@opencode-ai/core/schema" export const Info = Schema.Struct({ name: Schema.String, @@ -86,6 +88,10 @@ export const layer = Layer.effect( path.join(Global.Path.tmp, "*"), ...skillDirs.map((dir) => path.join(dir, "*")), ] + const readonlyExternalDirectory = { + "*": "ask", + ...Object.fromEntries(whitelistedDirs.map((dir) => [dir, "allow"])), + } satisfies Record const defaults = Permission.fromConfig({ "*": "allow", @@ -97,6 +103,8 @@ export const layer = Layer.effect( question: "deny", plan_enter: "deny", plan_exit: "deny", + repo_clone: "deny", + repo_overview: "deny", // mirrors github.com/github/gitignore Node.gitignore pattern for .env files read: { "*": "allow", @@ -174,10 +182,7 @@ export const layer = Layer.effect( webfetch: "allow", websearch: "allow", read: "allow", - external_directory: { - "*": "ask", - ...Object.fromEntries(whitelistedDirs.map((dir) => [dir, "allow"])), - }, + external_directory: readonlyExternalDirectory, }), user, ), @@ -187,6 +192,37 @@ export const layer = Layer.effect( mode: "subagent", native: true, }, + ...(Flag.OPENCODE_EXPERIMENTAL_SCOUT + ? { + scout: { + name: "scout", + permission: Permission.merge( + defaults, + Permission.fromConfig({ + "*": "deny", + grep: "allow", + glob: "allow", + webfetch: "allow", + websearch: "allow", + codesearch: "allow", + read: "allow", + repo_clone: "allow", + repo_overview: "allow", + external_directory: { + ...readonlyExternalDirectory, + [path.join(Global.Path.repos, "*")]: "allow", + }, + }), + user, + ), + description: `Docs and dependency-source specialist. Use this when you need to inspect external documentation, clone dependency repositories into the managed cache, and research library implementation details without modifying the user's workspace.`, + prompt: PROMPT_SCOUT, + options: {}, + mode: "subagent" as const, + native: true, + }, + } + : {}), compaction: { name: "compaction", mode: "primary", diff --git a/packages/opencode/src/agent/prompt/scout.txt b/packages/opencode/src/agent/prompt/scout.txt new file mode 100644 index 0000000000..c315cc5a6b --- /dev/null +++ b/packages/opencode/src/agent/prompt/scout.txt @@ -0,0 +1,36 @@ +You are `scout`, a read-only research agent for external libraries, dependency source, and documentation. + +Your purpose is to investigate code outside the local workspace and return evidence-backed findings without modifying the user's workspace. + +Use this agent when asked to: +- inspect dependency repositories or library source +- compare local code against upstream implementations +- research public GitHub repositories the environment can clone +- explain how a library or framework works by reading its source and docs +- investigate third-party APIs, workflows, or behavior outside the current workspace + +Working style: +1. When the task involves a GitHub repository or dependency source, use `repo_clone` first. +2. After cloning, use `Glob`, `Grep`, and `Read` to inspect the cloned repository. +3. Use `WebFetch` for official documentation pages when source alone is not enough. +4. Prefer direct code and documentation evidence over assumptions. +5. If multiple external repositories are relevant, inspect each one before drawing conclusions. + +Research standards: +- cite exact absolute file paths and line references whenever possible +- separate what is verified from what is inferred +- if the answer depends on branch state, note that you are reading the repository's current default clone state unless the caller specifies otherwise +- if a repository cannot be cloned or accessed, say so explicitly and continue with whatever evidence is still available +- call out uncertainty clearly instead of smoothing over gaps + +Output expectations: +- start with the direct answer +- then explain the evidence repository by repository or source by source +- include file references when relevant +- keep the explanation organized and easy to scan + +Constraints: +- do not modify files or run tools that change the user's workspace +- return absolute file paths for cloned-repo findings in your final response + +Complete the user's research request efficiently and report your findings clearly. diff --git a/packages/opencode/src/agent/subagent-permissions.ts b/packages/opencode/src/agent/subagent-permissions.ts new file mode 100644 index 0000000000..1174ec31ad --- /dev/null +++ b/packages/opencode/src/agent/subagent-permissions.ts @@ -0,0 +1,33 @@ +import type { Permission } from "../permission" +import type { Agent } from "./agent" + +/** + * Build the `permission` ruleset for a subagent's session when it's spawned + * via the task tool. Combines: + * + * 1. The parent **agent's** deny rules — Plan Mode and other agent-level + * restrictions live on the agent ruleset, not on the session, so a + * subagent that only inherited the parent SESSION's permission would + * silently bypass them. (#26514) + * 2. The parent **session's** deny rules and external_directory rules — + * same forwarding the original code already did. + * 3. Default `todowrite` and `task` denies if the subagent's own ruleset + * doesn't already permit them. + */ +export function deriveSubagentSessionPermission(input: { + parentSessionPermission: Permission.Ruleset + parentAgent: Agent.Info | undefined + subagent: Agent.Info +}): Permission.Ruleset { + const canTask = input.subagent.permission.some((rule) => rule.permission === "task") + const canTodo = input.subagent.permission.some((rule) => rule.permission === "todowrite") + const parentAgentDenies = input.parentAgent?.permission.filter((rule) => rule.action === "deny") ?? [] + return [ + ...parentAgentDenies, + ...input.parentSessionPermission.filter( + (rule) => rule.permission === "external_directory" || rule.action === "deny", + ), + ...(canTodo ? [] : [{ permission: "todowrite" as const, pattern: "*" as const, action: "deny" as const }]), + ...(canTask ? [] : [{ permission: "task" as const, pattern: "*" as const, action: "deny" as const }]), + ] +} diff --git a/packages/opencode/src/audio.d.ts b/packages/opencode/src/audio.d.ts index 54a86efa30..c7c947450d 100644 --- a/packages/opencode/src/audio.d.ts +++ b/packages/opencode/src/audio.d.ts @@ -2,3 +2,8 @@ declare module "*.wav" { const file: string export default file } + +declare module "*.wasm" { + const file: string + export default file +} diff --git a/packages/opencode/src/auth/index.ts b/packages/opencode/src/auth/index.ts index 3d6a0d91d0..f7c6319357 100644 --- a/packages/opencode/src/auth/index.ts +++ b/packages/opencode/src/auth/index.ts @@ -1,7 +1,7 @@ import path from "path" import { Effect, Layer, Record, Result, Schema, Context } from "effect" -import { zod } from "@/util/effect-zod" -import { NonNegativeInt } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { NonNegativeInt } from "@opencode-ai/core/schema" import { Global } from "@opencode-ai/core/global" import { AppFileSystem } from "@opencode-ai/core/filesystem" diff --git a/packages/opencode/src/bus/bus-event.ts b/packages/opencode/src/bus/bus-event.ts index 3250c166ab..3533706318 100644 --- a/packages/opencode/src/bus/bus-event.ts +++ b/packages/opencode/src/bus/bus-event.ts @@ -1,6 +1,4 @@ -import z from "zod" import { Schema } from "effect" -import { zodObject } from "@/util/effect-zod" export type Definition = { type: Type @@ -18,23 +16,6 @@ export function define( return result } -export function payloads() { - return registry - .entries() - .map(([type, def]) => { - return z - .object({ - id: z.string(), - type: z.literal(type), - properties: zodObject(def.properties), - }) - .meta({ - ref: `Event.${def.type}`, - }) - }) - .toArray() -} - export function effectPayloads() { return registry .entries() diff --git a/packages/opencode/src/cli/cmd/export.ts b/packages/opencode/src/cli/cmd/export.ts index bf73ce941e..9eb1faffea 100644 --- a/packages/opencode/src/cli/cmd/export.ts +++ b/packages/opencode/src/cli/cmd/export.ts @@ -23,11 +23,11 @@ function span(id: string, value: { value: string; start: number; end: number }) } } -function diff(kind: string, diffs: { file: string; patch: string }[] | undefined) { +function diff(kind: string, diffs: { file?: string; patch?: string }[] | undefined) { return diffs?.map((item, i) => ({ ...item, - file: redact(`${kind}-file`, String(i), item.file), - patch: redact(`${kind}-patch`, String(i), item.patch), + file: item.file === undefined ? undefined : redact(`${kind}-file`, String(i), item.file), + patch: item.patch === undefined ? undefined : redact(`${kind}-patch`, String(i), item.patch), })) } diff --git a/packages/opencode/src/cli/cmd/generate.ts b/packages/opencode/src/cli/cmd/generate.ts index cb15b484e3..2555c3ad7b 100644 --- a/packages/opencode/src/cli/cmd/generate.ts +++ b/packages/opencode/src/cli/cmd/generate.ts @@ -1,28 +1,13 @@ import { Server } from "../../server/server" import type { CommandModule } from "yargs" -type Args = { - httpapi: boolean - hono: boolean -} +type Args = {} export const GenerateCommand = { command: "generate", - builder: (yargs) => - yargs - .option("httpapi", { - type: "boolean", - default: false, - description: - "Generate OpenAPI from the Effect HttpApi contract (default; flag retained for backwards compatibility)", - }) - .option("hono", { - type: "boolean", - default: false, - description: "Generate OpenAPI from the legacy Hono backend (parity-diff only; will be removed)", - }), - handler: async (args) => { - const specs = args.hono ? await Server.openapiHono() : await Server.openapi() + builder: (yargs) => yargs, + handler: async () => { + const specs = (await Server.openapi()) as { paths: Record> } for (const item of Object.values(specs.paths)) { for (const method of ["get", "post", "put", "delete", "patch"] as const) { const operation = item[method] diff --git a/packages/opencode/src/cli/cmd/github.ts b/packages/opencode/src/cli/cmd/github.ts index ea5b35ef78..a6754ec2df 100644 --- a/packages/opencode/src/cli/cmd/github.ts +++ b/packages/opencode/src/cli/cmd/github.ts @@ -32,6 +32,7 @@ import { SessionPrompt } from "@/session/prompt" import { Git } from "@/git" import { setTimeout as sleep } from "node:timers/promises" import { Process } from "@/util/process" +import { parseGitHubRemote } from "@/util/repository" import { Effect } from "effect" type GitHubAuthor = { @@ -151,18 +152,7 @@ const SUPPORTED_EVENTS = [...USER_EVENTS, ...REPO_EVENTS] as const type UserEvent = (typeof USER_EVENTS)[number] type RepoEvent = (typeof REPO_EVENTS)[number] -// Parses GitHub remote URLs in various formats: -// - https://github.com/owner/repo.git -// - https://github.com/owner/repo -// - git@github.com:owner/repo.git -// - git@github.com:owner/repo -// - ssh://git@github.com/owner/repo.git -// - ssh://git@github.com/owner/repo -export function parseGitHubRemote(url: string): { owner: string; repo: string } | null { - const match = url.match(/^(?:(?:https?|ssh):\/\/)?(?:git@)?github\.com[:/]([^/]+)\/([^/]+?)(?:\.git)?$/) - if (!match) return null - return { owner: match[1], repo: match[2] } -} +export { parseGitHubRemote } /** * Extracts displayable text from assistant response parts. diff --git a/packages/opencode/src/cli/cmd/run/runtime.boot.ts b/packages/opencode/src/cli/cmd/run/runtime.boot.ts index 9d4aa3658c..3ff9801c6a 100644 --- a/packages/opencode/src/cli/cmd/run/runtime.boot.ts +++ b/packages/opencode/src/cli/cmd/run/runtime.boot.ts @@ -6,7 +6,9 @@ // history ring. All are async because they read config or hit the SDK, but // none block each other. import { Context, Effect, Layer } from "effect" +import { stringifyKeyStroke } from "@opentui/keymap" import { TuiConfig } from "@/cli/cmd/tui/config/tui" +import { TuiKeybind } from "@/cli/cmd/tui/config/keybind" import { makeRuntime } from "@/effect/run-service" import { reusePendingTask } from "./runtime.shared" import { resolveSession, sessionHistory } from "./session.shared" @@ -14,7 +16,7 @@ import type { FooterKeybinds, RunDiffStyle, RunInput, RunPrompt, RunProvider } f import { pickVariant } from "./variant.shared" const DEFAULT_KEYBINDS: FooterKeybinds = { - leader: "ctrl+x", + leader: TuiKeybind.LeaderDefault, leaderTimeout: 2000, commandList: [{ key: "ctrl+p" }], variantCycle: [{ key: "ctrl+t" }], @@ -78,22 +80,28 @@ function emptySessionInfo(): SessionInfo { } } +function leaderKey(config: Config) { + const key = config.keybinds.get("leader")?.[0]?.key + if (!key) return TuiKeybind.LeaderDefault + return typeof key === "string" ? key : stringifyKeyStroke(key) +} + function footerKeybinds(config: Config | undefined): FooterKeybinds { if (!config) { return DEFAULT_KEYBINDS } return { - leader: config.keymap.leader, - leaderTimeout: config.keymap.leader_timeout, - commandList: config.keymap.get("global", "command.palette.show") ?? [], - variantCycle: config.keymap.get("global", "variant.cycle") ?? [], - interrupt: config.keymap.get("prompt", "session.interrupt") ?? [], - historyPrevious: config.keymap.get("prompt", "prompt.history.previous") ?? [], - historyNext: config.keymap.get("prompt", "prompt.history.next") ?? [], - inputClear: config.keymap.get("prompt", "prompt.clear") ?? [], - inputSubmit: config.keymap.get("input", "input.submit") ?? [], - inputNewline: config.keymap.get("input", "input.newline") ?? [], + leader: leaderKey(config), + leaderTimeout: config.leader_timeout, + commandList: config.keybinds.get("command.palette.show"), + variantCycle: config.keybinds.get("variant.cycle"), + interrupt: config.keybinds.get("session.interrupt"), + historyPrevious: config.keybinds.get("prompt.history.previous"), + historyNext: config.keybinds.get("prompt.history.next"), + inputClear: config.keybinds.get("prompt.clear"), + inputSubmit: config.keybinds.get("input.submit"), + inputNewline: config.keybinds.get("input.newline"), } } diff --git a/packages/opencode/src/cli/cmd/run/runtime.ts b/packages/opencode/src/cli/cmd/run/runtime.ts index d811106bd8..882ff2e6c7 100644 --- a/packages/opencode/src/cli/cmd/run/runtime.ts +++ b/packages/opencode/src/cli/cmd/run/runtime.ts @@ -487,6 +487,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise { const handle = await mod.createSessionTransport({ sdk: ctx.sdk, + directory: ctx.directory, sessionID: state.sessionID, thinking: input.thinking, limits: () => state.limits, diff --git a/packages/opencode/src/cli/cmd/run/stream.transport.ts b/packages/opencode/src/cli/cmd/run/stream.transport.ts index d4b73ce6fa..22240ebf56 100644 --- a/packages/opencode/src/cli/cmd/run/stream.transport.ts +++ b/packages/opencode/src/cli/cmd/run/stream.transport.ts @@ -1,8 +1,9 @@ -// SDK event subscription and prompt turn coordination. +// Global event subscription and prompt turn coordination. // -// Creates a long-lived event stream subscription and feeds every event -// through the session-data reducer. The reducer produces scrollback commits -// and footer patches, which get forwarded to the footer through stream.ts. +// Creates a long-lived global event stream subscription and feeds relevant +// events for the current session tree through the reducers. The reducers +// produce scrollback commits and footer patches, which get forwarded to the +// footer through stream.ts. // // Prompt turns are one-at-a-time: runPromptTurn() sends the prompt to the // SDK, arms a deferred Wait, and resolves when the session becomes idle. @@ -14,7 +15,7 @@ // The tick counter prevents stale idle events from resolving the wrong turn. // We also re-check live session status before resolving an idle event so a // delayed idle from an older turn cannot complete a newer busy turn. -import type { Event, OpencodeClient } from "@opencode-ai/sdk/v2" +import type { Event, GlobalEvent, OpencodeClient } from "@opencode-ai/sdk/v2" import { Context, Deferred, Effect, Exit, Layer, Scope, Stream } from "effect" import { makeRuntime } from "@/effect/run-service" import { @@ -62,6 +63,7 @@ type Trace = { type StreamInput = { sdk: OpencodeClient + directory?: string sessionID: string thinking: boolean limits: () => Record @@ -151,6 +153,40 @@ function isEvent(value: unknown): value is Event { return typeof type === "string" && !!properties && typeof properties === "object" } +function isGlobalEvent(value: unknown): value is GlobalEvent { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return false + } + + const payload = Reflect.get(value, "payload") + return !!payload && typeof payload === "object" +} + +function globalPayloadEvent(value: unknown): Event | undefined { + if (!isGlobalEvent(value)) { + return undefined + } + + const payload = value.payload + if (payload.type === "sync") { + return undefined + } + + return isEvent(payload) ? payload : undefined +} + +function isMatchingDisposeEvent(value: unknown, directory: string | undefined): boolean { + if (!directory || !isGlobalEvent(value)) { + return false + } + + if (value.directory !== directory) { + return false + } + + return value.payload.type === "server.instance.disposed" +} + function active(event: Event, sessionID: string): boolean { if (sid(event) !== sessionID) { return false @@ -371,7 +407,7 @@ function createLayer(input: StreamInput) { const events = yield* Scope.provide(scope)( Effect.acquireRelease( Effect.promise(() => - input.sdk.event.subscribe(undefined, { + input.sdk.global.event({ signal: abort.signal, }), ), @@ -397,7 +433,6 @@ function createLayer(input: StreamInput) { blockers: new Map(), } const recovering = new Set() - const currentSubagentState = () => { if (state.selectedSubagent && !state.subagent.tabs.has(state.selectedSubagent)) { state.selectedSubagent = undefined @@ -526,6 +561,38 @@ function createLayer(input: StreamInput) { Effect.orElseSucceed(() => []), ) + const bootstrapSubagentHistory = Effect.fn("RunStreamTransport.bootstrapSubagentHistory")(function* ( + sessions: string[], + ) { + yield* Effect.forEach( + sessions, + (sessionID) => + messages(sessionID, SUBAGENT_CALL_BOOTSTRAP_LIMIT).pipe( + Effect.tap((messagesList) => + Effect.sync(() => { + if ( + !bootstrapSubagentCalls({ + data: state.subagent, + sessionID, + messages: messagesList, + thinking: input.thinking, + limits: input.limits(), + }) + ) { + return + } + + syncFooter([], undefined, currentSubagentState()) + }), + ), + ), + { + concurrency: 4, + discard: true, + }, + ) + }) + const bootstrap = Effect.fn("RunStreamTransport.bootstrap")(function* () { const [messagesList, children, permissions, questions] = yield* Effect.all( [ @@ -566,33 +633,6 @@ function createLayer(input: StreamInput) { questions, }) - const sessions = [ - ...new Set( - listSubagentPermissions(state.subagent) - .filter((item) => item.tool && item.metadata?.input === undefined) - .map((item) => item.sessionID), - ), - ] - yield* Effect.forEach( - sessions, - (sessionID) => - messages(sessionID, SUBAGENT_CALL_BOOTSTRAP_LIMIT).pipe( - Effect.tap((messagesList) => - Effect.sync(() => { - bootstrapSubagentCalls({ - data: state.subagent, - sessionID, - messages: messagesList, - }) - }), - ), - ), - { - concurrency: "unbounded", - discard: true, - }, - ) - for (const request of [ ...state.data.permissions, ...listSubagentPermissions(state.subagent), @@ -605,6 +645,16 @@ function createLayer(input: StreamInput) { const snapshot = currentSubagentState() traceTabs(input.trace, [], snapshot.tabs) syncFooter([], undefined, snapshot) + + const sessions = [...state.subagent.tabs.keys()] + if (sessions.length === 0) { + return + } + + yield* bootstrapSubagentHistory(sessions).pipe( + Effect.forkIn(scope, { startImmediately: true }), + Effect.asVoid, + ) }) const idle = Effect.fn("RunStreamTransport.idle")((fallback: boolean) => @@ -700,11 +750,22 @@ function createLayer(input: StreamInput) { return } - if (!isEvent(item)) { + if (isMatchingDisposeEvent(item, input.directory)) { + yield* fail(new Error("instance disposed")) + yield* closeScope() + return + } + + const event = globalPayloadEvent(item) + if (!event) { + return + } + + const sessionID = sid(event) + if (sessionID !== input.sessionID && (!sessionID || !state.subagent.tabs.has(sessionID))) { return } - const event = item input.trace?.write("recv.event", event) trackBlocker(event) @@ -754,7 +815,7 @@ function createLayer(input: StreamInput) { Effect.ensuring( Effect.gen(function* () { if (!abort.signal.aborted && !state.fault) { - yield* fail(new Error("session event stream closed")) + yield* fail(new Error("global event stream closed")) } closeStream() }), diff --git a/packages/opencode/src/cli/cmd/run/subagent-data.ts b/packages/opencode/src/cli/cmd/run/subagent-data.ts index e834ff74f0..e9dcd6538a 100644 --- a/packages/opencode/src/cli/cmd/run/subagent-data.ts +++ b/packages/opencode/src/cli/cmd/run/subagent-data.ts @@ -1,4 +1,4 @@ -import type { Event, Part, PermissionRequest, QuestionRequest, ToolPart } from "@opencode-ai/sdk/v2" +import type { Event, Message, Part, PermissionRequest, QuestionRequest, ToolPart } from "@opencode-ai/sdk/v2" import * as Locale from "@/util/locale" import { bootstrapSessionData, @@ -22,6 +22,10 @@ type SessionMessage = { parts: Part[] } +type BootstrapChildMessage = SessionMessage & { + info: Message +} + type Frame = { key: string commit: StreamCommit @@ -513,6 +517,70 @@ function applyChildEvent(input: { return changed || queueChanged(input.detail.data, before) } +function bootstrapChildEvent(input: { + detail: DetailState + event: Event + thinking: boolean + limits: Record +}) { + const out = reduceSessionData({ + data: input.detail.data, + event: input.event, + sessionID: input.detail.sessionID, + thinking: input.thinking, + limits: input.limits, + }) + + return appendCommits(input.detail, out.commits) +} + +function bootstrapChildMessages(input: { + detail: DetailState + messages: BootstrapChildMessage[] + thinking: boolean + limits: Record +}) { + let changed = false + + for (const message of input.messages) { + changed = + bootstrapChildEvent({ + detail: input.detail, + event: { + id: `bootstrap:message:${message.info.id}`, + type: "message.updated", + properties: { + sessionID: input.detail.sessionID, + info: message.info, + }, + }, + thinking: input.thinking, + limits: input.limits, + }) || changed + + for (const part of message.parts) { + changed = + bootstrapChildEvent({ + detail: input.detail, + event: { + id: `bootstrap:part:${part.id}`, + type: "message.part.updated", + properties: { + sessionID: input.detail.sessionID, + part, + time: 0, + }, + }, + thinking: input.thinking, + limits: input.limits, + }) || changed + } + } + + compactDetail(input.detail) + return changed +} + function knownSession(data: SubagentData, sessionID: string) { return data.tabs.has(sessionID) } @@ -634,7 +702,13 @@ export function bootstrapSubagentData(input: BootstrapSubagentInput) { return changed } -export function bootstrapSubagentCalls(input: { data: SubagentData; sessionID: string; messages: SessionMessage[] }) { +export function bootstrapSubagentCalls(input: { + data: SubagentData + sessionID: string + messages: BootstrapChildMessage[] + thinking: boolean + limits: Record +}) { if (!knownSession(input.data, input.sessionID) || input.messages.length === 0) { return false } @@ -648,9 +722,14 @@ export function bootstrapSubagentCalls(input: { data: SubagentData; sessionID: s permissions: detail.data.permissions, questions: detail.data.questions, }) - compactDetail(detail) + const changed = bootstrapChildMessages({ + detail, + messages: input.messages, + thinking: input.thinking, + limits: input.limits, + }) - return beforeCallCount !== detail.data.call.size || queueChanged(detail.data, before) + return changed || beforeCallCount !== detail.data.call.size || queueChanged(detail.data, before) } export function clearFinishedSubagents(data: SubagentData) { diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 275a494578..cc2afd1cdf 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -70,6 +70,41 @@ import { OpencodeKeymapProvider, registerOpencodeKeymap, useBindings, useOpencod import type { EventSource } from "./context/sdk" import { DialogVariant } from "./component/dialog-variant" +const appBindingCommands = [ + "command.palette.show", + "session.list", + "session.new", + "model.list", + "model.cycle_recent", + "model.cycle_recent_reverse", + "model.cycle_favorite", + "model.cycle_favorite_reverse", + "agent.list", + "mcp.list", + "agent.cycle", + "agent.cycle.reverse", + "variant.cycle", + "variant.list", + "provider.connect", + "console.org.switch", + "opencode.status", + "theme.switch", + "theme.switch_mode", + "theme.mode.lock", + "help.show", + "docs.open", + "app.debug", + "app.console", + "app.heap_snapshot", + "terminal.suspend", + "terminal.title.toggle", + "app.toggle.animations", + "app.toggle.file_context", + "app.toggle.diffwrap", + "app.toggle.paste_summary", + "app.toggle.session_directory_filter", +] as const + function rendererConfig(_config: TuiConfig.Resolved): CliRendererConfig { const mouseEnabled = !Flag.OPENCODE_DISABLE_MOUSE && (_config.mouse ?? true) @@ -215,9 +250,6 @@ export function tui(input: { function App(props: { onSnapshot?: () => Promise }) { const tuiConfig = useTuiConfig() - const { - keymap: { sections }, - } = tuiConfig const route = useRoute() const dimensions = useTerminalDimensions() const renderer = useRenderer() @@ -615,11 +647,6 @@ function App(props: { onSnapshot?: () => Promise }) { title: "Exit the app", slashName: "exit", slashAliases: ["quit", "q"], - enabled: () => { - const current = promptRef.current - if (!current?.focused) return true - return current.current.input === "" - }, run: () => exit(), category: "System", }, @@ -749,7 +776,18 @@ function App(props: { onSnapshot?: () => Promise }) { useBindings(() => ({ enabled: command.matcher, - bindings: sections.global, + bindings: tuiConfig.keybinds.gather("app", appBindingCommands), + })) + + useBindings(() => ({ + enabled: () => { + const ok = command.matcher.get() + if (!ok) return false + const current = promptRef.current + if (!current?.focused) return true + return current.current.input === "" + }, + bindings: tuiConfig.keybinds.gather("app_exit", ["app.exit"]), })) event.on(TuiEvent.CommandExecute.type, (evt) => { diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-mcp.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-mcp.tsx index faa26dc3a6..c577d49329 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-mcp.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-mcp.tsx @@ -46,7 +46,7 @@ export function DialogMcp() { const actions = createMemo(() => [ { - command: "dialog.action.toggle", + command: "dialog.mcp.toggle", title: "toggle", onTrigger: async (option: DialogSelectOption) => { // Prevent toggling while an operation is already in progress diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx index 068c6a1e03..09c2d64b00 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx @@ -8,13 +8,11 @@ import { createDialogProviderOptions, DialogProvider } from "./dialog-provider" import { DialogVariant } from "./dialog-variant" import * as fuzzysort from "fuzzysort" import { useConnected } from "./use-connected" -import { useTuiConfig } from "../context/tui-config" export function DialogModel(props: { providerID?: string }) { const local = useLocal() const sync = useSync() const dialog = useDialog() - const tuiConfig = useTuiConfig() const [query, setQuery] = createSignal("") const connected = useConnected() @@ -167,7 +165,6 @@ export function DialogModel(props: { providerID?: string }) { }, }, ]} - bindings={tuiConfig.keymap.sections.model} onFilter={setQuery} flat={true} skipFilter={true} diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx index 542449f5df..35c966937c 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx @@ -28,7 +28,7 @@ export function DialogSessionList() { const toast = useToast() const [toDelete, setToDelete] = createSignal() const [search, setSearch] = createDebouncedSignal("", 150) - const deleteHint = useCommandShortcut("dialog.action.delete") + const deleteHint = useCommandShortcut("session.delete") const [searchResults, { refetch }] = createResource( () => ({ query: search(), filter: sync.session.query() }), @@ -107,6 +107,7 @@ export function DialogSessionList() { dialog, sdk, sync, + project, toast, onSelect: (selection) => { void warp(selection) @@ -118,15 +119,29 @@ export function DialogSessionList() { )) } + function orderByRecency(sessionsList: NonNullable>) { + return sessionsList + .filter((x) => x.parentID === undefined) + .toSorted((a, b) => b.time.updated - a.time.updated) + .map((x) => x.id) + } + + const [browseOrder] = createSignal(orderByRecency(sync.data.session)) + const options = createMemo(() => { const today = new Date().toDateString() - return sessions() - .filter((x) => x.parentID === undefined) - .toSorted((a, b) => { - const updatedDay = new Date(b.time.updated).setHours(0, 0, 0, 0) - new Date(a.time.updated).setHours(0, 0, 0, 0) - if (updatedDay !== 0) return updatedDay - return b.time.created - a.time.created - }) + const sessionMap = new Map( + sessions() + .filter((x) => x.parentID === undefined) + .map((x) => [x.id, x]), + ) + + const searchResult = searchResults() + const displayOrder = searchResult ? orderByRecency(searchResult) : browseOrder() + + return displayOrder + .map((id) => sessionMap.get(id)) + .filter((x) => x !== undefined) .map((x) => { const workspace = x.workspaceID ? project.workspace.get(x.workspaceID) : undefined @@ -189,7 +204,7 @@ export function DialogSessionList() { }} actions={[ { - command: "dialog.action.delete", + command: "session.delete", title: "delete", onTrigger: async (option) => { if (toDelete() === option.value) { @@ -237,7 +252,7 @@ export function DialogSessionList() { }, }, { - command: "dialog.action.rename", + command: "session.rename", title: "rename", onTrigger: async (option) => { dialog.replace(() => ) diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-stash.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-stash.tsx index 62843c2527..2dfe2dee9c 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-stash.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-stash.tsx @@ -32,7 +32,7 @@ export function DialogStash(props: { onSelect: (entry: StashEntry) => void }) { const { theme } = useTheme() const [toDelete, setToDelete] = createSignal() - const deleteHint = useCommandShortcut("dialog.action.delete") + const deleteHint = useCommandShortcut("stash.delete") const options = createMemo(() => { const entries = stash.list() @@ -70,7 +70,7 @@ export function DialogStash(props: { onSelect: (entry: StashEntry) => void }) { }} actions={[ { - command: "dialog.action.delete", + command: "stash.delete", title: "delete", onTrigger: (option) => { if (toDelete() === option.value) { diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx index d7e212ab15..538428e8f1 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx @@ -36,21 +36,14 @@ export type WorkspaceSelection = type WorkspaceSelectValue = WorkspaceSelection | { type: "existing-list" } type ExistingWorkspaceSelectValue = { workspace: Workspace } -export function recentConnectedWorkspaces(input: { - sessions: readonly { workspaceID?: string; time: { updated: number } }[] - get: (workspaceID: string) => WorkspaceInfo | undefined +export function recentConnectedWorkspaces(input: { + workspaces: readonly WorkspaceInfo[] status: (workspaceID: string) => string | undefined limit?: number omitWorkspaceID?: string }) { - const workspaces = input.sessions - .toSorted((a, b) => b.time.updated - a.time.updated) - .flatMap((session) => { - const workspace = session.workspaceID ? input.get(session.workspaceID) : undefined - return workspace && input.status(workspace.id) === "connected" ? [workspace] : [] - }) - .filter((workspace) => workspace.id !== input.omitWorkspaceID) - .filter((workspace, index, list) => list.findIndex((item) => item.id === workspace.id) === index) + const allWorkspaces = input.workspaces.filter((workspace) => input.status(workspace.id) === "connected") + const workspaces = allWorkspaces.toSorted((a, b) => Number(b.timeUsed) - Number(a.timeUsed)) const recent = workspaces.slice(0, input.limit ?? 3) return { recent, hasMore: recent.length < workspaces.length } @@ -83,10 +76,13 @@ export async function openWorkspaceSelect(input: { dialog: ReturnType sdk: ReturnType sync: ReturnType + project: ReturnType toast: ReturnType onSelect: (selection: WorkspaceSelection) => Promise | void }) { input.dialog.clear() + await input.sdk.client.experimental.workspace.syncList().catch(() => undefined) + await input.project.workspace.sync().catch(() => undefined) const adapters = await loadWorkspaceAdapters(input) if (!adapters) return input.dialog.replace(() => ) @@ -200,8 +196,7 @@ export function DialogWorkspaceSelect(props: { const list = adapters() if (!list) return [] const { recent, hasMore } = recentConnectedWorkspaces({ - sessions: sync.data.session, - get: project.workspace.get, + workspaces: project.workspace.list(), status: project.workspace.status, omitWorkspaceID: omittedWorkspaceID(), }) diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx index 57c890f5a2..7f390f0eb6 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx @@ -87,9 +87,6 @@ export function Autocomplete(props: { const dimensions = useTerminalDimensions() const frecency = useFrecency() const tuiConfig = useTuiConfig() - const { - keymap: { sections }, - } = tuiConfig const [store, setStore] = createStore({ index: 0, selected: 0, @@ -575,7 +572,13 @@ export function Autocomplete(props: { }, }, ], - bindings: sections.autocomplete, + bindings: tuiConfig.keybinds.gather("prompt.autocomplete", [ + "prompt.autocomplete.prev", + "prompt.autocomplete.next", + "prompt.autocomplete.hide", + "prompt.autocomplete.select", + "prompt.autocomplete.complete", + ]), })) function show(mode: "@" | "/") { diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index cafb1ba373..f3217fcbab 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -147,7 +147,6 @@ export function Prompt(props: PromptProps) { const project = useProject() const sync = useSync() const tuiConfig = useTuiConfig() - const keymapConfig = tuiConfig.keymap const dialog = useDialog() const toast = useToast() const status = createMemo(() => sync.data.session_status?.[props.sessionID ?? ""] ?? { type: "idle" }) @@ -610,6 +609,7 @@ export function Prompt(props: PromptProps) { dialog, sdk, sync, + project, toast, onSelect: (selection) => { void warpSession(selection) @@ -629,7 +629,7 @@ export function Prompt(props: PromptProps) { useBindings(() => ({ enabled: command.matcher, - bindings: keymapConfig.pick("prompt", [ + bindings: tuiConfig.keybinds.gather("prompt.palette", [ "prompt.submit", "prompt.editor", "prompt.editor_context.clear", @@ -712,7 +712,6 @@ export function Prompt(props: PromptProps) { ...input.traits, ...computePromptTraits({ mode: store.mode, - disabled: !!props.disabled, autocompleteVisible: !!auto()?.visible, }), } @@ -864,7 +863,7 @@ export function Prompt(props: PromptProps) { return { target: inputTarget, enabled: inputTarget() !== undefined && !props.disabled, - bindings: keymapConfig.pick("prompt", ["prompt.paste"]), + bindings: tuiConfig.keybinds.get("prompt.paste"), } }) @@ -872,7 +871,7 @@ export function Prompt(props: PromptProps) { return { target: inputTarget, enabled: inputTarget() !== undefined && !props.disabled && store.prompt.input !== "", - bindings: keymapConfig.pick("prompt", ["prompt.clear"]), + bindings: tuiConfig.keybinds.get("prompt.clear"), } }) @@ -927,13 +926,7 @@ export function Prompt(props: PromptProps) { target: inputTarget, enabled: (() => { cursorVersion() - return ( - inputTarget() !== undefined && - !props.disabled && - !auto()?.visible && - input !== undefined && - (input.cursorOffset === 0 || input.visualCursor.visualRow === 0) - ) + return inputTarget() !== undefined && !props.disabled && !auto()?.visible && input !== undefined })(), commands: [ { @@ -942,12 +935,12 @@ export function Prompt(props: PromptProps) { category: "Prompt", run() { if (input.cursorOffset !== 0) { - input.cursorOffset = 0 - return + if (input.scrollY + input.visualCursor.visualRow === 0) input.cursorOffset = 0 + return false } const item = history.move(-1, input.plainText) - if (!item) return + if (!item) return false input.setText(item.input) setStore("prompt", item) setStore("mode", item.mode ?? "normal") @@ -956,7 +949,7 @@ export function Prompt(props: PromptProps) { }, }, ], - bindings: keymapConfig.pick("prompt", ["prompt.history.previous"]), + bindings: tuiConfig.keybinds.get("prompt.history.previous"), } }) @@ -965,13 +958,7 @@ export function Prompt(props: PromptProps) { target: inputTarget, enabled: (() => { cursorVersion() - return ( - inputTarget() !== undefined && - !props.disabled && - !auto()?.visible && - input !== undefined && - (input.cursorOffset === input.plainText.length || input.visualCursor.visualRow === input.height - 1) - ) + return inputTarget() !== undefined && !props.disabled && !auto()?.visible && input !== undefined })(), commands: [ { @@ -980,12 +967,16 @@ export function Prompt(props: PromptProps) { category: "Prompt", run() { if (input.cursorOffset !== input.plainText.length) { - input.cursorOffset = input.plainText.length - return + if ( + input.scrollY + input.visualCursor.visualRow === + Math.max(0, input.editorView.getTotalVirtualLineCount() - 1) + ) + input.cursorOffset = input.plainText.length + return false } const item = history.move(1, input.plainText) - if (!item) return + if (!item) return false input.setText(item.input) setStore("prompt", item) setStore("mode", item.mode ?? "normal") @@ -994,7 +985,7 @@ export function Prompt(props: PromptProps) { }, }, ], - bindings: keymapConfig.pick("prompt", ["prompt.history.next"]), + bindings: tuiConfig.keybinds.get("prompt.history.next"), } }) @@ -1036,6 +1027,7 @@ export function Prompt(props: PromptProps) { dialog, sdk, sync, + project, toast, onSelect: (selection) => { void warpSession(selection) diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/traits.ts b/packages/opencode/src/cli/cmd/tui/component/prompt/traits.ts index a701396562..03b0580529 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/traits.ts +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/traits.ts @@ -4,7 +4,6 @@ export type PromptMode = "normal" | "shell" export interface PromptTraitsInput { mode: PromptMode - disabled: boolean autocompleteVisible: boolean } @@ -16,10 +15,9 @@ export type PromptTraits = EditorTraits & { /** * Compute the textarea editor traits for the prompt. * - * `traits.suspend` gates the textarea's keybinding actions (backspace, - * delete-word, arrow movement, undo/redo, etc.). Shell mode is an active - * editing mode — only `disabled` should suspend the textarea, otherwise - * users can type in shell mode but cannot delete or move the cursor. + * The OpenTUI managed textarea keymap owns `traits.suspend`. Prompt traits + * only expose capture/status metadata so focus changes cannot unsuspend the + * keymap-managed editor mappings. */ export function computePromptTraits(input: PromptTraitsInput): PromptTraits { const capture = @@ -30,7 +28,6 @@ export function computePromptTraits(input: PromptTraitsInput): PromptTraits { : undefined return { capture, - suspend: input.disabled, status: input.mode === "shell" ? "SHELL" : undefined, owner: "opencode", role: "prompt", diff --git a/packages/opencode/src/cli/cmd/tui/config/keybind.ts b/packages/opencode/src/cli/cmd/tui/config/keybind.ts new file mode 100644 index 0000000000..46a48e18e9 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/config/keybind.ts @@ -0,0 +1,384 @@ +export * as TuiKeybind from "./keybind" + +import type { KeyEvent, Renderable } from "@opentui/core" +import type { Binding } from "@opentui/keymap" +import type { BindingCommandMap, BindingConfig, BindingDefaults, BindingValue } from "@opentui/keymap/extras" +import z from "zod" + +const KeyStroke = z + .object({ + name: z.string(), + ctrl: z.boolean().optional(), + shift: z.boolean().optional(), + meta: z.boolean().optional(), + super: z.boolean().optional(), + hyper: z.boolean().optional(), + }) + .strict() + +const BindingObject = z + .object({ + key: z.union([z.string(), KeyStroke]), + event: z.enum(["press", "release"]).optional(), + preventDefault: z.boolean().optional(), + fallthrough: z.boolean().optional(), + }) + .passthrough() + +const BindingItem = z.union([z.string(), KeyStroke, BindingObject]) +export const BindingValueSchema = z.union([z.literal(false), z.literal("none"), BindingItem, z.array(BindingItem)]) + +type Definition = { + default: z.input + description: string +} + +const inputUndoDefault = process.platform === "win32" ? "ctrl+z,ctrl+-,super+z" : "ctrl+-,super+z" +export const LeaderDefault = "ctrl+x" + +const keybind = (value: Definition["default"], description: string): Definition => ({ default: value, description }) + +const Definitions = { + leader: keybind(LeaderDefault, "Leader key for keybind combinations"), + + app_exit: keybind("ctrl+c,ctrl+d,q", "Exit the application"), + app_debug: keybind("none", "Toggle debug panel"), + app_console: keybind("none", "Toggle console"), + app_heap_snapshot: keybind("none", "Write heap snapshot"), + app_toggle_animations: keybind("none", "Toggle animations"), + app_toggle_file_context: keybind("none", "Toggle file context"), + app_toggle_diffwrap: keybind("none", "Toggle diff wrapping"), + app_toggle_paste_summary: keybind("none", "Toggle paste summary"), + app_toggle_session_directory_filter: keybind("none", "Toggle session directory filtering"), + command_list: keybind("ctrl+p", "List available commands"), + help_show: keybind("none", "Open help dialog"), + docs_open: keybind("none", "Open documentation"), + + editor_open: keybind("e", "Open external editor"), + theme_list: keybind("t", "List available themes"), + theme_switch_mode: keybind("none", "Switch between light and dark theme mode"), + theme_mode_lock: keybind("none", "Lock or unlock theme mode"), + sidebar_toggle: keybind("b", "Toggle sidebar"), + scrollbar_toggle: keybind("none", "Toggle session scrollbar"), + status_view: keybind("s", "View status"), + + session_export: keybind("x", "Export session to editor"), + session_copy: keybind("none", "Copy session transcript"), + session_new: keybind("n", "Create a new session"), + session_list: keybind("l", "List all sessions"), + session_timeline: keybind("g", "Show session timeline"), + session_fork: keybind("none", "Fork session from message"), + session_rename: keybind("ctrl+r", "Rename session"), + session_delete: keybind("ctrl+d", "Delete session"), + session_share: keybind("none", "Share current session"), + session_unshare: keybind("none", "Unshare current session"), + session_interrupt: keybind("escape", "Interrupt current session"), + session_compact: keybind("c", "Compact the session"), + session_toggle_timestamps: keybind("none", "Toggle message timestamps"), + session_toggle_generic_tool_output: keybind("none", "Toggle generic tool output"), + session_child_first: keybind("down", "Go to first child session"), + session_child_cycle: keybind("right", "Go to next child session"), + session_child_cycle_reverse: keybind("left", "Go to previous child session"), + session_parent: keybind("up", "Go to parent session"), + + stash_delete: keybind("ctrl+d", "Delete stash entry"), + model_provider_list: keybind("ctrl+a", "Open provider list from model dialog"), + model_favorite_toggle: keybind("ctrl+f", "Toggle model favorite status"), + model_list: keybind("m", "List available models"), + model_cycle_recent: keybind("f2", "Next recently used model"), + model_cycle_recent_reverse: keybind("shift+f2", "Previous recently used model"), + model_cycle_favorite: keybind("none", "Next favorite model"), + model_cycle_favorite_reverse: keybind("none", "Previous favorite model"), + mcp_list: keybind("none", "List MCP servers"), + provider_connect: keybind("none", "Connect provider"), + console_org_switch: keybind("none", "Switch console organization"), + agent_list: keybind("a", "List agents"), + agent_cycle: keybind("tab", "Next agent"), + agent_cycle_reverse: keybind("shift+tab", "Previous agent"), + variant_cycle: keybind("ctrl+t", "Cycle model variants"), + variant_list: keybind("none", "List model variants"), + + messages_page_up: keybind("pageup,ctrl+alt+b", "Scroll messages up by one page"), + messages_page_down: keybind("pagedown,ctrl+alt+f", "Scroll messages down by one page"), + messages_line_up: keybind("ctrl+alt+y", "Scroll messages up by one line"), + messages_line_down: keybind("ctrl+alt+e", "Scroll messages down by one line"), + messages_half_page_up: keybind("ctrl+alt+u", "Scroll messages up by half page"), + messages_half_page_down: keybind("ctrl+alt+d", "Scroll messages down by half page"), + messages_first: keybind("ctrl+g,home", "Navigate to first message"), + messages_last: keybind("ctrl+alt+g,end", "Navigate to last message"), + messages_next: keybind("none", "Navigate to next message"), + messages_previous: keybind("none", "Navigate to previous message"), + messages_last_user: keybind("none", "Navigate to last user message"), + messages_copy: keybind("y", "Copy message"), + messages_undo: keybind("u", "Undo message"), + messages_redo: keybind("r", "Redo message"), + messages_toggle_conceal: keybind("h", "Toggle code block concealment in messages"), + tool_details: keybind("none", "Toggle tool details visibility"), + display_thinking: keybind("none", "Toggle thinking blocks visibility"), + + prompt_submit: keybind("none", "Submit prompt"), + prompt_editor_context_clear: keybind("none", "Clear editor context"), + prompt_skills: keybind("none", "Open skill selector"), + prompt_stash: keybind("none", "Stash prompt"), + prompt_stash_pop: keybind("none", "Pop stashed prompt"), + prompt_stash_list: keybind("none", "List stashed prompts"), + workspace_set: keybind("none", "Set workspace"), + + input_clear: keybind("ctrl+c", "Clear input field"), + input_paste: keybind({ key: "ctrl+v", preventDefault: false }, "Paste from clipboard"), + input_submit: keybind("return", "Submit input"), + input_newline: keybind("shift+return,ctrl+return,alt+return,ctrl+j", "Insert newline in input"), + input_move_left: keybind("left,ctrl+b", "Move cursor left in input"), + input_move_right: keybind("right,ctrl+f", "Move cursor right in input"), + input_move_up: keybind("up", "Move cursor up in input"), + input_move_down: keybind("down", "Move cursor down in input"), + input_select_left: keybind("shift+left", "Select left in input"), + input_select_right: keybind("shift+right", "Select right in input"), + input_select_up: keybind("shift+up", "Select up in input"), + input_select_down: keybind("shift+down", "Select down in input"), + input_line_home: keybind("ctrl+a", "Move to start of line in input"), + input_line_end: keybind("ctrl+e", "Move to end of line in input"), + input_select_line_home: keybind("ctrl+shift+a", "Select to start of line in input"), + input_select_line_end: keybind("ctrl+shift+e", "Select to end of line in input"), + input_visual_line_home: keybind("alt+a", "Move to start of visual line in input"), + input_visual_line_end: keybind("alt+e", "Move to end of visual line in input"), + input_select_visual_line_home: keybind("alt+shift+a", "Select to start of visual line in input"), + input_select_visual_line_end: keybind("alt+shift+e", "Select to end of visual line in input"), + input_buffer_home: keybind("home", "Move to start of buffer in input"), + input_buffer_end: keybind("end", "Move to end of buffer in input"), + input_select_buffer_home: keybind("shift+home", "Select to start of buffer in input"), + input_select_buffer_end: keybind("shift+end", "Select to end of buffer in input"), + input_delete_line: keybind("ctrl+shift+d", "Delete line in input"), + input_delete_to_line_end: keybind("ctrl+k", "Delete to end of line in input"), + input_delete_to_line_start: keybind("ctrl+u", "Delete to start of line in input"), + input_backspace: keybind("backspace,shift+backspace", "Backspace in input"), + input_delete: keybind("ctrl+d,delete,shift+delete", "Delete character in input"), + input_undo: keybind(inputUndoDefault, "Undo in input"), + input_redo: keybind("ctrl+.,super+shift+z", "Redo in input"), + input_word_forward: keybind("alt+f,alt+right,ctrl+right", "Move word forward in input"), + input_word_backward: keybind("alt+b,alt+left,ctrl+left", "Move word backward in input"), + input_select_word_forward: keybind("alt+shift+f,alt+shift+right", "Select word forward in input"), + input_select_word_backward: keybind("alt+shift+b,alt+shift+left", "Select word backward in input"), + input_delete_word_forward: keybind("alt+d,alt+delete,ctrl+delete", "Delete word forward in input"), + input_delete_word_backward: keybind("ctrl+w,ctrl+backspace,alt+backspace", "Delete word backward in input"), + input_select_all: keybind("super+a", "Select all in input"), + history_previous: keybind("up", "Previous history item"), + history_next: keybind("down", "Next history item"), + + "dialog.select.prev": keybind("up,ctrl+p", "Move to previous dialog item"), + "dialog.select.next": keybind("down,ctrl+n", "Move to next dialog item"), + "dialog.select.page_up": keybind("pageup", "Move up one page in dialog"), + "dialog.select.page_down": keybind("pagedown", "Move down one page in dialog"), + "dialog.select.home": keybind("home", "Move to first dialog item"), + "dialog.select.end": keybind("end", "Move to last dialog item"), + "dialog.select.submit": keybind("return", "Submit selected dialog item"), + "dialog.mcp.toggle": keybind("space", "Toggle MCP in MCP dialog"), + "prompt.autocomplete.prev": keybind("up,ctrl+p", "Move to previous autocomplete item"), + "prompt.autocomplete.next": keybind("down,ctrl+n", "Move to next autocomplete item"), + "prompt.autocomplete.hide": keybind("escape", "Hide autocomplete"), + "prompt.autocomplete.select": keybind("return", "Select autocomplete item"), + "prompt.autocomplete.complete": keybind("tab", "Complete autocomplete item"), + "permission.prompt.fullscreen": keybind("ctrl+f", "Toggle permission prompt fullscreen"), + "plugins.toggle": keybind("space", "Toggle plugin"), + "dialog.plugins.install": keybind("shift+i", "Install plugin from plugin dialog"), + + terminal_suspend: keybind("ctrl+z", "Suspend terminal"), + terminal_title_toggle: keybind("none", "Toggle terminal title"), + tips_toggle: keybind("h", "Toggle tips on home screen"), + plugin_manager: keybind("none", "Open plugin manager dialog"), + plugin_install: keybind("none", "Install plugin"), + + which_key_toggle: keybind("ctrl+alt+k", "Toggle which-key panel"), + which_key_layout_toggle: keybind("ctrl+alt+shift+k", "Switch which-key layout"), + which_key_pending_toggle: keybind("ctrl+alt+shift+p", "Toggle which-key pending preview"), + which_key_group_previous: keybind("ctrl+alt+left,ctrl+alt+[", "Previous which-key group"), + which_key_group_next: keybind("ctrl+alt+right,ctrl+alt+]", "Next which-key group"), + which_key_scroll_up: keybind("ctrl+alt+up,ctrl+alt+p", "Scroll which-key up"), + which_key_scroll_down: keybind("ctrl+alt+down,ctrl+alt+n", "Scroll which-key down"), + which_key_page_up: keybind("ctrl+alt+pageup", "Page which-key up"), + which_key_page_down: keybind("ctrl+alt+pagedown", "Page which-key down"), + which_key_home: keybind("ctrl+alt+home", "Jump to first which-key binding"), + which_key_end: keybind("ctrl+alt+end", "Jump to last which-key binding"), +} satisfies Record + +type KeybindName = keyof typeof Definitions & string + +const KeybindShape = Object.fromEntries( + Object.entries(Definitions).map(([name, item]) => [ + name, + BindingValueSchema.optional().default(item.default).describe(item.description), + ]), +) as Record>> + +const KeybindOverrideShape = Object.fromEntries( + Object.entries(Definitions).map(([name, item]) => [name, BindingValueSchema.optional().describe(item.description)]), +) as Record> + +export const Keybinds = z.strictObject(KeybindShape).describe("TUI keybinding configuration") +export const KeybindOverrides = z.strictObject(KeybindOverrideShape).describe("TUI keybinding overrides") +export const Descriptions = Object.fromEntries( + Object.entries(Definitions).map(([name, item]) => [name, item.description]), +) as Record +export const CommandMap = { + app_exit: "app.exit", + app_debug: "app.debug", + app_console: "app.console", + app_heap_snapshot: "app.heap_snapshot", + app_toggle_animations: "app.toggle.animations", + app_toggle_file_context: "app.toggle.file_context", + app_toggle_diffwrap: "app.toggle.diffwrap", + app_toggle_paste_summary: "app.toggle.paste_summary", + app_toggle_session_directory_filter: "app.toggle.session_directory_filter", + command_list: "command.palette.show", + help_show: "help.show", + docs_open: "docs.open", + editor_open: "prompt.editor", + theme_list: "theme.switch", + theme_switch_mode: "theme.switch_mode", + theme_mode_lock: "theme.mode.lock", + sidebar_toggle: "session.sidebar.toggle", + scrollbar_toggle: "session.toggle.scrollbar", + status_view: "opencode.status", + session_export: "session.export", + session_copy: "session.copy", + session_new: "session.new", + session_list: "session.list", + session_timeline: "session.timeline", + session_fork: "session.fork", + session_rename: "session.rename", + session_delete: "session.delete", + session_share: "session.share", + session_unshare: "session.unshare", + session_interrupt: "session.interrupt", + session_compact: "session.compact", + session_toggle_timestamps: "session.toggle.timestamps", + session_toggle_generic_tool_output: "session.toggle.generic_tool_output", + session_child_first: "session.child.first", + session_child_cycle: "session.child.next", + session_child_cycle_reverse: "session.child.previous", + session_parent: "session.parent", + stash_delete: "stash.delete", + model_provider_list: "model.dialog.provider", + model_favorite_toggle: "model.dialog.favorite", + model_list: "model.list", + model_cycle_recent: "model.cycle_recent", + model_cycle_recent_reverse: "model.cycle_recent_reverse", + model_cycle_favorite: "model.cycle_favorite", + model_cycle_favorite_reverse: "model.cycle_favorite_reverse", + mcp_list: "mcp.list", + provider_connect: "provider.connect", + console_org_switch: "console.org.switch", + agent_list: "agent.list", + agent_cycle: "agent.cycle", + agent_cycle_reverse: "agent.cycle.reverse", + variant_cycle: "variant.cycle", + variant_list: "variant.list", + messages_page_up: "session.page.up", + messages_page_down: "session.page.down", + messages_line_up: "session.line.up", + messages_line_down: "session.line.down", + messages_half_page_up: "session.half.page.up", + messages_half_page_down: "session.half.page.down", + messages_first: "session.first", + messages_last: "session.last", + messages_next: "session.message.next", + messages_previous: "session.message.previous", + messages_last_user: "session.messages_last_user", + messages_copy: "messages.copy", + messages_undo: "session.undo", + messages_redo: "session.redo", + messages_toggle_conceal: "session.toggle.conceal", + tool_details: "session.toggle.actions", + display_thinking: "session.toggle.thinking", + prompt_submit: "prompt.submit", + prompt_editor_context_clear: "prompt.editor_context.clear", + prompt_skills: "prompt.skills", + prompt_stash: "prompt.stash", + prompt_stash_pop: "prompt.stash.pop", + prompt_stash_list: "prompt.stash.list", + workspace_set: "workspace.set", + input_clear: "prompt.clear", + input_paste: "prompt.paste", + input_submit: "input.submit", + input_newline: "input.newline", + input_move_left: "input.move.left", + input_move_right: "input.move.right", + input_move_up: "input.move.up", + input_move_down: "input.move.down", + input_select_left: "input.select.left", + input_select_right: "input.select.right", + input_select_up: "input.select.up", + input_select_down: "input.select.down", + input_line_home: "input.line.home", + input_line_end: "input.line.end", + input_select_line_home: "input.select.line.home", + input_select_line_end: "input.select.line.end", + input_visual_line_home: "input.visual.line.home", + input_visual_line_end: "input.visual.line.end", + input_select_visual_line_home: "input.select.visual.line.home", + input_select_visual_line_end: "input.select.visual.line.end", + input_buffer_home: "input.buffer.home", + input_buffer_end: "input.buffer.end", + input_select_buffer_home: "input.select.buffer.home", + input_select_buffer_end: "input.select.buffer.end", + input_delete_line: "input.delete.line", + input_delete_to_line_end: "input.delete.to.line.end", + input_delete_to_line_start: "input.delete.to.line.start", + input_backspace: "input.backspace", + input_delete: "input.delete", + input_undo: "input.undo", + input_redo: "input.redo", + input_word_forward: "input.word.forward", + input_word_backward: "input.word.backward", + input_select_word_forward: "input.select.word.forward", + input_select_word_backward: "input.select.word.backward", + input_delete_word_forward: "input.delete.word.forward", + input_delete_word_backward: "input.delete.word.backward", + input_select_all: "input.select.all", + history_previous: "prompt.history.previous", + history_next: "prompt.history.next", + terminal_suspend: "terminal.suspend", + terminal_title_toggle: "terminal.title.toggle", + tips_toggle: "tips.toggle", + plugin_manager: "plugins.list", + plugin_install: "plugins.install", + which_key_toggle: "which-key.toggle", + which_key_layout_toggle: "which-key.layout.toggle", + which_key_pending_toggle: "which-key.pending.toggle", + which_key_group_previous: "which-key.group.previous", + which_key_group_next: "which-key.group.next", + which_key_scroll_up: "which-key.scroll.up", + which_key_scroll_down: "which-key.scroll.down", + which_key_page_up: "which-key.page.up", + which_key_page_down: "which-key.page.down", + which_key_home: "which-key.home", + which_key_end: "which-key.end", +} satisfies BindingCommandMap +const CommandDescriptions = Object.fromEntries( + Object.entries(Definitions).map(([name, item]) => [ + CommandMap[name as keyof typeof CommandMap] ?? name, + item.description, + ]), +) as Record + +export type Keybinds = z.output +export type KeybindOverrides = z.output +export type BindingLookupView = { + readonly bindings: readonly Binding[] + get(command: string): readonly Binding[] + has(command: string): boolean + gather(name: string, commands: readonly string[]): readonly Binding[] + pick(name: string, commands: readonly string[]): Binding[] + omit(name: string, commands: readonly string[]): Binding[] +} + +export function toBindingConfig(keybinds: Keybinds): BindingConfig { + return Object.fromEntries(Object.entries(keybinds)) as BindingConfig +} + +export function bindingDefaults(): BindingDefaults { + return ({ command, binding }) => { + if (binding.desc !== undefined) return + return { desc: CommandDescriptions[command] } + } +} diff --git a/packages/opencode/src/cli/cmd/tui/config/legacy-keymap-transform.ts b/packages/opencode/src/cli/cmd/tui/config/legacy-keymap-transform.ts deleted file mode 100644 index 4b266a4ecc..0000000000 --- a/packages/opencode/src/cli/cmd/tui/config/legacy-keymap-transform.ts +++ /dev/null @@ -1,187 +0,0 @@ -import type { KeyEvent, Renderable } from "@opentui/core" -import type { Binding } from "@opentui/keymap" -import type { BindingValue } from "@opentui/keymap/extras" -import { ConfigKeybinds } from "@/config/keybinds" -import { type KeymapConfigInput, type KeymapSection } from "./tui-schema" - -type LegacyKeybinds = Partial -type SectionsConfig = Record>> - -const inputCommands = { - input_submit: "input.submit", - input_newline: "input.newline", - input_move_left: "input.move.left", - input_move_right: "input.move.right", - input_move_up: "input.move.up", - input_move_down: "input.move.down", - input_select_left: "input.select.left", - input_select_right: "input.select.right", - input_select_up: "input.select.up", - input_select_down: "input.select.down", - input_line_home: "input.line.home", - input_line_end: "input.line.end", - input_select_line_home: "input.select.line.home", - input_select_line_end: "input.select.line.end", - input_visual_line_home: "input.visual.line.home", - input_visual_line_end: "input.visual.line.end", - input_select_visual_line_home: "input.select.visual.line.home", - input_select_visual_line_end: "input.select.visual.line.end", - input_buffer_home: "input.buffer.home", - input_buffer_end: "input.buffer.end", - input_select_buffer_home: "input.select.buffer.home", - input_select_buffer_end: "input.select.buffer.end", - input_delete_line: "input.delete.line", - input_delete_to_line_end: "input.delete.to.line.end", - input_delete_to_line_start: "input.delete.to.line.start", - input_backspace: "input.backspace", - input_delete: "input.delete", - input_undo: "input.undo", - input_redo: "input.redo", - input_word_forward: "input.word.forward", - input_word_backward: "input.word.backward", - input_select_word_forward: "input.select.word.forward", - input_select_word_backward: "input.select.word.backward", - input_delete_word_forward: "input.delete.word.forward", - input_delete_word_backward: "input.delete.word.backward", - input_select_all: "input.select.all", -} as const satisfies Partial> - -function add( - config: SectionsConfig, - section: KeymapSection, - command: string, - binding: BindingValue | undefined, -) { - if (binding === undefined) return - config[section] ??= {} - config[section][command] = binding -} - -function bindingWith(key: string | undefined, input: Omit, "key" | "cmd">) { - if (!key) return undefined - if (key === "none") return "none" - return { ...input, key } -} - -function combineBindings(...keys: (string | undefined)[]) { - const result = Array.from( - new Set( - keys.flatMap((key) => { - if (!key || key === "none") return [] - return key - .split(",") - .map((part) => part.trim()) - .filter((part) => part && part !== "none") - }), - ), - ) - if (result.length) return result.join(",") - if (keys.some((key) => key === "none")) return "none" - return undefined -} - -export function create(keybinds: LegacyKeybinds): KeymapConfigInput { - const config: SectionsConfig = {} - - add(config, "global", "command.palette.show", keybinds.command_list) - add(config, "global", "session.list", keybinds.session_list) - add(config, "global", "session.new", keybinds.session_new) - add(config, "global", "model.list", keybinds.model_list) - add(config, "global", "model.cycle_recent", keybinds.model_cycle_recent) - add(config, "global", "model.cycle_recent_reverse", keybinds.model_cycle_recent_reverse) - add(config, "global", "model.cycle_favorite", keybinds.model_cycle_favorite) - add(config, "global", "model.cycle_favorite_reverse", keybinds.model_cycle_favorite_reverse) - add(config, "global", "agent.list", keybinds.agent_list) - add(config, "global", "agent.cycle", keybinds.agent_cycle) - add(config, "global", "agent.cycle.reverse", keybinds.agent_cycle_reverse) - add(config, "global", "variant.cycle", keybinds.variant_cycle) - add(config, "global", "variant.list", keybinds.variant_list) - add(config, "prompt", "prompt.editor", keybinds.editor_open) - add(config, "global", "opencode.status", keybinds.status_view) - add(config, "global", "theme.switch", keybinds.theme_list) - add(config, "global", "app.exit", keybinds.app_exit) - add(config, "global", "terminal.suspend", keybinds.terminal_suspend) - add(config, "global", "terminal.title.toggle", keybinds.terminal_title_toggle) - - add(config, "session", "session.share", keybinds.session_share) - add(config, "session", "session.rename", keybinds.session_rename) - add(config, "session", "session.timeline", keybinds.session_timeline) - add(config, "session", "session.fork", keybinds.session_fork) - add(config, "session", "session.compact", keybinds.session_compact) - add(config, "session", "session.unshare", keybinds.session_unshare) - add(config, "session", "session.undo", keybinds.messages_undo) - add(config, "session", "session.redo", keybinds.messages_redo) - add(config, "session", "session.sidebar.toggle", keybinds.sidebar_toggle) - add(config, "session", "session.toggle.conceal", keybinds.messages_toggle_conceal) - add(config, "session", "session.toggle.thinking", keybinds.display_thinking) - add(config, "session", "session.toggle.actions", keybinds.tool_details) - add(config, "session", "session.toggle.scrollbar", keybinds.scrollbar_toggle) - add(config, "session", "session.page.up", keybinds.messages_page_up) - add(config, "session", "session.page.down", keybinds.messages_page_down) - add(config, "session", "session.line.up", keybinds.messages_line_up) - add(config, "session", "session.line.down", keybinds.messages_line_down) - add(config, "session", "session.half.page.up", keybinds.messages_half_page_up) - add(config, "session", "session.half.page.down", keybinds.messages_half_page_down) - add(config, "session", "session.first", keybinds.messages_first) - add(config, "session", "session.last", keybinds.messages_last) - add(config, "session", "session.messages_last_user", keybinds.messages_last_user) - add(config, "session", "session.message.next", keybinds.messages_next) - add(config, "session", "session.message.previous", keybinds.messages_previous) - add(config, "session", "messages.copy", keybinds.messages_copy) - add(config, "session", "session.export", keybinds.session_export) - add(config, "session", "session.child.first", keybinds.session_child_first) - add(config, "session", "session.parent", keybinds.session_parent) - add(config, "session", "session.child.next", keybinds.session_child_cycle) - add(config, "session", "session.child.previous", keybinds.session_child_cycle_reverse) - - add(config, "prompt", "session.interrupt", keybinds.session_interrupt) - add(config, "prompt", "prompt.clear", keybinds.input_clear) - add(config, "prompt", "prompt.paste", bindingWith(keybinds.input_paste, { preventDefault: false })) - add(config, "prompt", "prompt.history.previous", keybinds.history_previous) - add(config, "prompt", "prompt.history.next", keybinds.history_next) - - add(config, "autocomplete", "prompt.autocomplete.prev", keybinds["prompt.autocomplete.prev"]) - add(config, "autocomplete", "prompt.autocomplete.next", keybinds["prompt.autocomplete.next"]) - add(config, "autocomplete", "prompt.autocomplete.hide", keybinds["prompt.autocomplete.hide"]) - add(config, "autocomplete", "prompt.autocomplete.select", keybinds["prompt.autocomplete.select"]) - add(config, "autocomplete", "prompt.autocomplete.complete", keybinds["prompt.autocomplete.complete"]) - - for (const [legacy, command] of Object.entries(inputCommands) as [keyof typeof inputCommands, string][]) { - add(config, "input", command, keybinds[legacy]) - } - - add(config, "dialog_select", "dialog.select.prev", keybinds["dialog.select.prev"]) - add(config, "dialog_select", "dialog.select.next", keybinds["dialog.select.next"]) - add(config, "dialog_select", "dialog.select.page_up", keybinds["dialog.select.page_up"]) - add(config, "dialog_select", "dialog.select.page_down", keybinds["dialog.select.page_down"]) - add(config, "dialog_select", "dialog.select.home", keybinds["dialog.select.home"]) - add(config, "dialog_select", "dialog.select.end", keybinds["dialog.select.end"]) - add(config, "dialog_select", "dialog.select.submit", keybinds["dialog.select.submit"]) - add(config, "dialog_actions", "dialog.action.delete", combineBindings(keybinds.stash_delete, keybinds.session_delete)) - add(config, "dialog_actions", "dialog.action.rename", keybinds.session_rename) - add( - config, - "dialog_actions", - "dialog.action.toggle", - combineBindings(keybinds["dialog.mcp.toggle"], keybinds["plugins.toggle"]), - ) - add(config, "model", "model.dialog.provider", keybinds.model_provider_list) - add(config, "model", "model.dialog.favorite", keybinds.model_favorite_toggle) - - add(config, "permission", "permission.reject.cancel", keybinds.app_exit) - add(config, "permission", "permission.prompt.escape", keybinds.app_exit) - add(config, "permission", "permission.prompt.fullscreen", keybinds["permission.prompt.fullscreen"]) - add(config, "question", "question.reject", keybinds.app_exit) - add(config, "question", "question.edit.clear", keybinds.input_clear) - - add(config, "plugins", "plugins.list", keybinds.plugin_manager) - add(config, "plugins", "plugin.dialog.install", keybinds["dialog.plugins.install"]) - add(config, "home_tips", "tips.toggle", keybinds.tips_toggle) - - return { - ...(keybinds.leader && keybinds.leader !== "none" && { leader: keybinds.leader }), - sections: config, - } -} - -export * as LegacyKeymapTransform from "./legacy-keymap-transform" diff --git a/packages/opencode/src/cli/cmd/tui/config/tui-schema.ts b/packages/opencode/src/cli/cmd/tui/config/tui-schema.ts index 8e142dc101..d08836e1dd 100644 --- a/packages/opencode/src/cli/cmd/tui/config/tui-schema.ts +++ b/packages/opencode/src/cli/cmd/tui/config/tui-schema.ts @@ -1,339 +1,12 @@ import z from "zod" -import type { KeyEvent, Renderable } from "@opentui/core" -import type { Binding } from "@opentui/keymap" -import type { ResolvedBindingSections } from "@opentui/keymap/extras" import { ConfigPlugin } from "@/config/plugin" -import { ConfigKeybinds } from "@/config/keybinds" +import { TuiKeybind } from "./keybind" -const KeybindOverride = z - .object( - Object.fromEntries(Object.keys(ConfigKeybinds.Keybinds.shape).map((key) => [key, z.string().optional()])) as Record< - string, - z.ZodOptional - >, - ) - .strict() - -const KeyStroke = z - .object({ - name: z.string(), - ctrl: z.boolean().optional(), - shift: z.boolean().optional(), - meta: z.boolean().optional(), - super: z.boolean().optional(), - hyper: z.boolean().optional(), - }) - .strict() - -const KeymapBindingObject = z - .object({ - key: z.union([z.string(), KeyStroke]), - event: z.enum(["press", "release"]).optional(), - preventDefault: z.boolean().optional(), - fallthrough: z.boolean().optional(), - }) - .passthrough() - -const KeymapBindingItem = z.union([z.string(), KeyStroke, KeymapBindingObject]) -const KeymapBindingValue = z.union([z.literal(false), z.literal("none"), KeymapBindingItem, z.array(KeymapBindingItem)]) - -const keymapBinding = (value: z.input | (() => z.input)) => - KeymapBindingValue.prefault(value) -const keymapSection = (shape: Shape) => { - const schema = z.object(shape).strict() - return schema.prefault({} as z.input) -} -const keymapSectionInput = (shape: Shape) => - z - .object( - Object.fromEntries(Object.keys(shape).map((key) => [key, KeymapBindingValue.optional()])) as { - [Key in keyof Shape]: z.ZodOptional - }, - ) - .strict() - -const GlobalKeymapSection = { - "command.palette.show": keymapBinding("ctrl+p"), - "session.list": keymapBinding("l"), - "session.new": keymapBinding("n"), - "model.list": keymapBinding("m"), - "model.cycle_recent": keymapBinding("f2"), - "model.cycle_recent_reverse": keymapBinding("shift+f2"), - "model.cycle_favorite": keymapBinding("none"), - "model.cycle_favorite_reverse": keymapBinding("none"), - "agent.list": keymapBinding("a"), - "mcp.list": keymapBinding("none"), - "agent.cycle": keymapBinding("tab"), - "agent.cycle.reverse": keymapBinding("shift+tab"), - "variant.cycle": keymapBinding("ctrl+t"), - "variant.list": keymapBinding("none"), - "provider.connect": keymapBinding("none"), - "console.org.switch": keymapBinding("none"), - "opencode.status": keymapBinding("s"), - "theme.switch": keymapBinding("t"), - "theme.switch_mode": keymapBinding("none"), - "theme.mode.lock": keymapBinding("none"), - "help.show": keymapBinding("none"), - "docs.open": keymapBinding("none"), - "app.exit": keymapBinding("ctrl+c,ctrl+d,q"), - "app.debug": keymapBinding("none"), - "app.console": keymapBinding("none"), - "app.heap_snapshot": keymapBinding("none"), - "app.toggle.animations": keymapBinding("none"), - "app.toggle.file_context": keymapBinding("none"), - "app.toggle.diffwrap": keymapBinding("none"), - "app.toggle.paste_summary": keymapBinding("none"), - "app.toggle.session_directory_filter": keymapBinding("none"), - "terminal.suspend": keymapBinding(() => (process.platform === "win32" ? "none" : "ctrl+z")), - "terminal.title.toggle": keymapBinding("none"), -} - -const WhichKeyKeymapSection = { - "tui-which-key.toggle": keymapBinding("ctrl+alt+k"), - "tui-which-key.layout.toggle": keymapBinding("ctrl+alt+shift+k"), - "tui-which-key.pending.toggle": keymapBinding("ctrl+alt+shift+p"), - "tui-which-key.group.previous": keymapBinding("ctrl+alt+left,ctrl+alt+["), - "tui-which-key.group.next": keymapBinding("ctrl+alt+right,ctrl+alt+]"), - "tui-which-key.scroll.up": keymapBinding("ctrl+alt+up,ctrl+alt+p"), - "tui-which-key.scroll.down": keymapBinding("ctrl+alt+down,ctrl+alt+n"), - "tui-which-key.page.up": keymapBinding("ctrl+alt+pageup"), - "tui-which-key.page.down": keymapBinding("ctrl+alt+pagedown"), - "tui-which-key.home": keymapBinding("ctrl+alt+home"), - "tui-which-key.end": keymapBinding("ctrl+alt+end"), -} - -const SessionKeymapSection = { - "session.share": keymapBinding("none"), - "session.rename": keymapBinding("ctrl+r"), - "session.timeline": keymapBinding("g"), - "session.fork": keymapBinding("none"), - "session.compact": keymapBinding("c"), - "session.unshare": keymapBinding("none"), - "session.undo": keymapBinding("u"), - "session.redo": keymapBinding("r"), - "session.sidebar.toggle": keymapBinding("b"), - "session.toggle.conceal": keymapBinding("h"), - "session.toggle.timestamps": keymapBinding("none"), - "session.toggle.thinking": keymapBinding("none"), - "session.toggle.actions": keymapBinding("none"), - "session.toggle.scrollbar": keymapBinding("none"), - "session.toggle.generic_tool_output": keymapBinding("none"), - "session.page.up": keymapBinding("pageup,ctrl+alt+b"), - "session.page.down": keymapBinding("pagedown,ctrl+alt+f"), - "session.line.up": keymapBinding("ctrl+alt+y"), - "session.line.down": keymapBinding("ctrl+alt+e"), - "session.half.page.up": keymapBinding("ctrl+alt+u"), - "session.half.page.down": keymapBinding("ctrl+alt+d"), - "session.first": keymapBinding("ctrl+g,home"), - "session.last": keymapBinding("ctrl+alt+g,end"), - "session.messages_last_user": keymapBinding("none"), - "session.message.next": keymapBinding("none"), - "session.message.previous": keymapBinding("none"), - "messages.copy": keymapBinding("y"), - "session.copy": keymapBinding("none"), - "session.export": keymapBinding("x"), - "session.child.first": keymapBinding("down"), - "session.parent": keymapBinding("up"), - "session.child.next": keymapBinding("right"), - "session.child.previous": keymapBinding("left"), -} - -const PromptKeymapSection = { - "prompt.submit": keymapBinding("none"), - "prompt.editor": keymapBinding("e"), - "prompt.editor_context.clear": keymapBinding("none"), - "prompt.skills": keymapBinding("none"), - "prompt.stash": keymapBinding("none"), - "prompt.stash.pop": keymapBinding("none"), - "prompt.stash.list": keymapBinding("none"), - "workspace.set": keymapBinding("none"), - "session.interrupt": keymapBinding("escape"), - "prompt.clear": keymapBinding("ctrl+c"), - "prompt.paste": keymapBinding({ key: "ctrl+v", preventDefault: false }), - "prompt.history.previous": keymapBinding("up"), - "prompt.history.next": keymapBinding("down"), -} - -const AutocompleteKeymapSection = { - "prompt.autocomplete.prev": keymapBinding("up,ctrl+p"), - "prompt.autocomplete.next": keymapBinding("down,ctrl+n"), - "prompt.autocomplete.hide": keymapBinding("escape"), - "prompt.autocomplete.select": keymapBinding("return"), - "prompt.autocomplete.complete": keymapBinding("tab"), -} - -const InputKeymapSection = { - "input.submit": keymapBinding("return"), - "input.newline": keymapBinding("shift+return,ctrl+return,alt+return,ctrl+j"), - "input.move.left": keymapBinding("left,ctrl+b"), - "input.move.right": keymapBinding("right,ctrl+f"), - "input.move.up": keymapBinding("up"), - "input.move.down": keymapBinding("down"), - "input.select.left": keymapBinding("shift+left"), - "input.select.right": keymapBinding("shift+right"), - "input.select.up": keymapBinding("shift+up"), - "input.select.down": keymapBinding("shift+down"), - "input.line.home": keymapBinding("ctrl+a"), - "input.line.end": keymapBinding("ctrl+e"), - "input.select.line.home": keymapBinding("ctrl+shift+a"), - "input.select.line.end": keymapBinding("ctrl+shift+e"), - "input.visual.line.home": keymapBinding("alt+a"), - "input.visual.line.end": keymapBinding("alt+e"), - "input.select.visual.line.home": keymapBinding("alt+shift+a"), - "input.select.visual.line.end": keymapBinding("alt+shift+e"), - "input.buffer.home": keymapBinding("home"), - "input.buffer.end": keymapBinding("end"), - "input.select.buffer.home": keymapBinding("shift+home"), - "input.select.buffer.end": keymapBinding("shift+end"), - "input.delete.line": keymapBinding("ctrl+shift+d"), - "input.delete.to.line.end": keymapBinding("ctrl+k"), - "input.delete.to.line.start": keymapBinding("ctrl+u"), - "input.backspace": keymapBinding("backspace,shift+backspace"), - "input.delete": keymapBinding("ctrl+d,delete,shift+delete"), - "input.undo": keymapBinding(() => (process.platform === "win32" ? "ctrl+z,ctrl+-,super+z" : "ctrl+-,super+z")), - "input.redo": keymapBinding("ctrl+.,super+shift+z"), - "input.word.forward": keymapBinding("alt+f,alt+right,ctrl+right"), - "input.word.backward": keymapBinding("alt+b,alt+left,ctrl+left"), - "input.select.word.forward": keymapBinding("alt+shift+f,alt+shift+right"), - "input.select.word.backward": keymapBinding("alt+shift+b,alt+shift+left"), - "input.delete.word.forward": keymapBinding("alt+d,alt+delete,ctrl+delete"), - "input.delete.word.backward": keymapBinding("ctrl+w,ctrl+backspace,alt+backspace"), - "input.select.all": keymapBinding("super+a"), -} - -const DialogSelectKeymapSection = { - "dialog.select.prev": keymapBinding("up,ctrl+p"), - "dialog.select.next": keymapBinding("down,ctrl+n"), - "dialog.select.page_up": keymapBinding("pageup"), - "dialog.select.page_down": keymapBinding("pagedown"), - "dialog.select.home": keymapBinding("home"), - "dialog.select.end": keymapBinding("end"), - "dialog.select.submit": keymapBinding("return"), -} - -const DialogActionsKeymapSection = { - "dialog.action.toggle": keymapBinding("space"), - "dialog.action.delete": keymapBinding("ctrl+d"), - "dialog.action.rename": keymapBinding("ctrl+r"), -} - -const ModelKeymapSection = { - "model.dialog.provider": keymapBinding("ctrl+a"), - "model.dialog.favorite": keymapBinding("ctrl+f"), -} - -const PermissionKeymapSection = { - "permission.reject.cancel": keymapBinding("ctrl+c,ctrl+d,q"), - "permission.prompt.escape": keymapBinding("ctrl+c,ctrl+d,q"), - "permission.prompt.fullscreen": keymapBinding("ctrl+f"), -} - -const QuestionKeymapSection = { - "question.reject": keymapBinding("ctrl+c,ctrl+d,q"), - "question.edit.clear": keymapBinding("ctrl+c"), -} - -const PluginsKeymapSection = { - "plugins.list": keymapBinding("none"), - "plugins.install": keymapBinding("none"), - "plugin.dialog.install": keymapBinding("shift+i"), -} - -const HomeTipsKeymapSection = { - "tips.toggle": keymapBinding("h"), -} - -const KeymapSectionsShape = { - global: keymapSection(GlobalKeymapSection), - which_key: keymapSection(WhichKeyKeymapSection), - session: keymapSection(SessionKeymapSection), - prompt: keymapSection(PromptKeymapSection), - autocomplete: keymapSection(AutocompleteKeymapSection), - input: keymapSection(InputKeymapSection), - dialog_select: keymapSection(DialogSelectKeymapSection), - dialog_actions: keymapSection(DialogActionsKeymapSection), - model: keymapSection(ModelKeymapSection), - permission: keymapSection(PermissionKeymapSection), - question: keymapSection(QuestionKeymapSection), - plugins: keymapSection(PluginsKeymapSection), - home_tips: keymapSection(HomeTipsKeymapSection), -} - -const KeymapSectionsInputShape = { - global: keymapSectionInput(GlobalKeymapSection).optional(), - which_key: keymapSectionInput(WhichKeyKeymapSection).optional(), - session: keymapSectionInput(SessionKeymapSection).optional(), - prompt: keymapSectionInput(PromptKeymapSection).optional(), - autocomplete: keymapSectionInput(AutocompleteKeymapSection).optional(), - input: keymapSectionInput(InputKeymapSection).optional(), - dialog_select: keymapSectionInput(DialogSelectKeymapSection).optional(), - dialog_actions: keymapSectionInput(DialogActionsKeymapSection).optional(), - model: keymapSectionInput(ModelKeymapSection).optional(), - permission: keymapSectionInput(PermissionKeymapSection).optional(), - question: keymapSectionInput(QuestionKeymapSection).optional(), - plugins: keymapSectionInput(PluginsKeymapSection).optional(), - home_tips: keymapSectionInput(HomeTipsKeymapSection).optional(), -} - -export const KeymapSections = z.object(KeymapSectionsShape).strict().prefault({}) -export type KeymapSections = z.output -export type KeymapSection = keyof KeymapSections -export const KeymapSectionNames = Object.keys(KeymapSectionsShape) as KeymapSection[] export const KeymapLeaderTimeoutDefault = 2000 -export type KeymapInfo = { - leader: string - leader_timeout: number -} & ResolvedBindingSections - -export const KeymapSectionGroups = { - global: "Global", - which_key: "System", - session: "Session", - prompt: "Prompt", - autocomplete: "Autocomplete", - input: "Text Editing", - dialog_select: "Dialog", - dialog_actions: "Dialog", - model: "Model", - permission: "Permission", - question: "Question", - plugins: "Plugins", - home_tips: "Home", -} satisfies Record - -export function keymapBindingDefaults(input: { section: string; binding: Readonly> }) { - if (input.binding.group !== undefined) return - if (!Object.hasOwn(KeymapSectionGroups, input.section)) return - return { group: KeymapSectionGroups[input.section as KeymapSection] } -} - -export const KeymapConfig = z - .object({ - leader: z.string().prefault("ctrl+x"), - leader_timeout: z - .number() - .int() - .positive() - .prefault(KeymapLeaderTimeoutDefault) - .describe("Leader key timeout in milliseconds"), - sections: KeymapSections, - }) - .strict() - .describe("TUI keymap configuration") -export type KeymapConfig = z.output - -const KeymapSectionsInput = z.object(KeymapSectionsInputShape).strict().optional() -export const KeymapConfigInput = z - .object({ - leader: z.string().optional(), - leader_timeout: z.number().int().positive().optional().describe("Leader key timeout in milliseconds"), - sections: KeymapSectionsInput, - }) - .strict() - .describe("TUI keymap configuration") -export type KeymapConfigInput = z.output +const KeymapLeaderTimeout = z.number().int().positive().describe("Leader key timeout in milliseconds") export const TuiOptions = z.object({ + leader_timeout: KeymapLeaderTimeout.optional(), scroll_speed: z.number().min(0.001).optional().describe("TUI scroll speed"), scroll_acceleration: z .object({ @@ -352,17 +25,11 @@ export const TuiInfo = z .object({ $schema: z.string().optional(), theme: z.string().optional(), - keybinds: KeybindOverride.optional().meta({ - deprecated: true, - description: "Use keymap instead. This will be removed in opencode v2.0.", - }), - keymap: KeymapConfigInput.optional(), + keybinds: TuiKeybind.KeybindOverrides.optional(), plugin: ConfigPlugin.Spec.zod.array().optional(), plugin_enabled: z.record(z.string(), z.boolean()).optional(), }) .extend(TuiOptions.shape) .strict() -export const TuiJsonSchemaInfo = TuiInfo.extend({ - keymap: KeymapConfig.optional(), -}).strict() +export const TuiJsonSchemaInfo = TuiInfo diff --git a/packages/opencode/src/cli/cmd/tui/config/tui.ts b/packages/opencode/src/cli/cmd/tui/config/tui.ts index 429d7e5c1c..14d9918160 100644 --- a/packages/opencode/src/cli/cmd/tui/config/tui.ts +++ b/packages/opencode/src/cli/cmd/tui/config/tui.ts @@ -1,29 +1,26 @@ export * as TuiConfig from "./tui" import type z from "zod" -import type { KeyEvent, Renderable } from "@opentui/core" -import { resolveBindingSections, type BindingSectionsConfig } from "@opentui/keymap/extras" +import { createBindingLookup } from "@opentui/keymap/extras" import { mergeDeep, unique } from "remeda" import { Context, Effect, Fiber, Layer } from "effect" import { ConfigParse } from "@/config/parse" import * as ConfigPaths from "@/config/paths" import { migrateTuiConfig } from "./tui-migrate" -import { KeymapConfig, TuiInfo, TuiJsonSchemaInfo } from "./tui-schema" +import { KeymapLeaderTimeoutDefault, TuiInfo, TuiJsonSchemaInfo } from "./tui-schema" import { Flag } from "@opencode-ai/core/flag/flag" import { isRecord } from "@/util/record" import { Global } from "@opencode-ai/core/global" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { CurrentWorkingDirectory } from "./cwd" import { ConfigPlugin } from "@/config/plugin" -import { ConfigKeybinds } from "@/config/keybinds" +import { TuiKeybind } from "./keybind" import { InstallationLocal, InstallationVersion } from "@opencode-ai/core/installation/version" import { makeRuntime } from "@opencode-ai/core/effect/runtime" import { Filesystem } from "@/util/filesystem" import * as Log from "@opencode-ai/core/util/log" import { ConfigVariable } from "@/config/variable" import { Npm } from "@opencode-ai/core/npm" -import { LegacyKeymapTransform } from "./legacy-keymap-transform" -import { KeymapSectionNames, keymapBindingDefaults, type KeymapInfo, type KeymapSection } from "./tui-schema" const log = Log.create({ service: "tui.config" }) @@ -36,9 +33,9 @@ type Acc = { plugin_origins: ConfigPlugin.Origin[] } -export type Resolved = Omit & { - keybinds: ConfigKeybinds.Keybinds - keymap: KeymapInfo +export type Resolved = Omit & { + keybinds: TuiKeybind.BindingLookupView + leader_timeout: number // Internal resolved plugin list used by runtime loading. plugin_origins?: ConfigPlugin.Origin[] } @@ -186,31 +183,18 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: keybinds.terminal_suspend = "none" keybinds.input_undo ??= unique([ "ctrl+z", - ...ConfigKeybinds.Keybinds.shape.input_undo.parse(undefined).split(","), + ...String(TuiKeybind.Keybinds.shape.input_undo.parse(undefined)).split(","), ]).join(",") } - const parsedKeybinds = ConfigKeybinds.Keybinds.parse(keybinds) - const keymapInput = acc.result.keymap ?? LegacyKeymapTransform.create(acc.result.keybinds ?? {}) - const keymapConfig = KeymapConfig.parse(keymapInput) - const keymap = { - leader: !keymapConfig.leader || keymapConfig.leader === "none" ? "ctrl+x" : keymapConfig.leader, - leader_timeout: keymapConfig.leader_timeout, - ...resolveBindingSections, KeymapSection>( - keymapConfig.sections, - { - sections: KeymapSectionNames, - bindingDefaults: keymapBindingDefaults, - }, - ), - } + const parsedKeybinds = TuiKeybind.Keybinds.parse(keybinds) const result: Resolved = { ...acc.result, - keybinds: parsedKeybinds, + keybinds: createBindingLookup(TuiKeybind.toBindingConfig(parsedKeybinds), { + commandMap: TuiKeybind.CommandMap, + bindingDefaults: TuiKeybind.bindingDefaults(), + }), + leader_timeout: acc.result.leader_timeout ?? KeymapLeaderTimeoutDefault, plugin_origins: acc.plugin_origins.length ? acc.plugin_origins : undefined, - // `keybinds` is deprecated and will be removed in opencode v2.0. Keep it - // only as the legacy fallback; once `keymap` is configured, ignore - // `keybinds` for keymap resolution. - keymap, } return { diff --git a/packages/opencode/src/cli/cmd/tui/context/aggregate-failures.ts b/packages/opencode/src/cli/cmd/tui/context/aggregate-failures.ts new file mode 100644 index 0000000000..63b3fb4487 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/context/aggregate-failures.ts @@ -0,0 +1,34 @@ +/** + * Aggregate Promise.allSettled results into a single Error that names every + * failed endpoint, or return null when all fulfilled. Used at TUI bootstrap + * boundaries so a single 4xx doesn't drown its parallel siblings as + * unhandled rejections — every failure surfaces in one labeled message. + */ +export type LabeledSettled = { + name: string + result: PromiseSettledResult +} + +export function aggregateFailures(labeled: LabeledSettled[]): Error | null { + const failed = labeled.filter( + (x): x is { name: string; result: PromiseRejectedResult } => x.result.status === "rejected", + ) + if (failed.length === 0) return null + + const reasons = failed.map((f) => `${f.name}: ${reasonMessage(f.result.reason)}`).join("; ") + const summary = `${failed.length} of ${labeled.length} requests failed: ${reasons}` + const err = new Error(summary) + err.cause = { failures: failed.map((f) => ({ name: f.name, reason: f.result.reason })) } + return err +} + +function reasonMessage(reason: unknown): string { + if (reason instanceof Error) return reason.message + if (typeof reason === "string") return reason + if (reason && typeof reason === "object") { + const obj = reason as { message?: unknown; name?: unknown } + if (typeof obj.message === "string") return obj.message + if (typeof obj.name === "string") return obj.name + } + return String(reason) +} diff --git a/packages/opencode/src/cli/cmd/tui/context/path-format.tsx b/packages/opencode/src/cli/cmd/tui/context/path-format.tsx new file mode 100644 index 0000000000..1c9f19c6c6 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/context/path-format.tsx @@ -0,0 +1,39 @@ +import path from "path" +import { createContext, useContext, type ParentProps } from "solid-js" +import { Global } from "@opencode-ai/core/global" + +const context = createContext<{ + path: () => string + format: (input?: string) => string +}>() + +export function PathFormatterProvider(props: ParentProps<{ path: string | undefined }>) { + return ( + props.path || process.cwd(), format: (input) => formatPath(input, props.path) }} + > + {props.children} + + ) +} + +export function usePathFormatter() { + const value = useContext(context) + if (!value) throw new Error("PathFormatter context must be used within a PathFormatterProvider") + return value +} + +function formatPath(input: string | undefined, base: string | undefined) { + if (!input) return "" + + const root = base || process.cwd() + const absolute = path.isAbsolute(input) ? input : path.resolve(root, input) + const relative = path.relative(root, absolute) + + if (!relative) return "." + if (relative !== ".." && !relative.startsWith(".." + path.sep)) return relative + if (Global.Path.home && (absolute === Global.Path.home || absolute.startsWith(Global.Path.home + path.sep))) { + return absolute.replace(Global.Path.home, "~") + } + return absolute +} diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index 24609dd81e..0d4cb2e6e2 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -32,6 +32,7 @@ import * as Log from "@opencode-ai/core/util/log" import { emptyConsoleState, type ConsoleState } from "@/config/console-state" import path from "path" import { useKV } from "./kv" +import { aggregateFailures } from "./aggregate-failures" export const { use: useSync, provider: SyncProvider } = createSimpleContext({ name: "Sync", @@ -391,16 +392,23 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ .catch(() => emptyConsoleState) const agentsPromise = sdk.client.app.agents({ workspace }, { throwOnError: true }) const configPromise = sdk.client.config.get({ workspace }, { throwOnError: true }) - const blockingRequests: Promise[] = [ - providersPromise, - providerListPromise, - agentsPromise, - configPromise, - projectPromise, - ...(args.continue ? [sessionListPromise] : []), + const blockingRequests: { name: string; promise: Promise }[] = [ + { name: "config.providers", promise: providersPromise }, + { name: "provider.list", promise: providerListPromise }, + { name: "app.agents", promise: agentsPromise }, + { name: "config.get", promise: configPromise }, + { name: "project.sync", promise: projectPromise }, + ...(args.continue ? [{ name: "session.list", promise: sessionListPromise }] : []), ] - await Promise.all(blockingRequests) + await Promise.allSettled(blockingRequests.map((r) => r.promise)) + .then((settled) => { + // Surface every failed endpoint in one labeled message instead of + // letting the first rejection drown its siblings as unhandled + // rejections. + const failure = aggregateFailures(blockingRequests.map((r, i) => ({ name: r.name, result: settled[i] }))) + if (failure) throw failure + }) .then(async () => { const providersResponse = providersPromise.then((x) => x.data!) const providerListResponse = providerListPromise.then((x) => x.data!) @@ -526,10 +534,12 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ if (match.found) draft.session[match.index] = session.data! if (!match.found) draft.session.splice(match.index, 0, session.data!) draft.todo[sessionID] = todo.data ?? [] - draft.message[sessionID] = messages.data!.map((x) => x.info) - for (const message of messages.data!) { + const infos: (typeof draft.message)[string] = [] + for (const message of messages.data ?? []) { + infos.push(message.info) draft.part[message.info.id] = message.parts } + draft.message[sessionID] = infos draft.session_diff[sessionID] = diff.data ?? [] }), ) diff --git a/packages/opencode/src/cli/cmd/tui/event.ts b/packages/opencode/src/cli/cmd/tui/event.ts index fbe5ce7f9f..bebb1fc6aa 100644 --- a/packages/opencode/src/cli/cmd/tui/event.ts +++ b/packages/opencode/src/cli/cmd/tui/event.ts @@ -1,6 +1,6 @@ import { BusEvent } from "@/bus/bus-event" import { SessionID } from "@/session/schema" -import { PositiveInt } from "@/util/schema" +import { PositiveInt } from "@opencode-ai/core/schema" import { Effect, Schema } from "effect" const DEFAULT_TOAST_DURATION = 5000 diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/home/tips.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/home/tips.tsx index beb92578fa..69071b1f7c 100644 --- a/packages/opencode/src/cli/cmd/tui/feature-plugins/home/tips.tsx +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/home/tips.tsx @@ -20,7 +20,7 @@ function View(props: { api: TuiPluginApi; hidden: boolean; show: boolean; connec }, }, ], - bindings: props.api.tuiConfig.keymap.sections.home_tips, + bindings: props.api.tuiConfig.keybinds.get("tips.toggle"), })) return ( diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/lsp.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/lsp.tsx index 5127ca0e4b..f640fbee90 100644 --- a/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/lsp.tsx +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/lsp.tsx @@ -8,7 +8,7 @@ function View(props: { api: TuiPluginApi }) { const [open, setOpen] = createSignal(true) const theme = () => props.api.theme.current const list = createMemo(() => props.api.state.lsp()) - const off = createMemo(() => props.api.state.config.lsp === false) + const off = createMemo(() => !props.api.state.config.lsp) return ( @@ -22,9 +22,7 @@ function View(props: { api: TuiPluginApi }) { - - {off() ? "LSPs have been disabled in settings" : "LSPs will activate as files are read"} - + {off() ? "LSPs are disabled" : "LSPs will activate as files are read"} {(item) => ( diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/system/plugins.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/plugins.tsx index 34666ff88c..2cf03c4a8f 100644 --- a/packages/opencode/src/cli/cmd/tui/feature-plugins/system/plugins.tsx +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/plugins.tsx @@ -207,7 +207,7 @@ function View(props: { api: TuiPluginApi }) { actions={[ { title: "toggle", - command: "dialog.action.toggle", + command: "plugins.toggle", disabled: lock(), onTrigger: (item) => { setCur(item.value) @@ -216,14 +216,13 @@ function View(props: { api: TuiPluginApi }) { }, { title: "install", - command: "plugin.dialog.install", + command: "dialog.plugins.install", disabled: lock(), onTrigger: () => { showInstall(props.api) }, }, ]} - bindings={props.api.tuiConfig.keymap.pick("plugins", ["plugin.dialog.install"])} onSelect={(item) => { setCur(item.value) flip(item.value) @@ -258,7 +257,7 @@ const tui: TuiPlugin = async (api) => { }, }, ], - bindings: api.tuiConfig.keymap.omit("plugins", ["plugin.dialog.install"]), + bindings: api.tuiConfig.keybinds.gather("plugins.palette", ["plugins.list", "plugins.install"]), }) } diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/system/which-key.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/which-key.tsx index 2735939a00..3fcd244a2b 100644 --- a/packages/opencode/src/cli/cmd/tui/feature-plugins/system/which-key.tsx +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/which-key.tsx @@ -8,17 +8,17 @@ import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui" import type { InternalTuiPlugin } from "../../plugin/internal" const command = { - toggle: "tui-which-key.toggle", - toggleLayout: "tui-which-key.layout.toggle", - togglePending: "tui-which-key.pending.toggle", - groupPrevious: "tui-which-key.group.previous", - groupNext: "tui-which-key.group.next", - scrollUp: "tui-which-key.scroll.up", - scrollDown: "tui-which-key.scroll.down", - pageUp: "tui-which-key.page.up", - pageDown: "tui-which-key.page.down", - home: "tui-which-key.home", - end: "tui-which-key.end", + toggle: "which-key.toggle", + toggleLayout: "which-key.layout.toggle", + togglePending: "which-key.pending.toggle", + groupPrevious: "which-key.group.previous", + groupNext: "which-key.group.next", + scrollUp: "which-key.scroll.up", + scrollDown: "which-key.scroll.down", + pageUp: "which-key.page.up", + pageDown: "which-key.page.down", + home: "which-key.home", + end: "which-key.end", } as const const LAYER_PRIORITY = 900 @@ -112,8 +112,7 @@ function skin(api: TuiPluginApi): Skin { } function activeKeyLabel(active: ActiveKey) { - const group = text(active.bindingAttrs?.group) - if (active.continues) return group ?? text(active.tokenName) ?? UNKNOWN + if (active.continues) return text(active.tokenName) ?? text(active.display) ?? UNKNOWN return ( text(active.commandAttrs?.title) ?? text(active.bindingAttrs?.desc) ?? text(active.commandAttrs?.desc) ?? UNKNOWN ) @@ -361,7 +360,9 @@ function WhichKeyPanel(props: { }, }, ], - bindings: props.api.tuiConfig.keymap.pick("which_key", pendingMode() ? scrollCommands : panelCommands), + bindings: pendingMode() + ? props.api.tuiConfig.keybinds.gather("which-key.scroll", scrollCommands) + : props.api.tuiConfig.keybinds.gather("which-key.panel", panelCommands), })) createEffect(() => { @@ -571,7 +572,7 @@ const tui: TuiPlugin = async (api) => { }, }, ], - bindings: api.tuiConfig.keymap.pick("which_key", toggleCommands), + bindings: api.tuiConfig.keybinds.gather("which-key.toggle", toggleCommands), }) api.slots.register({ @@ -599,7 +600,7 @@ const tui: TuiPlugin = async (api) => { } const plugin: InternalTuiPlugin = { - id: "tui-which-key", + id: "which-key", enabled: false, tui, } diff --git a/packages/opencode/src/cli/cmd/tui/keymap.tsx b/packages/opencode/src/cli/cmd/tui/keymap.tsx index 0d65057d79..289bb901d6 100644 --- a/packages/opencode/src/cli/cmd/tui/keymap.tsx +++ b/packages/opencode/src/cli/cmd/tui/keymap.tsx @@ -1,5 +1,6 @@ import { type CliRenderer } from "@opentui/core" import * as addons from "@opentui/keymap/addons/opentui" +import { stringifyKeyStroke } from "@opentui/keymap" import { formatCommandBindings as formatCommandBindingsExtra, formatKeySequence as formatKeySequenceExtra, @@ -7,13 +8,14 @@ import { import { KeymapProvider, reactiveMatcherFromSignal, - useBindings, useKeymap, useKeymapSelector, + useBindings, } from "@opentui/keymap/solid" import type { Accessor } from "solid-js" import type { TuiConfig } from "./config/tui" import { useTuiConfig } from "./context/tui-config" +import { TuiKeybind } from "./config/keybind" export const LEADER_TOKEN = "leader" @@ -24,10 +26,77 @@ export { reactiveMatcherFromSignal, useBindings, useKeymapSelector } export type OpenTuiKeymap = ReturnType +const KEY_ALIASES = { + enter: "return", + esc: "escape", +} as const + +function expandKeyAliases(input: string) { + const result = Object.entries(KEY_ALIASES).reduce( + (acc, [alias, key]) => acc.replace(new RegExp(`(^|[+,\\s>])${alias}(?=$|[+,\\s<])`, "gi"), `$1${key}`), + input, + ) + if (result === input) return + return result +} + +function registerKeyAliases(keymap: OpenTuiKeymap) { + return keymap.appendBindingExpander((ctx) => { + const key = expandKeyAliases(ctx.input) + if (!key) return + return [{ key, displays: ctx.displays }] + }) +} + +const inputCommands = [ + "input.move.left", + "input.move.right", + "input.move.up", + "input.move.down", + "input.select.left", + "input.select.right", + "input.select.up", + "input.select.down", + "input.line.home", + "input.line.end", + "input.select.line.home", + "input.select.line.end", + "input.visual.line.home", + "input.visual.line.end", + "input.select.visual.line.home", + "input.select.visual.line.end", + "input.buffer.home", + "input.buffer.end", + "input.select.buffer.home", + "input.select.buffer.end", + "input.delete.line", + "input.delete.to.line.end", + "input.delete.to.line.start", + "input.backspace", + "input.delete", + "input.newline", + "input.undo", + "input.redo", + "input.word.forward", + "input.word.backward", + "input.select.word.forward", + "input.select.word.backward", + "input.delete.word.forward", + "input.delete.word.backward", + "input.select.all", + "input.submit", +] as const + +function leaderDisplay(config: TuiConfig.Resolved) { + const key = config.keybinds.get(LEADER_TOKEN)?.[0]?.key + if (!key) return TuiKeybind.LeaderDefault + return typeof key === "string" ? key : stringifyKeyStroke(key) +} + function formatOptions(config: TuiConfig.Resolved) { return { tokenDisplay: { - [LEADER_TOKEN]: config.keymap.leader, + [LEADER_TOKEN]: leaderDisplay(config), }, keyNameAliases: { pageup: "pgup", @@ -51,19 +120,24 @@ export function formatKeyBindings( return formatCommandBindingsExtra(bindings, formatOptions(config)) } -export function registerOpencodeKeymap(keymap: OpenTuiKeymap, renderer: CliRenderer, config: TuiConfig.Resolved) { +export function registerOpencodeKeymap( + keymap: OpenTuiKeymap, + renderer: CliRenderer, + config: Pick, +) { const offCommaBindings = addons.registerCommaBindings(keymap) + const offAliasExpander = registerKeyAliases(keymap) const offBaseLayout = addons.registerBaseLayoutFallback(keymap) const offLeader = addons.registerTimedLeader(keymap, { - trigger: config.keymap.leader, + trigger: config.keybinds.get(LEADER_TOKEN), name: LEADER_TOKEN, - timeoutMs: config.keymap.leader_timeout, + timeoutMs: config.leader_timeout, }) const offEscape = addons.registerEscapeClearsPendingSequence(keymap) const offBackspace = addons.registerBackspacePopsPendingSequence(keymap) const offInputBindings = addons.registerManagedTextareaLayer(keymap, renderer, { enabled: () => renderer.currentFocusedEditor !== null, - bindings: config.keymap.sections.input, + bindings: config.keybinds.gather("input", inputCommands), }) return () => { @@ -71,6 +145,7 @@ export function registerOpencodeKeymap(keymap: OpenTuiKeymap, renderer: CliRende offBackspace() offEscape() offLeader() + offAliasExpander() offBaseLayout() offCommaBindings() } diff --git a/packages/opencode/src/cli/cmd/tui/plugin/api.tsx b/packages/opencode/src/cli/cmd/tui/plugin/api.tsx index 7b7ce0bbb5..54059f4a2d 100644 --- a/packages/opencode/src/cli/cmd/tui/plugin/api.tsx +++ b/packages/opencode/src/cli/cmd/tui/plugin/api.tsx @@ -17,6 +17,7 @@ import { Slot as HostSlot } from "./slots" import type { useToast } from "../ui/toast" import { InstallationVersion } from "@opencode-ai/core/installation/version" import * as Keymap from "../keymap" +import { createCommandShim } from "./command-shim" type RouteEntry = { key: symbol @@ -147,7 +148,9 @@ function stateApi(sync: ReturnType): TuiPluginApi["state"] { return sync.data.session.length }, diff(sessionID) { - return sync.data.session_diff[sessionID] ?? [] + return (sync.data.session_diff[sessionID] ?? []).flatMap((item) => + item.file === undefined ? [] : [{ ...item, file: item.file }], + ) }, todo(sessionID) { return sync.data.todo[sessionID] ?? [] @@ -200,6 +203,8 @@ export function createTuiApi(input: Input): TuiPluginApi { } return { app: appApi(), + // Keep deprecated `api.command` working for v1 plugins; remove in v2. + command: createCommandShim(input.keymap, input.dialog, input.tuiConfig.keybinds), keys: { formatSequence(parts) { return Keymap.formatKeySequence(parts, input.tuiConfig) diff --git a/packages/opencode/src/cli/cmd/tui/plugin/command-shim.ts b/packages/opencode/src/cli/cmd/tui/plugin/command-shim.ts new file mode 100644 index 0000000000..61eb833fe7 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/plugin/command-shim.ts @@ -0,0 +1,109 @@ +// Legacy `api.command` bridge for v1 plugins; remove in v2. +import type { TuiCommand, TuiPluginApi } from "@opencode-ai/plugin/tui" +import { TuiKeybind } from "../config/keybind" +import type { DialogContext } from "../ui/dialog" + +const COMMAND_PALETTE_SHOW = "command.palette.show" +const warned = new Set() + +type Warn = (api: string, replacement: string) => void +type LegacyDialog = TuiPluginApi["ui"]["dialog"] +type CommandShimDialog = DialogContext | LegacyDialog +type LegacyKeybinds = TuiPluginApi["tuiConfig"]["keybinds"] + +function warnCommandShim(api: string, replacement: string) { + // Warn v1 plugins about deprecated `api.command`; remove this shim path in v2. + console.warn("[tui.plugin] deprecated TUI plugin API", { api, replacement }) +} + +function createCommandShimDialog(dialog: CommandShimDialog): LegacyDialog { + if (!("stack" in dialog)) return dialog + return { + replace(render, onClose) { + dialog.replace(render, onClose) + }, + clear() { + dialog.clear() + }, + setSize(size) { + dialog.setSize(size) + }, + get size() { + return dialog.size + }, + get depth() { + return dialog.stack.length + }, + get open() { + return dialog.stack.length > 0 + }, + } +} + +function warnOnce(api: string, replacement: string, warn: Warn) { + if (warned.has(api)) return + warned.add(api) + warn(api, replacement) +} + +function toCommand(item: TuiCommand, dialog: LegacyDialog) { + return { + namespace: "palette", + name: item.value, + title: item.title, + desc: item.description, + category: item.category, + suggested: item.suggested, + hidden: item.hidden, + enabled: item.enabled, + slashName: item.slash?.name, + slashAliases: item.slash?.aliases, + run() { + return item.onSelect?.(dialog) + }, + } +} + +function toBindings(commands: TuiCommand[], keybinds: LegacyKeybinds) { + return commands.flatMap((item) => + item.keybind + ? keybinds.has(TuiKeybind.CommandMap[item.keybind as keyof typeof TuiKeybind.CommandMap] ?? item.keybind) + ? keybinds + .get(TuiKeybind.CommandMap[item.keybind as keyof typeof TuiKeybind.CommandMap] ?? item.keybind) + .map((binding) => ({ ...binding, cmd: item.value, desc: binding.desc ?? item.title })) + : [ + { + key: item.keybind, + cmd: item.value, + desc: item.title, + }, + ] + : [], + ) +} + +export function createCommandShim( + keymap: TuiPluginApi["keymap"], + dialog: CommandShimDialog, + keybinds: LegacyKeybinds, +): TuiPluginApi["command"] { + const shimDialog = createCommandShimDialog(dialog) + return { + register(cb) { + warnOnce("api.command.register", "api.keymap.registerLayer({ commands, bindings })", warnCommandShim) + const commands = cb() + return keymap.registerLayer({ + commands: commands.map((item) => toCommand(item, shimDialog)), + bindings: toBindings(commands, keybinds), + }) + }, + trigger(value) { + warnOnce("api.command.trigger", "api.keymap.dispatchCommand(name)", warnCommandShim) + keymap.dispatchCommand(value) + }, + show() { + warnOnce("api.command.show", `api.keymap.dispatchCommand("${COMMAND_PALETTE_SHOW}")`, warnCommandShim) + keymap.dispatchCommand(COMMAND_PALETTE_SHOW) + }, + } +} diff --git a/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts b/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts index 91ccaaaa01..64961b20f7 100644 --- a/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts +++ b/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts @@ -39,6 +39,7 @@ import { INTERNAL_TUI_PLUGINS, type InternalTuiPlugin } from "./internal" import { setupSlots, Slot as View } from "./slots" import type { HostPluginApi, HostSlots } from "./slots" import { ConfigPlugin } from "@/config/plugin" +import { createCommandShim } from "./command-shim" ensureRuntimePluginSupport({ additional: keymapRuntimeModules }) @@ -576,6 +577,8 @@ function pluginApi(runtime: RuntimeState, plugin: PluginEntry, scope: PluginScop return { app: api.app, + // Keep deprecated `api.command` working for v1 plugins; remove in v2. + command: createCommandShim(keymap, api.ui.dialog, api.tuiConfig.keybinds), keys: api.keys, keymap, route, diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index f214540c96..b2ee3af622 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -75,7 +75,6 @@ import stripAnsi from "strip-ansi" import { usePromptRef } from "../../context/prompt" import { useExit } from "../../context/exit" import { Filesystem } from "@/util/filesystem" -import { Global } from "@opencode-ai/core/global" import { PermissionPrompt } from "./permission" import { QuestionPrompt } from "./question" import { DialogExportOptions } from "../../ui/dialog-export-options" @@ -90,12 +89,69 @@ import { SessionRetry } from "@/session/retry" import { getRevertDiffFiles } from "../../util/revert-diff" import { useCommandPalette } from "../../context/command-palette" import { useBindings, useCommandShortcut } from "../../keymap" +import { PathFormatterProvider, usePathFormatter } from "../../context/path-format" addDefaultParsers(parsers.parsers) -const GO_UPSELL_LAST_SEEN_AT = "go_upsell_last_seen_at" -const GO_UPSELL_DONT_SHOW = "go_upsell_dont_show" +const GO_UPSELL_FREE_TIER_LAST_SEEN_AT = "go_upsell_last_seen_at" +const GO_UPSELL_FREE_TIER_DONT_SHOW = "go_upsell_dont_show" +const GO_UPSELL_ACCOUNT_RATE_LIMIT_LAST_SEEN_AT = "go_upsell_account_rate_limit_last_seen_at" +const GO_UPSELL_ACCOUNT_RATE_LIMIT_DONT_SHOW = "go_upsell_account_rate_limit_dont_show" const GO_UPSELL_WINDOW = 86_400_000 // 24 hrs +const GO_UPSELL_PROVIDERS = new Set(["opencode", "opencode-go"]) + +function goUpsellKeys(action: SessionRetry.Retryable["action"]) { + if (!action) return + if (!GO_UPSELL_PROVIDERS.has(action.provider)) return + if (action.reason === "free_tier_limit") { + return { + lastSeenAt: GO_UPSELL_FREE_TIER_LAST_SEEN_AT, + dontShow: GO_UPSELL_FREE_TIER_DONT_SHOW, + } + } + if (action.reason === "account_rate_limit") { + return { + lastSeenAt: GO_UPSELL_ACCOUNT_RATE_LIMIT_LAST_SEEN_AT, + dontShow: GO_UPSELL_ACCOUNT_RATE_LIMIT_DONT_SHOW, + } + } +} + +const sessionBindingCommands = [ + "session.share", + "session.rename", + "session.timeline", + "session.fork", + "session.compact", + "session.unshare", + "session.undo", + "session.redo", + "session.sidebar.toggle", + "session.toggle.conceal", + "session.toggle.timestamps", + "session.toggle.thinking", + "session.toggle.actions", + "session.toggle.scrollbar", + "session.toggle.generic_tool_output", + "session.page.up", + "session.page.down", + "session.line.up", + "session.line.down", + "session.half.page.up", + "session.half.page.down", + "session.first", + "session.last", + "session.messages_last_user", + "session.message.next", + "session.message.previous", + "messages.copy", + "session.copy", + "session.export", + "session.child.first", + "session.parent", + "session.child.next", + "session.child.previous", +] as const const context = createContext<{ width: number @@ -124,9 +180,6 @@ export function Session() { const event = useEvent() const project = useProject() const tuiConfig = useTuiConfig() - const { - keymap: { sections }, - } = tuiConfig const kv = useKV() const { theme } = useTheme() const promptRef = usePromptRef() @@ -263,14 +316,17 @@ export function Session() { if (!evt.properties.status.action) return if (dialog.stack.length > 0) return - const seen = kv.get(GO_UPSELL_LAST_SEEN_AT) + const keys = goUpsellKeys(evt.properties.status.action) + if (!keys) return + + const seen = kv.get(keys.lastSeenAt) if (typeof seen === "number" && Date.now() - seen < GO_UPSELL_WINDOW) return - if (kv.get(GO_UPSELL_DONT_SHOW)) return + if (kv.get(keys.dontShow)) return void DialogRetryAction.show(dialog, evt.properties.status.action).then((dontShowAgain) => { - if (dontShowAgain) kv.set(GO_UPSELL_DONT_SHOW, true) - kv.set(GO_UPSELL_LAST_SEEN_AT, Date.now()) + if (dontShowAgain) kv.set(keys.dontShow, true) + kv.set(keys.lastSeenAt, Date.now()) }) }) @@ -690,7 +746,7 @@ export function Session() { title: "Line up", value: "session.line.up", category: "Session", - enabled: false, + hidden: true, run: () => { scroll.scrollBy(-1) dialog.clear() @@ -700,7 +756,7 @@ export function Session() { title: "Line down", value: "session.line.down", category: "Session", - enabled: false, + hidden: true, run: () => { scroll.scrollBy(1) dialog.clear() @@ -992,7 +1048,7 @@ export function Session() { useBindings(() => ({ enabled: command.matcher, - bindings: sections.session, + bindings: tuiConfig.keybinds.gather("session", sessionBindingCommands), })) const revertInfo = createMemo(() => session()?.revert) @@ -1022,199 +1078,201 @@ export function Session() { createEffect(on(() => route.sessionID, toBottom)) return ( - - - - - (scroll = r)} - viewportOptions={{ - paddingRight: showScrollbar() ? 1 : 0, - }} - verticalScrollbarOptions={{ - paddingLeft: 1, - visible: showScrollbar(), - trackOptions: { - backgroundColor: theme.backgroundElement, - foregroundColor: theme.border, - }, - }} - stickyScroll={true} - stickyStart="bottom" - flexGrow={1} - scrollAcceleration={scrollAcceleration()} - > - - - {(message, index) => ( - - - {(function () { - const command = useCommandPalette() - const redoShortcut = useCommandShortcut("session.redo") - const [hover, setHover] = createSignal(false) - const dialog = useDialog() - - const handleUnrevert = async () => { - const confirmed = await DialogConfirm.show( - dialog, - "Confirm Redo", - "Are you sure you want to restore the reverted messages?", - ) - if (confirmed) { - command.run("session.redo") - } - } - - return ( - setHover(true)} - onMouseOut={() => setHover(false)} - onMouseUp={handleUnrevert} - marginTop={1} - flexShrink={0} - border={["left"]} - customBorderChars={SplitBorder.customBorderChars} - borderColor={theme.backgroundPanel} - > - - {revert()!.reverted.length} message reverted - - {redoShortcut()} or /redo to restore - - - - - {(file) => ( - - {file.filename} - 0}> - +{file.additions} - - 0}> - -{file.deletions} - - - )} - - - - - - ) - })()} - - = revert()!.messageID}> - <> - - - { - if (renderer.getSelection()?.getSelectedText()) return - dialog.replace(() => ( - prompt?.set(promptInfo)} - /> - )) - }} - message={message as UserMessage} - parts={sync.data.part[message.id] ?? []} - pending={pending()} - /> - - - - - - )} - - - - 0}> - - - 0}> - - - - - - - - { - toBottom() - }} - sessionID={route.sessionID} - right={} - /> - - - - - - - - - - - - - + + + + + (scroll = r)} + viewportOptions={{ + paddingRight: showScrollbar() ? 1 : 0, + }} + verticalScrollbarOptions={{ + paddingLeft: 1, + visible: showScrollbar(), + trackOptions: { + backgroundColor: theme.backgroundElement, + foregroundColor: theme.border, + }, + }} + stickyScroll={true} + stickyStart="bottom" + flexGrow={1} + scrollAcceleration={scrollAcceleration()} > - + + + {(message, index) => ( + + + {(function () { + const command = useCommandPalette() + const redoShortcut = useCommandShortcut("session.redo") + const [hover, setHover] = createSignal(false) + const dialog = useDialog() + + const handleUnrevert = async () => { + const confirmed = await DialogConfirm.show( + dialog, + "Confirm Redo", + "Are you sure you want to restore the reverted messages?", + ) + if (confirmed) { + command.run("session.redo") + } + } + + return ( + setHover(true)} + onMouseOut={() => setHover(false)} + onMouseUp={handleUnrevert} + marginTop={1} + flexShrink={0} + border={["left"]} + customBorderChars={SplitBorder.customBorderChars} + borderColor={theme.backgroundPanel} + > + + {revert()!.reverted.length} message reverted + + {redoShortcut()} or /redo to restore + + + + + {(file) => ( + + {file.filename} + 0}> + +{file.additions} + + 0}> + -{file.deletions} + + + )} + + + + + + ) + })()} + + = revert()!.messageID}> + <> + + + { + if (renderer.getSelection()?.getSelectedText()) return + dialog.replace(() => ( + prompt?.set(promptInfo)} + /> + )) + }} + message={message as UserMessage} + parts={sync.data.part[message.id] ?? []} + pending={pending()} + /> + + + + + + )} + + + + 0}> + + + 0}> + + + + + + + + { + toBottom() + }} + sessionID={route.sessionID} + right={} + /> + + - - - - - + + + + + + + + + + + + + + + + + + ) } @@ -1771,7 +1829,7 @@ function BlockTool(props: { function Shell(props: ToolProps) { const { theme } = useTheme() - const sync = useSync() + const pathFormatter = usePathFormatter() const isRunning = createMemo(() => props.part.state.status === "running") const output = createMemo(() => stripAnsi(props.metadata.output?.trim() ?? "")) const [expanded, setExpanded] = createSignal(false) @@ -1785,18 +1843,7 @@ function Shell(props: ToolProps) { const workdirDisplay = createMemo(() => { const workdir = props.input.workdir if (!workdir || workdir === ".") return undefined - - const base = sync.path.directory - if (!base) return undefined - - const absolute = path.resolve(base, workdir) - if (absolute === base) return undefined - - const home = Global.Path.home - if (!home) return absolute - - const match = absolute === home || absolute.startsWith(home + path.sep) - return match ? absolute.replace(home, "~") : absolute + return pathFormatter.format(workdir) }) const title = createMemo(() => { @@ -1838,6 +1885,7 @@ function Shell(props: ToolProps) { function Write(props: ToolProps) { const { theme, syntax } = useTheme() + const pathFormatter = usePathFormatter() const code = createMemo(() => { if (!props.input.content) return "" return props.input.content @@ -1846,7 +1894,7 @@ function Write(props: ToolProps) { return ( - + ) { - Write {normalizePath(props.input.filePath!)} + Write {pathFormatter.format(props.input.filePath)} @@ -1869,9 +1917,10 @@ function Write(props: ToolProps) { } function Glob(props: ToolProps) { + const pathFormatter = usePathFormatter() return ( - Glob "{props.input.pattern}" in {normalizePath(props.input.path)} + Glob "{props.input.pattern}" in {pathFormatter.format(props.input.path)} ({props.metadata.count} {props.metadata.count === 1 ? "match" : "matches"}) @@ -1881,6 +1930,7 @@ function Glob(props: ToolProps) { function Read(props: ToolProps) { const { theme } = useTheme() + const pathFormatter = usePathFormatter() const isRunning = createMemo(() => props.part.state.status === "running") const loaded = createMemo(() => { if (props.part.state.status !== "completed") return [] @@ -1898,13 +1948,13 @@ function Read(props: ToolProps) { spinner={isRunning()} part={props.part} > - Read {normalizePath(props.input.filePath!)} {input(props.input, ["filePath"])} + Read {pathFormatter.format(props.input.filePath)} {input(props.input, ["filePath"])} {(filepath) => ( - ↳ Loaded {normalizePath(filepath)} + ↳ Loaded {pathFormatter.format(filepath)} )} @@ -1914,9 +1964,10 @@ function Read(props: ToolProps) { } function Grep(props: ToolProps) { + const pathFormatter = usePathFormatter() return ( - Grep "{props.input.pattern}" in {normalizePath(props.input.path)} + Grep "{props.input.pattern}" in {pathFormatter.format(props.input.path)} ({props.metadata.matches} {props.metadata.matches === 1 ? "match" : "matches"}) @@ -2015,6 +2066,7 @@ function Task(props: ToolProps) { function Edit(props: ToolProps) { const ctx = use() const { theme, syntax } = useTheme() + const pathFormatter = usePathFormatter() const view = createMemo(() => { const diffStyle = ctx.tui.diff_style @@ -2030,7 +2082,7 @@ function Edit(props: ToolProps) { return ( - + ) { - Edit {normalizePath(props.input.filePath!)} {input({ replaceAll: props.input.replaceAll })} + Edit {pathFormatter.format(props.input.filePath)} {input({ replaceAll: props.input.replaceAll })} @@ -2067,6 +2119,7 @@ function Edit(props: ToolProps) { function ApplyPatch(props: ToolProps) { const ctx = use() const { theme, syntax } = useTheme() + const pathFormatter = usePathFormatter() const files = createMemo(() => props.metadata.files ?? []) @@ -2105,7 +2158,7 @@ function ApplyPatch(props: ToolProps) { function title(file: { type: string; relativePath: string; filePath: string; deletions: number }) { if (file.type === "delete") return "# Deleted " + file.relativePath if (file.type === "add") return "# Created " + file.relativePath - if (file.type === "move") return "# Moved " + normalizePath(file.filePath) + " → " + file.relativePath + if (file.type === "move") return "# Moved " + pathFormatter.format(file.filePath) + " → " + file.relativePath return "← Patched " + file.relativePath } @@ -2225,20 +2278,6 @@ function Diagnostics(props: { diagnostics?: Record[] ) } -function normalizePath(input?: string) { - if (!input) return "" - - const cwd = process.cwd() - const absolute = path.isAbsolute(input) ? input : path.resolve(cwd, input) - const relative = path.relative(cwd, absolute) - - if (!relative) return "." - if (!relative.startsWith("..")) return relative - - // outside cwd - use absolute - return absolute -} - function input(input: Record, omit?: string[]): string { const primitives = Object.entries(input).filter(([key, value]) => { if (omit?.includes(key)) return false diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx index 036b56dbd5..5b40c3c318 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx @@ -11,34 +11,16 @@ import { useProject } from "../../context/project" import path from "path" import { LANGUAGE_EXTENSIONS } from "@/lsp/language" import { Locale } from "@/util/locale" -import { Global } from "@opencode-ai/core/global" import { ShellID } from "@/tool/shell/id" import { webSearchProviderLabel } from "@/tool/websearch" import { useDialog } from "../../ui/dialog" import { getScrollAcceleration } from "../../util/scroll" import { useTuiConfig } from "../../context/tui-config" import { useBindings, useCommandShortcut } from "../../keymap" +import { usePathFormatter } from "../../context/path-format" type PermissionStage = "permission" | "always" | "reject" -function normalizePath(input?: string) { - if (!input) return "" - - const cwd = process.cwd() - const home = Global.Path.home - const absolute = path.isAbsolute(input) ? input : path.resolve(cwd, input) - const relative = path.relative(cwd, absolute) - - if (!relative) return "." - if (!relative.startsWith("..")) return relative - - // outside cwd - use ~ or absolute - if (home && (absolute === home || absolute.startsWith(home + path.sep))) { - return absolute.replace(home, "~") - } - return absolute -} - function filetype(input?: string) { if (!input) return "none" const ext = path.extname(input) @@ -137,6 +119,7 @@ export function PermissionPrompt(props: { request: PermissionRequest }) { const [store, setStore] = createStore({ stage: "permission" as PermissionStage, }) + const pathFormatter = usePathFormatter() const session = createMemo(() => sync.data.session.find((s) => s.id === props.request.sessionID)) @@ -220,7 +203,7 @@ export function PermissionPrompt(props: { request: PermissionRequest }) { const filepath = typeof raw === "string" ? raw : "" return { icon: "→", - title: `Edit ${normalizePath(filepath)}`, + title: `Edit ${pathFormatter.format(filepath)}`, body: , } } @@ -230,11 +213,11 @@ export function PermissionPrompt(props: { request: PermissionRequest }) { const filePath = typeof raw === "string" ? raw : "" return { icon: "→", - title: `Read ${normalizePath(filePath)}`, + title: `Read ${pathFormatter.format(filePath)}`, body: ( - {"Path: " + normalizePath(filePath)} + {"Path: " + pathFormatter.format(filePath)} ), @@ -276,11 +259,11 @@ export function PermissionPrompt(props: { request: PermissionRequest }) { const dir = typeof raw === "string" ? raw : "" return { icon: "→", - title: `List ${normalizePath(dir)}`, + title: `List ${pathFormatter.format(dir)}`, body: ( - {"Path: " + normalizePath(dir)} + {"Path: " + pathFormatter.format(dir)} ), @@ -359,7 +342,7 @@ export function PermissionPrompt(props: { request: PermissionRequest }) { typeof pattern === "string" ? (pattern.includes("*") ? path.dirname(pattern) : pattern) : undefined const raw = parent ?? filepath ?? derived - const dir = normalizePath(raw) + const dir = pathFormatter.format(raw) const patterns = (props.request.patterns ?? []).filter((p): p is string => typeof p === "string") return { @@ -463,7 +446,6 @@ function RejectPrompt(props: { onConfirm: (message: string) => void; onCancel: ( let input: TextareaRenderable const { theme } = useTheme() const tuiConfig = useTuiConfig() - const keymapConfig = tuiConfig.keymap const dimensions = useTerminalDimensions() const narrow = createMemo(() => dimensions().width < 80) const dialog = useDialog() @@ -471,7 +453,7 @@ function RejectPrompt(props: { onConfirm: (message: string) => void; onCancel: ( enabled: dialog.stack.length === 0, commands: [ { - name: "permission.reject.cancel", + name: "app.exit", title: "Cancel permission rejection", category: "Permission", run() { @@ -481,7 +463,7 @@ function RejectPrompt(props: { onConfirm: (message: string) => void; onCancel: ( ], bindings: [ { key: "escape", desc: "Cancel permission rejection", group: "Permission", cmd: () => props.onCancel() }, - ...keymapConfig.pick("permission", ["permission.reject.cancel"]), + ...tuiConfig.keybinds.get("app.exit"), { key: "return", desc: "Confirm permission rejection", @@ -553,7 +535,6 @@ function Prompt>(props: { }) { const { theme } = useTheme() const tuiConfig = useTuiConfig() - const keymapConfig = tuiConfig.keymap const dimensions = useTerminalDimensions() const keys = Object.keys(props.options) as (keyof T)[] const [store, setStore] = createStore({ @@ -568,7 +549,7 @@ function Prompt>(props: { enabled: dialog.stack.length === 0, commands: [ { - name: "permission.prompt.escape", + name: "app.exit", title: "Reject permission", category: "Permission", run() { @@ -643,8 +624,8 @@ function Prompt>(props: { }, ] : []), - ...(props.escapeKey ? keymapConfig.pick("permission", ["permission.prompt.escape"]) : []), - ...(props.fullscreen ? keymapConfig.pick("permission", ["permission.prompt.fullscreen"]) : []), + ...(props.escapeKey ? tuiConfig.keybinds.get("app.exit") : []), + ...(props.fullscreen ? tuiConfig.keybinds.get("permission.prompt.fullscreen") : []), ], })) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/question.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/question.tsx index e37b51e0a4..e690f6f327 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/question.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/question.tsx @@ -13,10 +13,6 @@ export function QuestionPrompt(props: { request: QuestionRequest }) { const sdk = useSDK() const { theme } = useTheme() const tuiConfig = useTuiConfig() - const { - keymap: { sections }, - } = tuiConfig - const keymapConfig = tuiConfig.keymap const questions = createMemo(() => props.request.questions) const single = createMemo(() => questions().length === 1 && questions()[0]?.multiple !== true) @@ -128,7 +124,7 @@ export function QuestionPrompt(props: { request: QuestionRequest }) { enabled: store.editing && !confirm(), commands: [ { - name: "question.edit.clear", + name: "prompt.clear", title: "Clear answer edit", category: "Question", run() { @@ -150,7 +146,7 @@ export function QuestionPrompt(props: { request: QuestionRequest }) { setStore("editing", false) }, }, - ...keymapConfig.pick("question", ["question.edit.clear"]), + ...tuiConfig.keybinds.get("prompt.clear"), { key: "return", desc: "Submit answer edit", @@ -208,7 +204,7 @@ export function QuestionPrompt(props: { request: QuestionRequest }) { enabled: dialog.stack.length === 0 && !store.editing, commands: [ { - name: "question.reject", + name: "app.exit", title: "Reject question", category: "Question", run() { @@ -243,7 +239,7 @@ export function QuestionPrompt(props: { request: QuestionRequest }) { ? [ { key: "return", desc: "Submit answer", group: "Question", cmd: () => submit() }, { key: "escape", desc: "Reject question", group: "Question", cmd: () => reject() }, - ...sections.question, + ...tuiConfig.keybinds.get("app.exit"), ] : [ ...Array.from({ length: max }, (_, index) => ({ @@ -271,7 +267,7 @@ export function QuestionPrompt(props: { request: QuestionRequest }) { { key: "j", desc: "Next answer", group: "Question", cmd: () => moveTo((store.selected + 1) % total) }, { key: "return", desc: "Select answer", group: "Question", cmd: () => selectOption() }, { key: "escape", desc: "Reject question", group: "Question", cmd: () => reject() }, - ...sections.question, + ...tuiConfig.keybinds.get("app.exit"), ]), ], } diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx index afa9d50571..a791aebc30 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx @@ -65,9 +65,6 @@ export function DialogSelect(props: DialogSelectProps) { const dialog = useDialog() const { theme } = useTheme() const tuiConfig = useTuiConfig() - const { - keymap: { sections }, - } = tuiConfig const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig)) const [store, setStore] = createStore({ @@ -308,11 +305,16 @@ export function DialogSelect(props: DialogSelectProps) { })), ], bindings: [ - ...sections.dialog_select, - ...tuiConfig.keymap.pick( - "dialog_actions", - enabledActions.map((item) => item.command), - ), + ...tuiConfig.keybinds.gather("dialog.select", [ + "dialog.select.prev", + "dialog.select.next", + "dialog.select.page_up", + "dialog.select.page_down", + "dialog.select.home", + "dialog.select.end", + "dialog.select.submit", + ]), + ...enabledActions.flatMap((item) => tuiConfig.keybinds.get(item.command)), ...(props.bindings ?? []).filter((binding) => { if (typeof binding.cmd !== "string") return true return enabledActions.some((item) => item.command === binding.cmd) diff --git a/packages/opencode/src/cli/cmd/tui/validate-session.ts b/packages/opencode/src/cli/cmd/tui/validate-session.ts index e2a21d51e1..31329a6533 100644 --- a/packages/opencode/src/cli/cmd/tui/validate-session.ts +++ b/packages/opencode/src/cli/cmd/tui/validate-session.ts @@ -1,5 +1,8 @@ import { createOpencodeClient } from "@opencode-ai/sdk/v2" import { SessionID } from "@/session/schema" +import { Schema } from "effect" + +const decodeSessionID = Schema.decodeUnknownSync(SessionID) export async function validateSession(input: { url: string @@ -10,9 +13,11 @@ export async function validateSession(input: { }) { if (!input.sessionID) return - const result = SessionID.zod.safeParse(input.sessionID) - if (!result.success) { - throw new Error(`Invalid session ID: ${result.error.issues.at(0)?.message ?? "unknown error"}`) + let sessionID: SessionID + try { + sessionID = decodeSessionID(input.sessionID) + } catch (error) { + throw new Error(`Invalid session ID: ${error instanceof Error ? error.message : "unknown error"}`, { cause: error }) } await createOpencodeClient({ @@ -20,5 +25,5 @@ export async function validateSession(input: { directory: input.directory, fetch: input.fetch, headers: input.headers, - }).session.get({ sessionID: result.data }, { throwOnError: true }) + }).session.get({ sessionID }, { throwOnError: true }) } diff --git a/packages/opencode/src/command/index.ts b/packages/opencode/src/command/index.ts index 140d2b8a7a..e26c4068b1 100644 --- a/packages/opencode/src/command/index.ts +++ b/packages/opencode/src/command/index.ts @@ -5,8 +5,8 @@ import type { InstanceContext } from "@/project/instance" import { SessionID, MessageID } from "@/session/schema" import { Effect, Layer, Context, Schema } from "effect" import z from "zod" -import { zod, ZodOverride } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod, ZodOverride } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" import { Config } from "@/config/config" import { MCP } from "../mcp" import { Skill } from "../skill" diff --git a/packages/opencode/src/config/agent.ts b/packages/opencode/src/config/agent.ts index e72f658728..94c8d8fe00 100644 --- a/packages/opencode/src/config/agent.ts +++ b/packages/opencode/src/config/agent.ts @@ -2,8 +2,8 @@ export * as ConfigAgent from "./agent" import { Exit, Schema, SchemaGetter } from "effect" import { Bus } from "@/bus" -import { zod } from "@/util/effect-zod" -import { PositiveInt, withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { PositiveInt, withStatics } from "@opencode-ai/core/schema" import * as Log from "@opencode-ai/core/util/log" import { NamedError } from "@opencode-ai/core/util/error" import { Glob } from "@opencode-ai/core/util/glob" diff --git a/packages/opencode/src/config/attachment.ts b/packages/opencode/src/config/attachment.ts new file mode 100644 index 0000000000..7af429afde --- /dev/null +++ b/packages/opencode/src/config/attachment.ts @@ -0,0 +1,30 @@ +export * as ConfigAttachment from "./attachment" + +import { Schema } from "effect" +import { zod } from "@opencode-ai/core/effect-zod" +import { PositiveInt, withStatics } from "@opencode-ai/core/schema" + +export const Image = Schema.Struct({ + auto_resize: Schema.optional(Schema.Boolean).annotate({ + description: "Resize images before sending them to the model when they exceed configured limits (default: true)", + }), + max_width: Schema.optional(PositiveInt).annotate({ + description: "Maximum image width before resizing or rejecting the attachment (default: 2000)", + }), + max_height: Schema.optional(PositiveInt).annotate({ + description: "Maximum image height before resizing or rejecting the attachment (default: 2000)", + }), + max_base64_bytes: Schema.optional(PositiveInt).annotate({ + description: "Maximum base64 payload bytes for an image attachment (default: 4718592)", + }), +}) + .annotate({ identifier: "ImageAttachmentConfig" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type Image = Schema.Schema.Type + +export const Info = Schema.Struct({ + image: Schema.optional(Image).annotate({ description: "Image attachment configuration" }), +}) + .annotate({ identifier: "AttachmentConfig" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type Info = Schema.Schema.Type diff --git a/packages/opencode/src/config/command.ts b/packages/opencode/src/config/command.ts index 4d0fec6872..c611f3c198 100644 --- a/packages/opencode/src/config/command.ts +++ b/packages/opencode/src/config/command.ts @@ -5,8 +5,8 @@ import { Schema } from "effect" import { NamedError } from "@opencode-ai/core/util/error" import { Glob } from "@opencode-ai/core/util/glob" import { Bus } from "@/bus" -import { zod } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" import { configEntryNameFromPath } from "./entry-name" import { InvalidError } from "./error" import * as ConfigMarkdown from "./markdown" diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index fcdb4e7b1c..114a388036 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -22,9 +22,10 @@ import { InstanceState } from "@/effect/instance-state" import { Context, Duration, Effect, Exit, Fiber, Layer, Option, Schema } from "effect" import { EffectFlock } from "@opencode-ai/core/util/effect-flock" import { containsPath } from "../project/instance-context" -import { zod } from "@/util/effect-zod" -import { NonNegativeInt, PositiveInt, withStatics, type DeepMutable } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { NonNegativeInt, PositiveInt, withStatics, type DeepMutable } from "@opencode-ai/core/schema" import { ConfigAgent } from "./agent" +import { ConfigAttachment } from "./attachment" import { ConfigCommand } from "./command" import { ConfigFormatter } from "./formatter" import { ConfigLayout } from "./layout" @@ -37,6 +38,7 @@ import { ConfigPaths } from "./paths" import { ConfigPermission } from "./permission" import { ConfigPlugin } from "./plugin" import { ConfigProvider } from "./provider" +import { ConfigReference } from "./reference" import { ConfigServer } from "./server" import { ConfigSkills } from "./skills" import { ConfigVariable } from "./variable" @@ -120,12 +122,12 @@ const LogLevelRef = Schema.Literals(["DEBUG", "INFO", "WARN", "ERROR"]).annotate }) // The Effect Schema is the canonical source of truth. The `.zod` compatibility -// surface is derived so existing Hono validators keep working without a parallel -// Zod definition. +// surface is derived from it so plugin/SDK Zod consumers keep working without +// a parallel hand-maintained Zod definition. // // The walker emits `z.object({...})` which is non-strict by default. Config // historically uses `.strict()` (additionalProperties: false in openapi.json), -// so layer that on after derivation. Re-apply the Config ref afterward +// so layer that on after derivation. Re-apply the Config ref afterward // since `.strict()` strips the walker's meta annotation. export const Info = Schema.Struct({ $schema: Schema.optional(Schema.String).annotate({ @@ -142,6 +144,9 @@ export const Info = Schema.Struct({ description: "Command configuration, see https://opencode.ai/docs/commands", }), skills: Schema.optional(ConfigSkills.Info).annotate({ description: "Additional skill folder paths" }), + reference: Schema.optional(ConfigReference.Info).annotate({ + description: "Named git or local directory references that can be @ mentioned as Scout-backed subagents", + }), watcher: Schema.optional( Schema.Struct({ ignore: Schema.optional(Schema.mutable(Schema.Array(Schema.String))), @@ -201,6 +206,7 @@ export const Info = Schema.Struct({ // subagent general: Schema.optional(ConfigAgent.Info), explore: Schema.optional(ConfigAgent.Info), + scout: Schema.optional(ConfigAgent.Info), // specialized title: Schema.optional(ConfigAgent.Info), summary: Schema.optional(ConfigAgent.Info), @@ -236,6 +242,9 @@ export const Info = Schema.Struct({ layout: Schema.optional(ConfigLayout.Layout).annotate({ description: "@deprecated Always uses stretch layout." }), permission: Schema.optional(ConfigPermission.Info), tools: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)), + attachment: Schema.optional(ConfigAttachment.Info).annotate({ + description: "Attachment processing configuration, including image size limits and resizing behavior", + }), enterprise: Schema.optional( Schema.Struct({ url: Schema.optional(Schema.String).annotate({ description: "Enterprise URL" }), @@ -264,10 +273,10 @@ export const Info = Schema.Struct({ }), tail_turns: Schema.optional(NonNegativeInt).annotate({ description: - "Number of recent user turns, including their following assistant/tool responses, to keep verbatim during compaction (default: 2)", + "Number of recent user turns, including their following assistant/tool responses, to serialize into the compaction summary (default: 2)", }), preserve_recent_tokens: Schema.optional(NonNegativeInt).annotate({ - description: "Maximum number of tokens from recent turns to preserve verbatim after compaction", + description: "Maximum number of tokens from recent turns to serialize into the compaction summary", }), reserved: Schema.optional(NonNegativeInt).annotate({ description: "Token buffer for compaction. Leaves enough window to avoid overflow during compaction.", @@ -302,7 +311,7 @@ export const Info = Schema.Struct({ })), ) -// Uses the shared `DeepMutable` from `@/util/schema`. See the definition +// Uses the shared `DeepMutable` from `@opencode-ai/core/schema`. See the definition // there for why the local variant is needed over `Types.DeepMutable` from // effect-smol (the upstream version collapses `unknown` to `{}`). export type Info = DeepMutable> & { diff --git a/packages/opencode/src/config/console-state.ts b/packages/opencode/src/config/console-state.ts index 0d4f20df91..485e334167 100644 --- a/packages/opencode/src/config/console-state.ts +++ b/packages/opencode/src/config/console-state.ts @@ -1,6 +1,6 @@ import { Schema } from "effect" -import { zod } from "@/util/effect-zod" -import { NonNegativeInt } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { NonNegativeInt } from "@opencode-ai/core/schema" export class ConsoleState extends Schema.Class("ConsoleState")({ consoleManagedProviders: Schema.mutable(Schema.Array(Schema.String)), diff --git a/packages/opencode/src/config/formatter.ts b/packages/opencode/src/config/formatter.ts index 8c1f09a247..222a750057 100644 --- a/packages/opencode/src/config/formatter.ts +++ b/packages/opencode/src/config/formatter.ts @@ -1,8 +1,8 @@ export * as ConfigFormatter from "./formatter" import { Schema } from "effect" -import { zod } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" export const Entry = Schema.Struct({ disabled: Schema.optional(Schema.Boolean), diff --git a/packages/opencode/src/config/keybinds.ts b/packages/opencode/src/config/keybinds.ts deleted file mode 100644 index d9a397f516..0000000000 --- a/packages/opencode/src/config/keybinds.ts +++ /dev/null @@ -1,143 +0,0 @@ -export * as ConfigKeybinds from "./keybinds" - -import { Effect, Schema } from "effect" -import type z from "zod" -import { zod } from "@/util/effect-zod" - -// Every keybind field has the same shape: an optional string with a default -// binding and a human description. `keybind()` keeps the declaration list -// below dense and readable. -const keybind = (value: string, description: string) => - Schema.String.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed(value))).annotate({ description }) - -// Windows prepends ctrl+z to the undo binding because `terminal_suspend` -// cannot consume ctrl+z on native Windows terminals (no POSIX suspend). -const inputUndoDefault = process.platform === "win32" ? "ctrl+z,ctrl+-,super+z" : "ctrl+-,super+z" - -const KeybindsSchema = Schema.Struct({ - leader: keybind("ctrl+x", "Leader key for keybind combinations"), - app_exit: keybind("ctrl+c,ctrl+d,q", "Exit the application"), - editor_open: keybind("e", "Open external editor"), - theme_list: keybind("t", "List available themes"), - sidebar_toggle: keybind("b", "Toggle sidebar"), - scrollbar_toggle: keybind("none", "Toggle session scrollbar"), - status_view: keybind("s", "View status"), - session_export: keybind("x", "Export session to editor"), - session_new: keybind("n", "Create a new session"), - session_list: keybind("l", "List all sessions"), - session_timeline: keybind("g", "Show session timeline"), - session_fork: keybind("none", "Fork session from message"), - session_rename: keybind("ctrl+r", "Rename session"), - session_delete: keybind("ctrl+d", "Delete session"), - stash_delete: keybind("ctrl+d", "Delete stash entry"), - model_provider_list: keybind("ctrl+a", "Open provider list from model dialog"), - model_favorite_toggle: keybind("ctrl+f", "Toggle model favorite status"), - session_share: keybind("none", "Share current session"), - session_unshare: keybind("none", "Unshare current session"), - session_interrupt: keybind("escape", "Interrupt current session"), - session_compact: keybind("c", "Compact the session"), - messages_page_up: keybind("pageup,ctrl+alt+b", "Scroll messages up by one page"), - messages_page_down: keybind("pagedown,ctrl+alt+f", "Scroll messages down by one page"), - messages_line_up: keybind("ctrl+alt+y", "Scroll messages up by one line"), - messages_line_down: keybind("ctrl+alt+e", "Scroll messages down by one line"), - messages_half_page_up: keybind("ctrl+alt+u", "Scroll messages up by half page"), - messages_half_page_down: keybind("ctrl+alt+d", "Scroll messages down by half page"), - messages_first: keybind("ctrl+g,home", "Navigate to first message"), - messages_last: keybind("ctrl+alt+g,end", "Navigate to last message"), - messages_next: keybind("none", "Navigate to next message"), - messages_previous: keybind("none", "Navigate to previous message"), - messages_last_user: keybind("none", "Navigate to last user message"), - messages_copy: keybind("y", "Copy message"), - messages_undo: keybind("u", "Undo message"), - messages_redo: keybind("r", "Redo message"), - messages_toggle_conceal: keybind("h", "Toggle code block concealment in messages"), - tool_details: keybind("none", "Toggle tool details visibility"), - model_list: keybind("m", "List available models"), - model_cycle_recent: keybind("f2", "Next recently used model"), - model_cycle_recent_reverse: keybind("shift+f2", "Previous recently used model"), - model_cycle_favorite: keybind("none", "Next favorite model"), - model_cycle_favorite_reverse: keybind("none", "Previous favorite model"), - command_list: keybind("ctrl+p", "List available commands"), - "dialog.select.prev": keybind("up,ctrl+p", "Move to previous dialog item"), - "dialog.select.next": keybind("down,ctrl+n", "Move to next dialog item"), - "dialog.select.page_up": keybind("pageup", "Move up one page in dialog"), - "dialog.select.page_down": keybind("pagedown", "Move down one page in dialog"), - "dialog.select.home": keybind("home", "Move to first dialog item"), - "dialog.select.end": keybind("end", "Move to last dialog item"), - "dialog.select.submit": keybind("return", "Submit selected dialog item"), - "dialog.mcp.toggle": keybind("space", "Toggle MCP in MCP dialog"), - "prompt.autocomplete.prev": keybind("up,ctrl+p", "Move to previous autocomplete item"), - "prompt.autocomplete.next": keybind("down,ctrl+n", "Move to next autocomplete item"), - "prompt.autocomplete.hide": keybind("escape", "Hide autocomplete"), - "prompt.autocomplete.select": keybind("return", "Select autocomplete item"), - "prompt.autocomplete.complete": keybind("tab", "Complete autocomplete item"), - "permission.prompt.fullscreen": keybind("ctrl+f", "Toggle permission prompt fullscreen"), - "plugins.toggle": keybind("space", "Toggle plugin"), - "dialog.plugins.install": keybind("shift+i", "Install plugin from plugin dialog"), - agent_list: keybind("a", "List agents"), - agent_cycle: keybind("tab", "Next agent"), - agent_cycle_reverse: keybind("shift+tab", "Previous agent"), - variant_cycle: keybind("ctrl+t", "Cycle model variants"), - variant_list: keybind("none", "List model variants"), - input_clear: keybind("ctrl+c", "Clear input field"), - input_paste: keybind("ctrl+v", "Paste from clipboard"), - input_submit: keybind("return", "Submit input"), - input_newline: keybind("shift+return,ctrl+return,alt+return,ctrl+j", "Insert newline in input"), - input_move_left: keybind("left,ctrl+b", "Move cursor left in input"), - input_move_right: keybind("right,ctrl+f", "Move cursor right in input"), - input_move_up: keybind("up", "Move cursor up in input"), - input_move_down: keybind("down", "Move cursor down in input"), - input_select_left: keybind("shift+left", "Select left in input"), - input_select_right: keybind("shift+right", "Select right in input"), - input_select_up: keybind("shift+up", "Select up in input"), - input_select_down: keybind("shift+down", "Select down in input"), - input_line_home: keybind("ctrl+a", "Move to start of line in input"), - input_line_end: keybind("ctrl+e", "Move to end of line in input"), - input_select_line_home: keybind("ctrl+shift+a", "Select to start of line in input"), - input_select_line_end: keybind("ctrl+shift+e", "Select to end of line in input"), - input_visual_line_home: keybind("alt+a", "Move to start of visual line in input"), - input_visual_line_end: keybind("alt+e", "Move to end of visual line in input"), - input_select_visual_line_home: keybind("alt+shift+a", "Select to start of visual line in input"), - input_select_visual_line_end: keybind("alt+shift+e", "Select to end of visual line in input"), - input_buffer_home: keybind("home", "Move to start of buffer in input"), - input_buffer_end: keybind("end", "Move to end of buffer in input"), - input_select_buffer_home: keybind("shift+home", "Select to start of buffer in input"), - input_select_buffer_end: keybind("shift+end", "Select to end of buffer in input"), - input_delete_line: keybind("ctrl+shift+d", "Delete line in input"), - input_delete_to_line_end: keybind("ctrl+k", "Delete to end of line in input"), - input_delete_to_line_start: keybind("ctrl+u", "Delete to start of line in input"), - input_backspace: keybind("backspace,shift+backspace", "Backspace in input"), - input_delete: keybind("ctrl+d,delete,shift+delete", "Delete character in input"), - input_undo: keybind(inputUndoDefault, "Undo in input"), - input_redo: keybind("ctrl+.,super+shift+z", "Redo in input"), - input_word_forward: keybind("alt+f,alt+right,ctrl+right", "Move word forward in input"), - input_word_backward: keybind("alt+b,alt+left,ctrl+left", "Move word backward in input"), - input_select_word_forward: keybind("alt+shift+f,alt+shift+right", "Select word forward in input"), - input_select_word_backward: keybind("alt+shift+b,alt+shift+left", "Select word backward in input"), - input_delete_word_forward: keybind("alt+d,alt+delete,ctrl+delete", "Delete word forward in input"), - input_delete_word_backward: keybind("ctrl+w,ctrl+backspace,alt+backspace", "Delete word backward in input"), - input_select_all: keybind("super+a", "Select all in input"), - history_previous: keybind("up", "Previous history item"), - history_next: keybind("down", "Next history item"), - session_child_first: keybind("down", "Go to first child session"), - session_child_cycle: keybind("right", "Go to next child session"), - session_child_cycle_reverse: keybind("left", "Go to previous child session"), - session_parent: keybind("up", "Go to parent session"), - // `terminal_suspend` was formerly `.default("ctrl+z").transform((v) => win32 ? "none" : v)`, - // but `tui.ts` already forces the binding to "none" on win32 before calling - // `Keybinds.parse(...)`, so the schema-level transform was redundant. - terminal_suspend: keybind("ctrl+z", "Suspend terminal"), - terminal_title_toggle: keybind("none", "Toggle terminal title"), - tips_toggle: keybind("h", "Toggle tips on home screen"), - plugin_manager: keybind("none", "Open plugin manager dialog"), - display_thinking: keybind("none", "Toggle thinking blocks visibility"), -}).annotate({ identifier: "KeybindsConfig" }) - -export type Keybinds = Schema.Schema.Type - -// Consumers access `Keybinds.shape` and `Keybinds.shape.X.parse(undefined)`, -// which requires the runtime type to be a ZodObject, not just ZodType. Every -// field is `string().optional().default(...)` at runtime, so widen to that. -export const Keybinds = zod(KeybindsSchema) as unknown as z.ZodObject< - Record>> -> diff --git a/packages/opencode/src/config/layout.ts b/packages/opencode/src/config/layout.ts index 49c34b6639..a5299ea955 100644 --- a/packages/opencode/src/config/layout.ts +++ b/packages/opencode/src/config/layout.ts @@ -1,6 +1,6 @@ import { Schema } from "effect" -import { zod } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" export const Layout = Schema.Literals(["auto", "stretch"]) .annotate({ identifier: "LayoutConfig" }) diff --git a/packages/opencode/src/config/lsp.ts b/packages/opencode/src/config/lsp.ts index 1cf93177e4..accfbee3b2 100644 --- a/packages/opencode/src/config/lsp.ts +++ b/packages/opencode/src/config/lsp.ts @@ -1,8 +1,8 @@ export * as ConfigLSP from "./lsp" import { Schema } from "effect" -import { zod } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" import * as LSPServer from "../lsp/server" export const Disabled = Schema.Struct({ diff --git a/packages/opencode/src/config/mcp.ts b/packages/opencode/src/config/mcp.ts index fc31ba356f..bb4fd88f04 100644 --- a/packages/opencode/src/config/mcp.ts +++ b/packages/opencode/src/config/mcp.ts @@ -1,6 +1,6 @@ import { Schema } from "effect" -import { zod } from "@/util/effect-zod" -import { PositiveInt, withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { PositiveInt, withStatics } from "@opencode-ai/core/schema" export const Local = Schema.Struct({ type: Schema.Literal("local").annotate({ description: "Type of MCP server connection" }), diff --git a/packages/opencode/src/config/model-id.ts b/packages/opencode/src/config/model-id.ts index 3ad9e035ce..26fa2e0b34 100644 --- a/packages/opencode/src/config/model-id.ts +++ b/packages/opencode/src/config/model-id.ts @@ -1,7 +1,7 @@ import { Schema } from "effect" import z from "zod" -import { zod, ZodOverride } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod, ZodOverride } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" // The original Zod schema carried an external $ref pointing at the models.dev // JSON schema. That external reference is not a named SDK component — it is a diff --git a/packages/opencode/src/config/parse.ts b/packages/opencode/src/config/parse.ts index 9351047894..f964ed4e15 100644 --- a/packages/opencode/src/config/parse.ts +++ b/packages/opencode/src/config/parse.ts @@ -3,7 +3,7 @@ export * as ConfigParse from "./parse" import { type ParseError as JsoncParseError, parse as parseJsoncImpl, printParseErrorCode } from "jsonc-parser" import { Cause, Exit, Schema as EffectSchema, SchemaIssue } from "effect" import z from "zod" -import type { DeepMutable } from "@/util/schema" +import type { DeepMutable } from "@opencode-ai/core/schema" import { InvalidError, JsonError } from "./error" type ZodSchema = z.ZodType diff --git a/packages/opencode/src/config/permission.ts b/packages/opencode/src/config/permission.ts index 9513951c29..8c5f854996 100644 --- a/packages/opencode/src/config/permission.ts +++ b/packages/opencode/src/config/permission.ts @@ -1,7 +1,7 @@ export * as ConfigPermission from "./permission" import { Schema, SchemaGetter } from "effect" -import { zod } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" export const Action = Schema.Literals(["ask", "allow", "deny"]) .annotate({ identifier: "PermissionActionConfig" }) @@ -35,6 +35,9 @@ const InputObject = Schema.StructWithRest( question: Schema.optional(Action), webfetch: Schema.optional(Action), websearch: Schema.optional(Action), + codesearch: Schema.optional(Action), + repo_clone: Schema.optional(Rule), + repo_overview: Schema.optional(Rule), lsp: Schema.optional(Rule), doom_loop: Schema.optional(Action), skill: Schema.optional(Rule), diff --git a/packages/opencode/src/config/plugin.ts b/packages/opencode/src/config/plugin.ts index 9667dbb59a..b1e3ec6f42 100644 --- a/packages/opencode/src/config/plugin.ts +++ b/packages/opencode/src/config/plugin.ts @@ -2,8 +2,8 @@ import { Glob } from "@opencode-ai/core/util/glob" import { Schema } from "effect" import { pathToFileURL } from "url" import { isPathPluginSpec, parsePluginSpecifier, resolvePathPluginTarget } from "@/plugin/shared" -import { zod } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" import path from "path" export const Options = Schema.Record(Schema.String, Schema.Unknown).pipe(withStatics((s) => ({ zod: zod(s) }))) diff --git a/packages/opencode/src/config/provider.ts b/packages/opencode/src/config/provider.ts index 7821bca5a9..af9aac6964 100644 --- a/packages/opencode/src/config/provider.ts +++ b/packages/opencode/src/config/provider.ts @@ -1,6 +1,7 @@ import { Schema } from "effect" -import { zod } from "@/util/effect-zod" -import { PositiveInt, withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { PositiveInt, withStatics } from "@opencode-ai/core/schema" +import { ModelStatus } from "@/provider/model-status" export const Model = Schema.Struct({ id: Schema.optional(Schema.String), @@ -49,7 +50,7 @@ export const Model = Schema.Struct({ }), ), experimental: Schema.optional(Schema.Boolean), - status: Schema.optional(Schema.Literals(["alpha", "beta", "deprecated"])), + status: Schema.optional(ModelStatus), provider: Schema.optional( Schema.Struct({ npm: Schema.optional(Schema.String), api: Schema.optional(Schema.String) }), ), diff --git a/packages/opencode/src/config/reference.ts b/packages/opencode/src/config/reference.ts new file mode 100644 index 0000000000..36a8faff7e --- /dev/null +++ b/packages/opencode/src/config/reference.ts @@ -0,0 +1,27 @@ +export * as ConfigReference from "./reference" + +import { Schema } from "effect" +import { zod } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" + +const Git = Schema.Struct({ + repository: Schema.String.annotate({ + description: "Git repository URL, host/path reference, or GitHub owner/repo shorthand", + }), + branch: Schema.optional(Schema.String).annotate({ + description: "Branch or ref Scout should clone and inspect", + }), +}) + +const Local = Schema.Struct({ + path: Schema.String.annotate({ + description: "Absolute path, ~/ path, or workspace-relative path to a local reference directory", + }), +}) + +export const Entry = Schema.Union([Schema.String, Git, Local]).annotate({ identifier: "ReferenceConfigEntry" }) + +export const Info = Schema.Record(Schema.String, Entry) + .annotate({ identifier: "ReferenceConfig" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type Info = Schema.Schema.Type diff --git a/packages/opencode/src/config/server.ts b/packages/opencode/src/config/server.ts index 3f13698269..159ba0ce5a 100644 --- a/packages/opencode/src/config/server.ts +++ b/packages/opencode/src/config/server.ts @@ -1,6 +1,6 @@ import { Schema } from "effect" -import { zod } from "@/util/effect-zod" -import { PositiveInt, withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { PositiveInt, withStatics } from "@opencode-ai/core/schema" export const Server = Schema.Struct({ port: Schema.optional(PositiveInt).annotate({ diff --git a/packages/opencode/src/config/skills.ts b/packages/opencode/src/config/skills.ts index f29d854f50..f707e922ee 100644 --- a/packages/opencode/src/config/skills.ts +++ b/packages/opencode/src/config/skills.ts @@ -1,6 +1,6 @@ import { Schema } from "effect" -import { zod } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" export const Info = Schema.Struct({ paths: Schema.optional(Schema.Array(Schema.String)).annotate({ diff --git a/packages/opencode/src/control-plane/adapters/index.ts b/packages/opencode/src/control-plane/adapters/index.ts index 963e2a2ed5..e5fa13714b 100644 --- a/packages/opencode/src/control-plane/adapters/index.ts +++ b/packages/opencode/src/control-plane/adapters/index.ts @@ -18,22 +18,18 @@ export function getAdapter(projectID: ProjectID, type: string): WorkspaceAdapter throw new Error(`Unknown workspace adapter: ${type}`) } -export async function listAdapters(projectID: ProjectID): Promise { - const builtin = await Promise.all( - Object.entries(BUILTIN).map(async ([type, adapter]) => { - return { - type, - name: adapter.name, - description: adapter.description, - } - }), - ) - const custom = [...(state.get(projectID)?.entries() ?? [])].map(([type, adapter]) => ({ +export function listAdapters(projectID: ProjectID): WorkspaceAdapterEntry[] { + return registeredAdapters(projectID).map(([type, adapter]) => ({ type, name: adapter.name, description: adapter.description, })) - return [...builtin, ...custom] +} + +export function registeredAdapters(projectID: ProjectID): [string, WorkspaceAdapter][] { + const adapters = new Map(Object.entries(BUILTIN)) + for (const [type, adapter] of state.get(projectID)?.entries() ?? []) adapters.set(type, adapter) + return [...adapters.entries()] } // Plugins can be loaded per-project so we need to scope them. If you diff --git a/packages/opencode/src/control-plane/adapters/worktree.ts b/packages/opencode/src/control-plane/adapters/worktree.ts index af8f5d8d43..605d114ace 100644 --- a/packages/opencode/src/control-plane/adapters/worktree.ts +++ b/packages/opencode/src/control-plane/adapters/worktree.ts @@ -3,14 +3,18 @@ import { type WorkspaceAdapter, WorkspaceInfo } from "../types" const WorktreeConfig = Schema.Struct({ name: WorkspaceInfo.fields.name, - branch: Schema.String, + branch: Schema.optional(Schema.NullOr(Schema.String)), directory: Schema.String, }) const decodeWorktreeConfig = Schema.decodeUnknownSync(WorktreeConfig) async function loadWorktree() { - const [{ AppRuntime }, { Worktree }] = await Promise.all([import("@/effect/app-runtime"), import("@/worktree")]) - return { AppRuntime, Worktree } + const [{ AppRuntime }, { Instance }, { Worktree }] = await Promise.all([ + import("@/effect/app-runtime"), + import("@/project/instance"), + import("@/worktree"), + ]) + return { AppRuntime, Instance, Worktree } } export const WorktreeAdapter: WorkspaceAdapter = { @@ -34,11 +38,22 @@ export const WorktreeAdapter: WorkspaceAdapter = { svc.createFromInfo({ name: config.name, directory: config.directory, - branch: config.branch, + branch: config.branch ?? config.name, }), ), ) }, + async list() { + const { AppRuntime, Instance, Worktree } = await loadWorktree() + return (await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.list()))).map((info) => ({ + type: "worktree", + name: info.name, + branch: info.branch ?? null, + directory: info.directory, + extra: null, + projectID: Instance.project.id, + })) + }, async remove(info) { const { AppRuntime, Worktree } = await loadWorktree() const config = decodeWorktreeConfig(info) diff --git a/packages/opencode/src/control-plane/schema.ts b/packages/opencode/src/control-plane/schema.ts index 5a0850a249..1954543f4a 100644 --- a/packages/opencode/src/control-plane/schema.ts +++ b/packages/opencode/src/control-plane/schema.ts @@ -1,18 +1,14 @@ import { Schema } from "effect" import { Identifier } from "@/id/id" -import { zod, ZodOverride } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { withStatics } from "@opencode-ai/core/schema" -const workspaceIdSchema = Schema.String.annotate({ [ZodOverride]: Identifier.schema("workspace") }).pipe( - Schema.brand("WorkspaceID"), -) +const workspaceIdSchema = Schema.String.check(Schema.isStartsWith("wrk")).pipe(Schema.brand("WorkspaceID")) export type WorkspaceID = typeof workspaceIdSchema.Type export const WorkspaceID = workspaceIdSchema.pipe( withStatics((schema: typeof workspaceIdSchema) => ({ ascending: (id?: string) => schema.make(Identifier.ascending("workspace", id)), - zod: zod(schema), })), ) diff --git a/packages/opencode/src/control-plane/types.ts b/packages/opencode/src/control-plane/types.ts index 7f3aad7ed1..e78d728e04 100644 --- a/packages/opencode/src/control-plane/types.ts +++ b/packages/opencode/src/control-plane/types.ts @@ -1,8 +1,7 @@ -import { Schema } from "effect" +import { Schema, Struct } from "effect" import { ProjectID } from "@/project/schema" import { WorkspaceID } from "./schema" -import { zod } from "@/util/effect-zod" -import { type DeepMutable, withStatics } from "@/util/schema" +import type { DeepMutable } from "@opencode-ai/core/schema" export const WorkspaceInfo = Schema.Struct({ id: WorkspaceID, @@ -12,16 +11,19 @@ export const WorkspaceInfo = Schema.Struct({ directory: Schema.NullOr(Schema.String), extra: Schema.NullOr(Schema.Unknown), projectID: ProjectID, -}) - .annotate({ identifier: "Workspace" }) - .pipe(withStatics((s) => ({ zod: zod(s) }))) +}).annotate({ identifier: "Workspace" }) export type WorkspaceInfo = DeepMutable> +export const WorkspaceListedInfo = Schema.Struct(Struct.omit(WorkspaceInfo.fields, ["id"])).annotate({ + identifier: "WorkspaceListedInfo", +}) +export type WorkspaceListedInfo = DeepMutable> + export const WorkspaceAdapterEntry = Schema.Struct({ type: Schema.String, name: Schema.String, description: Schema.String, -}).pipe(withStatics((s) => ({ zod: zod(s) }))) +}) export type WorkspaceAdapterEntry = Schema.Schema.Type export type Target = @@ -40,6 +42,7 @@ export type WorkspaceAdapter = { description: string configure(info: WorkspaceInfo): WorkspaceInfo | Promise create(info: WorkspaceInfo, env: Record, from?: WorkspaceInfo): Promise + list?(): WorkspaceListedInfo[] | Promise remove(info: WorkspaceInfo): Promise target(info: WorkspaceInfo): Target | Promise } diff --git a/packages/opencode/src/control-plane/workspace.sql.ts b/packages/opencode/src/control-plane/workspace.sql.ts index a6a4ce2c86..1afaf7cbc9 100644 --- a/packages/opencode/src/control-plane/workspace.sql.ts +++ b/packages/opencode/src/control-plane/workspace.sql.ts @@ -1,4 +1,4 @@ -import { sqliteTable, text } from "drizzle-orm/sqlite-core" +import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core" import { ProjectTable } from "../project/project.sql" import type { ProjectID } from "../project/schema" import type { WorkspaceID } from "./schema" @@ -14,4 +14,7 @@ export const WorkspaceTable = sqliteTable("workspace", { .$type() .notNull() .references(() => ProjectTable.id, { onDelete: "cascade" }), + time_used: integer() + .notNull() + .$default(() => Date.now()), }) diff --git a/packages/opencode/src/control-plane/workspace.ts b/packages/opencode/src/control-plane/workspace.ts index f9bab469b7..e7e65f8901 100644 --- a/packages/opencode/src/control-plane/workspace.ts +++ b/packages/opencode/src/control-plane/workspace.ts @@ -5,7 +5,6 @@ import { asc } from "drizzle-orm" import { eq } from "drizzle-orm" import { inArray } from "drizzle-orm" import { Project } from "@/project/project" -import { Instance } from "@/project/instance" import { BusEvent } from "@/bus/bus-event" import { GlobalBus } from "@/bus/global" import { Auth } from "@/auth" @@ -17,7 +16,7 @@ import { Filesystem } from "@/util/filesystem" import { ProjectID } from "@/project/schema" import { Slug } from "@opencode-ai/core/util/slug" import { WorkspaceTable } from "./workspace.sql" -import { getAdapter } from "./adapters" +import { getAdapter, registeredAdapters } from "./adapters" import { type Target, type WorkspaceInfo, WorkspaceInfo as WorkspaceInfoSchema } from "./types" import { WorkspaceID } from "./schema" import { Session } from "@/session/session" @@ -29,14 +28,15 @@ import { errorData } from "@/util/error" import { waitEvent } from "./util" import { WorkspaceContext } from "./workspace-context" import { EffectBridge } from "@/effect/bridge" -import { withStatics } from "@/util/schema" -import { zod as effectZod, zodObject } from "@/util/effect-zod" import { Vcs } from "@/project/vcs" import { InstanceStore } from "@/project/instance-store" import { InstanceBootstrap } from "@/project/bootstrap" -export const Info = WorkspaceInfoSchema -export type Info = WorkspaceInfo +export const Info = Schema.Struct({ + ...WorkspaceInfoSchema.fields, + timeUsed: Schema.Number, +}).annotate({ identifier: "Workspace" }) +export type Info = WorkspaceInfo & { timeUsed: number } export const ConnectionStatus = Schema.Struct({ workspaceID: WorkspaceID, @@ -69,6 +69,7 @@ function fromRow(row: typeof WorkspaceTable.$inferSelect): Info { directory: row.directory, extra: row.extra, projectID: row.project_id, + timeUsed: row.time_used, } } @@ -83,14 +84,14 @@ export const CreateInput = Schema.Struct({ branch: Info.fields.branch, projectID: ProjectID, extra: Schema.optional(Info.fields.extra), -}).pipe(withStatics((s) => ({ zod: effectZod(s), zodObject: zodObject(s) }))) +}) export type CreateInput = Schema.Schema.Type export const SessionWarpInput = Schema.Struct({ workspaceID: Schema.NullOr(WorkspaceID), sessionID: SessionID, copyChanges: Schema.optional(Schema.Boolean), -}).pipe(withStatics((s) => ({ zod: effectZod(s), zodObject: zodObject(s) }))) +}) export type SessionWarpInput = Schema.Schema.Type export class SyncHttpError extends Schema.TaggedErrorClass()("WorkspaceSyncHttpError", { @@ -150,6 +151,7 @@ export interface Interface { readonly create: (input: CreateInput) => Effect.Effect readonly sessionWarp: (input: SessionWarpInput) => Effect.Effect readonly list: (project: Project.Info) => Effect.Effect + readonly syncList: (project: Project.Info) => Effect.Effect readonly get: (id: WorkspaceID) => Effect.Effect readonly remove: (id: WorkspaceID) => Effect.Effect readonly status: () => Effect.Effect @@ -483,7 +485,19 @@ export const layer = Layer.effect( if (!Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) return const adapter = getAdapter(space.projectID, space.type) - const target = yield* EffectBridge.fromPromise(() => adapter.target(space)) + const target = yield* EffectBridge.fromPromise(() => adapter.target(space)).pipe( + Effect.catch((error) => + Effect.sync(() => { + setStatus(space.id, "error") + log.warn("workspace target failed", { + workspaceID: space.id, + error: errorData(error), + }) + return null + }), + ), + ) + if (!target) return if (target.type === "local") { setStatus(space.id, (yield* Effect.promise(() => Filesystem.exists(target.directory))) ? "connected" : "error") @@ -523,7 +537,13 @@ export const layer = Layer.effect( const id = WorkspaceID.ascending(input.id) const adapter = getAdapter(input.projectID, input.type) const config = yield* EffectBridge.fromPromise(() => - adapter.configure({ ...input, id, name: Slug.create(), directory: null, extra: input.extra ?? null }), + adapter.configure({ + ...input, + id, + name: Slug.create(), + directory: null, + extra: input.extra ?? null, + }), ) const info: Info = { @@ -534,6 +554,7 @@ export const layer = Layer.effect( directory: config.directory ?? null, extra: config.extra ?? null, projectID: input.projectID, + timeUsed: Date.now(), } yield* db((db) => { @@ -546,6 +567,7 @@ export const layer = Layer.effect( directory: info.directory, extra: info.extra, project_id: info.projectID, + time_used: info.timeUsed, }) .run() }) @@ -619,7 +641,7 @@ export const layer = Layer.effect( // "claim" this session so any future events coming from // the old workspace are ignored - SyncEvent.claim(input.sessionID, input.workspaceID ?? Instance.project.id) + yield* sync.claim(input.sessionID, input.workspaceID ?? previous.projectID) } } @@ -654,14 +676,12 @@ export const layer = Layer.effect( } if (input.workspaceID === null) { - yield* Effect.sync(() => - SyncEvent.run(Session.Event.Updated, { - sessionID: input.sessionID, - info: { - workspaceID: null, - }, - }), - ) + yield* sync.run(Session.Event.Updated, { + sessionID: input.sessionID, + info: { + workspaceID: null, + }, + }) log.info("session warp complete", { workspaceID: input.workspaceID, @@ -828,6 +848,63 @@ export const layer = Layer.effect( ) }) + const syncList = Effect.fn("Workspace.syncList")(function* (project: Project.Info) { + const names = new Set((yield* list(project)).map((workspace) => workspace.name)) + const discovered = yield* Effect.forEach( + registeredAdapters(project.id), + ([type, adapter]) => + adapter.list + ? EffectBridge.fromPromise(() => Promise.resolve(adapter.list?.() ?? [])).pipe( + Effect.catchCause((error) => + Effect.sync(() => { + log.warn("workspace adapter list failed", { type, error }) + return [] + }), + ), + ) + : Effect.succeed([]), + { concurrency: "unbounded" }, + ).pipe(Effect.map((items) => items.flat())) + + yield* Effect.forEach( + discovered, + (item) => + Effect.gen(function* () { + if (names.has(item.name)) return + names.add(item.name) + + const info: Info = { + id: WorkspaceID.ascending(), + type: item.type, + branch: item.branch, + name: item.name, + directory: item.directory, + extra: item.extra, + projectID: item.projectID, + timeUsed: Date.now(), + } + + yield* db((db) => { + db.insert(WorkspaceTable) + .values({ + id: info.id, + type: info.type, + branch: info.branch, + name: info.name, + directory: info.directory, + extra: info.extra, + project_id: info.projectID, + time_used: info.timeUsed, + }) + .run() + }) + + yield* startSync(info) + }), + { concurrency: 1 }, + ) + }) + const get = Effect.fn("Workspace.get")(function* (id: WorkspaceID) { const row = yield* db((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get()) if (!row) return @@ -916,13 +993,10 @@ export const layer = Layer.effect( }) const startWorkspaceSyncing = Effect.fn("Workspace.startWorkspaceSyncing")(function* (projectID: ProjectID) { - // This session table join makes this query only return - // workspaces that have sessions const rows = yield* db((db) => db .selectDistinct({ workspace: WorkspaceTable }) .from(WorkspaceTable) - .innerJoin(SessionTable, eq(SessionTable.workspace_id, WorkspaceTable.id)) .where(eq(WorkspaceTable.project_id, projectID)) .all(), ) @@ -947,6 +1021,7 @@ export const layer = Layer.effect( create, sessionWarp, list, + syncList, get, remove, status, diff --git a/packages/opencode/src/data-migration.sql.ts b/packages/opencode/src/data-migration.sql.ts new file mode 100644 index 0000000000..ba446b501c --- /dev/null +++ b/packages/opencode/src/data-migration.sql.ts @@ -0,0 +1,6 @@ +import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core" + +export const DataMigrationTable = sqliteTable("data_migration", { + name: text().primaryKey(), + time_completed: integer().notNull(), +}) diff --git a/packages/opencode/src/data-migration.ts b/packages/opencode/src/data-migration.ts new file mode 100644 index 0000000000..0a2973de5d --- /dev/null +++ b/packages/opencode/src/data-migration.ts @@ -0,0 +1,59 @@ +import { Context, Effect, Layer } from "effect" +import { Database } from "./storage/db" +import { DataMigrationTable } from "./data-migration.sql" +import * as Log from "@opencode-ai/core/util/log" +import { eq } from "drizzle-orm" + +export type Migration = { + name: string + run: Effect.Effect +} + +const log = Log.create({ service: "data-migration" }) + +export interface Interface {} + +export class Service extends Context.Service()("@opencode/DataMigration") {} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const migrations: Migration[] = [] + + yield* Effect.gen(function* () { + if (migrations.length === 0) return + + // Migrations run in a background fiber, so they must be resumable until + // their completion row is written. + for (const migration of migrations) { + const completed = Database.use((db) => + db + .select({ name: DataMigrationTable.name }) + .from(DataMigrationTable) + .where(eq(DataMigrationTable.name, migration.name)) + .get(), + ) + if (completed) continue + + log.info("running data migration", { name: migration.name }) + yield* migration.run.pipe(Effect.withSpan("DataMigration", { attributes: { name: migration.name } })) + Database.use((db) => + db + .insert(DataMigrationTable) + .values({ name: migration.name, time_completed: Date.now() }) + .onConflictDoNothing() + .run(), + ) + } + }).pipe( + Effect.tapCause((cause) => Effect.logError("failed to run data migrations", { cause })), + Effect.ignore, + Effect.forkScoped, + ) + return Service.of({}) + }), +) + +export const defaultLayer = layer + +export * as DataMigration from "./data-migration" diff --git a/packages/opencode/src/effect/app-runtime.ts b/packages/opencode/src/effect/app-runtime.ts index 76ed26d302..4c1637006c 100644 --- a/packages/opencode/src/effect/app-runtime.ts +++ b/packages/opencode/src/effect/app-runtime.ts @@ -43,6 +43,7 @@ import { Format } from "@/format" import { InstanceLayer } from "@/project/instance-layer" import { Project } from "@/project/project" import { Vcs } from "@/project/vcs" +import { Reference } from "@/reference/reference" import { Workspace } from "@/control-plane/workspace" import { Worktree } from "@/worktree" import { Pty } from "@/pty" @@ -53,6 +54,7 @@ import { SessionShare } from "@/share/session" import { SyncEvent } from "@/sync" import { Npm } from "@opencode-ai/core/npm" import { memoMap } from "@opencode-ai/core/effect/memo-map" +import { DataMigration } from "@/data-migration" export const AppLayer = Layer.mergeAll( Npm.defaultLayer, @@ -96,6 +98,7 @@ export const AppLayer = Layer.mergeAll( Format.defaultLayer, Project.defaultLayer, Vcs.defaultLayer, + Reference.defaultLayer, Workspace.defaultLayer, Worktree.appLayer, Pty.defaultLayer, @@ -104,6 +107,7 @@ export const AppLayer = Layer.mergeAll( ShareNext.defaultLayer, SessionShare.defaultLayer, SyncEvent.defaultLayer, + DataMigration.defaultLayer, ).pipe(Layer.provideMerge(InstanceLayer.layer), Layer.provideMerge(Observability.layer)) const rt = ManagedRuntime.make(AppLayer, { memoMap }) diff --git a/packages/opencode/src/file/index.ts b/packages/opencode/src/file/index.ts index 4dd6a3ae7a..52f2b8486d 100644 --- a/packages/opencode/src/file/index.ts +++ b/packages/opencode/src/file/index.ts @@ -14,8 +14,8 @@ import { containsPath } from "../project/instance-context" import * as Log from "@opencode-ai/core/util/log" import { Protected } from "./protected" import { Ripgrep } from "./ripgrep" -import { zod } from "@/util/effect-zod" -import { NonNegativeInt, type DeepMutable, withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { NonNegativeInt, type DeepMutable, withStatics } from "@opencode-ai/core/schema" export const Info = Schema.Struct({ path: Schema.String, diff --git a/packages/opencode/src/file/ripgrep.ts b/packages/opencode/src/file/ripgrep.ts index 27fd5f2323..8459dd9ac1 100644 --- a/packages/opencode/src/file/ripgrep.ts +++ b/packages/opencode/src/file/ripgrep.ts @@ -11,8 +11,8 @@ import { Global } from "@opencode-ai/core/global" import * as Log from "@opencode-ai/core/util/log" import { sanitizedProcessEnv } from "@opencode-ai/core/util/opencode-process" import { which } from "@/util/which" -import { zod } from "@/util/effect-zod" -import { NonNegativeInt, withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { NonNegativeInt, withStatics } from "@opencode-ai/core/schema" const log = Log.create({ service: "ripgrep" }) const VERSION = "15.1.0" diff --git a/packages/opencode/src/format/index.ts b/packages/opencode/src/format/index.ts index a61eb7be29..c9ab433f11 100644 --- a/packages/opencode/src/format/index.ts +++ b/packages/opencode/src/format/index.ts @@ -7,8 +7,8 @@ import { mergeDeep } from "remeda" import { Config } from "@/config/config" import * as Log from "@opencode-ai/core/util/log" import * as Formatter from "./formatter" -import { zod } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" const log = Log.create({ service: "format" }) diff --git a/packages/opencode/src/id/id.ts b/packages/opencode/src/id/id.ts index 6d9a6447a0..9e163cd6b8 100644 --- a/packages/opencode/src/id/id.ts +++ b/packages/opencode/src/id/id.ts @@ -1,4 +1,3 @@ -import z from "zod" import { randomBytes } from "crypto" const prefixes = { @@ -7,19 +6,12 @@ const prefixes = { message: "msg", permission: "per", question: "que", - user: "usr", part: "prt", pty: "pty", tool: "tool", workspace: "wrk", - entry: "ent", - account: "act", } as const -export function schema(prefix: keyof typeof prefixes) { - return z.string().startsWith(prefixes[prefix]) -} - const LENGTH = 26 // State for monotonic ID generation diff --git a/packages/opencode/src/image/image.ts b/packages/opencode/src/image/image.ts new file mode 100644 index 0000000000..2115e19198 --- /dev/null +++ b/packages/opencode/src/image/image.ts @@ -0,0 +1,180 @@ +import { Config } from "@/config/config" +import type { MessageV2 } from "@/session/message-v2" +import * as Log from "@opencode-ai/core/util/log" +import { Context, Effect, Layer, Schema } from "effect" + +const MAX_BASE64_BYTES = 4.5 * 1024 * 1024 +const MAX_WIDTH = 2000 +const MAX_HEIGHT = 2000 +const AUTO_RESIZE = true +const JPEG_QUALITIES = [80, 85, 70, 55, 40] +const log = Log.create({ service: "image" }) + +export class PhotonUnavailableError extends Schema.TaggedErrorClass()( + "ImagePhotonUnavailableError", + {}, +) { + override get message() { + return "Photon image processor is unavailable" + } +} + +export class InvalidDataUrlError extends Schema.TaggedErrorClass()("ImageInvalidDataUrlError", { + url: Schema.String, +}) { + override get message() { + return "Image URL must be a base64 data URL" + } +} + +export class DecodeError extends Schema.TaggedErrorClass()("ImageDecodeError", {}) { + override get message() { + return "Image could not be decoded" + } +} + +export class SizeError extends Schema.TaggedErrorClass()("ImageSizeError", { + bytes: Schema.Number, + max: Schema.Number, + width: Schema.Number, + height: Schema.Number, + max_width: Schema.Number, + max_height: Schema.Number, +}) { + override get message() { + return `Image ${this.width}x${this.height} with base64 size ${this.bytes} exceeds configured limits and could not be resized below ${this.max_width}x${this.max_height}/${this.max} bytes` + } +} + +export type Error = PhotonUnavailableError | InvalidDataUrlError | DecodeError | SizeError + +export interface Interface { + readonly normalize: (input: MessageV2.FilePart) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/Image") {} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const config = yield* Config.Service + const loadPhoton = yield* Effect.cached( + Effect.promise(async () => { + try { + const photonWasm = (await import("@silvia-odwyer/photon-node/photon_rs_bg.wasm", { with: { type: "file" } })) + .default + // Patched photon-node reads this during module init so Bun compiled binaries use the embedded wasm path. + ;(globalThis as typeof globalThis & { __OPENCODE_PHOTON_WASM_PATH?: string }).__OPENCODE_PHOTON_WASM_PATH = + photonWasm + return await import("@silvia-odwyer/photon-node") + } catch { + return null + } + }), + ) + + const normalize = Effect.fn("Image.normalize")(function* (input: MessageV2.FilePart) { + const image = (yield* config.get()).attachment?.image + const info = { + autoResize: image?.auto_resize ?? AUTO_RESIZE, + maxWidth: image?.max_width ?? MAX_WIDTH, + maxHeight: image?.max_height ?? MAX_HEIGHT, + maxBase64Bytes: image?.max_base64_bytes ?? MAX_BASE64_BYTES, + } + if (!input.url.startsWith("data:") || !input.url.includes(";base64,")) + return yield* new InvalidDataUrlError({ url: input.url }) + + const base64 = input.url.slice(input.url.indexOf(";base64,") + ";base64,".length) + const photon = yield* loadPhoton + if (!photon) return yield* new PhotonUnavailableError() + + const decoded = yield* Effect.sync(() => { + try { + return photon.PhotonImage.new_from_byteslice(Buffer.from(base64, "base64")) + } catch { + return undefined + } + }) + if (!decoded) return yield* new DecodeError() + + try { + const originalWidth = decoded.get_width() + const originalHeight = decoded.get_height() + if ( + originalWidth <= info.maxWidth && + originalHeight <= info.maxHeight && + Buffer.byteLength(base64, "utf8") <= info.maxBase64Bytes + ) + return input + if (!info.autoResize) + return yield* new SizeError({ + bytes: Buffer.byteLength(base64, "utf8"), + max: info.maxBase64Bytes, + width: originalWidth, + height: originalHeight, + max_width: info.maxWidth, + max_height: info.maxHeight, + }) + + const scale = Math.min(1, info.maxWidth / originalWidth, info.maxHeight / originalHeight) + for (const size of Array.from({ length: 32 }).reduce>((acc) => { + const previous = acc.at(-1) ?? { + width: Math.max(1, Math.round(originalWidth * scale)), + height: Math.max(1, Math.round(originalHeight * scale)), + } + const next = + acc.length === 0 + ? previous + : { + width: previous.width === 1 ? 1 : Math.max(1, Math.floor(previous.width * 0.75)), + height: previous.height === 1 ? 1 : Math.max(1, Math.floor(previous.height * 0.75)), + } + return acc.some((item) => item.width === next.width && item.height === next.height) ? acc : [...acc, next] + }, [])) { + const resized = photon.resize(decoded, size.width, size.height, photon.SamplingFilter.Lanczos3) + const candidate = [ + { data: Buffer.from(resized.get_bytes()).toString("base64"), mime: "image/png" }, + ...JPEG_QUALITIES.map((quality) => ({ + data: Buffer.from(resized.get_bytes_jpeg(quality)).toString("base64"), + mime: "image/jpeg", + })), + ] + .map((item) => ({ ...item, bytes: Buffer.byteLength(item.data, "utf8") })) + .find((item) => item.bytes <= info.maxBase64Bytes) + resized.free() + + if (candidate) { + log.info("using resized image", { + from_mime: input.mime, + to_mime: candidate.mime, + from: `${originalWidth}x${originalHeight}`, + to: `${size.width}x${size.height}`, + }) + return { + ...input, + mime: candidate.mime, + url: `data:${candidate.mime};base64,${candidate.data}`, + } + } + } + + return yield* new SizeError({ + bytes: Buffer.byteLength(base64, "utf8"), + max: info.maxBase64Bytes, + width: originalWidth, + height: originalHeight, + max_width: info.maxWidth, + max_height: info.maxHeight, + }) + } finally { + decoded.free() + } + }) + + return Service.of({ normalize }) + }), +) + +export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer)) + +export * as Image from "./image" diff --git a/packages/opencode/src/lsp/lsp.ts b/packages/opencode/src/lsp/lsp.ts index 5110eccbf8..a647dc099f 100644 --- a/packages/opencode/src/lsp/lsp.ts +++ b/packages/opencode/src/lsp/lsp.ts @@ -13,8 +13,8 @@ import { spawn as lspspawn } from "./launch" import { Effect, Layer, Context, Schema } from "effect" import { InstanceState } from "@/effect/instance-state" import { containsPath } from "@/project/instance-context" -import { NonNegativeInt, withStatics } from "@/util/schema" -import { zod, ZodOverride } from "@/util/effect-zod" +import { NonNegativeInt, withStatics } from "@opencode-ai/core/schema" +import { zod, ZodOverride } from "@opencode-ai/core/effect-zod" const log = Log.create({ service: "lsp" }) diff --git a/packages/opencode/src/markdown.d.ts b/packages/opencode/src/markdown.d.ts new file mode 100644 index 0000000000..eb3e3b92d6 --- /dev/null +++ b/packages/opencode/src/markdown.d.ts @@ -0,0 +1,4 @@ +declare module "*.md" { + const content: string + export default content +} diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index fe71802388..ed74c648ad 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -6,6 +6,7 @@ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js" import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js" import { CallToolResultSchema, + ToolSchema, type Tool as MCPToolDef, ToolListChangedNotificationSchema, } from "@modelcontextprotocol/sdk/types.js" @@ -30,12 +31,21 @@ import { EffectBridge } from "@/effect/bridge" import { InstanceState } from "@/effect/instance-state" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" -import { zod as effectZod } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod as effectZod } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" const log = Log.create({ service: "mcp" }) const DEFAULT_TIMEOUT = 30_000 +const TolerantToolSchema = ToolSchema.extend({ + outputSchema: z.unknown().optional(), +}) + +const TolerantListToolsResultSchema = z.looseObject({ + tools: z.array(TolerantToolSchema), + nextCursor: z.string().optional(), +}) + export const Resource = Schema.Struct({ name: Schema.String, uri: Schema.String, @@ -119,6 +129,38 @@ function remoteURL(key: string, value: string) { log.warn("invalid remote mcp url", { key }) } +function isOutputSchemaValidationError(error: Error) { + return /can't resolve reference|resolves to more than one schema|outputSchema|schema.*reference|reference.*schema/i.test( + error.message, + ) +} + +function listTools(key: string, client: MCPClient, timeout: number) { + return Effect.tryPromise({ + try: () => client.listTools(undefined, { timeout }), + catch: (err) => (err instanceof Error ? err : new Error(String(err))), + }).pipe( + Effect.map((result) => result.tools), + Effect.catch((error) => { + if (!isOutputSchemaValidationError(error)) return Effect.fail(error) + + log.warn("failed to validate MCP tool output schemas, retrying without output schema validation", { key, error }) + return Effect.tryPromise({ + try: () => client.request({ method: "tools/list" }, TolerantListToolsResultSchema, { timeout }), + catch: (err) => (err instanceof Error ? err : new Error(String(err))), + }).pipe( + Effect.map((result) => + result.tools.map((tool) => ({ + name: tool.name, + description: tool.description, + inputSchema: tool.inputSchema, + })), + ), + ) + }), + ) +} + // Convert MCP tool definition to AI SDK Tool type function convertMcpTool(mcpTool: MCPToolDef, client: MCPClient, timeout?: number): Tool { const inputSchema = mcpTool.inputSchema @@ -151,11 +193,7 @@ function convertMcpTool(mcpTool: MCPToolDef, client: MCPClient, timeout?: number } function defs(key: string, client: MCPClient, timeout?: number) { - return Effect.tryPromise({ - try: () => withTimeout(client.listTools(), timeout ?? DEFAULT_TIMEOUT), - catch: (err) => (err instanceof Error ? err : new Error(String(err))), - }).pipe( - Effect.map((result) => result.tools), + return listTools(key, client, timeout ?? DEFAULT_TIMEOUT).pipe( Effect.catch((err) => { log.error("failed to get tools from client", { key, error: err }) return Effect.succeed(undefined) diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts index d93670709e..f4bd2e2cc1 100644 --- a/packages/opencode/src/permission/index.ts +++ b/packages/opencode/src/permission/index.ts @@ -7,9 +7,9 @@ import { MessageID, SessionID } from "@/session/schema" import { PermissionTable } from "@/session/session.sql" import { Database } from "@/storage/db" import { eq } from "drizzle-orm" -import { zod } from "@/util/effect-zod" +import { zod } from "@opencode-ai/core/effect-zod" import * as Log from "@opencode-ai/core/util/log" -import { withStatics } from "@/util/schema" +import { withStatics } from "@opencode-ai/core/schema" import { Wildcard } from "@/util/wildcard" import { Deferred, Effect, Layer, Schema, Context } from "effect" import os from "os" diff --git a/packages/opencode/src/permission/schema.ts b/packages/opencode/src/permission/schema.ts index 4eddc6a47a..f7c6e2c5b7 100644 --- a/packages/opencode/src/permission/schema.ts +++ b/packages/opencode/src/permission/schema.ts @@ -1,12 +1,12 @@ import { Schema } from "effect" import { Identifier } from "@/id/id" -import { zod, ZodOverride } from "@/util/effect-zod" -import { Newtype } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { Newtype } from "@opencode-ai/core/schema" export class PermissionID extends Newtype()( "PermissionID", - Schema.String.annotate({ [ZodOverride]: Identifier.schema("permission") }), + Schema.String.check(Schema.isStartsWith("per")), ) { static ascending(id?: string): PermissionID { return this.make(Identifier.ascending("permission", id)) diff --git a/packages/opencode/src/project/bootstrap.ts b/packages/opencode/src/project/bootstrap.ts index fb3e1bb32d..6103a9efb4 100644 --- a/packages/opencode/src/project/bootstrap.ts +++ b/packages/opencode/src/project/bootstrap.ts @@ -12,6 +12,7 @@ import { ShareNext } from "@/share/share-next" import { Effect, Layer } from "effect" import { Config } from "@/config/config" import { Service } from "./bootstrap-service" +import { Reference } from "@/reference/reference" export { Service } from "./bootstrap-service" export type { Interface } from "./bootstrap-service" @@ -29,6 +30,7 @@ export const layer = Layer.effect( const lsp = yield* LSP.Service const plugin = yield* Plugin.Service const project = yield* Project.Service + const reference = yield* Reference.Service const shareNext = yield* ShareNext.Service const snapshot = yield* Snapshot.Service const vcs = yield* Vcs.Service @@ -43,7 +45,7 @@ export const layer = Layer.effect( // Each service self-manages its own slow work via Effect.forkScoped against // its per-instance state scope. We just await materialization here. yield* Effect.forEach( - [lsp, shareNext, format, file, fileWatcher, vcs, snapshot, project], + [reference, lsp, shareNext, format, file, fileWatcher, vcs, snapshot, project], (s) => s.init().pipe(Effect.catchCause((cause) => Effect.logWarning("init failed", { cause }))), { concurrency: "unbounded", discard: true }, ).pipe(Effect.withSpan("InstanceBootstrap.init")) @@ -63,6 +65,7 @@ export const defaultLayer: Layer.Layer = layer.pipe( LSP.defaultLayer, Plugin.defaultLayer, Project.defaultLayer, + Reference.defaultLayer, ShareNext.defaultLayer, Snapshot.defaultLayer, Vcs.defaultLayer, diff --git a/packages/opencode/src/project/instance-store.ts b/packages/opencode/src/project/instance-store.ts index 4fa1c3dfff..9707305f93 100644 --- a/packages/opencode/src/project/instance-store.ts +++ b/packages/opencode/src/project/instance-store.ts @@ -86,7 +86,7 @@ export const layer: Layer.Layer runDisposers(ctx.directory)) yield* emitDisposed({ directory: ctx.directory, project: ctx.project.id }) }) @@ -109,7 +109,7 @@ export const layer: Layer.Layer() } cache.set(directory, entry) yield* Effect.gen(function* () { - yield* Effect.logInfo("creating instance", { directory }) + yield* Effect.logInfo("creating instance").pipe(Effect.annotateLogs("directory", directory)) yield* completeLoad(directory, input, entry) }).pipe(Effect.forkIn(scope, { startImmediately: true })) return yield* restore(Deferred.await(entry.deferred)) @@ -125,7 +125,7 @@ export const layer: Layer.Layer() } cache.set(directory, entry) yield* Effect.gen(function* () { - yield* Effect.logInfo("reloading instance", { directory }) + yield* Effect.logInfo("reloading instance").pipe(Effect.annotateLogs("directory", directory)) if (previous) { yield* Deferred.await(previous.deferred).pipe(Effect.ignore) yield* Effect.promise(() => runDisposers(directory)) diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts index a2c1a097b1..25feb657c1 100644 --- a/packages/opencode/src/project/project.ts +++ b/packages/opencode/src/project/project.ts @@ -18,8 +18,8 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import { NodePath } from "@effect/platform-node" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" -import { zod } from "@/util/effect-zod" -import { NonNegativeInt, optionalOmitUndefined, withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { NonNegativeInt, optionalOmitUndefined, withStatics } from "@opencode-ai/core/schema" import { serviceUse } from "@/effect/service-use" const log = Log.create({ service: "project" }) diff --git a/packages/opencode/src/project/schema.ts b/packages/opencode/src/project/schema.ts index 7708b8de1e..c6cff94fde 100644 --- a/packages/opencode/src/project/schema.ts +++ b/packages/opencode/src/project/schema.ts @@ -1,7 +1,7 @@ import { Schema } from "effect" -import { zod } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" const projectIdSchema = Schema.String.pipe(Schema.brand("ProjectID")) diff --git a/packages/opencode/src/project/vcs.ts b/packages/opencode/src/project/vcs.ts index 02173453db..092444c444 100644 --- a/packages/opencode/src/project/vcs.ts +++ b/packages/opencode/src/project/vcs.ts @@ -6,8 +6,8 @@ import { InstanceState } from "@/effect/instance-state" import { FileWatcher } from "@/file/watcher" import { Git } from "@/git" import * as Log from "@opencode-ai/core/util/log" -import { zod, zodObject } from "@/util/effect-zod" -import { NonNegativeInt, withStatics } from "@/util/schema" +import { zod, zodObject } from "@opencode-ai/core/effect-zod" +import { NonNegativeInt, withStatics } from "@opencode-ai/core/schema" const log = Log.create({ service: "vcs" }) const PATCH_CONTEXT_LINES = 2_147_483_647 @@ -230,9 +230,12 @@ export type Info = Schema.Schema.Type export const FileDiff = Schema.Struct({ file: Schema.String, - patch: Schema.String, - additions: NonNegativeInt, - deletions: NonNegativeInt, + // Mirrors Snapshot.FileDiff (see #26574). The current producer always + // populates patch, but loosening matches the sibling schema so a + // future code path that omits it can't crash /instance/vcs/diff. + patch: Schema.optional(Schema.String), + additions: Schema.Finite, + deletions: Schema.Finite, status: Schema.optional(Schema.Literals(["added", "deleted", "modified"])), }) .annotate({ identifier: "VcsFileDiff" }) @@ -241,8 +244,8 @@ export type FileDiff = Schema.Schema.Type export const FileStatus = Schema.Struct({ file: Schema.String, - additions: NonNegativeInt, - deletions: NonNegativeInt, + additions: Schema.Finite, + deletions: Schema.Finite, status: Schema.Literals(["added", "deleted", "modified"]), }) .annotate({ identifier: "VcsFileStatus" }) diff --git a/packages/opencode/src/provider/auth.ts b/packages/opencode/src/provider/auth.ts index 9b2ca33c31..135df6fecf 100644 --- a/packages/opencode/src/provider/auth.ts +++ b/packages/opencode/src/provider/auth.ts @@ -1,9 +1,9 @@ import type { AuthOAuthResult, Hooks } from "@opencode-ai/plugin" import { Auth } from "@/auth" import { InstanceState } from "@/effect/instance-state" -import { zod } from "@/util/effect-zod" +import { zod } from "@opencode-ai/core/effect-zod" import { namedSchemaError } from "@/util/named-schema-error" -import { optionalOmitUndefined, withStatics } from "@/util/schema" +import { optionalOmitUndefined, withStatics } from "@opencode-ai/core/schema" import { Plugin } from "../plugin" import { ProviderID } from "./schema" import { Array as Arr, Effect, Layer, Record, Result, Context, Schema } from "effect" diff --git a/packages/opencode/src/provider/model-status.ts b/packages/opencode/src/provider/model-status.ts new file mode 100644 index 0000000000..468b59ce39 --- /dev/null +++ b/packages/opencode/src/provider/model-status.ts @@ -0,0 +1,9 @@ +import { Schema } from "effect" + +export const CatalogModelStatus = Schema.Literals(["alpha", "beta", "deprecated"]) +export type CatalogModelStatus = typeof CatalogModelStatus.Type + +export const ModelStatus = Schema.Literals(["alpha", "beta", "deprecated", "active"]) +export type ModelStatus = typeof ModelStatus.Type + +export * as ProviderModelStatus from "./model-status" diff --git a/packages/opencode/src/provider/models.ts b/packages/opencode/src/provider/models.ts index 77e217eb7f..fb240e4cf1 100644 --- a/packages/opencode/src/provider/models.ts +++ b/packages/opencode/src/provider/models.ts @@ -8,6 +8,7 @@ import { Flock } from "@opencode-ai/core/util/flock" import { Hash } from "@opencode-ai/core/util/hash" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { withTransientReadRetry } from "@/util/effect-http-client" +import { CatalogModelStatus } from "./model-status" const Cost = Schema.Struct({ input: Schema.Finite, @@ -71,7 +72,7 @@ export const Model = Schema.Struct({ ), }), ), - status: Schema.optional(Schema.Literals(["alpha", "beta", "deprecated"])), + status: Schema.optional(CatalogModelStatus), provider: Schema.optional( Schema.Struct({ npm: Schema.optional(Schema.String), api: Schema.optional(Schema.String) }), ), diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 4013dcee36..c27b69b6a2 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -13,7 +13,7 @@ import { Auth } from "../auth" import { Env } from "../env" import { InstallationVersion } from "@opencode-ai/core/installation/version" import { Flag } from "@opencode-ai/core/flag/flag" -import { zod } from "@/util/effect-zod" +import { zod } from "@opencode-ai/core/effect-zod" import { namedSchemaError } from "@/util/named-schema-error" import { iife } from "@/util/iife" import { Global } from "@opencode-ai/core/global" @@ -24,10 +24,11 @@ import { EffectBridge } from "@/effect/bridge" import { InstanceState } from "@/effect/instance-state" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { isRecord } from "@/util/record" -import { optionalOmitUndefined, withStatics } from "@/util/schema" +import { optionalOmitUndefined, withStatics } from "@opencode-ai/core/schema" import * as ProviderTransform from "./transform" import { ModelID, ProviderID } from "./schema" +import { ModelStatus } from "./model-status" const log = Log.create({ service: "provider" }) @@ -897,7 +898,7 @@ export const Model = Schema.Struct({ capabilities: ProviderCapabilities, cost: ProviderCost, limit: ProviderLimit, - status: Schema.Literals(["alpha", "beta", "deprecated", "active"]), + status: ModelStatus, options: Schema.Record(Schema.String, Schema.Any), headers: Schema.Record(Schema.String, Schema.String), release_date: Schema.String, @@ -935,6 +936,16 @@ export const ConfigProvidersResult = Schema.Struct({ }).pipe(withStatics((s) => ({ zod: zod(s) }))) export type ConfigProvidersResult = Types.DeepMutable> +export function toPublicInfo(provider: Info): Info { + return JSON.parse( + JSON.stringify(provider, (_, value) => { + if (typeof value === "function" || typeof value === "symbol" || value === undefined) return undefined + if (typeof value === "bigint") return value.toString() + return value + }), + ) +} + export function defaultModelIDs }>(providers: Record) { return mapValues(providers, (item) => sort(Object.values(item.models))[0].id) } @@ -1152,7 +1163,7 @@ const layer: Layer.Layer< const pluginAuth = yield* auth.get(providerID).pipe(Effect.orDie) provider.models = yield* Effect.promise(async () => { - const next = await models(provider, { auth: pluginAuth }) + const next = await models(toPublicInfo(provider), { auth: pluginAuth }) return Object.fromEntries( Object.entries(next).map(([id, model]) => [ id, @@ -1299,7 +1310,7 @@ const layer: Layer.Layer< const options = yield* Effect.promise(() => plugin.auth!.loader!( () => bridge.promise(auth.get(providerID).pipe(Effect.orDie)) as any, - database[plugin.auth!.provider], + toPublicInfo(database[plugin.auth!.provider]), ), ) const opts = options ?? {} diff --git a/packages/opencode/src/provider/schema.ts b/packages/opencode/src/provider/schema.ts index ea3cac3424..757b70f3ff 100644 --- a/packages/opencode/src/provider/schema.ts +++ b/packages/opencode/src/provider/schema.ts @@ -1,7 +1,7 @@ import { Schema } from "effect" -import { zod } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" const providerIdSchema = Schema.String.pipe(Schema.brand("ProviderID")) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 3f52f6a2aa..bd778dacc5 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -603,6 +603,29 @@ function anthropicAdaptiveEfforts(apiId: string): string[] | null { return null } +function googleThinkingLevelEfforts(apiId: string) { + const id = apiId.toLowerCase() + if (!id.includes("gemini-3")) return ["low", "high"] + if (id.includes("flash-image")) return ["minimal", "high"] + if (id.includes("pro-image")) return ["high"] + if (id.includes("flash")) return ["minimal", "low", "medium", "high"] + return ["low", "medium", "high"] +} + +function googleThinkingBudgetMax(apiId: string) { + const id = apiId.toLowerCase() + if (id.includes("2.5") && id.includes("pro") && !id.includes("flash")) return 32_768 + return 24_576 +} + +function googleSmallThinkingConfig(apiId: string) { + const levels = googleThinkingLevelEfforts(apiId) + if (apiId.toLowerCase().includes("gemini-3")) { + return { thinkingLevel: levels.includes("minimal") ? "minimal" : levels.includes("low") ? "low" : "high" } + } + return { thinkingBudget: googleThinkingBudgetMax(apiId) === 32_768 ? 128 : 0 } +} + export function variants(model: Provider.Model): Record> { if (!model.capabilities.reasoning) return {} @@ -908,18 +931,14 @@ export function variants(model: Provider.Model): Record [ + googleThinkingLevelEfforts(id).map((effort) => [ effort, { thinkingConfig: { @@ -1186,10 +1205,7 @@ export function smallOptions(model: Provider.Model) { } if (model.providerID === "google") { // gemini-3 uses thinkingLevel, gemini-2.5 uses thinkingBudget - if (model.api.id.includes("gemini-3")) { - return { thinkingConfig: { thinkingLevel: "minimal" } } - } - return { thinkingConfig: { thinkingBudget: 0 } } + return { thinkingConfig: googleSmallThinkingConfig(model.api.id) } } if (model.providerID === "openrouter" || model.providerID === "llmgateway") { if (model.api.id.includes("google")) { diff --git a/packages/opencode/src/pty/index.ts b/packages/opencode/src/pty/index.ts index ade4b5d02e..85e0840cb7 100644 --- a/packages/opencode/src/pty/index.ts +++ b/packages/opencode/src/pty/index.ts @@ -10,8 +10,8 @@ import type { Proc } from "#pty" import * as Log from "@opencode-ai/core/util/log" import { PtyID } from "./schema" import { Effect, Layer, Context, Schema, Types } from "effect" -import { zod } from "@/util/effect-zod" -import { NonNegativeInt, PositiveInt, withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { NonNegativeInt, PositiveInt, withStatics } from "@opencode-ai/core/schema" const log = Log.create({ service: "pty" }) diff --git a/packages/opencode/src/pty/schema.ts b/packages/opencode/src/pty/schema.ts index 6b4d779f26..fadb0457e7 100644 --- a/packages/opencode/src/pty/schema.ts +++ b/packages/opencode/src/pty/schema.ts @@ -1,10 +1,10 @@ import { Schema } from "effect" import { Identifier } from "@/id/id" -import { zod, ZodOverride } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" -const ptyIdSchema = Schema.String.annotate({ [ZodOverride]: Identifier.schema("pty") }).pipe(Schema.brand("PtyID")) +const ptyIdSchema = Schema.String.check(Schema.isStartsWith("pty")).pipe(Schema.brand("PtyID")) export type PtyID = typeof ptyIdSchema.Type diff --git a/packages/opencode/src/pty/ticket.ts b/packages/opencode/src/pty/ticket.ts index b5e5747c51..0978e52083 100644 --- a/packages/opencode/src/pty/ticket.ts +++ b/packages/opencode/src/pty/ticket.ts @@ -3,7 +3,7 @@ export * as PtyTicket from "./ticket" import { WorkspaceID } from "@/control-plane/schema" import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref" import { PtyID } from "@/pty/schema" -import { PositiveInt } from "@/util/schema" +import { PositiveInt } from "@opencode-ai/core/schema" import { Cache, Context, Duration, Effect, Layer, Schema } from "effect" const DEFAULT_TTL = Duration.seconds(60) diff --git a/packages/opencode/src/question/index.ts b/packages/opencode/src/question/index.ts index d52f353da9..c041462ad4 100644 --- a/packages/opencode/src/question/index.ts +++ b/packages/opencode/src/question/index.ts @@ -3,9 +3,9 @@ import { Bus } from "@/bus" import { BusEvent } from "@/bus/bus-event" import { InstanceState } from "@/effect/instance-state" import { SessionID, MessageID } from "@/session/schema" -import { zod } from "@/util/effect-zod" +import { zod } from "@opencode-ai/core/effect-zod" import * as Log from "@opencode-ai/core/util/log" -import { withStatics } from "@/util/schema" +import { withStatics } from "@opencode-ai/core/schema" import { QuestionID } from "./schema" const log = Log.create({ service: "question" }) diff --git a/packages/opencode/src/question/schema.ts b/packages/opencode/src/question/schema.ts index f7a0e096a3..1856c94bc7 100644 --- a/packages/opencode/src/question/schema.ts +++ b/packages/opencode/src/question/schema.ts @@ -1,13 +1,10 @@ import { Schema } from "effect" import { Identifier } from "@/id/id" -import { zod, ZodOverride } from "@/util/effect-zod" -import { Newtype } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { Newtype } from "@opencode-ai/core/schema" -export class QuestionID extends Newtype()( - "QuestionID", - Schema.String.annotate({ [ZodOverride]: Identifier.schema("question") }), -) { +export class QuestionID extends Newtype()("QuestionID", Schema.String.check(Schema.isStartsWith("que"))) { static ascending(id?: string): QuestionID { return this.make(Identifier.ascending("question", id)) } diff --git a/packages/opencode/src/reference/reference.ts b/packages/opencode/src/reference/reference.ts new file mode 100644 index 0000000000..09e0a825d8 --- /dev/null +++ b/packages/opencode/src/reference/reference.ts @@ -0,0 +1,237 @@ +import path from "path" +import { Effect, Context, Layer, Scope } from "effect" +import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { Flag } from "@opencode-ai/core/flag/flag" +import { Global } from "@opencode-ai/core/global" +import { Config } from "@/config/config" +import { InstanceState } from "@/effect/instance-state" +import { Git } from "@/git" +import { parseRepositoryReference, repositoryCachePath, type Reference as RepositoryReference } from "@/util/repository" +import { RepositoryCache } from "./repository-cache" + +type ReferenceEntry = NonNullable[string] + +export type Resolved = + | { + name: string + kind: "local" + path: string + } + | { + name: string + kind: "git" + repository: string + reference: RepositoryReference + path: string + branch?: string + } + | { + name: string + kind: "invalid" + repository: string + message: string + } + +type State = { + references: Resolved[] + materializeAll: Effect.Effect + materializeByPath: { path: string; run: Effect.Effect }[] +} + +export interface Interface { + readonly init: () => Effect.Effect + readonly list: () => Effect.Effect + readonly get: (name: string) => Effect.Effect + readonly ensure: (target?: string) => Effect.Effect + readonly contains: (target?: string) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/Reference") {} + +export function referencePath(input: { directory: string; worktree: string; value: string }) { + if (input.value.startsWith("~/")) return path.join(Global.Path.home, input.value.slice(2)) + return path.isAbsolute(input.value) + ? input.value + : path.resolve(input.worktree === "/" ? input.directory : input.worktree, input.value) +} + +function resolveGit( + input: { name: string; repository: string } | { name: string; repository: string; branch: string | undefined }, +): Resolved { + const parsed = parseRepositoryReference(input.repository) + if (!parsed || parsed.protocol === "file:") { + return { + name: input.name, + kind: "invalid", + repository: input.repository, + message: "Repository must be a git URL, host/path reference, or GitHub owner/repo shorthand", + } + } + return { + name: input.name, + kind: "git", + repository: input.repository, + reference: parsed, + path: repositoryCachePath(parsed), + ...("branch" in input ? { branch: input.branch } : {}), + } +} + +function branchLabel(branch: string | undefined) { + return branch ?? "default branch" +} + +function normalizedTarget(target?: string) { + if (!target) return + return process.platform === "win32" ? AppFileSystem.normalizePath(target) : target +} + +function containsReferencePath(referencePath: string, target: string) { + return AppFileSystem.contains(normalizedTarget(referencePath) ?? referencePath, target) +} + +export function resolve(input: { + name: string + reference: ReferenceEntry + directory: string + worktree: string +}): Resolved { + if (typeof input.reference === "string") { + if (input.reference.startsWith(".") || input.reference.startsWith("/") || input.reference.startsWith("~")) { + return { name: input.name, kind: "local", path: referencePath({ ...input, value: input.reference }) } + } + return resolveGit({ name: input.name, repository: input.reference }) + } + + if ("path" in input.reference) { + return { name: input.name, kind: "local", path: referencePath({ ...input, value: input.reference.path }) } + } + + return resolveGit({ name: input.name, repository: input.reference.repository, branch: input.reference.branch }) +} + +export function resolveAll(input: { + references: NonNullable + directory: string + worktree: string +}) { + const seen = new Map() + return Object.entries(input.references).map(([name, reference]) => { + const resolved = resolve({ name, reference, directory: input.directory, worktree: input.worktree }) + if (resolved.kind !== "git") return resolved + + const existing = seen.get(resolved.path) + if (!existing) { + seen.set(resolved.path, { name, branch: resolved.branch }) + return resolved + } + if (existing.branch === resolved.branch) return resolved + + return { + name, + kind: "invalid" as const, + repository: resolved.repository, + message: `Reference conflicts with @${existing.name}: both use ${resolved.path}, but @${existing.name} requests ${branchLabel(existing.branch)} and @${name} requests ${branchLabel(resolved.branch)}`, + } + }) +} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const config = yield* Config.Service + const fs = yield* AppFileSystem.Service + const git = yield* Git.Service + const scope = yield* Scope.Scope + + const state = yield* InstanceState.make( + Effect.fn("Reference.state")(function* (ctx) { + const cfg = yield* config.get() + const references = resolveAll({ + references: cfg.reference ?? {}, + directory: ctx.directory, + worktree: ctx.worktree, + }) + const seenPath = new Set() + const gitReferences = references.filter((reference): reference is Extract => { + if (reference.kind !== "git") return false + if (seenPath.has(reference.path)) return false + seenPath.add(reference.path) + return true + }) + const materializeByPath = yield* Effect.forEach( + gitReferences, + Effect.fnUntraced(function* (reference) { + const run = yield* Effect.cached( + RepositoryCache.ensure( + { reference: reference.reference, branch: reference.branch, refresh: true }, + { fs, git }, + ).pipe( + Effect.asVoid, + Effect.catchCause((cause) => + Effect.logWarning("failed to materialize reference repository", { name: reference.name, cause }), + ), + ), + ) + return { path: reference.path, run } + }), + { concurrency: "unbounded" }, + ) + + const materializeAll = yield* Effect.cached( + Flag.OPENCODE_EXPERIMENTAL_SCOUT + ? Effect.gen(function* () { + yield* Effect.forEach( + materializeByPath, + Effect.fnUntraced(function* (item) { + yield* item.run + }), + { concurrency: 4, discard: true }, + ) + }) + : Effect.void, + ) + + return { references, materializeAll, materializeByPath } + }), + ) + + return Service.of({ + init: Effect.fn("Reference.init")(function* () { + if (!Flag.OPENCODE_EXPERIMENTAL_SCOUT) return + yield* InstanceState.useEffect(state, (s) => s.materializeAll).pipe(Effect.forkIn(scope), Effect.asVoid) + }), + list: Effect.fn("Reference.list")(function* () { + return yield* InstanceState.use(state, (s) => s.references) + }), + get: Effect.fn("Reference.get")(function* (name: string) { + return yield* InstanceState.use(state, (s) => s.references.find((reference) => reference.name === name)) + }), + ensure: Effect.fn("Reference.ensure")(function* (target?: string) { + if (!Flag.OPENCODE_EXPERIMENTAL_SCOUT) return + const full = normalizedTarget(target) + if (!full) return yield* InstanceState.useEffect(state, (s) => s.materializeAll) + return yield* InstanceState.useEffect( + state, + (s) => s.materializeByPath.find((item) => containsReferencePath(item.path, full))?.run ?? Effect.void, + ) + }), + contains: Effect.fn("Reference.contains")(function* (target?: string) { + if (!Flag.OPENCODE_EXPERIMENTAL_SCOUT) return false + const full = normalizedTarget(target) + if (!full) return false + return yield* InstanceState.use(state, (s) => + s.references.some((reference) => reference.kind === "git" && containsReferencePath(reference.path, full)), + ) + }), + }) + }), +) + +export const defaultLayer = layer.pipe( + Layer.provide(Config.defaultLayer), + Layer.provide(AppFileSystem.defaultLayer), + Layer.provide(Git.defaultLayer), +) + +export * as Reference from "./reference" diff --git a/packages/opencode/src/reference/repository-cache.ts b/packages/opencode/src/reference/repository-cache.ts new file mode 100644 index 0000000000..d31db8ab5f --- /dev/null +++ b/packages/opencode/src/reference/repository-cache.ts @@ -0,0 +1,147 @@ +import path from "path" +import { Effect } from "effect" +import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { Flock } from "@opencode-ai/core/util/flock" +import { Git } from "@/git" +import { + repositoryCachePath, + sameRepositoryReference, + parseRepositoryReference, + validateRepositoryBranch, + type Reference as RepositoryReference, +} from "@/util/repository" + +export type Result = { + repository: string + host: string + remote: string + localPath: string + status: "cached" | "cloned" | "refreshed" + head?: string + branch?: string +} + +function statusForRepository(input: { reuse: boolean; refresh?: boolean; branchMatches?: boolean }) { + if (!input.reuse) return "cloned" as const + if (input.branchMatches === false) return "refreshed" as const + if (input.refresh) return "refreshed" as const + return "cached" as const +} + +function resetTarget(input: { + requestedBranch?: string + remoteHead: { code: number; stdout: string } + branch: { code: number; stdout: string } +}) { + if (input.requestedBranch) return `origin/${input.requestedBranch}` + if (input.remoteHead.code === 0 && input.remoteHead.stdout) { + return input.remoteHead.stdout.replace(/^refs\/remotes\//, "") + } + if (input.branch.code === 0 && input.branch.stdout) { + return `origin/${input.branch.stdout}` + } + return "HEAD" +} + +export const ensure = Effect.fn("RepositoryCache.ensure")(function* ( + input: { + reference: RepositoryReference + refresh?: boolean + branch?: string + }, + services: { + fs: AppFileSystem.Interface + git: Git.Interface + }, +) { + if (input.branch) validateRepositoryBranch(input.branch) + + const repository = input.reference.label + const remote = input.reference.remote + const localPath = repositoryCachePath(input.reference) + const cloneTarget = parseRepositoryReference(remote) ?? input.reference + + return yield* Effect.acquireUseRelease( + Effect.promise((signal) => Flock.acquire(`repo-clone:${localPath}`, { signal })), + () => + Effect.gen(function* () { + yield* services.fs.ensureDir(path.dirname(localPath)).pipe(Effect.orDie) + + const exists = yield* services.fs.existsSafe(localPath) + const hasGitDir = yield* services.fs.existsSafe(path.join(localPath, ".git")) + const origin = hasGitDir + ? yield* services.git.run(["config", "--get", "remote.origin.url"], { cwd: localPath }) + : undefined + const originReference = origin?.exitCode === 0 ? parseRepositoryReference(origin.text().trim()) : undefined + const reuse = hasGitDir && Boolean(originReference && sameRepositoryReference(originReference, cloneTarget)) + if (exists && !reuse) { + yield* services.fs.remove(localPath, { recursive: true }).pipe(Effect.orDie) + } + + const currentBranch = hasGitDir ? yield* services.git.branch(localPath) : undefined + const status = statusForRepository({ + reuse, + refresh: input.refresh, + branchMatches: input.branch ? currentBranch === input.branch : undefined, + }) + + if (status === "cloned") { + const clone = yield* services.git.run( + ["clone", "--depth", "100", ...(input.branch ? ["--branch", input.branch] : []), "--", remote, localPath], + { cwd: path.dirname(localPath) }, + ) + if (clone.exitCode !== 0) { + throw new Error(clone.stderr.toString().trim() || clone.text().trim() || `Failed to clone ${repository}`) + } + } + + if (status === "refreshed") { + const fetch = yield* services.git.run(["fetch", "--all", "--prune"], { cwd: localPath }) + if (fetch.exitCode !== 0) { + throw new Error(fetch.stderr.toString().trim() || fetch.text().trim() || `Failed to refresh ${repository}`) + } + + if (input.branch) { + const checkout = yield* services.git.run(["checkout", "-B", input.branch, `origin/${input.branch}`], { + cwd: localPath, + }) + if (checkout.exitCode !== 0) { + throw new Error( + checkout.stderr.toString().trim() || checkout.text().trim() || `Failed to checkout ${input.branch}`, + ) + } + } + + const remoteHead = yield* services.git.run(["symbolic-ref", "refs/remotes/origin/HEAD"], { cwd: localPath }) + const branch = yield* services.git.run(["symbolic-ref", "--quiet", "--short", "HEAD"], { cwd: localPath }) + const target = resetTarget({ + requestedBranch: input.branch, + remoteHead: { code: remoteHead.exitCode, stdout: remoteHead.text().trim() }, + branch: { code: branch.exitCode, stdout: branch.text().trim() }, + }) + + const reset = yield* services.git.run(["reset", "--hard", target], { cwd: localPath }) + if (reset.exitCode !== 0) { + throw new Error(reset.stderr.toString().trim() || reset.text().trim() || `Failed to reset ${repository}`) + } + } + + const head = yield* services.git.run(["rev-parse", "HEAD"], { cwd: localPath }) + const branch = yield* services.git.branch(localPath) + const headText = head.exitCode === 0 ? head.text().trim() : undefined + + return { + repository, + host: input.reference.host, + remote, + localPath, + status, + head: headText, + branch, + } satisfies Result + }), + (lock) => Effect.promise(() => lock.release()).pipe(Effect.ignore), + ) +}) + +export * as RepositoryCache from "./repository-cache" diff --git a/packages/opencode/src/server/adapter.bun.ts b/packages/opencode/src/server/adapter.bun.ts deleted file mode 100644 index b1f3bae27a..0000000000 --- a/packages/opencode/src/server/adapter.bun.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { Hono } from "hono" -import { createBunWebSocket } from "hono/bun" -import type { Adapter, FetchApp, Opts } from "./adapter" - -function listen(app: FetchApp, opts: Opts, websocket?: ReturnType["websocket"]) { - const start = (port: number) => { - try { - if (websocket) { - return Bun.serve({ fetch: app.fetch, hostname: opts.hostname, idleTimeout: 0, websocket, port }) - } - return Bun.serve({ fetch: app.fetch, hostname: opts.hostname, idleTimeout: 0, port }) - } catch { - return - } - } - const server = opts.port === 0 ? (start(4096) ?? start(0)) : start(opts.port) - if (!server) { - throw new Error(`Failed to start server on port ${opts.port}`) - } - if (!server.port) { - throw new Error(`Failed to resolve server address for port ${opts.port}`) - } - return { - port: server.port, - stop(close?: boolean) { - return Promise.resolve(server.stop(close)) - }, - } -} - -export const adapter: Adapter = { - create(app: Hono) { - const ws = createBunWebSocket() - return { - upgradeWebSocket: ws.upgradeWebSocket, - listen: (opts) => Promise.resolve(listen(app, opts, ws.websocket)), - } - }, - createFetch(app) { - return { - listen: (opts) => Promise.resolve(listen(app, opts)), - } - }, -} diff --git a/packages/opencode/src/server/adapter.node.ts b/packages/opencode/src/server/adapter.node.ts deleted file mode 100644 index 55ced40f77..0000000000 --- a/packages/opencode/src/server/adapter.node.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { EventEmitter } from "node:events" -import { createAdaptorServer, type ServerType } from "@hono/node-server" -import { createNodeWebSocket } from "@hono/node-ws" -import type { Hono } from "hono" -import type { Adapter, FetchApp, Opts } from "./adapter" - -async function listen(app: FetchApp, opts: Opts, inject?: (server: ServerType) => void) { - const start = (port: number) => - new Promise((resolve, reject) => { - const server = createAdaptorServer({ fetch: app.fetch }) - const events = server as EventEmitter - inject?.(server) - const fail = (err: Error) => { - cleanup() - reject(err) - } - const ready = () => { - cleanup() - resolve(server) - } - const cleanup = () => { - events.off("error", fail) - events.off("listening", ready) - } - events.once("error", fail) - events.once("listening", ready) - server.listen(port, opts.hostname) - }) - - const server = opts.port === 0 ? await start(4096).catch(() => start(0)) : await start(opts.port) - const addr = server.address() - if (!addr || typeof addr === "string") { - throw new Error(`Failed to resolve server address for port ${opts.port}`) - } - - let closing: Promise | undefined - return { - port: addr.port, - stop(close?: boolean) { - closing ??= new Promise((resolve, reject) => { - server.close((err) => { - if (err) { - reject(err) - return - } - resolve() - }) - if (close) { - if ("closeAllConnections" in server && typeof server.closeAllConnections === "function") { - server.closeAllConnections() - } - if ("closeIdleConnections" in server && typeof server.closeIdleConnections === "function") { - server.closeIdleConnections() - } - } - }) - return closing - }, - } -} - -export const adapter: Adapter = { - create(app: Hono) { - const ws = createNodeWebSocket({ app }) - return { - upgradeWebSocket: ws.upgradeWebSocket, - listen: (opts) => listen(app, opts, ws.injectWebSocket), - } - }, - createFetch(app) { - return { - listen: (opts) => listen(app, opts), - } - }, -} diff --git a/packages/opencode/src/server/adapter.ts b/packages/opencode/src/server/adapter.ts deleted file mode 100644 index 7f4edd2c17..0000000000 --- a/packages/opencode/src/server/adapter.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { Hono } from "hono" -import type { UpgradeWebSocket } from "hono/ws" - -export type FetchApp = { - fetch(request: Request): Response | Promise -} - -export type Opts = { - port: number - hostname: string -} - -export type Listener = { - port: number - stop: (close?: boolean) => Promise -} - -export interface Runtime { - upgradeWebSocket: UpgradeWebSocket - listen(opts: Opts): Promise -} - -export interface Adapter { - create(app: Hono): Runtime - createFetch(app: FetchApp): Omit -} diff --git a/packages/opencode/src/server/backend.ts b/packages/opencode/src/server/backend.ts deleted file mode 100644 index f456dc0be5..0000000000 --- a/packages/opencode/src/server/backend.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { Flag } from "@opencode-ai/core/flag/flag" -import { InstallationChannel, InstallationVersion } from "@opencode-ai/core/installation/version" - -export type Backend = "effect-httpapi" | "hono" - -export type Selection = { - backend: Backend - reason: "env" | "stable" | "explicit" -} - -export type Attributes = ReturnType - -export function select(): Selection { - if (Flag.OPENCODE_EXPERIMENTAL_HTTPAPI) return { backend: "effect-httpapi", reason: "env" } - return { backend: "hono", reason: "stable" } -} - -export function attributes(selection: Selection): Record { - return { - "opencode.server.backend": selection.backend, - "opencode.server.backend.reason": selection.reason, - "opencode.installation.channel": InstallationChannel, - "opencode.installation.version": InstallationVersion, - } -} - -export function force(selection: Selection, backend: Backend): Selection { - return { - backend, - reason: selection.backend === backend ? selection.reason : "explicit", - } -} diff --git a/packages/opencode/src/server/error.ts b/packages/opencode/src/server/error.ts deleted file mode 100644 index 506e798187..0000000000 --- a/packages/opencode/src/server/error.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { resolver } from "hono-openapi" -import z from "zod" -import { NotFoundError } from "@/storage/storage" - -export const ERRORS = { - 400: { - description: "Bad request", - content: { - "application/json": { - schema: resolver( - z - .object({ - data: z.any(), - errors: z.array(z.record(z.string(), z.any())), - success: z.literal(false), - }) - .meta({ - ref: "BadRequestError", - }), - ), - }, - }, - }, - 403: { - description: "Forbidden", - }, - 404: { - description: "Not found", - content: { - "application/json": { - schema: resolver(NotFoundError.Schema), - }, - }, - }, -} as const - -export function errors(...codes: number[]) { - return Object.fromEntries(codes.map((code) => [code, ERRORS[code as keyof typeof ERRORS]])) -} diff --git a/packages/opencode/src/server/fence.ts b/packages/opencode/src/server/fence.ts deleted file mode 100644 index 1b8c42c899..0000000000 --- a/packages/opencode/src/server/fence.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { MiddlewareHandler } from "hono" -import * as Log from "@opencode-ai/core/util/log" -import { HEADER, diff, load } from "./shared/fence" - -const log = Log.create({ service: "fence-middleware" }) - -export const FenceMiddleware: MiddlewareHandler = async (c, next) => { - if (c.req.method === "GET" || c.req.method === "HEAD" || c.req.method === "OPTIONS") return next() - - const prev = load() - await next() - const current = diff(prev, load()) - - if (Object.keys(current).length > 0) { - log.info("header", { - diff: current, - }) - c.res.headers.set(HEADER, JSON.stringify(current)) - } -} diff --git a/packages/opencode/src/server/httpapi-server.node.ts b/packages/opencode/src/server/httpapi-server.node.ts index 5d29fae33f..d6c6cbd2fd 100644 --- a/packages/opencode/src/server/httpapi-server.node.ts +++ b/packages/opencode/src/server/httpapi-server.node.ts @@ -1,13 +1,14 @@ import { NodeHttpServer } from "@effect/platform-node" import { Effect, Layer } from "effect" import { createServer } from "node:http" -import type { Opts } from "./adapter" import { Service } from "./httpapi-server" export { Service } export const name = "node-http-server" +export type Opts = { port: number; hostname: string } + export const layer = (opts: Opts) => { const server = createServer() const serverRef = { closeStarted: false, forceStop: false } diff --git a/packages/opencode/src/server/middleware.ts b/packages/opencode/src/server/middleware.ts deleted file mode 100644 index 160d258796..0000000000 --- a/packages/opencode/src/server/middleware.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { Provider } from "@/provider/provider" -import { NamedError } from "@opencode-ai/core/util/error" -import { NotFoundError } from "@/storage/storage" -import { Session } from "@/session/session" -import type { ContentfulStatusCode } from "hono/utils/http-status" -import type { ErrorHandler, MiddlewareHandler } from "hono" -import { HTTPException } from "hono/http-exception" -import * as Log from "@opencode-ai/core/util/log" -import { Flag } from "@opencode-ai/core/flag/flag" -import { basicAuth } from "hono/basic-auth" -import { cors } from "hono/cors" -import { compress } from "hono/compress" -import * as ServerBackend from "./backend" -import { isAllowedCorsOrigin, type CorsOptions } from "./cors" -import { isPtyConnectPath, PTY_CONNECT_TICKET_QUERY } from "./shared/pty-ticket" -import { isPublicUIPath } from "./shared/public-ui" - -const log = Log.create({ service: "server" }) - -export const ErrorMiddleware: ErrorHandler = (err, c) => { - log.error("failed", { - error: err, - }) - if (err instanceof NamedError) { - let status: ContentfulStatusCode - if (err instanceof NotFoundError) status = 404 - else if (err instanceof Provider.ModelNotFoundError) status = 400 - else if (err.name === "ProviderAuthValidationFailed") status = 400 - else if (err.name.startsWith("Worktree")) status = 400 - else status = 500 - return c.json(err.toObject(), { status }) - } - if (err instanceof Session.BusyError) { - return c.json(new NamedError.Unknown({ message: err.message }).toObject(), { status: 400 }) - } - if (err instanceof HTTPException) return err.getResponse() - const message = err instanceof Error && err.stack ? err.stack : err.toString() - return c.json(new NamedError.Unknown({ message }).toObject(), { - status: 500, - }) -} - -export const AuthMiddleware: MiddlewareHandler = (c, next) => { - // Allow CORS preflight requests to succeed without auth. - // Browser clients sending Authorization headers will preflight with OPTIONS. - if (c.req.method === "OPTIONS") return next() - const password = Flag.OPENCODE_SERVER_PASSWORD - if (!password) return next() - if (isPublicUIPath(c.req.method, c.req.path)) return next() - if (isPtyConnectPath(c.req.path) && c.req.query(PTY_CONNECT_TICKET_QUERY)) return next() - const username = Flag.OPENCODE_SERVER_USERNAME ?? "opencode" - - if (c.req.query("auth_token")) c.req.raw.headers.set("authorization", `Basic ${c.req.query("auth_token")}`) - - return basicAuth({ username, password })(c, next) -} - -export function LoggerMiddleware(backendAttributes: ServerBackend.Attributes): MiddlewareHandler { - return async (c, next) => { - const skip = c.req.path === "/log" - if (skip) return next() - const attributes = { - method: c.req.method, - path: c.req.path, - // If this logger grows full-URL fields, redact auth_token and ticket query params. - ...backendAttributes, - } - log.info("request", attributes) - const timer = log.time("request", attributes) - await next() - timer.stop() - } -} - -export function CorsMiddleware(opts?: CorsOptions): MiddlewareHandler { - return cors({ - maxAge: 86_400, - origin(input) { - if (isAllowedCorsOrigin(input, opts)) return input - }, - }) -} - -const zipped = compress() -export const CompressionMiddleware: MiddlewareHandler = (c, next) => { - const path = c.req.path - const method = c.req.method - if (path === "/event" || path === "/global/event") return next() - if (method === "POST" && /\/session\/[^/]+\/(message|prompt_async)$/.test(path)) return next() - return zipped(c, next) -} diff --git a/packages/opencode/src/server/proxy.ts b/packages/opencode/src/server/proxy.ts deleted file mode 100644 index 069f308512..0000000000 --- a/packages/opencode/src/server/proxy.ts +++ /dev/null @@ -1,149 +0,0 @@ -import { Hono } from "hono" -import type { UpgradeWebSocket } from "hono/ws" -import * as Log from "@opencode-ai/core/util/log" -import * as Fence from "./shared/fence" -import type { WorkspaceID } from "@/control-plane/schema" -import { Workspace } from "@/control-plane/workspace" -import { AppRuntime } from "@/effect/app-runtime" -import { ProxyUtil } from "./proxy-util" -import { Effect, Stream } from "effect" -import { FetchHttpClient, HttpBody, HttpClient, HttpClientRequest } from "effect/unstable/http" - -type Msg = string | ArrayBuffer | Uint8Array - -function send(ws: { send(data: string | ArrayBuffer | Uint8Array): void }, data: any) { - if (data instanceof Blob) { - return data.arrayBuffer().then((x) => ws.send(x)) - } - return ws.send(data) -} - -const app = (upgrade: UpgradeWebSocket) => - new Hono().get( - "/__workspace_ws", - upgrade((c) => { - const url = c.req.header("x-opencode-proxy-url") - const queue: Msg[] = [] - let remote: WebSocket | undefined - return { - onOpen(_, ws) { - if (!url) { - ws.close(1011, "missing proxy target") - return - } - remote = new WebSocket(url, ProxyUtil.websocketProtocols(c.req.raw)) - remote.binaryType = "arraybuffer" - remote.onopen = () => { - for (const item of queue) remote?.send(item) - queue.length = 0 - } - remote.onmessage = (event) => { - void send(ws, event.data) - } - remote.onerror = () => { - ws.close(1011, "proxy error") - } - remote.onclose = (event) => { - ws.close(event.code, event.reason) - } - }, - onMessage(event) { - const data = event.data - if (typeof data !== "string" && !(data instanceof Uint8Array) && !(data instanceof ArrayBuffer)) return - if (remote?.readyState === WebSocket.OPEN) { - remote.send(data) - return - } - queue.push(data) - }, - onClose(event) { - remote?.close(event.code, event.reason) - }, - } - }), - ) - -const log = Log.create({ service: "server-proxy" }) - -function statusText(response: unknown) { - return (response as { source?: Response }).source?.statusText -} - -export function httpEffect(url: string | URL, extra: HeadersInit | undefined, req: Request, workspaceID: WorkspaceID) { - return Effect.gen(function* () { - const syncing = yield* Workspace.Service.use((workspace) => workspace.isSyncing(workspaceID)) - if (!syncing) { - return new Response(`broken sync connection for workspace: ${workspaceID}`, { - status: 503, - headers: { - "content-type": "text/plain; charset=utf-8", - }, - }) - } - - const response = yield* HttpClient.execute( - HttpClientRequest.make(req.method as never)(url, { - headers: ProxyUtil.headers(req, extra), - body: - req.method === "GET" || req.method === "HEAD" - ? HttpBody.empty - : HttpBody.raw(req.body, { - contentType: req.headers.get("content-type") ?? undefined, - contentLength: req.headers.get("content-length") - ? Number(req.headers.get("content-length")) - : undefined, - }), - }), - ) - const next = new Headers(response.headers as HeadersInit) - const sync = Fence.parse(next) - next.delete("content-encoding") - next.delete("content-length") - - if (sync) yield* Fence.waitEffect(workspaceID, sync, req.signal) - const body = yield* Stream.toReadableStreamEffect(response.stream.pipe(Stream.catchCause(() => Stream.empty))) - return new Response(body, { - status: response.status, - statusText: statusText(response), - headers: next, - }) - }).pipe( - Effect.provide(FetchHttpClient.layer), - Effect.catch(() => Effect.succeed(new Response(null, { status: 500 }))), - ) -} - -export function http(url: string | URL, extra: HeadersInit | undefined, req: Request, workspaceID: WorkspaceID) { - return AppRuntime.runPromise(httpEffect(url, extra, req, workspaceID)) -} - -export function websocket( - upgrade: UpgradeWebSocket, - target: string | URL, - extra: HeadersInit | undefined, - req: Request, - env: unknown, -) { - const proxy = new URL(req.url) - proxy.pathname = "/__workspace_ws" - proxy.search = "" - const next = new Headers(req.headers) - next.set("x-opencode-proxy-url", ProxyUtil.websocketTargetURL(target)) - for (const [key, value] of new Headers(extra).entries()) { - next.set(key, value) - } - log.info("proxy websocket", { - request: req.url, - target: String(target), - }) - return app(upgrade).fetch( - new Request(proxy, { - method: req.method, - headers: next, - signal: req.signal, - }), - env as never, - ) -} - -export * as ServerProxy from "./proxy" diff --git a/packages/opencode/src/server/routes/control/index.ts b/packages/opencode/src/server/routes/control/index.ts deleted file mode 100644 index c5b39abde1..0000000000 --- a/packages/opencode/src/server/routes/control/index.ts +++ /dev/null @@ -1,160 +0,0 @@ -import { Auth } from "@/auth" -import { AppRuntime } from "@/effect/app-runtime" -import * as Log from "@opencode-ai/core/util/log" -import { Effect } from "effect" -import { ProviderID } from "@/provider/schema" -import { Hono } from "hono" -import { describeRoute, resolver, validator, openAPIRouteHandler } from "hono-openapi" -import z from "zod" -import { errors } from "../../error" - -export function ControlPlaneRoutes(): Hono { - const app = new Hono() - return app - .put( - "/auth/:providerID", - describeRoute({ - summary: "Set auth credentials", - description: "Set authentication credentials", - operationId: "auth.set", - responses: { - 200: { - description: "Successfully set authentication credentials", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - ...errors(400), - }, - }), - validator( - "param", - z.object({ - providerID: ProviderID.zod, - }), - ), - validator("json", Auth.Info.zod), - async (c) => { - const providerID = c.req.valid("param").providerID - const info = c.req.valid("json") - await AppRuntime.runPromise( - Effect.gen(function* () { - const auth = yield* Auth.Service - yield* auth.set(providerID, info) - }), - ) - return c.json(true) - }, - ) - .delete( - "/auth/:providerID", - describeRoute({ - summary: "Remove auth credentials", - description: "Remove authentication credentials", - operationId: "auth.remove", - responses: { - 200: { - description: "Successfully removed authentication credentials", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - ...errors(400), - }, - }), - validator( - "param", - z.object({ - providerID: ProviderID.zod, - }), - ), - async (c) => { - const providerID = c.req.valid("param").providerID - await AppRuntime.runPromise( - Effect.gen(function* () { - const auth = yield* Auth.Service - yield* auth.remove(providerID) - }), - ) - return c.json(true) - }, - ) - .get( - "/doc", - openAPIRouteHandler(app, { - documentation: { - info: { - title: "opencode", - version: "0.0.3", - description: "opencode api", - }, - openapi: "3.1.1", - }, - }), - ) - .use( - validator( - "query", - z.object({ - directory: z.string().optional(), - workspace: z.string().optional(), - }), - ), - ) - .post( - "/log", - describeRoute({ - summary: "Write log", - description: "Write a log entry to the server logs with specified level and metadata.", - operationId: "app.log", - responses: { - 200: { - description: "Log entry written successfully", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - ...errors(400), - }, - }), - validator( - "json", - z.object({ - service: z.string().meta({ description: "Service name for the log entry" }), - level: z.enum(["debug", "info", "error", "warn"]).meta({ description: "Log level" }), - message: z.string().meta({ description: "Log message" }), - extra: z - .record(z.string(), z.any()) - .optional() - .meta({ description: "Additional metadata for the log entry" }), - }), - ), - async (c) => { - const { service, level, message, extra } = c.req.valid("json") - const logger = Log.create({ service }) - - switch (level) { - case "debug": - logger.debug(message, extra) - break - case "info": - logger.info(message, extra) - break - case "error": - logger.error(message, extra) - break - case "warn": - logger.warn(message, extra) - break - } - - return c.json(true) - }, - ) -} diff --git a/packages/opencode/src/server/routes/control/workspace.ts b/packages/opencode/src/server/routes/control/workspace.ts deleted file mode 100644 index 0c1bf252ed..0000000000 --- a/packages/opencode/src/server/routes/control/workspace.ts +++ /dev/null @@ -1,211 +0,0 @@ -import { Hono } from "hono" -import { describeRoute, resolver, validator } from "hono-openapi" -import z from "zod" -import { Effect } from "effect" -import { listAdapters } from "@/control-plane/adapters" -import { Workspace } from "@/control-plane/workspace" -import { AppRuntime } from "@/effect/app-runtime" -import { WorkspaceAdapterEntry } from "@/control-plane/types" -import { zodObject } from "@/util/effect-zod" -import { Instance } from "@/project/instance" -import { Vcs } from "@/project/vcs" -import { errors } from "../../error" -import { lazy } from "@/util/lazy" - -export const WorkspaceRoutes = lazy(() => - new Hono() - .get( - "/adapter", - describeRoute({ - summary: "List workspace adapters", - description: "List all available workspace adapters for the current project.", - operationId: "experimental.workspace.adapter.list", - responses: { - 200: { - description: "Workspace adapters", - content: { - "application/json": { - schema: resolver(z.array(zodObject(WorkspaceAdapterEntry))), - }, - }, - }, - }, - }), - async (c) => { - return c.json(await listAdapters(Instance.project.id)) - }, - ) - .post( - "/", - describeRoute({ - summary: "Create workspace", - description: "Create a workspace for the current project.", - operationId: "experimental.workspace.create", - responses: { - 200: { - description: "Workspace created", - content: { - "application/json": { - schema: resolver(Workspace.Info.zod), - }, - }, - }, - ...errors(400), - }, - }), - validator( - "json", - Workspace.CreateInput.zodObject.omit({ - projectID: true, - }), - ), - async (c) => { - const body = c.req.valid("json") as Omit - const workspace = await AppRuntime.runPromise( - Workspace.Service.use((svc) => - svc.create({ - projectID: Instance.project.id, - ...body, - }), - ), - ) - return c.json(workspace) - }, - ) - .get( - "/", - describeRoute({ - summary: "List workspaces", - description: "List all workspaces.", - operationId: "experimental.workspace.list", - responses: { - 200: { - description: "Workspaces", - content: { - "application/json": { - schema: resolver(z.array(Workspace.Info.zod)), - }, - }, - }, - }, - }), - async (c) => { - return c.json(await AppRuntime.runPromise(Workspace.Service.use((svc) => svc.list(Instance.project)))) - }, - ) - .get( - "/status", - describeRoute({ - summary: "Workspace status", - description: "Get connection status for workspaces in the current project.", - operationId: "experimental.workspace.status", - responses: { - 200: { - description: "Workspace status", - content: { - "application/json": { - schema: resolver(z.array(zodObject(Workspace.ConnectionStatus))), - }, - }, - }, - }, - }), - async (c) => { - const result = await AppRuntime.runPromise( - Workspace.Service.use((svc) => Effect.all([svc.list(Instance.project), svc.status()])), - ) - const ids = new Set(result[0].map((item) => item.id)) - return c.json(result[1].filter((item) => ids.has(item.workspaceID))) - }, - ) - .delete( - "/:id", - describeRoute({ - summary: "Remove workspace", - description: "Remove an existing workspace.", - operationId: "experimental.workspace.remove", - responses: { - 200: { - description: "Workspace removed", - content: { - "application/json": { - schema: resolver(Workspace.Info.zod.optional()), - }, - }, - }, - ...errors(400), - }, - }), - validator( - "param", - z.object({ - id: zodObject(Workspace.Info).shape.id, - }), - ), - async (c) => { - const { id } = c.req.valid("param") - return c.json(await AppRuntime.runPromise(Workspace.Service.use((svc) => svc.remove(id)))) - }, - ) - .post( - "/warp", - describeRoute({ - summary: "Warp session into workspace", - description: "Move a session's sync history into the target workspace, or detach it to the local project.", - operationId: "experimental.workspace.warp", - responses: { - 204: { - description: "Session warped", - }, - ...errors(400), - }, - }), - validator( - "json", - z.object({ - id: zodObject(Workspace.Info).shape.id.nullable(), - sessionID: Workspace.SessionWarpInput.zodObject.shape.sessionID, - copyChanges: z.boolean().optional(), - }), - ), - async (c) => { - const body = c.req.valid("json") - return AppRuntime.runPromise( - Workspace.Service.use((workspace) => - workspace.sessionWarp({ - workspaceID: body.id, - sessionID: body.sessionID, - copyChanges: body.copyChanges, - }), - ).pipe( - Effect.match({ - onFailure: (error) => { - if (error instanceof Vcs.PatchApplyError) { - return c.json( - { - name: "VcsApplyError", - data: { - message: error.message, - reason: error.reason, - }, - }, - 400, - ) - } - return c.json( - { - name: "WorkspaceWarpError", - data: { - message: error.message, - }, - }, - 400, - ) - }, - onSuccess: () => c.body(null, 204), - }), - ), - ) - }, - ), -) diff --git a/packages/opencode/src/server/routes/global.ts b/packages/opencode/src/server/routes/global.ts deleted file mode 100644 index da3614d228..0000000000 --- a/packages/opencode/src/server/routes/global.ts +++ /dev/null @@ -1,286 +0,0 @@ -import { Hono, type Context } from "hono" -import { describeRoute, resolver, validator } from "hono-openapi" -import { streamSSE } from "hono/streaming" -import { Effect } from "effect" -import z from "zod" -import { BusEvent } from "@/bus/bus-event" -import { SyncEvent } from "@/sync" -import { GlobalBus } from "@/bus/global" -import { Bus } from "@/bus" -import { AppRuntime } from "@/effect/app-runtime" -import { AsyncQueue } from "@/util/queue" -import { Installation } from "@/installation" -import { InstallationVersion } from "@opencode-ai/core/installation/version" -import * as Log from "@opencode-ai/core/util/log" -import { lazy } from "../../util/lazy" -import { Config } from "@/config/config" -import { errors } from "../error" -import { disposeAllInstancesAndEmitGlobalDisposed } from "../global-lifecycle" - -const log = Log.create({ service: "server" }) - -async function streamEvents(c: Context, subscribe: (q: AsyncQueue) => () => void) { - return streamSSE(c, async (stream) => { - const q = new AsyncQueue() - let done = false - - q.push( - JSON.stringify({ - payload: { - id: Bus.createID(), - type: "server.connected", - properties: {}, - }, - }), - ) - - // Send heartbeat every 10s to prevent stalled proxy streams. - const heartbeat = setInterval(() => { - q.push( - JSON.stringify({ - payload: { - id: Bus.createID(), - type: "server.heartbeat", - properties: {}, - }, - }), - ) - }, 10_000) - - const stop = () => { - if (done) return - done = true - clearInterval(heartbeat) - unsub() - q.push(null) - log.info("global event disconnected") - } - - const unsub = subscribe(q) - - stream.onAbort(stop) - - try { - for await (const data of q) { - if (data === null) return - await stream.writeSSE({ data }) - } - } finally { - stop() - } - }) -} - -export const GlobalRoutes = lazy(() => - new Hono() - .get( - "/health", - describeRoute({ - summary: "Get health", - description: "Get health information about the OpenCode server.", - operationId: "global.health", - responses: { - 200: { - description: "Health information", - content: { - "application/json": { - schema: resolver(z.object({ healthy: z.literal(true), version: z.string() })), - }, - }, - }, - }, - }), - async (c) => { - return c.json({ healthy: true, version: InstallationVersion }) - }, - ) - .get( - "/event", - describeRoute({ - summary: "Get global events", - description: "Subscribe to global events from the OpenCode system using server-sent events.", - operationId: "global.event", - responses: { - 200: { - description: "Event stream", - content: { - "text/event-stream": { - schema: resolver( - z - .object({ - directory: z.string(), - project: z.string().optional(), - workspace: z.string().optional(), - payload: z.union([...BusEvent.payloads(), ...SyncEvent.payloads()]), - }) - .meta({ - ref: "GlobalEvent", - }), - ), - }, - }, - }, - }, - }), - async (c) => { - log.info("global event connected") - c.header("Cache-Control", "no-cache, no-transform") - c.header("X-Accel-Buffering", "no") - c.header("X-Content-Type-Options", "nosniff") - - return streamEvents(c, (q) => { - async function handler(event: any) { - q.push(JSON.stringify(event)) - } - GlobalBus.on("event", handler) - return () => GlobalBus.off("event", handler) - }) - }, - ) - .get( - "/config", - describeRoute({ - summary: "Get global configuration", - description: "Retrieve the current global OpenCode configuration settings and preferences.", - operationId: "global.config.get", - responses: { - 200: { - description: "Get global config info", - content: { - "application/json": { - schema: resolver(Config.Info.zod), - }, - }, - }, - }, - }), - async (c) => { - return c.json(await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.getGlobal()))) - }, - ) - .patch( - "/config", - describeRoute({ - summary: "Update global configuration", - description: "Update global OpenCode configuration settings and preferences.", - operationId: "global.config.update", - responses: { - 200: { - description: "Successfully updated global config", - content: { - "application/json": { - schema: resolver(Config.Info.zod), - }, - }, - }, - ...errors(400), - }, - }), - validator("json", Config.Info.zod), - async (c) => { - const config = c.req.valid("json") - const result = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.updateGlobal(config))) - if (result.changed) { - void AppRuntime.runPromise(disposeAllInstancesAndEmitGlobalDisposed({ swallowErrors: true })).catch( - () => undefined, - ) - } - return c.json(result.info) - }, - ) - .post( - "/dispose", - describeRoute({ - summary: "Dispose instance", - description: "Clean up and dispose all OpenCode instances, releasing all resources.", - operationId: "global.dispose", - responses: { - 200: { - description: "Global disposed", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - }, - }), - async (c) => { - await AppRuntime.runPromise(disposeAllInstancesAndEmitGlobalDisposed()) - return c.json(true) - }, - ) - .post( - "/upgrade", - describeRoute({ - summary: "Upgrade opencode", - description: "Upgrade opencode to the specified version or latest if not specified.", - operationId: "global.upgrade", - responses: { - 200: { - description: "Upgrade result", - content: { - "application/json": { - schema: resolver( - z.union([ - z.object({ - success: z.literal(true), - version: z.string(), - }), - z.object({ - success: z.literal(false), - error: z.string(), - }), - ]), - ), - }, - }, - }, - ...errors(400), - }, - }), - validator( - "json", - z.object({ - target: z.string().optional(), - }), - ), - async (c) => { - const result = await AppRuntime.runPromise( - Installation.Service.use((svc) => - Effect.gen(function* () { - const method = yield* svc.method() - if (method === "unknown") { - return { success: false as const, status: 400 as const, error: "Unknown installation method" } - } - - const target = c.req.valid("json").target || (yield* svc.latest(method)) - const result = yield* Effect.catch( - svc.upgrade(method, target).pipe(Effect.as({ success: true as const, version: target })), - (err) => - Effect.succeed({ - success: false as const, - status: 500 as const, - error: err instanceof Error ? err.message : String(err), - }), - ) - if (!result.success) return result - return { ...result, status: 200 as const } - }), - ), - ) - if (!result.success) { - return c.json({ success: false, error: result.error }, result.status) - } - const target = result.version - GlobalBus.emit("event", { - directory: "global", - payload: { - type: Installation.Event.Updated.type, - properties: { version: target }, - }, - }) - return c.json({ success: true, version: target }) - }, - ), -) diff --git a/packages/opencode/src/server/routes/instance/AGENTS.md b/packages/opencode/src/server/routes/instance/AGENTS.md deleted file mode 100644 index c94fa64af7..0000000000 --- a/packages/opencode/src/server/routes/instance/AGENTS.md +++ /dev/null @@ -1,8 +0,0 @@ -# Instance Route Parity - -This directory contains the legacy Hono instance routes and the experimental Effect HttpApi implementation under `httpapi/`. Keep them behaviorally aligned. - -- When adding, removing, or changing a legacy Hono route, update the matching Effect HttpApi group and handler in `httpapi/` in the same change unless the route is intentionally unsupported. -- When changing an Effect HttpApi route, verify the legacy Hono route has the same public behavior, request shape, response shape, status codes, and instance/workspace routing semantics. -- Keep OpenAPI/SDK-visible schemas aligned. If a difference is only an OpenAPI generation artifact, prefer fixing the source schema first; use `httpapi/public.ts` normalization only for compatibility shims that cannot be represented cleanly in the source schema. -- Add or update parity coverage in `test/server/httpapi-bridge.test.ts` or the focused HttpApi tests when behavior or schema parity could regress. diff --git a/packages/opencode/src/server/routes/instance/config.ts b/packages/opencode/src/server/routes/instance/config.ts deleted file mode 100644 index 949734f81a..0000000000 --- a/packages/opencode/src/server/routes/instance/config.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { Hono } from "hono" -import { describeRoute, validator, resolver } from "hono-openapi" -import { Config } from "@/config/config" -import { InstanceState } from "@/effect/instance-state" -import { InstanceStore } from "@/project/instance-store" -import { Provider } from "@/provider/provider" -import { errors } from "../../error" -import { lazy } from "@/util/lazy" -import { jsonRequest, runRequest } from "./trace" -import { Effect } from "effect" -import * as Log from "@opencode-ai/core/util/log" - -const log = Log.create({ service: "server.config" }) - -export const ConfigRoutes = lazy(() => - new Hono() - .get( - "/", - describeRoute({ - summary: "Get configuration", - description: "Retrieve the current OpenCode configuration settings and preferences.", - operationId: "config.get", - responses: { - 200: { - description: "Get config info", - content: { - "application/json": { - schema: resolver(Config.Info.zod), - }, - }, - }, - }, - }), - async (c) => - jsonRequest("ConfigRoutes.get", c, function* () { - const cfg = yield* Config.Service - return yield* cfg.get() - }), - ) - .patch( - "/", - describeRoute({ - summary: "Update configuration", - description: "Update OpenCode configuration settings and preferences.", - operationId: "config.update", - responses: { - 200: { - description: "Successfully updated config", - content: { - "application/json": { - schema: resolver(Config.Info.zod), - }, - }, - }, - ...errors(400), - }, - }), - validator("json", Config.Info.zod), - async (c) => { - const result = await runRequest( - "ConfigRoutes.update", - c, - Effect.gen(function* () { - const config = c.req.valid("json") - const cfg = yield* Config.Service - yield* cfg.update(config) - return { config, ctx: yield* InstanceState.context } - }), - ) - const response = c.json(result.config) - void runRequest( - "ConfigRoutes.update.dispose", - c, - InstanceStore.Service.use((store) => store.dispose(result.ctx)).pipe( - Effect.uninterruptible, - Effect.catchCause((cause) => Effect.sync(() => log.warn("instance disposal failed", { cause }))), - ), - ) - return response - }, - ) - .get( - "/providers", - describeRoute({ - summary: "List config providers", - description: "Get a list of all configured AI providers and their default models.", - operationId: "config.providers", - responses: { - 200: { - description: "List of providers", - content: { - "application/json": { - schema: resolver(Provider.ConfigProvidersResult.zod), - }, - }, - }, - }, - }), - async (c) => - jsonRequest("ConfigRoutes.providers", c, function* () { - const svc = yield* Provider.Service - const providers = yield* svc.list() - return { - providers: Object.values(providers), - default: Provider.defaultModelIDs(providers), - } - }), - ), -) diff --git a/packages/opencode/src/server/routes/instance/event.ts b/packages/opencode/src/server/routes/instance/event.ts deleted file mode 100644 index aeb1da5393..0000000000 --- a/packages/opencode/src/server/routes/instance/event.ts +++ /dev/null @@ -1,90 +0,0 @@ -import z from "zod" -import { Hono } from "hono" -import { describeRoute, resolver } from "hono-openapi" -import { streamSSE } from "hono/streaming" -import * as Log from "@opencode-ai/core/util/log" -import { BusEvent } from "@/bus/bus-event" -import { Bus } from "@/bus" -import { AsyncQueue } from "@/util/queue" - -const log = Log.create({ service: "server" }) - -export const EventRoutes = () => - new Hono().get( - "/event", - describeRoute({ - summary: "Subscribe to events", - description: "Get events", - operationId: "event.subscribe", - responses: { - 200: { - description: "Event stream", - content: { - "text/event-stream": { - schema: resolver( - z.union(BusEvent.payloads()).meta({ - ref: "Event", - }), - ), - }, - }, - }, - }, - }), - async (c) => { - log.info("event connected") - c.header("Cache-Control", "no-cache, no-transform") - c.header("X-Accel-Buffering", "no") - c.header("X-Content-Type-Options", "nosniff") - return streamSSE(c, async (stream) => { - const q = new AsyncQueue() - let done = false - - q.push( - JSON.stringify({ - id: Bus.createID(), - type: "server.connected", - properties: {}, - }), - ) - - // Send heartbeat every 10s to prevent stalled proxy streams. - const heartbeat = setInterval(() => { - q.push( - JSON.stringify({ - id: Bus.createID(), - type: "server.heartbeat", - properties: {}, - }), - ) - }, 10_000) - - const stop = () => { - if (done) return - done = true - clearInterval(heartbeat) - unsub() - q.push(null) - log.info("event disconnected") - } - - const unsub = Bus.subscribeAll((event) => { - q.push(JSON.stringify(event)) - if (event.type === Bus.InstanceDisposed.type) { - stop() - } - }) - - stream.onAbort(stop) - - try { - for await (const data of q) { - if (data === null) return - await stream.writeSSE({ data }) - } - } finally { - stop() - } - }) - }, - ) diff --git a/packages/opencode/src/server/routes/instance/experimental.ts b/packages/opencode/src/server/routes/instance/experimental.ts deleted file mode 100644 index 7e09fb9ad3..0000000000 --- a/packages/opencode/src/server/routes/instance/experimental.ts +++ /dev/null @@ -1,419 +0,0 @@ -import { Hono } from "hono" -import { describeRoute, validator, resolver } from "hono-openapi" -import z from "zod" -import * as EffectZod from "@/util/effect-zod" -import { ProviderID, ModelID } from "@/provider/schema" -import { ToolRegistry } from "@/tool/registry" -import { Worktree } from "@/worktree" -import { Instance } from "@/project/instance" -import { Project } from "@/project/project" -import { MCP } from "@/mcp" -import { Session } from "@/session/session" -import { Config } from "@/config/config" -import { ConsoleState } from "@/config/console-state" -import { Account } from "@/account/account" -import { AccountID, OrgID } from "@/account/schema" -import { errors } from "../../error" -import { lazy } from "@/util/lazy" -import { Effect, Option } from "effect" -import { Agent } from "@/agent/agent" -import { jsonRequest, runRequest } from "./trace" - -const ConsoleOrgOption = z.object({ - accountID: z.string(), - accountEmail: z.string(), - accountUrl: z.string(), - orgID: z.string(), - orgName: z.string(), - active: z.boolean(), -}) - -const ConsoleOrgList = z.object({ - orgs: z.array(ConsoleOrgOption), -}) - -const ConsoleSwitchBody = z.object({ - accountID: z.string(), - orgID: z.string(), -}) - -const QueryBoolean = z.union([ - z.preprocess((value) => (value === "true" ? true : value === "false" ? false : value), z.boolean()), - z.enum(["true", "false"]), -]) - -function queryBoolean(value: z.infer | undefined) { - if (value === undefined) return - return value === true || value === "true" -} - -export const ExperimentalRoutes = lazy(() => - new Hono() - .get( - "/console", - describeRoute({ - summary: "Get active Console provider metadata", - description: "Get the active Console org name and the set of provider IDs managed by that Console org.", - operationId: "experimental.console.get", - responses: { - 200: { - description: "Active Console provider metadata", - content: { - "application/json": { - schema: resolver(ConsoleState.zod), - }, - }, - }, - }, - }), - async (c) => - jsonRequest("ExperimentalRoutes.console.get", c, function* () { - const config = yield* Config.Service - const account = yield* Account.Service - const [state, groups] = yield* Effect.all([config.getConsoleState(), account.orgsByAccount()], { - concurrency: "unbounded", - }) - return { - ...state, - switchableOrgCount: groups.reduce((count, group) => count + group.orgs.length, 0), - } - }), - ) - .get( - "/console/orgs", - describeRoute({ - summary: "List switchable Console orgs", - description: "Get the available Console orgs across logged-in accounts, including the current active org.", - operationId: "experimental.console.listOrgs", - responses: { - 200: { - description: "Switchable Console orgs", - content: { - "application/json": { - schema: resolver(ConsoleOrgList), - }, - }, - }, - }, - }), - async (c) => - jsonRequest("ExperimentalRoutes.console.listOrgs", c, function* () { - const account = yield* Account.Service - const [groups, active] = yield* Effect.all([account.orgsByAccount(), account.active()], { - concurrency: "unbounded", - }) - const info = Option.getOrUndefined(active) - const orgs = groups.flatMap((group) => - group.orgs.map((org) => ({ - accountID: group.account.id, - accountEmail: group.account.email, - accountUrl: group.account.url, - orgID: org.id, - orgName: org.name, - active: !!info && info.id === group.account.id && info.active_org_id === org.id, - })), - ) - return { orgs } - }), - ) - .post( - "/console/switch", - describeRoute({ - summary: "Switch active Console org", - description: "Persist a new active Console account/org selection for the current local OpenCode state.", - operationId: "experimental.console.switchOrg", - responses: { - 200: { - description: "Switch success", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - }, - }), - validator("json", ConsoleSwitchBody), - async (c) => - jsonRequest("ExperimentalRoutes.console.switchOrg", c, function* () { - const body = c.req.valid("json") - const account = yield* Account.Service - yield* account.use(AccountID.make(body.accountID), Option.some(OrgID.make(body.orgID))) - return true - }), - ) - .get( - "/tool/ids", - describeRoute({ - summary: "List tool IDs", - description: - "Get a list of all available tool IDs, including both built-in tools and dynamically registered tools.", - operationId: "tool.ids", - responses: { - 200: { - description: "Tool IDs", - content: { - "application/json": { - schema: resolver(z.array(z.string()).meta({ ref: "ToolIDs" })), - }, - }, - }, - ...errors(400), - }, - }), - async (c) => - jsonRequest("ExperimentalRoutes.tool.ids", c, function* () { - const registry = yield* ToolRegistry.Service - return yield* registry.ids() - }), - ) - .get( - "/tool", - describeRoute({ - summary: "List tools", - description: - "Get a list of available tools with their JSON schema parameters for a specific provider and model combination.", - operationId: "tool.list", - responses: { - 200: { - description: "Tools", - content: { - "application/json": { - schema: resolver( - z - .array( - z - .object({ - id: z.string(), - description: z.string(), - parameters: z.any(), - }) - .meta({ ref: "ToolListItem" }), - ) - .meta({ ref: "ToolList" }), - ), - }, - }, - }, - ...errors(400), - }, - }), - validator( - "query", - z.object({ - provider: z.string(), - model: z.string(), - }), - ), - async (c) => { - const { provider, model } = c.req.valid("query") - const tools = await runRequest( - "ExperimentalRoutes.tool.list", - c, - Effect.gen(function* () { - const agents = yield* Agent.Service - const registry = yield* ToolRegistry.Service - return yield* registry.tools({ - providerID: ProviderID.make(provider), - modelID: ModelID.make(model), - agent: yield* agents.get(yield* agents.defaultAgent()), - }) - }), - ) - return c.json( - tools.map((t) => ({ - id: t.id, - description: t.description, - parameters: EffectZod.toJsonSchema(t.parameters), - })), - ) - }, - ) - .post( - "/worktree", - describeRoute({ - summary: "Create worktree", - description: "Create a new git worktree for the current project and run any configured startup scripts.", - operationId: "worktree.create", - responses: { - 200: { - description: "Worktree created", - content: { - "application/json": { - schema: resolver(Worktree.Info.zod), - }, - }, - }, - ...errors(400), - }, - }), - validator("json", Worktree.CreateInput.zod.optional()), - async (c) => - jsonRequest("ExperimentalRoutes.worktree.create", c, function* () { - const body = c.req.valid("json") - const svc = yield* Worktree.Service - return yield* svc.create(body) - }), - ) - .get( - "/worktree", - describeRoute({ - summary: "List worktrees", - description: "List all sandbox worktrees for the current project.", - operationId: "worktree.list", - responses: { - 200: { - description: "List of worktree directories", - content: { - "application/json": { - schema: resolver(z.array(z.string())), - }, - }, - }, - }, - }), - async (c) => - jsonRequest("ExperimentalRoutes.worktree.list", c, function* () { - const svc = yield* Project.Service - return yield* svc.sandboxes(Instance.project.id) - }), - ) - .delete( - "/worktree", - describeRoute({ - summary: "Remove worktree", - description: "Remove a git worktree and delete its branch.", - operationId: "worktree.remove", - responses: { - 200: { - description: "Worktree removed", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - ...errors(400), - }, - }), - validator("json", Worktree.RemoveInput.zod), - async (c) => - jsonRequest("ExperimentalRoutes.worktree.remove", c, function* () { - const body = c.req.valid("json") - const worktree = yield* Worktree.Service - const project = yield* Project.Service - yield* worktree.remove(body) - yield* project.removeSandbox(Instance.project.id, body.directory) - return true - }), - ) - .post( - "/worktree/reset", - describeRoute({ - summary: "Reset worktree", - description: "Reset a worktree branch to the primary default branch.", - operationId: "worktree.reset", - responses: { - 200: { - description: "Worktree reset", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - ...errors(400), - }, - }), - validator("json", Worktree.ResetInput.zod), - async (c) => - jsonRequest("ExperimentalRoutes.worktree.reset", c, function* () { - const body = c.req.valid("json") - const svc = yield* Worktree.Service - yield* svc.reset(body) - return true - }), - ) - .get( - "/session", - describeRoute({ - summary: "List sessions", - description: - "Get a list of all OpenCode sessions across projects, sorted by most recently updated. Archived sessions are excluded by default.", - operationId: "experimental.session.list", - responses: { - 200: { - description: "List of sessions", - content: { - "application/json": { - schema: resolver(Session.GlobalInfo.zod.array()), - }, - }, - }, - }, - }), - validator( - "query", - z.object({ - directory: z.string().optional().meta({ description: "Filter sessions by project directory" }), - roots: QueryBoolean.optional().meta({ description: "Only return root sessions (no parentID)" }), - start: z.coerce - .number() - .optional() - .meta({ description: "Filter sessions updated on or after this timestamp (milliseconds since epoch)" }), - cursor: z.coerce - .number() - .optional() - .meta({ description: "Return sessions updated before this timestamp (milliseconds since epoch)" }), - search: z.string().optional().meta({ description: "Filter sessions by title (case-insensitive)" }), - limit: z.coerce.number().optional().meta({ description: "Maximum number of sessions to return" }), - archived: QueryBoolean.optional().meta({ description: "Include archived sessions (default false)" }), - }), - ), - async (c) => { - const query = c.req.valid("query") - const limit = query.limit ?? 100 - const sessions: Session.GlobalInfo[] = [] - for await (const session of Session.listGlobal({ - directory: query.directory, - roots: queryBoolean(query.roots), - start: query.start, - cursor: query.cursor, - search: query.search, - limit: limit + 1, - archived: queryBoolean(query.archived), - })) { - sessions.push(session) - } - const hasMore = sessions.length > limit - const list = hasMore ? sessions.slice(0, limit) : sessions - if (hasMore && list.length > 0) { - c.header("x-next-cursor", String(list[list.length - 1].time.updated)) - } - return c.json(list) - }, - ) - .get( - "/resource", - describeRoute({ - summary: "Get MCP resources", - description: "Get all available MCP resources from connected servers. Optionally filter by name.", - operationId: "experimental.resource.list", - responses: { - 200: { - description: "MCP resources", - content: { - "application/json": { - schema: resolver(z.record(z.string(), MCP.Resource.zod)), - }, - }, - }, - }, - }), - async (c) => - jsonRequest("ExperimentalRoutes.resource.list", c, function* () { - const mcp = yield* MCP.Service - return yield* mcp.resources() - }), - ), -) diff --git a/packages/opencode/src/server/routes/instance/file.ts b/packages/opencode/src/server/routes/instance/file.ts deleted file mode 100644 index d0e9ee6186..0000000000 --- a/packages/opencode/src/server/routes/instance/file.ts +++ /dev/null @@ -1,190 +0,0 @@ -import { Hono } from "hono" -import { describeRoute, validator, resolver } from "hono-openapi" -import z from "zod" -import { File } from "@/file" -import { Ripgrep } from "@/file/ripgrep" -import { LSP } from "@/lsp/lsp" -import { Instance } from "@/project/instance" -import { lazy } from "@/util/lazy" -import { jsonRequest } from "./trace" - -export const FileRoutes = lazy(() => - new Hono() - .get( - "/find", - describeRoute({ - summary: "Find text", - description: "Search for text patterns across files in the project using ripgrep.", - operationId: "find.text", - responses: { - 200: { - description: "Matches", - content: { - "application/json": { - schema: resolver(Ripgrep.SearchMatch.zod.array()), - }, - }, - }, - }, - }), - validator( - "query", - z.object({ - pattern: z.string(), - }), - ), - async (c) => - jsonRequest("FileRoutes.findText", c, function* () { - const pattern = c.req.valid("query").pattern - const svc = yield* Ripgrep.Service - const result = yield* svc.search({ cwd: Instance.directory, pattern, limit: 10 }) - return result.items - }), - ) - .get( - "/find/file", - describeRoute({ - summary: "Find files", - description: "Search for files or directories by name or pattern in the project directory.", - operationId: "find.files", - responses: { - 200: { - description: "File paths", - content: { - "application/json": { - schema: resolver(z.string().array()), - }, - }, - }, - }, - }), - validator( - "query", - z.object({ - query: z.string(), - dirs: z.enum(["true", "false"]).optional(), - type: z.enum(["file", "directory"]).optional(), - limit: z.coerce.number().int().min(1).max(200).optional(), - }), - ), - async (c) => - jsonRequest("FileRoutes.findFile", c, function* () { - const query = c.req.valid("query") - const svc = yield* File.Service - return yield* svc.search({ - query: query.query, - limit: query.limit ?? 10, - dirs: query.dirs !== "false", - type: query.type, - }) - }), - ) - .get( - "/find/symbol", - describeRoute({ - summary: "Find symbols", - description: "Search for workspace symbols like functions, classes, and variables using LSP.", - operationId: "find.symbols", - responses: { - 200: { - description: "Symbols", - content: { - "application/json": { - schema: resolver(LSP.Symbol.zod.array()), - }, - }, - }, - }, - }), - validator( - "query", - z.object({ - query: z.string(), - }), - ), - async (c) => { - return c.json([]) - }, - ) - .get( - "/file", - describeRoute({ - summary: "List files", - description: "List files and directories in a specified path.", - operationId: "file.list", - responses: { - 200: { - description: "Files and directories", - content: { - "application/json": { - schema: resolver(File.Node.zod.array()), - }, - }, - }, - }, - }), - validator( - "query", - z.object({ - path: z.string(), - }), - ), - async (c) => - jsonRequest("FileRoutes.list", c, function* () { - const svc = yield* File.Service - return yield* svc.list(c.req.valid("query").path) - }), - ) - .get( - "/file/content", - describeRoute({ - summary: "Read file", - description: "Read the content of a specified file.", - operationId: "file.read", - responses: { - 200: { - description: "File content", - content: { - "application/json": { - schema: resolver(File.Content.zod), - }, - }, - }, - }, - }), - validator( - "query", - z.object({ - path: z.string(), - }), - ), - async (c) => - jsonRequest("FileRoutes.read", c, function* () { - const svc = yield* File.Service - return yield* svc.read(c.req.valid("query").path) - }), - ) - .get( - "/file/status", - describeRoute({ - summary: "Get file status", - description: "Get the git status of all files in the project.", - operationId: "file.status", - responses: { - 200: { - description: "File status", - content: { - "application/json": { - schema: resolver(File.Info.zod.array()), - }, - }, - }, - }, - }), - async (c) => - jsonRequest("FileRoutes.status", c, function* () { - const svc = yield* File.Service - return yield* svc.status() - }), - ), -) diff --git a/packages/opencode/src/server/routes/instance/httpapi/api.ts b/packages/opencode/src/server/routes/instance/httpapi/api.ts index 1cf1584e3e..4c6e46a455 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/api.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/api.ts @@ -20,13 +20,18 @@ import { SyncApi } from "./groups/sync" import { TuiApi } from "./groups/tui" import { WorkspaceApi } from "./groups/workspace" import { V2Api } from "./groups/v2" +import { Authorization } from "./middleware/authorization" +import { SchemaErrorMiddleware } from "./middleware/schema-error" -// SSE event schemas built from the same BusEvent/SyncEvent registries that -// the Hono spec uses, so both specs emit identical Event/SyncEvent components. +// SSE event schemas built from the BusEvent/SyncEvent registries. const EventSchema = Schema.Union(BusEvent.effectPayloads()).annotate({ identifier: "Event" }) const SyncEventSchemas = SyncEvent.effectPayloads() -export const RootHttpApi = HttpApi.make("opencode-root").addHttpApi(ControlApi).addHttpApi(GlobalApi) +export const RootHttpApi = HttpApi.make("opencode-root") + .addHttpApi(ControlApi) + .addHttpApi(GlobalApi) + .middleware(SchemaErrorMiddleware) + .middleware(Authorization) export const InstanceHttpApi = HttpApi.make("opencode-instance") .addHttpApi(ConfigApi) @@ -44,6 +49,7 @@ export const InstanceHttpApi = HttpApi.make("opencode-instance") .addHttpApi(V2Api) .addHttpApi(TuiApi) .addHttpApi(WorkspaceApi) + .middleware(SchemaErrorMiddleware) export const OpenCodeHttpApi = HttpApi.make("opencode") .addHttpApi(RootHttpApi) diff --git a/packages/opencode/src/server/routes/instance/httpapi/event.ts b/packages/opencode/src/server/routes/instance/httpapi/event.ts index a5c328ac0e..8113c76f51 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/event.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/event.ts @@ -5,6 +5,7 @@ import * as Stream from "effect/Stream" import { HttpServerResponse } from "effect/unstable/http" import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" import * as Sse from "effect/unstable/encoding/Sse" +import { WorkspaceRoutingQuery } from "./middleware/workspace-routing" const log = Log.create({ service: "server" }) @@ -16,6 +17,7 @@ export const EventApi = HttpApi.make("event").add( HttpApiGroup.make("event") .add( HttpApiEndpoint.get("subscribe", EventPaths.event, { + query: WorkspaceRoutingQuery, success: Schema.String.pipe(HttpApiSchema.asText({ contentType: "text/event-stream" })), }).annotateMerge( OpenApi.annotations({ diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/config.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/config.ts index fa77785a9b..a86845beff 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/config.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/config.ts @@ -3,7 +3,7 @@ import { Provider } from "@/provider/provider" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" -import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" +import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing" import { described } from "./metadata" const root = "/config" @@ -13,6 +13,7 @@ export const ConfigApi = HttpApi.make("config") HttpApiGroup.make("config") .add( HttpApiEndpoint.get("get", root, { + query: WorkspaceRoutingQuery, success: described(Config.Info, "Get config info"), }).annotateMerge( OpenApi.annotations({ @@ -22,6 +23,7 @@ export const ConfigApi = HttpApi.make("config") }), ), HttpApiEndpoint.patch("update", root, { + query: WorkspaceRoutingQuery, payload: Config.Info, success: described(Config.Info, "Successfully updated config"), error: HttpApiError.BadRequest, @@ -33,6 +35,7 @@ export const ConfigApi = HttpApi.make("config") }), ), HttpApiEndpoint.get("providers", `${root}/providers`, { + query: WorkspaceRoutingQuery, success: described(Provider.ConfigProvidersResult, "List of providers"), }).annotateMerge( OpenApi.annotations({ diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts index e4a86ca139..99a8a21a9e 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts @@ -3,13 +3,18 @@ import { MCP } from "@/mcp" import { ProviderID, ModelID } from "@/provider/schema" import { Session } from "@/session/session" import { Worktree } from "@/worktree" -import { NonNegativeInt } from "@/util/schema" -import { Schema, SchemaGetter } from "effect" +import { NonNegativeInt } from "@opencode-ai/core/schema" +import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" -import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" +import { + WorkspaceRoutingMiddleware, + WorkspaceRoutingQuery, + WorkspaceRoutingQueryFields, +} from "../middleware/workspace-routing" import { described } from "./metadata" +import { QueryBoolean } from "./query" const ConsoleStateResponse = Schema.Struct({ consoleManagedProviders: Schema.mutable(Schema.Array(Schema.String)), @@ -43,19 +48,14 @@ const ToolListItem = Schema.Struct({ }).annotate({ identifier: "ToolListItem" }) const ToolList = Schema.Array(ToolListItem).annotate({ identifier: "ToolList" }) export const ToolListQuery = Schema.Struct({ + ...WorkspaceRoutingQueryFields, provider: ProviderID, model: ModelID, }) -const QueryBoolean = Schema.Literals(["true", "false"]).pipe( - Schema.decodeTo(Schema.Boolean, { - decode: SchemaGetter.transform((value) => value === "true"), - encode: SchemaGetter.transform((value) => (value ? "true" : "false")), - }), -) const WorktreeList = Schema.Array(Schema.String) export const SessionListQuery = Schema.Struct({ - directory: Schema.optional(Schema.String), + ...WorkspaceRoutingQueryFields, roots: Schema.optional(QueryBoolean), start: Schema.optional(Schema.NumberFromString), cursor: Schema.optional(Schema.NumberFromString), @@ -81,7 +81,9 @@ export const ExperimentalApi = HttpApi.make("experimental") HttpApiGroup.make("experimental") .add( HttpApiEndpoint.get("console", ExperimentalPaths.console, { + query: WorkspaceRoutingQuery, success: described(ConsoleStateResponse, "Active Console provider metadata"), + error: HttpApiError.InternalServerError, }).annotateMerge( OpenApi.annotations({ identifier: "experimental.console.get", @@ -90,7 +92,9 @@ export const ExperimentalApi = HttpApi.make("experimental") }), ), HttpApiEndpoint.get("consoleOrgs", ExperimentalPaths.consoleOrgs, { + query: WorkspaceRoutingQuery, success: described(ConsoleOrgList, "Switchable Console orgs"), + error: HttpApiError.InternalServerError, }).annotateMerge( OpenApi.annotations({ identifier: "experimental.console.listOrgs", @@ -99,6 +103,7 @@ export const ExperimentalApi = HttpApi.make("experimental") }), ), HttpApiEndpoint.post("consoleSwitch", ExperimentalPaths.consoleSwitch, { + query: WorkspaceRoutingQuery, payload: ConsoleSwitchPayload, success: described(Schema.Boolean, "Switch success"), error: HttpApiError.BadRequest, @@ -122,6 +127,7 @@ export const ExperimentalApi = HttpApi.make("experimental") }), ), HttpApiEndpoint.get("toolIDs", ExperimentalPaths.toolIDs, { + query: WorkspaceRoutingQuery, success: described(ToolIDs, "Tool IDs"), error: HttpApiError.BadRequest, }).annotateMerge( @@ -133,6 +139,7 @@ export const ExperimentalApi = HttpApi.make("experimental") }), ), HttpApiEndpoint.get("worktree", ExperimentalPaths.worktree, { + query: WorkspaceRoutingQuery, success: described(WorktreeList, "List of worktree directories"), }).annotateMerge( OpenApi.annotations({ @@ -142,6 +149,7 @@ export const ExperimentalApi = HttpApi.make("experimental") }), ), HttpApiEndpoint.post("worktreeCreate", ExperimentalPaths.worktree, { + query: WorkspaceRoutingQuery, payload: Schema.optional(Worktree.CreateInput), success: described(Worktree.Info, "Worktree created"), error: HttpApiError.BadRequest, @@ -153,6 +161,7 @@ export const ExperimentalApi = HttpApi.make("experimental") }), ), HttpApiEndpoint.delete("worktreeRemove", ExperimentalPaths.worktree, { + query: WorkspaceRoutingQuery, payload: Worktree.RemoveInput, success: described(Schema.Boolean, "Worktree removed"), error: HttpApiError.BadRequest, @@ -164,6 +173,7 @@ export const ExperimentalApi = HttpApi.make("experimental") }), ), HttpApiEndpoint.post("worktreeReset", ExperimentalPaths.worktreeReset, { + query: WorkspaceRoutingQuery, payload: Worktree.ResetInput, success: described(Schema.Boolean, "Worktree reset"), error: HttpApiError.BadRequest, @@ -186,6 +196,7 @@ export const ExperimentalApi = HttpApi.make("experimental") }), ), HttpApiEndpoint.get("resource", ExperimentalPaths.resource, { + query: WorkspaceRoutingQuery, success: described(Schema.Record(Schema.String, MCP.Resource), "MCP resources"), }).annotateMerge( OpenApi.annotations({ diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/file.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/file.ts index b950adb383..c636e583d7 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/file.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/file.ts @@ -5,18 +5,25 @@ import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" -import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" +import { + WorkspaceRoutingMiddleware, + WorkspaceRoutingQuery, + WorkspaceRoutingQueryFields, +} from "../middleware/workspace-routing" import { described } from "./metadata" export const FileQuery = Schema.Struct({ + ...WorkspaceRoutingQueryFields, path: Schema.String, }) export const FindTextQuery = Schema.Struct({ + ...WorkspaceRoutingQueryFields, pattern: Schema.String, }) export const FindFileQuery = Schema.Struct({ + ...WorkspaceRoutingQueryFields, query: Schema.String, dirs: Schema.optional(Schema.Literals(["true", "false"])), type: Schema.optional(Schema.Literals(["file", "directory"])), @@ -26,6 +33,7 @@ export const FindFileQuery = Schema.Struct({ }) export const FindSymbolQuery = Schema.Struct({ + ...WorkspaceRoutingQueryFields, query: Schema.String, }) @@ -93,6 +101,7 @@ export const FileApi = HttpApi.make("file") }), ), HttpApiEndpoint.get("status", FilePaths.status, { + query: WorkspaceRoutingQuery, success: described(Schema.Array(File.Info), "File status"), }).annotateMerge( OpenApi.annotations({ diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/instance.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/instance.ts index f2b0504a05..ea8db35035 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/instance.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/instance.ts @@ -8,7 +8,11 @@ import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" -import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" +import { + WorkspaceRoutingMiddleware, + WorkspaceRoutingQuery, + WorkspaceRoutingQueryFields, +} from "../middleware/workspace-routing" import { described } from "./metadata" const PathInfo = Schema.Struct({ @@ -20,6 +24,7 @@ const PathInfo = Schema.Struct({ }).annotate({ identifier: "Path" }) export const VcsDiffQuery = Schema.Struct({ + ...WorkspaceRoutingQueryFields, mode: Vcs.Mode, }) @@ -54,6 +59,7 @@ export const InstanceApi = HttpApi.make("instance") HttpApiGroup.make("instance") .add( HttpApiEndpoint.post("dispose", InstancePaths.dispose, { + query: WorkspaceRoutingQuery, success: described(Schema.Boolean, "Instance disposed"), }).annotateMerge( OpenApi.annotations({ @@ -63,6 +69,7 @@ export const InstanceApi = HttpApi.make("instance") }), ), HttpApiEndpoint.get("path", InstancePaths.path, { + query: WorkspaceRoutingQuery, success: PathInfo, }).annotateMerge( OpenApi.annotations({ @@ -73,6 +80,7 @@ export const InstanceApi = HttpApi.make("instance") }), ), HttpApiEndpoint.get("vcs", InstancePaths.vcs, { + query: WorkspaceRoutingQuery, success: described(Vcs.Info, "VCS info"), }).annotateMerge( OpenApi.annotations({ @@ -83,6 +91,7 @@ export const InstanceApi = HttpApi.make("instance") }), ), HttpApiEndpoint.get("vcsStatus", InstancePaths.vcsStatus, { + query: WorkspaceRoutingQuery, success: described(Schema.Array(Vcs.FileStatus), "VCS status"), }).annotateMerge( OpenApi.annotations({ @@ -102,6 +111,7 @@ export const InstanceApi = HttpApi.make("instance") }), ), HttpApiEndpoint.get("vcsDiffRaw", InstancePaths.vcsDiffRaw, { + query: WorkspaceRoutingQuery, success: described( Schema.String.pipe(HttpApiSchema.asText({ contentType: "text/x-diff; charset=utf-8" })), "Raw VCS diff", @@ -114,6 +124,7 @@ export const InstanceApi = HttpApi.make("instance") }), ), HttpApiEndpoint.post("vcsApply", InstancePaths.vcsApply, { + query: WorkspaceRoutingQuery, payload: Vcs.ApplyInput, success: described(Vcs.ApplyResult, "VCS patch applied"), error: ApiVcsApplyError, @@ -125,6 +136,7 @@ export const InstanceApi = HttpApi.make("instance") }), ), HttpApiEndpoint.get("command", InstancePaths.command, { + query: WorkspaceRoutingQuery, success: described(Schema.Array(Command.Info), "List of commands"), }).annotateMerge( OpenApi.annotations({ @@ -134,6 +146,7 @@ export const InstanceApi = HttpApi.make("instance") }), ), HttpApiEndpoint.get("agent", InstancePaths.agent, { + query: WorkspaceRoutingQuery, success: described(Schema.Array(Agent.Info), "List of agents"), }).annotateMerge( OpenApi.annotations({ @@ -143,6 +156,7 @@ export const InstanceApi = HttpApi.make("instance") }), ), HttpApiEndpoint.get("skill", InstancePaths.skill, { + query: WorkspaceRoutingQuery, success: described(Schema.Array(Skill.Info), "List of skills"), }).annotateMerge( OpenApi.annotations({ @@ -152,6 +166,7 @@ export const InstanceApi = HttpApi.make("instance") }), ), HttpApiEndpoint.get("lsp", InstancePaths.lsp, { + query: WorkspaceRoutingQuery, success: described(Schema.Array(LSP.Status), "LSP server status"), }).annotateMerge( OpenApi.annotations({ @@ -161,6 +176,7 @@ export const InstanceApi = HttpApi.make("instance") }), ), HttpApiEndpoint.get("formatter", InstancePaths.formatter, { + query: WorkspaceRoutingQuery, success: described(Schema.Array(Format.Status), "Formatter status"), }).annotateMerge( OpenApi.annotations({ diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/mcp.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/mcp.ts index b30714c196..c7ed4a9b95 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/mcp.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/mcp.ts @@ -4,7 +4,7 @@ import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" -import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" +import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing" import { described } from "./metadata" export const AddPayload = Schema.Struct({ @@ -42,6 +42,7 @@ export const McpApi = HttpApi.make("mcp") HttpApiGroup.make("mcp") .add( HttpApiEndpoint.get("status", McpPaths.status, { + query: WorkspaceRoutingQuery, success: described(Schema.Record(Schema.String, MCP.Status), "MCP server status"), }).annotateMerge( OpenApi.annotations({ @@ -51,6 +52,7 @@ export const McpApi = HttpApi.make("mcp") }), ), HttpApiEndpoint.post("add", McpPaths.status, { + query: WorkspaceRoutingQuery, payload: AddPayload, success: described(StatusMap, "MCP server added successfully"), error: HttpApiError.BadRequest, @@ -63,6 +65,7 @@ export const McpApi = HttpApi.make("mcp") ), HttpApiEndpoint.post("authStart", McpPaths.auth, { params: { name: Schema.String }, + query: WorkspaceRoutingQuery, success: described(AuthStartResponse, "OAuth flow started"), error: [UnsupportedOAuthError, HttpApiError.NotFound], }).annotateMerge( @@ -74,6 +77,7 @@ export const McpApi = HttpApi.make("mcp") ), HttpApiEndpoint.post("authCallback", McpPaths.authCallback, { params: { name: Schema.String }, + query: WorkspaceRoutingQuery, payload: AuthCallbackPayload, success: described(MCP.Status, "OAuth authentication completed"), error: [HttpApiError.BadRequest, HttpApiError.NotFound], @@ -87,6 +91,7 @@ export const McpApi = HttpApi.make("mcp") ), HttpApiEndpoint.post("authAuthenticate", McpPaths.authAuthenticate, { params: { name: Schema.String }, + query: WorkspaceRoutingQuery, success: described(MCP.Status, "OAuth authentication completed"), error: [UnsupportedOAuthError, HttpApiError.NotFound], }).annotateMerge( @@ -98,6 +103,7 @@ export const McpApi = HttpApi.make("mcp") ), HttpApiEndpoint.delete("authRemove", McpPaths.auth, { params: { name: Schema.String }, + query: WorkspaceRoutingQuery, success: described(AuthRemoveResponse, "OAuth credentials removed"), error: HttpApiError.NotFound, }).annotateMerge( @@ -109,6 +115,7 @@ export const McpApi = HttpApi.make("mcp") ), HttpApiEndpoint.post("connect", McpPaths.connect, { params: { name: Schema.String }, + query: WorkspaceRoutingQuery, success: described(Schema.Boolean, "MCP server connected successfully"), }).annotateMerge( OpenApi.annotations({ @@ -118,6 +125,7 @@ export const McpApi = HttpApi.make("mcp") ), HttpApiEndpoint.post("disconnect", McpPaths.disconnect, { params: { name: Schema.String }, + query: WorkspaceRoutingQuery, success: described(Schema.Boolean, "MCP server disconnected successfully"), }).annotateMerge( OpenApi.annotations({ diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/permission.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/permission.ts index 22c4d6f6d3..5326596d39 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/permission.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/permission.ts @@ -4,7 +4,7 @@ import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" -import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" +import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing" import { described } from "./metadata" const root = "/permission" @@ -18,6 +18,7 @@ export const PermissionApi = HttpApi.make("permission") HttpApiGroup.make("permission") .add( HttpApiEndpoint.get("list", root, { + query: WorkspaceRoutingQuery, success: described(Schema.Array(Permission.Request), "List of pending permissions"), }).annotateMerge( OpenApi.annotations({ @@ -28,6 +29,7 @@ export const PermissionApi = HttpApi.make("permission") ), HttpApiEndpoint.post("reply", `${root}/:requestID/reply`, { params: { requestID: PermissionID }, + query: WorkspaceRoutingQuery, payload: ReplyPayload, success: described(Schema.Boolean, "Permission processed successfully"), error: [HttpApiError.BadRequest, HttpApiError.NotFound], diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/project.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/project.ts index 1a2084547d..f95199eb01 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/project.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/project.ts @@ -4,7 +4,7 @@ import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" -import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" +import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing" import { described } from "./metadata" const root = "/project" @@ -19,6 +19,7 @@ export const ProjectApi = HttpApi.make("project") HttpApiGroup.make("project") .add( HttpApiEndpoint.get("list", root, { + query: WorkspaceRoutingQuery, success: described(Schema.Array(Project.Info), "List of projects"), }).annotateMerge( OpenApi.annotations({ @@ -28,6 +29,7 @@ export const ProjectApi = HttpApi.make("project") }), ), HttpApiEndpoint.get("current", `${root}/current`, { + query: WorkspaceRoutingQuery, success: described(Project.Info, "Current project information"), }).annotateMerge( OpenApi.annotations({ @@ -37,6 +39,7 @@ export const ProjectApi = HttpApi.make("project") }), ), HttpApiEndpoint.post("initGit", `${root}/git/init`, { + query: WorkspaceRoutingQuery, success: described(Project.Info, "Project information after git initialization"), }).annotateMerge( OpenApi.annotations({ @@ -47,6 +50,7 @@ export const ProjectApi = HttpApi.make("project") ), HttpApiEndpoint.patch("update", `${root}/:projectID`, { params: { projectID: ProjectID }, + query: WorkspaceRoutingQuery, payload: UpdatePayload, success: described(Project.Info, "Updated project information"), error: [HttpApiError.BadRequest, HttpApiError.NotFound], diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/provider.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/provider.ts index 4a9bbffc54..49792898df 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/provider.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/provider.ts @@ -5,7 +5,7 @@ import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" -import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" +import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing" import { described } from "./metadata" const root = "/provider" @@ -15,6 +15,7 @@ export const ProviderApi = HttpApi.make("provider") HttpApiGroup.make("provider") .add( HttpApiEndpoint.get("list", root, { + query: WorkspaceRoutingQuery, success: described(Provider.ListResult, "List of providers"), }).annotateMerge( OpenApi.annotations({ @@ -24,6 +25,7 @@ export const ProviderApi = HttpApi.make("provider") }), ), HttpApiEndpoint.get("auth", `${root}/auth`, { + query: WorkspaceRoutingQuery, success: described(ProviderAuth.Methods, "Provider auth methods"), }).annotateMerge( OpenApi.annotations({ @@ -34,6 +36,7 @@ export const ProviderApi = HttpApi.make("provider") ), HttpApiEndpoint.post("authorize", `${root}/:providerID/oauth/authorize`, { params: { providerID: ProviderID }, + query: WorkspaceRoutingQuery, payload: ProviderAuth.AuthorizeInput, success: described(Schema.UndefinedOr(ProviderAuth.Authorization), "Authorization URL and method"), error: HttpApiError.BadRequest, @@ -46,6 +49,7 @@ export const ProviderApi = HttpApi.make("provider") ), HttpApiEndpoint.post("callback", `${root}/:providerID/oauth/callback`, { params: { providerID: ProviderID }, + query: WorkspaceRoutingQuery, payload: ProviderAuth.CallbackInput, success: described(Schema.Boolean, "OAuth callback processed successfully"), error: HttpApiError.BadRequest, diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/pty.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/pty.ts index ad513e0ad4..1391d2a919 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/pty.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/pty.ts @@ -5,13 +5,20 @@ import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" -import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" +import { + WorkspaceRoutingMiddleware, + WorkspaceRoutingQuery, + WorkspaceRoutingQueryFields, +} from "../middleware/workspace-routing" import { ApiNotFoundError } from "../errors" import { described } from "./metadata" const root = "/pty" export const Params = Schema.Struct({ ptyID: PtyID }) -export const CursorQuery = Schema.Struct({ cursor: Schema.optional(Schema.String) }) +export const CursorQuery = Schema.Struct({ + ...WorkspaceRoutingQueryFields, + cursor: Schema.optional(Schema.String), +}) export const ShellItem = Schema.Struct({ path: Schema.String, name: Schema.String, @@ -34,6 +41,7 @@ export const PtyApi = HttpApi.make("pty") HttpApiGroup.make("pty") .add( HttpApiEndpoint.get("shells", PtyPaths.shells, { + query: WorkspaceRoutingQuery, success: described(Schema.Array(ShellItem), "List of shells"), }).annotateMerge( OpenApi.annotations({ @@ -43,6 +51,7 @@ export const PtyApi = HttpApi.make("pty") }), ), HttpApiEndpoint.get("list", PtyPaths.list, { + query: WorkspaceRoutingQuery, success: described(Schema.Array(Pty.Info), "List of sessions"), }).annotateMerge( OpenApi.annotations({ @@ -52,6 +61,7 @@ export const PtyApi = HttpApi.make("pty") }), ), HttpApiEndpoint.post("create", PtyPaths.create, { + query: WorkspaceRoutingQuery, payload: Pty.CreateInput, success: described(Pty.Info, "Created session"), error: HttpApiError.BadRequest, @@ -64,6 +74,7 @@ export const PtyApi = HttpApi.make("pty") ), HttpApiEndpoint.get("get", PtyPaths.get, { params: { ptyID: PtyID }, + query: WorkspaceRoutingQuery, success: described(Pty.Info, "Session info"), error: ApiNotFoundError, }).annotateMerge( @@ -75,6 +86,7 @@ export const PtyApi = HttpApi.make("pty") ), HttpApiEndpoint.put("update", PtyPaths.update, { params: { ptyID: PtyID }, + query: WorkspaceRoutingQuery, payload: Pty.UpdateInput, success: described(Pty.Info, "Updated session"), error: [HttpApiError.BadRequest, ApiNotFoundError], @@ -87,6 +99,7 @@ export const PtyApi = HttpApi.make("pty") ), HttpApiEndpoint.delete("remove", PtyPaths.remove, { params: { ptyID: PtyID }, + query: WorkspaceRoutingQuery, success: described(Schema.Boolean, "Session removed"), error: ApiNotFoundError, }).annotateMerge( @@ -98,6 +111,7 @@ export const PtyApi = HttpApi.make("pty") ), HttpApiEndpoint.post("connectToken", PtyPaths.connectToken, { params: { ptyID: PtyID }, + query: WorkspaceRoutingQuery, success: described(PtyTicket.ConnectToken, "WebSocket connect token"), error: [HttpApiError.Forbidden, ApiNotFoundError], }).annotateMerge( @@ -126,6 +140,7 @@ export const PtyConnectApi = HttpApi.make("pty-connect").add( .add( HttpApiEndpoint.get("connect", PtyPaths.connect, { params: Params, + query: WorkspaceRoutingQuery, success: described(Schema.Boolean, "Connected session"), error: [HttpApiError.Forbidden, HttpApiError.NotFound], }).annotateMerge( diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/query.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/query.ts new file mode 100644 index 0000000000..c780f5222c --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/query.ts @@ -0,0 +1,12 @@ +import { Schema, SchemaGetter } from "effect" + +export const QueryBoolean = Schema.Literals(["true", "false"]).pipe( + Schema.decodeTo(Schema.Boolean, { + decode: SchemaGetter.transform((value) => value === "true"), + encode: SchemaGetter.transform((value) => (value ? "true" : "false")), + }), +) + +export const QueryBooleanOpenApi = { + anyOf: [{ type: "boolean" }, { type: "string", enum: ["true", "false"] }], +} diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/question.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/question.ts index de2d4fca8e..35cd3314b5 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/question.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/question.ts @@ -4,7 +4,7 @@ import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" -import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" +import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing" import { described } from "./metadata" const root = "/question" @@ -19,6 +19,7 @@ export const QuestionApi = HttpApi.make("question") HttpApiGroup.make("question") .add( HttpApiEndpoint.get("list", root, { + query: WorkspaceRoutingQuery, success: described(Schema.Array(Question.Request), "List of pending questions"), }).annotateMerge( OpenApi.annotations({ @@ -29,6 +30,7 @@ export const QuestionApi = HttpApi.make("question") ), HttpApiEndpoint.post("reply", `${root}/:requestID/reply`, { params: { requestID: QuestionID }, + query: WorkspaceRoutingQuery, payload: ReplyPayload, success: described(Schema.Boolean, "Question answered successfully"), error: [HttpApiError.BadRequest, HttpApiError.NotFound], @@ -41,6 +43,7 @@ export const QuestionApi = HttpApi.make("question") ), HttpApiEndpoint.post("reject", `${root}/:requestID/reject`, { params: { requestID: QuestionID }, + query: WorkspaceRoutingQuery, success: described(Schema.Boolean, "Question rejected successfully"), error: [HttpApiError.BadRequest, HttpApiError.NotFound], }).annotateMerge( diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts index 1159c88030..2053aba3b4 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts @@ -10,23 +10,22 @@ import { SessionSummary } from "@/session/summary" import { Todo } from "@/session/todo" import { MessageID, PartID, SessionID } from "@/session/schema" import { Snapshot } from "@/snapshot" -import { Schema, SchemaGetter, Struct } from "effect" +import { Schema, Struct } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" -import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" +import { + WorkspaceRoutingMiddleware, + WorkspaceRoutingQuery, + WorkspaceRoutingQueryFields, +} from "../middleware/workspace-routing" import { ApiNotFoundError } from "../errors" import { described } from "./metadata" +import { QueryBoolean } from "./query" const root = "/session" -const QueryBoolean = Schema.Literals(["true", "false"]).pipe( - Schema.decodeTo(Schema.Boolean, { - decode: SchemaGetter.transform((value) => value === "true"), - encode: SchemaGetter.transform((value) => (value ? "true" : "false")), - }), -) export const ListQuery = Schema.Struct({ - directory: Schema.optional(Schema.String), + ...WorkspaceRoutingQueryFields, scope: Schema.optional(Schema.Literals(["project"])), path: Schema.optional(Schema.String), roots: Schema.optional(QueryBoolean), @@ -34,8 +33,12 @@ export const ListQuery = Schema.Struct({ search: Schema.optional(Schema.String), limit: Schema.optional(Schema.NumberFromString), }) -export const DiffQuery = Schema.Struct(Struct.omit(SessionSummary.DiffInput.fields, ["sessionID"])) +export const DiffQuery = Schema.Struct({ + ...WorkspaceRoutingQueryFields, + ...Struct.omit(SessionSummary.DiffInput.fields, ["sessionID"]), +}) export const MessagesQuery = Schema.Struct({ + ...WorkspaceRoutingQueryFields, limit: Schema.optional(Schema.NumberFromString.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(0))), before: Schema.optional(Schema.String), }) @@ -112,6 +115,7 @@ export const SessionApi = HttpApi.make("session") }), ), HttpApiEndpoint.get("status", SessionPaths.status, { + query: WorkspaceRoutingQuery, success: described(StatusMap, "Get session status"), error: HttpApiError.BadRequest, }).annotateMerge( @@ -123,6 +127,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.get("get", SessionPaths.get, { params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, success: described(Session.Info, "Get session"), error: [HttpApiError.BadRequest, ApiNotFoundError], }).annotateMerge( @@ -134,6 +139,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.get("children", SessionPaths.children, { params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, success: described(Schema.Array(Session.Info), "List of children"), error: [HttpApiError.BadRequest, HttpApiError.NotFound], }).annotateMerge( @@ -145,6 +151,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.get("todo", SessionPaths.todo, { params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, success: described(Schema.Array(Todo.Info), "Todo list"), error: [HttpApiError.BadRequest, HttpApiError.NotFound], }).annotateMerge( @@ -179,6 +186,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.get("message", SessionPaths.message, { params: { sessionID: SessionID, messageID: MessageID }, + query: WorkspaceRoutingQuery, success: described(MessageV2.WithParts, "Message"), error: [HttpApiError.BadRequest, ApiNotFoundError], }).annotateMerge( @@ -189,6 +197,7 @@ export const SessionApi = HttpApi.make("session") }), ), HttpApiEndpoint.post("create", SessionPaths.create, { + query: WorkspaceRoutingQuery, payload: [HttpApiSchema.NoContent, Session.CreateInput], success: described(Session.Info, "Successfully created session"), error: HttpApiError.BadRequest, @@ -201,6 +210,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.delete("remove", SessionPaths.remove, { params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, success: described(Schema.Boolean, "Successfully deleted session"), error: [HttpApiError.BadRequest, ApiNotFoundError], }).annotateMerge( @@ -212,6 +222,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.patch("update", SessionPaths.update, { params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, payload: UpdatePayload, success: described(Session.Info, "Successfully updated session"), error: [HttpApiError.BadRequest, ApiNotFoundError], @@ -224,9 +235,10 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.post("fork", SessionPaths.fork, { params: { sessionID: SessionID }, - payload: ForkPayload, + query: WorkspaceRoutingQuery, + payload: Schema.optional(ForkPayload), success: described(Session.Info, "200"), - error: ApiNotFoundError, + error: [HttpApiError.BadRequest, ApiNotFoundError], }).annotateMerge( OpenApi.annotations({ identifier: "session.fork", @@ -236,6 +248,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.post("abort", SessionPaths.abort, { params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, success: described(Schema.Boolean, "Aborted session"), error: [HttpApiError.BadRequest, HttpApiError.NotFound], }).annotateMerge( @@ -247,6 +260,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.post("init", SessionPaths.init, { params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, payload: InitPayload, success: described(Schema.Boolean, "200"), error: [HttpApiError.BadRequest, HttpApiError.NotFound], @@ -260,8 +274,9 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.post("share", SessionPaths.share, { params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, success: described(Session.Info, "Successfully shared session"), - error: [HttpApiError.BadRequest, ApiNotFoundError], + error: [HttpApiError.InternalServerError, ApiNotFoundError], }).annotateMerge( OpenApi.annotations({ identifier: "session.share", @@ -271,8 +286,9 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.delete("unshare", SessionPaths.share, { params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, success: described(Session.Info, "Successfully unshared session"), - error: [HttpApiError.BadRequest, ApiNotFoundError], + error: [HttpApiError.InternalServerError, ApiNotFoundError], }).annotateMerge( OpenApi.annotations({ identifier: "session.unshare", @@ -282,6 +298,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.post("summarize", SessionPaths.summarize, { params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, payload: SummarizePayload, success: described(Schema.Boolean, "Summarized session"), error: [HttpApiError.BadRequest, ApiNotFoundError], @@ -294,6 +311,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.post("prompt", SessionPaths.prompt, { params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, payload: PromptPayload, success: described(MessageV2.WithParts, "Created message"), error: [HttpApiError.BadRequest, HttpApiError.NotFound], @@ -306,6 +324,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.post("promptAsync", SessionPaths.promptAsync, { params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, payload: PromptPayload, success: described(HttpApiSchema.NoContent, "Prompt accepted"), error: [HttpApiError.BadRequest, HttpApiError.NotFound], @@ -319,6 +338,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.post("command", SessionPaths.command, { params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, payload: CommandPayload, success: described(MessageV2.WithParts, "Created message"), error: [HttpApiError.BadRequest, HttpApiError.NotFound], @@ -331,6 +351,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.post("shell", SessionPaths.shell, { params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, payload: ShellPayload, success: described(MessageV2.WithParts, "Created message"), error: [HttpApiError.BadRequest, HttpApiError.NotFound], @@ -343,6 +364,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.post("revert", SessionPaths.revert, { params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, payload: RevertPayload, success: described(Session.Info, "Updated session"), error: [HttpApiError.BadRequest, HttpApiError.NotFound], @@ -356,6 +378,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.post("unrevert", SessionPaths.unrevert, { params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, success: described(Session.Info, "Updated session"), error: [HttpApiError.BadRequest, HttpApiError.NotFound], }).annotateMerge( @@ -367,6 +390,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.post("permissionRespond", SessionPaths.permissions, { params: { sessionID: SessionID, permissionID: PermissionID }, + query: WorkspaceRoutingQuery, payload: PermissionResponsePayload, success: described(Schema.Boolean, "Permission processed successfully"), error: [HttpApiError.BadRequest, HttpApiError.NotFound], @@ -380,6 +404,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.delete("deleteMessage", SessionPaths.deleteMessage, { params: { sessionID: SessionID, messageID: MessageID }, + query: WorkspaceRoutingQuery, success: described(Schema.Boolean, "Successfully deleted message"), error: [HttpApiError.BadRequest, HttpApiError.NotFound], }).annotateMerge( @@ -392,6 +417,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.delete("deletePart", SessionPaths.deletePart, { params: { sessionID: SessionID, messageID: MessageID, partID: PartID }, + query: WorkspaceRoutingQuery, success: described(Schema.Boolean, "Successfully deleted part"), error: [HttpApiError.BadRequest, HttpApiError.NotFound], }).annotateMerge( @@ -402,6 +428,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.patch("updatePart", SessionPaths.updatePart, { params: { sessionID: SessionID, messageID: MessageID, partID: PartID }, + query: WorkspaceRoutingQuery, payload: MessageV2.Part, success: described(MessageV2.Part, "Successfully updated part"), error: [HttpApiError.BadRequest, HttpApiError.NotFound], diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/sync.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/sync.ts index 442e656554..38a93240eb 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/sync.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/sync.ts @@ -1,10 +1,10 @@ -import { NonNegativeInt } from "@/util/schema" +import { NonNegativeInt } from "@opencode-ai/core/schema" import { SessionID } from "@/session/schema" import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" -import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" +import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing" import { described } from "./metadata" const root = "/sync" @@ -46,6 +46,7 @@ export const SyncApi = HttpApi.make("sync") HttpApiGroup.make("sync") .add( HttpApiEndpoint.post("start", SyncPaths.start, { + query: WorkspaceRoutingQuery, success: described(Schema.Boolean, "Workspace sync started"), }).annotateMerge( OpenApi.annotations({ @@ -55,6 +56,7 @@ export const SyncApi = HttpApi.make("sync") }), ), HttpApiEndpoint.post("replay", SyncPaths.replay, { + query: WorkspaceRoutingQuery, payload: ReplayPayload, success: described(ReplayResponse, "Replayed sync events"), error: HttpApiError.BadRequest, @@ -66,6 +68,7 @@ export const SyncApi = HttpApi.make("sync") }), ), HttpApiEndpoint.post("steal", SyncPaths.steal, { + query: WorkspaceRoutingQuery, payload: SessionPayload, success: described(SessionPayload, "Session stolen into workspace"), error: HttpApiError.BadRequest, @@ -77,6 +80,7 @@ export const SyncApi = HttpApi.make("sync") }), ), HttpApiEndpoint.post("history", SyncPaths.history, { + query: WorkspaceRoutingQuery, payload: HistoryPayload, success: described(Schema.Array(HistoryEvent), "Sync events"), error: HttpApiError.BadRequest, diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/tui.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/tui.ts index 8ab43f6654..3cf3de5b8e 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/tui.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/tui.ts @@ -1,18 +1,15 @@ import { TuiEvent } from "@/cli/cmd/tui/event" +import { TuiRequest as TuiRequestPayload } from "@/server/shared/tui-control" import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" -import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" +import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing" import { ApiNotFoundError } from "../errors" import { described } from "./metadata" const root = "/tui" export const CommandPayload = Schema.Struct({ command: Schema.String }) -export const TuiRequestPayload = Schema.Struct({ - path: Schema.String, - body: Schema.Unknown, -}) const EventTuiPromptAppend = Schema.Struct({ type: Schema.Literal(TuiEvent.PromptAppend.type), properties: TuiEvent.PromptAppend.properties, @@ -57,6 +54,7 @@ export const TuiApi = HttpApi.make("tui") HttpApiGroup.make("tui") .add( HttpApiEndpoint.post("appendPrompt", TuiPaths.appendPrompt, { + query: WorkspaceRoutingQuery, payload: TuiEvent.PromptAppend.properties, success: described(Schema.Boolean, "Prompt processed successfully"), error: HttpApiError.BadRequest, @@ -68,6 +66,7 @@ export const TuiApi = HttpApi.make("tui") }), ), HttpApiEndpoint.post("openHelp", TuiPaths.openHelp, { + query: WorkspaceRoutingQuery, success: described(Schema.Boolean, "Help dialog opened successfully"), }).annotateMerge( OpenApi.annotations({ @@ -77,6 +76,7 @@ export const TuiApi = HttpApi.make("tui") }), ), HttpApiEndpoint.post("openSessions", TuiPaths.openSessions, { + query: WorkspaceRoutingQuery, success: described(Schema.Boolean, "Session dialog opened successfully"), }).annotateMerge( OpenApi.annotations({ @@ -86,6 +86,7 @@ export const TuiApi = HttpApi.make("tui") }), ), HttpApiEndpoint.post("openThemes", TuiPaths.openThemes, { + query: WorkspaceRoutingQuery, success: described(Schema.Boolean, "Theme dialog opened successfully"), }).annotateMerge( OpenApi.annotations({ @@ -95,6 +96,7 @@ export const TuiApi = HttpApi.make("tui") }), ), HttpApiEndpoint.post("openModels", TuiPaths.openModels, { + query: WorkspaceRoutingQuery, success: described(Schema.Boolean, "Model dialog opened successfully"), }).annotateMerge( OpenApi.annotations({ @@ -104,6 +106,7 @@ export const TuiApi = HttpApi.make("tui") }), ), HttpApiEndpoint.post("submitPrompt", TuiPaths.submitPrompt, { + query: WorkspaceRoutingQuery, success: described(Schema.Boolean, "Prompt submitted successfully"), }).annotateMerge( OpenApi.annotations({ @@ -113,6 +116,7 @@ export const TuiApi = HttpApi.make("tui") }), ), HttpApiEndpoint.post("clearPrompt", TuiPaths.clearPrompt, { + query: WorkspaceRoutingQuery, success: described(Schema.Boolean, "Prompt cleared successfully"), }).annotateMerge( OpenApi.annotations({ @@ -122,6 +126,7 @@ export const TuiApi = HttpApi.make("tui") }), ), HttpApiEndpoint.post("executeCommand", TuiPaths.executeCommand, { + query: WorkspaceRoutingQuery, payload: CommandPayload, success: described(Schema.Boolean, "Command executed successfully"), error: HttpApiError.BadRequest, @@ -133,6 +138,7 @@ export const TuiApi = HttpApi.make("tui") }), ), HttpApiEndpoint.post("showToast", TuiPaths.showToast, { + query: WorkspaceRoutingQuery, payload: TuiEvent.ToastShow.properties, success: described(Schema.Boolean, "Toast notification shown successfully"), }).annotateMerge( @@ -143,6 +149,7 @@ export const TuiApi = HttpApi.make("tui") }), ), HttpApiEndpoint.post("publish", TuiPaths.publish, { + query: WorkspaceRoutingQuery, payload: TuiPublishPayload, success: described(Schema.Boolean, "Event published successfully"), error: HttpApiError.BadRequest, @@ -154,6 +161,7 @@ export const TuiApi = HttpApi.make("tui") }), ), HttpApiEndpoint.post("selectSession", TuiPaths.selectSession, { + query: WorkspaceRoutingQuery, payload: TuiEvent.SessionSelect.properties, success: described(Schema.Boolean, "Session selected successfully"), error: [HttpApiError.BadRequest, ApiNotFoundError], @@ -165,6 +173,7 @@ export const TuiApi = HttpApi.make("tui") }), ), HttpApiEndpoint.get("controlNext", TuiPaths.controlNext, { + query: WorkspaceRoutingQuery, success: described(TuiRequestPayload, "Next TUI request"), }).annotateMerge( OpenApi.annotations({ @@ -174,6 +183,7 @@ export const TuiApi = HttpApi.make("tui") }), ), HttpApiEndpoint.post("controlResponse", TuiPaths.controlResponse, { + query: WorkspaceRoutingQuery, payload: Schema.Unknown, success: described(Schema.Boolean, "Response submitted successfully"), }).annotateMerge( diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/message.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/message.ts index 3b0b2fa5b1..060c6c8a83 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/message.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/message.ts @@ -3,46 +3,31 @@ import { SessionMessage } from "@/v2/session-message" import { Schema } from "effect" import { HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../../middleware/authorization" +import { WorkspaceRoutingQueryFields } from "../../middleware/workspace-routing" + +export const MessagesQuery = Schema.Struct({ + ...WorkspaceRoutingQueryFields, + limit: Schema.optional( + Schema.NumberFromString.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(1), Schema.isLessThanOrEqualTo(200)), + ).annotate({ + description: "Maximum number of messages to return. When omitted, the endpoint returns its default page size.", + }), + order: Schema.optional(Schema.Union([Schema.Literal("asc"), Schema.Literal("desc")])).annotate({ + description: "Message order for the first page. Use desc for newest first or asc for oldest first.", + }), + cursor: Schema.optional( + Schema.String.annotate({ + description: + "Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response. Do not combine with order.", + }), + ), +}).annotate({ identifier: "V2SessionMessagesQuery" }) export const MessageGroup = HttpApiGroup.make("v2.message") .add( HttpApiEndpoint.get("messages", "/api/session/:sessionID/message", { params: { sessionID: SessionID }, - query: Schema.Union([ - Schema.Struct({ - limit: Schema.optional( - Schema.NumberFromString.check( - Schema.isInt(), - Schema.isGreaterThanOrEqualTo(1), - Schema.isLessThanOrEqualTo(200), - ), - ).annotate({ - description: - "Maximum number of messages to return. When omitted, the endpoint returns its default page size.", - }), - order: Schema.optional(Schema.Union([Schema.Literal("asc"), Schema.Literal("desc")])).annotate({ - description: "Message order for the first page. Use desc for newest first or asc for oldest first.", - }), - cursor: Schema.optional(Schema.Never), - }), - Schema.Struct({ - limit: Schema.optional( - Schema.NumberFromString.check( - Schema.isInt(), - Schema.isGreaterThanOrEqualTo(1), - Schema.isLessThanOrEqualTo(200), - ), - ).annotate({ - description: - "Maximum number of messages to return. When omitted, the endpoint returns its default page size.", - }), - cursor: Schema.String.annotate({ - description: - "Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response. Do not combine with order.", - }), - order: Schema.optional(Schema.Never), - }), - ]).annotate({ identifier: "V2SessionMessagesQuery" }), + query: MessagesQuery, success: Schema.Struct({ items: Schema.Array(SessionMessage.Message), cursor: Schema.Struct({ diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/session.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/session.ts index 17ddcaeda3..231f1915bb 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/session.ts @@ -1,67 +1,39 @@ -import { WorkspaceID } from "@/control-plane/schema" import { SessionID } from "@/session/schema" import { SessionMessage } from "@/v2/session-message" import { Prompt } from "@/v2/session-prompt" import { SessionV2 } from "@/v2/session" -import { Schema, SchemaGetter } from "effect" +import { Schema } from "effect" import { HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../../middleware/authorization" +import { WorkspaceRoutingQuery, WorkspaceRoutingQueryFields } from "../../middleware/workspace-routing" +import { QueryBoolean } from "../query" + +export const SessionsQuery = Schema.Struct({ + ...WorkspaceRoutingQueryFields, + limit: Schema.optional( + Schema.NumberFromString.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(1), Schema.isLessThanOrEqualTo(200)), + ).annotate({ + description: "Maximum number of sessions to return. Defaults to the newest 50 sessions.", + }), + order: Schema.optional(Schema.Union([Schema.Literal("asc"), Schema.Literal("desc")])).annotate({ + description: "Session order for the first page. Use desc for newest first or asc for oldest first.", + }), + path: Schema.optional(Schema.String), + roots: Schema.optional(QueryBoolean), + start: Schema.optional(Schema.NumberFromString), + search: Schema.optional(Schema.String), + cursor: Schema.optional( + Schema.String.annotate({ + description: + "Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response. Do not combine with order or filters.", + }), + ), +}).annotate({ identifier: "V2SessionsQuery" }) export const SessionGroup = HttpApiGroup.make("v2.session") .add( HttpApiEndpoint.get("sessions", "/api/session", { - query: Schema.Union([ - Schema.Struct({ - limit: Schema.optional( - Schema.NumberFromString.check( - Schema.isInt(), - Schema.isGreaterThanOrEqualTo(1), - Schema.isLessThanOrEqualTo(200), - ), - ).annotate({ - description: "Maximum number of sessions to return. Defaults to the newest 50 sessions.", - }), - order: Schema.optional(Schema.Union([Schema.Literal("asc"), Schema.Literal("desc")])).annotate({ - description: "Session order for the first page. Use desc for newest first or asc for oldest first.", - }), - directory: Schema.String.pipe(Schema.optional), - path: Schema.String.pipe(Schema.optional), - workspace: WorkspaceID.pipe(Schema.optional), - roots: Schema.Literals(["true", "false"]) - .pipe( - Schema.decodeTo(Schema.Boolean, { - decode: SchemaGetter.transform((value) => value === "true"), - encode: SchemaGetter.transform((value) => (value ? "true" : "false")), - }), - ) - .pipe(Schema.optional), - start: Schema.NumberFromString.pipe(Schema.optional), - search: Schema.String.pipe(Schema.optional), - cursor: Schema.optional(Schema.Never), - }), - Schema.Struct({ - limit: Schema.optional( - Schema.NumberFromString.check( - Schema.isInt(), - Schema.isGreaterThanOrEqualTo(1), - Schema.isLessThanOrEqualTo(200), - ), - ).annotate({ - description: "Maximum number of sessions to return. Defaults to the newest 50 sessions.", - }), - cursor: Schema.String.annotate({ - description: - "Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response. Do not combine with order.", - }), - order: Schema.optional(Schema.Never), - directory: Schema.optional(Schema.Never), - path: Schema.optional(Schema.Never), - workspace: Schema.optional(Schema.Never), - roots: Schema.optional(Schema.Never), - start: Schema.optional(Schema.Never), - search: Schema.optional(Schema.Never), - }), - ]).annotate({ identifier: "V2SessionsQuery" }), + query: SessionsQuery, success: Schema.Struct({ items: Schema.Array(SessionV2.Info), cursor: Schema.Struct({ @@ -82,6 +54,7 @@ export const SessionGroup = HttpApiGroup.make("v2.session") .add( HttpApiEndpoint.post("prompt", "/api/session/:sessionID/prompt", { params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, payload: Schema.Struct({ prompt: Prompt, delivery: SessionV2.Delivery.pipe(Schema.optional), @@ -98,6 +71,7 @@ export const SessionGroup = HttpApiGroup.make("v2.session") .add( HttpApiEndpoint.post("compact", "/api/session/:sessionID/compact", { params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, success: HttpApiSchema.NoContent, }).annotateMerge( OpenApi.annotations({ @@ -110,6 +84,7 @@ export const SessionGroup = HttpApiGroup.make("v2.session") .add( HttpApiEndpoint.post("wait", "/api/session/:sessionID/wait", { params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, success: HttpApiSchema.NoContent, }).annotateMerge( OpenApi.annotations({ @@ -122,6 +97,7 @@ export const SessionGroup = HttpApiGroup.make("v2.session") .add( HttpApiEndpoint.get("context", "/api/session/:sessionID/context", { params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, success: Schema.Array(SessionMessage.Message), }).annotateMerge( OpenApi.annotations({ diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts index 66422c13b6..1c40ae3cb8 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts @@ -5,7 +5,7 @@ import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, Op import { ApiVcsApplyError } from "./instance" import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" -import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" +import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing" import { described } from "./metadata" const root = "/experimental/workspace" @@ -29,6 +29,7 @@ export class ApiWorkspaceWarpError extends Schema.ErrorClass Effect.fail(new HttpApiError.InternalServerError({})))), + ], { concurrency: "unbounded", }, @@ -40,7 +43,10 @@ export const experimentalHandlers = HttpApiBuilder.group(InstanceHttpApi, "exper const listConsoleOrgs = Effect.fn("ExperimentalHttpApi.consoleOrgs")(function* () { const [groups, active] = yield* Effect.all( - [account.orgsByAccount().pipe(Effect.orDie), account.active().pipe(Effect.orDie)], + [ + account.orgsByAccount().pipe(Effect.catch(() => Effect.fail(new HttpApiError.InternalServerError({})))), + account.active().pipe(Effect.catch(() => Effect.fail(new HttpApiError.InternalServerError({})))), + ], { concurrency: "unbounded", }, diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts index f9df530a92..7027e666ca 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts @@ -30,7 +30,7 @@ export const providerHandlers = HttpApiBuilder.group(InstanceHttpApi, "provider" connected, ) return { - all: Object.values(providers), + all: Object.values(providers).map(Provider.toPublicInfo), default: Provider.defaultModelIDs(providers), connected: Object.keys(connected), } @@ -61,9 +61,11 @@ export const providerHandlers = HttpApiBuilder.group(InstanceHttpApi, "provider" const payload = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(ProviderAuth.AuthorizeInput))(body).pipe( Effect.mapError(() => new HttpApiError.BadRequest({})), ) + // Match legacy Hono behavior: when authorize() resolves without a + // result (e.g. no further redirect), serialize as JSON `null` instead + // of an empty body so clients can `.json()` parse the response. const result = yield* authorize({ params: ctx.params, payload }) - if (result === undefined) return HttpServerResponse.empty({ status: 200 }) - return HttpServerResponse.jsonUnsafe(result) + return HttpServerResponse.jsonUnsafe(result ?? null) }) const callback = Effect.fn("ProviderHttpApi.callback")(function* (ctx: { diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts index 7b8395d809..369ca91d02 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts @@ -153,6 +153,12 @@ export const ptyConnectRoute = HttpRouter.use((router) => return HttpServerResponse.empty() } + // No `pending[]`-style early-frame buffer (the legacy Hono handler had one). + // `request.upgrade` returns a Socket without running the WS handshake; the + // handshake fires inside `socket.runRaw` below, AFTER `pty.connect` resolves + // and the message callback is registered. The client therefore can't fire + // `open` and start sending until the listener is already wired. Don't move + // `runRaw` ahead of `pty.connect` without re-introducing a buffer. yield* socket .runRaw((message) => handlePtyInput(handler, message)) .pipe( diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts index 56fa7adb15..9230a6fe57 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts @@ -187,13 +187,30 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", const fork = Effect.fn("SessionHttpApi.fork")(function* (ctx: { params: { sessionID: SessionID } - payload: typeof ForkPayload.Type + payload?: typeof ForkPayload.Type }) { return yield* SessionError.mapStorageNotFound( - session.fork({ sessionID: ctx.params.sessionID, messageID: ctx.payload.messageID }), + session.fork({ sessionID: ctx.params.sessionID, messageID: ctx.payload?.messageID }), ) }) + const forkRaw = Effect.fn("SessionHttpApi.forkRaw")(function* (ctx: { + params: { sessionID: SessionID } + request: HttpServerRequest.HttpServerRequest + }) { + const body = yield* Effect.orDie(ctx.request.text) + if (body.trim().length === 0) return yield* fork({ params: ctx.params }) + + const json = yield* Effect.try({ + try: () => JSON.parse(body) as unknown, + catch: () => new HttpApiError.BadRequest({}), + }) + const payload = yield* Schema.decodeUnknownEffect(ForkPayload)(json).pipe( + Effect.mapError(() => new HttpApiError.BadRequest({})), + ) + return yield* fork({ params: ctx.params, payload }) + }) + const abort = Effect.fn("SessionHttpApi.abort")(function* (ctx: { params: { sessionID: SessionID } }) { yield* promptSvc.cancel(ctx.params.sessionID) return true @@ -203,23 +220,32 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", params: { sessionID: SessionID } payload: typeof InitPayload.Type }) { - yield* promptSvc.command({ - sessionID: ctx.params.sessionID, - messageID: ctx.payload.messageID, - model: `${ctx.payload.providerID}/${ctx.payload.modelID}`, - command: Command.Default.INIT, - arguments: "", - }) + yield* promptSvc + .command({ + sessionID: ctx.params.sessionID, + messageID: ctx.payload.messageID, + model: `${ctx.payload.providerID}/${ctx.payload.modelID}`, + command: Command.Default.INIT, + arguments: "", + }) + .pipe(Effect.mapError(() => new HttpApiError.BadRequest({}))) return true }) + // share/unshare errors aren't all client-induced — storage and network + // failures from SessionShare are real possibilities. Map to a typed 500 + // (matches the legacy Hono path which routed any failure through + // ErrorMiddleware → NamedError.Unknown 500) instead of blanket-mapping + // every failure to a 400 BadRequest. const share = Effect.fn("SessionHttpApi.share")(function* (ctx: { params: { sessionID: SessionID } }) { - yield* shareSvc.share(ctx.params.sessionID).pipe(Effect.mapError(() => new HttpApiError.BadRequest({}))) + yield* shareSvc.share(ctx.params.sessionID).pipe(Effect.mapError(() => new HttpApiError.InternalServerError({}))) return yield* SessionError.mapStorageNotFound(session.get(ctx.params.sessionID)) }) const unshare = Effect.fn("SessionHttpApi.unshare")(function* (ctx: { params: { sessionID: SessionID } }) { - yield* shareSvc.unshare(ctx.params.sessionID).pipe(Effect.mapError(() => new HttpApiError.BadRequest({}))) + yield* shareSvc + .unshare(ctx.params.sessionID) + .pipe(Effect.mapError(() => new HttpApiError.InternalServerError({}))) return yield* SessionError.mapStorageNotFound(session.get(ctx.params.sessionID)) }) @@ -251,20 +277,19 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", }) { const instance = yield* InstanceState.context const workspace = yield* InstanceState.workspaceID - return HttpServerResponse.stream( - Stream.fromEffect( - promptSvc - .prompt({ - ...ctx.payload, - sessionID: ctx.params.sessionID, - }) - .pipe(Effect.provideService(InstanceRef, instance), Effect.provideService(WorkspaceRef, workspace)), - ).pipe( - Stream.map((message) => JSON.stringify(message)), - Stream.encodeText, - ), - { contentType: "application/json" }, - ) + const message = yield* promptSvc + .prompt({ + ...ctx.payload, + sessionID: ctx.params.sessionID, + }) + .pipe( + Effect.provideService(InstanceRef, instance), + Effect.provideService(WorkspaceRef, workspace), + Effect.mapError(() => new HttpApiError.BadRequest({})), + ) + return HttpServerResponse.stream(Stream.make(JSON.stringify(message)).pipe(Stream.encodeText), { + contentType: "application/json", + }) }) const promptAsync = Effect.fn("SessionHttpApi.promptAsync")(function* (ctx: { @@ -290,7 +315,9 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", params: { sessionID: SessionID } payload: typeof CommandPayload.Type }) { - return yield* promptSvc.command({ ...ctx.payload, sessionID: ctx.params.sessionID }) + return yield* promptSvc + .command({ ...ctx.payload, sessionID: ctx.params.sessionID }) + .pipe(Effect.mapError(() => new HttpApiError.BadRequest({}))) }) const shell = Effect.fn("SessionHttpApi.shell")(function* (ctx: { @@ -363,7 +390,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", .handleRaw("create", createRaw) .handle("remove", remove) .handle("update", update) - .handle("fork", fork) + .handleRaw("fork", forkRaw) .handle("abort", abort) .handle("init", init) .handle("share", share) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/message.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/message.ts index 3485d80fd6..92e37142b4 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/message.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/message.ts @@ -34,6 +34,7 @@ export const messageHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.message return handlers.handle( "messages", Effect.fn(function* (ctx) { + if (ctx.query.cursor && ctx.query.order !== undefined) return yield* new HttpApiError.BadRequest({}) const decoded = yield* Effect.try({ try: () => (ctx.query.cursor ? cursor.decode(ctx.query.cursor) : undefined), catch: () => new HttpApiError.BadRequest({}), diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/session.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/session.ts index 558e34dd18..275fa2956c 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/session.ts @@ -22,6 +22,31 @@ type SessionCursor = typeof SessionCursor.Type const decodeCursor = Schema.decodeUnknownSync(SessionCursor) +function hasCursorFilter(query: { + readonly order?: unknown + readonly path?: unknown + readonly roots?: unknown + readonly start?: unknown + readonly search?: unknown +}) { + return ( + query.order !== undefined || + query.path !== undefined || + query.roots !== undefined || + query.start !== undefined || + query.search !== undefined + ) +} + +function hasCursorRoutingMismatch( + query: { readonly directory?: string; readonly workspace?: string }, + decoded: SessionCursor | undefined, +) { + if (!decoded) return false + if (query.directory !== undefined && query.directory !== decoded.directory) return true + return query.workspace !== undefined && query.workspace !== decoded.workspaceID +} + const sessionCursor = { encode( session: SessionV2.Info, @@ -46,10 +71,12 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.session .handle( "sessions", Effect.fn(function* (ctx) { + if (ctx.query.cursor && hasCursorFilter(ctx.query)) return yield* new HttpApiError.BadRequest({}) const decoded = yield* Effect.try({ try: () => (ctx.query.cursor ? sessionCursor.decode(ctx.query.cursor) : undefined), catch: () => new HttpApiError.BadRequest({}), }) + if (hasCursorRoutingMismatch(ctx.query, decoded)) return yield* new HttpApiError.BadRequest({}) const order = decoded?.order ?? ctx.query.order ?? "desc" const filters = decoded ?? { directory: ctx.query.directory, diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/workspace.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/workspace.ts index d908eda9d1..c22b82ddeb 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/workspace.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/workspace.ts @@ -14,7 +14,7 @@ export const workspaceHandlers = HttpApiBuilder.group(InstanceHttpApi, "workspac const adapters = Effect.fn("WorkspaceHttpApi.adapters")(function* () { const instance = yield* InstanceState.context - return yield* Effect.promise(() => listAdapters(instance.project.id)) + return yield* Effect.sync(() => listAdapters(instance.project.id)) }) const list = Effect.fn("WorkspaceHttpApi.list")(function* () { @@ -32,6 +32,10 @@ export const workspaceHandlers = HttpApiBuilder.group(InstanceHttpApi, "workspac .pipe(Effect.mapError(() => new HttpApiError.BadRequest({}))) }) + const syncList = Effect.fn("WorkspaceHttpApi.syncList")(function* () { + yield* workspace.syncList((yield* InstanceState.context).project) + }) + const status = Effect.fn("WorkspaceHttpApi.status")(function* () { const ids = new Set((yield* workspace.list((yield* InstanceState.context).project)).map((item) => item.id)) return (yield* workspace.status()).filter((item) => ids.has(item.workspaceID)) @@ -73,6 +77,7 @@ export const workspaceHandlers = HttpApiBuilder.group(InstanceHttpApi, "workspac .handle("adapters", adapters) .handle("list", list) .handle("create", create) + .handle("syncList", syncList) .handle("status", status) .handle("remove", remove) .handle("warp", warp) diff --git a/packages/opencode/src/server/routes/instance/httpapi/lifecycle.ts b/packages/opencode/src/server/routes/instance/httpapi/lifecycle.ts index 53d54e2a81..4edfa80787 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/lifecycle.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/lifecycle.ts @@ -1,9 +1,12 @@ import { EffectBridge } from "@/effect/bridge" import type { InstanceContext } from "@/project/instance" import { InstanceStore } from "@/project/instance-store" +import * as Log from "@opencode-ai/core/util/log" import { Effect } from "effect" import { HttpEffect, HttpMiddleware, HttpServerRequest } from "effect/unstable/http" +const log = Log.create({ service: "server" }) + type MarkedInstance = { ctx: InstanceContext store: InstanceStore.Interface @@ -47,6 +50,8 @@ export const disposeMiddleware: HttpMiddleware.HttpMiddleware = (effect) => const marked = disposeAfterResponse.get(request.source) if (!marked) return response disposeAfterResponse.delete(request.source) - yield* Effect.uninterruptible(marked.bridge.run(marked.store.dispose(marked.ctx))) + yield* Effect.uninterruptible(marked.bridge.run(marked.store.dispose(marked.ctx))).pipe( + Effect.catchCause((cause) => Effect.sync(() => log.warn("instance disposal failed", { cause }))), + ) return response }) diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts index 6f5648f30a..73676bd665 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts @@ -1,6 +1,6 @@ import { ServerAuth } from "@/server/auth" import { Effect, Encoding, Layer, Redacted } from "effect" -import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" +import { HttpEffect, HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" import { HttpApiError, HttpApiMiddleware } from "effect/unstable/httpapi" import { hasPtyConnectTicketURL } from "@/server/shared/pty-ticket" import { isPublicUIPath } from "@/server/shared/public-ui" @@ -33,7 +33,12 @@ function validateCredential( ) { return Effect.gen(function* () { if (!ServerAuth.required(config)) return yield* effect - if (!ServerAuth.authorized(credential, config)) return yield* new HttpApiError.Unauthorized({}) + if (!ServerAuth.authorized(credential, config)) { + yield* HttpEffect.appendPreResponseHandler((_request, response) => + Effect.succeed(HttpServerResponse.setHeader(response, "www-authenticate", WWW_AUTHENTICATE)), + ) + return yield* new HttpApiError.Unauthorized({}) + } return yield* effect }) } diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/compression.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/compression.ts new file mode 100644 index 0000000000..9dc9bc01ec --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/compression.ts @@ -0,0 +1,64 @@ +import { deflateSync, gzipSync } from "node:zlib" +import { Effect } from "effect" +import { HttpBody, HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" + +// Mirror of Hono's compressible content-type set so wire behavior matches. +const COMPRESSIBLE_CONTENT_TYPE_REGEX = + /^\s*(?:text\/(?!event-stream(?:[;\s]|$))[^;\s]+|application\/(?:javascript|json|xml|xml-dtd|ecmascript|dart|postscript|rtf|tar|toml|vnd\.dart|vnd\.ms-fontobject|vnd\.ms-opentype|wasm|x-httpd-php|x-javascript|x-ns-proxy-autoconfig|x-sh|x-tar|x-www-form-urlencoded)|font\/(?:otf|ttf)|image\/(?:bmp|vnd\.adobe\.photoshop|vnd\.microsoft\.icon|vnd\.ms-dds|x-icon|x-ms-bmp)|message\/rfc822|model\/gltf-binary|x-shader\/x-fragment|x-shader\/x-vertex|[^;\s]+?\+(?:json|text|xml|yaml))(?:[;\s]|$)/i + +const NO_TRANSFORM_REGEX = /(?:^|,)\s*?no-transform\s*?(?:,|$)/i + +const STREAMING_PATHS = new Set(["/event", "/global/event"]) +const STREAMING_POST_REGEX = /^\/session\/[^/]+\/(?:message|prompt_async)$/ + +const THRESHOLD_BYTES = 1024 + +type Encoding = "gzip" | "deflate" + +function pickEncoding(acceptEncoding: string | undefined): Encoding | undefined { + if (!acceptEncoding) return undefined + const lower = acceptEncoding.toLowerCase() + if (lower.includes("gzip")) return "gzip" + if (lower.includes("deflate")) return "deflate" + return undefined +} + +function pathOf(url: string): string { + const queryIndex = url.indexOf("?") + return queryIndex === -1 ? url : url.slice(0, queryIndex) +} + +export const compressionLayer = HttpRouter.middleware<{ handles: unknown }>()((effect) => + Effect.gen(function* () { + const response = yield* effect + const request = yield* HttpServerRequest.HttpServerRequest + + if (request.method === "HEAD") return response + if (response.headers["content-encoding"]) return response + if (response.headers["transfer-encoding"]) return response + + const body = response.body + if (body._tag !== "Uint8Array") return response + if (body.body.byteLength < THRESHOLD_BYTES) return response + + const cacheControl = response.headers["cache-control"] + if (cacheControl && NO_TRANSFORM_REGEX.test(cacheControl)) return response + + const path = pathOf(request.url) + if (STREAMING_PATHS.has(path)) return response + if (request.method === "POST" && STREAMING_POST_REGEX.test(path)) return response + + const contentType = body.contentType + if (!COMPRESSIBLE_CONTENT_TYPE_REGEX.test(contentType)) return response + + const encoding = pickEncoding(request.headers["accept-encoding"]) + if (!encoding) return response + + const compressed = encoding === "gzip" ? gzipSync(body.body) : deflateSync(body.body) + return HttpServerResponse.setHeader( + HttpServerResponse.setBody(response, HttpBody.uint8Array(compressed, contentType)), + "content-encoding", + encoding, + ) + }), +).layer diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/cors-vary.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/cors-vary.ts new file mode 100644 index 0000000000..add533560c --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/cors-vary.ts @@ -0,0 +1,29 @@ +import { Effect } from "effect" +import { HttpRouter, HttpServerResponse } from "effect/unstable/http" + +// effect-smol's HttpMiddleware.cors builds OPTIONS preflight responses by +// spreading allowOrigin() and allowHeaders() into the same record. Both set +// the `vary` key, so allowHeaders' `Vary: Access-Control-Request-Headers` +// overwrites allowOrigin's `Vary: Origin`. With dynamic origin echoing, the +// missing `Vary: Origin` lets shared caches reuse a preflight cached for one +// origin against a different origin. +// +// TODO: upstream a fix that merges Vary values in headersFromRequestOptions +// (packages/effect/src/unstable/http/HttpMiddleware.ts ~line 332). +export const corsVaryFix = HttpRouter.middleware( + (effect) => + Effect.gen(function* () { + const response = yield* effect + const allowOrigin = response.headers["access-control-allow-origin"] + if (!allowOrigin || allowOrigin === "*") return response + + const vary = response.headers["vary"] + if (!vary) return HttpServerResponse.setHeader(response, "vary", "Origin") + + const tokens = vary.split(",").map((s) => s.trim().toLowerCase()) + if (tokens.includes("origin") || tokens.includes("*")) return response + + return HttpServerResponse.setHeader(response, "vary", `${vary}, Origin`) + }), + { global: true }, +) diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/fence.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/fence.ts new file mode 100644 index 0000000000..f3bfe06689 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/fence.ts @@ -0,0 +1,20 @@ +import { Flag } from "@opencode-ai/core/flag/flag" +import { Effect } from "effect" +import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" +import * as Fence from "@/server/shared/fence" + +const ignoredMethods = new Set(["GET", "HEAD", "OPTIONS"]) + +export const fenceLayer = HttpRouter.middleware<{ handles: unknown }>()((effect) => + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest + if (!Flag.OPENCODE_WORKSPACE_ID || ignoredMethods.has(request.method)) return yield* effect + + const previous = Fence.load() + const response = yield* effect + const current = Fence.diff(previous, Fence.load()) + if (Object.keys(current).length === 0) return response + + return HttpServerResponse.setHeader(response, Fence.HEADER, JSON.stringify(current)) + }), +).layer diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/schema-error.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/schema-error.ts new file mode 100644 index 0000000000..e7d661c5a8 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/schema-error.ts @@ -0,0 +1,30 @@ +import { Effect } from "effect" +import { HttpServerResponse } from "effect/unstable/http" +import { HttpApiMiddleware } from "effect/unstable/httpapi" +import * as Log from "@opencode-ai/core/util/log" + +const log = Log.create({ service: "server" }) + +// Effect's Issue formatter recursively dumps the rejected `actual` value with +// no truncation, so a 5KB invalid array produces a ~360KB string. Cap to keep +// 4xx responses small and avoid mirroring entire request payloads (which may +// contain secrets) into the response body and log file. +const REASON_LIMIT = 1024 +function truncateReason(reason: string) { + if (reason.length <= REASON_LIMIT) return reason + return reason.slice(0, REASON_LIMIT) + `… (${reason.length - REASON_LIMIT} more chars)` +} + +// Default Respondable returns an empty 400 body. Match the NamedError shape +// used by other 4xx/5xx so the SDK's `wrapClientError` extracts `.data.message`. +export class SchemaErrorMiddleware extends HttpApiMiddleware.Service()( + "@opencode/HttpApiSchemaError", +) {} + +export const schemaErrorLayer = HttpApiMiddleware.layerSchemaErrorTransform(SchemaErrorMiddleware, (error) => { + const reason = truncateReason(error.cause.message) + log.warn("schema rejection", { kind: error.kind, reason }) + return Effect.succeed( + HttpServerResponse.jsonUnsafe({ name: "BadRequest", data: { message: reason, kind: error.kind } }, { status: 400 }), + ) +}) diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts index 8ec9f74860..1d665fd5c9 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts @@ -9,11 +9,23 @@ import * as Fence from "@/server/shared/fence" import { getWorkspaceRouteSessionID, isLocalWorkspaceRoute, workspaceProxyURL } from "@/server/shared/workspace-routing" import { NotFoundError } from "@/storage/storage" import { Flag } from "@opencode-ai/core/flag/flag" -import { Context, Data, Effect, Layer } from "effect" +import { Context, Data, Effect, Layer, Schema } from "effect" import { HttpClient, HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" import { HttpApiMiddleware } from "effect/unstable/httpapi" import * as Socket from "effect/unstable/socket/Socket" +// Query fields this middleware reads from the URL. Spread into every +// endpoint query schema in groups that apply WorkspaceRoutingMiddleware, +// otherwise HttpApi rejects requests carrying these params with 400. +// HttpApiMiddleware in effect-smol cannot declare query params today — +// remove this once upstream supports middleware-declared query schemas. +export const WorkspaceRoutingQueryFields = { + directory: Schema.optional(Schema.String), + workspace: Schema.optional(Schema.String), +} + +export const WorkspaceRoutingQuery = Schema.Struct(WorkspaceRoutingQueryFields) + type RemoteTarget = Extract type RequestPlan = Data.TaggedEnum<{ diff --git a/packages/opencode/src/server/routes/instance/httpapi/public.ts b/packages/opencode/src/server/routes/instance/httpapi/public.ts index b2ac719a2a..460a2be7a5 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/public.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/public.ts @@ -1,5 +1,6 @@ import { OpenApi } from "effect/unstable/httpapi" import { OpenCodeHttpApi } from "./api" +import { QueryBooleanOpenApi } from "./groups/query" type OpenApiParameter = { name: string @@ -51,47 +52,31 @@ type OpenApiResponse = { content?: Record } -// Instance routes use middleware for directory/workspace resolution, but HttpApi -// doesn't surface middleware query params in the spec. Inject them explicitly. -const InstanceQueryParameters = [ - { - name: "directory", - in: "query", - required: false, - schema: { type: "string" }, - }, - { - name: "workspace", - in: "query", - required: false, - schema: { type: "string" }, - }, -] satisfies OpenApiParameter[] - // Query schemas describe decoded Effect values, but the generated SDK needs the // public call shape. These keep SDK callers passing numbers/booleans while the // server still decodes string query params at runtime. -const QueryNumberParameters = new Set(["start", "cursor", "limit", "method"]) -const QueryBooleanParameters = new Set(["roots", "archived"]) -const QueryParameterSchemas = { +const QueryParameterSchemas: Record = { + "GET /experimental/session start": { type: "number" }, + "GET /experimental/session roots": QueryBooleanOpenApi, + "GET /experimental/session archived": QueryBooleanOpenApi, "GET /find/file limit": { type: "integer", minimum: 1, maximum: 200 }, - "GET /session/{sessionID}/diff messageID": { type: "string", pattern: "^msg.*" }, + "GET /experimental/session cursor": { type: "number" }, + "GET /experimental/session limit": { type: "number" }, + "GET /session start": { type: "number" }, + "GET /session roots": QueryBooleanOpenApi, + "GET /session limit": { type: "number" }, "GET /session/{sessionID}/message limit": { type: "integer", minimum: 0, maximum: Number.MAX_SAFE_INTEGER }, -} satisfies Record + "GET /api/session limit": { type: "number" }, + "GET /api/session start": { type: "number" }, + "GET /api/session roots": QueryBooleanOpenApi, + "GET /api/session/{sessionID}/message limit": { type: "number" }, +} -const PathParameterSchemas = { - sessionID: { type: "string", pattern: "^ses.*" }, - messageID: { type: "string", pattern: "^msg.*" }, - partID: { type: "string", pattern: "^prt.*" }, - permissionID: { type: "string", pattern: "^per.*" }, - ptyID: { type: "string", pattern: "^pty.*" }, -} satisfies Record - -const LegacyComponentDescriptions = { +const LegacyComponentDescriptions: Record = { LogLevel: "Log level", ServerConfig: "Server configuration for opencode serve and web commands", LayoutConfig: "@deprecated Always uses stretch layout.", -} satisfies Record +} function matchLegacyOpenApi(input: Record) { const spec = input as OpenApiSpec @@ -122,7 +107,6 @@ function matchLegacyOpenApi(input: Record) { delete spec.components?.securitySchemes for (const [path, item] of Object.entries(spec.paths ?? {})) { - const isInstanceRoute = !path.startsWith("/global/") && !path.startsWith("/auth/") for (const method of ["get", "post", "put", "delete", "patch"] as const) { const operation = item[method] if (!operation) continue @@ -183,14 +167,8 @@ function matchLegacyOpenApi(input: Record) { }, } } - if (!isInstanceRoute) continue - operation.parameters = [ - ...InstanceQueryParameters, - ...(operation.parameters ?? []).filter( - (param) => param.in !== "query" || (param.name !== "directory" && param.name !== "workspace"), - ), - ] - for (const param of operation.parameters) normalizeParameter(param, `${method.toUpperCase()} ${path}`) + const route = `${method.toUpperCase()} ${path}` + for (const param of operation.parameters ?? []) normalizeParameter(param, route) } } return input @@ -200,17 +178,20 @@ function addLegacyErrorSchemas(spec: OpenApiSpec) { if (!spec.components?.schemas) return spec.components.schemas.BadRequestError = { type: "object", - required: ["data", "errors", "success"], + required: ["name", "data"], properties: { - data: {}, - errors: { - type: "array", - items: { - type: "object", - additionalProperties: {}, + name: { type: "string", enum: ["BadRequest"] }, + data: { + type: "object", + required: ["message"], + properties: { + message: { type: "string" }, + kind: { + type: "string", + enum: ["Params", "Headers", "Query", "Body", "Payload"], + }, }, }, - success: { type: "boolean", enum: [false] }, }, } spec.components.schemas.NotFoundError = { @@ -292,7 +273,7 @@ function applyLegacySchemaOverrides(spec: OpenApiSpec) { function normalizeComponentDescriptions(spec: OpenApiSpec) { for (const [name, schema] of Object.entries(spec.components?.schemas ?? {})) { - const description = LegacyComponentDescriptions[name as keyof typeof LegacyComponentDescriptions] + const description = LegacyComponentDescriptions[name] if (description) { schema.description = description continue @@ -438,7 +419,7 @@ function fixSelfReferencingComponents(spec: OpenApiSpec) { } } // Simplest fix: generate the raw spec (without transform) to get correct schemas - const raw = OpenApi.fromApi(OpenCodeHttpApi) as unknown as OpenApiSpec + const raw: OpenApiSpec = OpenApi.fromApi(OpenCodeHttpApi) const rawSchemas = raw.components?.schemas if (!rawSchemas) return for (const name of selfRefs) { @@ -503,38 +484,19 @@ function flattenOptions(options: OpenApiSchema[] | undefined): OpenApiSchema[] | function normalizeParameter(param: OpenApiParameter, route: string) { if (!param.schema || typeof param.schema !== "object") return if (param.in === "path") { - param.schema = pathParameterSchema(route, param.name) ?? stripOptionalNull(param.schema) + param.schema = stripOptionalNull(param.schema) return } if (param.in === "query") { - const override = QueryParameterSchemas[`${route} ${param.name}` as keyof typeof QueryParameterSchemas] + const override = QueryParameterSchemas[`${route} ${param.name}`] if (override) { param.schema = override return } - if (QueryNumberParameters.has(param.name)) { - param.schema = { type: "number" } - return - } - if (QueryBooleanParameters.has(param.name)) { - param.schema = { - anyOf: [{ type: "boolean" }, { type: "string", enum: ["true", "false"] }], - } - return - } } param.schema = stripOptionalNull(param.schema) } -function pathParameterSchema(route: string, name: string) { - if (name in PathParameterSchemas) return PathParameterSchemas[name as keyof typeof PathParameterSchemas] - if (name === "id" && route.startsWith("DELETE /experimental/workspace/")) return { type: "string", pattern: "^wrk.*" } - if (name === "id" && route.startsWith("POST /experimental/workspace/")) return { type: "string", pattern: "^wrk.*" } - if (name === "requestID" && route.startsWith("POST /permission/")) return { type: "string", pattern: "^per.*" } - if (name === "requestID" && route.startsWith("POST /question/")) return { type: "string", pattern: "^que.*" } - return undefined -} - export const PublicApi = OpenCodeHttpApi.annotateMerge( OpenApi.annotations({ title: "opencode", diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index ef966036a9..7ce21dfadb 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -1,6 +1,13 @@ import { Context, Effect, Layer } from "effect" -import { HttpApiBuilder } from "effect/unstable/httpapi" -import { FetchHttpClient, HttpClient, HttpMiddleware, HttpRouter, HttpServer } from "effect/unstable/http" +import { HttpApiBuilder, OpenApi } from "effect/unstable/httpapi" +import { + FetchHttpClient, + HttpClient, + HttpMiddleware, + HttpRouter, + HttpServer, + HttpServerResponse, +} from "effect/unstable/http" import * as Socket from "effect/unstable/socket/Socket" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Account } from "@/account/account" @@ -49,6 +56,7 @@ import { CorsConfig, isAllowedCorsOrigin, type CorsOptions } from "@/server/cors import { serveUIEffect } from "@/server/shared/ui" import { ServerAuth } from "@/server/auth" import { InstanceHttpApi, RootHttpApi } from "./api" +import { PublicApi } from "./public" import { authorizationLayer, authorizationRouterMiddleware } from "./middleware/authorization" import { EventApi, eventHandlers } from "./event" import { configHandlers } from "./handlers/config" @@ -72,16 +80,18 @@ import { instanceContextLayer, instanceRouterMiddleware } from "./middleware/ins import { workspaceRouterMiddleware, workspaceRoutingLayer } from "./middleware/workspace-routing" import { disposeMiddleware } from "./lifecycle" import { memoMap } from "@opencode-ai/core/effect/memo-map" -import * as ServerBackend from "@/server/backend" +import { compressionLayer } from "./middleware/compression" +import { corsVaryFix } from "./middleware/cors-vary" import { errorLayer } from "./middleware/error" +import { fenceLayer } from "./middleware/fence" +import { schemaErrorLayer } from "./middleware/schema-error" export const context = Context.makeUnsafe(new Map()) const runtime = HttpRouter.middleware()( Effect.succeed((effect) => Effect.gen(function* () { - const selected = ServerBackend.select() - yield* Effect.annotateCurrentSpan(ServerBackend.attributes(ServerBackend.force(selected, "effect-httpapi"))) + yield* Effect.annotateCurrentSpan({ "opencode.server.backend": "effect-httpapi" }) return yield* effect }), ), @@ -96,7 +106,18 @@ const cors = (corsOptions?: CorsOptions) => { global: true }, ) -const rootApiRoutes = HttpApiBuilder.layer(RootHttpApi).pipe(Layer.provide([controlHandlers, globalHandlers])) +// Route tree: +// - rootApiRoutes: typed /global/* and control routes; auth is declared by RootHttpApi. +// - eventApiRoutes/rawInstanceRoutes: raw instance routes; auth and workspace routing happen as router middleware. +// - instanceApiRoutes: schema routes; auth is declared on each group and workspace context is provided below. +// - uiRoute: raw catch-all fallback; auth is router middleware so public static assets can bypass it. +const authOnlyRouterLayer = authorizationRouterMiddleware.layer.pipe(Layer.provide(ServerAuth.Config.defaultLayer)) +const httpApiAuthLayer = authorizationLayer.pipe(Layer.provide(ServerAuth.Config.defaultLayer)) +const rootApiRoutes = HttpApiBuilder.layer(RootHttpApi).pipe( + Layer.provide([controlHandlers, globalHandlers]), + Layer.provide(schemaErrorLayer), + Layer.provide(httpApiAuthLayer), +) const instanceRouterLayer = authorizationRouterMiddleware .combine(instanceRouterMiddleware) .combine(workspaceRouterMiddleware) @@ -128,24 +149,39 @@ const instanceApiRoutes = HttpApiBuilder.layer(InstanceHttpApi).pipe( const rawInstanceRoutes = Layer.mergeAll(ptyConnectRoute).pipe(Layer.provide(instanceRouterLayer)) const instanceRoutes = Layer.mergeAll(rawInstanceRoutes, instanceApiRoutes).pipe( Layer.provide([ - authorizationLayer.pipe(Layer.provide(ServerAuth.Config.defaultLayer)), + httpApiAuthLayer, workspaceRoutingLayer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal)), instanceContextLayer, + schemaErrorLayer, ]), ) +// `OpenApi.fromApi` is non-trivial; defer until /doc is actually hit so +// processes that never serve it (CLI, scripts) don't pay at module load. +// `HttpServerResponse.jsonUnsafe` runs JSON.stringify eagerly, so caching +// the response also caches the serialized body — every /doc request reuses +// the same Uint8Array instead of re-stringifying the spec. +const docResponse = lazy(() => HttpServerResponse.jsonUnsafe(OpenApi.fromApi(PublicApi))) + +const docRoute = HttpRouter.use((router) => router.add("GET", "/doc", () => Effect.succeed(docResponse()))).pipe( + Layer.provide(authOnlyRouterLayer), +) + const uiRoute = HttpRouter.use((router) => Effect.gen(function* () { const fs = yield* AppFileSystem.Service const client = yield* HttpClient.HttpClient yield* router.add("*", "/*", (request) => serveUIEffect(request, { fs, client })) }), -).pipe(Layer.provide(authorizationRouterMiddleware.layer.pipe(Layer.provide(ServerAuth.Config.defaultLayer)))) +).pipe(Layer.provide(authOnlyRouterLayer)) export function createRoutes(corsOptions?: CorsOptions) { - return Layer.mergeAll(rootApiRoutes, eventApiRoutes, instanceRoutes, uiRoute).pipe( + return Layer.mergeAll(rootApiRoutes, eventApiRoutes, instanceRoutes, docRoute, uiRoute).pipe( Layer.provide([ errorLayer, + compressionLayer, + corsVaryFix, + fenceLayer, cors(corsOptions), runtime, Account.defaultLayer, diff --git a/packages/opencode/src/server/routes/instance/index.ts b/packages/opencode/src/server/routes/instance/index.ts deleted file mode 100644 index b6bf8baa74..0000000000 --- a/packages/opencode/src/server/routes/instance/index.ts +++ /dev/null @@ -1,502 +0,0 @@ -import { describeRoute, resolver, validator } from "hono-openapi" -import { Hono } from "hono" -import type { UpgradeWebSocket } from "hono/ws" -import { Context, Effect } from "effect" -import { Flag } from "@opencode-ai/core/flag/flag" -import z from "zod" -import { Format } from "@/format" -import { TuiRoutes } from "./tui" -import { Instance } from "@/project/instance" -import { InstanceRuntime } from "@/project/instance-runtime" -import { Vcs } from "@/project/vcs" -import { Agent } from "@/agent/agent" -import { Skill } from "@/skill" -import { Global } from "@opencode-ai/core/global" -import { LSP } from "@/lsp/lsp" -import { Command } from "@/command" -import { QuestionRoutes } from "./question" -import { PermissionRoutes } from "./permission" -import { ProjectRoutes } from "./project" -import { SessionRoutes } from "./session" -import { PtyRoutes } from "./pty" -import { McpRoutes } from "./mcp" -import { FileRoutes } from "./file" -import { ConfigRoutes } from "./config" -import { ExperimentalRoutes } from "./experimental" -import { ProviderRoutes } from "./provider" -import { EventRoutes } from "./event" -import { SyncRoutes } from "./sync" -import { InstanceMiddleware } from "./middleware" -import { jsonRequest, runRequest } from "./trace" -import { ExperimentalHttpApiServer } from "./httpapi/server" -import { EventPaths } from "./httpapi/event" -import { ExperimentalPaths } from "./httpapi/groups/experimental" -import { FilePaths } from "./httpapi/groups/file" -import { InstancePaths } from "./httpapi/groups/instance" -import { McpPaths } from "./httpapi/groups/mcp" -import { PtyPaths } from "./httpapi/groups/pty" -import { SessionPaths } from "./httpapi/groups/session" -import { SyncPaths } from "./httpapi/groups/sync" -import { TuiPaths } from "./httpapi/groups/tui" -import { WorkspacePaths } from "./httpapi/groups/workspace" -import type { CorsOptions } from "@/server/cors" -import { errors } from "@/server/error" - -export const InstanceRoutes = (upgrade: UpgradeWebSocket, opts?: CorsOptions): Hono => { - const app = new Hono() - const handler = ExperimentalHttpApiServer.webHandler(opts).handler - const context = Context.empty() as Context.Context - - app.all("/api/*", (c) => handler(c.req.raw, context)) - - if (Flag.OPENCODE_EXPERIMENTAL_HTTPAPI) { - app.get(EventPaths.event, (c) => handler(c.req.raw, context)) - app.get("/question", (c) => handler(c.req.raw, context)) - app.post("/question/:requestID/reply", (c) => handler(c.req.raw, context)) - app.post("/question/:requestID/reject", (c) => handler(c.req.raw, context)) - app.get("/permission", (c) => handler(c.req.raw, context)) - app.post("/permission/:requestID/reply", (c) => handler(c.req.raw, context)) - app.get("/config", (c) => handler(c.req.raw, context)) - app.patch("/config", (c) => handler(c.req.raw, context)) - app.get("/config/providers", (c) => handler(c.req.raw, context)) - app.get(ExperimentalPaths.console, (c) => handler(c.req.raw, context)) - app.get(ExperimentalPaths.consoleOrgs, (c) => handler(c.req.raw, context)) - app.post(ExperimentalPaths.consoleSwitch, (c) => handler(c.req.raw, context)) - app.get(ExperimentalPaths.tool, (c) => handler(c.req.raw, context)) - app.get(ExperimentalPaths.toolIDs, (c) => handler(c.req.raw, context)) - app.get(ExperimentalPaths.worktree, (c) => handler(c.req.raw, context)) - app.post(ExperimentalPaths.worktree, (c) => handler(c.req.raw, context)) - app.delete(ExperimentalPaths.worktree, (c) => handler(c.req.raw, context)) - app.post(ExperimentalPaths.worktreeReset, (c) => handler(c.req.raw, context)) - app.get(ExperimentalPaths.session, (c) => handler(c.req.raw, context)) - app.get(ExperimentalPaths.resource, (c) => handler(c.req.raw, context)) - app.get("/provider", (c) => handler(c.req.raw, context)) - app.get("/provider/auth", (c) => handler(c.req.raw, context)) - app.post("/provider/:providerID/oauth/authorize", (c) => handler(c.req.raw, context)) - app.post("/provider/:providerID/oauth/callback", (c) => handler(c.req.raw, context)) - app.get("/project", (c) => handler(c.req.raw, context)) - app.get("/project/current", (c) => handler(c.req.raw, context)) - app.post("/project/git/init", (c) => handler(c.req.raw, context)) - app.patch("/project/:projectID", (c) => handler(c.req.raw, context)) - app.get(FilePaths.findText, (c) => handler(c.req.raw, context)) - app.get(FilePaths.findFile, (c) => handler(c.req.raw, context)) - app.get(FilePaths.findSymbol, (c) => handler(c.req.raw, context)) - app.get(FilePaths.list, (c) => handler(c.req.raw, context)) - app.get(FilePaths.content, (c) => handler(c.req.raw, context)) - app.get(FilePaths.status, (c) => handler(c.req.raw, context)) - app.get(InstancePaths.path, (c) => handler(c.req.raw, context)) - app.post(InstancePaths.dispose, (c) => handler(c.req.raw, context)) - app.get(InstancePaths.vcs, (c) => handler(c.req.raw, context)) - app.get(InstancePaths.vcsStatus, (c) => handler(c.req.raw, context)) - app.get(InstancePaths.vcsDiff, (c) => handler(c.req.raw, context)) - app.get(InstancePaths.vcsDiffRaw, (c) => handler(c.req.raw, context)) - app.post(InstancePaths.vcsApply, (c) => handler(c.req.raw, context)) - app.get(InstancePaths.command, (c) => handler(c.req.raw, context)) - app.get(InstancePaths.agent, (c) => handler(c.req.raw, context)) - app.get(InstancePaths.skill, (c) => handler(c.req.raw, context)) - app.get(InstancePaths.lsp, (c) => handler(c.req.raw, context)) - app.get(InstancePaths.formatter, (c) => handler(c.req.raw, context)) - app.get(McpPaths.status, (c) => handler(c.req.raw, context)) - app.post(McpPaths.status, (c) => handler(c.req.raw, context)) - app.post(McpPaths.auth, (c) => handler(c.req.raw, context)) - app.post(McpPaths.authCallback, (c) => handler(c.req.raw, context)) - app.post(McpPaths.authAuthenticate, (c) => handler(c.req.raw, context)) - app.delete(McpPaths.auth, (c) => handler(c.req.raw, context)) - app.post(McpPaths.connect, (c) => handler(c.req.raw, context)) - app.post(McpPaths.disconnect, (c) => handler(c.req.raw, context)) - app.post(SyncPaths.start, (c) => handler(c.req.raw, context)) - app.post(SyncPaths.replay, (c) => handler(c.req.raw, context)) - app.post(SyncPaths.history, (c) => handler(c.req.raw, context)) - app.get(PtyPaths.list, (c) => handler(c.req.raw, context)) - app.post(PtyPaths.create, (c) => handler(c.req.raw, context)) - app.get(PtyPaths.get, (c) => handler(c.req.raw, context)) - app.put(PtyPaths.update, (c) => handler(c.req.raw, context)) - app.delete(PtyPaths.remove, (c) => handler(c.req.raw, context)) - app.post(PtyPaths.connectToken, (c) => handler(c.req.raw, context)) - app.get(PtyPaths.connect, (c) => handler(c.req.raw, context)) - app.get(SessionPaths.list, (c) => handler(c.req.raw, context)) - app.get(SessionPaths.status, (c) => handler(c.req.raw, context)) - app.get(SessionPaths.get, (c) => handler(c.req.raw, context)) - app.get(SessionPaths.children, (c) => handler(c.req.raw, context)) - app.get(SessionPaths.todo, (c) => handler(c.req.raw, context)) - app.get(SessionPaths.diff, (c) => handler(c.req.raw, context)) - app.get(SessionPaths.messages, (c) => handler(c.req.raw, context)) - app.get(SessionPaths.message, (c) => handler(c.req.raw, context)) - app.post(SessionPaths.create, (c) => handler(c.req.raw, context)) - app.delete(SessionPaths.remove, (c) => handler(c.req.raw, context)) - app.patch(SessionPaths.update, (c) => handler(c.req.raw, context)) - app.post(SessionPaths.init, (c) => handler(c.req.raw, context)) - app.post(SessionPaths.fork, (c) => handler(c.req.raw, context)) - app.post(SessionPaths.abort, (c) => handler(c.req.raw, context)) - app.post(SessionPaths.share, (c) => handler(c.req.raw, context)) - app.delete(SessionPaths.share, (c) => handler(c.req.raw, context)) - app.post(SessionPaths.summarize, (c) => handler(c.req.raw, context)) - app.post(SessionPaths.prompt, (c) => handler(c.req.raw, context)) - app.post(SessionPaths.promptAsync, (c) => handler(c.req.raw, context)) - app.post(SessionPaths.command, (c) => handler(c.req.raw, context)) - app.post(SessionPaths.shell, (c) => handler(c.req.raw, context)) - app.post(SessionPaths.revert, (c) => handler(c.req.raw, context)) - app.post(SessionPaths.unrevert, (c) => handler(c.req.raw, context)) - app.post(SessionPaths.permissions, (c) => handler(c.req.raw, context)) - app.delete(SessionPaths.deleteMessage, (c) => handler(c.req.raw, context)) - app.delete(SessionPaths.deletePart, (c) => handler(c.req.raw, context)) - app.patch(SessionPaths.updatePart, (c) => handler(c.req.raw, context)) - app.post(TuiPaths.appendPrompt, (c) => handler(c.req.raw, context)) - app.post(TuiPaths.openHelp, (c) => handler(c.req.raw, context)) - app.post(TuiPaths.openSessions, (c) => handler(c.req.raw, context)) - app.post(TuiPaths.openThemes, (c) => handler(c.req.raw, context)) - app.post(TuiPaths.openModels, (c) => handler(c.req.raw, context)) - app.post(TuiPaths.submitPrompt, (c) => handler(c.req.raw, context)) - app.post(TuiPaths.clearPrompt, (c) => handler(c.req.raw, context)) - app.post(TuiPaths.executeCommand, (c) => handler(c.req.raw, context)) - app.post(TuiPaths.showToast, (c) => handler(c.req.raw, context)) - app.post(TuiPaths.publish, (c) => handler(c.req.raw, context)) - app.post(TuiPaths.selectSession, (c) => handler(c.req.raw, context)) - app.get(TuiPaths.controlNext, (c) => handler(c.req.raw, context)) - app.post(TuiPaths.controlResponse, (c) => handler(c.req.raw, context)) - app.get(WorkspacePaths.adapters, (c) => handler(c.req.raw, context)) - app.post(WorkspacePaths.list, (c) => handler(c.req.raw, context)) - app.get(WorkspacePaths.list, (c) => handler(c.req.raw, context)) - app.get(WorkspacePaths.status, (c) => handler(c.req.raw, context)) - app.delete(WorkspacePaths.remove, (c) => handler(c.req.raw, context)) - app.post(WorkspacePaths.warp, (c) => handler(c.req.raw, context)) - } - - return app - .route("/project", ProjectRoutes()) - .route("/pty", PtyRoutes(upgrade, opts)) - .route("/config", ConfigRoutes()) - .route("/experimental", ExperimentalRoutes()) - .route("/session", SessionRoutes()) - .route("/permission", PermissionRoutes()) - .route("/question", QuestionRoutes()) - .route("/provider", ProviderRoutes()) - .route("/sync", SyncRoutes()) - .route("/", FileRoutes()) - .route("/", EventRoutes()) - .route("/mcp", McpRoutes()) - .route("/tui", TuiRoutes()) - .post( - "/instance/dispose", - describeRoute({ - summary: "Dispose instance", - description: "Clean up and dispose the current OpenCode instance, releasing all resources.", - operationId: "instance.dispose", - responses: { - 200: { - description: "Instance disposed", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - }, - }), - async (c) => { - await InstanceRuntime.disposeInstance(Instance.current) - return c.json(true) - }, - ) - .get( - "/path", - describeRoute({ - summary: "Get paths", - description: "Retrieve the current working directory and related path information for the OpenCode instance.", - operationId: "path.get", - responses: { - 200: { - description: "Path", - content: { - "application/json": { - schema: resolver( - z - .object({ - home: z.string(), - state: z.string(), - config: z.string(), - worktree: z.string(), - directory: z.string(), - }) - .meta({ - ref: "Path", - }), - ), - }, - }, - }, - }, - }), - async (c) => { - return c.json({ - home: Global.Path.home, - state: Global.Path.state, - config: Global.Path.config, - worktree: Instance.worktree, - directory: Instance.directory, - }) - }, - ) - .get( - "/vcs", - describeRoute({ - summary: "Get VCS info", - description: "Retrieve version control system (VCS) information for the current project, such as git branch.", - operationId: "vcs.get", - responses: { - 200: { - description: "VCS info", - content: { - "application/json": { - schema: resolver(Vcs.Info.zod), - }, - }, - }, - }, - }), - async (c) => - jsonRequest("InstanceRoutes.vcs.get", c, function* () { - const vcs = yield* Vcs.Service - const [branch, default_branch] = yield* Effect.all([vcs.branch(), vcs.defaultBranch()], { - concurrency: 2, - }) - return { branch, default_branch } - }), - ) - .get( - "/vcs/diff", - describeRoute({ - summary: "Get VCS diff", - description: "Retrieve the current git diff for the working tree or against the default branch.", - operationId: "vcs.diff", - responses: { - 200: { - description: "VCS diff", - content: { - "application/json": { - schema: resolver(Vcs.FileDiff.zod.array()), - }, - }, - }, - }, - }), - validator( - "query", - z.object({ - mode: Vcs.Mode.zod, - }), - ), - async (c) => - jsonRequest("InstanceRoutes.vcs.diff", c, function* () { - const vcs = yield* Vcs.Service - return yield* vcs.diff(c.req.valid("query").mode) - }), - ) - .get( - "/vcs/status", - describeRoute({ - summary: "Get VCS status", - description: "Retrieve changed files in the current working tree without patches.", - operationId: "vcs.status", - responses: { - 200: { - description: "VCS status", - content: { - "application/json": { - schema: resolver(Vcs.FileStatus.zod.array()), - }, - }, - }, - }, - }), - async (c) => - jsonRequest("InstanceRoutes.vcs.status", c, function* () { - const vcs = yield* Vcs.Service - return yield* vcs.status() - }), - ) - .get( - "/vcs/diff/raw", - describeRoute({ - summary: "Get raw VCS diff", - description: "Retrieve a raw patch for current uncommitted changes.", - operationId: "vcs.diff.raw", - responses: { - 200: { - description: "Raw VCS diff", - content: { - "text/x-diff": { - schema: resolver(z.string()), - }, - }, - }, - }, - }), - async (c) => { - const patch = await runRequest( - "InstanceRoutes.vcs.diffRaw", - c, - Vcs.Service.use((vcs) => vcs.diffRaw()), - ) - return c.text(patch, 200, { "content-type": "text/x-diff; charset=utf-8" }) - }, - ) - .post( - "/vcs/apply", - describeRoute({ - summary: "Apply VCS patch", - description: "Apply a raw patch to the current working tree.", - operationId: "vcs.apply", - responses: { - 200: { - description: "VCS patch applied", - content: { - "application/json": { - schema: resolver(Vcs.ApplyResult.zod), - }, - }, - }, - ...errors(400), - }, - }), - validator("json", Vcs.ApplyInput.zodObject), - async (c) => { - const result = await runRequest( - "InstanceRoutes.vcs.apply", - c, - Vcs.Service.use((vcs) => vcs.apply(c.req.valid("json") as Vcs.ApplyInput)).pipe( - Effect.match({ - onFailure: (error) => ({ ok: false as const, error }), - onSuccess: (value) => ({ ok: true as const, value }), - }), - ), - ) - if (result.ok) return c.json(result.value) - return c.json( - { - name: "VcsApplyError", - data: { - message: result.error.message, - reason: result.error.reason, - }, - }, - 400, - ) - }, - ) - .get( - "/command", - describeRoute({ - summary: "List commands", - description: "Get a list of all available commands in the OpenCode system.", - operationId: "command.list", - responses: { - 200: { - description: "List of commands", - content: { - "application/json": { - schema: resolver(Command.Info.zod.array()), - }, - }, - }, - }, - }), - async (c) => - jsonRequest("InstanceRoutes.command.list", c, function* () { - const svc = yield* Command.Service - return yield* svc.list() - }), - ) - .get( - "/agent", - describeRoute({ - summary: "List agents", - description: "Get a list of all available AI agents in the OpenCode system.", - operationId: "app.agents", - responses: { - 200: { - description: "List of agents", - content: { - "application/json": { - schema: resolver(Agent.Info.zod.array()), - }, - }, - }, - }, - }), - async (c) => - jsonRequest("InstanceRoutes.agent.list", c, function* () { - const svc = yield* Agent.Service - return yield* svc.list() - }), - ) - .get( - "/skill", - describeRoute({ - summary: "List skills", - description: "Get a list of all available skills in the OpenCode system.", - operationId: "app.skills", - responses: { - 200: { - description: "List of skills", - content: { - "application/json": { - schema: resolver(Skill.Info.zod.array()), - }, - }, - }, - }, - }), - async (c) => - jsonRequest("InstanceRoutes.skill.list", c, function* () { - const skill = yield* Skill.Service - return yield* skill.all() - }), - ) - .get( - "/lsp", - describeRoute({ - summary: "Get LSP status", - description: "Get LSP server status", - operationId: "lsp.status", - responses: { - 200: { - description: "LSP server status", - content: { - "application/json": { - schema: resolver(LSP.Status.zod.array()), - }, - }, - }, - }, - }), - async (c) => - jsonRequest("InstanceRoutes.lsp.status", c, function* () { - const lsp = yield* LSP.Service - return yield* lsp.status() - }), - ) - .get( - "/formatter", - describeRoute({ - summary: "Get formatter status", - description: "Get formatter status", - operationId: "formatter.status", - responses: { - 200: { - description: "Formatter status", - content: { - "application/json": { - schema: resolver(Format.Status.zod.array()), - }, - }, - }, - }, - }), - async (c) => - jsonRequest("InstanceRoutes.formatter.status", c, function* () { - const svc = yield* Format.Service - return yield* svc.status() - }), - ) -} diff --git a/packages/opencode/src/server/routes/instance/mcp.ts b/packages/opencode/src/server/routes/instance/mcp.ts deleted file mode 100644 index d5542f042b..0000000000 --- a/packages/opencode/src/server/routes/instance/mcp.ts +++ /dev/null @@ -1,277 +0,0 @@ -import { Hono } from "hono" -import { describeRoute, validator, resolver } from "hono-openapi" -import z from "zod" -import { MCP } from "@/mcp" -import { ConfigMCP } from "@/config/mcp" -import { errors } from "../../error" -import { lazy } from "@/util/lazy" -import { Effect } from "effect" -import { jsonRequest, runRequest } from "./trace" - -const UnsupportedOAuthError = z - .object({ - error: z.string(), - }) - .meta({ ref: "McpUnsupportedOAuthError" }) - -const unsupportedOAuthErrorResponse = { - description: "MCP server does not support OAuth", - content: { - "application/json": { - schema: resolver(UnsupportedOAuthError), - }, - }, -} - -export const McpRoutes = lazy(() => - new Hono() - .get( - "/", - describeRoute({ - summary: "Get MCP status", - description: "Get the status of all Model Context Protocol (MCP) servers.", - operationId: "mcp.status", - responses: { - 200: { - description: "MCP server status", - content: { - "application/json": { - schema: resolver(z.record(z.string(), MCP.Status.zod)), - }, - }, - }, - }, - }), - async (c) => - jsonRequest("McpRoutes.status", c, function* () { - const mcp = yield* MCP.Service - return yield* mcp.status() - }), - ) - .post( - "/", - describeRoute({ - summary: "Add MCP server", - description: "Dynamically add a new Model Context Protocol (MCP) server to the system.", - operationId: "mcp.add", - responses: { - 200: { - description: "MCP server added successfully", - content: { - "application/json": { - schema: resolver(z.record(z.string(), MCP.Status.zod)), - }, - }, - }, - ...errors(400), - }, - }), - validator( - "json", - z.object({ - name: z.string(), - config: ConfigMCP.Info.zod, - }), - ), - async (c) => - jsonRequest("McpRoutes.add", c, function* () { - const { name, config } = c.req.valid("json") - const mcp = yield* MCP.Service - const result = yield* mcp.add(name, config) - return result.status - }), - ) - .post( - "/:name/auth", - describeRoute({ - summary: "Start MCP OAuth", - description: "Start OAuth authentication flow for a Model Context Protocol (MCP) server.", - operationId: "mcp.auth.start", - responses: { - 200: { - description: "OAuth flow started", - content: { - "application/json": { - schema: resolver( - z.object({ - authorizationUrl: z.string().describe("URL to open in browser for authorization"), - }), - ), - }, - }, - }, - 400: unsupportedOAuthErrorResponse, - ...errors(404), - }, - }), - async (c) => { - const name = c.req.param("name") - const result = await runRequest( - "McpRoutes.auth.start", - c, - Effect.gen(function* () { - const mcp = yield* MCP.Service - const supports = yield* mcp.supportsOAuth(name) - if (!supports) return { supports } - return { - supports, - auth: yield* mcp.startAuth(name), - } - }), - ) - if (!result.supports) { - return c.json({ error: `MCP server ${name} does not support OAuth` }, 400) - } - return c.json(result.auth) - }, - ) - .post( - "/:name/auth/callback", - describeRoute({ - summary: "Complete MCP OAuth", - description: - "Complete OAuth authentication for a Model Context Protocol (MCP) server using the authorization code.", - operationId: "mcp.auth.callback", - responses: { - 200: { - description: "OAuth authentication completed", - content: { - "application/json": { - schema: resolver(MCP.Status.zod), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator( - "json", - z.object({ - code: z.string().describe("Authorization code from OAuth callback"), - }), - ), - async (c) => - jsonRequest("McpRoutes.auth.callback", c, function* () { - const name = c.req.param("name") - const { code } = c.req.valid("json") - const mcp = yield* MCP.Service - return yield* mcp.finishAuth(name, code) - }), - ) - .post( - "/:name/auth/authenticate", - describeRoute({ - summary: "Authenticate MCP OAuth", - description: "Start OAuth flow and wait for callback (opens browser)", - operationId: "mcp.auth.authenticate", - responses: { - 200: { - description: "OAuth authentication completed", - content: { - "application/json": { - schema: resolver(MCP.Status.zod), - }, - }, - }, - 400: unsupportedOAuthErrorResponse, - ...errors(404), - }, - }), - async (c) => { - const name = c.req.param("name") - const result = await runRequest( - "McpRoutes.auth.authenticate", - c, - Effect.gen(function* () { - const mcp = yield* MCP.Service - const supports = yield* mcp.supportsOAuth(name) - if (!supports) return { supports } - return { - supports, - status: yield* mcp.authenticate(name), - } - }), - ) - if (!result.supports) { - return c.json({ error: `MCP server ${name} does not support OAuth` }, 400) - } - return c.json(result.status) - }, - ) - .delete( - "/:name/auth", - describeRoute({ - summary: "Remove MCP OAuth", - description: "Remove OAuth credentials for an MCP server", - operationId: "mcp.auth.remove", - responses: { - 200: { - description: "OAuth credentials removed", - content: { - "application/json": { - schema: resolver(z.object({ success: z.literal(true) })), - }, - }, - }, - ...errors(404), - }, - }), - async (c) => - jsonRequest("McpRoutes.auth.remove", c, function* () { - const name = c.req.param("name") - const mcp = yield* MCP.Service - yield* mcp.removeAuth(name) - return { success: true as const } - }), - ) - .post( - "/:name/connect", - describeRoute({ - description: "Connect an MCP server", - operationId: "mcp.connect", - responses: { - 200: { - description: "MCP server connected successfully", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - }, - }), - validator("param", z.object({ name: z.string() })), - async (c) => - jsonRequest("McpRoutes.connect", c, function* () { - const { name } = c.req.valid("param") - const mcp = yield* MCP.Service - yield* mcp.connect(name) - return true - }), - ) - .post( - "/:name/disconnect", - describeRoute({ - description: "Disconnect an MCP server", - operationId: "mcp.disconnect", - responses: { - 200: { - description: "MCP server disconnected successfully", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - }, - }), - validator("param", z.object({ name: z.string() })), - async (c) => - jsonRequest("McpRoutes.disconnect", c, function* () { - const { name } = c.req.valid("param") - const mcp = yield* MCP.Service - yield* mcp.disconnect(name) - return true - }), - ), -) diff --git a/packages/opencode/src/server/routes/instance/middleware.ts b/packages/opencode/src/server/routes/instance/middleware.ts deleted file mode 100644 index 23707faf79..0000000000 --- a/packages/opencode/src/server/routes/instance/middleware.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { MiddlewareHandler } from "hono" -import { WithInstance } from "@/project/with-instance" -import { AppFileSystem } from "@opencode-ai/core/filesystem" -import { WorkspaceContext } from "@/control-plane/workspace-context" -import { WorkspaceID } from "@/control-plane/schema" - -export function InstanceMiddleware(workspaceID?: WorkspaceID): MiddlewareHandler { - return async (c, next) => { - const raw = c.req.query("directory") || c.req.header("x-opencode-directory") || process.cwd() - const directory = AppFileSystem.resolve( - (() => { - try { - return decodeURIComponent(raw) - } catch { - return raw - } - })(), - ) - - return WorkspaceContext.provide({ - workspaceID, - async fn() { - return WithInstance.provide({ - directory, - async fn() { - return next() - }, - }) - }, - }) - } -} diff --git a/packages/opencode/src/server/routes/instance/permission.ts b/packages/opencode/src/server/routes/instance/permission.ts deleted file mode 100644 index c18f4734b4..0000000000 --- a/packages/opencode/src/server/routes/instance/permission.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { Hono } from "hono" -import { describeRoute, validator, resolver } from "hono-openapi" -import z from "zod" -import { Permission } from "@/permission" -import { PermissionID } from "@/permission/schema" -import { errors } from "../../error" -import { lazy } from "@/util/lazy" -import { jsonRequest } from "./trace" - -export const PermissionRoutes = lazy(() => - new Hono() - .post( - "/:requestID/reply", - describeRoute({ - summary: "Respond to permission request", - description: "Approve or deny a permission request from the AI assistant.", - operationId: "permission.reply", - responses: { - 200: { - description: "Permission processed successfully", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - requestID: PermissionID.zod, - }), - ), - validator("json", z.object({ reply: Permission.Reply.zod, message: z.string().optional() })), - async (c) => - jsonRequest("PermissionRoutes.reply", c, function* () { - const params = c.req.valid("param") - const json = c.req.valid("json") - const svc = yield* Permission.Service - yield* svc.reply({ - requestID: params.requestID, - reply: json.reply, - message: json.message, - }) - return true - }), - ) - .get( - "/", - describeRoute({ - summary: "List pending permissions", - description: "Get all pending permission requests across all sessions.", - operationId: "permission.list", - responses: { - 200: { - description: "List of pending permissions", - content: { - "application/json": { - schema: resolver(Permission.Request.zod.array()), - }, - }, - }, - }, - }), - async (c) => - jsonRequest("PermissionRoutes.list", c, function* () { - const svc = yield* Permission.Service - return yield* svc.list() - }), - ), -) diff --git a/packages/opencode/src/server/routes/instance/project.ts b/packages/opencode/src/server/routes/instance/project.ts deleted file mode 100644 index 3d8bb605bd..0000000000 --- a/packages/opencode/src/server/routes/instance/project.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { Hono } from "hono" -import { describeRoute, validator } from "hono-openapi" -import { resolver } from "hono-openapi" -import { Instance } from "@/project/instance" -import { InstanceRuntime } from "@/project/instance-runtime" -import { Project } from "@/project/project" -import z from "zod" -import { ProjectID } from "@/project/schema" -import { errors } from "../../error" -import { lazy } from "@/util/lazy" -import { jsonRequest, runRequest } from "./trace" - -export const ProjectRoutes = lazy(() => - new Hono() - .get( - "/", - describeRoute({ - summary: "List all projects", - description: "Get a list of projects that have been opened with OpenCode.", - operationId: "project.list", - responses: { - 200: { - description: "List of projects", - content: { - "application/json": { - schema: resolver(Project.Info.zod.array()), - }, - }, - }, - }, - }), - async (c) => { - const projects = Project.list() - return c.json(projects) - }, - ) - .get( - "/current", - describeRoute({ - summary: "Get current project", - description: "Retrieve the currently active project that OpenCode is working with.", - operationId: "project.current", - responses: { - 200: { - description: "Current project information", - content: { - "application/json": { - schema: resolver(Project.Info.zod), - }, - }, - }, - }, - }), - async (c) => { - return c.json(Instance.project) - }, - ) - .post( - "/git/init", - describeRoute({ - summary: "Initialize git repository", - description: "Create a git repository for the current project and return the refreshed project info.", - operationId: "project.initGit", - responses: { - 200: { - description: "Project information after git initialization", - content: { - "application/json": { - schema: resolver(Project.Info.zod), - }, - }, - }, - }, - }), - async (c) => { - const dir = Instance.directory - const prev = Instance.project - const next = await runRequest( - "ProjectRoutes.initGit", - c, - Project.Service.use((svc) => svc.initGit({ directory: dir, project: prev })), - ) - if (next.id === prev.id && next.vcs === prev.vcs && next.worktree === prev.worktree) return c.json(next) - await InstanceRuntime.reloadInstance({ directory: dir, worktree: dir, project: next }) - return c.json(next) - }, - ) - .patch( - "/:projectID", - describeRoute({ - summary: "Update project", - description: "Update project properties such as name, icon, and commands.", - operationId: "project.update", - responses: { - 200: { - description: "Updated project information", - content: { - "application/json": { - schema: resolver(Project.Info.zod), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator("param", z.object({ projectID: ProjectID.zod })), - validator("json", Project.UpdateInput.omit({ projectID: true })), - async (c) => - jsonRequest("ProjectRoutes.update", c, function* () { - const projectID = c.req.valid("param").projectID - const body = c.req.valid("json") - const svc = yield* Project.Service - return yield* svc.update({ ...body, projectID }) - }), - ), -) diff --git a/packages/opencode/src/server/routes/instance/provider.ts b/packages/opencode/src/server/routes/instance/provider.ts deleted file mode 100644 index 8ff7bc3103..0000000000 --- a/packages/opencode/src/server/routes/instance/provider.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { Hono } from "hono" -import { describeRoute, validator, resolver } from "hono-openapi" -import z from "zod" -import { Config } from "@/config/config" -import { Provider } from "@/provider/provider" -import { ModelsDev } from "@/provider/models" -import { ProviderAuth } from "@/provider/auth" -import { ProviderID } from "@/provider/schema" -import { mapValues } from "remeda" -import { errors } from "../../error" -import { lazy } from "@/util/lazy" -import { Effect } from "effect" -import { jsonRequest } from "./trace" - -export const ProviderRoutes = lazy(() => - new Hono() - .get( - "/", - describeRoute({ - summary: "List providers", - description: "Get a list of all available AI providers, including both available and connected ones.", - operationId: "provider.list", - responses: { - 200: { - description: "List of providers", - content: { - "application/json": { - schema: resolver(Provider.ListResult.zod), - }, - }, - }, - }, - }), - async (c) => - jsonRequest("ProviderRoutes.list", c, function* () { - const svc = yield* Provider.Service - const cfg = yield* Config.Service - const config = yield* cfg.get() - const all = yield* ModelsDev.Service.use((s) => s.get()) - const disabled = new Set(config.disabled_providers ?? []) - const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined - const filtered: Record = {} - for (const [key, value] of Object.entries(all)) { - if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) { - filtered[key] = value - } - } - const connected = yield* svc.list() - const providers = Object.assign( - mapValues(filtered, (x) => Provider.fromModelsDevProvider(x)), - connected, - ) - return { - all: Object.values(providers), - default: Provider.defaultModelIDs(providers), - connected: Object.keys(connected), - } - }), - ) - .get( - "/auth", - describeRoute({ - summary: "Get provider auth methods", - description: "Retrieve available authentication methods for all AI providers.", - operationId: "provider.auth", - responses: { - 200: { - description: "Provider auth methods", - content: { - "application/json": { - schema: resolver(ProviderAuth.Methods.zod), - }, - }, - }, - }, - }), - async (c) => - jsonRequest("ProviderRoutes.auth", c, function* () { - const svc = yield* ProviderAuth.Service - return yield* svc.methods() - }), - ) - .post( - "/:providerID/oauth/authorize", - describeRoute({ - summary: "OAuth authorize", - description: "Initiate OAuth authorization for a specific AI provider to get an authorization URL.", - operationId: "provider.oauth.authorize", - responses: { - 200: { - description: "Authorization URL and method", - content: { - "application/json": { - schema: resolver(ProviderAuth.Authorization.zod.optional()), - }, - }, - }, - ...errors(400), - }, - }), - validator( - "param", - z.object({ - providerID: ProviderID.zod.meta({ description: "Provider ID" }), - }), - ), - validator("json", ProviderAuth.AuthorizeInput.zod), - async (c) => - jsonRequest("ProviderRoutes.oauth.authorize", c, function* () { - const providerID = c.req.valid("param").providerID - const { method, inputs } = c.req.valid("json") - const svc = yield* ProviderAuth.Service - return yield* svc.authorize({ - providerID, - method, - inputs, - }) - }), - ) - .post( - "/:providerID/oauth/callback", - describeRoute({ - summary: "OAuth callback", - description: "Handle the OAuth callback from a provider after user authorization.", - operationId: "provider.oauth.callback", - responses: { - 200: { - description: "OAuth callback processed successfully", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - ...errors(400), - }, - }), - validator( - "param", - z.object({ - providerID: ProviderID.zod.meta({ description: "Provider ID" }), - }), - ), - validator("json", ProviderAuth.CallbackInput.zod), - async (c) => - jsonRequest("ProviderRoutes.oauth.callback", c, function* () { - const providerID = c.req.valid("param").providerID - const { method, code } = c.req.valid("json") - const svc = yield* ProviderAuth.Service - yield* svc.callback({ - providerID, - method, - code, - }) - return true - }), - ), -) diff --git a/packages/opencode/src/server/routes/instance/pty.ts b/packages/opencode/src/server/routes/instance/pty.ts deleted file mode 100644 index fb8d5e356d..0000000000 --- a/packages/opencode/src/server/routes/instance/pty.ts +++ /dev/null @@ -1,340 +0,0 @@ -import { Hono } from "hono" -import type { Context } from "hono" -import { describeRoute, validator, resolver } from "hono-openapi" -import type { UpgradeWebSocket } from "hono/ws" -import { Effect, Schema } from "effect" -import z from "zod" -import { AppRuntime } from "@/effect/app-runtime" -import { Pty } from "@/pty" -import { PtyID } from "@/pty/schema" -import { PtyTicket } from "@/pty/ticket" -import { Shell } from "@/shell/shell" -import { NotFoundError } from "@/storage/storage" -import { errors } from "../../error" -import { jsonRequest, runRequest } from "./trace" -import { HTTPException } from "hono/http-exception" -import { isAllowedRequestOrigin, type CorsOptions } from "@/server/cors" -import { - PTY_CONNECT_TICKET_QUERY, - PTY_CONNECT_TOKEN_HEADER, - PTY_CONNECT_TOKEN_HEADER_VALUE, -} from "@/server/shared/pty-ticket" -import { zod as effectZod } from "@/util/effect-zod" - -const ShellItem = z.object({ - path: z.string(), - name: z.string(), - acceptable: z.boolean(), -}) -const decodePtyID = Schema.decodeUnknownSync(PtyID) - -function validOrigin(c: Context, opts?: CorsOptions) { - return isAllowedRequestOrigin(c.req.header("origin"), c.req.header("host"), opts) -} - -export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket, opts?: CorsOptions) { - return new Hono() - .get( - "/shells", - describeRoute({ - summary: "List available shells", - description: "Get a list of available shells on the system.", - operationId: "pty.shells", - responses: { - 200: { - description: "List of shells", - content: { - "application/json": { - schema: resolver(z.array(ShellItem)), - }, - }, - }, - }, - }), - async (c) => { - return c.json(await Shell.list()) - }, - ) - .get( - "/", - describeRoute({ - summary: "List PTY sessions", - description: "Get a list of all active pseudo-terminal (PTY) sessions managed by OpenCode.", - operationId: "pty.list", - responses: { - 200: { - description: "List of sessions", - content: { - "application/json": { - schema: resolver(Pty.Info.zod.array()), - }, - }, - }, - }, - }), - async (c) => - jsonRequest("PtyRoutes.list", c, function* () { - const pty = yield* Pty.Service - return yield* pty.list() - }), - ) - .post( - "/", - describeRoute({ - summary: "Create PTY session", - description: "Create a new pseudo-terminal (PTY) session for running shell commands and processes.", - operationId: "pty.create", - responses: { - 200: { - description: "Created session", - content: { - "application/json": { - schema: resolver(Pty.Info.zod), - }, - }, - }, - ...errors(400), - }, - }), - validator("json", Pty.CreateInput.zod), - async (c) => - jsonRequest("PtyRoutes.create", c, function* () { - const pty = yield* Pty.Service - return yield* pty.create(c.req.valid("json") as Pty.CreateInput) - }), - ) - .get( - "/:ptyID", - describeRoute({ - summary: "Get PTY session", - description: "Retrieve detailed information about a specific pseudo-terminal (PTY) session.", - operationId: "pty.get", - responses: { - 200: { - description: "Session info", - content: { - "application/json": { - schema: resolver(Pty.Info.zod), - }, - }, - }, - ...errors(404), - }, - }), - validator("param", z.object({ ptyID: PtyID.zod })), - async (c) => { - const info = await runRequest( - "PtyRoutes.get", - c, - Effect.gen(function* () { - const pty = yield* Pty.Service - return yield* pty.get(c.req.valid("param").ptyID) - }), - ) - if (!info) { - throw new NotFoundError({ message: "Session not found" }) - } - return c.json(info) - }, - ) - .put( - "/:ptyID", - describeRoute({ - summary: "Update PTY session", - description: "Update properties of an existing pseudo-terminal (PTY) session.", - operationId: "pty.update", - responses: { - 200: { - description: "Updated session", - content: { - "application/json": { - schema: resolver(Pty.Info.zod), - }, - }, - }, - ...errors(400), - }, - }), - validator("param", z.object({ ptyID: PtyID.zod })), - validator("json", Pty.UpdateInput.zod), - async (c) => - jsonRequest("PtyRoutes.update", c, function* () { - const pty = yield* Pty.Service - return yield* pty.update(c.req.valid("param").ptyID, c.req.valid("json") as Pty.UpdateInput) - }), - ) - .delete( - "/:ptyID", - describeRoute({ - summary: "Remove PTY session", - description: "Remove and terminate a specific pseudo-terminal (PTY) session.", - operationId: "pty.remove", - responses: { - 200: { - description: "Session removed", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - ...errors(404), - }, - }), - validator("param", z.object({ ptyID: PtyID.zod })), - async (c) => - jsonRequest("PtyRoutes.remove", c, function* () { - const pty = yield* Pty.Service - yield* pty.remove(c.req.valid("param").ptyID) - return true - }), - ) - .post( - "/:ptyID/connect-token", - describeRoute({ - summary: "Create PTY WebSocket token", - description: "Create a short-lived token for opening a PTY WebSocket connection.", - operationId: "pty.connectToken", - responses: { - 200: { - description: "WebSocket connect token", - content: { - "application/json": { - schema: resolver(effectZod(PtyTicket.ConnectToken)), - }, - }, - }, - ...errors(403, 404), - }, - }), - validator("param", z.object({ ptyID: PtyID.zod })), - async (c) => { - if (c.req.header(PTY_CONNECT_TOKEN_HEADER) !== PTY_CONNECT_TOKEN_HEADER_VALUE || !validOrigin(c, opts)) - throw new HTTPException(403) - const result = await runRequest( - "PtyRoutes.connectToken", - c, - Effect.gen(function* () { - const pty = yield* Pty.Service - const id = c.req.valid("param").ptyID - if (!(yield* pty.get(id))) return - const tickets = yield* PtyTicket.Service - return yield* tickets.issue({ ptyID: id, ...(yield* PtyTicket.scope) }) - }), - ) - if (!result) throw new NotFoundError({ message: "Session not found" }) - return c.json(result) - }, - ) - .get( - "/:ptyID/connect", - describeRoute({ - summary: "Connect to PTY session", - description: "Establish a WebSocket connection to interact with a pseudo-terminal (PTY) session in real-time.", - operationId: "pty.connect", - responses: { - 200: { - description: "Connected session", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - ...errors(403, 404), - }, - }), - validator("param", z.object({ ptyID: PtyID.zod })), - upgradeWebSocket(async (c) => { - type Handler = { - onMessage: (message: string | ArrayBuffer) => void - onClose: () => void - } - - const id = decodePtyID(c.req.param("ptyID")) - if ( - !(await runRequest( - "PtyRoutes.connect", - c, - Effect.gen(function* () { - const pty = yield* Pty.Service - return yield* pty.get(id) - }), - )) - ) { - throw new NotFoundError({ message: "Session not found" }) - } - const ticket = c.req.query(PTY_CONNECT_TICKET_QUERY) - if (ticket) { - if (!validOrigin(c, opts)) throw new HTTPException(403) - const valid = await runRequest( - "PtyRoutes.connect.ticket", - c, - Effect.gen(function* () { - const tickets = yield* PtyTicket.Service - return yield* tickets.consume({ ticket, ptyID: id, ...(yield* PtyTicket.scope) }) - }), - ) - if (!valid) throw new HTTPException(403) - } - const cursor = (() => { - const value = c.req.query("cursor") - if (!value) return - const parsed = Number(value) - if (!Number.isSafeInteger(parsed) || parsed < -1) return - return parsed - })() - let handler: Handler | undefined - - type Socket = { - readyState: number - send: (data: string | Uint8Array | ArrayBuffer) => void - close: (code?: number, reason?: string) => void - } - - const isSocket = (value: unknown): value is Socket => { - if (!value || typeof value !== "object") return false - if (!("readyState" in value)) return false - if (!("send" in value) || typeof (value as { send?: unknown }).send !== "function") return false - if (!("close" in value) || typeof (value as { close?: unknown }).close !== "function") return false - return typeof (value as { readyState?: unknown }).readyState === "number" - } - - const pending: string[] = [] - let ready = false - - return { - async onOpen(_event, ws) { - const socket = ws.raw - if (!isSocket(socket)) { - ws.close() - return - } - handler = await AppRuntime.runPromise( - Effect.gen(function* () { - const pty = yield* Pty.Service - return yield* pty.connect(id, socket, cursor) - }).pipe(Effect.withSpan("PtyRoutes.connect.open")), - ) - ready = true - for (const msg of pending) handler?.onMessage(msg) - pending.length = 0 - }, - onMessage(event) { - if (typeof event.data !== "string") return - if (!ready) { - pending.push(event.data) - return - } - handler?.onMessage(event.data) - }, - onClose() { - handler?.onClose() - }, - onError() { - handler?.onClose() - }, - } - }), - ) -} diff --git a/packages/opencode/src/server/routes/instance/question.ts b/packages/opencode/src/server/routes/instance/question.ts deleted file mode 100644 index 51ecb48ccd..0000000000 --- a/packages/opencode/src/server/routes/instance/question.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { Hono } from "hono" -import { describeRoute, validator } from "hono-openapi" -import { resolver } from "hono-openapi" -import { QuestionID } from "@/question/schema" -import { Question } from "@/question" -import z from "zod" -import { errors } from "../../error" -import { lazy } from "@/util/lazy" -import { jsonRequest } from "./trace" - -const Reply = z.object({ - answers: Question.Answer.zod - .array() - .describe("User answers in order of questions (each answer is an array of selected labels)"), -}) - -export const QuestionRoutes = lazy(() => - new Hono() - .get( - "/", - describeRoute({ - summary: "List pending questions", - description: "Get all pending question requests across all sessions.", - operationId: "question.list", - responses: { - 200: { - description: "List of pending questions", - content: { - "application/json": { - schema: resolver(Question.Request.zod.array()), - }, - }, - }, - }, - }), - async (c) => - jsonRequest("QuestionRoutes.list", c, function* () { - const svc = yield* Question.Service - return yield* svc.list() - }), - ) - .post( - "/:requestID/reply", - describeRoute({ - summary: "Reply to question request", - description: "Provide answers to a question request from the AI assistant.", - operationId: "question.reply", - responses: { - 200: { - description: "Question answered successfully", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - requestID: QuestionID.zod, - }), - ), - validator("json", Reply), - async (c) => - jsonRequest("QuestionRoutes.reply", c, function* () { - const params = c.req.valid("param") - const json = c.req.valid("json") - const svc = yield* Question.Service - yield* svc.reply({ - requestID: params.requestID, - answers: json.answers, - }) - return true - }), - ) - .post( - "/:requestID/reject", - describeRoute({ - summary: "Reject question request", - description: "Reject a question request from the AI assistant.", - operationId: "question.reject", - responses: { - 200: { - description: "Question rejected successfully", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - requestID: QuestionID.zod, - }), - ), - async (c) => - jsonRequest("QuestionRoutes.reject", c, function* () { - const params = c.req.valid("param") - const svc = yield* Question.Service - yield* svc.reject(params.requestID) - return true - }), - ), -) diff --git a/packages/opencode/src/server/routes/instance/session.ts b/packages/opencode/src/server/routes/instance/session.ts deleted file mode 100644 index a16a92f927..0000000000 --- a/packages/opencode/src/server/routes/instance/session.ts +++ /dev/null @@ -1,1124 +0,0 @@ -import { Hono } from "hono" -import { stream } from "hono/streaming" -import { describeRoute, validator, resolver } from "hono-openapi" -import { SessionID, MessageID, PartID } from "@/session/schema" -import z from "zod" -import { Session } from "@/session/session" -import { MessageV2 } from "@/session/message-v2" -import { SessionPrompt } from "@/session/prompt" -import { SessionRunState } from "@/session/run-state" -import { SessionCompaction } from "@/session/compaction" -import { SessionRevert } from "@/session/revert" -import { SessionShare } from "@/share/session" -import { SessionStatus } from "@/session/status" -import { SessionSummary } from "@/session/summary" -import { Todo } from "@/session/todo" -import { Effect } from "effect" -import { Agent } from "@/agent/agent" -import { Snapshot } from "@/snapshot" -import { Command } from "@/command" -import * as Log from "@opencode-ai/core/util/log" -import { Permission } from "@/permission" -import { PermissionID } from "@/permission/schema" -import { ModelID, ProviderID } from "@/provider/schema" -import { errors } from "../../error" -import { lazy } from "@/util/lazy" -import { zodObject } from "@/util/effect-zod" -import { Bus } from "@/bus" -import { NamedError } from "@opencode-ai/core/util/error" -import { jsonRequest, runRequest } from "./trace" - -const log = Log.create({ service: "server" }) - -const QueryBoolean = z.union([ - z.preprocess((value) => (value === "true" ? true : value === "false" ? false : value), z.boolean()), - z.enum(["true", "false"]), -]) - -function queryBoolean(value: z.infer | undefined) { - if (value === undefined) return - return value === true || value === "true" -} - -export const SessionRoutes = lazy(() => - new Hono() - .get( - "/", - describeRoute({ - summary: "List sessions", - description: "Get a list of all OpenCode sessions, sorted by most recently updated.", - operationId: "session.list", - responses: { - 200: { - description: "List of sessions", - content: { - "application/json": { - schema: resolver(Session.Info.zod.array()), - }, - }, - }, - }, - }), - validator( - "query", - z.object({ - directory: z.string().optional().meta({ description: "Filter sessions by directory" }), - // TODO: in 2.0 remove `scope` and `directory` and default - // to list all sessions for a project - scope: z.enum(["project"]).optional().meta({ description: "List all sessions for the current project" }), - path: z.string().optional().meta({ description: "Filter sessions by project-relative path" }), - roots: QueryBoolean.optional().meta({ description: "Only return root sessions (no parentID)" }), - start: z.coerce - .number() - .optional() - .meta({ description: "Filter sessions updated on or after this timestamp (milliseconds since epoch)" }), - search: z.string().optional().meta({ description: "Filter sessions by title (case-insensitive)" }), - limit: z.coerce.number().optional().meta({ description: "Maximum number of sessions to return" }), - }), - ), - async (c) => { - const query = c.req.valid("query") - return c.json( - await runRequest( - "SessionRoutes.list", - c, - Session.Service.use((svc) => - svc.list({ - directory: query.scope === "project" ? undefined : query.directory, - path: query.path, - roots: queryBoolean(query.roots), - start: query.start, - search: query.search, - limit: query.limit, - }), - ), - ), - ) - }, - ) - .get( - "/status", - describeRoute({ - summary: "Get session status", - description: "Retrieve the current status of all sessions, including active, idle, and completed states.", - operationId: "session.status", - responses: { - 200: { - description: "Get session status", - content: { - "application/json": { - schema: resolver(z.record(z.string(), SessionStatus.Info.zod)), - }, - }, - }, - ...errors(400), - }, - }), - async (c) => - jsonRequest("SessionRoutes.status", c, function* () { - const svc = yield* SessionStatus.Service - return Object.fromEntries(yield* svc.list()) - }), - ) - .get( - "/:sessionID", - describeRoute({ - summary: "Get session", - description: "Retrieve detailed information about a specific OpenCode session.", - tags: ["Session"], - operationId: "session.get", - responses: { - 200: { - description: "Get session", - content: { - "application/json": { - schema: resolver(Session.Info.zod), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - sessionID: Session.GetInput.zod, - }), - ), - async (c) => { - const sessionID = c.req.valid("param").sessionID - return jsonRequest("SessionRoutes.get", c, function* () { - const session = yield* Session.Service - return yield* session.get(sessionID) - }) - }, - ) - .get( - "/:sessionID/children", - describeRoute({ - summary: "Get session children", - tags: ["Session"], - description: "Retrieve all child sessions that were forked from the specified parent session.", - operationId: "session.children", - responses: { - 200: { - description: "List of children", - content: { - "application/json": { - schema: resolver(Session.Info.zod.array()), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - sessionID: Session.ChildrenInput.zod, - }), - ), - async (c) => { - const sessionID = c.req.valid("param").sessionID - return jsonRequest("SessionRoutes.children", c, function* () { - const session = yield* Session.Service - return yield* session.children(sessionID) - }) - }, - ) - .get( - "/:sessionID/todo", - describeRoute({ - summary: "Get session todos", - description: "Retrieve the todo list associated with a specific session, showing tasks and action items.", - operationId: "session.todo", - responses: { - 200: { - description: "Todo list", - content: { - "application/json": { - schema: resolver(Todo.Info.zod.array()), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - sessionID: SessionID.zod, - }), - ), - async (c) => { - const sessionID = c.req.valid("param").sessionID - return jsonRequest("SessionRoutes.todo", c, function* () { - const todo = yield* Todo.Service - return yield* todo.get(sessionID) - }) - }, - ) - .post( - "/", - describeRoute({ - summary: "Create session", - description: "Create a new OpenCode session for interacting with AI assistants and managing conversations.", - operationId: "session.create", - responses: { - ...errors(400), - 200: { - description: "Successfully created session", - content: { - "application/json": { - schema: resolver(Session.Info.zod), - }, - }, - }, - }, - }), - validator("json", Session.CreateInput.zod), - async (c) => - jsonRequest("SessionRoutes.create", c, function* () { - const body = c.req.valid("json") ?? {} - const svc = yield* SessionShare.Service - return yield* svc.create(body) - }), - ) - .delete( - "/:sessionID", - describeRoute({ - summary: "Delete session", - description: "Delete a session and permanently remove all associated data, including messages and history.", - operationId: "session.delete", - responses: { - 200: { - description: "Successfully deleted session", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - sessionID: Session.RemoveInput.zod, - }), - ), - async (c) => - jsonRequest("SessionRoutes.delete", c, function* () { - const sessionID = c.req.valid("param").sessionID - const svc = yield* Session.Service - yield* svc.remove(sessionID) - return true - }), - ) - .patch( - "/:sessionID", - describeRoute({ - summary: "Update session", - description: "Update properties of an existing session, such as title or other metadata.", - operationId: "session.update", - responses: { - 200: { - description: "Successfully updated session", - content: { - "application/json": { - schema: resolver(Session.Info.zod), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - sessionID: SessionID.zod, - }), - ), - validator( - "json", - z.object({ - title: z.string().optional(), - permission: Permission.Ruleset.zod.optional(), - time: z - .object({ - archived: z.number().optional(), - }) - .optional(), - }), - ), - async (c) => - jsonRequest("SessionRoutes.update", c, function* () { - const sessionID = c.req.valid("param").sessionID - const updates = c.req.valid("json") - const session = yield* Session.Service - const current = yield* session.get(sessionID) - - if (updates.title !== undefined) { - yield* session.setTitle({ sessionID, title: updates.title }) - } - if (updates.permission !== undefined) { - yield* session.setPermission({ - sessionID, - permission: Permission.merge(current.permission ?? [], updates.permission), - }) - } - if (updates.time?.archived !== undefined) { - yield* session.setArchived({ sessionID, time: updates.time.archived }) - } - - return yield* session.get(sessionID) - }), - ) - // TODO(v2): remove this dedicated route and rely on the normal `/init` command flow. - .post( - "/:sessionID/init", - describeRoute({ - summary: "Initialize session", - description: - "Analyze the current application and create an AGENTS.md file with project-specific agent configurations.", - operationId: "session.init", - responses: { - 200: { - description: "200", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - sessionID: SessionID.zod, - }), - ), - validator( - "json", - z.object({ - modelID: ModelID.zod, - providerID: ProviderID.zod, - messageID: MessageID.zod, - }), - ), - async (c) => - jsonRequest("SessionRoutes.init", c, function* () { - const sessionID = c.req.valid("param").sessionID - const body = c.req.valid("json") - const svc = yield* SessionPrompt.Service - yield* svc.command({ - sessionID, - messageID: body.messageID, - model: body.providerID + "/" + body.modelID, - command: Command.Default.INIT, - arguments: "", - }) - return true - }), - ) - .post( - "/:sessionID/fork", - describeRoute({ - summary: "Fork session", - description: "Create a new session by forking an existing session at a specific message point.", - operationId: "session.fork", - responses: { - 200: { - description: "200", - content: { - "application/json": { - schema: resolver(Session.Info.zod), - }, - }, - }, - }, - }), - validator( - "param", - z.object({ - sessionID: SessionID.zod, - }), - ), - validator("json", zodObject(Session.ForkInput).omit({ sessionID: true })), - async (c) => - jsonRequest("SessionRoutes.fork", c, function* () { - const sessionID = c.req.valid("param").sessionID - const body = c.req.valid("json") as { messageID?: MessageID } - const svc = yield* Session.Service - return yield* svc.fork({ ...body, sessionID }) - }), - ) - .post( - "/:sessionID/abort", - describeRoute({ - summary: "Abort session", - description: "Abort an active session and stop any ongoing AI processing or command execution.", - operationId: "session.abort", - responses: { - 200: { - description: "Aborted session", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - sessionID: SessionID.zod, - }), - ), - async (c) => - jsonRequest("SessionRoutes.abort", c, function* () { - const svc = yield* SessionPrompt.Service - yield* svc.cancel(c.req.valid("param").sessionID) - return true - }), - ) - .post( - "/:sessionID/share", - describeRoute({ - summary: "Share session", - description: "Create a shareable link for a session, allowing others to view the conversation.", - operationId: "session.share", - responses: { - 200: { - description: "Successfully shared session", - content: { - "application/json": { - schema: resolver(Session.Info.zod), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - sessionID: SessionID.zod, - }), - ), - async (c) => - jsonRequest("SessionRoutes.share", c, function* () { - const sessionID = c.req.valid("param").sessionID - const share = yield* SessionShare.Service - const session = yield* Session.Service - yield* share.share(sessionID) - return yield* session.get(sessionID) - }), - ) - .get( - "/:sessionID/diff", - describeRoute({ - summary: "Get message diff", - description: "Get the file changes (diff) that resulted from a specific user message in the session.", - operationId: "session.diff", - responses: { - 200: { - description: "Successfully retrieved diff", - content: { - "application/json": { - schema: resolver(Snapshot.FileDiff.zod.array()), - }, - }, - }, - }, - }), - validator( - "param", - z.object({ - sessionID: SessionID.zod, - }), - ), - validator("query", zodObject(SessionSummary.DiffInput).omit({ sessionID: true })), - async (c) => - jsonRequest("SessionRoutes.diff", c, function* () { - const query = c.req.valid("query") as Omit - const params = c.req.valid("param") - const summary = yield* SessionSummary.Service - return yield* summary.diff({ - sessionID: params.sessionID, - messageID: query.messageID, - }) - }), - ) - .delete( - "/:sessionID/share", - describeRoute({ - summary: "Unshare session", - description: "Remove the shareable link for a session, making it private again.", - operationId: "session.unshare", - responses: { - 200: { - description: "Successfully unshared session", - content: { - "application/json": { - schema: resolver(Session.Info.zod), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - sessionID: SessionID.zod, - }), - ), - async (c) => - jsonRequest("SessionRoutes.unshare", c, function* () { - const sessionID = c.req.valid("param").sessionID - const share = yield* SessionShare.Service - const session = yield* Session.Service - yield* share.unshare(sessionID) - return yield* session.get(sessionID) - }), - ) - .post( - "/:sessionID/summarize", - describeRoute({ - summary: "Summarize session", - description: "Generate a concise summary of the session using AI compaction to preserve key information.", - operationId: "session.summarize", - responses: { - 200: { - description: "Summarized session", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - sessionID: SessionID.zod, - }), - ), - validator( - "json", - z.object({ - providerID: ProviderID.zod, - modelID: ModelID.zod, - auto: z.boolean().optional().default(false), - }), - ), - async (c) => - jsonRequest("SessionRoutes.summarize", c, function* () { - const sessionID = c.req.valid("param").sessionID - const body = c.req.valid("json") - const session = yield* Session.Service - const revert = yield* SessionRevert.Service - const compact = yield* SessionCompaction.Service - const prompt = yield* SessionPrompt.Service - const agent = yield* Agent.Service - - yield* revert.cleanup(yield* session.get(sessionID)) - const msgs = yield* session.messages({ sessionID }) - const defaultAgent = yield* agent.defaultAgent() - let currentAgent = defaultAgent - for (let i = msgs.length - 1; i >= 0; i--) { - const info = msgs[i].info - if (info.role === "user") { - currentAgent = info.agent || defaultAgent - break - } - } - - yield* compact.create({ - sessionID, - agent: currentAgent, - model: { - providerID: body.providerID, - modelID: body.modelID, - }, - auto: body.auto, - }) - yield* prompt.loop({ sessionID }) - return true - }), - ) - .get( - "/:sessionID/message", - describeRoute({ - summary: "Get session messages", - description: "Retrieve all messages in a session, including user prompts and AI responses.", - operationId: "session.messages", - responses: { - 200: { - description: "List of messages", - content: { - "application/json": { - schema: resolver(MessageV2.WithParts.zod.array()), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - sessionID: SessionID.zod, - }), - ), - validator( - "query", - z - .object({ - limit: z.coerce - .number() - .int() - .min(0) - .optional() - .meta({ description: "Maximum number of messages to return" }), - before: z - .string() - .optional() - .meta({ description: "Opaque cursor for loading older messages" }) - .refine( - (value) => { - if (!value) return true - try { - MessageV2.cursor.decode(value) - return true - } catch { - return false - } - }, - { message: "Invalid cursor" }, - ), - }) - .refine((value) => !value.before || value.limit !== undefined, { - message: "before requires limit", - path: ["before"], - }), - ), - async (c) => { - const query = c.req.valid("query") - const sessionID = c.req.valid("param").sessionID - if (query.limit === undefined || query.limit === 0) { - const messages = await runRequest( - "SessionRoutes.messages", - c, - Effect.gen(function* () { - const session = yield* Session.Service - yield* session.get(sessionID) - return yield* session.messages({ sessionID }) - }), - ) - return c.json(messages) - } - - const page = await MessageV2.page({ - sessionID, - limit: query.limit, - before: query.before, - }) - if (page.cursor) { - const url = new URL(c.req.url) - url.searchParams.set("limit", query.limit.toString()) - url.searchParams.set("before", page.cursor) - c.header("Access-Control-Expose-Headers", "Link, X-Next-Cursor") - c.header("Link", `<${url.toString()}>; rel="next"`) - c.header("X-Next-Cursor", page.cursor) - } - return c.json(page.items) - }, - ) - .get( - "/:sessionID/message/:messageID", - describeRoute({ - summary: "Get message", - description: "Retrieve a specific message from a session by its message ID.", - operationId: "session.message", - responses: { - 200: { - description: "Message", - content: { - "application/json": { - schema: resolver( - z.object({ - info: MessageV2.Info.zod, - parts: MessageV2.Part.zod.array(), - }), - ), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - sessionID: SessionID.zod, - messageID: MessageID.zod, - }), - ), - async (c) => { - const params = c.req.valid("param") - const message = await MessageV2.get({ - sessionID: params.sessionID, - messageID: params.messageID, - }) - return c.json(message) - }, - ) - .delete( - "/:sessionID/message/:messageID", - describeRoute({ - summary: "Delete message", - description: - "Permanently delete a specific message (and all of its parts) from a session. This does not revert any file changes that may have been made while processing the message.", - operationId: "session.deleteMessage", - responses: { - 200: { - description: "Successfully deleted message", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - sessionID: SessionID.zod, - messageID: MessageID.zod, - }), - ), - async (c) => - jsonRequest("SessionRoutes.deleteMessage", c, function* () { - const params = c.req.valid("param") - const state = yield* SessionRunState.Service - const session = yield* Session.Service - yield* state.assertNotBusy(params.sessionID) - yield* session.removeMessage({ - sessionID: params.sessionID, - messageID: params.messageID, - }) - return true - }), - ) - .delete( - "/:sessionID/message/:messageID/part/:partID", - describeRoute({ - description: "Delete a part from a message", - operationId: "part.delete", - responses: { - 200: { - description: "Successfully deleted part", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - sessionID: SessionID.zod, - messageID: MessageID.zod, - partID: PartID.zod, - }), - ), - async (c) => - jsonRequest("SessionRoutes.deletePart", c, function* () { - const params = c.req.valid("param") - const svc = yield* Session.Service - yield* svc.removePart({ - sessionID: params.sessionID, - messageID: params.messageID, - partID: params.partID, - }) - return true - }), - ) - .patch( - "/:sessionID/message/:messageID/part/:partID", - describeRoute({ - description: "Update a part in a message", - operationId: "part.update", - responses: { - 200: { - description: "Successfully updated part", - content: { - "application/json": { - schema: resolver(MessageV2.Part.zod), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - sessionID: SessionID.zod, - messageID: MessageID.zod, - partID: PartID.zod, - }), - ), - validator("json", MessageV2.Part.zod), - async (c) => { - const params = c.req.valid("param") - const body = c.req.valid("json") - if (body.id !== params.partID || body.messageID !== params.messageID || body.sessionID !== params.sessionID) { - throw new Error( - `Part mismatch: body.id='${body.id}' vs partID='${params.partID}', body.messageID='${body.messageID}' vs messageID='${params.messageID}', body.sessionID='${body.sessionID}' vs sessionID='${params.sessionID}'`, - ) - } - return jsonRequest("SessionRoutes.updatePart", c, function* () { - const svc = yield* Session.Service - return yield* svc.updatePart(body) - }) - }, - ) - .post( - "/:sessionID/message", - describeRoute({ - summary: "Send message", - description: "Create and send a new message to a session, streaming the AI response.", - operationId: "session.prompt", - responses: { - 200: { - description: "Created message", - content: { - "application/json": { - schema: resolver( - z.object({ - info: MessageV2.Assistant.zod, - parts: MessageV2.Part.zod.array(), - }), - ), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - sessionID: SessionID.zod, - }), - ), - validator("json", zodObject(SessionPrompt.PromptInput).omit({ sessionID: true })), - async (c) => { - c.status(200) - c.header("Content-Type", "application/json") - return stream(c, async (stream) => { - const sessionID = c.req.valid("param").sessionID - const body = c.req.valid("json") - const msg = await runRequest( - "SessionRoutes.prompt", - c, - SessionPrompt.Service.use((svc) => - svc.prompt({ ...body, sessionID } as unknown as SessionPrompt.PromptInput), - ), - ) - void stream.write(JSON.stringify(msg)) - }) - }, - ) - .post( - "/:sessionID/prompt_async", - describeRoute({ - summary: "Send async message", - description: - "Create and send a new message to a session asynchronously, starting the session if needed and returning immediately.", - operationId: "session.prompt_async", - responses: { - 204: { - description: "Prompt accepted", - }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - sessionID: SessionID.zod, - }), - ), - validator("json", zodObject(SessionPrompt.PromptInput).omit({ sessionID: true })), - async (c) => { - const sessionID = c.req.valid("param").sessionID - const body = c.req.valid("json") - void runRequest( - "SessionRoutes.prompt_async", - c, - SessionPrompt.Service.use((svc) => - svc.prompt({ ...body, sessionID } as unknown as SessionPrompt.PromptInput), - ), - ).catch((err) => { - log.error("prompt_async failed", { sessionID, error: err }) - void Bus.publish(Session.Event.Error, { - sessionID, - error: new NamedError.Unknown({ message: err instanceof Error ? err.message : String(err) }).toObject(), - }) - }) - - return c.body(null, 204) - }, - ) - .post( - "/:sessionID/command", - describeRoute({ - summary: "Send command", - description: "Send a new command to a session for execution by the AI assistant.", - operationId: "session.command", - responses: { - 200: { - description: "Created message", - content: { - "application/json": { - schema: resolver( - z.object({ - info: MessageV2.Assistant.zod, - parts: MessageV2.Part.zod.array(), - }), - ), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - sessionID: SessionID.zod, - }), - ), - validator("json", zodObject(SessionPrompt.CommandInput).omit({ sessionID: true })), - async (c) => - jsonRequest("SessionRoutes.command", c, function* () { - const sessionID = c.req.valid("param").sessionID - const body = c.req.valid("json") as Omit - const svc = yield* SessionPrompt.Service - return yield* svc.command({ ...body, sessionID }) - }), - ) - .post( - "/:sessionID/shell", - describeRoute({ - summary: "Run shell command", - description: "Execute a shell command within the session context and return the AI's response.", - operationId: "session.shell", - responses: { - 200: { - description: "Created message", - content: { - "application/json": { - schema: resolver(MessageV2.WithParts.zod), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - sessionID: SessionID.zod, - }), - ), - validator("json", zodObject(SessionPrompt.ShellInput).omit({ sessionID: true })), - async (c) => - jsonRequest("SessionRoutes.shell", c, function* () { - const sessionID = c.req.valid("param").sessionID - const body = c.req.valid("json") as Omit - const svc = yield* SessionPrompt.Service - return yield* svc.shell({ ...body, sessionID }) - }), - ) - .post( - "/:sessionID/revert", - describeRoute({ - summary: "Revert message", - description: "Revert a specific message in a session, undoing its effects and restoring the previous state.", - operationId: "session.revert", - responses: { - 200: { - description: "Updated session", - content: { - "application/json": { - schema: resolver(Session.Info.zod), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - sessionID: SessionID.zod, - }), - ), - validator("json", zodObject(SessionRevert.RevertInput).omit({ sessionID: true })), - async (c) => { - const sessionID = c.req.valid("param").sessionID - const body = c.req.valid("json") as Omit - log.info("revert", body) - return jsonRequest("SessionRoutes.revert", c, function* () { - const svc = yield* SessionRevert.Service - return yield* svc.revert({ sessionID, ...body }) - }) - }, - ) - .post( - "/:sessionID/unrevert", - describeRoute({ - summary: "Restore reverted messages", - description: "Restore all previously reverted messages in a session.", - operationId: "session.unrevert", - responses: { - 200: { - description: "Updated session", - content: { - "application/json": { - schema: resolver(Session.Info.zod), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - sessionID: SessionID.zod, - }), - ), - async (c) => - jsonRequest("SessionRoutes.unrevert", c, function* () { - const sessionID = c.req.valid("param").sessionID - const svc = yield* SessionRevert.Service - return yield* svc.unrevert({ sessionID }) - }), - ) - .post( - "/:sessionID/permissions/:permissionID", - describeRoute({ - summary: "Respond to permission", - deprecated: true, - description: "Approve or deny a permission request from the AI assistant.", - operationId: "permission.respond", - responses: { - 200: { - description: "Permission processed successfully", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - sessionID: SessionID.zod, - permissionID: PermissionID.zod, - }), - ), - validator("json", z.object({ response: Permission.Reply.zod })), - async (c) => - jsonRequest("SessionRoutes.permissionRespond", c, function* () { - const params = c.req.valid("param") - const svc = yield* Permission.Service - yield* svc.reply({ - requestID: params.permissionID, - reply: c.req.valid("json").response, - }) - return true - }), - ), -) diff --git a/packages/opencode/src/server/routes/instance/sync.ts b/packages/opencode/src/server/routes/instance/sync.ts deleted file mode 100644 index 9894d8c8ee..0000000000 --- a/packages/opencode/src/server/routes/instance/sync.ts +++ /dev/null @@ -1,199 +0,0 @@ -import z from "zod" -import { Hono } from "hono" -import { describeRoute, validator, resolver } from "hono-openapi" -import { SyncEvent } from "@/sync" -import { Database } from "@/storage/db" -import { asc } from "drizzle-orm" -import { and } from "drizzle-orm" -import { not } from "drizzle-orm" -import { or } from "drizzle-orm" -import { lte } from "drizzle-orm" -import { eq } from "drizzle-orm" -import { EventTable } from "@/sync/event.sql" -import { lazy } from "@/util/lazy" -import * as Log from "@opencode-ai/core/util/log" -import { Workspace } from "@/control-plane/workspace" -import { AppRuntime } from "@/effect/app-runtime" -import { Instance } from "@/project/instance" -import { errors } from "../../error" -import { Session } from "@/session/session" -import { WorkspaceContext } from "@/control-plane/workspace-context" -import { SessionID } from "@/session/schema" - -const ReplayEvent = z.object({ - id: z.string(), - aggregateID: z.string(), - seq: z.number().int().min(0), - type: z.string(), - data: z.record(z.string(), z.unknown()), -}) -const SessionPayload = z.object({ - sessionID: SessionID.zod, -}) - -const log = Log.create({ service: "server.sync" }) - -export const SyncRoutes = lazy(() => - new Hono() - .post( - "/start", - describeRoute({ - summary: "Start workspace sync", - description: "Start sync loops for workspaces in the current project that have active sessions.", - operationId: "sync.start", - responses: { - 200: { - description: "Workspace sync started", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - }, - }), - async (c) => { - void AppRuntime.runPromise( - Workspace.Service.use((workspace) => workspace.startWorkspaceSyncing(Instance.project.id)), - ) - return c.json(true) - }, - ) - .post( - "/replay", - describeRoute({ - summary: "Replay sync events", - description: "Validate and replay a complete sync event history.", - operationId: "sync.replay", - responses: { - 200: { - description: "Replayed sync events", - content: { - "application/json": { - schema: resolver( - z.object({ - sessionID: z.string(), - }), - ), - }, - }, - }, - ...errors(400), - }, - }), - validator( - "json", - z.object({ - directory: z.string(), - events: z.array(ReplayEvent).min(1), - }), - ), - async (c) => { - const body = c.req.valid("json") - const events = body.events - const source = events[0].aggregateID - - log.info("sync replay requested", { - sessionID: source, - events: events.length, - first: events[0]?.seq, - last: events.at(-1)?.seq, - directory: body.directory, - }) - await AppRuntime.runPromise(SyncEvent.use.replayAll(events)) - - log.info("sync replay complete", { - sessionID: source, - events: events.length, - first: events[0]?.seq, - last: events.at(-1)?.seq, - }) - - return c.json({ - sessionID: source, - }) - }, - ) - .post( - "/steal", - describeRoute({ - summary: "Steal session into workspace", - description: "Update a session to belong to the current workspace through the sync event system.", - operationId: "sync.steal", - responses: { - 200: { - description: "Session stolen into workspace", - content: { - "application/json": { - schema: resolver(SessionPayload), - }, - }, - }, - ...errors(400), - }, - }), - validator("json", SessionPayload), - async (c) => { - const body = c.req.valid("json") - const workspaceID = WorkspaceContext.workspaceID - if (!workspaceID) throw new Error("Cannot steal session without workspace context") - - SyncEvent.run(Session.Event.Updated, { - sessionID: body.sessionID, - info: { - workspaceID, - }, - }) - - log.info("sync session stolen", { - sessionID: body.sessionID, - workspaceID, - }) - - return c.json({ - sessionID: body.sessionID, - }) - }, - ) - .post( - "/history", - describeRoute({ - summary: "List sync events", - description: - "List sync events for all aggregates. Keys are aggregate IDs the client already knows about, values are the last known sequence ID. Events with seq > value are returned for those aggregates. Aggregates not listed in the input get their full history.", - operationId: "sync.history.list", - responses: { - 200: { - description: "Sync events", - content: { - "application/json": { - schema: resolver( - z.array( - z.object({ - id: z.string(), - aggregate_id: z.string(), - seq: z.number(), - type: z.string(), - data: z.record(z.string(), z.unknown()), - }), - ), - ), - }, - }, - }, - ...errors(400), - }, - }), - validator("json", z.record(z.string(), z.number().int().min(0))), - async (c) => { - const body = c.req.valid("json") - const exclude = Object.entries(body) - const where = - exclude.length > 0 - ? not(or(...exclude.map(([id, seq]) => and(eq(EventTable.aggregate_id, id), lte(EventTable.seq, seq))))!) - : undefined - const rows = Database.use((db) => db.select().from(EventTable).where(where).orderBy(asc(EventTable.seq)).all()) - return c.json(rows) - }, - ), -) diff --git a/packages/opencode/src/server/routes/instance/trace.ts b/packages/opencode/src/server/routes/instance/trace.ts deleted file mode 100644 index 4c7119ef3a..0000000000 --- a/packages/opencode/src/server/routes/instance/trace.ts +++ /dev/null @@ -1,59 +0,0 @@ -import type { Context } from "hono" -import { Effect } from "effect" -import { AppRuntime } from "@/effect/app-runtime" - -type AppEnv = Parameters[0] extends Effect.Effect ? R : never - -// Build the base span attributes for an HTTP handler: method, path, and every -// matched route param. Names follow OTel attribute-naming guidance: -// domain-first (`session.id`, `message.id`, …) so they match the existing -// OTel `session.id` semantic convention and the bare `message.id` we -// already emit from Tool.execute. Non-standard route params fall back to -// `opencode.` since those are internal implementation details -// (per https://opentelemetry.io/blog/2025/how-to-name-your-span-attributes/). -export interface RequestLike { - readonly req: { - readonly method: string - readonly url: string - param(): Record - } -} - -// Normalize a Hono route param key (e.g. `sessionID`, `messageID`, `name`) -// to an OTel attribute key. `fooID` → `foo.id` for ID-shaped params; any -// other param is namespaced under `opencode.` to avoid colliding with -// standard conventions. -export function paramToAttributeKey(key: string): string { - const m = key.match(/^(.+)ID$/) - if (m) return `${m[1].toLowerCase()}.id` - return `opencode.${key}` -} - -export function requestAttributes(c: RequestLike): Record { - const attributes: Record = { - "http.method": c.req.method, - "http.path": new URL(c.req.url).pathname, - } - for (const [key, value] of Object.entries(c.req.param())) { - attributes[paramToAttributeKey(key)] = value - } - return attributes -} - -export function runRequest(name: string, c: Context, effect: Effect.Effect) { - return AppRuntime.runPromise(effect.pipe(Effect.withSpan(name, { attributes: requestAttributes(c) }))) -} - -export async function jsonRequest( - name: string, - c: C, - effect: (c: C) => Effect.gen.Return, -) { - return c.json( - await runRequest( - name, - c, - Effect.gen(() => effect(c)), - ), - ) -} diff --git a/packages/opencode/src/server/routes/instance/tui.ts b/packages/opencode/src/server/routes/instance/tui.ts deleted file mode 100644 index a7a0c9cbdc..0000000000 --- a/packages/opencode/src/server/routes/instance/tui.ts +++ /dev/null @@ -1,387 +0,0 @@ -import { Hono, type Context } from "hono" -import { describeRoute, validator, resolver } from "hono-openapi" -import { Schema } from "effect" -import z from "zod" -import { Bus } from "@/bus" -import { Session } from "@/session/session" -import type { SessionID } from "@/session/schema" -import { TuiEvent } from "@/cli/cmd/tui/event" -import { zodObject } from "@/util/effect-zod" -import { errors } from "../../error" -import { lazy } from "@/util/lazy" -import { runRequest } from "./trace" -import { - TuiRequest, - nextTuiRequest, - nextTuiResponse, - submitTuiRequest, - submitTuiResponse, -} from "@/server/shared/tui-control" - -export async function callTui(ctx: Context) { - const body = await ctx.req.json() - submitTuiRequest({ - path: ctx.req.path, - body, - }) - return nextTuiResponse() -} - -const TuiControlRoutes = new Hono() - .get( - "/next", - describeRoute({ - summary: "Get next TUI request", - description: "Retrieve the next TUI (Terminal User Interface) request from the queue for processing.", - operationId: "tui.control.next", - responses: { - 200: { - description: "Next TUI request", - content: { - "application/json": { - schema: resolver(TuiRequest), - }, - }, - }, - }, - }), - async (c) => { - const req = await nextTuiRequest() - return c.json(req) - }, - ) - .post( - "/response", - describeRoute({ - summary: "Submit TUI response", - description: "Submit a response to the TUI request queue to complete a pending request.", - operationId: "tui.control.response", - responses: { - 200: { - description: "Response submitted successfully", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - }, - }), - validator("json", z.any()), - async (c) => { - const body = c.req.valid("json") - submitTuiResponse(body) - return c.json(true) - }, - ) - -export const TuiRoutes = lazy(() => - new Hono() - .post( - "/append-prompt", - describeRoute({ - summary: "Append TUI prompt", - description: "Append prompt to the TUI", - operationId: "tui.appendPrompt", - responses: { - 200: { - description: "Prompt processed successfully", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - ...errors(400), - }, - }), - validator("json", zodObject(TuiEvent.PromptAppend.properties)), - async (c) => { - await Bus.publish(TuiEvent.PromptAppend, c.req.valid("json") as { text: string }) - return c.json(true) - }, - ) - .post( - "/open-help", - describeRoute({ - summary: "Open help dialog", - description: "Open the help dialog in the TUI to display user assistance information.", - operationId: "tui.openHelp", - responses: { - 200: { - description: "Help dialog opened successfully", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - }, - }), - async (c) => { - await Bus.publish(TuiEvent.CommandExecute, { - command: "help.show", - }) - return c.json(true) - }, - ) - .post( - "/open-sessions", - describeRoute({ - summary: "Open sessions dialog", - description: "Open the session dialog", - operationId: "tui.openSessions", - responses: { - 200: { - description: "Session dialog opened successfully", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - }, - }), - async (c) => { - await Bus.publish(TuiEvent.CommandExecute, { - command: "session.list", - }) - return c.json(true) - }, - ) - .post( - "/open-themes", - describeRoute({ - summary: "Open themes dialog", - description: "Open the theme dialog", - operationId: "tui.openThemes", - responses: { - 200: { - description: "Theme dialog opened successfully", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - }, - }), - async (c) => { - await Bus.publish(TuiEvent.CommandExecute, { - command: "session.list", - }) - return c.json(true) - }, - ) - .post( - "/open-models", - describeRoute({ - summary: "Open models dialog", - description: "Open the model dialog", - operationId: "tui.openModels", - responses: { - 200: { - description: "Model dialog opened successfully", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - }, - }), - async (c) => { - await Bus.publish(TuiEvent.CommandExecute, { - command: "model.list", - }) - return c.json(true) - }, - ) - .post( - "/submit-prompt", - describeRoute({ - summary: "Submit TUI prompt", - description: "Submit the prompt", - operationId: "tui.submitPrompt", - responses: { - 200: { - description: "Prompt submitted successfully", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - }, - }), - async (c) => { - await Bus.publish(TuiEvent.CommandExecute, { - command: "prompt.submit", - }) - return c.json(true) - }, - ) - .post( - "/clear-prompt", - describeRoute({ - summary: "Clear TUI prompt", - description: "Clear the prompt", - operationId: "tui.clearPrompt", - responses: { - 200: { - description: "Prompt cleared successfully", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - }, - }), - async (c) => { - await Bus.publish(TuiEvent.CommandExecute, { - command: "prompt.clear", - }) - return c.json(true) - }, - ) - .post( - "/execute-command", - describeRoute({ - summary: "Execute TUI command", - description: "Execute a TUI command (e.g. agent_cycle)", - operationId: "tui.executeCommand", - responses: { - 200: { - description: "Command executed successfully", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - ...errors(400), - }, - }), - validator("json", z.object({ command: z.string() })), - async (c) => { - const command = c.req.valid("json").command - await Bus.publish(TuiEvent.CommandExecute, { - // @ts-expect-error - command: { - session_new: "session.new", - session_share: "session.share", - session_interrupt: "session.interrupt", - session_compact: "session.compact", - messages_page_up: "session.page.up", - messages_page_down: "session.page.down", - messages_line_up: "session.line.up", - messages_line_down: "session.line.down", - messages_half_page_up: "session.half.page.up", - messages_half_page_down: "session.half.page.down", - messages_first: "session.first", - messages_last: "session.last", - agent_cycle: "agent.cycle", - }[command], - }) - return c.json(true) - }, - ) - .post( - "/show-toast", - describeRoute({ - summary: "Show TUI toast", - description: "Show a toast notification in the TUI", - operationId: "tui.showToast", - responses: { - 200: { - description: "Toast notification shown successfully", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - }, - }), - validator("json", zodObject(TuiEvent.ToastShow.properties)), - async (c) => { - await Bus.publish( - TuiEvent.ToastShow, - c.req.valid("json") as Schema.Schema.Type, - ) - return c.json(true) - }, - ) - .post( - "/publish", - describeRoute({ - summary: "Publish TUI event", - description: "Publish a TUI event", - operationId: "tui.publish", - responses: { - 200: { - description: "Event published successfully", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - ...errors(400), - }, - }), - validator( - "json", - z.union( - Object.values(TuiEvent).map((def) => { - return z - .object({ - type: z.literal(def.type), - properties: zodObject(def.properties), - }) - .meta({ - ref: `Event.${def.type}`, - }) - }), - ), - ), - async (c) => { - const evt = c.req.valid("json") as { type: string; properties: Record } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - await Bus.publish(Object.values(TuiEvent).find((def) => def.type === evt.type)! as any, evt.properties as any) - return c.json(true) - }, - ) - .post( - "/select-session", - describeRoute({ - summary: "Select session", - description: "Navigate the TUI to display the specified session.", - operationId: "tui.selectSession", - responses: { - 200: { - description: "Session selected successfully", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator("json", zodObject(TuiEvent.SessionSelect.properties)), - async (c) => { - const { sessionID } = c.req.valid("json") as { sessionID: SessionID } - await runRequest( - "TuiRoutes.sessionSelect", - c, - Session.Service.use((svc) => svc.get(sessionID)), - ) - await Bus.publish(TuiEvent.SessionSelect, { sessionID }) - return c.json(true) - }, - ) - .route("/control", TuiControlRoutes), -) diff --git a/packages/opencode/src/server/routes/ui.ts b/packages/opencode/src/server/routes/ui.ts deleted file mode 100644 index 608525b63a..0000000000 --- a/packages/opencode/src/server/routes/ui.ts +++ /dev/null @@ -1,40 +0,0 @@ -import fs from "node:fs/promises" -import { AppFileSystem } from "@opencode-ai/core/filesystem" -import { Hono } from "hono" -import { proxy } from "hono/proxy" -import { ProxyUtil } from "../proxy-util" -import { UI_UPSTREAM, csp, cspForHtml, embeddedUI, upstreamURL } from "../shared/ui" - -export async function serveUI(request: Request) { - const embeddedWebUI = await embeddedUI() - const path = new URL(request.url).pathname - - if (embeddedWebUI) { - const match = embeddedWebUI[path.replace(/^\//, "")] ?? embeddedWebUI["index.html"] ?? null - if (!match) return Response.json({ error: "Not Found" }, { status: 404 }) - - if (await fs.exists(match)) { - const mime = AppFileSystem.mimeType(match) - const headers = new Headers({ "content-type": mime }) - const body = new Uint8Array(await fs.readFile(match)) - if (mime.startsWith("text/html")) { - headers.set("content-security-policy", cspForHtml(new TextDecoder().decode(body))) - } - return new Response(body, { headers }) - } - - return Response.json({ error: "Not Found" }, { status: 404 }) - } - - const response = await proxy(upstreamURL(path), { - raw: request, - headers: ProxyUtil.headers(request, { host: UI_UPSTREAM.host }), - }) - response.headers.set( - "Content-Security-Policy", - response.headers.get("content-type")?.includes("text/html") ? cspForHtml(await response.clone().text()) : csp(), - ) - return response -} - -export const UIRoutes = (): Hono => new Hono().all("/*", (c) => serveUI(c.req.raw)) diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index bc09667c29..67a728b801 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -1,30 +1,14 @@ -import { generateSpecs } from "hono-openapi" -import { Hono } from "hono" -import { adapter } from "#hono" -import { lazy } from "@/util/lazy" import * as Log from "@opencode-ai/core/util/log" -import { Flag } from "@opencode-ai/core/flag/flag" -import { WorkspaceID } from "@/control-plane/schema" import { ConfigProvider, Context, Effect, Exit, Layer, Scope } from "effect" import { HttpRouter, HttpServer } from "effect/unstable/http" import { OpenApi } from "effect/unstable/httpapi" import * as HttpApiServer from "#httpapi-server" import { MDNS } from "./mdns" -import { AuthMiddleware, CompressionMiddleware, CorsMiddleware, ErrorMiddleware, LoggerMiddleware } from "./middleware" -import { FenceMiddleware } from "./fence" import { initProjectors } from "./projectors" -import { InstanceRoutes } from "./routes/instance" -import { ControlPlaneRoutes } from "./routes/control" -import { UIRoutes } from "./routes/ui" -import { GlobalRoutes } from "./routes/global" -import { WorkspaceRouterMiddleware } from "./workspace" -import { InstanceMiddleware } from "./routes/instance/middleware" -import { WorkspaceRoutes } from "./routes/control/workspace" import { ExperimentalHttpApiServer } from "./routes/instance/httpapi/server" import { disposeMiddleware } from "./routes/instance/httpapi/lifecycle" import { WebSocketTracker } from "./routes/instance/httpapi/websocket-tracker" import { PublicApi } from "./routes/instance/httpapi/public" -import * as ServerBackend from "./backend" import type { CorsOptions } from "./cors" // @ts-ignore This global is needed to prevent ai-sdk from logging warnings to stdout https://github.com/vercel/ai/blob/2dc67e0ef538307f21368db32d5a12345d98831b/packages/ai/src/logger/log-warnings.ts#L85 @@ -53,203 +37,27 @@ type ListenOptions = CorsOptions & { mdnsDomain?: string } -const DefaultHono = lazy(() => - withBackend({ backend: "hono", reason: "stable" }, createHono({}, { backend: "hono", reason: "stable" })), -) -const DefaultHttpApi = lazy(() => createDefaultHttpApi()) - -function select() { - return ServerBackend.select() -} - -export const backend = select - -export const Default = () => { - const selected = select() - return selected.backend === "effect-httpapi" ? DefaultHttpApi() : DefaultHono() -} - -function create(opts: ListenOptions) { - const selected = select() - return selected.backend === "effect-httpapi" - ? withBackend(selected, createHttpApi(opts)) - : withBackend(selected, createHono(opts, selected)) -} - -export function Legacy(opts: CorsOptions = {}) { - return withBackend({ backend: "hono", reason: "explicit" }, createHono(opts, { backend: "hono", reason: "explicit" })) -} - -function createDefaultHttpApi() { - return withBackend(select(), createHttpApi()) -} - -function withBackend(selection: ServerBackend.Selection, built: T) { - log.info("server backend selected", ServerBackend.attributes(selection)) - return built -} - -function createHttpApi(corsOptions?: CorsOptions) { - const handler = ExperimentalHttpApiServer.webHandler(corsOptions).handler +const defaultHttpApi = (() => { + const handler = ExperimentalHttpApiServer.webHandler().handler const app: ServerApp = { fetch: (request: Request) => handler(request, ExperimentalHttpApiServer.context), request(input, init) { return app.fetch(input instanceof Request ? input : new Request(new URL(input, "http://localhost"), init)) }, } - return { - app, - runtime: adapter.createFetch(app), - } -} + return { app } +})() -function createHono(opts: CorsOptions, selection: ServerBackend.Selection = ServerBackend.force(select(), "hono")) { - const backendAttributes = ServerBackend.attributes(selection) - const app = new Hono() - .onError(ErrorMiddleware) - .use(CorsMiddleware(opts)) - .use(LoggerMiddleware(backendAttributes)) - .use(AuthMiddleware) - .use(CompressionMiddleware) - .route("/global", GlobalRoutes()) +export const Default = () => defaultHttpApi - const runtime = adapter.create(app) - - if (Flag.OPENCODE_WORKSPACE_ID) { - return { - app: app - .use(InstanceMiddleware(Flag.OPENCODE_WORKSPACE_ID ? WorkspaceID.make(Flag.OPENCODE_WORKSPACE_ID) : undefined)) - .use(FenceMiddleware) - .route("/", InstanceRoutes(runtime.upgradeWebSocket, opts)), - runtime, - } - } - - const workspaceApp = new Hono() - const workspaceLegacyApp = new Hono() - .use(InstanceMiddleware()) - .route("/experimental/workspace", WorkspaceRoutes()) - .use(WorkspaceRouterMiddleware(runtime.upgradeWebSocket)) - workspaceApp.route("/", workspaceLegacyApp) - - return { - app: app - .route("/", ControlPlaneRoutes()) - .route("/", workspaceApp) - .route("/", InstanceRoutes(runtime.upgradeWebSocket, opts)) - .route("/", UIRoutes()), - runtime, - } -} - -/** - * Generate the OpenAPI document used by the SDK build. - * - * Since the Effect HttpApi backend now covers every Hono route (plus the new - * `/api/session/*` v2 routes — see `httpapi-bridge.test.ts` for the parity - * audit), `Server.openapi()` derives the spec from `OpenApi.fromApi(PublicApi)`. - * `PublicApi` is `OpenCodeHttpApi` annotated with the `matchLegacyOpenApi` - * transform that injects instance query parameters, strips Effect's optional - * null arms, normalizes component names, and patches SSE response schemas so - * the generated SDK keeps the legacy Hono shape. - * - * The Hono-derived spec is still reachable via `openapiHono()` so reviewers - * can diff the two outputs while the Hono backend lingers; once the Hono - * backend is deleted that helper goes with it. - */ export async function openapi() { return OpenApi.fromApi(PublicApi) } -/** - * Hono-derived OpenAPI spec, retained for parity diffing only. Delete once - * the Hono backend is removed. - */ -export async function openapiHono() { - // Build a fresh app with all routes registered directly so - // hono-openapi can see describeRoute metadata (`.route()` wraps - // handlers when the sub-app has a custom errorHandler, which - // strips the metadata symbol). - const { app } = createHono({}) - const result = await generateSpecs(app, { - documentation: { - info: { - title: "opencode", - version: "1.0.0", - description: "opencode api", - }, - openapi: "3.1.1", - }, - }) - return result -} - export let url: URL export async function listen(opts: ListenOptions): Promise { - const selected = select() - const inner: Listener = - selected.backend === "effect-httpapi" ? await listenHttpApi(opts, selected) : await listenLegacy(opts) - - const next = new URL(inner.url) - url = next - - const mdns = - opts.mdns && inner.port && opts.hostname !== "127.0.0.1" && opts.hostname !== "localhost" && opts.hostname !== "::1" - if (mdns) { - MDNS.publish(inner.port, opts.mdnsDomain) - } else if (opts.mdns) { - log.warn("mDNS enabled but hostname is loopback; skipping mDNS publish") - } - - let closing: Promise | undefined - let mdnsUnpublished = false - const unpublish = () => { - if (!mdns || mdnsUnpublished) return - mdnsUnpublished = true - MDNS.unpublish() - } - return { - hostname: inner.hostname, - port: inner.port, - url: next, - stop(close?: boolean) { - unpublish() - // Always forward stop(true), even if a graceful stop was requested - // first, so native listeners can escalate shutdown in-place. - const next = inner.stop(close) - closing ??= next - return close ? next.then(() => closing!) : closing - }, - } -} - -async function listenLegacy(opts: ListenOptions): Promise { - const built = create(opts) - const server = await built.runtime.listen(opts) - const innerUrl = new URL("http://localhost") - innerUrl.hostname = opts.hostname - innerUrl.port = String(server.port) - return { - hostname: opts.hostname, - port: server.port, - url: innerUrl, - stop: (close?: boolean) => server.stop(close), - } -} - -/** - * Run the effect-httpapi backend on a native Effect HTTP server. This - * lets HttpApi routes that call `request.upgrade` (PTY connect, the - * workspace-routing proxy WS bridge) work end-to-end; the legacy Hono - * adapter path can't surface `request.upgrade` because its fetch handler has - * no reference to the platform server instance for websocket upgrades. - */ -async function listenHttpApi(opts: ListenOptions, selection: ServerBackend.Selection): Promise { - log.info("server backend selected", { - ...ServerBackend.attributes(selection), - "opencode.server.runtime": HttpApiServer.name, - }) + log.info("server backend", { "opencode.server.runtime": HttpApiServer.name }) const buildLayer = (port: number) => HttpRouter.serve(ExperimentalHttpApiServer.createRoutes(opts), { @@ -270,10 +78,6 @@ async function listenHttpApi(opts: ListenOptions, selection: ServerBackend.Selec const start = async (port: number) => { const scope = Scope.makeUnsafe() try { - // Effect's `HttpMiddleware` interface returns `Effect<…, any, any>` by - // design, which leaks `R = any` through `HttpRouter.serve`. The actual - // requirements at this point are fully satisfied by `createRoutes` and the - // platform HTTP server layer; cast away the `any` to satisfy `runPromise`. const layer = buildLayer(port) as Layer.Layer< HttpServer.HttpServer | WebSocketTracker.Service | HttpApiServer.Service, unknown, @@ -308,8 +112,24 @@ async function listenHttpApi(opts: ListenOptions, selection: ServerBackend.Selec const innerUrl = new URL("http://localhost") innerUrl.hostname = opts.hostname innerUrl.port = String(port) + url = innerUrl + + const mdns = + opts.mdns && port && opts.hostname !== "127.0.0.1" && opts.hostname !== "localhost" && opts.hostname !== "::1" + if (mdns) { + MDNS.publish(port, opts.mdnsDomain) + } else if (opts.mdns) { + log.warn("mDNS enabled but hostname is loopback; skipping mDNS publish") + } + let forceStopPromise: Promise | undefined let stopPromise: Promise | undefined + let mdnsUnpublished = false + const unpublish = () => { + if (!mdns || mdnsUnpublished) return + mdnsUnpublished = true + MDNS.unpublish() + } const forceStop = () => { forceStopPromise ??= Effect.runPromiseExit( Effect.gen(function* () { @@ -325,9 +145,8 @@ async function listenHttpApi(opts: ListenOptions, selection: ServerBackend.Selec port, url: innerUrl, stop: (close?: boolean) => { + unpublish() const requested = close ? forceStop() : Promise.resolve() - // The first call starts scope shutdown. A later stop(true) cannot undo - // that, but it still runs forceStop() before awaiting the original close. stopPromise ??= requested .then(() => Effect.runPromiseExit(Scope.close(resolved!.scope, Exit.void))) .then(() => undefined) diff --git a/packages/opencode/src/server/shared/tui-control.ts b/packages/opencode/src/server/shared/tui-control.ts index 40aaf04a96..03b62bbab0 100644 --- a/packages/opencode/src/server/shared/tui-control.ts +++ b/packages/opencode/src/server/shared/tui-control.ts @@ -1,12 +1,12 @@ -import z from "zod" import { AsyncQueue } from "@/util/queue" +import { Schema } from "effect" -export const TuiRequest = z.object({ - path: z.string(), - body: z.any(), +export const TuiRequest = Schema.Struct({ + path: Schema.String, + body: Schema.Unknown, }) -export type TuiRequest = z.infer +export type TuiRequest = Schema.Schema.Type const request = new AsyncQueue() const response = new AsyncQueue() diff --git a/packages/opencode/src/server/workspace.ts b/packages/opencode/src/server/workspace.ts deleted file mode 100644 index 0972875305..0000000000 --- a/packages/opencode/src/server/workspace.ts +++ /dev/null @@ -1,93 +0,0 @@ -import type { MiddlewareHandler } from "hono" -import type { UpgradeWebSocket } from "hono/ws" -import { getAdapter } from "@/control-plane/adapters" -import { WorkspaceID } from "@/control-plane/schema" -import { WorkspaceContext } from "@/control-plane/workspace-context" -import { Workspace } from "@/control-plane/workspace" -import { Flag } from "@opencode-ai/core/flag/flag" -import { AppRuntime } from "@/effect/app-runtime" -import { WithInstance } from "@/project/with-instance" -import { Session } from "@/session/session" -import { Effect } from "effect" -import * as Log from "@opencode-ai/core/util/log" -import { ServerProxy } from "./proxy" -import { getWorkspaceRouteSessionID, isLocalWorkspaceRoute, workspaceProxyURL } from "./shared/workspace-routing" - -async function getSessionWorkspace(url: URL) { - const id = getWorkspaceRouteSessionID(url) - if (!id) return null - - const session = await AppRuntime.runPromise( - Session.Service.use((svc) => svc.get(id)).pipe(Effect.withSpan("WorkspaceRouter.lookup")), - ).catch(() => undefined) - return session?.workspaceID -} - -export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): MiddlewareHandler { - const log = Log.create({ service: "workspace-router" }) - - return async (c, next) => { - const url = new URL(c.req.url) - - const sessionWorkspaceID = await getSessionWorkspace(url) - const workspaceID = sessionWorkspaceID || url.searchParams.get("workspace") - - if (!workspaceID || url.pathname.startsWith("/console") || Flag.OPENCODE_WORKSPACE_ID) { - return next() - } - - const workspace = await AppRuntime.runPromise( - Workspace.Service.use((svc) => svc.get(WorkspaceID.make(workspaceID))), - ) - - if (!workspace) { - return new Response(`Workspace not found: ${workspaceID}`, { - status: 500, - headers: { - "content-type": "text/plain; charset=utf-8", - }, - }) - } - - if (isLocalWorkspaceRoute(c.req.method, url.pathname)) { - // No instance provided because we are serving cached data; there - // is no instance to work with - return next() - } - - const adapter = getAdapter(workspace.projectID, workspace.type) - const target = await adapter.target(workspace) - - if (target.type === "local") { - return WorkspaceContext.provide({ - workspaceID: WorkspaceID.make(workspaceID), - fn: () => - WithInstance.provide({ - directory: target.directory, - async fn() { - return next() - }, - }), - }) - } - - const proxyURL = workspaceProxyURL(target.url, url) - - log.info("workspace proxy forwarding", { - workspaceID, - request: url.toString(), - target: String(target.url), - proxy: proxyURL.toString(), - }) - - if (c.req.header("upgrade")?.toLowerCase() === "websocket") { - return ServerProxy.websocket(upgrade, proxyURL, target.headers, c.req.raw, c.env) - } - - const headers = new Headers(c.req.raw.headers) - headers.delete("x-opencode-workspace") - - const req = new Request(c.req.raw, { headers }) - return ServerProxy.http(proxyURL, target.headers, req, workspace.id) - } -} diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index 067d43da2e..3ca4f074f9 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -4,7 +4,6 @@ import * as Session from "./session" import { SessionID, MessageID, PartID } from "./schema" import { Provider } from "@/provider/provider" import { MessageV2 } from "./message-v2" -import z from "zod" import { Token } from "@/util/token" import * as Log from "@opencode-ai/core/util/log" import { SessionProcessor } from "./processor" @@ -18,9 +17,10 @@ import * as DateTime from "effect/DateTime" import { InstanceState } from "@/effect/instance-state" import { isOverflow as overflow, usable } from "./overflow" import { makeRuntime } from "@/effect/run-service" -import { fn } from "@/util/fn" -import { EventV2 } from "@/v2/event" +import { serviceUse } from "@/effect/service-use" +import { SyncEvent } from "@/sync" import { SessionEvent } from "@/v2/session-event" +import { Flag } from "@opencode-ai/core/flag/flag" const log = Log.create({ service: "session.compaction" }) @@ -79,12 +79,10 @@ Rules: type Turn = { start: number end: number - id: MessageID } type Tail = { start: number - id: MessageID } type CompletedCompaction = { @@ -121,19 +119,41 @@ function completedCompactions(messages: MessageV2.WithParts[]) { }) } -function buildPrompt(input: { previousSummary?: string; context: string[] }) { +function buildPrompt(input: { previousSummary?: string; context: string[]; tail?: string }) { + const source = input.tail + ? "the conversation history above and the serialized recent conversation tail below" + : "the conversation history above" const anchor = input.previousSummary ? [ - "Update the anchored summary below using the conversation history above.", + `Update the anchored summary below using ${source}.`, "Preserve still-true details, remove stale details, and merge in the new facts.", "", input.previousSummary, "", ].join("\n") - : "Create a new anchored summary from the conversation history above." - return [anchor, SUMMARY_TEMPLATE, ...input.context].join("\n\n") + : `Create a new anchored summary from ${source}.` + const tail = input.tail + ? [ + "Fold this serialized recent conversation tail into the summary; it is not provider message history.", + "", + input.tail, + "", + ].join("\n") + : undefined + return [anchor, ...(tail ? [tail] : []), SUMMARY_TEMPLATE, ...input.context].join("\n\n") } +const serialize = Effect.fn("SessionCompaction.serialize")(function* (input: { + messages: MessageV2.WithParts[] + model: Provider.Model +}) { + const messages = yield* MessageV2.toModelMessagesEffect(input.messages, input.model, { + stripMedia: true, + toolOutputMaxChars: TOOL_OUTPUT_MAX_CHARS, + }) + return messages.length ? JSON.stringify(messages, null, 2) : undefined +}) + function preserveRecentBudget(input: { cfg: Config.Info; model: Provider.Model }) { return ( input.cfg.compaction?.preserve_recent_tokens ?? @@ -150,7 +170,6 @@ function turns(messages: MessageV2.WithParts[]) { result.push({ start: i, end: messages.length, - id: msg.info.id, }) } for (let i = 0; i < result.length - 1; i++) { @@ -177,7 +196,6 @@ function splitTurn(input: { if (size > input.budget) continue return { start, - id: input.messages[start]!.info.id, } satisfies Tail } return undefined @@ -208,6 +226,8 @@ export interface Interface { export class Service extends Context.Service()("@opencode/SessionCompaction") {} +export const use = serviceUse(Service) + export const layer: Layer.Layer< Service, never, @@ -218,6 +238,7 @@ export const layer: Layer.Layer< | Plugin.Service | SessionProcessor.Service | Provider.Service + | SyncEvent.Service > = Layer.effect( Service, Effect.gen(function* () { @@ -228,6 +249,7 @@ export const layer: Layer.Layer< const plugin = yield* Plugin.Service const processors = yield* SessionProcessor.Service const provider = yield* Provider.Service + const sync = yield* SyncEvent.Service const isOverflow = Effect.fn("SessionCompaction.isOverflow")(function* (input: { tokens: MessageV2.Assistant["tokens"] @@ -240,8 +262,7 @@ export const layer: Layer.Layer< messages: MessageV2.WithParts[] model: Provider.Model }) { - const msgs = yield* MessageV2.toModelMessagesEffect(input.messages, input.model) - return Token.estimate(JSON.stringify(msgs)) + return Token.estimate((yield* serialize(input)) ?? "") }) const select = Effect.fn("SessionCompaction.select")(function* (input: { @@ -250,10 +271,10 @@ export const layer: Layer.Layer< model: Provider.Model }) { const limit = input.cfg.compaction?.tail_turns ?? DEFAULT_TAIL_TURNS - if (limit <= 0) return { head: input.messages, tail_start_id: undefined } + if (limit <= 0) return { head: input.messages, tail: [] } const budget = preserveRecentBudget({ cfg: input.cfg, model: input.model }) const all = turns(input.messages) - if (!all.length) return { head: input.messages, tail_start_id: undefined } + if (!all.length) return { head: input.messages, tail: [] } const recent = all.slice(-limit) const sizes = yield* Effect.forEach( recent, @@ -272,7 +293,7 @@ export const layer: Layer.Layer< const size = sizes[i] if (total + size <= budget) { total += size - keep = { start: turn.start, id: turn.id } + keep = { start: turn.start } continue } const remaining = budget - total @@ -288,10 +309,10 @@ export const layer: Layer.Layer< break } - if (!keep || keep.start === 0) return { head: input.messages, tail_start_id: undefined } + if (!keep) return { head: input.messages, tail: [] } return { head: input.messages.slice(0, keep.start), - tail_start_id: keep.id, + tail: input.messages.slice(keep.start), } }) @@ -402,7 +423,10 @@ export const layer: Layer.Layer< { sessionID: input.sessionID }, { context: [], prompt: undefined }, ) - const nextPrompt = compacting.prompt ?? buildPrompt({ previousSummary, context: compacting.context }) + const tailMessages = structuredClone(selected.tail) + yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: tailMessages }) + const tail = yield* serialize({ messages: tailMessages, model }) + const nextPrompt = compacting.prompt ?? buildPrompt({ previousSummary, context: compacting.context, tail }) const msgs = structuredClone(selected.head) yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs }) const modelMessages = yield* MessageV2.toModelMessagesEffect(msgs, model, { @@ -469,13 +493,6 @@ export const layer: Layer.Layer< return "stop" } - if (compactionPart && selected.tail_start_id && compactionPart.tail_start_id !== selected.tail_start_id) { - yield* session.updatePart({ - ...compactionPart, - tail_start_id: selected.tail_start_id, - }) - } - if (result === "continue" && input.auto) { if (replay) { const original = replay.info @@ -566,12 +583,13 @@ export const layer: Layer.Layer< parts: [], }, ) - EventV2.run(SessionEvent.Compaction.Ended.Sync, { - sessionID: input.sessionID, - timestamp: DateTime.makeUnsafe(Date.now()), - text: summary ?? "", - include: selected.tail_start_id, - }) + if (Flag.OPENCODE_EXPERIMENTAL_EVENT_SYSTEM) { + yield* sync.run(SessionEvent.Compaction.Ended.Sync, { + sessionID: input.sessionID, + timestamp: DateTime.makeUnsafe(Date.now()), + text: summary ?? "", + }) + } yield* bus.publish(Event.Compacted, { sessionID: input.sessionID }) } return result @@ -600,11 +618,13 @@ export const layer: Layer.Layer< auto: input.auto, overflow: input.overflow, }) - EventV2.run(SessionEvent.Compaction.Started.Sync, { - sessionID: input.sessionID, - timestamp: DateTime.makeUnsafe(Date.now()), - reason: input.auto ? "auto" : "manual", - }) + if (Flag.OPENCODE_EXPERIMENTAL_EVENT_SYSTEM) { + yield* sync.run(SessionEvent.Compaction.Started.Sync, { + sessionID: input.sessionID, + timestamp: DateTime.makeUnsafe(Date.now()), + reason: input.auto ? "auto" : "manual", + }) + } }) return Service.of({ @@ -625,6 +645,7 @@ export const defaultLayer = Layer.suspend(() => Layer.provide(Plugin.defaultLayer), Layer.provide(Bus.layer), Layer.provide(Config.defaultLayer), + Layer.provide(SyncEvent.defaultLayer), ), ) @@ -638,15 +659,4 @@ export async function prune(input: { sessionID: SessionID }) { return runPromise((svc) => svc.prune(input)) } -export const create = fn( - z.object({ - sessionID: SessionID.zod, - agent: z.string(), - model: z.object({ providerID: ProviderID.zod, modelID: ModelID.zod }), - auto: z.boolean(), - overflow: z.boolean().optional(), - }), - (input) => runPromise((svc) => svc.create(input)), -) - export * as SessionCompaction from "./compaction" diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index e76583f2d3..c7990d1b35 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -225,6 +225,7 @@ const live: Layer.Layer< execute: async () => ({ output: "", title: "", metadata: {} }), }) } + const sortedTools = Object.fromEntries(Object.entries(tools).toSorted(([a], [b]) => a.localeCompare(b))) // Wire up toolExecutor for DWS workflow models so that tool calls // from the workflow service are executed via opencode's tool system @@ -238,7 +239,7 @@ const live: Layer.Layer< workflowModel.sessionID = input.sessionID workflowModel.systemPrompt = system.join("\n") workflowModel.toolExecutor = async (toolName, argsJson, _requestID) => { - const t = tools[toolName] + const t = sortedTools[toolName] if (!t || !t.execute) { return { result: "", error: `Unknown tool: ${toolName}` } } @@ -260,7 +261,7 @@ const live: Layer.Layer< } const ruleset = Permission.merge(input.agent.permission ?? [], input.permission ?? []) - workflowModel.sessionPreapprovedTools = Object.keys(tools).filter((name) => { + workflowModel.sessionPreapprovedTools = Object.keys(sortedTools).filter((name) => { const match = ruleset.findLast((rule) => Wildcard.match(name, rule.permission)) return !match || match.action !== "ask" }) @@ -341,7 +342,7 @@ const live: Layer.Layer< }, async experimental_repairToolCall(failed) { const lower = failed.toolCall.toolName.toLowerCase() - if (lower !== failed.toolCall.toolName && tools[lower]) { + if (lower !== failed.toolCall.toolName && sortedTools[lower]) { l.info("repairing tool call", { tool: failed.toolCall.toolName, repaired: lower, @@ -364,8 +365,8 @@ const live: Layer.Layer< topP: params.topP, topK: params.topK, providerOptions: ProviderTransform.providerOptions(input.model, params.options), - activeTools: Object.keys(tools).filter((x) => x !== "invalid"), - tools, + activeTools: Object.keys(sortedTools).filter((x) => x !== "invalid"), + tools: sortedTools, toolChoice: input.toolChoice, maxOutputTokens: params.maxOutputTokens, abortSignal: input.abort, diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 2930dbaeb3..e3539021b0 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -23,8 +23,8 @@ import type { SystemError } from "bun" import type { Provider } from "@/provider/provider" import { ModelID, ProviderID } from "@/provider/schema" import { Effect, Schema, Types } from "effect" -import { zod, ZodOverride } from "@/util/effect-zod" -import { NonNegativeInt, withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { NonNegativeInt, withStatics } from "@opencode-ai/core/schema" import { namedSchemaError } from "@/util/named-schema-error" import * as EffectLogger from "@opencode-ai/core/effect/logger" @@ -143,8 +143,8 @@ export type ReasoningPart = Types.DeepMutable ({ zod: zod(s) }))) export type User = Types.DeepMutable> -const _Part = Schema.Union([ +export const Part = Schema.Union([ TextPart, SubtaskPart, ReasoningPart, @@ -416,22 +416,6 @@ const _Part = Schema.Union([ RetryPart, CompactionPart, ]).annotate({ discriminator: "type", identifier: "Part" }) -export const Part = Object.assign(_Part, { - zod: zod(_Part) as unknown as z.ZodType< - | TextPart - | SubtaskPart - | ReasoningPart - | FilePart - | ToolPart - | StepStartPart - | StepFinishPart - | SnapshotPart - | PatchPart - | AgentPart - | RetryPart - | CompactionPart - >, -}) export type Part = | TextPart | SubtaskPart @@ -446,19 +430,6 @@ export type Part = | RetryPart | CompactionPart -// Zod discriminated union kept for the legacy Hono OpenAPI path. -const AssistantErrorZod = z.discriminatedUnion("name", [ - AuthError.Schema, - NamedError.Unknown.Schema, - OutputLengthError.Schema, - AbortedError.Schema, - StructuredOutputError.Schema, - ContextOverflowError.Schema, - APIError.Schema, -]) -type AssistantError = z.infer - -// Effect Schema for the same union — used by HttpApi OpenAPI generation. const AssistantErrorSchema = Schema.Union([ AuthError.EffectSchema, Schema.Struct({ name: Schema.Literal("UnknownError"), data: Schema.Struct({ message: Schema.String }) }).annotate({ @@ -470,6 +441,7 @@ const AssistantErrorSchema = Schema.Union([ ContextOverflowError.EffectSchema, APIError.EffectSchema, ]).annotate({ discriminator: "name" }) +type AssistantError = Schema.Schema.Type // ── Prompt input schemas ───────────────────────────────────────────────────── // @@ -566,13 +538,13 @@ export const Assistant = Schema.Struct({ summary: Schema.optional(Schema.Boolean), cost: Schema.Finite, tokens: Schema.Struct({ - total: Schema.optional(NonNegativeInt), - input: NonNegativeInt, - output: NonNegativeInt, - reasoning: NonNegativeInt, + total: Schema.optional(Schema.Finite), + input: Schema.Finite, + output: Schema.Finite, + reasoning: Schema.Finite, cache: Schema.Struct({ - read: NonNegativeInt, - write: NonNegativeInt, + read: Schema.Finite, + write: Schema.Finite, }), }), structured: Schema.optional(Schema.Any), @@ -585,15 +557,12 @@ export type Assistant = Omit, -}) +export const Info = Schema.Union([User, Assistant]).annotate({ discriminator: "role", identifier: "Message" }) export type Info = User | Assistant const UpdatedEventSchema = Schema.Struct({ sessionID: SessionID, - info: _Info, + info: Info, }) const RemovedEventSchema = Schema.Struct({ @@ -603,7 +572,7 @@ const RemovedEventSchema = Schema.Struct({ const PartUpdatedEventSchema = Schema.Struct({ sessionID: SessionID, - part: _Part, + part: Part, time: NonNegativeInt, }) @@ -651,8 +620,8 @@ export const Event = { } export const WithParts = Schema.Struct({ - info: _Info, - parts: Schema.Array(_Part), + info: Info, + parts: Schema.Array(Part), }).pipe(withStatics((s) => ({ zod: zod(s) }))) export type WithParts = { info: Info @@ -871,12 +840,13 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* ( return part.metadata?.anthropic?.signature != null }) for (const part of msg.parts) { + if (msg.info.summary && part.type !== "text") continue if (part.type === "text") { const text = part.text === "" && hasSignedReasoning ? " " : part.text assistantMessage.parts.push({ type: "text", text, - ...(differentModel ? {} : { providerMetadata: part.metadata }), + ...(differentModel || msg.info.summary ? {} : { providerMetadata: part.metadata }), }) } if (part.type === "step-start") @@ -1102,53 +1072,16 @@ export function get(input: { sessionID: SessionID; messageID: MessageID }): With export function filterCompacted(msgs: Iterable) { const result = [] as WithParts[] const completed = new Set() - let retain: MessageID | undefined for (const msg of msgs) { result.push(msg) - if (retain) { - if (msg.info.id === retain) break - continue - } if (msg.info.role === "user" && completed.has(msg.info.id)) { - const part = msg.parts.find((item): item is CompactionPart => item.type === "compaction") - if (!part) continue - if (!part.tail_start_id) break - retain = part.tail_start_id - if (msg.info.id === retain) break + if (msg.parts.some((item): item is CompactionPart => item.type === "compaction")) break continue } - if (msg.info.role === "user" && completed.has(msg.info.id) && msg.parts.some((part) => part.type === "compaction")) - break if (msg.info.role === "assistant" && msg.info.summary && msg.info.finish && !msg.info.error) completed.add(msg.info.parentID) } result.reverse() - const compactionIndex = result.findLastIndex( - (msg) => - msg.info.role === "user" && - msg.parts.some((item): item is CompactionPart => item.type === "compaction" && item.tail_start_id !== undefined), - ) - const compaction = result[compactionIndex] - const part = compaction?.parts.find( - (item): item is CompactionPart => item.type === "compaction" && item.tail_start_id !== undefined, - ) - const summaryIndex = compaction - ? result.findIndex( - (msg, index) => - index > compactionIndex && - msg.info.role === "assistant" && - msg.info.summary && - msg.info.parentID === compaction.info.id, - ) - : -1 - const tailIndex = part?.tail_start_id ? result.findIndex((msg) => msg.info.id === part.tail_start_id) : -1 - if (tailIndex >= 0 && tailIndex < compactionIndex && summaryIndex > compactionIndex) { - return [ - ...result.slice(compactionIndex, summaryIndex + 1), - ...result.slice(tailIndex, compactionIndex), - ...result.slice(summaryIndex + 1), - ] - } return result } diff --git a/packages/opencode/src/session/message.ts b/packages/opencode/src/session/message.ts index 9d67c48686..32a815db14 100644 --- a/packages/opencode/src/session/message.ts +++ b/packages/opencode/src/session/message.ts @@ -1,8 +1,8 @@ import { Schema } from "effect" import { SessionID } from "./schema" import { ModelID, ProviderID } from "../provider/schema" -import { zod } from "@/util/effect-zod" -import { NonNegativeInt, withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { NonNegativeInt, withStatics } from "@opencode-ai/core/schema" import { namedSchemaError } from "@/util/named-schema-error" export const OutputLengthError = namedSchemaError("MessageOutputLengthError", {}) @@ -172,12 +172,12 @@ export const Info = Schema.Struct({ cost: Schema.Finite, summary: Schema.optional(Schema.Boolean), tokens: Schema.Struct({ - input: NonNegativeInt, - output: NonNegativeInt, - reasoning: NonNegativeInt, + input: Schema.Finite, + output: Schema.Finite, + reasoning: Schema.Finite, cache: Schema.Struct({ - read: NonNegativeInt, - write: NonNegativeInt, + read: Schema.Finite, + write: Schema.Finite, }), }), }), diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 66a2d47975..579c4cc42c 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -1,4 +1,4 @@ -import { Cause, Deferred, Effect, Layer, Context, Scope } from "effect" +import { Cause, Deferred, Effect, Exit, Layer, Context, Scope } from "effect" import * as Stream from "effect/Stream" import { Agent } from "@/agent/agent" import { Bus } from "@/bus" @@ -9,6 +9,7 @@ import { Snapshot } from "@/snapshot" import * as Session from "./session" import { LLM } from "./llm" import { MessageV2 } from "./message-v2" +import { Image } from "@/image/image" import { isOverflow } from "./overflow" import { PartID } from "./schema" import type { SessionID } from "./schema" @@ -20,10 +21,11 @@ import { Question } from "@/question" import { errorMessage } from "@/util/error" import * as Log from "@opencode-ai/core/util/log" import { isRecord } from "@/util/record" -import { EventV2 } from "@/v2/event" +import { SyncEvent } from "@/sync" import { SessionEvent } from "@/v2/session-event" import { Modelv2 } from "@/v2/model" import * as DateTime from "effect/DateTime" +import { Flag } from "@opencode-ai/core/flag/flag" const DOOM_LOOP_THRESHOLD = 3 const log = Log.create({ service: "session.processor" }) @@ -92,8 +94,10 @@ export const layer: Layer.Layer< | LLM.Service | Permission.Service | Plugin.Service + | Image.Service | SessionSummary.Service | SessionStatus.Service + | SyncEvent.Service > = Layer.effect( Service, Effect.gen(function* () { @@ -108,6 +112,8 @@ export const layer: Layer.Layer< const summary = yield* SessionSummary.Service const scope = yield* Scope.Scope const status = yield* SessionStatus.Service + const image = yield* Image.Service + const sync = yield* SyncEvent.Service const create = Effect.fn("SessionProcessor.create")(function* (input: Input) { // Pre-capture snapshot before the LLM stream starts. The AI SDK @@ -226,11 +232,13 @@ export const layer: Layer.Layer< case "reasoning-start": if (value.id in ctx.reasoningMap) return // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - EventV2.run(SessionEvent.Reasoning.Started.Sync, { - sessionID: ctx.sessionID, - reasoningID: value.id, - timestamp: DateTime.makeUnsafe(Date.now()), - }) + if (Flag.OPENCODE_EXPERIMENTAL_EVENT_SYSTEM) { + yield* sync.run(SessionEvent.Reasoning.Started.Sync, { + sessionID: ctx.sessionID, + reasoningID: value.id, + timestamp: DateTime.makeUnsafe(Date.now()), + }) + } ctx.reasoningMap[value.id] = { id: PartID.ascending(), messageID: ctx.assistantMessage.id, @@ -259,12 +267,14 @@ export const layer: Layer.Layer< case "reasoning-end": if (!(value.id in ctx.reasoningMap)) return // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - EventV2.run(SessionEvent.Reasoning.Ended.Sync, { - sessionID: ctx.sessionID, - reasoningID: value.id, - text: ctx.reasoningMap[value.id].text, - timestamp: DateTime.makeUnsafe(Date.now()), - }) + if (Flag.OPENCODE_EXPERIMENTAL_EVENT_SYSTEM) { + yield* sync.run(SessionEvent.Reasoning.Ended.Sync, { + sessionID: ctx.sessionID, + reasoningID: value.id, + text: ctx.reasoningMap[value.id].text, + timestamp: DateTime.makeUnsafe(Date.now()), + }) + } // oxlint-disable-next-line no-self-assign -- reactivity trigger ctx.reasoningMap[value.id].text = ctx.reasoningMap[value.id].text ctx.reasoningMap[value.id].time = { ...ctx.reasoningMap[value.id].time, end: Date.now() } @@ -278,12 +288,14 @@ export const layer: Layer.Layer< throw new Error(`Tool call not allowed while generating summary: ${value.toolName}`) } // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - EventV2.run(SessionEvent.Tool.Input.Started.Sync, { - sessionID: ctx.sessionID, - callID: value.id, - name: value.toolName, - timestamp: DateTime.makeUnsafe(Date.now()), - }) + if (Flag.OPENCODE_EXPERIMENTAL_EVENT_SYSTEM) { + yield* sync.run(SessionEvent.Tool.Input.Started.Sync, { + sessionID: ctx.sessionID, + callID: value.id, + name: value.toolName, + timestamp: DateTime.makeUnsafe(Date.now()), + }) + } const part = yield* session.updatePart({ id: ctx.toolcalls[value.id]?.partID ?? PartID.ascending(), messageID: ctx.assistantMessage.id, @@ -307,12 +319,14 @@ export const layer: Layer.Layer< case "tool-input-end": { // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - EventV2.run(SessionEvent.Tool.Input.Ended.Sync, { - sessionID: ctx.sessionID, - callID: value.id, - text: "", - timestamp: DateTime.makeUnsafe(Date.now()), - }) + if (Flag.OPENCODE_EXPERIMENTAL_EVENT_SYSTEM) { + yield* sync.run(SessionEvent.Tool.Input.Ended.Sync, { + sessionID: ctx.sessionID, + callID: value.id, + text: "", + timestamp: DateTime.makeUnsafe(Date.now()), + }) + } return } @@ -322,17 +336,19 @@ export const layer: Layer.Layer< } const toolCall = yield* readToolCall(value.toolCallId) // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - EventV2.run(SessionEvent.Tool.Called.Sync, { - sessionID: ctx.sessionID, - callID: value.toolCallId, - tool: value.toolName, - input: value.input, - provider: { - executed: toolCall?.part.metadata?.providerExecuted === true, - ...(value.providerMetadata ? { metadata: value.providerMetadata } : {}), - }, - timestamp: DateTime.makeUnsafe(Date.now()), - }) + if (Flag.OPENCODE_EXPERIMENTAL_EVENT_SYSTEM) { + yield* sync.run(SessionEvent.Tool.Called.Sync, { + sessionID: ctx.sessionID, + callID: value.toolCallId, + tool: value.toolName, + input: value.input, + provider: { + executed: toolCall?.part.metadata?.providerExecuted === true, + ...(value.providerMetadata ? { metadata: value.providerMetadata } : {}), + }, + timestamp: DateTime.makeUnsafe(Date.now()), + }) + } yield* updateToolCall(value.toolCallId, (match) => ({ ...match, tool: value.toolName, @@ -377,47 +393,75 @@ export const layer: Layer.Layer< case "tool-result": { const toolCall = yield* readToolCall(value.toolCallId) + const toolAttachments: MessageV2.FilePart[] = ( + Array.isArray(value.output.attachments) ? value.output.attachments : [] + ).filter( + (attachment: unknown): attachment is MessageV2.FilePart => + isRecord(attachment) && + attachment.type === "file" && + typeof attachment.mime === "string" && + typeof attachment.url === "string", + ) + const normalized = yield* Effect.forEach(toolAttachments, (attachment) => + attachment.mime.startsWith("image/") + ? image.normalize(attachment).pipe(Effect.exit) + : Effect.succeed(Exit.succeed(attachment)), + ) + const omitted = normalized.filter(Exit.isFailure).length + const attachments = normalized.filter(Exit.isSuccess).map((item) => item.value) + const output = { + ...value.output, + output: + omitted === 0 + ? value.output.output + : `${value.output.output}\n\n[${omitted} image${omitted === 1 ? "" : "s"} omitted: could not be resized below the inline image size limit.]`, + attachments: attachments?.length ? attachments : undefined, + } // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - EventV2.run(SessionEvent.Tool.Success.Sync, { - sessionID: ctx.sessionID, - callID: value.toolCallId, - structured: value.output.metadata, - content: [ - { - type: "text", - text: value.output.output, + if (Flag.OPENCODE_EXPERIMENTAL_EVENT_SYSTEM) { + yield* sync.run(SessionEvent.Tool.Success.Sync, { + sessionID: ctx.sessionID, + callID: value.toolCallId, + structured: output.metadata, + content: [ + { + type: "text", + text: output.output, + }, + ...(output.attachments?.map((item: MessageV2.FilePart) => ({ + type: "file", + uri: item.url, + mime: item.mime, + name: item.filename, + })) ?? []), + ], + provider: { + executed: toolCall?.part.metadata?.providerExecuted === true, }, - ...(value.output.attachments?.map((item: MessageV2.FilePart) => ({ - type: "file", - uri: item.url, - mime: item.mime, - name: item.filename, - })) ?? []), - ], - provider: { - executed: toolCall?.part.metadata?.providerExecuted === true, - }, - timestamp: DateTime.makeUnsafe(Date.now()), - }) - yield* completeToolCall(value.toolCallId, value.output) + timestamp: DateTime.makeUnsafe(Date.now()), + }) + } + yield* completeToolCall(value.toolCallId, output) return } case "tool-error": { const toolCall = yield* readToolCall(value.toolCallId) // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - EventV2.run(SessionEvent.Tool.Failed.Sync, { - sessionID: ctx.sessionID, - callID: value.toolCallId, - error: { - type: "unknown", - message: errorMessage(value.error), - }, - provider: { - executed: toolCall?.part.metadata?.providerExecuted === true, - }, - timestamp: DateTime.makeUnsafe(Date.now()), - }) + if (Flag.OPENCODE_EXPERIMENTAL_EVENT_SYSTEM) { + yield* sync.run(SessionEvent.Tool.Failed.Sync, { + sessionID: ctx.sessionID, + callID: value.toolCallId, + error: { + type: "unknown", + message: errorMessage(value.error), + }, + provider: { + executed: toolCall?.part.metadata?.providerExecuted === true, + }, + timestamp: DateTime.makeUnsafe(Date.now()), + }) + } yield* failToolCall(value.toolCallId, value.error) return } @@ -429,17 +473,19 @@ export const layer: Layer.Layer< if (!ctx.snapshot) ctx.snapshot = yield* snapshot.track() if (!ctx.assistantMessage.summary) { // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - EventV2.run(SessionEvent.Step.Started.Sync, { - sessionID: ctx.sessionID, - agent: input.assistantMessage.agent, - model: { - id: Modelv2.ID.make(ctx.model.id), - providerID: Modelv2.ProviderID.make(ctx.model.providerID), - variant: Modelv2.VariantID.make(input.assistantMessage.variant ?? "default"), - }, - snapshot: ctx.snapshot, - timestamp: DateTime.makeUnsafe(Date.now()), - }) + if (Flag.OPENCODE_EXPERIMENTAL_EVENT_SYSTEM) { + yield* sync.run(SessionEvent.Step.Started.Sync, { + sessionID: ctx.sessionID, + agent: input.assistantMessage.agent, + model: { + id: Modelv2.ID.make(ctx.model.id), + providerID: Modelv2.ProviderID.make(ctx.model.providerID), + variant: Modelv2.VariantID.make(input.assistantMessage.variant ?? "default"), + }, + snapshot: ctx.snapshot, + timestamp: DateTime.makeUnsafe(Date.now()), + }) + } } yield* session.updatePart({ id: PartID.ascending(), @@ -459,14 +505,16 @@ export const layer: Layer.Layer< }) if (!ctx.assistantMessage.summary) { // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - EventV2.run(SessionEvent.Step.Ended.Sync, { - sessionID: ctx.sessionID, - finish: value.finishReason, - cost: usage.cost, - tokens: usage.tokens, - snapshot: completedSnapshot, - timestamp: DateTime.makeUnsafe(Date.now()), - }) + if (Flag.OPENCODE_EXPERIMENTAL_EVENT_SYSTEM) { + yield* sync.run(SessionEvent.Step.Ended.Sync, { + sessionID: ctx.sessionID, + finish: value.finishReason, + cost: usage.cost, + tokens: usage.tokens, + snapshot: completedSnapshot, + timestamp: DateTime.makeUnsafe(Date.now()), + }) + } } ctx.assistantMessage.finish = value.finishReason ctx.assistantMessage.cost += usage.cost @@ -514,10 +562,12 @@ export const layer: Layer.Layer< case "text-start": if (!ctx.assistantMessage.summary) { // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - EventV2.run(SessionEvent.Text.Started.Sync, { - sessionID: ctx.sessionID, - timestamp: DateTime.makeUnsafe(Date.now()), - }) + if (Flag.OPENCODE_EXPERIMENTAL_EVENT_SYSTEM) { + yield* sync.run(SessionEvent.Text.Started.Sync, { + sessionID: ctx.sessionID, + timestamp: DateTime.makeUnsafe(Date.now()), + }) + } } ctx.currentText = { id: PartID.ascending(), @@ -559,11 +609,13 @@ export const layer: Layer.Layer< )).text if (!ctx.assistantMessage.summary) { // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - EventV2.run(SessionEvent.Text.Ended.Sync, { - sessionID: ctx.sessionID, - text: ctx.currentText.text, - timestamp: DateTime.makeUnsafe(Date.now()), - }) + if (Flag.OPENCODE_EXPERIMENTAL_EVENT_SYSTEM) { + yield* sync.run(SessionEvent.Text.Ended.Sync, { + sessionID: ctx.sessionID, + text: ctx.currentText.text, + timestamp: DateTime.makeUnsafe(Date.now()), + }) + } } { const end = Date.now() @@ -653,14 +705,16 @@ export const layer: Layer.Layer< } if (!ctx.assistantMessage.summary) { // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - EventV2.run(SessionEvent.Step.Failed.Sync, { - sessionID: ctx.sessionID, - error: { - type: "unknown", - message: errorMessage(e), - }, - timestamp: DateTime.makeUnsafe(Date.now()), - }) + if (Flag.OPENCODE_EXPERIMENTAL_EVENT_SYSTEM) { + yield* sync.run(SessionEvent.Step.Failed.Sync, { + sessionID: ctx.sessionID, + error: { + type: "unknown", + message: errorMessage(e), + }, + timestamp: DateTime.makeUnsafe(Date.now()), + }) + } } ctx.assistantMessage.error = error yield* bus.publish(Session.Event.Error, { @@ -701,25 +755,32 @@ export const layer: Layer.Layer< ), Effect.retry( SessionRetry.policy({ + provider: input.model.providerID, parse, set: (info) => { // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - EventV2.run(SessionEvent.Retried.Sync, { - sessionID: ctx.sessionID, - attempt: info.attempt, - error: { - message: info.message, - isRetryable: true, - }, - timestamp: DateTime.makeUnsafe(Date.now()), - }) - return status.set(ctx.sessionID, { - type: "retry", - attempt: info.attempt, - message: info.message, - action: info.action, - next: info.next, - }) + const event = Flag.OPENCODE_EXPERIMENTAL_EVENT_SYSTEM + ? sync.run(SessionEvent.Retried.Sync, { + sessionID: ctx.sessionID, + attempt: info.attempt, + error: { + message: info.message, + isRetryable: true, + }, + timestamp: DateTime.makeUnsafe(Date.now()), + }) + : Effect.void + return event.pipe( + Effect.andThen( + status.set(ctx.sessionID, { + type: "retry", + attempt: info.attempt, + message: info.message, + action: info.action, + next: info.next, + }), + ), + ) }, }), ), @@ -757,8 +818,10 @@ export const defaultLayer = Layer.suspend(() => Layer.provide(Plugin.defaultLayer), Layer.provide(SessionSummary.defaultLayer), Layer.provide(SessionStatus.defaultLayer), + Layer.provide(Image.defaultLayer), Layer.provide(Bus.layer), Layer.provide(Config.defaultLayer), + Layer.provide(SyncEvent.defaultLayer), ), ) diff --git a/packages/opencode/src/session/projectors.ts b/packages/opencode/src/session/projectors.ts index 9819ad810f..93acd4546d 100644 --- a/packages/opencode/src/session/projectors.ts +++ b/packages/opencode/src/session/projectors.ts @@ -5,6 +5,7 @@ import { SyncEvent } from "@/sync" import * as Session from "./session" import { MessageV2 } from "./message-v2" import { SessionTable, MessageTable, PartTable } from "./session.sql" +import { WorkspaceTable } from "@/control-plane/workspace.sql" import { Log } from "@opencode-ai/core/util/log" import nextProjectors from "./projectors-next" @@ -69,6 +70,10 @@ export default [ db.insert(SessionTable) .values(Session.toRow(data.info as Session.Info)) .run() + + if (data.info.workspaceID) { + db.update(WorkspaceTable).set({ time_used: Date.now() }).where(eq(WorkspaceTable.id, data.info.workspaceID)).run() + } }), SyncEvent.project(Session.Event.Updated, (db, data) => { diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index fef8c43836..3b919e2f0a 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1,6 +1,6 @@ import path from "path" import os from "os" -import * as EffectZod from "@/util/effect-zod" +import * as EffectZod from "@opencode-ai/core/effect-zod" import { SessionID, MessageID, PartID } from "./schema" import { MessageV2 } from "./message-v2" import * as Log from "@opencode-ai/core/util/log" @@ -46,17 +46,18 @@ import { Truncate } from "@/tool/truncate" import { decodeDataUrl } from "@/util/data-url" import { Process } from "@/util/process" import { Cause, Effect, Exit, Latch, Layer, Option, Scope, Context, Schema, Types } from "effect" -import { zod } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" import * as EffectLogger from "@opencode-ai/core/effect/logger" import { InstanceState } from "@/effect/instance-state" import { TaskTool, type TaskPromptOps } from "@/tool/task" import { SessionRunState } from "./run-state" import { EffectBridge } from "@/effect/bridge" -import { EventV2 } from "@/v2/event" +import { SyncEvent } from "@/sync" import { SessionEvent } from "@/v2/session-event" import { Modelv2 } from "@/v2/model" -import { AgentAttachment, FileAttachment, Source } from "@/v2/session-prompt" +import { AgentAttachment, FileAttachment, ReferenceAttachment, Source } from "@/v2/session-prompt" +import { Reference } from "@/reference/reference" import * as DateTime from "effect/DateTime" import { eq } from "@/storage/db" import * as Database from "@/storage/db" @@ -65,6 +66,9 @@ import { SessionTable } from "./session.sql" // @ts-ignore globalThis.AI_SDK_LOG_WARNINGS = false +const decodeMessageInfo = Schema.decodeUnknownExit(MessageV2.Info) +const decodeMessagePart = Schema.decodeUnknownExit(MessageV2.Part) + const STRUCTURED_OUTPUT_DESCRIPTION = `Use this tool to return your final response in the requested structured format. IMPORTANT: @@ -78,6 +82,45 @@ const STRUCTURED_OUTPUT_SYSTEM_PROMPT = `IMPORTANT: The user has requested struc const log = Log.create({ service: "session.prompt" }) const elog = EffectLogger.create({ service: "session.prompt" }) +type ReferencePromptMetadata = { + name: string + kind: "local" | "git" | "invalid" + path?: string + repository?: string + branch?: string + target?: string + targetPath?: string + problem?: string + source: { value: string; start: number; end: number } +} + +function stringField(record: Record, key: string) { + return typeof record[key] === "string" ? record[key] : undefined +} + +function referencePromptMetadata(input: unknown): ReferencePromptMetadata | undefined { + if (!input || typeof input !== "object" || Array.isArray(input)) return + const record = input as Record + const name = stringField(record, "name") + const kind = stringField(record, "kind") + if (!name || (kind !== "local" && kind !== "git" && kind !== "invalid")) return + if (!record.source || typeof record.source !== "object" || Array.isArray(record.source)) return + const source = record.source as Record + const value = stringField(source, "value") + if (!value || typeof source.start !== "number" || typeof source.end !== "number") return + return { + name, + kind, + path: stringField(record, "path"), + repository: stringField(record, "repository"), + branch: stringField(record, "branch"), + target: stringField(record, "target"), + targetPath: stringField(record, "targetPath"), + problem: stringField(record, "problem"), + source: { value, start: source.start, end: source.end }, + } +} + export interface Interface { readonly cancel: (sessionID: SessionID) => Effect.Effect readonly prompt: (input: PromptInput) => Effect.Effect @@ -116,6 +159,8 @@ export const layer = Layer.effect( const summary = yield* SessionSummary.Service const sys = yield* SystemPrompt.Service const llm = yield* LLM.Service + const references = yield* Reference.Service + const sync = yield* SyncEvent.Service const runner = Effect.fn("SessionPrompt.runner")(function* () { return yield* EffectBridge.make() }) @@ -137,12 +182,116 @@ export const layer = Layer.effect( const parts: Types.DeepMutable = [{ type: "text", text: template }] const files = ConfigMarkdown.files(template) const seen = new Set() + const mentionSource = (match: RegExpMatchArray) => { + const start = match.index ?? 0 + return { value: match[0], start, end: start + match[0].length } + } + const referenceTextPart = (input: { + reference: Reference.Resolved + source: ReturnType + target?: string + targetPath?: string + problem?: string + }): MessageV2.TextPartInput => { + const metadata: ReferencePromptMetadata = { + name: input.reference.name, + kind: input.reference.kind, + ...(input.reference.kind === "invalid" + ? { repository: input.reference.repository } + : { path: input.reference.path }), + ...(input.reference.kind === "git" + ? { repository: input.reference.repository, branch: input.reference.branch } + : {}), + ...(input.target === undefined ? {} : { target: input.target }), + ...(input.targetPath ? { targetPath: input.targetPath } : {}), + problem: input.problem ?? (input.reference.kind === "invalid" ? input.reference.message : undefined), + source: input.source, + } + const label = metadata.target === undefined ? `@${metadata.name}` : `@${metadata.name}/${metadata.target}` + return { + type: "text", + synthetic: true, + text: [ + `Referenced configured reference ${label}.`, + ...(metadata.kind === "local" ? ["Kind: local directory"] : []), + ...(metadata.kind === "git" ? ["Kind: git repository"] : []), + ...(metadata.repository ? [`Repository: ${metadata.repository}`] : []), + ...(metadata.branch ? [`Branch/ref: ${metadata.branch}`] : []), + ...(metadata.path ? [`Reference root: ${metadata.path}`] : []), + ...(metadata.targetPath ? [`Resolved path: ${metadata.targetPath}`] : []), + ...(metadata.problem + ? [`Problem: ${metadata.problem}`] + : [ + "For targeted context, inspect the reference path directly with Read, Glob, and Grep. For broader research, call the task tool with subagent scout and include this reference path.", + ]), + ].join("\n"), + metadata: { reference: metadata }, + } + } yield* Effect.forEach( files, Effect.fnUntraced(function* (match) { const name = match[1] + if (!name) return if (seen.has(name)) return seen.add(name) + + const slash = name.indexOf("/") + const alias = slash === -1 ? name : name.slice(0, slash) + const reference = yield* references.get(alias) + if (reference) { + const source = mentionSource(match) + if (reference.kind === "invalid") { + parts.push( + referenceTextPart({ reference, source, target: slash === -1 ? undefined : name.slice(slash + 1) }), + ) + return + } + + yield* references.ensure(reference.path) + if (slash === -1) { + parts.push(referenceTextPart({ reference, source })) + return + } + + const target = name.slice(slash + 1) + const targetPath = path.resolve(reference.path, target) + if (!AppFileSystem.contains(reference.path, targetPath)) { + parts.push( + referenceTextPart({ + reference, + source, + target, + targetPath, + problem: `Path escapes configured reference @${alias}: ${target}`, + }), + ) + return + } + + const info = yield* fsys.stat(targetPath).pipe(Effect.option) + if (Option.isNone(info)) { + parts.push( + referenceTextPart({ + reference, + source, + target, + targetPath, + problem: `Path does not exist inside configured reference @${alias}: ${target}`, + }), + ) + return + } + + parts.push({ + type: "file", + url: pathToFileURL(targetPath).href, + filename: name, + mime: info.value.type === "Directory" ? "application/x-directory" : "text/plain", + }) + return + } + const filepath = name.startsWith("~/") ? path.join(os.homedir(), name.slice(2)) : path.resolve(ctx.worktree, name) @@ -756,7 +905,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the yield* bus.publish(Session.Event.Error, { sessionID: input.sessionID, error: error.toObject() }) throw error } - const model = input.model ?? agent.model ?? (yield* lastModel(input.sessionID)) + const model = input.model ?? agent.model ?? (yield* currentModel(input.sessionID)) const userMsg: MessageV2.User = { id: input.messageID ?? MessageID.ascending(), sessionID: input.sessionID, @@ -807,12 +956,14 @@ NOTE: At any point in time through this workflow you should feel free to ask the }, } yield* sessions.updatePart(part) - EventV2.run(SessionEvent.Shell.Started.Sync, { - sessionID: input.sessionID, - timestamp: DateTime.makeUnsafe(started), - callID, - command: input.command, - }) + if (Flag.OPENCODE_EXPERIMENTAL_EVENT_SYSTEM) { + yield* sync.run(SessionEvent.Shell.Started.Sync, { + sessionID: input.sessionID, + timestamp: DateTime.makeUnsafe(started), + callID, + command: input.command, + }) + } return { msg, part, cwd: ctx.directory } }).pipe(Effect.ensuring(markReady)) @@ -828,12 +979,14 @@ NOTE: At any point in time through this workflow you should feel free to ask the output += "\n\n" + ["", "User aborted the command", ""].join("\n") } const completed = Date.now() - EventV2.run(SessionEvent.Shell.Ended.Sync, { - sessionID: input.sessionID, - timestamp: DateTime.makeUnsafe(completed), - callID: part.callID, - output, - }) + if (Flag.OPENCODE_EXPERIMENTAL_EVENT_SYSTEM) { + yield* sync.run(SessionEvent.Shell.Ended.Sync, { + sessionID: input.sessionID, + timestamp: DateTime.makeUnsafe(completed), + callID: part.callID, + output, + }) + } if (!msg.time.completed) { msg.time.completed = completed yield* sessions.updateMessage(msg) @@ -914,7 +1067,17 @@ NOTE: At any point in time through this workflow you should feel free to ask the return yield* Effect.failCause(exit.cause) }) - const lastModel = Effect.fnUntraced(function* (sessionID: SessionID) { + const currentModel = Effect.fnUntraced(function* (sessionID: SessionID) { + const current = Database.use((db) => + db.select({ model: SessionTable.model }).from(SessionTable).where(eq(SessionTable.id, sessionID)).get(), + ) + if (current?.model) { + return { + providerID: ProviderID.make(current.model.providerID), + modelID: ModelID.make(current.model.id), + ...(current.model.variant && current.model.variant !== "default" ? { variant: current.model.variant } : {}), + } + } const match = yield* sessions.findMessage(sessionID, (m) => m.info.role === "user" && !!m.info.model) if (Option.isSome(match) && match.value.info.role === "user") return match.value.info.model return yield* provider.defaultModel() @@ -931,7 +1094,14 @@ NOTE: At any point in time through this workflow you should feel free to ask the throw error } - const model = input.model ?? ag.model ?? (yield* lastModel(input.sessionID)) + const current = Database.use((db) => + db + .select({ agent: SessionTable.agent, model: SessionTable.model }) + .from(SessionTable) + .where(eq(SessionTable.id, input.sessionID)) + .get(), + ) + const model = input.model ?? ag.model ?? (yield* currentModel(input.sessionID)) const same = ag.model && model.providerID === ag.model.providerID && model.modelID === ag.model.modelID const full = !input.variant && ag.variant && same @@ -955,15 +1125,8 @@ NOTE: At any point in time through this workflow you should feel free to ask the format: input.format, } - const current = Database.use((db) => - db - .select({ agent: SessionTable.agent, model: SessionTable.model }) - .from(SessionTable) - .where(eq(SessionTable.id, input.sessionID)) - .get(), - ) if (current?.agent !== info.agent) { - EventV2.run(SessionEvent.AgentSwitched.Sync, { + yield* sync.run(SessionEvent.AgentSwitched.Sync, { sessionID: input.sessionID, timestamp: DateTime.makeUnsafe(info.time.created), agent: info.agent, @@ -972,9 +1135,9 @@ NOTE: At any point in time through this workflow you should feel free to ask the if ( current?.model?.providerID !== info.model.providerID || current.model.id !== info.model.modelID || - current.model.variant !== info.model.variant + (current.model.variant === "default" ? undefined : current.model.variant) !== info.model.variant ) { - EventV2.run(SessionEvent.ModelSwitched.Sync, { + yield* sync.run(SessionEvent.ModelSwitched.Sync, { sessionID: input.sessionID, timestamp: DateTime.makeUnsafe(info.time.created), model: { @@ -1259,7 +1422,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the return [{ ...part, messageID: info.id, sessionID: input.sessionID }] }) - const parts = yield* Effect.forEach(input.parts, resolvePart, { concurrency: "unbounded" }).pipe( + const resolvedParts = yield* Effect.forEach(input.parts, resolvePart, { concurrency: "unbounded" }).pipe( Effect.map((x) => x.flat().map(assign)), ) @@ -1272,29 +1435,31 @@ NOTE: At any point in time through this workflow you should feel free to ask the messageID: input.messageID, variant: input.variant, }, - { message: info, parts }, + { message: info, parts: resolvedParts }, ) - const parsed = MessageV2.Info.zod.safeParse(info) - if (!parsed.success) { + const parts = resolvedParts + + const parsed = decodeMessageInfo(info, { errors: "all", propertyOrder: "original" }) + if (Exit.isFailure(parsed)) { log.error("invalid user message before save", { sessionID: input.sessionID, messageID: info.id, agent: info.agent, model: info.model, - issues: parsed.error.issues, + cause: Cause.pretty(parsed.cause), }) } parts.forEach((part, index) => { - const p = MessageV2.Part.zod.safeParse(part) - if (p.success) return + const p = decodeMessagePart(part, { errors: "all", propertyOrder: "original" }) + if (Exit.isSuccess(p)) return log.error("invalid user part before save", { sessionID: input.sessionID, messageID: info.id, partID: part.id, partType: part.type, index, - issues: p.error.issues, + cause: Cause.pretty(p.cause), part, }) }) @@ -1306,6 +1471,26 @@ NOTE: At any point in time through this workflow you should feel free to ask the if (part.type === "text") { if (part.synthetic) result.synthetic.push(part.text) else result.text.push(part.text) + const reference = referencePromptMetadata(part.metadata?.reference) + if (reference) { + result.references.push( + new ReferenceAttachment({ + name: reference.name, + kind: reference.kind, + uri: reference.path ? pathToFileURL(reference.path).href : undefined, + repository: reference.repository, + branch: reference.branch, + target: reference.target, + targetUri: reference.targetPath ? pathToFileURL(reference.targetPath).href : undefined, + problem: reference.problem, + source: new Source({ + start: reference.source.start, + end: reference.source.end, + text: reference.source.value, + }), + }), + ) + } } if (part.type === "file") { result.files.push( @@ -1343,27 +1528,33 @@ NOTE: At any point in time through this workflow you should feel free to ask the text: [] as string[], files: [] as FileAttachment[], agents: [] as AgentAttachment[], + references: [] as ReferenceAttachment[], synthetic: [] as string[], }, ) // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - EventV2.run(SessionEvent.Prompted.Sync, { - sessionID: input.sessionID, - timestamp: DateTime.makeUnsafe(info.time.created), - prompt: { - text: nextPrompt.text.join("\n"), - files: nextPrompt.files, - agents: nextPrompt.agents, - }, - }) - for (const text of nextPrompt.synthetic) { - // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - EventV2.run(SessionEvent.Synthetic.Sync, { + if (Flag.OPENCODE_EXPERIMENTAL_EVENT_SYSTEM) { + yield* sync.run(SessionEvent.Prompted.Sync, { sessionID: input.sessionID, timestamp: DateTime.makeUnsafe(info.time.created), - text, + prompt: { + text: nextPrompt.text.join("\n"), + files: nextPrompt.files, + agents: nextPrompt.agents, + references: nextPrompt.references, + }, }) } + for (const text of nextPrompt.synthetic) { + // TODO(v2): Temporary dual-write while migrating session messages to v2 events. + if (Flag.OPENCODE_EXPERIMENTAL_EVENT_SYSTEM) { + yield* sync.run(SessionEvent.Synthetic.Sync, { + sessionID: input.sessionID, + timestamp: DateTime.makeUnsafe(info.time.created), + text, + }) + } + } return { info, parts } }, Effect.scoped) @@ -1698,7 +1889,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the if (cmdAgent?.model) return cmdAgent.model } if (input.model) return Provider.parseModel(input.model) - return yield* lastModel(input.sessionID) + return yield* currentModel(input.sessionID) }) yield* getModel(taskModel.providerID, taskModel.modelID, input.sessionID) @@ -1731,7 +1922,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the const userModel = isSubtask ? input.model ? Provider.parseModel(input.model) - : yield* lastModel(input.sessionID) + : yield* currentModel(input.sessionID) : taskModel yield* plugin.trigger( @@ -1793,8 +1984,10 @@ export const defaultLayer = Layer.suspend(() => Agent.defaultLayer, SystemPrompt.defaultLayer, LLM.defaultLayer, + Reference.defaultLayer, Bus.layer, CrossSpawnSpawner.defaultLayer, + SyncEvent.defaultLayer, ), ), ), diff --git a/packages/opencode/src/session/retry.ts b/packages/opencode/src/session/retry.ts index 3bccee212d..1f73dee31f 100644 --- a/packages/opencode/src/session/retry.ts +++ b/packages/opencode/src/session/retry.ts @@ -7,10 +7,13 @@ export type Err = ReturnType export const GO_UPSELL_MESSAGE = "Free usage exceeded, subscribe to Go" export const GO_UPSELL_URL = "https://opencode.ai/go" +export type RetryReason = "free_tier_limit" | "account_rate_limit" | (string & {}) export type Retryable = { message: string action?: { + reason: RetryReason + provider: string title: string message: string label: string @@ -60,7 +63,7 @@ export function delay(attempt: number, error?: MessageV2.APIError) { return cap(Math.min(RETRY_INITIAL_DELAY * Math.pow(RETRY_BACKOFF_FACTOR, attempt - 1), RETRY_MAX_DELAY_NO_HEADERS)) } -export function retryable(error: Err) { +export function retryable(error: Err, provider: string) { // context overflow errors should not be retried if (MessageV2.ContextOverflowError.isInstance(error)) return undefined if (MessageV2.APIError.isInstance(error)) { @@ -72,6 +75,8 @@ export function retryable(error: Err) { return { message: GO_UPSELL_MESSAGE, action: { + reason: "free_tier_limit", + provider, title: "Free limit reached", message: "Subscribe to OpenCode Go for reliable access to the best open-source models, starting at $5/month.", label: "subscribe", @@ -97,12 +102,14 @@ export function retryable(error: Err) { return minutes > 0 ? unit(minutes, "minute") : "less than a minute" }) - const message = `${limitName} usage limit reached. It will reset in ${resetIn}. To continue using this model now, enable usage from your available balance` + const message = `${limitName ? `${limitName} usage limit` : "Usage limit"} reached. It will reset in ${resetIn}. To continue using this model now, enable usage from your available balance` const link = `https://opencode.ai/workspace/${workspace}/go` return { message: `${message} - ${link}`, action: { + reason: "account_rate_limit", + provider, title: "Go limit reached", message, label: "open settings", @@ -165,13 +172,14 @@ function parseJSON(value: unknown) { } export function policy(opts: { + provider: string parse: (error: unknown) => Err set: (input: { attempt: number; message: string; action?: Retryable["action"]; next: number }) => Effect.Effect }) { return Schedule.fromStepWithMetadata( Effect.succeed((meta: Schedule.InputMetadata) => { const error = opts.parse(meta.input) - const retry = retryable(error) + const retry = retryable(error, opts.provider) if (!retry) return Cause.done(meta.attempt) return Effect.gen(function* () { const wait = delay(meta.attempt, MessageV2.APIError.isInstance(error) ? error : undefined) diff --git a/packages/opencode/src/session/revert.ts b/packages/opencode/src/session/revert.ts index abf7c3441f..12c81180eb 100644 --- a/packages/opencode/src/session/revert.ts +++ b/packages/opencode/src/session/revert.ts @@ -4,8 +4,8 @@ import { Snapshot } from "../snapshot" import { Storage } from "@/storage/storage" import { SyncEvent } from "../sync" import * as Log from "@opencode-ai/core/util/log" -import { zod } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" import * as Session from "./session" import { MessageV2 } from "./message-v2" import { SessionID, MessageID, PartID } from "./schema" diff --git a/packages/opencode/src/session/schema.ts b/packages/opencode/src/session/schema.ts index 487cbcd34a..caf8f9d783 100644 --- a/packages/opencode/src/session/schema.ts +++ b/packages/opencode/src/session/schema.ts @@ -1,34 +1,30 @@ import { Schema } from "effect" import { Identifier } from "@/id/id" -import { zod, ZodOverride } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { withStatics } from "@opencode-ai/core/schema" -export const SessionID = Schema.String.annotate({ [ZodOverride]: Identifier.schema("session") }).pipe( +export const SessionID = Schema.String.check(Schema.isStartsWith("ses")).pipe( Schema.brand("SessionID"), withStatics((s) => ({ descending: (id?: string) => s.make(Identifier.descending("session", id)), - zod: zod(s), })), ) export type SessionID = Schema.Schema.Type -export const MessageID = Schema.String.annotate({ [ZodOverride]: Identifier.schema("message") }).pipe( +export const MessageID = Schema.String.check(Schema.isStartsWith("msg")).pipe( Schema.brand("MessageID"), withStatics((s) => ({ ascending: (id?: string) => s.make(Identifier.ascending("message", id)), - zod: zod(s), })), ) export type MessageID = Schema.Schema.Type -export const PartID = Schema.String.annotate({ [ZodOverride]: Identifier.schema("part") }).pipe( +export const PartID = Schema.String.check(Schema.isStartsWith("prt")).pipe( Schema.brand("PartID"), withStatics((s) => ({ ascending: (id?: string) => s.make(Identifier.ascending("part", id)), - zod: zod(s), })), ) diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index 5c938ff693..f50f8750b3 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -37,8 +37,8 @@ import type { Provider } from "@/provider/provider" import { Permission } from "@/permission" import { Global } from "@opencode-ai/core/global" import { Effect, Layer, Option, Context, Schema, Types } from "effect" -import { zod } from "@/util/effect-zod" -import { NonNegativeInt, optionalOmitUndefined, withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { NonNegativeInt, optionalOmitUndefined, withStatics } from "@opencode-ai/core/schema" const log = Log.create({ service: "session" }) @@ -142,9 +142,9 @@ function sessionPath(worktree: string, cwd: string) { } const Summary = Schema.Struct({ - additions: NonNegativeInt, - deletions: NonNegativeInt, - files: NonNegativeInt, + additions: Schema.Finite, + deletions: Schema.Finite, + files: Schema.Finite, diffs: optionalOmitUndefined(Schema.Array(Snapshot.FileDiff)), }) @@ -353,7 +353,7 @@ export function plan(input: { slug: string; time: { created: number } }, instanc export const getUsage = (input: { model: Provider.Model; usage: LanguageModelUsage; metadata?: ProviderMetadata }) => { const safe = (value: number) => { if (!Number.isFinite(value)) return 0 - return value + return Math.max(0, value) } const inputTokens = safe(input.usage.inputTokens ?? 0) const outputTokens = safe(input.usage.outputTokens ?? 0) diff --git a/packages/opencode/src/session/status.ts b/packages/opencode/src/session/status.ts index 1d6e96d935..1dd36ec53a 100644 --- a/packages/opencode/src/session/status.ts +++ b/packages/opencode/src/session/status.ts @@ -2,8 +2,8 @@ import { BusEvent } from "@/bus/bus-event" import { Bus } from "@/bus" import { InstanceState } from "@/effect/instance-state" import { SessionID } from "./schema" -import { zod } from "@/util/effect-zod" -import { NonNegativeInt, withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { NonNegativeInt, withStatics } from "@opencode-ai/core/schema" import { Effect, Layer, Context, Schema } from "effect" import z from "zod" @@ -17,6 +17,8 @@ export const Info = Schema.Union([ message: Schema.String, action: Schema.optional( Schema.Struct({ + reason: Schema.String, + provider: Schema.String, title: Schema.String, message: Schema.String, label: Schema.String, diff --git a/packages/opencode/src/session/summary.ts b/packages/opencode/src/session/summary.ts index f1709d5a2f..e39bd85e9a 100644 --- a/packages/opencode/src/session/summary.ts +++ b/packages/opencode/src/session/summary.ts @@ -2,8 +2,8 @@ import { Effect, Layer, Context, Schema } from "effect" import { Bus } from "@/bus" import { Snapshot } from "@/snapshot" import { Storage } from "@/storage/storage" -import { zod } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" import * as Session from "./session" import { MessageV2 } from "./message-v2" import { SessionID, MessageID } from "./schema" @@ -134,6 +134,7 @@ export const layer = Layer.effect( .read(["session_diff", input.sessionID]) .pipe(Effect.catch(() => Effect.succeed([] as Snapshot.FileDiff[]))) const next = diffs.map((item) => { + if (item.file === undefined) return item const file = unquoteGitPath(item.file) if (file === item.file) return item return { ...item, file } diff --git a/packages/opencode/src/session/todo.ts b/packages/opencode/src/session/todo.ts index 32a8370464..9b7daf7f0c 100644 --- a/packages/opencode/src/session/todo.ts +++ b/packages/opencode/src/session/todo.ts @@ -1,8 +1,8 @@ import { BusEvent } from "@/bus/bus-event" import { Bus } from "@/bus" import { SessionID } from "./schema" -import { zod } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" import { Effect, Layer, Context, Schema } from "effect" import z from "zod" import { Database } from "@/storage/db" diff --git a/packages/opencode/src/skill/index.ts b/packages/opencode/src/skill/index.ts index a4e3fb6d93..01bffdb02a 100644 --- a/packages/opencode/src/skill/index.ts +++ b/packages/opencode/src/skill/index.ts @@ -2,8 +2,8 @@ import path from "path" import { pathToFileURL } from "url" import z from "zod" import { Effect, Layer, Context, Schema } from "effect" -import { zod } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" import { NamedError } from "@opencode-ai/core/util/error" import type { Agent } from "@/agent/agent" import { Bus } from "@/bus" @@ -17,6 +17,7 @@ import { ConfigMarkdown } from "@/config/markdown" import { Glob } from "@opencode-ai/core/util/glob" import * as Log from "@opencode-ai/core/util/log" import { Discovery } from "./discovery" +import CUSTOMIZE_OPENCODE_SKILL_BODY from "./prompt/customize-opencode.md" with { type: "text" } const log = Log.create({ service: "skill" }) const CLAUDE_EXTERNAL_DIR = ".claude" @@ -25,9 +26,18 @@ const EXTERNAL_SKILL_PATTERN = "skills/**/SKILL.md" const OPENCODE_SKILL_PATTERN = "{skill,skills}/**/SKILL.md" const SKILL_PATTERN = "**/SKILL.md" +// Built-in skill that ships with opencode. The model's intuition for what an +// opencode.json should look like is often wrong, and opencode hard-fails on +// invalid config, so users hit cryptic startup errors. Loading this skill +// when the model is asked to touch opencode's own config files gives it the +// actual schemas instead of guesses. +const CUSTOMIZE_OPENCODE_SKILL_NAME = "customize-opencode" +const CUSTOMIZE_OPENCODE_SKILL_DESCRIPTION = + "Use ONLY when the user is editing or creating opencode's own configuration: opencode.json, opencode.jsonc, files under .opencode/, or files under ~/.config/opencode/. Also use when creating or fixing opencode agents, subagents, skills, plugins, MCP servers, or permission rules. Do not use for the user's own application code, or for any project that is not configuring opencode itself." + export const Info = Schema.Struct({ name: Schema.String, - description: Schema.String, + description: Schema.optional(Schema.String), location: Schema.String, content: Schema.String, }).pipe(withStatics((s) => ({ zod: zod(s) }))) @@ -93,7 +103,7 @@ const add = Effect.fnUntraced(function* (state: State, match: string, bus: Bus.I if (!md) return - const parsed = z.object({ name: z.string(), description: z.string() }).safeParse(md.data) + const parsed = z.object({ name: z.string(), description: z.string().optional() }).safeParse(md.data) if (!parsed.success) return if (state.skills[parsed.data.name]) { @@ -230,6 +240,16 @@ export const layer = Layer.effect( const state = yield* InstanceState.make( Effect.fn("Skill.state")(function* () { const s: State = { skills: {}, dirs: new Set() } + // Register the built-in skill BEFORE disk discovery so a user-disk + // skill with the same name can override it. + if (Flag.OPENCODE_EXPERIMENTAL_CUSTOMIZE_SKILL) { + s.skills[CUSTOMIZE_OPENCODE_SKILL_NAME] = { + name: CUSTOMIZE_OPENCODE_SKILL_NAME, + description: CUSTOMIZE_OPENCODE_SKILL_DESCRIPTION, + location: "", + content: CUSTOMIZE_OPENCODE_SKILL_BODY, + } + } yield* loadSkills(s, yield* InstanceState.get(discovered), bus) return s }), @@ -269,12 +289,13 @@ export const defaultLayer = layer.pipe( ) export function fmt(list: Info[], opts: { verbose: boolean }) { - if (list.length === 0) return "No skills are currently available." + const described = list.filter((skill) => skill.description !== undefined) + if (described.length === 0) return "No skills are currently available." if (opts.verbose) { return [ "", - ...list - .sort((a, b) => a.name.localeCompare(b.name)) + ...described + .toSorted((a, b) => a.name.localeCompare(b.name)) .flatMap((skill) => [ " ", ` ${skill.name}`, @@ -288,7 +309,7 @@ export function fmt(list: Info[], opts: { verbose: boolean }) { return [ "## Available Skills", - ...list + ...described .toSorted((a, b) => a.name.localeCompare(b.name)) .map((skill) => `- **${skill.name}**: ${skill.description}`), ].join("\n") diff --git a/packages/opencode/src/skill/prompt/customize-opencode.md b/packages/opencode/src/skill/prompt/customize-opencode.md new file mode 100644 index 0000000000..6158aae085 --- /dev/null +++ b/packages/opencode/src/skill/prompt/customize-opencode.md @@ -0,0 +1,354 @@ + + +# Customizing opencode + +opencode validates its own config strictly and refuses to start when a field +is wrong. The shapes below are the accepted shapes. When in doubt, fetch +`https://opencode.ai/config.json` (the JSON Schema) and validate against it. + +Every `opencode.json` should declare `"$schema": "https://opencode.ai/config.json"` +so the user's editor catches mistakes as they type. + +## Where files live + +| Scope | Path | +| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------- | +| Project config | `./opencode.json`, `./opencode.jsonc`, or `.opencode/opencode.json` (opencode walks up from the cwd to the worktree root) | +| Global config | `~/.config/opencode/opencode.json` (NOT `~/.opencode/`) | +| Project agents | `.opencode/agent/.md` or `.opencode/agents/.md` | +| Global agents | `~/.config/opencode/agent(s)/.md` | +| Project skills | `.opencode/skill(s)//SKILL.md` | +| Global skills | `~/.config/opencode/skill(s)//SKILL.md` | +| External skills (auto-loaded) | `~/.claude/skills//SKILL.md`, `~/.agents/skills//SKILL.md` | + +Configs from each scope are deep-merged. Project overrides global. Unknown +top-level keys in `opencode.json` are rejected with `ConfigInvalidError`. + +## opencode.json + +Every field is optional. + +```json +{ + "$schema": "https://opencode.ai/config.json", + "username": "string", + "model": "provider/model-id", + "small_model": "provider/model-id", + "default_agent": "agent-name", + "shell": "/bin/zsh", + "logLevel": "DEBUG" | "INFO" | "WARN" | "ERROR", + "share": "manual" | "auto" | "disabled", + "autoupdate": true | false | "notify", + "snapshot": true, + "instructions": ["AGENTS.md", "docs/style.md"], + + "skills": { + "paths": [".opencode/skills", "/abs/path/to/skills"], + "urls": ["https://example.com/.well-known/skills/"] + }, + + "agent": { + "my-agent": { + "model": "anthropic/claude-sonnet-4-6", + "mode": "subagent", + "description": "...", + "permission": { "edit": "deny" } + } + }, + + "command": { + "deploy": { "description": "...", "prompt": "..." } + }, + + "provider": { + "anthropic": { "options": { "apiKey": "..." } } + }, + "disabled_providers": ["openai"], + "enabled_providers": ["anthropic"], + + "mcp": { + "playwright": { + "type": "local", + "command": ["npx", "-y", "@playwright/mcp"], + "enabled": true, + "env": {} + }, + "remote-thing": { + "type": "remote", + "url": "https://...", + "headers": { "Authorization": "Bearer ..." } + } + }, + + "plugin": [ + "opencode-gemini-auth", + "opencode-foo@1.2.3", + "./local-plugin.ts", + ["opencode-bar", { "option": "value" }] + ], + + "permission": { + "edit": "deny", + "bash": { "git *": "allow", "*": "ask" } + }, + + "formatter": false, + "lsp": false, + + "experimental": { + "primary_tools": ["edit"], + "mcp_timeout": 30000 + }, + + "tool_output": { "max_lines": 200, "max_bytes": 8192 }, + + "compaction": { "auto": true, "tail_turns": 15 } +} +``` + +Shape notes worth being explicit about: + +- `model` always carries a provider prefix: `"anthropic/claude-sonnet-4-6"`. +- `skills` is an object with `paths` and/or `urls`, not an array. +- `agent` is an object keyed by agent name, not an array. +- `plugin` is an array of strings or `[name, options]` tuples, not an object. +- `mcp[name].command` is an array of strings, never a single string. `type` is required. +- `permission` is either a string action or an object keyed by tool name. + +## Skills + +opencode's skill loader scans for `**/SKILL.md` inside skill directories. The +file is named `SKILL.md` exactly, and lives in its own folder named after the +skill: + +``` +.opencode/skills/my-skill/SKILL.md +``` + +Frontmatter: + +```markdown +--- +name: my-skill +description: One sentence covering what this skill does AND when to trigger it. Front-load the literal keywords or filenames the user is likely to say. +--- + +# My Skill + +(skill body in markdown: instructions, examples, references) +``` + +- `name` is required, lowercase hyphen-separated, up to 64 chars, and matches the folder name. +- `description` is effectively required: skills without one are filtered out and never surfaced to the model. Cover both _what_ the skill does and _when_ to use it. Write in third person ("Use when...", not "I help with..."). Front-load concrete trigger keywords and filenames; gate with "Use ONLY when..." if the skill should stay quiet on adjacent topics. +- Optional: `license`, `compatibility`, `metadata` (string-string map). + +Register skills from non-default locations via `skills.paths` (scanned +recursively for `**/SKILL.md`) and `skills.urls` (each URL serves a list of +skills). + +## Agents + +Two ways to define an agent. Use the file form for anything non-trivial. + +### Inline (in `opencode.json`) + +```json +{ + "agent": { + "my-reviewer": { + "description": "Reviews PRs for style violations.", + "mode": "subagent", + "model": "anthropic/claude-sonnet-4-6", + "permission": { "edit": "deny", "bash": "ask" }, + "prompt": "You are a strict PR reviewer..." + } + } +} +``` + +### File + +``` +.opencode/agent/my-reviewer.md OR .opencode/agents/my-reviewer.md +``` + +```markdown +--- +description: Reviews PRs for style violations. +mode: subagent +model: anthropic/claude-sonnet-4-6 +permission: + edit: deny + bash: ask +--- + +You are a strict PR reviewer. Focus on... +``` + +The file body becomes the agent's `prompt`. Do not also put `prompt:` in the +frontmatter. + +`mode` is one of `"primary"`, `"subagent"`, `"all"`. + +Allowed top-level frontmatter fields: `name, model, variant, description, mode, +hidden, color, steps, options, permission, disable, temperature, top_p`. Any +unknown field is silently routed into `options`. + +To disable a built-in agent: `agent: { build: { disable: true } }`, or in a +file, `disable: true` in frontmatter. + +`default_agent` must point to a non-hidden, primary-mode agent. + +### Built-in agents + +opencode ships with `build`, `plan`, `general`, `explore`, plus optionally +`scout` (gated on `OPENCODE_EXPERIMENTAL_SCOUT`). Hidden internal agents: +`compaction`, `title`, `summary`. To override a built-in's fields, define the +same key in `agent: { : { ... } }`. + +## Plugins + +`plugin:` is an array. Each entry is one of: + +```json +"plugin": [ + "opencode-gemini-auth", // npm spec, latest + "opencode-foo@1.2.3", // npm spec, pinned + "./local-plugin.ts", // file path, relative to the declaring config + "file:///abs/path/plugin.js", // file URL + ["opencode-bar", { "key": "val" }] // tuple form with options +] +``` + +Auto-discovered plugins (no config entry needed): any `*.ts` or `*.js` file in +`.opencode/plugin/` or `.opencode/plugins/`. + +A plugin module exports `default` (or any named export) of type +`Plugin = (input: PluginInput, options?) => Promise`. The export is a +function, not a plain object literal, and the function returns an object +(return `{}` if there is nothing to register). + +```ts +import type { Plugin } from "@opencode-ai/plugin" + +export default (async ({ client, project, directory, $ }) => { + return { + config: (cfg) => { + // cfg is the live merged config; mutate fields here. + }, + "tool.execute.before": async (input, output) => { + // mutate output.args before the tool runs + }, + } +}) satisfies Plugin +``` + +Hook surface (mutate `output` in place; return `void`): + +- `event(input)`: every bus event +- `config(cfg)`: once on init with the merged config +- `chat.message`, `chat.params`, `chat.headers` +- `tool.execute.before`, `tool.execute.after` +- `tool.definition` +- `command.execute.before` +- `shell.env` +- `permission.ask` +- `experimental.chat.messages.transform`, `experimental.chat.system.transform`, + `experimental.session.compacting`, `experimental.compaction.autocontinue`, + `experimental.text.complete` + +Special object-shaped (not callbacks): `tool: { my_tool: { ... } }`, +`auth: { ... }`, `provider: { ... }`. + +## MCP servers + +`mcp:` is an object keyed by server name. Each server is discriminated by +`type`: + +```json +{ + "mcp": { + "playwright": { + "type": "local", + "command": ["npx", "-y", "@playwright/mcp"], + "enabled": true, + "env": { "BROWSER": "chromium" } + }, + "github": { + "type": "remote", + "url": "https://...", + "enabled": true, + "headers": { "Authorization": "Bearer ${GITHUB_TOKEN}" } + }, + "old-server": { "enabled": false } + } +} +``` + +`command` is an array of strings. `type` is required. Use `enabled: false` to +disable a server inherited from a parent config. + +## Permissions + +```json +"permission": { + "edit": "deny", + "bash": { "git *": "allow", "rm *": "deny", "*": "ask" }, + "external_directory": { "~/secrets/**": "deny", "*": "allow" } +} +``` + +Actions: `"allow"`, `"ask"`, `"deny"`. + +Per-tool value forms: `"allow"` shorthand (treated as `{"*": "allow"}`), or an +object `{ pattern: action }`. Within an object, **insertion order matters**. +opencode evaluates the LAST matching rule, so put broad rules first and narrow +rules last. + +`permission: "allow"` (a string at the top level) is shorthand for "allow +everything" and is rarely what the user wants. + +Known permission keys: `read, edit, glob, grep, list, bash, task, +external_directory, todowrite, question, webfetch, websearch, codesearch, +repo_clone, repo_overview, lsp, doom_loop, skill`. Some of these (`todowrite, +question, webfetch, websearch, codesearch, doom_loop`) only accept a flat +action, not a per-pattern object. + +`external_directory` patterns are filesystem paths (use `~/`, absolute paths, +or globs like `~/projects/**`). + +Per-agent `permission:` overrides top-level `permission:`. Plan Mode lives on +the `plan` agent's permission ruleset (`edit: deny *`). + +## Escape hatches + +When a user's config is broken and opencode won't start, these env vars help: + +- `OPENCODE_DISABLE_PROJECT_CONFIG=1`: skip the project's local `opencode.json` + and start from globals only. Run from the project directory, opencode loads, + the user edits the broken file, then they restart without the flag. +- `OPENCODE_CONFIG=/path/to/file.json`: load an additional explicit config. +- `OPENCODE_CONFIG_CONTENT='{"$schema":"https://opencode.ai/config.json"}'`: + inject inline JSON as a final local-scope merge. +- `OPENCODE_DISABLE_DEFAULT_PLUGINS=1`: skip default plugins. +- `OPENCODE_PURE=1`: skip external plugins entirely. +- `OPENCODE_DISABLE_EXTERNAL_SKILLS=1`, + `OPENCODE_DISABLE_CLAUDE_CODE_SKILLS=1`: skip the external skill scans under + `~/.claude/` and `~/.agents/`. + +## When proposing edits + +- Validate against the schema before writing. If you are unsure of a field's + exact shape, fetch `https://opencode.ai/config.json` rather than guessing. +- Preserve `$schema` and any existing fields the user did not ask to change. +- For agent, skill, and plugin definitions, prefer creating new files in the + correct location over inlining everything in `opencode.json`. +- If the user's existing config is malformed, point them at the env-var escape + hatch above so they can edit from inside opencode without breaking their + session. +- opencode hard-fails on invalid config by design. There is no graceful + degradation, so get the shape right the first time. diff --git a/packages/opencode/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts index ea30f5afc7..848a067c3d 100644 --- a/packages/opencode/src/snapshot/index.ts +++ b/packages/opencode/src/snapshot/index.ts @@ -10,8 +10,8 @@ import { Hash } from "@opencode-ai/core/util/hash" import { Config } from "@/config/config" import { Global } from "@opencode-ai/core/global" import * as Log from "@opencode-ai/core/util/log" -import { NonNegativeInt, withStatics } from "@/util/schema" -import { zod } from "@/util/effect-zod" +import { NonNegativeInt, withStatics } from "@opencode-ai/core/schema" +import { zod } from "@opencode-ai/core/effect-zod" export const Patch = Schema.Struct({ hash: Schema.String, @@ -20,10 +20,13 @@ export const Patch = Schema.Struct({ export type Patch = typeof Patch.Type export const FileDiff = Schema.Struct({ - file: Schema.String, - patch: Schema.String, - additions: NonNegativeInt, - deletions: NonNegativeInt, + // Optional because legacy/imported `summary_diffs` on disk may omit + // file details and patch text. Required Schema rejected the whole + // session response and broke session loading on Desktop. + file: Schema.optional(Schema.String), + patch: Schema.optional(Schema.String), + additions: Schema.Finite, + deletions: Schema.Finite, status: Schema.optional(Schema.Literals(["added", "deleted", "modified"])), }) .annotate({ identifier: "SnapshotFileDiff" }) diff --git a/packages/opencode/src/storage/storage.ts b/packages/opencode/src/storage/storage.ts index 5b2df1e899..bc4d8b8f17 100644 --- a/packages/opencode/src/storage/storage.ts +++ b/packages/opencode/src/storage/storage.ts @@ -5,7 +5,7 @@ import { NamedError } from "@opencode-ai/core/util/error" import z from "zod" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Effect, Exit, Layer, Option, RcMap, Schema, Context, TxReentrantLock } from "effect" -import { NonNegativeInt } from "@/util/schema" +import { NonNegativeInt } from "@opencode-ai/core/schema" import { Git } from "@/git" const log = Log.create({ service: "storage" }) diff --git a/packages/opencode/src/sync/index.ts b/packages/opencode/src/sync/index.ts index 62b30ccf9a..5c29101b6c 100644 --- a/packages/opencode/src/sync/index.ts +++ b/packages/opencode/src/sync/index.ts @@ -1,4 +1,3 @@ -import z from "zod" import { Database } from "@/storage/db" import { eq } from "drizzle-orm" import { GlobalBus } from "@/bus/global" @@ -10,9 +9,7 @@ import type { WorkspaceID } from "@/control-plane/schema" import { EventID } from "./schema" import { Flag } from "@opencode-ai/core/flag/flag" import { Context, Effect, Layer, Schema as EffectSchema } from "effect" -import { zodObject } from "@/util/effect-zod" -import type { DeepMutable } from "@/util/schema" -import { makeRuntime } from "@/effect/run-service" +import type { DeepMutable } from "@opencode-ai/core/schema" import { serviceUse } from "@/effect/service-use" import { InstanceState } from "@/effect/instance-state" @@ -65,6 +62,7 @@ export interface Interface { options?: { publish: boolean; ownerID?: string }, ) => Effect.Effect readonly remove: (aggregateID: string) => Effect.Effect + readonly claim: (aggregateID: string, ownerID: string) => Effect.Effect } export class Service extends Context.Service()("@opencode/SyncEvent") {} @@ -177,11 +175,24 @@ export const layer = Layer.effect(Service)( }) }) + const claim: Interface["claim"] = Effect.fn("SyncEvent.claim")((aggregateID, ownerID) => + Effect.sync(() => + Database.use((db) => + db + .update(EventSequenceTable) + .set({ owner_id: ownerID }) + .where(eq(EventSequenceTable.aggregate_id, aggregateID)) + .run(), + ), + ), + ) + return Service.of({ run, replay, replayAll, remove, + claim, }) }), ) @@ -190,8 +201,6 @@ export const defaultLayer = layer export const use = serviceUse(Service) -const runtime = makeRuntime(Service, defaultLayer) - export const registry = new Map() let projectors: Map | undefined const versions = new Map() @@ -338,52 +347,6 @@ function process( }) } -export function replay(event: SerializedEvent, options?: { publish: boolean; ownerID?: string }) { - return runtime.runSync((sync) => sync.replay(event, options)) -} - -export function replayAll(events: SerializedEvent[], options?: { publish: boolean; ownerID?: string }) { - return runtime.runSync((sync) => sync.replayAll(events, options)) -} - -export function run(def: Def, data: Event["data"], options?: { publish?: boolean }) { - return runtime.runSync((sync) => sync.run(def, data, options)) -} - -export function remove(aggregateID: string) { - return runtime.runSync((sync) => sync.remove(aggregateID)) -} - -export function claim(aggregateID: string, ownerID: string) { - Database.use((db) => - db - .update(EventSequenceTable) - .set({ owner_id: ownerID }) - .where(eq(EventSequenceTable.aggregate_id, aggregateID)) - .run(), - ) -} - -export function payloads() { - return registry - .entries() - .map(([type, def]) => { - return z - .object({ - type: z.literal("sync"), - name: z.literal(type), - id: z.string(), - seq: z.number(), - aggregateID: z.literal(def.aggregate), - data: zodObject(def.schema), - }) - .meta({ - ref: `SyncEvent.${def.type}`, - }) - }) - .toArray() -} - export function effectPayloads() { return registry .entries() diff --git a/packages/opencode/src/sync/schema.ts b/packages/opencode/src/sync/schema.ts index e714b86ae0..dde2e53d17 100644 --- a/packages/opencode/src/sync/schema.ts +++ b/packages/opencode/src/sync/schema.ts @@ -1,10 +1,10 @@ import { Schema } from "effect" import { Identifier } from "@/id/id" -import { zod, ZodOverride } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" -export const EventID = Schema.String.annotate({ [ZodOverride]: Identifier.schema("event") }).pipe( +export const EventID = Schema.String.check(Schema.isStartsWith("evt")).pipe( Schema.brand("EventID"), withStatics((s) => ({ ascending: (id?: string) => s.make(Identifier.ascending("event", id)), diff --git a/packages/opencode/src/tool/codesearch.ts b/packages/opencode/src/tool/codesearch.ts new file mode 100644 index 0000000000..4616d5900a --- /dev/null +++ b/packages/opencode/src/tool/codesearch.ts @@ -0,0 +1,63 @@ +import { Effect, Schema } from "effect" +import { HttpClient } from "effect/unstable/http" +import * as Tool from "./tool" +import * as McpWebSearch from "./mcp-websearch" +import DESCRIPTION from "./codesearch.txt" + +export const Parameters = Schema.Struct({ + query: Schema.String.annotate({ + description: + "Search query to find relevant context for APIs, Libraries, and SDKs. For example, 'React useState hook examples', 'Python pandas dataframe filtering', 'Express.js middleware', 'Next js partial prerendering configuration'", + }), + tokensNum: Schema.Number.check(Schema.isGreaterThanOrEqualTo(1000)) + .check(Schema.isLessThanOrEqualTo(50000)) + .pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed(5000))) + .annotate({ + description: + "Number of tokens to return (1000-50000). Default is 5000 tokens. Adjust this value based on how much context you need - use lower values for focused queries and higher values for comprehensive documentation.", + }), +}) + +export const CodeSearchTool = Tool.define( + "codesearch", + Effect.gen(function* () { + const http = yield* HttpClient.HttpClient + + return { + description: DESCRIPTION, + parameters: Parameters, + execute: (params: { query: string; tokensNum: number }, ctx: Tool.Context) => + Effect.gen(function* () { + yield* ctx.ask({ + permission: "codesearch", + patterns: [params.query], + always: ["*"], + metadata: { + query: params.query, + tokensNum: params.tokensNum, + }, + }) + + const result = yield* McpWebSearch.call( + http, + McpWebSearch.EXA_URL, + "get_code_context_exa", + McpWebSearch.CodeArgs, + { + query: params.query, + tokensNum: params.tokensNum, + }, + "30 seconds", + ) + + return { + output: + result ?? + "No code snippets or documentation found. Please try a different query, be more specific about the library or programming concept, or check the spelling of framework names.", + title: `Code search: ${params.query}`, + metadata: {}, + } + }).pipe(Effect.orDie), + } + }), +) diff --git a/packages/opencode/src/tool/codesearch.txt b/packages/opencode/src/tool/codesearch.txt new file mode 100644 index 0000000000..4187f08d12 --- /dev/null +++ b/packages/opencode/src/tool/codesearch.txt @@ -0,0 +1,12 @@ +- Search and get relevant context for any programming task using Exa Code API +- Provides the highest quality and freshest context for libraries, SDKs, and APIs +- Use this tool for ANY question or task related to programming +- Returns comprehensive code examples, documentation, and API references +- Optimized for finding specific programming patterns and solutions + +Usage notes: + - Adjustable token count (1000-50000) for focused or comprehensive results + - Default 5000 tokens provides balanced context for most queries + - Use lower values for specific questions, higher values for comprehensive documentation + - Supports queries about frameworks, libraries, APIs, and programming concepts + - Examples: 'React useState hook examples', 'Python pandas dataframe filtering', 'Express.js middleware' diff --git a/packages/opencode/src/tool/glob.ts b/packages/opencode/src/tool/glob.ts index 0c97b9cdf7..ce58331ea3 100644 --- a/packages/opencode/src/tool/glob.ts +++ b/packages/opencode/src/tool/glob.ts @@ -7,6 +7,7 @@ import { Ripgrep } from "../file/ripgrep" import { assertExternalDirectoryEffect } from "./external-directory" import DESCRIPTION from "./glob.txt" import * as Tool from "./tool" +import { Reference } from "@/reference/reference" export const Parameters = Schema.Struct({ pattern: Schema.String.annotate({ description: "The glob pattern to match files against" }), @@ -20,6 +21,7 @@ export const GlobTool = Tool.define( Effect.gen(function* () { const rg = yield* Ripgrep.Service const fs = yield* AppFileSystem.Service + const reference = yield* Reference.Service return { description: DESCRIPTION, @@ -39,11 +41,15 @@ export const GlobTool = Tool.define( let search = params.path ?? ins.directory search = path.isAbsolute(search) ? search : path.resolve(ins.directory, search) + yield* reference.ensure(search) const info = yield* fs.stat(search).pipe(Effect.catch(() => Effect.succeed(undefined))) if (info?.type === "File") { throw new Error(`glob path must be a directory: ${search}`) } - yield* assertExternalDirectoryEffect(ctx, search, { kind: "directory" }) + yield* assertExternalDirectoryEffect(ctx, search, { + bypass: yield* reference.contains(search), + kind: "directory", + }) const limit = 100 let truncated = false diff --git a/packages/opencode/src/tool/grep.ts b/packages/opencode/src/tool/grep.ts index fb3e70cad2..4e89198dff 100644 --- a/packages/opencode/src/tool/grep.ts +++ b/packages/opencode/src/tool/grep.ts @@ -7,6 +7,7 @@ import { Ripgrep } from "../file/ripgrep" import { assertExternalDirectoryEffect } from "./external-directory" import DESCRIPTION from "./grep.txt" import * as Tool from "./tool" +import { Reference } from "@/reference/reference" const MAX_LINE_LENGTH = 2000 @@ -25,6 +26,7 @@ export const GrepTool = Tool.define( Effect.gen(function* () { const fs = yield* AppFileSystem.Service const rg = yield* Ripgrep.Service + const reference = yield* Reference.Service return { description: DESCRIPTION, @@ -57,10 +59,12 @@ export const GrepTool = Tool.define( ? (params.path ?? ins.directory) : path.join(ins.directory, params.path ?? "."), ) + yield* reference.ensure(search) const info = yield* fs.stat(search).pipe(Effect.catch(() => Effect.succeed(undefined))) const cwd = info?.type === "Directory" ? search : path.dirname(search) const file = info?.type === "Directory" ? undefined : [path.relative(cwd, search)] yield* assertExternalDirectoryEffect(ctx, search, { + bypass: yield* reference.contains(search), kind: info?.type === "Directory" ? "directory" : "file", }) diff --git a/packages/opencode/src/tool/mcp-websearch.ts b/packages/opencode/src/tool/mcp-websearch.ts index 208924cba5..42b864c6fa 100644 --- a/packages/opencode/src/tool/mcp-websearch.ts +++ b/packages/opencode/src/tool/mcp-websearch.ts @@ -48,6 +48,11 @@ export const SearchArgs = Schema.Struct({ contextMaxCharacters: Schema.optional(Schema.Number), }) +export const CodeArgs = Schema.Struct({ + query: Schema.String, + tokensNum: Schema.Number, +}) + export const ParallelSearchArgs = Schema.Struct({ objective: Schema.String, search_queries: Schema.Array(Schema.String), diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts index bf01fc7d2d..ad3c33e742 100644 --- a/packages/opencode/src/tool/read.ts +++ b/packages/opencode/src/tool/read.ts @@ -1,5 +1,5 @@ import { Effect, Option, Schema, Scope } from "effect" -import { NonNegativeInt } from "@/util/schema" +import { NonNegativeInt } from "@opencode-ai/core/schema" import { createReadStream } from "fs" import * as path from "path" import { createInterface } from "readline" @@ -11,6 +11,7 @@ import { InstanceState } from "@/effect/instance-state" import { assertExternalDirectoryEffect } from "./external-directory" import { Instruction } from "../session/instruction" import { isPdfAttachment, sniffAttachmentMime } from "@/util/media" +import { Reference } from "@/reference/reference" const DEFAULT_READ_LIMIT = 2000 const MAX_LINE_LENGTH = 2000 @@ -41,6 +42,7 @@ export const ReadTool = Tool.define( const fs = yield* AppFileSystem.Service const instruction = yield* Instruction.Service const lsp = yield* LSP.Service + const reference = yield* Reference.Service const scope = yield* Scope.Scope const miss = Effect.fn("ReadTool.miss")(function* (filepath: string) { @@ -162,6 +164,7 @@ export const ReadTool = Tool.define( if (process.platform === "win32") { filepath = AppFileSystem.normalizePath(filepath) } + yield* reference.ensure(filepath) const title = path.relative(instance.worktree, filepath) const stat = yield* fs.stat(filepath).pipe( @@ -172,13 +175,13 @@ export const ReadTool = Tool.define( ) yield* assertExternalDirectoryEffect(ctx, filepath, { - bypass: Boolean(ctx.extra?.["bypassCwdCheck"]), + bypass: Boolean(ctx.extra?.["bypassCwdCheck"]) || (yield* reference.contains(filepath)), kind: stat?.type === "Directory" ? "directory" : "file", }) yield* ctx.ask({ permission: "read", - patterns: [filepath], + patterns: [path.relative(instance.worktree, filepath)], always: ["*"], metadata: {}, }) diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index b288bf7ae5..68251c342c 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -17,11 +17,14 @@ import { Config } from "@/config/config" import { type ToolContext as PluginToolContext, type ToolDefinition } from "@opencode-ai/plugin" import { Schema } from "effect" import z from "zod" -import { ZodOverride } from "@/util/effect-zod" +import { ZodOverride } from "@opencode-ai/core/effect-zod" import { Plugin } from "../plugin" import { Provider } from "@/provider/provider" import { ProviderID, type ModelID } from "../provider/schema" import { WebSearchTool } from "./websearch" +import { CodeSearchTool } from "./codesearch" +import { RepoCloneTool } from "./repo_clone" +import { RepoOverviewTool } from "./repo_overview" import { Flag } from "@opencode-ai/core/flag/flag" import * as Log from "@opencode-ai/core/util/log" import { LspTool } from "./lsp" @@ -44,8 +47,10 @@ import { Instruction } from "../session/instruction" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Bus } from "../bus" import { Agent } from "../agent/agent" +import { Git } from "@/git" import { Skill } from "../skill" import { Permission } from "@/permission" +import { Reference } from "@/reference/reference" const log = Log.create({ service: "tool.registry" }) @@ -86,6 +91,8 @@ export const layer: Layer.Layer< | Skill.Service | Session.Service | Provider.Service + | Git.Service + | Reference.Service | LSP.Service | Instruction.Service | AppFileSystem.Service @@ -113,6 +120,9 @@ export const layer: Layer.Layer< const plan = yield* PlanExitTool const webfetch = yield* WebFetchTool const websearch = yield* WebSearchTool + const codesearch = yield* CodeSearchTool + const repoClone = yield* RepoCloneTool + const repoOverview = yield* RepoOverviewTool const shell = yield* ShellTool const globtool = yield* GlobTool const writetool = yield* WriteTool @@ -212,6 +222,9 @@ export const layer: Layer.Layer< fetch: Tool.init(webfetch), todo: Tool.init(todo), search: Tool.init(websearch), + code: Tool.init(codesearch), + repo_clone: Tool.init(repoClone), + repo_overview: Tool.init(repoOverview), skill: Tool.init(skilltool), patch: Tool.init(patchtool), question: Tool.init(question), @@ -234,6 +247,7 @@ export const layer: Layer.Layer< tool.fetch, tool.todo, tool.search, + ...(Flag.OPENCODE_EXPERIMENTAL_SCOUT ? [tool.code, tool.repo_clone, tool.repo_overview] : []), tool.skill, tool.patch, ...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [tool.lsp] : []), @@ -348,6 +362,8 @@ export const defaultLayer = Layer.suspend(() => Layer.provide(Agent.defaultLayer), Layer.provide(Session.defaultLayer), Layer.provide(Provider.defaultLayer), + Layer.provide(Git.defaultLayer), + Layer.provide(Reference.defaultLayer), Layer.provide(LSP.defaultLayer), Layer.provide(Instruction.defaultLayer), Layer.provide(AppFileSystem.defaultLayer), diff --git a/packages/opencode/src/tool/repo_clone.ts b/packages/opencode/src/tool/repo_clone.ts new file mode 100644 index 0000000000..2b5e41844e --- /dev/null +++ b/packages/opencode/src/tool/repo_clone.ts @@ -0,0 +1,80 @@ +import { Effect, Schema } from "effect" +import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { Git } from "@/git" +import DESCRIPTION from "./repo_clone.txt" +import * as Tool from "./tool" +import { parseRemoteRepositoryReference, repositoryCachePath, validateRepositoryBranch } from "@/util/repository" +import { RepositoryCache } from "@/reference/repository-cache" + +export const Parameters = Schema.Struct({ + repository: Schema.String.annotate({ + description: "Repository to clone, as a git URL, host/path reference, or GitHub owner/repo shorthand", + }), + refresh: Schema.optional(Schema.Boolean).annotate({ + description: "When true, fetches the latest remote state into the managed cache", + }), + branch: Schema.optional(Schema.String).annotate({ + description: "Branch or ref to clone and inspect", + }), +}) + +type Metadata = { + repository: string + host: string + remote: string + localPath: string + status: "cached" | "cloned" | "refreshed" + head?: string + branch?: string +} + +export const RepoCloneTool = Tool.define( + "repo_clone", + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const git = yield* Git.Service + + return { + description: DESCRIPTION, + parameters: Parameters, + execute: (params: Schema.Schema.Type, ctx: Tool.Context) => + Effect.gen(function* () { + const reference = parseRemoteRepositoryReference(params.repository) + if (params.branch) validateRepositoryBranch(params.branch) + + const repository = reference.label + const remote = reference.remote + const localPath = repositoryCachePath(reference) + + yield* ctx.ask({ + permission: "repo_clone", + patterns: [repository], + always: [repository], + metadata: { + repository, + remote, + path: localPath, + refresh: Boolean(params.refresh), + branch: params.branch, + }, + }) + + const result = yield* RepositoryCache.ensure( + { reference, refresh: params.refresh, branch: params.branch }, + { fs, git }, + ) + return { + title: repository, + metadata: result, + output: [ + `Repository ready: ${repository}`, + `Status: ${result.status}`, + `Local path: ${localPath}`, + ...(result.branch ? [`Branch: ${result.branch}`] : []), + ...(result.head ? [`HEAD: ${result.head}`] : []), + ].join("\n"), + } + }).pipe(Effect.orDie), + } satisfies Tool.DefWithoutID + }), +) diff --git a/packages/opencode/src/tool/repo_clone.txt b/packages/opencode/src/tool/repo_clone.txt new file mode 100644 index 0000000000..7944015506 --- /dev/null +++ b/packages/opencode/src/tool/repo_clone.txt @@ -0,0 +1,5 @@ +- Clone or refresh a repository into OpenCode's managed cache under the data directory +- Accepts git URLs, forge host/path references, or GitHub owner/repo shorthand +- Returns the cached absolute local path so other tools can explore the cloned source +- Use this before Read, Glob, or Grep when the code you need lives outside the current workspace +- This tool is intended for dependency and documentation research workflows, not for modifying the user's workspace diff --git a/packages/opencode/src/tool/repo_overview.ts b/packages/opencode/src/tool/repo_overview.ts new file mode 100644 index 0000000000..b08516d2c6 --- /dev/null +++ b/packages/opencode/src/tool/repo_overview.ts @@ -0,0 +1,277 @@ +import path from "path" +import { Effect, Schema } from "effect" +import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { Git } from "@/git" +import { assertExternalDirectoryEffect } from "./external-directory" +import DESCRIPTION from "./repo_overview.txt" +import * as Tool from "./tool" +import { parseRepositoryReference, repositoryCachePath } from "@/util/repository" +import { Instance } from "@/project/instance" + +export const Parameters = Schema.Struct({ + repository: Schema.optional(Schema.String).annotate({ + description: "Cached repository to inspect, as a git URL, host/path reference, or GitHub owner/repo shorthand", + }), + path: Schema.optional(Schema.String).annotate({ + description: "Directory path to inspect instead of a cached repository", + }), + depth: Schema.optional(Schema.Number).annotate({ + description: "Maximum structure depth to include. Defaults to 3.", + }), +}) + +type Metadata = { + path: string + repository?: string + branch?: string + head?: string + package_manager?: string + ecosystems: string[] + dependency_files: string[] + entrypoints: string[] + depth: number + truncated: boolean +} + +const IGNORED_DIRS = new Set([ + ".git", + "node_modules", + "__pycache__", + ".venv", + "dist", + "build", + ".next", + "target", + "vendor", +]) +const STRUCTURE_LIMIT = 200 +const DEPENDENCY_FILES = [ + "package.json", + "package-lock.json", + "bun.lock", + "bun.lockb", + "pnpm-lock.yaml", + "yarn.lock", + "requirements.txt", + "pyproject.toml", + "go.mod", + "Cargo.toml", + "Gemfile", + "build.gradle", + "build.gradle.kts", + "pom.xml", + "composer.json", +] + +function packageManager(files: Set) { + if (files.has("bun.lock") || files.has("bun.lockb")) return "bun" + if (files.has("pnpm-lock.yaml")) return "pnpm" + if (files.has("yarn.lock")) return "yarn" + if (files.has("package-lock.json")) return "npm" +} + +function ecosystems(files: Set) { + return [ + ...(files.has("package.json") ? ["Node.js"] : []), + ...(files.has("pyproject.toml") || files.has("requirements.txt") ? ["Python"] : []), + ...(files.has("go.mod") ? ["Go"] : []), + ...(files.has("Cargo.toml") ? ["Rust"] : []), + ...(files.has("Gemfile") ? ["Ruby"] : []), + ...(files.has("build.gradle") || files.has("build.gradle.kts") || files.has("pom.xml") ? ["Java/Kotlin"] : []), + ...(files.has("composer.json") ? ["PHP"] : []), + ] +} + +function commonEntrypoints(files: Set) { + return [ + "index.ts", + "index.tsx", + "index.js", + "index.mjs", + "main.ts", + "main.js", + "src/index.ts", + "src/index.tsx", + "src/index.js", + "src/main.ts", + "src/main.js", + ].filter((file) => files.has(file)) +} + +export const RepoOverviewTool = Tool.define( + "repo_overview", + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const git = yield* Git.Service + + const resolveTarget = Effect.fn("RepoOverviewTool.resolveTarget")(function* ( + params: Schema.Schema.Type, + ) { + if (params.path) { + const full = path.isAbsolute(params.path) ? params.path : path.resolve(Instance.directory, params.path) + return { path: full, repository: params.repository } + } + + if (!params.repository) throw new Error("Either repository or path is required") + + const parsed = parseRepositoryReference(params.repository) + if (!parsed) throw new Error("Repository must be a git URL, host/path reference, or GitHub owner/repo shorthand") + + const repository = parsed.label + return { + repository, + path: repositoryCachePath(parsed), + } + }) + + const structure = Effect.fn("RepoOverviewTool.structure")(function* (root: string, depth: number) { + let truncated = false + const lines: string[] = [] + + const visit: (dir: string, level: number) => Effect.Effect = Effect.fnUntraced(function* ( + dir: string, + level: number, + ) { + if (level >= depth || lines.length >= STRUCTURE_LIMIT) { + truncated = truncated || lines.length >= STRUCTURE_LIMIT + return + } + + const entries = yield* fs.readDirectoryEntries(dir).pipe(Effect.orElseSucceed(() => [])) + const sorted = yield* Effect.forEach( + entries, + Effect.fnUntraced(function* (entry) { + if (IGNORED_DIRS.has(entry.name)) return undefined + const full = path.join(dir, entry.name) + const info = yield* fs.stat(full).pipe(Effect.catch(() => Effect.succeed(undefined))) + if (!info) return undefined + return { name: entry.name, full, directory: info.type === "Directory" } + }), + { concurrency: 16 }, + ).pipe( + Effect.map((items) => + items + .filter((item): item is { name: string; full: string; directory: boolean } => Boolean(item)) + .sort((a, b) => Number(b.directory) - Number(a.directory) || a.name.localeCompare(b.name)), + ), + ) + + for (const entry of sorted) { + if (lines.length >= STRUCTURE_LIMIT) { + truncated = true + return + } + + lines.push(`${" ".repeat(level)}${entry.name}${entry.directory ? "/" : ""}`) + if (entry.directory) yield* visit(entry.full, level + 1) + } + }) + + yield* visit(root, 0) + return { lines, truncated } + }) + + return { + description: DESCRIPTION, + parameters: Parameters, + execute: (params: Schema.Schema.Type, ctx: Tool.Context) => + Effect.gen(function* () { + const target = yield* resolveTarget(params) + const depth = + !params.depth || !Number.isInteger(params.depth) || params.depth < 1 || params.depth > 6 ? 3 : params.depth + + yield* assertExternalDirectoryEffect(ctx, target.path, { kind: "directory" }) + yield* ctx.ask({ + permission: "repo_overview", + patterns: [target.repository ?? target.path], + always: [target.repository ?? target.path], + metadata: { + repository: target.repository, + path: target.path, + depth, + }, + }) + + const info = yield* fs.stat(target.path).pipe(Effect.catch(() => Effect.succeed(undefined))) + if (!info) { + if (target.repository) + throw new Error(`Repository is not cloned: ${target.repository}. Use repo_clone first.`) + throw new Error(`Directory not found: ${target.path}`) + } + if (info.type !== "Directory") throw new Error(`Path is not a directory: ${target.path}`) + + const entries = yield* fs.readDirectoryEntries(target.path).pipe(Effect.orElseSucceed(() => [])) + const topLevel = new Set(entries.map((entry) => entry.name)) + const dependencyFiles = DEPENDENCY_FILES.filter((file) => topLevel.has(file)) + const packageJson = topLevel.has("package.json") + ? ((yield* fs + .readJson(path.join(target.path, "package.json")) + .pipe(Effect.orElseSucceed(() => ({})))) as Record) + : {} + + const entrypoints = [ + ...(typeof packageJson.main === "string" ? [`main: ${packageJson.main}`] : []), + ...(typeof packageJson.module === "string" ? [`module: ${packageJson.module}`] : []), + ...(typeof packageJson.types === "string" ? [`types: ${packageJson.types}`] : []), + ...(typeof packageJson.bin === "string" ? [`bin: ${packageJson.bin}`] : []), + ...(packageJson.bin && typeof packageJson.bin === "object" && !Array.isArray(packageJson.bin) + ? Object.keys(packageJson.bin as Record).map((name) => `bin: ${name}`) + : []), + ...(packageJson.exports && typeof packageJson.exports === "object" && !Array.isArray(packageJson.exports) + ? Object.keys(packageJson.exports as Record) + .slice(0, 10) + .map((name) => `exports: ${name}`) + : []), + ] + + const common = commonEntrypoints( + new Set([ + ...topLevel, + ...entries + .filter((entry) => entry.name === "src") + .flatMap(() => ["src/index.ts", "src/index.tsx", "src/index.js", "src/main.ts", "src/main.js"]), + ]), + ) + const structureResult = yield* structure(target.path, depth) + const branch = yield* git.branch(target.path) + const head = yield* git.run(["rev-parse", "HEAD"], { cwd: target.path }) + const headText = head.exitCode === 0 ? head.text().trim() : undefined + + const metadata: Metadata = { + path: target.path, + repository: target.repository, + branch, + head: headText, + package_manager: packageManager(topLevel), + ecosystems: ecosystems(topLevel), + dependency_files: dependencyFiles, + entrypoints: [...entrypoints, ...common.map((file) => `file: ${file}`)], + depth, + truncated: structureResult.truncated, + } + + return { + title: target.repository ?? path.basename(target.path), + metadata, + output: [ + `Path: ${target.path}`, + ...(target.repository ? [`Repository: ${target.repository}`] : []), + ...(branch ? [`Branch: ${branch}`] : []), + ...(headText ? [`HEAD: ${headText}`] : []), + ...(metadata.ecosystems.length ? [`Ecosystems: ${metadata.ecosystems.join(", ")}`] : []), + ...(metadata.package_manager ? [`Package manager: ${metadata.package_manager}`] : []), + ...(metadata.dependency_files.length + ? [`Dependency files: ${metadata.dependency_files.join(", ")}`] + : []), + ...(metadata.entrypoints.length + ? ["Likely entrypoints:", ...metadata.entrypoints.map((entry) => `- ${entry}`)] + : []), + "Top-level structure:", + ...structureResult.lines, + ...(structureResult.truncated ? ["(Structure truncated)"] : []), + ].join("\n"), + } + }).pipe(Effect.orDie), + } satisfies Tool.DefWithoutID + }), +) diff --git a/packages/opencode/src/tool/repo_overview.txt b/packages/opencode/src/tool/repo_overview.txt new file mode 100644 index 0000000000..2109838746 --- /dev/null +++ b/packages/opencode/src/tool/repo_overview.txt @@ -0,0 +1,4 @@ +- Summarize the structure and likely entrypoints of a cloned repository or local directory +- Accepts either a cached repository reference or a directory path +- Reports detected ecosystems, dependency files, package manager, likely entrypoints, and a compact structure tree +- Use this after repo_clone to orient quickly before deeper Read, Glob, or Grep investigation diff --git a/packages/opencode/src/tool/schema.ts b/packages/opencode/src/tool/schema.ts index 9ce7bece2b..a80d915153 100644 --- a/packages/opencode/src/tool/schema.ts +++ b/packages/opencode/src/tool/schema.ts @@ -1,10 +1,10 @@ import { Schema } from "effect" import { Identifier } from "@/id/id" -import { zod, ZodOverride } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" -const toolIdSchema = Schema.String.annotate({ [ZodOverride]: Identifier.schema("tool") }).pipe(Schema.brand("ToolID")) +const toolIdSchema = Schema.String.check(Schema.isStartsWith("tool")).pipe(Schema.brand("ToolID")) export type ToolID = typeof toolIdSchema.Type diff --git a/packages/opencode/src/tool/shell/prompt.ts b/packages/opencode/src/tool/shell/prompt.ts index 45c637863a..f26e364b61 100644 --- a/packages/opencode/src/tool/shell/prompt.ts +++ b/packages/opencode/src/tool/shell/prompt.ts @@ -1,6 +1,6 @@ import { Schema } from "effect" import DESCRIPTION from "./shell.txt" -import { PositiveInt } from "@/util/schema" +import { PositiveInt } from "@opencode-ai/core/schema" import { Global } from "@opencode-ai/core/global" import { ShellID } from "./id" diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index 22e4e5671c..c4d5bf7f4a 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -4,6 +4,7 @@ import { Session } from "@/session/session" import { SessionID, MessageID } from "../session/schema" import { MessageV2 } from "../session/message-v2" import { Agent } from "../agent/agent" +import { deriveSubagentSessionPermission } from "../agent/subagent-permissions" import type { SessionPrompt } from "../session/prompt" import { Config } from "@/config/config" import { Effect, Exit, Schema } from "effect" @@ -58,41 +59,25 @@ export const TaskTool = Tool.define( return yield* Effect.fail(new Error(`Unknown agent type: ${params.subagent_type} is not a valid agent type`)) } - const canTask = next.permission.some((rule) => rule.permission === id) - const canTodo = next.permission.some((rule) => rule.permission === "todowrite") - const taskID = params.task_id const session = taskID ? yield* sessions.get(SessionID.make(taskID)).pipe(Effect.catchCause(() => Effect.succeed(undefined))) : undefined const parent = yield* sessions.get(ctx.sessionID) + const parentAgent = parent.agent + ? yield* agent.get(parent.agent).pipe(Effect.catchCause(() => Effect.succeed(undefined))) + : undefined const nextSession = session ?? (yield* sessions.create({ parentID: ctx.sessionID, title: params.description + ` (@${next.name} subagent)`, permission: [ - ...(parent.permission ?? []).filter( - (rule) => rule.permission === "external_directory" || rule.action === "deny", - ), - ...(canTodo - ? [] - : [ - { - permission: "todowrite" as const, - pattern: "*" as const, - action: "deny" as const, - }, - ]), - ...(canTask - ? [] - : [ - { - permission: id, - pattern: "*" as const, - action: "deny" as const, - }, - ]), + ...deriveSubagentSessionPermission({ + parentSessionPermission: parent.permission ?? [], + parentAgent, + subagent: next, + }), ...(cfg.experimental?.primary_tools?.map((item) => ({ pattern: "*", action: "allow" as const, @@ -144,8 +129,8 @@ export const TaskTool = Tool.define( }, agent: next.name, tools: { - ...(canTodo ? {} : { todowrite: false }), - ...(canTask ? {} : { task: false }), + ...(next.permission.some((rule) => rule.permission === "todowrite") ? {} : { todowrite: false }), + ...(next.permission.some((rule) => rule.permission === id) ? {} : { task: false }), ...Object.fromEntries((cfg.experimental?.primary_tools ?? []).map((item) => [item, false])), }, parts, diff --git a/packages/opencode/src/util/fn.ts b/packages/opencode/src/util/fn.ts deleted file mode 100644 index c75fc1bb54..0000000000 --- a/packages/opencode/src/util/fn.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { z } from "zod" - -export function fn(schema: T, cb: (input: z.infer) => Result) { - const result = (input: z.infer) => { - let parsed - try { - parsed = schema.parse(input) - } catch (e) { - console.trace("schema validation failure stack trace:") - if (e instanceof z.ZodError) { - console.error("schema validation issues:", JSON.stringify(e.issues, null, 2)) - } - throw e - } - - return cb(parsed) - } - result.force = (input: z.infer) => cb(input) - result.schema = schema - return result -} diff --git a/packages/opencode/src/util/named-schema-error.ts b/packages/opencode/src/util/named-schema-error.ts index d9b92a23cc..d87e1dcdb5 100644 --- a/packages/opencode/src/util/named-schema-error.ts +++ b/packages/opencode/src/util/named-schema-error.ts @@ -1,6 +1,6 @@ import { Schema } from "effect" import z from "zod" -import { zod } from "@/util/effect-zod" +import { zod } from "@opencode-ai/core/effect-zod" /** * Create a Schema-backed NamedError-shaped class. diff --git a/packages/opencode/src/util/repository.ts b/packages/opencode/src/util/repository.ts new file mode 100644 index 0000000000..71c001255f --- /dev/null +++ b/packages/opencode/src/util/repository.ts @@ -0,0 +1,158 @@ +import path from "path" +import { fileURLToPath } from "url" +import { Global } from "@opencode-ai/core/global" + +export type Reference = { + host: string + path: string + segments: string[] + owner?: string + repo: string + remote: string + label: string + protocol?: string +} + +function normalize(input: string) { + return input + .trim() + .replace(/^git\+/, "") + .replace(/#.*$/, "") + .replace(/\/+$/, "") +} + +function trimGitSuffix(input: string) { + return input.replace(/\.git$/, "") +} + +function parts(input: string) { + return input + .split("/") + .map((item) => trimGitSuffix(item.trim())) + .filter(Boolean) +} + +function safeHost(input: string) { + return Boolean(input) && !input.startsWith("-") && !/[\s/\\]/.test(input) +} + +function safeSegment(input: string) { + return input !== "." && input !== ".." && !input.includes(":") && !/[\s/\\]/.test(input) +} + +function hostLike(input: string) { + return input.includes(".") || input.includes(":") || input === "localhost" +} + +function withSlash(input: string) { + return input.endsWith("/") ? input : `${input}/` +} + +function githubRemote(pathname: string) { + const base = process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL + if (!base) return `https://github.com/${pathname}.git` + return new URL(`${pathname}.git`, withSlash(base)).href +} + +function build(input: { host: string; segments: string[]; remote?: string; protocol?: string }) { + const segments = input.segments.map(trimGitSuffix).filter(Boolean) + if (!safeHost(input.host) || !segments.length || segments.some((segment) => !safeSegment(segment))) return null + const pathname = segments.join("/") + const repo = segments[segments.length - 1] + const host = input.host.toLowerCase() + return { + host, + path: pathname, + segments, + owner: segments.length === 2 ? segments[0] : undefined, + repo, + remote: input.remote ?? (host === "github.com" ? githubRemote(pathname) : `https://${host}/${pathname}.git`), + label: host === "github.com" && segments.length === 2 ? pathname : `${host}/${pathname}`, + protocol: input.protocol, + } satisfies Reference +} + +function buildFile(input: { url: URL; remote: string }) { + const filePath = path.normalize(fileURLToPath(input.url)) + const segments = filePath.split(/[\\/]+/).filter(Boolean) + if (!segments.length) return null + return { + host: "file", + path: filePath, + segments: segments.map((segment) => segment.replace(/:$/, "")), + owner: undefined, + repo: trimGitSuffix(segments[segments.length - 1]), + remote: input.remote, + label: filePath, + protocol: "file:", + } satisfies Reference +} + +export function parseRepositoryReference(input: string) { + const cleaned = normalize(input) + if (!cleaned) return null + + const githubPrefixed = cleaned.match(/^github:([^/\s]+)\/([^/\s]+)$/) + if (githubPrefixed) return build({ host: "github.com", segments: [githubPrefixed[1], githubPrefixed[2]] }) + + if (!cleaned.includes("://")) { + const scp = cleaned.match(/^(?:[^@/\s]+@)?([^:/\s]+):(.+)$/) + if (scp) return build({ host: scp[1], segments: parts(scp[2]), remote: cleaned }) + + const direct = parts(cleaned) + if (direct.length >= 2 && hostLike(direct[0])) { + return build({ host: direct[0], segments: direct.slice(1) }) + } + + if (direct.length === 2) { + return build({ host: "github.com", segments: direct }) + } + } + + try { + const url = new URL(cleaned) + if (url.protocol === "file:") return buildFile({ url, remote: cleaned }) + const pathname = parts(url.pathname) + const host = url.host + return build({ + host, + segments: pathname, + remote: host === "github.com" ? githubRemote(pathname.join("/")) : cleaned, + protocol: url.protocol, + }) + } catch { + return null + } +} + +export function parseRemoteRepositoryReference(input: string) { + const reference = parseRepositoryReference(input) + if (!reference) throw new Error("Repository must be a git URL, host/path reference, or GitHub owner/repo shorthand") + if (reference.protocol === "file:") throw new Error("Local file repositories are not supported") + return reference +} + +export function validateRepositoryBranch(branch: string) { + if (!/^[A-Za-z0-9/_.-]+$/.test(branch) || branch.startsWith("-") || branch.includes("..")) { + throw new Error( + "Branch must contain only alphanumeric characters, /, _, ., and -, and cannot start with - or contain ..", + ) + } +} + +export function parseGitHubRemote(input: string) { + const cleaned = normalize(input) + if (!cleaned.includes("://") && !cleaned.match(/^(?:[^@/\s]+@)?github\.com:/)) return null + + const parsed = parseRepositoryReference(cleaned) + if (!parsed || parsed.host !== "github.com" || !parsed.owner || parsed.segments.length !== 2) return null + return { owner: parsed.owner, repo: parsed.repo } +} + +export function repositoryCachePath(input: Reference) { + return path.join(Global.Path.repos, ...input.host.split(":"), ...input.segments) +} + +export function sameRepositoryReference(left: Reference, right: Reference) { + return left.host === right.host && left.path === right.path +} diff --git a/packages/opencode/src/util/update-schema.ts b/packages/opencode/src/util/update-schema.ts deleted file mode 100644 index f2246ece33..0000000000 --- a/packages/opencode/src/util/update-schema.ts +++ /dev/null @@ -1,13 +0,0 @@ -import z from "zod" - -export function updateSchema(schema: z.ZodObject) { - const next = {} as { - [K in keyof T]: z.ZodOptional> - } - - for (const [k, v] of Object.entries(schema.required().shape) as [keyof T & string, z.ZodTypeAny][]) { - next[k] = v.nullable() as unknown as (typeof next)[typeof k] - } - - return z.object(next) -} diff --git a/packages/opencode/src/v2/auth.ts b/packages/opencode/src/v2/auth.ts index 1cc443974d..0ac6223a66 100644 --- a/packages/opencode/src/v2/auth.ts +++ b/packages/opencode/src/v2/auth.ts @@ -1,7 +1,7 @@ import path from "path" import { Effect, Layer, Option, Schema, Context, SynchronizedRef } from "effect" import { Identifier } from "@opencode-ai/core/util/identifier" -import { NonNegativeInt, withStatics } from "@/util/schema" +import { NonNegativeInt, withStatics } from "@opencode-ai/core/schema" import { Global } from "@opencode-ai/core/global" import { AppFileSystem } from "@opencode-ai/core/filesystem" diff --git a/packages/opencode/src/v2/event.ts b/packages/opencode/src/v2/event.ts index fde8d4326f..14ee44dd52 100644 --- a/packages/opencode/src/v2/event.ts +++ b/packages/opencode/src/v2/event.ts @@ -1,7 +1,6 @@ import { Identifier } from "@/id/id" import { SyncEvent } from "@/sync" -import { withStatics } from "@/util/schema" -import { Flag } from "@opencode-ai/core/flag/flag" +import { withStatics } from "@opencode-ai/core/schema" import * as Schema from "effect/Schema" export const ID = Schema.String.pipe( @@ -41,13 +40,4 @@ export function define( - def: Def, - data: SyncEvent.Event["data"], - options?: { publish?: boolean }, -) { - if (!Flag.OPENCODE_EXPERIMENTAL_EVENT_SYSTEM) return - SyncEvent.run(def, data, options) -} - export * as EventV2 from "./event" diff --git a/packages/opencode/src/v2/model.ts b/packages/opencode/src/v2/model.ts index db66199a59..56357ab400 100644 --- a/packages/opencode/src/v2/model.ts +++ b/packages/opencode/src/v2/model.ts @@ -1,4 +1,5 @@ -import { withStatics } from "@/util/schema" +import { withStatics } from "@opencode-ai/core/schema" +import { ModelStatus } from "@/provider/model-status" import { Array, Context, Effect, HashMap, Layer, Option, Order, pipe, Schema } from "effect" import { DateTimeUtcFromMillis } from "effect/Schema" @@ -114,7 +115,7 @@ export class Info extends Schema.Class("Model.Info")({ released: DateTimeUtcFromMillis, }), cost: Cost.pipe(Schema.Array), - status: Schema.Literals(["alpha", "beta", "deprecated", "active"]), + status: ModelStatus, limit: Schema.Struct({ context: Schema.Int, input: Schema.Int.pipe(Schema.optional), diff --git a/packages/opencode/src/v2/session-event.ts b/packages/opencode/src/v2/session-event.ts index 7c768bd551..fa211bd8c4 100644 --- a/packages/opencode/src/v2/session-event.ts +++ b/packages/opencode/src/v2/session-event.ts @@ -1,5 +1,5 @@ import { SessionID } from "@/session/schema" -import { NonNegativeInt } from "@/util/schema" +import { NonNegativeInt } from "@opencode-ai/core/schema" import { EventV2 } from "./event" import { FileAttachment, Prompt } from "./session-prompt" import { Schema } from "effect" @@ -118,12 +118,12 @@ export namespace Step { finish: Schema.String, cost: Schema.Finite, tokens: Schema.Struct({ - input: NonNegativeInt, - output: NonNegativeInt, - reasoning: NonNegativeInt, + input: Schema.Finite, + output: Schema.Finite, + reasoning: Schema.Finite, cache: Schema.Struct({ - read: NonNegativeInt, - write: NonNegativeInt, + read: Schema.Finite, + write: Schema.Finite, }), }), snapshot: Schema.String.pipe(Schema.optional), @@ -305,7 +305,7 @@ export namespace Tool { export const RetryError = Schema.Struct({ message: Schema.String, - statusCode: NonNegativeInt.pipe(Schema.optional), + statusCode: Schema.Finite.pipe(Schema.optional), isRetryable: Schema.Boolean, responseHeaders: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional), responseBody: Schema.String.pipe(Schema.optional), @@ -320,7 +320,7 @@ export const Retried = EventV2.define({ aggregate: "sessionID", schema: { ...Base, - attempt: NonNegativeInt, + attempt: Schema.Finite, error: RetryError, }, }) diff --git a/packages/opencode/src/v2/session-message-updater.ts b/packages/opencode/src/v2/session-message-updater.ts index 80ecb1011e..bbdf59c555 100644 --- a/packages/opencode/src/v2/session-message-updater.ts +++ b/packages/opencode/src/v2/session-message-updater.ts @@ -123,6 +123,7 @@ export function update(adapter: Adapter, event: SessionEvent.Eve text: event.data.prompt.text, files: event.data.prompt.files, agents: event.data.prompt.agents, + references: event.data.prompt.references, time: { created: event.data.timestamp }, }), ) diff --git a/packages/opencode/src/v2/session-message.ts b/packages/opencode/src/v2/session-message.ts index 024e28c450..62fc75fc83 100644 --- a/packages/opencode/src/v2/session-message.ts +++ b/packages/opencode/src/v2/session-message.ts @@ -34,6 +34,7 @@ export class User extends Schema.Class("Session.Message.User")({ text: Prompt.fields.text, files: Prompt.fields.files, agents: Prompt.fields.agents, + references: Prompt.fields.references, type: Schema.Literal("user"), time: Schema.Struct({ created: V2Schema.DateTimeUtcFromMillis, diff --git a/packages/opencode/src/v2/session-prompt.ts b/packages/opencode/src/v2/session-prompt.ts index 86d8e52eb7..14167fc288 100644 --- a/packages/opencode/src/v2/session-prompt.ts +++ b/packages/opencode/src/v2/session-prompt.ts @@ -29,8 +29,21 @@ export class AgentAttachment extends Schema.Class("Prompt.Agent source: Source.pipe(Schema.optional), }) {} +export class ReferenceAttachment extends Schema.Class("Prompt.ReferenceAttachment")({ + name: Schema.String, + kind: Schema.Literals(["local", "git", "invalid"]), + uri: Schema.String.pipe(Schema.optional), + repository: Schema.String.pipe(Schema.optional), + branch: Schema.String.pipe(Schema.optional), + target: Schema.String.pipe(Schema.optional), + targetUri: Schema.String.pipe(Schema.optional), + problem: Schema.String.pipe(Schema.optional), + source: Source.pipe(Schema.optional), +}) {} + export class Prompt extends Schema.Class("Prompt")({ text: Schema.String, files: Schema.Array(FileAttachment).pipe(Schema.optional), agents: Schema.Array(AgentAttachment).pipe(Schema.optional), + references: Schema.Array(ReferenceAttachment).pipe(Schema.optional), }) {} diff --git a/packages/opencode/src/v2/session.ts b/packages/opencode/src/v2/session.ts index bb86f039b2..3b0b61dcbc 100644 --- a/packages/opencode/src/v2/session.ts +++ b/packages/opencode/src/v2/session.ts @@ -10,8 +10,9 @@ import { EventV2 } from "./event" import { ProjectID } from "@/project/schema" import { SessionEvent } from "./session-event" import { V2Schema } from "./schema" -import { optionalOmitUndefined } from "@/util/schema" +import { optionalOmitUndefined } from "@opencode-ai/core/schema" import { Modelv2 } from "./model" +import { SyncEvent } from "@/sync" export const Delivery = Schema.Literals(["immediate", "deferred"]).annotate({ identifier: "Session.Delivery", @@ -113,6 +114,7 @@ export class Service extends Context.Service()("@opencode/v2 export const layer = Layer.effect( Service, Effect.gen(function* () { + const sync = yield* SyncEvent.Service const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Message) const decode = (row: typeof SessionMessageTable.$inferSelect) => @@ -269,14 +271,14 @@ export const layer = Layer.effect( shell: Effect.fn("V2Session.shell")(function* (_input) {}), skill: Effect.fn("V2Session.skill")(function* (_input) {}), switchAgent: Effect.fn("V2Session.switchAgent")(function* (input) { - EventV2.run(SessionEvent.AgentSwitched.Sync, { + yield* sync.run(SessionEvent.AgentSwitched.Sync, { sessionID: input.sessionID, timestamp: DateTime.makeUnsafe(Date.now()), agent: input.agent, }) }), switchModel: Effect.fn("V2Session.switchModel")(function* (input) { - EventV2.run(SessionEvent.ModelSwitched.Sync, { + yield* sync.run(SessionEvent.ModelSwitched.Sync, { sessionID: input.sessionID, timestamp: DateTime.makeUnsafe(Date.now()), model: input.model, @@ -311,6 +313,6 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer +export const defaultLayer = layer.pipe(Layer.provide(SyncEvent.defaultLayer)) export * as SessionV2 from "./session" diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts index f4e4d2721c..a6599debdf 100644 --- a/packages/opencode/src/worktree/index.ts +++ b/packages/opencode/src/worktree/index.ts @@ -21,8 +21,6 @@ import { AppFileSystem } from "@opencode-ai/core/filesystem" import { BootstrapRuntime } from "@/effect/bootstrap-runtime" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { InstanceState } from "@/effect/instance-state" -import { zod as effectZod } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" const log = Log.create({ service: "worktree" }) @@ -46,9 +44,7 @@ export const Info = Schema.Struct({ name: Schema.String, branch: Schema.String, directory: Schema.String, -}) - .annotate({ identifier: "Worktree" }) - .pipe(withStatics((s) => ({ zod: effectZod(s) }))) +}).annotate({ identifier: "Worktree" }) export type Info = Schema.Schema.Type export const CreateInput = Schema.Struct({ @@ -56,23 +52,17 @@ export const CreateInput = Schema.Struct({ startCommand: Schema.optional( Schema.String.annotate({ description: "Additional startup script to run after the project's start command" }), ), -}) - .annotate({ identifier: "WorktreeCreateInput" }) - .pipe(withStatics((s) => ({ zod: effectZod(s) }))) +}).annotate({ identifier: "WorktreeCreateInput" }) export type CreateInput = Schema.Schema.Type export const RemoveInput = Schema.Struct({ directory: Schema.String, -}) - .annotate({ identifier: "WorktreeRemoveInput" }) - .pipe(withStatics((s) => ({ zod: effectZod(s) }))) +}).annotate({ identifier: "WorktreeRemoveInput" }) export type RemoveInput = Schema.Schema.Type export const ResetInput = Schema.Struct({ directory: Schema.String, -}) - .annotate({ identifier: "WorktreeResetInput" }) - .pipe(withStatics((s) => ({ zod: effectZod(s) }))) +}).annotate({ identifier: "WorktreeResetInput" }) export type ResetInput = Schema.Schema.Type export const NotGitError = NamedError.create( @@ -117,6 +107,13 @@ export const ResetFailedError = NamedError.create( }), ) +export const ListFailedError = NamedError.create( + "WorktreeListFailedError", + z.object({ + message: z.string(), + }), +) + function slugify(input: string) { return input .trim() @@ -149,6 +146,7 @@ export interface Interface { readonly makeWorktreeInfo: (name?: string) => Effect.Effect readonly createFromInfo: (info: Info, startCommand?: string) => Effect.Effect readonly create: (input?: CreateInput) => Effect.Effect + readonly list: () => Effect.Effect<(Omit & { branch?: string })[]> readonly remove: (input: RemoveInput) => Effect.Effect readonly reset: (input: ResetInput) => Effect.Effect } @@ -341,6 +339,34 @@ export const layer: Layer.Layer< return undefined }) + const list = Effect.fn("Worktree.list")(function* () { + const ctx = yield* InstanceState.context + if (ctx.project.vcs !== "git") { + return [] + } + + const result = yield* git(["worktree", "list", "--porcelain"], { cwd: ctx.worktree }) + if (result.code !== 0) { + throw new ListFailedError({ message: result.stderr || result.text || "Failed to read git worktrees" }) + } + + const primary = yield* canonical(ctx.worktree) + const primaryName = pathSvc.basename(primary).toLowerCase() + return yield* Effect.forEach(parseWorktreeList(result.text), (entry) => + Effect.gen(function* () { + if (!entry.path) return undefined + const directory = yield* canonical(entry.path) + if (directory === primary) return undefined + const name = pathSvc.basename(directory).toLowerCase() + return { + name: name === primaryName ? pathSvc.basename(pathSvc.dirname(directory)) : name, + directory, + ...(entry.branch ? { branch: entry.branch.replace(/^refs\/heads\//, "") } : {}), + } + }), + ).pipe(Effect.map((items) => items.filter((item) => item !== undefined))) + }) + function stopFsmonitor(target: string) { return fs.exists(target).pipe( Effect.orDie, @@ -579,7 +605,7 @@ export const layer: Layer.Layer< return true }) - return Service.of({ makeWorktreeInfo, createFromInfo, create, remove, reset }) + return Service.of({ makeWorktreeInfo, createFromInfo, create, list, remove, reset }) }), ) diff --git a/packages/opencode/test/AGENTS.md b/packages/opencode/test/AGENTS.md index 41372b15a0..52bd6ee98e 100644 --- a/packages/opencode/test/AGENTS.md +++ b/packages/opencode/test/AGENTS.md @@ -142,3 +142,18 @@ Use `provideTmpdirInstance(...)` or `tmpdirScoped()` plus `provideInstance(...)` - Yield services directly with `yield* MyService.Service` or `yield* MyTool`. - Avoid custom `ManagedRuntime`, `attach(...)`, or ad hoc `run(...)` wrappers when `testEffect(...)` already provides the runtime. - When a test needs instance-local state, prefer `it.instance(...)` over manual `Instance.provide(...)` inside Promise-style tests. + +### Partial Service Stubs + +When a test only needs to override one or two methods of a service, prefer `Layer.mock` over a hand-rolled `Layer.succeed(Service, Service.of({ ... }))`. `Layer.mock` lets you supply just the methods that matter — anything else throws an `UnimplementedError` defect if the test accidentally calls it, which is exactly the signal you want. + +```typescript +import { Effect, Layer } from "effect" +import { Account } from "@/account/account" + +const failingAccountLayer = Layer.mock(Account.Service, { + orgsByAccount: () => Effect.fail(new Account.AccountServiceError({ message: "simulated upstream failure" })), +}) +``` + +This is much shorter than stubbing every method with `Effect.void` / `Effect.succeed(...)` placeholders, and it keeps the test focused on the behaviour under test. diff --git a/packages/opencode/test/acp/event-subscription.test.ts b/packages/opencode/test/acp/event-subscription.test.ts index 2722757ab9..791b5c578f 100644 --- a/packages/opencode/test/acp/event-subscription.test.ts +++ b/packages/opencode/test/acp/event-subscription.test.ts @@ -1,8 +1,13 @@ import { describe, expect, test } from "bun:test" import { ACP } from "../../src/acp/agent" import type { AgentSideConnection } from "@agentclientprotocol/sdk" -import type { Event, EventMessagePartUpdated, ToolStatePending, ToolStateRunning } from "@opencode-ai/sdk/v2" -import { Instance } from "../../src/project/instance" +import type { + Event, + EventMessagePartUpdated, + ToolStateCompleted, + ToolStatePending, + ToolStateRunning, +} from "@opencode-ai/sdk/v2" import { WithInstance } from "../../src/project/with-instance" import { tmpdir } from "../fixture/fixture" @@ -36,6 +41,14 @@ function isToolCallUpdate( return update.sessionUpdate === "tool_call_update" } +function completedToolUpdate(sessionUpdates: SessionUpdateParams[], sessionId: string, callID: string) { + return sessionUpdates + .filter((u) => u.sessionId === sessionId) + .map((u) => u.update) + .filter(isToolCallUpdate) + .find((u) => u.toolCallId === callID && u.status === "completed") +} + function toolEvent( sessionId: string, cwd: string, @@ -78,6 +91,46 @@ function toolEvent( return { directory: cwd, payload } } +function completedToolEvent( + sessionId: string, + cwd: string, + opts: { + callID: string + tool: string + input: Record + output: string + attachments?: ToolStateCompleted["attachments"] + }, +): GlobalEventEnvelope { + const state: ToolStateCompleted = { + status: "completed", + input: opts.input, + output: opts.output, + title: opts.tool, + metadata: {}, + time: { start: Date.now() - 1, end: Date.now() }, + ...(opts.attachments && { attachments: opts.attachments }), + } + const payload: EventMessagePartUpdated = { + id: `evt_${opts.callID}`, + type: "message.part.updated", + properties: { + sessionID: sessionId, + time: Date.now(), + part: { + id: `part_${opts.callID}`, + sessionID: sessionId, + messageID: `msg_${opts.callID}`, + type: "tool", + callID: opts.callID, + tool: opts.tool, + state, + }, + }, + } + return { directory: cwd, payload } +} + function createEventStream() { const queue: GlobalEventEnvelope[] = [] const waiters: Array<(value: GlobalEventEnvelope | undefined) => void> = [] @@ -616,6 +669,130 @@ describe("acp.agent event subscription", () => { }) }) + test("emits image attachments as ACP tool content blocks on live completed tool updates", async () => { + await using tmp = await tmpdir() + await WithInstance.provide({ + directory: tmp.path, + fn: async () => { + const { agent, controller, sessionUpdates, stop } = createFakeAgent() + const cwd = "/tmp/opencode-acp-test" + const sessionId = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId) + const data = Buffer.from("image-data").toString("base64") + + controller.push( + completedToolEvent(sessionId, cwd, { + callID: "call_image", + tool: "read", + input: { filePath: "/tmp/image.png" }, + output: "Image read successfully", + attachments: [ + { + id: "part_image", + sessionID: sessionId, + messageID: "msg_image", + type: "file", + mime: "image/png", + filename: "image.png", + url: `data:image/png;base64,${data}`, + }, + { + id: "part_text", + sessionID: sessionId, + messageID: "msg_image", + type: "file", + mime: "text/plain", + filename: "note.txt", + url: "data:text/plain;base64,Zm9v", + }, + ], + }), + ) + await new Promise((r) => setTimeout(r, 20)) + + const update = completedToolUpdate(sessionUpdates, sessionId, "call_image") + expect(update?.content).toContainEqual({ + type: "content", + content: { type: "text", text: "Image read successfully" }, + }) + expect(update?.content).toContainEqual({ + type: "content", + content: { type: "image", mimeType: "image/png", data }, + }) + expect(update?.content?.some((item) => item.type === "content" && item.content.type === "resource")).toBe(false) + expect((update?.rawOutput as { attachments?: unknown[] } | undefined)?.attachments?.length).toBe(2) + + stop() + }, + }) + }) + + test("replays completed tool image attachments as ACP tool content blocks", async () => { + await using tmp = await tmpdir() + await WithInstance.provide({ + directory: tmp.path, + fn: async () => { + const { agent, sessionUpdates, stop, sdk } = createFakeAgent() + const cwd = "/tmp/opencode-acp-test" + const sessionId = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId) + const data = Buffer.from("replay-image").toString("base64") + + sdk.session.messages = async () => ({ + data: [ + { + info: { + role: "assistant", + sessionID: sessionId, + }, + parts: [ + { + id: "part_replay", + sessionID: sessionId, + messageID: "msg_replay", + type: "tool", + callID: "call_replay_image", + tool: "webfetch", + state: { + status: "completed", + input: { url: "https://example.com/image.png" }, + output: "Image fetched successfully", + title: "webfetch", + metadata: {}, + time: { start: Date.now() - 1, end: Date.now() }, + attachments: [ + { + id: "part_replay_image", + sessionID: sessionId, + messageID: "msg_replay", + type: "file", + mime: "image/jpeg", + filename: "image.jpg", + url: `data:image/jpeg;base64,${data}`, + }, + ], + }, + }, + ], + }, + ], + }) + + await agent.loadSession({ sessionId, cwd, mcpServers: [] } as any) + + const update = completedToolUpdate(sessionUpdates, sessionId, "call_replay_image") + expect(update?.content).toContainEqual({ + type: "content", + content: { type: "text", text: "Image fetched successfully" }, + }) + expect(update?.content).toContainEqual({ + type: "content", + content: { type: "image", mimeType: "image/jpeg", data }, + }) + + stop() + }, + }) + }) + test("does not emit duplicate synthetic pending after replayed running tool", async () => { await using tmp = await tmpdir() await WithInstance.provide({ diff --git a/packages/opencode/test/agent/agent.test.ts b/packages/opencode/test/agent/agent.test.ts index 6996e54b47..df68fdfdc6 100644 --- a/packages/opencode/test/agent/agent.test.ts +++ b/packages/opencode/test/agent/agent.test.ts @@ -2,11 +2,11 @@ import { afterEach, test, expect } from "bun:test" import { Effect } from "effect" import path from "path" import { disposeAllInstances, provideInstance, tmpdir } from "../fixture/fixture" -import { Instance } from "../../src/project/instance" import { WithInstance } from "../../src/project/with-instance" import { Agent } from "../../src/agent/agent" -import { Permission } from "../../src/permission" import { Global } from "@opencode-ai/core/global" +import { Flag } from "@opencode-ai/core/flag/flag" +import { Permission } from "../../src/permission" // Helper to evaluate permission for a tool with wildcard pattern function evalPerm(agent: Agent.Info | undefined, permission: string): Permission.Action | undefined { @@ -18,25 +18,38 @@ function load(dir: string, fn: (svc: Agent.Interface) => Effect.Effect) { return Effect.runPromise(provideInstance(dir)(Agent.Service.use(fn)).pipe(Effect.provide(Agent.defaultLayer))) } +async function withExperimentalScout(enabled: boolean, fn: () => Promise) { + const original = Flag.OPENCODE_EXPERIMENTAL_SCOUT + Flag.OPENCODE_EXPERIMENTAL_SCOUT = enabled + try { + await fn() + } finally { + Flag.OPENCODE_EXPERIMENTAL_SCOUT = original + } +} + afterEach(async () => { await disposeAllInstances() }) test("returns default native agents when no config", async () => { - await using tmp = await tmpdir() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const agents = await load(tmp.path, (svc) => svc.list()) - const names = agents.map((a) => a.name) - expect(names).toContain("build") - expect(names).toContain("plan") - expect(names).toContain("general") - expect(names).toContain("explore") - expect(names).toContain("compaction") - expect(names).toContain("title") - expect(names).toContain("summary") - }, + await withExperimentalScout(false, async () => { + await using tmp = await tmpdir() + await WithInstance.provide({ + directory: tmp.path, + fn: async () => { + const agents = await load(tmp.path, (svc) => svc.list()) + const names = agents.map((a) => a.name) + expect(names).toContain("build") + expect(names).toContain("plan") + expect(names).toContain("general") + expect(names).toContain("explore") + expect(names).not.toContain("scout") + expect(names).toContain("compaction") + expect(names).toContain("title") + expect(names).toContain("summary") + }, + }) }) }) @@ -51,6 +64,8 @@ test("build agent has correct default properties", async () => { expect(build?.native).toBe(true) expect(evalPerm(build, "edit")).toBe("allow") expect(evalPerm(build, "bash")).toBe("allow") + expect(evalPerm(build, "repo_clone")).toBe("deny") + expect(evalPerm(build, "repo_overview")).toBe("deny") }, }) }) @@ -102,6 +117,62 @@ test("explore agent asks for external directories and allows whitelisted externa }) }) +test("scout agent allows repo cloning and repo cache reads", async () => { + await withExperimentalScout(true, async () => { + await using tmp = await tmpdir() + await WithInstance.provide({ + directory: tmp.path, + fn: async () => { + const scout = await load(tmp.path, (svc) => svc.get("scout")) + expect(scout).toBeDefined() + expect(scout?.mode).toBe("subagent") + expect(evalPerm(scout, "repo_clone")).toBe("allow") + expect(evalPerm(scout, "repo_overview")).toBe("allow") + expect(evalPerm(scout, "edit")).toBe("deny") + expect( + Permission.evaluate( + "external_directory", + path.join(Global.Path.repos, "github.com", "owner", "repo", "README.md"), + scout!.permission, + ).action, + ).toBe("allow") + }, + }) + }) +}) + +test("reference config does not create subagents", async () => { + await withExperimentalScout(true, async () => { + await using tmp = await tmpdir({ + config: { + reference: { + effect: "github.com/effect/effect-smol", + effectFull: { + repository: "Effect-TS/effect", + branch: "main", + }, + localdocs: "../docs", + localdocsFull: { + path: "../local-docs", + }, + }, + }, + }) + await WithInstance.provide({ + directory: tmp.path, + fn: async () => { + const agents = await load(tmp.path, (svc) => svc.list()) + const names = agents.map((agent) => agent.name) + expect(names).toContain("scout") + expect(names).not.toContain("effect") + expect(names).not.toContain("effectFull") + expect(names).not.toContain("localdocs") + expect(names).not.toContain("localdocsFull") + }, + }) + }) +}) + test("general agent denies todo tools", async () => { await using tmp = await tmpdir() await WithInstance.provide({ diff --git a/packages/opencode/test/agent/plan-mode-subagent-bypass.test.ts b/packages/opencode/test/agent/plan-mode-subagent-bypass.test.ts new file mode 100644 index 0000000000..5ba6b54834 --- /dev/null +++ b/packages/opencode/test/agent/plan-mode-subagent-bypass.test.ts @@ -0,0 +1,141 @@ +/** + * Reproducer for opencode issue #26514: + * + * In Plan Mode (the `plan` agent), the main agent's edit/write tools are + * blocked by the plan agent's permission ruleset (`edit: { "*": "deny" }`). + * However, when the plan agent spawns a subagent via the `task` tool, the + * subagent retains full file modification capabilities — a security bypass. + * + * This test replicates the permission ruleset that would govern a + * `general` subagent when launched from a `plan` parent session, mirroring + * the logic in `src/tool/task.ts` (filtered parent permissions ++ runtime + * subagent agent permissions, evaluated as in `session/prompt.ts`). + * + * The expected (secure) behavior is that the subagent inherits the plan + * mode read-only restriction and `edit`/`write` resolve to `deny`. On + * origin/dev this assertion fails because the parent **agent** permissions + * are not propagated to the subagent — only the parent **session** + * permissions are passed through, and Plan Mode's restrictions live on the + * agent, not the session. + */ +import { test, expect, afterEach } from "bun:test" +import { Effect } from "effect" +import { disposeAllInstances, provideInstance, tmpdir } from "../fixture/fixture" +import { WithInstance } from "../../src/project/with-instance" +import { Agent } from "../../src/agent/agent" +import { deriveSubagentSessionPermission } from "../../src/agent/subagent-permissions" +import { Permission } from "../../src/permission" + +afterEach(async () => { + await disposeAllInstances() +}) + +function load(dir: string, fn: (svc: Agent.Interface) => Effect.Effect) { + return Effect.runPromise(provideInstance(dir)(Agent.Service.use(fn)).pipe(Effect.provide(Agent.defaultLayer))) +} + +// `deriveSubagentSessionPermission` is imported from production. The test +// exercises the actual helper that task.ts uses to build the subagent's +// session permission, so any regression in that helper trips this test. + +test("[#26514] subagent spawned from plan mode inherits read-only restriction (edit denied)", async () => { + await using tmp = await tmpdir() + await WithInstance.provide({ + directory: tmp.path, + fn: async () => { + const planAgent = await load(tmp.path, (svc) => svc.get("plan")) + const generalAgent = await load(tmp.path, (svc) => svc.get("general")) + + expect(planAgent).toBeDefined() + expect(generalAgent).toBeDefined() + // Sanity: the plan agent itself blocks edit. (Note: `write` and + // `apply_patch` route through the `edit` permission at the runtime + // tool layer — see Permission.disabled / EDIT_TOOLS.) + expect(Permission.evaluate("edit", "/some/file.ts", planAgent!.permission).action).toBe("deny") + + // Simulate the plan-mode parent session: in real flow the plan + // session's `permission` field is empty (Plan Mode lives on the agent + // ruleset, not the session). So we pass [] through as the parent + // session permission, exactly like the actual code path. + const parentSessionPermission: Permission.Ruleset = [] + + const subagentSessionPermission = deriveSubagentSessionPermission({ + parentSessionPermission, + parentAgent: planAgent, + subagent: generalAgent!, + }) + + // Mirror the runtime evaluation in session/prompt.ts (~line 410, 639): + // ruleset: Permission.merge(agent.permission, session.permission ?? []) + const effective = Permission.merge(generalAgent!.permission, subagentSessionPermission) + + expect(Permission.evaluate("edit", "/some/file.ts", effective).action).toBe("deny") + expect(Permission.evaluate("edit", "/another/path/index.tsx", effective).action).toBe("deny") + }, + }) +}) + +test("[#26514] explore subagent launched from plan mode also stays read-only", async () => { + // Sibling check: even though `explore` is intrinsically read-only, the + // bug surface is the same. Including this case to document that the fix + // should propagate the parent **agent** permissions, not just deny edit + // when the subagent happens to already deny it. + await using tmp = await tmpdir() + await WithInstance.provide({ + directory: tmp.path, + fn: async () => { + const planAgent = await load(tmp.path, (svc) => svc.get("plan")) + const explore = await load(tmp.path, (svc) => svc.get("explore")) + expect(planAgent).toBeDefined() + expect(explore).toBeDefined() + + const parentSessionPermission: Permission.Ruleset = [] + const subagentSessionPermission = deriveSubagentSessionPermission({ + parentSessionPermission, + parentAgent: planAgent, + subagent: explore!, + }) + const effective = Permission.merge(explore!.permission, subagentSessionPermission) + + // Already deny — sanity check. + expect(Permission.evaluate("edit", "/x.ts", effective).action).toBe("deny") + }, + }) +}) + +test("[#26514] custom user subagent launched from plan mode bypasses Plan Mode read-only", async () => { + // The most damaging case: a user-defined subagent with default + // permissions (allow-by-default, like `general`). The subagent must NOT + // be able to edit when the parent agent is `plan`. + await using tmp = await tmpdir({ + config: { + agent: { + my_subagent: { + description: "A user-defined subagent", + mode: "subagent", + }, + }, + }, + }) + await WithInstance.provide({ + directory: tmp.path, + fn: async () => { + const planAgent = await load(tmp.path, (svc) => svc.get("plan")) + const my = await load(tmp.path, (svc) => svc.get("my_subagent")) + expect(planAgent).toBeDefined() + expect(my).toBeDefined() + + const parentSessionPermission: Permission.Ruleset = [] + const subagentSessionPermission = deriveSubagentSessionPermission({ + parentSessionPermission, + parentAgent: planAgent, + subagent: my!, + }) + const effective = Permission.merge(my!.permission, subagentSessionPermission) + + // BUG: on origin/dev edit resolves to "allow" because the plan + // agent's `edit: deny *` rule never reaches the subagent. + expect(Permission.evaluate("edit", "/some/file.ts", effective).action).toBe("deny") + }, + }) +}) diff --git a/packages/opencode/test/cli/cmd/tui/aggregate-failures.test.ts b/packages/opencode/test/cli/cmd/tui/aggregate-failures.test.ts new file mode 100644 index 0000000000..c9b3551d9a --- /dev/null +++ b/packages/opencode/test/cli/cmd/tui/aggregate-failures.test.ts @@ -0,0 +1,55 @@ +/** + * Regression test for the TUI bootstrap aggregation helper. Replaces the + * pre-fix Promise.all behavior where the first rejection drowned every + * sibling endpoint's failure as an unhandled rejection. + */ +import { describe, expect, test } from "bun:test" +import { aggregateFailures } from "@/cli/cmd/tui/context/aggregate-failures" + +describe("aggregateFailures", () => { + test("returns null when every result is fulfilled", () => { + expect( + aggregateFailures([ + { name: "config", result: { status: "fulfilled", value: 1 } }, + { name: "providers", result: { status: "fulfilled", value: 2 } }, + ]), + ).toBeNull() + }) + + test("names the failed endpoint when one rejects", () => { + const err = aggregateFailures([ + { name: "config", result: { status: "fulfilled", value: 1 } }, + { + name: "providers", + result: { status: "rejected", reason: new Error("Service unavailable") }, + }, + ]) + expect(err).toBeInstanceOf(Error) + expect(err!.message).toContain("1 of 2") + expect(err!.message).toContain("providers: Service unavailable") + }) + + test("names every failed endpoint when multiple reject", () => { + const err = aggregateFailures([ + { name: "config", result: { status: "rejected", reason: new Error("400 Bad Request") } }, + { name: "providers", result: { status: "fulfilled", value: 1 } }, + { name: "agents", result: { status: "rejected", reason: { message: "boom" } } }, + ]) + expect(err).toBeInstanceOf(Error) + expect(err!.message).toContain("2 of 3") + expect(err!.message).toContain("config: 400 Bad Request") + expect(err!.message).toContain("agents: boom") + }) + + test("attaches structured failure list under .cause", () => { + const reason = new Error("nope") + const err = aggregateFailures([{ name: "providers", result: { status: "rejected", reason } }]) + const cause = err!.cause as { failures: Array<{ name: string; reason: unknown }> } + expect(cause.failures).toEqual([{ name: "providers", reason }]) + }) + + test("falls back to String() for opaque reasons", () => { + const err = aggregateFailures([{ name: "x", result: { status: "rejected", reason: 42 } }]) + expect(err!.message).toContain("x: 42") + }) +}) diff --git a/packages/opencode/test/cli/cmd/tui/dialog-workspace-create.test.ts b/packages/opencode/test/cli/cmd/tui/dialog-workspace-create.test.ts index a32dc61125..00b480ca00 100644 --- a/packages/opencode/test/cli/cmd/tui/dialog-workspace-create.test.ts +++ b/packages/opencode/test/cli/cmd/tui/dialog-workspace-create.test.ts @@ -2,13 +2,13 @@ import { describe, expect, test } from "bun:test" import { recentConnectedWorkspaces } from "../../../../src/cli/cmd/tui/component/dialog-workspace-create" describe("recentConnectedWorkspaces", () => { - test("returns unique connected workspaces after filtering missing and inactive entries", () => { + test("returns connected workspaces sorted by time used", () => { const workspaces = [ - { id: "wrk_a", name: "alpha" }, - { id: "wrk_b", name: "beta" }, - { id: "wrk_c", name: "gamma" }, - { id: "wrk_d", name: "delta" }, - { id: "wrk_e", name: "epsilon" }, + { id: "wrk_a", name: "alpha", timeUsed: 700 }, + { id: "wrk_b", name: "beta", timeUsed: 800 }, + { id: "wrk_c", name: "gamma", timeUsed: 400 }, + { id: "wrk_d", name: "delta", timeUsed: 300 }, + { id: "wrk_e", name: "epsilon", timeUsed: 200 }, ] const status = { wrk_a: "connected", @@ -19,45 +19,10 @@ describe("recentConnectedWorkspaces", () => { } as const const { recent } = recentConnectedWorkspaces({ - sessions: [ - { time: { updated: 900 } }, - { workspaceID: "wrk_b", time: { updated: 800 } }, - { workspaceID: "wrk_a", time: { updated: 700 } }, - { workspaceID: "wrk_a", time: { updated: 600 } }, - { workspaceID: "wrk_missing", time: { updated: 500 } }, - { workspaceID: "wrk_c", time: { updated: 400 } }, - { workspaceID: "wrk_d", time: { updated: 300 } }, - { workspaceID: "wrk_e", time: { updated: 200 } }, - ], - get: (workspaceID) => workspaces.find((workspace) => workspace.id === workspaceID), + workspaces, status: (workspaceID) => status[workspaceID as keyof typeof status], }) expect(recent.map((workspace) => workspace.id)).toEqual(["wrk_a", "wrk_d", "wrk_e"]) }) - - test("omits the active workspace before limiting recent workspaces", () => { - const workspaces = [ - { id: "wrk_a", name: "alpha" }, - { id: "wrk_b", name: "beta" }, - { id: "wrk_c", name: "gamma" }, - { id: "wrk_d", name: "delta" }, - ] - - const { recent, hasMore } = recentConnectedWorkspaces({ - sessions: [ - { workspaceID: "wrk_a", time: { updated: 400 } }, - { workspaceID: "wrk_b", time: { updated: 300 } }, - { workspaceID: "wrk_c", time: { updated: 200 } }, - { workspaceID: "wrk_d", time: { updated: 100 } }, - ], - get: (workspaceID) => workspaces.find((workspace) => workspace.id === workspaceID), - status: () => "connected", - limit: 3, - omitWorkspaceID: "wrk_a", - }) - - expect(recent.map((workspace) => workspace.id)).toEqual(["wrk_b", "wrk_c", "wrk_d"]) - expect(hasMore).toBe(false) - }) }) diff --git a/packages/opencode/test/cli/cmd/tui/prompt-traits.test.ts b/packages/opencode/test/cli/cmd/tui/prompt-traits.test.ts index 34a16aedd6..a7b1643357 100644 --- a/packages/opencode/test/cli/cmd/tui/prompt-traits.test.ts +++ b/packages/opencode/test/cli/cmd/tui/prompt-traits.test.ts @@ -3,36 +3,27 @@ import { computePromptTraits } from "../../../../src/cli/cmd/tui/component/promp describe("computePromptTraits", () => { test("normal mode without autocomplete only captures tab", () => { - const traits = computePromptTraits({ mode: "normal", disabled: false, autocompleteVisible: false }) + const traits = computePromptTraits({ mode: "normal", autocompleteVisible: false }) expect(traits.capture).toEqual(["tab"]) - expect(traits.suspend).toBe(false) + expect(traits.suspend).toBeUndefined() expect(traits.status).toBeUndefined() }) test("normal mode with autocomplete captures navigation keys", () => { - const traits = computePromptTraits({ mode: "normal", disabled: false, autocompleteVisible: true }) + const traits = computePromptTraits({ mode: "normal", autocompleteVisible: true }) expect(traits.capture).toEqual(["escape", "navigate", "submit", "tab"]) - expect(traits.suspend).toBe(false) + expect(traits.suspend).toBeUndefined() expect(traits.status).toBeUndefined() }) - test("shell mode does not suspend the textarea", () => { - // Suspending the textarea would gate every keybinding action - // (backspace, delete-word-backward, arrow movement, etc.) — see - // @opentui/core 0.2.x TextareaRenderable.handleKeyPress. Shell mode is - // an active editing mode, so suspend must stay off. - const traits = computePromptTraits({ mode: "shell", disabled: false, autocompleteVisible: false }) - expect(traits.suspend).toBe(false) + test("shell mode does not write the keymap-owned suspend trait", () => { + const traits = computePromptTraits({ mode: "shell", autocompleteVisible: false }) + expect(traits.suspend).toBeUndefined() }) test("shell mode disables capture and labels the prompt", () => { - const traits = computePromptTraits({ mode: "shell", disabled: false, autocompleteVisible: false }) + const traits = computePromptTraits({ mode: "shell", autocompleteVisible: false }) expect(traits.capture).toBeUndefined() expect(traits.status).toBe("SHELL") }) - - test("disabled suspends regardless of mode", () => { - expect(computePromptTraits({ mode: "normal", disabled: true, autocompleteVisible: false }).suspend).toBe(true) - expect(computePromptTraits({ mode: "shell", disabled: true, autocompleteVisible: false }).suspend).toBe(true) - }) }) diff --git a/packages/opencode/test/cli/cmd/tui/sync-fixture.tsx b/packages/opencode/test/cli/cmd/tui/sync-fixture.tsx new file mode 100644 index 0000000000..d9ecdbe9d5 --- /dev/null +++ b/packages/opencode/test/cli/cmd/tui/sync-fixture.tsx @@ -0,0 +1,120 @@ +/** @jsxImportSource @opentui/solid */ +import { testRender } from "@opentui/solid" +import { onMount } from "solid-js" +import { ArgsProvider } from "../../../../src/cli/cmd/tui/context/args" +import { ExitProvider } from "../../../../src/cli/cmd/tui/context/exit" +import { KVProvider, useKV } from "../../../../src/cli/cmd/tui/context/kv" +import { ProjectProvider } from "../../../../src/cli/cmd/tui/context/project" +import { SDKProvider, type EventSource } from "../../../../src/cli/cmd/tui/context/sdk" +import { SyncProvider, useSync } from "../../../../src/cli/cmd/tui/context/sync" + +export const worktree = "/tmp/opencode" +export const directory = `${worktree}/packages/opencode` + +export async function wait(fn: () => boolean, timeout = 2000) { + const start = Date.now() + while (!fn()) { + if (Date.now() - start > timeout) throw new Error("timed out waiting for condition") + await Bun.sleep(10) + } +} + +export function json(data: unknown, init?: ResponseInit) { + return new Response(JSON.stringify(data), { + ...init, + headers: { "content-type": "application/json", ...(init?.headers ?? {}) }, + }) +} + +export function eventSource(): EventSource { + return { subscribe: async () => () => {} } +} + +type FetchHandler = (url: URL) => Response | Promise | undefined + +export function createFetch(override?: FetchHandler) { + const session = [] as URL[] + const fetch = (async (input: RequestInfo | URL) => { + const url = new URL(input instanceof Request ? input.url : String(input)) + if (url.pathname === "/session") session.push(url) + + const overridden = await override?.(url) + if (overridden) return overridden + + switch (url.pathname) { + case "/agent": + case "/command": + case "/experimental/workspace": + case "/experimental/workspace/status": + case "/formatter": + case "/lsp": + return json([]) + case "/config": + case "/experimental/resource": + case "/mcp": + case "/provider/auth": + case "/session/status": + return json({}) + case "/config/providers": + return json({ providers: {}, default: {} }) + case "/experimental/console": + return json({ consoleManagedProviders: [], switchableOrgCount: 0 }) + case "/path": + return json({ home: "", state: "", config: "", worktree, directory }) + case "/project/current": + return json({ id: "proj_test" }) + case "/provider": + return json({ all: [], default: {}, connected: [] }) + case "/session": + return json([]) + case "/vcs": + return json({ branch: "main" }) + } + + throw new Error(`unexpected request: ${url.pathname}`) + }) as typeof globalThis.fetch + + return { fetch, session } +} + +type Ctx = { kv: ReturnType; sync: ReturnType } + +export async function mount(override?: FetchHandler) { + const calls = createFetch(override) + let sync!: ReturnType + let kv!: ReturnType + let done!: () => void + const ready = new Promise((resolve) => { + done = resolve + }) + + function Probe() { + const ctx: Ctx = { kv: useKV(), sync: useSync() } + onMount(() => { + sync = ctx.sync + kv = ctx.kv + done() + }) + return + } + + const app = await testRender(() => ( + + + + + + + + + + + + + + )) + + await ready + await wait(() => sync.status === "complete") + return { app, kv, sync, session: calls.session } +} diff --git a/packages/opencode/test/cli/cmd/tui/sync-undefined-messages.test.tsx b/packages/opencode/test/cli/cmd/tui/sync-undefined-messages.test.tsx new file mode 100644 index 0000000000..5fb7ece94d --- /dev/null +++ b/packages/opencode/test/cli/cmd/tui/sync-undefined-messages.test.tsx @@ -0,0 +1,47 @@ +/** @jsxImportSource @opentui/solid */ +/** + * Reproducer for #26560 — TUI crashes with + * `TypeError: undefined is not an object (evaluating 'f.data.map')` + * when entering a session whose messages endpoint returns a non-2xx. + * The failure path is `sync.tsx#sync.session.sync` reading + * `messages.data!` while the SDK leaves `data` undefined on error. + */ +import { describe, expect, test } from "bun:test" +import { Global } from "@opencode-ai/core/global" +import { tmpdir } from "../../../fixture/fixture" +import { directory, json, mount } from "./sync-fixture" + +const sessionID = "ses_undef" + +describe("tui sync (#26560)", () => { + test("entering a session whose messages endpoint errors does not crash sync", async () => { + const previous = Global.Path.state + await using tmp = await tmpdir() + Global.Path.state = tmp.path + await Bun.write(`${tmp.path}/kv.json`, "{}") + + const sessionPayload = { + id: sessionID, + title: "broken", + time: { created: 0, updated: 0 }, + version: "1.14.42", + directory, + project_id: "proj_test", + } + const { app, sync } = await mount((url) => { + if (url.pathname === `/session/${sessionID}`) return json(sessionPayload) + if (url.pathname === `/session/${sessionID}/messages`) return json({}, { status: 500 }) + if (url.pathname === `/session/${sessionID}/todo`) return json([]) + if (url.pathname === `/session/${sessionID}/diff`) return json([]) + if (url.pathname === "/session") return json([sessionPayload]) + return undefined + }) + + try { + await expect(sync.session.sync(sessionID)).resolves.toBeUndefined() + } finally { + app.renderer.destroy() + Global.Path.state = previous + } + }) +}) diff --git a/packages/opencode/test/cli/cmd/tui/sync.test.tsx b/packages/opencode/test/cli/cmd/tui/sync.test.tsx index 993484d3ca..f67257f6ce 100644 --- a/packages/opencode/test/cli/cmd/tui/sync.test.tsx +++ b/packages/opencode/test/cli/cmd/tui/sync.test.tsx @@ -1,127 +1,8 @@ /** @jsxImportSource @opentui/solid */ import { describe, expect, test } from "bun:test" -import { testRender } from "@opentui/solid" -import { onMount } from "solid-js" import { Global } from "@opencode-ai/core/global" -import { ArgsProvider } from "../../../../src/cli/cmd/tui/context/args" -import { ExitProvider } from "../../../../src/cli/cmd/tui/context/exit" -import { KVProvider, useKV } from "../../../../src/cli/cmd/tui/context/kv" -import { ProjectProvider } from "../../../../src/cli/cmd/tui/context/project" -import { SDKProvider, type EventSource } from "../../../../src/cli/cmd/tui/context/sdk" -import { SyncProvider, useSync } from "../../../../src/cli/cmd/tui/context/sync" import { tmpdir } from "../../../fixture/fixture" - -const worktree = "/tmp/opencode" -const directory = `${worktree}/packages/opencode` - -async function wait(fn: () => boolean, timeout = 2000) { - const start = Date.now() - while (!fn()) { - if (Date.now() - start > timeout) throw new Error("timed out waiting for condition") - await Bun.sleep(10) - } -} - -function json(data: unknown) { - return new Response(JSON.stringify(data), { - headers: { "content-type": "application/json" }, - }) -} - -function eventSource(): EventSource { - return { - subscribe: async () => () => {}, - } -} - -function createFetch() { - const session = [] as URL[] - const fetch = (async (input: RequestInfo | URL) => { - const url = new URL(input instanceof Request ? input.url : String(input)) - if (url.pathname === "/session") session.push(url) - - switch (url.pathname) { - case "/agent": - case "/command": - case "/experimental/workspace": - case "/experimental/workspace/status": - case "/formatter": - case "/lsp": - return json([]) - case "/config": - case "/experimental/resource": - case "/mcp": - case "/provider/auth": - case "/session/status": - return json({}) - case "/config/providers": - return json({ providers: {}, default: {} }) - case "/experimental/console": - return json({ consoleManagedProviders: [], switchableOrgCount: 0 }) - case "/path": - return json({ home: "", state: "", config: "", worktree, directory }) - case "/project/current": - return json({ id: "proj_test" }) - case "/provider": - return json({ all: [], default: {}, connected: [] }) - case "/session": - return json([]) - case "/vcs": - return json({ branch: "main" }) - } - - throw new Error(`unexpected request: ${url.pathname}`) - }) as typeof globalThis.fetch - - return { fetch, session } -} - -async function mount() { - const calls = createFetch() - let sync!: ReturnType - let kv!: ReturnType - let done!: () => void - const ready = new Promise((resolve) => { - done = resolve - }) - - const app = await testRender(() => ( - - - - - - - { - sync = ctx.sync - kv = ctx.kv - done() - }} - /> - - - - - - - )) - - await ready - await wait(() => sync.status === "complete") - return { app, kv, sync, session: calls.session } -} - -function Probe(props: { onReady: (ctx: { kv: ReturnType; sync: ReturnType }) => void }) { - const kv = useKV() - const sync = useSync() - - onMount(() => { - props.onReady({ kv, sync }) - }) - - return -} +import { mount } from "./sync-fixture" describe("tui sync", () => { test("refresh scopes sessions by default and lists project sessions when disabled", async () => { diff --git a/packages/opencode/test/cli/github-action.test.ts b/packages/opencode/test/cli/github-action.test.ts index 279ed27d08..263f3a45f3 100644 --- a/packages/opencode/test/cli/github-action.test.ts +++ b/packages/opencode/test/cli/github-action.test.ts @@ -7,8 +7,8 @@ import { SessionID, MessageID, PartID } from "../../src/session/schema" function createTextPart(text: string): MessageV2.Part { return { id: PartID.ascending(), - sessionID: SessionID.make("s"), - messageID: MessageID.make("m"), + sessionID: SessionID.make("ses_test"), + messageID: MessageID.make("msg_test"), type: "text" as const, text, } @@ -17,8 +17,8 @@ function createTextPart(text: string): MessageV2.Part { function createReasoningPart(text: string): MessageV2.Part { return { id: PartID.ascending(), - sessionID: SessionID.make("s"), - messageID: MessageID.make("m"), + sessionID: SessionID.make("ses_test"), + messageID: MessageID.make("msg_test"), type: "reasoning" as const, text, time: { start: 0 }, @@ -29,8 +29,8 @@ function createToolPart(tool: string, title: string, status: "completed" | "runn if (status === "completed") { return { id: PartID.ascending(), - sessionID: SessionID.make("s"), - messageID: MessageID.make("m"), + sessionID: SessionID.make("ses_test"), + messageID: MessageID.make("msg_test"), type: "tool" as const, callID: "c1", tool, @@ -46,8 +46,8 @@ function createToolPart(tool: string, title: string, status: "completed" | "runn } return { id: PartID.ascending(), - sessionID: SessionID.make("s"), - messageID: MessageID.make("m"), + sessionID: SessionID.make("ses_test"), + messageID: MessageID.make("msg_test"), type: "tool" as const, callID: "c1", tool, @@ -62,8 +62,8 @@ function createToolPart(tool: string, title: string, status: "completed" | "runn function createStepStartPart(): MessageV2.Part { return { id: PartID.ascending(), - sessionID: SessionID.make("s"), - messageID: MessageID.make("m"), + sessionID: SessionID.make("ses_test"), + messageID: MessageID.make("msg_test"), type: "step-start" as const, } } @@ -71,8 +71,8 @@ function createStepStartPart(): MessageV2.Part { function createStepFinishPart(): MessageV2.Part { return { id: PartID.ascending(), - sessionID: SessionID.make("s"), - messageID: MessageID.make("m"), + sessionID: SessionID.make("ses_test"), + messageID: MessageID.make("msg_test"), type: "step-finish" as const, reason: "done", cost: 0, diff --git a/packages/opencode/test/cli/github-remote.test.ts b/packages/opencode/test/cli/github-remote.test.ts index 80102d986e..ed37b92d41 100644 --- a/packages/opencode/test/cli/github-remote.test.ts +++ b/packages/opencode/test/cli/github-remote.test.ts @@ -25,6 +25,16 @@ test("parses ssh:// URL without .git suffix", () => { expect(parseGitHubRemote("ssh://git@github.com/sst/opencode")).toEqual({ owner: "sst", repo: "opencode" }) }) +test("parses git protocol URLs from package metadata", () => { + expect(parseGitHubRemote("git://github.com/facebook/react.git")).toEqual({ owner: "facebook", repo: "react" }) + expect(parseGitHubRemote("git+https://github.com/facebook/react.git")).toEqual({ owner: "facebook", repo: "react" }) + expect(parseGitHubRemote("git+ssh://git@github.com/facebook/react.git")).toEqual({ owner: "facebook", repo: "react" }) +}) + +test("parses npm-style github shorthand", () => { + expect(parseGitHubRemote("github:facebook/react")).toBeNull() +}) + test("parses http URL", () => { expect(parseGitHubRemote("http://github.com/owner/repo")).toEqual({ owner: "owner", repo: "repo" }) }) diff --git a/packages/opencode/test/cli/run/runtime.boot.test.ts b/packages/opencode/test/cli/run/runtime.boot.test.ts index c0d66bf75d..e2569b0ac6 100644 --- a/packages/opencode/test/cli/run/runtime.boot.test.ts +++ b/packages/opencode/test/cli/run/runtime.boot.test.ts @@ -1,12 +1,11 @@ import { afterEach, describe, expect, mock, spyOn, test } from "bun:test" import type { KeyEvent, Renderable } from "@opentui/core" import type { Binding } from "@opentui/keymap" -import { resolveBindingSections, type BindingSectionsConfig } from "@opentui/keymap/extras" +import { createBindingLookup } from "@opentui/keymap/extras" import { OpencodeClient, type Provider } from "@opencode-ai/sdk/v2" import { TuiConfig, type Resolved } from "@/cli/cmd/tui/config/tui" import { formatBindings } from "@/cli/cmd/run/keymap.shared" -import { KeymapSectionNames, keymapBindingDefaults, type KeymapSection } from "@/cli/cmd/tui/config/tui-schema" -import { ConfigKeybinds } from "@/config/keybinds" +import { TuiKeybind } from "@/cli/cmd/tui/config/keybind" import { resolveDiffStyle, resolveFooterKeybinds, resolveModelInfo } from "@/cli/cmd/run/runtime.boot" type RunBinding = Binding @@ -82,34 +81,24 @@ function config(input?: { }> }): Resolved { const bind = input?.bindings - const sections = { - global: Object.fromEntries([ - ...(bind?.commandList ? [["command.palette.show", bind.commandList] as const] : []), - ...(bind?.variantCycle ? [["variant.cycle", bind.variantCycle] as const] : []), - ]), - prompt: Object.fromEntries([ - ...(bind?.interrupt ? [["session.interrupt", bind.interrupt] as const] : []), - ...(bind?.historyPrevious ? [["prompt.history.previous", bind.historyPrevious] as const] : []), - ...(bind?.historyNext ? [["prompt.history.next", bind.historyNext] as const] : []), - ...(bind?.inputClear ? [["prompt.clear", bind.inputClear] as const] : []), - ]), - input: Object.fromEntries([ - ...(bind?.inputSubmit ? [["input.submit", bind.inputSubmit] as const] : []), - ...(bind?.inputNewline ? [["input.newline", bind.inputNewline] as const] : []), - ]), - } satisfies BindingSectionsConfig - + const keybinds = TuiKeybind.Keybinds.parse({ + ...(input?.leader && { leader: input.leader }), + ...(bind?.commandList && { command_list: bind.commandList }), + ...(bind?.variantCycle && { variant_cycle: bind.variantCycle }), + ...(bind?.interrupt && { session_interrupt: bind.interrupt }), + ...(bind?.historyPrevious && { history_previous: bind.historyPrevious }), + ...(bind?.historyNext && { history_next: bind.historyNext }), + ...(bind?.inputClear && { input_clear: bind.inputClear }), + ...(bind?.inputSubmit && { input_submit: bind.inputSubmit }), + ...(bind?.inputNewline && { input_newline: bind.inputNewline }), + }) return { diff_style: input?.diff_style, - keybinds: ConfigKeybinds.Keybinds.parse({}), - keymap: { - leader: input?.leader ?? "ctrl+x", - leader_timeout: input?.leaderTimeout ?? 2000, - ...resolveBindingSections(sections, { - sections: KeymapSectionNames, - bindingDefaults: keymapBindingDefaults, - }), - }, + keybinds: createBindingLookup(TuiKeybind.toBindingConfig(keybinds), { + commandMap: TuiKeybind.CommandMap, + bindingDefaults: TuiKeybind.bindingDefaults(), + }), + leader_timeout: input?.leaderTimeout ?? 2000, } } @@ -118,7 +107,7 @@ describe("run runtime boot", () => { mock.restore() }) - test("reads footer keybinds from resolved keymap config", async () => { + test("reads footer keybinds from resolved keybind config", async () => { spyOn(TuiConfig, "get").mockResolvedValue( config({ leader: "ctrl+g", diff --git a/packages/opencode/test/cli/run/stream.transport.test.ts b/packages/opencode/test/cli/run/stream.transport.test.ts index dab5264bd6..3358ae774d 100644 --- a/packages/opencode/test/cli/run/stream.transport.test.ts +++ b/packages/opencode/test/cli/run/stream.transport.test.ts @@ -1,9 +1,10 @@ import { afterEach, describe, expect, mock, spyOn, test } from "bun:test" -import { OpencodeClient } from "@opencode-ai/sdk/v2" +import { OpencodeClient, type GlobalEvent } from "@opencode-ai/sdk/v2" import { createSessionTransport } from "@/cli/cmd/run/stream.transport" import type { FooterApi, FooterEvent, RunFilePart, StreamCommit } from "@/cli/cmd/run/types" type EventStream = Awaited>["stream"] +type GlobalEventStream = Awaited>["stream"] type SdkEvent = EventStream extends AsyncGenerator ? T : never type SessionMessage = NonNullable>["data"]>[number] type SessionChild = NonNullable>["data"]>[number] @@ -81,12 +82,12 @@ function assistant(id: string) { } satisfies SdkEvent } -function feed() { - const list: SdkEvent[] = [] +function feed() { + const list: T[] = [] let done = false let wake: (() => void) | undefined - const stream: EventStream = (async function* () { + const wrapped = (async function* () { while (!done || list.length > 0) { if (list.length === 0) { await new Promise((resolve) => { @@ -105,8 +106,8 @@ function feed() { })() return { - stream, - push(value: SdkEvent) { + stream: wrapped, + push(value: T) { list.push(value) wake?.() wake = undefined @@ -119,6 +120,14 @@ function feed() { } } +function eventFeed() { + return feed() +} + +function globalFeed() { + return feed() +} + function emptyStream(): EventStream { return (async function* (): AsyncGenerator {})() } @@ -136,6 +145,18 @@ function sse(stream: EventStream) { return Promise.resolve({ stream }) } +function globalSse(stream: GlobalEventStream) { + return Promise.resolve({ stream }) +} + +function wrapGlobalStream(stream: EventStream): GlobalEventStream { + return (async function* () { + for await (const event of stream) { + yield globalEvent(event) + } + })() +} + function statusMap(busy: boolean): SessionStatusMap { if (busy) { return { "session-1": { type: "busy" } } @@ -235,10 +256,10 @@ function completedTool(input: { } } -function textPart(id: string, messageID: string, text: string): TextPart { +function textPart(id: string, messageID: string, text: string, sessionID = "session-1"): TextPart { return { id, - sessionID: "session-1", + sessionID, messageID, type: "text", text, @@ -298,6 +319,14 @@ function child(id: string): SessionChild { } } +function globalEvent(payload: GlobalEvent["payload"]): GlobalEvent { + return { + directory: "/tmp", + project: "project-1", + payload, + } +} + function footer(fn?: (commit: StreamCommit) => void) { const commits: StreamCommit[] = [] const events: FooterEvent[] = [] @@ -333,7 +362,9 @@ function footer(fn?: (commit: StreamCommit) => void) { function sdk( input: { stream?: EventStream + globalStream?: GlobalEventStream subscribe?: OpencodeClient["event"]["subscribe"] + globalEvent?: OpencodeClient["global"]["event"] promptAsync?: OpencodeClient["session"]["promptAsync"] status?: OpencodeClient["session"]["status"] messages?: OpencodeClient["session"]["messages"] @@ -345,6 +376,8 @@ function sdk( const client = new OpencodeClient() const subscribe: OpencodeClient["event"]["subscribe"] = input.subscribe ?? (() => sse(input.stream ?? emptyStream())) + const globalEvent: OpencodeClient["global"]["event"] = + input.globalEvent ?? (() => globalSse(input.globalStream ?? wrapGlobalStream(input.stream ?? emptyStream()))) const promptAsync: OpencodeClient["session"]["promptAsync"] = input.promptAsync ?? (() => ok(undefined)) const status: OpencodeClient["session"]["status"] = input.status ?? (() => ok({})) const messages: OpencodeClient["session"]["messages"] = input.messages ?? (() => ok([])) @@ -353,6 +386,7 @@ function sdk( const questions: OpencodeClient["question"]["list"] = input.questions ?? (() => ok([])) spyOn(client.event, "subscribe").mockImplementation(subscribe) + spyOn(client.global, "event").mockImplementation(globalEvent) spyOn(client.session, "promptAsync").mockImplementation(promptAsync) spyOn(client.session, "status").mockImplementation(status) spyOn(client.session, "messages").mockImplementation(messages) @@ -365,7 +399,7 @@ function sdk( describe("run stream transport", () => { test("bootstraps child tabs and resumed blocker input", async () => { - const src = feed() + const src = eventFeed() const ui = footer() const transport = await createSessionTransport({ sdk: sdk({ @@ -440,61 +474,70 @@ describe("run stream transport", () => { }) try { - expect(ui.events).toContainEqual({ - type: "stream.subagent", - state: { - tabs: [ - expect.objectContaining({ - sessionID: "child-1", - label: "Explore", - description: "Explore run folder", - status: "running", - }), - ], - details: {}, - permissions: [ - expect.objectContaining({ - id: "perm-1", - sessionID: "child-1", - metadata: { - input: { - filePath: "src/run/subagent-data.ts", - diff: "@@ -1 +1 @@", - }, - }, - }), - ], - questions: [], - }, + const boot = await waitFor(() => { + const item = ui.events.findLast((event) => event.type === "stream.subagent") + const state = item?.type === "stream.subagent" ? item.state : undefined + return state?.tabs.some((tab) => tab.sessionID === "child-1") && + state.permissions.some((req) => req.id === "perm-1") + ? state + : undefined }) + expect(boot.tabs).toEqual([ + expect.objectContaining({ + sessionID: "child-1", + label: "Explore", + description: "Explore run folder", + status: "running", + }), + ]) + expect(boot.permissions).toEqual([ + expect.objectContaining({ + id: "perm-1", + sessionID: "child-1", + metadata: { + input: { + filePath: "src/run/subagent-data.ts", + diff: "@@ -1 +1 @@", + }, + }, + }), + ]) + transport.selectSubagent("child-1") - expect(ui.events).toContainEqual({ - type: "stream.subagent", - state: { - tabs: [ + const selected = await waitFor(() => { + const item = ui.events.findLast((event) => event.type === "stream.subagent") + const state = item?.type === "stream.subagent" ? item.state : undefined + const detail = state?.details["child-1"] + return detail?.commits.some( + (commit) => commit.kind === "tool" && commit.tool === "edit" && commit.phase === "start", + ) + ? state + : undefined + }) + + expect(selected.details).toEqual({ + "child-1": { + sessionID: "child-1", + commits: [ expect.objectContaining({ - sessionID: "child-1", - label: "Explore", + kind: "tool", + tool: "edit", + phase: "start", }), ], - details: { - "child-1": { - sessionID: "child-1", - commits: [], - }, - }, - permissions: [ - expect.objectContaining({ - id: "perm-1", - }), - ], - questions: [], }, }) - expect(ui.events).toContainEqual({ + expect( + await waitFor(() => { + const item = ui.events.findLast((event) => event.type === "stream.view") + return item?.type === "stream.view" && item.view.type === "permission" && item.view.request.id === "perm-1" + ? item + : undefined + }), + ).toEqual({ type: "stream.view", view: { type: "permission", @@ -515,8 +558,265 @@ describe("run stream transport", () => { } }) + test("bootstraps child session output before selection", async () => { + const ui = footer() + const transport = await createSessionTransport({ + sdk: sdk({ + messages: async ({ sessionID }) => { + if (sessionID === "session-1") { + return ok([ + assistantMessage({ + sessionID: "session-1", + id: "msg-1", + parts: [ + completedTool({ + sessionID: "session-1", + messageID: "msg-1", + id: "task-1", + callID: "call-1", + tool: "task", + body: { + description: "Explore run.ts", + subagent_type: "explore", + }, + metadata: { + sessionId: "child-1", + }, + }), + ], + }), + ]) + } + + return sessionID === "child-1" + ? ok([ + assistantMessage({ + sessionID: "child-1", + id: "msg-child-1", + parts: [textPart("txt-child-1", "msg-child-1", "subagent summary", "child-1")], + }), + ]) + : ok([]) + }, + children: async () => ok([child("child-1")]), + }), + sessionID: "session-1", + thinking: true, + limits: () => ({}), + footer: ui.api, + }) + + try { + await waitFor(() => { + const item = ui.events.findLast((event) => event.type === "stream.subagent") + return item?.type === "stream.subagent" && item.state.tabs.some((tab) => tab.sessionID === "child-1") + ? item + : undefined + }) + + transport.selectSubagent("child-1") + + expect( + await waitFor(() => { + const item = ui.events.findLast((event) => event.type === "stream.subagent") + const detail = item?.type === "stream.subagent" ? item.state.details["child-1"] : undefined + return detail?.commits.some((commit) => commit.kind === "assistant" && commit.text === "subagent summary") + ? detail + : undefined + }), + ).toEqual({ + sessionID: "child-1", + commits: [ + expect.objectContaining({ + kind: "assistant", + text: "subagent summary", + }), + ], + }) + } finally { + await transport.close() + } + }) + + test("does not block startup on child history bootstrap", async () => { + const pending = defer>>>() + const ui = footer() + let transport: Awaited> | undefined + + const task = createSessionTransport({ + sdk: sdk({ + messages: async ({ sessionID }) => { + if (sessionID === "session-1") { + return ok([ + assistantMessage({ + sessionID: "session-1", + id: "msg-1", + parts: [ + runningTool({ + sessionID: "session-1", + messageID: "msg-1", + id: "task-1", + callID: "call-1", + tool: "task", + body: { + description: "Explore run.ts", + subagent_type: "explore", + }, + metadata: { + sessionId: "child-1", + }, + }), + ], + }), + ]) + } + + if (sessionID === "child-1") { + return pending.promise + } + + return ok([]) + }, + children: async () => ok([child("child-1")]), + }), + sessionID: "session-1", + thinking: true, + limits: () => ({}), + footer: ui.api, + }).then((item) => { + transport = item + return item + }) + + try { + const state = await waitFor(() => { + const item = ui.events.findLast((event) => event.type === "stream.subagent") + return item?.type === "stream.subagent" && item.state.tabs.some((tab) => tab.sessionID === "child-1") + ? item.state + : undefined + }) + + await waitFor(() => transport) + + expect(state).toEqual({ + tabs: [expect.objectContaining({ sessionID: "child-1", status: "running" })], + details: {}, + permissions: [], + questions: [], + }) + } finally { + pending.resolve(ok([])) + await task + await transport?.close() + } + }) + + test("streams selected subagent output from global events while it is running", async () => { + const global = globalFeed() + const ui = footer() + const transport = await createSessionTransport({ + sdk: sdk({ + globalStream: global.stream, + }), + sessionID: "session-1", + thinking: true, + limits: () => ({}), + footer: ui.api, + }) + + try { + global.push(globalEvent(assistant("msg-1"))) + global.push( + globalEvent( + toolUpdated( + runningTool({ + sessionID: "session-1", + messageID: "msg-1", + id: "task-1", + callID: "call-1", + tool: "task", + body: { + description: "Explore run.ts", + subagent_type: "explore", + }, + metadata: { + sessionId: "child-1", + }, + }), + ), + ), + ) + + await waitFor(() => { + const item = ui.events.findLast((event) => event.type === "stream.subagent") + return item?.type === "stream.subagent" && item.state.tabs.some((tab) => tab.sessionID === "child-1") + ? item + : undefined + }) + + transport.selectSubagent("child-1") + + global.push( + globalEvent({ + id: "evt-child-message", + type: "message.updated", + properties: { + sessionID: "child-1", + info: assistantMessage({ + sessionID: "child-1", + id: "msg-child-1", + parts: [], + }).info, + }, + }), + ) + global.push(globalEvent(textUpdated(textPart("txt-child-1", "msg-child-1", "hello", "child-1")))) + + expect( + await waitFor(() => { + const item = ui.events.findLast((event) => event.type === "stream.subagent") + const detail = item?.type === "stream.subagent" ? item.state.details["child-1"] : undefined + return detail?.commits.some((commit) => commit.kind === "assistant" && commit.text === "hello") + ? detail + : undefined + }), + ).toEqual({ + sessionID: "child-1", + commits: [ + expect.objectContaining({ + kind: "assistant", + text: "hello", + }), + ], + }) + + global.push(globalEvent(textUpdated(textPart("txt-child-1", "msg-child-1", "hello world", "child-1")))) + + expect( + await waitFor(() => { + const item = ui.events.findLast((event) => event.type === "stream.subagent") + const detail = item?.type === "stream.subagent" ? item.state.details["child-1"] : undefined + return detail?.commits.some((commit) => commit.kind === "assistant" && commit.text === "hello world") + ? detail + : undefined + }, 2_000), + ).toEqual({ + sessionID: "child-1", + commits: [ + expect.objectContaining({ + kind: "assistant", + text: "hello world", + }), + ], + }) + } finally { + global.close() + await transport.close() + } + }) + test("recovers pending questions from question.list when question.asked is missed", async () => { - const src = feed() + const src = eventFeed() const ui = footer() let questionCalls = 0 const request = { @@ -639,7 +939,7 @@ describe("run stream transport", () => { }) test("does not resurrect questions if question.list resolves after tool completion", async () => { - const src = feed() + const src = eventFeed() const ui = footer() const started = defer() const request = { @@ -736,6 +1036,12 @@ describe("run stream transport", () => { }), ), ) + await waitFor(() => { + const commit = ui.commits.findLast( + (item) => item.kind === "tool" && item.partID === "question-race-tool-1" && item.toolState === "completed", + ) + return commit ? true : undefined + }) pending.resolve(ok([request])) await Bun.sleep(50) @@ -756,7 +1062,7 @@ describe("run stream transport", () => { }) test("respects the includeFiles flag when building prompt payloads", async () => { - const src = feed() + const src = eventFeed() const ui = footer() const seen: unknown[] = [] const file: RunFilePart = { @@ -818,7 +1124,7 @@ describe("run stream transport", () => { }) test("falls back to session status polling when idle events are missing", async () => { - const src = feed() + const src = eventFeed() const ui = footer() let busy = true const transport = await createSessionTransport({ @@ -858,7 +1164,7 @@ describe("run stream transport", () => { }) test("flushes interrupted output when the active turn aborts", async () => { - const src = feed() + const src = eventFeed() const seen = defer() const ui = footer((commit) => { if (commit.kind === "assistant" && commit.phase === "progress") { @@ -927,7 +1233,7 @@ describe("run stream transport", () => { }) test("closes an active turn without rejecting it", async () => { - const src = feed() + const src = eventFeed() const ui = footer() const ready = defer() let aborted = false @@ -982,11 +1288,11 @@ describe("run stream transport", () => { const transport = await createSessionTransport({ sdk: sdk({ - subscribe: () => - sse( - (async function* (): AsyncGenerator { + globalEvent: () => + globalSse( + (async function* (): AsyncGenerator { await ready.promise - yield busy() + yield globalEvent(busy()) throw new Error("boom") })(), ), @@ -1018,8 +1324,56 @@ describe("run stream transport", () => { } }) + test("rejects the active turn when the backing instance is disposed", async () => { + const ui = footer() + const ready = defer() + + const transport = await createSessionTransport({ + sdk: sdk({ + globalEvent: () => + globalSse( + (async function* (): AsyncGenerator { + await ready.promise + yield globalEvent({ + id: "evt-disposed", + type: "server.instance.disposed", + properties: { + directory: "/tmp", + }, + }) + })(), + ), + promptAsync: async () => { + ready.resolve() + return ok(undefined) + }, + status: async () => ok({}), + }), + directory: "/tmp", + sessionID: "session-1", + thinking: true, + limits: () => ({}), + footer: ui.api, + }) + + try { + await expect( + transport.runPromptTurn({ + agent: undefined, + model: undefined, + variant: undefined, + prompt: { text: "hello", parts: [] }, + files: [], + includeFiles: false, + }), + ).rejects.toThrow("instance disposed") + } finally { + await transport.close() + } + }) + test("rejects concurrent turns", async () => { - const src = feed() + const src = eventFeed() const ui = footer() const transport = await createSessionTransport({ sdk: sdk({ diff --git a/packages/opencode/test/cli/run/subagent-data.test.ts b/packages/opencode/test/cli/run/subagent-data.test.ts index 8d53c5485d..8d2dad365b 100644 --- a/packages/opencode/test/cli/run/subagent-data.test.ts +++ b/packages/opencode/test/cli/run/subagent-data.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test" import type { Event } from "@opencode-ai/sdk/v2" import { entryBody } from "@/cli/cmd/run/entry.body" import { + bootstrapSubagentCalls, bootstrapSubagentData, clearFinishedSubagents, createSubagentData, @@ -10,6 +11,7 @@ import { } from "@/cli/cmd/run/subagent-data" type SessionMessage = Parameters[0]["messages"][number] +type ChildMessage = Parameters[0]["messages"][number] function visible(commits: Array[0]>) { return commits.flatMap((item) => { @@ -120,6 +122,65 @@ function question(id: string, sessionID: string) { } } +function childMessage(input: { + messageID: string + sessionID: string + role: "user" | "assistant" + parts: ChildMessage["parts"] +}) { + if (input.role === "user") { + return { + info: { + id: input.messageID, + sessionID: input.sessionID, + role: "user", + time: { + created: 1, + }, + agent: "test", + model: { + providerID: "openai", + modelID: "gpt-5", + }, + }, + parts: input.parts, + } satisfies ChildMessage + } + + return { + info: { + id: input.messageID, + sessionID: input.sessionID, + role: "assistant", + time: { + created: 2, + completed: 3, + }, + parentID: "msg-user-1", + providerID: "openai", + modelID: "gpt-5", + mode: "default", + agent: "explore", + path: { + cwd: "/tmp", + root: "/tmp", + }, + cost: 0, + tokens: { + input: 1, + output: 1, + reasoning: 0, + cache: { + read: 0, + write: 0, + }, + }, + finish: "stop", + }, + parts: input.parts, + } satisfies ChildMessage +} + describe("run subagent data", () => { test("bootstraps tabs and child blockers from parent task parts", () => { const data = createSubagentData() @@ -309,6 +370,73 @@ describe("run subagent data", () => { expect(snapshot.questions).toEqual([]) }) + test("replays bootstrapped child session messages into inspector commits", () => { + const data = createSubagentData() + + bootstrapSubagentData({ + data, + messages: [taskMessage("child-1", "completed")], + children: [{ id: "child-1" }], + permissions: [], + questions: [], + }) + + expect( + bootstrapSubagentCalls({ + data, + sessionID: "child-1", + messages: [ + childMessage({ + messageID: "msg-user-1", + sessionID: "child-1", + role: "user", + parts: [ + { + id: "txt-user-1", + messageID: "msg-user-1", + sessionID: "child-1", + type: "text", + text: "Inspect footer tabs", + time: { start: 1, end: 1 }, + }, + ], + }), + childMessage({ + messageID: "msg-assistant-1", + sessionID: "child-1", + role: "assistant", + parts: [ + { + id: "reason-1", + messageID: "msg-assistant-1", + sessionID: "child-1", + type: "reasoning", + text: "planning next steps", + time: { start: 2, end: 2 }, + }, + { + id: "txt-1", + messageID: "msg-assistant-1", + sessionID: "child-1", + type: "text", + text: "hello world", + time: { start: 2, end: 3 }, + }, + ], + }), + ], + thinking: true, + limits: {}, + }), + ).toBe(true) + + expect(visible(snapshotSubagentData(data).details["child-1"]?.commits ?? [])).toEqual([ + "› Inspect footer tabs", + "_Thinking:_ planning next steps", + "hello world", + ]) + }) + test("clears finished tabs on the next parent prompt", () => { const data = createSubagentData() diff --git a/packages/opencode/test/cli/tui/plugin-loader.test.ts b/packages/opencode/test/cli/tui/plugin-loader.test.ts index 1702101233..d62bc19bfe 100644 --- a/packages/opencode/test/cli/tui/plugin-loader.test.ts +++ b/packages/opencode/test/cli/tui/plugin-loader.test.ts @@ -81,7 +81,7 @@ async function load(): Promise { await Bun.write( localPluginPath, - `import { resolveBindingSections } from "@opentui/keymap/extras" + `import { createBindingLookup } from "@opentui/keymap/extras" import { useBindings } from "@opentui/keymap/solid" export const ignored = async (_input, options) => { @@ -97,20 +97,18 @@ export default { const cfg_diff = api.tuiConfig.diff_style const cfg_speed = api.tuiConfig.scroll_speed const cfg_accel = api.tuiConfig.scroll_acceleration?.enabled - const cfg_submit = api.tuiConfig.keybinds?.input_submit const has_keys = typeof api.keys.formatBindings === "function" - const keymap = resolveBindingSections(options.keymap?.sections ?? { - main: { - "plugin.loader.local": "ctrl+shift+m", - "plugin.loader.close": "escape", - }, - }, { sections: ["main"] }).sections - const key_modal = keymap.main.find((item) => item.cmd === "plugin.loader.local")?.key - const key_close = keymap.main.find((item) => item.cmd === "plugin.loader.close")?.key + const keybinds = createBindingLookup(options.keybinds ?? { + "plugin.loader.local": "ctrl+shift+m", + "plugin.loader.close": "escape", + }) + const bindings = keybinds.gather("plugin.loader", ["plugin.loader.local", "plugin.loader.close"]) + const key_modal = bindings.find((item) => item.cmd === "plugin.loader.local")?.key + const key_close = bindings.find((item) => item.cmd === "plugin.loader.close")?.key const key_unknown = "ctrl+k" const off = api.keymap.registerLayer({ commands: [{ name: "plugin.loader.local", run() {} }, { name: "plugin.loader.close", run() {} }], - bindings: keymap.main, + bindings, }) off() const kv_before = api.kv.get(options.kv_key, "missing") @@ -153,7 +151,7 @@ export default { key_unknown, has_keys, has_keymap: typeof api.keymap.registerLayer === "function", - has_resolve_binding_sections: typeof resolveBindingSections === "function", + has_create_binding_lookup: typeof createBindingLookup === "function", has_keymap_solid: typeof useBindings === "function", kv_before, kv_after, @@ -176,7 +174,6 @@ export default { cfg_diff, cfg_speed, cfg_accel, - cfg_submit, }), ) }, @@ -356,13 +353,9 @@ export default { theme_name: tmp.extra.localThemeName, kv_key: "plugin_state_key", session_id: "ses_test", - keymap: { - sections: { - main: { - "plugin.loader.local": "ctrl+alt+m", - "plugin.loader.close": "q", - }, - }, + keybinds: { + "plugin.loader.local": "ctrl+alt+m", + "plugin.loader.close": "q", }, } const invalidOpts = { @@ -408,9 +401,6 @@ export default { diff_style: "stacked", scroll_speed: 1.5, scroll_acceleration: { enabled: true }, - keybinds: { - input_submit: "ctrl+enter", - }, }, state: { session: { @@ -670,7 +660,7 @@ describe("tui.plugin.loader", () => { expect(data.local.key_unknown).toBe("ctrl+k") expect(data.local.has_keys).toBe(true) expect(data.local.has_keymap).toBe(true) - expect(data.local.has_resolve_binding_sections).toBe(true) + expect(data.local.has_create_binding_lookup).toBe(true) expect(data.local.has_keymap_solid).toBe(true) expect(data.local.kv_before).toBe("missing") expect(data.local.kv_after).toBe("stored") @@ -693,7 +683,6 @@ describe("tui.plugin.loader", () => { expect(data.local.cfg_diff).toBe("stacked") expect(data.local.cfg_speed).toBe(1.5) expect(data.local.cfg_accel).toBe(true) - expect(data.local.cfg_submit).toBe("ctrl+enter") }) test("installs themes in the correct scope and remains resilient", () => { diff --git a/packages/opencode/test/cli/tui/plugin-toggle.test.ts b/packages/opencode/test/cli/tui/plugin-toggle.test.ts index 0f3f663c02..a3ee744bff 100644 --- a/packages/opencode/test/cli/tui/plugin-toggle.test.ts +++ b/packages/opencode/test/cli/tui/plugin-toggle.test.ts @@ -171,26 +171,26 @@ test("loads disabled-by-default internal plugin inactive and activates on demand enabled: true, active: true, }) - expect(TuiPluginRuntime.list().find((item) => item.id === "tui-which-key")).toEqual({ - id: "tui-which-key", + expect(TuiPluginRuntime.list().find((item) => item.id === "which-key")).toEqual({ + id: "which-key", source: "internal", - spec: "tui-which-key", - target: "tui-which-key", + spec: "which-key", + target: "which-key", enabled: false, active: false, }) - await expect(TuiPluginRuntime.activatePlugin("tui-which-key")).resolves.toBe(true) - expect(TuiPluginRuntime.list().find((item) => item.id === "tui-which-key")).toEqual({ - id: "tui-which-key", + await expect(TuiPluginRuntime.activatePlugin("which-key")).resolves.toBe(true) + expect(TuiPluginRuntime.list().find((item) => item.id === "which-key")).toEqual({ + id: "which-key", source: "internal", - spec: "tui-which-key", - target: "tui-which-key", + spec: "which-key", + target: "which-key", enabled: true, active: true, }) expect(api.kv.get("plugin_enabled", {})).toEqual({ - "tui-which-key": true, + "which-key": true, }) } finally { await TuiPluginRuntime.dispose() diff --git a/packages/opencode/test/config/tui.test.ts b/packages/opencode/test/config/tui.test.ts index 4ad942b251..db04568573 100644 --- a/packages/opencode/test/config/tui.test.ts +++ b/packages/opencode/test/config/tui.test.ts @@ -163,7 +163,7 @@ test("migrates tui-specific keys from opencode.json when tui.json does not exist const config = await getTuiConfig(tmp.path) expect(config.theme).toBe("migrated-theme") expect(config.scroll_speed).toBe(5) - expect(config.keybinds?.app_exit).toBe("ctrl+q") + expect(config.keybinds.get("app.exit")?.[0]?.key).toBe("ctrl+q") const text = await Filesystem.readText(path.join(tmp.path, "tui.json")) expect(JSON.parse(text)).toMatchObject({ theme: "migrated-theme", @@ -398,83 +398,64 @@ test("merges keybind overrides across precedence layers", async () => { }, }) const config = await getTuiConfig(tmp.path) - expect(config.keybinds?.app_exit).toBe("ctrl+q") - expect(config.keybinds?.theme_list).toBe("ctrl+k") + expect(config.keybinds.get("app.exit")?.[0]?.key).toBe("ctrl+q") + expect(config.keybinds.get("theme.switch")?.[0]?.key).toBe("ctrl+k") }) -test("resolves semantic keymap sections", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await Bun.write( - path.join(dir, "tui.json"), - JSON.stringify({ - keybinds: { command_list: "ctrl+z" }, - keymap: { - sections: { - global: { "command.palette.show": "alt+p" }, - which_key: { "tui-which-key.toggle": "alt+k" }, - prompt: { "prompt.editor": "ctrl+e" }, - autocomplete: { "prompt.autocomplete.next": "ctrl+j" }, - dialog_actions: { "dialog.action.toggle": "ctrl+t" }, - model: { "model.dialog.favorite": "ctrl+f" }, - plugins: { "plugin.dialog.install": "shift+i" }, - }, - }, - }), - ) - }, - }) - - const config = await getTuiConfig(tmp.path) - expect(config.keymap.sections.global.find((binding) => binding.cmd === "command.palette.show")?.key).toBe("alt+p") - expect(config.keymap.sections.global.find((binding) => binding.cmd === "session.new")?.key).toBe("n") - expect(config.keymap.sections.which_key.find((binding) => binding.cmd === "tui-which-key.toggle")?.key).toBe("alt+k") - expect(config.keymap.sections.which_key.find((binding) => binding.cmd === "tui-which-key.layout.toggle")?.key).toBe( - "ctrl+alt+shift+k", - ) - expect(config.keymap.sections.which_key.find((binding) => binding.cmd === "tui-which-key.pending.toggle")?.key).toBe( - "ctrl+alt+shift+p", - ) - expect(config.keymap.sections.which_key.find((binding) => binding.cmd === "tui-which-key.group.next")?.key).toBe( - "ctrl+alt+right,ctrl+alt+]", - ) - expect( - ( - config.keymap.sections.which_key.find((binding) => binding.cmd === "tui-which-key.toggle") as - | { group?: unknown } - | undefined - )?.group, - ).toBe("System") - expect(config.keymap.sections.prompt.find((binding) => binding.cmd === "prompt.editor")?.key).toBe("ctrl+e") - expect(config.keymap.sections.autocomplete.find((binding) => binding.cmd === "prompt.autocomplete.next")?.key).toBe( - "ctrl+j", - ) - expect(config.keymap.sections.dialog_actions.find((binding) => binding.cmd === "dialog.action.toggle")?.key).toBe( - "ctrl+t", - ) - expect(config.keymap.sections.model.find((binding) => binding.cmd === "model.dialog.favorite")?.key).toBe("ctrl+f") - expect(config.keymap.sections.plugins.find((binding) => binding.cmd === "plugin.dialog.install")?.key).toBe("shift+i") - expect(config.keymap.pick("plugins", ["plugin.dialog.install"]).map((binding) => binding.cmd)).toEqual([ - "plugin.dialog.install", - ]) - expect((config.keymap.pick("plugins", ["plugin.dialog.install"])[0] as { group?: unknown } | undefined)?.group).toBe( - "Plugins", - ) - expect(config.keymap.omit("plugins", ["plugin.dialog.install"]).map((binding) => binding.cmd)).toEqual([]) -}) - -test("legacy keybinds transform into semantic keymap sections", async () => { +test("resolves keybind lookup from canonical keybinds", async () => { await using tmp = await tmpdir({ init: async (dir) => { await Bun.write( path.join(dir, "tui.json"), JSON.stringify({ keybinds: { + leader: { key: { name: "g", ctrl: true } }, command_list: "alt+p", + which_key_toggle: "alt+k", editor_open: "ctrl+e", "prompt.autocomplete.next": "ctrl+j", "dialog.mcp.toggle": "ctrl+t", + model_favorite_toggle: "ctrl+f", "dialog.plugins.install": "shift+i", + }, + leader_timeout: 1234, + }), + ) + }, + }) + + const config = await getTuiConfig(tmp.path) + expect(config.keybinds.get("leader")?.[0]?.key).toEqual({ name: "g", ctrl: true }) + expect(config.leader_timeout).toBe(1234) + expect(config.keybinds.get("command.palette.show")?.[0]?.key).toBe("alt+p") + expect(config.keybinds.get("session.new")?.[0]?.key).toBe("n") + expect(config.keybinds.get("which-key.toggle")?.[0]?.key).toBe("alt+k") + expect(config.keybinds.get("which-key.layout.toggle")?.[0]?.key).toBe("ctrl+alt+shift+k") + expect(config.keybinds.get("which-key.pending.toggle")?.[0]?.key).toBe("ctrl+alt+shift+p") + expect(config.keybinds.get("which-key.group.next")?.[0]?.key).toBe("ctrl+alt+right,ctrl+alt+]") + expect((config.keybinds.get("which-key.toggle")?.[0] as { desc?: unknown } | undefined)?.desc).toBe( + "Toggle which-key panel", + ) + expect(config.keybinds.get("prompt.editor")?.[0]?.key).toBe("ctrl+e") + expect(config.keybinds.get("prompt.autocomplete.next")?.[0]?.key).toBe("ctrl+j") + expect(config.keybinds.get("dialog.mcp.toggle")?.[0]?.key).toBe("ctrl+t") + expect(config.keybinds.get("model.dialog.favorite")?.[0]?.key).toBe("ctrl+f") + expect(config.keybinds.get("dialog.plugins.install")?.[0]?.key).toBe("shift+i") + expect(config.keybinds.gather("plugins.dialog", ["dialog.plugins.install"]).map((binding) => binding.cmd)).toEqual([ + "dialog.plugins.install", + ]) +}) + +test("keybinds accept OpenTUI binding specs", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "tui.json"), + JSON.stringify({ + keybinds: { + command_list: [{ key: "alt+p", preventDefault: false }], + editor_open: { key: { name: "e", ctrl: true }, group: "Explicit" }, + "prompt.autocomplete.next": false, plugin_manager: "ctrl+shift+p", }, }), @@ -483,52 +464,23 @@ test("legacy keybinds transform into semantic keymap sections", async () => { }) const config = await getTuiConfig(tmp.path) - expect(Object.keys(config.keymap.sections)).toEqual([ - "global", - "which_key", - "session", - "prompt", - "autocomplete", - "input", - "dialog_select", - "dialog_actions", - "model", - "permission", - "question", - "plugins", - "home_tips", - ]) - expect(config.keymap.sections.global.find((binding) => binding.cmd === "command.palette.show")?.key).toBe("alt+p") - expect(config.keymap.sections.which_key.find((binding) => binding.cmd === "tui-which-key.toggle")?.key).toBe( - "ctrl+alt+k", - ) - expect(config.keymap.sections.prompt.find((binding) => binding.cmd === "prompt.editor")?.key).toBe("ctrl+e") - expect(config.keymap.sections.autocomplete.find((binding) => binding.cmd === "prompt.autocomplete.next")?.key).toBe( - "ctrl+j", - ) - expect(config.keymap.sections.dialog_actions.find((binding) => binding.cmd === "dialog.action.toggle")?.key).toBe( - "ctrl+t", - ) - expect(config.keymap.sections.model.find((binding) => binding.cmd === "model.dialog.provider")?.key).toBe("ctrl+a") - expect(config.keymap.sections.model.find((binding) => binding.cmd === "model.dialog.favorite")?.key).toBe("ctrl+f") - expect(config.keymap.sections.plugins.find((binding) => binding.cmd === "plugin.dialog.install")?.key).toBe("shift+i") - expect(config.keymap.sections.plugins.find((binding) => binding.cmd === "plugins.list")?.key).toBe("ctrl+shift+p") - expect(config.keymap.pick("plugins", ["plugin.dialog.install"]).map((binding) => binding.cmd)).toEqual([ - "plugin.dialog.install", - ]) - expect((config.keymap.omit("plugins", ["plugin.dialog.install"])[0] as { group?: unknown } | undefined)?.group).toBe( - "Plugins", - ) - expect(config.keymap.omit("plugins", ["plugin.dialog.install"]).map((binding) => binding.cmd)).toEqual([ - "plugins.list", + expect(config.keybinds.get("command.palette.show")).toEqual([ + { key: "alt+p", cmd: "command.palette.show", preventDefault: false, desc: "List available commands" }, ]) + expect(config.keybinds.get("prompt.editor")?.[0]).toMatchObject({ + key: { name: "e", ctrl: true }, + cmd: "prompt.editor", + group: "Explicit", + }) + expect(config.keybinds.get("prompt.autocomplete.next")).toEqual([]) + expect(config.keybinds.get("plugins.list")?.[0]?.key).toBe("ctrl+shift+p") }) wintest("defaults Ctrl+Z to input undo on Windows", async () => { await using tmp = await tmpdir() const config = await getTuiConfig(tmp.path) - expect(config.keybinds?.terminal_suspend).toBe("none") - expect(config.keybinds?.input_undo).toBe("ctrl+z,ctrl+-,super+z") + expect(config.keybinds.get("terminal.suspend")).toEqual([]) + expect(config.keybinds.get("input.undo")?.[0]?.key).toBe("ctrl+z,ctrl+-,super+z") }) wintest("keeps explicit input undo overrides on Windows", async () => { @@ -538,8 +490,8 @@ wintest("keeps explicit input undo overrides on Windows", async () => { }, }) const config = await getTuiConfig(tmp.path) - expect(config.keybinds?.terminal_suspend).toBe("none") - expect(config.keybinds?.input_undo).toBe("ctrl+y") + expect(config.keybinds.get("terminal.suspend")).toEqual([]) + expect(config.keybinds.get("input.undo")?.[0]?.key).toBe("ctrl+y") }) wintest("ignores terminal suspend bindings on Windows", async () => { @@ -550,33 +502,29 @@ wintest("ignores terminal suspend bindings on Windows", async () => { }) const config = await getTuiConfig(tmp.path) - expect(config.keybinds?.terminal_suspend).toBe("none") - expect(config.keybinds?.input_undo).toBe("ctrl+z,ctrl+-,super+z") + expect(config.keybinds.get("terminal.suspend")).toEqual([]) + expect(config.keybinds.get("input.undo")?.[0]?.key).toBe("ctrl+z,ctrl+-,super+z") }) -test("applies Windows keymap defaults", async () => { +test("applies Windows keybind defaults", async () => { await withPlatform("win32", async () => { await using tmp = await tmpdir() const config = await getTuiConfig(tmp.path) - expect(config.keymap.sections.global.find((binding) => binding.cmd === "terminal.suspend")).toBeUndefined() - expect(config.keymap.sections.input.find((binding) => binding.cmd === "input.undo")?.key).toBe( - "ctrl+z,ctrl+-,super+z", - ) + expect(config.keybinds.get("terminal.suspend")).toEqual([]) + expect(config.keybinds.get("input.undo")?.[0]?.key).toBe("ctrl+z,ctrl+-,super+z") }) }) -test("keeps explicit configured keymap terminal suspend binding on Windows", async () => { +test("ignores explicit keybind terminal suspend binding on Windows", async () => { await withPlatform("win32", async () => { await using tmp = await tmpdir({ init: async (dir) => { await Bun.write( path.join(dir, "tui.json"), JSON.stringify({ - keymap: { - sections: { - global: { "terminal.suspend": "alt+z" }, - }, + keybinds: { + terminal_suspend: "alt+z", }, }), ) @@ -584,21 +532,19 @@ test("keeps explicit configured keymap terminal suspend binding on Windows", asy }) const config = await getTuiConfig(tmp.path) - expect(config.keymap.sections.global.find((binding) => binding.cmd === "terminal.suspend")?.key).toBe("alt+z") + expect(config.keybinds.get("terminal.suspend")).toEqual([]) }) }) -test("keeps explicit configured keymap input undo on Windows", async () => { +test("keeps explicit configured keybind input undo on Windows", async () => { await withPlatform("win32", async () => { await using tmp = await tmpdir({ init: async (dir) => { await Bun.write( path.join(dir, "tui.json"), JSON.stringify({ - keymap: { - sections: { - input: { "input.undo": "ctrl+y" }, - }, + keybinds: { + input_undo: "ctrl+y", }, }), ) @@ -606,7 +552,7 @@ test("keeps explicit configured keymap input undo on Windows", async () => { }) const config = await getTuiConfig(tmp.path) - expect(config.keymap.sections.input.find((binding) => binding.cmd === "input.undo")?.key).toBe("ctrl+y") + expect(config.keybinds.get("input.undo")?.[0]?.key).toBe("ctrl+y") }) }) @@ -655,7 +601,7 @@ test("applies env and file substitutions in tui.json", async () => { }) const config = await getTuiConfig(tmp.path) expect(config.theme).toBe("env-theme") - expect(config.keybinds?.app_exit).toBe("ctrl+q") + expect(config.keybinds.get("app.exit")?.[0]?.key).toBe("ctrl+q") } finally { if (original === undefined) delete process.env.TUI_THEME_TEST else process.env.TUI_THEME_TEST = original diff --git a/packages/opencode/test/control-plane/workspace.test.ts b/packages/opencode/test/control-plane/workspace.test.ts index 0eba431e1a..3c4837e318 100644 --- a/packages/opencode/test/control-plane/workspace.test.ts +++ b/packages/opencode/test/control-plane/workspace.test.ts @@ -5,7 +5,7 @@ import Http from "node:http" import path from "node:path" import { setTimeout as delay } from "node:timers/promises" import { NodeHttpServer } from "@effect/platform-node" -import { Effect, Layer } from "effect" +import { Effect, Layer, Schema } from "effect" import { HttpServer, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" import { eq } from "drizzle-orm" import * as Log from "@opencode-ai/core/util/log" @@ -28,7 +28,7 @@ import { registerAdapter } from "../../src/control-plane/adapters" import { WorkspaceID } from "../../src/control-plane/schema" import { WorkspaceTable } from "../../src/control-plane/workspace.sql" import type { Target, WorkspaceAdapter, WorkspaceInfo } from "../../src/control-plane/types" -import * as WorkspaceOld from "../../src/control-plane/workspace" +import * as Workspace from "../../src/control-plane/workspace" import { AppRuntime } from "@/effect/app-runtime" import { InstanceStore } from "@/project/instance-store" import { InstanceBootstrap } from "@/project/bootstrap" @@ -37,10 +37,7 @@ void Log.init({ print: false }) const testServerLayer = Layer.mergeAll( NodeHttpServer.layer(Http.createServer, { host: "127.0.0.1", port: 0 }), - WorkspaceOld.defaultLayer.pipe( - Layer.provide(InstanceStore.defaultLayer), - Layer.provide(InstanceBootstrap.defaultLayer), - ), + Workspace.defaultLayer.pipe(Layer.provide(InstanceStore.defaultLayer), Layer.provide(InstanceBootstrap.defaultLayer)), SessionNs.defaultLayer, ) const it = testEffect(testServerLayer) @@ -64,6 +61,7 @@ type RecordedAdapter = { calls: { configure: WorkspaceInfo[] create: RecordedCreate[] + list: number remove: WorkspaceInfo[] target: WorkspaceInfo[] } @@ -125,23 +123,25 @@ async function initGitRepo(dir: string) { await $`git commit -m "base"`.cwd(dir).quiet() } -const runWorkspace = (effect: Effect.Effect) => AppRuntime.runPromise(effect) -const createWorkspace = (input: WorkspaceOld.CreateInput) => - runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.create(input))) -const warpWorkspaceSession = (input: WorkspaceOld.SessionWarpInput) => - runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.sessionWarp(input))) -const listWorkspaces = (project: Parameters[0]) => - runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.list(project))) -const getWorkspace = (id: WorkspaceID) => runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.get(id))) -const removeWorkspace = (id: WorkspaceID) => runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.remove(id))) -const workspaceStatus = () => runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.status())) +const runWorkspace = (effect: Effect.Effect) => AppRuntime.runPromise(effect) +const createWorkspace = (input: Workspace.CreateInput) => + runWorkspace(Workspace.Service.use((workspace) => workspace.create(input))) +const warpWorkspaceSession = (input: Workspace.SessionWarpInput) => + runWorkspace(Workspace.Service.use((workspace) => workspace.sessionWarp(input))) +const listWorkspaces = (project: Parameters[0]) => + runWorkspace(Workspace.Service.use((workspace) => workspace.list(project))) +const syncListWorkspaces = (project: Parameters[0]) => + runWorkspace(Workspace.Service.use((workspace) => workspace.syncList(project))) +const getWorkspace = (id: WorkspaceID) => runWorkspace(Workspace.Service.use((workspace) => workspace.get(id))) +const removeWorkspace = (id: WorkspaceID) => runWorkspace(Workspace.Service.use((workspace) => workspace.remove(id))) +const workspaceStatus = () => runWorkspace(Workspace.Service.use((workspace) => workspace.status())) const isWorkspaceSyncing = (id: WorkspaceID) => - runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.isSyncing(id))) + runWorkspace(Workspace.Service.use((workspace) => workspace.isSyncing(id))) const startWorkspaceSyncing = (projectID: ProjectID) => { - void runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.startWorkspaceSyncing(projectID))) + void runWorkspace(Workspace.Service.use((workspace) => workspace.startWorkspaceSyncing(projectID))) } const waitForWorkspaceSync = (workspaceID: WorkspaceID, state: Record, signal?: AbortSignal) => - runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.waitForSync(workspaceID, state, signal))) + runWorkspace(Workspace.Service.use((workspace) => workspace.waitForSync(workspaceID, state, signal))) function captureGlobalEvents() { const events: GlobalEvent[] = [] @@ -187,11 +187,13 @@ function recordedAdapter(input: { target: (info: WorkspaceInfo) => Target | Promise configure?: (info: WorkspaceInfo) => WorkspaceInfo | Promise create?: (info: WorkspaceInfo, env: Record, from?: WorkspaceInfo) => Promise + list?: () => Omit[] | Promise[]> remove?: (info: WorkspaceInfo) => Promise }): RecordedAdapter { const calls: RecordedAdapter["calls"] = { configure: [], create: [], + list: 0, remove: [], target: [], } @@ -213,6 +215,14 @@ function recordedAdapter(input: { }) await input.create?.(info, env, from) }, + ...(input.list + ? { + async list() { + calls.list += 1 + return input.list?.() ?? [] + }, + } + : {}), async remove(info) { calls.remove.push(structuredClone(info)) await input.remove?.(info) @@ -272,7 +282,7 @@ function serverUrl() { }) } -function workspaceInfo(projectID: ProjectID, type: string, input?: Partial): WorkspaceInfo { +function workspaceInfo(projectID: ProjectID, type: string, input?: Partial): Workspace.Info { return { id: input?.id ?? WorkspaceID.ascending(), type, @@ -281,10 +291,11 @@ function workspaceInfo(projectID: ProjectID, type: string, input?: Partial db .insert(WorkspaceTable) @@ -296,6 +307,7 @@ function insertWorkspace(info: WorkspaceInfo) { directory: info.directory, extra: info.extra, project_id: info.projectID, + time_used: info.timeUsed, }) .run(), ) @@ -348,11 +360,11 @@ function sessionUpdatedType() { return SyncEvent.versionedType(SessionNs.Event.Updated.type, SessionNs.Event.Updated.version) } -describe("workspace-old schemas and exports", () => { +describe("workspace schemas and exports", () => { test("keeps the historical event type names", () => { - expect(WorkspaceOld.Event.Ready.type).toBe("workspace.ready") - expect(WorkspaceOld.Event.Failed.type).toBe("workspace.failed") - expect(WorkspaceOld.Event.Status.type).toBe("workspace.status") + expect(Workspace.Event.Ready.type).toBe("workspace.ready") + expect(Workspace.Event.Failed.type).toBe("workspace.failed") + expect(Workspace.Event.Status.type).toBe("workspace.status") }) test("validates create input with workspace id, project id, branch, type, and extra", () => { @@ -364,13 +376,14 @@ describe("workspace-old schemas and exports", () => { extra: { nested: true }, } - expect(WorkspaceOld.CreateInput.zod.parse(input)).toEqual(input) - expect(() => WorkspaceOld.CreateInput.zod.parse({ ...input, id: "bad" })).toThrow() - expect(() => WorkspaceOld.CreateInput.zod.parse({ ...input, branch: 1 })).toThrow() + const decode = Schema.decodeUnknownSync(Workspace.CreateInput) + expect(decode(input)).toEqual(input) + expect(() => decode({ ...input, id: 1 })).toThrow() + expect(() => decode({ ...input, branch: 1 })).toThrow() }) }) -describe("workspace-old CRUD", () => { +describe("workspace CRUD", () => { test("get returns undefined for a missing workspace", async () => { await withInstance(async () => { expect(await getWorkspace(WorkspaceID.ascending("wrk_missing_get"))).toBeUndefined() @@ -447,13 +460,22 @@ describe("workspace-old CRUD", () => { directory: targetDir, extra: { configured: true }, projectID: Instance.project.id, + timeUsed: info.timeUsed, }) expect(await getWorkspace(workspaceID)).toEqual(info) expect(await listWorkspaces(Instance.project)).toEqual([info]) expect(recorded.calls.configure).toHaveLength(1) expect(recorded.calls.configure[0]).toMatchObject({ id: workspaceID, type, directory: null }) expect(recorded.calls.create).toHaveLength(1) - expect(recorded.calls.create[0].info).toEqual(info) + expect(recorded.calls.create[0].info).toEqual({ + id: workspaceID, + type, + branch: "configured-branch", + name: "Configured Name", + directory: targetDir, + extra: { configured: true }, + projectID: Instance.project.id, + }) expect(JSON.parse(recorded.calls.create[0].env.OPENCODE_AUTH_CONTENT ?? "{}")).toEqual({ test: { type: "api", key: "secret" }, }) @@ -532,6 +554,120 @@ describe("workspace-old CRUD", () => { }) }) + test("syncList registers adapter-listed workspaces that are missing by name", async () => { + await withInstance(async (dir) => { + const type = unique("list-sync") + const existing = workspaceInfo(Instance.project.id, type, { + id: WorkspaceID.ascending("wrk_list_sync_existing"), + name: "existing", + directory: path.join(dir, "existing"), + }) + insertWorkspace(existing) + + const discovered = { + type, + name: "discovered", + branch: "feature/discovered", + directory: path.join(dir, "discovered"), + extra: { source: "adapter" }, + projectID: Instance.project.id, + } + const recorded = recordedAdapter({ + list() { + return [ + { + type, + name: existing.name, + branch: "ignored", + directory: path.join(dir, "ignored"), + extra: null, + projectID: Instance.project.id, + }, + discovered, + ] + }, + target(info) { + return { type: "local", directory: info.directory ?? dir } + }, + }) + registerAdapter(Instance.project.id, type, recorded.adapter) + + await syncListWorkspaces(Instance.project) + const synced = (await listWorkspaces(Instance.project)).filter((item) => item.name === discovered.name) + + expect(synced).toHaveLength(1) + expect(synced[0]).toMatchObject(discovered) + expect(synced[0]?.id).toStartWith("wrk_") + expect(await listWorkspaces(Instance.project)).toEqual(expect.arrayContaining([existing, synced[0]])) + expect(recorded.calls.list).toBe(1) + expect(recorded.calls.configure).toHaveLength(0) + expect(recorded.calls.create).toHaveLength(0) + expect(recorded.calls.target).toHaveLength(1) + }) + }) + + test("syncList calls every registered adapter with a list method", async () => { + await withInstance(async (dir) => { + const typeA = unique("list-sync-a") + const typeB = unique("list-sync-b") + const adapterA = recordedAdapter({ + list() { + return [ + { + type: typeA, + name: "adapter-a", + branch: null, + directory: path.join(dir, "adapter-a"), + extra: null, + projectID: Instance.project.id, + }, + ] + }, + target(info) { + return { type: "local", directory: info.directory ?? dir } + }, + }) + const adapterB = recordedAdapter({ + list() { + return [ + { + type: typeB, + name: "adapter-b", + branch: null, + directory: path.join(dir, "adapter-b"), + extra: null, + projectID: Instance.project.id, + }, + ] + }, + target(info) { + return { type: "local", directory: info.directory ?? dir } + }, + }) + const noList = recordedAdapter({ + target() { + return { type: "local", directory: dir } + }, + }) + registerAdapter(Instance.project.id, typeA, adapterA.adapter) + registerAdapter(Instance.project.id, typeB, adapterB.adapter) + registerAdapter(Instance.project.id, unique("list-sync-none"), noList.adapter) + + await syncListWorkspaces(Instance.project) + const synced = await listWorkspaces(Instance.project) + + expect( + synced + .filter((item) => item.type === typeA || item.type === typeB) + .map((item) => item.name) + .toSorted(), + ).toEqual(["adapter-a", "adapter-b"]) + expect(adapterA.calls.list).toBe(1) + expect(adapterB.calls.list).toBe(1) + expect(noList.calls.list).toBe(0) + }) + }) + it.live("remote create connects to routed event and history endpoints", () => { const calls: FetchCall[] = [] return Effect.gen(function* () { @@ -557,7 +693,7 @@ describe("workspace-old CRUD", () => { yield* provideTmpdirInstance( (dir) => Effect.gen(function* () { - const workspace = yield* WorkspaceOld.Service + const workspace = yield* Workspace.Service const type = unique("remote-create") const recorded = remoteAdapter(`${url}/base/?ignored=1#hash`, { directory: dir }) registerAdapter(Instance.project.id, type, recorded.adapter) @@ -713,6 +849,41 @@ describe("workspace-old CRUD", () => { }) }) + test("sessionWarp detaches to the source project when invoked from a workspace instance", async () => { + await withInstance(async () => { + const projectID = Instance.project.id + await using workspaceTmp = await tmpdir({ git: true }) + const previousType = unique("warp-detach-workspace-instance") + const previous = workspaceInfo(projectID, previousType) + insertWorkspace(previous) + registerAdapter(projectID, previousType, localAdapter(workspaceTmp.path, { createDir: false }).adapter) + const session = await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({}))) + attachSessionToWorkspace(session.id, previous.id) + + const workspaceProjectID = await WithInstance.provide({ + directory: workspaceTmp.path, + fn: async () => { + const id = Instance.project.id + expect(id).not.toBe(projectID) + await warpWorkspaceSession({ workspaceID: null, sessionID: session.id }) + return id + }, + }) + + expect( + Database.use((db) => + db + .select({ workspaceID: SessionTable.workspace_id }) + .from(SessionTable) + .where(eq(SessionTable.id, session.id)) + .get(), + )?.workspaceID, + ).toBeNull() + expect(sessionSequenceOwner(session.id)).toBe(projectID) + expect(sessionSequenceOwner(session.id)).not.toBe(workspaceProjectID) + }) + }) + it.live("sessionWarp syncs previous remote history, replays it, steals, and claims the sequence", () => { const calls: FetchCall[] = [] let historySessionID: SessionID | undefined @@ -754,7 +925,7 @@ describe("workspace-old CRUD", () => { yield* provideTmpdirInstance( () => Effect.gen(function* () { - const workspace = yield* WorkspaceOld.Service + const workspace = yield* Workspace.Service const sessionSvc = yield* SessionNs.Service const previousType = unique("warp-remote-source") const targetType = unique("warp-remote-target") @@ -805,7 +976,7 @@ describe("workspace-old CRUD", () => { }) }) -describe("workspace-old sync state", () => { +describe("workspace sync state", () => { test("startWorkspaceSyncing is disabled by the experimental workspace flag", async () => { await withInstance(async (dir) => { Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = false @@ -823,35 +994,29 @@ describe("workspace-old sync state", () => { }) }) - test("startWorkspaceSyncing starts only workspaces with sessions", async () => { + test("startWorkspaceSyncing starts all workspaces", async () => { await withInstance(async (dir) => { - const withSessionType = unique("with-session") - const withoutSessionType = unique("without-session") - const withSession = workspaceInfo(Instance.project.id, withSessionType) - const withoutSession = workspaceInfo(Instance.project.id, withoutSessionType) - const withSessionDir = path.join(dir, "with-session") - const withoutSessionDir = path.join(dir, "without-session") - await fs.mkdir(withSessionDir, { recursive: true }) - await fs.mkdir(withoutSessionDir, { recursive: true }) - insertWorkspace(withSession) - insertWorkspace(withoutSession) - registerAdapter(Instance.project.id, withSessionType, localAdapter(withSessionDir).adapter) - registerAdapter(Instance.project.id, withoutSessionType, localAdapter(withoutSessionDir).adapter) - attachSessionToWorkspace( - (await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({})))).id, - withSession.id, - ) + const firstType = unique("first") + const secondType = unique("second") + const first = workspaceInfo(Instance.project.id, firstType) + const second = workspaceInfo(Instance.project.id, secondType) + await fs.mkdir(path.join(dir, "first"), { recursive: true }) + await fs.mkdir(path.join(dir, "second"), { recursive: true }) + insertWorkspace(first) + insertWorkspace(second) + registerAdapter(Instance.project.id, firstType, localAdapter(path.join(dir, "first")).adapter) + registerAdapter(Instance.project.id, secondType, localAdapter(path.join(dir, "second")).adapter) startWorkspaceSyncing(Instance.project.id) await eventually(() => - workspaceStatus().then((status) => - expect(status.find((item) => item.workspaceID === withSession.id)?.status).toBe("connected"), - ), + workspaceStatus().then((status) => { + expect(status.find((item) => item.workspaceID === first.id)?.status).toBe("connected") + expect(status.find((item) => item.workspaceID === second.id)?.status).toBe("connected") + }), ) - expect((await workspaceStatus()).find((item) => item.workspaceID === withoutSession.id)?.status).toBeUndefined() - await removeWorkspace(withSession.id) - await removeWorkspace(withoutSession.id) + await removeWorkspace(first.id) + await removeWorkspace(second.id) }) }) @@ -907,7 +1072,7 @@ describe("workspace-old sync state", () => { ) expect( captured.events.filter( - (event) => event.workspace === info.id && event.payload.type === WorkspaceOld.Event.Status.type, + (event) => event.workspace === info.id && event.payload.type === Workspace.Event.Status.type, ), ).toHaveLength(1) await removeWorkspace(info.id) @@ -941,7 +1106,7 @@ describe("workspace-old sync state", () => { yield* provideTmpdirInstance( () => Effect.gen(function* () { - const workspace = yield* WorkspaceOld.Service + const workspace = yield* Workspace.Service const sessionSvc = yield* SessionNs.Service const captured = captureGlobalEvents() try { @@ -964,9 +1129,7 @@ describe("workspace-old sync state", () => { expect( captured.events - .filter( - (event) => event.workspace === info.id && event.payload.type === WorkspaceOld.Event.Status.type, - ) + .filter((event) => event.workspace === info.id && event.payload.type === Workspace.Event.Status.type) .map((event) => event.payload.properties.status), ).toEqual(["disconnected", "connecting", "connected"]) expect(calls.filter((call) => call.url.pathname === "/sync/global/event")).toHaveLength(1) @@ -998,7 +1161,7 @@ describe("workspace-old sync state", () => { yield* provideTmpdirInstance( () => Effect.gen(function* () { - const workspace = yield* WorkspaceOld.Service + const workspace = yield* Workspace.Service const sessionSvc = yield* SessionNs.Service const type = unique("remote-connect-fail") const info = workspaceInfo(Instance.project.id, type) @@ -1038,7 +1201,7 @@ describe("workspace-old sync state", () => { yield* provideTmpdirInstance( () => Effect.gen(function* () { - const workspace = yield* WorkspaceOld.Service + const workspace = yield* Workspace.Service const sessionSvc = yield* SessionNs.Service const type = unique("remote-history-fail") const info = workspaceInfo(Instance.project.id, type) @@ -1093,7 +1256,7 @@ describe("workspace-old sync state", () => { yield* provideTmpdirInstance( () => Effect.gen(function* () { - const workspace = yield* WorkspaceOld.Service + const workspace = yield* Workspace.Service const sessionSvc = yield* SessionNs.Service const captured = captureGlobalEvents() try { @@ -1160,7 +1323,7 @@ describe("workspace-old sync state", () => { yield* provideTmpdirInstance( () => Effect.gen(function* () { - const workspace = yield* WorkspaceOld.Service + const workspace = yield* Workspace.Service const sessionSvc = yield* SessionNs.Service const captured = captureGlobalEvents() try { @@ -1241,7 +1404,7 @@ describe("workspace-old sync state", () => { yield* provideTmpdirInstance( () => Effect.gen(function* () { - const workspace = yield* WorkspaceOld.Service + const workspace = yield* Workspace.Service const sessionSvc = yield* SessionNs.Service const captured = captureGlobalEvents() try { @@ -1280,7 +1443,7 @@ describe("workspace-old sync state", () => { }) }) -describe("workspace-old waitForSync", () => { +describe("workspace waitForSync", () => { test("returns immediately for an empty fence", async () => { await withInstance(async () => { await expect(waitForWorkspaceSync(WorkspaceID.ascending("wrk_wait_empty"), {})).resolves.toBeUndefined() diff --git a/packages/opencode/test/fixture/flag.ts b/packages/opencode/test/fixture/flag.ts new file mode 100644 index 0000000000..224c5ef1f4 --- /dev/null +++ b/packages/opencode/test/fixture/flag.ts @@ -0,0 +1,20 @@ +import type { WorkspaceID } from "@/control-plane/schema" +import { Flag } from "@opencode-ai/core/flag/flag" +import { Effect, Scope } from "effect" + +/** + * Scoped override for `Flag.OPENCODE_WORKSPACE_ID`. Saves the previous value + * on entry and restores it via finalizer when the surrounding scope closes — + * preserves the original try/finally semantics regardless of test outcome. + */ +export function withFixedWorkspaceID(id: WorkspaceID): Effect.Effect { + return Effect.gen(function* () { + const previous = Flag.OPENCODE_WORKSPACE_ID + Flag.OPENCODE_WORKSPACE_ID = id + yield* Effect.addFinalizer(() => + Effect.sync(() => { + Flag.OPENCODE_WORKSPACE_ID = previous + }), + ) + }) +} diff --git a/packages/opencode/test/fixture/tui-plugin.ts b/packages/opencode/test/fixture/tui-plugin.ts index a4a5aaad60..62a3ae6e6b 100644 --- a/packages/opencode/test/fixture/tui-plugin.ts +++ b/packages/opencode/test/fixture/tui-plugin.ts @@ -1,9 +1,7 @@ import { createOpencodeClient } from "@opencode-ai/sdk/v2" import { RGBA, type CliRenderer } from "@opentui/core" import type { HostPluginApi } from "../../src/cli/cmd/tui/plugin/slots" -import { LegacyKeymapTransform } from "../../src/cli/cmd/tui/config/legacy-keymap-transform" -import { ConfigKeybinds } from "../../src/config/keybinds" -import { createTuiResolvedKeymap } from "./tui-runtime" +import { createTuiResolvedConfig } from "./tui-runtime" type Count = { event_add: number @@ -112,11 +110,9 @@ type Opts = { } function tuiConfig(input?: Partial): HostPluginApi["tuiConfig"] { - const keybinds = ConfigKeybinds.Keybinds.parse(input?.keybinds ?? {}) return { + ...createTuiResolvedConfig(), ...input, - keybinds, - keymap: input?.keymap ?? createTuiResolvedKeymap(LegacyKeymapTransform.create(input?.keybinds ?? {})), } } diff --git a/packages/opencode/test/fixture/tui-runtime.ts b/packages/opencode/test/fixture/tui-runtime.ts index d1e4c744b0..64537b6c50 100644 --- a/packages/opencode/test/fixture/tui-runtime.ts +++ b/packages/opencode/test/fixture/tui-runtime.ts @@ -1,45 +1,29 @@ import { spyOn } from "bun:test" import path from "path" -import type { KeyEvent, Renderable } from "@opentui/core" -import { resolveBindingSections, type BindingSectionsConfig } from "@opentui/keymap/extras" +import { createBindingLookup } from "@opentui/keymap/extras" import { TuiConfig } from "../../src/cli/cmd/tui/config/tui" -import { LegacyKeymapTransform } from "../../src/cli/cmd/tui/config/legacy-keymap-transform" -import { ConfigKeybinds } from "../../src/config/keybinds" -import { - KeymapConfig, - KeymapSectionNames, - keymapBindingDefaults, - type KeymapConfigInput, - type KeymapSection, -} from "../../src/cli/cmd/tui/config/tui-schema" +import { TuiKeybind } from "../../src/cli/cmd/tui/config/keybind" type PluginSpec = string | [string, Record] -type ResolvedInput = Omit & { - keybinds?: TuiConfig.Resolved["keybinds"] - keymap?: TuiConfig.Resolved["keymap"] +type ResolvedInput = Omit & { + keybinds?: Partial + leader_timeout?: number } -export function createTuiResolvedKeymap(input: KeymapConfigInput): TuiConfig.Resolved["keymap"] { - const config = KeymapConfig.parse(input) - return { - leader: !config.leader || config.leader === "none" ? "ctrl+x" : config.leader, - leader_timeout: config.leader_timeout, - ...resolveBindingSections, KeymapSection>( - config.sections, - { - sections: KeymapSectionNames, - bindingDefaults: keymapBindingDefaults, - }, - ), - } +export function createTuiResolvedKeybinds(input: Partial = {}): TuiConfig.Resolved["keybinds"] { + const keybinds = TuiKeybind.Keybinds.parse(input) + return createBindingLookup(TuiKeybind.toBindingConfig(keybinds), { + commandMap: TuiKeybind.CommandMap, + bindingDefaults: TuiKeybind.bindingDefaults(), + }) } export function createTuiResolvedConfig(input: ResolvedInput = {}): TuiConfig.Resolved { - const keybinds = input.keybinds ?? ConfigKeybinds.Keybinds.parse({}) + const keybinds = TuiKeybind.Keybinds.parse(input.keybinds ?? {}) return { ...input, - keybinds, - keymap: input.keymap ?? createTuiResolvedKeymap(LegacyKeymapTransform.create(input.keybinds ?? {})), + keybinds: createTuiResolvedKeybinds(keybinds), + leader_timeout: input.leader_timeout ?? 2000, } } diff --git a/packages/opencode/test/image/image.test.ts b/packages/opencode/test/image/image.test.ts new file mode 100644 index 0000000000..bf5c0b3948 --- /dev/null +++ b/packages/opencode/test/image/image.test.ts @@ -0,0 +1,82 @@ +import { describe, expect } from "bun:test" +import { Cause, Effect, Exit, Layer } from "effect" +import { Image } from "@/image/image" +import { MessageID, PartID, SessionID } from "@/session/schema" +import { TestConfig } from "../fixture/config" +import { testEffect } from "../lib/effect" + +const it = testEffect(Layer.mergeAll(Image.layer.pipe(Layer.provide(TestConfig.layer())))) +const tiny = testEffect( + Layer.mergeAll( + Image.layer.pipe( + Layer.provide( + TestConfig.layer({ get: () => Effect.succeed({ attachment: { image: { max_base64_bytes: 1 } } }) }), + ), + ), + ), +) + +function part(mime: string, data: string) { + return { + id: PartID.ascending(), + messageID: MessageID.ascending(), + sessionID: SessionID.make("ses_test"), + type: "file" as const, + mime, + url: `data:${mime};base64,${data}`, + } +} + +describe("Image", () => { + it.effect("normalizes generated png and jpeg attachments", () => + Effect.gen(function* () { + const photon = yield* Effect.promise(() => import("@silvia-odwyer/photon-node")) + const source = new photon.PhotonImage( + new Uint8Array(Array.from({ length: 64 * 64 * 4 }, (_, index) => (index % 4 === 3 ? 255 : index % 251))), + 64, + 64, + ) + const image = yield* Image.Service + const results = yield* Effect.all([ + image.normalize(part("image/png", Buffer.from(source.get_bytes()).toString("base64"))), + image.normalize(part("image/jpeg", Buffer.from(source.get_bytes_jpeg(90)).toString("base64"))), + ]) + + source.free() + expect(results.map((result) => result.url.startsWith(`data:${result.mime};base64,`))).toEqual([true, true]) + expect(results.every((result) => result.mime === "image/png" || result.mime === "image/jpeg")).toBe(true) + }), + ) + + it.effect("accepts webp attachments that are already within limits", () => + Effect.gen(function* () { + const image = yield* Image.Service + const input = part("image/webp", "UklGRiIAAABXRUJQVlA4IBYAAAAwAQCdASoBAAEADsD+JaQAA3AAAAAA") + + expect(yield* image.normalize(input)).toEqual(input) + }), + ) + + tiny.effect("fails with a typed size error when no resized candidate fits", () => + Effect.gen(function* () { + const photon = yield* Effect.promise(() => import("@silvia-odwyer/photon-node")) + const source = new photon.PhotonImage(new Uint8Array(Array.from({ length: 4 }, () => 255)), 1, 1) + const image = yield* Image.Service + const exit = yield* image + .normalize(part("image/png", Buffer.from(source.get_bytes()).toString("base64"))) + .pipe(Effect.exit) + + source.free() + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) { + const error = Cause.squash(exit.cause) + expect(error).toBeInstanceOf(Image.SizeError) + if (error instanceof Image.SizeError) { + expect(error.width).toBe(1) + expect(error.height).toBe(1) + expect(error.max).toBe(1) + } + } + }), + ) +}) diff --git a/packages/opencode/test/mcp/lifecycle.test.ts b/packages/opencode/test/mcp/lifecycle.test.ts index 10547c9f08..5afc85e3b5 100644 --- a/packages/opencode/test/mcp/lifecycle.test.ts +++ b/packages/opencode/test/mcp/lifecycle.test.ts @@ -7,8 +7,9 @@ import type { MCP as MCPNS } from "../../src/mcp/index" // Per-client state for controlling mock behavior interface MockClientState { - tools: Array<{ name: string; description?: string; inputSchema: object }> + tools: Array<{ name: string; description?: string; inputSchema: object; outputSchema?: object }> listToolsCalls: number + requestCalls: number listToolsShouldFail: boolean listToolsError: string listPromptsShouldFail: boolean @@ -36,6 +37,7 @@ function getOrCreateClientState(name?: string): MockClientState { state = { tools: [{ name: "test_tool", description: "A test tool", inputSchema: { type: "object", properties: {} } }], listToolsCalls: 0, + requestCalls: 0, listToolsShouldFail: false, listToolsError: "listTools failed", listPromptsShouldFail: false, @@ -139,6 +141,12 @@ void mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({ return { tools: this._state?.tools ?? [] } } + async request(request: { method: string }, schema: { parse: (value: unknown) => unknown }) { + if (this._state) this._state.requestCalls++ + if (request.method === "tools/list") return schema.parse({ tools: this._state?.tools ?? [] }) + throw new Error(`unsupported request: ${request.method}`) + } + async listPrompts() { if (this._state?.listPromptsShouldFail) { throw new Error("listPrompts failed") @@ -205,6 +213,11 @@ function withInstance( } } +function statusName(status: Record | MCPNS.Status, server: string) { + if ("status" in status) return status.status + return status[server]?.status +} + // ======================================================================== // Test: tools() are cached after connect // ======================================================================== @@ -433,6 +446,59 @@ test( ), ) +test( + "falls back when MCP output schema refs fail SDK tool discovery", + withInstance({}, (mcp) => + Effect.gen(function* () { + lastCreatedClientName = "stitch-like-server" + const serverState = getOrCreateClientState("stitch-like-server") + serverState.listToolsShouldFail = true + serverState.listToolsError = "can't resolve reference #/$defs/ScreenInstance from id #" + serverState.tools = [ + { + name: "render_screen", + description: "renders a screen", + inputSchema: { type: "object", properties: { prompt: { type: "string" } }, required: ["prompt"] }, + outputSchema: { type: "object", properties: { screen: { $ref: "#/$defs/ScreenInstance" } } }, + }, + ] + + const addResult = yield* mcp.add("stitch-like-server", { + type: "local", + command: ["echo", "test"], + }) + + expect(statusName(addResult.status, "stitch-like-server")).toBe("connected") + + const tools = yield* mcp.tools() + expect(Object.keys(tools).some((key) => key.includes("render_screen"))).toBe(true) + expect(serverState.listToolsCalls).toBe(1) + expect(serverState.requestCalls).toBe(1) + }), + ), +) + +test( + "does not fall back for non-schema MCP tool discovery errors", + withInstance({}, (mcp) => + Effect.gen(function* () { + lastCreatedClientName = "broken-server" + const serverState = getOrCreateClientState("broken-server") + serverState.listToolsShouldFail = true + serverState.listToolsError = "transport closed" + + const addResult = yield* mcp.add("broken-server", { + type: "local", + command: ["echo", "test"], + }) + + expect(statusName(addResult.status, "broken-server")).toBe("failed") + expect(serverState.listToolsCalls).toBe(1) + expect(serverState.requestCalls).toBe(0) + }), + ), +) + // ======================================================================== // Test: disabled server via config // ======================================================================== diff --git a/packages/opencode/test/preload.ts b/packages/opencode/test/preload.ts index b408f7ef11..1ba0554d3e 100644 --- a/packages/opencode/test/preload.ts +++ b/packages/opencode/test/preload.ts @@ -35,6 +35,11 @@ process.env["XDG_CONFIG_HOME"] = path.join(dir, "config") process.env["XDG_STATE_HOME"] = path.join(dir, "state") process.env["OPENCODE_MODELS_PATH"] = path.join(import.meta.dir, "tool", "fixtures", "models-api.json") process.env["OPENCODE_EXPERIMENTAL_EVENT_SYSTEM"] = "true" +// Tests assert exact skill counts from disk discovery; the built-in +// customize-opencode skill is opt-in for stable channels and on by default +// for unstable channels (including "local" where CI runs). Disable it here +// so disk-discovery tests aren't off-by-one. +process.env["OPENCODE_EXPERIMENTAL_CUSTOMIZE_SKILL"] = "false" // Set test home directory to isolate tests from user's actual home directory // This prevents tests from picking up real user configs/skills from ~/.claude/skills diff --git a/packages/opencode/test/project/instance-bootstrap-regression.test.ts b/packages/opencode/test/project/instance-bootstrap.test.ts similarity index 65% rename from packages/opencode/test/project/instance-bootstrap-regression.test.ts rename to packages/opencode/test/project/instance-bootstrap.test.ts index c01450549b..baad8df592 100644 --- a/packages/opencode/test/project/instance-bootstrap-regression.test.ts +++ b/packages/opencode/test/project/instance-bootstrap.test.ts @@ -1,19 +1,21 @@ import { afterEach, expect, test } from "bun:test" -import { Hono } from "hono" import { existsSync } from "node:fs" import path from "node:path" import { pathToFileURL } from "node:url" import { bootstrap as cliBootstrap } from "../../src/cli/bootstrap" -import { Instance } from "../../src/project/instance" import { WithInstance } from "../../src/project/with-instance" import { InstanceRuntime } from "../../src/project/instance-runtime" -import { InstanceMiddleware } from "../../src/server/routes/instance/middleware" import { disposeAllInstances, tmpdir } from "../fixture/fixture" -// These regressions cover the legacy instance-loading paths fixed by PRs -// #25389 and #25449. The plugin config hook writes a marker file, and the test -// bodies deliberately avoid touching Plugin or config directly. The marker only -// exists if InstanceBootstrap ran at the instance boundary. +// InstanceBootstrap must run before any code touches the instance — +// originally tracked by PRs #25389 and #25449, now a permanent +// invariant. The plugin config hook writes a marker file; the test +// bodies deliberately avoid Plugin/config directly. The marker only +// appears if InstanceBootstrap ran at the instance boundary. +// +// The Hono variant of this check lived alongside these tests and is +// going away with the Hono backend. The boundaries below are backend- +// agnostic and stay. afterEach(async () => { await disposeAllInstances() @@ -48,7 +50,7 @@ async function bootstrapFixture() { }) } -test("Instance.provide runs InstanceBootstrap before fn (boundary invariant)", async () => { +test("WithInstance.provide runs InstanceBootstrap before fn", async () => { await using tmp = await bootstrapFixture() await WithInstance.provide({ @@ -67,16 +69,6 @@ test("CLI bootstrap runs InstanceBootstrap before callback", async () => { expect(existsSync(tmp.extra)).toBe(true) }) -test("legacy Hono instance middleware runs InstanceBootstrap before next handler", async () => { - await using tmp = await bootstrapFixture() - const app = new Hono().use(InstanceMiddleware()).get("/probe", (c) => c.text("ok")) - - const response = await app.request("/probe", { headers: { "x-opencode-directory": tmp.path } }) - - expect(response.status).toBe(200) - expect(existsSync(tmp.extra)).toBe(true) -}) - test("InstanceRuntime.reloadInstance runs InstanceBootstrap", async () => { await using tmp = await bootstrapFixture() diff --git a/packages/opencode/test/project/migrate-global.test.ts b/packages/opencode/test/project/migrate-global.test.ts index 2a1580579d..c476c108b4 100644 --- a/packages/opencode/test/project/migrate-global.test.ts +++ b/packages/opencode/test/project/migrate-global.test.ts @@ -22,8 +22,9 @@ function run(fn: (svc: Project.Interface) => Effect.Effect) { ) } -function uid() { - return SessionID.make(crypto.randomUUID()) +function legacySessionID() { + // Global-session migration covers persisted IDs from before prefixed session IDs. + return crypto.randomUUID() as SessionID } function seed(opts: { id: SessionID; dir: string; project: ProjectID }) { @@ -73,7 +74,7 @@ describe("migrateFromGlobal", () => { expect(pre.id).toBe(ProjectID.global) // 2. Seed a session under "global" with matching directory - const id = uid() + const id = legacySessionID() seed({ id, dir: tmp.path, project: ProjectID.global }) // 3. Make a commit so the project gets a real ID @@ -100,7 +101,7 @@ describe("migrateFromGlobal", () => { // 3. Seed a session under "global" with matching directory. // This simulates a session created before git init that wasn't // present when the real project row was first created. - const id = uid() + const id = legacySessionID() seed({ id, dir: tmp.path, project: ProjectID.global }) // 4. Call fromDirectory again — project row already exists, @@ -121,7 +122,7 @@ describe("migrateFromGlobal", () => { // Legacy sessions may lack a directory value. // Without a matching origin directory, they should remain global. - const id = uid() + const id = legacySessionID() seed({ id, dir: "", project: ProjectID.global }) await run((svc) => svc.fromDirectory(tmp.path)) @@ -139,7 +140,7 @@ describe("migrateFromGlobal", () => { ensureGlobal() // Seed a session under "global" but for a DIFFERENT directory - const id = uid() + const id = legacySessionID() seed({ id, dir: "/some/other/dir", project: ProjectID.global }) await run((svc) => svc.fromDirectory(tmp.path)) diff --git a/packages/opencode/test/project/worktree.test.ts b/packages/opencode/test/project/worktree.test.ts index b191a3c952..4f0ead54e4 100644 --- a/packages/opencode/test/project/worktree.test.ts +++ b/packages/opencode/test/project/worktree.test.ts @@ -200,6 +200,35 @@ describe("Worktree", () => { ) }) + describe("list", () => { + it.live("uses parent folder name when worktree basename matches the primary worktree", () => + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + const svc = yield* Worktree.Service + const parent = path.join(path.dirname(dir), `${path.basename(dir)}-parent`) + const target = path.join(parent, path.basename(dir)) + const branch = `same-basename-list-${Date.now()}` + + yield* Effect.promise(() => fs.mkdir(parent, { recursive: true })) + yield* Effect.promise(() => $`git worktree add -b ${branch} ${target}`.cwd(dir).quiet()) + + const list = yield* svc.list() + const directory = yield* Effect.promise(() => fs.realpath(target).catch(() => target)) + + expect(list).toContainEqual({ + name: path.basename(parent), + branch, + directory: directory.toLowerCase(), + }) + + yield* svc.remove({ directory: target }) + }), + { git: true }, + ), + ) + }) + describe("remove edge cases", () => { it.live("remove non-existent directory succeeds silently", () => provideTmpdirInstance( diff --git a/packages/opencode/test/provider/model-status.test.ts b/packages/opencode/test/provider/model-status.test.ts new file mode 100644 index 0000000000..e6fa645e71 --- /dev/null +++ b/packages/opencode/test/provider/model-status.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, test } from "bun:test" +import { Schema } from "effect" +import { ConfigProvider } from "@/config/provider" +import { CatalogModelStatus, ModelStatus } from "@/provider/model-status" +import { ModelsDev } from "@/provider/models" +import { Provider } from "@/provider/provider" + +describe("provider model status schemas", () => { + test("keeps catalog status separate from normalized provider status", () => { + expect(Schema.decodeUnknownSync(CatalogModelStatus)("deprecated")).toBe("deprecated") + expect(() => Schema.decodeUnknownSync(CatalogModelStatus)("active")).toThrow() + expect(Schema.decodeUnknownSync(ModelStatus)("active")).toBe("active") + }) + + test("accepts active status across public provider schemas", () => { + expect(Schema.decodeUnknownSync(ConfigProvider.Model)({ status: "active" }).status).toBe("active") + expect( + Schema.decodeUnknownSync(ModelsDev.Model)({ + id: "test-model", + name: "Test Model", + release_date: "2026-01-01", + attachment: false, + reasoning: false, + temperature: true, + tool_call: true, + limit: { context: 128000, output: 8192 }, + }).status, + ).toBeUndefined() + expect( + Schema.decodeUnknownSync(Provider.Model)({ + id: "test-model", + providerID: "test-provider", + api: { + id: "test-model", + url: "", + npm: "@ai-sdk/openai-compatible", + }, + name: "Test Model", + capabilities: { + temperature: true, + reasoning: false, + attachment: false, + toolcall: true, + input: { text: true, audio: false, image: false, video: false, pdf: false }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: false, + }, + cost: { + input: 0, + output: 0, + cache: { read: 0, write: 0 }, + }, + limit: { context: 128000, output: 8192 }, + status: "active", + options: {}, + headers: {}, + release_date: "2026-01-01", + }).status, + ).toBe("active") + }) +}) diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index c52a7bfa44..df21922b09 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -3292,89 +3292,74 @@ describe("ProviderTransform.variants", () => { }) }) - describe("@ai-sdk/google", () => { - test("gemini-2.5 returns high and max with thinkingConfig and thinkingBudget", () => { - const model = createMockModel({ - id: "google/gemini-2.5-pro", - providerID: "google", - api: { - id: "gemini-2.5-pro", - url: "https://generativelanguage.googleapis.com", - npm: "@ai-sdk/google", + for (const provider of [ + { name: "@ai-sdk/google", providerID: "google", url: "https://generativelanguage.googleapis.com" }, + { name: "@ai-sdk/google-vertex", providerID: "google-vertex", url: "https://vertexai.googleapis.com" }, + ]) { + describe(provider.name, () => { + for (const testCase of [ + { + apiId: "gemini-2.5-pro", + efforts: ["high", "max"], + expectedHigh: { thinkingConfig: { includeThoughts: true, thinkingBudget: 16_000 } }, + expectedMax: { thinkingConfig: { includeThoughts: true, thinkingBudget: 32_768 } }, }, - }) - const result = ProviderTransform.variants(model) - expect(Object.keys(result)).toEqual(["high", "max"]) - expect(result.high).toEqual({ - thinkingConfig: { - includeThoughts: true, - thinkingBudget: 16000, + { + apiId: "gemini-2.5-flash", + efforts: ["high", "max"], + expectedHigh: { thinkingConfig: { includeThoughts: true, thinkingBudget: 16_000 } }, + expectedMax: { thinkingConfig: { includeThoughts: true, thinkingBudget: 24_576 } }, }, - }) - expect(result.max).toEqual({ - thinkingConfig: { - includeThoughts: true, - thinkingBudget: 24576, + { + apiId: "gemini-3-pro-preview", + efforts: ["low", "medium", "high"], + expectedHigh: { thinkingConfig: { includeThoughts: true, thinkingLevel: "high" } }, }, - }) + { + apiId: "gemini-3.1-pro-preview", + efforts: ["low", "medium", "high"], + expectedHigh: { thinkingConfig: { includeThoughts: true, thinkingLevel: "high" } }, + }, + { + apiId: "gemini-3-flash-preview", + efforts: ["minimal", "low", "medium", "high"], + expectedHigh: { thinkingConfig: { includeThoughts: true, thinkingLevel: "high" } }, + }, + { + apiId: "gemini-3.1-flash-lite", + efforts: ["minimal", "low", "medium", "high"], + expectedHigh: { thinkingConfig: { includeThoughts: true, thinkingLevel: "high" } }, + }, + { + apiId: "gemini-3.1-flash-image-preview", + efforts: ["minimal", "high"], + expectedHigh: { thinkingConfig: { includeThoughts: true, thinkingLevel: "high" } }, + }, + { + apiId: "gemini-3-pro-image-preview", + efforts: ["high"], + expectedHigh: { thinkingConfig: { includeThoughts: true, thinkingLevel: "high" } }, + }, + ]) { + test(`${testCase.apiId} returns supported thinking controls`, () => { + const result = ProviderTransform.variants( + createMockModel({ + id: `${provider.providerID}/${testCase.apiId}`, + providerID: provider.providerID, + api: { + id: testCase.apiId, + url: provider.url, + npm: provider.name, + }, + }), + ) + expect(Object.keys(result)).toEqual(testCase.efforts) + expect(result.high).toEqual(testCase.expectedHigh) + if (testCase.expectedMax) expect(result.max).toEqual(testCase.expectedMax) + }) + } }) - - test("other gemini models return low and high with thinkingLevel", () => { - const model = createMockModel({ - id: "google/gemini-2.0-pro", - providerID: "google", - api: { - id: "gemini-2.0-pro", - url: "https://generativelanguage.googleapis.com", - npm: "@ai-sdk/google", - }, - }) - const result = ProviderTransform.variants(model) - expect(Object.keys(result)).toEqual(["low", "high"]) - expect(result.low).toEqual({ - thinkingConfig: { - includeThoughts: true, - thinkingLevel: "low", - }, - }) - expect(result.high).toEqual({ - thinkingConfig: { - includeThoughts: true, - thinkingLevel: "high", - }, - }) - }) - }) - - describe("@ai-sdk/google-vertex", () => { - test("gemini-2.5 returns high and max with thinkingConfig and thinkingBudget", () => { - const model = createMockModel({ - id: "google-vertex/gemini-2.5-pro", - providerID: "google-vertex", - api: { - id: "gemini-2.5-pro", - url: "https://vertexai.googleapis.com", - npm: "@ai-sdk/google-vertex", - }, - }) - const result = ProviderTransform.variants(model) - expect(Object.keys(result)).toEqual(["high", "max"]) - }) - - test("other vertex models return low and high with thinkingLevel", () => { - const model = createMockModel({ - id: "google-vertex/gemini-2.0-pro", - providerID: "google-vertex", - api: { - id: "gemini-2.0-pro", - url: "https://vertexai.googleapis.com", - npm: "@ai-sdk/google-vertex", - }, - }) - const result = ProviderTransform.variants(model) - expect(Object.keys(result)).toEqual(["low", "high"]) - }) - }) + } describe("@ai-sdk/cohere", () => { test("returns empty object", () => { @@ -3640,6 +3625,32 @@ describe("ProviderTransform.smallOptions - gpt-5 chat/search", () => { } }) +describe("ProviderTransform.smallOptions - google thinking controls", () => { + const createGoogleModel = (apiId: string) => + ({ + id: `google/${apiId}`, + providerID: "google", + api: { + id: apiId, + url: "https://generativelanguage.googleapis.com", + npm: "@ai-sdk/google", + }, + }) as any + + for (const testCase of [ + { id: "gemini-3-pro-preview", options: { thinkingConfig: { thinkingLevel: "low" } } }, + { id: "gemini-3-flash-preview", options: { thinkingConfig: { thinkingLevel: "minimal" } } }, + { id: "gemini-3.1-flash-image-preview", options: { thinkingConfig: { thinkingLevel: "minimal" } } }, + { id: "gemini-3-pro-image-preview", options: { thinkingConfig: { thinkingLevel: "high" } } }, + { id: "gemini-2.5-pro", options: { thinkingConfig: { thinkingBudget: 128 } } }, + { id: "gemini-2.5-flash", options: { thinkingConfig: { thinkingBudget: 0 } } }, + ]) { + test(`${testCase.id} returns supported small thinking options`, () => { + expect(ProviderTransform.smallOptions(createGoogleModel(testCase.id))).toEqual(testCase.options) + }) + } +}) + describe("ProviderTransform.providerOptions - ai-gateway-provider", () => { const createModel = (overrides: Partial = {}) => ({ diff --git a/packages/opencode/test/reference/reference.test.ts b/packages/opencode/test/reference/reference.test.ts new file mode 100644 index 0000000000..4717c61d25 --- /dev/null +++ b/packages/opencode/test/reference/reference.test.ts @@ -0,0 +1,244 @@ +import { afterEach, describe, expect } from "bun:test" +import path from "path" +import { Effect, Layer } from "effect" +import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { Flag } from "@opencode-ai/core/flag/flag" +import { Global } from "@opencode-ai/core/global" +import { Git } from "../../src/git" +import { Reference } from "../../src/reference/reference" +import { disposeAllInstances, provideTmpdirInstance, tmpdirScoped } from "../fixture/fixture" +import { testEffect } from "../lib/effect" + +afterEach(async () => { + await disposeAllInstances() +}) + +const it = testEffect( + Layer.mergeAll(AppFileSystem.defaultLayer, CrossSpawnSpawner.defaultLayer, Git.defaultLayer, Reference.defaultLayer), +) + +const experimentalScout = (self: Effect.Effect) => + Effect.acquireUseRelease( + Effect.sync(() => { + const previous = Flag.OPENCODE_EXPERIMENTAL_SCOUT + Flag.OPENCODE_EXPERIMENTAL_SCOUT = true + return previous + }), + () => self, + (previous) => + Effect.sync(() => { + Flag.OPENCODE_EXPERIMENTAL_SCOUT = previous + }), + ) + +const githubBase = (url: string, self: Effect.Effect) => + Effect.acquireUseRelease( + Effect.sync(() => { + const previous = process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL + process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL = url + return previous + }), + () => self, + (previous) => + Effect.sync(() => { + if (previous) process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL = previous + else delete process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL + }), + ) + +const git = Effect.fn("ReferenceTest.git")(function* (cwd: string, args: string[]) { + return yield* Effect.promise(async () => { + const proc = Bun.spawn(["git", ...args], { + cwd, + stdout: "pipe", + stderr: "pipe", + }) + const [stdout, stderr, code] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]) + if (code !== 0) throw new Error(stderr.trim() || stdout.trim() || `git ${args.join(" ")} failed`) + return stdout.trim() + }) +}) + +const waitForContent = ( + fs: AppFileSystem.Interface, + file: string, + content: string, + attempts = 50, +): Effect.Effect => + Effect.gen(function* () { + if ((yield* fs.readFileStringSafe(file)) === content) return + if (attempts <= 0) throw new Error(`timed out waiting for ${file}`) + yield* Effect.sleep("100 millis") + yield* waitForContent(fs, file, content, attempts - 1) + }) + +describe("reference", () => { + it.live("resolves local and git references", () => + Effect.gen(function* () { + const root = path.resolve("opencode-reference-root") + const local = Reference.resolve({ + name: "docs", + reference: { path: "../docs" }, + directory: path.join(root, "packages", "app"), + worktree: root, + }) + const repo = Reference.resolve({ + name: "effect", + reference: { repository: "Effect-TS/effect", branch: "main" }, + directory: path.join(root, "packages", "app"), + worktree: root, + }) + + expect(local.kind).toBe("local") + if (local.kind === "local") expect(local.path).toBe(path.resolve(root, "../docs")) + expect(repo.kind).toBe("git") + if (repo.kind === "git") { + expect(repo.repository).toBe("Effect-TS/effect") + expect(repo.branch).toBe("main") + expect(repo.path).toBe(path.join(Global.Path.repos, "github.com", "Effect-TS", "effect")) + } + }), + ) + + it.live("marks same-cache references with different branches invalid", () => + Effect.gen(function* () { + const root = path.resolve("opencode-reference-root") + const references = Reference.resolveAll({ + directory: root, + worktree: root, + references: { + main: { repository: "owner/repo", branch: "main" }, + dev: { repository: "github.com/owner/repo", branch: "dev" }, + alsoMain: { repository: "https://github.com/owner/repo", branch: "main" }, + }, + }) + + expect(references.map((reference) => reference.kind)).toEqual(["git", "invalid", "git"]) + expect(references[1]?.kind).toBe("invalid") + if (references[1]?.kind === "invalid") { + expect(references[1].message).toContain("conflicts with @main") + expect(references[1].message).toContain("@dev requests dev") + } + }), + ) + + it.live("materializes configured git references during init", () => + experimentalScout( + provideTmpdirInstance( + (_dir) => + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const cache = path.join(Global.Path.repos, "github.com", "opencode-reference-test", "repo") + yield* fs.remove(cache, { recursive: true }).pipe(Effect.ignore) + yield* Effect.addFinalizer(() => fs.remove(cache, { recursive: true }).pipe(Effect.ignore)) + + const source = yield* tmpdirScoped({ git: true }) + const remoteRoot = yield* tmpdirScoped() + const remoteDir = path.join(remoteRoot, "opencode-reference-test") + const remoteRepo = path.join(remoteDir, "repo.git") + + yield* Effect.promise(() => Bun.write(path.join(source, "README.md"), "configured\n")) + yield* git(source, ["add", "."]) + yield* git(source, ["commit", "-m", "add readme"]) + yield* fs.makeDirectory(remoteDir, { recursive: true }).pipe(Effect.orDie) + yield* git(remoteRoot, ["clone", "--bare", source, remoteRepo]) + + const reference = yield* Reference.Service + yield* githubBase( + `file://${remoteRoot}/`, + Effect.gen(function* () { + yield* reference.init() + yield* waitForContent(fs, path.join(cache, "README.md"), "configured\n") + }), + ) + + expect(yield* fs.existsSafe(path.join(cache, ".git"))).toBe(true) + expect(yield* fs.readFileString(path.join(cache, "README.md"))).toBe("configured\n") + + const resolved = yield* reference.get("docs") + expect(resolved?.kind).toBe("git") + if (resolved?.kind === "git") expect(resolved.path).toBe(cache) + }), + { + config: { + reference: { + docs: "opencode-reference-test/repo", + }, + }, + }, + ), + ), + ) + + it.live("refreshes configured git references on new instance init", () => + experimentalScout( + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const cache = path.join(Global.Path.repos, "github.com", "opencode-reference-refresh", "repo") + yield* fs.remove(cache, { recursive: true }).pipe(Effect.ignore) + yield* Effect.addFinalizer(() => fs.remove(cache, { recursive: true }).pipe(Effect.ignore)) + + const source = yield* tmpdirScoped({ git: true }) + const remoteRoot = yield* tmpdirScoped() + const remoteDir = path.join(remoteRoot, "opencode-reference-refresh") + const remoteRepo = path.join(remoteDir, "repo.git") + + yield* Effect.promise(() => Bun.write(path.join(source, "README.md"), "v1\n")) + yield* git(source, ["add", "."]) + yield* git(source, ["commit", "-m", "add readme"]) + yield* fs.makeDirectory(remoteDir, { recursive: true }).pipe(Effect.orDie) + yield* git(remoteRoot, ["clone", "--bare", source, remoteRepo]) + + yield* githubBase( + `file://${remoteRoot}/`, + provideTmpdirInstance( + (_dir) => + Effect.gen(function* () { + const reference = yield* Reference.Service + yield* reference.init() + yield* waitForContent(fs, path.join(cache, "README.md"), "v1\n") + }), + { + config: { + reference: { + docs: "opencode-reference-refresh/repo", + }, + }, + }, + ), + ) + + const branch = yield* git(source, ["branch", "--show-current"]) + yield* git(source, ["remote", "add", "origin", remoteRepo]) + yield* Effect.promise(() => Bun.write(path.join(source, "README.md"), "v2\n")) + yield* git(source, ["add", "."]) + yield* git(source, ["commit", "-m", "update readme"]) + yield* git(source, ["push", "origin", `${branch}:${branch}`]) + + yield* githubBase( + `file://${remoteRoot}/`, + provideTmpdirInstance( + (_dir) => + Effect.gen(function* () { + const reference = yield* Reference.Service + yield* reference.init() + yield* waitForContent(fs, path.join(cache, "README.md"), "v2\n") + }), + { + config: { + reference: { + docs: "opencode-reference-refresh/repo", + }, + }, + }, + ), + ) + }), + ), + ) +}) diff --git a/packages/opencode/test/server/httpapi-authorization.test.ts b/packages/opencode/test/server/httpapi-authorization.test.ts index 850098926a..e99a91e1d0 100644 --- a/packages/opencode/test/server/httpapi-authorization.test.ts +++ b/packages/opencode/test/server/httpapi-authorization.test.ts @@ -72,7 +72,9 @@ describe("HttpApi authorization middleware", () => { ) expect(missing.status).toBe(401) + expect(missing.headers["www-authenticate"] ?? "").toContain("Basic") expect(badPassword.status).toBe(401) + expect(badPassword.headers["www-authenticate"] ?? "").toContain("Basic") expect(good.status).toBe(200) }), ) diff --git a/packages/opencode/test/server/httpapi-bridge.test.ts b/packages/opencode/test/server/httpapi-bridge.test.ts deleted file mode 100644 index 615899f2b4..0000000000 --- a/packages/opencode/test/server/httpapi-bridge.test.ts +++ /dev/null @@ -1,443 +0,0 @@ -import { afterEach, describe, expect, test } from "bun:test" -import { Flag } from "@opencode-ai/core/flag/flag" -import { Instance } from "../../src/project/instance" -import { ControlPaths } from "../../src/server/routes/instance/httpapi/groups/control" -import { FilePaths } from "../../src/server/routes/instance/httpapi/groups/file" -import { GlobalPaths } from "../../src/server/routes/instance/httpapi/groups/global" -import { PublicApi } from "../../src/server/routes/instance/httpapi/public" -import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server" -import { Server } from "../../src/server/server" -import * as Log from "@opencode-ai/core/util/log" -import { ConfigProvider, Layer } from "effect" -import { HttpRouter } from "effect/unstable/http" -import { OpenApi } from "effect/unstable/httpapi" -import { resetDatabase } from "../fixture/db" -import { disposeAllInstances, tmpdir } from "../fixture/fixture" - -void Log.init({ print: false }) - -const original = { - OPENCODE_EXPERIMENTAL_HTTPAPI: Flag.OPENCODE_EXPERIMENTAL_HTTPAPI, - OPENCODE_SERVER_PASSWORD: Flag.OPENCODE_SERVER_PASSWORD, - OPENCODE_SERVER_USERNAME: Flag.OPENCODE_SERVER_USERNAME, -} - -const methods = ["get", "post", "put", "delete", "patch"] as const -let effectSpec: ReturnType | undefined - -function effectOpenApi() { - return (effectSpec ??= OpenApi.fromApi(PublicApi)) -} - -function app(input?: { password?: string; username?: string }) { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true - Flag.OPENCODE_SERVER_PASSWORD = input?.password - Flag.OPENCODE_SERVER_USERNAME = input?.username - - const handler = HttpRouter.toWebHandler( - ExperimentalHttpApiServer.routes.pipe( - Layer.provide( - ConfigProvider.layer( - ConfigProvider.fromUnknown({ - OPENCODE_SERVER_PASSWORD: input?.password, - OPENCODE_SERVER_USERNAME: input?.username, - }), - ), - ), - ), - { disableLogger: true }, - ).handler - return { - fetch: (request: Request) => handler(request, ExperimentalHttpApiServer.context), - request(input: string | URL | Request, init?: RequestInit) { - return this.fetch(input instanceof Request ? input : new Request(new URL(input, "http://localhost"), init)) - }, - } -} - -function openApiRouteKeys(spec: { paths: Record>> }) { - return Object.entries(spec.paths) - .flatMap(([path, item]) => - methods.filter((method) => item[method]).map((method) => `${method.toUpperCase()} ${path}`), - ) - .sort() -} - -function openApiParameters(spec: { paths: Record>> }) { - return Object.fromEntries( - Object.entries(spec.paths).flatMap(([path, item]) => - methods - .filter((method) => item[method]) - .map((method) => [ - `${method.toUpperCase()} ${path}`, - (item[method]?.parameters ?? []) - .map(parameterKey) - .filter((param) => param !== undefined) - .sort(), - ]), - ), - ) -} - -function openApiRequestBodies(spec: OpenApiSpec) { - return Object.fromEntries( - Object.entries(spec.paths).flatMap(([path, item]) => - methods - .filter((method) => item[method]) - .map((method) => [`${method.toUpperCase()} ${path}`, requestBodyKey(spec, item[method]?.requestBody)]), - ), - ) -} - -type OpenApiSpec = { - components?: { - schemas?: Record - } - paths: Record>> -} - -type OpenApiSchema = { - $ref?: string - allOf?: unknown[] - anyOf?: unknown[] - oneOf?: unknown[] - properties?: Record - type?: string | string[] -} - -type Operation = { - parameters?: unknown[] - responses?: unknown - requestBody?: unknown -} - -type RequestBody = { - content?: Record - required?: boolean -} - -function parameterKey(param: unknown): string | undefined { - if (!param || typeof param !== "object" || !("in" in param) || !("name" in param)) return undefined - if (typeof param.in !== "string" || typeof param.name !== "string") return undefined - return `${param.in}:${param.name}:${"required" in param && param.required === true}:${stableSchema( - "schema" in param ? param.schema : undefined, - )}` -} - -function stableSchema(input: unknown): string { - return JSON.stringify(sortSchema(input)) -} - -function sortSchema(input: unknown): unknown { - if (Array.isArray(input)) return input.map(sortSchema) - if (!input || typeof input !== "object") return input - return Object.fromEntries( - Object.entries(input) - .sort(([left], [right]) => left.localeCompare(right)) - .map(([key, value]) => [key, sortSchema(value)]), - ) -} - -function parameterSchema(input: { - spec: { paths: Record>> } - path: string - method: (typeof methods)[number] - name: string -}): unknown { - const param = input.spec.paths[input.path]?.[input.method]?.parameters?.find( - (param) => !!param && typeof param === "object" && "name" in param && param.name === input.name, - ) - if (!param || typeof param !== "object" || !("schema" in param)) return undefined - return param.schema -} - -function requestBodyKey(spec: OpenApiSpec, body: unknown) { - if (!body || typeof body !== "object" || !("content" in body)) return "" - // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- Guarded above; test helper only needs this OpenAPI subset. - const requestBody = body as RequestBody - return JSON.stringify({ - required: requestBody.required === true, - content: Object.entries(requestBody.content ?? {}) - .map(([type, value]) => [type, requestBodySchemaKind(spec, value.schema)] as const) - .sort(([left], [right]) => left.localeCompare(right)), - }) -} - -function requestBodySchemaKind(spec: OpenApiSpec, schema: OpenApiSchema | undefined) { - if (!schema) return "" - // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- `$ref` lookup is constrained to OpenAPI schema components in this test helper. - const resolved = ( - schema.$ref ? spec.components?.schemas?.[schema.$ref.replace("#/components/schemas/", "")] : schema - ) as OpenApiSchema | undefined - if (resolved?.properties) return "object" - if (resolved?.anyOf ?? resolved?.oneOf ?? resolved?.allOf) return "object" - return resolved?.type ?? schema.type ?? "inline" -} - -function responseContentTypes(input: { - spec: { paths: Record>> } - path: string - method: (typeof methods)[number] - status: string -}) { - const responses = input.spec.paths[input.path]?.[input.method]?.responses - if (!responses || typeof responses !== "object" || !(input.status in responses)) return [] - // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- Guarded dynamic OpenAPI response lookup. - const response = (responses as Record)[input.status] - if (!response || typeof response !== "object" || !("content" in response)) return [] - const content = (response as { content?: unknown }).content - if (!content || typeof content !== "object") { - return [] - } - return Object.keys(content).sort() -} - -function authorization(username: string, password: string) { - return `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}` -} - -function fileUrl(input?: { directory?: string; token?: string }) { - const url = new URL(`http://localhost${FilePaths.content}`) - url.searchParams.set("path", "hello.txt") - if (input?.directory) url.searchParams.set("directory", input.directory) - if (input?.token) url.searchParams.set("auth_token", input.token) - return url -} - -afterEach(async () => { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original.OPENCODE_EXPERIMENTAL_HTTPAPI - Flag.OPENCODE_SERVER_PASSWORD = original.OPENCODE_SERVER_PASSWORD - Flag.OPENCODE_SERVER_USERNAME = original.OPENCODE_SERVER_USERNAME - await disposeAllInstances() - await resetDatabase() -}) - -describe("HttpApi server", () => { - test("keeps Effect HttpApi behind the feature flag", () => { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = false - expect(Server.backend()).toEqual({ backend: "hono", reason: "stable" }) - - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true - expect(Server.backend()).toEqual({ backend: "effect-httpapi", reason: "env" }) - }) - - test("covers every generated OpenAPI route with Effect HttpApi contracts", async () => { - const honoRoutes = openApiRouteKeys(await Server.openapiHono()) - const effectRoutes = openApiRouteKeys(effectOpenApi()) - - expect(honoRoutes.filter((route) => !effectRoutes.includes(route))).toEqual([]) - expect(effectRoutes.filter((route) => !honoRoutes.includes(route))).toEqual([ - "GET /api/session", - "GET /api/session/{sessionID}/context", - "GET /api/session/{sessionID}/message", - "POST /api/session/{sessionID}/compact", - "POST /api/session/{sessionID}/prompt", - "POST /api/session/{sessionID}/wait", - ]) - }) - - test("matches generated OpenAPI route parameters", async () => { - const hono = openApiParameters(await Server.openapiHono()) - const effect = openApiParameters(effectOpenApi()) - - expect( - Object.keys(hono) - .filter((route) => JSON.stringify(hono[route]) !== JSON.stringify(effect[route])) - .map((route) => ({ route, hono: hono[route], effect: effect[route] })), - ).toEqual([]) - }) - - test("matches generated OpenAPI request body shape", async () => { - const hono = openApiRequestBodies(await Server.openapiHono()) - const effect = openApiRequestBodies(effectOpenApi()) - - expect( - Object.keys(hono) - .filter((route) => hono[route] !== effect[route]) - .map((route) => ({ route, hono: hono[route], effect: effect[route] })), - ).toEqual([]) - }) - - test("matches SDK-affecting query parameter schemas", async () => { - const effect = effectOpenApi() - - expect(parameterSchema({ spec: effect, path: "/session", method: "get", name: "roots" })).toEqual({ - anyOf: [{ type: "boolean" }, { type: "string", enum: ["true", "false"] }], - }) - expect(parameterSchema({ spec: effect, path: "/session", method: "get", name: "start" })).toEqual({ - type: "number", - }) - expect(parameterSchema({ spec: effect, path: "/find/file", method: "get", name: "limit" })).toEqual({ - type: "integer", - minimum: 1, - maximum: 200, - }) - expect( - parameterSchema({ spec: effect, path: "/session/{sessionID}/message", method: "get", name: "limit" }), - ).toEqual({ - type: "integer", - minimum: 0, - maximum: Number.MAX_SAFE_INTEGER, - }) - }) - - test("matches SDK-affecting request schema details", () => { - const effect = effectOpenApi() - const sessionUpdate = effect.paths["/session/{sessionID}"]?.patch?.requestBody - const sessionUpdateSchema = - typeof sessionUpdate === "object" && sessionUpdate && "content" in sessionUpdate - ? sessionUpdate.content?.["application/json"]?.schema - : undefined - const sessionUpdateProperties = sessionUpdateSchema?.properties as Record | undefined - const time = sessionUpdateProperties?.time - expect(time?.properties?.archived).toEqual({ type: "number" }) - }) - - test("documents event routes as server-sent events", () => { - const effect = effectOpenApi() - - expect(responseContentTypes({ spec: effect, path: "/event", method: "get", status: "200" })).toEqual([ - "text/event-stream", - ]) - expect(responseContentTypes({ spec: effect, path: "/global/event", method: "get", status: "200" })).toEqual([ - "text/event-stream", - ]) - }) - - test("allows requests when auth is disabled", async () => { - await using tmp = await tmpdir({ git: true }) - await Bun.write(`${tmp.path}/hello.txt`, "hello") - - const response = await app().request(fileUrl(), { - headers: { - "x-opencode-directory": tmp.path, - }, - }) - - expect(response.status).toBe(200) - expect(await response.json()).toMatchObject({ content: "hello" }) - }) - - test("provides instance context to bridged handlers", async () => { - await using tmp = await tmpdir({ git: true }) - - const response = await app().request("/project/current", { - headers: { - "x-opencode-directory": tmp.path, - }, - }) - - expect(response.status).toBe(200) - expect(await response.json()).toMatchObject({ worktree: tmp.path }) - }) - - test("requires credentials when auth is enabled", async () => { - await using tmp = await tmpdir({ git: true }) - await Bun.write(`${tmp.path}/hello.txt`, "hello") - - const [missing, bad, good] = await Promise.all([ - app({ password: "secret" }).request(fileUrl(), { - headers: { "x-opencode-directory": tmp.path }, - }), - app({ password: "secret" }).request(fileUrl(), { - headers: { - authorization: authorization("opencode", "wrong"), - "x-opencode-directory": tmp.path, - }, - }), - app({ password: "secret" }).request(fileUrl(), { - headers: { - authorization: authorization("opencode", "secret"), - "x-opencode-directory": tmp.path, - }, - }), - ]) - - expect(missing.status).toBe(401) - expect(bad.status).toBe(401) - expect(good.status).toBe(200) - }) - - test("accepts auth_token query credentials", async () => { - await using tmp = await tmpdir({ git: true }) - await Bun.write(`${tmp.path}/hello.txt`, "hello") - - const response = await app({ password: "secret" }).request( - fileUrl({ token: Buffer.from("opencode:secret").toString("base64") }), - { - headers: { - "x-opencode-directory": tmp.path, - }, - }, - ) - - expect(response.status).toBe(200) - }) - - test("selects instance from query before directory header", async () => { - await using header = await tmpdir({ git: true }) - await using query = await tmpdir({ git: true }) - await Bun.write(`${header.path}/hello.txt`, "header") - await Bun.write(`${query.path}/hello.txt`, "query") - - const response = await app().request(fileUrl({ directory: query.path }), { - headers: { - "x-opencode-directory": header.path, - }, - }) - - expect(response.status).toBe(200) - expect(await response.json()).toMatchObject({ content: "query" }) - }) - - test("serves global health from Effect HttpApi", async () => { - const response = await app().request(`${GlobalPaths.health}?directory=/does/not/exist/opencode-test`) - - expect(response.status).toBe(200) - expect(await response.json()).toMatchObject({ healthy: true }) - }) - - test("serves global event stream from Effect HttpApi", async () => { - const response = await app().request(GlobalPaths.event) - if (!response.body) throw new Error("missing event stream body") - const reader = response.body.getReader() - const chunk = await reader.read() - await reader.cancel() - - expect(response.status).toBe(200) - expect(response.headers.get("content-type")).toContain("text/event-stream") - expect(new TextDecoder().decode(chunk.value)).toContain("server.connected") - }) - - test("serves control log from Effect HttpApi", async () => { - const response = await app().request(ControlPaths.log, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ service: "httpapi-test", level: "info", message: "hello" }), - }) - - expect(response.status).toBe(200) - expect(await response.json()).toBe(true) - }) - - test("validates control auth without falling through to 404", async () => { - const response = await app().request(ControlPaths.auth.replace(":providerID", "test"), { - method: "PUT", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ type: "api" }), - }) - - expect(response.status).toBe(400) - }) - - test("validates global upgrade without invoking installers", async () => { - const response = await app().request(GlobalPaths.upgrade, { - method: "POST", - headers: { "content-type": "application/json" }, - body: "not-json", - }) - - expect(response.status).toBe(400) - expect(await response.json()).toMatchObject({ success: false }) - }) -}) diff --git a/packages/opencode/test/server/httpapi-compression.test.ts b/packages/opencode/test/server/httpapi-compression.test.ts new file mode 100644 index 0000000000..4fcf8864fe --- /dev/null +++ b/packages/opencode/test/server/httpapi-compression.test.ts @@ -0,0 +1,154 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { gunzipSync, inflateSync } from "node:zlib" +import * as Log from "@opencode-ai/core/util/log" +import { Server } from "../../src/server/server" +import { resetDatabase } from "../fixture/db" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" + +void Log.init({ print: false }) + +afterEach(async () => { + await disposeAllInstances() + await resetDatabase() +}) + +function app() { + return Server.Default().app +} + +// /config echoes the config back. Padding the config pushes the response body +// well past the 1024 B threshold so we can observe compression behavior. +function fatConfig() { + const instructions: string[] = [] + for (let i = 0; i < 50; i++) { + instructions.push(`padding-instruction-${i}-${"x".repeat(40)}`) + } + return { + formatter: false, + lsp: false, + username: "compression-test-user", + instructions, + } +} + +describe("HttpApi compression", () => { + describe("encodes responses", () => { + test("gzips JSON when Accept-Encoding includes gzip and body exceeds threshold", async () => { + await using tmp = await tmpdir({ config: fatConfig() }) + const response = await app().request("/config", { + headers: { "x-opencode-directory": tmp.path, "accept-encoding": "gzip" }, + }) + expect(response.status).toBe(200) + expect(response.headers.get("content-encoding")).toBe("gzip") + const compressed = new Uint8Array(await response.arrayBuffer()) + const decompressed = gunzipSync(compressed) + const json = JSON.parse(new TextDecoder().decode(decompressed)) + expect(json).toMatchObject({ username: "compression-test-user" }) + expect(compressed.byteLength).toBeLessThan(decompressed.byteLength) + }) + + test("uses deflate when only deflate is acceptable", async () => { + await using tmp = await tmpdir({ config: fatConfig() }) + const response = await app().request("/config", { + headers: { "x-opencode-directory": tmp.path, "accept-encoding": "deflate" }, + }) + expect(response.status).toBe(200) + expect(response.headers.get("content-encoding")).toBe("deflate") + const compressed = new Uint8Array(await response.arrayBuffer()) + const decompressed = inflateSync(compressed) + const json = JSON.parse(new TextDecoder().decode(decompressed)) + expect(json).toMatchObject({ username: "compression-test-user" }) + }) + + test("prefers gzip when both gzip and deflate are acceptable", async () => { + await using tmp = await tmpdir({ config: fatConfig() }) + const response = await app().request("/config", { + headers: { "x-opencode-directory": tmp.path, "accept-encoding": "gzip, deflate" }, + }) + expect(response.headers.get("content-encoding")).toBe("gzip") + }) + + test("does not include the original Content-Length when compressed", async () => { + await using tmp = await tmpdir({ config: fatConfig() }) + const response = await app().request("/config", { + headers: { "x-opencode-directory": tmp.path, "accept-encoding": "gzip" }, + }) + const compressed = new Uint8Array(await response.arrayBuffer()) + const declared = response.headers.get("content-length") + // Either absent (transfer-encoding chunked) or matches the compressed length. + if (declared !== null) expect(Number(declared)).toBe(compressed.byteLength) + }) + }) + + describe("skips", () => { + test("when no Accept-Encoding header is present", async () => { + await using tmp = await tmpdir({ config: fatConfig() }) + const response = await app().request("/config", { + headers: { "x-opencode-directory": tmp.path }, + }) + expect(response.headers.get("content-encoding")).toBeNull() + }) + + test("when Accept-Encoding only allows unsupported encodings", async () => { + await using tmp = await tmpdir({ config: fatConfig() }) + const response = await app().request("/config", { + headers: { "x-opencode-directory": tmp.path, "accept-encoding": "br" }, + }) + expect(response.headers.get("content-encoding")).toBeNull() + }) + + test("when the response body is below the 1024-byte threshold", async () => { + // A bare config produces a tiny response (~few hundred bytes). + await using tmp = await tmpdir({ config: { formatter: false, lsp: false } }) + const response = await app().request("/config", { + headers: { "x-opencode-directory": tmp.path, "accept-encoding": "gzip" }, + }) + expect(response.status).toBe(200) + const body = new Uint8Array(await response.arrayBuffer()) + expect(body.byteLength).toBeLessThan(1024) + expect(response.headers.get("content-encoding")).toBeNull() + }) + + test("HEAD requests", async () => { + await using tmp = await tmpdir({ config: fatConfig() }) + const response = await app().request("/config", { + method: "HEAD", + headers: { "x-opencode-directory": tmp.path, "accept-encoding": "gzip" }, + }) + expect(response.headers.get("content-encoding")).toBeNull() + }) + }) + + describe("streaming exclusions", () => { + test("/event SSE is not compressed", async () => { + await using tmp = await tmpdir({ config: { formatter: false, lsp: false } }) + const controller = new AbortController() + const response = await app().request("/event", { + headers: { "x-opencode-directory": tmp.path, "accept-encoding": "gzip" }, + signal: controller.signal, + }) + try { + expect(response.status).toBe(200) + expect(response.headers.get("content-encoding")).toBeNull() + } finally { + controller.abort() + await response.body?.cancel().catch(() => {}) + } + }) + + test("/global/event SSE is not compressed", async () => { + const controller = new AbortController() + const response = await app().request("/global/event", { + headers: { "accept-encoding": "gzip" }, + signal: controller.signal, + }) + try { + expect(response.status).toBe(200) + expect(response.headers.get("content-encoding")).toBeNull() + } finally { + controller.abort() + await response.body?.cancel().catch(() => {}) + } + }) + }) +}) diff --git a/packages/opencode/test/server/httpapi-config.test.ts b/packages/opencode/test/server/httpapi-config.test.ts index 16e8975ea1..509a067d08 100644 --- a/packages/opencode/test/server/httpapi-config.test.ts +++ b/packages/opencode/test/server/httpapi-config.test.ts @@ -1,6 +1,5 @@ import { afterEach, describe, expect, test } from "bun:test" import path from "path" -import { Flag } from "@opencode-ai/core/flag/flag" import { Server } from "../../src/server/server" import * as Log from "@opencode-ai/core/util/log" import { resetDatabase } from "../fixture/db" @@ -9,10 +8,7 @@ import { waitGlobalBusEventPromise } from "./global-bus" void Log.init({ print: false }) -const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI - function app() { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true return Server.Default().app } @@ -24,7 +20,6 @@ async function waitDisposed(directory: string) { } afterEach(async () => { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original await disposeAllInstances() await resetDatabase() }) @@ -52,4 +47,41 @@ describe("config HttpApi", () => { lsp: false, }) }) + + test("serves config with active provider model status", async () => { + await using tmp = await tmpdir({ + config: { + formatter: false, + lsp: false, + provider: { + omniroute: { + models: { + "gpt-4o": { + status: "active", + }, + }, + }, + }, + }, + }) + + const response = await app().request("/config", { + headers: { + "x-opencode-directory": tmp.path, + }, + }) + + expect(response.status).toBe(200) + expect(await response.json()).toMatchObject({ + provider: { + omniroute: { + models: { + "gpt-4o": { + status: "active", + }, + }, + }, + }, + }) + }) }) diff --git a/packages/opencode/test/server/httpapi-cors-vary.test.ts b/packages/opencode/test/server/httpapi-cors-vary.test.ts new file mode 100644 index 0000000000..74a09cb253 --- /dev/null +++ b/packages/opencode/test/server/httpapi-cors-vary.test.ts @@ -0,0 +1,66 @@ +import { afterEach, describe, expect, test } from "bun:test" +import * as Log from "@opencode-ai/core/util/log" +import { Server } from "../../src/server/server" +import { resetDatabase } from "../fixture/db" +import { disposeAllInstances } from "../fixture/fixture" + +void Log.init({ print: false }) + +afterEach(async () => { + await disposeAllInstances() + await resetDatabase() +}) + +function app() { + return Server.Default().app +} + +const PREFLIGHT_HEADERS = { + origin: "http://localhost:3000", + "access-control-request-method": "POST", + "access-control-request-headers": "content-type, x-opencode-directory", +} + +// effect-smol's HttpMiddleware.cors overwrites `Vary: Origin` with +// `Vary: Access-Control-Request-Headers` on OPTIONS preflight responses +// (the two share the same record key during the spread). With dynamic +// origin echoing, missing Vary: Origin lets shared caches serve a preflight +// cached for one origin against a different origin. corsVaryFixLayer +// restores the merged form. +describe("CORS preflight Vary header", () => { + test("HTTP API backend preflight Vary contains Origin", async () => { + const response = await app().request("/global/config", { + method: "OPTIONS", + headers: PREFLIGHT_HEADERS, + }) + + expect([200, 204]).toContain(response.status) + expect(response.headers.get("access-control-allow-origin")).toBe("http://localhost:3000") + expect((response.headers.get("vary") ?? "").toLowerCase()).toContain("origin") + }) + + test("HTTP API backend preflight Vary still preserves Access-Control-Request-Headers", async () => { + const response = await app().request("/global/config", { + method: "OPTIONS", + headers: PREFLIGHT_HEADERS, + }) + + const vary = (response.headers.get("vary") ?? "").toLowerCase() + expect(vary).toContain("origin") + expect(vary).toContain("access-control-request-headers") + }) + + test("HTTP API backend does not duplicate Origin in Vary", async () => { + const response = await app().request("/global/config", { + method: "OPTIONS", + headers: PREFLIGHT_HEADERS, + }) + + const vary = response.headers.get("vary") ?? "" + const originCount = vary + .split(",") + .map((s: string) => s.trim().toLowerCase()) + .filter((s: string) => s === "origin").length + expect(originCount).toBe(1) + }) +}) diff --git a/packages/opencode/test/server/httpapi-cors.test.ts b/packages/opencode/test/server/httpapi-cors.test.ts index 8d7e95dfbf..6c83b00d53 100644 --- a/packages/opencode/test/server/httpapi-cors.test.ts +++ b/packages/opencode/test/server/httpapi-cors.test.ts @@ -1,7 +1,7 @@ import { NodeHttpServer, NodeServices } from "@effect/platform-node" import { Flag } from "@opencode-ai/core/flag/flag" import { describe, expect } from "bun:test" -import { Config, Effect, Layer } from "effect" +import { Config, ConfigProvider, Effect, Layer } from "effect" import { HttpClient, HttpClientRequest, HttpRouter, HttpServer } from "effect/unstable/http" import * as Socket from "effect/unstable/socket/Socket" import { Server } from "../../src/server/server" @@ -13,15 +13,12 @@ import { testEffect } from "../lib/effect" const testStateLayer = Layer.effectDiscard( Effect.gen(function* () { const original = { - OPENCODE_EXPERIMENTAL_HTTPAPI: Flag.OPENCODE_EXPERIMENTAL_HTTPAPI, OPENCODE_SERVER_PASSWORD: Flag.OPENCODE_SERVER_PASSWORD, } - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true Flag.OPENCODE_SERVER_PASSWORD = "secret" yield* Effect.promise(() => resetDatabase()) yield* Effect.addFinalizer(() => Effect.promise(async () => { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original.OPENCODE_EXPERIMENTAL_HTTPAPI Flag.OPENCODE_SERVER_PASSWORD = original.OPENCODE_SERVER_PASSWORD await resetDatabase() }), @@ -63,12 +60,21 @@ describe("HttpApi CORS", () => { }), ) - it.live("adds CORS headers to legacy unauthorized responses", () => + it.live("adds CORS headers to unauthorized responses", () => Effect.gen(function* () { - const response = yield* Effect.promise(async () => - Server.Legacy().app.request("/global/config", { - headers: { origin: "https://app.opencode.ai" }, - }), + const handler = HttpRouter.toWebHandler( + ExperimentalHttpApiServer.createRoutes().pipe( + Layer.provide(ConfigProvider.layer(ConfigProvider.fromUnknown({ OPENCODE_SERVER_PASSWORD: "secret" }))), + ), + { disableLogger: true }, + ).handler + const response = yield* Effect.promise(() => + handler( + new Request(new URL("/global/config", "http://localhost"), { + headers: { origin: "https://app.opencode.ai" }, + }), + ExperimentalHttpApiServer.context, + ), ) expect(response.status).toBe(401) diff --git a/packages/opencode/test/server/httpapi-event.test.ts b/packages/opencode/test/server/httpapi-event.test.ts index 940efed9c3..df716ed096 100644 --- a/packages/opencode/test/server/httpapi-event.test.ts +++ b/packages/opencode/test/server/httpapi-event.test.ts @@ -1,5 +1,4 @@ import { afterEach, describe, expect, test } from "bun:test" -import { Flag } from "@opencode-ai/core/flag/flag" import { Instance } from "../../src/project/instance" import { Server } from "../../src/server/server" import { EventPaths } from "../../src/server/routes/instance/httpapi/event" @@ -9,11 +8,8 @@ import { disposeAllInstances, tmpdir } from "../fixture/fixture" void Log.init({ print: false }) -const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI - -function app(experimental = true) { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = experimental - return experimental ? Server.Default().app : Server.Legacy().app +function app() { + return Server.Default().app } async function readFirstChunk(response: Response) { @@ -36,13 +32,12 @@ async function readFirstEvent(response: Response) { } afterEach(async () => { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original await disposeAllInstances() await resetDatabase() }) -describe("event HttpApi bridge", () => { - test("serves event stream through experimental Effect route", async () => { +describe("event HttpApi", () => { + test("serves event stream", async () => { await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) const response = await app().request(EventPaths.event, { headers: { "x-opencode-directory": tmp.path } }) @@ -54,15 +49,11 @@ describe("event HttpApi bridge", () => { expect(await readFirstEvent(response)).toMatchObject({ type: "server.connected", properties: {} }) }) - test("matches legacy first event frame", async () => { + test("serves the initial server connected event", async () => { await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) const headers = { "x-opencode-directory": tmp.path } - const legacy = await app(false).request(EventPaths.event, { headers }) - const effect = await app(true).request(EventPaths.event, { headers }) + const response = await app().request(EventPaths.event, { headers }) - const legacyEvent = await readFirstEvent(legacy) - const effectEvent = await readFirstEvent(effect) - expect(effectEvent.type).toBe(legacyEvent.type) - expect(effectEvent.properties).toEqual(legacyEvent.properties) + expect(await readFirstEvent(response)).toMatchObject({ type: "server.connected", properties: {} }) }) }) diff --git a/packages/opencode/test/server/httpapi-exercise/assertions.ts b/packages/opencode/test/server/httpapi-exercise/assertions.ts new file mode 100644 index 0000000000..c59acfb366 --- /dev/null +++ b/packages/opencode/test/server/httpapi-exercise/assertions.ts @@ -0,0 +1,64 @@ +import type { CallResult, JsonObject } from "./types" + +export function parse(text: string): unknown { + if (!text) return undefined + try { + return JSON.parse(text) as unknown + } catch { + return text + } +} + +export function looksJson(result: CallResult) { + return result.contentType.includes("application/json") || result.text.startsWith("{") || result.text.startsWith("[") +} + +export function stable(value: unknown): string { + return JSON.stringify(sort(value)) +} + +function sort(value: unknown): unknown { + if (Array.isArray(value)) return value.map(sort) + if (!value || typeof value !== "object") return value + return Object.fromEntries( + Object.entries(value) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, item]) => [key, sort(item)]), + ) +} + +export function array(value: unknown): asserts value is unknown[] { + if (!Array.isArray(value)) throw new Error("expected array") +} + +export function object(value: unknown): asserts value is JsonObject { + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("expected object") +} + +export function boolean(value: unknown): asserts value is boolean { + if (typeof value !== "boolean") throw new Error("expected boolean") +} + +export function isRecord(value: unknown): value is JsonObject { + return !!value && typeof value === "object" && !Array.isArray(value) +} + +export function check(value: boolean, message: string): asserts value { + if (!value) throw new Error(message) +} + +export function message(error: unknown) { + if (error instanceof Error) return error.message + return String(error) +} + +export function pad(value: string, size: number) { + return value.length >= size ? value : value + " ".repeat(size - value.length) +} + +export function indent(value: string) { + return value + .split("\n") + .map((line) => ` ${line}`) + .join("\n") +} diff --git a/packages/opencode/test/server/httpapi-exercise/backend.ts b/packages/opencode/test/server/httpapi-exercise/backend.ts new file mode 100644 index 0000000000..fac5f699c3 --- /dev/null +++ b/packages/opencode/test/server/httpapi-exercise/backend.ts @@ -0,0 +1,144 @@ +import { ConfigProvider, Effect, Layer } from "effect" +import { HttpRouter } from "effect/unstable/http" +import { parse } from "./assertions" +import { runtime, type Runtime } from "./runtime" +import type { ActiveScenario, Backend, BackendApp, CallResult, CaptureMode, SeededContext } from "./types" + +type CallOptions = { + auth?: { + password?: string + username?: string + } +} + +export function call( + backend: Backend, + scenario: ActiveScenario, + ctx: SeededContext, + options: CallOptions = {}, +) { + return Effect.promise(async () => + capture(await app(await runtime(), backend, options).request(toRequest(scenario, ctx)), scenario.capture), + ) +} + +export function callAuthProbe( + backend: Backend, + scenario: ActiveScenario, + credentials: "missing" | "valid" = "missing", +) { + return Effect.promise(async () => { + const controller = new AbortController() + return Promise.race([ + Promise.resolve( + app(await runtime(), backend, { auth: { password: "secret" } }).request( + toAuthProbeRequest(scenario, credentials, controller.signal), + ), + ).then((response) => capture(response, scenario.capture)), + Bun.sleep(1_000).then(() => { + controller.abort("auth probe timed out") + return { + status: 0, + contentType: "", + text: "auth probe timed out", + body: undefined, + timedOut: true, + } + }), + ]) + }) +} + +const appCache: Partial> = {} + +function app(modules: Runtime, backend: Backend, options: CallOptions) { + const username = options.auth?.username + const password = options.auth?.password + const cacheKey = `${backend}:${username ?? ""}:${password ?? ""}` + if (appCache[cacheKey]) return appCache[cacheKey] + + const handler = HttpRouter.toWebHandler( + modules.ExperimentalHttpApiServer.routes.pipe( + Layer.provide( + ConfigProvider.layer( + ConfigProvider.fromUnknown({ OPENCODE_SERVER_PASSWORD: password, OPENCODE_SERVER_USERNAME: username }), + ), + ), + ), + { disableLogger: true }, + ).handler + return (appCache[cacheKey] = { + request(input: string | URL | Request, init?: RequestInit) { + return handler( + input instanceof Request ? input : new Request(new URL(input, "http://localhost"), init), + modules.ExperimentalHttpApiServer.context, + ) + }, + }) +} + +function toRequest(scenario: ActiveScenario, ctx: SeededContext) { + const spec = scenario.request(ctx, ctx.state) + return new Request(new URL(spec.path, "http://localhost"), { + method: scenario.method, + headers: spec.body === undefined ? spec.headers : { "content-type": "application/json", ...spec.headers }, + body: spec.body === undefined ? undefined : JSON.stringify(spec.body), + }) +} + +function toAuthProbeRequest(scenario: ActiveScenario, credentials: "missing" | "valid", signal: AbortSignal) { + const spec = scenario.authProbe ?? { + path: authProbePath(scenario.path), + body: scenario.method === "GET" ? undefined : {}, + } + const headers = { + ...(spec.body === undefined ? {} : { "content-type": "application/json" }), + ...spec.headers, + ...(credentials === "valid" ? { authorization: basic("opencode", "secret") } : {}), + } + return new Request(new URL(spec.path, "http://localhost"), { + method: scenario.method, + headers, + body: spec.body === undefined ? undefined : JSON.stringify(spec.body), + signal, + }) +} + +function basic(username: string, password: string) { + return `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}` +} + +function authProbePath(path: string) { + return path + .replace(/\{([^}]+)\}/g, (_match, key: string) => `auth_${key}`) + .replace(/:([^/]+)/g, (_match, key: string) => `auth_${key}`) +} + +async function capture(response: Response, mode: CaptureMode): Promise { + const text = mode === "stream" ? await captureStream(response) : await response.text() + return { + status: response.status, + contentType: response.headers.get("content-type") ?? "", + text, + body: parse(text), + timedOut: false, + } +} + +async function captureStream(response: Response) { + if (!response.body) return "" + const reader = response.body.getReader() + const read = reader.read().then( + (result) => ({ result }), + (error: unknown) => ({ error }), + ) + const winner = await Promise.race([read, Bun.sleep(1_000).then(() => ({ timeout: true }))]) + if ("timeout" in winner) { + await reader.cancel("timed out waiting for stream chunk").catch(() => undefined) + throw new Error("timed out waiting for stream chunk") + } + if ("error" in winner) throw winner.error + await reader.cancel().catch(() => undefined) + if (winner.result.done) return "" + return new TextDecoder().decode(winner.result.value) +} diff --git a/packages/opencode/test/server/httpapi-exercise/dsl.ts b/packages/opencode/test/server/httpapi-exercise/dsl.ts new file mode 100644 index 0000000000..60d41576f0 --- /dev/null +++ b/packages/opencode/test/server/httpapi-exercise/dsl.ts @@ -0,0 +1,210 @@ +import { Effect } from "effect" +import { looksJson } from "./assertions" +import type { + ActiveScenario, + AuthPolicy, + BuilderState, + CallResult, + Comparison, + Method, + ProjectOptions, + RequestSpec, + ScenarioContext, + SeededContext, + TodoScenario, +} from "./types" + +class ScenarioBuilder { + private readonly state: BuilderState + + constructor(method: Method, path: string, name: string, auth: AuthPolicy) { + this.state = { + method, + path, + name, + project: { git: true }, + // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- The unseeded builder state is intentionally undefined until `.seeded(...)` narrows it. + seed: () => Effect.succeed(undefined as S), + request: (ctx) => ({ path, headers: ctx.headers() }), + authProbe: undefined, + capture: "full", + mutates: false, + reset: true, + auth, + } + } + + global() { + return this.clone({ project: undefined, request: () => ({ path: this.state.path }) }) + } + + inProject(project: ProjectOptions = { git: true }) { + return this.clone({ project }) + } + + withLlm() { + return this.clone({ project: { ...(this.state.project ?? { git: true }), llm: true } }) + } + + at(request: BuilderState["request"]) { + return this.clone({ request }) + } + + probe(authProbe: RequestSpec) { + return this.clone({ authProbe }) + } + + mutating() { + return this.clone({ mutates: true }) + } + + preserveDatabase() { + return this.clone({ reset: false }) + } + + stream() { + return this.clone({ capture: "stream" }) + } + + protected() { + return this.auth("protected") + } + + public() { + return this.auth("public") + } + + publicBypass() { + return this.auth("public-bypass") + } + + ticketBypass() { + return this.auth("ticket-bypass") + } + + private auth(auth: AuthPolicy) { + return this.clone({ auth }) + } + + /** Assert a non-JSON or shape-only response. */ + ok(status = 200, compare: Comparison = "status") { + return this.done(compare, (_ctx, result) => + Effect.sync(() => { + if (result.status !== status) throw new Error(`expected ${status}, got ${result.status}: ${result.text}`) + }), + ) + } + + status( + status = 200, + inspect?: (ctx: SeededContext, result: CallResult) => Effect.Effect, + compare: Comparison = "status", + ) { + return this.done(compare, (ctx, result) => + Effect.gen(function* () { + if (result.status !== status) throw new Error(`expected ${status}, got ${result.status}: ${result.text}`) + if (inspect) yield* inspect(ctx, result) + }), + ) + } + + /** Assert JSON status/content-type plus an optional synchronous body check. */ + json(status = 200, inspect?: (body: unknown, ctx: SeededContext) => void, compare: Comparison = "json") { + return this.jsonEffect(status, inspect ? (body, ctx) => Effect.sync(() => inspect(body, ctx)) : undefined, compare) + } + + /** Assert JSON status/content-type plus optional Effect assertions, e.g. DB side effects. */ + jsonEffect( + status = 200, + inspect?: (body: unknown, ctx: SeededContext) => Effect.Effect, + compare: Comparison = "json", + ) { + return this.done(compare, (ctx, result) => + Effect.gen(function* () { + if (result.status !== status) throw new Error(`expected ${status}, got ${result.status}: ${result.text}`) + if (!looksJson(result)) + throw new Error(`expected JSON response, got ${result.contentType || "no content-type"}`) + if (inspect) yield* inspect(result.body, ctx) + }), + ) + } + + private clone(next: Partial>) { + const builder = new ScenarioBuilder(this.state.method, this.state.path, this.state.name, this.state.auth) + Object.assign(builder.state, this.state, next) + return builder + } + + /** + * Seed typed state before the HTTP request. The returned value becomes `ctx.state` + * for `.at(...)` and assertions, giving stateful route tests type-safe setup. + */ + seeded(seed: (ctx: ScenarioContext) => Effect.Effect) { + const builder = new ScenarioBuilder(this.state.method, this.state.path, this.state.name, this.state.auth) + Object.assign(builder.state, this.state, { seed }) + return builder + } + + private done( + compare: Comparison, + expect: (ctx: SeededContext, result: CallResult) => Effect.Effect, + ): ActiveScenario { + const state = this.state + return { + kind: "active", + method: state.method, + path: state.path, + name: state.name, + project: state.project, + seed: state.seed, + authProbe: state.authProbe, + // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- `.seeded(...)` preserves the paired request/state type inside the builder. + request: (ctx, seeded) => state.request({ ...ctx, state: seeded as S }), + // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- `.seeded(...)` preserves the paired assertion/state type inside the builder. + expect: (ctx, seeded, result) => expect({ ...ctx, state: seeded as S }, result), + compare, + capture: state.capture, + mutates: state.mutates, + reset: state.reset, + auth: state.auth, + } + } +} + +const routes = (auth: AuthPolicy) => ({ + get: (path: string, name: string) => new ScenarioBuilder("GET", path, name, auth), + post: (path: string, name: string) => new ScenarioBuilder("POST", path, name, auth), + put: (path: string, name: string) => new ScenarioBuilder("PUT", path, name, auth), + patch: (path: string, name: string) => new ScenarioBuilder("PATCH", path, name, auth), + delete: (path: string, name: string) => new ScenarioBuilder("DELETE", path, name, auth), +}) + +export const http = { + protected: routes("protected"), + public: routes("public"), + publicBypass: routes("public-bypass"), + ticketBypass: routes("ticket-bypass"), +} + +export const pending = (method: Method, path: string, name: string, reason: string): TodoScenario => ({ + kind: "todo", + method, + path, + name, + reason, +}) + +export function route(template: string, params: Record) { + return Object.entries(params).reduce( + (next, [key, value]) => next.replaceAll(`{${key}}`, value).replaceAll(`:${key}`, value), + template, + ) +} + +export function controlledPtyInput(title: string | undefined) { + return { + command: "/bin/sh", + args: ["-c", "sleep 30"], + ...(title ? { title } : {}), + } +} diff --git a/packages/opencode/test/server/httpapi-exercise/environment.ts b/packages/opencode/test/server/httpapi-exercise/environment.ts new file mode 100644 index 0000000000..9d3eaa0e53 --- /dev/null +++ b/packages/opencode/test/server/httpapi-exercise/environment.ts @@ -0,0 +1,40 @@ +import { Flag } from "@opencode-ai/core/flag/flag" +import { Effect } from "effect" +import path from "path" + +const preserveExerciseGlobalRoot = !!process.env.OPENCODE_HTTPAPI_EXERCISE_GLOBAL +export const exerciseGlobalRoot = + process.env.OPENCODE_HTTPAPI_EXERCISE_GLOBAL ?? + path.join(process.env.TMPDIR ?? "/tmp", `opencode-httpapi-global-${process.pid}`) +process.env.XDG_DATA_HOME = path.join(exerciseGlobalRoot, "data") +process.env.XDG_CONFIG_HOME = path.join(exerciseGlobalRoot, "config") +process.env.XDG_STATE_HOME = path.join(exerciseGlobalRoot, "state") +process.env.XDG_CACHE_HOME = path.join(exerciseGlobalRoot, "cache") +process.env.OPENCODE_DISABLE_SHARE = "true" +export const exerciseConfigDirectory = path.join(exerciseGlobalRoot, "config", "opencode") +export const exerciseDataDirectory = path.join(exerciseGlobalRoot, "data", "opencode") + +const preserveExerciseDatabase = !!process.env.OPENCODE_HTTPAPI_EXERCISE_DB +export const exerciseDatabasePath = + process.env.OPENCODE_HTTPAPI_EXERCISE_DB ?? + path.join(process.env.TMPDIR ?? "/tmp", `opencode-httpapi-exercise-${process.pid}.db`) +process.env.OPENCODE_DB = exerciseDatabasePath +Flag.OPENCODE_DB = exerciseDatabasePath + +export const original = { + OPENCODE_SERVER_PASSWORD: Flag.OPENCODE_SERVER_PASSWORD, + OPENCODE_SERVER_USERNAME: Flag.OPENCODE_SERVER_USERNAME, +} + +export const cleanupExercisePaths = Effect.promise(async () => { + const fs = await import("fs/promises") + if (!preserveExerciseDatabase) { + await Promise.all( + [exerciseDatabasePath, `${exerciseDatabasePath}-wal`, `${exerciseDatabasePath}-shm`].map((file) => + fs.rm(file, { force: true }).catch(() => undefined), + ), + ) + } + if (!preserveExerciseGlobalRoot) + await fs.rm(exerciseGlobalRoot, { recursive: true, force: true }).catch(() => undefined) +}) diff --git a/packages/opencode/test/server/httpapi-exercise/index.ts b/packages/opencode/test/server/httpapi-exercise/index.ts new file mode 100644 index 0000000000..0d6bec2dfe --- /dev/null +++ b/packages/opencode/test/server/httpapi-exercise/index.ts @@ -0,0 +1,1421 @@ +/** + * End-to-end exerciser for the Effect HttpApi routes. + * + * The goal is not to be a normal unit test file. This is a route-coverage harness: + * every public route should have a small scenario that proves the route decodes + * requests, uses the right instance context, mutates storage when expected, and + * returns the expected response shape. + * + * The script intentionally isolates `OPENCODE_DB` before importing modules that touch + * storage. Scenarios may create/delete sessions and reset the database after each run, + * so this must never point at a developer's real session database. + * + * DSL shape: + * - `http.protected.get/post/...` starts a scenario for one OpenAPI route key. + * - `.seeded(...)` creates typed per-scenario state using Effect helpers on `ctx`. + * - `.at(...)` builds the request from that typed state. + * - `.json(...)` / `.jsonEffect(...)` assert response shape and optional side effects. + * - `.mutating()` tells the runner to reset isolated state after destructive routes. + */ +import { Effect } from "effect" +import { OpenApi } from "effect/unstable/httpapi" +import { TestLLMServer } from "../../lib/llm-server" +import path from "path" +import { array, boolean, check, isRecord, message, object, stable } from "./assertions" +import { controlledPtyInput, http, route } from "./dsl" +import { + cleanupExercisePaths, + exerciseConfigDirectory, + exerciseDataDirectory, + exerciseDatabasePath, + exerciseGlobalRoot, +} from "./environment" +import { color, printHeader, printResults } from "./report" +import { coverageResult, parseOptions, routeKey, routeKeys, selectedScenarios } from "./routing" +import { runScenario } from "./runner" +import { runtime } from "./runtime" +import { type Scenario } from "./types" + +void (await import("@opencode-ai/core/util/log")).init({ print: false }) + +function cursor(input: Record) { + return Buffer.from(JSON.stringify(input)).toString("base64url") +} + +const scenarios: Scenario[] = [ + http.protected + .get("/global/health", "global.health") + .global() + .json(200, (body) => { + object(body) + check(body.healthy === true, "server should report healthy") + }), + http.protected + .get("/global/event", "global.event") + .global() + .stream() + .status( + 200, + (_ctx, result) => + Effect.sync(() => { + check(result.contentType.includes("text/event-stream"), "global event should be an SSE stream") + check(result.text.includes("server.connected"), "global event should emit initial connection event") + }), + "status", + ), + http.protected.get("/global/config", "global.config.get").global().json(), + http.protected + .patch("/global/config", "global.config.update") + .global() + .seeded(() => + Effect.promise(() => + Bun.write( + path.join(exerciseConfigDirectory, "opencode.jsonc"), + JSON.stringify({ username: "httpapi-global" }, null, 2), + ), + ), + ) + .at(() => ({ path: "/global/config", body: { username: "httpapi-global" } })) + .jsonEffect( + 200, + (body) => + Effect.gen(function* () { + object(body) + check(body.username === "httpapi-global", "global config update should return patched config") + const text = yield* Effect.promise(() => + Bun.file(path.join(exerciseConfigDirectory, "opencode.jsonc")).text(), + ) + check(text.includes('"username": "httpapi-global"'), "global config update should write isolated config file") + }), + "status", + ), + http.protected + .post("/global/dispose", "global.dispose") + .global() + .mutating() + .json( + 200, + (body) => { + check(body === true, "global dispose should return true") + }, + "status", + ), + http.protected.get("/path", "path.get").json(200, (body, ctx) => { + object(body) + check(body.directory === ctx.directory, "directory should resolve from x-opencode-directory") + check(body.worktree === ctx.directory, "worktree should resolve from x-opencode-directory") + }), + http.protected.get("/vcs", "vcs.get").json(), + http.protected.get("/vcs/status", "vcs.status").json(200, array), + http.protected + .get("/vcs/diff", "vcs.diff") + .at((ctx) => ({ path: "/vcs/diff?mode=git", headers: ctx.headers() })) + .json(200, array), + http.protected.get("/vcs/diff/raw", "vcs.diff.raw").status( + 200, + (_ctx, result) => + Effect.sync(() => { + check(typeof result.text === "string", "raw VCS diff should return text") + }), + "status", + ), + http.protected + .post("/vcs/apply", "vcs.apply") + .inProject({ git: false }) + .at((ctx) => ({ path: "/vcs/apply", headers: ctx.headers(), body: { patch: "" } })) + .status(400, undefined, "status"), + http.protected.get("/command", "command.list").json(200, array, "status"), + http.protected.get("/agent", "app.agents").json(200, array, "status"), + http.protected.get("/skill", "app.skills").json(200, array, "status"), + http.protected.get("/lsp", "lsp.status").json(200, array), + http.protected.get("/formatter", "formatter.status").json(200, array), + http.protected.get("/config", "config.get").json(200, undefined, "status"), + http.protected + .patch("/config", "config.update") + .mutating() + .at((ctx) => ({ path: "/config", headers: ctx.headers(), body: { username: "httpapi-local" } })) + .json( + 200, + (body) => { + object(body) + check(body.username === "httpapi-local", "local config update should return patched config") + }, + "status", + ), + http.protected + .patch("/config", "config.update.invalid") + .at((ctx) => ({ path: "/config", headers: ctx.headers(), body: { username: 1 } })) + .status(400), + http.protected.get("/config/providers", "config.providers").json(), + http.protected.get("/project", "project.list").json(200, array, "status"), + http.protected.get("/project/current", "project.current").json( + 200, + (body, ctx) => { + object(body) + check(body.worktree === ctx.directory, "current project should resolve from scenario directory") + }, + "status", + ), + http.protected + .patch("/project/{projectID}", "project.update") + .mutating() + .seeded((ctx) => ctx.project()) + .at((ctx) => ({ + path: route("/project/{projectID}", { projectID: ctx.state.id }), + headers: ctx.headers(), + body: { name: "HTTP API Project", commands: { start: "bun --version" } }, + })) + .json( + 200, + (body) => { + object(body) + check(body.name === "HTTP API Project", "project update should return patched name") + check( + isRecord(body.commands) && body.commands.start === "bun --version", + "project update should return patched command", + ) + }, + "status", + ), + http.protected + .post("/project/git/init", "project.initGit") + .mutating() + .inProject({ git: false }) + .json( + 200, + (body, ctx) => { + object(body) + check(body.worktree === ctx.directory, "git init should return current project") + check(body.vcs === "git", "git init should mark the project as git-backed") + }, + "status", + ), + http.protected.get("/provider", "provider.list").json(), + http.protected.get("/provider/auth", "provider.auth").json(), + http.protected + .post("/provider/{providerID}/oauth/authorize", "provider.oauth.authorize") + .at((ctx) => ({ + path: route("/provider/{providerID}/oauth/authorize", { providerID: "httpapi" }), + headers: ctx.headers(), + body: { method: "bad" }, + })) + .status(400), + http.protected + .post("/provider/{providerID}/oauth/callback", "provider.oauth.callback") + .at((ctx) => ({ + path: route("/provider/{providerID}/oauth/callback", { providerID: "httpapi" }), + headers: ctx.headers(), + body: { method: "bad" }, + })) + .status(400), + http.protected.get("/permission", "permission.list").json(200, array), + http.protected + .post("/permission/{requestID}/reply", "permission.reply.invalid") + .at((ctx) => ({ + path: route("/permission/{requestID}/reply", { requestID: "per_httpapi" }), + headers: ctx.headers(), + body: { reply: "bad" }, + })) + .status(400), + http.protected + .post("/permission/{requestID}/reply", "permission.reply") + .at((ctx) => ({ + path: route("/permission/{requestID}/reply", { requestID: "per_httpapi" }), + headers: ctx.headers(), + body: { reply: "once" }, + })) + .json(200, (body) => { + check(body === true, "permission reply should return true even when request is no longer pending") + }), + http.protected.get("/question", "question.list").json(200, array), + http.protected + .post("/question/{requestID}/reply", "question.reply.invalid") + .at((ctx) => ({ + path: route("/question/{requestID}/reply", { requestID: "que_httpapi_reply" }), + headers: ctx.headers(), + body: { answers: "Yes" }, + })) + .status(400), + http.protected + .post("/question/{requestID}/reply", "question.reply") + .at((ctx) => ({ + path: route("/question/{requestID}/reply", { requestID: "que_httpapi_reply" }), + headers: ctx.headers(), + body: { answers: [["Yes"]] }, + })) + .json(200, (body) => { + check(body === true, "question reply should return true even when request is no longer pending") + }), + http.protected + .post("/question/{requestID}/reject", "question.reject") + .at((ctx) => ({ + path: route("/question/{requestID}/reject", { requestID: "que_httpapi_reject" }), + headers: ctx.headers(), + })) + .json(200, (body) => { + check(body === true, "question reject should return true even when request is no longer pending") + }), + http.protected + .get("/file", "file.list") + .seeded((ctx) => ctx.file("hello.txt", "hello\n")) + .at((ctx) => ({ path: `/file?${new URLSearchParams({ path: "." })}`, headers: ctx.headers() })) + .json(200, array), + http.protected + .get("/file/content", "file.read") + .seeded((ctx) => ctx.file("hello.txt", "hello\n")) + .at((ctx) => ({ path: `/file/content?${new URLSearchParams({ path: "hello.txt" })}`, headers: ctx.headers() })) + .json(200, (body) => { + object(body) + check(body.content === "hello", `content should match seeded file: ${JSON.stringify(body)}`) + }), + http.protected + .get("/file/content", "file.read.missing") + .at((ctx) => ({ path: `/file/content?${new URLSearchParams({ path: "missing.txt" })}`, headers: ctx.headers() })) + .json(200, (body) => { + object(body) + check(body.type === "text" && body.content === "", "missing file content should return an empty text result") + }), + http.protected.get("/file/status", "file.status").json(200, array), + http.protected + .get("/find", "find.text") + .seeded((ctx) => ctx.file("hello.txt", "hello\n")) + .at((ctx) => ({ path: `/find?${new URLSearchParams({ pattern: "hello" })}`, headers: ctx.headers() })) + .json(200, array), + http.protected + .get("/find/file", "find.files") + .seeded((ctx) => ctx.file("hello.txt", "hello\n")) + .at((ctx) => ({ + path: `/find/file?${new URLSearchParams({ query: "hello", dirs: "false" })}`, + headers: ctx.headers(), + })) + .json(200, array), + http.protected + .get("/find/symbol", "find.symbols") + .seeded((ctx) => ctx.file("hello.ts", "export const hello = 1\n")) + .at((ctx) => ({ path: `/find/symbol?${new URLSearchParams({ query: "hello" })}`, headers: ctx.headers() })) + .json(200, array), + http.protected + .get("/event", "event.stream") + .stream() + .status( + 200, + (_ctx, result) => + Effect.sync(() => { + check(result.contentType.includes("text/event-stream"), "event should be an SSE stream") + check(result.text.includes("server.connected"), "event should emit initial connection event") + }), + "status", + ), + http.protected.get("/mcp", "mcp.status").json(), + http.protected + .post("/mcp", "mcp.add") + .mutating() + .at((ctx) => ({ + path: "/mcp", + headers: ctx.headers(), + body: { name: "httpapi-disabled", config: { type: "local", command: ["bun", "--version"], enabled: false } }, + })) + .json( + 200, + (body) => { + object(body) + object(body["httpapi-disabled"]) + check(body["httpapi-disabled"].status === "disabled", "disabled MCP server should be added without spawning") + }, + "status", + ), + http.protected + .post("/mcp", "mcp.add.invalid") + .at((ctx) => ({ + path: "/mcp", + headers: ctx.headers(), + body: { name: "httpapi-invalid", config: { type: "invalid" } }, + })) + .status(400), + http.protected + .post("/mcp/{name}/auth", "mcp.auth.start") + .at((ctx) => ({ path: route("/mcp/{name}/auth", { name: "httpapi-missing" }), headers: ctx.headers() })) + .json( + 400, + (body) => { + object(body) + check(typeof body.error === "string", "unsupported MCP OAuth response should include error") + }, + "status", + ), + http.protected + .delete("/mcp/{name}/auth", "mcp.auth.remove") + .mutating() + .at((ctx) => ({ path: route("/mcp/{name}/auth", { name: "httpapi-missing" }), headers: ctx.headers() })) + .json(200, (body) => { + object(body) + check(body.success === true, "MCP auth removal should return success") + }), + http.protected + .post("/mcp/{name}/auth/authenticate", "mcp.auth.authenticate") + .at((ctx) => ({ + path: route("/mcp/{name}/auth/authenticate", { name: "httpapi-missing" }), + headers: ctx.headers(), + })) + .json( + 400, + (body) => { + object(body) + check(typeof body.error === "string", "unsupported MCP OAuth authenticate response should include error") + }, + "status", + ), + http.protected + .post("/mcp/{name}/auth/callback", "mcp.auth.callback") + .at((ctx) => ({ + path: route("/mcp/{name}/auth/callback", { name: "httpapi-missing" }), + headers: ctx.headers(), + body: { code: 1 }, + })) + .status(400), + http.protected + .post("/mcp/{name}/connect", "mcp.connect") + .mutating() + .at((ctx) => ({ path: route("/mcp/{name}/connect", { name: "httpapi-missing" }), headers: ctx.headers() })) + .json(200, (body) => { + check(body === true, "missing MCP connect should remain a no-op success") + }), + http.protected + .post("/mcp/{name}/disconnect", "mcp.disconnect") + .mutating() + .at((ctx) => ({ path: route("/mcp/{name}/disconnect", { name: "httpapi-missing" }), headers: ctx.headers() })) + .json(200, (body) => { + check(body === true, "missing MCP disconnect should remain a no-op success") + }), + http.protected.get("/pty/shells", "pty.shells").json(200, array), + http.protected.get("/pty", "pty.list").json(200, array), + http.protected + .post("/pty", "pty.create") + .mutating() + .at((ctx) => ({ path: "/pty", headers: ctx.headers(), body: controlledPtyInput("HTTP API PTY") })) + .json( + 200, + (body, ctx) => { + object(body) + check(body.title === "HTTP API PTY", "PTY create should return requested title") + check(body.command === "/bin/sh", "PTY create should use controlled shell command") + check(body.cwd === ctx.directory, "PTY create should default cwd to scenario directory") + }, + "status", + ), + http.protected + .post("/pty", "pty.create.invalid") + .at((ctx) => ({ path: "/pty", headers: ctx.headers(), body: { command: 1 } })) + .status(400), + http.protected + .post("/pty/{ptyID}/connect-token", "pty.connectToken.invalid") + .at((ctx) => ({ + path: route("/pty/{ptyID}/connect-token", { ptyID: "pty_httpapi_missing" }), + headers: ctx.headers(), + })) + .status(403, undefined, "status"), + http.protected + .get("/pty/{ptyID}", "pty.get") + .at((ctx) => ({ path: route("/pty/{ptyID}", { ptyID: "pty_httpapi_missing" }), headers: ctx.headers() })) + .status(404), + http.protected + .put("/pty/{ptyID}", "pty.update") + .mutating() + .at((ctx) => ({ + path: route("/pty/{ptyID}", { ptyID: "pty_httpapi_missing" }), + headers: ctx.headers(), + body: { size: { rows: 0, cols: 0 } }, + })) + .status(400), + http.protected + .delete("/pty/{ptyID}", "pty.remove") + .mutating() + .at((ctx) => ({ path: route("/pty/{ptyID}", { ptyID: "pty_httpapi_missing" }), headers: ctx.headers() })) + .json(200, (body) => { + check(body === true, "PTY remove should return true") + }), + http.protected + .get("/pty/{ptyID}/connect", "pty.connect") + .at((ctx) => ({ path: route("/pty/{ptyID}/connect", { ptyID: "pty_httpapi_missing" }), headers: ctx.headers() })) + .status(404, undefined, "none"), + http.protected.get("/experimental/console", "experimental.console.get").json(), + http.protected.get("/experimental/console/orgs", "experimental.console.listOrgs").json(), + http.protected + .post("/experimental/console/switch", "experimental.console.switchOrg") + .at((ctx) => ({ + path: "/experimental/console/switch", + headers: ctx.headers(), + body: { accountID: "httpapi-account", orgID: "httpapi-org" }, + })) + .status(400, undefined, "none"), + http.protected.get("/experimental/workspace/adapter", "experimental.workspace.adapter.list").json(200, array), + http.protected.get("/experimental/workspace", "experimental.workspace.list").json(200, array), + http.protected.get("/experimental/workspace/status", "experimental.workspace.status").json(200, array), + http.protected + .post("/experimental/workspace", "experimental.workspace.create") + .at((ctx) => ({ path: "/experimental/workspace", headers: ctx.headers(), body: {} })) + .status(400), + http.protected + .post("/experimental/workspace/sync-list", "experimental.workspace.syncList") + .status(204, undefined, "status"), + http.protected + .delete("/experimental/workspace/{id}", "experimental.workspace.remove") + .mutating() + .at((ctx) => ({ + path: route("/experimental/workspace/{id}", { id: "wrk_httpapi_missing" }), + headers: ctx.headers(), + })) + .status(200), + http.protected + .post("/experimental/workspace/warp", "experimental.workspace.warp") + .at((ctx) => ({ + path: "/experimental/workspace/warp", + headers: ctx.headers(), + body: {}, + })) + .status(400), + http.protected + .get("/experimental/tool", "tool.list") + .at((ctx) => ({ + path: `/experimental/tool?${new URLSearchParams({ provider: "opencode", model: "test" })}`, + headers: ctx.headers(), + })) + .json(200, array, "status"), + http.protected.get("/experimental/tool/ids", "tool.ids").json(200, array), + http.protected.get("/experimental/worktree", "worktree.list").json(200, array), + http.protected + .post("/experimental/worktree", "worktree.create") + .mutating() + .at((ctx) => ({ path: "/experimental/worktree", headers: ctx.headers(), body: { name: "api-dsl" } })) + .jsonEffect( + 200, + (body, ctx) => + Effect.gen(function* () { + object(body) + check(typeof body.directory === "string", "created worktree should include directory") + yield* ctx.worktreeRemove(body.directory) + }), + "status", + ), + http.protected + .post("/experimental/worktree", "worktree.create.invalid") + .at((ctx) => ({ path: "/experimental/worktree", headers: ctx.headers(), body: { name: 1 } })) + .status(400), + http.protected + .delete("/experimental/worktree", "worktree.remove") + .mutating() + .seeded((ctx) => ctx.worktree({ name: "api-remove" })) + .at((ctx) => ({ path: "/experimental/worktree", headers: ctx.headers(), body: { directory: ctx.state.directory } })) + .json(200, (body) => { + check(body === true, "worktree remove should return true") + }), + http.protected + .post("/experimental/worktree/reset", "worktree.reset") + .mutating() + .seeded((ctx) => ctx.worktree({ name: "api-reset" })) + .at((ctx) => ({ + path: "/experimental/worktree/reset", + headers: ctx.headers(), + body: { directory: ctx.state.directory }, + })) + .jsonEffect(200, (body, ctx) => + Effect.gen(function* () { + check(body === true, "worktree reset should return true") + yield* ctx.worktreeRemove(ctx.state.directory) + }), + ), + http.protected + .get("/experimental/session", "experimental.session.list") + .at((ctx) => ({ path: "/experimental/session?roots=false&archived=false", headers: ctx.headers() })) + .json(200, array), + http.protected.get("/experimental/resource", "experimental.resource.list").json(), + http.protected + .post("/sync/history", "sync.history.list") + .at((ctx) => ({ path: "/sync/history", headers: ctx.headers(), body: {} })) + .json(200, array), + http.protected + .post("/sync/replay", "sync.replay") + .at((ctx) => ({ path: "/sync/replay", headers: ctx.headers(), body: { directory: ctx.directory, events: [] } })) + .status(400), + http.protected + .post("/sync/steal", "sync.steal.invalid") + .at((ctx) => ({ path: "/sync/steal", headers: ctx.headers(), body: {} })) + .status(400, undefined, "status"), + http.protected + .post("/sync/start", "sync.start") + .mutating() + .preserveDatabase() + .json(200, (body) => { + check(body === true, "sync start should return true when no workspace sessions exist") + }), + http.protected + .post("/instance/dispose", "instance.dispose") + .mutating() + .json(200, (body) => { + check(body === true, "instance dispose should return true") + }), + http.protected + .post("/log", "app.log") + .global() + .at(() => ({ path: "/log", body: { service: "httpapi-exercise", level: "info", message: "route coverage" } })) + .json(200, (body) => { + check(body === true, "log route should return true") + }), + http.protected + .put("/auth/{providerID}", "auth.set") + .global() + .at(() => ({ path: route("/auth/{providerID}", { providerID: "test" }), body: { type: "api", key: "test-key" } })) + .jsonEffect(200, (body) => + Effect.gen(function* () { + check(body === true, "auth set should return true") + const auth = yield* Effect.promise(() => Bun.file(path.join(exerciseDataDirectory, "auth.json")).json()) + object(auth) + check(isRecord(auth.test) && auth.test.key === "test-key", "auth set should write isolated auth file") + }), + ), + http.protected + .delete("/auth/{providerID}", "auth.remove") + .global() + .seeded(() => + Effect.promise(() => + Bun.write( + path.join(exerciseDataDirectory, "auth.json"), + JSON.stringify({ test: { type: "api", key: "remove-me" } }), + ), + ), + ) + .at(() => ({ path: route("/auth/{providerID}", { providerID: "test" }) })) + .jsonEffect(200, (body) => + Effect.gen(function* () { + check(body === true, "auth remove should return true") + const auth = yield* Effect.promise(() => Bun.file(path.join(exerciseDataDirectory, "auth.json")).json()) + object(auth) + check(auth.test === undefined, "auth remove should delete provider from isolated auth file") + }), + ), + http.protected + .get("/api/session", "v2.session.list") + .at((ctx) => ({ path: "/api/session?roots=true", headers: ctx.headers() })) + .json( + 200, + (body) => { + object(body) + array(body.items) + object(body.cursor) + }, + "none", + ), + http.protected + .get("/api/session", "v2.session.list.filters") + .at((ctx) => ({ + path: `/api/session?${new URLSearchParams({ + limit: "2", + order: "asc", + path: ".", + roots: "false", + start: "0", + search: "missing", + directory: ctx.directory ?? "", + })}`, + headers: ctx.headers(), + })) + .json( + 200, + (body) => { + object(body) + array(body.items) + object(body.cursor) + }, + "none", + ), + http.protected + .get("/api/session", "v2.session.list.cursor") + .at((ctx) => ({ + path: `/api/session?${new URLSearchParams({ + limit: "2", + directory: ctx.directory ?? "", + cursor: cursor({ + id: "ses_httpapi_missing", + time: 0, + order: "desc", + direction: "next", + directory: ctx.directory, + }), + })}`, + headers: ctx.headers(), + })) + .json( + 200, + (body) => { + object(body) + array(body.items) + object(body.cursor) + }, + "none", + ), + http.protected + .get("/api/session", "v2.session.list.cursor.invalid") + .at((ctx) => ({ + path: `/api/session?${new URLSearchParams({ + cursor: cursor({ id: "ses_httpapi_missing", time: 0, order: "desc", direction: "next" }), + search: "not-allowed-with-cursor", + })}`, + headers: ctx.headers(), + })) + .status(400, undefined, "none"), + http.protected + .get("/api/session/{sessionID}/context", "v2.session.context") + .at((ctx) => ({ + path: route("/api/session/{sessionID}/context", { sessionID: "ses_httpapi_missing" }), + headers: ctx.headers(), + })) + .json(200, array, "none"), + http.protected + .get("/api/session/{sessionID}/message", "v2.session.messages") + .at((ctx) => ({ + path: route("/api/session/{sessionID}/message", { sessionID: "ses_httpapi_missing" }), + headers: ctx.headers(), + })) + .json( + 200, + (body) => { + object(body) + array(body.items) + object(body.cursor) + }, + "none", + ), + http.protected + .get("/api/session/{sessionID}/message", "v2.session.messages.params") + .at((ctx) => ({ + path: `${route("/api/session/{sessionID}/message", { sessionID: "ses_httpapi_missing" })}?${new URLSearchParams({ + limit: "2", + order: "asc", + })}`, + headers: ctx.headers(), + })) + .json( + 200, + (body) => { + object(body) + array(body.items) + object(body.cursor) + }, + "none", + ), + http.protected + .get("/api/session/{sessionID}/message", "v2.session.messages.cursor") + .at((ctx) => ({ + path: `${route("/api/session/{sessionID}/message", { sessionID: "ses_httpapi_missing" })}?${new URLSearchParams({ + limit: "2", + directory: ctx.directory ?? "", + cursor: cursor({ id: "msg_httpapi_missing", time: 0, order: "desc", direction: "next" }), + })}`, + headers: ctx.headers(), + })) + .json( + 200, + (body) => { + object(body) + array(body.items) + object(body.cursor) + }, + "none", + ), + http.protected + .get("/api/session/{sessionID}/message", "v2.session.messages.cursor.invalid") + .at((ctx) => ({ + path: `${route("/api/session/{sessionID}/message", { sessionID: "ses_httpapi_missing" })}?${new URLSearchParams({ + cursor: cursor({ id: "msg_httpapi_missing", time: 0, order: "desc", direction: "next" }), + order: "asc", + })}`, + headers: ctx.headers(), + })) + .status(400, undefined, "none"), + http.protected + .post("/api/session/{sessionID}/prompt", "v2.session.prompt.invalid") + .at((ctx) => ({ + path: route("/api/session/{sessionID}/prompt", { sessionID: "ses_httpapi_missing" }), + headers: ctx.headers(), + body: {}, + })) + .status(400, undefined, "none"), + http.protected + .post("/api/session/{sessionID}/compact", "v2.session.compact") + .at((ctx) => ({ + path: route("/api/session/{sessionID}/compact", { sessionID: "ses_httpapi_missing" }), + headers: ctx.headers(), + })) + .status(204, undefined, "none"), + http.protected + .post("/api/session/{sessionID}/wait", "v2.session.wait") + .at((ctx) => ({ + path: route("/api/session/{sessionID}/wait", { sessionID: "ses_httpapi_missing" }), + headers: ctx.headers(), + })) + .status(204, undefined, "none"), + http.protected + .get("/session", "session.list") + .seeded((ctx) => ctx.session({ title: "List me" })) + .at((ctx) => ({ path: "/session?roots=true", headers: ctx.headers() })) + .json(200, (body, ctx) => { + array(body) + check( + body.some((item) => isRecord(item) && item.id === ctx.state.id && item.title === "List me"), + "seeded session should be listed", + ) + }), + http.protected + .get("/session/status", "session.status") + .seeded((ctx) => ctx.session({ title: "Status session" })) + .json(200, object), + http.protected + .post("/session", "session.create") + .mutating() + .at((ctx) => ({ path: "/session", headers: ctx.headers(), body: { title: "Created session" } })) + .json( + 200, + (body, ctx) => { + object(body) + check(body.title === "Created session", "created session should use requested title") + check(body.directory === ctx.directory, "created session should use scenario directory") + }, + "status", + ), + http.protected + .get("/session/{sessionID}", "session.get") + .seeded((ctx) => ctx.session({ title: "Get me" })) + .at((ctx) => ({ path: route("/session/{sessionID}", { sessionID: ctx.state.id }), headers: ctx.headers() })) + .json(200, (body, ctx) => { + object(body) + check(body.id === ctx.state.id, "should return requested session") + check(body.title === "Get me", "should preserve seeded title") + }), + http.protected + .get("/session/{sessionID}", "session.get.missing") + .at((ctx) => ({ + path: route("/session/{sessionID}", { sessionID: "ses_httpapi_missing" }), + headers: ctx.headers(), + })) + .status(404), + http.protected + .patch("/session/{sessionID}", "session.update") + .mutating() + .seeded((ctx) => ctx.session({ title: "Before rename" })) + .at((ctx) => ({ + path: route("/session/{sessionID}", { sessionID: ctx.state.id }), + headers: ctx.headers(), + body: { title: "After rename" }, + })) + .json( + 200, + (body) => { + object(body) + check(body.title === "After rename", "updated session should use new title") + }, + "status", + ), + http.protected + .patch("/session/{sessionID}", "session.update.invalid") + .mutating() + .at((ctx) => ({ + path: route("/session/{sessionID}", { sessionID: "ses_httpapi_missing" }), + headers: ctx.headers(), + body: { title: 1 }, + })) + .status(400), + http.protected + .delete("/session/{sessionID}", "session.delete") + .mutating() + .seeded((ctx) => ctx.session({ title: "Delete me" })) + .at((ctx) => ({ path: route("/session/{sessionID}", { sessionID: ctx.state.id }), headers: ctx.headers() })) + .jsonEffect(200, (body, ctx) => + Effect.gen(function* () { + check(body === true, "delete should return true") + check((yield* ctx.sessionGet(ctx.state.id)) === undefined, "deleted session should not remain in storage") + }), + ), + http.protected + .get("/session/{sessionID}/children", "session.children") + .seeded((ctx) => + Effect.gen(function* () { + const parent = yield* ctx.session({ title: "Parent" }) + const child = yield* ctx.session({ title: "Child", parentID: parent.id }) + return { parent, child } + }), + ) + .at((ctx) => ({ + path: route("/session/{sessionID}/children", { sessionID: ctx.state.parent.id }), + headers: ctx.headers(), + })) + .json(200, (body, ctx) => { + array(body) + check( + body.some((item) => isRecord(item) && item.id === ctx.state.child.id && item.parentID === ctx.state.parent.id), + "children should include seeded child", + ) + }), + http.protected + .get("/session/{sessionID}/todo", "session.todo") + .seeded((ctx) => + Effect.gen(function* () { + const session = yield* ctx.session({ title: "Todo session" }) + const todos = [{ content: "cover session todo", status: "pending", priority: "high" }] + yield* ctx.todos(session.id, todos) + return { session, todos } + }), + ) + .at((ctx) => ({ + path: route("/session/{sessionID}/todo", { sessionID: ctx.state.session.id }), + headers: ctx.headers(), + })) + .json(200, (body, ctx) => { + check(stable(body) === stable(ctx.state.todos), "todos should match seeded state") + }), + http.protected + .get("/session/{sessionID}/diff", "session.diff") + .seeded((ctx) => ctx.session({ title: "Diff session" })) + .at((ctx) => ({ path: route("/session/{sessionID}/diff", { sessionID: ctx.state.id }), headers: ctx.headers() })) + .json(200, array), + http.protected + .get("/session/{sessionID}/message", "session.messages") + .seeded((ctx) => ctx.session({ title: "Messages session" })) + .at((ctx) => ({ path: route("/session/{sessionID}/message", { sessionID: ctx.state.id }), headers: ctx.headers() })) + .json(200, (body) => { + array(body) + check(body.length === 0, "new session should have no messages") + }), + http.protected + .get("/session/{sessionID}/message/{messageID}", "session.message") + .seeded((ctx) => + Effect.gen(function* () { + const session = yield* ctx.session({ title: "Message get session" }) + const message = yield* ctx.message(session.id, { text: "read me" }) + return { session, message } + }), + ) + .at((ctx) => ({ + path: route("/session/{sessionID}/message/{messageID}", { + sessionID: ctx.state.session.id, + messageID: ctx.state.message.info.id, + }), + headers: ctx.headers(), + })) + .json(200, (body, ctx) => { + object(body) + check(isRecord(body.info) && body.info.id === ctx.state.message.info.id, "should return requested message") + check( + Array.isArray(body.parts) && body.parts.some((part) => isRecord(part) && part.id === ctx.state.message.part.id), + "message should include seeded part", + ) + }), + http.protected + .patch("/session/{sessionID}/message/{messageID}/part/{partID}", "part.update") + .mutating() + .seeded((ctx) => + Effect.gen(function* () { + const session = yield* ctx.session({ title: "Part update session" }) + const message = yield* ctx.message(session.id, { text: "before" }) + return { session, message } + }), + ) + .at((ctx) => ({ + path: route("/session/{sessionID}/message/{messageID}/part/{partID}", { + sessionID: ctx.state.session.id, + messageID: ctx.state.message.info.id, + partID: ctx.state.message.part.id, + }), + headers: ctx.headers(), + body: { ...ctx.state.message.part, text: "after" }, + })) + .json( + 200, + (body) => { + object(body) + check(body.type === "text" && body.text === "after", "updated part should be returned") + }, + "status", + ), + http.protected + .delete("/session/{sessionID}/message/{messageID}/part/{partID}", "part.delete") + .mutating() + .seeded((ctx) => + Effect.gen(function* () { + const session = yield* ctx.session({ title: "Part delete session" }) + const message = yield* ctx.message(session.id, { text: "delete part" }) + return { session, message } + }), + ) + .at((ctx) => ({ + path: route("/session/{sessionID}/message/{messageID}/part/{partID}", { + sessionID: ctx.state.session.id, + messageID: ctx.state.message.info.id, + partID: ctx.state.message.part.id, + }), + headers: ctx.headers(), + })) + .jsonEffect(200, (body, ctx) => + Effect.gen(function* () { + check(body === true, "delete part should return true") + const messages = yield* ctx.messages(ctx.state.session.id) + check(messages[0]?.parts.length === 0, "deleted part should not remain on message") + }), + ), + http.protected + .delete("/session/{sessionID}/message/{messageID}", "session.deleteMessage") + .mutating() + .seeded((ctx) => + Effect.gen(function* () { + const session = yield* ctx.session({ title: "Message delete session" }) + const message = yield* ctx.message(session.id, { text: "delete message" }) + return { session, message } + }), + ) + .at((ctx) => ({ + path: route("/session/{sessionID}/message/{messageID}", { + sessionID: ctx.state.session.id, + messageID: ctx.state.message.info.id, + }), + headers: ctx.headers(), + })) + .jsonEffect(200, (body, ctx) => + Effect.gen(function* () { + check(body === true, "delete message should return true") + check((yield* ctx.messages(ctx.state.session.id)).length === 0, "deleted message should not remain") + }), + ), + http.protected + .post("/session/{sessionID}/fork", "session.fork") + .mutating() + .seeded((ctx) => ctx.session({ title: "Fork source" })) + .at((ctx) => ({ + path: route("/session/{sessionID}/fork", { sessionID: ctx.state.id }), + headers: ctx.headers(), + body: {}, + })) + .json( + 200, + (body) => { + object(body) + check(typeof body.id === "string", "fork should return a session") + }, + "status", + ), + http.protected + .post("/session/{sessionID}/abort", "session.abort") + .mutating() + .seeded((ctx) => ctx.session({ title: "Abort session" })) + .at((ctx) => ({ path: route("/session/{sessionID}/abort", { sessionID: ctx.state.id }), headers: ctx.headers() })) + .json(200, (body) => { + check(body === true, "abort should return true") + }), + http.protected + .post("/session/{sessionID}/abort", "session.abort.missing") + .at((ctx) => ({ + path: route("/session/{sessionID}/abort", { sessionID: "ses_httpapi_missing" }), + headers: ctx.headers(), + })) + .json(200, (body) => { + check(body === true, "missing session abort should remain a no-op success") + }), + http.protected + .post("/session/{sessionID}/init", "session.init") + .preserveDatabase() + .withLlm() + .seeded((ctx) => + Effect.gen(function* () { + const session = yield* ctx.session({ title: "Init session" }) + const message = yield* ctx.message(session.id, { text: "initialize" }) + yield* ctx.llmText("initialized") + yield* ctx.llmText("initialized") + return { session, message } + }), + ) + .at((ctx) => ({ + path: route("/session/{sessionID}/init", { sessionID: ctx.state.session.id }), + headers: ctx.headers(), + body: { providerID: "test", modelID: "test-model", messageID: ctx.state.message.info.id }, + })) + .jsonEffect(200, (body, ctx) => + Effect.gen(function* () { + check(body === true, "init should return true") + yield* ctx.llmWait(1) + }), + ), + http.protected + .post("/session/{sessionID}/message", "session.prompt") + .preserveDatabase() + .withLlm() + .seeded((ctx) => + Effect.gen(function* () { + const session = yield* ctx.session({ title: "LLM prompt session" }) + yield* ctx.llmText("fake assistant") + yield* ctx.llmText("fake assistant") + return session + }), + ) + .at((ctx) => ({ + path: route("/session/{sessionID}/message", { sessionID: ctx.state.id }), + headers: ctx.headers(), + body: { + agent: "build", + model: { providerID: "test", modelID: "test-model" }, + parts: [{ type: "text", text: "hello llm" }], + }, + })) + .jsonEffect( + 200, + (body, ctx) => + Effect.gen(function* () { + object(body) + check(isRecord(body.info) && body.info.role === "assistant", "prompt should return assistant message") + check( + Array.isArray(body.parts) && body.parts.some((part) => isRecord(part) && part.text === "fake assistant"), + "assistant message should use fake LLM text", + ) + yield* ctx.llmWait(1) + }), + "status", + ), + http.protected + .post("/session/{sessionID}/prompt_async", "session.prompt_async") + .preserveDatabase() + .withLlm() + .seeded((ctx) => + Effect.gen(function* () { + const session = yield* ctx.session({ title: "Async prompt session" }) + yield* ctx.llmText("fake async assistant") + yield* ctx.llmText("fake async assistant") + return session + }), + ) + .at((ctx) => ({ + path: route("/session/{sessionID}/prompt_async", { sessionID: ctx.state.id }), + headers: ctx.headers(), + body: { + agent: "build", + model: { providerID: "test", modelID: "test-model" }, + parts: [{ type: "text", text: "hello async" }], + }, + })) + .status(204, (ctx) => + Effect.gen(function* () { + yield* ctx.llmWait(1) + }), + ), + http.protected + .post("/session/{sessionID}/command", "session.command") + .preserveDatabase() + .withLlm() + .seeded((ctx) => + Effect.gen(function* () { + const session = yield* ctx.session({ title: "Command session" }) + yield* ctx.llmText("command done") + yield* ctx.llmText("command done") + return session + }), + ) + .at((ctx) => ({ + path: route("/session/{sessionID}/command", { sessionID: ctx.state.id }), + headers: ctx.headers(), + body: { command: "init", arguments: "", model: "test/test-model" }, + })) + .jsonEffect( + 200, + (body, ctx) => + Effect.gen(function* () { + object(body) + check(isRecord(body.info) && body.info.role === "assistant", "command should return assistant message") + yield* ctx.llmWait(1) + }), + "status", + ), + http.protected + .post("/session/{sessionID}/shell", "session.shell") + .preserveDatabase() + .mutating() + .seeded((ctx) => ctx.session({ title: "Shell session" })) + .at((ctx) => ({ + path: route("/session/{sessionID}/shell", { sessionID: ctx.state.id }), + headers: ctx.headers(), + body: { agent: "build", model: { providerID: "test", modelID: "test-model" }, command: "printf shell-ok" }, + })) + .json( + 200, + (body) => { + object(body) + check(isRecord(body.info) && body.info.role === "assistant", "shell should return assistant message") + check( + Array.isArray(body.parts) && body.parts.some((part) => isRecord(part) && part.type === "tool"), + "shell should return a tool part", + ) + }, + "status", + ), + http.protected + .post("/session/{sessionID}/summarize", "session.summarize") + .preserveDatabase() + .withLlm() + .seeded((ctx) => + Effect.gen(function* () { + const session = yield* ctx.session({ title: "Summarize session" }) + yield* ctx.message(session.id, { text: "summarize this work" }) + const summary = [ + "## Goal", + "- Exercise session summarize.", + "", + "## Constraints & Preferences", + "- Use fake LLM.", + "", + "## Progress", + "### Done", + "- Summary generated.", + "", + "### In Progress", + "- (none)", + "", + "### Blocked", + "- (none)", + "", + "## Key Decisions", + "- Keep route local.", + "", + "## Next Steps", + "- (none)", + "", + "## Critical Context", + "- Test fixture.", + "", + "## Relevant Files", + "- test/server/httpapi-exercise/index.ts: scenario", + ].join("\n") + yield* ctx.llmText(summary) + yield* ctx.llmText(summary) + return session + }), + ) + .at((ctx) => ({ + path: route("/session/{sessionID}/summarize", { sessionID: ctx.state.id }), + headers: ctx.headers(), + body: { providerID: "test", modelID: "test-model", auto: false }, + })) + .jsonEffect( + 200, + (body, ctx) => + Effect.gen(function* () { + check(body === true, "summarize should return true") + const messages = yield* ctx.messages(ctx.state.id) + check( + messages.some((message) => message.info.role === "assistant" && message.info.summary === true), + "summarize should create a summary assistant message", + ) + yield* ctx.llmWait(1) + }), + "status", + ), + http.protected + .post("/session/{sessionID}/revert", "session.revert") + .mutating() + .seeded((ctx) => + Effect.gen(function* () { + const session = yield* ctx.session({ title: "Revert session" }) + const message = yield* ctx.message(session.id, { text: "revert me" }) + return { session, message } + }), + ) + .at((ctx) => ({ + path: route("/session/{sessionID}/revert", { sessionID: ctx.state.session.id }), + headers: ctx.headers(), + body: { messageID: ctx.state.message.info.id }, + })) + .json( + 200, + (body, ctx) => { + object(body) + check(body.id === ctx.state.session.id, "revert should return the session") + check( + isRecord(body.revert) && body.revert.messageID === ctx.state.message.info.id, + "revert should record reverted message", + ) + }, + "status", + ), + http.protected + .post("/session/{sessionID}/unrevert", "session.unrevert") + .mutating() + .seeded((ctx) => ctx.session({ title: "Unrevert session" })) + .at((ctx) => ({ + path: route("/session/{sessionID}/unrevert", { sessionID: ctx.state.id }), + headers: ctx.headers(), + })) + .json( + 200, + (body, ctx) => { + object(body) + check(body.id === ctx.state.id, "unrevert should return the session") + }, + "status", + ), + http.protected + .post("/session/{sessionID}/permissions/{permissionID}", "permission.respond") + .seeded((ctx) => ctx.session({ title: "Deprecated permission session" })) + .at((ctx) => ({ + path: route("/session/{sessionID}/permissions/{permissionID}", { + sessionID: ctx.state.id, + permissionID: "per_httpapi_deprecated", + }), + headers: ctx.headers(), + body: { response: "once" }, + })) + .json(200, (body) => { + check(body === true, "deprecated permission response should return true") + }), + http.protected + .post("/session/{sessionID}/share", "session.share") + .mutating() + .seeded((ctx) => ctx.session({ title: "Share session" })) + .at((ctx) => ({ path: route("/session/{sessionID}/share", { sessionID: ctx.state.id }), headers: ctx.headers() })) + .json( + 200, + (body, ctx) => { + object(body) + check(body.id === ctx.state.id, "share should return the session") + }, + "status", + ), + http.protected + .delete("/session/{sessionID}/share", "session.unshare") + .mutating() + .seeded((ctx) => ctx.session({ title: "Unshare session" })) + .at((ctx) => ({ path: route("/session/{sessionID}/share", { sessionID: ctx.state.id }), headers: ctx.headers() })) + .json( + 200, + (body, ctx) => { + object(body) + check(body.id === ctx.state.id, "unshare should return the session") + }, + "status", + ), + http.protected + .post("/tui/append-prompt", "tui.appendPrompt") + .at((ctx) => ({ path: "/tui/append-prompt", headers: ctx.headers(), body: { text: "hello" } })) + .json(200, boolean, "status"), + http.protected + .post("/tui/select-session", "tui.selectSession.invalid") + .at((ctx) => ({ path: "/tui/select-session", headers: ctx.headers(), body: { sessionID: "invalid" } })) + .status(400), + http.protected.post("/tui/open-help", "tui.openHelp").json(200, boolean, "status"), + http.protected.post("/tui/open-sessions", "tui.openSessions").json(200, boolean, "status"), + http.protected.post("/tui/open-themes", "tui.openThemes").json(200, boolean, "status"), + http.protected.post("/tui/open-models", "tui.openModels").json(200, boolean, "status"), + http.protected.post("/tui/submit-prompt", "tui.submitPrompt").json(200, boolean, "status"), + http.protected.post("/tui/clear-prompt", "tui.clearPrompt").json(200, boolean, "status"), + http.protected + .post("/tui/execute-command", "tui.executeCommand") + .at((ctx) => ({ path: "/tui/execute-command", headers: ctx.headers(), body: { command: "agent_cycle" } })) + .json(200, boolean, "status"), + http.protected + .post("/tui/show-toast", "tui.showToast") + .at((ctx) => ({ + path: "/tui/show-toast", + headers: ctx.headers(), + body: { title: "Exercise", message: "covered", variant: "info", duration: 1000 }, + })) + .json(200, boolean, "status"), + http.protected + .post("/tui/publish", "tui.publish") + .at((ctx) => ({ + path: "/tui/publish", + headers: ctx.headers(), + body: { type: "tui.prompt.append", properties: { text: "published" } }, + })) + .json(200, boolean, "status"), + http.protected + .post("/tui/select-session", "tui.selectSession") + .seeded((ctx) => ctx.session({ title: "TUI select" })) + .at((ctx) => ({ path: "/tui/select-session", headers: ctx.headers(), body: { sessionID: ctx.state.id } })) + .json(200, boolean, "status"), + http.protected + .post("/tui/control/response", "tui.control.response") + .at((ctx) => ({ path: "/tui/control/response", headers: ctx.headers(), body: { ok: true } })) + .json(200, boolean, "status"), + http.protected + .get("/tui/control/next", "tui.control.next") + .mutating() + .seeded((ctx) => ctx.tuiRequest({ path: "/tui/exercise", body: { text: "queued" } })) + .json( + 200, + (body) => { + object(body) + check(body.path === "/tui/exercise", "control next should return queued path") + object(body.body) + check(body.body.text === "queued", "control next should return queued body") + }, + "status", + ), + http.protected + .post("/global/upgrade", "global.upgrade") + .global() + .probe({ path: "/global/upgrade", body: { target: 1 } }) + .at(() => ({ path: "/global/upgrade", body: { target: 1 } })) + .status(400), +] + +const llmScenarios = new Set([ + "session.init", + "session.prompt", + "session.prompt_async", + "session.command", + "session.summarize", +]) + +const main = Effect.gen(function* () { + yield* Effect.addFinalizer(() => cleanupExercisePaths) + const options = parseOptions(Bun.argv.slice(2)) + const modules = yield* Effect.promise(() => runtime()) + const effectRoutes = routeKeys(OpenApi.fromApi(modules.PublicApi)) + const selected = selectedScenarios(options, scenarios) + const missing = effectRoutes.filter((route) => !scenarios.some((scenario) => route === routeKey(scenario))) + const extra = scenarios.filter((scenario) => !effectRoutes.includes(routeKey(scenario))) + + for (const scenario of scenarios) { + if (scenario.kind === "active" && llmScenarios.has(scenario.name) && !scenario.project?.llm) { + return yield* Effect.fail(new Error(`${scenario.name} must use TestLLMServer via .withLlm()`)) + } + } + + printHeader(options, effectRoutes, selected, missing, extra, { + database: exerciseDatabasePath, + global: exerciseGlobalRoot, + }) + + const results = + options.mode === "coverage" + ? selected.map(coverageResult) + : yield* Effect.forEach( + selected, + (scenario) => + Effect.gen(function* () { + if (options.progress) console.log(`${color.dim}RUN ${routeKey(scenario)} ${scenario.name}${color.reset}`) + return yield* runScenario(options)(scenario) + }), + { concurrency: 1 }, + ) + printResults(results, missing, extra) + + if (results.some((result) => result.status === "fail")) + return yield* Effect.fail(new Error("one or more scenarios failed")) + if (options.failOnSkip && results.some((result) => result.status === "skip")) + return yield* Effect.fail(new Error("one or more scenarios are skipped")) + if (options.failOnMissing && missing.length > 0) + return yield* Effect.fail(new Error("one or more routes have no scenario")) + return undefined +}) + +Effect.runPromise(main.pipe(Effect.provide(TestLLMServer.layer), Effect.scoped)).then( + () => process.exit(0), + (error: unknown) => { + console.error(`${color.red}${message(error)}${color.reset}`) + process.exit(1) + }, +) diff --git a/packages/opencode/test/server/httpapi-exercise/report.ts b/packages/opencode/test/server/httpapi-exercise/report.ts new file mode 100644 index 0000000000..7e79e972cb --- /dev/null +++ b/packages/opencode/test/server/httpapi-exercise/report.ts @@ -0,0 +1,66 @@ +import { Duration } from "effect" +import { indent, pad } from "./assertions" +import type { Options, Result, Scenario } from "./types" + +export const color = { + dim: "\x1b[2m", + green: "\x1b[32m", + red: "\x1b[31m", + yellow: "\x1b[33m", + cyan: "\x1b[36m", + reset: "\x1b[0m", +} + +export function printHeader( + options: Options, + effectRoutes: string[], + selected: Scenario[], + missing: string[], + extra: Scenario[], + paths: { database: string; global: string }, +) { + console.log(`${color.cyan}HttpApi exerciser${color.reset}`) + console.log(`${color.dim}db=${paths.database}${color.reset}`) + console.log(`${color.dim}global=${paths.global}${color.reset}`) + console.log( + `${color.dim}mode=${options.mode} selected=${selected.length} scenarioTimeout=${Duration.format(options.scenarioTimeout)} effectRoutes=${effectRoutes.length} missing=${missing.length} extra=${extra.length}${color.reset}`, + ) + console.log("") +} + +export function printResults(results: Result[], missing: string[], extra: Scenario[]) { + for (const result of results) { + if (result.status === "pass") { + console.log( + `${color.green}PASS${color.reset} ${pad(result.scenario.method, 6)} ${pad(result.scenario.path, 48)} ${result.scenario.name}`, + ) + continue + } + if (result.status === "skip") { + console.log( + `${color.yellow}SKIP${color.reset} ${pad(result.scenario.method, 6)} ${pad(result.scenario.path, 48)} ${result.scenario.name} ${color.dim}${result.scenario.reason}${color.reset}`, + ) + continue + } + console.log( + `${color.red}FAIL${color.reset} ${pad(result.scenario.method, 6)} ${pad(result.scenario.path, 48)} ${result.scenario.name}`, + ) + console.log(`${color.red}${indent(result.message)}${color.reset}`) + } + if (missing.length > 0) { + console.log("\nMissing scenarios") + for (const route of missing) console.log(`${color.red}MISS${color.reset} ${route}`) + } + if (extra.length > 0) { + console.log("\nExtra scenarios") + for (const scenario of extra) + console.log(`${color.yellow}EXTRA${color.reset} ${routeKey(scenario)} ${scenario.name}`) + } + console.log( + `\n${color.dim}summary pass=${results.filter((result) => result.status === "pass").length} fail=${results.filter((result) => result.status === "fail").length} skip=${results.filter((result) => result.status === "skip").length} missing=${missing.length} extra=${extra.length}${color.reset}`, + ) +} + +function routeKey(scenario: Scenario) { + return `${scenario.method} ${scenario.path}` +} diff --git a/packages/opencode/test/server/httpapi-exercise/routing.ts b/packages/opencode/test/server/httpapi-exercise/routing.ts new file mode 100644 index 0000000000..9e432af2e3 --- /dev/null +++ b/packages/opencode/test/server/httpapi-exercise/routing.ts @@ -0,0 +1,96 @@ +import { Duration } from "effect" +import { OpenApiMethods, type OpenApiSpec, type Options, type Result, type Scenario } from "./types" + +type ScenarioTimeout = `${number} ${Duration.Unit}` + +const durationUnits = new Set([ + "nano", + "nanos", + "micro", + "micros", + "milli", + "millis", + "second", + "seconds", + "minute", + "minutes", + "hour", + "hours", + "day", + "days", + "week", + "weeks", +]) + +export function routeKeys(spec: OpenApiSpec) { + return Object.entries(spec.paths ?? {}) + .flatMap(([path, item]) => + OpenApiMethods.filter((method) => item[method]).map((method) => `${method.toUpperCase()} ${path}`), + ) + .sort() +} + +export function routeKey(scenario: Scenario) { + return `${scenario.method} ${scenario.path}` +} + +export function coverageResult(scenario: Scenario): Result { + if (scenario.kind === "todo") return { status: "skip", scenario } + return { status: "pass", scenario } +} + +export function parseOptions(args: string[]): Options { + const mode = option(args, "--mode") ?? "effect" + if (mode !== "effect" && mode !== "coverage" && mode !== "auth") throw new Error(`invalid --mode ${mode}`) + return { + mode, + include: option(args, "--include"), + startAt: option(args, "--start-at"), + stopAt: option(args, "--stop-at"), + failOnMissing: args.includes("--fail-on-missing"), + failOnSkip: args.includes("--fail-on-skip"), + scenarioTimeout: parseScenarioTimeout(option(args, "--scenario-timeout") ?? "30 seconds"), + progress: args.includes("--progress"), + trace: args.includes("--trace"), + } +} + +export function matches(options: Options, scenario: Scenario) { + if (!options.include) return true + return ( + scenario.name.includes(options.include) || + scenario.path.includes(options.include) || + scenario.method.includes(options.include.toUpperCase()) + ) +} + +export function selectedScenarios(options: Options, scenarios: Scenario[]) { + const included = scenarios.filter((scenario) => matches(options, scenario)) + const start = options.startAt ? included.findIndex((scenario) => matchesName(options.startAt!, scenario)) : 0 + const end = options.stopAt + ? included.findIndex((scenario) => matchesName(options.stopAt!, scenario)) + : included.length - 1 + if (start === -1) throw new Error(`--start-at matched no scenario: ${options.startAt}`) + if (end === -1) throw new Error(`--stop-at matched no scenario: ${options.stopAt}`) + return included.slice(start, end + 1) +} + +function matchesName(value: string, scenario: Scenario) { + return scenario.name.includes(value) || scenario.path.includes(value) || scenario.method.includes(value.toUpperCase()) +} + +function option(args: string[], name: string) { + const index = args.indexOf(name) + if (index === -1) return undefined + return args[index + 1] +} + +function parseScenarioTimeout(input: string) { + if (!isScenarioTimeout(input)) throw new Error(`invalid --scenario-timeout ${input}`) + return Duration.fromInputUnsafe(input) +} + +function isScenarioTimeout(input: string): input is ScenarioTimeout { + const [amount, unit, extra] = input.trim().split(/\s+/) + return extra === undefined && amount !== undefined && Number.isFinite(Number(amount)) && durationUnits.has(unit ?? "") +} diff --git a/packages/opencode/test/server/httpapi-exercise/runner.ts b/packages/opencode/test/server/httpapi-exercise/runner.ts new file mode 100644 index 0000000000..2b3f720c84 --- /dev/null +++ b/packages/opencode/test/server/httpapi-exercise/runner.ts @@ -0,0 +1,259 @@ +import { Flag } from "@opencode-ai/core/flag/flag" +import { Cause, Duration, Effect } from "effect" +import { TestLLMServer } from "../../lib/llm-server" +import type { Config } from "../../../src/config/config" +import { ModelID, ProviderID } from "../../../src/provider/schema" +import type { MessageV2 } from "../../../src/session/message-v2" +import { MessageID, PartID } from "../../../src/session/schema" +import { call, callAuthProbe } from "./backend" +import { original } from "./environment" +import { runtime } from "./runtime" +import type { ActiveScenario, Options, ProjectOptions, Result, Scenario, ScenarioContext, SeededContext } from "./types" + +export function runScenario(options: Options) { + return (scenario: Scenario) => { + if (scenario.kind === "todo") return Effect.succeed({ status: "skip", scenario } as Result) + return runActive(options, scenario).pipe( + Effect.timeoutOrElse({ + duration: options.scenarioTimeout, + orElse: () => Effect.die(new Error(`scenario timed out after ${Duration.format(options.scenarioTimeout)}`)), + }), + Effect.as({ status: "pass", scenario } as Result), + Effect.catchCause((cause) => Effect.succeed({ status: "fail" as const, scenario, message: Cause.pretty(cause) })), + Effect.scoped, + ) + } +} + +function runActive(options: Options, scenario: ActiveScenario) { + if (options.mode === "auth") return runAuth(scenario) + + return withContext(options, scenario, "shared", (ctx) => + Effect.gen(function* () { + yield* trace(options, scenario, "effect request start") + const effect = yield* call("effect", scenario, ctx) + yield* trace(options, scenario, `effect response ${effect.status}`) + yield* trace(options, scenario, "effect expect start") + yield* scenario.expect(ctx, ctx.state, effect) + yield* trace(options, scenario, "effect expect done") + }), + ) +} + +function runAuth(scenario: ActiveScenario) { + return Effect.gen(function* () { + const effect = yield* callAuthProbe("effect", scenario, "missing") + if (scenario.auth === "protected") { + if (effect.status !== 401) throw new Error(`effect auth expected 401, got ${effect.status}`) + const effectAuthed = yield* callAuthProbe("effect", scenario, "valid") + if (effectAuthed.status === 401) throw new Error("effect auth rejected valid credentials") + return + } + + if (effect.status === 401) throw new Error("effect auth expected public access, got 401") + if (effect.timedOut) throw new Error("effect auth expected public access, probe timed out") + }) +} + +function withContext( + options: Options, + scenario: ActiveScenario, + label: string, + use: (ctx: SeededContext) => Effect.Effect, +) { + return Effect.acquireRelease( + Effect.gen(function* () { + yield* trace(options, scenario, `${label} context acquire start`) + const llm = scenario.project?.llm ? yield* TestLLMServer : undefined + const project = scenario.project + const dir = project + ? yield* Effect.promise(async () => (await runtime()).tmpdir(projectOptions(project, llm?.url))) + : undefined + yield* trace(options, scenario, `${label} context acquire done`) + return { dir, llm } + }), + (ctx) => + Effect.gen(function* () { + yield* trace(options, scenario, `${label} tmpdir cleanup start`) + yield* Effect.promise(async () => { + await ctx.dir?.[Symbol.asyncDispose]() + }).pipe(Effect.ignore) + yield* trace(options, scenario, `${label} tmpdir cleanup done`) + }), + ).pipe( + Effect.flatMap((context) => + Effect.gen(function* () { + yield* trace(options, scenario, `${label} runtime start`) + const modules = yield* Effect.promise(() => runtime()) + yield* trace(options, scenario, `${label} runtime done`) + const path = context.dir?.path + const instance = path + ? yield* trace(options, scenario, `${label} instance load start`).pipe( + Effect.andThen( + modules.InstanceStore.Service.use((store) => store.load({ directory: path })).pipe( + Effect.provide(modules.AppLayer), + Effect.catchCause((cause) => + Effect.sleep("100 millis").pipe( + Effect.andThen( + modules.InstanceStore.Service.use((store) => store.load({ directory: path })).pipe( + Effect.provide(modules.AppLayer), + ), + ), + Effect.catchCause(() => Effect.failCause(cause)), + ), + ), + ), + ), + Effect.tap(() => trace(options, scenario, `${label} instance load done`)), + ) + : undefined + const run = (effect: Effect.Effect) => + effect.pipe(Effect.provideService(modules.InstanceRef, instance), Effect.provide(modules.AppLayer)) + const directory = () => { + if (!context.dir?.path) throw new Error("scenario needs a project directory") + return context.dir.path + } + const llm = () => { + if (!context.llm) throw new Error("scenario needs fake LLM") + return context.llm + } + const base: ScenarioContext = { + directory: context.dir?.path, + headers: (extra) => ({ + ...(context.dir?.path ? { "x-opencode-directory": context.dir.path } : {}), + ...extra, + }), + file: (name, content) => + Effect.promise(() => { + return Bun.write(`${directory()}/${name}`, content) + }).pipe(Effect.asVoid), + session: (input) => + run(modules.Session.Service.use((svc) => svc.create({ title: input?.title, parentID: input?.parentID }))), + sessionGet: (sessionID) => + run(modules.Session.Service.use((svc) => svc.get(sessionID))).pipe( + Effect.catchCause(() => Effect.succeed(undefined)), + ), + project: () => + Effect.sync(() => { + if (!instance) throw new Error("scenario needs a project directory") + return instance.project + }), + message: (sessionID, input) => + Effect.gen(function* () { + const info: MessageV2.User = { + id: MessageID.ascending(), + sessionID, + role: "user", + time: { created: Date.now() }, + agent: "build", + model: { + providerID: ProviderID.opencode, + modelID: ModelID.make("test"), + }, + } + const part: MessageV2.TextPart = { + id: PartID.ascending(), + sessionID, + messageID: info.id, + type: "text", + text: input?.text ?? "hello", + } + yield* run( + modules.Session.Service.use((svc) => + Effect.gen(function* () { + yield* svc.updateMessage(info) + yield* svc.updatePart(part) + }), + ), + ) + return { info, part } + }), + messages: (sessionID) => run(modules.Session.Service.use((svc) => svc.messages({ sessionID }))), + todos: (sessionID, todos) => run(modules.Todo.Service.use((svc) => svc.update({ sessionID, todos }))), + worktree: (input) => run(modules.Worktree.Service.use((svc) => svc.create(input))), + worktreeRemove: (directory) => + run(modules.Worktree.Service.use((svc) => svc.remove({ directory })).pipe(Effect.ignore)), + llmText: (value) => Effect.suspend(() => llm().text(value)), + llmWait: (count) => Effect.suspend(() => llm().wait(count)), + tuiRequest: (request) => Effect.sync(() => modules.Tui.submitTuiRequest(request)), + } + yield* trace(options, scenario, `${label} seed start`) + const state = yield* scenario.seed(base) + yield* trace(options, scenario, `${label} seed done`) + yield* trace(options, scenario, `${label} use start`) + const result = yield* use({ ...base, state }) + yield* trace(options, scenario, `${label} use done`) + return result + }).pipe(Effect.ensuring(context.llm ? context.llm.reset : Effect.void)), + ), + Effect.ensuring(scenario.reset ? resetState : Effect.void), + ) +} + +function trace(options: Options, scenario: ActiveScenario, phase: string) { + return Effect.sync(() => { + if (!options.trace) return + console.log(`[trace] ${scenario.name}: ${phase}`) + }) +} + +function projectOptions( + project: ProjectOptions, + llmUrl: string | undefined, +): { git?: boolean; config?: Partial } { + if (!project.llm || !llmUrl) return { git: project.git, config: project.config } + const fake = fakeLlmConfig(llmUrl) + return { + git: project.git, + config: { + ...fake, + ...project.config, + provider: { + ...fake.provider, + ...project.config?.provider, + }, + }, + } +} + +function fakeLlmConfig(url: string): Partial { + return { + model: "test/test-model", + small_model: "test/test-model", + provider: { + test: { + name: "Test", + id: "test", + env: [], + npm: "@ai-sdk/openai-compatible", + models: { + "test-model": { + id: "test-model", + name: "Test Model", + attachment: false, + reasoning: false, + temperature: false, + tool_call: true, + release_date: "2025-01-01", + limit: { context: 100000, output: 10000 }, + cost: { input: 0, output: 0 }, + options: {}, + }, + }, + options: { + apiKey: "test-key", + baseURL: url, + }, + }, + }, + } +} + +const resetState = Effect.promise(async () => { + const modules = await runtime() + Flag.OPENCODE_SERVER_PASSWORD = original.OPENCODE_SERVER_PASSWORD + Flag.OPENCODE_SERVER_USERNAME = original.OPENCODE_SERVER_USERNAME + await modules.disposeAllInstances() + await modules.resetDatabase() + await Bun.sleep(25) +}) diff --git a/packages/opencode/test/server/httpapi-exercise/runtime.ts b/packages/opencode/test/server/httpapi-exercise/runtime.ts new file mode 100644 index 0000000000..7163cf0c5a --- /dev/null +++ b/packages/opencode/test/server/httpapi-exercise/runtime.ts @@ -0,0 +1,52 @@ +export type Runtime = { + PublicApi: (typeof import("../../../src/server/routes/instance/httpapi/public"))["PublicApi"] + ExperimentalHttpApiServer: (typeof import("../../../src/server/routes/instance/httpapi/server"))["ExperimentalHttpApiServer"] + AppLayer: (typeof import("../../../src/effect/app-runtime"))["AppLayer"] + InstanceRef: (typeof import("../../../src/effect/instance-ref"))["InstanceRef"] + Instance: (typeof import("../../../src/project/instance"))["Instance"] + InstanceStore: (typeof import("../../../src/project/instance-store"))["InstanceStore"] + Session: (typeof import("../../../src/session/session"))["Session"] + Todo: (typeof import("../../../src/session/todo"))["Todo"] + Worktree: (typeof import("../../../src/worktree"))["Worktree"] + Project: (typeof import("../../../src/project/project"))["Project"] + Tui: typeof import("../../../src/server/shared/tui-control") + disposeAllInstances: (typeof import("../../fixture/fixture"))["disposeAllInstances"] + tmpdir: (typeof import("../../fixture/fixture"))["tmpdir"] + resetDatabase: (typeof import("../../fixture/db"))["resetDatabase"] +} + +let runtimePromise: Promise | undefined + +export function runtime() { + return (runtimePromise ??= (async () => { + const publicApi = await import("../../../src/server/routes/instance/httpapi/public") + const httpApiServer = await import("../../../src/server/routes/instance/httpapi/server") + const appRuntime = await import("../../../src/effect/app-runtime") + const instanceRef = await import("../../../src/effect/instance-ref") + const instance = await import("../../../src/project/instance") + const instanceStore = await import("../../../src/project/instance-store") + const session = await import("../../../src/session/session") + const todo = await import("../../../src/session/todo") + const worktree = await import("../../../src/worktree") + const project = await import("../../../src/project/project") + const tui = await import("../../../src/server/shared/tui-control") + const fixture = await import("../../fixture/fixture") + const db = await import("../../fixture/db") + return { + PublicApi: publicApi.PublicApi, + ExperimentalHttpApiServer: httpApiServer.ExperimentalHttpApiServer, + AppLayer: appRuntime.AppLayer, + InstanceRef: instanceRef.InstanceRef, + Instance: instance.Instance, + InstanceStore: instanceStore.InstanceStore, + Session: session.Session, + Todo: todo.Todo, + Worktree: worktree.Worktree, + Project: project.Project, + Tui: tui, + disposeAllInstances: fixture.disposeAllInstances, + tmpdir: fixture.tmpdir, + resetDatabase: db.resetDatabase, + } + })()) +} diff --git a/packages/opencode/test/server/httpapi-exercise/types.ts b/packages/opencode/test/server/httpapi-exercise/types.ts new file mode 100644 index 0000000000..a0466d7b70 --- /dev/null +++ b/packages/opencode/test/server/httpapi-exercise/types.ts @@ -0,0 +1,122 @@ +import type { Duration, Effect } from "effect" +import type { Config } from "../../../src/config/config" +import type { Project } from "../../../src/project/project" +import type { Worktree } from "../../../src/worktree" +import type { MessageV2 } from "../../../src/session/message-v2" +import type { SessionID } from "../../../src/session/schema" + +export const OpenApiMethods = ["get", "post", "put", "delete", "patch"] as const +export const Methods = ["GET", "POST", "PUT", "DELETE", "PATCH"] as const + +export type Method = (typeof Methods)[number] +export type OpenApiMethod = (typeof OpenApiMethods)[number] +export type Mode = "effect" | "coverage" | "auth" +export type Backend = "effect" +export type Comparison = "none" | "status" | "json" +export type CaptureMode = "full" | "stream" +export type AuthPolicy = "protected" | "public" | "public-bypass" | "ticket-bypass" +export type ProjectOptions = { git?: boolean; config?: Partial; llm?: boolean } +export type OpenApiSpec = { paths?: Record>> } +export type JsonObject = Record + +export type Options = { + mode: Mode + include: string | undefined + startAt: string | undefined + stopAt: string | undefined + failOnMissing: boolean + failOnSkip: boolean + scenarioTimeout: Duration.Duration + progress: boolean + trace: boolean +} + +export type RequestSpec = { + path: string + headers?: Record + body?: unknown +} + +export type CallResult = { + status: number + contentType: string + body: unknown + text: string + timedOut: boolean +} + +export type BackendApp = { + request(input: string | URL | Request, init?: RequestInit): Response | Promise +} + +/** Effect-native helpers available while setting up and asserting a scenario. */ +export type ScenarioContext = { + directory: string | undefined + headers: (extra?: Record) => Record + file: (name: string, content: string) => Effect.Effect + session: (input?: { title?: string; parentID?: SessionID }) => Effect.Effect + sessionGet: (sessionID: SessionID) => Effect.Effect + project: () => Effect.Effect + message: (sessionID: SessionID, input?: { text?: string }) => Effect.Effect + messages: (sessionID: SessionID) => Effect.Effect + todos: (sessionID: SessionID, todos: TodoInfo[]) => Effect.Effect + worktree: (input?: { name?: string }) => Effect.Effect + worktreeRemove: (directory: string) => Effect.Effect + llmText: (value: string) => Effect.Effect + llmWait: (count: number) => Effect.Effect + tuiRequest: (request: { path: string; body: unknown }) => Effect.Effect +} + +/** Scenario context after `.seeded(...)`; `state` preserves the seed return type in the DSL. */ +export type SeededContext = ScenarioContext & { + state: S +} + +export type Scenario = ActiveScenario | TodoScenario +export type ActiveScenario = { + kind: "active" + method: Method + path: string + name: string + project: ProjectOptions | undefined + seed: (ctx: ScenarioContext) => Effect.Effect + request: (ctx: ScenarioContext, state: unknown) => RequestSpec + authProbe: RequestSpec | undefined + expect: (ctx: ScenarioContext, state: unknown, result: CallResult) => Effect.Effect + compare: Comparison + capture: CaptureMode + mutates: boolean + reset: boolean + auth: AuthPolicy +} + +export type BuilderState = { + method: Method + path: string + name: string + project: ProjectOptions | undefined + seed: (ctx: ScenarioContext) => Effect.Effect + request: (ctx: SeededContext) => RequestSpec + authProbe: RequestSpec | undefined + capture: CaptureMode + mutates: boolean + reset: boolean + auth: AuthPolicy +} + +export type TodoScenario = { + kind: "todo" + method: Method + path: string + name: string + reason: string +} + +export type Result = + | { status: "pass"; scenario: ActiveScenario } + | { status: "fail"; scenario: ActiveScenario; message: string } + | { status: "skip"; scenario: TodoScenario } + +export type SessionInfo = { id: SessionID; title: string; parentID?: SessionID } +export type TodoInfo = { content: string; status: string; priority: string } +export type MessageSeed = { info: MessageV2.User; part: MessageV2.TextPart } diff --git a/packages/opencode/test/server/httpapi-experimental.test.ts b/packages/opencode/test/server/httpapi-experimental.test.ts index 8684edf134..0b8d8051bc 100644 --- a/packages/opencode/test/server/httpapi-experimental.test.ts +++ b/packages/opencode/test/server/httpapi-experimental.test.ts @@ -1,6 +1,5 @@ import { afterEach, describe, expect, test } from "bun:test" import { Effect } from "effect" -import { Flag } from "@opencode-ai/core/flag/flag" import { Instance } from "../../src/project/instance" import { WithInstance } from "../../src/project/with-instance" import { Server } from "../../src/server/server" @@ -15,11 +14,9 @@ import { waitGlobalBusEventPromise } from "./global-bus" void Log.init({ print: false }) -const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI const testWorktreeMutations = process.platform === "win32" ? test.skip : test function app() { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true return Server.Default().app } @@ -39,7 +36,6 @@ async function waitReady(directory: string) { } afterEach(async () => { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original await disposeAllInstances() await resetDatabase() }) diff --git a/packages/opencode/test/server/httpapi-instance-context.test.ts b/packages/opencode/test/server/httpapi-instance-context.test.ts index 5e00d77708..1b72f34775 100644 --- a/packages/opencode/test/server/httpapi-instance-context.test.ts +++ b/packages/opencode/test/server/httpapi-instance-context.test.ts @@ -7,6 +7,7 @@ import * as Socket from "effect/unstable/socket/Socket" import { mkdir } from "node:fs/promises" import path from "node:path" import { registerAdapter } from "../../src/control-plane/adapters" +import { WorkspaceID } from "../../src/control-plane/schema" import type { WorkspaceAdapter } from "../../src/control-plane/types" import { Workspace } from "../../src/control-plane/workspace" import { InstanceRef, WorkspaceRef } from "../../src/effect/instance-ref" @@ -20,6 +21,7 @@ import { instanceRouterMiddleware } from "../../src/server/routes/instance/httpa import { workspaceRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/workspace-routing" import { resetDatabase } from "../fixture/db" import { disposeAllInstances, tmpdirScoped } from "../fixture/fixture" +import { withFixedWorkspaceID } from "../fixture/flag" import { waitGlobalBusEvent } from "./global-bus" import { testEffect } from "../lib/effect" @@ -203,6 +205,94 @@ describe("HttpApi instance context middleware", () => { }), ) + it.live("uses configured workspace id instead of routing to the requested workspace", () => + Effect.gen(function* () { + const fixedWorkspaceID = WorkspaceID.ascending() + yield* withFixedWorkspaceID(fixedWorkspaceID) + + const dir = yield* tmpdirScoped({ git: true }) + const project = yield* Project.use.fromDirectory(dir) + const workspaceDir = path.join(dir, ".workspace-local") + const workspace = yield* createLocalWorkspace({ + projectID: project.project.id, + type: "instance-context-fixed-workspace-ref", + directory: workspaceDir, + }) + yield* serveProbe() + + const response = yield* HttpClientRequest.get(`/probe?workspace=${workspace.id}`).pipe( + HttpClientRequest.setHeader("x-opencode-directory", dir), + HttpClient.execute, + ) + + expect(response.status).toBe(200) + expect(yield* response.json).toMatchObject({ + directory: dir, + workspaceID: fixedWorkspaceID, + }) + }), + ) + + it.live("falls through to local instead of MissingWorkspace when configured workspace id is set", () => + Effect.gen(function* () { + const fixedWorkspaceID = WorkspaceID.ascending() + yield* withFixedWorkspaceID(fixedWorkspaceID) + + const dir = yield* tmpdirScoped({ git: true }) + yield* Project.use.fromDirectory(dir) + yield* serveProbe() + + // Reference a workspace id that is not registered locally. Without the + // configured env override, this would short-circuit to a 500 + // MissingWorkspace response. With the env set, planRequest must skip the + // MissingWorkspace branch and fall through to Local with the configured + // workspace id. + const unknownWorkspaceID = WorkspaceID.ascending() + const response = yield* HttpClientRequest.get(`/probe?workspace=${unknownWorkspaceID}`).pipe( + HttpClientRequest.setHeader("x-opencode-directory", dir), + HttpClient.execute, + ) + + expect(response.status).toBe(200) + expect(yield* response.json).toMatchObject({ + directory: dir, + workspaceID: fixedWorkspaceID, + }) + }), + ) + + it.live("keeps configured workspace id on control-plane routes without remote routing", () => + Effect.gen(function* () { + const fixedWorkspaceID = WorkspaceID.ascending() + yield* withFixedWorkspaceID(fixedWorkspaceID) + + const dir = yield* tmpdirScoped({ git: true }) + const project = yield* Project.use.fromDirectory(dir) + const workspaceDir = path.join(dir, ".workspace-local") + const workspace = yield* createLocalWorkspace({ + projectID: project.project.id, + type: "instance-context-fixed-workspace-control-plane", + directory: workspaceDir, + }) + // /session is matched by isLocalWorkspaceRoute, so shouldStayOnControlPlane + // is true. Combined with the env override, the route must stay Local with + // the configured workspace id (not divert to the requested workspace's + // local directory). + yield* serveProbe("/session") + + const response = yield* HttpClientRequest.get(`/session?workspace=${workspace.id}`).pipe( + HttpClientRequest.setHeader("x-opencode-directory", dir), + HttpClient.execute, + ) + + expect(response.status).toBe(200) + expect(yield* response.json).toMatchObject({ + directory: dir, + workspaceID: fixedWorkspaceID, + }) + }), + ) + it.live("preserves selected workspace id on instance disposal events", () => Effect.gen(function* () { const dir = yield* tmpdirScoped({ git: true }) diff --git a/packages/opencode/test/server/httpapi-instance.legacy.test.ts b/packages/opencode/test/server/httpapi-instance.legacy.test.ts deleted file mode 100644 index b5f0805e4c..0000000000 --- a/packages/opencode/test/server/httpapi-instance.legacy.test.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { afterEach, describe, expect, test } from "bun:test" -import { Flag } from "@opencode-ai/core/flag/flag" -import { Server } from "../../src/server/server" -import { InstancePaths } from "../../src/server/routes/instance/httpapi/groups/instance" -import * as Log from "@opencode-ai/core/util/log" -import { resetDatabase } from "../fixture/db" -import { disposeAllInstances, tmpdir } from "../fixture/fixture" -import { waitGlobalBusEventPromise } from "./global-bus" - -void Log.init({ print: false }) - -const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI - -function app() { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true - return Server.Default().app -} - -async function waitDisposed(directory: string) { - await waitGlobalBusEventPromise({ - message: "timed out waiting for instance disposal", - predicate: (event) => event.payload.type === "server.instance.disposed" && event.directory === directory, - }) -} - -afterEach(async () => { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original - await disposeAllInstances() - await resetDatabase() -}) - -describe("instance HttpApi", () => { - test("serves catalog read endpoints through Hono bridge", async () => { - await using tmp = await tmpdir({ config: { formatter: false, lsp: false } }) - - const [commands, agents, skills, lsp, formatter] = await Promise.all([ - app().request(InstancePaths.command, { headers: { "x-opencode-directory": tmp.path } }), - app().request(InstancePaths.agent, { headers: { "x-opencode-directory": tmp.path } }), - app().request(InstancePaths.skill, { headers: { "x-opencode-directory": tmp.path } }), - app().request(InstancePaths.lsp, { headers: { "x-opencode-directory": tmp.path } }), - app().request(InstancePaths.formatter, { headers: { "x-opencode-directory": tmp.path } }), - ]) - - expect(commands.status).toBe(200) - expect(await commands.json()).toContainEqual(expect.objectContaining({ name: "init", source: "command" })) - - expect(agents.status).toBe(200) - expect(await agents.json()).toContainEqual(expect.objectContaining({ name: "build", mode: "primary" })) - - expect(skills.status).toBe(200) - expect(await skills.json()).toBeArray() - - expect(lsp.status).toBe(200) - expect(await lsp.json()).toEqual([]) - - expect(formatter.status).toBe(200) - expect(await formatter.json()).toEqual([]) - }) - - test("serves project git init through Hono bridge", async () => { - await using tmp = await tmpdir({ config: { formatter: false, lsp: false } }) - const disposed = waitDisposed(tmp.path) - - const response = await app().request("/project/git/init", { - method: "POST", - headers: { "x-opencode-directory": tmp.path }, - }) - - expect(response.status).toBe(200) - expect(await response.json()).toMatchObject({ vcs: "git", worktree: tmp.path }) - await disposed - - const current = await app().request("/project/current", { headers: { "x-opencode-directory": tmp.path } }) - expect(current.status).toBe(200) - expect(await current.json()).toMatchObject({ vcs: "git", worktree: tmp.path }) - }) - - test("serves project update through Hono bridge", async () => { - await using tmp = await tmpdir({ config: { formatter: false, lsp: false } }) - - const current = await app().request("/project/current", { headers: { "x-opencode-directory": tmp.path } }) - expect(current.status).toBe(200) - const project = (await current.json()) as { id: string } - - const response = await app().request(`/project/${project.id}`, { - method: "PATCH", - headers: { "x-opencode-directory": tmp.path, "content-type": "application/json" }, - body: JSON.stringify({ name: "patched-project", commands: { start: "bun dev" } }), - }) - - expect(response.status).toBe(200) - expect(await response.json()).toMatchObject({ - id: project.id, - name: "patched-project", - commands: { start: "bun dev" }, - }) - - const list = await app().request("/project", { headers: { "x-opencode-directory": tmp.path } }) - expect(list.status).toBe(200) - expect(await list.json()).toContainEqual( - expect.objectContaining({ id: project.id, name: "patched-project", commands: { start: "bun dev" } }), - ) - }) - - test("serves instance dispose through Hono bridge", async () => { - await using tmp = await tmpdir() - - const disposed = waitGlobalBusEventPromise({ - message: "timed out waiting for instance disposal", - predicate: (event) => event.payload.type === "server.instance.disposed", - }) - - const response = await app().request(InstancePaths.dispose, { - method: "POST", - headers: { "x-opencode-directory": tmp.path }, - }) - - expect(response.status).toBe(200) - expect(await response.json()).toBe(true) - expect((await disposed).directory).toBe(tmp.path) - }) -}) diff --git a/packages/opencode/test/server/httpapi-instance.test.ts b/packages/opencode/test/server/httpapi-instance.test.ts index 61b1af6135..946de2835c 100644 --- a/packages/opencode/test/server/httpapi-instance.test.ts +++ b/packages/opencode/test/server/httpapi-instance.test.ts @@ -1,27 +1,32 @@ import { NodeHttpServer, NodeServices } from "@effect/platform-node" import { Flag } from "@opencode-ai/core/flag/flag" import { describe, expect } from "bun:test" -import { Config, Effect, FileSystem, Layer, Path } from "effect" +import { Config, Context, Effect, FileSystem, Layer, Path } from "effect" import { HttpClient, HttpClientRequest, HttpRouter, HttpServer } from "effect/unstable/http" import * as Socket from "effect/unstable/socket/Socket" +import { WorkspaceID } from "../../src/control-plane/schema" +import { ControlPaths } from "../../src/server/routes/instance/httpapi/groups/control" import { InstancePaths } from "../../src/server/routes/instance/httpapi/groups/instance" +import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session" import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server" +import { HEADER as FenceHeader } from "../../src/server/shared/fence" import { resetDatabase } from "../fixture/db" import { disposeAllInstances, tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" -// Flip the experimental HttpApi flag so backend selection telemetry on the -// production routes reports the right backend, and reset the database around -// the test so per-instance state does not leak between runs. resetDatabase() -// already calls disposeAllInstances(), so we don't repeat it. +// Flip the experimental workspaces flag so SyncEvent.run actually writes to +// EventSequenceTable (the source of truth the fence middleware reads). Reset +// the database around the test so per-instance state does not leak between +// runs. resetDatabase() already calls disposeAllInstances(), so we don't +// repeat it. const testStateLayer = Layer.effectDiscard( Effect.gen(function* () { - const originalHttpApi = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true + const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES + Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true yield* Effect.promise(() => resetDatabase()) yield* Effect.addFinalizer(() => Effect.promise(async () => { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = originalHttpApi + Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces await resetDatabase() }), ) @@ -44,10 +49,109 @@ const httpApiServerLayer = servedRoutes.pipe( ) const it = testEffect(Layer.mergeAll(testStateLayer, httpApiServerLayer)) +const handlerContext = Context.empty() as Context.Context const directoryHeader = (dir: string) => HttpClientRequest.setHeader("x-opencode-directory", dir) describe("instance HttpApi", () => { + it.live("serves the OpenAPI document", () => + Effect.gen(function* () { + const response = yield* HttpClient.get("/doc") + + expect(response.status).toBe(200) + expect(response.headers["content-type"]).toContain("application/json") + expect(yield* response.json).toMatchObject({ + openapi: expect.any(String), + info: expect.any(Object), + paths: expect.objectContaining({ + "/global/health": expect.any(Object), + "/session": expect.any(Object), + }), + }) + }), + ) + + it.live("emits a sync fence header for fixed-workspace mutations", () => + Effect.gen(function* () { + const originalWorkspaceID = Flag.OPENCODE_WORKSPACE_ID + Flag.OPENCODE_WORKSPACE_ID = WorkspaceID.ascending() + yield* Effect.addFinalizer(() => + Effect.sync(() => { + Flag.OPENCODE_WORKSPACE_ID = originalWorkspaceID + }), + ) + + const dir = yield* tmpdirScoped({ git: true }) + const response = yield* HttpClientRequest.post(SessionPaths.create).pipe( + directoryHeader(dir), + HttpClientRequest.bodyJson({ title: "fenced" }), + Effect.flatMap(HttpClient.execute), + ) + + expect(response.status).toBe(200) + expect(JSON.parse(response.headers[FenceHeader] ?? "{}")).not.toEqual({}) + }), + ) + + it.live("does not emit sync fence headers for fixed-workspace reads or no-op mutations", () => + Effect.gen(function* () { + const originalWorkspaceID = Flag.OPENCODE_WORKSPACE_ID + Flag.OPENCODE_WORKSPACE_ID = WorkspaceID.ascending() + yield* Effect.addFinalizer(() => + Effect.sync(() => { + Flag.OPENCODE_WORKSPACE_ID = originalWorkspaceID + }), + ) + + const dir = yield* tmpdirScoped({ git: true }) + const read = yield* HttpClientRequest.get(InstancePaths.path).pipe(directoryHeader(dir), HttpClient.execute) + const log = yield* HttpClientRequest.post(ControlPaths.log).pipe( + directoryHeader(dir), + HttpClientRequest.bodyJson({ service: "fence-test", level: "info", message: "noop" }), + Effect.flatMap(HttpClient.execute), + ) + + expect(read.status).toBe(200) + expect(read.headers[FenceHeader]).toBeUndefined() + expect(log.status).toBe(200) + expect(log.headers[FenceHeader]).toBeUndefined() + }), + ) + + it.live("rejects malformed permission and question request ids", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + const request = (path: string, init?: RequestInit) => + Effect.promise(() => + ExperimentalHttpApiServer.webHandler().handler( + new Request(`http://localhost${path}`, { + ...init, + headers: { "x-opencode-directory": dir, "content-type": "application/json", ...init?.headers }, + }), + handlerContext, + ), + ) + const [permission, questionReply, questionReject] = yield* Effect.all( + [ + request("/permission/invalid-permission-id/reply", { + method: "POST", + body: JSON.stringify({ reply: "once" }), + }), + request("/question/invalid-question-id/reply", { + method: "POST", + body: JSON.stringify({ answers: [["Yes"]] }), + }), + request("/question/invalid-question-id/reject", { method: "POST" }), + ], + { concurrency: "unbounded" }, + ) + + expect(permission.status).toBe(400) + expect(questionReply.status).toBe(400) + expect(questionReject.status).toBe(400) + }), + ) + it.live("serves path and VCS read endpoints", () => Effect.gen(function* () { const dir = yield* tmpdirScoped({ git: true }) diff --git a/packages/opencode/test/server/httpapi-json-parity.test.ts b/packages/opencode/test/server/httpapi-json-parity.test.ts deleted file mode 100644 index 656541be71..0000000000 --- a/packages/opencode/test/server/httpapi-json-parity.test.ts +++ /dev/null @@ -1,254 +0,0 @@ -import { afterEach, describe, expect } from "bun:test" -import { Effect } from "effect" -import { Flag } from "@opencode-ai/core/flag/flag" -import { ModelID, ProviderID } from "../../src/provider/schema" -import { Instance } from "../../src/project/instance" -import { Server } from "../../src/server/server" -import { ExperimentalPaths } from "../../src/server/routes/instance/httpapi/groups/experimental" -import { FilePaths } from "../../src/server/routes/instance/httpapi/groups/file" -import { GlobalPaths } from "../../src/server/routes/instance/httpapi/groups/global" -import { InstancePaths } from "../../src/server/routes/instance/httpapi/groups/instance" -import { McpPaths } from "../../src/server/routes/instance/httpapi/groups/mcp" -import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty" -import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session" -import { MessageID, PartID } from "../../src/session/schema" -import { Session } from "@/session/session" -import * as Log from "@opencode-ai/core/util/log" -import { resetDatabase } from "../fixture/db" -import { disposeAllInstances, provideInstance, tmpdir } from "../fixture/fixture" -import { it } from "../lib/effect" - -void Log.init({ print: false }) - -const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI - -function app(experimental: boolean) { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = experimental - return experimental ? Server.Default().app : Server.Legacy().app -} -type TestApp = ReturnType - -function pathFor(path: string, params: Record) { - return Object.entries(params).reduce((result, [key, value]) => result.replace(`:${key}`, value), path) -} - -const seedSessions = Effect.gen(function* () { - const svc = yield* Session.Service - const parent = yield* svc.create({ title: "parent" }) - yield* svc.create({ title: "child", parentID: parent.id }) - const message = yield* svc.updateMessage({ - id: MessageID.ascending(), - role: "user", - sessionID: parent.id, - agent: "build", - model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") }, - time: { created: Date.now() }, - }) - yield* svc.updatePart({ - id: PartID.ascending(), - sessionID: parent.id, - messageID: message.id, - type: "text", - text: "hello", - }) - return { parent, message } -}) - -function withTmp( - options: Parameters[0], - fn: (tmp: Awaited>) => Effect.Effect, -) { - return Effect.acquireRelease( - Effect.promise(() => tmpdir(options)), - (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), - ).pipe(Effect.flatMap((tmp) => fn(tmp).pipe(provideInstance(tmp.path)))) -} - -function readJson(label: string, serverApp: TestApp, path: string, headers: HeadersInit) { - return Effect.promise(async () => { - const response = await serverApp.request(path, { headers }) - if (response.status !== 200) throw new Error(`${label} returned ${response.status}: ${await response.text()}`) - return await response.json() - }) -} - -function expectJsonParity(input: { - label: string - legacy: TestApp - httpapi: TestApp - path: string - headers: HeadersInit -}) { - return Effect.gen(function* () { - const legacy = yield* readJson(input.label, input.legacy, input.path, input.headers) - const httpapi = yield* readJson(input.label, input.httpapi, input.path, input.headers) - expect({ label: input.label, body: httpapi }).toEqual({ label: input.label, body: legacy }) - return httpapi - }) -} - -afterEach(async () => { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original - await disposeAllInstances() - await resetDatabase() -}) - -describe("HttpApi JSON parity", () => { - it.live( - "matches legacy JSON shape for safe GET endpoints", - withTmp( - { - git: true, - config: { - formatter: false, - lsp: false, - mcp: { - demo: { - type: "local", - command: ["echo", "demo"], - enabled: false, - }, - }, - }, - }, - (tmp) => - Effect.gen(function* () { - yield* Effect.promise(() => Bun.write(`${tmp.path}/hello.txt`, "hello\n")) - - const headers = { "x-opencode-directory": tmp.path } - const legacy = app(false) - const httpapi = app(true) - - yield* Effect.forEach( - [ - { label: "global.health", path: GlobalPaths.health, headers: {} }, - { label: "global.config", path: GlobalPaths.config, headers: {} }, - { label: "instance.path", path: InstancePaths.path, headers }, - { label: "instance.vcs", path: InstancePaths.vcs, headers }, - { label: "instance.vcsDiff", path: `${InstancePaths.vcsDiff}?mode=git`, headers }, - { label: "instance.command", path: InstancePaths.command, headers }, - { label: "instance.agent", path: InstancePaths.agent, headers }, - { label: "instance.skill", path: InstancePaths.skill, headers }, - { label: "instance.lsp", path: InstancePaths.lsp, headers }, - { label: "instance.formatter", path: InstancePaths.formatter, headers }, - { label: "config.get", path: "/config", headers }, - { label: "config.providers", path: "/config/providers", headers }, - { label: "project.list", path: "/project", headers }, - { label: "project.current", path: "/project/current", headers }, - { label: "provider.list", path: "/provider", headers }, - { label: "provider.auth", path: "/provider/auth", headers }, - { label: "permission.list", path: "/permission", headers }, - { label: "question.list", path: "/question", headers }, - { label: "mcp.status", path: McpPaths.status, headers }, - { label: "pty.shells", path: PtyPaths.shells, headers }, - { label: "pty.list", path: PtyPaths.list, headers }, - { label: "file.list", path: `${FilePaths.list}?${new URLSearchParams({ path: "." })}`, headers }, - { - label: "file.content", - path: `${FilePaths.content}?${new URLSearchParams({ path: "hello.txt" })}`, - headers, - }, - { label: "file.status", path: FilePaths.status, headers }, - { - label: "find.file", - path: `${FilePaths.findFile}?${new URLSearchParams({ query: "hello", dirs: "false" })}`, - headers, - }, - { - label: "find.text", - path: `${FilePaths.findText}?${new URLSearchParams({ pattern: "hello" })}`, - headers, - }, - { - label: "find.symbol", - path: `${FilePaths.findSymbol}?${new URLSearchParams({ query: "hello" })}`, - headers, - }, - { label: "experimental.console", path: ExperimentalPaths.console, headers }, - { label: "experimental.consoleOrgs", path: ExperimentalPaths.consoleOrgs, headers }, - { label: "experimental.toolIDs", path: ExperimentalPaths.toolIDs, headers }, - { label: "experimental.worktree", path: ExperimentalPaths.worktree, headers }, - { label: "experimental.resource", path: ExperimentalPaths.resource, headers }, - ], - (input) => expectJsonParity({ ...input, legacy, httpapi }), - { concurrency: 1 }, - ) - }), - ), - ) - - it.live( - "matches legacy JSON shape for session read endpoints", - withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) => - Effect.gen(function* () { - const headers = { "x-opencode-directory": tmp.path } - const seeded = yield* seedSessions.pipe(Effect.provide(Session.defaultLayer)) - const legacy = app(false) - const httpapi = app(true) - - const rootsFalse = yield* expectJsonParity({ - label: "session.list roots false", - legacy, - httpapi, - path: `${SessionPaths.list}?roots=false`, - headers, - }) - expect((rootsFalse as Session.Info[]).map((session) => session.id)).toContain(seeded.parent.id) - expect((rootsFalse as Session.Info[]).length).toBe(2) - - const experimentalRootsFalse = yield* expectJsonParity({ - label: "experimental.session roots false", - legacy, - httpapi, - path: `${ExperimentalPaths.session}?${new URLSearchParams({ directory: tmp.path, limit: "10", roots: "false" })}`, - headers, - }) - expect((experimentalRootsFalse as Session.GlobalInfo[]).length).toBe(2) - - const experimentalArchivedFalse = yield* expectJsonParity({ - label: "experimental.session archived false", - legacy, - httpapi, - path: `${ExperimentalPaths.session}?${new URLSearchParams({ directory: tmp.path, limit: "10", archived: "false" })}`, - headers, - }) - expect((experimentalArchivedFalse as Session.GlobalInfo[]).length).toBe(2) - - yield* Effect.forEach( - [ - { label: "session.list roots", path: `${SessionPaths.list}?roots=true`, headers }, - { label: "session.list all", path: SessionPaths.list, headers }, - { label: "session.get", path: pathFor(SessionPaths.get, { sessionID: seeded.parent.id }), headers }, - { - label: "session.children", - path: pathFor(SessionPaths.children, { sessionID: seeded.parent.id }), - headers, - }, - { - label: "session.messages", - path: pathFor(SessionPaths.messages, { sessionID: seeded.parent.id }), - headers, - }, - { - label: "session.messages empty before", - path: `${pathFor(SessionPaths.messages, { sessionID: seeded.parent.id })}?before=`, - headers, - }, - { - label: "session.message", - path: pathFor(SessionPaths.message, { sessionID: seeded.parent.id, messageID: seeded.message.id }), - headers, - }, - { - label: "experimental.session", - path: `${ExperimentalPaths.session}?${new URLSearchParams({ directory: tmp.path, limit: "10" })}`, - headers, - }, - ], - (input) => expectJsonParity({ ...input, legacy, httpapi }), - { concurrency: 1 }, - ) - }), - ), - ) -}) diff --git a/packages/opencode/test/server/httpapi-listen.test.ts b/packages/opencode/test/server/httpapi-listen.test.ts index b49fbe98b5..b2ff28ec67 100644 --- a/packages/opencode/test/server/httpapi-listen.test.ts +++ b/packages/opencode/test/server/httpapi-listen.test.ts @@ -10,7 +10,6 @@ import { disposeAllInstances, tmpdir } from "../fixture/fixture" void Log.init({ print: false }) const original = { - OPENCODE_EXPERIMENTAL_HTTPAPI: Flag.OPENCODE_EXPERIMENTAL_HTTPAPI, OPENCODE_SERVER_PASSWORD: Flag.OPENCODE_SERVER_PASSWORD, OPENCODE_SERVER_USERNAME: Flag.OPENCODE_SERVER_USERNAME, envPassword: process.env.OPENCODE_SERVER_PASSWORD, @@ -20,7 +19,6 @@ const auth = { username: "opencode", password: "listen-secret" } const testPty = process.platform === "win32" ? test.skip : test afterEach(async () => { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original.OPENCODE_EXPERIMENTAL_HTTPAPI Flag.OPENCODE_SERVER_PASSWORD = original.OPENCODE_SERVER_PASSWORD Flag.OPENCODE_SERVER_USERNAME = original.OPENCODE_SERVER_USERNAME if (original.envPassword === undefined) delete process.env.OPENCODE_SERVER_PASSWORD @@ -31,8 +29,7 @@ afterEach(async () => { await resetDatabase() }) -async function startListener(backend: "effect-httpapi" | "hono" = "effect-httpapi") { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = backend === "effect-httpapi" +async function startListener() { Flag.OPENCODE_SERVER_PASSWORD = auth.password Flag.OPENCODE_SERVER_USERNAME = auth.username process.env.OPENCODE_SERVER_PASSWORD = auth.password @@ -40,8 +37,7 @@ async function startListener(backend: "effect-httpapi" | "hono" = "effect-httpap return Server.listen({ hostname: "127.0.0.1", port: 0 }) } -async function startNoAuthListener(backend: "effect-httpapi" | "hono" = "effect-httpapi") { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = backend === "effect-httpapi" +async function startNoAuthListener() { Flag.OPENCODE_SERVER_PASSWORD = undefined Flag.OPENCODE_SERVER_USERNAME = auth.username delete process.env.OPENCODE_SERVER_PASSWORD @@ -212,22 +208,6 @@ describe("HttpApi Server.listen", () => { } }) - testPty("serves PTY websocket tickets through legacy Hono Server.listen", async () => { - await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) - const listener = await startListener("hono") - try { - const info = await createCat(listener, tmp.path) - const ticket = await connectTicket(listener, info.id, tmp.path) - const ws = await openSocket(socketURL(listener, info.id, tmp.path, ticket.ticket)) - const message = waitForMessage(ws, (message) => message.includes("ping-hono-ticket")) - ws.send("ping-hono-ticket\n") - expect(await message).toContain("ping-hono-ticket") - ws.close(1000) - } finally { - await stop(listener, "timed out cleaning up hono listener").catch(() => undefined) - } - }) - testPty("rejects unsafe PTY ticket mint and connect requests", async () => { await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) const listener = await startListener() @@ -300,20 +280,18 @@ describe("HttpApi Server.listen", () => { } }) - for (const backend of ["effect-httpapi", "hono"] as const) { - testPty(`keeps PTY websocket tickets optional when server auth is disabled (${backend})`, async () => { - await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) - const listener = await startNoAuthListener(backend) - try { - const info = await createCat(listener, tmp.path) - const ws = await openSocket(socketURL(listener, info.id, tmp.path)) - const message = waitForMessage(ws, (message) => message.includes(`ping-no-auth-${backend}`)) - ws.send(`ping-no-auth-${backend}\n`) - expect(await message).toContain(`ping-no-auth-${backend}`) - ws.close(1000) - } finally { - await stop(listener, "timed out cleaning up no-auth listener").catch(() => undefined) - } - }) - } + testPty("keeps PTY websocket tickets optional when server auth is disabled", async () => { + await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) + const listener = await startNoAuthListener() + try { + const info = await createCat(listener, tmp.path) + const ws = await openSocket(socketURL(listener, info.id, tmp.path)) + const message = waitForMessage(ws, (message) => message.includes("ping-no-auth")) + ws.send("ping-no-auth\n") + expect(await message).toContain("ping-no-auth") + ws.close(1000) + } finally { + await stop(listener, "timed out cleaning up no-auth listener").catch(() => undefined) + } + }) }) diff --git a/packages/opencode/test/server/httpapi-mcp.test.ts b/packages/opencode/test/server/httpapi-mcp.test.ts index f442df5770..b6c7aebcd2 100644 --- a/packages/opencode/test/server/httpapi-mcp.test.ts +++ b/packages/opencode/test/server/httpapi-mcp.test.ts @@ -1,7 +1,6 @@ import { afterEach, describe, expect, test } from "bun:test" import { Context, Effect, FileSystem, Layer, Path } from "effect" import { NodeFileSystem, NodePath } from "@effect/platform-node" -import { Flag } from "@opencode-ai/core/flag/flag" import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server" import { McpPaths } from "../../src/server/routes/instance/httpapi/groups/mcp" import { Instance } from "../../src/project/instance" @@ -15,13 +14,11 @@ import { testEffect } from "../lib/effect" void Log.init({ print: false }) -const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI const context = Context.empty() as Context.Context const it = testEffect(Layer.mergeAll(NodeFileSystem.layer, NodePath.layer)) -function app(experimental: boolean) { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = experimental - return experimental ? Server.Default().app : Server.Legacy().app +function app() { + return Server.Default().app } type TestApp = ReturnType @@ -79,7 +76,6 @@ const readResponse = Effect.fnUntraced(function* (input: { app: TestApp; path: s }) afterEach(async () => { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original await disposeAllInstances() await resetDatabase() }) @@ -165,23 +161,19 @@ describe("mcp HttpApi", () => { }) it.live( - "matches legacy unsupported OAuth error responses", + "returns unsupported OAuth error responses", withMcpProject((dir) => Effect.gen(function* () { const headers = { "x-opencode-directory": dir } - const legacy = app(false) - const httpapi = app(true) yield* Effect.forEach(["/mcp/demo/auth", "/mcp/demo/auth/authenticate"], (path) => Effect.gen(function* () { - const legacyResponse = yield* readResponse({ app: legacy, path, headers }) - const httpapiResponse = yield* readResponse({ app: httpapi, path, headers }) + const response = yield* readResponse({ app: app(), path, headers }) - expect(legacyResponse).toEqual({ + expect(response).toEqual({ status: 400, body: JSON.stringify({ error: "MCP server demo does not support OAuth" }), }) - expect(httpapiResponse).toEqual(legacyResponse) }), ) }), diff --git a/packages/opencode/test/server/httpapi-parity.test.ts b/packages/opencode/test/server/httpapi-parity.test.ts deleted file mode 100644 index 9d7eff4964..0000000000 --- a/packages/opencode/test/server/httpapi-parity.test.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { afterEach, describe, expect, test } from "bun:test" -import { Effect } from "effect" -import { Flag } from "@opencode-ai/core/flag/flag" -import * as Log from "@opencode-ai/core/util/log" -import { WithInstance } from "../../src/project/with-instance" -import { Server } from "../../src/server/server" -import { Session } from "@/session/session" -import { MessageID } from "../../src/session/schema" -import { ModelID, ProviderID } from "../../src/provider/schema" -import { resetDatabase } from "../fixture/db" -import { disposeAllInstances, tmpdir } from "../fixture/fixture" - -void Log.init({ print: false }) - -const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI - -afterEach(async () => { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original - await disposeAllInstances() - await resetDatabase() -}) - -function app(experimental: boolean) { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = experimental - return experimental ? Server.Default().app : Server.Legacy().app -} - -function runSession(fx: Effect.Effect) { - return Effect.runPromise(fx.pipe(Effect.provide(Session.defaultLayer))) -} - -function createSessionWithMessages(directory: string, count: number) { - return WithInstance.provide({ - directory, - fn: async () => { - const session = await runSession(Session.Service.use((svc) => svc.create({}))) - for (let i = 0; i < count; i++) { - await runSession( - Effect.gen(function* () { - const svc = yield* Session.Service - yield* svc.updateMessage({ - id: MessageID.ascending(), - role: "user", - sessionID: session.id, - agent: "build", - model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") }, - time: { created: Date.now() }, - }) - }), - ) - } - return session.id - }, - }) -} - -// ────────────────────────────────────────────────────────────────────────────── -// Reproducer 1: Link header should reflect the request's actual Host header, -// not "localhost". HttpApi uses `new URL(request.url, "http://localhost")` -// which embeds localhost because request.url is path-only. Fix: use -// `HttpServerRequest.toURL(request)` which honors the Host header. -// ────────────────────────────────────────────────────────────────────────────── -describe("Link header host", () => { - test("HttpApi pagination Link header echoes request host", async () => { - await using tmp = await tmpdir({ config: { formatter: false, lsp: false } }) - const sessionID = await createSessionWithMessages(tmp.path, 3) - - const response = await app(true).request(`/session/${sessionID}/message?limit=2`, { - headers: { - host: "opencode.test:4096", - "x-opencode-directory": tmp.path, - }, - }) - - expect(response.status).toBe(200) - const link = response.headers.get("link") - expect(link).not.toBeNull() - // Link should contain the request's Host, not "localhost". - expect(link).toContain("opencode.test") - expect(link).not.toContain("localhost") - }) -}) - -// ────────────────────────────────────────────────────────────────────────────── -// Reproducer 2: GET /session/{missing-id}/todo should return 404, not 500. -// The session.todo handler in HttpApi doesn't wrap with `mapNotFound`, so a -// `NotFoundError` from the service surfaces as a defect → 500. Hono's -// equivalent maps to 404 via `errors.notFound`. -// -// Affected endpoints (handlers without mapNotFound): todo, diff, summarize, -// fork, abort, init, deleteMessage, command, shell, revert, unrevert. -// -// FIXME: unskip when mapNotFound coverage is added (next PR). -// ────────────────────────────────────────────────────────────────────────────── -describe("404 mapping for missing session", () => { - test.todo("HttpApi /session/{missing}/todo returns 404 not 500", async () => { - await using tmp = await tmpdir({ config: { formatter: false, lsp: false } }) - - const response = await app(true).request("/session/ses_does_not_exist/todo", { - headers: { "x-opencode-directory": tmp.path }, - }) - - expect(response.status).toBe(404) - }) -}) - -// ────────────────────────────────────────────────────────────────────────────── -// Reproducer 3: 404 response body shape should match Hono's public NamedError -// envelope `{ name, data: { message } }`. SDK consumers read -// `error.data.message`, so returning an Effect built-in `{ _tag }` body is a -// compatibility break. -// ────────────────────────────────────────────────────────────────────────────── -describe("Error JSON shape parity", () => { - test("HttpApi 404 body matches Hono shape", async () => { - await using tmp = await tmpdir({ config: { formatter: false, lsp: false } }) - const headers = { "x-opencode-directory": tmp.path } - - const hono = await app(false).request("/session/ses_does_not_exist", { headers }) - const httpapi = await app(true).request("/session/ses_does_not_exist", { headers }) - - expect(httpapi.status).toBe(hono.status) - const body = (await httpapi.json()) as { name?: string; data?: { message?: string } } - expect(body).toEqual(await hono.json()) - expect(body.name).toBe("NotFoundError") - expect(typeof body.data?.message).toBe("string") - }) -}) diff --git a/packages/opencode/test/server/httpapi-promptasync-context.test.ts b/packages/opencode/test/server/httpapi-promptasync-context.test.ts new file mode 100644 index 0000000000..a7a66ff4f6 --- /dev/null +++ b/packages/opencode/test/server/httpapi-promptasync-context.test.ts @@ -0,0 +1,189 @@ +// Regression coverage for issue #26526's claim that promptAsync's +// Effect.forkIn loses the request's InstanceRef/WorkspaceRef. It does not — +// forkIn preserves Context.Reference values via standard fiber inheritance. +// +// The companion claim that the streaming prompt handler "captures and +// provides" those services is true and load-bearing: Stream.fromEffect's +// body runs detached from the request fiber's context, so the explicit +// Effect.provideService calls there are required, not defensive duplication. + +import { NodeHttpServer, NodeServices } from "@effect/platform-node" +import { Flag } from "@opencode-ai/core/flag/flag" +import { describe, expect } from "bun:test" +import { Deferred, Effect, Layer, Scope } from "effect" +import * as Stream from "effect/Stream" +import { HttpClient, HttpRouter, HttpServerResponse } from "effect/unstable/http" +import * as Socket from "effect/unstable/socket/Socket" +import { mkdir } from "node:fs/promises" +import { registerAdapter } from "../../src/control-plane/adapters" +import type { WorkspaceAdapter } from "../../src/control-plane/types" +import { Workspace } from "../../src/control-plane/workspace" +import { InstanceRef, WorkspaceRef } from "../../src/effect/instance-ref" +import { InstanceBootstrap } from "../../src/project/bootstrap" +import { InstanceLayer } from "../../src/project/instance-layer" +import { InstanceStore } from "../../src/project/instance-store" +import { Project } from "../../src/project/project" +import { instanceRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/instance-context" +import { workspaceRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/workspace-routing" +import { resetDatabase } from "../fixture/db" +import { disposeAllInstances, tmpdirScoped } from "../fixture/fixture" +import { testEffect } from "../lib/effect" + +const testStateLayer = Layer.effectDiscard( + Effect.gen(function* () { + const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES + yield* Effect.promise(() => resetDatabase()) + Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true + yield* Effect.addFinalizer(() => + Effect.promise(async () => { + Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces + await disposeAllInstances() + await resetDatabase() + }), + ) + }), +) + +const workspaceLayer = Workspace.defaultLayer.pipe( + Layer.provide(InstanceStore.defaultLayer), + Layer.provide(InstanceBootstrap.defaultLayer), +) + +const it = testEffect( + Layer.mergeAll( + testStateLayer, + NodeHttpServer.layerTest, + NodeServices.layer, + InstanceLayer.layer, + Project.defaultLayer, + workspaceLayer, + ), +) + +const instanceContextTestLayer = instanceRouterMiddleware + .combine(workspaceRouterMiddleware) + .layer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal)) + +const localAdapter = (directory: string): WorkspaceAdapter => ({ + name: "Local Test", + description: "Create a local test workspace", + configure: (info) => ({ ...info, name: "local-test", directory }), + create: async () => { + await mkdir(directory, { recursive: true }) + }, + async remove() {}, + target: () => ({ type: "local" as const, directory }), +}) + +const setupWorkspace = (kind: string) => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + yield* Project.use.fromDirectory(dir) + const projectID = yield* Project.Service.use((svc) => svc.fromDirectory(dir).pipe(Effect.map((p) => p.project.id))) + registerAdapter(projectID, kind, localAdapter(dir)) + const workspace = yield* Workspace.Service.use((svc) => + svc.create({ type: kind, branch: null, extra: null, projectID }), + ) + return { dir, workspace } + }) + +type Capture = { directory?: string; workspaceID?: string } + +const captureInstance = Effect.gen(function* () { + const instance = yield* InstanceRef + const workspaceID = yield* WorkspaceRef + return { directory: instance?.directory, workspaceID } satisfies Capture +}) + +describe("HttpApi handler context inheritance", () => { + // Mirrors handlers/session.ts:281 promptAsync. The forked fiber inherits + // the request's Context — including InstanceRef and WorkspaceRef provided + // by InstanceContextMiddleware — without any explicit re-provide. + it.live("Effect.forkIn preserves InstanceRef/WorkspaceRef across the fork", () => + Effect.gen(function* () { + const { dir, workspace } = yield* setupWorkspace("local-fork") + const capture = yield* Deferred.make() + + yield* HttpRouter.add( + "POST", + "/fork-probe", + Effect.gen(function* () { + const scope = yield* Scope.Scope + yield* Effect.gen(function* () { + yield* Deferred.succeed(capture, yield* captureInstance) + }).pipe(Effect.forkIn(scope, { startImmediately: true })) + return HttpServerResponse.empty({ status: 204 }) + }), + ).pipe(Layer.provide(instanceContextTestLayer), HttpRouter.serve, Layer.build) + + const response = yield* HttpClient.post( + `/fork-probe?directory=${encodeURIComponent(dir)}&workspace=${encodeURIComponent(workspace.id)}`, + ) + expect(response.status).toBe(204) + + const observed = yield* Deferred.await(capture).pipe(Effect.timeout("2 seconds")) + expect(observed.directory).toBe(dir) + expect(observed.workspaceID).toBe(workspace.id) + }), + ) + + // Mirrors handlers/session.ts:255 prompt — the streaming handler reads + // InstanceRef/WorkspaceRef in the request fiber and re-provides them to + // the Stream.fromEffect body. This test locks in why the explicit + // provides are required: without them the stream body sees undefined. + it.live("Stream.fromEffect body needs explicit provides — inheritance does not carry through", () => + Effect.gen(function* () { + const { dir, workspace } = yield* setupWorkspace("local-stream") + const withoutCapture = yield* Deferred.make() + const withCapture = yield* Deferred.make() + + yield* HttpRouter.add( + "POST", + "/stream-probe-without", + Effect.gen(function* () { + return HttpServerResponse.stream( + Stream.fromEffect( + Effect.gen(function* () { + yield* Deferred.succeed(withoutCapture, yield* captureInstance) + return "" + }), + ).pipe(Stream.encodeText), + { contentType: "application/json" }, + ) + }), + ).pipe(Layer.provide(instanceContextTestLayer), HttpRouter.serve, Layer.build) + + yield* HttpRouter.add( + "POST", + "/stream-probe-with", + Effect.gen(function* () { + const instance = yield* InstanceRef + const workspaceID = yield* WorkspaceRef + return HttpServerResponse.stream( + Stream.fromEffect( + Effect.gen(function* () { + yield* Deferred.succeed(withCapture, yield* captureInstance) + return "" + }).pipe(Effect.provideService(InstanceRef, instance), Effect.provideService(WorkspaceRef, workspaceID)), + ).pipe(Stream.encodeText), + { contentType: "application/json" }, + ) + }), + ).pipe(Layer.provide(instanceContextTestLayer), HttpRouter.serve, Layer.build) + + const queryString = `directory=${encodeURIComponent(dir)}&workspace=${encodeURIComponent(workspace.id)}` + const responseWithout = yield* HttpClient.post(`/stream-probe-without?${queryString}`) + yield* responseWithout.text + const responseWith = yield* HttpClient.post(`/stream-probe-with?${queryString}`) + yield* responseWith.text + + const without = yield* Deferred.await(withoutCapture).pipe(Effect.timeout("2 seconds")) + expect(without.directory).toBeUndefined() + expect(without.workspaceID).toBeUndefined() + + const withProvide = yield* Deferred.await(withCapture).pipe(Effect.timeout("2 seconds")) + expect(withProvide.directory).toBe(dir) + expect(withProvide.workspaceID).toBe(workspace.id) + }), + ) +}) diff --git a/packages/opencode/test/server/httpapi-provider.test.ts b/packages/opencode/test/server/httpapi-provider.test.ts index c45a81838a..68db6663d2 100644 --- a/packages/opencode/test/server/httpapi-provider.test.ts +++ b/packages/opencode/test/server/httpapi-provider.test.ts @@ -1,7 +1,6 @@ import { afterEach, describe, expect } from "bun:test" import { Effect, FileSystem, Layer, Path } from "effect" import { NodeFileSystem, NodePath } from "@effect/platform-node" -import { Flag } from "@opencode-ai/core/flag/flag" import { Instance } from "../../src/project/instance" import { WithInstance } from "../../src/project/with-instance" import { InstanceRuntime } from "../../src/project/instance-runtime" @@ -13,15 +12,61 @@ import { testEffect } from "../lib/effect" void Log.init({ print: false }) -const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI const it = testEffect(Layer.mergeAll(NodeFileSystem.layer, NodePath.layer)) const providerID = "test-oauth-parity" const oauthURL = "https://example.com/oauth" const oauthInstructions = "Finish OAuth" -function app(experimental: boolean) { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = experimental - return experimental ? Server.Default().app : Server.Legacy().app +function app() { + return Server.Default().app +} + +function providerListHasFetch(list: unknown) { + if (!Array.isArray(list)) return false + return list.some((item: unknown) => { + if (typeof item !== "object" || item === null || !("id" in item) || !("options" in item)) return false + if (item.id !== "google") return false + if (typeof item.options !== "object" || item.options === null) return false + return "fetch" in item.options + }) +} + +function hasProviderWithFetch(input: unknown, key: "all" | "providers") { + if (typeof input !== "object" || input === null) return false + if (key === "all") return "all" in input && providerListHasFetch(input.all) + return "providers" in input && providerListHasFetch(input.providers) +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + +function providerList(input: unknown, key: "all" | "providers") { + if (!isRecord(input)) return [] + if (!Array.isArray(input[key])) return [] + return input[key] +} + +function providerByID(input: unknown, key: "all" | "providers", id: string) { + return providerList(input, key).find((provider) => isRecord(provider) && provider.id === id) +} + +function hasNonZeroModelCost(input: unknown, key: "all" | "providers", id: string) { + const provider = providerByID(input, key, id) + if (!isRecord(provider) || !isRecord(provider.models)) return false + return Object.values(provider.models).some((model) => { + if (!isRecord(model) || !isRecord(model.cost) || !isRecord(model.cost.cache)) return false + return [model.cost.input, model.cost.output, model.cost.cache.read, model.cost.cache.write].some( + (cost) => typeof cost === "number" && cost > 0, + ) + }) +} + +function hasProviderMutationMarker(input: unknown, key: "all" | "providers", id: string) { + const provider = providerByID(input, key, id) + if (!isRecord(provider)) return false + if (provider.name === "mutated-provider") return true + return isRecord(provider.options) && provider.options.mutatedByPlugin === true } function requestAuthorize(input: { @@ -79,6 +124,73 @@ function writeProviderAuthPlugin(dir: string) { }) } +function writeFunctionOptionsPlugin(dir: string) { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + const path = yield* Path.Path + + yield* fs.makeDirectory(path.join(dir, ".opencode", "plugin"), { recursive: true }) + yield* fs.writeFileString( + path.join(dir, ".opencode", "plugin", "provider-function-options.ts"), + [ + "export default {", + ' id: "test.provider-function-options",', + " server: async () => ({", + " auth: {", + ' provider: "google",', + " loader: async (_getAuth, provider) => {", + " for (const model of Object.values(provider.models ?? {})) {", + " model.cost = { input: 0, output: 0 }", + " }", + " return {", + ' apiKey: "",', + " fetch: async (input, init) => fetch(input, init),", + " }", + " },", + " methods: [{ type: 'api', label: 'API key' }],", + " },", + " }),", + "}", + "", + ].join("\n"), + ) + }) +} + +function writeProviderModelsMutationPlugin(dir: string) { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + const path = yield* Path.Path + + yield* fs.makeDirectory(path.join(dir, ".opencode", "plugin"), { recursive: true }) + yield* fs.writeFileString( + path.join(dir, ".opencode", "plugin", "provider-models-mutation.ts"), + [ + "export default {", + ' id: "test.provider-models-mutation",', + " server: async () => ({", + " provider: {", + ' id: "google",', + " models: async (provider) => {", + " const models = Object.fromEntries(", + " Object.entries(provider.models ?? {}).map(([id, model]) => [id, { ...model }]),", + " )", + ' provider.name = "mutated-provider"', + " provider.options = { ...provider.options, mutatedByPlugin: true }", + " for (const model of Object.values(provider.models ?? {})) {", + " model.cost = { input: 0, output: 0 }", + " }", + " return models", + " },", + " },", + " }),", + "}", + "", + ].join("\n"), + ) + }) +} + function withProviderProject(self: (dir: string) => Effect.Effect) { return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem @@ -101,49 +213,37 @@ function withProviderProject(self: (dir: string) => Effect.Effect { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original await disposeAllInstances() await resetDatabase() }) describe("provider HttpApi", () => { it.live( - "matches legacy OAuth authorize response shapes", + "serves OAuth authorize response shapes", withProviderProject((dir) => Effect.gen(function* () { const headers = { "x-opencode-directory": dir, "content-type": "application/json" } - const legacy = app(false) - const httpapi = app(true) + const server = app() - const apiLegacy = yield* requestAuthorize({ - app: legacy, + const api = yield* requestAuthorize({ + app: server, providerID, method: 0, headers, }) - const apiHttpApi = yield* requestAuthorize({ - app: httpapi, - providerID, - method: 0, - headers, - }) - expect(apiLegacy).toEqual({ status: 200, body: "" }) - expect(apiHttpApi).toEqual(apiLegacy) + // method 0 (api-key style) — authorize() resolves with no further + // redirect; #26474 changed the wire format to JSON `null` so clients + // can `.json()` parse uniformly instead of getting an empty body + // that throws. + expect(api).toEqual({ status: 200, body: "null" }) - const oauthLegacy = yield* requestAuthorize({ - app: legacy, + const oauth = yield* requestAuthorize({ + app: server, providerID, method: 1, headers, }) - const oauthHttpApi = yield* requestAuthorize({ - app: httpapi, - providerID, - method: 1, - headers, - }) - expect(oauthHttpApi).toEqual(oauthLegacy) - expect(JSON.parse(oauthHttpApi.body)).toEqual({ + expect(JSON.parse(oauth.body)).toEqual({ url: oauthURL, method: "code", instructions: oauthInstructions, @@ -151,4 +251,74 @@ describe("provider HttpApi", () => { }), ), ) + + it.live("serves provider lists when auth loaders add runtime fetch options", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + const path = yield* Path.Path + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "opencode-test-" }) + const previous = process.env.OPENCODE_AUTH_CONTENT + + yield* fs.writeFileString( + path.join(dir, "opencode.json"), + JSON.stringify({ $schema: "https://opencode.ai/config.json", formatter: false, lsp: false }), + ) + yield* writeFunctionOptionsPlugin(dir) + yield* Effect.sync(() => { + process.env.OPENCODE_AUTH_CONTENT = JSON.stringify({ + google: { type: "oauth", refresh: "dummy", access: "dummy", expires: 9999999999999 }, + }) + }) + yield* Effect.addFinalizer(() => + Effect.sync(() => { + if (previous === undefined) delete process.env.OPENCODE_AUTH_CONTENT + if (previous !== undefined) process.env.OPENCODE_AUTH_CONTENT = previous + }), + ) + const headers = { "x-opencode-directory": dir } + const providerResponse = yield* Effect.promise(() => Promise.resolve(app().request("/provider", { headers }))) + const configResponse = yield* Effect.promise(() => + Promise.resolve(app().request("/config/providers", { headers })), + ) + + expect(providerResponse.status).toBe(200) + expect(configResponse.status).toBe(200) + + const providerBody = yield* Effect.promise(() => providerResponse.json()) + const configBody = yield* Effect.promise(() => configResponse.json()) + expect(hasProviderWithFetch(providerBody, "all")).toBe(false) + expect(hasProviderWithFetch(configBody, "providers")).toBe(false) + expect(hasNonZeroModelCost(providerBody, "all", "google")).toBe(true) + expect(hasNonZeroModelCost(configBody, "providers", "google")).toBe(true) + }), + ) + + it.live("keeps provider.models hook input mutations out of provider state", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + const path = yield* Path.Path + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "opencode-test-" }) + + yield* fs.writeFileString( + path.join(dir, "opencode.json"), + JSON.stringify({ $schema: "https://opencode.ai/config.json", formatter: false, lsp: false }), + ) + yield* writeProviderModelsMutationPlugin(dir) + + const headers = { "x-opencode-directory": dir } + const providerResponse = yield* Effect.promise(() => Promise.resolve(app().request("/provider", { headers }))) + const configResponse = yield* Effect.promise(() => + Promise.resolve(app().request("/config/providers", { headers })), + ) + + expect(providerResponse.status).toBe(200) + expect(configResponse.status).toBe(200) + + const providerBody = yield* Effect.promise(() => providerResponse.json()) + const configBody = yield* Effect.promise(() => configResponse.json()) + expect(hasProviderMutationMarker(providerBody, "all", "google")).toBe(false) + expect(hasProviderMutationMarker(configBody, "providers", "google")).toBe(false) + expect(hasNonZeroModelCost(providerBody, "all", "google")).toBe(true) + }), + ) }) diff --git a/packages/opencode/test/server/httpapi-pty.test.ts b/packages/opencode/test/server/httpapi-pty.test.ts index 5e63eae61c..987eba6b38 100644 --- a/packages/opencode/test/server/httpapi-pty.test.ts +++ b/packages/opencode/test/server/httpapi-pty.test.ts @@ -1,6 +1,5 @@ import { afterEach, describe, expect, test } from "bun:test" import { NodeHttpServer, NodeServices } from "@effect/platform-node" -import { Flag } from "@opencode-ai/core/flag/flag" import { PtyID } from "../../src/pty/schema" import { Instance } from "../../src/project/instance" import { Server } from "../../src/server/server" @@ -17,16 +16,13 @@ import { testEffect } from "../lib/effect" void Log.init({ print: false }) -const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI const testPty = process.platform === "win32" ? test.skip : test const testStateLayer = Layer.effectDiscard( Effect.gen(function* () { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true yield* Effect.promise(() => resetDatabase()) yield* Effect.addFinalizer(() => Effect.promise(async () => { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original await resetDatabase() }), ) @@ -50,9 +46,8 @@ const effectIt = testEffect( ), ) -function app(experimental = true) { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = experimental - return experimental ? Server.Default().app : Server.Legacy().app +function app() { + return Server.Default().app } function serverUrl() { @@ -62,7 +57,6 @@ function serverUrl() { const directoryHeader = (dir: string) => HttpClientRequest.setHeader("x-opencode-directory", dir) afterEach(async () => { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original await disposeAllInstances() await resetDatabase() }) @@ -121,18 +115,6 @@ describe("pty HttpApi bridge", () => { expect(missing.status).toBe(404) }) - test("matches Hono missing PTY error body", async () => { - await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) - const headers = { "x-opencode-directory": tmp.path } - const path = PtyPaths.get.replace(":ptyID", PtyID.ascending()) - - const hono = await app(false).request(path, { headers }) - const httpapi = await app().request(path, { headers }) - - expect(httpapi.status).toBe(hono.status) - expect(await httpapi.json()).toEqual(await hono.json()) - }) - test("returns 404 for missing PTY websocket before upgrade", async () => { await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) const response = await app().request(PtyPaths.connect.replace(":ptyID", PtyID.ascending()), { diff --git a/packages/opencode/test/server/httpapi-query-schema-drift.test.ts b/packages/opencode/test/server/httpapi-query-schema-drift.test.ts new file mode 100644 index 0000000000..d9f2b56cb0 --- /dev/null +++ b/packages/opencode/test/server/httpapi-query-schema-drift.test.ts @@ -0,0 +1,335 @@ +import { afterEach, describe, expect } from "bun:test" +import { Effect, Schema } from "effect" +import { OpenApi } from "effect/unstable/httpapi" +import { Flag } from "@opencode-ai/core/flag/flag" +import { Server } from "../../src/server/server" +import { SessionID } from "../../src/session/schema" +import { PublicApi } from "../../src/server/routes/instance/httpapi/public" +import { + FilePaths, + FileQuery, + FindFileQuery, + FindTextQuery, +} from "../../src/server/routes/instance/httpapi/groups/file" +import { + ExperimentalPaths, + SessionListQuery as ExperimentalSessionListQuery, + ToolListQuery, +} from "../../src/server/routes/instance/httpapi/groups/experimental" +import { InstancePaths, VcsDiffQuery } from "../../src/server/routes/instance/httpapi/groups/instance" +import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/groups/workspace" +import { + ListQuery as SessionListQuery, + MessagesQuery, + SessionPaths, +} from "../../src/server/routes/instance/httpapi/groups/session" +import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty" +import { MessagesQuery as V2MessagesQuery } from "../../src/server/routes/instance/httpapi/groups/v2/message" +import { SessionsQuery as V2SessionsQuery } from "../../src/server/routes/instance/httpapi/groups/v2/session" +import { QueryBoolean, QueryBooleanOpenApi } from "../../src/server/routes/instance/httpapi/groups/query" +import { resetDatabase } from "../fixture/db" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" +import { it } from "../lib/effect" + +const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES + +type Method = "get" | "post" | "put" | "delete" | "patch" +type QuerySchema = { readonly fields: Record } +type OpenApiSchema = { + readonly anyOf?: readonly OpenApiSchema[] + readonly enum?: readonly string[] + readonly maximum?: number + readonly minimum?: number + readonly pattern?: string + readonly type?: string +} +type OpenApiParameter = { readonly name: string; readonly in: string; readonly schema?: OpenApiSchema } +type OpenApiOperation = { readonly parameters?: readonly OpenApiParameter[] } + +const openApiDriftRoutes = [ + { method: "get", path: SessionPaths.list, query: SessionListQuery }, + { method: "get", path: SessionPaths.messages, query: MessagesQuery }, + { method: "get", path: FilePaths.findFile, query: FindFileQuery }, + { method: "get", path: FilePaths.findText, query: FindTextQuery }, + { method: "get", path: FilePaths.list, query: FileQuery }, + { method: "get", path: ExperimentalPaths.session, query: ExperimentalSessionListQuery }, + { method: "get", path: ExperimentalPaths.tool, query: ToolListQuery }, + { method: "get", path: InstancePaths.vcsDiff, query: VcsDiffQuery }, + { method: "get", path: "/api/session", query: V2SessionsQuery }, + { method: "get", path: "/api/session/:sessionID/message", query: V2MessagesQuery }, +] satisfies Array<{ method: Method; path: string; query: QuerySchema }> + +const numericSdkQueryParams = [ + { method: "get", path: ExperimentalPaths.session, name: "start", schema: { type: "number" } }, + { method: "get", path: ExperimentalPaths.session, name: "cursor", schema: { type: "number" } }, + { method: "get", path: ExperimentalPaths.session, name: "limit", schema: { type: "number" } }, + { method: "get", path: FilePaths.findFile, name: "limit", schema: { type: "integer", minimum: 1, maximum: 200 } }, + { method: "get", path: SessionPaths.list, name: "start", schema: { type: "number" } }, + { method: "get", path: SessionPaths.list, name: "limit", schema: { type: "number" } }, + { + method: "get", + path: SessionPaths.messages, + name: "limit", + schema: { type: "integer", minimum: 0, maximum: Number.MAX_SAFE_INTEGER }, + }, + { method: "get", path: "/api/session", name: "limit", schema: { type: "number" } }, + { method: "get", path: "/api/session", name: "start", schema: { type: "number" } }, + { method: "get", path: "/api/session/:sessionID/message", name: "limit", schema: { type: "number" } }, +] satisfies Array<{ method: Method; path: string; name: string; schema: OpenApiSchema }> + +const booleanSdkQueryParams = [ + { method: "get", path: ExperimentalPaths.session, name: "roots" }, + { method: "get", path: ExperimentalPaths.session, name: "archived" }, + { method: "get", path: SessionPaths.list, name: "roots" }, + { method: "get", path: "/api/session", name: "roots" }, +] satisfies Array<{ method: Method; path: string; name: string }> + +const queryParamPatterns = [ + { method: "get", path: SessionPaths.diff, name: "messageID", pattern: "^msg" }, +] satisfies Array<{ method: Method; path: string; name: string; pattern: string }> + +const pathParamPatterns = [ + { method: "get", path: SessionPaths.get, name: "sessionID", pattern: "^ses" }, + { method: "get", path: SessionPaths.message, name: "messageID", pattern: "^msg" }, + { method: "patch", path: SessionPaths.updatePart, name: "partID", pattern: "^prt" }, + { method: "post", path: SessionPaths.permissions, name: "permissionID", pattern: "^per" }, + { method: "post", path: "/permission/:requestID/reply", name: "requestID", pattern: "^per" }, + { method: "post", path: "/question/:requestID/reply", name: "requestID", pattern: "^que" }, + { method: "put", path: PtyPaths.update, name: "ptyID", pattern: "^pty" }, + { method: "delete", path: WorkspacePaths.remove, name: "id", pattern: "^wrk" }, +] satisfies Array<{ method: Method; path: string; name: string; pattern: string }> + +function app() { + return Server.Default().app +} + +function request(url: string, init?: RequestInit) { + return Effect.promise(async () => app().request(url, init)) +} + +function withTmp( + options: Parameters[0], + fn: (tmp: Awaited>) => Effect.Effect, +) { + return Effect.acquireRelease( + Effect.promise(() => tmpdir(options)), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ).pipe(Effect.flatMap(fn)) +} + +function openApiPath(path: string) { + return path.replace(/:([A-Za-z0-9_]+)/g, "{$1}") +} + +function queryParameters(operation: OpenApiOperation | undefined) { + return (operation?.parameters ?? []).filter((param) => param.in === "query").map((param) => param.name) +} + +function queryParameter(operation: OpenApiOperation | undefined, name: string) { + return (operation?.parameters ?? []).find((param) => param.in === "query" && param.name === name) +} + +function pathParameter(operation: OpenApiOperation | undefined, name: string) { + return (operation?.parameters ?? []).find((param) => param.in === "path" && param.name === name) +} + +function assertAdvertisedQueryParamsAreRuntimeFields(input: { + readonly method: Method + readonly operation: OpenApiOperation | undefined + readonly path: string + readonly query: QuerySchema +}) { + const runtimeFields = new Set(Object.keys(input.query.fields)) + const advertisedOnly = queryParameters(input.operation).filter((name) => !runtimeFields.has(name)) + + expect( + advertisedOnly, + `${input.method.toUpperCase()} ${input.path} advertises query params not accepted by runtime schema`, + ).toEqual([]) +} + +afterEach(async () => { + Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces + await disposeAllInstances() + await resetDatabase() +}) + +// Regression for the "OpenAPI advertises ?directory&workspace, runtime +// rejects them" drift class. Each affected route must accept both params +// without 400. +describe("httpapi query schema drift", () => { + const routingParams = (dir: string) => + `directory=${encodeURIComponent(dir)}&workspace=${encodeURIComponent("ws_test")}` + + const expectNotSchemaRejection = (status: number, url: string) => { + expect(status, `route ${url} 400'd, query schema is missing routing fields`).not.toBe(400) + } + + it.effect( + "boolean query schema accepts only true and false strings", + Effect.sync(() => { + const decode = Schema.decodeUnknownSync(QueryBoolean) + const encode = Schema.encodeUnknownSync(QueryBoolean) + + expect(decode("true")).toBe(true) + expect(decode("false")).toBe(false) + expect(encode(true)).toBe("true") + expect(encode(false)).toBe("false") + + for (const input of ["1", "yes", "True", "", true, false]) { + expect(() => decode(input)).toThrow() + } + }), + ) + + it.effect( + "OpenAPI query params are declared by runtime query schemas", + Effect.sync(() => { + const spec = OpenApi.fromApi(PublicApi) + for (const route of openApiDriftRoutes) { + assertAdvertisedQueryParamsAreRuntimeFields({ + ...route, + operation: spec.paths[openApiPath(route.path)]?.[route.method], + }) + } + }), + ) + + it.effect( + "OpenAPI query and path schemas preserve compatibility metadata", + Effect.sync(() => { + const spec = OpenApi.fromApi(PublicApi) + for (const expected of numericSdkQueryParams) { + expect( + queryParameter(spec.paths[openApiPath(expected.path)]?.[expected.method], expected.name)?.schema, + `${expected.method.toUpperCase()} ${expected.path} ${expected.name}`, + ).toEqual(expected.schema) + } + for (const expected of booleanSdkQueryParams) { + expect( + queryParameter(spec.paths[openApiPath(expected.path)]?.[expected.method], expected.name)?.schema, + `${expected.method.toUpperCase()} ${expected.path} ${expected.name}`, + ).toEqual(QueryBooleanOpenApi) + } + for (const expected of queryParamPatterns) { + expect( + queryParameter(spec.paths[openApiPath(expected.path)]?.[expected.method], expected.name)?.schema, + `${expected.method.toUpperCase()} ${expected.path} ${expected.name}`, + ).toEqual({ type: "string", pattern: expected.pattern }) + } + for (const expected of pathParamPatterns) { + expect( + pathParameter(spec.paths[openApiPath(expected.path)]?.[expected.method], expected.name)?.schema, + `${expected.method.toUpperCase()} ${expected.path} ${expected.name}`, + ).toEqual({ type: "string", pattern: expected.pattern }) + } + }), + ) + + it.effect( + "drift assertion catches spec-only workspace query params", + Effect.sync(() => { + expect(() => + assertAdvertisedQueryParamsAreRuntimeFields({ + method: "get", + operation: { + parameters: [ + { name: "directory", in: "query" }, + { name: "workspace", in: "query" }, + ], + }, + path: "/fixture", + query: { fields: {} }, + }), + ).toThrow("advertises query params not accepted by runtime schema") + }), + ) + + it.live( + "session list accepts directory and workspace", + withTmp({ config: { formatter: false, lsp: false } }, (tmp) => + Effect.gen(function* () { + const url = `/session?${routingParams(tmp.path)}` + const response = yield* request(url) + expectNotSchemaRejection(response.status, url) + }), + ), + ) + + it.live( + "session messages accepts directory and workspace", + withTmp({ config: { formatter: false, lsp: false } }, (tmp) => + Effect.gen(function* () { + const url = `/session/${SessionID.descending()}/message?limit=80&${routingParams(tmp.path)}` + const response = yield* request(url) + expectNotSchemaRejection(response.status, url) + }), + ), + ) + + it.live( + "file find/file accepts directory and workspace", + withTmp({ config: { formatter: false, lsp: false } }, (tmp) => + Effect.gen(function* () { + const url = `/find/file?query=foo&${routingParams(tmp.path)}` + const response = yield* request(url) + expectNotSchemaRejection(response.status, url) + }), + ), + ) + + it.live( + "file find/text accepts directory and workspace", + withTmp({ config: { formatter: false, lsp: false } }, (tmp) => + Effect.gen(function* () { + const url = `/find?pattern=foo&${routingParams(tmp.path)}` + const response = yield* request(url) + expectNotSchemaRejection(response.status, url) + }), + ), + ) + + it.live( + "file read accepts directory and workspace", + withTmp({ config: { formatter: false, lsp: false } }, (tmp) => + Effect.gen(function* () { + const url = `/file?path=foo&${routingParams(tmp.path)}` + const response = yield* request(url) + expectNotSchemaRejection(response.status, url) + }), + ), + ) + + it.live( + "experimental session list accepts directory and workspace", + withTmp({ config: { formatter: false, lsp: false } }, (tmp) => + Effect.gen(function* () { + const url = `/experimental/session?${routingParams(tmp.path)}` + const response = yield* request(url) + expectNotSchemaRejection(response.status, url) + }), + ), + ) + + it.live( + "experimental tool list accepts directory and workspace", + withTmp({ config: { formatter: false, lsp: false } }, (tmp) => + Effect.gen(function* () { + const url = `/experimental/tool?provider=anthropic&model=claude&${routingParams(tmp.path)}` + const response = yield* request(url) + expectNotSchemaRejection(response.status, url) + }), + ), + ) + + it.live( + "vcs diff accepts directory and workspace", + withTmp({ config: { formatter: false, lsp: false } }, (tmp) => + Effect.gen(function* () { + const url = `/vcs/diff?mode=working&${routingParams(tmp.path)}` + const response = yield* request(url) + expectNotSchemaRejection(response.status, url) + }), + ), + ) +}) diff --git a/packages/opencode/test/server/httpapi-raw-route-auth.test.ts b/packages/opencode/test/server/httpapi-raw-route-auth.test.ts index fd82e78639..b1d4af76b8 100644 --- a/packages/opencode/test/server/httpapi-raw-route-auth.test.ts +++ b/packages/opencode/test/server/httpapi-raw-route-auth.test.ts @@ -1,7 +1,6 @@ import { afterEach, describe, expect, test } from "bun:test" import { ConfigProvider, Layer } from "effect" import { HttpRouter } from "effect/unstable/http" -import { Flag } from "@opencode-ai/core/flag/flag" import { Instance } from "../../src/project/instance" import { EventPaths } from "../../src/server/routes/instance/httpapi/event" import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty" @@ -13,10 +12,7 @@ import * as Log from "@opencode-ai/core/util/log" void Log.init({ print: false }) -const originalHttpApi = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI - function app(input: { password?: string; username?: string }) { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true const handler = HttpRouter.toWebHandler( ExperimentalHttpApiServer.routes.pipe( Layer.provide( @@ -48,7 +44,6 @@ async function cancelBody(response: Response) { } afterEach(async () => { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = originalHttpApi await disposeAllInstances() await resetDatabase() }) diff --git a/packages/opencode/test/server/httpapi-schema-error-body.test.ts b/packages/opencode/test/server/httpapi-schema-error-body.test.ts new file mode 100644 index 0000000000..fe6a1caad0 --- /dev/null +++ b/packages/opencode/test/server/httpapi-schema-error-body.test.ts @@ -0,0 +1,162 @@ +import { afterEach, describe, expect } from "bun:test" +import { Effect } from "effect" +import { eq } from "drizzle-orm" +import * as Database from "@/storage/db" +import { ModelID, ProviderID } from "../../src/provider/schema" +import { WithInstance } from "../../src/project/with-instance" +import { Server } from "../../src/server/server" +import { Session } from "@/session/session" +import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session" +import { SyncPaths } from "../../src/server/routes/instance/httpapi/groups/sync" +import { MessageID, PartID } from "../../src/session/schema" +import { PartTable } from "@/session/session.sql" +import { resetDatabase } from "../fixture/db" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" +import { it } from "../lib/effect" + +afterEach(async () => { + await disposeAllInstances() + await resetDatabase() +}) + +const withTmp = ( + options: Parameters[0], + fn: (tmp: Awaited>) => Effect.Effect, +) => + Effect.acquireRelease( + Effect.promise(() => tmpdir(options)), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ).pipe(Effect.flatMap(fn)) + +async function seedCorruptStepFinishPart(directory: string) { + return WithInstance.provide({ + directory, + fn: () => + Effect.runPromise( + Effect.gen(function* () { + const session = yield* Session.Service + const info = yield* session.create({}) + const message = yield* session.updateMessage({ + id: MessageID.ascending(), + role: "user", + sessionID: info.id, + agent: "build", + model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") }, + time: { created: Date.now() }, + }) + const partID = PartID.ascending() + yield* session.updatePart({ + id: partID, + sessionID: info.id, + messageID: message.id, + type: "step-finish", + reason: "stop", + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + }) + // Schema.Finite still rejects NaN at encode — exact mirror of the + // corrupt row that broke the user's session in the OMO/Windows bug. + Database.use((db) => + db + .update(PartTable) + .set({ + data: { + type: "step-finish", + reason: "stop", + cost: 0, + tokens: { input: 0, output: NaN, reasoning: 0, cache: { read: 0, write: 0 } }, + } as never, // drizzle's .set() can't narrow the discriminated union + }) + .where(eq(PartTable.id, partID)) + .run(), + ) + return info.id + }).pipe(Effect.provide(Session.defaultLayer)), + ), + }) +} + +describe("schema-rejection wire shape", () => { + it.live( + "Payload schema rejection returns NamedError-shaped JSON, not empty", + withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) => + Effect.gen(function* () { + const res = yield* Effect.promise(async () => + Server.Default().app.request(SyncPaths.history, { + method: "POST", + headers: { "x-opencode-directory": tmp.path, "content-type": "application/json" }, + body: JSON.stringify({ aggregate: -1 }), + }), + ) + const body = yield* Effect.promise(async () => res.text()) + expect(res.status).toBe(400) + expect(res.headers.get("content-type") ?? "").toContain("application/json") + const parsed = JSON.parse(body) + expect(parsed).toMatchObject({ + name: "BadRequest", + data: { kind: expect.stringMatching(/^(Body|Payload)$/) }, + }) + expect(parsed.data.message).toEqual(expect.any(String)) + expect(parsed.data.message.length).toBeGreaterThan(0) + }), + ), + ) + + it.live( + "Query schema rejection returns NamedError-shaped JSON", + withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) => + Effect.gen(function* () { + // /find/file?limit=999999 violates the limit constraint check. + const url = `/find/file?query=foo&limit=999999&directory=${encodeURIComponent(tmp.path)}` + const res = yield* Effect.promise(async () => Server.Default().app.request(url)) + const body = yield* Effect.promise(async () => res.text()) + expect(res.status).toBe(400) + const parsed = JSON.parse(body) + expect(parsed).toMatchObject({ name: "BadRequest", data: { kind: "Query" } }) + }), + ), + ) + + it.live( + "rejected request body never echoes back unbounded — message is capped", + // Defense against DoS-amplification + secret-echo: Effect's Issue formatter + // dumps the rejected `actual` verbatim. A multi-MB invalid array would + // become a multi-MB 400 response and log line. Cap kicks in around 1KB. + withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) => + Effect.gen(function* () { + const huge = "X".repeat(50_000) + const res = yield* Effect.promise(async () => + Server.Default().app.request(SyncPaths.history, { + method: "POST", + headers: { "x-opencode-directory": tmp.path, "content-type": "application/json" }, + body: JSON.stringify({ aggregate: huge }), + }), + ) + const body = yield* Effect.promise(async () => res.text()) + expect(res.status).toBe(400) + // 1 KB cap + small JSON envelope ≈ <2 KB — never tens of KB. + expect(body.length).toBeLessThan(2 * 1024) + const parsed = JSON.parse(body) + expect(parsed.data.message).not.toContain(huge) + }), + ), + ) + + it.live( + "response-encode failure: corrupted stored row returns NamedError-shaped JSON with field path", + withTmp({ config: { formatter: false, lsp: false } }, (tmp) => + Effect.gen(function* () { + const sessionID = yield* Effect.promise(() => seedCorruptStepFinishPart(tmp.path)) + const url = `${SessionPaths.messages.replace(":sessionID", sessionID)}?limit=80&directory=${encodeURIComponent(tmp.path)}` + const res = yield* Effect.promise(async () => Server.Default().app.request(url)) + const body = yield* Effect.promise(async () => res.text()) + expect(res.status).toBe(400) + expect(res.headers.get("content-type") ?? "").toContain("application/json") + const parsed = JSON.parse(body) + expect(parsed).toMatchObject({ name: "BadRequest", data: { kind: "Body" } }) + // Field path in data.message — what made this PR worth shipping. + expect(parsed.data.message).toMatch(/output/) + }), + ), + ) +}) diff --git a/packages/opencode/test/server/httpapi-sdk.test.ts b/packages/opencode/test/server/httpapi-sdk.test.ts index 6d2df45078..0201f98c25 100644 --- a/packages/opencode/test/server/httpapi-sdk.test.ts +++ b/packages/opencode/test/server/httpapi-sdk.test.ts @@ -22,23 +22,21 @@ import { disposeAllInstances, tmpdir } from "../fixture/fixture" import { it } from "../lib/effect" const original = { - OPENCODE_EXPERIMENTAL_HTTPAPI: Flag.OPENCODE_EXPERIMENTAL_HTTPAPI, OPENCODE_SERVER_PASSWORD: Flag.OPENCODE_SERVER_PASSWORD, OPENCODE_SERVER_USERNAME: Flag.OPENCODE_SERVER_USERNAME, } -type Backend = "legacy" | "httpapi" +type ServerPath = "default" | "raw" type Sdk = ReturnType type SdkResult = { response: Response; data?: unknown; error?: unknown } type Captured = { status: number; data?: unknown; error?: unknown } type ProjectFixture = { sdk: Sdk; directory: string } type LlmProjectFixture = ProjectFixture & { llm: TestLLMServer["Service"] } -function app(backend: Backend, input?: { password?: string; username?: string }) { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = backend === "httpapi" +function app(serverPath: ServerPath, input?: { password?: string; username?: string }) { Flag.OPENCODE_SERVER_PASSWORD = input?.password Flag.OPENCODE_SERVER_USERNAME = input?.username - if (backend === "legacy") return Server.Legacy().app + if (serverPath === "default") return Server.Default().app const handler = HttpRouter.toWebHandler( ExperimentalHttpApiServer.routes.pipe( @@ -62,7 +60,7 @@ function app(backend: Backend, input?: { password?: string; username?: string }) } function client( - backend: Backend, + serverPath: ServerPath, directory?: string, input?: { password?: string; username?: string; headers?: Record }, ) { @@ -70,12 +68,12 @@ function client( baseUrl: "http://localhost", directory, headers: input?.headers, - fetch: serverFetch(backend, input), + fetch: serverFetch(serverPath, input), }) } -function serverFetch(backend: Backend, input?: { password?: string; username?: string }) { - const serverApp = app(backend, input) +function serverFetch(serverPath: ServerPath, input?: { password?: string; username?: string }) { + const serverApp = app(serverPath, input) return Object.assign( async (request: RequestInfo | URL, init?: RequestInit) => await serverApp.fetch(request instanceof Request ? request : new Request(request, init)), @@ -194,20 +192,20 @@ function httpapi(name: string, effect: Effect.Effect) { it.live(name, effect) } -function parity(name: string, scenario: (backend: Backend) => Effect.Effect) { +function serverPathParity(name: string, scenario: (serverPath: ServerPath) => Effect.Effect) { it.live( name, Effect.gen(function* () { - const legacy = yield* scenario("legacy") + const standard = yield* scenario("default") yield* resetState() - const httpapi = yield* scenario("httpapi") - expect(httpapi).toEqual(legacy) + const raw = yield* scenario("raw") + expect(raw).toEqual(standard) }), ) } function withProject( - backend: Backend, + serverPath: ServerPath, options: { git?: boolean; config?: Partial; setup?: (dir: string) => Effect.Effect }, run: (input: ProjectFixture) => Effect.Effect, ) { @@ -216,18 +214,36 @@ function withProject( (tmp) => call(() => tmp[Symbol.asyncDispose]()).pipe(Effect.ignore), ).pipe( Effect.tap((tmp) => options.setup?.(tmp.path) ?? Effect.void), - Effect.flatMap((tmp) => run({ sdk: client(backend, tmp.path), directory: tmp.path })), + Effect.flatMap((tmp) => run({ sdk: client(serverPath, tmp.path), directory: tmp.path })), ) } -function withStandardProject(backend: Backend, run: (input: ProjectFixture) => Effect.Effect) { - return withProject(backend, { setup: writeStandardFiles }, run) +function withStandardProject(serverPath: ServerPath, run: (input: ProjectFixture) => Effect.Effect) { + return withProject(serverPath, { setup: writeStandardFiles }, run) } -function withFakeLlm(backend: Backend, run: (input: LlmProjectFixture) => Effect.Effect) { +function withFakeLlm(serverPath: ServerPath, run: (input: LlmProjectFixture) => Effect.Effect) { return Effect.gen(function* () { const llm = yield* TestLLMServer - return yield* withProject(backend, { config: providerConfig(llm.url) }, (input) => run({ ...input, llm })) + return yield* withProject(serverPath, { config: providerConfig(llm.url) }, (input) => run({ ...input, llm })) + }).pipe(Effect.provide(TestLLMServer.layer)) +} + +function withFakeLlmProject( + serverPath: ServerPath, + options: { setup?: (dir: string) => Effect.Effect }, + run: (input: LlmProjectFixture) => Effect.Effect, +) { + return Effect.gen(function* () { + const llm = yield* TestLLMServer + return yield* withProject( + serverPath, + { + config: providerConfig(llm.url), + setup: options.setup, + }, + (input) => run({ ...input, llm }), + ) }).pipe(Effect.provide(TestLLMServer.layer)) } @@ -238,6 +254,21 @@ function writeStandardFiles(dir: string) { ]).pipe(Effect.asVoid) } +function writeProjectSkill(dir: string) { + return call(() => + Bun.write( + path.join(dir, ".opencode", "skills", "project-rest-skill", "SKILL.md"), + `--- +name: project-rest-skill +description: A project skill visible to REST API prompts. +--- + +# Project REST Skill +`, + ), + ).pipe(Effect.asVoid) +} + function seedMessage(directory: string, sessionID: string) { const id = SessionID.make(sessionID) return call( @@ -273,7 +304,6 @@ function seedMessage(directory: string, sessionID: string) { } afterEach(async () => { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original.OPENCODE_EXPERIMENTAL_HTTPAPI Flag.OPENCODE_SERVER_PASSWORD = original.OPENCODE_SERVER_PASSWORD Flag.OPENCODE_SERVER_USERNAME = original.OPENCODE_SERVER_USERNAME await disposeAllInstances() @@ -284,7 +314,7 @@ describe("HttpApi SDK", () => { httpapi( "uses the generated SDK for global and control routes", Effect.gen(function* () { - const sdk = client("httpapi") + const sdk = client("raw") const health = yield* call(() => sdk.global.health()) const log = yield* call(() => sdk.app.log({ service: "httpapi-sdk-test", level: "info", message: "hello" })) @@ -301,7 +331,7 @@ describe("HttpApi SDK", () => { httpapi( "uses the generated SDK for safe instance routes", - withProject("httpapi", { git: false, setup: writeStandardFiles }, ({ sdk }) => + withProject("raw", { git: false, setup: writeStandardFiles }, ({ sdk }) => Effect.gen(function* () { const file = yield* call(() => sdk.file.read({ path: "hello.txt" })) const session = yield* call(() => sdk.session.create({ title: "sdk" })) @@ -324,9 +354,9 @@ describe("HttpApi SDK", () => { ), ) - parity("matches generated SDK global and control behavior across backends", (backend) => + serverPathParity("matches generated SDK global and control behavior", (serverPath) => Effect.gen(function* () { - const sdk = client(backend) + const sdk = client(serverPath) const health = yield* capture(() => sdk.global.health()) const log = yield* capture(() => sdk.app.log({ service: "sdk-parity", level: "info", message: "hello" })) const invalidAuth = yield* capture(() => sdk.auth.set({ providerID: "test" })) @@ -339,22 +369,22 @@ describe("HttpApi SDK", () => { }), ) - parity("matches generated SDK global event stream across backends", (backend) => - firstEvent(() => client(backend).global.event({ signal: AbortSignal.timeout(1_000) })).pipe( + serverPathParity("matches generated SDK global event stream", (serverPath) => + firstEvent(() => client(serverPath).global.event({ signal: AbortSignal.timeout(1_000) })).pipe( Effect.map((event) => ({ type: record(record(event).payload).type })), ), ) - parity("matches generated SDK instance event stream across backends", (backend) => - withStandardProject(backend, ({ sdk }) => + serverPathParity("matches generated SDK instance event stream", (serverPath) => + withStandardProject(serverPath, ({ sdk }) => firstEvent(() => sdk.event.subscribe(undefined, { signal: AbortSignal.timeout(1_000) })).pipe( Effect.map((event) => ({ type: record(record(event).payload).type })), ), ), ) - parity("matches generated SDK missing session errors across backends", (backend) => - withStandardProject(backend, ({ sdk }) => + serverPathParity("matches generated SDK missing session errors", (serverPath) => + withStandardProject(serverPath, ({ sdk }) => Effect.gen(function* () { const sessionID = "ses_missing" const expected = { @@ -364,8 +394,16 @@ describe("HttpApi SDK", () => { const missing = yield* capture(() => sdk.session.get({ sessionID })) const thrown = yield* captureThrown(() => sdk.session.get({ sessionID }, { throwOnError: true })) + // Result-tuple path: error body is preserved as-is so existing + // consumers reading `result.error.name` / `JSON.stringify(error)` + // keep working byte-for-byte. expect(missing.error).toEqual(expected) - expect(thrown).toEqual(expected) + // throwOnError path: SDK wraps the body in a real Error with the + // server's message, with the original parsed body preserved under + // `.cause.body`. + expect(thrown).toBeInstanceOf(Error) + expect((thrown as Error).message).toBe(expected.data.message) + expect(((thrown as Error).cause as { body: unknown }).body).toEqual(expected) return { status: missing.status, error: missing.error, @@ -375,8 +413,8 @@ describe("HttpApi SDK", () => { ), ) - parity("formats missing session validation errors for -s", (backend) => - withStandardProject(backend, ({ directory }) => + serverPathParity("formats missing session validation errors for -s", (serverPath) => + withStandardProject(serverPath, ({ directory }) => Effect.gen(function* () { const sessionID = "ses_206f84f18ffeZ6hhD7pFYAiW5T" const thrown = yield* captureThrown(() => @@ -384,7 +422,7 @@ describe("HttpApi SDK", () => { url: "http://localhost", directory, sessionID, - fetch: serverFetch(backend), + fetch: serverFetch(serverPath), }), ) expect(errorMessage(thrown)).toBe(`Session not found: ${sessionID}`) @@ -393,20 +431,21 @@ describe("HttpApi SDK", () => { ), ) - parity("matches generated SDK basic auth behavior across backends", (backend) => - withStandardProject(backend, ({ directory }) => + httpapi( + "uses generated SDK basic auth behavior", + withStandardProject("raw", ({ directory }) => Effect.gen(function* () { const missing = yield* capture(() => - client(backend, directory, { password: "secret" }).file.read({ path: "hello.txt" }), + client("raw", directory, { password: "secret" }).file.read({ path: "hello.txt" }), ) const bad = yield* capture(() => - client(backend, directory, { + client("raw", directory, { password: "secret", headers: { authorization: authorization("opencode", "wrong") }, }).file.read({ path: "hello.txt" }), ) const good = yield* capture(() => - client(backend, directory, { + client("raw", directory, { password: "secret", headers: { authorization: authorization("opencode", "secret") }, }).file.read({ path: "hello.txt" }), @@ -420,8 +459,8 @@ describe("HttpApi SDK", () => { ), ) - parity("matches generated SDK instance read routes across backends", (backend) => - withStandardProject(backend, ({ sdk, directory }) => + serverPathParity("matches generated SDK instance read routes", (serverPath) => + withStandardProject(serverPath, ({ sdk, directory }) => Effect.gen(function* () { const project = yield* capture(() => sdk.project.current()) const projects = yield* capture(() => sdk.project.list()) @@ -471,8 +510,8 @@ describe("HttpApi SDK", () => { ), ) - parity("matches generated SDK session lifecycle routes across backends", (backend) => - withStandardProject(backend, ({ sdk }) => + serverPathParity("matches generated SDK session lifecycle routes", (serverPath) => + withStandardProject(serverPath, ({ sdk }) => Effect.gen(function* () { const parent = yield* capture(() => sdk.session.create({ title: "parent" })) const parentID = String(record(parent.data).id) @@ -524,8 +563,8 @@ describe("HttpApi SDK", () => { ), ) - parity("matches generated SDK session message and part routes across backends", (backend) => - withStandardProject(backend, ({ sdk, directory }) => + serverPathParity("matches generated SDK session message and part routes", (serverPath) => + withStandardProject(serverPath, ({ sdk, directory }) => Effect.gen(function* () { const session = yield* capture(() => sdk.session.create({ title: "messages" })) const sessionID = String(record(session.data).id) @@ -576,8 +615,8 @@ describe("HttpApi SDK", () => { ), ) - parity("matches generated SDK prompt no-reply routes across backends", (backend) => - withStandardProject(backend, ({ sdk }) => + serverPathParity("matches generated SDK prompt no-reply routes", (serverPath) => + withStandardProject(serverPath, ({ sdk }) => Effect.gen(function* () { const session = yield* capture(() => sdk.session.create({ title: "prompt" })) const sessionID = String(record(session.data).id) @@ -613,8 +652,8 @@ describe("HttpApi SDK", () => { ), ) - parity("matches generated SDK prompt streaming through fake LLM across backends", (backend) => - withFakeLlm(backend, ({ sdk, llm }) => + serverPathParity("matches generated SDK prompt streaming through fake LLM", (serverPath) => + withFakeLlm(serverPath, ({ sdk, llm }) => Effect.gen(function* () { yield* llm.text("fake world", { usage: { input: 11, output: 7 } }) const session = yield* capture(() => @@ -647,8 +686,38 @@ describe("HttpApi SDK", () => { ), ) - parity("matches generated SDK TUI validation and command routes across backends", (backend) => - withStandardProject(backend, ({ sdk }) => + httpapi( + "includes project skills in REST API async prompt context", + withFakeLlmProject("default", { setup: writeProjectSkill }, ({ sdk, llm }) => + Effect.gen(function* () { + yield* llm.text("skill context ok", { usage: { input: 11, output: 7 } }) + const session = yield* capture(() => + sdk.session.create({ + title: "project skill prompt", + permission: [{ permission: "*", pattern: "*", action: "allow" }], + }), + ) + const sessionID = String(record(session.data).id) + const prompt = yield* capture(() => + sdk.session.promptAsync({ + sessionID, + agent: "build", + model: { providerID: "test", modelID: "test-model" }, + parts: [{ type: "text", text: "hello skill context" }], + }), + ) + yield* llm.wait(1) + const inputs = yield* llm.inputs + + expect(session.status).toBe(200) + expect(prompt.status).toBe(204) + expect(JSON.stringify(inputs[0])).toContain("project-rest-skill") + }), + ), + ) + + serverPathParity("matches generated SDK TUI validation and command routes", (serverPath) => + withStandardProject(serverPath, ({ sdk }) => Effect.gen(function* () { const session = yield* capture(() => sdk.session.create({ title: "tui" })) const sessionID = String(record(session.data).id) @@ -698,8 +767,8 @@ describe("HttpApi SDK", () => { ), ) - parity("matches generated SDK project git initialization across backends", (backend) => - withProject(backend, { git: false }, ({ sdk, directory }) => + serverPathParity("matches generated SDK project git initialization", (serverPath) => + withProject(serverPath, { git: false }, ({ sdk, directory }) => Effect.gen(function* () { const before = yield* capture(() => sdk.project.current()) const init = yield* capture(() => sdk.project.initGit()) diff --git a/packages/opencode/test/server/httpapi-session.test.ts b/packages/opencode/test/server/httpapi-session.test.ts index c1d82446b9..210863e0c9 100644 --- a/packages/opencode/test/server/httpapi-session.test.ts +++ b/packages/opencode/test/server/httpapi-session.test.ts @@ -30,16 +30,14 @@ import { it } from "../lib/effect" void Log.init({ print: false }) -const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES const workspaceLayer = Workspace.defaultLayer.pipe( Layer.provide(InstanceStore.defaultLayer), Layer.provide(InstanceBootstrap.defaultLayer), ) -function app(experimental = true) { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = experimental - return experimental ? Server.Default().app : Server.Legacy().app +function app() { + return Server.Default().app } function runSession(fx: Effect.Effect) { @@ -119,10 +117,6 @@ function request(path: string, init?: RequestInit) { return Effect.promise(async () => app().request(path, init)) } -function requestWithBackend(experimental: boolean, path: string, init?: RequestInit) { - return Effect.promise(async () => app(experimental).request(path, init)) -} - function json(response: Response) { return Effect.promise(async () => { if (response.status !== 200) throw new Error(await response.text()) @@ -149,7 +143,6 @@ function withTmp( } afterEach(async () => { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces await disposeAllInstances() await resetDatabase() @@ -198,7 +191,7 @@ describe("session HttpApi", () => { ) it.live( - "serves read routes through Hono bridge", + "serves read routes", withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) => Effect.gen(function* () { const headers = { "x-opencode-directory": tmp.path } @@ -305,7 +298,37 @@ describe("session HttpApi", () => { ) it.live( - "serves lifecycle mutation routes through Hono bridge", + "serves sessions with migrated summary diffs missing file details", + withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) => + Effect.gen(function* () { + const session = yield* createSession(tmp.path, { title: "legacy diff" }) + yield* Effect.sync(() => + Database.use((db) => + db + .update(SessionTable) + .set({ + summary_additions: 1, + summary_deletions: 0, + summary_files: 1, + summary_diffs: [{ additions: 1, deletions: 0 }], + }) + .where(eq(SessionTable.id, session.id)) + .run(), + ), + ) + + const response = yield* request(pathFor(SessionPaths.get, { sessionID: session.id }), { + headers: { "x-opencode-directory": tmp.path }, + }) + + expect(response.status).toBe(200) + expect((yield* json(response)).summary?.diffs).toEqual([{ additions: 1, deletions: 0 }]) + }), + ), + ) + + it.live( + "serves lifecycle mutation routes", withTmp({ git: true, config: { formatter: false, lsp: false, share: "disabled" } }, (tmp) => Effect.gen(function* () { const headers = { "x-opencode-directory": tmp.path, "content-type": "application/json" } @@ -333,7 +356,6 @@ describe("session HttpApi", () => { const forked = yield* requestJson(pathFor(SessionPaths.fork, { sessionID: created.id }), { method: "POST", headers, - body: JSON.stringify({}), }) expect(forked.id).not.toBe(created.id) @@ -371,8 +393,15 @@ describe("session HttpApi", () => { headers: { "x-opencode-directory": tmp.path, "content-type": "application/json" }, body: JSON.stringify({ title: "workspace session" }), }) + const messages = yield* request( + `${pathFor(SessionPaths.messages, { sessionID: created.id })}?workspace=${workspace.id}`, + { + headers: { "x-opencode-directory": tmp.path }, + }, + ) expect(created).toMatchObject({ id: created.id, workspaceID: workspace.id }) + expect(messages.status).toBe(200) expect( yield* Effect.sync(() => Database.use((db) => @@ -389,39 +418,26 @@ describe("session HttpApi", () => { ) it.live( - "matches legacy archived timestamp validation", + "validates archived timestamp values", withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) => Effect.gen(function* () { const headers = { "x-opencode-directory": tmp.path, "content-type": "application/json" } - const legacy = yield* createSession(tmp.path, { title: "legacy" }) - const effect = yield* createSession(tmp.path, { title: "effect" }) + const session = yield* createSession(tmp.path, { title: "archived" }) const body = JSON.stringify({ time: { archived: -1 } }) - const legacyResponse = yield* requestWithBackend( - false, - pathFor(SessionPaths.update, { sessionID: legacy.id }), - { - method: "PATCH", - headers, - body, - }, - ) - expect(legacyResponse.status).toBe(200) - expect((yield* json(legacyResponse)).time.archived).toBe(-1) - - const effectResponse = yield* requestWithBackend(true, pathFor(SessionPaths.update, { sessionID: effect.id }), { + const response = yield* request(pathFor(SessionPaths.update, { sessionID: session.id }), { method: "PATCH", headers, body, }) - expect(effectResponse.status).toBe(legacyResponse.status) - expect((yield* json(effectResponse)).time.archived).toBe(-1) + expect(response.status).toBe(200) + expect((yield* json(response)).time.archived).toBe(-1) }), ), ) it.live( - "matches legacy project-scoped path and directory precedence", + "uses project-scoped path and directory precedence", withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) => Effect.gen(function* () { const currentDir = path.join(tmp.path, "packages", "opencode", "src") @@ -441,22 +457,18 @@ describe("session HttpApi", () => { directory: currentDir, }) const headers = { "x-opencode-directory": tmp.path } - const legacy = (yield* json( - yield* requestWithBackend(false, `${SessionPaths.list}?${query}`, { headers }), - )).map((item) => item.id) - const effect = (yield* json( - yield* requestWithBackend(true, `${SessionPaths.list}?${query}`, { headers }), + const sessions = (yield* json( + yield* request(`${SessionPaths.list}?${query}`, { headers }), )).map((item) => item.id) - expect(legacy).toContain(pathSession.id) - expect(legacy).not.toContain(pathlessSession.id) - expect(effect).toEqual(legacy) + expect(sessions).toContain(pathSession.id) + expect(sessions).not.toContain(pathlessSession.id) }), ), ) it.live( - "matches legacy paginated message link headers", + "serves paginated message link headers", withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) => Effect.gen(function* () { const headers = { "x-opencode-directory": tmp.path } @@ -465,20 +477,17 @@ describe("session HttpApi", () => { yield* createTextMessage(tmp.path, session.id, "second") const route = `${pathFor(SessionPaths.messages, { sessionID: session.id })}?limit=1` - const legacy = yield* requestWithBackend(false, route, { headers }) - const effect = yield* requestWithBackend(true, route, { headers }) + const response = yield* request(route, { headers }) - expect(effect.headers.get("x-next-cursor")).toBe(legacy.headers.get("x-next-cursor")) - expect(effect.headers.get("link")).toBe(legacy.headers.get("link")) - expect(effect.headers.get("access-control-expose-headers")).toBe( - legacy.headers.get("access-control-expose-headers"), - ) + expect(response.headers.get("x-next-cursor")).toBeTruthy() + expect(response.headers.get("link")).toContain("limit=1") + expect(response.headers.get("access-control-expose-headers")?.toLowerCase()).toContain("x-next-cursor") }), ), ) it.live( - "serves message mutation routes through Hono bridge", + "serves message mutation routes", withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) => Effect.gen(function* () { const headers = { "x-opencode-directory": tmp.path, "content-type": "application/json" } @@ -522,7 +531,7 @@ describe("session HttpApi", () => { ) it.live( - "serves remaining non-LLM session mutation routes through Hono bridge", + "serves remaining non-LLM session mutation routes", withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) => Effect.gen(function* () { const headers = { "x-opencode-directory": tmp.path, "content-type": "application/json" } diff --git a/packages/opencode/test/server/httpapi-sync.test.ts b/packages/opencode/test/server/httpapi-sync.test.ts index b85658ea1e..cd626c28f4 100644 --- a/packages/opencode/test/server/httpapi-sync.test.ts +++ b/packages/opencode/test/server/httpapi-sync.test.ts @@ -1,10 +1,11 @@ import { afterEach, describe, expect, mock, spyOn, test } from "bun:test" -import { Effect } from "effect" +import { Context, Effect } from "effect" import { Flag } from "@opencode-ai/core/flag/flag" import { Instance } from "../../src/project/instance" import { WithInstance } from "../../src/project/with-instance" import { Server } from "../../src/server/server" import { SyncPaths } from "../../src/server/routes/instance/httpapi/groups/sync" +import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server" import { Session } from "@/session/session" import * as Log from "@opencode-ai/core/util/log" import { resetDatabase } from "../fixture/db" @@ -12,12 +13,11 @@ import { disposeAllInstances, tmpdir } from "../fixture/fixture" void Log.init({ print: false }) -const originalHttpApi = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES +const context = Context.empty() as Context.Context -function app(httpapi = true) { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = httpapi - return httpapi ? Server.Default().app : Server.Legacy().app +function app() { + return Server.Default().app } function runSession(fx: Effect.Effect) { @@ -26,14 +26,13 @@ function runSession(fx: Effect.Effect) { afterEach(async () => { mock.restore() - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = originalHttpApi Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces await disposeAllInstances() await resetDatabase() }) describe("sync HttpApi", () => { - test("serves sync routes through Hono bridge", async () => { + test("serves sync routes", async () => { Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) const headers = { "x-opencode-directory": tmp.path, "content-type": "application/json" } @@ -85,7 +84,7 @@ describe("sync HttpApi", () => { expect(info.mock.calls.some(([message]) => message === "sync replay complete")).toBe(true) }) - test("matches legacy seq validation", async () => { + test("validates seq values", async () => { await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) const headers = { "x-opencode-directory": tmp.path, "content-type": "application/json" } const cases = [ @@ -114,18 +113,30 @@ describe("sync HttpApi", () => { ] for (const item of cases) { - const legacy = await app(false).request(item.path, { + const response = await app().request(item.path, { method: "POST", headers, body: JSON.stringify(item.body), }) - const httpapi = await app(true).request(item.path, { - method: "POST", - headers, - body: JSON.stringify(item.body), - }) - expect(httpapi.status).toBe(legacy.status) - expect(httpapi.status).toBe(400) + expect(response.status).toBe(400) } }) + + test.todo("returns structured validation errors", async () => { + await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) + const response = await ExperimentalHttpApiServer.webHandler().handler( + new Request(`http://localhost${SyncPaths.history}`, { + method: "POST", + headers: { "x-opencode-directory": tmp.path, "content-type": "application/json" }, + body: JSON.stringify({ aggregate: -1 }), + }), + context, + ) + + expect(response.status).toBe(400) + expect(response.headers.get("content-type") ?? "").toContain("application/json") + const body = (await response.json()) as Record + expect(body.success).toBe(false) + expect(Array.isArray(body.error) || Array.isArray(body.errors)).toBe(true) + }) }) diff --git a/packages/opencode/test/server/httpapi-tui.test.ts b/packages/opencode/test/server/httpapi-tui.test.ts deleted file mode 100644 index 91cad362a9..0000000000 --- a/packages/opencode/test/server/httpapi-tui.test.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { afterEach, describe, expect, test } from "bun:test" -import type { Context } from "hono" -import { Flag } from "@opencode-ai/core/flag/flag" -import { TuiEvent } from "../../src/cli/cmd/tui/event" -import { SessionID } from "../../src/session/schema" -import { Instance } from "../../src/project/instance" -import { TuiApi, TuiPaths } from "../../src/server/routes/instance/httpapi/groups/tui" -import { callTui } from "../../src/server/routes/instance/tui" -import { Server } from "../../src/server/server" -import * as Log from "@opencode-ai/core/util/log" -import { OpenApi } from "effect/unstable/httpapi" -import { resetDatabase } from "../fixture/db" -import { disposeAllInstances, tmpdir } from "../fixture/fixture" -import { waitGlobalBusEventPromise } from "./global-bus" - -void Log.init({ print: false }) - -const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI - -function app(experimental = true) { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = experimental - return experimental ? Server.Default().app : Server.Legacy().app -} - -function nextCommandExecute() { - return waitGlobalBusEventPromise({ - predicate: (event) => event.payload.type === TuiEvent.CommandExecute.type, - }).then((event) => event.payload.properties?.command) -} - -async function expectTrue(path: string, headers: Record, body?: unknown) { - const response = await app().request(path, { - method: "POST", - headers: { ...headers, "content-type": "application/json" }, - body: JSON.stringify(body ?? {}), - }) - expect(response.status).toBe(200) - expect(await response.json()).toBe(true) -} - -afterEach(async () => { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original - await disposeAllInstances() - await resetDatabase() -}) - -describe("tui HttpApi bridge", () => { - test("documents legacy bad request responses", async () => { - const legacy = await Server.openapiHono() - const effect = OpenApi.fromApi(TuiApi) - for (const path of [TuiPaths.appendPrompt, TuiPaths.executeCommand, TuiPaths.publish, TuiPaths.selectSession]) { - expect(legacy.paths[path].post?.responses?.[400]).toBeDefined() - expect(effect.paths[path].post?.responses?.[400]).toBeDefined() - } - }) - - test("serves TUI command and event routes through experimental Effect routes", async () => { - await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) - const headers = { "x-opencode-directory": tmp.path } - - await expectTrue(TuiPaths.appendPrompt, headers, { text: "hello" }) - await expectTrue(TuiPaths.openHelp, headers) - await expectTrue(TuiPaths.openSessions, headers) - await expectTrue(TuiPaths.openThemes, headers) - await expectTrue(TuiPaths.openModels, headers) - await expectTrue(TuiPaths.submitPrompt, headers) - await expectTrue(TuiPaths.clearPrompt, headers) - await expectTrue(TuiPaths.executeCommand, headers, { command: "agent_cycle" }) - await expectTrue(TuiPaths.showToast, headers, { message: "Saved", variant: "success" }) - await expectTrue(TuiPaths.publish, headers, { - type: "tui.prompt.append", - properties: { text: "from publish" }, - }) - - const missingSessionID = SessionID.descending() - const missing = await app().request(TuiPaths.selectSession, { - method: "POST", - headers: { ...headers, "content-type": "application/json" }, - body: JSON.stringify({ sessionID: missingSessionID }), - }) - expect(missing.status).toBe(404) - }) - - test("matches Hono missing selected session error body", async () => { - await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) - const headers = { "x-opencode-directory": tmp.path, "content-type": "application/json" } - const body = JSON.stringify({ sessionID: SessionID.descending() }) - - const hono = await app(false).request(TuiPaths.selectSession, { method: "POST", headers, body }) - const httpapi = await app().request(TuiPaths.selectSession, { method: "POST", headers, body }) - - expect(httpapi.status).toBe(hono.status) - expect(await httpapi.json()).toEqual(await hono.json()) - }) - - test("matches legacy unknown execute command behavior", async () => { - await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) - const headers = { "x-opencode-directory": tmp.path, "content-type": "application/json" } - const body = JSON.stringify({ command: "unknown_command" }) - - const legacyCommand = nextCommandExecute() - const legacy = await app(false).request(TuiPaths.executeCommand, { method: "POST", headers, body }) - expect(legacy.status).toBe(200) - expect(await legacy.json()).toBe(true) - - const effectCommand = nextCommandExecute() - const effect = await app().request(TuiPaths.executeCommand, { method: "POST", headers, body }) - expect(effect.status).toBe(200) - expect(await effect.json()).toBe(true) - - const legacyPublished = await legacyCommand - const effectPublished = await effectCommand - expect(effectPublished).toBe(legacyPublished) - expect(legacyPublished).toBeUndefined() - }) - - test("serves TUI control queue through experimental Effect routes", async () => { - await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) - const pending = callTui({ req: { json: async () => ({ value: 1 }), path: "/demo" } } as unknown as Context) - const headers = { "x-opencode-directory": tmp.path } - - const next = await app().request(TuiPaths.controlNext, { headers }) - expect(next.status).toBe(200) - expect(await next.json()).toEqual({ path: "/demo", body: { value: 1 } }) - - await expectTrue(TuiPaths.controlResponse, headers, { ok: true }) - expect(await pending).toEqual({ ok: true }) - }) -}) diff --git a/packages/opencode/test/server/httpapi-ui.test.ts b/packages/opencode/test/server/httpapi-ui.test.ts index 440aeaecb5..256c450193 100644 --- a/packages/opencode/test/server/httpapi-ui.test.ts +++ b/packages/opencode/test/server/httpapi-ui.test.ts @@ -22,7 +22,6 @@ import { Server } from "../../src/server/server" void Log.init({ print: false }) const original = { - OPENCODE_EXPERIMENTAL_HTTPAPI: Flag.OPENCODE_EXPERIMENTAL_HTTPAPI, OPENCODE_DISABLE_EMBEDDED_WEB_UI: Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI, OPENCODE_SERVER_PASSWORD: Flag.OPENCODE_SERVER_PASSWORD, OPENCODE_SERVER_USERNAME: Flag.OPENCODE_SERVER_USERNAME, @@ -31,7 +30,6 @@ const original = { } afterEach(() => { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original.OPENCODE_EXPERIMENTAL_HTTPAPI Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI = original.OPENCODE_DISABLE_EMBEDDED_WEB_UI Flag.OPENCODE_SERVER_PASSWORD = original.OPENCODE_SERVER_PASSWORD Flag.OPENCODE_SERVER_USERNAME = original.OPENCODE_SERVER_USERNAME @@ -117,7 +115,6 @@ function httpClient(response: Response, onRequest?: (request: HttpClientRequest. describe("HttpApi UI fallback", () => { test("serves the web UI through the experimental backend", async () => { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI = true let proxiedUrl: string | undefined @@ -137,7 +134,6 @@ describe("HttpApi UI fallback", () => { }) test("strips upstream transfer encoding headers from proxied assets", async () => { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI = true let proxiedUrl: string | undefined @@ -189,7 +185,6 @@ describe("HttpApi UI fallback", () => { // forwarded through the proxy while the proxy itself re-frames the body, // causing browsers to fail with `ERR_INVALID_CHUNKED_ENCODING`. test("strips upstream transfer-encoding header from proxied assets", async () => { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI = true const response = await Effect.runPromise( @@ -232,7 +227,6 @@ describe("HttpApi UI fallback", () => { }) test("serves embedded UI assets when Bun can read them but access reports missing", async () => { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true let readPath: string | undefined const response = await Effect.runPromise( @@ -262,7 +256,6 @@ describe("HttpApi UI fallback", () => { }) test("allows embedded UI terminal wasm and theme preload CSP", async () => { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true const script = 'document.documentElement.dataset.theme = "dark"' const response = await Effect.runPromise( @@ -294,15 +287,12 @@ describe("HttpApi UI fallback", () => { }) test("keeps matched API routes ahead of the UI fallback", async () => { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true - - const response = await Server.Default().app.request("/session/nope") + const response = await Server.Default().app.request("/session/ses_nope") expect(response.status).toBe(404) }) test("requires server password for the web UI", async () => { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI = true const response = await uiApp({ password: "secret", username: "opencode" }).request("/") @@ -312,7 +302,6 @@ describe("HttpApi UI fallback", () => { }) test("accepts auth token for the web UI", async () => { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI = true const response = await uiApp({ @@ -326,7 +315,6 @@ describe("HttpApi UI fallback", () => { }) test("accepts basic auth for the web UI", async () => { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI = true const response = await uiApp({ password: "secret", username: "opencode" }).request("/", { @@ -342,7 +330,6 @@ describe("HttpApi UI fallback", () => { // server returning 401 breaks PWA install. These specific public assets // should bypass auth. test("serves the PWA manifest without auth even when a server password is set", async () => { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI = true for (const path of ["/site.webmanifest", "/web-app-manifest-192x192.png", "/web-app-manifest-512x512.png"]) { @@ -356,8 +343,6 @@ describe("HttpApi UI fallback", () => { }) test("allows web UI preflight without auth", async () => { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true - const response = await app({ password: "secret", username: "opencode" }).request("/", { method: "OPTIONS", headers: { diff --git a/packages/opencode/test/server/httpapi-workspace-routing.test.ts b/packages/opencode/test/server/httpapi-workspace-routing.test.ts index 379b71a91e..a62ca1db74 100644 --- a/packages/opencode/test/server/httpapi-workspace-routing.test.ts +++ b/packages/opencode/test/server/httpapi-workspace-routing.test.ts @@ -1,7 +1,7 @@ import { NodeHttpServer, NodeServices } from "@effect/platform-node" import { Flag } from "@opencode-ai/core/flag/flag" import { describe, expect } from "bun:test" -import { Context, Effect, Layer, Queue } from "effect" +import { Context, Effect, Layer, Queue, Ref } from "effect" import { FetchHttpClient, HttpClient, @@ -28,6 +28,7 @@ import { WorkspaceRouteContext, workspaceRouterMiddleware, } from "../../src/server/routes/instance/httpapi/middleware/workspace-routing" +import { HEADER as FenceHeader } from "../../src/server/shared/fence" import { Database } from "../../src/storage/db" import { resetDatabase } from "../fixture/db" import { tmpdirScoped } from "../fixture/fixture" @@ -289,6 +290,64 @@ describe("HttpApi workspace routing middleware", () => { }), ) + it.live("waits for sync fence headers from remote workspace HTTP responses", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + const project = yield* Project.use.fromDirectory(dir) + const workspaceID = WorkspaceID.ascending() + const type = "remote-http-fence-target" + const waited = yield* Ref.make<{ workspaceID: WorkspaceID; state: Record } | undefined>(undefined) + + const remoteUrl = yield* startRemoteWorkspaceHttpServer(() => + HttpServerResponse.json( + { proxied: true }, + { status: 202, headers: { [FenceHeader]: JSON.stringify({ aggregate: 3 }) } }, + ), + ) + registerAdapter(project.project.id, type, remoteAdapter(path.join(dir, `.${type}`), `${remoteUrl}/base`)) + + const workspace = Workspace.Service.of({ + create: () => Effect.die("unused"), + sessionWarp: () => Effect.die("unused"), + list: () => Effect.die("unused"), + syncList: () => Effect.die("unused"), + get: (id) => + Effect.succeed( + id === workspaceID + ? { + id: workspaceID, + type, + branch: null, + name: "remote-http-fence-target", + directory: null, + extra: null, + projectID: project.project.id, + timeUsed: Date.now(), + } + : undefined, + ), + remove: () => Effect.die("unused"), + status: () => Effect.die("unused"), + isSyncing: () => Effect.succeed(true), + waitForSync: (id, state) => Ref.set(waited, { workspaceID: id, state }), + startWorkspaceSyncing: () => Effect.die("unused"), + }) + + yield* HttpRouter.add("PATCH", "/probe", HttpServerResponse.text("route called")).pipe( + Layer.provide(workspaceRoutingTestLayer), + Layer.provide(Layer.succeed(Workspace.Service, workspace)), + HttpRouter.serve, + Layer.build, + ) + + const response = yield* HttpClientRequest.patch(`/probe?workspace=${workspaceID}`).pipe(HttpClient.execute) + + expect(response.status).toBe(202) + expect(yield* response.json).toEqual({ proxied: true }) + expect(yield* Ref.get(waited)).toEqual({ workspaceID, state: { aggregate: 3 } }) + }), + ) + it.live("returns 503 when a remote workspace is not actively syncing", () => Effect.gen(function* () { const dir = yield* tmpdirScoped({ git: true }) diff --git a/packages/opencode/test/server/httpapi-workspace.test.ts b/packages/opencode/test/server/httpapi-workspace.test.ts index 9b38cb44a2..a2de1362fb 100644 --- a/packages/opencode/test/server/httpapi-workspace.test.ts +++ b/packages/opencode/test/server/httpapi-workspace.test.ts @@ -24,16 +24,14 @@ import { testEffect } from "../lib/effect" void Log.init({ print: false }) const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES -const originalHttpApi = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI const workspaceLayer = Workspace.defaultLayer.pipe( Layer.provide(InstanceStore.defaultLayer), Layer.provide(InstanceBootstrap.defaultLayer), ) const it = testEffect(Layer.mergeAll(NodeServices.layer, Project.defaultLayer, Session.defaultLayer, workspaceLayer)) -function request(path: string, directory: string, init: RequestInit = {}, httpApi = true) { +function request(path: string, directory: string, init: RequestInit = {}) { return Effect.promise(() => { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = httpApi const headers = new Headers(init.headers) headers.set("x-opencode-directory", directory) return Promise.resolve(Server.Default().app.request(path, { ...init, headers })) @@ -64,6 +62,36 @@ function localAdapter(directory: string): WorkspaceAdapter { } } +function listedAdapter(directory: string, type: string): WorkspaceAdapter { + return { + name: "Listed Test", + description: "List a local test workspace", + configure(info) { + return { ...info, name: "unused", directory } + }, + async create() {}, + async remove() {}, + list() { + return [ + { + type, + name: "listed-test", + branch: "listed/main", + directory, + extra: { listed: true }, + projectID: Instance.project.id, + }, + ] + }, + target() { + return { + type: "local" as const, + directory, + } + }, + } +} + function remoteAdapter(directory: string, url: string, headers?: HeadersInit): WorkspaceAdapter { return { name: "Remote Test", @@ -131,7 +159,6 @@ function eventStreamResponse() { afterEach(async () => { mock.restore() Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = originalHttpApi await disposeAllInstances() await resetDatabase() }) @@ -196,6 +223,30 @@ describe("workspace HttpApi", () => { }), ) + it.live("serves list sync endpoint", () => + Effect.gen(function* () { + Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true + const dir = yield* tmpdirScoped({ git: true }) + const project = yield* Project.use.fromDirectory(dir) + const type = `listed-${Math.random().toString(36).slice(2)}` + registerAdapter(project.project.id, type, listedAdapter(path.join(dir, ".listed"), type)) + + const response = yield* request(WorkspacePaths.syncList, dir, { method: "POST" }) + + expect(response.status).toBe(204) + const listed = yield* request(WorkspacePaths.list, dir) + expect(yield* Effect.promise(() => listed.json())).toMatchObject([ + { + type, + name: "listed-test", + branch: "listed/main", + directory: path.join(dir, ".listed"), + extra: { listed: true }, + }, + ]) + }), + ) + it.live("creates workspace with the TUI payload shape", () => Effect.gen(function* () { Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true @@ -235,32 +286,6 @@ describe("workspace HttpApi", () => { }), ) - it.live("documents legacy Hono accepting the TUI payload shape", () => - Effect.gen(function* () { - Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true - const dir = yield* tmpdirScoped({ git: true }) - const project = yield* Project.use.fromDirectory(dir) - registerAdapter(project.project.id, "local-test", localAdapter(path.join(dir, ".workspace"))) - - const created = yield* request( - WorkspacePaths.list, - dir, - { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ type: "local-test", branch: null }), - }, - false, - ) - - expect(created.status).toBe(200) - expect((yield* Effect.promise(() => created.json())) as Workspace.Info).toMatchObject({ - type: "local-test", - name: "local-test", - }) - }), - ) - it.live("routes local workspace requests through the workspace target directory", () => Effect.gen(function* () { Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true diff --git a/packages/opencode/test/server/negative-tokens-regression.test.ts b/packages/opencode/test/server/negative-tokens-regression.test.ts new file mode 100644 index 0000000000..77ad1bc279 --- /dev/null +++ b/packages/opencode/test/server/negative-tokens-regression.test.ts @@ -0,0 +1,97 @@ +// Regression: a stored step-finish part with a negative token count made the +// messages endpoint 400. Some providers reported `outputTokens` excluding +// reasoning while also reporting `reasoningTokens` separately, so the +// `outputTokens - reasoningTokens` math in Session.getUsage underflowed to +// negative. The pre-fix `safe()` clamp only guarded against non-finite. The +// strict `NonNegativeInt` schema then made every load of the message list +// fail to encode, killing Desktop boot for every user with such a row. +import { afterEach, describe, expect } from "bun:test" +import { Effect } from "effect" +import { eq } from "drizzle-orm" +import { ModelID, ProviderID } from "../../src/provider/schema" +import { WithInstance } from "../../src/project/with-instance" +import { Server } from "../../src/server/server" +import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session" +import { Session } from "@/session/session" +import { MessageID, PartID } from "../../src/session/schema" +import * as Database from "@/storage/db" +import { PartTable } from "@/session/session.sql" +import { resetDatabase } from "../fixture/db" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" +import { it } from "../lib/effect" + +afterEach(async () => { + await disposeAllInstances() + await resetDatabase() +}) + +function seedNegativeTokenSession(directory: string) { + return Effect.promise(async () => + WithInstance.provide({ + directory, + fn: () => + Effect.runPromise( + Effect.gen(function* () { + const session = yield* Session.Service + const info = yield* session.create({}) + const message = yield* session.updateMessage({ + id: MessageID.ascending(), + role: "user", + sessionID: info.id, + agent: "build", + model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") }, + time: { created: Date.now() }, + }) + const partID = PartID.ascending() + yield* session.updatePart({ + id: partID, + sessionID: info.id, + messageID: message.id, + type: "step-finish", + reason: "stop", + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + }) + + // Bypass the schema with a direct SQL update to install the + // negative `output` value we want to test loading. + Database.use((db) => + db + .update(PartTable) + .set({ + data: { + type: "step-finish", + reason: "stop", + cost: 0, + tokens: { input: 0, output: -42, reasoning: 0, cache: { read: 0, write: 0 } }, + } as never, + }) + .where(eq(PartTable.id, partID)) + .run(), + ) + + return info.id + }).pipe(Effect.provide(Session.defaultLayer)), + ), + }), + ) +} + +describe("messages endpoint tolerates legacy negative token counts", () => { + it.live( + "returns 200 even when a step-finish part has tokens.output < 0", + Effect.acquireRelease( + Effect.promise(() => tmpdir({ config: { formatter: false, lsp: false } })), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((tmp) => + Effect.gen(function* () { + const sessionID = yield* seedNegativeTokenSession(tmp.path) + const url = `${SessionPaths.messages.replace(":sessionID", sessionID)}?limit=80&directory=${encodeURIComponent(tmp.path)}` + const res = yield* Effect.promise(async () => Server.Default().app.request(url)) + expect(res.status, "messages endpoint 400'd on legacy negative tokens").not.toBe(400) + }), + ), + ), + ) +}) diff --git a/packages/opencode/test/server/sdk-error-shape.test.ts b/packages/opencode/test/server/sdk-error-shape.test.ts new file mode 100644 index 0000000000..f41fe2cb30 --- /dev/null +++ b/packages/opencode/test/server/sdk-error-shape.test.ts @@ -0,0 +1,84 @@ +/** + * Regression tests for the SDK error shape — the v2 SDK's `throwOnError: true` + * path used to throw raw values (empty strings or POJOs from JSON-decoded + * error bodies). The TUI catches those and `e.message`/`e.stack` are + * undefined, so users see `[object Object]` or a blank crash. + * + * Both cases must throw a real `Error` instance with a non-empty `.message` + * extracted from the response body, plus `.status` and `.body` attached. + */ +import { afterEach, describe, expect, test } from "bun:test" +import { createOpencodeClient } from "@opencode-ai/sdk/v2" +import { Server } from "../../src/server/server" +import * as Log from "@opencode-ai/core/util/log" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" +import { resetDatabase } from "../fixture/db" + +void Log.init({ print: false }) + +afterEach(async () => { + await disposeAllInstances() + await resetDatabase() +}) + +function client(directory: string) { + return createOpencodeClient({ + baseUrl: "http://test", + directory, + fetch: ((req: Request) => Server.Default().app.fetch(req)) as unknown as typeof fetch, + }) +} + +describe("v2 SDK error shape", () => { + test("404 with NamedError body throws a real Error carrying the server message", async () => { + await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) + const sdk = client(tmp.path) + + let caught: unknown + try { + await sdk.session.get({ sessionID: "ses_no_such" }, { throwOnError: true }) + } catch (e) { + caught = e + } + + expect(caught).toBeInstanceOf(Error) + const err = caught as Error + const cause = err.cause as { body?: any; status?: number } + expect(err.message).toContain("Session not found") + expect(cause.status).toBe(404) + expect(cause.body).toMatchObject({ + name: "NotFoundError", + data: { message: expect.stringContaining("Session not found") }, + }) + }) + + test("400 schema rejection: SDK extracts the field-level reason from the NamedError body", async () => { + // Canary for the #26631 wire shape. Asserts the contract end-to-end: + // server emits {name:"BadRequest", data:{message, kind}}, SDK's + // wrapClientError extracts .data.message into Error.message. If either + // side regresses (#26457 reverted because both layers were missing), + // this test fails before users see (empty response body). + await using tmp = await tmpdir({ config: { formatter: false, lsp: false } }) + const sdk = client(tmp.path) + + let caught: unknown + try { + await sdk.sync.history.list({ body: { aggregate: -1 } as any }, { throwOnError: true }) + } catch (e) { + caught = e + } + + expect(caught).toBeInstanceOf(Error) + const err = caught as Error + const cause = err.cause as { body?: any; status?: number } + expect(cause.status).toBe(400) + expect(cause.body).toMatchObject({ + name: "BadRequest", + data: { kind: expect.stringMatching(/^(Body|Payload)$/) }, + }) + expect(typeof cause.body.data.message).toBe("string") + expect(cause.body.data.message.length).toBeGreaterThan(0) + // Whatever the server put in data.message must be what the user sees. + expect(err.message).toBe(cause.body.data.message) + }) +}) diff --git a/packages/opencode/test/server/sdk-v1-smoke.test.ts b/packages/opencode/test/server/sdk-v1-smoke.test.ts new file mode 100644 index 0000000000..2b09e0c872 --- /dev/null +++ b/packages/opencode/test/server/sdk-v1-smoke.test.ts @@ -0,0 +1,60 @@ +// Smoke test: v1 SDK (the plugin contract) can actually reach core endpoints +// against the current server. v1 generation has been frozen since #5216 +// (2025-12-07) so types may be stale, but runtime calls should still work +// for endpoints the v1 SDK was generated against. +import { afterEach, describe, expect, test } from "bun:test" +import { createOpencodeClient } from "@opencode-ai/sdk" +import { Server } from "../../src/server/server" +import { tmpdir, disposeAllInstances } from "../fixture/fixture" +import { resetDatabase } from "../fixture/db" +import * as Log from "@opencode-ai/core/util/log" + +void Log.init({ print: false }) + +afterEach(async () => { + await disposeAllInstances() + await resetDatabase() +}) + +function client(directory: string) { + return createOpencodeClient({ + baseUrl: "http://test", + directory, + fetch: ((req: Request) => Server.Default().app.fetch(req)) as unknown as typeof fetch, + }) +} + +describe("v1 SDK runtime smoke", () => { + test("session.list reaches the server and returns 200", async () => { + await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) + const sdk = client(tmp.path) + const result = await sdk.session.list() + expect(result.error).toBeUndefined() + expect(Array.isArray(result.data)).toBe(true) + }) + + test("path.get reaches the server and returns 200", async () => { + await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) + const sdk = client(tmp.path) + const result = await sdk.path.get() + expect(result.error).toBeUndefined() + expect(result.data).toBeDefined() + }) + + test("config.get reaches the server and returns 200", async () => { + await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) + const sdk = client(tmp.path) + const result = await sdk.config.get() + expect(result.error).toBeUndefined() + expect(result.data).toBeDefined() + }) + + test("session 404: result-tuple path returns the error body", async () => { + await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) + const sdk = client(tmp.path) + const result = await sdk.session.get({ path: { id: "ses_no_such" } as never }) + expect(result.error).toBeDefined() + // wire body for 404 is NamedError-shaped + expect(result.error).toMatchObject({ name: "NotFoundError" }) + }) +}) diff --git a/packages/opencode/test/server/session-diff-missing-patch.test.ts b/packages/opencode/test/server/session-diff-missing-patch.test.ts new file mode 100644 index 0000000000..5f27a4e2fd --- /dev/null +++ b/packages/opencode/test/server/session-diff-missing-patch.test.ts @@ -0,0 +1,81 @@ +/** + * Regression test for the same bug class as #26574 (sibling of #26566 and + * #26553). The Desktop app calls GET /session//diff; before #26574 + * the response was Schema-encoded against `Snapshot.FileDiff` with + * `patch: Schema.String` (required), so any session whose stored + * `summary_diffs` had a row without `patch` returned HTTP 400 and the + * session never loaded. + * + * This test inserts a session row with a missing-patch diff entry and + * asserts that GET /session//diff returns 200 with the row intact. + */ +import { afterEach, describe, expect } from "bun:test" +import { Effect } from "effect" +import { Server } from "@/server/server" +import { SessionPaths } from "@/server/routes/instance/httpapi/groups/session" +import { Session } from "@/session/session" +import { Storage } from "@/storage/storage" +import { WithInstance } from "@/project/with-instance" +import { resetDatabase } from "../fixture/db" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" +import { it } from "../lib/effect" +import * as Log from "@opencode-ai/core/util/log" + +void Log.init({ print: false }) + +afterEach(async () => { + await disposeAllInstances() + await resetDatabase() +}) + +function pathFor(template: string, params: Record) { + return Object.entries(params).reduce((result, [key, value]) => result.replace(`:${key}`, value), template) +} + +describe("session diff with missing patch (#26574)", () => { + it.live("GET /session//diff returns 200 when summary_diffs row has no patch", () => + Effect.gen(function* () { + const tmp = yield* Effect.acquireRelease( + Effect.promise(() => tmpdir({ git: true, config: { formatter: false, lsp: false } })), + (t) => Effect.promise(() => t[Symbol.asyncDispose]()), + ) + + yield* Effect.promise(() => + WithInstance.provide({ + directory: tmp.path, + fn: async () => { + const session = await Effect.runPromise( + Effect.provide( + Session.Service.use((s) => s.create({ title: "missing-patch" })), + Session.defaultLayer, + ), + ) + + // Mimic legacy/imported on-disk shape: a diff entry with no + // `patch` text. Pre-fix the typed response encoder rejects + // this and returns 400. + await Effect.runPromise( + Effect.provide( + Storage.Service.use((s) => + s.write(["session_diff", session.id], [{ file: "legacy.txt", additions: 1, deletions: 0 }]), + ), + Storage.defaultLayer, + ), + ) + + const headers = { "x-opencode-directory": tmp.path } + const response = await Server.Default().app.request(pathFor(SessionPaths.diff, { sessionID: session.id }), { + headers, + }) + expect(response.status).toBe(200) + const body = (await response.json()) as Array<{ file: string; patch?: string; additions: number }> + expect(body).toHaveLength(1) + expect(body[0]?.file).toBe("legacy.txt") + expect(body[0]?.additions).toBe(1) + expect(body[0]?.patch).toBeUndefined() + }, + }), + ) + }), + ) +}) diff --git a/packages/opencode/test/server/session-messages.test.ts b/packages/opencode/test/server/session-messages.test.ts index e3c5e83136..f5ee5bdcb0 100644 --- a/packages/opencode/test/server/session-messages.test.ts +++ b/packages/opencode/test/server/session-messages.test.ts @@ -1,6 +1,5 @@ import { afterEach, describe, expect, test } from "bun:test" import { Effect } from "effect" -import { Instance } from "../../src/project/instance" import { WithInstance } from "../../src/project/with-instance" import { Server } from "../../src/server/server" import { Session as SessionNs } from "@/session/session" @@ -165,4 +164,28 @@ describe("session messages endpoint", () => { }), ) }) + + test("accepts directory query used by workspace routing", async () => { + await using tmp = await tmpdir({ git: true }) + await withoutWatcher(() => + WithInstance.provide({ + directory: tmp.path, + fn: async () => { + const session = await svc.create({}) + await fill(session.id, 1) + const app = Server.Default().app + + const res = await app.request( + `/session/${session.id}/message?limit=80&directory=${encodeURIComponent(tmp.path)}`, + ) + expect(res.status).toBe(200) + const body = await res.json() + expect(Array.isArray(body)).toBe(true) + expect(body).toHaveLength(1) + + await svc.remove(session.id) + }, + }), + ) + }) }) diff --git a/packages/opencode/test/server/trace-attributes.test.ts b/packages/opencode/test/server/trace-attributes.test.ts deleted file mode 100644 index c6e8005a20..0000000000 --- a/packages/opencode/test/server/trace-attributes.test.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { describe, expect, test } from "bun:test" -import { paramToAttributeKey, requestAttributes } from "../../src/server/routes/instance/trace" - -function fakeContext(method: string, url: string, params: Record) { - return { - req: { - method, - url, - param: () => params, - }, - } -} - -describe("paramToAttributeKey", () => { - test("converts fooID to foo.id", () => { - expect(paramToAttributeKey("sessionID")).toBe("session.id") - expect(paramToAttributeKey("messageID")).toBe("message.id") - expect(paramToAttributeKey("partID")).toBe("part.id") - expect(paramToAttributeKey("projectID")).toBe("project.id") - expect(paramToAttributeKey("providerID")).toBe("provider.id") - expect(paramToAttributeKey("ptyID")).toBe("pty.id") - expect(paramToAttributeKey("permissionID")).toBe("permission.id") - expect(paramToAttributeKey("requestID")).toBe("request.id") - expect(paramToAttributeKey("workspaceID")).toBe("workspace.id") - }) - - test("namespaces non-ID params under opencode.", () => { - expect(paramToAttributeKey("name")).toBe("opencode.name") - expect(paramToAttributeKey("slug")).toBe("opencode.slug") - }) -}) - -describe("requestAttributes", () => { - test("includes http method and path", () => { - const attrs = requestAttributes(fakeContext("GET", "http://localhost/session", {})) - expect(attrs["http.method"]).toBe("GET") - expect(attrs["http.path"]).toBe("/session") - }) - - test("strips query string from path", () => { - const attrs = requestAttributes(fakeContext("GET", "http://localhost/file/search?query=foo&limit=10", {})) - expect(attrs["http.path"]).toBe("/file/search") - }) - - test("emits OTel-style .id for ID-shaped route params", () => { - const attrs = requestAttributes( - fakeContext("GET", "http://localhost/session/ses_abc/message/msg_def/part/prt_ghi", { - sessionID: "ses_abc", - messageID: "msg_def", - partID: "prt_ghi", - }), - ) - expect(attrs["session.id"]).toBe("ses_abc") - expect(attrs["message.id"]).toBe("msg_def") - expect(attrs["part.id"]).toBe("prt_ghi") - // No camelCase leftovers: - expect(attrs["opencode.sessionID"]).toBeUndefined() - expect(attrs["opencode.messageID"]).toBeUndefined() - expect(attrs["opencode.partID"]).toBeUndefined() - }) - - test("produces no param attributes when no params are matched", () => { - const attrs = requestAttributes(fakeContext("POST", "http://localhost/config", {})) - expect(Object.keys(attrs).filter((k) => k !== "http.method" && k !== "http.path")).toEqual([]) - }) - - test("namespaces non-ID params under opencode. (e.g. mcp :name)", () => { - const attrs = requestAttributes( - fakeContext("POST", "http://localhost/mcp/exa/connect", { - name: "exa", - }), - ) - expect(attrs["opencode.name"]).toBe("exa") - expect(attrs["name"]).toBeUndefined() - }) -}) diff --git a/packages/opencode/test/server/worktree-endpoint-repro.test.ts b/packages/opencode/test/server/worktree-endpoint-repro.test.ts index 768a261a00..e95d706d54 100644 --- a/packages/opencode/test/server/worktree-endpoint-repro.test.ts +++ b/packages/opencode/test/server/worktree-endpoint-repro.test.ts @@ -13,16 +13,13 @@ import { testEffect } from "../lib/effect" const stateLayer = Layer.effectDiscard( Effect.gen(function* () { const original = { - OPENCODE_EXPERIMENTAL_HTTPAPI: Flag.OPENCODE_EXPERIMENTAL_HTTPAPI, OPENCODE_EXPERIMENTAL_WORKSPACES: Flag.OPENCODE_EXPERIMENTAL_WORKSPACES, } - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true yield* Effect.addFinalizer(() => Effect.promise(async () => { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original.OPENCODE_EXPERIMENTAL_HTTPAPI Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = original.OPENCODE_EXPERIMENTAL_WORKSPACES await resetDatabase() }), diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts index cde9c1397f..13400d79c8 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/packages/opencode/test/session/compaction.test.ts @@ -1,20 +1,18 @@ import { afterEach, describe, expect, mock, test } from "bun:test" import { APICallError } from "ai" -import { Cause, Effect, Exit, Layer, ManagedRuntime } from "effect" +import { Cause, Deferred, Effect, Exit, Fiber, Layer } from "effect" import * as Stream from "effect/Stream" -import z from "zod" import { Bus } from "../../src/bus" import { Config } from "@/config/config" +import { Image } from "@/image/image" import { Agent } from "../../src/agent/agent" import { LLM } from "../../src/session/llm" import { SessionCompaction } from "../../src/session/compaction" import { Token } from "@/util/token" -import { Instance } from "../../src/project/instance" -import { WithInstance } from "../../src/project/with-instance" import * as Log from "@opencode-ai/core/util/log" import { Permission } from "../../src/permission" import { Plugin } from "../../src/plugin" -import { provideTmpdirInstance, tmpdir } from "../fixture/fixture" +import { provideTmpdirInstance, TestInstance } from "../fixture/fixture" import { Session as SessionNs } from "@/session/session" import { MessageV2 } from "../../src/session/message-v2" import { MessageID, PartID, SessionID } from "../../src/session/schema" @@ -29,29 +27,10 @@ import { ProviderTest } from "../fake/provider" import { testEffect } from "../lib/effect" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { TestConfig } from "../fixture/config" +import { SyncEvent } from "@/sync" void Log.init({ print: false }) -function run(fx: Effect.Effect) { - return Effect.runPromise(fx.pipe(Effect.provide(SessionNs.defaultLayer))) -} - -const svc = { - ...SessionNs, - create(input?: SessionNs.CreateInput) { - return run(SessionNs.Service.use((svc) => svc.create(input))) - }, - messages(input: z.output) { - return run(SessionNs.Service.use((svc) => svc.messages(input))) - }, - updateMessage(msg: T) { - return run(SessionNs.Service.use((svc) => svc.updateMessage(msg))) - }, - updatePart(part: T) { - return run(SessionNs.Service.use((svc) => svc.updatePart(part))) - }, -} - const summary = Layer.succeed( SessionSummary.Service, SessionSummary.Service.of({ @@ -102,87 +81,109 @@ function createModel(opts: { const wide = () => ProviderTest.fake({ model: createModel({ context: 100_000, output: 32_000 }) }) -async function user(sessionID: SessionID, text: string) { - const msg = await svc.updateMessage({ - id: MessageID.ascending(), - role: "user", - sessionID, - agent: "build", - model: ref, - time: { created: Date.now() }, +function createUserMessage(sessionID: SessionID, text: string) { + return Effect.gen(function* () { + const ssn = yield* SessionNs.Service + const msg = yield* ssn.updateMessage({ + id: MessageID.ascending(), + role: "user", + sessionID, + agent: "build", + model: ref, + time: { created: Date.now() }, + }) + yield* ssn.updatePart({ + id: PartID.ascending(), + messageID: msg.id, + sessionID, + type: "text", + text, + }) + return msg }) - await svc.updatePart({ - id: PartID.ascending(), - messageID: msg.id, - sessionID, - type: "text", - text, - }) - return msg } -async function assistant(sessionID: SessionID, parentID: MessageID, root: string) { - const msg: MessageV2.Assistant = { - id: MessageID.ascending(), - role: "assistant", - sessionID, - mode: "build", - agent: "build", - path: { cwd: root, root }, - cost: 0, - tokens: { - output: 0, - input: 0, - reasoning: 0, - cache: { read: 0, write: 0 }, - }, - modelID: ref.modelID, - providerID: ref.providerID, - parentID, - time: { created: Date.now() }, - finish: "end_turn", - } - await svc.updateMessage(msg) - return msg +function createAssistantMessage(sessionID: SessionID, parentID: MessageID, root: string) { + return SessionNs.Service.use((ssn) => + ssn.updateMessage({ + id: MessageID.ascending(), + role: "assistant", + sessionID, + mode: "build", + agent: "build", + path: { cwd: root, root }, + cost: 0, + tokens: { + output: 0, + input: 0, + reasoning: 0, + cache: { read: 0, write: 0 }, + }, + modelID: ref.modelID, + providerID: ref.providerID, + parentID, + time: { created: Date.now() }, + finish: "end_turn", + }), + ) } -async function summaryAssistant(sessionID: SessionID, parentID: MessageID, root: string, text: string) { - const msg: MessageV2.Assistant = { - id: MessageID.ascending(), - role: "assistant", - sessionID, - mode: "compaction", - agent: "compaction", - path: { cwd: root, root }, - cost: 0, - tokens: { - output: 0, - input: 0, - reasoning: 0, - cache: { read: 0, write: 0 }, - }, - modelID: ref.modelID, - providerID: ref.providerID, - parentID, - summary: true, - time: { created: Date.now() }, - finish: "end_turn", - } - await svc.updateMessage(msg) - await svc.updatePart({ - id: PartID.ascending(), - messageID: msg.id, - sessionID, - type: "text", - text, - }) - return msg +function createSummaryAssistantMessage(sessionID: SessionID, parentID: MessageID, root: string, text: string) { + return SessionNs.Service.use((ssn) => + Effect.gen(function* () { + const msg = yield* ssn.updateMessage({ + id: MessageID.ascending(), + role: "assistant", + sessionID, + mode: "compaction", + agent: "compaction", + path: { cwd: root, root }, + cost: 0, + tokens: { + output: 0, + input: 0, + reasoning: 0, + cache: { read: 0, write: 0 }, + }, + modelID: ref.modelID, + providerID: ref.providerID, + parentID, + summary: true, + time: { created: Date.now() }, + finish: "end_turn", + }) + yield* ssn.updatePart({ + id: PartID.ascending(), + messageID: msg.id, + sessionID, + type: "text", + text, + }) + return msg + }), + ) } -async function lastCompactionPart(sessionID: SessionID) { - return (await svc.messages({ sessionID })) - .at(-2) - ?.parts.find((item): item is MessageV2.CompactionPart => item.type === "compaction") +function createCompactionMarker(sessionID: SessionID) { + return SessionNs.Service.use((ssn) => + Effect.gen(function* () { + const msg = yield* ssn.updateMessage({ + id: MessageID.ascending(), + role: "user", + model: ref, + sessionID, + agent: "build", + time: { created: Date.now() }, + }) + yield* ssn.updatePart({ + id: PartID.ascending(), + messageID: msg.id, + sessionID: msg.sessionID, + type: "compaction", + auto: false, + }) + }), + ) } function fake( @@ -216,33 +217,14 @@ function cfg(compaction?: Config.Info["compaction"]) { }) } -function runtime( - result: "continue" | "compact", - plugin = Plugin.defaultLayer, - provider = ProviderTest.fake(), - config = Config.defaultLayer, -) { - const bus = Bus.layer - return ManagedRuntime.make( - Layer.mergeAll(SessionCompaction.layer, bus).pipe( - Layer.provide(provider.layer), - Layer.provide(SessionNs.defaultLayer), - Layer.provide(layer(result)), - Layer.provide(Agent.defaultLayer), - Layer.provide(plugin), - Layer.provide(bus), - Layer.provide(config), - ), - ) -} - const deps = Layer.mergeAll( - ProviderTest.fake().layer, + wide().layer, layer("continue"), Agent.defaultLayer, Plugin.defaultLayer, Bus.layer, Config.defaultLayer, + SyncEvent.defaultLayer, ) const env = Layer.mergeAll( @@ -253,6 +235,58 @@ const env = Layer.mergeAll( const it = testEffect(env) +const compactionEnv = Layer.mergeAll(SessionNs.defaultLayer, CrossSpawnSpawner.defaultLayer) +const itCompaction = testEffect(compactionEnv) + +type CompactionProcessOptions = { + result?: "continue" | "compact" + llm?: Layer.Layer + plugin?: Layer.Layer + provider?: ReturnType + config?: Layer.Layer +} + +function withCompaction(options?: CompactionProcessOptions) { + return Effect.provide(compactionProcessLayer(options)) +} + +function compactionProcessLayer(options?: CompactionProcessOptions) { + const bus = Bus.layer + const status = SessionStatus.layer.pipe(Layer.provide(bus)) + const processor = options?.llm + ? SessionProcessorModule.SessionProcessor.layer.pipe( + Layer.provide(summary), + Layer.provide(Image.defaultLayer), + Layer.provide(status), + ) + : layer(options?.result ?? "continue") + return Layer.mergeAll(SessionCompaction.layer.pipe(Layer.provide(processor)), processor, bus, status).pipe( + Layer.provide(SessionNs.defaultLayer), + Layer.provide((options?.provider ?? wide()).layer), + Layer.provide(Snapshot.defaultLayer), + Layer.provide(options?.llm ?? LLM.defaultLayer), + Layer.provide(Permission.defaultLayer), + Layer.provide(Agent.defaultLayer), + Layer.provide(options?.plugin ?? Plugin.defaultLayer), + Layer.provide(status), + Layer.provide(bus), + Layer.provide(options?.config ?? Config.defaultLayer), + Layer.provide(SyncEvent.defaultLayer), + ) +} + +function createSummaryCompaction(sessionID: SessionID) { + return SessionCompaction.use.create({ sessionID, agent: "build", model: ref, auto: false }) +} + +function readCompactionPart(sessionID: SessionID) { + return SessionNs.Service.use((ssn) => ssn.messages({ sessionID })).pipe( + Effect.map((messages) => + messages.at(-2)?.parts.find((item): item is MessageV2.CompactionPart => item.type === "compaction"), + ), + ) +} + function llm() { const queue: Array< Stream.Stream | ((input: LLM.StreamInput) => Stream.Stream) @@ -275,26 +309,6 @@ function llm() { } } -function liveRuntime(layer: Layer.Layer, provider = ProviderTest.fake(), config = Config.defaultLayer) { - const bus = Bus.layer - const status = SessionStatus.layer.pipe(Layer.provide(bus)) - const processor = SessionProcessorModule.SessionProcessor.layer.pipe(Layer.provide(summary)) - return ManagedRuntime.make( - Layer.mergeAll(SessionCompaction.layer.pipe(Layer.provide(processor)), processor, bus, status).pipe( - Layer.provide(provider.layer), - Layer.provide(SessionNs.defaultLayer), - Layer.provide(Snapshot.defaultLayer), - Layer.provide(layer), - Layer.provide(Permission.defaultLayer), - Layer.provide(Agent.defaultLayer), - Layer.provide(Plugin.defaultLayer), - Layer.provide(status), - Layer.provide(bus), - Layer.provide(config), - ), - ) -} - function reply( text: string, capture?: (input: LLM.StreamInput) => void, @@ -350,23 +364,14 @@ function reply( } } -function wait(ms = 50) { - return new Promise((resolve) => setTimeout(resolve, ms)) -} - -function defer() { - let resolve!: () => void - const promise = new Promise((done) => { - resolve = done - }) - return { promise, resolve } -} - -function plugin(ready: ReturnType) { +function plugin(ready: Deferred.Deferred) { return Layer.mock(Plugin.Service)({ trigger: (name: Name, _input: Input, output: Output) => { if (name !== "experimental.session.compacting") return Effect.succeed(output) - return Effect.sync(() => ready.resolve()).pipe(Effect.andThen(Effect.never), Effect.as(output)) + return Effect.sync(() => Deferred.doneUnsafe(ready, Effect.void)).pipe( + Effect.andThen(Effect.never), + Effect.as(output), + ) }, list: () => Effect.succeed([]), init: () => Effect.void, @@ -801,319 +806,216 @@ describe("session.compaction.prune", () => { }) describe("session.compaction.process", () => { - test("throws when parent is not a user message", async () => { - await using tmp = await tmpdir() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const session = await svc.create({}) - const msg = await user(session.id, "hello") - const reply = await assistant(session.id, msg.id, tmp.path) - const rt = runtime("continue") - try { - const msgs = await svc.messages({ sessionID: session.id }) - await expect( - rt.runPromise( - SessionCompaction.Service.use((svc) => - svc.process({ - parentID: reply.id, - messages: msgs, - sessionID: session.id, - auto: false, - }), - ), - ), - ).rejects.toThrow(`Compaction parent must be a user message: ${reply.id}`) - } finally { - await rt.dispose() - } - }, - }) - }) + it.instance( + "throws when parent is not a user message", + Effect.gen(function* () { + const test = yield* TestInstance + const ssn = yield* SessionNs.Service + const session = yield* ssn.create({}) + const msg = yield* createUserMessage(session.id, "hello") + const reply = yield* createAssistantMessage(session.id, msg.id, test.directory) + const msgs = yield* ssn.messages({ sessionID: session.id }) - test("publishes compacted event on continue", async () => { - await using tmp = await tmpdir() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const session = await svc.create({}) - const msg = await user(session.id, "hello") - const msgs = await svc.messages({ sessionID: session.id }) - const done = defer() - let seen = false - const rt = runtime("continue", Plugin.defaultLayer, wide()) - let unsub: (() => void) | undefined - try { - unsub = await rt.runPromise( - Bus.Service.use((svc) => - svc.subscribeCallback(SessionCompaction.Event.Compacted, (evt) => { - if (evt.properties.sessionID !== session.id) return - seen = true - done.resolve() - }), - ), - ) - - const result = await rt.runPromise( - SessionCompaction.Service.use((svc) => - svc.process({ - parentID: msg.id, - messages: msgs, - sessionID: session.id, - auto: false, - }), - ), - ) - - await Promise.race([ - done.promise, - wait(500).then(() => { - throw new Error("timed out waiting for compacted event") - }), - ]) - expect(result).toBe("continue") - expect(seen).toBe(true) - } finally { - unsub?.() - await rt.dispose() - } - }, - }) - }) - - test("marks summary message as errored on compact result", async () => { - await using tmp = await tmpdir() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const session = await svc.create({}) - const msg = await user(session.id, "hello") - const rt = runtime("compact", Plugin.defaultLayer, wide()) - try { - const msgs = await svc.messages({ sessionID: session.id }) - const result = await rt.runPromise( - SessionCompaction.Service.use((svc) => - svc.process({ - parentID: msg.id, - messages: msgs, - sessionID: session.id, - auto: false, - }), - ), - ) - - const summary = (await svc.messages({ sessionID: session.id })).find( - (msg) => msg.info.role === "assistant" && msg.info.summary, - ) - - expect(result).toBe("stop") - expect(summary?.info.role).toBe("assistant") - if (summary?.info.role === "assistant") { - expect(summary.info.finish).toBe("error") - expect(JSON.stringify(summary.info.error)).toContain("Session too large to compact") - } - } finally { - await rt.dispose() - } - }, - }) - }) - - test("adds synthetic continue prompt when auto is enabled", async () => { - await using tmp = await tmpdir() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const session = await svc.create({}) - const msg = await user(session.id, "hello") - const rt = runtime("continue", Plugin.defaultLayer, wide()) - try { - const msgs = await svc.messages({ sessionID: session.id }) - const result = await rt.runPromise( - SessionCompaction.Service.use((svc) => - svc.process({ - parentID: msg.id, - messages: msgs, - sessionID: session.id, - auto: true, - }), - ), - ) - - const all = await svc.messages({ sessionID: session.id }) - const last = all.at(-1) - - expect(result).toBe("continue") - expect(last?.info.role).toBe("user") - expect(last?.parts[0]).toMatchObject({ - type: "text", - synthetic: true, - metadata: { compaction_continue: true }, - }) - if (last?.parts[0]?.type === "text") { - expect(last.parts[0].text).toContain("Continue if you have next steps") - } - } finally { - await rt.dispose() - } - }, - }) - }) - - test("persists tail_start_id for retained recent turns", async () => { - await using tmp = await tmpdir() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const session = await svc.create({}) - await user(session.id, "first") - const keep = await user(session.id, "second") - await user(session.id, "third") - await SessionCompaction.create({ + const exit = yield* Effect.exit( + SessionCompaction.use.process({ + parentID: reply.id, + messages: msgs, sessionID: session.id, - agent: "build", - model: ref, auto: false, - }) + }), + ) - const rt = runtime( - "continue", - Plugin.defaultLayer, - wide(), - cfg({ tail_turns: 2, preserve_recent_tokens: 10_000 }), - ) - try { - const msgs = await svc.messages({ sessionID: session.id }) - const parent = msgs.at(-1)?.info.id - expect(parent).toBeTruthy() - await rt.runPromise( - SessionCompaction.Service.use((svc) => - svc.process({ - parentID: parent!, - messages: msgs, - sessionID: session.id, - auto: false, - }), - ), - ) - - const part = await lastCompactionPart(session.id) - expect(part?.type).toBe("compaction") - expect(part?.tail_start_id).toBe(keep.id) - } finally { - await rt.dispose() + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) { + const error = Cause.squash(exit.cause) + expect(error).toBeInstanceOf(Error) + if (error instanceof Error) { + expect(error.message).toContain(`Compaction parent must be a user message: ${reply.id}`) } - }, - }) - }) + } + }), + ) - test("shrinks retained tail to fit preserve token budget", async () => { - await using tmp = await tmpdir() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const session = await svc.create({}) - await user(session.id, "first") - await user(session.id, "x".repeat(2_000)) - const keep = await user(session.id, "tiny") - await SessionCompaction.create({ - sessionID: session.id, - agent: "build", - model: ref, - auto: false, - }) + it.instance( + "publishes compacted event on continue", + Effect.gen(function* () { + const bus = yield* Bus.Service + const ssn = yield* SessionNs.Service + const session = yield* ssn.create({}) + const msg = yield* createUserMessage(session.id, "hello") + const msgs = yield* ssn.messages({ sessionID: session.id }) + const done = yield* Deferred.make() + let seen = false + const unsub = yield* bus.subscribeCallback(SessionCompaction.Event.Compacted, (evt) => { + if (evt.properties.sessionID !== session.id) return + seen = true + Deferred.doneUnsafe(done, Effect.void) + }) + yield* Effect.addFinalizer(() => Effect.sync(unsub)) - const rt = runtime("continue", Plugin.defaultLayer, wide(), cfg({ tail_turns: 2, preserve_recent_tokens: 100 })) - try { - const msgs = await svc.messages({ sessionID: session.id }) - const parent = msgs.at(-1)?.info.id - expect(parent).toBeTruthy() - await rt.runPromise( - SessionCompaction.Service.use((svc) => - svc.process({ - parentID: parent!, - messages: msgs, - sessionID: session.id, - auto: false, - }), - ), - ) + const result = yield* SessionCompaction.use.process({ + parentID: msg.id, + messages: msgs, + sessionID: session.id, + auto: false, + }) - const part = await lastCompactionPart(session.id) - expect(part?.type).toBe("compaction") - expect(part?.tail_start_id).toBe(keep.id) - } finally { - await rt.dispose() - } - }, - }) - }) + yield* Deferred.await(done).pipe(Effect.timeout("500 millis")) + expect(result).toBe("continue") + expect(seen).toBe(true) + }), + ) - test("falls back to full summary when even one recent turn exceeds preserve token budget", async () => { - await using tmp = await tmpdir({ git: true }) - const stub = llm() - let captured = "" - stub.push( - reply("summary", (input) => { - captured = JSON.stringify(input.messages) - }), - ) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const session = await svc.create({}) - await user(session.id, "first") - await user(session.id, "y".repeat(2_000)) - await SessionCompaction.create({ - sessionID: session.id, - agent: "build", - model: ref, - auto: false, - }) + itCompaction.instance( + "marks summary message as errored on compact result", + Effect.gen(function* () { + const ssn = yield* SessionNs.Service + const session = yield* ssn.create({}) + const msg = yield* createUserMessage(session.id, "hello") + const msgs = yield* ssn.messages({ sessionID: session.id }) - const rt = liveRuntime(stub.layer, wide(), cfg({ tail_turns: 1, preserve_recent_tokens: 20 })) - try { - const msgs = await svc.messages({ sessionID: session.id }) - const parent = msgs.at(-1)?.info.id - expect(parent).toBeTruthy() - await rt.runPromise( - SessionCompaction.Service.use((svc) => - svc.process({ - parentID: parent!, - messages: msgs, - sessionID: session.id, - auto: false, - }), - ), - ) + const result = yield* SessionCompaction.use.process({ + parentID: msg.id, + messages: msgs, + sessionID: session.id, + auto: false, + }) - const part = await lastCompactionPart(session.id) - expect(part?.type).toBe("compaction") - expect(part?.tail_start_id).toBeUndefined() - expect(captured).toContain("yyyy") - } finally { - await rt.dispose() - } - }, - }) - }) + const summary = (yield* ssn.messages({ sessionID: session.id })).find( + (msg) => msg.info.role === "assistant" && msg.info.summary, + ) - test("falls back to full summary when retained tail media exceeds preserve token budget", async () => { - await using tmp = await tmpdir({ git: true }) - const stub = llm() - let captured = "" - stub.push( - reply("summary", (input) => { - captured = JSON.stringify(input.messages) - }), - ) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const session = await svc.create({}) - await user(session.id, "older") - const recent = await user(session.id, "recent image turn") - await svc.updatePart({ + expect(result).toBe("stop") + expect(summary?.info.role).toBe("assistant") + if (summary?.info.role === "assistant") { + expect(summary.info.finish).toBe("error") + expect(JSON.stringify(summary.info.error)).toContain("Session too large to compact") + } + }).pipe(withCompaction({ result: "compact" })), + ) + + it.instance( + "adds synthetic continue prompt when auto is enabled", + Effect.gen(function* () { + const ssn = yield* SessionNs.Service + const session = yield* ssn.create({}) + const msg = yield* createUserMessage(session.id, "hello") + const msgs = yield* ssn.messages({ sessionID: session.id }) + + const result = yield* SessionCompaction.use.process({ + parentID: msg.id, + messages: msgs, + sessionID: session.id, + auto: true, + }) + + const all = yield* ssn.messages({ sessionID: session.id }) + const last = all.at(-1) + + expect(result).toBe("continue") + expect(last?.info.role).toBe("user") + expect(last?.parts[0]).toMatchObject({ + type: "text", + synthetic: true, + metadata: { compaction_continue: true }, + }) + if (last?.parts[0]?.type === "text") { + expect(last.parts[0].text).toContain("Continue if you have next steps") + } + }), + ) + + itCompaction.instance( + "does not persist tail_start_id for serialized recent turns", + Effect.gen(function* () { + const ssn = yield* SessionNs.Service + const session = yield* ssn.create({}) + yield* createUserMessage(session.id, "first") + yield* createUserMessage(session.id, "second") + yield* createUserMessage(session.id, "third") + yield* createSummaryCompaction(session.id) + + const msgs = yield* ssn.messages({ sessionID: session.id }) + const parent = msgs.at(-1)?.info.id + expect(parent).toBeTruthy() + yield* SessionCompaction.use.process({ + parentID: parent!, + messages: msgs, + sessionID: session.id, + auto: false, + }) + + const part = yield* readCompactionPart(session.id) + expect(part?.type).toBe("compaction") + expect(part?.tail_start_id).toBeUndefined() + }).pipe(withCompaction({ config: cfg({ tail_turns: 2, preserve_recent_tokens: 10_000 }) })), + ) + + itCompaction.instance( + "does not persist tail_start_id when shrinking serialized tail", + Effect.gen(function* () { + const ssn = yield* SessionNs.Service + const session = yield* ssn.create({}) + yield* createUserMessage(session.id, "first") + yield* createUserMessage(session.id, "x".repeat(2_000)) + yield* createUserMessage(session.id, "tiny") + yield* createSummaryCompaction(session.id) + + const msgs = yield* ssn.messages({ sessionID: session.id }) + const parent = msgs.at(-1)?.info.id + expect(parent).toBeTruthy() + yield* SessionCompaction.use.process({ + parentID: parent!, + messages: msgs, + sessionID: session.id, + auto: false, + }) + + const part = yield* readCompactionPart(session.id) + expect(part?.type).toBe("compaction") + expect(part?.tail_start_id).toBeUndefined() + }).pipe(withCompaction({ config: cfg({ tail_turns: 2, preserve_recent_tokens: 100 }) })), + ) + + itCompaction.instance( + "falls back to full summary when even one recent turn exceeds preserve token budget", + () => { + const stub = llm() + let captured = "" + stub.push(reply("summary", (input) => (captured = JSON.stringify(input.messages)))) + return Effect.gen(function* () { + const ssn = yield* SessionNs.Service + const session = yield* ssn.create({}) + yield* createUserMessage(session.id, "first") + yield* createUserMessage(session.id, "y".repeat(2_000)) + yield* createSummaryCompaction(session.id) + + const msgs = yield* ssn.messages({ sessionID: session.id }) + const parent = msgs.at(-1)?.info.id + expect(parent).toBeTruthy() + yield* SessionCompaction.use.process({ parentID: parent!, messages: msgs, sessionID: session.id, auto: false }) + + const part = yield* readCompactionPart(session.id) + expect(part?.type).toBe("compaction") + expect(part?.tail_start_id).toBeUndefined() + expect(captured).toContain("yyyy") + }).pipe(withCompaction({ llm: stub.layer, config: cfg({ tail_turns: 1, preserve_recent_tokens: 20 }) })) + }, + { git: true }, + ) + + itCompaction.instance( + "serializes retained tail media as text in the summary input", + () => { + const stub = llm() + let captured = "" + stub.push(reply("summary", (input) => (captured = JSON.stringify(input.messages)))) + return Effect.gen(function* () { + const ssn = yield* SessionNs.Service + const session = yield* ssn.create({}) + yield* createUserMessage(session.id, "older") + const recent = yield* createUserMessage(session.id, "recent image turn") + yield* ssn.updatePart({ id: PartID.ascending(), messageID: recent.id, sessionID: session.id, @@ -1122,743 +1024,502 @@ describe("session.compaction.process", () => { filename: "big.png", url: `data:image/png;base64,${"a".repeat(4_000)}`, }) - await SessionCompaction.create({ - sessionID: session.id, - agent: "build", - model: ref, - auto: false, - }) + yield* createSummaryCompaction(session.id) - const rt = liveRuntime(stub.layer, wide(), cfg({ tail_turns: 1, preserve_recent_tokens: 100 })) - try { - const msgs = await svc.messages({ sessionID: session.id }) - const parent = msgs.at(-1)?.info.id - expect(parent).toBeTruthy() - await rt.runPromise( - SessionCompaction.Service.use((svc) => - svc.process({ - parentID: parent!, - messages: msgs, - sessionID: session.id, - auto: false, - }), - ), - ) + const msgs = yield* ssn.messages({ sessionID: session.id }) + const parent = msgs.at(-1)?.info.id + expect(parent).toBeTruthy() + yield* SessionCompaction.use.process({ parentID: parent!, messages: msgs, sessionID: session.id, auto: false }) - const part = await lastCompactionPart(session.id) - expect(part?.type).toBe("compaction") - expect(part?.tail_start_id).toBeUndefined() - expect(captured).toContain("recent image turn") - expect(captured).toContain("Attached image/png: big.png") - } finally { - await rt.dispose() - } - }, - }) - }) + const part = yield* readCompactionPart(session.id) + expect(part?.type).toBe("compaction") + expect(part?.tail_start_id).toBeUndefined() + expect(captured).toContain("recent image turn") + expect(captured).toContain("Attached image/png: big.png") + }).pipe(withCompaction({ llm: stub.layer, config: cfg({ tail_turns: 1, preserve_recent_tokens: 100 }) })) + }, + { git: true }, + ) - test("retains a split turn suffix when a later message fits the preserve token budget", async () => { - await using tmp = await tmpdir({ git: true }) - const stub = llm() - let captured = "" - stub.push( - reply("summary", (input) => { - captured = JSON.stringify(input.messages) - }), - ) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const session = await svc.create({}) - await user(session.id, "older") - const recent = await user(session.id, "recent turn") - const large = await assistant(session.id, recent.id, tmp.path) - await svc.updatePart({ + itCompaction.instance( + "retains a split turn suffix when a later message fits the preserve token budget", + () => { + const stub = llm() + let captured = "" + stub.push(reply("summary", (input) => (captured = JSON.stringify(input.messages)))) + return Effect.gen(function* () { + const test = yield* TestInstance + const ssn = yield* SessionNs.Service + const session = yield* ssn.create({}) + yield* createUserMessage(session.id, "older") + const recent = yield* createUserMessage(session.id, "recent turn") + const large = yield* createAssistantMessage(session.id, recent.id, test.directory) + yield* ssn.updatePart({ id: PartID.ascending(), messageID: large.id, sessionID: session.id, type: "text", text: "z".repeat(2_000), }) - const keep = await assistant(session.id, recent.id, tmp.path) - await svc.updatePart({ + const keep = yield* createAssistantMessage(session.id, recent.id, test.directory) + yield* ssn.updatePart({ id: PartID.ascending(), messageID: keep.id, sessionID: session.id, type: "text", text: "keep tail", }) - await SessionCompaction.create({ - sessionID: session.id, - agent: "build", - model: ref, - auto: false, - }) + yield* createSummaryCompaction(session.id) - const rt = liveRuntime(stub.layer, wide(), cfg({ tail_turns: 1, preserve_recent_tokens: 100 })) - try { - const msgs = await svc.messages({ sessionID: session.id }) - const parent = msgs.at(-1)?.info.id - expect(parent).toBeTruthy() - await rt.runPromise( - SessionCompaction.Service.use((svc) => - svc.process({ - parentID: parent!, - messages: msgs, - sessionID: session.id, - auto: false, - }), + const msgs = yield* ssn.messages({ sessionID: session.id }) + const parent = msgs.at(-1)?.info.id + expect(parent).toBeTruthy() + yield* SessionCompaction.use.process({ parentID: parent!, messages: msgs, sessionID: session.id, auto: false }) + + const part = yield* readCompactionPart(session.id) + expect(part?.type).toBe("compaction") + expect(part?.tail_start_id).toBeUndefined() + expect(captured).toContain("zzzz") + expect(captured).toContain("keep tail") + + const filtered = MessageV2.filterCompacted(MessageV2.stream(session.id)) + expect(filtered.map((msg) => msg.info.id)).toEqual([parent!, expect.any(String)]) + expect(filtered[1]?.info.role).toBe("assistant") + expect(filtered[1]?.info.role === "assistant" ? filtered[1].info.summary : false).toBe(true) + expect(filtered.map((msg) => msg.info.id)).not.toContain(large.id) + expect(filtered.map((msg) => msg.info.id)).not.toContain(keep.id) + }).pipe(withCompaction({ llm: stub.layer, config: cfg({ tail_turns: 1, preserve_recent_tokens: 100 }) })) + }, + { git: true }, + ) + + itCompaction.instance( + "allows plugins to disable synthetic continue prompt", + Effect.gen(function* () { + const ssn = yield* SessionNs.Service + const session = yield* ssn.create({}) + const msg = yield* createUserMessage(session.id, "hello") + const msgs = yield* ssn.messages({ sessionID: session.id }) + + const result = yield* SessionCompaction.use.process({ + parentID: msg.id, + messages: msgs, + sessionID: session.id, + auto: true, + }) + + const all = yield* ssn.messages({ sessionID: session.id }) + const last = all.at(-1) + + expect(result).toBe("continue") + expect(last?.info.role).toBe("assistant") + expect( + all.some( + (msg) => + msg.info.role === "user" && + msg.parts.some( + (part) => part.type === "text" && part.synthetic && part.text.includes("Continue if you have next steps"), ), - ) + ), + ).toBe(false) + }).pipe(withCompaction({ plugin: autocontinue(false) })), + ) - const part = await lastCompactionPart(session.id) - expect(part?.type).toBe("compaction") - expect(part?.tail_start_id).toBe(keep.id) - expect(captured).toContain("zzzz") - expect(captured).not.toContain("keep tail") + it.instance( + "replays the prior user turn on overflow when earlier context exists", + Effect.gen(function* () { + const ssn = yield* SessionNs.Service + const session = yield* ssn.create({}) + yield* createUserMessage(session.id, "root") + const replay = yield* createUserMessage(session.id, "image") + yield* ssn.updatePart({ + id: PartID.ascending(), + messageID: replay.id, + sessionID: session.id, + type: "file", + mime: "image/png", + filename: "cat.png", + url: "https://example.com/cat.png", + }) + const msg = yield* createUserMessage(session.id, "current") + const msgs = yield* ssn.messages({ sessionID: session.id }) - const filtered = MessageV2.filterCompacted(MessageV2.stream(session.id)) - expect(filtered.map((msg) => msg.info.id).slice(0, 3)).toEqual([parent!, expect.any(String), keep.id]) - expect(filtered[1]?.info.role).toBe("assistant") - expect(filtered[1]?.info.role === "assistant" ? filtered[1].info.summary : false).toBe(true) - expect(filtered.map((msg) => msg.info.id)).not.toContain(large.id) - } finally { - await rt.dispose() - } - }, - }) - }) + const result = yield* SessionCompaction.use.process({ + parentID: msg.id, + messages: msgs, + sessionID: session.id, + auto: true, + overflow: true, + }) - test("allows plugins to disable synthetic continue prompt", async () => { - await using tmp = await tmpdir() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const session = await svc.create({}) - const msg = await user(session.id, "hello") - const rt = runtime("continue", autocontinue(false), wide()) - try { - const msgs = await svc.messages({ sessionID: session.id }) - const result = await rt.runPromise( - SessionCompaction.Service.use((svc) => - svc.process({ - parentID: msg.id, - messages: msgs, - sessionID: session.id, - auto: true, - }), - ), - ) + const last = (yield* ssn.messages({ sessionID: session.id })).at(-1) - const all = await svc.messages({ sessionID: session.id }) - const last = all.at(-1) + expect(result).toBe("continue") + expect(last?.info.role).toBe("user") + expect(last?.parts.some((part) => part.type === "file")).toBe(false) + expect( + last?.parts.some((part) => part.type === "text" && part.text.includes("Attached image/png: cat.png")), + ).toBe(true) + }), + ) - expect(result).toBe("continue") - expect(last?.info.role).toBe("assistant") - expect( - all.some( - (msg) => - msg.info.role === "user" && - msg.parts.some( - (part) => - part.type === "text" && part.synthetic && part.text.includes("Continue if you have next steps"), - ), - ), - ).toBe(false) - } finally { - await rt.dispose() - } - }, - }) - }) + it.instance( + "falls back to overflow guidance when no replayable turn exists", + Effect.gen(function* () { + const ssn = yield* SessionNs.Service + const session = yield* ssn.create({}) + yield* createUserMessage(session.id, "earlier") + const msg = yield* createUserMessage(session.id, "current") + const msgs = yield* ssn.messages({ sessionID: session.id }) - test("replays the prior user turn on overflow when earlier context exists", async () => { - await using tmp = await tmpdir() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const session = await svc.create({}) - await user(session.id, "root") - const replay = await user(session.id, "image") - await svc.updatePart({ - id: PartID.ascending(), - messageID: replay.id, - sessionID: session.id, - type: "file", - mime: "image/png", - filename: "cat.png", - url: "https://example.com/cat.png", - }) - const msg = await user(session.id, "current") - const rt = runtime("continue", Plugin.defaultLayer, wide()) - try { - const msgs = await svc.messages({ sessionID: session.id }) - const result = await rt.runPromise( - SessionCompaction.Service.use((svc) => - svc.process({ - parentID: msg.id, - messages: msgs, - sessionID: session.id, - auto: true, - overflow: true, - }), - ), - ) + const result = yield* SessionCompaction.use.process({ + parentID: msg.id, + messages: msgs, + sessionID: session.id, + auto: true, + overflow: true, + }) - const last = (await svc.messages({ sessionID: session.id })).at(-1) + const last = (yield* ssn.messages({ sessionID: session.id })).at(-1) - expect(result).toBe("continue") - expect(last?.info.role).toBe("user") - expect(last?.parts.some((part) => part.type === "file")).toBe(false) - expect( - last?.parts.some((part) => part.type === "text" && part.text.includes("Attached image/png: cat.png")), - ).toBe(true) - } finally { - await rt.dispose() - } - }, - }) - }) + expect(result).toBe("continue") + expect(last?.info.role).toBe("user") + if (last?.parts[0]?.type === "text") { + expect(last.parts[0].text).toContain("previous request exceeded the provider's size limit") + } + }), + ) - test("falls back to overflow guidance when no replayable turn exists", async () => { - await using tmp = await tmpdir() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const session = await svc.create({}) - await user(session.id, "earlier") - const msg = await user(session.id, "current") - - const rt = runtime("continue", Plugin.defaultLayer, wide()) - try { - const msgs = await svc.messages({ sessionID: session.id }) - const result = await rt.runPromise( - SessionCompaction.Service.use((svc) => - svc.process({ - parentID: msg.id, - messages: msgs, - sessionID: session.id, - auto: true, - overflow: true, - }), - ), - ) - - const last = (await svc.messages({ sessionID: session.id })).at(-1) - - expect(result).toBe("continue") - expect(last?.info.role).toBe("user") - if (last?.parts[0]?.type === "text") { - expect(last.parts[0].text).toContain("previous request exceeded the provider's size limit") - } - } finally { - await rt.dispose() - } - }, - }) - }) - - test("stops quickly when aborted during retry backoff", async () => { - const stub = llm() - const ready = defer() - stub.push( - Stream.fromAsyncIterable( - { - async *[Symbol.asyncIterator]() { - yield { type: "start" } as LLM.Event - throw new APICallError({ - message: "boom", - url: "https://example.com/v1/chat/completions", - requestBodyValues: {}, - statusCode: 503, - responseHeaders: { "retry-after-ms": "10000" }, - responseBody: '{"error":"boom"}', - isRetryable: true, - }) - }, - }, - (err) => err, - ), - ) - - await using tmp = await tmpdir({ git: true }) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const session = await svc.create({}) - const msg = await user(session.id, "hello") - const msgs = await svc.messages({ sessionID: session.id }) - const abort = new AbortController() - const rt = liveRuntime(stub.layer, wide()) - let off: (() => void) | undefined - let run: Promise<"continue" | "stop"> | undefined - try { - off = await rt.runPromise( - Bus.Service.use((svc) => - svc.subscribeCallback(SessionStatus.Event.Status, (evt) => { - if (evt.properties.sessionID !== session.id) return - if (evt.properties.status.type !== "retry") return - ready.resolve() - }), - ), - ) - - run = rt - .runPromiseExit( - SessionCompaction.Service.use((svc) => - svc.process({ - parentID: msg.id, - messages: msgs, - sessionID: session.id, - auto: false, - }), - ), - { signal: abort.signal }, - ) - .then((exit) => { - if (Exit.isFailure(exit)) { - if (Cause.hasInterrupts(exit.cause) && abort.signal.aborted) return "stop" - throw Cause.squash(exit.cause) - } - return exit.value - }) - - await Promise.race([ - ready.promise, - wait(1000).then(() => { - throw new Error("timed out waiting for retry status") - }), - ]) - - const start = Date.now() - abort.abort() - const result = await Promise.race([ - run.then((value) => ({ kind: "done" as const, value, ms: Date.now() - start })), - wait(250).then(() => ({ kind: "timeout" as const })), - ]) - - expect(result.kind).toBe("done") - if (result.kind === "done") { - expect(result.value).toBe("stop") - expect(result.ms).toBeLessThan(250) - } - } finally { - off?.() - abort.abort() - await rt.dispose() - await run?.catch(() => undefined) - } - }, - }) - }) - - test("does not leave a summary assistant when aborted before processor setup", async () => { - const ready = defer() - - await using tmp = await tmpdir({ git: true }) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const session = await svc.create({}) - const msg = await user(session.id, "hello") - const msgs = await svc.messages({ sessionID: session.id }) - const abort = new AbortController() - const rt = runtime("continue", plugin(ready), wide()) - let run: Promise<"continue" | "stop"> | undefined - try { - run = rt - .runPromiseExit( - SessionCompaction.Service.use((svc) => - svc.process({ - parentID: msg.id, - messages: msgs, - sessionID: session.id, - auto: false, - }), - ), - { signal: abort.signal }, - ) - .then((exit) => { - if (Exit.isFailure(exit)) { - if (Cause.hasInterrupts(exit.cause) && abort.signal.aborted) return "stop" - throw Cause.squash(exit.cause) - } - return exit.value - }) - - await Promise.race([ - ready.promise, - wait(1000).then(() => { - throw new Error("timed out waiting for compaction hook") - }), - ]) - - abort.abort() - expect(await run).toBe("stop") - - const all = await svc.messages({ sessionID: session.id }) - expect(all.some((msg) => msg.info.role === "assistant" && msg.info.summary)).toBe(false) - } finally { - abort.abort() - await rt.dispose() - await run?.catch(() => undefined) - } - }, - }) - }) - - test("does not allow tool calls while generating the summary", async () => { - const stub = llm() - stub.push( - Stream.make( - { type: "start" } satisfies LLM.Event, - { type: "tool-input-start", id: "call-1", toolName: "_noop" } satisfies LLM.Event, - { type: "tool-call", toolCallId: "call-1", toolName: "_noop", input: {} } satisfies LLM.Event, - { - type: "finish-step", - finishReason: "tool-calls", - rawFinishReason: "tool_calls", - response: { id: "res", modelId: "test-model", timestamp: new Date() }, - providerMetadata: undefined, - usage: { - inputTokens: 1, - outputTokens: 1, - totalTokens: 2, - inputTokenDetails: { - noCacheTokens: undefined, - cacheReadTokens: undefined, - cacheWriteTokens: undefined, - }, - outputTokenDetails: { - textTokens: undefined, - reasoningTokens: undefined, + itCompaction.instance( + "stops quickly when aborted during retry backoff", + () => { + const stub = llm() + stub.push( + Stream.fromAsyncIterable( + { + async *[Symbol.asyncIterator]() { + yield { type: "start" } as LLM.Event + throw new APICallError({ + message: "boom", + url: "https://example.com/v1/chat/completions", + requestBodyValues: {}, + statusCode: 503, + responseHeaders: { "retry-after-ms": "10000" }, + responseBody: '{"error":"boom"}', + isRetryable: true, + }) }, }, - } satisfies LLM.Event, - { - type: "finish", - finishReason: "tool-calls", - rawFinishReason: "tool_calls", - totalUsage: { - inputTokens: 1, - outputTokens: 1, - totalTokens: 2, - inputTokenDetails: { - noCacheTokens: undefined, - cacheReadTokens: undefined, - cacheWriteTokens: undefined, - }, - outputTokenDetails: { - textTokens: undefined, - reasoningTokens: undefined, - }, - }, - } satisfies LLM.Event, - ), - ) + (err) => err, + ), + ) - await using tmp = await tmpdir({ git: true }) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const session = await svc.create({}) - const msg = await user(session.id, "hello") - const rt = liveRuntime(stub.layer, wide()) - try { - const msgs = await svc.messages({ sessionID: session.id }) - await rt.runPromise( - SessionCompaction.Service.use((svc) => - svc.process({ - parentID: msg.id, - messages: msgs, - sessionID: session.id, - auto: false, - }), - ), - ) - - const summary = (await svc.messages({ sessionID: session.id })).find( - (item) => item.info.role === "assistant" && item.info.summary, - ) - - expect(summary?.info.role).toBe("assistant") - expect(summary?.parts.some((part) => part.type === "tool")).toBe(false) - } finally { - await rt.dispose() - } - }, - }) - }) - - test("summarizes only the head while keeping recent tail out of summary input", async () => { - const stub = llm() - let captured = "" - stub.push( - reply("summary", (input) => { - captured = JSON.stringify(input.messages) - }), - ) - - await using tmp = await tmpdir({ git: true }) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const session = await svc.create({}) - await user(session.id, "older context") - await user(session.id, "keep this turn") - await user(session.id, "and this one too") - await SessionCompaction.create({ - sessionID: session.id, - agent: "build", - model: ref, - auto: false, + return Effect.gen(function* () { + const ssn = yield* SessionNs.Service + const bus = yield* Bus.Service + const ready = yield* Deferred.make() + const session = yield* ssn.create({}) + const msg = yield* createUserMessage(session.id, "hello") + const msgs = yield* ssn.messages({ sessionID: session.id }) + const off = yield* bus.subscribeCallback(SessionStatus.Event.Status, (evt) => { + if (evt.properties.sessionID !== session.id) return + if (evt.properties.status.type !== "retry") return + Deferred.doneUnsafe(ready, Effect.void) }) + yield* Effect.addFinalizer(() => Effect.sync(off)) - const rt = liveRuntime(stub.layer, wide()) - try { - const msgs = await svc.messages({ sessionID: session.id }) - const parent = msgs.at(-1)?.info.id - expect(parent).toBeTruthy() - await rt.runPromise( - SessionCompaction.Service.use((svc) => - svc.process({ - parentID: parent!, - messages: msgs, - sessionID: session.id, - auto: false, - }), - ), - ) - - expect(captured).toContain("older context") - expect(captured).not.toContain("keep this turn") - expect(captured).not.toContain("and this one too") - expect(captured).not.toContain("What did we do so far?") - } finally { - await rt.dispose() - } - }, - }) - }) - - test("anchors repeated compactions with the previous summary", async () => { - const stub = llm() - let captured = "" - stub.push(reply("summary one")) - stub.push( - reply("summary two", (input) => { - captured = JSON.stringify(input.messages) - }), - ) - - await using tmp = await tmpdir({ git: true }) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const session = await svc.create({}) - await user(session.id, "older context") - await user(session.id, "keep this turn") - await SessionCompaction.create({ - sessionID: session.id, - agent: "build", - model: ref, - auto: false, - }) - - const rt = liveRuntime(stub.layer, wide()) - try { - let msgs = await svc.messages({ sessionID: session.id }) - let parent = msgs.at(-1)?.info.id - expect(parent).toBeTruthy() - await rt.runPromise( - SessionCompaction.Service.use((svc) => - svc.process({ - parentID: parent!, - messages: msgs, - sessionID: session.id, - auto: false, - }), - ), - ) - - await user(session.id, "latest turn") - await SessionCompaction.create({ + const fiber = yield* SessionCompaction.use + .process({ + parentID: msg.id, + messages: msgs, sessionID: session.id, - agent: "build", - model: ref, auto: false, }) + .pipe(Effect.forkChild) - msgs = MessageV2.filterCompacted(MessageV2.stream(session.id)) - parent = msgs.at(-1)?.info.id - expect(parent).toBeTruthy() - await rt.runPromise( - SessionCompaction.Service.use((svc) => - svc.process({ - parentID: parent!, - messages: msgs, - sessionID: session.id, - auto: false, - }), - ), - ) + yield* Deferred.await(ready).pipe(Effect.timeout("1 second")) + const start = Date.now() + yield* Fiber.interrupt(fiber) + const exit = yield* Fiber.await(fiber).pipe(Effect.timeout("250 millis")) - expect(captured).toContain("") - expect(captured).toContain("summary one") - expect(captured.match(/summary one/g)?.length).toBe(1) - expect(captured).toContain("## Constraints & Preferences") - expect(captured).toContain("## Progress") - } finally { - await rt.dispose() + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) { + expect(Cause.hasInterrupts(exit.cause)).toBe(true) + expect(Date.now() - start).toBeLessThan(250) } - }, - }) - }) + }).pipe(withCompaction({ llm: stub.layer })) + }, + { git: true }, + ) - test("keeps recent pre-compaction turns across repeated compactions", async () => { + itCompaction.instance( + "does not leave a summary assistant when aborted before processor setup", + () => + Effect.gen(function* () { + const ready = yield* Deferred.make() + return yield* Effect.gen(function* () { + const ssn = yield* SessionNs.Service + const session = yield* ssn.create({}) + const msg = yield* createUserMessage(session.id, "hello") + const msgs = yield* ssn.messages({ sessionID: session.id }) + const fiber = yield* SessionCompaction.use + .process({ + parentID: msg.id, + messages: msgs, + sessionID: session.id, + auto: false, + }) + .pipe(Effect.forkChild) + + yield* Deferred.await(ready).pipe(Effect.timeout("1 second")) + yield* Fiber.interrupt(fiber) + const exit = yield* Fiber.await(fiber).pipe(Effect.timeout("250 millis")) + const all = yield* ssn.messages({ sessionID: session.id }) + + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) expect(Cause.hasInterrupts(exit.cause)).toBe(true) + expect(all.some((msg) => msg.info.role === "assistant" && msg.info.summary)).toBe(false) + }).pipe(withCompaction({ plugin: plugin(ready) })) + }), + { git: true }, + ) + + itCompaction.instance( + "does not allow tool calls while generating the summary", + () => { + const stub = llm() + stub.push( + Stream.make( + { type: "start" } satisfies LLM.Event, + { type: "tool-input-start", id: "call-1", toolName: "_noop" } satisfies LLM.Event, + { type: "tool-call", toolCallId: "call-1", toolName: "_noop", input: {} } satisfies LLM.Event, + { + type: "finish-step", + finishReason: "tool-calls", + rawFinishReason: "tool_calls", + response: { id: "res", modelId: "test-model", timestamp: new Date() }, + providerMetadata: undefined, + usage: { + inputTokens: 1, + outputTokens: 1, + totalTokens: 2, + inputTokenDetails: { + noCacheTokens: undefined, + cacheReadTokens: undefined, + cacheWriteTokens: undefined, + }, + outputTokenDetails: { + textTokens: undefined, + reasoningTokens: undefined, + }, + }, + } satisfies LLM.Event, + { + type: "finish", + finishReason: "tool-calls", + rawFinishReason: "tool_calls", + totalUsage: { + inputTokens: 1, + outputTokens: 1, + totalTokens: 2, + inputTokenDetails: { + noCacheTokens: undefined, + cacheReadTokens: undefined, + cacheWriteTokens: undefined, + }, + outputTokenDetails: { + textTokens: undefined, + reasoningTokens: undefined, + }, + }, + } satisfies LLM.Event, + ), + ) + return Effect.gen(function* () { + const ssn = yield* SessionNs.Service + const session = yield* ssn.create({}) + const msg = yield* createUserMessage(session.id, "hello") + const msgs = yield* ssn.messages({ sessionID: session.id }) + yield* SessionCompaction.use.process({ parentID: msg.id, messages: msgs, sessionID: session.id, auto: false }) + + const summary = (yield* ssn.messages({ sessionID: session.id })).find( + (item) => item.info.role === "assistant" && item.info.summary, + ) + + expect(summary?.info.role).toBe("assistant") + expect(summary?.parts.some((part) => part.type === "tool")).toBe(false) + }).pipe(withCompaction({ llm: stub.layer })) + }, + { git: true }, + ) + + itCompaction.instance( + "summarizes the head while serializing recent tail into summary input", + () => { + const stub = llm() + let captured: LLM.StreamInput["messages"] = [] + stub.push( + reply("summary", (input) => { + captured = input.messages + }), + ) + return Effect.gen(function* () { + const ssn = yield* SessionNs.Service + const session = yield* ssn.create({}) + yield* createUserMessage(session.id, "older context") + yield* createUserMessage(session.id, "keep this turn") + yield* createUserMessage(session.id, "and this one too") + yield* createCompactionMarker(session.id) + + const msgs = yield* ssn.messages({ sessionID: session.id }) + const parent = msgs.at(-1)?.info.id + expect(parent).toBeTruthy() + yield* SessionCompaction.use.process({ + parentID: parent!, + messages: msgs, + sessionID: session.id, + auto: false, + }) + + const head = JSON.stringify(captured.slice(0, -1)) + const prompt = JSON.stringify(captured.at(-1)) + expect(head).toContain("older context") + expect(head).not.toContain("keep this turn") + expect(head).not.toContain("and this one too") + expect(prompt).toContain("keep this turn") + expect(prompt).toContain("and this one too") + expect(prompt).toContain("recent-conversation-tail") + expect(prompt).not.toContain("What did we do so far?") + }).pipe(withCompaction({ llm: stub.layer })) + }, + { git: true }, + ) + + itCompaction.instance( + "anchors repeated compactions with the previous summary", + () => { + const stub = llm() + let captured = "" + stub.push(reply("summary one")) + stub.push( + reply("summary two", (input) => { + captured = JSON.stringify(input.messages) + }), + ) + + return Effect.gen(function* () { + const ssn = yield* SessionNs.Service + const session = yield* ssn.create({}) + yield* createUserMessage(session.id, "older context") + yield* createUserMessage(session.id, "keep this turn") + yield* createCompactionMarker(session.id) + + let msgs = yield* ssn.messages({ sessionID: session.id }) + let parent = msgs.at(-1)?.info.id + expect(parent).toBeTruthy() + yield* SessionCompaction.use.process({ parentID: parent!, messages: msgs, sessionID: session.id, auto: false }) + + yield* createUserMessage(session.id, "latest turn") + yield* createCompactionMarker(session.id) + + msgs = MessageV2.filterCompacted(MessageV2.stream(session.id)) + parent = msgs.at(-1)?.info.id + expect(parent).toBeTruthy() + yield* SessionCompaction.use.process({ parentID: parent!, messages: msgs, sessionID: session.id, auto: false }) + + expect(captured).toContain("") + expect(captured).toContain("summary one") + expect(captured.match(/summary one/g)?.length).toBe(1) + expect(captured).toContain("## Constraints & Preferences") + expect(captured).toContain("## Progress") + }).pipe(withCompaction({ llm: stub.layer })) + }, + { git: true }, + ) + + itCompaction.instance("does not replay recent pre-compaction turns across repeated compactions", () => { const stub = llm() stub.push(reply("summary one")) stub.push(reply("summary two")) - await using tmp = await tmpdir() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const session = await svc.create({}) - const u1 = await user(session.id, "one") - const u2 = await user(session.id, "two") - const u3 = await user(session.id, "three") - await SessionCompaction.create({ - sessionID: session.id, - agent: "build", - model: ref, - auto: false, - }) - const rt = liveRuntime(stub.layer, wide(), cfg({ tail_turns: 2, preserve_recent_tokens: 10_000 })) - try { - let msgs = await svc.messages({ sessionID: session.id }) - let parent = msgs.at(-1)?.info.id - expect(parent).toBeTruthy() - await rt.runPromise( - SessionCompaction.Service.use((svc) => - svc.process({ - parentID: parent!, - messages: msgs, - sessionID: session.id, - auto: false, - }), - ), - ) + return Effect.gen(function* () { + const ssn = yield* SessionNs.Service + const session = yield* ssn.create({}) + const u1 = yield* createUserMessage(session.id, "one") + const u2 = yield* createUserMessage(session.id, "two") + const u3 = yield* createUserMessage(session.id, "three") + yield* createCompactionMarker(session.id) - const u4 = await user(session.id, "four") - await SessionCompaction.create({ - sessionID: session.id, - agent: "build", - model: ref, - auto: false, - }) + let msgs = yield* ssn.messages({ sessionID: session.id }) + let parent = msgs.at(-1)?.info.id + expect(parent).toBeTruthy() + yield* SessionCompaction.use.process({ parentID: parent!, messages: msgs, sessionID: session.id, auto: false }) - msgs = MessageV2.filterCompacted(MessageV2.stream(session.id)) - parent = msgs.at(-1)?.info.id - expect(parent).toBeTruthy() - await rt.runPromise( - SessionCompaction.Service.use((svc) => - svc.process({ - parentID: parent!, - messages: msgs, - sessionID: session.id, - auto: false, - }), - ), - ) + const u4 = yield* createUserMessage(session.id, "four") + yield* createCompactionMarker(session.id) - const filtered = MessageV2.filterCompacted(MessageV2.stream(session.id)) - const ids = filtered.map((msg) => msg.info.id) + msgs = MessageV2.filterCompacted(MessageV2.stream(session.id)) + parent = msgs.at(-1)?.info.id + expect(parent).toBeTruthy() + yield* SessionCompaction.use.process({ parentID: parent!, messages: msgs, sessionID: session.id, auto: false }) - expect(ids).not.toContain(u1.id) - expect(ids).not.toContain(u2.id) - expect(ids).toContain(u3.id) - expect(ids).toContain(u4.id) - expect(filtered.some((msg) => msg.info.role === "assistant" && msg.info.summary)).toBe(true) - expect( - filtered.some((msg) => msg.info.role === "user" && msg.parts.some((part) => part.type === "compaction")), - ).toBe(true) - } finally { - await rt.dispose() - } - }, - }) + const filtered = MessageV2.filterCompacted(MessageV2.stream(session.id)) + const ids = filtered.map((msg) => msg.info.id) + + expect(ids).not.toContain(u1.id) + expect(ids).not.toContain(u2.id) + expect(ids).not.toContain(u3.id) + expect(ids).not.toContain(u4.id) + expect(filtered.some((msg) => msg.info.role === "assistant" && msg.info.summary)).toBe(true) + expect( + filtered.some((msg) => msg.info.role === "user" && msg.parts.some((part) => part.type === "compaction")), + ).toBe(true) + }).pipe(withCompaction({ llm: stub.layer, config: cfg({ tail_turns: 2, preserve_recent_tokens: 10_000 }) })) }) - test("ignores previous summaries when sizing the retained tail", async () => { - await using tmp = await tmpdir() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const session = await svc.create({}) - await user(session.id, "older") - const keep = await user(session.id, "keep this turn") - const keepReply = await assistant(session.id, keep.id, tmp.path) - await svc.updatePart({ - id: PartID.ascending(), - messageID: keepReply.id, - sessionID: session.id, - type: "text", - text: "keep reply", - }) + itCompaction.instance( + "ignores previous summaries when sizing the serialized tail", + Effect.gen(function* () { + const ssn = yield* SessionNs.Service + const test = yield* TestInstance + const session = yield* ssn.create({}) + yield* createUserMessage(session.id, "older") + const keep = yield* createUserMessage(session.id, "keep this turn") + const keepReply = yield* createAssistantMessage(session.id, keep.id, test.directory) + yield* ssn.updatePart({ + id: PartID.ascending(), + messageID: keepReply.id, + sessionID: session.id, + type: "text", + text: "keep reply", + }) - await SessionCompaction.create({ - sessionID: session.id, - agent: "build", - model: ref, - auto: false, - }) - const firstCompaction = (await svc.messages({ sessionID: session.id })).at(-1)?.info.id - expect(firstCompaction).toBeTruthy() - await summaryAssistant(session.id, firstCompaction!, tmp.path, "summary ".repeat(800)) + yield* createCompactionMarker(session.id) + const firstCompaction = (yield* ssn.messages({ sessionID: session.id })).at(-1)?.info.id + expect(firstCompaction).toBeTruthy() + yield* createSummaryAssistantMessage(session.id, firstCompaction!, test.directory, "summary ".repeat(800)) - const recent = await user(session.id, "recent turn") - const recentReply = await assistant(session.id, recent.id, tmp.path) - await svc.updatePart({ - id: PartID.ascending(), - messageID: recentReply.id, - sessionID: session.id, - type: "text", - text: "recent reply", - }) + const recent = yield* createUserMessage(session.id, "recent turn") + const recentReply = yield* createAssistantMessage(session.id, recent.id, test.directory) + yield* ssn.updatePart({ + id: PartID.ascending(), + messageID: recentReply.id, + sessionID: session.id, + type: "text", + text: "recent reply", + }) - await SessionCompaction.create({ - sessionID: session.id, - agent: "build", - model: ref, - auto: false, - }) + yield* createCompactionMarker(session.id) + const msgs = yield* ssn.messages({ sessionID: session.id }) + const parent = msgs.at(-1)?.info.id + expect(parent).toBeTruthy() + yield* SessionCompaction.use.process({ parentID: parent!, messages: msgs, sessionID: session.id, auto: false }) - const rt = runtime("continue", Plugin.defaultLayer, wide(), cfg({ tail_turns: 2, preserve_recent_tokens: 500 })) - try { - const msgs = await svc.messages({ sessionID: session.id }) - const parent = msgs.at(-1)?.info.id - expect(parent).toBeTruthy() - await rt.runPromise( - SessionCompaction.Service.use((svc) => - svc.process({ - parentID: parent!, - messages: msgs, - sessionID: session.id, - auto: false, - }), - ), - ) - - const part = await lastCompactionPart(session.id) - expect(part?.type).toBe("compaction") - expect(part?.tail_start_id).toBe(keep.id) - } finally { - await rt.dispose() - } - }, - }) - }) + const part = yield* readCompactionPart(session.id) + expect(part?.type).toBe("compaction") + expect(part?.tail_start_id).toBeUndefined() + }).pipe(withCompaction({ config: cfg({ tail_turns: 2, preserve_recent_tokens: 500 }) })), + ) }) describe("util.token.estimate", () => { diff --git a/packages/opencode/test/session/instruction.test.ts b/packages/opencode/test/session/instruction.test.ts index 3bb38c8786..5d40933954 100644 --- a/packages/opencode/test/session/instruction.test.ts +++ b/packages/opencode/test/session/instruction.test.ts @@ -61,7 +61,7 @@ const tmpWithFiles = (files: Record) => function loaded(filepath: string): MessageV2.WithParts[] { const sessionID = SessionID.make("session-loaded-1") - const messageID = MessageID.make("message-loaded-1") + const messageID = MessageID.make("msg_message-loaded-1") return [ { @@ -78,7 +78,7 @@ function loaded(filepath: string): MessageV2.WithParts[] { }, parts: [ { - id: PartID.make("part-loaded-1"), + id: PartID.make("prt_part-loaded-1"), messageID, sessionID, type: "tool", @@ -106,7 +106,7 @@ describe("Instruction.resolve", () => { const system = yield* svc.systemPaths() expect(system.has(path.join(dir, "AGENTS.md"))).toBe(true) - const results = yield* svc.resolve([], path.join(dir, "src", "file.ts"), MessageID.make("message-test-1")) + const results = yield* svc.resolve([], path.join(dir, "src", "file.ts"), MessageID.make("msg_message-test-1")) expect(results).toEqual([]) }), ), @@ -122,7 +122,7 @@ describe("Instruction.resolve", () => { const results = yield* svc.resolve( [], path.join(dir, "subdir", "nested", "file.ts"), - MessageID.make("message-test-2"), + MessageID.make("msg_message-test-2"), ) expect(results.length).toBe(1) expect(results[0].filepath).toBe(path.join(dir, "subdir", "AGENTS.md")) @@ -138,7 +138,7 @@ describe("Instruction.resolve", () => { const system = yield* svc.systemPaths() expect(system.has(filepath)).toBe(false) - const results = yield* svc.resolve([], filepath, MessageID.make("message-test-3")) + const results = yield* svc.resolve([], filepath, MessageID.make("msg_message-test-3")) expect(results).toEqual([]) }), ), @@ -149,7 +149,7 @@ describe("Instruction.resolve", () => { Effect.gen(function* () { const svc = yield* Instruction.Service const filepath = path.join(dir, "subdir", "nested", "file.ts") - const id = MessageID.make("message-claim-1") + const id = MessageID.make("msg_message-claim-1") const first = yield* svc.resolve([], filepath, id) const second = yield* svc.resolve([], filepath, id) @@ -166,7 +166,7 @@ describe("Instruction.resolve", () => { Effect.gen(function* () { const svc = yield* Instruction.Service const filepath = path.join(dir, "subdir", "nested", "file.ts") - const id = MessageID.make("message-claim-2") + const id = MessageID.make("msg_message-claim-2") const first = yield* svc.resolve([], filepath, id) yield* svc.clear(id) @@ -185,7 +185,7 @@ describe("Instruction.resolve", () => { const svc = yield* Instruction.Service const agents = path.join(dir, "subdir", "AGENTS.md") const filepath = path.join(dir, "subdir", "nested", "file.ts") - const id = MessageID.make("message-claim-3") + const id = MessageID.make("msg_message-claim-3") const results = yield* svc.resolve(loaded(agents), filepath, id) expect(results).toEqual([]) diff --git a/packages/opencode/test/session/llm.test.ts b/packages/opencode/test/session/llm.test.ts index 7b96084832..2879d04812 100644 --- a/packages/opencode/test/session/llm.test.ts +++ b/packages/opencode/test/session/llm.test.ts @@ -354,7 +354,7 @@ describe("session.llm.stream", () => { } satisfies Agent.Info const user = { - id: MessageID.make("user-1"), + id: MessageID.make("msg_user-1"), sessionID, role: "user", time: { created: Date.now() }, @@ -438,7 +438,7 @@ describe("session.llm.stream", () => { permission: [{ permission: "*", pattern: "*", action: "allow" }], } satisfies Agent.Info const user = { - id: MessageID.make("user-service-abort"), + id: MessageID.make("msg_user-service-abort"), sessionID, role: "user", time: { created: Date.now() }, @@ -529,7 +529,7 @@ describe("session.llm.stream", () => { } satisfies Agent.Info const user = { - id: MessageID.make("user-tools"), + id: MessageID.make("msg_user-tools"), sessionID, role: "user", time: { created: Date.now() }, @@ -644,7 +644,7 @@ describe("session.llm.stream", () => { } satisfies Agent.Info const user = { - id: MessageID.make("user-2"), + id: MessageID.make("msg_user-2"), sessionID, role: "user", time: { created: Date.now() }, @@ -759,7 +759,7 @@ describe("session.llm.stream", () => { } satisfies Agent.Info const user = { - id: MessageID.make("user-data-url"), + id: MessageID.make("msg_user-data-url"), sessionID, role: "user", time: { created: Date.now() }, @@ -880,7 +880,7 @@ describe("session.llm.stream", () => { } satisfies Agent.Info const user = { - id: MessageID.make("user-3"), + id: MessageID.make("msg_user-3"), sessionID, role: "user", time: { created: Date.now() }, @@ -995,7 +995,7 @@ describe("session.llm.stream", () => { permission: [{ permission: "*", pattern: "*", action: "allow" }], } satisfies Agent.Info const user = { - id: MessageID.make("user-anthropic-tools"), + id: MessageID.make("msg_user-anthropic-tools"), sessionID, role: "user", time: { created: Date.now() }, @@ -1239,7 +1239,7 @@ describe("session.llm.stream", () => { } satisfies Agent.Info const user = { - id: MessageID.make("user-4"), + id: MessageID.make("msg_user-4"), sessionID, role: "user", time: { created: Date.now() }, diff --git a/packages/opencode/test/session/message-v2.test.ts b/packages/opencode/test/session/message-v2.test.ts index 08629f5b1b..f742b7afc8 100644 --- a/packages/opencode/test/session/message-v2.test.ts +++ b/packages/opencode/test/session/message-v2.test.ts @@ -102,9 +102,9 @@ function assistantInfo( function basePart(messageID: string, id: string) { return { - id: PartID.make(id), + id: PartID.make(id.startsWith("prt") ? id : `prt_${id}`), sessionID, - messageID: MessageID.make(messageID), + messageID: MessageID.make(messageID.startsWith("msg") ? messageID : `msg_${messageID}`), } } diff --git a/packages/opencode/test/session/messages-pagination.test.ts b/packages/opencode/test/session/messages-pagination.test.ts index 05ec2bad49..86e1d85d0d 100644 --- a/packages/opencode/test/session/messages-pagination.test.ts +++ b/packages/opencode/test/session/messages-pagination.test.ts @@ -785,7 +785,7 @@ describe("MessageV2.filterCompacted", () => { }) }) - test("retains original tail when compaction stores tail_start_id", async () => { + test("ignores original tail when compaction stores tail_start_id", async () => { await WithInstance.provide({ directory: root, fn: async () => { @@ -834,14 +834,14 @@ describe("MessageV2.filterCompacted", () => { const result = MessageV2.filterCompacted(MessageV2.stream(session.id)) - expect(result.map((item) => item.info.id)).toEqual([c1, s1, u2, a2, u3, a3]) + expect(result.map((item) => item.info.id)).toEqual([c1, s1, u3, a3]) await svc.remove(session.id) }, }) }) - test("fork remaps compaction tail_start_id for filterCompacted", async () => { + test("fork keeps legacy tail_start_id without replaying the tail", async () => { await WithInstance.provide({ directory: root, fn: async () => { @@ -889,7 +889,7 @@ describe("MessageV2.filterCompacted", () => { }) const parentFiltered = MessageV2.filterCompacted(MessageV2.stream(session.id)) - expect(parentFiltered.map((item) => item.info.id)).toEqual([c1, s1, u2, a2, u3, a3]) + expect(parentFiltered.map((item) => item.info.id)).toEqual([c1, s1, u3, a3]) const forked = await svc.fork({ sessionID: session.id }) const childFiltered = MessageV2.filterCompacted(MessageV2.stream(forked.id)) @@ -899,7 +899,7 @@ describe("MessageV2.filterCompacted", () => { expect(tailPart?.type).toBe("compaction") if (!tailPart || tailPart.type !== "compaction") throw new Error("Expected forked compaction part") expect(tailPart.tail_start_id).toBeDefined() - expect(childFiltered.some((m) => m.info.id === tailPart.tail_start_id)).toBe(true) + expect(childFiltered.some((m) => m.info.id === tailPart.tail_start_id)).toBe(false) await svc.remove(forked.id) await svc.remove(session.id) @@ -907,7 +907,7 @@ describe("MessageV2.filterCompacted", () => { }) }) - test("retains an assistant tail when compaction starts inside a turn", async () => { + test("does not replay an assistant tail when compaction starts inside a turn", async () => { await WithInstance.provide({ directory: root, fn: async () => { @@ -964,7 +964,7 @@ describe("MessageV2.filterCompacted", () => { const result = MessageV2.filterCompacted(MessageV2.stream(session.id)) - expect(result.map((item) => item.info.id)).toEqual([c1, s1, a3, u3, a4]) + expect(result.map((item) => item.info.id)).toEqual([c1, s1, u3, a4]) await svc.remove(session.id) }, @@ -1041,7 +1041,7 @@ describe("MessageV2.filterCompacted", () => { const result = MessageV2.filterCompacted(MessageV2.stream(session.id)) - expect(result.map((item) => item.info.id)).toEqual([c2, s2, u3, a3, u4, a4]) + expect(result.map((item) => item.info.id)).toEqual([c2, s2, u4, a4]) await svc.remove(session.id) }, diff --git a/packages/opencode/test/session/processor-effect.test.ts b/packages/opencode/test/session/processor-effect.test.ts index 226bab9864..56ff102430 100644 --- a/packages/opencode/test/session/processor-effect.test.ts +++ b/packages/opencode/test/session/processor-effect.test.ts @@ -6,6 +6,7 @@ import type { Agent } from "../../src/agent/agent" import { Agent as AgentSvc } from "../../src/agent/agent" import { Bus } from "../../src/bus" import { Config } from "@/config/config" +import { Image } from "@/image/image" import { Permission } from "../../src/permission" import { Plugin } from "../../src/plugin" import { Provider } from "@/provider/provider" @@ -23,6 +24,7 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { provideTmpdirServer } from "../fixture/fixture" import { testEffect } from "../lib/effect" import { raw, reply, TestLLMServer } from "../lib/llm-server" +import { SyncEvent } from "@/sync" void Log.init({ print: false }) @@ -165,10 +167,11 @@ const deps = Layer.mergeAll( LLM.defaultLayer, Provider.defaultLayer, status, + SyncEvent.defaultLayer, ).pipe(Layer.provideMerge(infra)) const env = Layer.mergeAll( TestLLMServer.layer, - SessionProcessor.layer.pipe(Layer.provide(summary), Layer.provideMerge(deps)), + SessionProcessor.layer.pipe(Layer.provide(summary), Layer.provide(Image.defaultLayer), Layer.provideMerge(deps)), ) const it = testEffect(env) diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index c5170f3464..f5c1674658 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -2,6 +2,7 @@ import { NodeFileSystem } from "@effect/platform-node" import { FetchHttpClient } from "effect/unstable/http" import { expect } from "bun:test" import { Cause, Effect, Exit, Fiber, Layer } from "effect" +import fs from "fs/promises" import path from "path" import { fileURLToPath } from "url" import { NamedError } from "@opencode-ai/core/util/error" @@ -15,6 +16,8 @@ import { Permission } from "../../src/permission" import { Plugin } from "../../src/plugin" import { Provider as ProviderSvc } from "@/provider/provider" import { Env } from "../../src/env" +import { Git } from "../../src/git" +import { Image } from "../../src/image/image" import { ModelID, ProviderID } from "../../src/provider/schema" import { Question } from "../../src/question" import { Todo } from "../../src/session/todo" @@ -44,9 +47,11 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import * as Database from "../../src/storage/db" import { Ripgrep } from "../../src/file/ripgrep" import { Format } from "../../src/format" +import { Reference } from "../../src/reference/reference" import { provideTmpdirInstance, provideTmpdirServer } from "../fixture/fixture" import { testEffect } from "../lib/effect" import { reply, TestLLMServer } from "../lib/llm-server" +import { SyncEvent } from "@/sync" void Log.init({ print: false }) @@ -171,6 +176,7 @@ function makeHttp() { mcp, AppFileSystem.defaultLayer, status, + SyncEvent.defaultLayer, ).pipe(Layer.provideMerge(infra)) const question = Question.layer.pipe(Layer.provideMerge(deps)) const todo = Todo.layer.pipe(Layer.provideMerge(deps)) @@ -178,6 +184,8 @@ function makeHttp() { Layer.provide(Skill.defaultLayer), Layer.provide(FetchHttpClient.layer), Layer.provide(CrossSpawnSpawner.defaultLayer), + Layer.provide(Git.defaultLayer), + Layer.provide(Reference.defaultLayer), Layer.provide(Ripgrep.defaultLayer), Layer.provide(Format.defaultLayer), Layer.provideMerge(todo), @@ -185,12 +193,18 @@ function makeHttp() { Layer.provideMerge(deps), ) const trunc = Truncate.layer.pipe(Layer.provideMerge(deps)) - const proc = SessionProcessor.layer.pipe(Layer.provide(summary), Layer.provideMerge(deps)) + const proc = SessionProcessor.layer.pipe( + Layer.provide(summary), + Layer.provide(Image.defaultLayer), + Layer.provideMerge(deps), + ) const compact = SessionCompaction.layer.pipe(Layer.provideMerge(proc), Layer.provideMerge(deps)) return Layer.mergeAll( TestLLMServer.layer, SessionPrompt.layer.pipe( Layer.provide(SessionRevert.defaultLayer), + Layer.provide(Image.defaultLayer), + Layer.provide(Reference.defaultLayer), Layer.provide(summary), Layer.provideMerge(run), Layer.provideMerge(compact), @@ -1779,6 +1793,102 @@ it.live("keeps stored part order stable when file resolution is async", () => ), ) +it.live("resolves configured reference mentions before workspace paths and agents", () => + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + const docs = path.join(dir, "external-docs") + yield* Effect.promise(() => fs.mkdir(path.join(docs, "guide"), { recursive: true })) + yield* Effect.promise(() => fs.mkdir(path.join(dir, "docs"), { recursive: true })) + yield* Effect.promise(() => Bun.write(path.join(docs, "README.md"), "reference readme")) + yield* Effect.promise(() => Bun.write(path.join(docs, "guide", "intro.md"), "reference intro")) + yield* Effect.promise(() => Bun.write(path.join(dir, "docs", "README.md"), "workspace readme")) + + const prompt = yield* SessionPrompt.Service + const parts = yield* prompt.resolvePromptParts( + "Use @docs and @docs/README.md and @docs/guide and @docs/missing.md and @docs/README.md and @build", + ) + const references = parts.filter( + (part): part is MessageV2.TextPartInput => + part.type === "text" && part.synthetic === true && part.text.startsWith("Referenced configured reference "), + ) + const files = parts.filter((part): part is MessageV2.FilePartInput => part.type === "file") + const agents = parts.filter((part): part is MessageV2.AgentPartInput => part.type === "agent") + const bare = references.find((part) => part.text.includes("@docs.")) + const missing = references.find((part) => part.text.includes("@docs/missing.md")) + const guide = files.find((part) => part.filename === "docs/guide") + + expect(references.length).toBe(2) + expect(bare?.metadata?.reference).toMatchObject({ + name: "docs", + kind: "local", + path: docs, + }) + expect(missing?.text).toContain("Path does not exist inside configured reference @docs") + expect(missing?.metadata?.reference).toMatchObject({ + target: "missing.md", + targetPath: path.join(docs, "missing.md"), + }) + + expect(files.length).toBe(2) + expect(files.map((file) => fileURLToPath(file.url)).sort()).toEqual( + [path.join(docs, "README.md"), path.join(docs, "guide")].sort(), + ) + expect(guide?.mime).toBe("application/x-directory") + expect(agents.map((agent) => agent.name)).toEqual(["build"]) + }), + { + git: true, + config: { + ...cfg, + reference: { + docs: "./external-docs", + }, + }, + }, + ), +) + +it.live("injects metadata for bare configured reference mentions", () => + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + const docs = path.join(dir, "external-docs") + yield* Effect.promise(() => fs.mkdir(docs, { recursive: true })) + + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const session = yield* sessions.create({}) + const message = yield* prompt.prompt({ + sessionID: session.id, + noReply: true, + parts: yield* prompt.resolvePromptParts("Use @docs for context"), + }) + + const stored = MessageV2.get({ sessionID: session.id, messageID: message.info.id }) + const synthetic = stored.parts.filter( + (part): part is MessageV2.TextPart => part.type === "text" && part.synthetic === true, + ) + const reference = synthetic.find((part) => part.text.startsWith("Referenced configured reference @docs.")) + + expect(reference?.metadata?.reference).toMatchObject({ name: "docs", kind: "local", path: docs }) + expect(synthetic.some((part) => part.text.includes(`Reference root: ${docs}`))).toBe(true) + expect(synthetic.some((part) => part.text.includes("subagent scout"))).toBe(true) + + yield* sessions.remove(session.id) + }), + { + git: true, + config: { + ...cfg, + reference: { + docs: "./external-docs", + }, + }, + }, + ), +) + // Special characters in filenames it.live("handles filenames with # character", () => diff --git a/packages/opencode/test/session/retry.test.ts b/packages/opencode/test/session/retry.test.ts index 0b67294796..9da45c9112 100644 --- a/packages/opencode/test/session/retry.test.ts +++ b/packages/opencode/test/session/retry.test.ts @@ -13,6 +13,7 @@ import { provideTmpdirInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" const providerID = ProviderID.make("test") +const retryProvider = "test" const it = testEffect(Layer.mergeAll(SessionStatus.defaultLayer, CrossSpawnSpawner.defaultLayer)) function apiError(headers?: Record): MessageV2.APIError { @@ -92,6 +93,7 @@ describe("session.retry.delay", () => { const step = yield* Schedule.toStepWithMetadata( SessionRetry.policy({ + provider: "test", parse: (err) => MessageV2.APIError.Schema.parse(err), set: (info) => status.set(sessionID, { @@ -118,47 +120,47 @@ describe("session.retry.delay", () => { describe("session.retry.retryable", () => { test("maps too_many_requests json messages", () => { const error = wrap(JSON.stringify({ type: "error", error: { type: "too_many_requests" } })) - expect(SessionRetry.retryable(error)).toEqual({ message: "Too Many Requests" }) + expect(SessionRetry.retryable(error, retryProvider)).toEqual({ message: "Too Many Requests" }) }) test("maps overloaded provider codes", () => { const error = wrap(JSON.stringify({ code: "resource_exhausted" })) - expect(SessionRetry.retryable(error)).toEqual({ message: "Provider is overloaded" }) + expect(SessionRetry.retryable(error, retryProvider)).toEqual({ message: "Provider is overloaded" }) }) test("does not retry unknown json messages", () => { const error = wrap(JSON.stringify({ error: { message: "no_kv_space" } })) - expect(SessionRetry.retryable(error)).toBeUndefined() + expect(SessionRetry.retryable(error, retryProvider)).toBeUndefined() }) test("does not throw on numeric error codes", () => { const error = wrap(JSON.stringify({ type: "error", error: { code: 123 } })) - const result = SessionRetry.retryable(error) + const result = SessionRetry.retryable(error, retryProvider) expect(result).toBeUndefined() }) test("returns undefined for non-json message", () => { const error = wrap("not-json") - expect(SessionRetry.retryable(error)).toBeUndefined() + expect(SessionRetry.retryable(error, retryProvider)).toBeUndefined() }) test("retries plain text rate limit errors from Alibaba", () => { const msg = "Upstream error from Alibaba: Request rate increased too quickly. To ensure system stability, please adjust your client logic to scale requests more smoothly over time." const error = wrap(msg) - expect(SessionRetry.retryable(error)).toEqual({ message: msg }) + expect(SessionRetry.retryable(error, retryProvider)).toEqual({ message: msg }) }) test("retries plain text rate limit errors", () => { const msg = "Rate limit exceeded, please try again later" const error = wrap(msg) - expect(SessionRetry.retryable(error)).toEqual({ message: msg }) + expect(SessionRetry.retryable(error, retryProvider)).toEqual({ message: msg }) }) test("retries too many requests in plain text", () => { const msg = "Too many requests, please slow down" const error = wrap(msg) - expect(SessionRetry.retryable(error)).toEqual({ message: msg }) + expect(SessionRetry.retryable(error, retryProvider)).toEqual({ message: msg }) }) test("does not retry context overflow errors", () => { @@ -167,7 +169,7 @@ describe("session.retry.retryable", () => { responseBody: '{"error":{"code":"context_length_exceeded"}}', }).toObject() - expect(SessionRetry.retryable(error)).toBeUndefined() + expect(SessionRetry.retryable(error, retryProvider)).toBeUndefined() }) test("retries 500 errors even when isRetryable is false", () => { @@ -180,7 +182,7 @@ describe("session.retry.retryable", () => { }).toObject(), ) - expect(SessionRetry.retryable(error)).toEqual({ message: "Internal server error" }) + expect(SessionRetry.retryable(error, retryProvider)).toEqual({ message: "Internal server error" }) }) test("retries 502 bad gateway errors", () => { @@ -192,7 +194,7 @@ describe("session.retry.retryable", () => { }).toObject(), ) - expect(SessionRetry.retryable(error)).toEqual({ message: "Bad gateway" }) + expect(SessionRetry.retryable(error, retryProvider)).toEqual({ message: "Bad gateway" }) }) test("retries 503 service unavailable errors", () => { @@ -204,7 +206,7 @@ describe("session.retry.retryable", () => { }).toObject(), ) - expect(SessionRetry.retryable(error)).toEqual({ message: "Service unavailable" }) + expect(SessionRetry.retryable(error, retryProvider)).toEqual({ message: "Service unavailable" }) }) test("does not retry 4xx errors when isRetryable is false", () => { @@ -216,7 +218,7 @@ describe("session.retry.retryable", () => { }).toObject(), ) - expect(SessionRetry.retryable(error)).toBeUndefined() + expect(SessionRetry.retryable(error, retryProvider)).toBeUndefined() }) test("retries ZlibError decompression failures", () => { @@ -228,7 +230,7 @@ describe("session.retry.retryable", () => { }).toObject(), ) - const retryable = SessionRetry.retryable(error) + const retryable = SessionRetry.retryable(error, retryProvider) expect(retryable).toBeDefined() expect(retryable).toEqual({ message: "Response decompression failed" }) }) @@ -246,9 +248,11 @@ describe("session.retry.retryable", () => { }).toObject(), ) - expect(SessionRetry.retryable(error)).toEqual({ + expect(SessionRetry.retryable(error, "opencode")).toEqual({ message: SessionRetry.GO_UPSELL_MESSAGE, action: { + reason: "free_tier_limit", + provider: "opencode", title: "Free limit reached", message: "Subscribe to OpenCode Go for reliable access to the best open-source models, starting at $5/month.", label: "subscribe", @@ -280,10 +284,12 @@ describe("session.retry.retryable", () => { }).toObject(), ) - expect(SessionRetry.retryable(error)).toEqual({ + expect(SessionRetry.retryable(error, "opencode-go")).toEqual({ message: "5 hour usage limit reached. It will reset in 5 hours 23 minutes. To continue using this model now, enable usage from your available balance - https://opencode.ai/workspace/wrk_01K6XGM22R6FM8JVABE9XDQXGH/go", action: { + reason: "account_rate_limit", + provider: "opencode-go", title: "Go limit reached", message: "5 hour usage limit reached. It will reset in 5 hours 23 minutes. To continue using this model now, enable usage from your available balance", @@ -292,6 +298,33 @@ describe("session.retry.retryable", () => { }, }) }) + + test("maps Go subscription limits without limit metadata", () => { + const error = MessageV2.APIError.Schema.parse( + new MessageV2.APIError({ + message: "Subscription quota exceeded. You can continue using free models.", + isRetryable: true, + statusCode: 429, + responseHeaders: { + "retry-after": "900", + }, + responseBody: JSON.stringify({ + type: "error", + error: { + type: "GoUsageLimitError", + message: "Subscription quota exceeded. You can continue using free models.", + }, + metadata: { + workspace: "wrk_01K6XGM22R6FM8JVABE9XDQXGH", + }, + }), + }).toObject(), + ) + + expect(SessionRetry.retryable(error, "opencode-go")?.action?.message).toBe( + "Usage limit reached. It will reset in 15 minutes. To continue using this model now, enable usage from your available balance", + ) + }) }) describe("session.message-v2.fromError", () => { @@ -341,7 +374,7 @@ describe("session.message-v2.fromError", () => { }).toObject(), ) - const retryable = SessionRetry.retryable(error) + const retryable = SessionRetry.retryable(error, retryProvider) expect(retryable).toBeDefined() expect(retryable).toEqual({ message: "Connection reset by server" }) }) @@ -381,6 +414,8 @@ describe("session.message-v2.fromError", () => { expect(MessageV2.APIError.isInstance(result)).toBe(true) if (!MessageV2.APIError.isInstance(result)) throw new Error("expected APIError") expect(result.data.isRetryable).toBe(true) - expect(SessionRetry.retryable(result)).toEqual({ message: "An error occurred while processing your request." }) + expect(SessionRetry.retryable(result, retryProvider)).toEqual({ + message: "An error occurred while processing your request.", + }) }) }) diff --git a/packages/opencode/test/session/schema-decoding.test.ts b/packages/opencode/test/session/schema-decoding.test.ts index 8bb94bdd8c..67c438a386 100644 --- a/packages/opencode/test/session/schema-decoding.test.ts +++ b/packages/opencode/test/session/schema-decoding.test.ts @@ -15,20 +15,20 @@ import { WorkspaceID } from "../../src/control-plane/schema" // schema we assert: // 1. The Effect decoder (`Schema.decodeUnknownSync`) accepts valid input. // 2. The derived Zod (`X.zod.parse`) accepts the same input and returns the -// same shape. -// 3. Clearly-invalid input is rejected by both paths. +// same shape for schemas that still expose Zod statics. +// 3. Clearly-invalid input is rejected by both paths where both exist. // // The point is to lock down the Schema <-> Zod bridge so a future edit to // any input schema can't silently drop or widen a field on one side. // Representative valid IDs — the branded schemas require the right prefix // (see src/id/id.ts). -const sessionID = SessionID.zod.parse("ses_01J5Y5H0AH4Q4NXJ6P4C3P5V2K") -const sessionIDChild = SessionID.zod.parse("ses_01J5Y5H0AH4Q4NXJ6P4C3P5V2L") -const messageID = MessageID.zod.parse("msg_01J5Y5H0AH4Q4NXJ6P4C3P5V2M") -const partID = PartID.zod.parse("prt_01J5Y5H0AH4Q4NXJ6P4C3P5V2N") +const sessionID = Schema.decodeUnknownSync(SessionID)("ses_01J5Y5H0AH4Q4NXJ6P4C3P5V2K") +const sessionIDChild = Schema.decodeUnknownSync(SessionID)("ses_01J5Y5H0AH4Q4NXJ6P4C3P5V2L") +const messageID = Schema.decodeUnknownSync(MessageID)("msg_01J5Y5H0AH4Q4NXJ6P4C3P5V2M") +const partID = Schema.decodeUnknownSync(PartID)("prt_01J5Y5H0AH4Q4NXJ6P4C3P5V2N") const projectID = ProjectID.zod.parse("proj-alpha") -const workspaceID = WorkspaceID.zod.parse("wrk-primary") +const workspaceID = Schema.decodeUnknownSync(WorkspaceID)("wrk-primary") function decodeUnknown(schema: S) { const decode = Schema.decodeUnknownSync(schema as any) @@ -83,6 +83,26 @@ describe("Session.Info", () => { expect(Session.Info.zod.parse(input)).toEqual(input) }) + test("accepts migrated summary diffs without file details", () => { + const input = { + id: sessionID, + slug: "legacy-diff", + projectID, + directory: "/tmp/proj", + title: "Legacy diff", + version: "0.1.0", + summary: { + additions: 1, + deletions: 0, + files: 1, + diffs: [{ additions: 1, deletions: 0 }], + }, + time: { created: 1, updated: 2 }, + } + expect(decode(input)).toEqual(input) + expect(Session.Info.zod.parse(input)).toEqual(input) + }) + test("rejects unbranded session id", () => { const bad = { id: "not-a-session-id" } as unknown expect(() => decode(bad)).toThrow() @@ -236,6 +256,8 @@ describe("SessionStatus.Info", () => { attempt: 1, message: "transient", action: { + reason: "free_tier_limit", + provider: "opencode", title: "Free limit reached", message: "Subscribe to OpenCode Go.", label: "subscribe", diff --git a/packages/opencode/test/session/snapshot-tool-race.test.ts b/packages/opencode/test/session/snapshot-tool-race.test.ts index ab5a3ab7ed..8640612e98 100644 --- a/packages/opencode/test/session/snapshot-tool-race.test.ts +++ b/packages/opencode/test/session/snapshot-tool-race.test.ts @@ -30,6 +30,7 @@ import { TestLLMServer } from "../lib/llm-server" // Same layer setup as prompt-effect.test.ts import { NodeFileSystem } from "@effect/platform-node" import { Agent as AgentSvc } from "../../src/agent/agent" +import { Git } from "../../src/git" import { Bus } from "../../src/bus" import { Command } from "../../src/command" import { Config } from "@/config/config" @@ -40,6 +41,7 @@ import { Plugin } from "../../src/plugin" import { Provider as ProviderSvc } from "@/provider/provider" import { Env } from "../../src/env" import { Question } from "../../src/question" +import { Image } from "../../src/image/image" import { Skill } from "../../src/skill" import { SystemPrompt } from "../../src/session/system" import { Todo } from "../../src/session/todo" @@ -55,6 +57,8 @@ import { AppFileSystem } from "@opencode-ai/core/filesystem" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Ripgrep } from "../../src/file/ripgrep" import { Format } from "../../src/format" +import { Reference } from "../../src/reference/reference" +import { SyncEvent } from "@/sync" void Log.init({ print: false }) @@ -121,6 +125,7 @@ function makeHttp() { mcp, AppFileSystem.defaultLayer, status, + SyncEvent.defaultLayer, ).pipe(Layer.provideMerge(infra)) const question = Question.layer.pipe(Layer.provideMerge(deps)) const todo = Todo.layer.pipe(Layer.provideMerge(deps)) @@ -128,6 +133,8 @@ function makeHttp() { Layer.provide(Skill.defaultLayer), Layer.provide(FetchHttpClient.layer), Layer.provide(CrossSpawnSpawner.defaultLayer), + Layer.provide(Git.defaultLayer), + Layer.provide(Reference.defaultLayer), Layer.provide(Ripgrep.defaultLayer), Layer.provide(Format.defaultLayer), Layer.provideMerge(todo), @@ -135,13 +142,19 @@ function makeHttp() { Layer.provideMerge(deps), ) const trunc = Truncate.layer.pipe(Layer.provideMerge(deps)) - const proc = SessionProcessor.layer.pipe(Layer.provide(SessionSummary.defaultLayer), Layer.provideMerge(deps)) + const proc = SessionProcessor.layer.pipe( + Layer.provide(SessionSummary.defaultLayer), + Layer.provide(Image.defaultLayer), + Layer.provideMerge(deps), + ) const compact = SessionCompaction.layer.pipe(Layer.provideMerge(proc), Layer.provideMerge(deps)) return Layer.mergeAll( TestLLMServer.layer, SessionSummary.defaultLayer, SessionPrompt.layer.pipe( Layer.provide(SessionRevert.defaultLayer), + Layer.provide(Image.defaultLayer), + Layer.provide(Reference.defaultLayer), Layer.provide(SessionSummary.defaultLayer), Layer.provideMerge(run), Layer.provideMerge(compact), @@ -236,7 +249,7 @@ it.live("tool execution produces non-empty session diff (snapshot race)", () => expect(tool?.state.status).toBe("completed") // Poll for diff — summarize() is fire-and-forget - let diff: Array<{ file: string }> = [] + let diff: Array<{ file?: string }> = [] for (let i = 0; i < 50; i++) { diff = yield* summary.diff({ sessionID: session.id }) if (diff.length > 0) break diff --git a/packages/opencode/test/session/system.test.ts b/packages/opencode/test/session/system.test.ts index 6e5439da58..1cf9026725 100644 --- a/packages/opencode/test/session/system.test.ts +++ b/packages/opencode/test/session/system.test.ts @@ -26,6 +26,11 @@ const skills: Skill.Info[] = [ location: "/tmp/middle-skill/SKILL.md", content: "# middle-skill", }, + { + name: "manual-skill", + location: "/tmp/manual-skill/SKILL.md", + content: "# manual-skill", + }, ] const build: Agent.Info = { @@ -68,6 +73,7 @@ describe("session.system", () => { expect(alpha).toBeGreaterThan(-1) expect(middle).toBeGreaterThan(alpha) expect(zeta).toBeGreaterThan(middle) + expect(output).not.toContain("manual-skill") }), ) }) diff --git a/packages/opencode/test/skill/skill.test.ts b/packages/opencode/test/skill/skill.test.ts index bfcb0dcd67..d73750b083 100644 --- a/packages/opencode/test/skill/skill.test.ts +++ b/packages/opencode/test/skill/skill.test.ts @@ -163,6 +163,37 @@ Just some content without YAML frontmatter. ), ) + it.live("discovers skills without descriptions", () => + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + yield* Effect.promise(() => + Bun.write( + path.join(dir, ".opencode", "skill", "manual-skill", "SKILL.md"), + `--- +name: manual-skill +--- + +# Manual Skill + +Instructions here. +`, + ), + ) + + const skill = yield* Skill.Service + const list = yield* skill.all() + expect(list.length).toBe(1) + const item = list.find((x) => x.name === "manual-skill") + expect(item).toBeDefined() + expect(item!.description).toBeUndefined() + expect(Skill.fmt(list, { verbose: false })).toBe("No skills are currently available.") + expect(Skill.fmt(list, { verbose: true })).toBe("No skills are currently available.") + }), + { git: true }, + ), + ) + it.live("discovers skills from .claude/skills/ directory", () => provideTmpdirInstance( (dir) => diff --git a/packages/opencode/test/storage/workspace-time-migration.test.ts b/packages/opencode/test/storage/workspace-time-migration.test.ts new file mode 100644 index 0000000000..2d30646976 --- /dev/null +++ b/packages/opencode/test/storage/workspace-time-migration.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, test } from "bun:test" +import { Database } from "bun:sqlite" +import { drizzle } from "drizzle-orm/bun-sqlite" +import { migrate } from "drizzle-orm/bun-sqlite/migrator" +import { readFileSync, readdirSync } from "fs" +import path from "path" + +const target = "20260507164347_add_workspace_time" + +function migrations() { + return readdirSync(path.join(import.meta.dirname, "../../migration"), { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => ({ + name: entry.name, + timestamp: Number(entry.name.split("_")[0]), + sql: readFileSync(path.join(import.meta.dirname, "../../migration", entry.name, "migration.sql"), "utf-8"), + })) + .sort((a, b) => a.timestamp - b.timestamp) +} + +describe("workspace time migration", () => { + test("migrates existing workspace rows", () => { + const sqlite = new Database(":memory:") + const db = drizzle({ client: sqlite }) + const entries = migrations() + const index = entries.findIndex((entry) => entry.name === target) + + expect(index).toBeGreaterThan(0) + + migrate(db, entries.slice(0, index)) + sqlite.run( + "INSERT INTO project (id, worktree, vcs, name, time_created, time_updated, sandboxes) VALUES (?, ?, ?, ?, ?, ?, ?)", + ["project_1", "/tmp/project", "git", "project", 1, 1, "[]"], + ) + sqlite.run( + "INSERT INTO workspace (id, type, name, branch, directory, extra, project_id) VALUES (?, ?, ?, ?, ?, ?, ?)", + ["workspace_1", "local", "main", "main", "/tmp/project", null, "project_1"], + ) + + expect(() => migrate(db, entries.slice(index))).not.toThrow() + expect(sqlite.query("SELECT time_used FROM workspace WHERE id = ?").get("workspace_1")).toEqual({ time_used: 0 }) + }) +}) diff --git a/packages/opencode/test/sync/index.test.ts b/packages/opencode/test/sync/index.test.ts index 0986b39044..10f593a571 100644 --- a/packages/opencode/test/sync/index.test.ts +++ b/packages/opencode/test/sync/index.test.ts @@ -4,7 +4,7 @@ import { Effect, Layer, Schema } from "effect" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Bus } from "../../src/bus" import { SyncEvent } from "../../src/sync" -import { Database } from "@/storage/db" +import { Database, eq } from "@/storage/db" import { EventSequenceTable, EventTable } from "../../src/sync/event.sql" import { MessageID } from "../../src/session/schema" import { Flag } from "@opencode-ai/core/flag/flag" @@ -323,5 +323,28 @@ describe("SyncEvent", () => { }), ), ) + + it.live( + "claim updates the event sequence owner", + provideTmpdirInstance(() => + Effect.gen(function* () { + const { Created } = setup() + const id = MessageID.ascending() + + yield* SyncEvent.use.run(Created, { id, name: "claimed" }, { publish: false }) + yield* SyncEvent.use.claim(id, "owner-1") + yield* SyncEvent.use.claim(id, "owner-2") + + const row = Database.use((db) => + db + .select({ seq: EventSequenceTable.seq, ownerID: EventSequenceTable.owner_id }) + .from(EventSequenceTable) + .where(eq(EventSequenceTable.aggregate_id, id)) + .get(), + ) + expect(row).toEqual({ seq: 0, ownerID: "owner-2" }) + }), + ), + ) }) }) diff --git a/packages/opencode/test/tool/apply_patch.test.ts b/packages/opencode/test/tool/apply_patch.test.ts index fd24b557b3..3fc034e4e5 100644 --- a/packages/opencode/test/tool/apply_patch.test.ts +++ b/packages/opencode/test/tool/apply_patch.test.ts @@ -27,7 +27,7 @@ const runtime = ManagedRuntime.make( const baseCtx = { sessionID: SessionID.make("ses_test"), - messageID: MessageID.make(""), + messageID: MessageID.make("msg_test"), callID: "", agent: "build", abort: AbortSignal.any([]), diff --git a/packages/opencode/test/tool/edit.test.ts b/packages/opencode/test/tool/edit.test.ts index 23ae0e9090..a629ff07d1 100644 --- a/packages/opencode/test/tool/edit.test.ts +++ b/packages/opencode/test/tool/edit.test.ts @@ -17,7 +17,7 @@ import { SessionID, MessageID } from "../../src/session/schema" const ctx = { sessionID: SessionID.make("ses_test-edit-session"), - messageID: MessageID.make(""), + messageID: MessageID.make("msg_test"), callID: "", agent: "build", abort: AbortSignal.any([]), diff --git a/packages/opencode/test/tool/external-directory.test.ts b/packages/opencode/test/tool/external-directory.test.ts index 5914918178..0560ea0300 100644 --- a/packages/opencode/test/tool/external-directory.test.ts +++ b/packages/opencode/test/tool/external-directory.test.ts @@ -12,7 +12,7 @@ import { SessionID, MessageID } from "../../src/session/schema" const baseCtx: Omit = { sessionID: SessionID.make("ses_test"), - messageID: MessageID.make(""), + messageID: MessageID.make("msg_test"), callID: "", agent: "build", abort: AbortSignal.any([]), diff --git a/packages/opencode/test/tool/glob.test.ts b/packages/opencode/test/tool/glob.test.ts index 94f401afd8..45dc0b36a9 100644 --- a/packages/opencode/test/tool/glob.test.ts +++ b/packages/opencode/test/tool/glob.test.ts @@ -10,6 +10,7 @@ import { Truncate } from "@/tool/truncate" import { Agent } from "../../src/agent/agent" import { TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" +import { Reference } from "@/reference/reference" const it = testEffect( Layer.mergeAll( @@ -18,12 +19,13 @@ const it = testEffect( Ripgrep.defaultLayer, Truncate.defaultLayer, Agent.defaultLayer, + Reference.defaultLayer, ), ) const ctx = { sessionID: SessionID.make("ses_test"), - messageID: MessageID.make(""), + messageID: MessageID.make("msg_test"), callID: "", agent: "build", abort: AbortSignal.any([]), diff --git a/packages/opencode/test/tool/grep.test.ts b/packages/opencode/test/tool/grep.test.ts index 4b0da7c698..53f5d9a19c 100644 --- a/packages/opencode/test/tool/grep.test.ts +++ b/packages/opencode/test/tool/grep.test.ts @@ -10,6 +10,7 @@ import { Agent } from "../../src/agent/agent" import { Ripgrep } from "../../src/file/ripgrep" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { testEffect } from "../lib/effect" +import { Reference } from "@/reference/reference" const it = testEffect( Layer.mergeAll( @@ -18,12 +19,13 @@ const it = testEffect( Ripgrep.defaultLayer, Truncate.defaultLayer, Agent.defaultLayer, + Reference.defaultLayer, ), ) const ctx = { sessionID: SessionID.make("ses_test"), - messageID: MessageID.make(""), + messageID: MessageID.make("msg_test"), callID: "", agent: "build", abort: AbortSignal.any([]), diff --git a/packages/opencode/test/tool/lsp.test.ts b/packages/opencode/test/tool/lsp.test.ts index 27623375c2..875af8e010 100644 --- a/packages/opencode/test/tool/lsp.test.ts +++ b/packages/opencode/test/tool/lsp.test.ts @@ -20,7 +20,7 @@ afterEach(async () => { const ctx = { sessionID: SessionID.make("ses_test"), - messageID: MessageID.make(""), + messageID: MessageID.make("msg_test"), callID: "", agent: "build", abort: AbortSignal.any([]), diff --git a/packages/opencode/test/tool/parameters.test.ts b/packages/opencode/test/tool/parameters.test.ts index 9f6a0617ed..17af7b983e 100644 --- a/packages/opencode/test/tool/parameters.test.ts +++ b/packages/opencode/test/tool/parameters.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test" import { Result, Schema } from "effect" -import { toJsonSchema } from "../../src/util/effect-zod" +import { toJsonSchema } from "@opencode-ai/core/effect-zod" // Each tool exports its parameters schema at module scope so this test can // import them without running the tool's Effect-based init. The JSON Schema diff --git a/packages/opencode/test/tool/question.test.ts b/packages/opencode/test/tool/question.test.ts index 3f2cba8941..da215db770 100644 --- a/packages/opencode/test/tool/question.test.ts +++ b/packages/opencode/test/tool/question.test.ts @@ -10,7 +10,7 @@ import { testEffect } from "../lib/effect" const ctx = { sessionID: SessionID.make("ses_test-session"), - messageID: MessageID.make("test-message"), + messageID: MessageID.make("msg_test-message"), callID: "test-call", agent: "test-agent", abort: AbortSignal.any([]), diff --git a/packages/opencode/test/tool/read.test.ts b/packages/opencode/test/tool/read.test.ts index 695d96ec2f..11bb1513f3 100644 --- a/packages/opencode/test/tool/read.test.ts +++ b/packages/opencode/test/tool/read.test.ts @@ -4,6 +4,8 @@ import path from "path" import { Agent } from "../../src/agent/agent" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { Flag } from "@opencode-ai/core/flag/flag" +import { Global } from "@opencode-ai/core/global" import { LSP } from "@/lsp/lsp" import { Permission } from "../../src/permission" import { Instance } from "../../src/project/instance" @@ -15,6 +17,7 @@ import { Tool } from "@/tool/tool" import { Filesystem } from "@/util/filesystem" import { disposeAllInstances, provideInstance, TestInstance, tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" +import { Reference } from "@/reference/reference" const FIXTURES_DIR = path.join(import.meta.dir, "fixtures") @@ -24,7 +27,7 @@ afterEach(async () => { const ctx = { sessionID: SessionID.make("ses_test"), - messageID: MessageID.make(""), + messageID: MessageID.make("msg_test"), callID: "", agent: "build", abort: AbortSignal.any([]), @@ -40,6 +43,7 @@ const it = testEffect( CrossSpawnSpawner.defaultLayer, Instruction.defaultLayer, LSP.defaultLayer, + Reference.defaultLayer, Truncate.defaultLayer, ), ) @@ -81,6 +85,49 @@ const fail = Effect.fn("ReadToolTest.fail")(function* ( const full = (p: string) => (process.platform === "win32" ? Filesystem.normalizePath(p) : p) const glob = (p: string) => process.platform === "win32" ? Filesystem.normalizePathPattern(p) : p.replaceAll("\\", "/") +const experimentalScout = (self: Effect.Effect) => + Effect.acquireUseRelease( + Effect.sync(() => { + const previous = Flag.OPENCODE_EXPERIMENTAL_SCOUT + Flag.OPENCODE_EXPERIMENTAL_SCOUT = true + return previous + }), + () => self, + (previous) => + Effect.sync(() => { + Flag.OPENCODE_EXPERIMENTAL_SCOUT = previous + }), + ) +const githubBase = (url: string, self: Effect.Effect) => + Effect.acquireUseRelease( + Effect.sync(() => { + const previous = process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL + process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL = url + return previous + }), + () => self, + (previous) => + Effect.sync(() => { + if (previous) process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL = previous + else delete process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL + }), + ) +const git = Effect.fn("ReadToolTest.git")(function* (cwd: string, args: string[]) { + return yield* Effect.promise(async () => { + const proc = Bun.spawn(["git", ...args], { + cwd, + stdout: "pipe", + stderr: "pipe", + }) + const [stdout, stderr, code] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]) + if (code !== 0) throw new Error(stderr.trim() || stdout.trim() || `git ${args.join(" ")} failed`) + return stdout.trim() + }) +}) const put = Effect.fn("ReadToolTest.put")(function* (p: string, content: string | Buffer | Uint8Array) { const fs = yield* AppFileSystem.Service yield* fs.writeWithDirs(p, content) @@ -155,11 +202,24 @@ describe("tool.read external_directory permission", () => { yield* exec(dir, { filePath: alt }, next) const read = items.find((item) => item.permission === "read") expect(read).toBeDefined() - expect(read!.patterns).toEqual([full(target)]) + expect(read!.patterns).toEqual([path.relative(dir, full(target))]) }), ) } + it.live("uses worktree-relative path for read permission so user rules match like edit/write", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + yield* put(path.join(dir, "src", "secret.ts"), "shh") + + const { items, next } = asks() + yield* exec(dir, { filePath: path.join(dir, "src", "secret.ts") }, next) + const read = items.find((item) => item.permission === "read") + expect(read).toBeDefined() + expect(read!.patterns).toEqual([path.join("src", "secret.ts")]) + }), + ) + it.live("asks for directory-scoped external_directory permission when reading external directory", () => Effect.gen(function* () { const outer = yield* tmpdirScoped() @@ -199,6 +259,46 @@ describe("tool.read external_directory permission", () => { expect(ext).toBeUndefined() }), ) + + it.live("does not ask for external_directory permission when reading configured references", () => + experimentalScout( + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const cache = path.join(Global.Path.repos, "github.com", "opencode-read-reference", "repo") + yield* fs.remove(cache, { recursive: true }).pipe(Effect.ignore) + yield* Effect.addFinalizer(() => fs.remove(cache, { recursive: true }).pipe(Effect.ignore)) + + const source = yield* tmpdirScoped({ git: true }) + const remoteRoot = yield* tmpdirScoped() + const remoteDir = path.join(remoteRoot, "opencode-read-reference") + const remoteRepo = path.join(remoteDir, "repo.git") + yield* put(path.join(source, "notes.md"), "reference notes") + yield* git(source, ["add", "."]) + yield* git(source, ["commit", "-m", "add notes"]) + yield* fs.makeDirectory(remoteDir, { recursive: true }).pipe(Effect.orDie) + yield* git(remoteRoot, ["clone", "--bare", source, remoteRepo]) + + const dir = yield* tmpdirScoped({ + git: true, + config: { + reference: { + docs: "opencode-read-reference/repo", + }, + }, + }) + + const { items, next } = asks() + const result = yield* githubBase( + `file://${remoteRoot}/`, + exec(dir, { filePath: path.join(cache, "notes.md") }, next), + ) + const ext = items.find((item) => item.permission === "external_directory") + + expect(result.output).toContain("reference notes") + expect(ext).toBeUndefined() + }), + ), + ) }) describe("tool.read env file permissions", () => { diff --git a/packages/opencode/test/tool/registry.test.ts b/packages/opencode/test/tool/registry.test.ts index c33981ddff..dc66c308ac 100644 --- a/packages/opencode/test/tool/registry.test.ts +++ b/packages/opencode/test/tool/registry.test.ts @@ -4,6 +4,7 @@ import fs from "fs/promises" import { Effect, Layer } from "effect" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { ToolRegistry } from "@/tool/registry" +import { Flag } from "@opencode-ai/core/flag/flag" import { disposeAllInstances, TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" import { TestConfig } from "../fixture/config" @@ -15,6 +16,7 @@ import { Skill } from "@/skill" import { Agent } from "@/agent/agent" import { Session } from "@/session/session" import { Provider } from "@/provider/provider" +import { Git } from "@/git" import { LSP } from "@/lsp/lsp" import { Instruction } from "@/session/instruction" import { Bus } from "@/bus" @@ -23,8 +25,10 @@ import { Format } from "@/format" import { Ripgrep } from "@/file/ripgrep" import * as Truncate from "@/tool/truncate" import { InstanceState } from "@/effect/instance-state" +import { Reference } from "@/reference/reference" const node = CrossSpawnSpawner.defaultLayer +const originalExperimentalScout = Flag.OPENCODE_EXPERIMENTAL_SCOUT const configLayer = TestConfig.layer({ directories: () => InstanceState.directory.pipe(Effect.map((dir) => [path.join(dir, ".opencode")])), }) @@ -38,6 +42,8 @@ const registryLayer = ToolRegistry.layer.pipe( Layer.provide(Agent.defaultLayer), Layer.provide(Session.defaultLayer), Layer.provide(Provider.defaultLayer), + Layer.provide(Git.defaultLayer), + Layer.provide(Reference.defaultLayer), Layer.provide(LSP.defaultLayer), Layer.provide(Instruction.defaultLayer), Layer.provide(AppFileSystem.defaultLayer), @@ -52,10 +58,35 @@ const registryLayer = ToolRegistry.layer.pipe( const it = testEffect(Layer.mergeAll(registryLayer, node)) afterEach(async () => { + Flag.OPENCODE_EXPERIMENTAL_SCOUT = originalExperimentalScout await disposeAllInstances() }) describe("tool.registry", () => { + it.instance("hides repo research tools unless experimental", () => + Effect.gen(function* () { + Flag.OPENCODE_EXPERIMENTAL_SCOUT = false + const registry = yield* ToolRegistry.Service + const ids = yield* registry.ids() + + expect(ids).not.toContain("codesearch") + expect(ids).not.toContain("repo_clone") + expect(ids).not.toContain("repo_overview") + }), + ) + + it.instance("shows repo research tools when experimental scout is enabled", () => + Effect.gen(function* () { + Flag.OPENCODE_EXPERIMENTAL_SCOUT = true + const registry = yield* ToolRegistry.Service + const ids = yield* registry.ids() + + expect(ids).toContain("codesearch") + expect(ids).toContain("repo_clone") + expect(ids).toContain("repo_overview") + }), + ) + it.instance("loads tools from .opencode/tool (singular)", () => Effect.gen(function* () { const test = yield* TestInstance diff --git a/packages/opencode/test/tool/repo_clone.test.ts b/packages/opencode/test/tool/repo_clone.test.ts new file mode 100644 index 0000000000..1ac913328d --- /dev/null +++ b/packages/opencode/test/tool/repo_clone.test.ts @@ -0,0 +1,226 @@ +import { afterEach, describe, expect } from "bun:test" +import path from "path" +import { pathToFileURL } from "node:url" +import { Cause, Effect, Exit, Layer } from "effect" +import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { Agent } from "../../src/agent/agent" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { Git } from "../../src/git" +import { Global } from "@opencode-ai/core/global" +import { MessageID, SessionID } from "../../src/session/schema" +import { Truncate } from "../../src/tool/truncate" +import { RepoCloneTool } from "../../src/tool/repo_clone" +import { disposeAllInstances, provideTmpdirInstance, tmpdirScoped } from "../fixture/fixture" +import { testEffect } from "../lib/effect" + +afterEach(async () => { + await disposeAllInstances() +}) + +const ctx = { + sessionID: SessionID.make("ses_test"), + messageID: MessageID.make("msg_test"), + callID: "", + agent: "scout", + abort: AbortSignal.any([]), + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, +} + +const it = testEffect( + Layer.mergeAll( + Agent.defaultLayer, + AppFileSystem.defaultLayer, + CrossSpawnSpawner.defaultLayer, + Git.defaultLayer, + Truncate.defaultLayer, + ), +) + +const init = Effect.fn("RepoCloneToolTest.init")(function* () { + const info = yield* RepoCloneTool + return yield* info.init() +}) + +const git = Effect.fn("RepoCloneToolTest.git")(function* (cwd: string, args: string[]) { + return yield* Effect.promise(async () => { + const proc = Bun.spawn(["git", ...args], { + cwd, + stdout: "pipe", + stderr: "pipe", + }) + const [stdout, stderr, code] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]) + if (code !== 0) { + throw new Error(stderr.trim() || stdout.trim() || `git ${args.join(" ")} failed`) + } + return stdout.trim() + }) +}) + +const githubBase = (url: string, self: Effect.Effect) => + Effect.acquireUseRelease( + Effect.sync(() => { + const previous = process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL + process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL = url + return previous + }), + () => self, + (previous) => + Effect.sync(() => { + if (previous) process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL = previous + else delete process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL + }), + ) + +describe("tool.repo_clone", () => { + it.live("clones a repo into the managed cache and reuses it on subsequent calls", () => + provideTmpdirInstance((_dir) => + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const source = yield* tmpdirScoped({ git: true }) + const remoteRoot = yield* tmpdirScoped() + const remoteDir = path.join(remoteRoot, "owner") + const remoteRepo = path.join(remoteDir, "repo.git") + + yield* Effect.promise(() => Bun.write(path.join(source, "README.md"), "v1\n")) + yield* git(source, ["add", "."]) + yield* git(source, ["commit", "-m", "add readme"]) + yield* fs.makeDirectory(remoteDir, { recursive: true }).pipe(Effect.orDie) + yield* git(remoteRoot, ["clone", "--bare", source, remoteRepo]) + + const tool = yield* init() + const cloned = yield* githubBase(`file://${remoteRoot}/`, tool.execute({ repository: "owner/repo" }, ctx)) + const cached = yield* githubBase( + `file://${remoteRoot}/`, + tool.execute({ repository: "https://github.com/owner/repo.git" }, ctx), + ) + + expect(cloned.metadata.status).toBe("cloned") + expect(cloned.metadata.localPath).toBe(path.join(Global.Path.repos, "github.com", "owner", "repo")) + expect(cached.metadata.status).toBe("cached") + expect(yield* fs.readFileString(path.join(cloned.metadata.localPath, "README.md"))).toBe("v1\n") + }), + ), + ) + + it.live("refresh updates an existing cached clone", () => + provideTmpdirInstance((_dir) => + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const source = yield* tmpdirScoped({ git: true }) + const remoteRoot = yield* tmpdirScoped() + const remoteDir = path.join(remoteRoot, "owner") + const remoteRepo = path.join(remoteDir, "repo.git") + + yield* Effect.promise(() => Bun.write(path.join(source, "README.md"), "v1\n")) + yield* git(source, ["add", "."]) + yield* git(source, ["commit", "-m", "add readme"]) + yield* fs.makeDirectory(remoteDir, { recursive: true }).pipe(Effect.orDie) + yield* git(remoteRoot, ["clone", "--bare", source, remoteRepo]) + + const branch = yield* git(source, ["branch", "--show-current"]) + yield* git(source, ["remote", "add", "origin", remoteRepo]) + yield* git(source, ["push", "-u", "origin", `${branch}:${branch}`]) + + const tool = yield* init() + const first = yield* githubBase(`file://${remoteRoot}/`, tool.execute({ repository: "owner/repo" }, ctx)) + + yield* Effect.promise(() => Bun.write(path.join(source, "README.md"), "v2\n")) + yield* git(source, ["add", "."]) + yield* git(source, ["commit", "-m", "update readme"]) + yield* git(source, ["push", "origin", `${branch}:${branch}`]) + + const refreshed = yield* githubBase( + `file://${remoteRoot}/`, + tool.execute({ repository: "owner/repo", refresh: true }, ctx), + ) + + expect(first.metadata.status).toBe("cloned") + expect(refreshed.metadata.status).toBe("refreshed") + expect(yield* fs.readFileString(path.join(first.metadata.localPath, "README.md"))).toBe("v2\n") + }), + ), + ) + + it.live("clones a configured branch", () => + provideTmpdirInstance((_dir) => + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const source = yield* tmpdirScoped({ git: true }) + const remoteRoot = yield* tmpdirScoped() + const remoteDir = path.join(remoteRoot, "owner") + const remoteRepo = path.join(remoteDir, "repo.git") + + yield* Effect.promise(() => Bun.write(path.join(source, "README.md"), "main\n")) + yield* git(source, ["add", "."]) + yield* git(source, ["commit", "-m", "add readme"]) + yield* git(source, ["checkout", "-b", "docs"]) + yield* Effect.promise(() => Bun.write(path.join(source, "DOCS.md"), "docs\n")) + yield* git(source, ["add", "."]) + yield* git(source, ["commit", "-m", "add docs"]) + yield* fs.makeDirectory(remoteDir, { recursive: true }).pipe(Effect.orDie) + yield* git(remoteRoot, ["clone", "--bare", source, remoteRepo]) + + const tool = yield* init() + const result = yield* githubBase( + `file://${remoteRoot}/`, + tool.execute({ repository: "owner/repo", branch: "docs" }, ctx), + ) + + expect(result.metadata.status).toBe("cloned") + expect(result.metadata.branch).toBe("docs") + expect(yield* fs.readFileString(path.join(result.metadata.localPath, "DOCS.md"))).toBe("docs\n") + }), + ), + ) + + it.live("rejects invalid repository inputs", () => + provideTmpdirInstance((_dir) => + Effect.gen(function* () { + const tool = yield* init() + const inputs = [ + { repository: "not-a-repo", message: "git URL" }, + { repository: "git@github.com:../../../etc/passwd", message: "git URL" }, + { repository: "-u:foo/bar", message: "git URL" }, + { repository: pathToFileURL(path.join(_dir, "local.git")).href, message: "Local file" }, + ] + + yield* Effect.forEach( + inputs, + (input) => + Effect.gen(function* () { + const result = yield* tool.execute({ repository: input.repository }, ctx).pipe(Effect.exit) + + expect(Exit.isFailure(result)).toBe(true) + if (Exit.isFailure(result)) { + const error = Cause.squash(result.cause) + expect(error instanceof Error ? error.message : String(error)).toContain(input.message) + } + }), + { discard: true }, + ) + }), + ), + ) + + it.live("rejects local file repository URLs", () => + provideTmpdirInstance((_dir) => + Effect.gen(function* () { + const source = yield* tmpdirScoped({ git: true }) + const tool = yield* init() + const result = yield* tool.execute({ repository: pathToFileURL(source).href }, ctx).pipe(Effect.exit) + + expect(Exit.isFailure(result)).toBe(true) + if (Exit.isFailure(result)) { + const error = Cause.squash(result.cause) + expect(error instanceof Error ? error.message : String(error)).toContain("Local file") + } + }), + ), + ) +}) diff --git a/packages/opencode/test/tool/repo_overview.test.ts b/packages/opencode/test/tool/repo_overview.test.ts new file mode 100644 index 0000000000..556fa05d1f --- /dev/null +++ b/packages/opencode/test/tool/repo_overview.test.ts @@ -0,0 +1,150 @@ +import { afterEach, describe, expect } from "bun:test" +import path from "path" +import { Cause, Effect, Exit, Layer } from "effect" +import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { Agent } from "../../src/agent/agent" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { Git } from "../../src/git" +import { Global } from "@opencode-ai/core/global" +import { MessageID, SessionID } from "../../src/session/schema" +import { Truncate } from "../../src/tool/truncate" +import { RepoOverviewTool } from "../../src/tool/repo_overview" +import { disposeAllInstances, provideTmpdirInstance, tmpdirScoped } from "../fixture/fixture" +import { testEffect } from "../lib/effect" + +afterEach(async () => { + await disposeAllInstances() +}) + +const ctx = { + sessionID: SessionID.make("ses_test"), + messageID: MessageID.make("msg_test"), + callID: "", + agent: "scout", + abort: AbortSignal.any([]), + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, +} + +const it = testEffect( + Layer.mergeAll( + Agent.defaultLayer, + AppFileSystem.defaultLayer, + CrossSpawnSpawner.defaultLayer, + Git.defaultLayer, + Truncate.defaultLayer, + ), +) + +const init = Effect.fn("RepoOverviewToolTest.init")(function* () { + const info = yield* RepoOverviewTool + return yield* info.init() +}) + +describe("tool.repo_overview", () => { + it.live("summarizes a local repository path", () => + provideTmpdirInstance((_dir) => + Effect.gen(function* () { + const repo = yield* tmpdirScoped({ git: true }) + const fs = yield* AppFileSystem.Service + yield* fs.writeWithDirs( + path.join(repo, "package.json"), + JSON.stringify( + { + name: "example-repo", + main: "dist/index.js", + module: "dist/index.mjs", + types: "dist/index.d.ts", + exports: { + ".": "./dist/index.js", + "./server": "./dist/server.js", + }, + bin: { + example: "./bin/example.js", + }, + }, + null, + 2, + ), + ) + yield* fs.writeWithDirs(path.join(repo, "bun.lock"), "") + yield* fs.writeWithDirs(path.join(repo, "README.md"), "# Example\n") + yield* fs.writeWithDirs(path.join(repo, "src", "index.ts"), "export const value = 1\n") + + const tool = yield* init() + const result = yield* tool.execute({ path: repo }, ctx) + + expect(result.metadata.path).toBe(repo) + expect(result.metadata.ecosystems).toContain("Node.js") + expect(result.metadata.package_manager).toBe("bun") + expect(result.metadata.dependency_files).toEqual(expect.arrayContaining(["package.json", "bun.lock"])) + expect(result.metadata.entrypoints).toEqual( + expect.arrayContaining([ + "main: dist/index.js", + "module: dist/index.mjs", + "types: dist/index.d.ts", + "exports: .", + "exports: ./server", + "bin: example", + "file: src/index.ts", + ]), + ) + expect(result.output).toContain("Top-level structure:") + expect(result.output).toContain("src/") + expect(result.output).toContain("README.md") + }), + ), + ) + + it.live("resolves a cached repository from repository shorthand", () => + provideTmpdirInstance((_dir) => + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const cached = path.join(Global.Path.repos, "github.com", "owner", "repo") + yield* fs.writeWithDirs(path.join(cached, "package.json"), JSON.stringify({ name: "cached-repo" }, null, 2)) + yield* fs.writeWithDirs(path.join(cached, "README.md"), "cached\n") + + const tool = yield* init() + const result = yield* tool.execute({ repository: "owner/repo" }, ctx) + + expect(result.metadata.path).toBe(cached) + expect(result.metadata.repository).toBe("owner/repo") + expect(result.output).toContain("Repository: owner/repo") + expect(result.output).toContain(`Path: ${cached}`) + }), + ), + ) + + it.live("fails clearly when a repository is not cloned", () => + provideTmpdirInstance((_dir) => + Effect.gen(function* () { + const tool = yield* init() + const result = yield* tool.execute({ repository: "missing/repo" }, ctx).pipe(Effect.exit) + + expect(Exit.isFailure(result)).toBe(true) + if (Exit.isFailure(result)) { + const error = Cause.squash(result.cause) + expect(error instanceof Error ? error.message : String(error)).toContain("Use repo_clone first") + } + }), + ), + ) + + it.live("resolves cached repositories from host/path references", () => + provideTmpdirInstance((_dir) => + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const cached = path.join(Global.Path.repos, "gitlab.com", "group", "repo") + yield* fs.writeWithDirs(path.join(cached, "README.md"), "cached\n") + + const tool = yield* init() + const result = yield* tool.execute({ repository: "gitlab.com/group/repo" }, ctx) + + expect(result.metadata.path).toBe(cached) + expect(result.metadata.repository).toBe("gitlab.com/group/repo") + expect(result.output).toContain("Repository: gitlab.com/group/repo") + }), + ), + ) +}) diff --git a/packages/opencode/test/tool/shell.test.ts b/packages/opencode/test/tool/shell.test.ts index 9b5c17c222..287844141f 100644 --- a/packages/opencode/test/tool/shell.test.ts +++ b/packages/opencode/test/tool/shell.test.ts @@ -36,7 +36,7 @@ const initShell = initBash const ctx = { sessionID: SessionID.make("ses_test"), - messageID: MessageID.make(""), + messageID: MessageID.make("msg_test"), callID: "", agent: "build", abort: AbortSignal.any([]), diff --git a/packages/opencode/test/tool/skill.test.ts b/packages/opencode/test/tool/skill.test.ts index 7473d2d56a..c58d1a190d 100644 --- a/packages/opencode/test/tool/skill.test.ts +++ b/packages/opencode/test/tool/skill.test.ts @@ -14,7 +14,7 @@ import { testEffect } from "../lib/effect" const baseCtx: Omit = { sessionID: SessionID.make("ses_test"), - messageID: MessageID.make(""), + messageID: MessageID.make("msg_test"), callID: "", agent: "build", abort: AbortSignal.any([]), diff --git a/packages/opencode/test/tool/webfetch.test.ts b/packages/opencode/test/tool/webfetch.test.ts index 6c7f6aba77..f3890c0161 100644 --- a/packages/opencode/test/tool/webfetch.test.ts +++ b/packages/opencode/test/tool/webfetch.test.ts @@ -13,7 +13,7 @@ const projectRoot = path.join(import.meta.dir, "../..") const ctx = { sessionID: SessionID.make("ses_test"), - messageID: MessageID.make("message"), + messageID: MessageID.make("msg_message"), callID: "", agent: "build", abort: AbortSignal.any([]), diff --git a/packages/opencode/test/tool/write.test.ts b/packages/opencode/test/tool/write.test.ts index 8bba52a4b2..f6ac57a8ce 100644 --- a/packages/opencode/test/tool/write.test.ts +++ b/packages/opencode/test/tool/write.test.ts @@ -18,7 +18,7 @@ import { testEffect } from "../lib/effect" const ctx = { sessionID: SessionID.make("ses_test-write-session"), - messageID: MessageID.make(""), + messageID: MessageID.make("msg_test"), callID: "", agent: "build", abort: AbortSignal.any([]), diff --git a/packages/opencode/test/util/effect-zod.test.ts b/packages/opencode/test/util/effect-zod.test.ts index 70cd8f0e64..ab3923d8e0 100644 --- a/packages/opencode/test/util/effect-zod.test.ts +++ b/packages/opencode/test/util/effect-zod.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test" import { Effect, Schema, SchemaGetter } from "effect" import z from "zod" -import { zod, ZodOverride } from "../../src/util/effect-zod" +import { zod, ZodOverride } from "@opencode-ai/core/effect-zod" function json(schema: z.ZodTypeAny) { const { $schema: _, ...rest } = z.toJSONSchema(schema) diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 0c7a694834..a3ce97368d 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/plugin", - "version": "1.14.41", + "version": "1.14.48", "type": "module", "license": "MIT", "scripts": { @@ -22,9 +22,9 @@ "zod": "catalog:" }, "peerDependencies": { - "@opentui/core": ">=0.2.5", - "@opentui/keymap": ">=0.2.5", - "@opentui/solid": ">=0.2.5" + "@opentui/core": ">=0.2.6", + "@opentui/keymap": ">=0.2.6", + "@opentui/solid": ">=0.2.6" }, "peerDependenciesMeta": { "@opentui/core": { diff --git a/packages/plugin/src/tui.ts b/packages/plugin/src/tui.ts index b42bfdaf1f..851b0476e5 100644 --- a/packages/plugin/src/tui.ts +++ b/packages/plugin/src/tui.ts @@ -18,8 +18,9 @@ import type { import type { CliRenderer, KeyEvent, RGBA, Renderable, SlotMode } from "@opentui/core" import type { Binding, Keymap } from "@opentui/keymap" import { - resolveBindingSections as resolveKeymapBindingSections, - type BindingSectionsConfig, + createBindingLookup as createKeymapBindingLookup, + type BindingConfig, + type CreateBindingLookupOptions, type KeySequenceFormatPart, type SequenceBindingLike, } from "@opentui/keymap/extras" @@ -31,22 +32,21 @@ export { stringifyKeySequence, stringifyKeyStroke } from "@opentui/keymap" export type { Binding, KeyLike, KeySequencePart, KeyStringifyInput, StringifyOptions } from "@opentui/keymap" export { formatCommandBindings, formatKeySequence } from "@opentui/keymap/extras" export type { - BindingSectionsConfig, + BindingConfig, + BindingLookup, BindingValue, + CreateBindingLookupOptions, FormatCommandBindingsOptions, FormatKeySequenceOptions, KeySequenceFormatPart, SequenceBindingLike, } from "@opentui/keymap/extras" -export function resolveBindingSections
( - config: BindingSectionsConfig | undefined, - options: { sections: readonly Section[] }, +export function createBindingLookup( + config: BindingConfig | undefined, + options?: CreateBindingLookupOptions, ) { - return resolveKeymapBindingSections, Section>( - config ?? {}, - options, - ) + return createKeymapBindingLookup(config ?? {}, options) } export type TuiRouteCurrent = @@ -77,6 +77,42 @@ export type TuiKeys = { export type TuiKeymap = Keymap +/** + * Legacy `api.command` shape kept so v1 plugins can initialize. Remove in v2. + * + * @deprecated Use `api.keymap.registerLayer({ commands, bindings })` instead. + */ +export type TuiCommand = { + title: string + value: string + description?: string + category?: string + keybind?: string + suggested?: boolean + hidden?: boolean + enabled?: boolean + slash?: { + name: string + aliases?: string[] + } + onSelect?: (dialog?: TuiDialogStack) => void | Promise +} + +/** + * Legacy `api.command` API kept so v1 plugins can initialize. Remove in v2. + * + * @deprecated Use `api.keymap.registerLayer`, `api.keymap.dispatchCommand`, and + * `api.keymap.dispatchCommand("command.palette.show")` instead. + */ +export type TuiCommandApi = { + /** @deprecated Use `api.keymap.registerLayer({ commands, bindings })` instead. */ + register: (cb: () => TuiCommand[]) => () => void + /** @deprecated Use `api.keymap.dispatchCommand(name)` instead. */ + trigger: (value: string) => void + /** @deprecated Use `api.keymap.dispatchCommand("command.palette.show")` instead. */ + show: () => void +} + export type TuiDialogProps = { size?: "medium" | "large" | "xlarge" onClose: () => void @@ -286,17 +322,20 @@ export type TuiState = { mcp: () => ReadonlyArray } -type TuiConfigView = Pick & +type TuiBindingLookupView = { + readonly bindings: ReadonlyArray> + get: (command: string) => ReadonlyArray> + has: (command: string) => boolean + gather: (name: string, commands: readonly string[]) => ReadonlyArray> + pick: (name: string, commands: readonly string[]) => Binding[] + omit: (name: string, commands: readonly string[]) => Binding[] +} + +type TuiConfigView = Pick & NonNullable & { + leader_timeout: number plugin_enabled?: Record - keymap: { - leader: string - leader_timeout: number - sections: Record>> - get: (section: string, cmd: string) => ReadonlyArray> | undefined - pick: (section: string, commands: readonly string[]) => Binding[] - omit: (section: string, commands: readonly string[]) => Binding[] - } + keybinds: TuiBindingLookupView } export type TuiApp = { @@ -458,6 +497,13 @@ export type TuiWorkspace = { export type TuiPluginApi = { app: TuiApp + /** + * Legacy `api.command` API kept so v1 plugins can initialize. Remove in v2. + * + * @deprecated Use `api.keymap.registerLayer`, `api.keymap.dispatchCommand`, and + * `api.keymap.dispatchCommand("command.palette.show")` instead. + */ + command?: TuiCommandApi keys: TuiKeys keymap: TuiKeymap route: { diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index 2959cba2dd..65fbf98f0e 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/sdk", - "version": "1.14.41", + "version": "1.14.48", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/sdk/js/script/build.ts b/packages/sdk/js/script/build.ts index 946ad1402b..b3f74a1bf6 100755 --- a/packages/sdk/js/script/build.ts +++ b/packages/sdk/js/script/build.ts @@ -9,16 +9,9 @@ import path from "path" import { createClient } from "@hey-api/openapi-ts" -const openapiSource = process.env.OPENCODE_SDK_OPENAPI === "hono" ? "hono" : "httpapi" const opencode = path.resolve(dir, "../../opencode") -// `bun dev generate` now derives the spec from the Effect HttpApi contract by -// default; pass `--hono` to fall back to the legacy Hono spec for parity diffs. -if (openapiSource === "httpapi") { - await $`bun dev generate > ${dir}/openapi.json`.cwd(opencode) -} else { - await $`bun dev generate --hono > ${dir}/openapi.json`.cwd(opencode) -} +await $`bun dev generate > ${dir}/openapi.json`.cwd(opencode) await createClient({ input: "./openapi.json", diff --git a/packages/sdk/js/src/client.ts b/packages/sdk/js/src/client.ts index 05f4638252..5cf071e7b7 100644 --- a/packages/sdk/js/src/client.ts +++ b/packages/sdk/js/src/client.ts @@ -3,6 +3,7 @@ export * from "./gen/types.gen.js" import { createClient } from "./gen/client/client.gen.js" import { type Config } from "./gen/client/types.gen.js" import { OpencodeClient } from "./gen/sdk.gen.js" +import { wrapClientError } from "./error-interceptor.js" export { type Config as OpencodeClientConfig, OpencodeClient } function pick(value: string | null, fallback?: string) { @@ -51,5 +52,6 @@ export function createOpencodeClient(config?: Config & { directory?: string }) { const client = createClient(config) client.interceptors.request.use((request) => rewrite(request, config?.directory)) + client.interceptors.error.use(wrapClientError) return new OpencodeClient({ client }) } diff --git a/packages/sdk/js/src/error-interceptor.ts b/packages/sdk/js/src/error-interceptor.ts new file mode 100644 index 0000000000..26407ecfc9 --- /dev/null +++ b/packages/sdk/js/src/error-interceptor.ts @@ -0,0 +1,51 @@ +/** + * Wrap whatever the generated client decoded from a non-2xx error body + * into a real `Error` so downstream formatters (TUI, plugins) get a + * useful `.message` instead of `[object Object]` or blank. The original + * parsed body and status live under `.cause` for callers that need + * structured fields. + * + * Only fires when the caller used `{ throwOnError: true }`. Callers that + * read `result.error` directly (the result-tuple path) get the parsed + * body unchanged so existing field-level reads (`.error.name`, + * `JSON.stringify(error)`, etc.) are byte-for-byte identical to before. + */ +export function wrapClientError( + error: unknown, + response: Response | undefined, + request: Request | undefined, + opts: { throwOnError?: boolean } | undefined, +): unknown { + if (!opts?.throwOnError) return error + if (error instanceof Error) return error + + // NamedError-shaped responses (the common case for opencode 4xx) come + // through as POJOs — extract a useful message first, then wrap. + if (typeof error === "object" && error !== null && Object.keys(error).length > 0) { + const obj = error as { data?: { message?: unknown }; message?: unknown; name?: unknown } + const message = + (typeof obj.data?.message === "string" && obj.data.message) || + (typeof obj.message === "string" && obj.message) || + (typeof obj.name === "string" && obj.name) || + describe(request, response) + return new Error(message, { cause: { body: error, status: response?.status } }) + } + + if (typeof error === "string" && error.length > 0) { + return new Error(error, { cause: { body: error, status: response?.status } }) + } + + // Empty body / network failure / undefined / null / empty object. + const reason = response ? "(empty response body)" : "network error (no response)" + return new Error(`opencode server ${describe(request, response)}: ${reason}`, { + cause: { body: error, status: response?.status }, + }) +} + +function describe(request: Request | undefined, response: Response | undefined) { + const method = request?.method ?? "?" + const url = request?.url ?? "?" + const status = response?.status + const statusText = response?.statusText + return `${method} ${url}${status ? " → " + status : ""}${statusText ? " " + statusText : ""}` +} diff --git a/packages/sdk/js/src/gen/types.gen.ts b/packages/sdk/js/src/gen/types.gen.ts index 8eefe5bfe9..8fd2a02b92 100644 --- a/packages/sdk/js/src/gen/types.gen.ts +++ b/packages/sdk/js/src/gen/types.gen.ts @@ -752,11 +752,11 @@ export type Project = { } export type BadRequestError = { - data: unknown - errors: Array<{ - [key: string]: unknown - }> - success: false + name: "BadRequest" + data: { + message: string + kind?: "Params" | "Headers" | "Query" | "Body" | "Payload" + } } export type NotFoundError = { @@ -1065,7 +1065,7 @@ export type ProviderConfig = { output: Array<"text" | "audio" | "image" | "video" | "pdf"> } experimental?: boolean - status?: "alpha" | "beta" | "deprecated" + status?: "alpha" | "beta" | "deprecated" | "active" options?: { [key: string]: unknown } @@ -3012,7 +3012,7 @@ export type ProviderListResponses = { output: Array<"text" | "audio" | "image" | "video" | "pdf"> } experimental?: boolean - status?: "alpha" | "beta" | "deprecated" + status?: "alpha" | "beta" | "deprecated" | "active" options: { [key: string]: unknown } diff --git a/packages/sdk/js/src/v2/client.ts b/packages/sdk/js/src/v2/client.ts index 8b49e7f101..1c8afc0d64 100644 --- a/packages/sdk/js/src/v2/client.ts +++ b/packages/sdk/js/src/v2/client.ts @@ -3,6 +3,7 @@ export * from "./gen/types.gen.js" import { createClient } from "./gen/client/client.gen.js" import { type Config } from "./gen/client/types.gen.js" import { OpencodeClient } from "./gen/sdk.gen.js" +import { wrapClientError } from "../error-interceptor.js" export { type Config as OpencodeClientConfig, OpencodeClient } function pick(value: string | null, fallback?: string, encode?: (value: string) => string) { @@ -84,24 +85,6 @@ export function createOpencodeClient(config?: Config & { directory?: string; exp return response }) - // The generated client falls back to throwing a literal `{}` when the server - // responds with an empty / unparseable error body, which surfaces as a bare - // `{}` in TUI / CLI error output. Wrap ONLY that case in a real Error so - // downstream formatters get a useful message — but pass through any parsed - // JSON error body unchanged so existing consumers can still inspect fields. - client.interceptors.error.use((error, response, request) => { - const isEmpty = - error === undefined || - error === null || - error === "" || - (typeof error === "object" && !(error instanceof Error) && Object.keys(error).length === 0) - if (!isEmpty) return error - const method = request?.method ?? "?" - const url = request?.url ?? "?" - if (!response) return new Error(`opencode server ${method} ${url}: network error (no response)`) - const status = response.status - const statusText = response.statusText ? " " + response.statusText : "" - return new Error(`opencode server ${method} ${url} → ${status}${statusText}: (empty response body)`) - }) + client.interceptors.error.use(wrapClientError) return new OpencodeClient({ client }) } diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index ebedb1dd6b..bf3201a5c0 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -24,7 +24,9 @@ import type { EventTuiPromptAppend2, EventTuiSessionSelect2, EventTuiToastShow2, + ExperimentalConsoleGetErrors, ExperimentalConsoleGetResponses, + ExperimentalConsoleListOrgsErrors, ExperimentalConsoleListOrgsResponses, ExperimentalConsoleSwitchOrgResponses, ExperimentalResourceListResponses, @@ -36,6 +38,7 @@ import type { ExperimentalWorkspaceRemoveErrors, ExperimentalWorkspaceRemoveResponses, ExperimentalWorkspaceStatusResponses, + ExperimentalWorkspaceSyncListResponses, ExperimentalWorkspaceWarpErrors, ExperimentalWorkspaceWarpResponses, FileListResponses, @@ -686,7 +689,11 @@ export class Console extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get< + ExperimentalConsoleGetResponses, + ExperimentalConsoleGetErrors, + ThrowOnError + >({ url: "/experimental/console", ...options, ...params, @@ -716,7 +723,11 @@ export class Console extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get< + ExperimentalConsoleListOrgsResponses, + ExperimentalConsoleListOrgsErrors, + ThrowOnError + >({ url: "/experimental/console/orgs", ...options, ...params, @@ -949,6 +960,36 @@ export class Workspace extends HeyApiClient { }) } + /** + * Sync workspace list + * + * Register missing workspaces returned by workspace adapters. + */ + public syncList( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/experimental/workspace/sync-list", + ...options, + ...params, + }) + } + /** * Workspace status * @@ -4122,6 +4163,13 @@ export class Session3 extends HeyApiClient { parameters?: { directory?: string workspace?: string + limit?: number + order?: "asc" | "desc" + path?: string + roots?: boolean | "true" | "false" + start?: number + search?: string + cursor?: string }, options?: Options, ) { @@ -4132,6 +4180,13 @@ export class Session3 extends HeyApiClient { args: [ { in: "query", key: "directory" }, { in: "query", key: "workspace" }, + { in: "query", key: "limit" }, + { in: "query", key: "order" }, + { in: "query", key: "path" }, + { in: "query", key: "roots" }, + { in: "query", key: "start" }, + { in: "query", key: "search" }, + { in: "query", key: "cursor" }, ], }, ], @@ -4290,6 +4345,9 @@ export class Session3 extends HeyApiClient { sessionID: string directory?: string workspace?: string + limit?: number + order?: "asc" | "desc" + cursor?: string }, options?: Options, ) { @@ -4301,6 +4359,9 @@ export class Session3 extends HeyApiClient { { in: "path", key: "sessionID" }, { in: "query", key: "directory" }, { in: "query", key: "workspace" }, + { in: "query", key: "limit" }, + { in: "query", key: "order" }, + { in: "query", key: "cursor" }, ], }, ], diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 5a330c37b6..da80645ad7 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -119,8 +119,8 @@ export type PermissionRequest = { } export type SnapshotFileDiff = { - file: string - patch: string + file?: string + patch?: string additions: number deletions: number status?: "added" | "deleted" | "modified" @@ -267,6 +267,8 @@ export type SessionStatus = attempt: number message: string action?: { + reason: string + provider: string title: string message: string label: string @@ -769,6 +771,7 @@ export type Prompt = { text: string files?: Array agents?: Array + references?: Array } export type GlobalEvent = { @@ -899,6 +902,26 @@ export type ServerConfig = { cors?: Array } +export type ReferenceConfigEntry = + | string + | { + /** + * Git repository URL, host/path reference, or GitHub owner/repo shorthand + */ + repository: string + branch?: string + } + | { + /** + * Absolute path, ~/ path, or workspace-relative path to a local reference directory + */ + path: string + } + +export type ReferenceConfig = { + [key: string]: ReferenceConfigEntry +} + export type PermissionActionConfig = "ask" | "allow" | "deny" export type PermissionObjectConfig = { @@ -922,6 +945,9 @@ export type PermissionConfig = question?: PermissionActionConfig webfetch?: PermissionActionConfig websearch?: PermissionActionConfig + codesearch?: PermissionActionConfig + repo_clone?: PermissionRuleConfig + repo_overview?: PermissionRuleConfig lsp?: PermissionRuleConfig doom_loop?: PermissionActionConfig skill?: PermissionRuleConfig @@ -1035,7 +1061,7 @@ export type ProviderConfig = { output: Array<"text" | "audio" | "image" | "video" | "pdf"> } experimental?: boolean - status?: "alpha" | "beta" | "deprecated" + status?: "alpha" | "beta" | "deprecated" | "active" provider?: { npm?: string api?: string @@ -1107,6 +1133,17 @@ export type McpRemoteConfig = { */ export type LayoutConfig = "auto" | "stretch" +export type ImageAttachmentConfig = { + auto_resize?: boolean + max_width?: number + max_height?: number + max_base64_bytes?: number +} + +export type AttachmentConfig = { + image?: ImageAttachmentConfig +} + export type Config = { $schema?: string shell?: string @@ -1125,6 +1162,7 @@ export type Config = { paths?: Array urls?: Array } + reference?: ReferenceConfig watcher?: { ignore?: Array } @@ -1160,6 +1198,7 @@ export type Config = { build?: AgentConfig general?: AgentConfig explore?: AgentConfig + scout?: AgentConfig title?: AgentConfig summary?: AgentConfig compaction?: AgentConfig @@ -1219,6 +1258,7 @@ export type Config = { tools?: { [key: string]: boolean } + attachment?: AttachmentConfig enterprise?: { url?: string } @@ -1334,6 +1374,10 @@ export type ConsoleState = { switchableOrgCount: number } +export type EffectHttpApiErrorInternalServerError = { + _tag: "InternalServerError" +} + export type ToolListItem = { id: string description: string @@ -1489,7 +1533,7 @@ export type VcsFileStatus = { export type VcsFileDiff = { file: string - patch: string + patch?: string additions: number deletions: number status?: "added" | "deleted" | "modified" @@ -1755,6 +1799,7 @@ export type Workspace = { directory: string | null extra: unknown | null projectID: string + timeUsed: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" } export type WorkspaceWarpError = { @@ -2674,6 +2719,18 @@ export type PromptAgentAttachment = { source?: PromptSource } +export type PromptReferenceAttachment = { + name: string + kind: "local" | "git" | "invalid" + uri?: string + repository?: string + branch?: string + target?: string + targetUri?: string + problem?: string + source?: PromptSource +} + export type EventSessionNextPrompted = { id: string type: "session.next.prompted" @@ -3077,6 +3134,7 @@ export type SessionMessageUser = { text: string files?: Array agents?: Array + references?: Array type: "user" } @@ -3252,11 +3310,11 @@ export type EventTuiToastShow1 = { } export type BadRequestError = { - data: unknown - errors: Array<{ - [key: string]: unknown - }> - success: false + name: "BadRequest" + data: { + message: string + kind?: "Params" | "Headers" | "Query" | "Body" | "Payload" + } } export type AuthRemoveData = { @@ -3584,6 +3642,15 @@ export type ExperimentalConsoleGetData = { url: "/experimental/console" } +export type ExperimentalConsoleGetErrors = { + /** + * InternalServerError + */ + 500: EffectHttpApiErrorInternalServerError +} + +export type ExperimentalConsoleGetError = ExperimentalConsoleGetErrors[keyof ExperimentalConsoleGetErrors] + export type ExperimentalConsoleGetResponses = { /** * Active Console provider metadata @@ -3603,6 +3670,16 @@ export type ExperimentalConsoleListOrgsData = { url: "/experimental/console/orgs" } +export type ExperimentalConsoleListOrgsErrors = { + /** + * InternalServerError + */ + 500: EffectHttpApiErrorInternalServerError +} + +export type ExperimentalConsoleListOrgsError = + ExperimentalConsoleListOrgsErrors[keyof ExperimentalConsoleListOrgsErrors] + export type ExperimentalConsoleListOrgsResponses = { /** * Switchable Console orgs @@ -4192,7 +4269,7 @@ export type AppSkillsResponses = { */ 200: Array<{ name: string - description: string + description?: string location: string content: string }> @@ -5527,6 +5604,10 @@ export type SessionForkData = { } export type SessionForkErrors = { + /** + * Bad request + */ + 400: BadRequestError /** * NotFoundError */ @@ -5629,14 +5710,14 @@ export type SessionUnshareData = { } export type SessionUnshareErrors = { - /** - * Bad request - */ - 400: BadRequestError /** * NotFoundError */ 404: NotFoundError + /** + * InternalServerError + */ + 500: EffectHttpApiErrorInternalServerError } export type SessionUnshareError = SessionUnshareErrors[keyof SessionUnshareErrors] @@ -5663,14 +5744,14 @@ export type SessionShareData = { } export type SessionShareErrors = { - /** - * Bad request - */ - 400: BadRequestError /** * NotFoundError */ 404: NotFoundError + /** + * InternalServerError + */ + 500: EffectHttpApiErrorInternalServerError } export type SessionShareError = SessionShareErrors[keyof SessionShareErrors] @@ -6184,6 +6265,16 @@ export type V2SessionListData = { query?: { directory?: string workspace?: string + limit?: number + order?: "asc" | "desc" + path?: string + roots?: boolean | "true" | "false" + start?: number + search?: string + /** + * Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response. Do not combine with order or filters. + */ + cursor?: string } url: "/api/session" } @@ -6301,6 +6392,12 @@ export type V2SessionMessagesData = { query?: { directory?: string workspace?: string + limit?: number + order?: "asc" | "desc" + /** + * Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response. Do not combine with order. + */ + cursor?: string } url: "/api/session/{sessionID}/message" } @@ -6706,6 +6803,26 @@ export type ExperimentalWorkspaceCreateResponses = { export type ExperimentalWorkspaceCreateResponse = ExperimentalWorkspaceCreateResponses[keyof ExperimentalWorkspaceCreateResponses] +export type ExperimentalWorkspaceSyncListData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/experimental/workspace/sync-list" +} + +export type ExperimentalWorkspaceSyncListResponses = { + /** + * Workspace list synced + */ + 204: void +} + +export type ExperimentalWorkspaceSyncListResponse = + ExperimentalWorkspaceSyncListResponses[keyof ExperimentalWorkspaceSyncListResponses] + export type ExperimentalWorkspaceStatusData = { body?: never path?: never diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index fcd7a8547e..df0427f455 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -115,18 +115,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -446,18 +446,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -490,18 +490,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -532,18 +532,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -595,18 +595,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -656,18 +656,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -680,6 +680,16 @@ } } } + }, + "500": { + "description": "InternalServerError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/effect_HttpApiError_InternalServerError" + } + } + } } }, "description": "Get the active Console org name and the set of provider IDs managed by that Console org.", @@ -700,18 +710,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -757,6 +767,16 @@ } } } + }, + "500": { + "description": "InternalServerError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/effect_HttpApiError_InternalServerError" + } + } + } } }, "description": "Get the available Console orgs across logged-in accounts, including the current active org.", @@ -777,18 +797,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -841,18 +861,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "provider", @@ -911,18 +931,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -965,18 +985,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -1011,18 +1031,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -1072,18 +1092,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -1136,18 +1156,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -1200,18 +1220,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "roots", @@ -1312,18 +1332,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -1360,18 +1380,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "pattern", @@ -1476,18 +1496,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "query", @@ -1560,18 +1580,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "query", @@ -1616,18 +1636,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "path", @@ -1672,18 +1692,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "path", @@ -1724,18 +1744,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -1772,18 +1792,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -1817,18 +1837,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -1861,18 +1881,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -1905,18 +1925,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -1953,18 +1973,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "mode", @@ -2010,18 +2030,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -2054,18 +2074,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -2132,18 +2152,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -2180,18 +2200,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -2228,18 +2248,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -2265,7 +2285,7 @@ "type": "string" } }, - "required": ["name", "description", "location", "content"], + "required": ["name", "location", "content"], "additionalProperties": false }, "description": "List of skills" @@ -2292,18 +2312,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -2340,18 +2360,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -2388,18 +2408,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -2434,18 +2454,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -2515,22 +2535,6 @@ "tags": ["mcp"], "operationId": "mcp.auth.start", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "name", "in": "path", @@ -2538,6 +2542,22 @@ "type": "string" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -2596,22 +2616,6 @@ "tags": ["mcp"], "operationId": "mcp.auth.remove", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "name", "in": "path", @@ -2619,6 +2623,22 @@ "type": "string" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -2667,22 +2687,6 @@ "tags": ["mcp"], "operationId": "mcp.auth.callback", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "name", "in": "path", @@ -2690,6 +2694,22 @@ "type": "string" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -2755,22 +2775,6 @@ "tags": ["mcp"], "operationId": "mcp.auth.authenticate", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "name", "in": "path", @@ -2778,6 +2782,22 @@ "type": "string" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -2827,22 +2847,6 @@ "tags": ["mcp"], "operationId": "mcp.connect", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "name", "in": "path", @@ -2850,6 +2854,22 @@ "type": "string" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -2879,22 +2899,6 @@ "tags": ["mcp"], "operationId": "mcp.disconnect", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "name", "in": "path", @@ -2902,6 +2906,22 @@ "type": "string" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -2934,18 +2954,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -2982,18 +3002,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -3026,18 +3046,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -3067,22 +3087,6 @@ "tags": ["project"], "operationId": "project.update", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "projectID", "in": "path", @@ -3090,6 +3094,22 @@ "type": "string" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -3182,18 +3202,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -3243,18 +3263,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -3289,18 +3309,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -3373,30 +3393,30 @@ "tags": ["pty"], "operationId": "pty.get", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "ptyID", "in": "path", "schema": { "type": "string", - "pattern": "^pty.*" + "pattern": "^pty" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -3434,30 +3454,30 @@ "tags": ["pty"], "operationId": "pty.update", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "ptyID", "in": "path", "schema": { "type": "string", - "pattern": "^pty.*" + "pattern": "^pty" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -3525,30 +3545,30 @@ "tags": ["pty"], "operationId": "pty.remove", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "ptyID", "in": "path", "schema": { "type": "string", - "pattern": "^pty.*" + "pattern": "^pty" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -3589,30 +3609,30 @@ "tags": ["pty"], "operationId": "pty.connectToken", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "ptyID", "in": "path", "schema": { "type": "string", - "pattern": "^pty.*" + "pattern": "^pty" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -3677,18 +3697,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -3722,30 +3742,30 @@ "tags": ["question"], "operationId": "question.reply", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "requestID", "in": "path", "schema": { "type": "string", - "pattern": "^que.*" + "pattern": "^que" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -3816,30 +3836,30 @@ "tags": ["question"], "operationId": "question.reject", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "requestID", "in": "path", "schema": { "type": "string", - "pattern": "^que.*" + "pattern": "^que" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -3893,18 +3913,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -3938,30 +3958,30 @@ "tags": ["permission"], "operationId": "permission.reply", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "requestID", "in": "path", "schema": { "type": "string", - "pattern": "^per.*" + "pattern": "^per" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -4035,18 +4055,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -4102,18 +4122,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -4150,22 +4170,6 @@ "tags": ["provider"], "operationId": "provider.oauth.authorize", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "providerID", "in": "path", @@ -4173,6 +4177,22 @@ "type": "string" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -4235,22 +4255,6 @@ "tags": ["provider"], "operationId": "provider.oauth.callback", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "providerID", "in": "path", @@ -4258,6 +4262,22 @@ "type": "string" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -4321,18 +4341,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "scope", @@ -4424,18 +4444,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -4469,7 +4489,8 @@ "type": "object", "properties": { "parentID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "title": { "type": "string" @@ -4497,7 +4518,8 @@ "$ref": "#/components/schemas/PermissionRuleset" }, "workspaceID": { - "type": "string" + "type": "string", + "pattern": "^wrk" } }, "additionalProperties": false @@ -4521,18 +4543,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -4576,30 +4598,30 @@ "tags": ["session"], "operationId": "session.get", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", "schema": { "type": "string", - "pattern": "^ses.*" + "pattern": "^ses" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -4647,30 +4669,30 @@ "tags": ["session"], "operationId": "session.delete", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", "schema": { "type": "string", - "pattern": "^ses.*" + "pattern": "^ses" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -4719,30 +4741,30 @@ "tags": ["session"], "operationId": "session.update", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", "schema": { "type": "string", - "pattern": "^ses.*" + "pattern": "^ses" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -4819,30 +4841,30 @@ "tags": ["session"], "operationId": "session.children", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", "schema": { "type": "string", - "pattern": "^ses.*" + "pattern": "^ses" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -4896,30 +4918,30 @@ "tags": ["session"], "operationId": "session.todo", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", "schema": { "type": "string", - "pattern": "^ses.*" + "pattern": "^ses" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -4973,37 +4995,37 @@ "tags": ["session"], "operationId": "session.diff", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", "schema": { "type": "string", - "pattern": "^ses.*" + "pattern": "^ses" }, "required": true }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, { "name": "messageID", "in": "query", "schema": { "type": "string", - "pattern": "^msg.*" + "pattern": "^msg" }, "required": false } @@ -5039,31 +5061,31 @@ "tags": ["session"], "operationId": "session.messages", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", "schema": { "type": "string", - "pattern": "^ses.*" + "pattern": "^ses" }, "required": true }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, { "name": "limit", "in": "query", @@ -5145,30 +5167,30 @@ "tags": ["session"], "operationId": "session.prompt", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", "schema": { "type": "string", - "pattern": "^ses.*" + "pattern": "^ses" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -5224,7 +5246,8 @@ "type": "object", "properties": { "messageID": { - "type": "string" + "type": "string", + "pattern": "^msg" }, "model": { "type": "object", @@ -5299,28 +5322,12 @@ "tags": ["session"], "operationId": "session.message", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", "schema": { "type": "string", - "pattern": "^ses.*" + "pattern": "^ses" }, "required": true }, @@ -5329,9 +5336,25 @@ "in": "path", "schema": { "type": "string", - "pattern": "^msg.*" + "pattern": "^msg" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -5393,28 +5416,12 @@ "tags": ["session"], "operationId": "session.deleteMessage", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", "schema": { "type": "string", - "pattern": "^ses.*" + "pattern": "^ses" }, "required": true }, @@ -5423,9 +5430,25 @@ "in": "path", "schema": { "type": "string", - "pattern": "^msg.*" + "pattern": "^msg" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -5476,30 +5499,30 @@ "tags": ["session"], "operationId": "session.fork", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", "schema": { "type": "string", - "pattern": "^ses.*" + "pattern": "^ses" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -5513,6 +5536,16 @@ } } }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, "404": { "description": "NotFoundError", "content": { @@ -5533,7 +5566,8 @@ "type": "object", "properties": { "messageID": { - "type": "string" + "type": "string", + "pattern": "^msg" } }, "additionalProperties": false @@ -5554,30 +5588,30 @@ "tags": ["session"], "operationId": "session.abort", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", "schema": { "type": "string", - "pattern": "^ses.*" + "pattern": "^ses" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -5628,30 +5662,30 @@ "tags": ["session"], "operationId": "session.init", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", "schema": { "type": "string", - "pattern": "^ses.*" + "pattern": "^ses" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -5702,7 +5736,8 @@ "type": "string" }, "messageID": { - "type": "string" + "type": "string", + "pattern": "^msg" } }, "required": ["modelID", "providerID", "messageID"], @@ -5724,30 +5759,30 @@ "tags": ["session"], "operationId": "session.share", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", "schema": { "type": "string", - "pattern": "^ses.*" + "pattern": "^ses" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -5761,16 +5796,6 @@ } } }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } - }, "404": { "description": "NotFoundError", "content": { @@ -5780,6 +5805,16 @@ } } } + }, + "500": { + "description": "InternalServerError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/effect_HttpApiError_InternalServerError" + } + } + } } }, "description": "Create a shareable link for a session, allowing others to view the conversation.", @@ -5795,30 +5830,30 @@ "tags": ["session"], "operationId": "session.unshare", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", "schema": { "type": "string", - "pattern": "^ses.*" + "pattern": "^ses" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -5832,16 +5867,6 @@ } } }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } - }, "404": { "description": "NotFoundError", "content": { @@ -5851,6 +5876,16 @@ } } } + }, + "500": { + "description": "InternalServerError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/effect_HttpApiError_InternalServerError" + } + } + } } }, "description": "Remove the shareable link for a session, making it private again.", @@ -5868,30 +5903,30 @@ "tags": ["session"], "operationId": "session.summarize", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", "schema": { "type": "string", - "pattern": "^ses.*" + "pattern": "^ses" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -5964,30 +5999,30 @@ "tags": ["session"], "operationId": "session.prompt_async", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", "schema": { "type": "string", - "pattern": "^ses.*" + "pattern": "^ses" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -6024,7 +6059,8 @@ "type": "object", "properties": { "messageID": { - "type": "string" + "type": "string", + "pattern": "^msg" }, "model": { "type": "object", @@ -6099,30 +6135,30 @@ "tags": ["session"], "operationId": "session.command", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", "schema": { "type": "string", - "pattern": "^ses.*" + "pattern": "^ses" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -6178,7 +6214,8 @@ "type": "object", "properties": { "messageID": { - "type": "string" + "type": "string", + "pattern": "^msg" }, "agent": { "type": "string" @@ -6201,7 +6238,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^prt" }, "type": { "type": "string", @@ -6244,30 +6282,30 @@ "tags": ["session"], "operationId": "session.shell", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", "schema": { "type": "string", - "pattern": "^ses.*" + "pattern": "^ses" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -6325,7 +6363,8 @@ "type": "object", "properties": { "messageID": { - "type": "string" + "type": "string", + "pattern": "^msg" }, "agent": { "type": "string" @@ -6366,30 +6405,30 @@ "tags": ["session"], "operationId": "session.revert", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", "schema": { "type": "string", - "pattern": "^ses.*" + "pattern": "^ses" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -6433,10 +6472,12 @@ "type": "object", "properties": { "messageID": { - "type": "string" + "type": "string", + "pattern": "^msg" }, "partID": { - "type": "string" + "type": "string", + "pattern": "^prt" } }, "required": ["messageID"], @@ -6458,30 +6499,30 @@ "tags": ["session"], "operationId": "session.unrevert", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", "schema": { "type": "string", - "pattern": "^ses.*" + "pattern": "^ses" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -6531,28 +6572,12 @@ "tags": ["session"], "operationId": "permission.respond", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", "schema": { "type": "string", - "pattern": "^ses.*" + "pattern": "^ses" }, "required": true }, @@ -6561,9 +6586,25 @@ "in": "path", "schema": { "type": "string", - "pattern": "^per.*" + "pattern": "^per" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -6632,28 +6673,12 @@ "tags": ["session"], "operationId": "part.delete", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", "schema": { "type": "string", - "pattern": "^ses.*" + "pattern": "^ses" }, "required": true }, @@ -6662,7 +6687,7 @@ "in": "path", "schema": { "type": "string", - "pattern": "^msg.*" + "pattern": "^msg" }, "required": true }, @@ -6671,9 +6696,25 @@ "in": "path", "schema": { "type": "string", - "pattern": "^prt.*" + "pattern": "^prt" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -6721,28 +6762,12 @@ "tags": ["session"], "operationId": "part.update", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", "schema": { "type": "string", - "pattern": "^ses.*" + "pattern": "^ses" }, "required": true }, @@ -6751,7 +6776,7 @@ "in": "path", "schema": { "type": "string", - "pattern": "^msg.*" + "pattern": "^msg" }, "required": true }, @@ -6760,9 +6785,25 @@ "in": "path", "schema": { "type": "string", - "pattern": "^prt.*" + "pattern": "^prt" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -6823,18 +6864,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -6868,18 +6909,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -6973,18 +7014,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -6996,7 +7037,8 @@ "type": "object", "properties": { "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" } }, "required": ["sessionID"], @@ -7026,7 +7068,8 @@ "type": "object", "properties": { "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" } }, "required": ["sessionID"], @@ -7051,18 +7094,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -7142,18 +7185,84 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false + }, + { + "name": "limit", + "in": "query", + "schema": { + "type": "number" + }, + "required": false + }, + { + "name": "order", + "in": "query", + "schema": { + "type": "string", + "enum": ["asc", "desc"] + }, + "required": false + }, + { + "name": "path", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "roots", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "enum": ["true", "false"] + } + ] + }, + "required": false + }, + { + "name": "start", + "in": "query", + "schema": { + "type": "number" + }, + "required": false + }, + { + "name": "search", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "cursor", + "in": "query", + "schema": { + "type": "string", + "description": "Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response. Do not combine with order or filters." + }, + "required": false } ], "responses": { @@ -7193,30 +7302,30 @@ "tags": ["v2"], "operationId": "v2.session.prompt", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", "schema": { "type": "string", - "pattern": "^ses.*" + "pattern": "^ses" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -7265,30 +7374,30 @@ "tags": ["v2"], "operationId": "v2.session.compact", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", "schema": { "type": "string", - "pattern": "^ses.*" + "pattern": "^ses" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -7311,30 +7420,30 @@ "tags": ["v2"], "operationId": "v2.session.wait", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", "schema": { "type": "string", - "pattern": "^ses.*" + "pattern": "^ses" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -7357,30 +7466,30 @@ "tags": ["v2"], "operationId": "v2.session.context", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", "schema": { "type": "string", - "pattern": "^ses.*" + "pattern": "^ses" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -7413,30 +7522,56 @@ "tags": ["v2 messages"], "operationId": "v2.session.messages", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", "schema": { "type": "string", - "pattern": "^ses.*" + "pattern": "^ses" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "limit", + "in": "query", + "schema": { + "type": "number" + }, + "required": false + }, + { + "name": "order", + "in": "query", + "schema": { + "type": "string", + "enum": ["asc", "desc"] + }, + "required": false + }, + { + "name": "cursor", + "in": "query", + "schema": { + "type": "string", + "description": "Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response. Do not combine with order." + }, + "required": false } ], "responses": { @@ -7479,18 +7614,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -7550,18 +7685,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -7595,18 +7730,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -7640,18 +7775,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -7685,18 +7820,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -7730,18 +7865,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -7775,18 +7910,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -7820,18 +7955,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -7891,18 +8026,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -7963,18 +8098,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -8040,18 +8175,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -8097,6 +8232,7 @@ "properties": { "sessionID": { "type": "string", + "pattern": "^ses", "description": "Session ID to navigate to" } }, @@ -8122,18 +8258,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -8175,18 +8311,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -8227,18 +8363,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -8288,18 +8424,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -8334,18 +8470,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -8379,7 +8515,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^wrk" }, "type": { "type": "string" @@ -8417,6 +8554,43 @@ ] } }, + "/experimental/workspace/sync-list": { + "post": { + "tags": ["workspace"], + "operationId": "experimental.workspace.syncList", + "parameters": [ + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + } + ], + "responses": { + "204": { + "description": "Workspace list synced" + } + }, + "description": "Register missing workspaces returned by workspace adapters.", + "summary": "Sync workspace list", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.workspace.syncList({\n ...\n})" + } + ] + } + }, "/experimental/workspace/status": { "get": { "tags": ["workspace"], @@ -8425,18 +8599,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -8450,7 +8624,8 @@ "type": "object", "properties": { "workspaceID": { - "type": "string" + "type": "string", + "pattern": "^wrk" }, "status": { "type": "string", @@ -8481,30 +8656,30 @@ "tags": ["workspace"], "operationId": "experimental.workspace.remove", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "id", "in": "path", "schema": { "type": "string", - "pattern": "^wrk.*" + "pattern": "^wrk" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -8547,18 +8722,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -8594,7 +8769,8 @@ "id": { "anyOf": [ { - "type": "string" + "type": "string", + "pattern": "^wrk" }, { "type": "null" @@ -8602,7 +8778,8 @@ ] }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "copyChanges": { "type": "boolean" @@ -8627,30 +8804,30 @@ "tags": ["pty"], "operationId": "pty.connect", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "ptyID", "in": "path", "schema": { "type": "string", - "pattern": "^pty.*" + "pattern": "^pty" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -9000,10 +9177,12 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^per" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "permission": { "type": "string" @@ -9027,7 +9206,8 @@ "type": "object", "properties": { "messageID": { - "type": "string" + "type": "string", + "pattern": "^msg" }, "callID": { "type": "string" @@ -9050,19 +9230,17 @@ "type": "string" }, "additions": { - "type": "integer", - "minimum": 0 + "type": "number" }, "deletions": { - "type": "integer", - "minimum": 0 + "type": "number" }, "status": { "type": "string", "enum": ["added", "deleted", "modified"] } }, - "required": ["file", "patch", "additions", "deletions"], + "required": ["additions", "deletions"], "additionalProperties": false }, "ProviderAuthError": { @@ -9285,7 +9463,8 @@ "type": "object", "properties": { "messageID": { - "type": "string" + "type": "string", + "pattern": "^msg" }, "callID": { "type": "string" @@ -9298,10 +9477,12 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^que" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "questions": { "type": "array", @@ -9327,10 +9508,12 @@ "type": "object", "properties": { "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "requestID": { - "type": "string" + "type": "string", + "pattern": "^que" }, "answers": { "type": "array", @@ -9346,10 +9529,12 @@ "type": "object", "properties": { "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "requestID": { - "type": "string" + "type": "string", + "pattern": "^que" } }, "required": ["sessionID", "requestID"], @@ -9404,6 +9589,12 @@ "action": { "type": "object", "properties": { + "reason": { + "type": "string" + }, + "provider": { + "type": "string" + }, "title": { "type": "string" }, @@ -9417,7 +9608,7 @@ "type": "string" } }, - "required": ["title", "message", "label"], + "required": ["reason", "provider", "title", "message", "label"], "additionalProperties": false }, "next": { @@ -9564,6 +9755,7 @@ "properties": { "sessionID": { "type": "string", + "pattern": "^ses", "description": "Session ID to navigate to" } }, @@ -9648,7 +9840,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^pty" }, "title": { "type": "string" @@ -9723,10 +9916,12 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^msg" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "role": { "type": "string", @@ -9801,10 +9996,12 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^msg" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "role": { "type": "string", @@ -9851,7 +10048,8 @@ ] }, "parentID": { - "type": "string" + "type": "string", + "pattern": "^msg" }, "modelID": { "type": "string" @@ -9888,31 +10086,25 @@ "type": "object", "properties": { "total": { - "type": "integer", - "minimum": 0 + "type": "number" }, "input": { - "type": "integer", - "minimum": 0 + "type": "number" }, "output": { - "type": "integer", - "minimum": 0 + "type": "number" }, "reasoning": { - "type": "integer", - "minimum": 0 + "type": "number" }, "cache": { "type": "object", "properties": { "read": { - "type": "integer", - "minimum": 0 + "type": "number" }, "write": { - "type": "integer", - "minimum": 0 + "type": "number" } }, "required": ["read", "write"], @@ -9960,13 +10152,16 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^prt" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "messageID": { - "type": "string" + "type": "string", + "pattern": "^msg" }, "type": { "type": "string", @@ -10007,13 +10202,16 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^prt" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "messageID": { - "type": "string" + "type": "string", + "pattern": "^msg" }, "type": { "type": "string", @@ -10052,13 +10250,16 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^prt" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "messageID": { - "type": "string" + "type": "string", + "pattern": "^msg" }, "type": { "type": "string", @@ -10096,12 +10297,10 @@ "type": "string" }, "start": { - "type": "integer", - "minimum": 0 + "type": "number" }, "end": { - "type": "integer", - "minimum": 0 + "type": "number" } }, "required": ["value", "start", "end"], @@ -10225,13 +10424,16 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^prt" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "messageID": { - "type": "string" + "type": "string", + "pattern": "^msg" }, "type": { "type": "string", @@ -10404,13 +10606,16 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^prt" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "messageID": { - "type": "string" + "type": "string", + "pattern": "^msg" }, "type": { "type": "string", @@ -10436,13 +10641,16 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^prt" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "messageID": { - "type": "string" + "type": "string", + "pattern": "^msg" }, "type": { "type": "string", @@ -10459,13 +10667,16 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^prt" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "messageID": { - "type": "string" + "type": "string", + "pattern": "^msg" }, "type": { "type": "string", @@ -10484,31 +10695,25 @@ "type": "object", "properties": { "total": { - "type": "integer", - "minimum": 0 + "type": "number" }, "input": { - "type": "integer", - "minimum": 0 + "type": "number" }, "output": { - "type": "integer", - "minimum": 0 + "type": "number" }, "reasoning": { - "type": "integer", - "minimum": 0 + "type": "number" }, "cache": { "type": "object", "properties": { "read": { - "type": "integer", - "minimum": 0 + "type": "number" }, "write": { - "type": "integer", - "minimum": 0 + "type": "number" } }, "required": ["read", "write"], @@ -10526,13 +10731,16 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^prt" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "messageID": { - "type": "string" + "type": "string", + "pattern": "^msg" }, "type": { "type": "string", @@ -10549,13 +10757,16 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^prt" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "messageID": { - "type": "string" + "type": "string", + "pattern": "^msg" }, "type": { "type": "string", @@ -10578,13 +10789,16 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^prt" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "messageID": { - "type": "string" + "type": "string", + "pattern": "^msg" }, "type": { "type": "string", @@ -10619,13 +10833,16 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^prt" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "messageID": { - "type": "string" + "type": "string", + "pattern": "^msg" }, "type": { "type": "string", @@ -10657,13 +10874,16 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^prt" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "messageID": { - "type": "string" + "type": "string", + "pattern": "^msg" }, "type": { "type": "string", @@ -10676,7 +10896,8 @@ "type": "boolean" }, "tail_start_id": { - "type": "string" + "type": "string", + "pattern": "^msg" } }, "required": ["id", "sessionID", "messageID", "type", "auto"], @@ -10752,7 +10973,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "slug": { "type": "string" @@ -10761,7 +10983,8 @@ "type": "string" }, "workspaceID": { - "type": "string" + "type": "string", + "pattern": "^wrk" }, "directory": { "type": "string" @@ -10770,22 +10993,20 @@ "type": "string" }, "parentID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "summary": { "type": "object", "properties": { "additions": { - "type": "integer", - "minimum": 0 + "type": "number" }, "deletions": { - "type": "integer", - "minimum": 0 + "type": "number" }, "files": { - "type": "integer", - "minimum": 0 + "type": "number" }, "diffs": { "type": "array", @@ -10861,10 +11082,12 @@ "type": "object", "properties": { "messageID": { - "type": "string" + "type": "string", + "pattern": "^msg" }, "partID": { - "type": "string" + "type": "string", + "pattern": "^prt" }, "snapshot": { "type": "string" @@ -10897,6 +11120,12 @@ "items": { "$ref": "#/components/schemas/PromptAgentAttachment" } + }, + "references": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PromptReferenceAttachment" + } } }, "required": ["text"], @@ -11268,6 +11497,44 @@ "additionalProperties": false, "description": "Server configuration for opencode serve and web commands" }, + "ReferenceConfigEntry": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "repository": { + "type": "string", + "description": "Git repository URL, host/path reference, or GitHub owner/repo shorthand" + }, + "branch": { + "type": "string" + } + }, + "required": ["repository"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Absolute path, ~/ path, or workspace-relative path to a local reference directory" + } + }, + "required": ["path"], + "additionalProperties": false + } + ] + }, + "ReferenceConfig": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/ReferenceConfigEntry" + } + }, "PermissionActionConfig": { "type": "string", "enum": ["ask", "allow", "deny"] @@ -11332,6 +11599,15 @@ "websearch": { "$ref": "#/components/schemas/PermissionActionConfig" }, + "codesearch": { + "$ref": "#/components/schemas/PermissionActionConfig" + }, + "repo_clone": { + "$ref": "#/components/schemas/PermissionRuleConfig" + }, + "repo_overview": { + "$ref": "#/components/schemas/PermissionRuleConfig" + }, "lsp": { "$ref": "#/components/schemas/PermissionRuleConfig" }, @@ -11611,7 +11887,7 @@ }, "status": { "type": "string", - "enum": ["alpha", "beta", "deprecated"] + "enum": ["alpha", "beta", "deprecated", "active"] }, "provider": { "type": "object", @@ -11750,6 +12026,36 @@ "enum": ["auto", "stretch"], "description": "@deprecated Always uses stretch layout." }, + "ImageAttachmentConfig": { + "type": "object", + "properties": { + "auto_resize": { + "type": "boolean" + }, + "max_width": { + "type": "integer", + "exclusiveMinimum": 0 + }, + "max_height": { + "type": "integer", + "exclusiveMinimum": 0 + }, + "max_base64_bytes": { + "type": "integer", + "exclusiveMinimum": 0 + } + }, + "additionalProperties": false + }, + "AttachmentConfig": { + "type": "object", + "properties": { + "image": { + "$ref": "#/components/schemas/ImageAttachmentConfig" + } + }, + "additionalProperties": false + }, "Config": { "type": "object", "properties": { @@ -11808,6 +12114,9 @@ }, "additionalProperties": false }, + "reference": { + "$ref": "#/components/schemas/ReferenceConfig" + }, "watcher": { "type": "object", "properties": { @@ -11918,6 +12227,9 @@ "explore": { "$ref": "#/components/schemas/AgentConfig" }, + "scout": { + "$ref": "#/components/schemas/AgentConfig" + }, "title": { "$ref": "#/components/schemas/AgentConfig" }, @@ -12074,6 +12386,9 @@ "type": "boolean" } }, + "attachment": { + "$ref": "#/components/schemas/AttachmentConfig" + }, "enterprise": { "type": "object", "properties": { @@ -12425,6 +12740,17 @@ "required": ["consoleManagedProviders", "switchableOrgCount"], "additionalProperties": false }, + "effect_HttpApiError_InternalServerError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": ["InternalServerError"] + } + }, + "required": ["_tag"], + "additionalProperties": false + }, "ToolListItem": { "type": "object", "properties": { @@ -12520,7 +12846,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "slug": { "type": "string" @@ -12529,7 +12856,8 @@ "type": "string" }, "workspaceID": { - "type": "string" + "type": "string", + "pattern": "^wrk" }, "directory": { "type": "string" @@ -12538,22 +12866,20 @@ "type": "string" }, "parentID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "summary": { "type": "object", "properties": { "additions": { - "type": "integer", - "minimum": 0 + "type": "number" }, "deletions": { - "type": "integer", - "minimum": 0 + "type": "number" }, "files": { - "type": "integer", - "minimum": 0 + "type": "number" }, "diffs": { "type": "array", @@ -12629,10 +12955,12 @@ "type": "object", "properties": { "messageID": { - "type": "string" + "type": "string", + "pattern": "^msg" }, "partID": { - "type": "string" + "type": "string", + "pattern": "^prt" }, "snapshot": { "type": "string" @@ -12871,12 +13199,10 @@ "type": "string" }, "additions": { - "type": "integer", - "minimum": 0 + "type": "number" }, "deletions": { - "type": "integer", - "minimum": 0 + "type": "number" }, "status": { "type": "string", @@ -12896,19 +13222,17 @@ "type": "string" }, "additions": { - "type": "integer", - "minimum": 0 + "type": "number" }, "deletions": { - "type": "integer", - "minimum": 0 + "type": "number" }, "status": { "type": "string", "enum": ["added", "deleted", "modified"] } }, - "required": ["file", "patch", "additions", "deletions"], + "required": ["file", "additions", "deletions"], "additionalProperties": false }, "VcsApplyError": { @@ -13322,7 +13646,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^prt" }, "type": { "type": "string", @@ -13363,7 +13688,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^prt" }, "type": { "type": "string", @@ -13389,7 +13715,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^prt" }, "type": { "type": "string", @@ -13424,7 +13751,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^prt" }, "type": { "type": "string", @@ -13620,6 +13948,7 @@ "properties": { "sessionID": { "type": "string", + "pattern": "^ses", "description": "Session ID to navigate to" } }, @@ -13634,7 +13963,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^wrk" }, "type": { "type": "string" @@ -13672,9 +14002,32 @@ }, "projectID": { "type": "string" + }, + "timeUsed": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + }, + { + "type": "string", + "enum": ["Infinity", "-Infinity", "NaN"] + } + ] } }, - "required": ["id", "type", "name", "branch", "directory", "extra", "projectID"], + "required": ["id", "type", "name", "branch", "directory", "extra", "projectID", "timeUsed"], "additionalProperties": false }, "WorkspaceWarpError": { @@ -13723,7 +14076,8 @@ "type": "object", "properties": { "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "info": { "$ref": "#/components/schemas/Message" @@ -13761,10 +14115,12 @@ "type": "object", "properties": { "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "messageID": { - "type": "string" + "type": "string", + "pattern": "^msg" } }, "required": ["sessionID", "messageID"], @@ -13799,7 +14155,8 @@ "type": "object", "properties": { "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "part": { "$ref": "#/components/schemas/Part" @@ -13841,13 +14198,16 @@ "type": "object", "properties": { "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "messageID": { - "type": "string" + "type": "string", + "pattern": "^msg" }, "partID": { - "type": "string" + "type": "string", + "pattern": "^prt" } }, "required": ["sessionID", "messageID", "partID"], @@ -13882,7 +14242,8 @@ "type": "object", "properties": { "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "info": { "$ref": "#/components/schemas/Session" @@ -13920,7 +14281,8 @@ "type": "object", "properties": { "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "info": { "type": "object", @@ -13928,7 +14290,8 @@ "id": { "anyOf": [ { - "type": "string" + "type": "string", + "pattern": "^ses" }, { "type": "null" @@ -13958,7 +14321,8 @@ "workspaceID": { "anyOf": [ { - "type": "string" + "type": "string", + "pattern": "^wrk" }, { "type": "null" @@ -13988,7 +14352,8 @@ "parentID": { "anyOf": [ { - "type": "string" + "type": "string", + "pattern": "^ses" }, { "type": "null" @@ -14001,16 +14366,13 @@ "type": "object", "properties": { "additions": { - "type": "integer", - "minimum": 0 + "type": "number" }, "deletions": { - "type": "integer", - "minimum": 0 + "type": "number" }, "files": { - "type": "integer", - "minimum": 0 + "type": "number" }, "diffs": { "type": "array", @@ -14161,10 +14523,12 @@ "type": "object", "properties": { "messageID": { - "type": "string" + "type": "string", + "pattern": "^msg" }, "partID": { - "type": "string" + "type": "string", + "pattern": "^prt" }, "snapshot": { "type": "string" @@ -14217,7 +14581,8 @@ "type": "object", "properties": { "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "info": { "$ref": "#/components/schemas/Session" @@ -14258,7 +14623,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "agent": { "type": "string" @@ -14299,7 +14665,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "model": { "type": "object", @@ -14353,7 +14720,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "prompt": { "$ref": "#/components/schemas/Prompt" @@ -14394,7 +14762,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "text": { "type": "string" @@ -14435,7 +14804,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "callID": { "type": "string" @@ -14479,7 +14849,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "callID": { "type": "string" @@ -14523,7 +14894,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "agent": { "type": "string" @@ -14583,7 +14955,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "finish": { "type": "string" @@ -14595,27 +14968,22 @@ "type": "object", "properties": { "input": { - "type": "integer", - "minimum": 0 + "type": "number" }, "output": { - "type": "integer", - "minimum": 0 + "type": "number" }, "reasoning": { - "type": "integer", - "minimum": 0 + "type": "number" }, "cache": { "type": "object", "properties": { "read": { - "type": "integer", - "minimum": 0 + "type": "number" }, "write": { - "type": "integer", - "minimum": 0 + "type": "number" } }, "required": ["read", "write"], @@ -14664,7 +15032,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "error": { "$ref": "#/components/schemas/SessionErrorUnknown" @@ -14705,7 +15074,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" } }, "required": ["timestamp", "sessionID"], @@ -14743,7 +15113,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "delta": { "type": "string" @@ -14784,7 +15155,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "text": { "type": "string" @@ -14825,7 +15197,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "reasoningID": { "type": "string" @@ -14866,7 +15239,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "reasoningID": { "type": "string" @@ -14910,7 +15284,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "reasoningID": { "type": "string" @@ -14954,7 +15329,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "callID": { "type": "string" @@ -14998,7 +15374,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "callID": { "type": "string" @@ -15042,7 +15419,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "callID": { "type": "string" @@ -15086,7 +15464,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "callID": { "type": "string" @@ -15146,7 +15525,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "callID": { "type": "string" @@ -15203,7 +15583,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "callID": { "type": "string" @@ -15273,7 +15654,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "callID": { "type": "string" @@ -15330,11 +15712,11 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "attempt": { - "type": "integer", - "minimum": 0 + "type": "number" }, "error": { "$ref": "#/components/schemas/SessionNextRetry_error" @@ -15375,7 +15757,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "reason": { "type": "string", @@ -15417,7 +15800,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "text": { "type": "string" @@ -15458,7 +15842,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "text": { "type": "string" @@ -15609,13 +15994,16 @@ "type": "object", "properties": { "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "messageID": { - "type": "string" + "type": "string", + "pattern": "^msg" }, "partID": { - "type": "string" + "type": "string", + "pattern": "^prt" }, "field": { "type": "string" @@ -15662,10 +16050,12 @@ "type": "object", "properties": { "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "requestID": { - "type": "string" + "type": "string", + "pattern": "^per" }, "reply": { "type": "string", @@ -15693,7 +16083,8 @@ "type": "object", "properties": { "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "diff": { "type": "array", @@ -15723,7 +16114,8 @@ "type": "object", "properties": { "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "error": { "anyOf": [ @@ -15870,7 +16262,8 @@ "type": "object", "properties": { "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "todos": { "type": "array", @@ -15900,7 +16293,8 @@ "type": "object", "properties": { "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "status": { "$ref": "#/components/schemas/SessionStatus" @@ -15927,7 +16321,8 @@ "type": "object", "properties": { "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" } }, "required": ["sessionID"], @@ -15951,7 +16346,8 @@ "type": "object", "properties": { "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" } }, "required": ["sessionID"], @@ -16029,13 +16425,15 @@ "type": "string" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "arguments": { "type": "string" }, "messageID": { - "type": "string" + "type": "string", + "pattern": "^msg" } }, "required": ["name", "sessionID", "arguments", "messageID"], @@ -16147,7 +16545,8 @@ "type": "object", "properties": { "workspaceID": { - "type": "string" + "type": "string", + "pattern": "^wrk" }, "status": { "type": "string", @@ -16274,7 +16673,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^pty" }, "exitCode": { "type": "integer", @@ -16302,7 +16702,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^pty" } }, "required": ["id"], @@ -16326,7 +16727,8 @@ "type": "object", "properties": { "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "info": { "$ref": "#/components/schemas/Message" @@ -16353,10 +16755,12 @@ "type": "object", "properties": { "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "messageID": { - "type": "string" + "type": "string", + "pattern": "^msg" } }, "required": ["sessionID", "messageID"], @@ -16380,7 +16784,8 @@ "type": "object", "properties": { "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "part": { "$ref": "#/components/schemas/Part" @@ -16411,13 +16816,16 @@ "type": "object", "properties": { "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "messageID": { - "type": "string" + "type": "string", + "pattern": "^msg" }, "partID": { - "type": "string" + "type": "string", + "pattern": "^prt" } }, "required": ["sessionID", "messageID", "partID"], @@ -16441,7 +16849,8 @@ "type": "object", "properties": { "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "info": { "$ref": "#/components/schemas/Session" @@ -16468,7 +16877,8 @@ "type": "object", "properties": { "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "info": { "$ref": "#/components/schemas/Session" @@ -16495,7 +16905,8 @@ "type": "object", "properties": { "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "info": { "$ref": "#/components/schemas/Session" @@ -16525,7 +16936,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "agent": { "type": "string" @@ -16555,7 +16967,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "model": { "type": "object", @@ -16632,6 +17045,41 @@ "required": ["name"], "additionalProperties": false }, + "PromptReferenceAttachment": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "kind": { + "type": "string", + "enum": ["local", "git", "invalid"] + }, + "uri": { + "type": "string" + }, + "repository": { + "type": "string" + }, + "branch": { + "type": "string" + }, + "target": { + "type": "string" + }, + "targetUri": { + "type": "string" + }, + "problem": { + "type": "string" + }, + "source": { + "$ref": "#/components/schemas/PromptSource" + } + }, + "required": ["name", "kind"], + "additionalProperties": false + }, "EventSessionNextPrompted": { "type": "object", "properties": { @@ -16649,7 +17097,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "prompt": { "$ref": "#/components/schemas/Prompt" @@ -16679,7 +17128,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "text": { "type": "string" @@ -16709,7 +17159,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "callID": { "type": "string" @@ -16742,7 +17193,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "callID": { "type": "string" @@ -16775,7 +17227,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "agent": { "type": "string" @@ -16824,7 +17277,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "finish": { "type": "string" @@ -16836,27 +17290,22 @@ "type": "object", "properties": { "input": { - "type": "integer", - "minimum": 0 + "type": "number" }, "output": { - "type": "integer", - "minimum": 0 + "type": "number" }, "reasoning": { - "type": "integer", - "minimum": 0 + "type": "number" }, "cache": { "type": "object", "properties": { "read": { - "type": "integer", - "minimum": 0 + "type": "number" }, "write": { - "type": "integer", - "minimum": 0 + "type": "number" } }, "required": ["read", "write"], @@ -16908,7 +17357,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "error": { "$ref": "#/components/schemas/SessionErrorUnknown" @@ -16938,7 +17388,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" } }, "required": ["timestamp", "sessionID"], @@ -16965,7 +17416,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "delta": { "type": "string" @@ -16995,7 +17447,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "text": { "type": "string" @@ -17025,7 +17478,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "reasoningID": { "type": "string" @@ -17055,7 +17509,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "reasoningID": { "type": "string" @@ -17088,7 +17543,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "reasoningID": { "type": "string" @@ -17121,7 +17577,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "callID": { "type": "string" @@ -17154,7 +17611,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "callID": { "type": "string" @@ -17187,7 +17645,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "callID": { "type": "string" @@ -17220,7 +17679,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "callID": { "type": "string" @@ -17303,7 +17763,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "callID": { "type": "string" @@ -17349,7 +17810,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "callID": { "type": "string" @@ -17408,7 +17870,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "callID": { "type": "string" @@ -17444,8 +17907,7 @@ "type": "string" }, "statusCode": { - "type": "integer", - "minimum": 0 + "type": "number" }, "isRetryable": { "type": "boolean" @@ -17486,11 +17948,11 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "attempt": { - "type": "integer", - "minimum": 0 + "type": "number" }, "error": { "$ref": "#/components/schemas/SessionNextRetry_error" @@ -17520,7 +17982,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "reason": { "type": "string", @@ -17551,7 +18014,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "text": { "type": "string" @@ -17581,7 +18045,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "text": { "type": "string" @@ -17637,16 +18102,19 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "parentID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "projectID": { "type": "string" }, "workspaceID": { - "type": "string" + "type": "string", + "pattern": "^wrk" }, "path": { "type": "string" @@ -17804,6 +18272,12 @@ "$ref": "#/components/schemas/PromptAgentAttachment" } }, + "references": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PromptReferenceAttachment" + } + }, "type": { "type": "string", "enum": ["user"] @@ -17832,7 +18306,8 @@ "additionalProperties": false }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "text": { "type": "string" @@ -18307,19 +18782,24 @@ }, "BadRequestError": { "type": "object", - "required": ["data", "errors", "success"], + "required": ["name", "data"], "properties": { - "data": {}, - "errors": { - "type": "array", - "items": { - "type": "object", - "additionalProperties": {} - } + "name": { + "type": "string", + "enum": ["BadRequest"] }, - "success": { - "type": "boolean", - "enum": [false] + "data": { + "type": "object", + "required": ["message"], + "properties": { + "message": { + "type": "string" + }, + "kind": { + "type": "string", + "enum": ["Params", "Headers", "Query", "Body", "Payload"] + } + } } } } diff --git a/packages/slack/package.json b/packages/slack/package.json index 34175d66a2..55e09f5d36 100644 --- a/packages/slack/package.json +++ b/packages/slack/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/slack", - "version": "1.14.41", + "version": "1.14.48", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/ui/package.json b/packages/ui/package.json index fc065be9ef..12441c8d09 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "1.14.41", + "version": "1.14.48", "type": "module", "license": "MIT", "exports": { diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index d9771671a6..7a7d5b15fa 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -1461,7 +1461,7 @@ PART_MAPPING["text"] = function TextPartDisplay(props) { const streaming = createMemo( () => props.message.role === "assistant" && typeof (props.message as AssistantMessage).time.completed !== "number", ) - const text = () => (part().text ?? "").trim() + const text = () => (data.store.part_text_accum_delta?.[part().id] ?? part().text ?? "").trim() const isLastTextPart = createMemo(() => { const last = (data.store.part?.[props.message.id] ?? []) .filter((item): item is TextPart => item?.type === "text" && !!item.text?.trim()) @@ -1521,11 +1521,12 @@ PART_MAPPING["text"] = function TextPartDisplay(props) { } PART_MAPPING["reasoning"] = function ReasoningPartDisplay(props) { + const data = useData() const part = () => props.part as ReasoningPart const streaming = createMemo( () => props.message.role === "assistant" && typeof (props.message as AssistantMessage).time.completed !== "number", ) - const text = () => part().text.trim() + const text = () => (data.store.part_text_accum_delta?.[part().id] ?? part().text).trim() return ( @@ -1810,7 +1811,7 @@ ToolRegistry.register({ const sawPending = pending() const text = createMemo(() => { const cmd = props.input.command ?? props.metadata.command ?? "" - const out = stripAnsi(props.output || props.metadata.output || "") + const out = stripAnsi(props.output || props.metadata.output || "").replace(/\r\n?/g, "\n") return `$ ${cmd}${out ? "\n\n" + out : ""}` }) const [copied, setCopied] = createSignal(false) diff --git a/packages/ui/src/components/session-diff.ts b/packages/ui/src/components/session-diff.ts index bd6bed88d8..60dcffd83d 100644 --- a/packages/ui/src/components/session-diff.ts +++ b/packages/ui/src/components/session-diff.ts @@ -12,7 +12,8 @@ type LegacyDiff = { status?: "added" | "deleted" | "modified" } -type ReviewDiff = SnapshotFileDiff | VcsFileDiff | LegacyDiff +type SnapshotDiff = SnapshotFileDiff & { file: string } +type ReviewDiff = SnapshotDiff | VcsFileDiff | LegacyDiff export type ViewDiff = { file: string diff --git a/packages/ui/src/components/session-review.tsx b/packages/ui/src/components/session-review.tsx index 949402f439..1089587ee1 100644 --- a/packages/ui/src/components/session-review.tsx +++ b/packages/ui/src/components/session-review.tsx @@ -62,7 +62,12 @@ export type SessionReviewCommentActions = { export type SessionReviewFocus = { file: string; id: string } -type ReviewDiff = (SnapshotFileDiff | VcsFileDiff) & { preloaded?: PreloadMultiFileDiffResult } +type RawReviewDiff = (SnapshotFileDiff | VcsFileDiff) & { + preloaded?: PreloadMultiFileDiffResult +} +type ReviewDiff = ((SnapshotFileDiff & { file: string }) | VcsFileDiff) & { + preloaded?: PreloadMultiFileDiffResult +} type Item = ViewDiff & { preloaded?: PreloadMultiFileDiffResult } function diff(value: unknown): value is ReviewDiff { @@ -108,7 +113,7 @@ export interface SessionReviewProps { classList?: Record classes?: { root?: string; header?: string; container?: string } actions?: JSX.Element - diffs: ReviewDiff[] + diffs: RawReviewDiff[] onViewFile?: (file: string) => void readFile?: (path: string) => Promise lineCommentMention?: LineCommentEditorProps["mention"] diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index b35f718ef0..a39b9a7f64 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -90,6 +90,12 @@ function list(value: T[] | undefined | null, fallback: T[]) { return fallback } +type SummaryDiff = SnapshotFileDiff & { file: string } + +function summaryDiff(value: SnapshotFileDiff): value is SummaryDiff { + return typeof value.file === "string" +} + const hidden = new Set(["todowrite"]) function partState(part: PartType, showReasoningSummaries: boolean) { @@ -169,7 +175,7 @@ export function SessionTurn( const emptyMessages: MessageType[] = [] const emptyParts: PartType[] = [] const emptyAssistant: AssistantMessage[] = [] - const emptyDiffs: SnapshotFileDiff[] = [] + const emptyDiffs: SummaryDiff[] = [] const idle = { type: "idle" as const } const allMessages = createMemo(() => props.messages ?? list(data.store.message?.[props.sessionID], emptyMessages)) @@ -238,7 +244,8 @@ export function SessionTurn( const seen = new Set() return files - .reduceRight((result, diff) => { + .reduceRight((result, diff) => { + if (!summaryDiff(diff)) return result if (seen.has(diff.file)) return result seen.add(diff.file) result.push(diff) diff --git a/packages/ui/src/context/data.tsx b/packages/ui/src/context/data.tsx index 632bed0cfa..3d015257f3 100644 --- a/packages/ui/src/context/data.tsx +++ b/packages/ui/src/context/data.tsx @@ -24,6 +24,9 @@ type Data = { part: { [messageID: string]: Part[] } + part_text_accum_delta?: { + [partID: string]: string + } } export type NavigateToSessionFn = (sessionID: string) => void diff --git a/packages/web/package.json b/packages/web/package.json index 252c81a295..ec542077bf 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -2,7 +2,7 @@ "name": "@opencode-ai/web", "type": "module", "license": "MIT", - "version": "1.14.41", + "version": "1.14.48", "scripts": { "dev": "astro dev", "dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev", diff --git a/packages/web/src/content/docs/agents.mdx b/packages/web/src/content/docs/agents.mdx index d7c85bc517..53048b7927 100644 --- a/packages/web/src/content/docs/agents.mdx +++ b/packages/web/src/content/docs/agents.mdx @@ -36,13 +36,13 @@ look at these below. Subagents are specialized assistants that primary agents can invoke for specific tasks. You can also manually invoke them by **@ mentioning** them in your messages. -OpenCode comes with two built-in subagents, **General** and **Explore**. We'll look at this below. +OpenCode comes with three built-in subagents, **General**, **Explore**, and **Scout**. We'll look at this below. --- ## Built-in -OpenCode comes with two built-in primary agents and two built-in subagents. +OpenCode comes with two built-in primary agents and three built-in subagents. --- @@ -84,6 +84,14 @@ A fast, read-only agent for exploring codebases. Cannot modify files. Use this w --- +### Use scout + +_Mode_: `subagent` + +A read-only agent for external docs and dependency research. Use this when you need to clone a dependency repository into OpenCode's managed cache, inspect library source, or cross-reference local code against upstream implementations without modifying your workspace. + +--- + ### Use compaction _Mode_: `primary` diff --git a/packages/web/src/content/docs/ar/agents.mdx b/packages/web/src/content/docs/ar/agents.mdx index 01e13fda89..af12a67691 100644 --- a/packages/web/src/content/docs/ar/agents.mdx +++ b/packages/web/src/content/docs/ar/agents.mdx @@ -35,13 +35,13 @@ description: هيّئ الوكلاء المتخصصين واستخدمهم. الوكلاء الفرعيون هم مساعدين متخصصين يمكن للوكلاء الأساسيين استدعاؤهم لمهام محددة. يمكنك أيضا استدعاؤهم يدويا عبر **الإشارة بـ @** في رسائلك. -يأتي OpenCode مع وكيلين فرعيين مدمجين: **General** و **Explore**. سنلقي نظرة على ذلك أدناه. +يأتي OpenCode مع ثلاثة وكلاء فرعيين مدمجين: **General** و **Explore** و **Scout**. سنلقي نظرة على ذلك أدناه. --- ## المدمجة -يأتي OpenCode مع وكيلين أساسيين مدمجين ووكيلين فرعيين مدمجين. +يأتي OpenCode مع وكيلين أساسيين مدمجين وثلاثة وكلاء فرعيين مدمجين. --- @@ -83,6 +83,14 @@ _الوضع_: `subagent` --- +### استخدام Scout + +_الوضع_: `subagent` + +وكيل للقراءة فقط مخصص للوثائق الخارجية وأبحاث التبعيات. استخدمه عندما تحتاج إلى استنساخ مستودع تبعية داخل ذاكرة التخزين المؤقت المُدارة في OpenCode، أو فحص الشفرة المصدرية لمكتبة، أو إجراء مراجع متقاطعة بين الشفرة المحلية والتنفيذات upstream بدون تعديل مساحة العمل الخاصة بك. + +--- + ### استخدام Compaction _الوضع_: `primary` diff --git a/packages/web/src/content/docs/ar/zen.mdx b/packages/web/src/content/docs/ar/zen.mdx index 33fd9493ba..748384c211 100644 --- a/packages/web/src/content/docs/ar/zen.mdx +++ b/packages/web/src/content/docs/ar/zen.mdx @@ -97,8 +97,7 @@ OpenCode Zen هي بوابة AI تتيح لك الوصول إلى هذه الن | Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Ling 2.6 Flash | ling-2.6-flash | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Hy3 Preview Free | hy3-preview-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Ring 2.6 1T | ring-2.6-1t-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Nemotron 3 Super Free | nemotron-3-super-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | يستخدم [معرّف النموذج](/docs/config/#models) في إعدادات OpenCode الصيغة `opencode/`. على سبيل المثال، بالنسبة إلى GPT 5.5، ستستخدم `opencode/gpt-5.5` في إعداداتك. @@ -123,8 +122,7 @@ https://opencode.ai/zen/v1/models | --------------------------------- | ------- | ------- | --------------- | --------------- | | Big Pickle | Free | Free | Free | - | | MiniMax M2.5 Free | Free | Free | Free | - | -| Ling 2.6 Flash Free | Free | Free | Free | - | -| Hy3 Preview Free | Free | Free | Free | - | +| Ring 2.6 1T Free | Free | Free | Free | - | | Nemotron 3 Super Free | Free | Free | Free | - | | MiniMax M2.7 | $0.30 | $1.20 | $0.06 | $0.375 | | MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 | @@ -176,8 +174,7 @@ https://opencode.ai/zen/v1/models النماذج المجانية: - MiniMax M2.5 Free متاح على OpenCode لفترة محدودة. يستخدم الفريق هذه الفترة لجمع الملاحظات وتحسين النموذج. -- Ling 2.6 Flash Free متاح على OpenCode لفترة محدودة. يستخدم الفريق هذه الفترة لجمع الملاحظات وتحسين النموذج. -- Hy3 Preview Flash Free متاح على OpenCode لفترة محدودة. يستخدم الفريق هذه الفترة لجمع الملاحظات وتحسين النموذج. +- Ring 2.6 1T Free متاح على OpenCode لفترة محدودة. يستخدم الفريق هذه الفترة لجمع الملاحظات وتحسين النموذج. - Nemotron 3 Super Free متاح على OpenCode لفترة محدودة. يستخدم الفريق هذه الفترة لجمع الملاحظات وتحسين النموذج. - Big Pickle نموذج خفي ومتاح مجانا على OpenCode لفترة محدودة. يستخدم الفريق هذه الفترة لجمع الملاحظات وتحسين النموذج. @@ -229,8 +226,7 @@ https://opencode.ai/zen/v1/models - Big Pickle: خلال فترته المجانية، قد تُستخدم البيانات المجمعة لتحسين النموذج. - MiniMax M2.5 Free: خلال فترته المجانية، قد تُستخدم البيانات المجمعة لتحسين النموذج. -- Ling 2.6 Flash Free: خلال فترته المجانية، قد تُستخدم البيانات المجمعة لتحسين النموذج. -- Hy3 Preview Free: خلال فترته المجانية، قد تُستخدم البيانات المجمعة لتحسين النموذج. +- Ring 2.6 1T Free: خلال فترته المجانية، قد تُستخدم البيانات المجمعة لتحسين النموذج. - Nemotron 3 Super Free (نقاط نهاية NVIDIA المجانية): يُقدَّم بموجب [شروط خدمة النسخة التجريبية من واجهة NVIDIA API](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). للاستخدام التجريبي فقط، وليس للإنتاج أو البيانات الحساسة. تقوم NVIDIA بتسجيل المطالبات والمخرجات لتحسين نماذجها وخدماتها. لا ترسل بيانات شخصية أو سرية. - OpenAI APIs: يتم الاحتفاظ بالطلبات لمدة 30 يوما وفقا لـ [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data). - Anthropic APIs: يتم الاحتفاظ بالطلبات لمدة 30 يوما وفقا لـ [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage). diff --git a/packages/web/src/content/docs/bs/agents.mdx b/packages/web/src/content/docs/bs/agents.mdx index 8ff674ae67..a2e211b19a 100644 --- a/packages/web/src/content/docs/bs/agents.mdx +++ b/packages/web/src/content/docs/bs/agents.mdx @@ -35,13 +35,13 @@ OpenCode dolazi sa dva ugrađena primarna agenta, **Build** i **Plan**. Pogledat Subagenti su specijalizovani pomoćnici koje primarni agenti mogu pozvati za određene zadatke. Možete ih i ručno pozvati **@ spominjanjem** u svojim porukama. -OpenCode dolazi sa dva ugrađena subagenta, **General** i **Explore**. Ovo ćemo pogledati u nastavku. +OpenCode dolazi sa tri ugrađena subagenta, **General**, **Explore** i **Scout**. Ovo ćemo pogledati u nastavku. --- ## Ugrađeni -OpenCode dolazi sa dva ugrađena primarna agenta i dva ugrađena subagenta. +OpenCode dolazi sa dva ugrađena primarna agenta i tri ugrađena subagenta. --- @@ -83,6 +83,14 @@ Brzi agent samo za čitanje za istraživanje kodnih baza. Nije moguće mijenjati --- +### Scout agent + +_Režim_: `subagent` + +Agent samo za čitanje za istraživanje eksterne dokumentacije i zavisnosti. Koristite ga kada trebate klonirati repozitorij zavisnosti u OpenCode-ov upravljani cache, pregledati izvorni kod biblioteke ili uporediti lokalni kod sa upstream implementacijama bez mijenjanja vašeg radnog prostora. + +--- + ### Compaction agent _Režim_: `primary` diff --git a/packages/web/src/content/docs/bs/zen.mdx b/packages/web/src/content/docs/bs/zen.mdx index 3723cbaa3c..22299b12c9 100644 --- a/packages/web/src/content/docs/bs/zen.mdx +++ b/packages/web/src/content/docs/bs/zen.mdx @@ -102,8 +102,7 @@ Našim modelima možete pristupiti i preko sljedećih API endpointa. | Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Ling 2.6 Flash | ling-2.6-flash | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Hy3 Preview Free | hy3-preview-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Ring 2.6 1T | ring-2.6-1t-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Nemotron 3 Super Free | nemotron-3-super-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | [model id](/docs/config/#models) u vašoj OpenCode konfiguraciji koristi format @@ -130,8 +129,7 @@ Podržavamo pay-as-you-go model. Ispod su cijene **po 1M tokena**. | --------------------------------- | ------ | ------- | ----------- | ------------ | | Big Pickle | Free | Free | Free | - | | MiniMax M2.5 Free | Free | Free | Free | - | -| Ling 2.6 Flash Free | Free | Free | Free | - | -| Hy3 Preview Free | Free | Free | Free | - | +| Ring 2.6 1T Free | Free | Free | Free | - | | Nemotron 3 Super Free | Free | Free | Free | - | | MiniMax M2.7 | $0.30 | $1.20 | $0.06 | $0.375 | | MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 | @@ -183,8 +181,7 @@ Naknade za kreditne kartice prosljeđujemo po stvarnom trošku (4.4% + $0.30 po Besplatni modeli: - MiniMax M2.5 Free je dostupan na OpenCode ograničeno vrijeme. Tim koristi ovo vrijeme da prikupi povratne informacije i poboljša model. -- Ling 2.6 Flash Free je dostupan na OpenCode ograničeno vrijeme. Tim koristi ovo vrijeme da prikupi povratne informacije i poboljša model. -- Hy3 Preview Free je dostupan na OpenCode ograničeno vrijeme. Tim koristi ovo vrijeme da prikupi povratne informacije i poboljša model. +- Ring 2.6 1T Free je dostupan na OpenCode ograničeno vrijeme. Tim koristi ovo vrijeme da prikupi povratne informacije i poboljša model. - Nemotron 3 Super Free je dostupan na OpenCode ograničeno vrijeme. Tim koristi ovo vrijeme da prikupi povratne informacije i poboljša model. - Big Pickle je stealth model koji je besplatan na OpenCode ograničeno vrijeme. Tim koristi ovo vrijeme da prikupi povratne informacije i poboljša model. @@ -241,8 +238,7 @@ i ne koriste vaše podatke za treniranje modela, uz sljedeće izuzetke: - Big Pickle: Tokom besplatnog perioda, prikupljeni podaci mogu se koristiti za poboljšanje modela. - MiniMax M2.5 Free: Tokom besplatnog perioda, prikupljeni podaci mogu se koristiti za poboljšanje modela. -- Ling 2.6 Flash Free: Tokom besplatnog perioda, prikupljeni podaci mogu se koristiti za poboljšanje modela. -- Hy3 Preview Free: Tokom besplatnog perioda, prikupljeni podaci mogu se koristiti za poboljšanje modela. +- Ring 2.6 1T Free: Tokom besplatnog perioda, prikupljeni podaci mogu se koristiti za poboljšanje modela. - Nemotron 3 Super Free (besplatni NVIDIA endpointi): Dostupan je prema [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Samo za probnu upotrebu, nije za produkciju niti osjetljive podatke. NVIDIA bilježi promptove i izlaze radi poboljšanja svojih modela i usluga. Nemojte slati lične ili povjerljive podatke. - OpenAI APIs: Requests are retained for 30 days in accordance with [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data). - Anthropic APIs: Requests are retained for 30 days in accordance with [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage). diff --git a/packages/web/src/content/docs/config.mdx b/packages/web/src/content/docs/config.mdx index 39c9974c56..ec96069c70 100644 --- a/packages/web/src/content/docs/config.mdx +++ b/packages/web/src/content/docs/config.mdx @@ -525,26 +525,20 @@ You can also define commands using markdown files in `~/.config/opencode/command --- -### Keymap +### Keybinds -Customize TUI keyboard shortcuts in `tui.json` with `keymap`. +Customize TUI keyboard shortcuts in `tui.json` with `keybinds`. ```json title="tui.json" { "$schema": "https://opencode.ai/tui.json", - "keymap": { - "sections": { - "global": { - "command.palette.show": "ctrl+p" - } - } + "keybinds": { + "command_list": "ctrl+p" } } ``` -`keymap` is merged with built-in defaults, so you only need to configure the shortcuts you want to change. - -The older `keybinds` field is deprecated and only applies when `keymap` is not present. +`keybinds` is merged with built-in defaults, so you only need to configure the shortcuts you want to change. [Learn more here](/docs/keybinds). diff --git a/packages/web/src/content/docs/da/agents.mdx b/packages/web/src/content/docs/da/agents.mdx index 6ab2e7c39d..058f9eec6e 100644 --- a/packages/web/src/content/docs/da/agents.mdx +++ b/packages/web/src/content/docs/da/agents.mdx @@ -36,13 +36,13 @@ se på disse nedenfor. Subagenter er specialiserede assistenter, som primære agenter kan påbegynde sig til specifikke opgaver. Du kan også kalde dem manuelt ved at **@ nævne** dem i dine beskeder. -OpenCode leveres med to indbyggede underagenter, **Generelt** og **Udforsk**. Vi vil se på dette nedenfor. +OpenCode leveres med tre indbyggede subagenter, **General**, **Explore** og **Scout**. Vi ser nærmere på dem nedenfor. --- ## Indbyggede -OpenCode leveres med to indbyggede primære agenter og to indbyggede subagenter. +OpenCode leveres med to indbyggede primære agenter og tre indbyggede subagenter. --- @@ -84,6 +84,14 @@ En hurtig, skrivebeskyttet agent til at udforske kodebaser. Kan ikke ændre file --- +### Scout-agenten + +_Tilstand_: `subagent` + +En skrivebeskyttet agent til eksterne docs og research af dependencies. Brug denne, når du har brug for at klone et dependency-repository ind i OpenCode's administrerede cache, inspicere kildekoden i et bibliotek eller krydstjekke lokal kode mod upstream-implementeringer uden at ændre dit workspace. + +--- + ### Compact-agenten _Tilstand_: `primary` diff --git a/packages/web/src/content/docs/da/zen.mdx b/packages/web/src/content/docs/da/zen.mdx index d45f785a59..0fb2c9737a 100644 --- a/packages/web/src/content/docs/da/zen.mdx +++ b/packages/web/src/content/docs/da/zen.mdx @@ -102,8 +102,7 @@ Du kan også få adgang til vores modeller gennem følgende API-endpoints. | Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Ling 2.6 Flash | ling-2.6-flash | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Hy3 Preview Free | hy3-preview-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Ring 2.6 1T | ring-2.6-1t-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Nemotron 3 Super Free | nemotron-3-super-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | [model id](/docs/config/#models) i din OpenCode-konfiguration @@ -130,8 +129,7 @@ Vi understøtter en pay-as-you-go-model. Nedenfor er priserne **pr. 1M tokens**. | --------------------------------- | ------ | ------- | ----------- | ------------ | | Big Pickle | Free | Free | Free | - | | MiniMax M2.5 Free | Free | Free | Free | - | -| Ling 2.6 Flash Free | Free | Free | Free | - | -| Hy3 Preview Free | Free | Free | Free | - | +| Ring 2.6 1T Free | Free | Free | Free | - | | Nemotron 3 Super Free | Free | Free | Free | - | | MiniMax M2.7 | $0.30 | $1.20 | $0.06 | $0.375 | | MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 | @@ -183,8 +181,7 @@ Kreditkortgebyrer videregives til kostpris (4.4% + $0.30 pr. transaktion); vi op De gratis modeller: - MiniMax M2.5 Free er tilgængelig på OpenCode i en begrænset periode. Teamet bruger denne tid til at indsamle feedback og forbedre modellen. -- Ling 2.6 Flash Free er tilgængelig på OpenCode i en begrænset periode. Teamet bruger denne tid til at indsamle feedback og forbedre modellen. -- Hy3 Preview Free er tilgængelig på OpenCode i en begrænset periode. Teamet bruger denne tid til at indsamle feedback og forbedre modellen. +- Ring 2.6 1T Free er tilgængelig på OpenCode i en begrænset periode. Teamet bruger denne tid til at indsamle feedback og forbedre modellen. - Nemotron 3 Super Free er tilgængelig på OpenCode i en begrænset periode. Teamet bruger denne tid til at indsamle feedback og forbedre modellen. - Big Pickle er en stealth-model, som er gratis på OpenCode i en begrænset periode. Teamet bruger denne tid til at indsamle feedback og forbedre modellen. @@ -239,8 +236,7 @@ Alle vores modeller hostes i US. Vores udbydere følger en nul-opbevaringspoliti - Big Pickle: I den gratis periode kan indsamlede data blive brugt til at forbedre modellen. - MiniMax M2.5 Free: I den gratis periode kan indsamlede data blive brugt til at forbedre modellen. -- Ling 2.6 Flash Free: I den gratis periode kan indsamlede data blive brugt til at forbedre modellen. -- Hy3 Preview Free: I den gratis periode kan indsamlede data blive brugt til at forbedre modellen. +- Ring 2.6 1T Free: I den gratis periode kan indsamlede data blive brugt til at forbedre modellen. - Nemotron 3 Super Free (gratis NVIDIA-endpoints): Leveres under [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Kun til prøvebrug, ikke til produktion eller følsomme data. Prompts og outputs logges af NVIDIA for at forbedre deres modeller og tjenester. Indsend ikke personlige eller fortrolige data. - OpenAI APIs: Anmodninger opbevares i 30 dage i overensstemmelse med [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data). - Anthropic APIs: Anmodninger opbevares i 30 dage i overensstemmelse med [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage). diff --git a/packages/web/src/content/docs/de/agents.mdx b/packages/web/src/content/docs/de/agents.mdx index 289b113cf6..6bca53488d 100644 --- a/packages/web/src/content/docs/de/agents.mdx +++ b/packages/web/src/content/docs/de/agents.mdx @@ -70,6 +70,14 @@ Ein schneller, schreibgeschützter Agent zum Erkunden von Codebasen. Dateien kö --- +### Scout + +_Modus_: `subagent` + +Ein schreibgeschützter Agent für externe Dokumentation und Dependency-Recherche. Verwenden Sie ihn, wenn Sie ein Dependency-Repository in den von OpenCode verwalteten Cache klonen, den Quellcode einer Bibliothek untersuchen oder lokalen Code mit Upstream-Implementierungen abgleichen müssen, ohne Ihren Workspace zu verändern. + +--- + ### Compaction _Modus_: `primary` diff --git a/packages/web/src/content/docs/de/zen.mdx b/packages/web/src/content/docs/de/zen.mdx index 5e6c8eee80..425d9b6510 100644 --- a/packages/web/src/content/docs/de/zen.mdx +++ b/packages/web/src/content/docs/de/zen.mdx @@ -93,8 +93,7 @@ Du kannst auch über die folgenden API-Endpunkte auf unsere Modelle zugreifen. | Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Ling 2.6 Flash | ling-2.6-flash | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Hy3 Preview Free | hy3-preview-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Ring 2.6 1T | ring-2.6-1t-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Nemotron 3 Super Free | nemotron-3-super-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | Die [Model-ID](/docs/config/#models) in deiner OpenCode-Konfiguration verwendet das Format `opencode/`. Für GPT 5.5 würdest du zum Beispiel `opencode/gpt-5.5` in deiner Konfiguration verwenden. @@ -119,8 +118,7 @@ Wir unterstützen ein Pay-as-you-go-Modell. Unten findest du die Preise **pro 1M | --------------------------------- | ------ | ------- | ----------- | ------------ | | Big Pickle | Free | Free | Free | - | | MiniMax M2.5 Free | Free | Free | Free | - | -| Ling 2.6 Flash Free | Free | Free | Free | - | -| Hy3 Preview Free | Free | Free | Free | - | +| Ring 2.6 1T Free | Free | Free | Free | - | | Nemotron 3 Super Free | Free | Free | Free | - | | MiniMax M2.7 | $0.30 | $1.20 | $0.06 | $0.375 | | MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 | @@ -172,8 +170,7 @@ Kreditkartengebühren werden zum Selbstkostenpreis weitergegeben (4.4% + $0.30 p Die kostenlosen Modelle: - MiniMax M2.5 Free ist für begrenzte Zeit auf OpenCode verfügbar. Das Team nutzt diese Zeit, um Feedback zu sammeln und das Modell zu verbessern. -- Ling 2.6 Flash Free ist für begrenzte Zeit auf OpenCode verfügbar. Das Team nutzt diese Zeit, um Feedback zu sammeln und das Modell zu verbessern. -- Hy3 Preview Flash Free ist für begrenzte Zeit auf OpenCode verfügbar. Das Team nutzt diese Zeit, um Feedback zu sammeln und das Modell zu verbessern. +- Ring 2.6 1T Free ist für begrenzte Zeit auf OpenCode verfügbar. Das Team nutzt diese Zeit, um Feedback zu sammeln und das Modell zu verbessern. - Nemotron 3 Super Free ist für begrenzte Zeit auf OpenCode verfügbar. Das Team nutzt diese Zeit, um Feedback zu sammeln und das Modell zu verbessern. - Big Pickle ist ein Stealth-Modell, das für begrenzte Zeit kostenlos auf OpenCode verfügbar ist. Das Team nutzt diese Zeit, um Feedback zu sammeln und das Modell zu verbessern. @@ -225,8 +222,7 @@ Alle unsere Modelle werden in den USA gehostet. Unsere Provider folgen einer Zer - Big Pickle: Während des kostenlosen Zeitraums können gesammelte Daten zur Verbesserung des Modells verwendet werden. - MiniMax M2.5 Free: Während des kostenlosen Zeitraums können gesammelte Daten zur Verbesserung des Modells verwendet werden. -- Ling 2.6 Flash Free: Während des kostenlosen Zeitraums können gesammelte Daten zur Verbesserung des Modells verwendet werden. -- Hy3 Preview Free: Während des kostenlosen Zeitraums können gesammelte Daten zur Verbesserung des Modells verwendet werden. +- Ring 2.6 1T Free: Während des kostenlosen Zeitraums können gesammelte Daten zur Verbesserung des Modells verwendet werden. - Nemotron 3 Super Free (kostenlose NVIDIA-Endpunkte): Bereitgestellt gemäß den [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Nur für Testzwecke, nicht für Produktion oder sensible Daten. Eingaben und Ausgaben werden von NVIDIA protokolliert, um seine Modelle und Dienste zu verbessern. Übermitteln Sie keine personenbezogenen oder vertraulichen Daten. - OpenAI APIs: Anfragen werden in Übereinstimmung mit [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data) 30 Tage lang gespeichert. - Anthropic APIs: Anfragen werden in Übereinstimmung mit [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage) 30 Tage lang gespeichert. diff --git a/packages/web/src/content/docs/es/agents.mdx b/packages/web/src/content/docs/es/agents.mdx index 0b2736ac37..c98a4eb99e 100644 --- a/packages/web/src/content/docs/es/agents.mdx +++ b/packages/web/src/content/docs/es/agents.mdx @@ -36,13 +36,13 @@ mira estos a continuación. Los subagentes son asistentes especializados que los agentes principales pueden invocar para tareas específicas. También puedes invocarlos manualmente **@ mencionándolos** en tus mensajes. -OpenCode viene con dos subagentes integrados, **General** y **Explore**. Veremos esto a continuación. +OpenCode viene con tres subagentes integrados, **General**, **Explore** y **Scout**. Veremos esto a continuación. --- ## Integrados -OpenCode viene con dos agentes primarios integrados y dos subagentes integrados. +OpenCode viene con dos agentes primarios integrados y tres subagentes integrados. --- @@ -84,6 +84,14 @@ Un agente rápido y de solo lectura para explorar bases de código. No se pueden --- +### Scout + +_Modo_: `subagent` + +Un agente de solo lectura para investigar documentación externa y dependencias. Úsalo cuando necesites clonar el repositorio de una dependencia en la caché administrada de OpenCode, inspeccionar el código fuente de una librería o contrastar el código local con implementaciones upstream sin modificar tu espacio de trabajo. + +--- + ### Compactación _Modo_: `primary` diff --git a/packages/web/src/content/docs/es/zen.mdx b/packages/web/src/content/docs/es/zen.mdx index 15436226a5..8e0e51293d 100644 --- a/packages/web/src/content/docs/es/zen.mdx +++ b/packages/web/src/content/docs/es/zen.mdx @@ -102,8 +102,7 @@ También puedes acceder a nuestros modelos a través de los siguientes endpoints | Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Ling 2.6 Flash | ling-2.6-flash | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Hy3 Preview Free | hy3-preview-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Ring 2.6 1T | ring-2.6-1t-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Nemotron 3 Super Free | nemotron-3-super-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | El [identificador del modelo](/docs/config/#models) en tu configuración de OpenCode @@ -130,8 +129,7 @@ Admitimos un modelo de pago por uso. A continuación se muestran los precios **p | --------------------------------- | ------- | ------- | ---------------- | ------------------ | | Big Pickle | Free | Free | Free | - | | MiniMax M2.5 Free | Free | Free | Free | - | -| Ling 2.6 Flash Free | Free | Free | Free | - | -| Hy3 Preview Free | Free | Free | Free | - | +| Ring 2.6 1T Free | Free | Free | Free | - | | Nemotron 3 Super Free | Free | Free | Free | - | | MiniMax M2.7 | $0.30 | $1.20 | $0.06 | $0.375 | | MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 | @@ -183,8 +181,7 @@ Las comisiones de tarjeta de crédito se trasladan al costo (4.4% + $0.30 por tr Los modelos gratuitos: - MiniMax M2.5 Free está disponible en OpenCode por tiempo limitado. El equipo está usando este tiempo para recopilar comentarios y mejorar el modelo. -- Ling 2.6 Flash Free está disponible en OpenCode por tiempo limitado. El equipo está usando este tiempo para recopilar comentarios y mejorar el modelo. -- Hy3 Preview Flash Free está disponible en OpenCode por tiempo limitado. El equipo está usando este tiempo para recopilar comentarios y mejorar el modelo. +- Ring 2.6 1T Free está disponible en OpenCode por tiempo limitado. El equipo está usando este tiempo para recopilar comentarios y mejorar el modelo. - Nemotron 3 Super Free está disponible en OpenCode por tiempo limitado. El equipo está usando este tiempo para recopilar comentarios y mejorar el modelo. - Big Pickle es un modelo stealth que es gratuito en OpenCode por tiempo limitado. El equipo está usando este tiempo para recopilar comentarios y mejorar el modelo. @@ -239,8 +236,7 @@ Todos nuestros modelos están alojados en US. Nuestros proveedores siguen una po - Big Pickle: Durante su período gratuito, los datos recopilados pueden usarse para mejorar el modelo. - MiniMax M2.5 Free: Durante su período gratuito, los datos recopilados pueden usarse para mejorar el modelo. -- Ling 2.6 Flash Free: Durante su período gratuito, los datos recopilados pueden usarse para mejorar el modelo. -- Hy3 Preview Free: Durante su período gratuito, los datos recopilados pueden usarse para mejorar el modelo. +- Ring 2.6 1T Free: Durante su período gratuito, los datos recopilados pueden usarse para mejorar el modelo. - Nemotron 3 Super Free (endpoints gratuitos de NVIDIA): Se ofrece bajo los [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Solo para uso de prueba, no para producción ni datos sensibles. NVIDIA registra los prompts y las salidas para mejorar sus modelos y servicios. No envíes datos personales ni confidenciales. - OpenAI APIs: Las solicitudes se conservan durante 30 días de acuerdo con [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data). - Anthropic APIs: Las solicitudes se conservan durante 30 días de acuerdo con [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage). diff --git a/packages/web/src/content/docs/fr/agents.mdx b/packages/web/src/content/docs/fr/agents.mdx index b18d335394..a6b323dfc8 100644 --- a/packages/web/src/content/docs/fr/agents.mdx +++ b/packages/web/src/content/docs/fr/agents.mdx @@ -36,13 +36,13 @@ Nous les verrons ci-dessous. Les sous-agents sont des assistants spécialisés que les agents primaires peuvent appeler pour des tâches spécifiques. Vous pouvez également les invoquer manuellement en **@ les mentionnant** dans vos messages. -OpenCode est livré avec deux sous-agents intégrés, **General** et **Explore**. Nous verrons cela ci-dessous. +OpenCode est livré avec trois sous-agents intégrés, **General**, **Explore** et **Scout**. Nous les verrons ci-dessous. --- ## Agents intégrés -OpenCode est livré avec deux agents primaires intégrés et deux sous-agents intégrés. +OpenCode est livré avec deux agents primaires intégrés et trois sous-agents intégrés. --- @@ -84,6 +84,14 @@ Un agent rapide en lecture seule pour explorer les bases de code. Impossible de --- +### Agent Scout + +_Mode_ : `subagent` + +Un agent en lecture seule pour la recherche sur la documentation externe et les dépendances. Utilisez-le lorsque vous devez cloner le dépôt d'une dépendance dans le cache géré d'OpenCode, inspecter le code source d'une bibliothèque ou recouper le code local avec les implémentations upstream sans modifier votre espace de travail. + +--- + ### Agent Compaction _Mode_ : `primary` diff --git a/packages/web/src/content/docs/fr/zen.mdx b/packages/web/src/content/docs/fr/zen.mdx index fdf14e8fb0..0d07ee3144 100644 --- a/packages/web/src/content/docs/fr/zen.mdx +++ b/packages/web/src/content/docs/fr/zen.mdx @@ -93,8 +93,7 @@ Vous pouvez également accéder à nos modèles via les points de terminaison AP | Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Ling 2.6 Flash | ling-2.6-flash | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Hy3 Preview Free | hy3-preview-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Ring 2.6 1T | ring-2.6-1t-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Nemotron 3 Super Free | nemotron-3-super-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | Le [model id](/docs/config/#models) dans votre configuration OpenCode utilise le format `opencode/`. Par exemple, pour GPT 5.5, vous utiliseriez `opencode/gpt-5.5` dans votre configuration. @@ -119,8 +118,7 @@ Nous prenons en charge un modèle de paiement à l'utilisation. Vous trouverez c | --------------------------------- | ------ | ------- | ----------- | ------------ | | Big Pickle | Free | Free | Free | - | | MiniMax M2.5 Free | Free | Free | Free | - | -| Ling 2.6 Flash Free | Free | Free | Free | - | -| Hy3 Preview Free | Free | Free | Free | - | +| Ring 2.6 1T Free | Free | Free | Free | - | | Nemotron 3 Super Free | Free | Free | Free | - | | MiniMax M2.7 | $0.30 | $1.20 | $0.06 | $0.375 | | MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 | @@ -172,8 +170,7 @@ Les frais de carte de crédit sont répercutés au prix coûtant (4.4% + $0.30 p Les modèles gratuits : - MiniMax M2.5 Free est disponible sur OpenCode pour une durée limitée. L'équipe utilise cette période pour recueillir des retours et améliorer le modèle. -- Ling 2.6 Flash Free est disponible sur OpenCode pour une durée limitée. L'équipe utilise cette période pour recueillir des retours et améliorer le modèle. -- Hy3 Preview Free est disponible sur OpenCode pour une durée limitée. L'équipe utilise cette période pour recueillir des retours et améliorer le modèle. +- Ring 2.6 1T Free est disponible sur OpenCode pour une durée limitée. L'équipe utilise cette période pour recueillir des retours et améliorer le modèle. - Nemotron 3 Super Free est disponible sur OpenCode pour une durée limitée. L'équipe utilise cette période pour recueillir des retours et améliorer le modèle. - Big Pickle est un modèle stealth gratuit sur OpenCode pour une durée limitée. L'équipe utilise cette période pour recueillir des retours et améliorer le modèle. @@ -225,8 +222,7 @@ Tous nos modèles sont hébergés aux US. Nos fournisseurs suivent une politique - Big Pickle : Pendant sa période gratuite, les données collectées peuvent être utilisées pour améliorer le modèle. - MiniMax M2.5 Free : Pendant sa période gratuite, les données collectées peuvent être utilisées pour améliorer le modèle. -- Ling 2.6 Flash Free : Pendant sa période gratuite, les données collectées peuvent être utilisées pour améliorer le modèle. -- Hy3 Preview Free : Pendant sa période gratuite, les données collectées peuvent être utilisées pour améliorer le modèle. +- Ring 2.6 1T Free : Pendant sa période gratuite, les données collectées peuvent être utilisées pour améliorer le modèle. - Nemotron 3 Super Free (endpoints NVIDIA gratuits) : Fourni dans le cadre des [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Réservé à un usage d'essai, pas à la production ni aux données sensibles. Les prompts et les sorties sont journalisés par NVIDIA pour améliorer ses modèles et services. N'envoyez pas de données personnelles ou confidentielles. - OpenAI APIs : Les requêtes sont conservées pendant 30 jours conformément à [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data). - Anthropic APIs : Les requêtes sont conservées pendant 30 jours conformément à [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage). diff --git a/packages/web/src/content/docs/it/agents.mdx b/packages/web/src/content/docs/it/agents.mdx index 4ecc9fc2a2..70aea57533 100644 --- a/packages/web/src/content/docs/it/agents.mdx +++ b/packages/web/src/content/docs/it/agents.mdx @@ -35,13 +35,13 @@ OpenCode include due agenti primari integrati: **Build** e **Plan**. Li vediamo I subagenti sono assistenti specializzati che gli agenti primari possono invocare per task specifici. Puoi anche invocarli manualmente **menzionandoli con @** nei tuoi messaggi. -OpenCode include due subagenti integrati: **General** e **Explore**. Li vediamo sotto. +OpenCode include tre subagenti integrati: **General**, **Explore** e **Scout**. Li vediamo sotto. --- ## Integrati -OpenCode include due agenti primari integrati e due subagenti integrati. +OpenCode include due agenti primari integrati e tre subagenti integrati. --- @@ -83,6 +83,14 @@ Un agente rapido in sola lettura per esplorare codebase. Non può modificare fil --- +### Scout + +_Mode_: `subagent` + +Un agente in sola lettura per la ricerca su documentazione esterna e dipendenze. Usalo quando devi clonare il repository di una dipendenza nella cache gestita di OpenCode, ispezionare il codice sorgente di una libreria o confrontare il codice locale con implementazioni upstream senza modificare il tuo workspace. + +--- + ### Compaction _Mode_: `primary` diff --git a/packages/web/src/content/docs/it/zen.mdx b/packages/web/src/content/docs/it/zen.mdx index a53d6a2ba1..df24babb26 100644 --- a/packages/web/src/content/docs/it/zen.mdx +++ b/packages/web/src/content/docs/it/zen.mdx @@ -102,8 +102,7 @@ Puoi anche accedere ai nostri modelli tramite i seguenti endpoint API. | Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Ling 2.6 Flash | ling-2.6-flash | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Hy3 Preview Free | hy3-preview-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Ring 2.6 1T | ring-2.6-1t-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Nemotron 3 Super Free | nemotron-3-super-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | Il [model id](/docs/config/#models) nella config di OpenCode @@ -130,8 +129,7 @@ Supportiamo un modello pay-as-you-go. Qui sotto trovi i prezzi **per 1M token**. | --------------------------------- | ------ | ------- | ----------- | ------------ | | Big Pickle | Free | Free | Free | - | | MiniMax M2.5 Free | Free | Free | Free | - | -| Ling 2.6 Flash Free | Free | Free | Free | - | -| Hy3 Preview Free | Free | Free | Free | - | +| Ring 2.6 1T Free | Free | Free | Free | - | | Nemotron 3 Super Free | Free | Free | Free | - | | MiniMax M2.7 | $0.30 | $1.20 | $0.06 | $0.375 | | MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 | @@ -183,8 +181,7 @@ Le commissioni della carta di credito vengono trasferite al costo (4.4% + $0.30 I modelli gratuiti: - MiniMax M2.5 Free è disponibile su OpenCode per un periodo limitato. Il team usa questo periodo per raccogliere feedback e migliorare il modello. -- Ling 2.6 Flash Free è disponibile su OpenCode per un periodo limitato. Il team usa questo periodo per raccogliere feedback e migliorare il modello. -- Hy3 Preview Flash Free è disponibile su OpenCode per un periodo limitato. Il team usa questo periodo per raccogliere feedback e migliorare il modello. +- Ring 2.6 1T Free è disponibile su OpenCode per un periodo limitato. Il team usa questo periodo per raccogliere feedback e migliorare il modello. - Nemotron 3 Super Free è disponibile su OpenCode per un periodo limitato. Il team usa questo periodo per raccogliere feedback e migliorare il modello. - Big Pickle è un modello stealth che è gratuito su OpenCode per un periodo limitato. Il team usa questo periodo per raccogliere feedback e migliorare il modello. @@ -239,8 +236,7 @@ Tutti i nostri modelli sono ospitati negli US. I nostri provider seguono una pol - Big Pickle: durante il periodo gratuito, i dati raccolti possono essere usati per migliorare il modello. - MiniMax M2.5 Free: durante il periodo gratuito, i dati raccolti possono essere usati per migliorare il modello. -- Ling 2.6 Flash Free: durante il periodo gratuito, i dati raccolti possono essere usati per migliorare il modello. -- Hy3 Preview Free: durante il periodo gratuito, i dati raccolti possono essere usati per migliorare il modello. +- Ring 2.6 1T Free: durante il periodo gratuito, i dati raccolti possono essere usati per migliorare il modello. - Nemotron 3 Super Free (endpoint NVIDIA gratuiti): fornito secondo i [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Solo per uso di prova, non per produzione o dati sensibili. NVIDIA registra prompt e output per migliorare i propri modelli e servizi. Non inviare dati personali o riservati. - OpenAI APIs: le richieste vengono conservate per 30 giorni in conformità con [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data). - Anthropic APIs: le richieste vengono conservate per 30 giorni in conformità con [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage). diff --git a/packages/web/src/content/docs/ja/agents.mdx b/packages/web/src/content/docs/ja/agents.mdx index 879a43b057..539d30faf8 100644 --- a/packages/web/src/content/docs/ja/agents.mdx +++ b/packages/web/src/content/docs/ja/agents.mdx @@ -35,13 +35,13 @@ OpenCode には、**Build** と **Plan** という 2 つの組み込みプライ サブエージェントは、プライマリエージェントが特定のタスクのために呼び出すことができる特殊なアシスタントです。メッセージ内で **@ メンション**することで、手動で呼び出すこともできます。 -OpenCode には、**General** と **Explore** という 2 つの組み込みサブエージェントが付属しています。これについては以下で見ていきます。 +OpenCode には、**General**、**Explore**、**Scout** という 3 つの組み込みサブエージェントが付属しています。これについては以下で見ていきます。 --- ## 組み込み -OpenCode には、2 つの組み込みプライマリエージェントと 2 つの組み込みサブエージェントが付属しています。 +OpenCode には、2 つの組み込みプライマリエージェントと 3 つの組み込みサブエージェントが付属しています。 --- @@ -83,6 +83,14 @@ _モード_: `subagent` --- +### Scout + +_モード_: `subagent` + +外部ドキュメントや依存関係の調査を行うための読み取り専用エージェントです。依存関係のリポジトリを OpenCode の管理キャッシュにクローンしたいとき、ライブラリのソースコードを調べたいとき、あるいはワークスペースを変更せずにローカルコードを upstream の実装と突き合わせたいときに使用します。 + +--- + ### Compact _モード_: `primary` diff --git a/packages/web/src/content/docs/ja/zen.mdx b/packages/web/src/content/docs/ja/zen.mdx index 64427a72ec..05d6c8b812 100644 --- a/packages/web/src/content/docs/ja/zen.mdx +++ b/packages/web/src/content/docs/ja/zen.mdx @@ -93,8 +93,7 @@ OpenCode Zen は、OpenCode のほかのプロバイダーと同じように動 | Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Ling 2.6 Flash | ling-2.6-flash | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Hy3 Preview Free | hy3-preview-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Ring 2.6 1T | ring-2.6-1t-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Nemotron 3 Super Free | nemotron-3-super-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | OpenCode 設定で使う [model id](/docs/config/#models) は `opencode/` 形式です。たとえば、GPT 5.5 では設定に `opencode/gpt-5.5` を使用します。 @@ -119,8 +118,7 @@ https://opencode.ai/zen/v1/models | --------------------------------- | ------ | ------- | ----------- | ------------ | | Big Pickle | Free | Free | Free | - | | MiniMax M2.5 Free | Free | Free | Free | - | -| Ling 2.6 Flash Free | Free | Free | Free | - | -| Hy3 Preview Free | Free | Free | Free | - | +| Ring 2.6 1T Free | Free | Free | Free | - | | Nemotron 3 Super Free | Free | Free | Free | - | | MiniMax M2.7 | $0.30 | $1.20 | $0.06 | $0.375 | | MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 | @@ -172,8 +170,7 @@ https://opencode.ai/zen/v1/models 無料モデル: - MiniMax M2.5 Free は期間限定で OpenCode で利用できます。チームはこの期間中にフィードバックを集め、モデルを改善しています。 -- Ling 2.6 Flash Free は期間限定で OpenCode で利用できます。チームはこの期間中にフィードバックを集め、モデルを改善しています。 -- Hy3 Preview Flash Free は期間限定で OpenCode で利用できます。チームはこの期間中にフィードバックを集め、モデルを改善しています。 +- Ring 2.6 1T Free は期間限定で OpenCode で利用できます。チームはこの期間中にフィードバックを集め、モデルを改善しています。 - Nemotron 3 Super Free は期間限定で OpenCode で利用できます。チームはこの期間中にフィードバックを集め、モデルを改善しています。 - Big Pickle はステルスモデルで、期間限定で OpenCode で無料提供されています。チームはこの期間中にフィードバックを集め、モデルを改善しています。 @@ -225,8 +222,7 @@ https://opencode.ai/zen/v1/models - Big Pickle: 無料提供期間中、収集されたデータがモデル改善に使われる場合があります。 - MiniMax M2.5 Free: 無料提供期間中、収集されたデータがモデル改善に使われる場合があります。 -- Ling 2.6 Flash Free: 無料提供期間中、収集されたデータがモデル改善に使われる場合があります。 -- Hy3 Preview Free: 無料提供期間中、収集されたデータがモデル改善に使われる場合があります。 +- Ring 2.6 1T Free: 無料提供期間中、収集されたデータがモデル改善に使われる場合があります。 - Nemotron 3 Super Free(NVIDIA の無料エンドポイント): [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf) に基づいて提供されます。試用専用であり、本番環境や機密性の高いデータには使用しないでください。プロンプトと出力は、NVIDIA が自社のモデルとサービスを改善するために記録します。個人情報や機密データは送信しないでください。 - OpenAI APIs: リクエストは [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data) に従って 30 日間保持されます。 - Anthropic APIs: リクエストは [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage) に従って 30 日間保持されます。 diff --git a/packages/web/src/content/docs/keybinds.mdx b/packages/web/src/content/docs/keybinds.mdx index 599945428e..f083bb40b0 100644 --- a/packages/web/src/content/docs/keybinds.mdx +++ b/packages/web/src/content/docs/keybinds.mdx @@ -1,100 +1,218 @@ --- title: Keybinds -description: Customize your keyboard shortcuts. +description: Customize your keybinds. --- -OpenCode customizes TUI keyboard shortcuts with `keymap` in `tui.json`. - -The older `keybinds` field is still accepted as a migration fallback, but it is deprecated and will be removed in OpenCode v2.0. If `keymap` is present, OpenCode ignores `keybinds` for shortcut resolution. - -`keymap` is merged with built-in defaults, so you only need to configure the shortcuts you want to change. - ---- - -## Leader key - -OpenCode uses a `leader` key for many shortcuts. This avoids conflicts in your terminal. - -By default, `ctrl+x` is the leader key and leader shortcuts require you to first press the leader key and then the shortcut. For example, to start a new session you first press `ctrl+x` and then press `n`. - -You do not need to use a leader key, but we recommend doing so. - ---- - -## Minimal example +OpenCode has a list of keybinds that you can customize through `tui.json`. ```json title="tui.json" { "$schema": "https://opencode.ai/tui.json", - "keymap": { + "leader_timeout": 2000, + "keybinds": { "leader": "ctrl+x", - "leader_timeout": 2000, - "sections": { - "global": { - "command.palette.show": "ctrl+p", - "session.new": "n", - "session.list": "l" - }, - "session": { - "session.compact": "c", - "session.undo": "u", - "session.redo": "r" - }, - "input": { - "input.submit": "return", - "input.newline": ["shift+return", "ctrl+return", "alt+return", "ctrl+j"] - } - } + "app_exit": "ctrl+c,ctrl+d,q", + "app_debug": "none", + "app_console": "none", + "app_heap_snapshot": "none", + "app_toggle_animations": "none", + "app_toggle_file_context": "none", + "app_toggle_diffwrap": "none", + "app_toggle_paste_summary": "none", + "app_toggle_session_directory_filter": "none", + "command_list": "ctrl+p", + "help_show": "none", + "docs_open": "none", + + "editor_open": "e", + "theme_list": "t", + "theme_switch_mode": "none", + "theme_mode_lock": "none", + "sidebar_toggle": "b", + "scrollbar_toggle": "none", + "status_view": "s", + + "session_export": "x", + "session_copy": "none", + "session_new": "n", + "session_list": "l", + "session_timeline": "g", + "session_fork": "none", + "session_rename": "ctrl+r", + "session_delete": "ctrl+d", + "session_share": "none", + "session_unshare": "none", + "session_interrupt": "escape", + "session_compact": "c", + "session_toggle_timestamps": "none", + "session_toggle_generic_tool_output": "none", + "session_child_first": "down", + "session_child_cycle": "right", + "session_child_cycle_reverse": "left", + "session_parent": "up", + + "stash_delete": "ctrl+d", + "model_provider_list": "ctrl+a", + "model_favorite_toggle": "ctrl+f", + "model_list": "m", + "model_cycle_recent": "f2", + "model_cycle_recent_reverse": "shift+f2", + "model_cycle_favorite": "none", + "model_cycle_favorite_reverse": "none", + "mcp_list": "none", + "provider_connect": "none", + "console_org_switch": "none", + "agent_list": "a", + "agent_cycle": "tab", + "agent_cycle_reverse": "shift+tab", + "variant_cycle": "ctrl+t", + "variant_list": "none", + + "messages_page_up": "pageup,ctrl+alt+b", + "messages_page_down": "pagedown,ctrl+alt+f", + "messages_line_up": "ctrl+alt+y", + "messages_line_down": "ctrl+alt+e", + "messages_half_page_up": "ctrl+alt+u", + "messages_half_page_down": "ctrl+alt+d", + "messages_first": "ctrl+g,home", + "messages_last": "ctrl+alt+g,end", + "messages_next": "none", + "messages_previous": "none", + "messages_last_user": "none", + "messages_copy": "y", + "messages_undo": "u", + "messages_redo": "r", + "messages_toggle_conceal": "h", + "tool_details": "none", + "display_thinking": "none", + + "prompt_submit": "none", + "prompt_editor_context_clear": "none", + "prompt_skills": "none", + "prompt_stash": "none", + "prompt_stash_pop": "none", + "prompt_stash_list": "none", + "workspace_set": "none", + + "input_clear": "ctrl+c", + "input_paste": { + "key": "ctrl+v", + "preventDefault": false + }, + "input_submit": "return", + "input_newline": "shift+return,ctrl+return,alt+return,ctrl+j", + "input_move_left": "left,ctrl+b", + "input_move_right": "right,ctrl+f", + "input_move_up": "up", + "input_move_down": "down", + "input_select_left": "shift+left", + "input_select_right": "shift+right", + "input_select_up": "shift+up", + "input_select_down": "shift+down", + "input_line_home": "ctrl+a", + "input_line_end": "ctrl+e", + "input_select_line_home": "ctrl+shift+a", + "input_select_line_end": "ctrl+shift+e", + "input_visual_line_home": "alt+a", + "input_visual_line_end": "alt+e", + "input_select_visual_line_home": "alt+shift+a", + "input_select_visual_line_end": "alt+shift+e", + "input_buffer_home": "home", + "input_buffer_end": "end", + "input_select_buffer_home": "shift+home", + "input_select_buffer_end": "shift+end", + "input_delete_line": "ctrl+shift+d", + "input_delete_to_line_end": "ctrl+k", + "input_delete_to_line_start": "ctrl+u", + "input_backspace": "backspace,shift+backspace", + "input_delete": "ctrl+d,delete,shift+delete", + "input_undo": "ctrl+-,super+z", + "input_redo": "ctrl+.,super+shift+z", + "input_word_forward": "alt+f,alt+right,ctrl+right", + "input_word_backward": "alt+b,alt+left,ctrl+left", + "input_select_word_forward": "alt+shift+f,alt+shift+right", + "input_select_word_backward": "alt+shift+b,alt+shift+left", + "input_delete_word_forward": "alt+d,alt+delete,ctrl+delete", + "input_delete_word_backward": "ctrl+w,ctrl+backspace,alt+backspace", + "input_select_all": "super+a", + "history_previous": "up", + "history_next": "down", + + "dialog.select.prev": "up,ctrl+p", + "dialog.select.next": "down,ctrl+n", + "dialog.select.page_up": "pageup", + "dialog.select.page_down": "pagedown", + "dialog.select.home": "home", + "dialog.select.end": "end", + "dialog.select.submit": "return", + "dialog.mcp.toggle": "space", + "prompt.autocomplete.prev": "up,ctrl+p", + "prompt.autocomplete.next": "down,ctrl+n", + "prompt.autocomplete.hide": "escape", + "prompt.autocomplete.select": "return", + "prompt.autocomplete.complete": "tab", + "permission.prompt.fullscreen": "ctrl+f", + "plugins.toggle": "space", + "dialog.plugins.install": "shift+i", + + "terminal_suspend": "ctrl+z", + "terminal_title_toggle": "none", + "tips_toggle": "h", + "plugin_manager": "none", + "plugin_install": "none", + + "which_key_toggle": "ctrl+alt+k", + "which_key_layout_toggle": "ctrl+alt+shift+k", + "which_key_pending_toggle": "ctrl+alt+shift+p", + "which_key_group_previous": "ctrl+alt+left,ctrl+alt+[", + "which_key_group_next": "ctrl+alt+right,ctrl+alt+]", + "which_key_scroll_up": "ctrl+alt+up,ctrl+alt+p", + "which_key_scroll_down": "ctrl+alt+down,ctrl+alt+n", + "which_key_page_up": "ctrl+alt+pageup", + "which_key_page_down": "ctrl+alt+pagedown", + "which_key_home": "ctrl+alt+home", + "which_key_end": "ctrl+alt+end" } } ``` ---- +:::note +On Windows, the defaults for `input_undo` and `terminal_suspend` are different: -## Keymap structure - -`keymap.sections` is grouped by semantic area. Each section contains command names and the key sequence that triggers them. - -| Field | Description | -| ---------------- | --------------------------------------------------------------------------------------------------- | -| `leader` | The key used by `` sequences. Defaults to `ctrl+x`. | -| `leader_timeout` | How long OpenCode waits for the next key after the leader key, in milliseconds. Defaults to `2000`. | -| `sections` | A map of TUI areas to command bindings. | +- `input_undo` defaults to `ctrl+z,ctrl+-,super+z` when it is not explicitly configured. The `ctrl+z` binding is added because Windows terminals do not support POSIX suspend. +- `terminal_suspend` is forced to `none` because native Windows terminals do not support POSIX suspend. + ::: --- -## Binding values +## Leader Key -A string can contain one shortcut or multiple comma-separated shortcuts. You can also use an array for multiple shortcuts, or `"none"`/`false` to disable a command. +OpenCode uses a `leader` key for many keybinds. This avoids conflicts in your terminal. -```json title="tui.json" -{ - "$schema": "https://opencode.ai/tui.json", - "keymap": { - "sections": { - "session": { - "session.compact": "none", - "session.export": "x,ctrl+shift+x", - "session.copy": ["y", "ctrl+shift+c"] - } - } - } -} -``` +By default, `ctrl+x` is the leader key and many actions require you to first press the leader key and then the shortcut. For example, to start a new session you first press `ctrl+x` and then press `n`. + +You don't need to use a leader key for your keybinds but we recommend doing so. + +Some navigation keybinds intentionally do not use the leader key by default. For subagent sessions, the defaults are `session_child_first` = `down`, `session_child_cycle` = `right`, `session_child_cycle_reverse` = `left`, and `session_parent` = `up`. + +`leader_timeout` controls how long OpenCode waits for the next key after the leader key. It defaults to `2000` milliseconds. + +--- + +## Binding Values + +A string can contain one shortcut or multiple comma-separated shortcuts. You can also use an array for multiple shortcuts. For advanced cases, use an object with `key`, `event`, `preventDefault`, or `fallthrough`. ```json title="tui.json" { "$schema": "https://opencode.ai/tui.json", - "keymap": { - "sections": { - "prompt": { - "prompt.paste": { - "key": "ctrl+v", - "preventDefault": false - } - } + "keybinds": { + "messages_copy": ["y", "ctrl+shift+c"], + "input_paste": { + "key": "ctrl+v", + "preventDefault": false } } } @@ -102,219 +220,22 @@ For advanced cases, use an object with `key`, `event`, `preventDefault`, or `fal --- -## Complete keymap reference +## Disable Keybind -This example lists the built-in sections, command names, and default fallback bindings. Commands set to `"none"` are available to bind but disabled by default. - -```json title="tui.json" -{ - "$schema": "https://opencode.ai/tui.json", - "keymap": { - "leader": "ctrl+x", - "leader_timeout": 2000, - "sections": { - "global": { - "command.palette.show": "ctrl+p", - "session.list": "l", - "session.new": "n", - "model.list": "m", - "model.cycle_recent": "f2", - "model.cycle_recent_reverse": "shift+f2", - "model.cycle_favorite": "none", - "model.cycle_favorite_reverse": "none", - "agent.list": "a", - "mcp.list": "none", - "agent.cycle": "tab", - "agent.cycle.reverse": "shift+tab", - "variant.cycle": "ctrl+t", - "variant.list": "none", - "provider.connect": "none", - "console.org.switch": "none", - "opencode.status": "s", - "theme.switch": "t", - "theme.switch_mode": "none", - "theme.mode.lock": "none", - "help.show": "none", - "docs.open": "none", - "app.exit": "ctrl+c,ctrl+d,q", - "app.debug": "none", - "app.console": "none", - "app.heap_snapshot": "none", - "app.toggle.animations": "none", - "app.toggle.file_context": "none", - "app.toggle.diffwrap": "none", - "app.toggle.paste_summary": "none", - "app.toggle.session_directory_filter": "none", - "terminal.suspend": "ctrl+z", - "terminal.title.toggle": "none" - }, - "session": { - "session.share": "none", - "session.rename": "ctrl+r", - "session.timeline": "g", - "session.fork": "none", - "session.compact": "c", - "session.unshare": "none", - "session.undo": "u", - "session.redo": "r", - "session.sidebar.toggle": "b", - "session.toggle.conceal": "h", - "session.toggle.timestamps": "none", - "session.toggle.thinking": "none", - "session.toggle.actions": "none", - "session.toggle.scrollbar": "none", - "session.toggle.generic_tool_output": "none", - "session.page.up": "pageup,ctrl+alt+b", - "session.page.down": "pagedown,ctrl+alt+f", - "session.line.up": "ctrl+alt+y", - "session.line.down": "ctrl+alt+e", - "session.half.page.up": "ctrl+alt+u", - "session.half.page.down": "ctrl+alt+d", - "session.first": "ctrl+g,home", - "session.last": "ctrl+alt+g,end", - "session.messages_last_user": "none", - "session.message.next": "none", - "session.message.previous": "none", - "messages.copy": "y", - "session.copy": "none", - "session.export": "x", - "session.child.first": "down", - "session.parent": "up", - "session.child.next": "right", - "session.child.previous": "left" - }, - "prompt": { - "prompt.submit": "none", - "prompt.editor": "e", - "prompt.editor_context.clear": "none", - "prompt.skills": "none", - "prompt.stash": "none", - "prompt.stash.pop": "none", - "prompt.stash.list": "none", - "workspace.set": "none", - "session.interrupt": "escape", - "prompt.clear": "ctrl+c", - "prompt.paste": { - "key": "ctrl+v", - "preventDefault": false - }, - "prompt.history.previous": "up", - "prompt.history.next": "down" - }, - "autocomplete": { - "prompt.autocomplete.prev": "up,ctrl+p", - "prompt.autocomplete.next": "down,ctrl+n", - "prompt.autocomplete.hide": "escape", - "prompt.autocomplete.select": "return", - "prompt.autocomplete.complete": "tab" - }, - "input": { - "input.submit": "return", - "input.newline": "shift+return,ctrl+return,alt+return,ctrl+j", - "input.move.left": "left,ctrl+b", - "input.move.right": "right,ctrl+f", - "input.move.up": "up", - "input.move.down": "down", - "input.select.left": "shift+left", - "input.select.right": "shift+right", - "input.select.up": "shift+up", - "input.select.down": "shift+down", - "input.line.home": "ctrl+a", - "input.line.end": "ctrl+e", - "input.select.line.home": "ctrl+shift+a", - "input.select.line.end": "ctrl+shift+e", - "input.visual.line.home": "alt+a", - "input.visual.line.end": "alt+e", - "input.select.visual.line.home": "alt+shift+a", - "input.select.visual.line.end": "alt+shift+e", - "input.buffer.home": "home", - "input.buffer.end": "end", - "input.select.buffer.home": "shift+home", - "input.select.buffer.end": "shift+end", - "input.delete.line": "ctrl+shift+d", - "input.delete.to.line.end": "ctrl+k", - "input.delete.to.line.start": "ctrl+u", - "input.backspace": "backspace,shift+backspace", - "input.delete": "ctrl+d,delete,shift+delete", - "input.undo": "ctrl+-,super+z", - "input.redo": "ctrl+.,super+shift+z", - "input.word.forward": "alt+f,alt+right,ctrl+right", - "input.word.backward": "alt+b,alt+left,ctrl+left", - "input.select.word.forward": "alt+shift+f,alt+shift+right", - "input.select.word.backward": "alt+shift+b,alt+shift+left", - "input.delete.word.forward": "alt+d,alt+delete,ctrl+delete", - "input.delete.word.backward": "ctrl+w,ctrl+backspace,alt+backspace", - "input.select.all": "super+a" - }, - "dialog_select": { - "dialog.select.prev": "up,ctrl+p", - "dialog.select.next": "down,ctrl+n", - "dialog.select.page_up": "pageup", - "dialog.select.page_down": "pagedown", - "dialog.select.home": "home", - "dialog.select.end": "end", - "dialog.select.submit": "return" - }, - "dialog_actions": { - "dialog.action.toggle": "space", - "dialog.action.delete": "ctrl+d", - "dialog.action.rename": "ctrl+r" - }, - "model": { - "model.dialog.provider": "ctrl+a", - "model.dialog.favorite": "ctrl+f" - }, - "permission": { - "permission.reject.cancel": "ctrl+c,ctrl+d,q", - "permission.prompt.escape": "ctrl+c,ctrl+d,q", - "permission.prompt.fullscreen": "ctrl+f" - }, - "question": { - "question.reject": "ctrl+c,ctrl+d,q", - "question.edit.clear": "ctrl+c" - }, - "plugins": { - "plugins.list": "none", - "plugins.install": "none", - "plugin.dialog.install": "shift+i" - }, - "home_tips": { - "tips.toggle": "h" - } - } - } -} -``` - ---- - -## Legacy keybinds - -`keybinds` is deprecated. It is kept so existing configs continue to work while users migrate to `keymap`. - -Only use `keybinds` when `keymap` is not present. If both fields are set, `keymap` wins and `keybinds` are ignored for shortcut resolution. +You can disable a keybind by adding the key to `tui.json` with a value of `"none"` or `false`. ```json title="tui.json" { "$schema": "https://opencode.ai/tui.json", "keybinds": { - "command_list": "ctrl+p", - "session_new": "n", - "session_compact": "c" + "session_compact": "none" } } ``` -:::note -On native Windows, the defaults for undo and terminal suspend are different for both `keymap` and legacy `keybinds`: - -- `input.undo` defaults to `ctrl+z,ctrl+-,super+z` when it is not explicitly configured (the `ctrl+z` binding is added because Windows terminals do not support POSIX suspend). -- `terminal.suspend` is disabled because native Windows terminals do not support POSIX suspend. - ::: - --- -## Desktop prompt shortcuts +## Desktop Prompt Shortcuts The OpenCode desktop app prompt input supports common Readline/Emacs-style shortcuts for editing text. These are built-in and currently not configurable via `opencode.json`. diff --git a/packages/web/src/content/docs/ko/agents.mdx b/packages/web/src/content/docs/ko/agents.mdx index 34de6250d1..02f31c5b62 100644 --- a/packages/web/src/content/docs/ko/agents.mdx +++ b/packages/web/src/content/docs/ko/agents.mdx @@ -35,13 +35,13 @@ OpenCode에는 기본 제공 primary agent인 **Build**와 **Plan**이 포함되 subagent는 primary agent가 특정 작업을 위해 호출하는 전문 assistant입니다. 메시지에서 **@ mention**으로 직접 호출할 수도 있습니다. -OpenCode에는 기본 제공 subagent인 **General**과 **Explore**가 포함되어 있습니다. 아래에서 살펴보겠습니다. +OpenCode에는 기본 제공 subagent인 **General**, **Explore**, **Scout**가 포함되어 있습니다. 아래에서 살펴보겠습니다. --- ## 기본 제공 -OpenCode는 기본적으로 primary agent 2개와 subagent 2개를 제공합니다. +OpenCode는 기본적으로 primary agent 2개와 subagent 3개를 제공합니다. --- @@ -83,6 +83,14 @@ _Mode_: `subagent` --- +### Use Scout + +_Mode_: `subagent` + +외부 docs와 dependency 리서치를 위한 읽기 전용 agent입니다. dependency repository를 OpenCode의 관리형 cache에 clone하거나, 라이브러리 소스를 살펴보거나, workspace를 수정하지 않고 로컬 코드를 upstream 구현과 교차 확인해야 할 때 사용하세요. + +--- + ### Use compaction _Mode_: `primary` diff --git a/packages/web/src/content/docs/ko/zen.mdx b/packages/web/src/content/docs/ko/zen.mdx index e80a5e8710..513093a2c6 100644 --- a/packages/web/src/content/docs/ko/zen.mdx +++ b/packages/web/src/content/docs/ko/zen.mdx @@ -93,8 +93,7 @@ OpenCode Zen은 OpenCode의 다른 provider와 똑같이 작동합니다. | Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Ling 2.6 Flash | ling-2.6-flash | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Hy3 Preview Free | hy3-preview-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Ring 2.6 1T | ring-2.6-1t-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Nemotron 3 Super Free | nemotron-3-super-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | OpenCode config에서 사용하는 [모델 ID](/docs/config/#models)는 `opencode/` 형식입니다. 예를 들어 GPT 5.5를 사용하려면 config에서 `opencode/gpt-5.5`를 사용하면 됩니다. @@ -119,8 +118,7 @@ https://opencode.ai/zen/v1/models | --------------------------------- | ------ | ------- | ----------- | ------------ | | Big Pickle | Free | Free | Free | - | | MiniMax M2.5 Free | Free | Free | Free | - | -| Ling 2.6 Flash Free | Free | Free | Free | - | -| Hy3 Preview Free | Free | Free | Free | - | +| Ring 2.6 1T Free | Free | Free | Free | - | | Nemotron 3 Super Free | Free | Free | Free | - | | MiniMax M2.7 | $0.30 | $1.20 | $0.06 | $0.375 | | MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 | @@ -172,8 +170,7 @@ https://opencode.ai/zen/v1/models 무료 모델: - MiniMax M2.5 Free는 한정된 기간 동안 OpenCode에서 제공됩니다. 팀은 이 기간에 피드백을 수집하고 모델을 개선합니다. -- Ling 2.6 Flash Free는 한정된 기간 동안 OpenCode에서 제공됩니다. 팀은 이 기간에 피드백을 수집하고 모델을 개선합니다. -- Hy3 Preview Flash Free는 한정된 기간 동안 OpenCode에서 제공됩니다. 팀은 이 기간에 피드백을 수집하고 모델을 개선합니다. +- Ring 2.6 1T Free는 한정된 기간 동안 OpenCode에서 제공됩니다. 팀은 이 기간에 피드백을 수집하고 모델을 개선합니다. - Nemotron 3 Super Free는 한정된 기간 동안 OpenCode에서 제공됩니다. 팀은 이 기간에 피드백을 수집하고 모델을 개선합니다. - Big Pickle은 한정된 기간 동안 OpenCode에서 무료로 제공되는 stealth model입니다. 팀은 이 기간에 피드백을 수집하고 모델을 개선합니다. @@ -225,8 +222,7 @@ https://opencode.ai/zen/v1/models - Big Pickle: 무료 제공 기간에는 수집된 데이터가 모델 개선에 사용될 수 있습니다. - MiniMax M2.5 Free: 무료 제공 기간에는 수집된 데이터가 모델 개선에 사용될 수 있습니다. -- Ling 2.6 Flash Free: 무료 제공 기간에는 수집된 데이터가 모델 개선에 사용될 수 있습니다. -- Hy3 Preview Free: 무료 제공 기간에는 수집된 데이터가 모델 개선에 사용될 수 있습니다. +- Ring 2.6 1T Free: 무료 제공 기간에는 수집된 데이터가 모델 개선에 사용될 수 있습니다. - Nemotron 3 Super Free(NVIDIA 무료 엔드포인트): [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf)에 따라 제공됩니다. 평가판 전용이며 프로덕션 환경이나 민감한 데이터에는 사용할 수 없습니다. NVIDIA는 자사 모델과 서비스를 개선하기 위해 프롬프트와 출력을 기록합니다. 개인 정보나 기밀 데이터는 제출하지 마세요. - OpenAI APIs: 요청은 [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data)에 따라 30일 동안 보관됩니다. - Anthropic APIs: 요청은 [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage)에 따라 30일 동안 보관됩니다. diff --git a/packages/web/src/content/docs/nb/agents.mdx b/packages/web/src/content/docs/nb/agents.mdx index d7831e3387..f9971758d5 100644 --- a/packages/web/src/content/docs/nb/agents.mdx +++ b/packages/web/src/content/docs/nb/agents.mdx @@ -35,13 +35,13 @@ OpenCode kommer med to innebygde primære agenter, **Build** og **Plan**. Vi ser Underagenter er spesialiserte assistenter som primære agenter kan påkalle for spesifikke oppgaver. Du kan også starte dem manuelt ved å **@ nevne** dem i meldingene dine. -OpenCode kommer med to innebygde underagenter, **General** og **Explore**. Vi skal se på dette nedenfor. +OpenCode kommer med tre innebygde underagenter, **General**, **Explore** og **Scout**. Vi skal se på dette nedenfor. --- ## Innebygd -OpenCode kommer med to innebygde primære agenter og to innebygde underagenter. +OpenCode kommer med to innebygde primære agenter og tre innebygde underagenter. --- @@ -83,6 +83,14 @@ En rask, skrivebeskyttet agent for å utforske kodebaser. Kan ikke endre filer. --- +### Bruk av Scout + +_Modus_: `subagent` + +En skrivebeskyttet agent for ekstern dokumentasjon og forskning på avhengigheter. Bruk denne når du trenger å klone et avhengighetsrepo inn i OpenCode sin administrerte cache, inspisere kildekoden til et bibliotek eller kryssjekke lokal kode mot upstream-implementasjoner uten å endre arbeidsområdet ditt. + +--- + ### Bruk av Compaction _Modus_: `primary` diff --git a/packages/web/src/content/docs/nb/zen.mdx b/packages/web/src/content/docs/nb/zen.mdx index 4bd1e6115e..c5726f4b4b 100644 --- a/packages/web/src/content/docs/nb/zen.mdx +++ b/packages/web/src/content/docs/nb/zen.mdx @@ -102,8 +102,7 @@ Du kan også få tilgang til modellene våre gjennom følgende API-endepunkter. | Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Ling 2.6 Flash | ling-2.6-flash | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Hy3 Preview Free | hy3-preview-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Ring 2.6 1T | ring-2.6-1t-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Nemotron 3 Super Free | nemotron-3-super-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | [modell-id](/docs/config/#models) i OpenCode-konfigurasjonen din @@ -130,8 +129,7 @@ Vi støtter en pay-as-you-go-modell. Nedenfor er prisene **per 1M tokens**. | --------------------------------- | ------- | ------- | ------------- | --------------- | | Big Pickle | Free | Free | Free | - | | MiniMax M2.5 Free | Free | Free | Free | - | -| Ling 2.6 Flash Free | Free | Free | Free | - | -| Hy3 Preview Free | Free | Free | Free | - | +| Ring 2.6 1T Free | Free | Free | Free | - | | Nemotron 3 Super Free | Free | Free | Free | - | | MiniMax M2.7 | $0.30 | $1.20 | $0.06 | $0.375 | | MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 | @@ -183,8 +181,7 @@ Kredittkortgebyrer videreføres til kostpris (4.4% + $0.30 per transaction); vi Gratis-modellene: - MiniMax M2.5 Free er tilgjengelig på OpenCode i en begrenset periode. Teamet bruker denne tiden til å samle inn tilbakemeldinger og forbedre modellen. -- Ling 2.6 Flash Free er tilgjengelig på OpenCode i en begrenset periode. Teamet bruker denne tiden til å samle inn tilbakemeldinger og forbedre modellen. -- Hy3 Preview Free er tilgjengelig på OpenCode i en begrenset periode. Teamet bruker denne tiden til å samle inn tilbakemeldinger og forbedre modellen. +- Ring 2.6 1T Free er tilgjengelig på OpenCode i en begrenset periode. Teamet bruker denne tiden til å samle inn tilbakemeldinger og forbedre modellen. - Nemotron 3 Super Free er tilgjengelig på OpenCode i en begrenset periode. Teamet bruker denne tiden til å samle inn tilbakemeldinger og forbedre modellen. - Big Pickle er en stealth-modell som er gratis på OpenCode i en begrenset periode. Teamet bruker denne tiden til å samle inn tilbakemeldinger og forbedre modellen. @@ -239,8 +236,7 @@ Alle modellene våre hostes i US. Leverandørene våre følger en policy for zer - Big Pickle: I gratisperioden kan innsamlede data brukes til å forbedre modellen. - MiniMax M2.5 Free: I gratisperioden kan innsamlede data brukes til å forbedre modellen. -- Ling 2.6 Flash Free: I gratisperioden kan innsamlede data brukes til å forbedre modellen. -- Hy3 Preview Free: I gratisperioden kan innsamlede data brukes til å forbedre modellen. +- Ring 2.6 1T Free: I gratisperioden kan innsamlede data brukes til å forbedre modellen. - Nemotron 3 Super Free (gratis NVIDIA-endepunkter): Leveres under [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Kun for prøvebruk, ikke for produksjon eller sensitive data. Prompter og svar logges av NVIDIA for å forbedre modellene og tjenestene deres. Ikke send inn personopplysninger eller konfidensielle data. - OpenAI APIs: Forespørsler lagres i 30 dager i samsvar med [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data). - Anthropic APIs: Forespørsler lagres i 30 dager i samsvar med [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage). diff --git a/packages/web/src/content/docs/pl/agents.mdx b/packages/web/src/content/docs/pl/agents.mdx index 7a4d7a9960..8cf9561e16 100644 --- a/packages/web/src/content/docs/pl/agents.mdx +++ b/packages/web/src/content/docs/pl/agents.mdx @@ -35,13 +35,13 @@ OpenCode zawiera dwa wbudowane agenty główne: **Build** i **Plan**. Przyjrzymy Subagenci to asystenci pomocniczy, których mogą przywoływać agenci główni w celu wykonania konkretnych zadań. Możesz także wywoływać ich ręcznie, **wzmiankując ich (@)** w swoich wiadomościach. -OpenCode ma dwóch wbudowanych subagentów: **General** i **Explore**. Przyjrzymy się im poniżej. +OpenCode ma trzech wbudowanych subagentów: **General**, **Explore** i **Scout**. Przyjrzymy się im poniżej. --- ## Wbudowane -OpenCode ma dwa wbudowane agenty główne i dwa wbudowane subagenty. +OpenCode ma dwa wbudowane agenty główne i trzech wbudowanych subagentów. --- @@ -83,6 +83,14 @@ Szybki agent tylko do odczytu do eksploracji baz kodu. Nie może modyfikować pl --- +### Scout + +_Mode_: `subagent` + +Agent tylko do odczytu do pracy z zewnętrzną dokumentacją i badaniem zależności. Używaj go, gdy chcesz sklonować repozytorium zależności do zarządzanej pamięci podręcznej OpenCode, przejrzeć kod źródłowy biblioteki albo porównać lokalny kod z implementacjami upstream bez modyfikowania swojego workspace. + +--- + ### Compaction _Mode_: `primary` diff --git a/packages/web/src/content/docs/pl/zen.mdx b/packages/web/src/content/docs/pl/zen.mdx index ebd16d7856..f22d10b3ad 100644 --- a/packages/web/src/content/docs/pl/zen.mdx +++ b/packages/web/src/content/docs/pl/zen.mdx @@ -102,8 +102,7 @@ Możesz też uzyskać dostęp do naszych modeli przez poniższe endpointy API. | Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Ling 2.6 Flash | ling-2.6-flash | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Hy3 Preview Free | hy3-preview-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Ring 2.6 1T | ring-2.6-1t-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Nemotron 3 Super Free | nemotron-3-super-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | [ID modelu](/docs/config/#models) w Twojej konfiguracji OpenCode używa formatu @@ -130,8 +129,7 @@ Obsługujemy model pay-as-you-go. Poniżej znajdują się ceny **za 1M tokenów* | --------------------------------- | ------- | ------- | -------------- | -------------- | | Big Pickle | Free | Free | Free | - | | MiniMax M2.5 Free | Free | Free | Free | - | -| Ling 2.6 Flash Free | Free | Free | Free | - | -| Hy3 Preview Free | Free | Free | Free | - | +| Ring 2.6 1T Free | Free | Free | Free | - | | Nemotron 3 Super Free | Free | Free | Free | - | | MiniMax M2.7 | $0.30 | $1.20 | $0.06 | $0.375 | | MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 | @@ -184,8 +182,7 @@ Opłaty za karty kredytowe są przenoszone po kosztach (4.4% + $0.30 per transac Darmowe modele: - MiniMax M2.5 Free jest dostępny w OpenCode przez ograniczony czas. Zespół wykorzystuje ten czas do zbierania opinii i ulepszania modelu. -- Ling 2.6 Flash Free jest dostępny w OpenCode przez ograniczony czas. Zespół wykorzystuje ten czas do zbierania opinii i ulepszania modelu. -- Hy3 Preview Flash Free jest dostępny w OpenCode przez ograniczony czas. Zespół wykorzystuje ten czas do zbierania opinii i ulepszania modelu. +- Ring 2.6 1T Free jest dostępny w OpenCode przez ograniczony czas. Zespół wykorzystuje ten czas do zbierania opinii i ulepszania modelu. - Nemotron 3 Super Free jest dostępny w OpenCode przez ograniczony czas. Zespół wykorzystuje ten czas do zbierania opinii i ulepszania modelu. - Big Pickle to stealth model, który jest darmowy w OpenCode przez ograniczony czas. Zespół wykorzystuje ten czas do zbierania opinii i ulepszania modelu. @@ -240,8 +237,7 @@ Wszystkie nasze modele są hostowane w US. Nasi dostawcy stosują politykę zero - Big Pickle: W czasie darmowego okresu zebrane dane mogą być wykorzystywane do ulepszania modelu. - MiniMax M2.5 Free: W czasie darmowego okresu zebrane dane mogą być wykorzystywane do ulepszania modelu. -- Ling 2.6 Flash Free: W czasie darmowego okresu zebrane dane mogą być wykorzystywane do ulepszania modelu. -- Hy3 Preview Free: W czasie darmowego okresu zebrane dane mogą być wykorzystywane do ulepszania modelu. +- Ring 2.6 1T Free: W czasie darmowego okresu zebrane dane mogą być wykorzystywane do ulepszania modelu. - Nemotron 3 Super Free (darmowe endpointy NVIDIA): Udostępniany zgodnie z [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Tylko do użytku próbnego, nie do produkcji ani danych wrażliwych. NVIDIA rejestruje prompty i odpowiedzi, aby ulepszać swoje modele i usługi. Nie przesyłaj danych osobowych ani poufnych. - OpenAI APIs: Żądania są przechowywane przez 30 dni zgodnie z [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data). - Anthropic APIs: Żądania są przechowywane przez 30 dni zgodnie z [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage). diff --git a/packages/web/src/content/docs/pt-br/agents.mdx b/packages/web/src/content/docs/pt-br/agents.mdx index 9a831e8048..815264d840 100644 --- a/packages/web/src/content/docs/pt-br/agents.mdx +++ b/packages/web/src/content/docs/pt-br/agents.mdx @@ -36,13 +36,13 @@ ver isso abaixo. Subagentes são assistentes especializados que agentes primários podem invocar para tarefas específicas. Você também pode invocá-los manualmente mencionando-os com **@** em suas mensagens. -opencode vem com dois subagentes integrados, **General** e **Explore**. Vamos ver isso abaixo. +OpenCode vem com três subagentes integrados, **General**, **Explore** e **Scout**. Vamos ver isso abaixo. --- ## Integrados -opencode vem com dois agentes primários integrados e dois subagentes integrados. +OpenCode vem com dois agentes primários integrados e três subagentes integrados. --- @@ -84,6 +84,14 @@ Um agente rápido e somente leitura para explorar bases de código. Não pode mo --- +### Scout + +_Modo_: `subagent` + +Um agente somente leitura para pesquisa em documentação externa e dependências. Use-o quando você precisar clonar o repositório de uma dependência para o cache gerenciado do OpenCode, inspecionar o código-fonte de uma biblioteca ou cruzar o código local com implementações upstream sem modificar seu workspace. + +--- + ### compaction _Modo_: `primary` diff --git a/packages/web/src/content/docs/pt-br/zen.mdx b/packages/web/src/content/docs/pt-br/zen.mdx index 1dcc98c5d5..55678781aa 100644 --- a/packages/web/src/content/docs/pt-br/zen.mdx +++ b/packages/web/src/content/docs/pt-br/zen.mdx @@ -93,8 +93,7 @@ Você também pode acessar nossos modelos pelos seguintes endpoints de API. | Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Ling 2.6 Flash | ling-2.6-flash | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Hy3 Preview Free | hy3-preview-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Ring 2.6 1T | ring-2.6-1t-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Nemotron 3 Super Free | nemotron-3-super-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | O [model id](/docs/config/#models) na sua configuração do OpenCode usa o formato `opencode/`. Por exemplo, para GPT 5.5, você usaria `opencode/gpt-5.5` na sua configuração. @@ -119,8 +118,7 @@ Oferecemos um modelo pay-as-you-go. Abaixo estão os preços **por 1M tokens**. | --------------------------------- | ------- | ------- | ---------------- | ---------------- | | Big Pickle | Free | Free | Free | - | | MiniMax M2.5 Free | Free | Free | Free | - | -| Ling 2.6 Flash Free | Free | Free | Free | - | -| Hy3 Preview Free | Free | Free | Free | - | +| Ring 2.6 1T Free | Free | Free | Free | - | | Nemotron 3 Super Free | Free | Free | Free | - | | MiniMax M2.7 | $0.30 | $1.20 | $0.06 | $0.375 | | MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 | @@ -172,8 +170,7 @@ As taxas de cartão de crédito são repassadas a preço de custo (4.4% + $0.30 Os modelos gratuitos: - MiniMax M2.5 Free está disponível no OpenCode por tempo limitado. A equipe está usando esse período para coletar feedback e melhorar o modelo. -- Ling 2.6 Flash Free está disponível no OpenCode por tempo limitado. A equipe está usando esse período para coletar feedback e melhorar o modelo. -- Hy3 Preview Free está disponível no OpenCode por tempo limitado. A equipe está usando esse período para coletar feedback e melhorar o modelo. +- Ring 2.6 1T Free está disponível no OpenCode por tempo limitado. A equipe está usando esse período para coletar feedback e melhorar o modelo. - Nemotron 3 Super Free está disponível no OpenCode por tempo limitado. A equipe está usando esse período para coletar feedback e melhorar o modelo. - Big Pickle é um modelo stealth que está gratuito no OpenCode por tempo limitado. A equipe está usando esse período para coletar feedback e melhorar o modelo. @@ -225,8 +222,7 @@ Todos os nossos modelos são hospedados nos US. Nossos provedores seguem uma pol - Big Pickle: Durante seu período gratuito, os dados coletados podem ser usados para melhorar o modelo. - MiniMax M2.5 Free: Durante seu período gratuito, os dados coletados podem ser usados para melhorar o modelo. -- Ling 2.6 Flash Free: Durante seu período gratuito, os dados coletados podem ser usados para melhorar o modelo. -- Hy3 Preview Free: Durante seu período gratuito, os dados coletados podem ser usados para melhorar o modelo. +- Ring 2.6 1T Free: Durante seu período gratuito, os dados coletados podem ser usados para melhorar o modelo. - Nemotron 3 Super Free (endpoints gratuitos da NVIDIA): Fornecido sob os [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Apenas para uso de avaliação, não para produção nem dados sensíveis. A NVIDIA registra prompts e saídas para melhorar seus modelos e serviços. Não envie dados pessoais ou confidenciais. - OpenAI APIs: As solicitações são retidas por 30 dias de acordo com [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data). - Anthropic APIs: As solicitações são retidas por 30 dias de acordo com [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage). diff --git a/packages/web/src/content/docs/ru/agents.mdx b/packages/web/src/content/docs/ru/agents.mdx index f515c15d7b..767cbf862f 100644 --- a/packages/web/src/content/docs/ru/agents.mdx +++ b/packages/web/src/content/docs/ru/agents.mdx @@ -35,13 +35,13 @@ opencode поставляется с двумя встроенными осно Субагенты — это специализированные помощники, которых основные агенты могут вызывать для выполнения определенных задач. Вы также можете вызвать их вручную, **@ упомянув** их в своих сообщениях. -opencode поставляется с двумя встроенными субагентами: **General** и **Explore**. Мы рассмотрим это ниже. +OpenCode поставляется с тремя встроенными субагентами: **General**, **Explore** и **Scout**. Мы рассмотрим их ниже. --- ## Встроенные агенты -opencode поставляется с двумя встроенными основными агентами и двумя встроенными субагентами. +OpenCode поставляется с двумя встроенными основными агентами и тремя встроенными субагентами. --- @@ -83,6 +83,14 @@ _Режим_: `subagent` --- +### Использование Scout + +_Режим_: `subagent` + +Агент только для чтения для работы с внешней документацией и исследования зависимостей. Используйте его, когда нужно клонировать репозиторий зависимости в управляемый кэш OpenCode, изучить исходный код библиотеки или сверить локальный код с upstream-реализациями без изменений в рабочем пространстве. + +--- + ### Использование Compact _Режим_: `primary` diff --git a/packages/web/src/content/docs/ru/zen.mdx b/packages/web/src/content/docs/ru/zen.mdx index 10c55fc4dd..c685d2dacc 100644 --- a/packages/web/src/content/docs/ru/zen.mdx +++ b/packages/web/src/content/docs/ru/zen.mdx @@ -102,8 +102,7 @@ OpenCode Zen работает как любой другой провайдер | Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Ling 2.6 Flash | ling-2.6-flash | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Hy3 Preview Free | hy3-preview-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Ring 2.6 1T | ring-2.6-1t-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Nemotron 3 Super Free | nemotron-3-super-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | [идентификатор модели](/docs/config/#models) в вашей конфигурации OpenCode @@ -130,8 +129,7 @@ https://opencode.ai/zen/v1/models | --------------------------------- | ------ | ------- | ----------- | ------------ | | Big Pickle | Free | Free | Free | - | | MiniMax M2.5 Free | Free | Free | Free | - | -| Ling 2.6 Flash Free | Free | Free | Free | - | -| Hy3 Preview Free | Free | Free | Free | - | +| Ring 2.6 1T Free | Free | Free | Free | - | | Nemotron 3 Super Free | Free | Free | Free | - | | MiniMax M2.7 | $0.30 | $1.20 | $0.06 | $0.375 | | MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 | @@ -183,8 +181,7 @@ https://opencode.ai/zen/v1/models Бесплатные модели: - MiniMax M2.5 Free доступна в OpenCode ограниченное время. Команда использует это время, чтобы собирать отзывы и улучшать модель. -- Ling 2.6 Flash Free доступна в OpenCode ограниченное время. Команда использует это время, чтобы собирать отзывы и улучшать модель. -- Hy3 Preview Flash Free доступна в OpenCode ограниченное время. Команда использует это время, чтобы собирать отзывы и улучшать модель. +- Ring 2.6 1T Free доступна в OpenCode ограниченное время. Команда использует это время, чтобы собирать отзывы и улучшать модель. - Nemotron 3 Super Free доступна в OpenCode ограниченное время. Команда использует это время, чтобы собирать отзывы и улучшать модель. - Big Pickle — это скрытая модель, которая доступна бесплатно в OpenCode ограниченное время. Команда использует это время, чтобы собирать отзывы и улучшать модель. @@ -239,8 +236,7 @@ https://opencode.ai/zen/v1/models - Big Pickle: во время бесплатного периода собранные данные могут использоваться для улучшения модели. - MiniMax M2.5 Free: во время бесплатного периода собранные данные могут использоваться для улучшения модели. -- Ling 2.6 Flash Free: во время бесплатного периода собранные данные могут использоваться для улучшения модели. -- Hy3 Preview Free: во время бесплатного периода собранные данные могут использоваться для улучшения модели. +- Ring 2.6 1T Free: во время бесплатного периода собранные данные могут использоваться для улучшения модели. - Nemotron 3 Super Free (бесплатные эндпоинты NVIDIA): предоставляется в соответствии с [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Только для пробного использования, не для продакшена и не для чувствительных данных. NVIDIA логирует запросы и ответы, чтобы улучшать свои модели и сервисы. Не отправляйте персональные или конфиденциальные данные. - OpenAI APIs: запросы хранятся 30 дней в соответствии с [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data). - Anthropic APIs: запросы хранятся 30 дней в соответствии с [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage). diff --git a/packages/web/src/content/docs/th/agents.mdx b/packages/web/src/content/docs/th/agents.mdx index 567125aced..e37df6ce47 100644 --- a/packages/web/src/content/docs/th/agents.mdx +++ b/packages/web/src/content/docs/th/agents.mdx @@ -36,13 +36,13 @@ OpenCode มีเอเจนต์หลักในตัวได้แก Subagent คือผู้ช่วยเฉพาะทางที่ Primary Agent สามารถเรียกใช้งานได้ หรือคุณสามารถเรียกใช้โดยตรงโดยพิมพ์ **@** ตามด้วยชื่อเอเจนต์ในข้อความของคุณ -OpenCode มี subagent ในตัวได้แก่ **General** และ **Explore** +OpenCode มี subagent ในตัวได้แก่ **General**, **Explore** และ **Scout** ดูรายละเอียดด้านล่าง --- ## บิวท์อิน -OpenCode มาพร้อมกับเอเจนต์หลักและ subagent ในตัวดังนี้ +OpenCode มาพร้อมกับเอเจนต์หลัก 2 ตัวและ subagent ในตัว 3 ตัว --- @@ -84,6 +84,14 @@ _Mode_: `subagent` --- +### Scout + +_Mode_: `subagent` + +เอเจนต์แบบอ่านอย่างเดียวสำหรับค้นคว้าเอกสารภายนอกและ dependency ใช้สิ่งนี้เมื่อคุณต้องการ clone repository ของ dependency เข้าไปใน cache ที่ OpenCode จัดการให้, ตรวจสอบ source code ของไลบรารี, หรือเทียบโค้ดในเครื่องกับ implementation จาก upstream โดยไม่แก้ไข workspace ของคุณ + +--- + ### Compact _Mode_: `primary` diff --git a/packages/web/src/content/docs/th/zen.mdx b/packages/web/src/content/docs/th/zen.mdx index cb2556ef63..68139d9629 100644 --- a/packages/web/src/content/docs/th/zen.mdx +++ b/packages/web/src/content/docs/th/zen.mdx @@ -95,8 +95,7 @@ OpenCode Zen ทำงานเหมือน provider อื่น ๆ ใน | Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Ling 2.6 Flash | ling-2.6-flash | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Hy3 Preview Free | hy3-preview-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Ring 2.6 1T | ring-2.6-1t-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Nemotron 3 Super Free | nemotron-3-super-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | [model id](/docs/config/#models) ใน OpenCode config ของคุณใช้รูปแบบ `opencode/` ตัวอย่างเช่น สำหรับ GPT 5.5 คุณจะใช้ `opencode/gpt-5.5` ใน config ของคุณ @@ -121,8 +120,7 @@ https://opencode.ai/zen/v1/models | --------------------------------- | ------ | ------- | ----------- | ------------ | | Big Pickle | Free | Free | Free | - | | MiniMax M2.5 Free | Free | Free | Free | - | -| Ling 2.6 Flash Free | Free | Free | Free | - | -| Hy3 Preview Free | Free | Free | Free | - | +| Ring 2.6 1T Free | Free | Free | Free | - | | Nemotron 3 Super Free | Free | Free | Free | - | | MiniMax M2.7 | $0.30 | $1.20 | $0.06 | $0.375 | | MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 | @@ -174,8 +172,7 @@ https://opencode.ai/zen/v1/models โมเดลฟรี: - MiniMax M2.5 Free เปิดให้ใช้บน OpenCode ในช่วงเวลาจำกัด ทีมกำลังใช้ช่วงเวลานี้เพื่อเก็บ feedback และปรับปรุงโมเดล -- Ling 2.6 Flash Free เปิดให้ใช้บน OpenCode ในช่วงเวลาจำกัด ทีมกำลังใช้ช่วงเวลานี้เพื่อเก็บ feedback และปรับปรุงโมเดล -- Hy3 Preview Free เปิดให้ใช้บน OpenCode ในช่วงเวลาจำกัด ทีมกำลังใช้ช่วงเวลานี้เพื่อเก็บ feedback และปรับปรุงโมเดล +- Ring 2.6 1T Free เปิดให้ใช้บน OpenCode ในช่วงเวลาจำกัด ทีมกำลังใช้ช่วงเวลานี้เพื่อเก็บ feedback และปรับปรุงโมเดล - Nemotron 3 Super Free เปิดให้ใช้บน OpenCode ในช่วงเวลาจำกัด ทีมกำลังใช้ช่วงเวลานี้เพื่อเก็บ feedback และปรับปรุงโมเดล - Big Pickle เป็น stealth model ที่ใช้งานฟรีบน OpenCode ในช่วงเวลาจำกัด ทีมกำลังใช้ช่วงเวลานี้เพื่อเก็บ feedback และปรับปรุงโมเดล @@ -227,8 +224,7 @@ https://opencode.ai/zen/v1/models - Big Pickle: ระหว่างช่วงที่เปิดให้ใช้ฟรี ข้อมูลที่เก็บรวบรวมอาจถูกนำไปใช้เพื่อปรับปรุงโมเดล - MiniMax M2.5 Free: ระหว่างช่วงที่เปิดให้ใช้ฟรี ข้อมูลที่เก็บรวบรวมอาจถูกนำไปใช้เพื่อปรับปรุงโมเดล -- Ling 2.6 Flash Free: ระหว่างช่วงที่เปิดให้ใช้ฟรี ข้อมูลที่เก็บรวบรวมอาจถูกนำไปใช้เพื่อปรับปรุงโมเดล -- Hy3 Preview Free: ระหว่างช่วงที่เปิดให้ใช้ฟรี ข้อมูลที่เก็บรวบรวมอาจถูกนำไปใช้เพื่อปรับปรุงโมเดล +- Ring 2.6 1T Free: ระหว่างช่วงที่เปิดให้ใช้ฟรี ข้อมูลที่เก็บรวบรวมอาจถูกนำไปใช้เพื่อปรับปรุงโมเดล - Nemotron 3 Super Free (endpoint ฟรีของ NVIDIA): ให้บริการภายใต้ [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf) ใช้สำหรับการทดลองเท่านั้น ไม่เหมาะสำหรับ production หรือข้อมูลที่อ่อนไหว NVIDIA จะบันทึก prompt และ output เพื่อนำไปปรับปรุงโมเดลและบริการของตน โปรดอย่าส่งข้อมูลส่วนบุคคลหรือข้อมูลลับ. - OpenAI APIs: คำขอจะถูกเก็บไว้เป็นเวลา 30 วันตาม [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data). - Anthropic APIs: คำขอจะถูกเก็บไว้เป็นเวลา 30 วันตาม [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage). diff --git a/packages/web/src/content/docs/tr/agents.mdx b/packages/web/src/content/docs/tr/agents.mdx index 1f582511be..c523b2b3bf 100644 --- a/packages/web/src/content/docs/tr/agents.mdx +++ b/packages/web/src/content/docs/tr/agents.mdx @@ -35,13 +35,13 @@ opencode, **Build** ve **Plan** olmak üzere iki yerleşik birincil agent ile bi Alt agent'lar, birincil agent'ların belirli görevler için çağırabileceği uzman yardımcılardır. Ayrıca mesajlarınızda **@ bahsederek** bunları manuel olarak da çağırabilirsiniz. -opencode, **General** ve **Explore** olmak üzere iki yerleşik alt agent ile birlikte gelir. Buna aşağıda bakacağız. +OpenCode, **General**, **Explore** ve **Scout** olmak üzere üç yerleşik alt agent ile birlikte gelir. Buna aşağıda bakacağız. --- ## Yerleşik -opencode iki yerleşik birincil agent ve iki yerleşik alt agent ile birlikte gelir. +OpenCode iki yerleşik birincil agent ve üç yerleşik alt agent ile birlikte gelir. --- @@ -83,6 +83,14 @@ Kod tabanlarını keşfetmeye yönelik hızlı, salt okunur bir agent. Dosyalar --- +### Scout Kullanımı + +_Mod_: `subagent` + +Harici dokümanlar ve bağımlılık araştırmaları için salt okunur bir agent. Bir bağımlılık repository'sini OpenCode'un yönetilen cache'ine clone etmeniz, kütüphane kaynak kodunu incelemeniz veya workspace'inizi değiştirmeden yerel kodu upstream implementasyonlarla karşılaştırmanız gerektiğinde bunu kullanın. + +--- + ### Compaction Kullanımı _Mod_: `primary` diff --git a/packages/web/src/content/docs/tr/zen.mdx b/packages/web/src/content/docs/tr/zen.mdx index 36c1bfc66e..5d4ed76ad8 100644 --- a/packages/web/src/content/docs/tr/zen.mdx +++ b/packages/web/src/content/docs/tr/zen.mdx @@ -93,8 +93,7 @@ Modellerimize aşağıdaki API uç noktaları aracılığıyla da erişebilirsin | Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Ling 2.6 Flash | ling-2.6-flash | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Hy3 Preview Free | hy3-preview-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Ring 2.6 1T | ring-2.6-1t-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Nemotron 3 Super Free | nemotron-3-super-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | OpenCode yapılandırmanızdaki [model id](/docs/config/#models) `opencode/` biçimini kullanır. Örneğin, GPT 5.5 için yapılandırmanızda `opencode/gpt-5.5` kullanırsınız. @@ -119,8 +118,7 @@ Kullandıkça öde modelini destekliyoruz. Aşağıda **1M token başına** fiya | --------------------------------- | ------ | ------- | ----------- | ------------ | | Big Pickle | Free | Free | Free | - | | MiniMax M2.5 Free | Free | Free | Free | - | -| Ling 2.6 Flash Free | Free | Free | Free | - | -| Hy3 Preview Free | Free | Free | Free | - | +| Ring 2.6 1T Free | Free | Free | Free | - | | Nemotron 3 Super Free | Free | Free | Free | - | | MiniMax M2.7 | $0.30 | $1.20 | $0.06 | $0.375 | | MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 | @@ -172,8 +170,7 @@ Kredi kartı ücretleri maliyet üzerinden yansıtılır (%4.4 + işlem başına Ücretsiz modeller: - MiniMax M2.5 Free, sınırlı bir süre için OpenCode'da ücretsizdir. Ekip bu süreyi geri bildirim toplamak ve modeli iyileştirmek için kullanıyor. -- Ling 2.6 Flash Free, sınırlı bir süre için OpenCode'da ücretsizdir. Ekip bu süreyi geri bildirim toplamak ve modeli iyileştirmek için kullanıyor. -- Hy3 Preview Free, sınırlı bir süre için OpenCode'da ücretsizdir. Ekip bu süreyi geri bildirim toplamak ve modeli iyileştirmek için kullanıyor. +- Ring 2.6 1T Free, sınırlı bir süre için OpenCode'da ücretsizdir. Ekip bu süreyi geri bildirim toplamak ve modeli iyileştirmek için kullanıyor. - Nemotron 3 Super Free, sınırlı bir süre için OpenCode'da ücretsizdir. Ekip bu süreyi geri bildirim toplamak ve modeli iyileştirmek için kullanıyor. - Big Pickle, sınırlı bir süre için OpenCode'da ücretsiz olan gizli bir modeldir. Ekip bu süreyi geri bildirim toplamak ve modeli iyileştirmek için kullanıyor. @@ -225,8 +222,7 @@ Tüm modellerimiz US'de barındırılıyor. Sağlayıcılarımız zero-retention - Big Pickle: Ücretsiz döneminde toplanan veriler modeli iyileştirmek için kullanılabilir. - MiniMax M2.5 Free: Ücretsiz döneminde toplanan veriler modeli iyileştirmek için kullanılabilir. -- Ling 2.6 Flash Free: Ücretsiz döneminde toplanan veriler modeli iyileştirmek için kullanılabilir. -- Hy3 Preview Free: Ücretsiz döneminde toplanan veriler modeli iyileştirmek için kullanılabilir. +- Ring 2.6 1T Free: Ücretsiz döneminde toplanan veriler modeli iyileştirmek için kullanılabilir. - Nemotron 3 Super Free (ücretsiz NVIDIA uç noktaları): [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf) kapsamında sunulur. Yalnızca deneme amaçlıdır; üretim veya hassas veriler için uygun değildir. NVIDIA, modellerini ve hizmetlerini geliştirmek için promptları ve çıktıları kaydeder. Kişisel veya gizli veri göndermeyin. - OpenAI APIs: İstekler [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data) uyarınca 30 gün boyunca saklanır. - Anthropic APIs: İstekler [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage) uyarınca 30 gün boyunca saklanır. diff --git a/packages/web/src/content/docs/tui.mdx b/packages/web/src/content/docs/tui.mdx index 99e9aa752b..72d9658d16 100644 --- a/packages/web/src/content/docs/tui.mdx +++ b/packages/web/src/content/docs/tui.mdx @@ -353,14 +353,10 @@ You can customize TUI behavior through `tui.json` (or `tui.jsonc`). { "$schema": "https://opencode.ai/tui.json", "theme": "opencode", - "keymap": { + "leader_timeout": 2000, + "keybinds": { "leader": "ctrl+x", - "leader_timeout": 2000, - "sections": { - "global": { - "command.palette.show": "ctrl+p" - } - } + "command_list": "ctrl+p" }, "scroll_speed": 3, "scroll_acceleration": { @@ -373,13 +369,13 @@ You can customize TUI behavior through `tui.json` (or `tui.jsonc`). This is separate from `opencode.json`, which configures server/runtime behavior. -`keymap` is merged with built-in defaults, so you only need to configure the shortcuts you want to change. +`keybinds` is merged with built-in defaults, so you only need to configure the shortcuts you want to change. ### Options - `theme` - Sets your UI theme. [Learn more](/docs/themes). -- `keymap` - Customizes keyboard shortcuts. [Learn more](/docs/keybinds). -- `keybinds` - Deprecated legacy shortcut config. This only applies when `keymap` is not present. +- `keybinds` - Customizes keyboard shortcuts. [Learn more](/docs/keybinds). +- `leader_timeout` - Controls how long OpenCode waits after the leader key. Defaults to `2000`. - `scroll_acceleration.enabled` - Enable macOS-style scroll acceleration for smooth, natural scrolling. When enabled, scroll speed increases with rapid scrolling gestures and stays precise for slower movements. **This setting takes precedence over `scroll_speed` and overrides it when enabled.** - `scroll_speed` - Controls how fast the TUI scrolls when using scroll commands (minimum: `0.001`, supports decimal values). Defaults to `3`. **Note: This is ignored if `scroll_acceleration.enabled` is set to `true`.** - `diff_style` - Controls diff rendering. `"auto"` adapts to terminal width, `"stacked"` always shows a single-column layout. diff --git a/packages/web/src/content/docs/zen.mdx b/packages/web/src/content/docs/zen.mdx index 333e74434b..2d180b30be 100644 --- a/packages/web/src/content/docs/zen.mdx +++ b/packages/web/src/content/docs/zen.mdx @@ -102,8 +102,7 @@ You can also access our models through the following API endpoints. | Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Ling 2.6 Flash | ling-2.6-flash | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Hy3 Preview Free | hy3-preview-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Ring 2.6 1T | ring-2.6-1t-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Nemotron 3 Super Free | nemotron-3-super-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | The [model id](/docs/config/#models) in your OpenCode config @@ -130,8 +129,7 @@ We support a pay-as-you-go model. Below are the prices **per 1M tokens**. | --------------------------------- | ------ | ------- | ----------- | ------------ | | Big Pickle | Free | Free | Free | - | | MiniMax M2.5 Free | Free | Free | Free | - | -| Ling 2.6 Flash Free | Free | Free | Free | - | -| Hy3 Preview Free | Free | Free | Free | - | +| Ring 2.6 1T Free | Free | Free | Free | - | | Nemotron 3 Super Free | Free | Free | Free | - | | MiniMax M2.7 | $0.30 | $1.20 | $0.06 | $0.375 | | MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 | @@ -183,8 +181,7 @@ Credit card fees are passed along at cost (4.4% + $0.30 per transaction); we don The free models: - MiniMax M2.5 Free is available on OpenCode for a limited time. The team is using this time to collect feedback and improve the model. -- Ling 2.6 Flash Free is available on OpenCode for a limited time. The team is using this time to collect feedback and improve the model. -- Hy3 Preview Flash Free is available on OpenCode for a limited time. The team is using this time to collect feedback and improve the model. +- Ring 2.6 1T Free is available on OpenCode for a limited time. The team is using this time to collect feedback and improve the model. - Nemotron 3 Super Free is available on OpenCode for a limited time. The team is using this time to collect feedback and improve the model. - Big Pickle is a stealth model that's free on OpenCode for a limited time. The team is using this time to collect feedback and improve the model. @@ -239,8 +236,7 @@ All our models are hosted in the US. Our providers follow a zero-retention polic - Big Pickle: During its free period, collected data may be used to improve the model. - MiniMax M2.5 Free: During its free period, collected data may be used to improve the model. -- Ling 2.6 Flash Free: During its free period, collected data may be used to improve the model. -- Hy3 Preview Free: During its free period, collected data may be used to improve the model. +- Ring 2.6 1T Free: During its free period, collected data may be used to improve the model. - Nemotron 3 Super Free (NVIDIA free endpoints): Provided under the [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Trial use only — not for production or sensitive data. Prompts and outputs are logged by NVIDIA to improve its models and services. Do not submit personal or confidential data. - OpenAI APIs: Requests are retained for 30 days in accordance with [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data). - Anthropic APIs: Requests are retained for 30 days in accordance with [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage). diff --git a/packages/web/src/content/docs/zh-cn/agents.mdx b/packages/web/src/content/docs/zh-cn/agents.mdx index 2087c68366..6f821ff7f8 100644 --- a/packages/web/src/content/docs/zh-cn/agents.mdx +++ b/packages/web/src/content/docs/zh-cn/agents.mdx @@ -35,13 +35,13 @@ OpenCode 内置了两个主代理:**Build** 和 **Plan**。我们将在下面 子代理是主代理可以调用来执行特定任务的专业助手。您也可以通过在消息中 **@ 提及**它们来手动调用。 -OpenCode 内置了两个子代理:**General** 和 **Explore**。我们将在下面介绍它们。 +OpenCode 内置了三个子代理:**General**、**Explore** 和 **Scout**。我们将在下面介绍它们。 --- ## 内置代理 -OpenCode 内置了两个主代理和两个子代理。 +OpenCode 内置了两个主代理和三个子代理。 --- @@ -83,6 +83,14 @@ _模式_:`subagent` --- +### 使用 Scout + +_模式_:`subagent` + +一个用于外部文档和依赖研究的只读代理。当您需要将某个依赖仓库克隆到 OpenCode 的托管缓存中、检查库的源代码,或在不修改工作区的情况下将本地代码与 upstream 实现进行交叉对照时,请使用此代理。 + +--- + ### 使用 Compaction _模式_:`primary` diff --git a/packages/web/src/content/docs/zh-cn/zen.mdx b/packages/web/src/content/docs/zh-cn/zen.mdx index 9ad7e6b53d..e81e51a67c 100644 --- a/packages/web/src/content/docs/zh-cn/zen.mdx +++ b/packages/web/src/content/docs/zh-cn/zen.mdx @@ -93,8 +93,7 @@ OpenCode Zen 的工作方式与 OpenCode 中的任何其他提供商相同。 | Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Ling 2.6 Flash | ling-2.6-flash | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Hy3 Preview Free | hy3-preview-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Ring 2.6 1T | ring-2.6-1t-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Nemotron 3 Super Free | nemotron-3-super-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | 在你的 OpenCode 配置中,[模型 ID](/docs/config/#models) 使用 `opencode/` 格式。例如,对于 GPT 5.5,你需要在配置中使用 `opencode/gpt-5.5`。 @@ -119,8 +118,7 @@ https://opencode.ai/zen/v1/models | --------------------------------- | ------ | ------- | -------- | -------- | | Big Pickle | Free | Free | Free | - | | MiniMax M2.5 Free | Free | Free | Free | - | -| Ling 2.6 Flash Free | Free | Free | Free | - | -| Hy3 Preview Free | Free | Free | Free | - | +| Ring 2.6 1T Free | Free | Free | Free | - | | Nemotron 3 Super Free | Free | Free | Free | - | | MiniMax M2.7 | $0.30 | $1.20 | $0.06 | $0.375 | | MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 | @@ -172,8 +170,7 @@ https://opencode.ai/zen/v1/models 免费模型: - MiniMax M2.5 Free 目前在 OpenCode 上限时免费提供。团队正在利用这段时间收集反馈并改进模型。 -- Ling 2.6 Flash Free 目前在 OpenCode 上限时免费提供。团队正在利用这段时间收集反馈并改进模型。 -- Hy3 Preview Flash Free 目前在 OpenCode 上限时免费提供。团队正在利用这段时间收集反馈并改进模型。 +- Ring 2.6 1T Free 目前在 OpenCode 上限时免费提供。团队正在利用这段时间收集反馈并改进模型。 - Nemotron 3 Super Free 目前在 OpenCode 上限时免费提供。团队正在利用这段时间收集反馈并改进模型。 - Big Pickle 是一个隐身模型,目前在 OpenCode 上限时免费提供。团队正在利用这段时间收集反馈并改进模型。 @@ -225,8 +222,7 @@ https://opencode.ai/zen/v1/models - Big Pickle:在免费期间,收集的数据可能会被用于改进模型。 - MiniMax M2.5 Free:在免费期间,收集的数据可能会被用于改进模型。 -- Ling 2.6 Flash Free:在免费期间,收集的数据可能会被用于改进模型。 -- Hy3 Preview Free:在免费期间,收集的数据可能会被用于改进模型。 +- Ring 2.6 1T Free:在免费期间,收集的数据可能会被用于改进模型。 - Nemotron 3 Super Free(NVIDIA 免费端点):根据 [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf) 提供。仅供试用,不适用于生产环境或敏感数据。NVIDIA 会记录提示词和输出内容,以改进其模型和服务。请勿提交个人或机密数据。 - OpenAI APIs:请求会根据 [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data) 保留 30 天。 - Anthropic APIs:请求会根据 [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage) 保留 30 天。 diff --git a/packages/web/src/content/docs/zh-tw/agents.mdx b/packages/web/src/content/docs/zh-tw/agents.mdx index fa8f102543..a9c7bbadbf 100644 --- a/packages/web/src/content/docs/zh-tw/agents.mdx +++ b/packages/web/src/content/docs/zh-tw/agents.mdx @@ -35,13 +35,13 @@ OpenCode 內建了兩個主代理:**Build** 和 **Plan**。我們將在下面 子代理是主代理可以呼叫來執行特定任務的專業助手。您也可以透過在訊息中 **@ 提及**它們來手動呼叫。 -OpenCode 內建了兩個子代理:**General** 和 **Explore**。我們將在下面介紹它們。 +OpenCode 內建了三個子代理:**General**、**Explore** 和 **Scout**。我們將在下面介紹它們。 --- ## 內建代理 -OpenCode 內建了兩個主代理和兩個子代理。 +OpenCode 內建了兩個主代理和三個子代理。 --- @@ -83,6 +83,14 @@ _模式_:`subagent` --- +### 使用 Scout + +_模式_:`subagent` + +一個用於外部文件與依賴研究的唯讀代理。當您需要將某個依賴儲存庫 clone 到 OpenCode 的託管快取中、檢查函式庫的原始碼,或在不修改工作區的情況下將本機程式碼與 upstream 實作交叉比對時,請使用此代理。 + +--- + ### 使用 Compaction _模式_:`primary` diff --git a/packages/web/src/content/docs/zh-tw/zen.mdx b/packages/web/src/content/docs/zh-tw/zen.mdx index 9511bd9e24..1634543018 100644 --- a/packages/web/src/content/docs/zh-tw/zen.mdx +++ b/packages/web/src/content/docs/zh-tw/zen.mdx @@ -97,8 +97,7 @@ OpenCode Zen 的運作方式和 OpenCode 中的其他供應商一樣。 | Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Ling 2.6 Flash | ling-2.6-flash | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Hy3 Preview Free | hy3-preview-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Ring 2.6 1T | ring-2.6-1t-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Nemotron 3 Super Free | nemotron-3-super-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | OpenCode 設定中的 [模型 ID](/docs/config/#models) 會使用 `opencode/` @@ -124,8 +123,7 @@ https://opencode.ai/zen/v1/models | --------------------------------- | ------ | ------- | -------- | -------- | | Big Pickle | Free | Free | Free | - | | MiniMax M2.5 Free | Free | Free | Free | - | -| Ling 2.6 Flash Free | Free | Free | Free | - | -| Hy3 Preview Free | Free | Free | Free | - | +| Ring 2.6 1T Free | Free | Free | Free | - | | Nemotron 3 Super Free | Free | Free | Free | - | | MiniMax M2.7 | $0.30 | $1.20 | $0.06 | $0.375 | | MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 | @@ -178,8 +176,7 @@ https://opencode.ai/zen/v1/models 免費模型: - MiniMax M2.5 Free 在 OpenCode 上限時提供。團隊正在利用這段時間收集回饋並改進模型。 -- Ling 2.6 Flash Free 在 OpenCode 上限時提供。團隊正在利用這段時間收集回饋並改進模型。 -- Hy3 Preview Free 在 OpenCode 上限時提供。團隊正在利用這段時間收集回饋並改進模型。 +- Ring 2.6 1T Free 在 OpenCode 上限時提供。團隊正在利用這段時間收集回饋並改進模型。 - Nemotron 3 Super Free 在 OpenCode 上限時提供。團隊正在利用這段時間收集回饋並改進模型。 - Big Pickle 是一個隱身模型,在 OpenCode 上限時免費提供。團隊正在利用這段時間收集回饋並改進模型。 @@ -232,8 +229,7 @@ https://opencode.ai/zen/v1/models - Big Pickle: 在免費期間,收集到的資料可能會用於改進模型。 - MiniMax M2.5 Free: 在免費期間,收集到的資料可能會用於改進模型。 -- Ling 2.6 Flash Free: 在免費期間,收集到的資料可能會用於改進模型。 -- Hy3 Preview Free: 在免費期間,收集到的資料可能會用於改進模型。 +- Ring 2.6 1T Free: 在免費期間,收集到的資料可能會用於改進模型。 - Nemotron 3 Super Free(NVIDIA 免費端點):依據 [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf) 提供。僅供試用,不適用於正式環境或敏感資料。NVIDIA 會記錄提示詞與輸出內容,以改進其模型與服務。請勿提交個人或機密資料。 - OpenAI APIs: 請求會依據 [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data) 保留 30 天。 - Anthropic APIs: 請求會依據 [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage) 保留 30 天。 diff --git a/patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch b/patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch new file mode 100644 index 0000000000..2e43225562 --- /dev/null +++ b/patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch @@ -0,0 +1,14 @@ +diff --git a/photon_rs.js b/photon_rs.js +index 8f4144d..b83e9a9 100644 +--- a/photon_rs.js ++++ b/photon_rs.js +@@ -4509,7 +4509,8 @@ module.exports.__wbindgen_init_externref_table = function() { + ; + }; + +-const path = require('path').join(__dirname, 'photon_rs_bg.wasm'); ++// Allow opencode's Bun compiled binary to point photon-node at its embedded wasm asset. ++const path = globalThis.__OPENCODE_PHOTON_WASM_PATH || require('path').join(__dirname, 'photon_rs_bg.wasm'); + const bytes = require('fs').readFileSync(path); + + const wasmModule = new WebAssembly.Module(bytes); diff --git a/script/zen-limit-server.ts b/script/zen-limit-server.ts deleted file mode 100644 index 3be1b5e111..0000000000 --- a/script/zen-limit-server.ts +++ /dev/null @@ -1,37 +0,0 @@ -const retryAfterSeconds = 15 * 60 - -// const response = { -// type: "error", -// error: { -// type: "FreeUsageLimitError", -// message: "Free usage exceeded, subscribe to Go https://opencode.ai/go", -// }, -// metadata: {}, -// } - -const response = { - type: "error", - error: { - type: "GoUsageLimitError", - message: "Subscription quota exceeded. You can continue using free models.", - }, - metadata: { - workspace: "wrk_01K6XGM22R6FM8JVABE9XDQXGH", - limit: "5 hour", - resetAt: retryAfterSeconds, - }, -} - -Bun.serve({ - port: 4141, - fetch() { - return Response.json(response, { - status: 429, - headers: { - "retry-after": String(retryAfterSeconds), - }, - }) - }, -}) - -console.log("Zen limit repro server listening on http://localhost:4141") diff --git a/sdks/vscode/package.json b/sdks/vscode/package.json index 3eaca42fb7..01737ee4e5 100644 --- a/sdks/vscode/package.json +++ b/sdks/vscode/package.json @@ -2,7 +2,7 @@ "name": "opencode", "displayName": "opencode", "description": "opencode for VS Code", - "version": "1.14.41", + "version": "1.14.48", "publisher": "sst-dev", "repository": { "type": "git", diff --git a/turbo.json b/turbo.json index 0183fabca4..6c65881b85 100644 --- a/turbo.json +++ b/turbo.json @@ -13,6 +13,10 @@ "outputs": [], "passThroughEnv": ["*"] }, + "test:ci": { + "outputs": [".artifacts/unit/junit.xml"], + "passThroughEnv": ["*"] + }, "opencode#test:ci": { "dependsOn": ["^build"], "outputs": [".artifacts/unit/junit.xml"],