diff --git a/.github/releases/v1.0.36.md b/.github/releases/v1.0.36.md new file mode 100644 index 0000000000..bdbb12ad9a --- /dev/null +++ b/.github/releases/v1.0.36.md @@ -0,0 +1,57 @@ +## opencode {VERSION} + +{Prerelease/Stable} release from `{branch}` branch. Phased upstream sync: 53 curated commits absorbed from sst/opencode: session retry hardening, 17 provider-correctness fixes, an LLM reducer/schema feature set, MCP timeout configuration, and core stability fixes: with zero overlap into the fork-owned DAG / SpecGit / release-pipeline surfaces. + +--- + +### ๐ŸŽฏ Features + +- **MCP timeout configuration split, #33977**: startup and request phases now take independent timeout settings instead of one shared knob, so slow server initialization no longer starves per-call budgets. +- **LLM response reducer feature set, #34417/#34418/#34423/#34440/#34454**: response and event reducers with locked reducer laws, enforced request precedence, and tool schema projections in `packages/llm`. +- **Model defaults and compatibility data, #34436**: `packages/llm` models now carry `defaults` and `compatibility` metadata, the foundation the protocol fixes below build on. +- **Strict tool definitions passthrough, #33392**: tool definitions carry a `strict` flag for OpenAI Codex parity, defaulting to `false` so wire behavior is unchanged. +- **TUI subagent menu exits on up arrow, #36951**: pressing Up at the top of the subagent menu now closes it instead of doing nothing. +- **models.dev modes surface as standalone models, #34521**: modes are no longer collapsed into variants, making mode-specific model selection visible downstream. + +--- + +### ๐Ÿ› Bug Fixes + +- **Session retry hardening, #40694/#40707/#41939/#43640/#43806/#43813**: retry error matching simplified then expanded (xAI capacity, network error variants, raw `network_error` finish reasons), with exponential backoff capped by jitter so a flapping provider can no longer produce unbounded retry storms. +- **Parent session header missing, #44752**: proxied requests now send the parent session header. +- **Session import errors were opaque, #36258**: import failures now surface the underlying cause. +- **Unknown config fields broke parsing, #41312**: unknown top-level config keys are ignored instead of failing the parse, keeping older and forward configs loadable. +- **Provider correctness sweep, #34673/#34702/#35478/#36017/#36614/#36629/#36976/#37982/#38330/#38924/#41620/#43310/#43915/#45769**: sonnet 5 adaptive thinking, forced OpenAI reasoning variants, OpenRouter small-model effort preserved, Grok reasoning variants exposed, gateway variants routed by API id, xAI Responses `store` defaulted off, Meta reasoning defaulted to xhigh, Mistral tool-call IDs normalized, MiniMax M3 thinking variants corrected, deprecated Gemini sampling defaults omitted, DeepSeek V4 Flash sampling scoped, Qwen sampling defaults removed, `textVerbosity` injection guarded on openai-compatible providers, and unreplayable Bedrock reasoning filtered before prompt caching. +- **Prompt cache keys selected by SDK, #38424**: cache-key assignment keys off the npm SDK rather than provider id, fixing cross-provider cache misses. +- **Azure gpt-5.5+ reasoningEffort failure, #40265**: gpt-5.5-class models on Azure no longer fail with invalid reasoning effort. +- **Copilot PDF input detection, #41522**: models advertising PDF media support are detected from capabilities instead of a hardcoded list. +- **`/connect` hid authenticated providers, #39915**: already-authed providers now show correctly in the connect list. +- **Core stability, #34059/e39d726e/#35737/#35957/#36453/#36542/#39175/#42356/#43099**: remote skills refresh from cache, external read path authorization, home-relative permission paths matched on POSIX and Windows, the file watcher only watches git projects, unused FFF content caches disabled, `FSUtil.ensureDir` tolerates `AlreadyExists`, the built-in skill's MCP environment field corrected, grep previews preserve unicode, and oversized WebSocket requests fall back instead of dying. +- **Server safety and observability, #40135/#40136**: proxied workspace requests log upstream 5xx bodies for debugging, and the host directory is no longer forwarded to remote workspaces. +- **ACP permission prompts enriched, #34079**: permission prompts carry the context needed to make a decision. +- **TUI clipboard over ssh, #30472**: copying works under tmux `set-clipboard on` remote sessions. +- **LLM reasoning closed before responses, 327e432d**: reasoning is finalized before the response phase to avoid interleaved-state corruption. + +--- + +### ๐Ÿงช Test Summary + +- `bun typecheck` green across opencode/core/llm; `test:dag-core` behavior and coverage gate green (767 assertions). +- `packages/llm` suite 309 pass / 0 fail; targeted provider, config, and processor suites (505 tests) 0 fail. + +``` +typecheck: opencode PASS core PASS llm PASS +test:dag-core: 767 assertions, 0 fail +packages/llm: 309 pass / 0 fail, targeted suites: 505 pass / 0 fail +``` + +--- + +### ๐Ÿ” Verification + +- Fork-owned surfaces (DAG runtime, SpecGit harness, release pipeline, TUI feature plugins) verified untouched: zero diff overlap, with `compaction.ts`, `azure.ts`, and `layer-node.ts` byte-identical to the pre-sync base. +- Two-axis code review (Standards/Spec) passed: no conflict-marker residue, no duplicate test blocks, all skipped upstream commits confirmed absent from the tree. + +--- + +**Full changelog:** [`{previous_tag}`...`{current_tag}`](https://github.com/LeXwDeX/OpenCode-GraphAgent/compare/{previous_tag}...{current_tag}) diff --git a/.specgit.yaml b/.specgit.yaml index 61d705851c..26c1440be0 100644 --- a/.specgit.yaml +++ b/.specgit.yaml @@ -1,11 +1,11 @@ version: 1 -delivery: release-notes-v1-0-35 +delivery: tui-crashes-on context: kind: branch - branch: docs/460-release-notes-v1-0-35 + branch: fix/465-tui-crashes-on issues: - - 460 + - 465 issueKinds: - - issue: 460 - kind: kind::docs -pr: 461 + - issue: 465 + kind: kind::fix +pr: 466 diff --git a/packages/core/src/config/mcp.ts b/packages/core/src/config/mcp.ts index 54998e1850..f3a5ac9b25 100644 --- a/packages/core/src/config/mcp.ts +++ b/packages/core/src/config/mcp.ts @@ -3,6 +3,15 @@ export * as ConfigMCP from "./mcp" import { Schema } from "effect" import { PositiveInt } from "../schema" +export class Timeout extends Schema.Class("ConfigV2.MCP.Timeout")({ + startup: PositiveInt.pipe(Schema.optional).annotate({ + description: "Maximum time in milliseconds to establish and initialize the MCP server.", + }), + request: PositiveInt.pipe(Schema.optional).annotate({ + description: "Maximum time in milliseconds to wait for each MCP request after initialization.", + }), +}) {} + export class Local extends Schema.Class("ConfigV2.MCP.Local")({ type: Schema.Literal("local"), command: Schema.String.pipe(Schema.Array), @@ -11,7 +20,7 @@ export class Local extends Schema.Class("ConfigV2.MCP.Local")({ }), environment: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional), disabled: Schema.Boolean.pipe(Schema.optional), - timeout: PositiveInt.pipe(Schema.optional), + timeout: Timeout.pipe(Schema.optional), }) {} export class OAuth extends Schema.Class("ConfigV2.MCP.OAuth")({ @@ -28,12 +37,12 @@ export class Remote extends Schema.Class("ConfigV2.MCP.Remote")({ headers: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional), oauth: Schema.Union([OAuth, Schema.Literal(false)]).pipe(Schema.optional), disabled: Schema.Boolean.pipe(Schema.optional), - timeout: PositiveInt.pipe(Schema.optional), + timeout: Timeout.pipe(Schema.optional), }) {} export const Server = Schema.Union([Local, Remote]).pipe(Schema.toTaggedUnion("type")) export class Info extends Schema.Class("ConfigV2.MCP")({ - timeout: PositiveInt.pipe(Schema.optional), + timeout: Timeout.pipe(Schema.optional), servers: Schema.Record(Schema.String, Server).pipe(Schema.optional), }) {} diff --git a/packages/core/src/config/plugin/agent.ts b/packages/core/src/config/plugin/agent.ts index 48efe75804..5e0f712732 100644 --- a/packages/core/src/config/plugin/agent.ts +++ b/packages/core/src/config/plugin/agent.ts @@ -11,6 +11,11 @@ import { FSUtil } from "../../fs-util" import { ModelV2 } from "../../model" import { ConfigAgentV1 } from "../../v1/config/agent" import { ConfigMigrateV1 } from "../../v1/config/migrate" +import { Global } from "../../global" +import { PermissionV2 } from "../../permission" +import type { LocationMutation } from "../../location-mutation" +import type { ReadTool } from "../../tool/read" +import type { EditTool } from "../../tool/edit" const legacySources = [ { pattern: "{agent,agents}/**/*.md", primary: false }, @@ -19,6 +24,11 @@ const legacySources = [ const decodeAgent = Schema.decodeUnknownOption(ConfigAgent.Info) const decodeLegacyAgent = Schema.decodeUnknownOption(ConfigAgentV1.Info) const decodeConfig = Schema.decodeUnknownOption(Config.Info) +type PathAction = + | LocationMutation.ExternalDirectoryAuthorization["action"] + | typeof ReadTool.name + | typeof EditTool.name +const pathActions = ["external_directory", "read", "edit"] as const satisfies readonly PathAction[] const agentKeys = new Set([ "model", "variant", @@ -38,6 +48,7 @@ export const Plugin = define({ effect: Effect.fn(function* (ctx) { const config = yield* Config.Service const fs = yield* FSUtil.Service + const global = yield* Global.Service yield* ctx.agent.transform( Effect.fn(function* (draft) { const documents = yield* Effect.forEach(yield* config.entries(), (entry) => { @@ -56,11 +67,14 @@ export const Plugin = define({ ) }) }).pipe(Effect.map((documents) => documents.flat())) - const global = documents.flatMap((document) => document.info.permissions ?? []) + const permissions = expandPermissions( + documents.flatMap((document) => document.info.permissions ?? []), + global.home, + ) const configuredDefault = Config.latest(documents, "default_agent") if (configuredDefault !== undefined) draft.default(AgentV2.ID.make(configuredDefault)) for (const current of draft.list()) { - draft.update(current.id, (agent) => agent.permissions.push(...global)) + draft.update(current.id, (agent) => agent.permissions.push(...permissions)) } for (const document of documents) { @@ -73,7 +87,7 @@ export const Plugin = define({ const exists = draft.get(agentID) !== undefined draft.update(agentID, (agent) => { - if (!exists) agent.permissions.push(...global) + if (!exists) agent.permissions.push(...permissions) if (item.model !== undefined) { const model = ModelV2.parse(item.model) agent.model = { id: model.modelID, providerID: model.providerID, variant: agent.model?.variant } @@ -91,7 +105,9 @@ export const Plugin = define({ if (item.hidden !== undefined) agent.hidden = item.hidden if (item.color !== undefined) agent.color = item.color if (item.steps !== undefined) agent.steps = item.steps - if (item.permissions !== undefined) agent.permissions.push(...item.permissions) + if (item.permissions !== undefined) { + agent.permissions.push(...expandPermissions(item.permissions, global.home)) + } }) } } @@ -100,6 +116,25 @@ export const Plugin = define({ }), }) +function expandPermissions(rules: PermissionV2.Ruleset, home: string): PermissionV2.Ruleset { + // Expand only resources tools resolve as filesystem paths. Bash resources are raw shell text: + // rewriting `$HOME/private/**` would miss `$HOME/private/key`, and safe expansion needs shell-aware parsing. + return rules.map((rule) => (isPathAction(rule.action) ? { ...rule, resource: expandHome(rule.resource, home) } : rule)) +} + +function isPathAction(action: string): action is PathAction { + return pathActions.some((item) => item === action) +} + +function expandHome(resource: string, home: string) { + if (resource.startsWith("~/")) return home + resource.slice(1) + if (resource === "~") return home + if (resource === "$HOME") return home + if (resource.startsWith("$HOME/")) return home + resource.slice(5) + if (resource.startsWith("$HOME\\")) return home + resource.slice(5) + return resource +} + function discover(fs: FSUtil.Interface, directory: string) { return Effect.forEach(legacySources, (source) => fs diff --git a/packages/core/src/filesystem/search.ts b/packages/core/src/filesystem/search.ts index db94711480..9c07b1c711 100644 --- a/packages/core/src/filesystem/search.ts +++ b/packages/core/src/filesystem/search.ts @@ -127,6 +127,8 @@ export const fffLayer = Layer.effect( Fff.create({ basePath: location.directory, aiMode: true, + disableMmapCache: true, + disableContentIndexing: true, }), catch: (cause) => cause, }).pipe( diff --git a/packages/core/src/filesystem/watcher.ts b/packages/core/src/filesystem/watcher.ts index 5c8930898c..0093e08ef5 100644 --- a/packages/core/src/filesystem/watcher.ts +++ b/packages/core/src/filesystem/watcher.ts @@ -105,7 +105,7 @@ export const layer = Layer.effect( const config = (yield* (yield* Config.Service).entries()) .filter((entry): entry is Config.Document => entry.type === "document") .flatMap((item) => item.info.watcher?.ignore ?? []) - if (yield* Flag.OPENCODE_EXPERIMENTAL_FILEWATCHER) { + if (location.vcs && (yield* Flag.OPENCODE_EXPERIMENTAL_FILEWATCHER)) { yield* Effect.forkScoped( subscribe(location.directory, [...Ignore.PATTERNS, ...config, ...protecteds(location.directory)]), ) diff --git a/packages/core/src/fs-util.ts b/packages/core/src/fs-util.ts index 3363cc8c8d..2aef7640e9 100644 --- a/packages/core/src/fs-util.ts +++ b/packages/core/src/fs-util.ts @@ -104,7 +104,14 @@ export namespace FSUtil { }) const ensureDir = Effect.fn("FileSystem.ensureDir")(function* (path: string) { - yield* fs.makeDirectory(path, { recursive: true }) + yield* fs.makeDirectory(path, { recursive: true }).pipe( + // Bun on Windows can throw EEXIST here despite recursive mode. + // https://github.com/oven-sh/bun/issues/21901 + Effect.catchIf( + (error) => error.reason._tag === "AlreadyExists", + (error) => isDir(path).pipe(Effect.flatMap((exists) => (exists ? Effect.void : Effect.fail(error)))), + ), + ) }) const writeWithDirs = Effect.fn("FileSystem.writeWithDirs")(function* ( diff --git a/packages/core/src/plugin/models-dev.ts b/packages/core/src/plugin/models-dev.ts index 1adc979d25..075a6ed093 100644 --- a/packages/core/src/plugin/models-dev.ts +++ b/packages/core/src/plugin/models-dev.ts @@ -1,7 +1,7 @@ import { define } from "./internal" +import type { ModelV2Info } from "@opencode-ai/sdk/v2/types" import { Effect, Stream } from "effect" import { EventV2 } from "../event" -import { ModelV2 } from "../model" import { ModelsDev } from "../models-dev" import { ProviderV2 } from "../provider" @@ -10,7 +10,7 @@ function released(date: string) { return Number.isFinite(time) ? time : 0 } -function cost(input: ModelsDev.Model["cost"]) { +function cost(input: ModelsDev.Model["cost"]): ModelV2Info["cost"] { const base = { input: input?.input ?? 0, output: input?.output ?? 0, @@ -19,30 +19,101 @@ function cost(input: ModelsDev.Model["cost"]) { write: input?.cache_write ?? 0, }, } - if (!input?.context_over_200k) return [base] return [ base, - { - tier: { - type: "context" as const, - size: 200_000, - }, - input: input.context_over_200k.input, - output: input.context_over_200k.output, + ...(input?.tiers?.map((item) => ({ + tier: item.tier, + input: item.input, + output: item.output, cache: { - read: input.context_over_200k.cache_read ?? 0, - write: input.context_over_200k.cache_write ?? 0, + read: item.cache_read ?? 0, + write: item.cache_write ?? 0, }, - }, + })) ?? []), + ...(input?.context_over_200k + ? [ + { + tier: { + type: "context" as const, + size: 200_000, + }, + input: input.context_over_200k.input, + output: input.context_over_200k.output, + cache: { + read: input.context_over_200k.cache_read ?? 0, + write: input.context_over_200k.cache_write ?? 0, + }, + }, + ] + : []), ] } -function variants(model: ModelsDev.Model) { - return Object.entries(model.experimental?.modes ?? {}).map(([id, item]) => ({ - id: ModelV2.VariantID.make(id), - headers: { ...(item.provider?.headers ?? {}) }, - body: { ...(item.provider?.body ?? {}) }, - })) +function mergeCost(base: ModelV2Info["cost"], override: ModelsDev.Model["cost"] | undefined) { + if (!override) return base + const next = cost(override) + const [baseDefault, ...baseTiers] = base + const [nextDefault, ...nextTiers] = next + const tierKey = (item: ModelV2Info["cost"][number]) => `${item.tier?.type ?? "base"}:${item.tier?.size ?? 0}` + const merge = (left: ModelV2Info["cost"][number], right: ModelV2Info["cost"][number]) => ({ + ...left, + ...right, + tier: right.tier ?? left.tier, + cache: { ...left.cache, ...right.cache }, + }) + const tiers = new Map(baseTiers.map((item) => [tierKey(item), item])) + for (const item of nextTiers) { + const current = tiers.get(tierKey(item)) + tiers.set(tierKey(item), current ? merge(current, item) : item) + } + return [merge(baseDefault ?? { input: 0, output: 0, cache: { read: 0, write: 0 } }, nextDefault), ...tiers.values()] +} + +function modeName(model: ModelsDev.Model, mode: string) { + return `${model.name} ${mode.charAt(0).toUpperCase()}${mode.slice(1)}` +} + +function applyModel( + draft: ModelV2Info, + model: ModelsDev.Model, + input: { + readonly name?: string + readonly cost?: ModelV2Info["cost"] + readonly request?: NonNullable["modes"]>[string]["provider"] + } = {}, +) { + draft.name = input.name ?? model.name + draft.family = model.family + draft.api = model.provider?.npm + ? { + id: model.id, + type: "aisdk", + package: model.provider.npm, + url: model.provider.api, + } + : { + id: model.id, + type: "native", + url: model.provider?.api, + settings: {}, + } + draft.capabilities = { + tools: model.tool_call, + input: [...(model.modalities?.input ?? [])], + output: [...(model.modalities?.output ?? [])], + } + draft.variants = [] + draft.time.released = released(model.release_date) + draft.cost = input.cost ?? cost(model.cost) + draft.status = model.status ?? "active" + draft.enabled = true + draft.limit = { + context: model.limit.context, + input: model.limit.input, + output: model.limit.output, + } + Object.assign(draft.request.headers, input.request?.headers ?? {}) + Object.assign(draft.request.body, input.request?.body ?? {}) } export const ModelsDevPlugin = define({ @@ -89,39 +160,17 @@ export const ModelsDevPlugin = define({ }) for (const model of Object.values(item.models)) { - const modelID = ModelV2.ID.make(model.id) - catalog.model.update(providerID, modelID, (draft) => { - draft.name = model.name - draft.family = model.family ? ModelV2.Family.make(model.family) : undefined - draft.api = model.provider?.npm - ? { - id: draft.api.id, - type: "aisdk", - package: model.provider?.npm, - url: model.provider.api, - } - : { - id: draft.api.id, - type: "native", - url: model.provider?.api, - settings: {}, - } - draft.capabilities = { - tools: model.tool_call, - input: [...(model.modalities?.input ?? [])], - output: [...(model.modalities?.output ?? [])], - } - draft.variants = variants(model) - draft.time.released = released(model.release_date) - draft.cost = cost(model.cost) - draft.status = model.status ?? "active" - draft.enabled = true - draft.limit = { - context: model.limit.context, - input: model.limit.input, - output: model.limit.output, - } - }) + const baseCost = cost(model.cost) + catalog.model.update(providerID, model.id, (draft) => applyModel(draft, model, { cost: baseCost })) + for (const [mode, options] of Object.entries(model.experimental?.modes ?? {})) { + catalog.model.update(providerID, `${model.id}-${mode}`, (draft) => + applyModel(draft, model, { + name: modeName(model, mode), + cost: mergeCost(baseCost, options.cost), + request: options.provider, + }), + ) + } } } }), diff --git a/packages/core/src/plugin/skill/customize-opencode.md b/packages/core/src/plugin/skill/customize-opencode.md index 6932dbfd54..c2661172d3 100644 --- a/packages/core/src/plugin/skill/customize-opencode.md +++ b/packages/core/src/plugin/skill/customize-opencode.md @@ -40,7 +40,7 @@ already-loaded config until then. | 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/`) | +| Global config | `~/.config/opencode/opencode.json` or `~/.config/opencode/opencode.jsonc` (NOT `~/.opencode/`) | | Project agents | `.opencode/agent/.md` or `.opencode/agents/.md` | | Global agents | `~/.config/opencode/agent(s)/.md` | | Project commands | `.opencode/command/.md` or `.opencode/commands/.md` | @@ -112,7 +112,7 @@ Every field is optional. "type": "local", "command": ["npx", "-y", "@playwright/mcp"], "enabled": true, - "env": {} + "environment": {} }, "remote-thing": { "type": "remote", @@ -371,7 +371,7 @@ Special object-shaped (not callbacks): `tool: { my_tool: { ... } }`, "type": "local", "command": ["npx", "-y", "@playwright/mcp"], "enabled": true, - "env": { "BROWSER": "chromium" } + "environment": { "BROWSER": "chromium" } }, "github": { "type": "remote", @@ -384,7 +384,8 @@ Special object-shaped (not callbacks): `tool: { my_tool: { ... } }`, } ``` -`command` is an array of strings. `type` is required. Use `enabled: false` to +`command` is an array of strings. `environment` sets environment variables for +a local MCP server. `type` is required. Use `enabled: false` to disable a server inherited from a parent config. String values such as header tokens support `{env:VAR}` interpolation (and `{file:path}`); the shell-style `${VAR}` is not substituted. diff --git a/packages/core/src/ripgrep.ts b/packages/core/src/ripgrep.ts index 7f0c61e1f5..9aacb6f34f 100644 --- a/packages/core/src/ripgrep.ts +++ b/packages/core/src/ripgrep.ts @@ -264,7 +264,10 @@ export const layer = Layer.effect( }), line: match.line_number, offset: match.absolute_offset, - text: match.lines.text.length > 2_000 ? match.lines.text.slice(0, 2_000) + "..." : match.lines.text, + text: + match.lines.text.length > 2_000 + ? match.lines.text.slice(0, 2_000).replace(/[\uD800-\uDBFF]$/, "") + "..." + : match.lines.text, submatches: match.submatches.map((submatch) => ({ text: submatch.match.text, start: submatch.start, diff --git a/packages/core/src/skill/discovery.ts b/packages/core/src/skill/discovery.ts index 6402dd3b71..8c885eef1f 100644 --- a/packages/core/src/skill/discovery.ts +++ b/packages/core/src/skill/discovery.ts @@ -52,6 +52,7 @@ function isSafeRelativePath(value: string) { class IndexSkill extends Schema.Class("SkillDiscovery.IndexSkill")({ name: Schema.String, + version: Schema.optional(Schema.String), files: Schema.Array(Schema.String), }) {} @@ -80,12 +81,15 @@ export const layer = Layer.effect( ) const download = Effect.fn("SkillDiscovery.download")(function* (url: string, destination: string) { - if (yield* fs.exists(destination).pipe(Effect.orDie)) return - yield* HttpClientRequest.get(url).pipe( + if (yield* fs.exists(destination).pipe(Effect.orDie)) return true + return yield* HttpClientRequest.get(url).pipe( http.execute, Effect.flatMap((response) => response.arrayBuffer), Effect.flatMap((body) => fs.writeWithDirs(destination, new Uint8Array(body))), - Effect.catch((error) => Effect.logError("failed to download skill file", { url, error })), + Effect.as(true), + Effect.catch((error) => + Effect.logError("failed to download skill file", { url, error }).pipe(Effect.as(false)), + ), ) }) @@ -120,6 +124,7 @@ export const layer = Layer.effect( } const skillUrl = new URL(`${encodeURIComponent(skill.name)}/`, source) + const versionFile = path.join(root, ".opencode-version") const files = skill.files.map((file) => { if (!isSafeRelativePath(file)) return undefined let resource: URL @@ -135,23 +140,66 @@ export const layer = Layer.effect( return { url: resource.href, destination, + file, } }) if (files.some((file) => file === undefined)) { return [] } - return [{ skill, root, files: files as { url: string; destination: string }[] }] + return [{ skill, root, versionFile, files: files as { url: string; destination: string; file: string }[] }] }), - ({ skill, root, files }) => + ({ skill, root, versionFile, files }) => Effect.gen(function* () { - yield* Effect.forEach(files, (file) => download(file.url, file.destination), { - concurrency: fileConcurrency, - discard: true, - }) - return (yield* fs.exists(path.join(root, "SKILL.md")).pipe(Effect.orDie)) || + const version = skill.version + const current = + version === undefined + ? undefined + : yield* fs.readFileStringSafe(versionFile).pipe(Effect.catch(() => Effect.succeed(undefined))) + if (version === undefined || current === version) { + yield* Effect.forEach(files, (file) => download(file.url, file.destination), { + concurrency: fileConcurrency, + discard: true, + }) + } else { + const token = crypto.randomUUID() + const staging = `${root}.tmp-${token}` + const backup = `${root}.old-${token}` + yield* Effect.gen(function* () { + const downloaded = yield* Effect.forEach( + files, + (file) => download(file.url, path.resolve(staging, file.file)), + { concurrency: fileConcurrency }, + ) + if (!downloaded.every(Boolean)) return + const exists = + (yield* fs.exists(path.join(staging, "SKILL.md")).pipe(Effect.orDie)) || + (yield* fs.exists(path.join(staging, `${skill.name}.md`)).pipe(Effect.orDie)) + if (!exists) return + yield* fs.writeFileString(path.join(staging, ".opencode-version"), version) + yield* Effect.uninterruptible( + Effect.gen(function* () { + const cached = yield* fs.exists(root).pipe(Effect.orDie) + if (cached) yield* fs.rename(root, backup) + yield* fs.rename(staging, root).pipe( + Effect.catch((error) => + Effect.gen(function* () { + if (cached) yield* fs.rename(backup, root).pipe(Effect.ignore) + return yield* Effect.fail(error) + }), + ), + ) + if (cached) yield* fs.remove(backup, { recursive: true, force: true }).pipe(Effect.ignore) + }), + ) + }).pipe( + Effect.catch((error) => Effect.logError("failed to refresh skill", { skill: skill.name, error })), + Effect.ensuring(fs.remove(staging, { recursive: true, force: true }).pipe(Effect.ignore)), + ) + } + const exists = + (yield* fs.exists(path.join(root, "SKILL.md")).pipe(Effect.orDie)) || (yield* fs.exists(path.join(root, `${skill.name}.md`)).pipe(Effect.orDie)) - ? [AbsolutePath.make(root)] - : [] + return exists ? [AbsolutePath.make(root)] : [] }), { concurrency: skillConcurrency }, ).pipe(Effect.map((directories) => directories.flat())) diff --git a/packages/core/src/tool/read.ts b/packages/core/src/tool/read.ts index 2635e4653f..ba554cc8fb 100644 --- a/packages/core/src/tool/read.ts +++ b/packages/core/src/tool/read.ts @@ -1,12 +1,10 @@ export * as ReadTool from "./read" import { ToolFailure } from "@opencode-ai/llm" -import path from "path" import { Effect, Layer, Schema } from "effect" import { FileSystem } from "../filesystem" -import { FSUtil } from "../fs-util" import { Image } from "../image" -import { Location } from "../location" +import { LocationMutation } from "../location-mutation" import { PermissionV2 } from "../permission" import { AbsolutePath } from "../schema" import { ReadToolFileSystem } from "./read-filesystem" @@ -30,9 +28,8 @@ const Output = Schema.Union([FileSystem.Content, ReadToolFileSystem.TextPage, Re export const layer = Layer.effectDiscard( Effect.gen(function* () { const tools = yield* Tools.Service - const fs = yield* FSUtil.Service const reader = yield* ReadToolFileSystem.Service - const location = yield* Location.Service + const mutation = yield* LocationMutation.Service const image = yield* Image.Service const permission = yield* PermissionV2.Service @@ -40,7 +37,7 @@ export const layer = Layer.effectDiscard( .register({ [name]: Tool.make({ description: - "Read a text file or supported image, page through a large UTF-8 text file by line offset, or list a directory page. Relative paths resolve from the current location; absolute paths are read directly.", + "Read a text file or supported image, page through a large UTF-8 text file by line offset, or list a directory page. Relative paths resolve from the current location; absolute paths inside it are accepted, while external absolute paths require external_directory approval.", input: Input, output: Output, toModelOutput: ({ input, output }) => { @@ -53,27 +50,33 @@ export const layer = Layer.effectDiscard( }, execute: (input, context) => { return Effect.gen(function* () { - const absolute = path.resolve(location.directory, input.path) - const selected = path.isAbsolute(input.path) ? path.dirname(absolute) : location.directory - if (!path.isAbsolute(input.path) && !FSUtil.contains(location.directory, absolute)) - return yield* Effect.die(new Error("Path escapes the allowed read root")) - const real = yield* fs.realPath(absolute) - const root = yield* fs.realPath(selected) - if (!FSUtil.contains(root, real)) - return yield* Effect.die(new Error("Path escapes the allowed read root")) - const resource = path.relative(root, real).replaceAll("\\", "/") || "." - const target = AbsolutePath.make(real) - const type = yield* reader.inspect(target) + const source = { + type: "tool" as const, + messageID: context.assistantMessageID, + callID: context.toolCallID, + } + const target = yield* mutation.resolve({ path: input.path, kind: "directory" }) + const external = target.externalDirectory + if (external) + yield* permission.assert({ + ...LocationMutation.externalDirectoryPermission(external), + sessionID: context.sessionID, + agent: context.agent, + source, + }) + const resource = target.resource + const absolute = AbsolutePath.make(target.canonical) + const type = yield* reader.inspect(absolute) yield* permission.assert({ action: name, resources: [resource], save: ["*"], sessionID: context.sessionID, agent: context.agent, - source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID }, + source, }) - if (type === "directory") return yield* reader.list(target, { offset: input.offset, limit: input.limit }) - const content = yield* reader.read(target, resource, { + if (type === "directory") return yield* reader.list(absolute, { offset: input.offset, limit: input.limit }) + const content = yield* reader.read(absolute, resource, { offset: input.offset, limit: input.limit, }) diff --git a/packages/core/src/v1/config/migrate.ts b/packages/core/src/v1/config/migrate.ts index 1583c6c1d5..60fd4111f6 100644 --- a/packages/core/src/v1/config/migrate.ts +++ b/packages/core/src/v1/config/migrate.ts @@ -132,7 +132,7 @@ function mcp(info: typeof ConfigV1.Info.Type) { ) const timeout = info.experimental?.mcp_timeout if (!timeout && !Object.keys(servers).length) return undefined - return { timeout, servers } + return { timeout: timeout === undefined ? undefined : { request: timeout }, servers } } function migrateMcp(info: ConfigMCPV1.Info) { @@ -144,7 +144,7 @@ function migrateMcp(info: ConfigMCPV1.Info) { cwd: info.cwd, environment: info.environment, disabled, - timeout: info.timeout, + timeout: info.timeout === undefined ? undefined : { request: info.timeout }, } return { type: info.type, @@ -158,7 +158,7 @@ function migrateMcp(info: ConfigMCPV1.Info) { redirect_uri: info.oauth.redirectUri, }, disabled, - timeout: info.timeout, + timeout: info.timeout === undefined ? undefined : { request: info.timeout }, } } diff --git a/packages/core/test/config/agent.test.ts b/packages/core/test/config/agent.test.ts index ea553671bd..8ec3b3d5db 100644 --- a/packages/core/test/config/agent.test.ts +++ b/packages/core/test/config/agent.test.ts @@ -6,16 +6,43 @@ import { AgentV2 } from "@opencode-ai/core/agent" import { Config } from "@opencode-ai/core/config" import { ConfigAgentPlugin } from "@opencode-ai/core/config/plugin/agent" import { FSUtil } from "@opencode-ai/core/fs-util" +import { Global } from "@opencode-ai/core/global" import { PermissionV2 } from "@opencode-ai/core/permission" import { AbsolutePath } from "@opencode-ai/core/schema" +import { ConfigMigrateV1 } from "@opencode-ai/core/v1/config/migrate" import { tmpdir } from "../fixture/tmpdir" import { testEffect } from "../lib/effect" import { agentHost, host } from "../plugin/host" -const it = testEffect(Layer.mergeAll(AgentV2.locationLayer, FSUtil.defaultLayer)) +const it = testEffect(Layer.mergeAll(AgentV2.locationLayer, FSUtil.defaultLayer, Global.layer)) const decode = Schema.decodeUnknownSync(Config.Info) describe("ConfigAgentPlugin.Plugin", () => { + it.effect("matches POSIX paths against home-relative permissions", () => + Effect.gen(function* () { + const permissions = yield* loadHomePermissions("/home/test") + expect(PermissionV2.evaluate("external_directory", "/home/test/p/opencode/src/*", permissions).effect).toBe( + "allow", + ) + expect(PermissionV2.evaluate("external_directory", "/home/test/cache/files/*", permissions).effect).toBe("deny") + expect(PermissionV2.evaluate("external_directory", "/some/~/path", permissions).effect).toBe("deny") + expect(PermissionV2.evaluate("external_directory", "$HOMELESS/private/*", permissions).effect).toBe("deny") + expect(PermissionV2.evaluate("bash", "$HOME/private/key", permissions).effect).toBe("deny") + }), + ) + + it.effect("matches Windows paths against home-relative permissions", () => + Effect.gen(function* () { + const permissions = yield* loadHomePermissions("C:\\Users\\test") + expect( + PermissionV2.evaluate("external_directory", "C:\\Users\\test\\p\\opencode\\src\\*", permissions).effect, + ).toBe("allow") + expect( + PermissionV2.evaluate("external_directory", "C:\\Users\\test\\cache\\files\\*", permissions).effect, + ).toBe("deny") + }), + ) + it.effect("applies all global permissions before agent-specific permissions", () => Effect.gen(function* () { const agents = yield* AgentV2.Service @@ -270,3 +297,51 @@ Use native v2 fields.`, ), ) }) + +function loadHomePermissions(home: string) { + return Effect.gen(function* () { + const agents = yield* AgentV2.Service + const build = AgentV2.ID.make("build") + yield* agents.transform((editor) => editor.update(build, () => {})) + const config = Config.Service.of({ + entries: () => + Effect.succeed([ + new Config.Document({ + type: "document", + info: decode( + ConfigMigrateV1.migrate({ + permission: { + external_directory: { + "~/p/**": "allow", + "/some/~/path": "deny", + "$HOMELESS/**": "deny", + }, + bash: { + "$HOME/private/**": "deny", + }, + }, + agent: { + build: { + permission: { + external_directory: { + "$HOME/cache/**": "deny", + }, + }, + }, + }, + }), + ), + }), + ]), + }) + + yield* ConfigAgentPlugin.Plugin.effect(host({ agent: agentHost(agents) })).pipe( + Effect.provideService(Config.Service, config), + Effect.provideService(Global.Service, Global.Service.of({ ...Global.make(), home })), + ) + + const agent = yield* agents.get(build) + if (!agent) throw new Error("expected configured build agent") + return agent.permissions + }) +} diff --git a/packages/core/test/config/config.test.ts b/packages/core/test/config/config.test.ts index b092bb5582..859f1679fc 100644 --- a/packages/core/test/config/config.test.ts +++ b/packages/core/test/config/config.test.ts @@ -299,14 +299,14 @@ describe("Config", () => { }, tool_output: { max_lines: 1000, max_bytes: 32768 }, mcp: { - timeout: 5000, + timeout: { startup: 5000, request: 60000 }, servers: { local: { type: "local", command: ["node", "./mcp/server.js"], environment: { API_KEY: "secret" }, disabled: false, - timeout: 10000, + timeout: { request: 10000 }, }, remote: { type: "remote", @@ -314,6 +314,7 @@ describe("Config", () => { headers: { Authorization: "Bearer token" }, oauth: { client_id: "client", scope: "read write", callback_port: 19876 }, disabled: true, + timeout: { startup: 15000 }, }, }, }, @@ -384,14 +385,14 @@ describe("Config", () => { }) expect(documents[0]?.info.tool_output).toEqual({ max_lines: 1000, max_bytes: 32768 }) expect(documents[0]?.info.mcp).toEqual({ - timeout: 5000, + timeout: { startup: 5000, request: 60000 }, servers: { local: { type: "local", command: ["node", "./mcp/server.js"], environment: { API_KEY: "secret" }, disabled: false, - timeout: 10000, + timeout: { request: 10000 }, }, remote: { type: "remote", @@ -399,6 +400,7 @@ describe("Config", () => { headers: { Authorization: "Bearer token" }, oauth: { client_id: "client", scope: "read write", callback_port: 19876 }, disabled: true, + timeout: { startup: 15000 }, }, }, }) @@ -542,11 +544,12 @@ describe("Config", () => { compaction: { auto: true, tail_turns: 3, preserve_recent_tokens: 2000, reserved: 10000 }, experimental: { mcp_timeout: 5000 }, mcp: { - local: { type: "local", command: ["node", "server.js"], enabled: false }, + local: { type: "local", command: ["node", "server.js"], enabled: false, timeout: 10000 }, remote: { type: "remote", url: "https://mcp.example.com", oauth: { clientId: "client", callbackPort: 19876 }, + timeout: 20000, }, }, }), @@ -624,13 +627,19 @@ describe("Config", () => { buffer: 10000, }) expect(documents[0]?.info.mcp).toMatchObject({ - timeout: 5000, + timeout: { request: 5000 }, servers: { - local: { type: "local", command: ["node", "server.js"], disabled: true }, + local: { + type: "local", + command: ["node", "server.js"], + disabled: true, + timeout: { request: 10000 }, + }, remote: { type: "remote", url: "https://mcp.example.com", oauth: { client_id: "client", callback_port: 19876 }, + timeout: { request: 20000 }, }, }, }) diff --git a/packages/core/test/filesystem/watcher.test.ts b/packages/core/test/filesystem/watcher.test.ts index a189442e8b..f0826e37e7 100644 --- a/packages/core/test/filesystem/watcher.test.ts +++ b/packages/core/test/filesystem/watcher.test.ts @@ -169,16 +169,12 @@ describeWatcher("Watcher", () => { ), ) - it.live("watches non-git roots", () => + it.live("skips non-git roots", () => withTmp((directory) => Effect.gen(function* () { const fs = yield* FSUtil.Service const file = path.join(directory, "plain.txt") - yield* ready(directory) - expect(yield* nextUpdate((event) => event.file === file, fs.writeFileString(file, "plain"))).toEqual({ - file, - event: "add", - }) + yield* noUpdate((event) => event.file === file, fs.writeFileString(file, "plain")) }), ), ) @@ -191,7 +187,10 @@ describeWatcher("Watcher", () => { Effect.promise(() => tmpdir()), (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), ) - yield* ready(tmp.path).pipe(provide(tmp.path), Effect.scoped) + yield* ready(tmp.path).pipe( + provide(tmp.path, { type: "git", store: AbsolutePath.make(path.join(tmp.path, ".git")) }), + Effect.scoped, + ) const file = path.join(tmp.path, "after-dispose.txt") yield* noUpdate((event) => event.file === file, fs.writeFileString(file, "gone")).pipe( Effect.provideService(EventV2.Service, events), diff --git a/packages/core/test/plugin/models-dev.test.ts b/packages/core/test/plugin/models-dev.test.ts index 4c3071c774..91bda2bdbc 100644 --- a/packages/core/test/plugin/models-dev.test.ts +++ b/packages/core/test/plugin/models-dev.test.ts @@ -7,9 +7,11 @@ import { Credential } from "@opencode-ai/core/credential" import { EventV2 } from "@opencode-ai/core/event" import { Flag } from "@opencode-ai/core/flag/flag" import { Location } from "@opencode-ai/core/location" +import { ModelV2 } from "@opencode-ai/core/model" import { ModelsDev } from "@opencode-ai/core/models-dev" import { ModelsDevPlugin } from "@opencode-ai/core/plugin/models-dev" import { Policy } from "@opencode-ai/core/policy" +import { ProviderV2 } from "@opencode-ai/core/provider" import { AbsolutePath } from "@opencode-ai/core/schema" import { location } from "../fixture/location" import { testEffect } from "../lib/effect" @@ -30,6 +32,103 @@ const layer = Layer.mergeAll(catalog.pipe(Layer.provide(connections)), integrati const it = testEffect(layer) describe("ModelsDevPlugin", () => { + it.effect("projects models.dev modes as separate models instead of variants", () => + Effect.gen(function* () { + const integrations = yield* Integration.Service + const catalog = yield* Catalog.Service + const models = ModelsDev.Service.of({ + get: () => + Effect.succeed({ + acme: { + id: "acme", + name: "Acme", + env: [], + npm: "@ai-sdk/openai-compatible", + api: "https://api.acme.test/v1", + models: { + "gpt-5.4": { + id: "gpt-5.4", + name: "GPT-5.4", + family: "gpt", + release_date: "2026-01-01", + attachment: false, + reasoning: true, + temperature: true, + tool_call: true, + cost: { + input: 2.5, + output: 15, + tiers: [ + { + tier: { type: "context", size: 272_000 }, + input: 3, + output: 18, + cache_read: 0.25, + }, + ], + context_over_200k: { input: 5, output: 22.5, cache_read: 0.5 }, + }, + limit: { context: 1_050_000, input: 922_000, output: 128_000 }, + experimental: { + modes: { + fast: { + cost: { input: 5, output: 30, cache_read: 0.5 }, + provider: { + headers: { "x-mode": "fast" }, + body: { service_tier: "priority" }, + }, + }, + }, + }, + }, + }, + }, + } satisfies Record), + refresh: () => Effect.void, + }) + + yield* ModelsDevPlugin.effect( + host({ + catalog: catalogHost(catalog), + integration: integrationHost(integrations), + }), + ).pipe(Effect.provideService(ModelsDev.Service, models)) + + const providerID = ProviderV2.ID.make("acme") + const base = yield* catalog.model.get(providerID, ModelV2.ID.make("gpt-5.4")) + const fast = yield* catalog.model.get(providerID, ModelV2.ID.make("gpt-5.4-fast")) + + expect(base?.variants).toEqual([]) + expect(base?.request.body).toEqual({}) + expect(fast).toMatchObject({ + id: "gpt-5.4-fast", + providerID: "acme", + name: "GPT-5.4 Fast", + api: { id: "gpt-5.4" }, + request: { + headers: { "x-mode": "fast" }, + body: { service_tier: "priority" }, + }, + variants: [], + }) + expect(fast?.cost).toEqual([ + { input: 5, output: 30, cache: { read: 0.5, write: 0 } }, + { + tier: { type: "context", size: 272_000 }, + input: 3, + output: 18, + cache: { read: 0.25, write: 0 }, + }, + { + tier: { type: "context", size: 200_000 }, + input: 5, + output: 22.5, + cache: { read: 0.5, write: 0 }, + }, + ]) + }), + ) + it.effect("registers key methods for providers with environment variables", () => Effect.acquireUseRelease( Effect.sync(() => { diff --git a/packages/core/test/ripgrep.test.ts b/packages/core/test/ripgrep.test.ts index d7efe2a2e3..9bdb9708c7 100644 --- a/packages/core/test/ripgrep.test.ts +++ b/packages/core/test/ripgrep.test.ts @@ -61,4 +61,24 @@ describe("Ripgrep", () => { (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), ), ) + it.live("does not split surrogate pairs in oversized line previews", () => + Effect.acquireUseRelease( + Effect.promise(() => tmpdir()), + (tmp) => + Effect.gen(function* () { + yield* Effect.promise(() => + fs.writeFile(path.join(tmp.path, "unicode.txt"), `needle${"x".repeat(1_993)}๐Ÿ˜€\n`), + ) + + const matches = yield* (yield* Ripgrep.Service).grep({ + cwd: tmp.path, + pattern: "needle", + limit: 10, + }) + + expect(matches[0]?.text).toBe(`needle${"x".repeat(1_993)}...`) + }), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ), + ) }) diff --git a/packages/core/test/skill-discovery.test.ts b/packages/core/test/skill-discovery.test.ts index 5fcecae4c7..7049374f31 100644 --- a/packages/core/test/skill-discovery.test.ts +++ b/packages/core/test/skill-discovery.test.ts @@ -10,8 +10,8 @@ import { tmpdir } from "./fixture/tmpdir" const base = "https://skills.example.test/catalog/" -async function pull(skills: unknown[], files: Record = {}) { - const tmp = await tmpdir() +async function pull(skills: unknown[], files: Record = {}, cache?: Awaited>) { + const tmp = cache ?? (await tmpdir()) const requests: string[] = [] const http = Layer.succeed( HttpClient.HttpClient, @@ -101,4 +101,64 @@ describe("SkillDiscovery.pull", () => { await result.tmp[Symbol.asyncDispose]() } }) + + test("refreshes cached files when the version changes", async () => { + const tmp = await tmpdir() + try { + const first = await pull( + [{ name: "deploy", version: "1", files: ["SKILL.md"] }], + { + [`${base}deploy/SKILL.md`]: "# Old", + }, + tmp, + ) + const second = await pull( + [{ name: "deploy", version: "2", files: ["SKILL.md"] }], + { + [`${base}deploy/SKILL.md`]: "# New", + }, + tmp, + ) + + expect(await fs.readFile(path.join(first.directories[0], "SKILL.md"), "utf8")).toBe("# New") + expect(second.requests).toContain(`${base}deploy/SKILL.md`) + const third = await pull( + [{ name: "deploy", version: "2", files: ["SKILL.md"] }], + { [`${base}deploy/SKILL.md`]: "# Ignored" }, + tmp, + ) + expect(third.requests).toEqual([`${base}index.json`]) + } finally { + await tmp[Symbol.asyncDispose]() + } + }) + + test("publishes complete updates and removes stale files", async () => { + const tmp = await tmpdir() + try { + const first = await pull( + [{ name: "deploy", version: "1", files: ["SKILL.md", "old.md"] }], + { + [`${base}deploy/SKILL.md`]: "# Old", + [`${base}deploy/old.md`]: "old reference", + }, + tmp, + ) + const root = first.directories[0] + + await pull( + [{ name: "deploy", version: "2", files: ["SKILL.md", "missing.md"] }], + { [`${base}deploy/SKILL.md`]: "# Partial" }, + tmp, + ) + expect(await fs.readFile(path.join(root, "SKILL.md"), "utf8")).toBe("# Old") + expect(await fs.readFile(path.join(root, "old.md"), "utf8")).toBe("old reference") + + await pull([{ name: "deploy", version: "3", files: ["SKILL.md"] }], { [`${base}deploy/SKILL.md`]: "# New" }, tmp) + expect(await fs.readFile(path.join(root, "SKILL.md"), "utf8")).toBe("# New") + expect(await Bun.file(path.join(root, "old.md")).exists()).toBe(false) + } finally { + await tmp[Symbol.asyncDispose]() + } + }) }) diff --git a/packages/core/test/tool-read.test.ts b/packages/core/test/tool-read.test.ts index fcbec061b2..5b5bc2c9ff 100644 --- a/packages/core/test/tool-read.test.ts +++ b/packages/core/test/tool-read.test.ts @@ -11,6 +11,7 @@ import { PermissionV2 } from "@opencode-ai/core/permission" import { SessionV2 } from "@opencode-ai/core/session" import { AbsolutePath } from "@opencode-ai/core/schema" import { Global } from "@opencode-ai/core/global" +import { LocationMutation } from "@opencode-ai/core/location-mutation" import { location } from "./fixture/location" import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { ReadTool } from "@opencode-ai/core/tool/read" @@ -97,6 +98,32 @@ const infrastructure = Layer.mergeAll( Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(process.cwd()) }))), Global.layerWith({ data: Global.Path.data }), ) +const mutation = Layer.succeed( + LocationMutation.Service, + LocationMutation.Service.of({ + resolve: (input) => { + if (input.path === missingPath) + return Effect.fail(new LocationMutation.PathError({ path: input.path, reason: "non_directory_ancestor" })) + const canonical = path.resolve(process.cwd(), input.path) + const external = path.isAbsolute(input.path) && !FSUtil.contains(process.cwd(), canonical) + const resource = external ? canonical.replaceAll("\\", "/") : path.relative(process.cwd(), canonical) || "." + const directory = path.dirname(canonical) + const externalResource = path.join(directory, "*").replaceAll("\\", "/") + return Effect.succeed({ + canonical, + resource, + externalDirectory: external + ? { + action: "external_directory" as const, + directory, + resource: externalResource, + save: externalResource, + } + : undefined, + }) + }, + }), +) const unavailableImage = Layer.succeed( Image.Service, Image.Service.of({ normalize: () => Effect.fail(new Image.ResizerUnavailableError()) }), @@ -107,19 +134,21 @@ const read = ReadTool.layer.pipe( Layer.provide(permission), Layer.provide(config), Layer.provide(image), + Layer.provide(mutation), Layer.provide(infrastructure), ) -const it = testEffect(Layer.mergeAll(registry, reader, permission, config, image, infrastructure, read)) +const it = testEffect(Layer.mergeAll(registry, reader, permission, config, image, mutation, infrastructure, read)) const unavailableRead = ReadTool.layer.pipe( Layer.provide(registry), Layer.provide(reader), Layer.provide(permission), Layer.provide(config), Layer.provide(unavailableImage), + Layer.provide(mutation), Layer.provide(infrastructure), ) const itWithoutResizer = testEffect( - Layer.mergeAll(registry, reader, permission, config, unavailableImage, infrastructure, unavailableRead), + Layer.mergeAll(registry, reader, permission, config, unavailableImage, mutation, infrastructure, unavailableRead), ) const sessionID = SessionV2.ID.make("ses_read_tool_test") @@ -174,6 +203,32 @@ describe("ReadTool", () => { }), ) + it.effect("asks for external_directory approval before reading an external absolute path", () => + Effect.gen(function* () { + const registry = yield* ToolRegistry.Service + const external = path.join(path.parse(process.cwd()).root, "external-read", "notes.txt") + + expect( + yield* executeTool(registry, { + sessionID, + ...toolIdentity, + call: { type: "tool-call", id: "call-external-read", name: "read", input: { path: external } }, + }), + ).toMatchObject({ type: "json" }) + expect(assertions).toMatchObject([ + { + sessionID, + action: "external_directory", + resources: [path.join(path.dirname(external), "*").replaceAll("\\", "/")], + }, + { sessionID, action: "read", resources: [external.replaceAll("\\", "/")], save: ["*"] }, + ]) + expect(readCalls).toEqual([ + { input: AbsolutePath.make(external), page: { offset: undefined, limit: undefined } }, + ]) + }), + ) + it.effect("returns a small PNG as native media instead of durable base64 text", () => Effect.gen(function* () { const png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=" diff --git a/packages/llm/src/protocols/anthropic-messages.ts b/packages/llm/src/protocols/anthropic-messages.ts index a37cd2c9a7..26990b0063 100644 --- a/packages/llm/src/protocols/anthropic-messages.ts +++ b/packages/llm/src/protocols/anthropic-messages.ts @@ -9,6 +9,7 @@ import { Usage, type CacheHint, type FinishReason, + type JsonSchema, type LLMRequest, type MediaPart, type ProviderMetadata, @@ -21,6 +22,7 @@ import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./share import { isContextOverflow } from "../provider-error" import * as Cache from "./utils/cache" import { Lifecycle } from "./utils/lifecycle" +import { ToolSchemaProjection } from "./utils/tool-schema" import { ToolStream } from "./utils/tool-stream" const ADAPTER = "anthropic-messages" @@ -256,10 +258,10 @@ const signatureFromMetadata = (metadata: ProviderMetadata | undefined): string | return typeof anthropic.signature === "string" ? anthropic.signature : undefined } -const lowerTool = (breakpoints: Cache.Breakpoints, tool: ToolDefinition): AnthropicTool => ({ +const lowerTool = (breakpoints: Cache.Breakpoints, tool: ToolDefinition, inputSchema: JsonSchema): AnthropicTool => ({ name: tool.name, description: tool.description, - input_schema: tool.inputSchema, + input_schema: inputSchema, cache_control: cacheControl(breakpoints, tool.cache), }) @@ -504,6 +506,8 @@ const lowerThinking = Effect.fn("AnthropicMessages.lowerThinking")(function* (re const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (request: LLMRequest) { const toolChoice = request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined const generation = request.generation + const toolSchemaCompatibility = request.model.compatibility?.toolSchema + const outputLimit = request.model.defaults?.limits?.output ?? request.model.route.defaults.limits?.output ?? 4096 // Allocate the 4-breakpoint budget in invalidation order: tools โ†’ system โ†’ // messages. Tools live highest in the cache hierarchy, so when callers // over-mark we keep their tool hints and shed the message-tail ones first. @@ -511,7 +515,9 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques const tools = request.tools.length === 0 || request.toolChoice?.type === "none" ? undefined - : request.tools.map((tool) => lowerTool(breakpoints, tool)) + : request.tools.map((tool) => + lowerTool(breakpoints, tool, ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility)), + ) const system = request.system.length === 0 ? undefined @@ -533,7 +539,7 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques tools, tool_choice: toolChoice, stream: true as const, - max_tokens: generation?.maxTokens ?? request.model.route.defaults.limits?.output ?? 4096, + max_tokens: generation?.maxTokens ?? outputLimit, temperature: generation?.temperature, top_p: generation?.topP, top_k: generation?.topK, diff --git a/packages/llm/src/protocols/bedrock-converse.ts b/packages/llm/src/protocols/bedrock-converse.ts index 80412ca9dd..c447a1a39d 100644 --- a/packages/llm/src/protocols/bedrock-converse.ts +++ b/packages/llm/src/protocols/bedrock-converse.ts @@ -7,7 +7,9 @@ import { Usage, type CacheHint, type FinishReason, + type JsonSchema, type LLMRequest, + type ModelToolSchemaCompatibility, type ProviderMetadata, type ReasoningPart, type ToolCallPart, @@ -21,6 +23,7 @@ import { BedrockAuth } from "./utils/bedrock-auth" import { BedrockCache } from "./utils/bedrock-cache" import { BedrockMedia } from "./utils/bedrock-media" import { Lifecycle } from "./utils/lifecycle" +import { ToolSchemaProjection } from "./utils/tool-schema" import { ToolStream } from "./utils/tool-stream" const ADAPTER = "bedrock-converse" @@ -205,18 +208,22 @@ type BedrockEvent = Schema.Schema.Type // ============================================================================= // Request Lowering // ============================================================================= -const lowerToolSpec = (tool: ToolDefinition): BedrockToolSpec => ({ +const lowerToolSpec = (tool: ToolDefinition, inputSchema: JsonSchema): BedrockToolSpec => ({ toolSpec: { name: tool.name, description: tool.description, - inputSchema: { json: tool.inputSchema }, + inputSchema: { json: inputSchema }, }, }) -const lowerTools = (breakpoints: BedrockCache.Breakpoints, tools: ReadonlyArray): BedrockTool[] => { +const lowerTools = ( + compatibility: ModelToolSchemaCompatibility | undefined, + breakpoints: BedrockCache.Breakpoints, + tools: ReadonlyArray, +): BedrockTool[] => { const result: BedrockTool[] = [] for (const tool of tools) { - result.push(lowerToolSpec(tool)) + result.push(lowerToolSpec(tool, ToolSchemaProjection.modelCompatibility(tool.inputSchema, compatibility))) const cachePoint = BedrockCache.block(breakpoints, tool.cache) if (cachePoint) result.push(cachePoint) } @@ -386,7 +393,7 @@ const fromRequest = Effect.fn("BedrockConverse.fromRequest")(function* (request: const breakpoints = BedrockCache.breakpoints() const toolConfig = request.tools.length > 0 && request.toolChoice?.type !== "none" - ? { tools: lowerTools(breakpoints, request.tools), toolChoice } + ? { tools: lowerTools(request.model.compatibility?.toolSchema, breakpoints, request.tools), toolChoice } : undefined const system = request.system.length === 0 ? undefined : lowerSystem(breakpoints, request.system) const messages = yield* lowerMessages(request, breakpoints) diff --git a/packages/llm/src/protocols/gemini.ts b/packages/llm/src/protocols/gemini.ts index 3a2311c8fd..c4bb9476a4 100644 --- a/packages/llm/src/protocols/gemini.ts +++ b/packages/llm/src/protocols/gemini.ts @@ -8,6 +8,7 @@ import { LLMEvent, Usage, type FinishReason, + type JsonSchema, type LLMRequest, type MediaPart, type ProviderMetadata, @@ -19,6 +20,7 @@ import { import { JsonObject, optionalArray, ProviderShared } from "./shared" import { GeminiToolSchema } from "./utils/gemini-tool-schema" import { Lifecycle } from "./utils/lifecycle" +import { ToolSchemaProjection } from "./utils/tool-schema" const ADAPTER = "gemini" const MEDIA_MIMES = new Set(ProviderShared.MEDIA_MIMES) @@ -166,10 +168,10 @@ interface ParserState { // ============================================================================= // Request Lowering // ============================================================================= -const lowerTool = (tool: ToolDefinition) => ({ +const lowerTool = (tool: ToolDefinition, inputSchema: JsonSchema) => ({ name: tool.name, description: tool.description, - parameters: GeminiToolSchema.convert(tool.inputSchema), + parameters: GeminiToolSchema.convert(inputSchema), }) const lowerToolConfig = (toolChoice: NonNullable) => @@ -300,6 +302,7 @@ const thinkingConfig = (request: LLMRequest) => { const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMRequest) { const toolsEnabled = request.tools.length > 0 && request.toolChoice?.type !== "none" const generation = request.generation + const toolSchemaCompatibility = request.model.compatibility?.toolSchema const generationConfig = { maxOutputTokens: generation?.maxTokens, temperature: generation?.temperature, @@ -313,7 +316,15 @@ const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMReque contents: yield* lowerMessages(request), systemInstruction: request.system.length === 0 ? undefined : { parts: [{ text: ProviderShared.joinText(request.system) }] }, - tools: toolsEnabled ? [{ functionDeclarations: request.tools.map(lowerTool) }] : undefined, + tools: toolsEnabled + ? [ + { + functionDeclarations: request.tools.map((tool) => + lowerTool(tool, ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility)), + ), + }, + ] + : undefined, toolConfig: toolsEnabled && request.toolChoice ? yield* lowerToolConfig(request.toolChoice) : undefined, generationConfig: Object.values(generationConfig).some((value) => value !== undefined) ? generationConfig @@ -407,21 +418,35 @@ const step = (state: ParserState, event: GeminiEvent) => { if ("thoughtSignature" in part && part.thoughtSignature && "thought" in part && part.thought) reasoningSignature = part.thoughtSignature if ("text" in part && part.text.length > 0) { - lifecycle = part.thought - ? Lifecycle.reasoningDelta( - lifecycle, - events, - "reasoning-0", - part.text, - part.thoughtSignature ? googleMetadata({ thoughtSignature: part.thoughtSignature }) : undefined, - ) - : Lifecycle.textDelta(lifecycle, events, "text-0", part.text) + if (part.thought) { + lifecycle = Lifecycle.reasoningDelta( + lifecycle, + events, + "reasoning-0", + part.text, + part.thoughtSignature ? googleMetadata({ thoughtSignature: part.thoughtSignature }) : undefined, + ) + continue + } + lifecycle = Lifecycle.reasoningEnd( + lifecycle, + events, + "reasoning-0", + reasoningSignature ? googleMetadata({ thoughtSignature: reasoningSignature }) : undefined, + ) + lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", part.text) continue } if ("functionCall" in part) { const input = part.functionCall.args const id = `tool_${nextToolCallId++}` + lifecycle = Lifecycle.reasoningEnd( + lifecycle, + events, + "reasoning-0", + reasoningSignature ? googleMetadata({ thoughtSignature: reasoningSignature }) : undefined, + ) lifecycle = Lifecycle.stepStart(lifecycle, events) events.push( LLMEvent.toolCall({ diff --git a/packages/llm/src/protocols/openai-chat.ts b/packages/llm/src/protocols/openai-chat.ts index e37eec95ec..9ac85b07b1 100644 --- a/packages/llm/src/protocols/openai-chat.ts +++ b/packages/llm/src/protocols/openai-chat.ts @@ -8,6 +8,7 @@ import { LLMEvent, Usage, type FinishReason, + type JsonSchema, type LLMRequest, type MediaPart, type ReasoningPart, @@ -19,6 +20,7 @@ import { import { isRecord, JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared" import { OpenAIOptions } from "./utils/openai-options" import { Lifecycle } from "./utils/lifecycle" +import { ToolSchemaProjection } from "./utils/tool-schema" import { ToolStream } from "./utils/tool-stream" const ADAPTER = "openai-chat" @@ -174,12 +176,12 @@ const invalid = ProviderShared.invalidRequest // 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 => ({ +const lowerTool = (tool: ToolDefinition, inputSchema: JsonSchema): OpenAIChatTool => ({ type: "function", function: { name: tool.name, description: tool.description, - parameters: ProviderShared.openAiToolInputSchema(tool.inputSchema), + parameters: ToolSchemaProjection.openAI(inputSchema), }, }) @@ -343,10 +345,16 @@ const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* (request: LLMR // `fromRequest` returns the provider body only. Endpoint, auth, framing, // validation, and HTTP execution are composed by `Route.make`. const generation = request.generation + const toolSchemaCompatibility = request.model.compatibility?.toolSchema return { model: request.model.id, messages: yield* lowerMessages(request), - tools: request.tools.length === 0 ? undefined : request.tools.map(lowerTool), + tools: + request.tools.length === 0 + ? undefined + : request.tools.map((tool) => + lowerTool(tool, ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility)), + ), tool_choice: request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined, stream: true as const, stream_options: { include_usage: true }, @@ -411,7 +419,12 @@ const step = (state: ParserState, event: OpenAIChatEvent) => if (delta?.reasoning_content) lifecycle = Lifecycle.reasoningDelta(lifecycle, events, "reasoning-0", delta.reasoning_content) - if (delta?.content) lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.content) + if (delta?.content) { + lifecycle = Lifecycle.reasoningEnd(lifecycle, events, "reasoning-0") + lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.content) + } + + if (toolDeltas.length) lifecycle = Lifecycle.reasoningEnd(lifecycle, events, "reasoning-0") for (const tool of toolDeltas) { const result = ToolStream.appendOrStart( diff --git a/packages/llm/src/protocols/openai-responses.ts b/packages/llm/src/protocols/openai-responses.ts index b8a955640e..777af081fd 100644 --- a/packages/llm/src/protocols/openai-responses.ts +++ b/packages/llm/src/protocols/openai-responses.ts @@ -8,6 +8,7 @@ import { LLMEvent, Usage, type FinishReason, + type JsonSchema, type LLMRequest, type ProviderMetadata, type ReasoningPart, @@ -21,6 +22,7 @@ import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./share import { isContextOverflow } from "../provider-error" import { OpenAIOptions } from "./utils/openai-options" import { Lifecycle } from "./utils/lifecycle" +import { ToolSchemaProjection } from "./utils/tool-schema" import { ToolStream } from "./utils/tool-stream" const ADAPTER = "openai-responses" @@ -253,11 +255,13 @@ const invalid = ProviderShared.invalidRequest // ============================================================================= // Request Lowering // ============================================================================= -const lowerTool = (tool: ToolDefinition): OpenAIResponsesTool => ({ +const lowerTool = (tool: ToolDefinition, inputSchema: JsonSchema): OpenAIResponsesTool => ({ type: "function", name: tool.name, description: tool.description, - parameters: ProviderShared.openAiToolInputSchema(tool.inputSchema), + parameters: ToolSchemaProjection.openAI(inputSchema), + // TODO: Read this from OpenAI-specific tool options so direct LLM callers can opt into strict schemas. + strict: false, }) const lowerToolChoice = (toolChoice: NonNullable) => @@ -468,10 +472,16 @@ const lowerOptions = Effect.fn("OpenAIResponses.lowerOptions")(function* (reques const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request: LLMRequest) { const generation = request.generation const options = yield* lowerOptions(request) + const toolSchemaCompatibility = request.model.compatibility?.toolSchema return { model: request.model.id, input: yield* lowerMessages(request), - tools: request.tools.length === 0 ? undefined : request.tools.map(lowerTool), + tools: + request.tools.length === 0 + ? undefined + : request.tools.map((tool) => + lowerTool(tool, ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility)), + ), tool_choice: request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined, stream: true as const, max_output_tokens: generation?.maxTokens, diff --git a/packages/llm/src/protocols/shared.ts b/packages/llm/src/protocols/shared.ts index c5b6003fd2..173dc511bb 100644 --- a/packages/llm/src/protocols/shared.ts +++ b/packages/llm/src/protocols/shared.ts @@ -1,5 +1,5 @@ import { Buffer } from "node:buffer" -import { Effect, JsonSchema, Schema, Stream } from "effect" +import { Effect, Schema, Stream } from "effect" import * as Sse from "effect/unstable/encoding/Sse" import { Headers, HttpClientRequest } from "effect/unstable/http" import { @@ -24,39 +24,6 @@ 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)) -/** OpenAI function schemas require one flat object at the top level. */ -export const openAiToolInputSchema = (schema: JsonSchema.JsonSchema): JsonSchema.JsonSchema => { - const variants = Array.isArray(schema.anyOf) ? schema.anyOf.filter(isRecord) : [] - const flattened = - variants.length === 0 - ? { ...schema, type: "object" } - : { - ...Object.fromEntries(Object.entries(schema).filter(([key]) => key !== "anyOf")), - type: "object", - properties: variants.reduce( - (properties, variant) => ({ ...(isRecord(variant.properties) ? variant.properties : {}), ...properties }), - {}, - ), - additionalProperties: false, - } - const normalized = removeNullSchemas(flattened) - return isRecord(normalized) ? normalized : { type: "object" } -} - -const removeNullSchemas = (value: unknown): unknown => { - if (Array.isArray(value)) return value.map(removeNullSchemas) - if (!isRecord(value)) return value - const fields = Object.fromEntries( - Object.entries(value) - .filter(([key]) => key !== "anyOf") - .map(([key, field]) => [key, removeNullSchemas(field)]), - ) - if (!Array.isArray(value.anyOf)) return fields - const variants = value.anyOf.filter((variant) => !isRecord(variant) || variant.type !== "null").map(removeNullSchemas) - if (variants.length === 1 && isRecord(variants[0])) return { ...fields, ...variants[0] } - return { ...fields, anyOf: variants } -} - /** * Streaming tool-call accumulator. Adapters that build a tool call across * multiple `tool-input-delta` chunks store the partial JSON input string here diff --git a/packages/llm/src/protocols/utils/gemini-tool-schema.ts b/packages/llm/src/protocols/utils/gemini-tool-schema.ts index 7690b2e600..efdbe3f6ec 100644 --- a/packages/llm/src/protocols/utils/gemini-tool-schema.ts +++ b/packages/llm/src/protocols/utils/gemini-tool-schema.ts @@ -1,4 +1,4 @@ -import { ProviderShared } from "../shared" +import { isRecord } from "../../utils/record" // 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 @@ -20,8 +20,6 @@ const SCHEMA_INTENT_KEYS = [ "else", ] -const isRecord = ProviderShared.isRecord - const hasCombiner = (schema: unknown) => isRecord(schema) && (Array.isArray(schema.anyOf) || Array.isArray(schema.oneOf) || Array.isArray(schema.allOf)) diff --git a/packages/llm/src/protocols/utils/tool-schema.ts b/packages/llm/src/protocols/utils/tool-schema.ts new file mode 100644 index 0000000000..3a311eb34c --- /dev/null +++ b/packages/llm/src/protocols/utils/tool-schema.ts @@ -0,0 +1,86 @@ +import type { JsonSchema, ModelToolSchemaCompatibility } from "../../schema" +import { isRecord } from "../../utils/record" +import { GeminiToolSchema } from "./gemini-tool-schema" + +const removeNullSchemas = (value: unknown): unknown => { + if (Array.isArray(value)) return value.map(removeNullSchemas) + if (!isRecord(value)) return value + const fields = Object.fromEntries( + Object.entries(value) + .filter(([key]) => key !== "anyOf") + .map(([key, field]) => [key, removeNullSchemas(field)]), + ) + if (!Array.isArray(value.anyOf)) return fields + const variants = value.anyOf.filter((variant) => !isRecord(variant) || variant.type !== "null").map(removeNullSchemas) + if (variants.length === 1 && isRecord(variants[0])) return { ...fields, ...variants[0] } + return { ...fields, anyOf: variants } +} + +const tupleItemsSchema = (items: ReadonlyArray) => { + const projected = items.map(moonshotNode) + if (projected.length === 0) return {} + if (projected.length === 1) return projected[0] + return { anyOf: projected } +} + +const moonshotNode = (schema: unknown): unknown => { + if (Array.isArray(schema)) return schema.map(moonshotNode) + if (!isRecord(schema)) return schema + if (typeof schema.$ref === "string") return { $ref: schema.$ref } + return Object.fromEntries( + Object.entries(schema).flatMap(([key, value]) => { + if (key === "items" && Array.isArray(value)) return [[key, tupleItemsSchema(value)]] + if (key === "prefixItems") { + if ("items" in schema) return [] + return [["items", tupleItemsSchema(Array.isArray(value) ? value : [])]] + } + if (key === "unevaluatedItems") return [] + return [[key, moonshotNode(value)]] + }), + ) +} + +const moonshot = (schema: JsonSchema): JsonSchema => { + const projected = moonshotNode(schema) + return isRecord(projected) ? projected : {} +} + +const openAI = (schema: JsonSchema): JsonSchema => { + const variants = Array.isArray(schema.anyOf) ? schema.anyOf.filter(isRecord) : [] + const flattened = + variants.length === 0 + ? { ...schema, type: "object" } + : { + ...Object.fromEntries(Object.entries(schema).filter(([key]) => key !== "anyOf")), + type: "object", + properties: variants.reduce( + (properties, variant) => ({ ...(isRecord(variant.properties) ? variant.properties : {}), ...properties }), + {}, + ), + additionalProperties: false, + } + const normalized = removeNullSchemas(flattened) + return isRecord(normalized) ? normalized : { type: "object" } +} + +const gemini = (schema: JsonSchema): JsonSchema => GeminiToolSchema.convert(schema) ?? {} + +const modelCompatibility = ( + schema: JsonSchema, + compatibility: ModelToolSchemaCompatibility | undefined, +): JsonSchema => { + if (compatibility === undefined) return schema + switch (compatibility) { + case "gemini": + return gemini(schema) + case "moonshot": + return moonshot(schema) + } +} + +export const ToolSchemaProjection = { + gemini, + modelCompatibility, + moonshot, + openAI, +} as const diff --git a/packages/llm/src/provider.ts b/packages/llm/src/provider.ts index 7f69583418..c0406f3da0 100644 --- a/packages/llm/src/provider.ts +++ b/packages/llm/src/provider.ts @@ -1,7 +1,6 @@ -import type { RouteDefaultsInput } from "./route/client" import type { Model, ModelID, ProviderID } from "./schema" -export type ModelOptions = RouteDefaultsInput +export type ModelOptions = Pick /** * Advanced structural provider definition helper. Built-in providers should diff --git a/packages/llm/src/route/client.ts b/packages/llm/src/route/client.ts index 5b5bc5ab2d..183f171dcd 100644 --- a/packages/llm/src/route/client.ts +++ b/packages/llm/src/route/client.ts @@ -164,13 +164,16 @@ export interface GenerateMethod { export class Service extends Context.Service()("@opencode/LLMClient") {} -const resolveRequestOptions = (request: LLMRequest) => - LLMRequest.update(request, { - generation: - mergeGenerationOptions(request.model.route.defaults.generation, request.generation) ?? new GenerationOptions({}), - providerOptions: mergeProviderOptions(request.model.route.defaults.providerOptions, request.providerOptions), - http: mergeHttpOptions(request.model.route.defaults.http, request.http), +const resolveRequestOptions = (request: LLMRequest) => { + const routeDefaults = request.model.route.defaults + const modelDefaults = request.model.defaults + const generation = mergeGenerationOptions(routeDefaults.generation, modelDefaults?.generation, request.generation) + return LLMRequest.update(request, { + generation: generation ?? new GenerationOptions({}), + providerOptions: mergeProviderOptions(routeDefaults.providerOptions, modelDefaults?.providerOptions, request.providerOptions), + http: mergeHttpOptions(routeDefaults.http, modelDefaults?.http, request.http), }) +} export interface MakeInput { /** Route id used in diagnostics and prepared request metadata. */ @@ -374,17 +377,12 @@ const streamRequestWith = (runtime: TransportRuntime) => (request: LLMRequest) = const generateWith = (stream: Interface["stream"]) => Effect.fn("LLM.generate")(function* (request: LLMRequest) { - return new LLMResponse( - yield* stream(request).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 - }, - ), - ), + const state = yield* stream(request).pipe(Stream.runFold(LLMResponse.empty, LLMResponse.reduce)) + const response = LLMResponse.complete(state) + if (response) return response + return yield* ProviderShared.eventError( + `${request.model.provider}/${request.model.route.id}`, + "Provider stream ended without a terminal finish event", ) }) diff --git a/packages/llm/src/schema/events.ts b/packages/llm/src/schema/events.ts index 3e46013521..e0e09aa703 100644 --- a/packages/llm/src/schema/events.ts +++ b/packages/llm/src/schema/events.ts @@ -1,7 +1,7 @@ import { Schema } from "effect" import { ContentBlockID, FinishReason, ProtocolID, ProviderMetadata, RouteID, ToolCallID } from "./ids" import { ModelSchema } from "./options" -import { ToolOutput, ToolResultValue } from "./messages" +import { Message, ToolCallPart, ToolOutput, ToolResultPart, ToolResultValue, type ContentPart } from "./messages" import { ProviderFailureClassification } from "./errors" /** @@ -335,9 +335,231 @@ const responseUsage = (events: ReadonlyArray) => undefined, ) +interface ContentAssembly { + readonly contentIndex: number + readonly text: string + readonly providerMetadata?: ProviderMetadata +} + +interface ToolInputAssembly { + readonly name: string + readonly text: string + readonly providerMetadata?: ProviderMetadata +} + +interface ResponseState { + readonly events: ReadonlyArray + readonly message: Message + readonly usage?: Usage + readonly finishReason?: FinishReason + readonly textParts: Readonly> + readonly reasoningParts: Readonly> + readonly toolInputs: Readonly> +} + +const emptyResponseState = (): ResponseState => ({ + events: [], + message: Message.assistant([]), + textParts: {}, + reasoningParts: {}, + toolInputs: {}, +}) + +const appendEvent = (state: ResponseState, event: LLMEvent): ResponseState => { + const events = [...state.events, event] + if (LLMEvent.is.finish(event)) { + return { + ...state, + events, + usage: event.usage ?? state.usage, + finishReason: event.reason, + } + } + if (LLMEvent.is.providerError(event)) { + return { + ...state, + events, + finishReason: state.finishReason ?? "error", + } + } + return { + ...state, + events, + usage: "usage" in event && event.usage !== undefined ? event.usage : state.usage, + } +} + +const textContent = (text: string, providerMetadata: ProviderMetadata | undefined): ContentPart => + providerMetadata === undefined ? { type: "text", text } : { type: "text", text, providerMetadata } + +const reasoningContent = (text: string, providerMetadata: ProviderMetadata | undefined): ContentPart => + providerMetadata === undefined ? { type: "reasoning", text } : { type: "reasoning", text, providerMetadata } + +const contentWith = (state: ResponseState, content: ReadonlyArray): ResponseState => ({ + ...state, + message: Message.assistant(content), +}) + +const appendContent = (state: ResponseState, part: ContentPart) => + contentWith(state, [...state.message.content, part]) + +const replaceContent = (state: ResponseState, index: number, part: ContentPart) => + contentWith( + state, + state.message.content.map((item, itemIndex) => (itemIndex === index ? part : item)), + ) + +const ensureText = (state: ResponseState, id: string, providerMetadata?: ProviderMetadata): ResponseState => { + if (state.textParts[id]) return state + return { + ...appendContent(state, textContent("", providerMetadata)), + textParts: { + ...state.textParts, + [id]: { contentIndex: state.message.content.length, text: "", providerMetadata }, + }, + } +} + +const reduceTextDelta = (state: ResponseState, event: TextDelta): ResponseState => { + const started = ensureText(state, event.id, event.providerMetadata) + const current = started.textParts[event.id] + if (!current) return started + const text = current.text + event.text + const providerMetadata = event.providerMetadata ?? current.providerMetadata + return { + ...replaceContent(started, current.contentIndex, textContent(text, providerMetadata)), + textParts: { ...started.textParts, [event.id]: { ...current, text, providerMetadata } }, + } +} + +const reduceTextEnd = (state: ResponseState, event: TextEnd): ResponseState => { + const current = state.textParts[event.id] + if (!current) return state + const providerMetadata = event.providerMetadata ?? current.providerMetadata + return { + ...replaceContent(state, current.contentIndex, textContent(current.text, providerMetadata)), + textParts: { ...state.textParts, [event.id]: { ...current, providerMetadata } }, + } +} + +const ensureReasoning = (state: ResponseState, id: string, providerMetadata?: ProviderMetadata): ResponseState => { + if (state.reasoningParts[id]) return state + return { + ...appendContent(state, reasoningContent("", providerMetadata)), + reasoningParts: { + ...state.reasoningParts, + [id]: { contentIndex: state.message.content.length, text: "", providerMetadata }, + }, + } +} + +const reduceReasoningDelta = (state: ResponseState, event: ReasoningDelta): ResponseState => { + const started = ensureReasoning(state, event.id, event.providerMetadata) + const current = started.reasoningParts[event.id] + if (!current) return started + const text = current.text + event.text + const providerMetadata = event.providerMetadata ?? current.providerMetadata + return { + ...replaceContent(started, current.contentIndex, reasoningContent(text, providerMetadata)), + reasoningParts: { ...started.reasoningParts, [event.id]: { ...current, text, providerMetadata } }, + } +} + +const reduceReasoningEnd = (state: ResponseState, event: ReasoningEnd): ResponseState => { + const current = state.reasoningParts[event.id] + if (!current) return state + const providerMetadata = event.providerMetadata ?? current.providerMetadata + return { + ...replaceContent(state, current.contentIndex, reasoningContent(current.text, providerMetadata)), + reasoningParts: { ...state.reasoningParts, [event.id]: { ...current, providerMetadata } }, + } +} + +const reduceToolInputStart = (state: ResponseState, event: ToolInputStart): ResponseState => ({ + ...state, + toolInputs: { + ...state.toolInputs, + [event.id]: { name: event.name, text: "", providerMetadata: event.providerMetadata }, + }, +}) + +const reduceToolInputDelta = (state: ResponseState, event: ToolInputDelta): ResponseState => { + const current = state.toolInputs[event.id] ?? { name: event.name, text: "" } + return { + ...state, + toolInputs: { ...state.toolInputs, [event.id]: { ...current, text: current.text + event.text } }, + } +} + +const reduceToolInputEnd = (state: ResponseState, event: ToolInputEnd): ResponseState => { + const current = state.toolInputs[event.id] ?? { name: event.name, text: "" } + return { + ...state, + toolInputs: { + ...state.toolInputs, + [event.id]: { ...current, name: event.name, providerMetadata: event.providerMetadata ?? current.providerMetadata }, + }, + } +} + +const toolCallContent = (event: ToolCall): ContentPart => + ToolCallPart.make({ + id: event.id, + name: event.name, + input: event.input, + ...(event.providerExecuted === undefined ? {} : { providerExecuted: event.providerExecuted }), + ...(event.providerMetadata === undefined ? {} : { providerMetadata: event.providerMetadata }), + }) + +const toolResultContent = (event: ToolResult): ContentPart => + ToolResultPart.make({ + id: event.id, + name: event.name, + result: event.result, + ...(event.providerExecuted === undefined ? {} : { providerExecuted: event.providerExecuted }), + ...(event.providerMetadata === undefined ? {} : { providerMetadata: event.providerMetadata }), + }) + +const reduceToolCall = (state: ResponseState, event: ToolCall): ResponseState => { + const { [event.id]: _finished, ...toolInputs } = state.toolInputs + return { ...appendContent(state, toolCallContent(event)), toolInputs } +} + +const reduceResponseState = (state: ResponseState, event: LLMEvent): ResponseState => { + const next = appendEvent(state, event) + switch (event.type) { + case "text-start": + return ensureText(next, event.id, event.providerMetadata) + case "text-delta": + return reduceTextDelta(next, event) + case "text-end": + return reduceTextEnd(next, event) + case "reasoning-start": + return ensureReasoning(next, event.id, event.providerMetadata) + case "reasoning-delta": + return reduceReasoningDelta(next, event) + case "reasoning-end": + return reduceReasoningEnd(next, event) + case "tool-input-start": + return reduceToolInputStart(next, event) + case "tool-input-delta": + return reduceToolInputDelta(next, event) + case "tool-input-end": + return reduceToolInputEnd(next, event) + case "tool-call": + return reduceToolCall(next, event) + case "tool-result": + return appendContent(next, toolResultContent(event)) + default: + return next + } +} + export class LLMResponse extends Schema.Class("LLM.Response")({ + message: Message, events: Schema.Array(LLMEvent), usage: Schema.optional(Usage), + finishReason: FinishReason, }) { /** Concatenated assistant text assembled from streamed `text-delta` events. */ get text() { @@ -356,8 +578,29 @@ export class LLMResponse extends Schema.Class("LLM.Response")({ } export namespace LLMResponse { + export type State = ResponseState export type Output = LLMResponse | { readonly events: ReadonlyArray; readonly usage?: Usage } + /** Initial reducer state for assembling one provider attempt. */ + export const empty = emptyResponseState + + /** Purely fold one provider-neutral event into the attempt assembly state. */ + export const reduce = reduceResponseState + + /** Return a completed response only after a terminal finish or provider error. */ + export const complete = (state: State): LLMResponse | undefined => + state.finishReason === undefined + ? undefined + : new LLMResponse({ + message: state.message, + events: [...state.events], + usage: state.usage, + finishReason: state.finishReason, + }) + + /** Convenience reducer for callers that already have a collected event list. */ + export const fromEvents = (events: ReadonlyArray) => complete(events.reduce(reduce, empty())) + /** Concatenate assistant text from a response or collected event list. */ export const text = (response: Output) => responseText(response.events) diff --git a/packages/llm/src/schema/options.ts b/packages/llm/src/schema/options.ts index 747d2d5ff0..c041e93d22 100644 --- a/packages/llm/src/schema/options.ts +++ b/packages/llm/src/schema/options.ts @@ -136,15 +136,60 @@ export namespace ModelLimits { input instanceof ModelLimits ? input : new ModelLimits(input ?? {}) } +export class ModelDefaults extends Schema.Class("LLM.ModelDefaults")({ + limits: Schema.optional(ModelLimits), + generation: Schema.optional(GenerationOptions), + providerOptions: Schema.optional(ProviderOptions), + http: Schema.optional(HttpOptions), +}) {} + +export namespace ModelDefaults { + export type Input = ModelDefaults | { + readonly limits?: ModelLimits.Input + readonly generation?: GenerationOptions.Input + readonly providerOptions?: ProviderOptions + readonly http?: HttpOptions.Input + } + + /** Normalize selected-model request defaults without applying precedence. */ + export const make = (input: Input) => { + if (input instanceof ModelDefaults) return input + return new ModelDefaults({ + limits: input.limits === undefined ? undefined : ModelLimits.make(input.limits), + generation: input.generation === undefined ? undefined : GenerationOptions.make(input.generation), + providerOptions: input.providerOptions, + http: input.http === undefined ? undefined : HttpOptions.make(input.http), + }) + } +} + +export const ModelToolSchemaCompatibility = Schema.Literals(["gemini", "moonshot"]) +export type ModelToolSchemaCompatibility = Schema.Schema.Type + +export class ModelCompatibility extends Schema.Class("LLM.ModelCompatibility")({ + toolSchema: Schema.optional(ModelToolSchemaCompatibility), +}) {} + +export namespace ModelCompatibility { + export type Input = ModelCompatibility | ConstructorParameters[0] + + /** Normalize model/upstream compatibility metadata without projecting requests. */ + export const make = (input: Input) => (input instanceof ModelCompatibility ? input : new ModelCompatibility(input)) +} + export class Model { readonly id: ModelID readonly provider: ProviderID readonly route: AnyRoute + readonly defaults?: ModelDefaults + readonly compatibility?: ModelCompatibility constructor(input: Model.ConstructorInput) { this.id = input.id this.provider = input.provider this.route = input.route + this.defaults = input.defaults + this.compatibility = input.compatibility } static make(input: Model.Input) { @@ -152,6 +197,8 @@ export class Model { id: ModelID.make(input.id), provider: ProviderID.make(input.provider), route: input.route, + defaults: input.defaults === undefined ? undefined : ModelDefaults.make(input.defaults), + compatibility: input.compatibility === undefined ? undefined : ModelCompatibility.make(input.compatibility), }) } @@ -160,6 +207,8 @@ export class Model { id: model.id, provider: model.provider, route: model.route, + defaults: model.defaults, + compatibility: model.compatibility, } } @@ -177,11 +226,15 @@ export namespace Model { readonly id: ModelID readonly provider: ProviderID readonly route: AnyRoute + readonly defaults?: ModelDefaults + readonly compatibility?: ModelCompatibility } - export type Input = Omit & { + export type Input = Omit & { readonly id: string | ModelID readonly provider: string | ProviderID + readonly defaults?: ModelDefaults.Input + readonly compatibility?: ModelCompatibility.Input } } diff --git a/packages/llm/test/adapter.test.ts b/packages/llm/test/adapter.test.ts index 8e182948fa..bbbb29f37a 100644 --- a/packages/llm/test/adapter.test.ts +++ b/packages/llm/test/adapter.test.ts @@ -1,6 +1,6 @@ import { describe, expect } from "bun:test" import { Effect, Schema, Stream } from "effect" -import { LLM } from "../src" +import { LLM, LLMResponse } from "../src" import { Route, Endpoint, LLMClient, Protocol, type FramingDef } from "../src/route" import { Model } from "../src/schema" import { testEffect } from "./lib/effect" @@ -112,9 +112,16 @@ describe("llm route", () => { const llm = yield* LLMClient.Service const events = Array.from(yield* llm.stream(request).pipe(Stream.runCollect)) const response = yield* llm.generate(request) + const reduced = LLMResponse.fromEvents(events) expect(events.map((event) => event.type)).toEqual(["text-delta", "finish"]) - expect(response.events.map((event) => event.type)).toEqual(["text-delta", "finish"]) + expect(reduced).toBeDefined() + if (!reduced) throw new Error("stream reducer did not produce a completed response") + expect(response.events).toEqual(events) + expect(response.message).toEqual(reduced.message) + expect(response.usage).toEqual(reduced.usage) + expect(response.finishReason).toEqual(reduced.finishReason) + expect(response.message.content).toEqual([{ type: "text", text: 'echo:{"body":"hello"}' }]) }), ) 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 index 0145756887..209aadfc1a 100644 --- 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 @@ -21,7 +21,7 @@ "headers": { "content-type": "text/event-stream" }, - "body": "" + "body": "data: {\"candidates\":[{\"content\":{\"role\":\"model\",\"parts\":[{\"text\":\"Hi.\"}]},\"finishReason\":\"STOP\"}],\"usageMetadata\":{\"promptTokenCount\":1200,\"candidatesTokenCount\":2,\"totalTokenCount\":1202}}\n\n" } }, { @@ -39,7 +39,7 @@ "headers": { "content-type": "text/event-stream" }, - "body": "" + "body": "data: {\"candidates\":[{\"content\":{\"role\":\"model\",\"parts\":[{\"text\":\"Hi.\"}]},\"finishReason\":\"STOP\"}],\"usageMetadata\":{\"cachedContentTokenCount\":1100,\"promptTokenCount\":1200,\"candidatesTokenCount\":2,\"totalTokenCount\":1202}}\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 index a3f2e014df..9d441205fd 100644 --- 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 @@ -22,7 +22,7 @@ "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}" + "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},\"strict\":false}],\"stream\":true,\"max_output_tokens\":80}" }, "response": { "status": 200, @@ -40,7 +40,7 @@ "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}" + "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},\"strict\":false}],\"stream\":true,\"max_output_tokens\":80}" }, "response": { "status": 200, 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 index 172b8407e6..8aa5dedaaf 100644 --- 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 @@ -14,7 +14,7 @@ "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}" + "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},\"strict\":false}],\"tool_choice\":{\"type\":\"function\",\"name\":\"get_weather\"},\"stream\":true,\"max_output_tokens\":80}" }, "response": { "status": 200, diff --git a/packages/llm/test/fixtures/recordings/openai-responses/openai-responses-gpt-5-5-image-tool-result.json b/packages/llm/test/fixtures/recordings/openai-responses/openai-responses-gpt-5-5-image-tool-result.json index 21fb8944f4..4f65f43b57 100644 --- a/packages/llm/test/fixtures/recordings/openai-responses/openai-responses-gpt-5-5-image-tool-result.json +++ b/packages/llm/test/fixtures/recordings/openai-responses/openai-responses-gpt-5-5-image-tool-result.json @@ -28,7 +28,7 @@ "headers": { "content-type": "application/json" }, - "body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Read images carefully. Reply only with the visible text, lowercase, no punctuation.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Use the read_screenshot tool, then reply with the words shown.\"}]},{\"type\":\"function_call\",\"call_id\":\"call_screenshot_1\",\"name\":\"read_screenshot\",\"arguments\":\"{}\"},{\"type\":\"function_call_output\",\"call_id\":\"call_screenshot_1\",\"output\":[{\"type\":\"input_text\",\"text\":\"Image read successfully\"},{\"type\":\"input_image\",\"image_url\":\"data:image/png;base64,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\"}]}],\"tools\":[{\"type\":\"function\",\"name\":\"read_screenshot\",\"description\":\"Capture a screenshot of the current screen.\",\"parameters\":{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false}}],\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"max_output_tokens\":40,\"stream\":true}" + "body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Read images carefully. Reply only with the visible text, lowercase, no punctuation.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Use the read_screenshot tool, then reply with the words shown.\"}]},{\"type\":\"function_call\",\"call_id\":\"call_screenshot_1\",\"name\":\"read_screenshot\",\"arguments\":\"{}\"},{\"type\":\"function_call_output\",\"call_id\":\"call_screenshot_1\",\"output\":[{\"type\":\"input_text\",\"text\":\"Image read successfully\"},{\"type\":\"input_image\",\"image_url\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAnYAAACKCAYAAAAnmweyAAACKWlUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4KPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iWE1QIENvcmUgNi4wLjAiPgogPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4KICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIgogICAgeG1sbnM6ZXhpZj0iaHR0cDovL25zLmFkb2JlLmNvbS9leGlmLzEuMC8iCiAgICB4bWxuczp0aWZmPSJodHRwOi8vbnMuYWRvYmUuY29tL3RpZmYvMS4wLyIKICAgZXhpZjpQaXhlbFhEaW1lbnNpb249IjYzMCIKICAgZXhpZjpVc2VyQ29tbWVudD0iU2NyZWVuc2hvdCIKICAgZXhpZjpQaXhlbFlEaW1lbnNpb249IjEzOCIKICAgdGlmZjpZUmVzb2x1dGlvbj0iMTQ0LzEiCiAgIHRpZmY6WFJlc29sdXRpb249IjE0NC8xIgogICB0aWZmOlJlc29sdXRpb25Vbml0PSIyIi8+CiA8L3JkZjpSREY+CjwveDp4bXBtZXRhPgo8P3hwYWNrZXQgZW5kPSJyIj8+at0SpgAACrhpQ0NQSUNDIFByb2ZpbGUAAEiJlZcHUFNZF8fvey+dhJYQASmh994CSAmhBVCQDjZCEiAQQkxBwa4sruBaUBHBsqKrIgo2qg0RxbYo9r4gi4iyLhZsqHwPGMLufvN933xn5s75zXnn/u+5d959cx4AFFOuRCKC1QHIFsul0SEBjMSkZAb+JcACTUACnoDK5ckkrKioCIDahP+7fbgLoFF/y25U69+f/1fT4AtkPACgKJRT+TJeNsonAIABTyKVA4CgDEwWyCWjfB9lmhQtEOWBUU4fY8yoDi11nGljObHRbJQtASCQuVxpOgBkVzTOyOWlozrkWJQdxXyhGOUClH2zs3P4KLehbInmSFAe1Wem/kUn/W+aqUpNLjddyeN7GTNCoFAmEXHz/s/j+N+WLVJMrGGBDnKGNDQa9Xrouf2elROuZHHqjMgJFvLH8sc4QxEaN8E8GTt5gmWiGM4E87mB4Uod0YyICU4TBitzhHJO7AQLZEExEyzNiVaumyZlsyaYK52sQZEVp4xnCDhK/fyM2IQJzhXGz1DWlhUTPpnDVsalimjlXgTikIDJdYOV55At+8vehRzlXHlGbKjyHLiT9QvErElNWaKyNr4gMGgyJ06ZL5EHKNeSiKKU+QJRiDIuy41RzpWjL+fk3CjlGWZyw6ImGMQAOVAAPhCCHMAAgaiXAQkQAS7IkwsWykc3xM6R5EmF6RlyBgu9dQIGR8yzt2U4Ozq7AzB6h8dfkXf0sbsJ0a9MxlZVAeDTNDIycnIyFnYDgKMpAJDqJmOWcwBQ7wPg0imeQpo7Hhu7a1j0y6AGaEAHGAATYAnsgDNwB97AHwSBMBAJYkESmAt4IANkAylYABaDFaAQFIMNYAsoB7vAHnAAHAbHQAM4Bc6Bi+AquAHugEegC/SCV2AQfADDEAThIQpEhXQgQ8gMsoGcISbkCwVBEVA0lASlQOmQGFJAi6FVUDFUApVDu6Eq6CjUBJ2DLkOd0AOoG+qH3kJfYAQmwzRYHzaHHWAmzILD4Vh4DpwOz4fz4QJ4HVwGV8KH4Hr4HHwVvgN3wa/gIQQgKggdMULsECbCRiKRZCQNkSJLkSKkFKlEapBmpB25hXQhA8hnDA5DxTAwdhhvTCgmDsPDzMcsxazFlGMOYOoxbZhbmG7MIOY7loLVw9pgvbAcbCI2HbsAW4gtxe7D1mEvYO9ge7EfcDgcHWeB88CF4pJwmbhFuLW4HbhaXAuuE9eDG8Lj8Tp4G7wPPhLPxcvxhfht+EP4s/ib+F78J4IKwZDgTAgmJBPEhJWEUsJBwhnCTUIfYZioTjQjehEjiXxiHnE9cS+xmXid2EscJmmQLEg+pFhSJmkFqYxUQ7pAekx6p6KiYqziqTJTRaiyXKVM5YjKJZVulc9kTbI1mU2eTVaQ15H3k1vID8jvKBSKOcWfkkyRU9ZRqijnKU8pn1SpqvaqHFW+6jLVCtV61Zuqr9WIamZqLLW5avlqpWrH1a6rDagT1c3V2epc9aXqFepN6vfUhzSoGk4akRrZGms1Dmpc1nihidc01wzS5GsWaO7RPK/ZQ0WoJlQ2lUddRd1LvUDtpeFoFjQOLZNWTDtM66ANamlquWrFay3UqtA6rdVFR+jmdA5dRF9PP0a/S/8yRX8Ka4pgypopNVNuTvmoPVXbX1ugXaRdq31H+4sOQydIJ0tno06DzhNdjK617kzdBbo7dS/oDkylTfWeyptaNPXY1Id6sJ61XrTeIr09etf0hvQN9EP0Jfrb9M/rDxjQDfwNMg02G5wx6DekGvoaCg03G541fMnQYrAYIkYZo40xaKRnFGqkMNpt1GE0bGxhHGe80rjW+IkJyYRpkmay2aTVZNDU0HS66WLTatOHZkQzplmG2VazdrOP5hbmCearzRvMX1hoW3As8i2qLR5bUiz9LOdbVlretsJZMa2yrHZY3bCGrd2sM6wrrK/bwDbuNkKbHTadtlhbT1uxbaXtPTuyHcsu167artuebh9hv9K+wf61g6lDssNGh3aH745ujiLHvY6PnDSdwpxWOjU7vXW2duY5VzjfdqG4BLssc2l0eeNq4ypw3el6343qNt1ttVur2zd3D3epe417v4epR4rHdo97TBozirmWeckT6xnguczzlOdnL3cvudcxrz+97byzvA96v5hmMU0wbe+0Hh9jH67Pbp8uX4Zviu/Pvl1+Rn5cv0q/Z/4m/nz/ff59LCtWJusQ63WAY4A0oC7gI9uLvYTdEogEhgQWBXYEaQbFBZUHPQ02Dk4Prg4eDHELWRTSEooNDQ/dGHqPo8/hcao4g2EeYUvC2sLJ4THh5eHPIqwjpBHN0+HpYdM3TX88w2yGeEZDJIjkRG6KfBJlETU/6uRM3MyomRUzn0c7RS+Obo+hxsyLORjzITYgdn3sozjLOEVca7xa/Oz4qviPCYEJJQldiQ6JSxKvJukmCZMak/HJ8cn7kodmBc3aMqt3ttvswtl351jMWTjn8lzduaK5p+epzePOO56CTUlIOZjylRvJreQOpXJSt6cO8ti8rbxXfH/+Zn6/wEdQIuhL80krSXuR7pO+Kb0/wy+jNGNAyBaWC99khmbuyvyYFZm1P2tElCCqzSZkp2Q3iTXFWeK2HIOchTmdEhtJoaRrvtf8LfMHpeHSfTJINkfWKKehzdI1haXiB0V3rm9uRe6nBfELji/UWCheeC3POm9NXl9+cP4vizCLeItaFxstXrG4ewlrye6l0NLUpa3LTJYVLOtdHrL8wArSiqwVv650XFmy8v2qhFXNBfoFywt6fgj5obpQtVBaeG+19+pdP2J+FP7YscZlzbY134v4RVeKHYtLi7+u5a298pPTT2U/jaxLW9ex3n39zg24DeINdzf6bTxQolGSX9Kzafqm+s2MzUWb32+Zt+VyqWvprq2krYqtXWURZY3bTLdt2Pa1PKP8TkVARe12ve1rtn/cwd9xc6f/zppd+ruKd335Wfjz/d0hu+srzStL9+D25O55vjd+b/svzF+q9unuK973bb94f9eB6ANtVR5VVQf1Dq6vhqsV1f2HZh+6cTjwcGONXc3uWnpt8RFwRHHk5dGUo3ePhR9rPc48XnPC7MT2OmpdUT1Un1c/2JDR0NWY1NjZFNbU2uzdXHfS/uT+U0anKk5rnV5/hnSm4MzI2fyzQy2SloFz6ed6Wue1PjqfeP5228y2jgvhFy5dDL54vp3VfvaSz6VTl70uN11hXmm46n61/prbtbpf3X6t63DvqL/ucb3xhueN5s5pnWdu+t08dyvw1sXbnNtX78y403k37u79e7Pvdd3n33/xQPTgzcPch8OPlj/GPi56ov6k9Kne08rfrH6r7XLvOt0d2H3tWcyzRz28nle/y37/2lvwnPK8tM+wr+qF84tT/cH9N17Oetn7SvJqeKDwD40/tr+2fH3iT/8/rw0mDva+kb4Zebv2nc67/e9d37cORQ09/ZD9Yfhj0SedTwc+Mz+3f0n40je84Cv+a9k3q2/N38O/Px7JHhmRcKXcsVYAQQeclgbA2/0AUJIAoKI9BGnWeI89ZtD4f8EYgf/E4334mKGdSw3qRtsjdgsAR9BhvhwANX8ARlujWH8Au7gox0Q/PNa7jxoO/Yup8UK0Vjk9ta0C/7Txvv4vdf/TA6Xq3/y/AOOhDyne6KAWAAAAimVYSWZNTQAqAAAACAAEARoABQAAAAEAAAA+ARsABQAAAAEAAABGASgAAwAAAAEAAgAAh2kABAAAAAEAAABOAAAAAAAAAJAAAAABAAAAkAAAAAEAA5KGAAcAAAASAAAAeKACAAQAAAABAAACdqADAAQAAAABAAAAigAAAABBU0NJSQAAAFNjAAAAAAAAAADxh4F4AAAAHGlET1QAAAACAAAAAAAAAEUAAAAoAAAARQAAAEUAAAbT33OL9AAABp9JREFUeAHs3F9olWUcB/DnLHCT/rgKQxbhtLwpkIqsLrxZQfQXKggEA/tjZuCFCRHR1Wg3XiyhoKgVeKFd1k1CFNGNRAhhkFAQFBlSkLhjbqtNbW3jeOB0dt6dHc905/d8dnXe5332nvf3+b7jfGXMUt+NG6aTLwIECBAgQIAAgY4XKCl2HZ+hAQgQIECAAAECcwKKnQeBAAECBAgQIBBEQLELEqQxCBAgQIAAAQKKnWeAAAECBAgQIBBEQLELEqQxCBAgQIAAAQKKnWeAAAECBAgQIBBEQLELEqQxCBAgQIAAAQKKnWeAAAECBAgQIBBEQLELEqQxCBAgQIAAAQKKnWeAAAECBAgQIBBEQLELEqQxCBAgQIAAAQKKnWeAAAECBAgQIBBEoPT+4NHp+WY58edPaeST1+c7ZY0AAQIECBAgQGAZCpQ+H/ln3mJXPnMy7R4eWIa37JYIECBAgAABAgTmE1Ds5lOxRoAAAQIECBDoQIFFF7uelb0dOKZbJkCAAAECBAjEF1Ds4mdsQgIECBAgQCATAcUuk6CNSYAAAQIECMQXUOziZ2xCAgQIECBAIBMBxS6ToI1JgAABAgQIxBdQ7OJnbEICBAgQIEAgEwHFLpOgjUmAAAECBAjEF1Ds4mdsQgIECBAgQCATAcUuk6CNSYAAAQIECMQXUOziZ2xCAgQIECBAIBMBxS6ToI1JgAABAgQIxBdQ7OJnbEICBAgQIEAgEwHFLpOgjUmAAAECBAjEF1Ds4mdsQgIECBAgQCATAcUuk6CNSYAAAQIECMQXUOziZ2xCAgQIECBAIBMBxS6ToI1JgAABAgQIxBdQ7OJnbEICBAgQIEAgEwHFLpOgjUmAAAECBAjEF1Ds4mdsQgIECBAgQCATAcUuk6CNSYAAAQIECMQXUOziZ2xCAgQIECBAIBMBxS6ToI1JgAABAgQIxBdQ7OJnbEICBAgQIEAgEwHFLpOgjUmAAAECBAjEF1Ds4mdsQgIECBAgQCATAcUuk6CNSYAAAQIECMQXUOziZ2xCAgQIECBAIBMBxS6ToI1JgAABAgQIxBdQ7OJnbEICBAgQIEAgEwHFLpOgjUmAAAECBAjEF1Ds4mdsQgIECBAgQCATAcUuk6CNSYAAAQIECMQXUOziZ2xCAgQIECBAIBMBxS6ToI1JgAABAgQIxBdQ7OJnbEICBAgQIEAgEwHFLpOgjUmAAAECBAjEF1Ds4mdsQgIECBAgQCATgYbFburcZNoxdFcdQ8/K3ro1CwQIECBAgAABApdfoGGx+3d6Oj03uLHuDhW7OhILBAgQIECAAIFlIaDYLYsY3AQBAgQIEMhLYOvWp5dk4IMHDyzJdTvloopdpyTlPgkQIECAQCABxW5pwlTslsbVVQkQIECAAIECAcWuAOciTil2F4HnWwkQIECAAIHWBBS71twW+i7FbiEh5wkQIECAAIG2Cyh2bSedu2DDYnf2/Nn0wht31r2rv4qtI7FAgAABAgQILFJAsVskWJPbGxa78pmTaffwQN1lFLs6EgsECBAgQIDAIgUUu0WCNbldsatA3XrbxnTfA4/VsH343r7098REzVrRQXd3d3p+58upq+uK6rYD+99N5dFT1WMvFhboX3dzevTxLdWN4+Nn0v6Rt9P0zP+t6IsAAQKdIuAzoTgpxa7Yp9Wzil1FbuD+B9OLu16pcdzxzJPpr9Ona9aKDtb2r0t7931Qs2Xv0Gvp6LdHatYcFAs89MgTadv2XTWbtm15OE1OTtasOSBAgMByFvCZUJyOYlfs0+pZxa4ip9g1foRW9fam/nW3NN4wc+b8uXPp2PffFe5p9qRi16yUfQQILGcBxa44HcWu2KfVs4pdRU6xa/wI3X3v5rTn1cHGGypn3hoeSl8f/mrBfQttUOwWEnKeAIFOEFDsilNS7Ip9Wj2r2FXk/l/spqYm085nn0oTE+NN20b9IW622I2882b68otDTXs12qjYNZKxXiSwevUNqdTVlaZmfmVfLo8WbXWOwCURiPqZ0C48xa5dkrXXUewqHrO/buzru6mqMzrzBw9//H6ietzMi+6enrR+/Yaarb8d/yWNjY3VrLXjYLb8XHnV1dVLfXbo44t6n7X969OmezZXr3f815/TkW8Ozx3ffsemtP2lPdVzF15cs2pVWrGi+8Jhalexu/a669OaNX3V686++PGHYx3xxxNFjjUDOWirQKlUSvs/+jTN/gyWy6fm/lHW1jdwMQItCFzKz4QWbu+yf4titzQR/AcAAP//YCg3bwAAJAVJREFU7V0HvNXE0x1p0osivYMgIB2xIXb5oyKIoCAovUuvgvTee5WOFBEQULBgAxERQUBAlN5BQOkKCvrNCW7e3tzklpd738v73gy/R5LdzWZzNjc5Ozsze9unb1/7l2zkwuVz1H7U4345KVNl9EuThLhFIGu27DRuyjvmRU8cP0LdOzanv//+20wLd6dVu+5U6bGnzdMmjxtK679aax7b7aRPn4Gmz11uZr09eTR9vna1eZwYd2KDY2LEKdL3nCVrNho/dYFR7fXr16l+7WcjfQmpTxAQBCKMQN269SJc463qFiyI+T5G5QIer/Q2IXYe7yGb5tWoVY9efrWhkXPz5k3q1a01HTywz6ZkaEkpUtzOBG0ZpUyZyjhh86YNNHpYn6AnC7HzhSi2OPrWErdHadOmpatXr9K//9qO7+K2MS6uVrb8A9S15yCjhvggdv9fcHTRBYnm1HTp0tHly5cTzf1G80aF2EUH3URD7PIXKES33XZbyCheu/YnnTxxPGD53HnyUvLkKQKWiS3hypkzN2VhzVzmzFkp0x13Egjc2TOn6fSpE9SmQw+6K2t247pLFs2h5UvmB2wDMlOlTk158uSnzHdlpTvvykKpUqWmSxfO06+nT1KuPPno1debGXVc5LQu7RrRpUuXgtYZCWKXNGkyypsvf8Br/fnnH3Tq5ImAZVRmnrz5KVmyZHSDtZdHjx5WycY2W/achOcAcsedd/G9n6Bf9uwK6yUdDRyNBrn4D23KzvdmJ+fOnaFLFy+aWbfffjs9VbkqFSlawsDirizZ6N0Fs+j9pbe0XWZBm50778zMfVWQUqZKRRkyZKKzZ0/T0SOH+Ln8NWximJKfv/wFClKWLPyM8zMJuXDhd7rIf6f4d3fixDGbFtgn4Z5q1m5AVau/bBbo0bmFuW/dOcbPhZ12Oy5wTJ48OeXKnZfy5i9EWfg3fP63c4zhAeNZ/fOPP6xNDek4Y6Y7KD/XlztfAUqaJClBg3/k8AE68+vpgP2C90omPhdy7OgRxuQvSp8hA5Up9wBdv3aNU/+lrd9vMtJRpmChIlSocFG6fOkC3bhxg3Zs3/JfOeRGT25PmZLwPoTg/fQbYwbB+6fYvaX4PZmDf/PJ6ejh/XRg/146//tvRn6g//AsZ8iYybbI8WNH6a+/rpt5KPt0lWpUoGBhfmbvprTp0lO/nu3pZ353WCWa3wT0c15+v+Hdf8cdmbmNf9H587/RxfO/0+FD+/n3c97anKDHbp/HLFmyGnjgQngX4LlQgmep0N1FCe3+959/jPbu2/uT8VyqMkLsFBKR3SYaYjd38RrCByBU2fvzLur9ZruAxcdOnkvZsucKWKZOjacCvlytJ5e770GqVqMOFb6nuDXL7/jo4YPUvVNz+od/NE6SkV9ez75Qi57mjzk+XMFkxOC3+GX+bbBiRn4kiB0+LlNmLgl4vT27f6R+b3UIWEZlTpy+iIlCFgIxb1DneSM5W/YcVLteE7r/wUp+5P7K5Us0Y+pY2rRxnarCdhtNHG0vGEZimbIVqFuvIbZnrHr/XVo4b7qR91DFx6hu/eZ0Z+YsPmU/WbOCZr89wSdNP6jwQEVq1LwdZcx4iwToedjH4GD8qIH8Uf3FmuV3DK1m5Wer8zNe2/wg+BXihLO/nqIftn5Hy96dx4OMGGKqyqJNjzz2DA9W8hkf9nAGbWs+WEbzZk1WVZnbaOKId0/9xm/Qo09UpqRJk5rXVDvQmH752Uc0d+ZEgsYxFCnOpKZl224mMbae8/tvZ2kSm1Ts3rndmmUcv1ynAdV4+TVjf1DfLnQb/2vTsSelY8KkZOeOrTRicC9qzP2Ptuty6MBeGtyva1gDI/38UPeL3FOM+g259Xyu//JTmjpxBL1StxH977katu/03Tu30dgR/QK2CwPZF158xbYJwwa8Sdt+2GwM2jFYqPbSq37XGTO8L3337dd+50fjmwBi9NIrr9MTT1XhZyeZ3zWRgOcH/bGF392hDPQj9Ty24uev0uPPGG3q2aWlQaxTp07DmNWhKs/XIPzedYGCYuXyRfQeKyTQZiF2OjqR2xdi54BlfBC71xu1omervuTQIv/kQwf30ZudnDUT2XPkpL6DxjmOTP1rJK6vOR06uN8uyy/Ny8QOjW1WvwZrRbJRj74jCC8bJ8HLplv7JnT8+FHbItHG0faiYSQGIiRbNm+kqROGUafuA6ho8ZK2tToRO2g+6zVowR/QF23P0xNv3rxB82dPpY9Xv68n++yDfGG6tEy5+33SAx307NLKljCCaDz9vxcCneqY9/W6z2jSWH8iHC0cc+XOQ+079zE0446N+i/jFGsqRw3t7fgsohhwfIkJGT72wQgtPp4rli00tLLWa+vE7tOPVtEjjz5lO/g7d/ZXR/IY6oyB9drhHOvEDu+848eOGG0NVMcZHhgMHdDdcdYlELED6f/5px+pQ9e+BI22ncQVscM7dvDIqcZg1a4d1rRQzBAi+TzqxG7siP70065t1Kv/KMqdt4C1aT7H0yePoi/WrhFi54NK5A4SDbHr1X8kTyMUC4icrtELhdgNH/M2ZbVMgel14GK1X3wy4DVVJrQpbTv1UofGFtNFZ8+cIkxFpk6VxpiatY7YnBwW8MIfOX4m5cyV16fOC6y6P8+q+yScn4nV+ekz+DrDYJTfummdkLSMkSB2GI3qjiCqsTqOe3bvYI1dR5UVcKs0dig0c9o4qvNaE5PUXblymV/Yu3ha+waVKlPetClE2Y0bvmKt0wDs+khc4OhzwVgclCpdjjoycVOiYwfN1xmewi9eoozKNraYdjvGUycXL16gbzd8aeso06RFB562vaX1VCfjmfydp3czZrqTMEWmCzTHnds2dPyYPlPlBWrUzFcLjg/ROW4fpr4wxYXnQTdvcCJ29Ru3pieefs68vH7PSAyk9dr49Rc0bdJI81y1Ew0ccS/jpsznqf/M6jLGFu374+oVw8zCJ4MPftmzk/r0aG9NNo9BtBs0ecM8VjuYgkydJq2fdgn5g/p0pp0/blNFja1O7FQGNN3QsiRJkkQlmdur/PvBVJs+hYlp305tGpllorGjEzu9fpBWmKZAMBth1ShD2ziob1f9FHMfNsrP8UyGEv352bZ1s2EmgGdcF7w/TvLgD1OeK5ctMLRTej72I/lNQH3tu/SmBx56FLumoB2/8W+QX9L8/s5k9Ifqr2DELtLPo07soIkry4M2Rerwnv1p1w7WnF6iIjwDpc8UwOyiRcNaQuzMXo3sTqIhdqHA1rFbP8IUDyQUYmdXZ3VW29eu19jMCnUqts/A0axRKWWet2LpQlr1/mL644+rZlr69OnZlqgh4QOpZNPG9ca0gzpWW0zT9BowWh2yXcMpg7js3xczXQbSUoy1OCCU+ssaNnawuQkmkSB2Ttfo0WcYlSxd3siOzVSstd5PPlpJSxbMNBwFkAfP4mFMzJXDCDQlHd5oYD2N4gJHv4u6THj4kSeMKTW7ajDlvO7zj2k3v3B1OyJrWdgjjpow25w2xIt4+sSRtH3b98bUP6YT87JNFzQf95Ysa57uRJBRoPeAUWwTVdosu3D+2/TpmpXGtLlKxDNZuEhRKn9/ReODBs1IKHaqtes2puo1XzWqCfZxU9cKto0Ejs+9UJNea9jSvBRIw9LFcwybKGiKYYdUslQ5atGmm2Ebqgo6mUSAgMD7V/1e8fGcxv2yY/v3bH92wegv2JnWqtOQ4FCi5CBPk/fs2tpnwGYldqdOHqeBvTsZpK43vzuUHS/qwHsGnvJoc9tOb7FZwyNG1ZHCWrXTbmtH7E6eOEojh/TyGUQ88FAlgle6Pv0X6gxE05Yd6clnYgYKqh0YyHy8ejlt/nY94d0JMhmuxPabgPuYMf99837wLZg8bgj9sOU7H/MblCtZuiyVr1CRSpQqawzMndoY6edRJ3b6NX/atZ2mc5QERbzxnsXvvwDbaSpp0bAmPfecP+Yq381WvGIl3In5/MQnsZuz6EOTZOAl3IOnoOwEH77xU98xpwhg39Su5S07Gb3889VqGdNoKm34wB6GzZI61rewnWnZJmZki2kqTFcFk4RC7EAgVi1f7Hc7TVvxy/w/rQ9e4K+/UsXvxR0XOPo1zGWCHSH5lTUbs6aPYwKwNaTa23XuTQ8+fEtTAGzat6xnGq3rFcD2cMS4maZdFj58bZrVoXPnzurFjP05Cz9gx4vUxj5e/P17dfIrE9uEuCJ24eCIe53Av1Vls3bu7BkOS9SUrly54nebsEeCFlLJ3p93s41vW3VobmF/Cy20kvmzp9DqVUvVoblNkyYNDR093XxPIGMwa69+ZC2WEiux07WjLd7oQo89+T9VlJq8Vs1s90MVHzfIncqELSs0fdESO2LXsvHLtk4S1ncZbPImjx8WtGl2xG7Xjz+w1n9syI5bTheJLbGDo9eQUdPMapcunktL2eY0thKN59GO2H3/3Tc0bmR/H0cKtLlipSfojQ49zeb3ebMNlS8XMyg0MyKwI8ROiJ35GMUXsQNZW7hsrWkvE0xbOGbSXMqe45bTBj407Vq9bt6D2qnJ9jc1a9dXhzwl0YV27vjBPNZ3rERg4pjBtGH953oR2/2EQOzg7QmvTzuB8bTyBkZ+3ZqVjWlavWxc4KhfLxL71v4ESZ8+aZTp3RjsGpjWmvXOKvN5XPfFJzRlwnDH0+AM0bBpGzPf6VmbtWCVOS0O78ZWTV4xNEDmiS524oLYhYtj+QoPUuc3B5p3NXxQT9a2bDKPrTuDR0w2NRrQzjSq+4K1CA0dNZXysWcmxE4Lp5+gh4BBupUEWomdbjYC8ggSqUTPK1GyDPXsFzOVDVtWOwcXda7brZXYbdn8LWvr3rKtFhrNabOXmgMIOJh17dDUtqyeaCV2E8cM4nfgF3qRWO/Hltjly1/QIOfqwnDWgAY7thKN59FK7DBgw/Q3NLtWKXR3ERo4PMZpCQONEvcWtRaLyLEQOyF25oMUX8QODRjJWg+EHYFA6zFj6hjDuFRX/cO+7rlqNenV12JeVNvYc3AYa+OsgmmJ9l36mMnwmMLUhQoVoDIwJdm911CTKCJdeTepMk7bhEDsAk2FW22V7IhdXODohG9s063Erm2Luj4hBoLVa9UUDBvIXoI8hegk1vJOdp86KUFdsIGaN2tSSNP+TtdW6XFB7MLFUdfC4XfckInaNbaXdRI4qkBDrETXkqm0We+sNOzocPzBindpwdxbHs8qX99aCTocW+bMmGgW0YkdbDHbtKhn5j3Kno7wuIWgzQ1erWrm3VP0Xuo7eJx5HNfEDs4N8Gx2ki49BhKiC0BgF9j4tepORc10ndj9zuFUMOiIlMSW2MHhC4MhJXiG1n78AU/lzw4pHJU6T22j8TxaiV3T16s7eiPDRGD42BmqOYYGWYidCUdEd8TGToMzPomdnaE6HB2OHD7ExtApjLhb+ThWlZrWUc2GBx1U31ZB7KUJHPpDGdUiHy+GA/t+ZsPya6wmZ/settnD6B8aQyWIf9Wtw62YdirNaet1Yvcl25JN49AIThIKsYsLHJ3aF9t0t8TOSmZhi3fk0H7H5qRNl8FnYAD70MVsz2gVK94qH/aNMOyHRx1CVcQm+KsXiV19dnCo8p9HMRwbMH0YSODlC29fJdYBFoIgz5i/UmWTE4E2C/DO9DnLTAcp6yBQJ3bo3268eo0SLxO7YNq0Zq06sWPNs+pWjLBHwaaKdWKHKfM32JwgUhJbYofr698k1R44KUFbu4t/M7v5NwOHMDhDBZNIP4+4nk7sgpFoIXbBeihy+ULsNCz1H1Gw6VDtNJ/d2P6I8dIeMHQiZf8vEKdPpQ4HiHsFt3Fdq6cXtfNC1POt+/hh9u/VkcnkQWuW7bHXid1HHy7nuGCTbNuORCvRsNPYoVy0ccQ1Iiluid0LHGNO1wqH27bPPvnQ0Dhbz4PDRa06DYwpPn0woZfDRwteoXAcCqQl1M/BvheJna45cnLO0e8DS/rB+F/JkP7daMe2LerQCCit21whduBG9mgOJLDHRSBkyH4ODvtWt5gp84RK7Ky4WO8fzmt4Dytpz6YqyohfpVm3XiV2GHzDsUZ3hLG2HcGkEXdvycKZPs4k1nKRfh5Rv07sgikFhNhZeyR6x0LsNGzjk9ihGXdxYN1GzTtwnK8KWqv8d+Hh+sGKJfTZJx84kjp1FpwD4CQQSPAxhTfj+0vmhRXxP7EQO2AXTRwD9U1s8twSO+uUYLhtgAfy7OnjHU+DpzFisN1TrKSPRtl6wtqPV/FU7ZSQtBFeJHb9Bo81VvjAfZ0+dZzat4qxebXeK46thv9dObYiovkrsdq2BdNc4byJ0xeaMeisSwUmVGIH+zrY2TmJ/iygjJOjhX6+V4mdaiPCDj31zPPGiiVOgyJ4KL/DzjRr+btgJ5F+HnENIXZ2SMd/mhA7rQ/im9ihKbrHGZYY2rrlW8NOBPGjECj0NIckwFI+IGOhij5q//zT1XSDQySk4PhaqA9LTu1hg9czvCxUuGIldsFsX8KpPxLhTiKlsVPtjhaOqv5Ibd0SO2tIBESy3/fLTyE3D0vfOQV71ivBmpvFS5Tlv9JGaJusvDSUVUINgKt/zCMVgsMtjnoMMjiLNOfwDoEEwckRpFyJ1dsUgWVHjp+tsmkmr5ji9BFXhXSbvA9XvkfvzJmqsiihEjvEIMRshZPoJA1G/PVqVQ46ANbP8dJUrPUe4YVeileaKVXmPirBYYaspjkoj+XO9vy003qqT0y8SDyPuIAQOz+YPZEgxE7rBi8Qu85vDuB4RA8ZrRrA06KIN+ZGdM+qcAL9hnJNrEwwf8nHpo2e9cMRSh1OZbxG7KKJoxMGsU13S0hgeI5pGyWhekmr8rHdYs3g1xq28omLF2oAXJ3YYRCEj7lbcYsjprMxra0kkGE5yui2YZd5GbWm7G2qC+KVzV282vy9WZ0h9LLYh33opBnvmsnQokKbqiShEjuE/EDoDyeBJzI8QCGhkrSEQuz0e4bmDlO0r3OcRD1QPjTdCM5ulUg/j6hfiJ0VZW8cC7HT+iG+iR28oKaxsTMWZoY0a8BhBLQF3LWmhryrhy1w+sGHXJlNwcn84VBR9SNJHL1G7KKNow20sU5yS0hy5WLNEAcnVuIUU03lR3ILz++ps5aYmohQtW/WsDQIfhqbRdH1e3GLI6bP4BSlZArHU1vHcdXsBKt4jGJtHNY5huzfu4ft4fxXl5g6+z1zhQXY7XXh6Vp94XW9bmsYmqH9uxsBplWZhErsjh89TJ3bxQSBV/eDLXA0wp1wQFxIqM9uQiR2xg3yfxgQDRsT420K21R4slslGs+jEDsryt44FmKn9UN8EzvdEw3NQtwieLDi7y+2n7jEyz9hibFjRw/xeolHg04voI4J0xb4BCmFsTU+ltc5oOi1P/80lqaB/Q/WYLQLnIo6Akn/IeOo8D33mkXe6trKiNBuJvy3A+eQv1mTAkPfUMRrxC7aOIaCSahl3BISaGInz3yXvaZjlptDwNFvv1nn2IQCBe82FmVfveo9R+cbxLFCQO1AXq/w4h41fpbpRATP8BaNAnuTolHWe0ZYj0Dr1jreiJZhrTPccCe6lhfVIjZd947NbEPPNG/dmR7nRd6VIG4g4gdapVP3/nTf/Q+byU5auxw5c9GQkdPMZd8Q77Jjm4Y+8cUSKrHDzTvFSqxcpRo1bBYT2Bne2fDSDiZeJHYgqblz5zV+TwgS7iRYhm/qrPfMbMRKRMxEq0TjeRRiZ0XZG8dC7LR+CIfYQbuWiX9QVqlWsy7Bu01JZ36Z6l6rsG/79fQple2zta5y4JNpOcAHb/Omb+iD9xfRWXbPtxNoP6bNWUpp06azy/ZJU96IWJgZwYn1NvsUtBxgzcUatWLiX8GzFtHaf2Q7QNSBtWof5Qj2lR57xlj6bOv3vkbPeCmlYSyt0qPvcHNtQRBa2I3ocpU/khd4zVur6GvFRsrGLi5wtN5HOMdwutGXUXq40pNUgxeJV4LR+xmHZ+4k22za9bWdswjsudZ9/pHhYIO1hhEkOwdr9x59vLK5Fu0AXpJq987t6tI+W9h74WP14/YfaNM3X7JjwEEeWJw1gtsivWDBwlT1xTo+zkNbNm804i/6VGRzYNVaIPYaAlNv5OtA6w17vrwcLqjCg5VoB3sQol6rRANH/Z2C6x3je/549Qra+8suXgLsPBW6uyiVYHspFRYFZbBcVue2jW3taLGMG1aU0A3oYQKxi2MCHtj/s6E9L1zkXsPjOyeTAiV2jhYJmdghBA+mG7d8t8FYJhD2vlg7+JW6jUxsMIBt3eRlvwErsMueIyfdxv+UwMEMzjxKOtksL4g8hBVxskeO9DcBfY1lDzEg+H7TBg5rtYG/HSf4fX/WiC2IwXIJXo4OnuY5cuZRTWfv2Nm0/L13zGN9J9LPoxA7HV3v7Aux0/pC1xIhntaA3p21XN/dQSMmUcFC9/gmhnBkDQSqn4IXDpbygXdcqIIXzdgR/clKmNT5CB7bq/8oM6ipSg+0xb0PHfCmETsvUDnkYekirF2ZJgTyaF3/Ekvc4GOvx9oLdj2V/xXHqJtqE6MuGsQO14w2juq+wt1aDerDPb9+7WcNDa71PDyLQ0ZOMVc5sOY7HQcjdlik3iogljpR0fOd4jTqZdT+m72HGkbl6thpixUksGyeLtHCEdPawzn4eDjP+OhhfXjQtkFvns9+m449DQ2lT2KAA8So696phR+BT8jETr9dzAJgYGAVJ+9sa7xA63mBjkGee3aJWfpNLxvpb4Iidvo11L7TbwaEF8oEJ/IZ6edRiJ3qEW9thdhp/aFPtwULbjts9DRDA6CdHtJuIGKHCvCBK1m6HAclTmloYVLwEjlp06WnrFlzGAvXI0gxjnXBi63jG/X9VpVQZbJlz2FozlKkSGHUiZdg5sxZKQt7IWLkmidvAb8P60ccpX6uFqVe1WW3xRRys9adeAHyZHbZZpqV2Fkjq5sFQ9iJa2KHJkUbxxBu26+IVVPlVyBIghOxw2nQYLXgNYSLlygTpJZb2dCsYhH5o2wDZSe6h6ZdvjUNWgdoH0IVtHcQk1F9CtnuXDtiF00cKzxQkTDVGmzwc4W13VMnDAsYygP3A01Ns9ZdCPUGE5hzTJ80wtBqWcsmVGIHu7nC9xS33o7P8aaN6w3ybhe4F9pRBOuNjQQidpH+JgQidnZthwfw2BH9bAPW6+Uj+TwKsdOR9c6+ELv/+gIhF6DZUrJo/gxauXyROvTbWm3L/Ao4JFiDhDoUc0yG/VP5Cg/zGqdNzcCjKAwNBD5YsREQlipVaxLsU5TAFqpdy5jpPJXutIVGCx8vTHdZtS8Iq7Jh3ee05sOlPs4gIJgz5q0wnUWc6rZLR9+gj6wyasIsg8QiPZyp2CuXLxleiHbTktZrOB1HAkenup3Scc0xk+b5Ye5UXk/HtHmzBi/52F3p+dhHXyJ+FkJxwPPOqnmC4f6BfXt4qaNVhI+pkyE/6sIg4v4HH6X7ebm7/LziiRJMmWGNTwjwx+LrCLFiF7JBneO0Bel5+dXGxsoD+K1Y5TcO74MYkFb7u2jjmDnzXWz71Y6KFithaM+hWVHT5zCr2PvLHpo3cyKHHzprbbLjMTRP6Jds3C/oJ1Un+uA42+F+zmYV6Bcn0ddKRiBkBP5VUpqnh7uzBhQSaEkxDCrh5IU+jJZY14rtxU4lOTiQe3U2e4E5AOzP0NfAAP0LB5X3Fs3x01Cq9sGWEe+q2IiT/RrqivQ3AfdTuEgxw3zgfjYhUI41uJbqa+yjv9fzPa9YtsDWfhNlrBKp5xErpeA5hIQboLhbhyZU8eEYe1FrG90cy1qxslYsL7mTgXr3H22u1Qp7s45sYxEsWrmbB8/tuU9XrkqNW8TYncGeCAvex1bwEpnC3ogZM96yG8Tor27NZ8KuDmQNBr9JkiTlF+2tcAO/83JKbghT2I2IxxMihWM83oLjpfHxxAcV2mR4biNQNtYejk3fpmSvxYyZMlGGDJnYhuiK8dzBVhQ2d3ZaFsdGOWSgH6DBAxkFaYSd3fnfzznaozpUE5VktCtlqlSseUtPJ44fNWwM3VwI95crdz7+2F/j31wSrvMYk/Ubbqr01Ll2xG4few2jjxH7MEOGjAbROc82t3Z2t566GReNwaAF7+cMbJd8mbXjMGvAoBnv13DimlqbEOnn0Vp/oOO6desFyo51nhC7RE7sSpQqS42atjW98PAkrf9qLU0ed2u0GusnK4onYtqodbselIeNa5UEi8auytltoTnAyF83PD7MXrKwyxEJHQHBMXSspKQgECoCTsQu1POlnHcREGIXnb5JVFOxWKsSxv7wICpavBSVu+8BKlS4mA+yWKwbITugiYhPAUmAJ19a/kvPWg1MOeTkUXmx4iUpd578Pk3DVE4nNpi9evWqT7p+gNEt7h0LtqdjGz2o9eGxmidfQSpVuryf8fHC+W/TquWL9SpknxEQHOUxEATiFgEhdnGLd1xeTYhddNBONMRuxLgZfoTICikCfg7u1zXOp2uwjFgjtlVIniw5JeXpLhBQEIhQBK7wMFg/eGCfT3F4NIIM3qovmZ9tlE9hywE0lgimGpspNktVCf5QcEzwXSg3kMAREGKXwDswQPOF2AUAx0VWoiF2cxevMQ20rXjBpgfG1CvYRi2Q1st6XqSOq1Z/herWbxZWdTBW/mTNCtaqLfSL04SKAt2v04Xg8bWEbfV2bN/qVCTRpQuOia7L5YY9hoAQO491SASbI8QugmBqVSVaYgdPql/27KQd276njV9/Ea9Tr6EQO0y3nmSN4pEjh4xgpFhDFt5qThKMkIDMwjkEhtZ7f95FO3ds4RUtjjhVl2jTBcdE2/Vy4x5BwLrEHUI7nTxx3COtk2a4QUCInRv0nM91JHZ/3bhOzQaW9zszZaqYZYb8Mj2cgKC/WEYLi2tfunTBcAuPpot+OFDAGaIgR6C/ceNvunnjprFFGIHLHILj8uWLdJE9oHAcjjzGqz1AbrIrPOoFkb169TIhrMclYMBegjLVGhxRwTE4RlJCEIg2ArA3hnkK3lmBlqSLdjuk/sgiIMQusniq2hyJ3T/8A2rUL2aJFXVCQiV2qv2yFQQEAUFAEBAEBIH4R0CIXXT6QIhddHCVWgUBQUAQEAQEAUFAEIhzBITYxTnkckFBQBAQBAQBQUAQEASig4AQu+jgKrUKAoKAICAICAKCgCAQ5wgIsYtzyOWCgoAgIAgIAoKAICAIRAcBR2L3982/qemAsn5XFecJP0gkQRAQBAQBQUAQEAQEAU8g4EjsLlw+R+1HPe7XSCF2fpBIgiAgCAgCgoAgIAgIAp5AQIidJ7pBGiEICAKCgCAgCAgCgoB7BITYucdQahAEBAFBQBAQBAQBQcATCAix80Q3SCMEAUFAEBAEBAFBQBBwj4AQO/cYSg2CgCAgCAgCgoAgIAh4AgEhdp7oBmmEICAICAKCgCAgCAgC7hEQYuceQ6lBEBAEBAFBQBAQBAQBTyAgxM4T3SCNEAQEAUFAEBAEBAFBwD0CQuzcYyg1CAKCgCAgCAgCgoAg4AkEhNh5ohukEYKAICAICAKCgCAgCLhHQIidewylBkFAEBAEBAFBQBAQBDyBgBA7T3SDNEIQEAQEAUFAEBAEBAH3CAixc4+h1CAICAKCgCAgCAgCgoAnEBBi54lukEYIAoKAICAICAKCgCDgHgEhdu4xlBoEAUFAEBAEBAFBQBDwBAJC7DzRDdIIQUAQEAQEAUFAEBAE3CMgxM49hlKDICAICAKCgCAgCAgCnkBAiJ0nukEaIQgIAoKAICAICAKCgHsEhNi5x1BqEAQEAUFAEBAEBAFBwBMICLHzRDdIIwQBQUAQEAQEAUFAEHCPgBA79xhKDYKAICAICAKCgCAgCHgCASF2nugGaYQgIAgIAoKAICAICALuERBi5x5DqUEQEAQEAUFAEBAEBAFPICDEzhPdII0QBAQBQUAQEAQEAUHAPQJC7NxjKDUIAoKAICAICAKCgCDgCQSE2HmiG6QRgoAgIAgIAoKAICAIuEdAiJ17DKUGQUAQEAQEAUFAEBAEPIGAEDtPdIM0QhAQBAQBQUAQEAQEAfcICLFzj6HUIAgIAoKAICAICAKCgCcQEGLniW6QRggCgoAgIAgIAoKAIOAeASF27jGUGgQBQUAQEAQEAUFAEPAEAkLsPNEN0ghBQBAQBAQBQUAQEATcIyDEzj2GUoMgIAgIAoKAICAICAKeQECInSe6QRohCAgCgoAgIAgIAoKAewQcid1fN65Ts4Hl/a6QMlVGvzRJEAQEAUFAEBAEBAFBQBCIfwT+D/zF7ZhlIKO3AAAAAElFTkSuQmCC\"}]}],\"tools\":[{\"type\":\"function\",\"name\":\"read_screenshot\",\"description\":\"Capture a screenshot of the current screen.\",\"parameters\":{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"max_output_tokens\":40,\"stream\":true}" }, "response": { "status": 200, diff --git a/packages/llm/test/llm.test.ts b/packages/llm/test/llm.test.ts index 633a4662da..64346a8bb0 100644 --- a/packages/llm/test/llm.test.ts +++ b/packages/llm/test/llm.test.ts @@ -90,15 +90,47 @@ describe("llm constructors", () => { provider: "fake", route: chatRoute, }) - const updated = Model.update(base, { route: responsesRoute }) + const updated = Model.update(base, { + route: responsesRoute, + defaults: { generation: { maxTokens: 20 } }, + compatibility: { toolSchema: "gemini" }, + }) + const updatedInput = Model.input(updated) expect(updated).toBeInstanceOf(Model) expect(String(updated.id)).toBe("fake-model") expect(updated.route).toBe(responsesRoute) - expect(String(Model.input(updated).provider)).toBe("fake") + expect(updated.defaults?.generation).toEqual({ maxTokens: 20 }) + expect(updated.compatibility).toEqual({ toolSchema: "gemini" }) + expect(updatedInput.defaults).toBe(updated.defaults) + expect(updatedInput.compatibility).toBe(updated.compatibility) + expect(String(updatedInput.provider)).toBe("fake") expect(Model.update(updated, {})).toBe(updated) }) + test("carries model defaults and compatibility through route model selection", () => { + const model = chatRoute.model({ + id: "kimi-k2", + defaults: { + limits: { context: 128_000, output: 8_192 }, + generation: { maxTokens: 1_024, stop: ["END"] }, + providerOptions: { openai: { parallelToolCalls: false } }, + http: { body: { extra_body: true } }, + }, + compatibility: { toolSchema: "moonshot" }, + }) + const request = LLM.request({ model, prompt: "Say hello." }) + + expect(request.model.defaults?.limits).toEqual({ context: 128_000, output: 8_192 }) + expect(request.model.defaults?.generation).toEqual({ maxTokens: 1_024, stop: ["END"] }) + expect(request.model.defaults?.providerOptions).toEqual({ openai: { parallelToolCalls: false } }) + expect(request.model.defaults?.http).toEqual({ body: { extra_body: true } }) + expect(request.model.compatibility).toEqual({ toolSchema: "moonshot" }) + expect(request.generation).toBeUndefined() + expect(request.providerOptions).toBeUndefined() + expect(request.http).toBeUndefined() + }) + test("builds tool choices from names and tools", () => { const tool = ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }) diff --git a/packages/llm/test/prepare.test.ts b/packages/llm/test/prepare.test.ts new file mode 100644 index 0000000000..d6006095f4 --- /dev/null +++ b/packages/llm/test/prepare.test.ts @@ -0,0 +1,162 @@ +import { describe, expect, test } from "bun:test" +import { Effect, Schema } from "effect" +import { HttpClientRequest } from "effect/unstable/http" +import { LLM, mergeProviderOptions } from "../src" +import { AnthropicMessages, OpenAIChat } from "../src/protocols" +import { Auth, LLMClient } from "../src/route" +import { it } from "./lib/effect" +import { dynamicResponse } from "./lib/http" +import { deltaChunk } from "./lib/openai-chunks" +import { sseEvents } from "./lib/sse" + +const TargetJson = Schema.fromJsonString(Schema.Unknown) +const decodeJson = Schema.decodeUnknownSync(TargetJson) + +describe("request option precedence", () => { + test("deep-merges provider option records and replaces arrays, primitives, and null", () => { + const merged = mergeProviderOptions( + { + openai: { + include: ["route"], + metadata: { route: true, shared: "route" }, + nullable: "route", + primitive: "route", + }, + }, + { + openai: { + include: ["model"], + metadata: { model: true, shared: "model" }, + nullable: null, + primitive: "model", + }, + }, + { openai: { metadata: { request: true }, primitive: false } }, + ) + + expect(merged).toEqual({ + openai: { + include: ["model"], + metadata: { route: true, model: true, request: true, shared: "model" }, + nullable: null, + primitive: false, + }, + }) + }) + + it.effect("prepares bodies with route defaults, model defaults, and call options in order", () => + Effect.gen(function* () { + const route = OpenAIChat.route.with({ + endpoint: { baseURL: "https://api.openai.test/v1/" }, + auth: Auth.bearer("test"), + generation: { maxTokens: 10, temperature: 1, stop: ["route"] }, + providerOptions: { openai: { store: false, reasoningEffort: "low" } }, + }) + const model = route.model({ + id: "gpt-4o-mini", + defaults: { + generation: { maxTokens: 20, temperature: 0.5, frequencyPenalty: 0.25, stop: ["model"] }, + providerOptions: { openai: { reasoningEffort: "medium" } }, + }, + }) + const prepared = yield* LLMClient.prepare( + LLM.request({ + model, + prompt: "Say hello.", + generation: { maxTokens: 30, topP: 0.9, stop: ["request"] }, + providerOptions: { openai: { store: true } }, + }), + ) + + expect(prepared.body).toMatchObject({ + model: "gpt-4o-mini", + stream: true, + max_tokens: 30, + temperature: 0.5, + top_p: 0.9, + frequency_penalty: 0.25, + store: true, + reasoning_effort: "medium", + }) + expect(prepared.body.stop).toEqual(["request"]) + }), + ) + + it.effect("applies model HTTP defaults before request HTTP overlays", () => + LLMClient.generate( + LLM.request({ + model: OpenAIChat.route + .with({ + endpoint: { baseURL: "https://api.openai.test/v1/" }, + auth: Auth.bearer("fresh-key"), + http: { + body: { metadata: { route: true, shared: "route" }, value: "route" }, + headers: { "x-route": "route", "x-shared": "route" }, + query: { route: "1", shared: "route" }, + }, + }) + .model({ + id: "gpt-4o-mini", + defaults: { + http: { + body: { metadata: { model: true, shared: "model" }, value: "model" }, + headers: { "x-model": "model", "x-shared": "model" }, + query: { model: "1", shared: "model" }, + }, + }, + }), + prompt: "Say hello.", + http: { + body: { metadata: { request: true }, value: null }, + headers: { "x-request": "request" }, + query: { request: "1" }, + }, + }), + ).pipe( + Effect.provide( + dynamicResponse((input) => + Effect.gen(function* () { + const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie) + const url = new URL(web.url) + expect(url.searchParams.get("route")).toBe("1") + expect(url.searchParams.get("model")).toBe("1") + expect(url.searchParams.get("request")).toBe("1") + expect(url.searchParams.get("shared")).toBe("model") + expect(web.headers.get("authorization")).toBe("Bearer fresh-key") + expect(web.headers.get("x-route")).toBe("route") + expect(web.headers.get("x-model")).toBe("model") + expect(web.headers.get("x-request")).toBe("request") + expect(web.headers.get("x-shared")).toBe("model") + expect(decodeJson(input.text)).toMatchObject({ + metadata: { route: true, model: true, request: true, shared: "model" }, + value: null, + }) + return input.respond(sseEvents(deltaChunk({}, "stop")), { + headers: { "content-type": "text/event-stream" }, + }) + }), + ), + ), + ), + ) + + it.effect("uses model output limits after route limits and before call maxTokens", () => + Effect.gen(function* () { + const route = AnthropicMessages.route.with({ + endpoint: { baseURL: "https://api.anthropic.test/v1/" }, + auth: Auth.header("x-api-key", "test"), + limits: { output: 128 }, + }) + const model = route.model({ id: "claude-sonnet-4-5", defaults: { limits: { output: 64 } } }) + const withoutMaxTokens = yield* LLMClient.prepare( + LLM.request({ model, prompt: "Say hello.", cache: "none" }), + ) + const withMaxTokens = yield* LLMClient.prepare( + LLM.request({ model, prompt: "Say hello.", cache: "none", generation: { maxTokens: 32 } }), + ) + + expect(withoutMaxTokens.body.max_tokens).toBe(64) + expect(withMaxTokens.body.max_tokens).toBe(32) + }), + ) +}) diff --git a/packages/llm/test/provider/anthropic-messages.test.ts b/packages/llm/test/provider/anthropic-messages.test.ts index dabf512f6b..8989312958 100644 --- a/packages/llm/test/provider/anthropic-messages.test.ts +++ b/packages/llm/test/provider/anthropic-messages.test.ts @@ -395,6 +395,10 @@ describe("Anthropic Messages route", () => { expect(response.events.find((event) => event.type === "reasoning-end")).toMatchObject({ providerMetadata: { anthropic: { signature: "sig_1" } }, }) + expect(response.message.content).toEqual([ + { type: "text", text: "Hello!" }, + { type: "reasoning", text: "thinking", providerMetadata: { anthropic: { signature: "sig_1" } } }, + ]) expect(response.events.at(-1)).toMatchObject({ type: "finish", reason: "stop", diff --git a/packages/llm/test/provider/gemini.test.ts b/packages/llm/test/provider/gemini.test.ts index d30742c47d..1dc253c0ea 100644 --- a/packages/llm/test/provider/gemini.test.ts +++ b/packages/llm/test/provider/gemini.test.ts @@ -347,10 +347,10 @@ describe("Gemini route", () => { { type: "step-start", index: 0 }, { type: "reasoning-start", id: "reasoning-0" }, { type: "reasoning-delta", id: "reasoning-0", text: "thinking" }, + { type: "reasoning-end", id: "reasoning-0" }, { type: "text-start", id: "text-0" }, { type: "text-delta", id: "text-0", text: "Hello" }, { type: "text-delta", id: "text-0", text: "!" }, - { type: "reasoning-end", id: "reasoning-0" }, { type: "text-end", id: "text-0" }, { type: "step-finish", index: 0, reason: "stop", usage, providerMetadata: undefined }, { @@ -399,6 +399,9 @@ describe("Gemini route", () => { providerMetadata: { google: { thoughtSignature: "thought_sig" } }, }) expect(toolCall).toMatchObject({ providerMetadata: { google: { thoughtSignature: "tool_sig" } } }) + expect(response.events.findIndex((event) => event.type === "reasoning-end")).toBeLessThan( + response.events.findIndex((event) => event.type === "tool-call"), + ) const prepared = yield* LLMClient.prepare( LLM.request({ diff --git a/packages/llm/test/provider/openai-chat.test.ts b/packages/llm/test/provider/openai-chat.test.ts index 5dbc89f1ae..b736dc9dd3 100644 --- a/packages/llm/test/provider/openai-chat.test.ts +++ b/packages/llm/test/provider/openai-chat.test.ts @@ -1,7 +1,7 @@ import { describe, expect } from "bun:test" import { Effect, Schema, Stream } from "effect" import { HttpClientRequest } from "effect/unstable/http" -import { LLM, LLMError, Message, Model, ToolCallPart, Usage } from "../../src" +import { LLM, LLMError, LLMEvent, Message, Model, ToolCallPart, 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" @@ -542,9 +542,9 @@ describe("OpenAI Chat route", () => { { type: "step-start", index: 0 }, { type: "reasoning-start", id: "reasoning-0" }, { type: "reasoning-delta", id: "reasoning-0", text: "thinking" }, + { type: "reasoning-end", id: "reasoning-0" }, { type: "text-start", id: "text-0" }, { type: "text-delta", id: "text-0", text: "Hello" }, - { type: "reasoning-end", id: "reasoning-0" }, { type: "text-end", id: "text-0" }, { type: "step-finish", index: 0, reason: "stop" }, { type: "finish", reason: "stop" }, @@ -597,19 +597,22 @@ describe("OpenAI Chat route", () => { }), 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))) + const input = LLM.updateRequest(request, { + tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }], + }) + const events = Array.from( + yield* LLMClient.stream(input).pipe(Stream.runCollect, Effect.provide(fixedResponse(body))), + ) + const error = yield* LLMClient.generate(input).pipe(Effect.provide(fixedResponse(body)), Effect.flip) - expect(response.events).toEqual([ + expect(events).toEqual([ { type: "step-start", index: 0 }, { type: "tool-input-start", id: "call_1", name: "lookup", providerMetadata: undefined }, { 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([]) + expect(events.filter(LLMEvent.is.toolCall)).toEqual([]) + expect(error.message).toContain("Provider stream ended without a terminal finish event") }), ) diff --git a/packages/llm/test/provider/openai-responses.test.ts b/packages/llm/test/provider/openai-responses.test.ts index b854537fe2..f4969fb1a3 100644 --- a/packages/llm/test/provider/openai-responses.test.ts +++ b/packages/llm/test/provider/openai-responses.test.ts @@ -115,6 +115,7 @@ describe("OpenAI Responses route", () => { type: "function", name: "read", description: "Read a path or resource.", + strict: false, parameters: { type: "object", properties: { @@ -771,6 +772,11 @@ describe("OpenAI Responses route", () => { { type: "step-finish", index: 0, reason: "stop" }, { type: "finish", reason: "stop" }, ]) + expect(response.events.filter((event) => event.type === "finish")).toHaveLength(1) + expect(response.message.content).toEqual([ + { type: "reasoning", text: "thinking" }, + { type: "text", text: "Hello" }, + ]) }), ) diff --git a/packages/llm/test/response.test.ts b/packages/llm/test/response.test.ts new file mode 100644 index 0000000000..5e48e5ef45 --- /dev/null +++ b/packages/llm/test/response.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, test } from "bun:test" +import { LLMEvent, LLMResponse } from "../src" + +const reduce = (events: ReadonlyArray) => events.reduce(LLMResponse.reduce, LLMResponse.empty()) +const finishEvents = (events: ReadonlyArray) => events.filter(LLMEvent.is.finish) + +describe("LLMResponse reducer", () => { + test("assembles interleaved reasoning and text with end metadata", () => { + const events = [ + LLMEvent.reasoningStart({ id: "r1" }), + LLMEvent.reasoningDelta({ id: "r1", text: "I should " }), + LLMEvent.textStart({ id: "t1" }), + LLMEvent.reasoningDelta({ id: "r1", text: "compare..." }), + LLMEvent.reasoningEnd({ id: "r1", providerMetadata: { anthropic: { signature: "sig" } } }), + LLMEvent.textDelta({ id: "t1", text: "Answer" }), + LLMEvent.textEnd({ id: "t1" }), + LLMEvent.finish({ reason: "stop", usage: { outputTokens: 5 } }), + ] + const response = LLMResponse.fromEvents(events) + + expect(response?.finishReason).toBe("stop") + expect(response?.usage).toMatchObject({ outputTokens: 5 }) + expect(response?.events).toEqual(events) + expect(response?.events.map((event) => event.type)).toEqual([ + "reasoning-start", + "reasoning-delta", + "text-start", + "reasoning-delta", + "reasoning-end", + "text-delta", + "text-end", + "finish", + ]) + expect(finishEvents(response?.events ?? [])).toHaveLength(1) + expect(response?.message.content).toEqual([ + { + type: "reasoning", + text: "I should compare...", + providerMetadata: { anthropic: { signature: "sig" } }, + }, + { type: "text", text: "Answer" }, + ]) + }) + + test("preserves partial content without completing a failed stream", () => { + const state = reduce([LLMEvent.textStart({ id: "t1" }), LLMEvent.textDelta({ id: "t1", text: "partial" })]) + + expect(LLMResponse.complete(state)).toBeUndefined() + expect(state.message.content).toEqual([{ type: "text", text: "partial" }]) + }) + + test("does not complete ended content without a terminal finish", () => { + const state = reduce([ + LLMEvent.textStart({ id: "t1" }), + LLMEvent.textDelta({ id: "t1", text: "partial" }), + LLMEvent.textEnd({ id: "t1" }), + ]) + + expect(LLMResponse.complete(state)).toBeUndefined() + expect(state.message.content).toEqual([{ type: "text", text: "partial" }]) + }) + + test("uses terminal usage when present and keeps prior usage when finish omits it", () => { + const withFinishUsage = LLMResponse.fromEvents([ + LLMEvent.stepFinish({ index: 0, reason: "stop", usage: { inputTokens: 3 } }), + LLMEvent.finish({ reason: "stop", usage: { outputTokens: 2 } }), + ]) + const withoutFinishUsage = LLMResponse.fromEvents([ + LLMEvent.stepFinish({ index: 0, reason: "stop", usage: { inputTokens: 3 } }), + LLMEvent.finish({ reason: "stop" }), + ]) + + expect(withFinishUsage?.usage).toMatchObject({ outputTokens: 2 }) + expect(withoutFinishUsage?.usage).toMatchObject({ inputTokens: 3 }) + }) + + test("assembles tool-call content only after the completed tool call event", () => { + const pending = reduce([ + LLMEvent.toolInputStart({ id: "call_1", name: "lookup" }), + LLMEvent.toolInputDelta({ id: "call_1", name: "lookup", text: '{"query"' }), + ]) + + expect(pending.message.content).toEqual([]) + expect(pending.toolInputs.call_1?.text).toBe('{"query"') + + const response = LLMResponse.fromEvents([ + ...pending.events, + LLMEvent.toolInputDelta({ id: "call_1", name: "lookup", text: ':"weather"}' }), + LLMEvent.toolInputEnd({ id: "call_1", name: "lookup" }), + LLMEvent.toolCall({ id: "call_1", name: "lookup", input: { query: "weather" } }), + LLMEvent.finish({ reason: "tool-calls" }), + ]) + + expect(response?.message.content).toEqual([ + { type: "tool-call", id: "call_1", name: "lookup", input: { query: "weather" } }, + ]) + }) +}) diff --git a/packages/llm/test/tool-schema-projection.test.ts b/packages/llm/test/tool-schema-projection.test.ts new file mode 100644 index 0000000000..a9df815daf --- /dev/null +++ b/packages/llm/test/tool-schema-projection.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, test } from "bun:test" +import { Effect } from "effect" +import { LLM } from "../src" +import { OpenAIChat } from "../src/protocols" +import { ToolSchemaProjection } from "../src/protocols/utils/tool-schema" +import { Auth, LLMClient } from "../src/route" +import { it } from "./lib/effect" + +describe("tool schema projections", () => { + test("moonshot strips $ref siblings and converts tuple arrays to a schema object", () => { + expect( + ToolSchemaProjection.moonshot({ + type: "object", + properties: { + linked: { $ref: "#/$defs/Linked", description: "drop me" }, + tuple: { type: "array", items: [{ type: "string" }, { type: "number" }] }, + prefixTuple: { type: "array", prefixItems: [{ type: "boolean" }, { type: "string" }] }, + }, + }), + ).toEqual({ + type: "object", + properties: { + linked: { $ref: "#/$defs/Linked" }, + tuple: { type: "array", items: { anyOf: [{ type: "string" }, { type: "number" }] } }, + prefixTuple: { type: "array", items: { anyOf: [{ type: "boolean" }, { type: "string" }] } }, + }, + }) + }) + + test("gemini handles numeric enums, dangling required fields, untyped arrays, and scalar object keys", () => { + expect( + ToolSchemaProjection.gemini({ + type: "object", + required: ["status", "missing"], + properties: { + status: { type: "integer", enum: [1, 2] }, + tags: { type: "array" }, + name: { type: "string", properties: { ignored: { type: "string" } }, required: ["ignored"] }, + }, + }), + ).toEqual({ + type: "object", + required: ["status"], + properties: { + status: { type: "string", enum: ["1", "2"] }, + tags: { type: "array", items: { type: "string" } }, + name: { type: "string" }, + }, + }) + }) + + test("openai keeps one flat object top-level schema", () => { + expect( + ToolSchemaProjection.openAI({ + anyOf: [ + { + type: "object", + properties: { + path: { type: "string" }, + maybe: { anyOf: [{ type: "string" }, { type: "null" }] }, + }, + }, + { type: "object", properties: { resource: { type: "string" } } }, + ], + }), + ).toEqual({ + type: "object", + properties: { + path: { type: "string" }, + maybe: { type: "string" }, + resource: { type: "string" }, + }, + additionalProperties: false, + }) + }) + + it.effect("applies model compatibility before protocol projection", () => + Effect.gen(function* () { + const model = OpenAIChat.route + .with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") }) + .model({ id: "kimi-k2", compatibility: { toolSchema: "moonshot" } }) + const prepared = yield* LLMClient.prepare( + LLM.request({ + model, + prompt: "Use the tool.", + tools: [ + { + name: "lookup", + description: "Lookup data.", + inputSchema: { + type: "object", + anyOf: [ + { + type: "object", + properties: { + tuple: { type: "array", items: [{ type: "string" }, { type: "number" }] }, + linked: { $ref: "#/$defs/Linked", description: "drop me" }, + }, + }, + ], + }, + }, + ], + }), + ) + + expect(prepared.body.tools?.[0]?.function.parameters).toEqual({ + type: "object", + properties: { + tuple: { type: "array", items: { anyOf: [{ type: "string" }, { type: "number" }] } }, + linked: { $ref: "#/$defs/Linked" }, + }, + additionalProperties: false, + }) + }), + ) +}) diff --git a/packages/opencode/src/acp/permission.ts b/packages/opencode/src/acp/permission.ts index 357754e093..4eeca28f09 100644 --- a/packages/opencode/src/acp/permission.ts +++ b/packages/opencode/src/acp/permission.ts @@ -1,9 +1,16 @@ -import type { AgentSideConnection, PermissionOption, RequestPermissionResponse } from "@agentclientprotocol/sdk" +import type { + AgentSideConnection, + PermissionOption, + RequestPermissionResponse, + ToolCallContent, + ToolCallLocation, + ToolCallUpdate, +} from "@agentclientprotocol/sdk" import type { Event, OpencodeClient } from "@opencode-ai/sdk/v2" import { applyPatch } from "diff" import { exists, readText } from "@/util/filesystem" import type { ACPSession } from "./session" -import { toLocations, toToolKind, type ToolInput } from "./tool" +import { pendingToolCall, toLocations, type ToolInput } from "./tool" import { Effect } from "effect" type PermissionEvent = Extract @@ -54,14 +61,11 @@ export class Handler { const result = await this.input.connection .requestPermission({ sessionId: permission.sessionID, - toolCall: { + toolCall: await permissionToolCall({ toolCallId: permission.tool?.callID ?? permission.id, - status: "pending", - title: permission.permission, - rawInput: permission.metadata, - kind: toToolKind(permission.permission), - locations: toLocations(permission.permission, permission.metadata), - }, + toolName: permission.permission, + input: permission.metadata, + }), options: permissionOptions, }) .catch(async () => { @@ -111,6 +115,107 @@ export class Handler { } } +async function permissionToolCall(input: { + readonly toolCallId: string + readonly toolName: string + readonly input: ToolInput +}): Promise { + const toolCall = pendingToolCall({ + toolCallId: input.toolCallId, + toolName: input.toolName, + state: { + input: input.input, + title: permissionTitle(input.toolName, input.input), + }, + }) + const content = await permissionContent(input.toolName, input.input) + return { + ...toolCall, + locations: permissionLocations(input.toolName, input.input), + ...(content.length ? { content } : {}), + } +} + +function permissionTitle(toolName: string, input: ToolInput) { + const tool = toolName.toLocaleLowerCase() + switch (tool) { + case "external_directory": + return stringValue(input.description) ?? stringValue(input.command) ?? stringValue(input.parentDir) + + case "webfetch": + return stringValue(input.url) + + case "websearch": + return stringValue(input.query) + + case "grep": + case "glob": + return stringValue(input.pattern) + + case "read": + case "edit": + case "write": + return editTitle(input) + + default: + return undefined + } +} + +function editTitle(input: ToolInput) { + const files = fileMetadata(input) + if (files.length === 1) return files[0]?.relativePath ?? files[0]?.filePath + if (files.length > 1) return `${files.length} files` + return stringValue(input.filePath) ?? stringValue(input.filepath) ?? stringValue(input.path) +} + +function permissionLocations(toolName: string, input: ToolInput): ToolCallLocation[] { + const files = fileMetadata(input) + if (files.length) { + return Array.from( + new Set(files.flatMap((file) => [file.filePath, file.movePath].filter((path): path is string => !!path))), + (path) => ({ path }), + ) + } + return toLocations(toolName, input) +} + +async function permissionContent(toolName: string, input: ToolInput): Promise { + if (toolName.toLocaleLowerCase() !== "edit") return [] + + const files = fileMetadata(input) + if (files.length) return diffContentForFiles(files) + + const filepath = stringValue(input.filepath) ?? stringValue(input.filePath) + const diff = stringValue(input.diff) + if (!filepath || !diff) return [] + const content = await diffContentForPatch(filepath, diff) + return content ? [content] : [] +} + +async function diffContentForFiles(files: PermissionFileMetadata[]) { + const content = await Promise.all( + files.map(async (file) => { + if (!file.patch) return [] + const content = await diffContentForPatch(file.filePath, file.patch, file.movePath) + return content ? [content] : [] + }), + ) + return content.flat() +} + +async function diffContentForPatch(filepath: string, diff: string, displayPath = filepath) { + const content = (await exists(filepath)) ? await readText(filepath) : "" + const next = applyPatch(content, diff) + if (next === false) return undefined + return { + type: "diff" as const, + path: displayPath, + oldText: content, + newText: next, + } +} + function selectedReply(result: RequestPermissionResponse): Reply { if (result.outcome.outcome !== "selected") return "reject" if (result.outcome.optionId === "once" || result.outcome.optionId === "always") return result.outcome.optionId @@ -121,4 +226,29 @@ function stringValue(value: unknown) { return typeof value === "string" ? value : undefined } +type PermissionFileMetadata = { + readonly filePath: string + readonly relativePath?: string + readonly movePath?: string + readonly patch?: string +} + +function fileMetadata(input: ToolInput): PermissionFileMetadata[] { + if (!Array.isArray(input.files)) return [] + return input.files.flatMap((file): PermissionFileMetadata[] => { + if (!file || typeof file !== "object") return [] + const info = file as Record + const filePath = stringValue(info.filePath) + if (!filePath) return [] + return [ + { + filePath, + relativePath: stringValue(info.relativePath), + movePath: stringValue(info.movePath), + patch: stringValue(info.patch), + }, + ] + }) +} + export * as ACPPermission from "./permission" diff --git a/packages/opencode/src/cli/cmd/import.ts b/packages/opencode/src/cli/cmd/import.ts index 9eb151b633..1b7350f744 100644 --- a/packages/opencode/src/cli/cmd/import.ts +++ b/packages/opencode/src/cli/cmd/import.ts @@ -38,6 +38,17 @@ export function shouldAttachShareAuthHeaders(shareUrl: string, accountBaseUrl: s } } +export function formatImportFileError(file: string, error: FSUtil.Error) { + if (error._tag === "PlatformError") { + if (error.reason._tag === "NotFound") return `File not found: ${file}` + if (error.reason._tag === "PermissionDenied") return `Failed to read file: Permission denied` + return `Failed to read file: ${error.message}` + } + + const detail = error.cause instanceof Error ? error.cause.message : error.message + return `Invalid JSON in ${file}: ${detail}` +} + /** * Transform ShareNext API response (flat array) into the nested structure for local file storage. * @@ -154,14 +165,9 @@ const runImport = Effect.fn("Cli.import.body")(function* (file: string, ctx: Ins exportData = transformed } else { - exportData = (yield* fs.readJson(file).pipe(Effect.orElseSucceed(() => undefined))) as - | NonNullable - | undefined - if (!exportData) { - process.stdout.write(`File not found: ${file}`) - process.stdout.write(EOL) - return - } + exportData = (yield* fs + .readJson(file) + .pipe(Effect.mapError((error) => new CliError({ message: formatImportFileError(file, error) })))) as ExportData } if (!exportData) { diff --git a/packages/opencode/src/cli/cmd/run/footer.command.tsx b/packages/opencode/src/cli/cmd/run/footer.command.tsx index c97af0ce09..3176c1724f 100644 --- a/packages/opencode/src/cli/cmd/run/footer.command.tsx +++ b/packages/opencode/src/cli/cmd/run/footer.command.tsx @@ -633,6 +633,12 @@ export function RunSubagentSelectBody(props: { return } + if (event.name.toLowerCase() === "up" && menu.selected() === 0) { + event.preventDefault() + props.onClose() + return + } + handleKey({ event, menu, field: () => field, setQuery, select, close: props.onClose }) }) diff --git a/packages/opencode/src/cli/cmd/tui.ts b/packages/opencode/src/cli/cmd/tui.ts index dfd39d9156..37dce1f7b9 100644 --- a/packages/opencode/src/cli/cmd/tui.ts +++ b/packages/opencode/src/cli/cmd/tui.ts @@ -68,6 +68,19 @@ export function resolveThreadDirectory(project?: string, envPWD = process.env.PW return Filesystem.resolve(cwd) } +async function needsTranspileReexec() { + // The compiled binary embeds its sources; the candidate tsconfig only + // exists for source-mode runs, which are the only ones Bun re-transpiles. + const candidate = fileURLToPath(new URL("../../../tsconfig.json", import.meta.url)) + if (!(await Filesystem.exists(candidate))) return false + const local = path.join(process.cwd(), "tsconfig.json") + if (!(await Filesystem.exists(local))) return true + const raw = await Bun.file(local) + .text() + .catch(() => "") + return !raw.includes("@opentui/solid") +} + export const TuiThreadCommand = cmd({ command: "$0 [project]", describe: "start opencode tui", @@ -170,6 +183,24 @@ export const TuiThreadCommand = cmd({ return } + // Bun snapshots the JSX transpiler config from $cwd/tsconfig.json at + // startup. Booting the source CLI from a directory without an + // @opentui/solid jsxImportSource compiles the TUI's solid JSX against the + // react runtime and dies on the first component. Re-exec from the package + // directory, which owns a compatible tsconfig; the original directory + // still reaches the thread/worker via [project] resolution, so project + // keys stay on the user's directory. + if (await needsTranspileReexec()) { + const pkg = fileURLToPath(new URL("../../../", import.meta.url)) + const child = Bun.spawn([process.execPath, ...process.execArgv, Bun.main, ...process.argv.slice(2)], { + cwd: pkg, + stdin: "inherit", + stdout: "inherit", + stderr: "inherit", + }) + process.exit(await child.exited) + } + const unguard = win32InstallCtrlCGuard() try { const { TuiConfig } = await import("@/config/tui") diff --git a/packages/opencode/src/config/parse.ts b/packages/opencode/src/config/parse.ts index 3523908688..538f511311 100644 --- a/packages/opencode/src/config/parse.ts +++ b/packages/opencode/src/config/parse.ts @@ -37,22 +37,11 @@ export function schema>( data: unknown, source: string, ): DeepMutable { - const extra = topLevelExtraKeys(schema, data) - if (extra.length) { - throw new InvalidError({ - path: source, - issues: [ - { - code: "unrecognized_keys", - keys: extra, - path: [], - message: `Unrecognized key${extra.length === 1 ? "" : "s"}: ${extra.join(", ")}`, - }, - ], - }) - } - - const decoded = EffectSchema.decodeUnknownExit(schema)(data, { errors: "all", propertyOrder: "original" }) + const decoded = EffectSchema.decodeUnknownExit(schema)(data, { + errors: "all", + onExcessProperty: "ignore", + propertyOrder: "original", + }) if (Exit.isSuccess(decoded)) return decoded.value as DeepMutable const error = Cause.squash(decoded.cause) @@ -70,10 +59,3 @@ export function schema>( { cause: error }, ) } - -function topLevelExtraKeys(schema: EffectSchema.Top, data: unknown) { - if (typeof data !== "object" || data === null || Array.isArray(data)) return [] - if (schema.ast._tag !== "Objects" || schema.ast.indexSignatures.length > 0) return [] - const known = new Set(schema.ast.propertySignatures.map((item) => String(item.name))) - return Object.keys(data).filter((key) => !known.has(key)) -} diff --git a/packages/opencode/src/plugin/github-copilot/models.ts b/packages/opencode/src/plugin/github-copilot/models.ts index 7e6608e603..ffb8a090de 100644 --- a/packages/opencode/src/plugin/github-copilot/models.ts +++ b/packages/opencode/src/plugin/github-copilot/models.ts @@ -84,6 +84,9 @@ function build(key: string, remote: SelectableItem, url: string, prev?: Model): const image = (remote.capabilities.supports.vision ?? false) || (remote.capabilities.limits.vision?.supported_media_types ?? []).some((item) => item.startsWith("image/")) + const pdf = + (remote.capabilities.supports.vision ?? false) && + (remote.capabilities.limits.vision?.supported_media_types?.includes("application/pdf") ?? false) const isMsgApi = remote.supported_endpoints?.includes("/v1/messages") const prices = remote.billing?.token_prices @@ -115,7 +118,7 @@ function build(key: string, remote: SelectableItem, url: string, prev?: Model): audio: false, image, video: false, - pdf: false, + pdf, }, output: { text: true, diff --git a/packages/opencode/src/plugin/openai/ws-pool.ts b/packages/opencode/src/plugin/openai/ws-pool.ts index 3cbb29a301..939c2dc232 100644 --- a/packages/opencode/src/plugin/openai/ws-pool.ts +++ b/packages/opencode/src/plugin/openai/ws-pool.ts @@ -110,10 +110,11 @@ export function createWebSocketFetch(options?: CreateWebSocketFetchOptions) { invalidate(entry) } }, - onConnectionInvalid: (error) => { + onConnectionInvalid: (_error, closeCode) => { entry.busy = false entry.lastUsedAt = Date.now() - if (!entry.fallback) recordStreamFailure(entry) + if (closeCode === OpenAIWebSocket.MESSAGE_TOO_BIG_CLOSE_CODE) entry.fallback = true + else if (!entry.fallback) recordStreamFailure(entry) invalidate(entry) resolveFirstEvent(false) }, diff --git a/packages/opencode/src/plugin/openai/ws.ts b/packages/opencode/src/plugin/openai/ws.ts index 578d00b8ce..4335d9215a 100644 --- a/packages/opencode/src/plugin/openai/ws.ts +++ b/packages/opencode/src/plugin/openai/ws.ts @@ -9,6 +9,7 @@ import { ProxyEnv } from "@/util/proxy-env" import { isRecord } from "@/util/record" export const PROTOCOL_HEADER = "responses_websockets=2026-02-06" +export const MESSAGE_TOO_BIG_CLOSE_CODE = 1009 export interface ConnectResponsesWebSocketOptions { url: string @@ -26,7 +27,7 @@ export interface StreamResponsesWebSocketOptions { onComplete?: (event: Record) => void onTerminal?: (event: Record) => void onRetryableTerminal?: (event: Record) => Promise - onConnectionInvalid?: (error: ProviderError.ResponseStreamError) => void + onConnectionInvalid?: (error: ProviderError.ResponseStreamError, closeCode?: number) => void onAbort?: (error: Error) => void } @@ -162,11 +163,11 @@ export function streamResponsesWebSocket(options: StreamResponsesWebSocketOption controller?.close() } - function invalidate(error: ProviderError.ResponseStreamError) { + function invalidate(error: ProviderError.ResponseStreamError, closeCode?: number) { if (completed) return completed = true cleanup() - options.onConnectionInvalid?.(error) + options.onConnectionInvalid?.(error, closeCode) controller?.error(error) } @@ -274,6 +275,7 @@ export function streamResponsesWebSocket(options: StreamResponsesWebSocketOption if (completed) return invalidate( new ProviderError.ResponseStreamError(closeMessage("WebSocket closed before response.completed", code, reason)), + code, ) } @@ -373,7 +375,7 @@ function abortError(signal: AbortSignal | undefined) { function closeMessage(message: string, code: number, reason: Buffer) { const details = [`code ${code}`] - if (code === 1009) details.push("message too big") + if (code === MESSAGE_TOO_BIG_CLOSE_CODE) details.push("message too big") if (reason.length > 0) details.push(reason.toString()) return `${message} (${details.join(": ")})` } diff --git a/packages/opencode/src/provider/error.ts b/packages/opencode/src/provider/error.ts index 21149a2cf3..cebd08145d 100644 --- a/packages/opencode/src/provider/error.ts +++ b/packages/opencode/src/provider/error.ts @@ -144,6 +144,13 @@ export function parseStreamError(input: unknown): ParsedStreamError | undefined responseBody, } } + + return { + type: "api_error", + message: typeof body?.error?.message === "string" ? body.error.message : "Server error.", + isRetryable: true, + responseBody, + } } export type ParsedAPICallError = diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index e459380faa..b5789c03bd 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -46,6 +46,28 @@ function sdkKey(npm: string): string | undefined { return "vertex" case "@ai-sdk/google": return "google" + case "@ai-sdk/alibaba": + return "alibaba" + case "@ai-sdk/cerebras": + return "cerebras" + case "@ai-sdk/cohere": + return "cohere" + case "@ai-sdk/deepinfra": + return "deepinfra" + case "@ai-sdk/groq": + return "groq" + case "@ai-sdk/mistral": + return "mistral" + case "@ai-sdk/perplexity": + return "perplexity" + case "@ai-sdk/togetherai": + return "togetherai" + case "@ai-sdk/vercel": + return "vercel" + case "@ai-sdk/xai": + return "xai" + case "venice-ai-sdk-provider": + return "venice" case "@ai-sdk/gateway": return "gateway" case "@openrouter/ai-sdk-provider": @@ -172,11 +194,10 @@ function normalizeMessages( return part.text !== "" } if (part.type === "reasoning") { - return ( - part.text.trim().length > 0 || - part.providerOptions?.bedrock?.signature != null || - part.providerOptions?.bedrock?.redactedData != null - ) + // Match what the SDK can replay before assigning cache points. Otherwise + // unsigned reasoning can leave an empty or cache-point-only message. + const metadata = part.providerOptions?.[model.providerID] ?? part.providerOptions?.bedrock + return metadata?.signature != null || metadata?.redactedContent != null || metadata?.redactedData != null } return true }) @@ -215,10 +236,10 @@ function normalizeMessages( }) } + const modelID = model.api.id.toLowerCase() if ( model.providerID === "mistral" || - model.api.id.toLowerCase().includes("mistral") || - model.api.id.toLowerCase().includes("devstral") + ["mistral", "devstral", "codestral", "pixtral", "mixtral"].some((family) => modelID.includes(family)) ) { const scrub = (id: string) => { return id @@ -430,6 +451,9 @@ function mapProviderOptions( export function message(msgs: ModelMessage[], model: Provider.Model, options: Record) { msgs = unsupportedParts(msgs, model) msgs = normalizeMessages(msgs, model, options) + const usesAnthropicAutomaticCaching = + options.cacheControl !== undefined && + (model.api.npm === "@ai-sdk/anthropic" || model.api.npm === "@ai-sdk/google-vertex/anthropic") if ( (model.providerID === "anthropic" || model.providerID === "google-vertex-anthropic" || @@ -439,7 +463,8 @@ export function message(msgs: ModelMessage[], model: Provider.Model, options: Re model.id.includes("claude") || model.api.npm === "@ai-sdk/anthropic" || model.api.npm === "@ai-sdk/alibaba") && - model.api.npm !== "@ai-sdk/gateway" + model.api.npm !== "@ai-sdk/gateway" && + !usesAnthropicAutomaticCaching ) { msgs = applyCaching(msgs, model) } @@ -476,12 +501,19 @@ export function message(msgs: ModelMessage[], model: Provider.Model, options: Re return msgs } +const GEMINI_MODELS_WITH_SAMPLING_DEFAULTS = [ + /gemini-2[.-]5(?:[.-]|$)/, + /gemini-3-(?:flash|pro)(?:[.-]|$)/, + /gemini-3[.-]1(?:[.-]|$)/, + /gemini-3[.-]5-flash(?!-lite)(?:[.-]|$)/, +] + export function temperature(model: Provider.Model) { - const id = model.id.toLowerCase() + const id = model.api.id.toLowerCase() if (id.includes("north-mini-code")) return 1.0 - if (id.includes("qwen")) return 0.55 if (id.includes("claude")) return undefined - if (id.includes("gemini")) return 1.0 + if (id.includes("gemini")) + return GEMINI_MODELS_WITH_SAMPLING_DEFAULTS.some((model) => model.test(id)) ? 1.0 : undefined if (id.includes("glm-4.6")) return 1.0 if (id.includes("glm-4.7")) return 1.0 if (id.includes("minimax-m2")) return 1.0 @@ -496,21 +528,29 @@ export function temperature(model: Provider.Model) { } export function topP(model: Provider.Model) { - const id = model.id.toLowerCase() - if (id.includes("qwen")) return 1 - if (["minimax-m2", "gemini", "kimi-k2.5", "kimi-k2p5", "kimi-k2-5"].some((s) => id.includes(s))) { + const id = model.api.id.toLowerCase() + if (id.includes("gemini")) + return GEMINI_MODELS_WITH_SAMPLING_DEFAULTS.some((model) => model.test(id)) ? 0.95 : undefined + if (["minimax-m2", "kimi-k2.5", "kimi-k2p5", "kimi-k2-5"].some((s) => id.includes(s))) { + return 0.95 + } + if ( + ["deepseek-v4-flash-0731", "deepseek-v4-flash:0731"].some((name) => id.includes(name)) || + (id.includes("deepseek-v4-flash") && (model.providerID === "deepseek" || model.providerID.startsWith("opencode"))) + ) { return 0.95 } return undefined } export function topK(model: Provider.Model) { - const id = model.id.toLowerCase() + const id = model.api.id.toLowerCase() if (id.includes("minimax-m2")) { if (["m2.", "m25", "m21"].some((s) => id.includes(s))) return 40 return 20 } - if (id.includes("gemini")) return 64 + if (id.includes("gemini")) + return GEMINI_MODELS_WITH_SAMPLING_DEFAULTS.some((model) => model.test(id)) ? 64 : undefined return undefined } @@ -605,8 +645,14 @@ function anthropicOpus47OrLater(apiId: string) { return major > 4 || (major === 4 && minor >= 7) } +function anthropicSonnet5OrLater(apiId: string) { + const version = /sonnet-(\d+)(?:[.@-]|$)|claude-(\d+)-sonnet(?:[.@-]|$)/i.exec(apiId) + if (!version) return false + return Number(version[1] ?? version[2]) >= 5 +} + function anthropicAdaptiveEfforts(apiId: string): string[] | null { - if (anthropicOpus47OrLater(apiId) || apiId.includes("fable-5")) { + if (anthropicOpus47OrLater(apiId) || anthropicSonnet5OrLater(apiId) || apiId.includes("fable-5")) { return ["low", "medium", "high", "xhigh", "max"] } if ( @@ -620,7 +666,7 @@ function anthropicAdaptiveEfforts(apiId: string): string[] | null { } function anthropicOmitsThinking(apiId: string) { - return anthropicOpus47OrLater(apiId) || apiId.includes("fable-5") + return anthropicOpus47OrLater(apiId) || anthropicSonnet5OrLater(apiId) || apiId.includes("fable-5") } function googleThinkingLevelEfforts(apiId: string) { @@ -673,6 +719,12 @@ export function variants(model: Provider.Model): Record [ @@ -785,8 +836,8 @@ export function variants(model: Provider.Model): Record 5 || (Number(gptMajorVersion) === 5 && Number(gptMinorVersion) >= 5) + if (input.model.api.npm === "@ai-sdk/azure" && input.providerOptions?.useCompletionUrls) { + if (!isGpt55OrNewer) { + result["reasoningEffort"] = "medium" + } return result } @@ -1187,37 +1265,24 @@ export function options(input: { } } - // Only set textVerbosity for non-chat gpt-5.x models - // Chat models (e.g. gpt-5.2-chat-latest) only support "medium" verbosity + // Generic OpenAI-compatible APIs do not necessarily support OpenAI's verbosity parameter. + // Only enable the default for integrations known to implement it. if ( input.model.api.id.includes("gpt-5.") && !input.model.api.id.includes("codex") && !input.model.api.id.includes("-chat") && - input.model.providerID !== "azure" + (input.model.api.npm === "@ai-sdk/openai" || input.model.api.npm === "@ai-sdk/amazon-bedrock/mantle") ) { result["textVerbosity"] = "low" } - if (input.model.providerID.startsWith("opencode")) { + if (input.model.providerID.startsWith("opencode") && input.providerOptions?.setCacheKey !== false) { result["promptCacheKey"] = input.sessionID result["include"] = INCLUDE_ENCRYPTED_REASONING result["reasoningSummary"] = "auto" } } - if (input.model.providerID === "venice") { - result["promptCacheKey"] = input.sessionID - } - - if (input.model.providerID === "openrouter") { - result["prompt_cache_key"] = input.sessionID - } - if (input.model.api.npm === "@ai-sdk/gateway") { - result["gateway"] = { - caching: "auto", - } - } - return result } @@ -1226,15 +1291,13 @@ export function smallOptions(model: Provider.Model) { if ( model.providerID === "openai" || model.api.npm === "@ai-sdk/openai" || - model.api.npm === "@ai-sdk/github-copilot" + model.api.npm === "@ai-sdk/github-copilot" || + model.api.npm === "@ai-sdk/xai" ) { const base = { store: false } return mergeDeep(base, small) } if (model.providerID === "openrouter" || model.providerID === "llmgateway") { - if (model.providerID === "openrouter" && small.reasoning?.effort === "low") { - return { reasoning: { effort: "none" } } - } if (Object.keys(small).length === 0 && model.api.id.includes("google")) { return { reasoning: { enabled: false } } } @@ -1255,6 +1318,16 @@ const SLUG_OVERRIDES: Record = { } export function providerOptions(model: Provider.Model, options: { [x: string]: any }) { + const usesOpenAIReasoningGate = + model.api.npm === "@ai-sdk/openai" || + model.api.npm === "@ai-sdk/azure" || + model.api.npm === "@ai-sdk/amazon-bedrock/mantle" + const normalized = + usesOpenAIReasoningGate && + (model.capabilities.reasoning || options.reasoningEffort !== undefined || options.reasoningSummary !== undefined) + ? { ...options, forceReasoning: true } + : options + if (model.api.npm === "@ai-sdk/gateway") { // Gateway providerOptions are split across two namespaces: // - `gateway`: gateway-native routing/caching controls (order, only, byok, etc.) @@ -1264,8 +1337,8 @@ export function providerOptions(model: Provider.Model, options: { [x: string]: a const i = model.api.id.indexOf("/") const rawSlug = i > 0 ? model.api.id.slice(0, i) : undefined const slug = rawSlug ? (SLUG_OVERRIDES[rawSlug] ?? rawSlug) : undefined - const gateway = options.gateway - const rest = Object.fromEntries(Object.entries(options).filter(([k]) => k !== "gateway")) + const gateway = normalized.gateway + const rest = Object.fromEntries(Object.entries(normalized).filter(([k]) => k !== "gateway")) const has = Object.keys(rest).length > 0 const result: Record = {} @@ -1299,9 +1372,9 @@ export function providerOptions(model: Provider.Model, options: { [x: string]: a // providerOptions["openai"], but OpenAIResponsesLanguageModel checks // "azure" first. Pass both so model options work on either code path. if (model.api.npm === "@ai-sdk/azure") { - return { openai: options, azure: options } + return { openai: normalized, azure: normalized } } - return { [key]: options } + return { [key]: normalized } } export function maxOutputTokens(model: Provider.Model, outputTokenMax = OUTPUT_TOKEN_MAX): number { 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 e1377b6f75..6f0e5f608c 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts @@ -2,6 +2,7 @@ import { ProviderAuth } from "@/provider/auth" import { Config } from "@/config/config" import { ModelsDev } from "@opencode-ai/core/models-dev" import { Provider } from "@/provider/provider" +import { Auth } from "@/auth" import { mapValues } from "remeda" import { Effect, Schema } from "effect" @@ -36,6 +37,7 @@ export const providerHandlers = HttpApiBuilder.group(InstanceHttpApi, "provider" const cfg = yield* Config.Service const provider = yield* Provider.Service const svc = yield* ProviderAuth.Service + const authStore = yield* Auth.Service const list = Effect.fn("ProviderHttpApi.list")(function* () { const config = yield* cfg.get() @@ -47,6 +49,7 @@ export const providerHandlers = HttpApiBuilder.group(InstanceHttpApi, "provider" if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) filtered[key] = value } const connected = yield* provider.list() + const credentials = yield* authStore.all().pipe(Effect.orDie) const providers = Object.assign( mapValues(filtered, (item) => Provider.fromModelsDevProvider(item)), connected, @@ -54,7 +57,7 @@ export const providerHandlers = HttpApiBuilder.group(InstanceHttpApi, "provider" return { all: Object.values(providers).map(Provider.toPublicInfo), default: Provider.defaultModelIDs(providers), - connected: Object.keys(connected), + connected: Object.keys(providers).filter((id) => id in connected || credentials[id]), } }) diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/proxy.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/proxy.ts index e5362f8cbe..d21ab5647c 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/proxy.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/proxy.ts @@ -97,6 +97,29 @@ export function http( headers.delete("content-encoding") headers.delete("content-length") + // An upstream 5xx from a remote workspace sandbox arrives here as an opaque + // status โ€” its real cause (and log line) live only inside the sandbox. Buffer + // the small error body, log it locally so it shows up in the host's log, and + // forward it unchanged (preserving content-type so the client can still parse + // the structured error, e.g. its `ref`). + if (response.status >= 500) { + const body = yield* response.text.pipe(Effect.catch(() => Effect.succeed(""))) + const contentType = response.headers["content-type"] ?? "application/json" + headers.delete("content-type") + yield* Effect.logError("workspace proxy upstream error", { + url: url.toString(), + method: request.method, + status: response.status, + body: body.slice(0, 2000), + }) + return HttpServerResponse.text(body, { + status: response.status, + statusText: statusText(response), + headers, + contentType, + }) + } + return HttpServerResponse.stream(response.stream.pipe(Stream.catchCause(() => Stream.empty)), { status: response.status, statusText: statusText(response), diff --git a/packages/opencode/src/server/shared/workspace-routing.ts b/packages/opencode/src/server/shared/workspace-routing.ts index 0edfb2b305..de4dc67203 100644 --- a/packages/opencode/src/server/shared/workspace-routing.ts +++ b/packages/opencode/src/server/shared/workspace-routing.ts @@ -34,5 +34,12 @@ export function workspaceProxyURL(target: string | URL, requestURL: URL) { proxyURL.search = requestURL.search proxyURL.hash = requestURL.hash proxyURL.searchParams.delete("workspace") + // The `directory` param is the *host's* working directory (e.g. a Windows + // path like `F:\proj`). It is meaningless โ€” and dangerous โ€” on the remote: + // the sandbox would `path.resolve` it against its own cwd, producing a bogus + // path like `/home/daytona/workspace/repo/F:\proj` that does not exist and + // crashes prompt handling. Drop it so the remote falls back to its own + // project root. This mirrors ProxyUtil.headers stripping `x-opencode-directory`. + proxyURL.searchParams.delete("directory") return proxyURL } diff --git a/packages/opencode/src/session/llm/ai-sdk.ts b/packages/opencode/src/session/llm/ai-sdk.ts index 8db8985d7b..13d427aab6 100644 --- a/packages/opencode/src/session/llm/ai-sdk.ts +++ b/packages/opencode/src/session/llm/ai-sdk.ts @@ -2,6 +2,7 @@ import { FinishReason, LLMEvent, ProviderMetadata, ToolResultValue } from "@open import { Effect, Schema } from "effect" import { type streamText } from "ai" import { errorMessage } from "@/util/error" +import { ProviderError } from "@/provider/error" type Result = Awaited> type AISDKEvent = Result["fullStream"] extends AsyncIterable ? T : never @@ -85,6 +86,8 @@ export function toLLMEvents( return Effect.succeed([LLMEvent.stepStart({ index: state.step })]) case "finish-step": + if (event.rawFinishReason === "network_error") + return Effect.fail(new ProviderError.ResponseStreamError("Provider finish_reason: network_error")) return Effect.sync(() => { const original = providerMetadata(event.providerMetadata) const metadata = diff --git a/packages/opencode/src/session/llm/request.ts b/packages/opencode/src/session/llm/request.ts index 2785d98526..e000d6ca49 100644 --- a/packages/opencode/src/session/llm/request.ts +++ b/packages/opencode/src/session/llm/request.ts @@ -146,6 +146,16 @@ export const prepare = Effect.fn("LLMRequestPrep.prepare")(function* (input: Pre ) const tools = resolveTools(input) + // Codex parity: OpenAI Responses-family providers hardcode `strict: false` + // on every function tool so MCP-sourced and dynamic schemas that don't + // satisfy OpenAI's structured-outputs constraints still register. + if ( + input.model.api.npm === "@ai-sdk/openai" || + input.model.api.npm === "@ai-sdk/azure" || + input.model.api.npm === "@ai-sdk/amazon-bedrock/mantle" + ) { + for (const key of Object.keys(tools)) tools[key] = { ...tools[key], strict: false } + } if ( input.model.providerID.includes("github-copilot") && Object.keys(tools).length === 0 && @@ -186,9 +196,9 @@ export const prepare = Effect.fn("LLMRequestPrep.prepare")(function* (input: Pre : { "x-session-affinity": input.sessionID, "X-Session-Id": input.sessionID, - ...(input.parentSessionID ? { "x-parent-session-id": input.parentSessionID } : {}), "User-Agent": USER_AGENT, }), + ...(input.parentSessionID ? { "x-parent-session-id": input.parentSessionID } : {}), ...input.model.headers, ...headers, }, diff --git a/packages/opencode/src/session/retry.ts b/packages/opencode/src/session/retry.ts index 4139665bd2..9cc4199c85 100644 --- a/packages/opencode/src/session/retry.ts +++ b/packages/opencode/src/session/retry.ts @@ -25,14 +25,26 @@ export type Retryable = { export const RETRY_INITIAL_DELAY = 2000 export const RETRY_BACKOFF_FACTOR = 2 +export const RETRY_JITTER_FACTOR = 0.25 export const RETRY_MAX_DELAY_NO_HEADERS = 30_000 // 30 seconds export const RETRY_MAX_DELAY = 2_147_483_647 // max 32-bit signed integer for setTimeout +export const RETRY_MAX_RETRIES = 5 + +const RETRYABLE_MESSAGE_PATTERNS = [ + /429|500|502|503|504|524/i, + /rate increased too quickly|rate limit|rate-limit|rate_limit|too many requests/i, + /overloaded|service unavailable|service_unavailable|service-unavailable|internal error|internal_error|internal server error|server error|server_error|server-error|provider returned error|provider_returned_error|provider-returned-error/i, + /terminated|fetch failed|failed to fetch|network[-_\s]error|upstream connect|connection error|connection refused|connection lost|socket connection was closed|socket hang up|reset before headers|getaddrinfo|enotfound|eai_again|econnrefused|econnreset|etimedout/i, + /^timeout$|\b(?:request|response|connection|network|stream|read) (?:timeout|timed out|time out)\b/i, + /try your request again|retry your request|resource exhausted|resource_exhausted/i, + /\btry again (?:later|in\b)|\b(?:currently|temporarily) at capacity\b/i, +] function cap(ms: number) { return Math.min(ms, RETRY_MAX_DELAY) } -export function delay(attempt: number, error?: SessionV1.APIError) { +export function delay(attempt: number, error?: SessionV1.APIError, random = Math.random()) { if (error) { const headers = error.data.responseHeaders if (headers) { @@ -58,11 +70,16 @@ export function delay(attempt: number, error?: SessionV1.APIError) { } } - return cap(RETRY_INITIAL_DELAY * Math.pow(RETRY_BACKOFF_FACTOR, attempt - 1)) + return cap(exponential(attempt, random)) } } - return cap(Math.min(RETRY_INITIAL_DELAY * Math.pow(RETRY_BACKOFF_FACTOR, attempt - 1), RETRY_MAX_DELAY_NO_HEADERS)) + return cap(Math.min(exponential(attempt, random), RETRY_MAX_DELAY_NO_HEADERS)) +} + +function exponential(attempt: number, random: number) { + const base = RETRY_INITIAL_DELAY * Math.pow(RETRY_BACKOFF_FACTOR, attempt - 1) + return Math.ceil(base + base * RETRY_JITTER_FACTOR * random) } export function retryable(error: Err, provider: string) { @@ -72,7 +89,12 @@ export function retryable(error: Err, provider: string) { const status = error.data.statusCode // 5xx errors are transient server failures and should always be retried, // even when the provider SDK doesn't explicitly mark them as retryable. - if (!error.data.isRetryable && !(status !== undefined && status >= 500)) return undefined + if ( + !error.data.isRetryable && + !(status !== undefined && status >= 500) && + !matchesRetryableMessage(error.data.message) && + !matchesRetryableMessage(error.data.responseBody) + ) return undefined if (error.data.responseBody?.includes("FreeUsageLimitError")) { return { message: GO_UPSELL_MESSAGE, @@ -122,35 +144,19 @@ export function retryable(error: Err, provider: string) { return { message: error.data.message.includes("Overloaded") ? "Provider is overloaded" : error.data.message } } - // Check for rate limit patterns in plain text error messages - const msg = isRecord(error.data) ? error.data.message : undefined - if (typeof msg === "string") { - const lower = msg.toLowerCase() - if ( - lower.includes("rate increased too quickly") || - lower.includes("rate limit") || - lower.includes("too many requests") - ) { - return { message: msg } - } - } - - const json = parseJSON(msg) - if (!json || typeof json !== "object") return undefined - const code = typeof json.code === "string" ? json.code : "" - - if (json.type === "error" && json.error?.type === "too_many_requests") { - return { message: "Too Many Requests" } - } - if (code.includes("exhausted") || code.includes("unavailable")) { - return { message: "Provider is overloaded" } - } - if (json.type === "error" && typeof json.error?.code === "string" && json.error.code.includes("rate_limit")) { - return { message: "Rate Limited" } - } + const message = isRecord(error.data) ? error.data.message : undefined + if (typeof message !== "string") return undefined + const lower = message.toLowerCase() + if (lower.includes("too_many_requests")) return { message: "Too Many Requests" } + if (lower.includes("exhausted") || lower.includes("unavailable")) return { message: "Provider is overloaded" } + if (matchesRetryableMessage(message)) return { message } return undefined } +function matchesRetryableMessage(value: unknown) { + return typeof value === "string" && RETRYABLE_MESSAGE_PATTERNS.some((pattern) => pattern.test(value)) +} + function str(value: unknown) { if (value === undefined || value === null) return "" return String(value) @@ -183,6 +189,7 @@ export function policy(opts: { const error = opts.parse(meta.input) const retry = retryable(error, opts.provider) if (!retry) return Cause.done(meta.attempt) + if (meta.attempt > RETRY_MAX_RETRIES) return Cause.done(meta.attempt) return Effect.gen(function* () { const wait = delay(meta.attempt, SessionV1.APIError.isInstance(error) ? error : undefined) const now = yield* Clock.currentTimeMillis diff --git a/packages/opencode/src/skill/discovery.ts b/packages/opencode/src/skill/discovery.ts index 0495bc637d..bc2b19d7f5 100644 --- a/packages/opencode/src/skill/discovery.ts +++ b/packages/opencode/src/skill/discovery.ts @@ -13,6 +13,7 @@ const fileConcurrency = 8 class IndexSkill extends Schema.Class("IndexSkill")({ name: Schema.String, files: Schema.Array(Schema.String), + version: Schema.optional(Schema.String), }) {} class Index extends Schema.Class("Index")({ @@ -76,17 +77,53 @@ export const layer: Layer.Layer Effect.gen(function* () { const root = path.join(cache, skill.name) - - yield* Effect.forEach( - skill.files, - (file) => download(new URL(file, `${host}/${skill.name}/`).href, path.join(root, file)), - { - concurrency: fileConcurrency, - }, - ) - - const md = path.join(root, "SKILL.md") - return (yield* fs.exists(md).pipe(Effect.orDie)) ? root : null + const versionFile = path.join(root, ".opencode-version") + const version = skill.version + const current = + version === undefined + ? undefined + : yield* fs.readFileStringSafe(versionFile).pipe(Effect.catch(() => Effect.succeed(undefined))) + + if (version === undefined || current === version) { + yield* Effect.forEach( + skill.files, + (file) => download(new URL(file, `${host}/${skill.name}/`).href, path.join(root, file)), + { concurrency: fileConcurrency, discard: true }, + ) + } else { + const token = crypto.randomUUID() + const staging = `${root}.tmp-${token}` + const backup = `${root}.old-${token}` + yield* Effect.gen(function* () { + const downloaded = yield* Effect.forEach( + skill.files, + (file) => download(new URL(file, `${host}/${skill.name}/`).href, path.join(staging, file)), + { concurrency: fileConcurrency }, + ) + if (!downloaded.every(Boolean)) return + if (!(yield* fs.exists(path.join(staging, "SKILL.md")).pipe(Effect.orDie))) return + yield* fs.writeFileString(path.join(staging, ".opencode-version"), version) + yield* Effect.uninterruptible( + Effect.gen(function* () { + const cached = yield* fs.exists(root).pipe(Effect.orDie) + if (cached) yield* fs.rename(root, backup) + yield* fs.rename(staging, root).pipe( + Effect.catch((error) => + Effect.gen(function* () { + if (cached) yield* fs.rename(backup, root).pipe(Effect.ignore) + return yield* Effect.fail(error) + }), + ), + ) + if (cached) yield* fs.remove(backup, { recursive: true, force: true }).pipe(Effect.ignore) + }), + ) + }).pipe( + Effect.catch((error) => Effect.logError("failed to refresh skill", { skill: skill.name, error })), + Effect.ensuring(fs.remove(staging, { recursive: true, force: true }).pipe(Effect.ignore)), + ) + } + return (yield* fs.exists(path.join(root, "SKILL.md")).pipe(Effect.orDie)) ? root : null }), { concurrency: skillConcurrency }, ) diff --git a/packages/opencode/test/acp/permission.test.ts b/packages/opencode/test/acp/permission.test.ts index 966ff6ff1e..eaf3218e9c 100644 --- a/packages/opencode/test/acp/permission.test.ts +++ b/packages/opencode/test/acp/permission.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "bun:test" +import { afterEach, describe, expect, it } from "bun:test" import type { AgentSideConnection, RequestPermissionRequest, @@ -6,13 +6,22 @@ import type { SessionUpdate, } from "@agentclientprotocol/sdk" import type { Event, OpencodeClient } from "@opencode-ai/sdk/v2" +import { createTwoFilesPatch } from "diff" import { Effect, ManagedRuntime } from "effect" +import { mkdtemp, rm } from "node:fs/promises" +import { tmpdir } from "node:os" +import path from "node:path" import { ACPEvent } from "@/acp/event" import { ACPSession } from "@/acp/session" type PermissionEvent = Extract type PermissionReplyParams = Parameters[0] type SessionUpdateParams = Parameters[0] +const cleanupDirs: string[] = [] + +afterEach(async () => { + await Promise.all(cleanupDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))) +}) const pollUntil = async ( check: () => boolean | Promise, @@ -137,6 +146,14 @@ function textFromUpdates(updates: SessionUpdateParams[], sessionId: string) { .join("") } +async function tempFile(name: string, content: string) { + const dir = await mkdtemp(path.join(tmpdir(), "opencode-acp-permission-")) + cleanupDirs.push(dir) + const file = path.join(dir, name) + await Bun.write(file, content) + return file +} + describe("acp permissions", () => { it("sends requestPermission and replies with the selected outcome", async () => { const harness = createHarness() @@ -151,7 +168,7 @@ describe("acp permissions", () => { toolCall: { toolCallId: "call_1", status: "pending", - title: "bash", + title: "printf hello", rawInput: { command: "printf hello" }, kind: "execute", locations: [], @@ -165,6 +182,116 @@ describe("acp permissions", () => { expect(harness.replies).toEqual([{ requestID: "perm_1", reply: "once", directory: "/workspace" }]) }) + it("uses permission metadata for non-shell titles", async () => { + const harness = createHarness() + await createSession(harness.session, "ses_a") + + harness.subscription.handle( + permissionAsked("ses_a", "perm_fetch", { + permission: "webfetch", + metadata: { + url: "https://example.com/docs", + format: "markdown", + }, + tool: { messageID: "msg_1", callID: "call_1" }, + }), + ) + + await pollUntil(() => harness.replies.length === 1, "webfetch permission was never replied") + + expect(harness.requests[0]?.toolCall).toMatchObject({ + toolCallId: "call_1", + title: "https://example.com/docs", + kind: "fetch", + rawInput: { url: "https://example.com/docs", format: "markdown" }, + }) + }) + + it("includes a diff content block for edit permission metadata", async () => { + const filepath = await tempFile("file.ts", "before\n") + const harness = createHarness() + await createSession(harness.session, "ses_a") + + harness.subscription.handle( + permissionAsked("ses_a", "perm_edit", { + permission: "edit", + metadata: { + filepath, + diff: createTwoFilesPatch(filepath, filepath, "before\n", "after\n"), + }, + tool: { messageID: "msg_1", callID: "call_1" }, + }), + ) + + await pollUntil(() => harness.replies.length === 1, "edit permission was never replied") + + expect(harness.requests[0]?.toolCall).toMatchObject({ + toolCallId: "call_1", + title: filepath, + kind: "edit", + locations: [{ path: filepath }], + content: [ + { + type: "diff", + path: filepath, + oldText: "before\n", + newText: "after\n", + }, + ], + }) + }) + + it("includes per-file diff blocks and locations for apply_patch permission metadata", async () => { + const first = await tempFile("first.ts", "one\n") + const second = await tempFile("second.ts", "alpha\n") + const harness = createHarness() + await createSession(harness.session, "ses_a") + + harness.subscription.handle( + permissionAsked("ses_a", "perm_patch", { + permission: "edit", + metadata: { + filepath: "first.ts, second.ts", + files: [ + { + filePath: first, + relativePath: "first.ts", + patch: createTwoFilesPatch(first, first, "one\n", "two\n"), + }, + { + filePath: second, + relativePath: "second.ts", + patch: createTwoFilesPatch(second, second, "alpha\n", "beta\n"), + }, + ], + }, + tool: { messageID: "msg_1", callID: "call_1" }, + }), + ) + + await pollUntil(() => harness.replies.length === 1, "apply_patch permission was never replied") + + expect(harness.requests[0]?.toolCall).toMatchObject({ + toolCallId: "call_1", + title: "2 files", + locations: [{ path: first }, { path: second }], + content: [ + { + type: "diff", + path: first, + oldText: "one\n", + newText: "two\n", + }, + { + type: "diff", + path: second, + oldText: "alpha\n", + newText: "beta\n", + }, + ], + }) + }) + it("forwards external_directory metadata and locations to requestPermission", async () => { const harness = createHarness() await createSession(harness.session, "ses_a") @@ -189,7 +316,7 @@ describe("acp permissions", () => { toolCall: { toolCallId: "call_1", status: "pending", - title: "external_directory", + title: "Create external directory", rawInput: { command: "mkdir -p /tmp/outside", description: "Create external directory", diff --git a/packages/opencode/test/cli/import.test.ts b/packages/opencode/test/cli/import.test.ts index d7c0241e6b..59824c3efc 100644 --- a/packages/opencode/test/cli/import.test.ts +++ b/packages/opencode/test/cli/import.test.ts @@ -1,10 +1,46 @@ import { test, expect } from "bun:test" import { + formatImportFileError, parseShareUrl, shouldAttachShareAuthHeaders, transformShareData, type ShareData, } from "../../src/cli/cmd/import" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { PlatformError } from "effect" + +test("formats import file errors", () => { + expect( + formatImportFileError( + "test.json", + new PlatformError.PlatformError( + new PlatformError.SystemError({ + _tag: "NotFound", + module: "FileSystem", + method: "readFileString", + }), + ), + ), + ).toBe("File not found: test.json") + expect( + formatImportFileError( + "test.json", + new PlatformError.PlatformError( + new PlatformError.SystemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "readFileString", + }), + ), + ), + ).toBe("Failed to read file: Permission denied") + expect( + formatImportFileError( + "test.json", + new FSUtil.FileSystemError({ method: "readJson", cause: new SyntaxError("Unexpected token") }), + ), + ).toBe("Invalid JSON in test.json: Unexpected token") +}) // parseShareUrl tests test("parses valid share URLs", () => { diff --git a/packages/opencode/test/cli/run/footer.view.test.tsx b/packages/opencode/test/cli/run/footer.view.test.tsx index 2e9fd8ef17..d54e2e3fd6 100644 --- a/packages/opencode/test/cli/run/footer.view.test.tsx +++ b/packages/opencode/test/cli/run/footer.view.test.tsx @@ -642,6 +642,42 @@ test("direct subagent panel renders active subagents", async () => { } }) +test("direct subagent panel closes when moving up from the first item", async () => { + const [tabs] = createSignal([ + subagent({ sessionID: "s-1", label: "Explore", description: "Inspect auth flow" }), + subagent({ sessionID: "s-2", label: "General", description: "Write migration plan" }), + ]) + const [current] = createSignal() + let closed = 0 + + const app = await testRender( + () => ( + + RUN_THEME_FALLBACK.footer} + tabs={tabs} + current={current} + onClose={() => closed++} + onSelect={() => {}} + /> + + ), + { width: 100, height: RUN_SUBAGENT_PANEL_ROWS }, + ) + + try { + await app.renderOnce() + app.mockInput.pressKey("ARROW_DOWN") + app.mockInput.pressKey("ARROW_UP") + expect(closed).toBe(0) + + app.mockInput.pressKey("ARROW_UP") + expect(closed).toBe(1) + } finally { + app.renderer.destroy() + } +}) + test("direct queued prompt panel renders pending prompt actions", async () => { const [prompts] = createSignal([ { messageID: "m-1", partID: "p-1", prompt: { text: "fix the auth test", parts: [] } }, diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 02ace53668..a7487be04b 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -607,12 +607,12 @@ accountTokenIt.instance("resolves env templates in account config with account t }), ) -it.instance("validates config schema and throws on invalid fields", () => +it.instance("validates config schema and throws on invalid values", () => Effect.gen(function* () { const test = yield* TestInstance yield* writeConfigEffect(test.directory, { $schema: "https://opencode.ai/config.json", - invalid_field: "should cause error", + model: 42, }) const exit = yield* Config.use.get().pipe(Effect.exit) expect(Exit.isFailure(exit)).toBe(true) @@ -1316,7 +1316,7 @@ it.instance("permission config preserves user key order", () => }), ) -test("config parser preserves permission order while rejecting unknown top-level keys", () => { +test("config parser preserves permission order while ignoring unknown top-level keys", () => { const config = ConfigParse.schema( ConfigV1.Info, { @@ -1325,18 +1325,13 @@ test("config parser preserves permission order while rejecting unknown top-level "*": "deny", edit: "ask", }, + plugins: ["example"], }, "test", ) expect(Object.keys(config.permission!)).toEqual(["bash", "*", "edit"]) - try { - ConfigParse.schema(ConfigV1.Info, { invalid_field: true }, "test") - throw new Error("expected config parse to fail") - } catch (err) { - const error = err as { data?: { issues?: Array<{ code?: string; keys?: string[]; path?: string[] }> } } - expect(error.data?.issues?.[0]).toMatchObject({ code: "unrecognized_keys", keys: ["invalid_field"], path: [] }) - } + expect(config).not.toHaveProperty("plugins") }) // MCP config merging tests diff --git a/packages/opencode/test/fixtures/recordings/session/native-openai-oauth-tool-loop.json b/packages/opencode/test/fixtures/recordings/session/native-openai-oauth-tool-loop.json index 625140f991..380a572b0b 100644 --- a/packages/opencode/test/fixtures/recordings/session/native-openai-oauth-tool-loop.json +++ b/packages/opencode/test/fixtures/recordings/session/native-openai-oauth-tool-loop.json @@ -17,7 +17,7 @@ "headers": { "content-type": "application/json" }, - "body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]}],\"instructions\":\"Answer using tools when appropriate.\\nUse the get_weather tool exactly once to look up Paris, then reply with exactly: Paris is sunny.\",\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get the current weather for a city.\",\"parameters\":{\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}],\"store\":false,\"prompt_cache_key\":\"session-recorded-openai-oauth-loop\",\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"stream\":true}" + "body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]}],\"instructions\":\"Answer using tools when appropriate.\\nUse the get_weather tool exactly once to look up Paris, then reply with exactly: Paris is sunny.\",\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get the current weather for a city.\",\"parameters\":{\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"prompt_cache_key\":\"session-recorded-openai-oauth-loop\",\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"stream\":true}" }, "response": { "status": 200, @@ -33,7 +33,7 @@ "headers": { "content-type": "application/json" }, - "body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]},{\"type\":\"reasoning\",\"id\":\"rs_0812d6cbe7a2b19b016a1214d32f6881998bcd9ff2e739d7f2\",\"summary\":[],\"encrypted_content\":\"gAAAAABqEhTUCQT4XELlBu6r5VHqqtu5Il5WdX4m1upE8li0mPmIwgIykAmUTZWiE0213kmviuAgIrmhhiL4B8DXbWQD2vOEkQMhpZq_UCqc22SOg-4DpQLrebMWkzgAPL618VPu9mXNUIH9BW1sRhPdDSbbtK5_bitzsn-FMJGcO3UN7Ga2RW1Rdvt1M3m7J4MRlTutH8cwY8SthzgvOFEBS-_IrAhiwKVz4Se9Jlu3pVNMqhPF7kdrQOfDYui0v-AT8VrHBVomqekJl_dWESww0eWo6bS1PxZB4cLQHWp9JJi5pEECvU9Ntcz3GxuGJEtTKq5mFcRvCanXHOwZGmbBcWMNdVyikk3fxgIE2g9t8rCKJmhNXznMERtrfG2tey19qWbsVbo2YmBbg_5N02AA4NmEVvdfgHJx58nOfEEc2OZYk0YQ1fHBOkpBnwY61hxtrWFdj48QnTEKuvjAyNpX-KKFmMzL4531yLbEEzpaERlr11fDeoMpKofUoMsg3Jz8aTaZ1CpzI3O7iFzGDEV6gKh8vQYGrKOaOXnfBVDXDo8iJhZywpcQY6xB4NNf4pyyjFkR-vjgvBYV2hejlq2V1j8vQHgy8CsZJ6lW5oaTNMfP76MAHlwUwyMYj-cFmuX0epJdDWv8GDznUpOS-v2X5eNsvyx9qvvcTEMLsKJ--3_odisilj4vPhw16P9fB8eLmvESZmJRYmWM4mO7hPTVXOooOa-zxRHGhRQH9ouUea9UHSuH1A0o54qTEPr-JqYlQggugW449IuYW4HSMNMyeGdUNJfodWRu5cL0VPgk6zwTU3ArBq28FDgG7NZMk3njfCId351GZ8VRlTMA6U522_6FFaZ8-5gxsidOm0WULOwyTTo54tJsJFv2pgYUKs0VFWSwi3rvNMVMOgwOVIdSgZt1hFTxBImZh8HUIXUPvdOVKZzQmWT5M6uOTUsm5xsufhj8m79RuYZh2J0bkVOBzZ1As8zH-4v_r9d7e8464EuWXCln_6LAJdrTYgE2gVfHK0zeUaAMbIKhirOf0AVQZyfVsGvJ_CPqrPE_QSECeSA2D4TSa5Tc_IRY-Fb2_HKNCMEP2uvy\"},{\"type\":\"function_call\",\"call_id\":\"call_Ix5Urx04RtKsUJ75K0vTTgFF\",\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":{}}\"},{\"type\":\"function_call_output\",\"call_id\":\"call_Ix5Urx04RtKsUJ75K0vTTgFF\",\"output\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}],\"instructions\":\"Answer using tools when appropriate.\\nUse the get_weather tool exactly once to look up Paris, then reply with exactly: Paris is sunny.\",\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get the current weather for a city.\",\"parameters\":{\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}],\"store\":false,\"prompt_cache_key\":\"session-recorded-openai-oauth-loop\",\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"stream\":true}" + "body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]},{\"type\":\"reasoning\",\"id\":\"rs_0812d6cbe7a2b19b016a1214d32f6881998bcd9ff2e739d7f2\",\"summary\":[],\"encrypted_content\":\"gAAAAABqEhTUCQT4XELlBu6r5VHqqtu5Il5WdX4m1upE8li0mPmIwgIykAmUTZWiE0213kmviuAgIrmhhiL4B8DXbWQD2vOEkQMhpZq_UCqc22SOg-4DpQLrebMWkzgAPL618VPu9mXNUIH9BW1sRhPdDSbbtK5_bitzsn-FMJGcO3UN7Ga2RW1Rdvt1M3m7J4MRlTutH8cwY8SthzgvOFEBS-_IrAhiwKVz4Se9Jlu3pVNMqhPF7kdrQOfDYui0v-AT8VrHBVomqekJl_dWESww0eWo6bS1PxZB4cLQHWp9JJi5pEECvU9Ntcz3GxuGJEtTKq5mFcRvCanXHOwZGmbBcWMNdVyikk3fxgIE2g9t8rCKJmhNXznMERtrfG2tey19qWbsVbo2YmBbg_5N02AA4NmEVvdfgHJx58nOfEEc2OZYk0YQ1fHBOkpBnwY61hxtrWFdj48QnTEKuvjAyNpX-KKFmMzL4531yLbEEzpaERlr11fDeoMpKofUoMsg3Jz8aTaZ1CpzI3O7iFzGDEV6gKh8vQYGrKOaOXnfBVDXDo8iJhZywpcQY6xB4NNf4pyyjFkR-vjgvBYV2hejlq2V1j8vQHgy8CsZJ6lW5oaTNMfP76MAHlwUwyMYj-cFmuX0epJdDWv8GDznUpOS-v2X5eNsvyx9qvvcTEMLsKJ--3_odisilj4vPhw16P9fB8eLmvESZmJRYmWM4mO7hPTVXOooOa-zxRHGhRQH9ouUea9UHSuH1A0o54qTEPr-JqYlQggugW449IuYW4HSMNMyeGdUNJfodWRu5cL0VPgk6zwTU3ArBq28FDgG7NZMk3njfCId351GZ8VRlTMA6U522_6FFaZ8-5gxsidOm0WULOwyTTo54tJsJFv2pgYUKs0VFWSwi3rvNMVMOgwOVIdSgZt1hFTxBImZh8HUIXUPvdOVKZzQmWT5M6uOTUsm5xsufhj8m79RuYZh2J0bkVOBzZ1As8zH-4v_r9d7e8464EuWXCln_6LAJdrTYgE2gVfHK0zeUaAMbIKhirOf0AVQZyfVsGvJ_CPqrPE_QSECeSA2D4TSa5Tc_IRY-Fb2_HKNCMEP2uvy\"},{\"type\":\"function_call\",\"call_id\":\"call_Ix5Urx04RtKsUJ75K0vTTgFF\",\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":{}}\"},{\"type\":\"function_call_output\",\"call_id\":\"call_Ix5Urx04RtKsUJ75K0vTTgFF\",\"output\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}],\"instructions\":\"Answer using tools when appropriate.\\nUse the get_weather tool exactly once to look up Paris, then reply with exactly: Paris is sunny.\",\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get the current weather for a city.\",\"parameters\":{\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"prompt_cache_key\":\"session-recorded-openai-oauth-loop\",\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"stream\":true}" }, "response": { "status": 200, diff --git a/packages/opencode/test/fixtures/recordings/session/native-zen-tool-loop.json b/packages/opencode/test/fixtures/recordings/session/native-zen-tool-loop.json index afcfd9edc1..e25bc9fae6 100644 --- a/packages/opencode/test/fixtures/recordings/session/native-zen-tool-loop.json +++ b/packages/opencode/test/fixtures/recordings/session/native-zen-tool-loop.json @@ -17,7 +17,7 @@ "headers": { "content-type": "application/json" }, - "body": "{\"model\":\"gpt-5.2-codex\",\"input\":[{\"role\":\"system\",\"content\":\"Answer using tools when appropriate.\\nUse the get_weather tool exactly once to look up Paris, then reply with exactly: Paris is sunny.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get the current weather for a city.\",\"parameters\":{\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}],\"store\":false,\"prompt_cache_key\":\"session-recorded-opencode-loop\",\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"max_output_tokens\":32000,\"stream\":true}" + "body": "{\"model\":\"gpt-5.2-codex\",\"input\":[{\"role\":\"system\",\"content\":\"Answer using tools when appropriate.\\nUse the get_weather tool exactly once to look up Paris, then reply with exactly: Paris is sunny.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get the current weather for a city.\",\"parameters\":{\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"prompt_cache_key\":\"session-recorded-opencode-loop\",\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"max_output_tokens\":32000,\"stream\":true}" }, "response": { "status": 200, @@ -35,7 +35,7 @@ "headers": { "content-type": "application/json" }, - "body": "{\"model\":\"gpt-5.2-codex\",\"input\":[{\"role\":\"system\",\"content\":\"Answer using tools when appropriate.\\nUse the get_weather tool exactly once to look up Paris, then reply with exactly: Paris is sunny.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]},{\"type\":\"reasoning\",\"id\":\"rs_0fdce240b46054ad016a1214d326848196b269feebe1844759\",\"summary\":[],\"encrypted_content\":\"gAAAAABqEhTTGeallj_mC3ciDydiTVJLA6bjJfitoj4ftFfWwlxekFNaf_cDNWP3pE6qsvK9gKJNRfbAbpaEVf1qjAhQx53witrmt6H3KaaNJm3wXHG5sEi9gp3nLWK4T76tcVYHG1x6mbbTjEjCvhIuEkn_7Q7lJ1BErkEURYBBMPmkKya2-YuL8XP14Yrko9BA1t56BkwK5U3TFse4nwHI1qi82hdkX_aYAtz6YgbTpf-dvOCBGfeApxWLFotkt355Qy2b6MmPaH6cQwrvLJXOqEzGkwxFcs3mLEKLV103gd8Z5e_OapjJHTv_LarN-WN9C7nCQ0BBHClk4ND3SDdGb-XV665r23RB40GJ3Q9brJALGaJhij4uceXZNYbakZVOxgqLuDnX6EgABwEzrZb7vhVAKCewVYkLDu0LiS1rIvcFT8HpovxaBU2F2kVG7TRvzYewCW9zXWnAR048p5pUvi6zfMzapk8bnl4uM_uD45gp1sMzeSHryai1U0AUO2cLeQV1pA7KJoJBwWlHxo0YNPbDidI2KfByIoI0A7oiKoZ32vJkiwx3BEGePnzb-JQnv1eDXwlimICVKEVPk1BxpUZ2XBoWdUGYR77u5NGmZ2sKh4OM-qIaB0VaChGsCsJLyQ5_MCkeOm9EMjg1cXbIHDzs9jpF2BXlowY1Vw_L-Ve6nzwK7ZcyHM3ij27wEXYO2On6zbN_AqOvX_CFAjI7ktCYF2guftXuVpFCuiqRyDZ6i2RHXMhR77CoPT97sAvXDejN8feNtidqq4OH5uLa3BHYvW0UKfNlBCOL6A6927l4iTKURZznq_mVjLgTHWv9k-ByxP0hC5sIQHyB5hJaD8_svMr4Aqz_vH9Z8HShgjK47NsMQKxGGgaXdnq3xEdwydM-hTG4Pi35o6Kt0bbJ5KTRQ2ObjmnVTG7J__QTKMTrK2S6Ro4VIMrYzaai7BTLa8MGNotj\"},{\"type\":\"function_call\",\"call_id\":\"call_hwPdXfzZmrdySXU2ZmrL51Ln\",\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":{}}\"},{\"type\":\"function_call_output\",\"call_id\":\"call_hwPdXfzZmrdySXU2ZmrL51Ln\",\"output\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get the current weather for a city.\",\"parameters\":{\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}],\"store\":false,\"prompt_cache_key\":\"session-recorded-opencode-loop\",\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"max_output_tokens\":32000,\"stream\":true}" + "body": "{\"model\":\"gpt-5.2-codex\",\"input\":[{\"role\":\"system\",\"content\":\"Answer using tools when appropriate.\\nUse the get_weather tool exactly once to look up Paris, then reply with exactly: Paris is sunny.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]},{\"type\":\"reasoning\",\"id\":\"rs_0fdce240b46054ad016a1214d326848196b269feebe1844759\",\"summary\":[],\"encrypted_content\":\"gAAAAABqEhTTGeallj_mC3ciDydiTVJLA6bjJfitoj4ftFfWwlxekFNaf_cDNWP3pE6qsvK9gKJNRfbAbpaEVf1qjAhQx53witrmt6H3KaaNJm3wXHG5sEi9gp3nLWK4T76tcVYHG1x6mbbTjEjCvhIuEkn_7Q7lJ1BErkEURYBBMPmkKya2-YuL8XP14Yrko9BA1t56BkwK5U3TFse4nwHI1qi82hdkX_aYAtz6YgbTpf-dvOCBGfeApxWLFotkt355Qy2b6MmPaH6cQwrvLJXOqEzGkwxFcs3mLEKLV103gd8Z5e_OapjJHTv_LarN-WN9C7nCQ0BBHClk4ND3SDdGb-XV665r23RB40GJ3Q9brJALGaJhij4uceXZNYbakZVOxgqLuDnX6EgABwEzrZb7vhVAKCewVYkLDu0LiS1rIvcFT8HpovxaBU2F2kVG7TRvzYewCW9zXWnAR048p5pUvi6zfMzapk8bnl4uM_uD45gp1sMzeSHryai1U0AUO2cLeQV1pA7KJoJBwWlHxo0YNPbDidI2KfByIoI0A7oiKoZ32vJkiwx3BEGePnzb-JQnv1eDXwlimICVKEVPk1BxpUZ2XBoWdUGYR77u5NGmZ2sKh4OM-qIaB0VaChGsCsJLyQ5_MCkeOm9EMjg1cXbIHDzs9jpF2BXlowY1Vw_L-Ve6nzwK7ZcyHM3ij27wEXYO2On6zbN_AqOvX_CFAjI7ktCYF2guftXuVpFCuiqRyDZ6i2RHXMhR77CoPT97sAvXDejN8feNtidqq4OH5uLa3BHYvW0UKfNlBCOL6A6927l4iTKURZznq_mVjLgTHWv9k-ByxP0hC5sIQHyB5hJaD8_svMr4Aqz_vH9Z8HShgjK47NsMQKxGGgaXdnq3xEdwydM-hTG4Pi35o6Kt0bbJ5KTRQ2ObjmnVTG7J__QTKMTrK2S6Ro4VIMrYzaai7BTLa8MGNotj\"},{\"type\":\"function_call\",\"call_id\":\"call_hwPdXfzZmrdySXU2ZmrL51Ln\",\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":{}}\"},{\"type\":\"function_call_output\",\"call_id\":\"call_hwPdXfzZmrdySXU2ZmrL51Ln\",\"output\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get the current weather for a city.\",\"parameters\":{\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"prompt_cache_key\":\"session-recorded-opencode-loop\",\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"max_output_tokens\":32000,\"stream\":true}" }, "response": { "status": 200, diff --git a/packages/opencode/test/plugin/github-copilot-models.test.ts b/packages/opencode/test/plugin/github-copilot-models.test.ts index 1a63f3cb92..9ec4024dd4 100644 --- a/packages/opencode/test/plugin/github-copilot-models.test.ts +++ b/packages/opencode/test/plugin/github-copilot-models.test.ts @@ -330,3 +330,72 @@ test("remaps fallback oauth model urls to the enterprise host", async () => { expect(models.claude.api.url).toBe("https://copilot-api.ghe.example.com") expect(models.claude.api.npm).toBe("@ai-sdk/github-copilot") }) + +test("detects PDF input support when vision and media type are advertised", async () => { + globalThis.fetch = mock(() => + Promise.resolve( + new Response( + JSON.stringify({ + data: [ + { + model_picker_enabled: true, + id: "pdf-model", + name: "PDF Model", + version: "pdf-model-2026-06-01", + capabilities: { + family: "pdf-model", + limits: { + max_context_window_tokens: 128000, + max_output_tokens: 16384, + max_prompt_tokens: 128000, + vision: { + max_prompt_image_size: 10000000, + max_prompt_images: 10, + supported_media_types: ["application/pdf"], + }, + }, + supports: { + streaming: true, + vision: true, + tool_calls: true, + }, + }, + }, + { + model_picker_enabled: true, + id: "vision-only-model", + name: "Vision Only Model", + version: "vision-only-model-2026-06-01", + capabilities: { + family: "vision-only-model", + limits: { + max_context_window_tokens: 128000, + max_output_tokens: 16384, + max_prompt_tokens: 128000, + vision: { + max_prompt_image_size: 10000000, + max_prompt_images: 10, + supported_media_types: ["image/png"], + }, + }, + supports: { + streaming: true, + vision: true, + tool_calls: true, + }, + }, + }, + ], + }), + { status: 200 }, + ), + ), + ) as unknown as typeof fetch + + const models = (await CopilotModels.get("https://api.githubcopilot.com")).models + const model = models["pdf-model"] + + expect(model.capabilities.input.pdf).toBe(true) + expect(models["vision-only-model"].capabilities.input.pdf).toBe(false) +}) + diff --git a/packages/opencode/test/plugin/openai-ws.test.ts b/packages/opencode/test/plugin/openai-ws.test.ts index 7a125824e0..9e2ea3885a 100644 --- a/packages/opencode/test/plugin/openai-ws.test.ts +++ b/packages/opencode/test/plugin/openai-ws.test.ts @@ -232,6 +232,26 @@ describe("plugin.openai.ws-pool", () => { fetch.close() }) + test("falls back immediately to HTTP when a websocket request is too large", async () => { + let connections = 0 + await using server = await createWebSocketServer((socket) => { + connections += 1 + socket.once("message", () => socket.close(1009, "payload too large")) + }) + const fetch = OpenAIWebSocketPool.createWebSocketFetch({ + url: server.url, + }) + + const first = await fetch(server.url, streamRequest()) + const second = await fetch(server.url, streamRequest()) + + expect(await first.text()).toBe("http") + expect(await second.text()).toBe("http") + expect(connections).toBe(1) + expect(server.httpRequests).toHaveLength(2) + fetch.close() + }) + test("removes HTTP fallback when its session is deleted", async () => { let websocketAttempts = 0 await using server = await createRejectingWebSocketServer(() => websocketAttempts++) diff --git a/packages/opencode/test/provider/error.test.ts b/packages/opencode/test/provider/error.test.ts new file mode 100644 index 0000000000..49db280662 --- /dev/null +++ b/packages/opencode/test/provider/error.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, test } from "bun:test" +import { ProviderError } from "@/provider/error" + +describe("provider stream errors", () => { + test("retries provider stream errors without a code", () => { + const messages = [ + "The model is currently at capacity due to high demand. Please try again in a few minutes, or use a higher service tier for priority processing: https://docs.x.ai/developers/advanced-api-usage/priority-processing", + "The model is temporarily unavailable.", + ] + + for (const message of messages) + expect( + ProviderError.parseStreamError({ + type: "error", + error: { message }, + }), + ).toEqual({ + type: "api_error", + message, + isRetryable: true, + responseBody: JSON.stringify({ type: "error", error: { message } }), + }) + }) +}) diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index 5fa530155d..b74fe3c7ac 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -4,6 +4,9 @@ import { ProviderTransform } from "@/provider/transform" import { LLMRequestPrep } from "@/session/llm/request" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" +import { ModelsDev } from "@opencode-ai/core/models-dev" +import { generateText, jsonSchema, type ModelMessage } from "ai" +import { createAmazonBedrock } from "@ai-sdk/amazon-bedrock" describe("ProviderTransform.options - setCacheKey", () => { const sessionID = "test-session-123" @@ -86,6 +89,82 @@ describe("ProviderTransform.options - setCacheKey", () => { expect(result.promptCacheKey).toBe(sessionID) }) + test("should set promptCacheKey for the OpenAI SDK regardless of provider ID", () => { + const result = ProviderTransform.options({ + model: { + ...mockModel, + providerID: "custom-openai", + api: { id: "gpt-5", url: "https://example.com", npm: "@ai-sdk/openai" }, + }, + sessionID, + providerOptions: {}, + }) + expect(result.promptCacheKey).toBe(sessionID) + }) + + test("should not set promptCacheKey for the OpenAI-compatible SDK by provider name", () => { + const result = ProviderTransform.options({ + model: { + ...mockModel, + providerID: "openai", + api: { id: "gpt-5", url: "https://example.com", npm: "@ai-sdk/openai-compatible" }, + }, + sessionID, + providerOptions: {}, + }) + expect(result.promptCacheKey).toBeUndefined() + }) + + test("should not set promptCacheKey for openai when explicitly disabled", () => { + const openaiModel = { + ...mockModel, + providerID: "openai", + api: { + id: "gpt-4", + url: "https://api.openai.com", + npm: "@ai-sdk/openai", + }, + } + const result = ProviderTransform.options({ + model: openaiModel, + sessionID, + providerOptions: { setCacheKey: false }, + }) + expect(result.promptCacheKey).toBeUndefined() + }) + + test("should set promptCacheKey for the xAI SDK by default regardless of provider ID", () => { + const xaiModel = { + ...mockModel, + providerID: "custom-xai", + api: { + id: "grok-4", + url: "https://api.x.ai", + npm: "@ai-sdk/xai", + }, + } + const result = ProviderTransform.options({ model: xaiModel, sessionID, providerOptions: {} }) + expect(result.promptCacheKey).toBe(sessionID) + }) + + test("should not set promptCacheKey for the xAI SDK when explicitly disabled", () => { + const xaiModel = { + ...mockModel, + providerID: "xai", + api: { + id: "grok-4", + url: "https://api.x.ai", + npm: "@ai-sdk/xai", + }, + } + const result = ProviderTransform.options({ + model: xaiModel, + sessionID, + providerOptions: { setCacheKey: false }, + }) + expect(result.promptCacheKey).toBeUndefined() + }) + test("should set store=false for openai provider", () => { const openaiModel = { ...mockModel, @@ -104,6 +183,43 @@ describe("ProviderTransform.options - setCacheKey", () => { expect(result.store).toBe(false) }) + test("should set store=false for xAI provider by default", () => { + const xaiModel = { + ...mockModel, + providerID: "xai", + api: { + id: "grok-4", + url: "https://api.x.ai", + npm: "@ai-sdk/xai", + }, + } + const result = ProviderTransform.options({ + model: xaiModel, + sessionID, + providerOptions: {}, + }) + expect(result.store).toBe(false) + expect(result.promptCacheKey).toBe(sessionID) + }) + + test("should set store=false for xAI SDK regardless of provider ID", () => { + const xaiModel = { + ...mockModel, + providerID: "custom-xai", + api: { + id: "grok-4", + url: "https://api.x.ai", + npm: "@ai-sdk/xai", + }, + } + const result = ProviderTransform.options({ + model: xaiModel, + sessionID, + providerOptions: {}, + }) + expect(result.store).toBe(false) + }) + test("should set store=false for azure provider by default", () => { const azureModel = { ...mockModel, @@ -120,6 +236,70 @@ describe("ProviderTransform.options - setCacheKey", () => { providerOptions: {}, }) expect(result.store).toBe(false) + expect(result.promptCacheKey).toBe(sessionID) + }) + + test("should disable the Azure cache key without disabling store=false", () => { + const result = ProviderTransform.options({ + model: { + ...mockModel, + providerID: "azure", + api: { id: "gpt-5", url: "https://azure.com", npm: "@ai-sdk/azure" }, + }, + sessionID, + providerOptions: { setCacheKey: false }, + }) + expect(result.store).toBe(false) + expect(result.promptCacheKey).toBeUndefined() + }) + + test("should keep the Azure cache key for gpt-5.5 early return", () => { + const result = ProviderTransform.options({ + model: { + ...mockModel, + providerID: "azure", + api: { id: "gpt-5.5", url: "https://azure.com", npm: "@ai-sdk/azure" }, + }, + sessionID, + providerOptions: {}, + }) + expect(result.store).toBe(false) + expect(result.reasoningSummary).toBe("auto") + expect(result.promptCacheKey).toBe(sessionID) + }) + + for (const npm of ["@ai-sdk/deepinfra", "@ai-sdk/cerebras"]) { + test(`should set the snake-case cache key for ${npm}`, () => { + const result = ProviderTransform.options({ + model: { ...mockModel, providerID: "custom", api: { ...mockModel.api, npm } }, + sessionID, + providerOptions: {}, + }) + expect(result.prompt_cache_key).toBe(sessionID) + expect(result.promptCacheKey).toBeUndefined() + }) + } + + test("should set promptCacheKey for the Mistral SDK", () => { + const result = ProviderTransform.options({ + model: { ...mockModel, providerID: "custom", api: { ...mockModel.api, npm: "@ai-sdk/mistral" } }, + sessionID, + providerOptions: {}, + }) + expect(result.promptCacheKey).toBe(sessionID) + }) + + test("should not send an undocumented OpenRouter prompt_cache_key", () => { + const result = ProviderTransform.options({ + model: { + ...mockModel, + providerID: "openrouter", + api: { ...mockModel.api, npm: "@openrouter/ai-sdk-provider" }, + }, + sessionID, + providerOptions: {}, + }) + expect(result.prompt_cache_key).toBeUndefined() }) }) @@ -344,6 +524,7 @@ describe("ProviderTransform.options - gpt-5 textVerbosity", () => { expect(result.reasoningEffort).toBe("medium") expect(result.reasoningSummary).toBeUndefined() expect(result.include).toBeUndefined() + expect(result.textVerbosity).toBeUndefined() }) test("azure chat completions omit Responses-only reasoning options after variants merge", async () => { @@ -384,7 +565,12 @@ describe("ProviderTransform.options - gpt-5 textVerbosity", () => { } as any, system: [], messages: [{ role: "user", content: "Hello" }], - tools: {}, + tools: { + lookup: { + description: "Look up a value", + inputSchema: jsonSchema({ type: "object", properties: {} }), + }, + }, provider: { id: "azure", options: { useCompletionUrls: true } } as any, auth: undefined, plugin: { @@ -399,6 +585,7 @@ describe("ProviderTransform.options - gpt-5 textVerbosity", () => { expect(result.params.options.reasoningEffort).toBe("high") expect(result.params.options.reasoningSummary).toBeUndefined() expect(result.params.options.include).toBeUndefined() + expect(result.tools.lookup.strict).toBe(false) }) test("gpt-5.1 should have textVerbosity set to low", () => { @@ -496,15 +683,35 @@ describe("ProviderTransform.options - gpt-5 reasoningEffort", () => { expect(result.reasoningEffort).toBeUndefined() }) - test("gpt-5.5 should NOT set reasoningEffort", () => { + test("gpt-5.5 should NOT set reasoningEffort for the completions API", () => { const result = ProviderTransform.options({ model: createModel("gpt-5.5"), sessionID, - providerOptions: {}, + providerOptions: { useCompletionUrls: true }, + }) + + expect(result.reasoningEffort).toBeUndefined() + }) + + test("gpt-5.6 should NOT set reasoningEffort for the completions API", () => { + const result = ProviderTransform.options({ + model: createModel("gpt-5.6"), + sessionID, + providerOptions: { useCompletionUrls: true }, }) expect(result.reasoningEffort).toBeUndefined() }) + + test("gpt-5.6 should set reasoningEffort for the responses API", () => { + const result = ProviderTransform.options({ + model: createModel("gpt-5.6"), + sessionID, + providerOptions: {}, + }) + + expect(result.reasoningEffort).toBe("medium") + }) }) describe("ProviderTransform.options - gateway", () => { @@ -606,6 +813,95 @@ describe("ProviderTransform.providerOptions", () => { }) }) + test("forces reasoning for custom OpenAI package models with explicit effort", () => { + const model = createModel({ + providerID: "meta", + api: { + id: "muse-spark", + url: "https://api.ai.meta.com/v1", + npm: "@ai-sdk/openai", + }, + }) + + expect(ProviderTransform.providerOptions(model, { reasoningEffort: "xhigh", reasoningSummary: "auto" })).toEqual({ + openai: { forceReasoning: true, reasoningEffort: "xhigh", reasoningSummary: "auto" }, + }) + }) + + test("forces reasoning for OpenAI package models marked reasoning-capable", () => { + expect(ProviderTransform.providerOptions(createModel(), { store: false })).toEqual({ + openai: { forceReasoning: true, store: false }, + }) + }) + + test("uses canonical sdk key for custom xAI models", () => { + const model = createModel({ + providerID: "my-xai", + api: { id: "grok-4", url: "https://api.x.ai", npm: "@ai-sdk/xai" }, + }) + + expect(ProviderTransform.providerOptions(model, { promptCacheKey: "session" })).toEqual({ + xai: { promptCacheKey: "session" }, + }) + }) + + test("forces reasoning for explicit effort even when model is not marked reasoning-capable", () => { + const model = createModel({ + capabilities: { + temperature: true, + reasoning: false, + attachment: true, + toolcall: true, + input: { text: true, audio: false, image: true, video: false, pdf: false }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: false, + }, + }) + + expect(ProviderTransform.providerOptions(model, { reasoningEffort: "xhigh" })).toEqual({ + openai: { forceReasoning: true, reasoningEffort: "xhigh" }, + }) + }) + + test("forces reasoning for Azure OpenAI models with explicit effort", () => { + const model = createModel({ + providerID: "azure", + api: { + id: "custom-gpt-5-deployment", + url: "https://azure.openai.example.com/openai/v1", + npm: "@ai-sdk/azure", + }, + }) + + expect(ProviderTransform.providerOptions(model, { reasoningEffort: "xhigh" })).toEqual({ + openai: { forceReasoning: true, reasoningEffort: "xhigh" }, + azure: { forceReasoning: true, reasoningEffort: "xhigh" }, + }) + }) + + test("forces reasoning for Bedrock Mantle OpenAI models with explicit effort", () => { + const model = createModel({ + providerID: "amazon-bedrock", + api: { + id: "openai.gpt-5-custom", + url: "https://bedrock-mantle.us-east-2.api.aws/openai/v1", + npm: "@ai-sdk/amazon-bedrock/mantle", + }, + }) + + expect(ProviderTransform.providerOptions(model, { reasoningEffort: "xhigh" })).toEqual({ + openai: { forceReasoning: true, reasoningEffort: "xhigh" }, + }) + }) + + test("overrides forceReasoning false when reasoning should be forced", () => { + expect( + ProviderTransform.providerOptions(createModel(), { forceReasoning: false, reasoningEffort: "xhigh" }), + ).toEqual({ + openai: { forceReasoning: true, reasoningEffort: "xhigh" }, + }) + }) + test("uses gateway model provider slug for gateway models", () => { const model = createModel({ providerID: "vercel", @@ -700,7 +996,7 @@ describe("ProviderTransform.providerOptions", () => { }) expect(ProviderTransform.providerOptions(model, { reasoningEffort: "medium" })).toEqual({ - openai: { reasoningEffort: "medium" }, + openai: { forceReasoning: true, reasoningEffort: "medium" }, }) }) @@ -1499,6 +1795,55 @@ describe("ProviderTransform.schema - moonshot $ref siblings", () => { }) }) +describe("ProviderTransform.message - Mistral tool call IDs", () => { + test.each(["codestral-latest", "pixtral-large-latest", "open-mixtral-8x22b"])( + "normalizes IDs for custom OpenAI-compatible %s models", + (id) => { + const result = ProviderTransform.message( + [ + { + role: "assistant", + content: [ + { + type: "tool-call", + toolCallId: "toolu_01CBhTTz95qkd9LJMdC9sf8t", + toolName: "read", + input: { filePath: "/tmp/test" }, + }, + ], + }, + { + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "toolu_01CBhTTz95qkd9LJMdC9sf8t", + toolName: "read", + output: { type: "text", value: "test" }, + }, + ], + }, + ] as any, + { + id: `custom/${id}`, + providerID: "custom", + api: { + id, + url: "https://example.com/v1", + npm: "@ai-sdk/openai-compatible", + }, + } as any, + {}, + ) + + expect(result).toMatchObject([ + { role: "assistant", content: [{ type: "tool-call", toolCallId: "toolu01CB" }] }, + { role: "tool", content: [{ type: "tool-result", toolCallId: "toolu01CB" }] }, + ]) + }, + ) +}) + describe("ProviderTransform.message - DeepSeek reasoning content", () => { test("DeepSeek with tool calls includes reasoning_content in providerOptions", () => { const msgs = [ @@ -2018,6 +2363,134 @@ describe("ProviderTransform.message - anthropic empty content filtering", () => expect(result[1].content[0]).toEqual({ type: "text", text: "Answer" }) }) + describe("Bedrock reasoning replay", () => { + const model = { + ...anthropicModel, + id: "amazon-bedrock/anthropic.claude-opus-4-6", + providerID: "amazon-bedrock", + api: { + id: "anthropic.claude-opus-4-6", + url: "https://bedrock-runtime.us-east-1.amazonaws.com", + npm: "@ai-sdk/amazon-bedrock", + }, + } + + for (const cached of [false, true]) { + test(`omits unsigned reasoning before SDK conversion (caching: ${cached})`, async () => { + const selected = cached + ? model + : { ...model, id: "amazon-bedrock/openai.gpt-oss-120b", api: { ...model.api, id: "openai.gpt-oss-120b" } } + const messages = ProviderTransform.message( + [ + { role: "user", content: "Think" }, + { role: "assistant", content: [{ type: "text", text: "Earlier answer" }] }, + { + role: "assistant", + content: [ + { type: "reasoning", text: "Partial thought" }, + { type: "text", text: "" }, + ], + }, + { role: "user", content: "Continue" }, + ], + selected, + {}, + ) + expect(messages.map((message) => message.role)).toEqual(["user", "assistant", "user"]) + expect(messages[1].providerOptions?.bedrock?.cachePoint).toEqual(cached ? { type: "default" } : undefined) + const provider = createAmazonBedrock({ + apiKey: "test-key", + region: "us-east-1", + fetch: Object.assign( + async (...args: Parameters) => { + const body = JSON.parse(String(args[1]?.body)) + expect(body.messages).toEqual([ + { role: "user", content: [{ text: "Think" }] }, + { + role: "assistant", + content: [{ text: "Earlier answer" }, ...(cached ? [{ cachePoint: { type: "default" } }] : [])], + }, + { + role: "user", + content: [{ text: "Continue" }, ...(cached ? [{ cachePoint: { type: "default" } }] : [])], + }, + ]) + return Response.json({ + output: { message: { role: "assistant", content: [{ text: "Recovered" }] } }, + stopReason: "end_turn", + usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 }, + }) + }, + { preconnect: () => undefined }, + ), + }) + const result = await generateText({ model: provider(selected.api.id), messages, maxRetries: 0 }) + expect(result.text).toBe("Recovered") + }) + } + + for (const namespace of ["bedrock", "amazon-bedrock", "custom-bedrock"]) { + for (const field of ["signature", "redactedContent", "redactedData"]) { + test(`preserves ${namespace}.${field} on empty reasoning`, () => { + const result = ProviderTransform.message( + [ + { + role: "assistant", + content: [{ type: "reasoning", text: "", providerOptions: { [namespace]: { [field]: "opaque" } } }], + }, + ], + namespace === "custom-bedrock" ? { ...model, providerID: namespace } : model, + {}, + ) + expect(result).toHaveLength(1) + expect(result[0].content).toEqual([ + { type: "reasoning", text: "", providerOptions: { bedrock: { [field]: "opaque" } } }, + ]) + }) + } + } + + test("uses stored provider metadata when it will overwrite the SDK namespace", () => { + const result = ProviderTransform.message( + [ + { + role: "assistant", + content: [ + { + type: "reasoning", + text: "Partial thought", + providerOptions: { "amazon-bedrock": {}, bedrock: { signature: "overwritten" } }, + }, + ], + }, + ], + model, + {}, + ) + expect(result).toEqual([]) + }) + + test("keeps text and tool calls next to unsigned reasoning", () => { + const content: ModelMessage["content"] = [ + { type: "text", text: "Answer" }, + { type: "tool-call", toolCallId: "call_1", toolName: "lookup", input: {} }, + ] + const result = ProviderTransform.message( + [{ role: "assistant", content: [{ type: "reasoning", text: "Partial thought" }, ...content] }], + model, + {}, + ) + expect(result).toHaveLength(1) + expect(result[0].content).toEqual(content) + }) + + test("does not remove unsigned reasoning for other providers", () => { + const content: ModelMessage["content"] = [{ type: "reasoning", text: "Partial thought" }] + const result = ProviderTransform.message([{ role: "assistant", content }], anthropicModel, {}) + expect(result[0].content).toEqual(content) + }) + }) + test("does not filter for non-anthropic providers", () => { const openaiModel = { ...anthropicModel, @@ -2713,6 +3186,20 @@ describe("ProviderTransform.message - cache control on gateway", () => { }) }) + test("does not add explicit breakpoints when Anthropic automatic caching is enabled", () => { + const model = createModel({ + providerID: "anthropic", + api: { id: "claude-sonnet-4", url: "https://api.anthropic.com", npm: "@ai-sdk/anthropic" }, + }) + const msgs = [ + { role: "system", content: "You are a helpful assistant" }, + { role: "user", content: "Hello" }, + ] as any[] + + const result = ProviderTransform.message(msgs, model, { cacheControl: { type: "ephemeral" } }) as any[] + expect(result.every((message) => message.providerOptions === undefined)).toBe(true) + }) + test("google-vertex-anthropic applies cache control", () => { const model = createModel({ providerID: "google-vertex-anthropic", @@ -2773,7 +3260,82 @@ describe("ProviderTransform.message - cache control on gateway", () => { describe("ProviderTransform.temperature - Cohere North", () => { test("defaults north-mini-code models to 1.0", () => { - expect(ProviderTransform.temperature({ id: "cohere/North-Mini-Code-1-0-latest" } as any)).toBe(1.0) + expect( + ProviderTransform.temperature({ + id: "cohere/North-Mini-Code-1-0-latest", + api: { id: "North-Mini-Code-1-0-latest" }, + } as any), + ).toBe(1.0) + }) +}) + +describe("ProviderTransform sampling defaults - Qwen", () => { + test.each(["Qwen3.8-27B", "qwen3-coder-30b-a3b-instruct"])('leaves sampling unset for "%s"', (id) => { + const model = { + id: `custom/${id}`, + api: { id }, + } as any + + expect(ProviderTransform.temperature(model)).toBeUndefined() + expect(ProviderTransform.topP(model)).toBeUndefined() + expect(ProviderTransform.topK(model)).toBeUndefined() + }) +}) + +describe("ProviderTransform sampling defaults - Gemini", () => { + const model = (id: string) => + ({ + id: `google/${id}`, + api: { id }, + }) as any + + const alias = (id: string, apiID: string) => + ({ + id, + api: { id: apiID }, + }) as any + + test.each([ + "gemini-3.5-flash-lite", + "gemini-3-5-flash-lite", + "gemini-3.6-flash", + "gemini-3-6-flash", + "gemini-4-pro", + "gemini-future", + ])("omits deprecated sampling controls for %s", (id) => { + expect(ProviderTransform.temperature(model(id))).toBeUndefined() + expect(ProviderTransform.topP(model(id))).toBeUndefined() + expect(ProviderTransform.topK(model(id))).toBeUndefined() + }) + + test.each([ + "gemini-2.5-flash", + "gemini-2.5-pro", + "gemini-2.5-flash-lite", + "gemini-2-5-flash-lite", + "gemini-3-flash-preview", + "gemini-3-pro-image", + "gemini-3.1-flash-lite", + "gemini-3.1-pro-preview", + "gemini-3-1-pro-preview", + "gemini-3.5-flash", + "gemini-3-5-flash", + ])("preserves sampling defaults for %s", (id) => { + expect(ProviderTransform.temperature(model(id))).toBe(1) + expect(ProviderTransform.topP(model(id))).toBe(0.95) + expect(ProviderTransform.topK(model(id))).toBe(64) + }) + + test("uses the API model ID for configured aliases", () => { + const deprecated = alias("google/gemini-3.5-flash", "google/gemini-3.6-flash") + expect(ProviderTransform.temperature(deprecated)).toBeUndefined() + expect(ProviderTransform.topP(deprecated)).toBeUndefined() + expect(ProviderTransform.topK(deprecated)).toBeUndefined() + + const supported = alias("my-gemini", "google/gemini-2.5-flash") + expect(ProviderTransform.temperature(supported)).toBe(1) + expect(ProviderTransform.topP(supported)).toBe(0.95) + expect(ProviderTransform.topK(supported)).toBe(64) }) }) @@ -2881,6 +3443,22 @@ describe("ProviderTransform.variants", () => { }) }) + test.each(["nvidia", "lilac"])("%s minimax m3 returns chat template thinking toggles", (providerID) => { + const model = createMockModel({ + id: `${providerID}/minimaxai/minimax-m3`, + providerID, + api: { + id: "minimaxai/minimax-m3", + url: "https://api.example.com/v1", + npm: "@ai-sdk/openai-compatible", + }, + }) + expect(ProviderTransform.variants(model)).toEqual({ + none: { chat_template_kwargs: { thinking_mode: "disabled" } }, + thinking: { chat_template_kwargs: { thinking_mode: "enabled" } }, + }) + }) + test("glm returns empty object", () => { const model = createMockModel({ id: "glm/glm-4", @@ -3128,7 +3706,7 @@ describe("ProviderTransform.variants", () => { expect(Object.keys(result)).toEqual(["low", "medium", "high"]) }) - test("grok-4 returns empty object", () => { + test("grok-4 uses the provider's standard efforts", () => { const model = createMockModel({ id: "openrouter/grok-4", providerID: "openrouter", @@ -3139,7 +3717,8 @@ describe("ProviderTransform.variants", () => { }, }) const result = ProviderTransform.variants(model) - expect(result).toEqual({}) + expect(Object.keys(result)).toEqual(["low", "medium", "high"]) + expect(result.medium).toEqual({ reasoning: { effort: "medium" } }) }) test("grok-3-mini returns low and high with reasoning", () => { @@ -3160,6 +3739,42 @@ describe("ProviderTransform.variants", () => { }) describe("@ai-sdk/gateway", () => { + test("configured anthropic aliases route by the API ID", () => { + const model = createMockModel({ + id: "my-claude", + providerID: "gateway", + api: { + id: "anthropic/claude-sonnet-4-6", + url: "https://gateway.ai", + npm: "@ai-sdk/gateway", + }, + }) + const result = ProviderTransform.variants(model) + expect(Object.keys(result)).toEqual(["low", "medium", "high", "max"]) + expect(result.high).toEqual({ + thinking: { + type: "adaptive", + }, + effort: "high", + }) + }) + + test("configured google aliases route by the API ID", () => { + const model = createMockModel({ + id: "my-gemini", + providerID: "gateway", + api: { + id: "google/gemini-2.5-pro", + url: "https://gateway.ai", + npm: "@ai-sdk/gateway", + }, + }) + expect(ProviderTransform.variants(model)).toEqual({ + high: { thinkingConfig: { includeThoughts: true, thinkingBudget: 16_000 } }, + max: { thinkingConfig: { includeThoughts: true, thinkingBudget: 32_768 } }, + }) + }) + test("anthropic sonnet 4.6 models return adaptive thinking options", () => { const model = createMockModel({ id: "anthropic/claude-sonnet-4-6", @@ -3283,6 +3898,27 @@ describe("ProviderTransform.variants", () => { }) }) + test("anthropic sonnet 5 returns adaptive thinking options with xhigh", () => { + const model = createMockModel({ + id: "anthropic/claude-sonnet-5", + providerID: "gateway", + api: { + id: "anthropic/claude-sonnet-5", + url: "https://gateway.ai", + npm: "@ai-sdk/gateway", + }, + }) + const result = ProviderTransform.variants(model) + expect(Object.keys(result)).toEqual(["low", "medium", "high", "xhigh", "max"]) + expect(result.high).toEqual({ + thinking: { + type: "adaptive", + display: "summarized", + }, + effort: "high", + }) + }) + test("anthropic opus 4.6 omits display so it keeps the summarized default", () => { const model = createMockModel({ id: "anthropic/claude-opus-4-6", @@ -3534,18 +4170,19 @@ describe("ProviderTransform.variants", () => { }) describe("@ai-sdk/xai", () => { - test("grok-3 returns empty object", () => { + test("grok-4.5 uses standard reasoning efforts", () => { const model = createMockModel({ - id: "xai/grok-3", + id: "xai/grok-4.5", providerID: "xai", api: { - id: "grok-3", + id: "grok-4.5", url: "https://api.x.ai", npm: "@ai-sdk/xai", }, }) const result = ProviderTransform.variants(model) - expect(result).toEqual({}) + expect(Object.keys(result)).toEqual(["low", "medium", "high"]) + expect(result.medium).toEqual({ reasoningEffort: "medium" }) }) test("grok-3-mini returns low and high with reasoningEffort", () => { @@ -3870,6 +4507,12 @@ describe("ProviderTransform.variants", () => { efforts: ["low", "medium", "high", "xhigh", "max"], expectedHigh: { thinking: { type: "adaptive", display: "summarized" }, effort: "high" }, }, + { + name: "sonnet 5", + apiIds: ["claude-sonnet-5", "claude-sonnet-5-20260630"], + efforts: ["low", "medium", "high", "xhigh", "max"], + expectedHigh: { thinking: { type: "adaptive", display: "summarized" }, effort: "high" }, + }, { name: "fable 5", apiIds: ["claude-fable-5"], @@ -3967,6 +4610,28 @@ describe("ProviderTransform.variants", () => { effort: "high", }) }) + + test("sonnet 5 uses adaptive reasoning for Vertex model IDs", () => { + const result = ProviderTransform.variants( + createMockModel({ + id: "google-vertex-anthropic/claude-sonnet-5@default", + providerID: "google-vertex-anthropic", + api: { + id: "claude-sonnet-5@default", + url: "https://us-central1-aiplatform.googleapis.com", + npm: "@ai-sdk/google-vertex/anthropic", + }, + }), + ) + expect(Object.keys(result)).toEqual(["low", "medium", "high", "xhigh", "max"]) + expect(result.high).toEqual({ + thinking: { + type: "adaptive", + display: "summarized", + }, + effort: "high", + }) + }) }) describe("@ai-sdk/amazon-bedrock", () => { @@ -4040,6 +4705,28 @@ describe("ProviderTransform.variants", () => { }) }) + test("anthropic sonnet 5 returns adaptive reasoning options with xhigh", () => { + const result = ProviderTransform.variants( + createMockModel({ + id: "bedrock/anthropic-claude-sonnet-5", + providerID: "bedrock", + api: { + id: "anthropic.claude-sonnet-5", + url: "https://bedrock.amazonaws.com", + npm: "@ai-sdk/amazon-bedrock", + }, + }), + ) + expect(Object.keys(result)).toEqual(["low", "medium", "high", "xhigh", "max"]) + expect(result.high).toEqual({ + reasoningConfig: { + type: "adaptive", + maxReasoningEffort: "high", + display: "summarized", + }, + }) + }) + test("returns WIDELY_SUPPORTED_EFFORTS with reasoningConfig", () => { const model = createMockModel({ id: "bedrock/llama-4", @@ -4222,6 +4909,12 @@ describe("ProviderTransform.variants", () => { efforts: ["low", "medium", "high", "xhigh", "max"], thinking: { type: "adaptive", display: "summarized" }, }, + { + name: "sonnet 5", + apiIds: ["anthropic--claude-sonnet-5", "anthropic--claude-5-sonnet"], + efforts: ["low", "medium", "high", "xhigh", "max"], + thinking: { type: "adaptive", display: "summarized" }, + }, ]) { for (const apiId of testCase.apiIds) { test(`${testCase.name} ${apiId} returns adaptive thinking variants under modelParams`, () => { @@ -4400,12 +5093,12 @@ describe("ProviderTransform.smallOptions - gpt-5 chat/search", () => { } }) -test("ProviderTransform.smallOptions disables OpenRouter reasoning when the weakest effort is low", () => { +test("ProviderTransform.smallOptions preserves the weakest OpenRouter reasoning effort", () => { expect( ProviderTransform.smallOptions({ providerID: "openrouter", api: { - id: "anthropic/claude-sonnet-4.6", + id: "google/gemini-3.5-flash", npm: "@openrouter/ai-sdk-provider", }, variants: { @@ -4414,7 +5107,7 @@ test("ProviderTransform.smallOptions disables OpenRouter reasoning when the weak high: { reasoning: { effort: "high" } }, }, } as any), - ).toEqual({ reasoning: { effort: "none" } }) + ).toEqual({ reasoning: { effort: "low" } }) }) describe("ProviderTransform.smallOptions - google thinking controls", () => { @@ -4502,3 +5195,35 @@ describe("ProviderTransform.providerOptions - ai-gateway-provider", () => { expect(result).toEqual({ openaiCompatible: { reasoningEffort: "high" } }) }) }) + +describe("ProviderTransform sampling defaults - DeepSeek", () => { + const model = (providerID: string, id: string) => + ({ + id: `${providerID}/${id}`, + providerID, + api: { id }, + }) as any + + test.each([ + ["deepseek", "deepseek-v4-flash"], + ["opencode", "deepseek-v4-flash"], + ["opencode-go", "deepseek-v4-flash"], + ["openrouter", "deepseek/deepseek-v4-flash-0731"], + ["ollama-cloud", "deepseek-v4-flash:0731"], + ])("defaults top_p for %s/%s", (providerID, id) => { + expect(ProviderTransform.temperature(model(providerID, id))).toBeUndefined() + expect(ProviderTransform.topP(model(providerID, id))).toBe(0.95) + expect(ProviderTransform.topK(model(providerID, id))).toBeUndefined() + }) + + test.each([ + ["openrouter", "deepseek/deepseek-v4-flash"], + ["vercel", "deepseek/deepseek-v4-flash"], + ["custom", "deepseek-ai/DeepSeek-V4-Flash"], + ])("preserves legacy defaults for %s/%s", (providerID, id) => { + expect(ProviderTransform.temperature(model(providerID, id))).toBeUndefined() + expect(ProviderTransform.topP(model(providerID, id))).toBeUndefined() + expect(ProviderTransform.topK(model(providerID, id))).toBeUndefined() + }) +}) + diff --git a/packages/opencode/test/server/workspace-routing.test.ts b/packages/opencode/test/server/workspace-routing.test.ts index 9ae0f3e632..29c22038c3 100644 --- a/packages/opencode/test/server/workspace-routing.test.ts +++ b/packages/opencode/test/server/workspace-routing.test.ts @@ -80,6 +80,13 @@ describe("workspaceProxyURL", () => { expect(result.searchParams.get("keep")).toBe("yes") }) + test("strips the host directory param so the remote resolves its own root", () => { + const url = new URL("http://localhost/session/abc?directory=F%3A%5Cproj&keep=yes") + const result = workspaceProxyURL("http://remote:8080/base", url) + expect(result.searchParams.get("directory")).toBeNull() + expect(result.searchParams.get("keep")).toBe("yes") + }) + test("preserves hash from request", () => { const url = new URL("http://localhost/page#section") const result = workspaceProxyURL("http://remote:8080", url) diff --git a/packages/opencode/test/session/llm.test.ts b/packages/opencode/test/session/llm.test.ts index 0c5dadaf17..4ce134a14f 100644 --- a/packages/opencode/test/session/llm.test.ts +++ b/packages/opencode/test/session/llm.test.ts @@ -27,6 +27,7 @@ import { LLMAISDK } from "@/session/llm/ai-sdk" import { Session as SessionNs } from "@/session/session" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" +import { ProviderError } from "@/provider/error" type ConfigModel = NonNullable[string]["models"]>[string] @@ -754,6 +755,75 @@ function createEventResponse(chunks: unknown[], includeDone = false) { describe("session.llm.stream", () => { const vivgridFixture = { providerID: "vivgrid", modelID: "gemini-3.1-pro-preview" } + const opencodeFixture = { providerID: "opencode-test", modelID: vivgridFixture.modelID } + + it.instance( + "sends the parent session header for opencode providers", + () => + Effect.gen(function* () { + const fixture = loadFixture(vivgridFixture.providerID, vivgridFixture.modelID) + const request = waitRequest( + "/chat/completions", + new Response(createChatStream("Hello"), { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }), + ) + const resolved = yield* Provider.use.getModel( + ProviderV2.ID.make(opencodeFixture.providerID), + ModelV2.ID.make(opencodeFixture.modelID), + ) + const sessionID = SessionID.make("session-child") + const parentSessionID = SessionID.make("session-parent") + const agent = { + name: "test", + mode: "primary", + options: {}, + permission: [{ permission: "*", pattern: "*", action: "allow" }], + } satisfies Agent.Info + const user = { + id: MessageID.make("msg_user-parent-header"), + sessionID, + role: "user", + time: { created: Date.now() }, + agent: agent.name, + model: { + providerID: ProviderV2.ID.make(opencodeFixture.providerID), + modelID: resolved.id, + }, + } satisfies SessionV1.User + + yield* drain({ + user, + sessionID, + parentSessionID, + model: resolved, + agent, + system: ["You are a helpful assistant."], + messages: [{ role: "user", content: "Hello" }], + tools: {}, + }) + + expect((yield* Effect.promise(() => request)).headers.get("x-parent-session-id")).toBe(parentSessionID) + }), + { + config: () => { + const fixture = loadFixture(vivgridFixture.providerID, vivgridFixture.modelID) + return { + enabled_providers: [opencodeFixture.providerID], + provider: { + [opencodeFixture.providerID]: { + name: "OpenCode Test", + npm: "@ai-sdk/openai-compatible", + models: { [fixture.model.id]: configModel(fixture.model) as ConfigModel }, + options: { apiKey: "test-key", baseURL: `${state.server!.url.origin}/v1` }, + }, + }, + } + }, + }, + ) + it.instance( "sends temperature, tokens, and reasoning options for openai-compatible models", () => @@ -833,6 +903,70 @@ describe("session.llm.stream", () => { }, ) + it.instance( + "surfaces network_error finish reasons as retryable stream failures", + () => + Effect.gen(function* () { + const fixture = loadFixture(vivgridFixture.providerID, vivgridFixture.modelID) + const request = waitRequest( + "/chat/completions", + createEventResponse( + [ + { + id: "chatcmpl-network-error", + object: "chat.completion.chunk", + choices: [{ index: 0, delta: { role: "assistant", content: "" }, finish_reason: "network_error" }], + }, + ], + true, + ), + ) + const resolved = yield* Provider.use.getModel( + ProviderV2.ID.make(vivgridFixture.providerID), + ModelV2.ID.make(fixture.model.id), + ) + const sessionID = SessionID.make("session-test-network-error") + const agent = { + name: "test", + mode: "primary", + options: {}, + permission: [{ permission: "*", pattern: "*", action: "allow" }], + } satisfies Agent.Info + const user = { + id: MessageID.make("msg_user-network-error"), + sessionID, + role: "user", + time: { created: Date.now() }, + agent: agent.name, + model: { providerID: ProviderV2.ID.make(vivgridFixture.providerID), modelID: resolved.id }, + } satisfies SessionV1.User + + const error = yield* drain({ + user, + sessionID, + model: resolved, + agent, + system: ["You are a helpful assistant."], + messages: [{ role: "user", content: "Hello" }], + tools: {}, + }).pipe(Effect.flip) + yield* Effect.promise(() => request) + + if (!(error instanceof ProviderError.ResponseStreamError)) throw error + expect(error.message).toBe("Provider finish_reason: network_error") + }), + { + config: () => ({ + enabled_providers: [vivgridFixture.providerID], + provider: { + [vivgridFixture.providerID]: { + options: { apiKey: "test-key", baseURL: `${state.server!.url.origin}/v1` }, + }, + }, + }), + }, + ) + const alibabaQwenFixture = { providerID: "alibaba", modelID: "qwen-plus" } it.instance( "service stream cancellation cancels provider response body promptly", @@ -1314,6 +1448,7 @@ describe("session.llm.stream", () => { type: "function", name: "lookup", description: "Lookup data", + strict: false, parameters: { type: "object", properties: { query: { type: "string" } }, @@ -1402,6 +1537,7 @@ describe("session.llm.stream", () => { type: "function", name: "lookup", description: "Lookup data", + strict: false, parameters: { type: "object", properties: { query: { type: "string" } }, diff --git a/packages/opencode/test/session/processor-effect.test.ts b/packages/opencode/test/session/processor-effect.test.ts index c8f40d0de1..106067c6b2 100644 --- a/packages/opencode/test/session/processor-effect.test.ts +++ b/packages/opencode/test/session/processor-effect.test.ts @@ -605,6 +605,62 @@ it.live("session.processor effect tests retry recognized structured json errors" { config: (url) => providerCfg(url) }, ), ) +it.live("session.processor effect tests retry network_error finish reasons", () => + provideTmpdirServer( + ({ dir, llm }) => + Effect.gen(function* () { + const { processors, session, provider } = yield* boot() + + yield* llm.push( + raw({ + chunks: [ + { + id: "chatcmpl-network-error", + object: "chat.completion.chunk", + choices: [{ index: 0, delta: { role: "assistant", content: "" }, finish_reason: "network_error" }], + }, + ], + }), + ) + yield* llm.text("after retry") + + const chat = yield* session.create({}) + const parent = yield* user(chat.id, "retry network error") + const msg = yield* assistant(chat.id, parent.id, path.resolve(dir)) + const mdl = yield* provider.getModel(ref.providerID, ref.modelID) + const handle = yield* processors.create({ + assistantMessage: msg, + sessionID: chat.id, + model: mdl, + }) + + const value = yield* handle.process({ + user: { + id: parent.id, + sessionID: chat.id, + role: "user", + time: parent.time, + agent: parent.agent, + model: { providerID: ref.providerID, modelID: ref.modelID }, + } satisfies SessionV1.User, + sessionID: chat.id, + model: mdl, + agent: agent(), + system: [], + messages: [{ role: "user", content: "retry network error" }], + tools: {}, + }) + + const parts = yield* MessageV2.parts(msg.id) + + expect(value).toBe("continue") + expect(yield* llm.calls).toBe(2) + expect(parts.some((part) => part.type === "text" && part.text === "after retry")).toBe(true) + expect(handle.message.error).toBeUndefined() + }), + { config: (url) => providerCfg(url) }, + ), +) it.live("session.processor effect tests publish retry status updates", () => provideTmpdirServer( diff --git a/packages/opencode/test/session/retry.test.ts b/packages/opencode/test/session/retry.test.ts index e53a6c1f18..a8ddc3de78 100644 --- a/packages/opencode/test/session/retry.test.ts +++ b/packages/opencode/test/session/retry.test.ts @@ -35,10 +35,18 @@ function wrap(message: unknown): ReturnType { describe("session.retry.delay", () => { test("caps delay at 30 seconds when headers missing", () => { const error = apiError() - const delays = Array.from({ length: 10 }, (_, index) => SessionRetry.delay(index + 1, error)) + const delays = Array.from({ length: 10 }, (_, index) => SessionRetry.delay(index + 1, error, 0)) expect(delays).toStrictEqual([2000, 4000, 8000, 16000, 30000, 30000, 30000, 30000, 30000, 30000]) }) + test("adds jitter to exponential delays", () => { + const error = apiError() + expect(SessionRetry.delay(1, error, 0)).toBe(2000) + expect(SessionRetry.delay(1, error, 1)).toBe(2500) + expect(SessionRetry.delay(4, error, 1)).toBe(20000) + expect(SessionRetry.delay(5, error, 1)).toBe(30000) + }) + test("prefers retry-after-ms when shorter than exponential", () => { const error = apiError({ "retry-after-ms": "1500" }) expect(SessionRetry.delay(4, error)).toBe(1500) @@ -59,18 +67,18 @@ describe("session.retry.delay", () => { test("ignores invalid retry hints", () => { const error = apiError({ "retry-after": "not-a-number" }) - expect(SessionRetry.delay(1, error)).toBe(2000) + expect(SessionRetry.delay(1, error, 0)).toBe(2000) }) test("ignores malformed date retry hints", () => { const error = apiError({ "retry-after": "Invalid Date String" }) - expect(SessionRetry.delay(1, error)).toBe(2000) + expect(SessionRetry.delay(1, error, 0)).toBe(2000) }) test("ignores past date retry hints", () => { const pastDate = new Date(Date.now() - 5000).toUTCString() const error = apiError({ "retry-after": pastDate }) - expect(SessionRetry.delay(1, error)).toBe(2000) + expect(SessionRetry.delay(1, error, 0)).toBe(2000) }) test("uses retry-after values even when exceeding 10 minutes with headers", () => { @@ -115,19 +123,47 @@ describe("session.retry.delay", () => { }) }), ) + + it.instance("policy stops after five retries", () => + Effect.gen(function* () { + const attempts: number[] = [] + const error = apiError({ "retry-after-ms": "0" }) + const step = yield* Schedule.toStepWithMetadata( + SessionRetry.policy({ + provider: "test", + parse: Schema.decodeUnknownSync(SessionV1.APIError.Schema), + set: (info) => + Effect.sync(() => { + attempts.push(info.attempt) + }), + }), + ) + + yield* Effect.forEach(Array.from({ length: SessionRetry.RETRY_MAX_RETRIES + 1 }), () => + Effect.ignore(step(error)), + ) + + expect(attempts).toStrictEqual([1, 2, 3, 4, 5]) + }), + ) }) describe("session.retry.retryable", () => { - test("maps too_many_requests json messages", () => { + test("retries serialized too_many_requests messages", () => { const error = wrap(JSON.stringify({ type: "error", error: { type: "too_many_requests" } })) expect(SessionRetry.retryable(error, retryProvider)).toEqual({ message: "Too Many Requests" }) }) - test("maps overloaded provider codes", () => { + test("retries serialized overloaded provider codes", () => { const error = wrap(JSON.stringify({ code: "resource_exhausted" })) expect(SessionRetry.retryable(error, retryProvider)).toEqual({ message: "Provider is overloaded" }) }) + test("retries serialized rate_limit messages", () => { + const message = JSON.stringify({ type: "error", error: { code: "rate_limit_exceeded" } }) + expect(SessionRetry.retryable(wrap(message), retryProvider)).toEqual({ message }) + }) + test("does not retry unknown json messages", () => { const error = wrap(JSON.stringify({ error: { message: "no_kv_space" } })) expect(SessionRetry.retryable(error, retryProvider)).toBeUndefined() @@ -163,6 +199,51 @@ describe("session.retry.retryable", () => { expect(SessionRetry.retryable(error, retryProvider)).toEqual({ message: msg }) }) + test.each([ + "Internal server error", + "internal error", + "server-error", + "Provider returned error", + "provider-returned-error", + "terminated", + "fetch failed", + "network error", + "network-error", + "network_error", + "connection refused", + "connect ECONNREFUSED", + "request ETIMEDOUT", + "failed to fetch", + "EAI_AGAIN", + "response timed out", + "Please retry your request", + "try your request again", + "Please try again in a few minutes", + "The model is currently at capacity due to high demand", + "The service is temporarily at capacity", + "upstream returned status 524", + ])("retries matching API error text: %s", (message) => { + expect(SessionRetry.retryable(wrap(message), retryProvider)).toEqual({ message }) + }) + + test("retries hyphenated service-unavailable errors", () => { + expect(SessionRetry.retryable(wrap("service-unavailable"), retryProvider)).toEqual({ + message: "Provider is overloaded", + }) + }) + + test("matches retryable API response bodies", () => { + const error = Schema.decodeUnknownSync(SessionV1.APIError.Schema)( + new SessionV1.APIError({ + message: "Request failed", + isRetryable: false, + statusCode: 400, + responseBody: JSON.stringify({ error: { message: "upstream connection refused" } }), + }).toObject(), + ) + expect(SessionRetry.retryable(error, retryProvider)).toEqual({ message: "Request failed" }) + }) + test("retries transport timeout errors", () => { const request = MessageV2.fromError(new ProviderError.HeaderTimeoutError(10000), { providerID }) expect(SessionV1.APIError.isInstance(request)).toBe(true) diff --git a/packages/opencode/test/skill/discovery.test.ts b/packages/opencode/test/skill/discovery.test.ts index 5dc5d5195b..a5833e7baf 100644 --- a/packages/opencode/test/skill/discovery.test.ts +++ b/packages/opencode/test/skill/discovery.test.ts @@ -11,6 +11,10 @@ import { testEffect } from "../lib/effect" let CLOUDFLARE_SKILLS_URL: string let server: ReturnType let downloadCount = 0 +let mutableVersion = "1" +let mutableContent = "# Old" +let mutableDownloadCount = 0 +let mutableFiles = ["SKILL.md"] const fixturePath = path.join(import.meta.dir, "../fixture/skills") const cacheDir = path.join(Global.Path.cache, "skills") @@ -24,6 +28,15 @@ beforeAll(async () => { async fetch(req) { const url = new URL(req.url) + if (url.pathname === "/mutable/index.json") { + return Response.json({ skills: [{ name: "mutable", version: mutableVersion, files: mutableFiles }] }) + } + if (url.pathname === "/mutable/mutable/SKILL.md") { + mutableDownloadCount++ + return new Response(mutableContent) + } + if (url.pathname === "/mutable/mutable/old.md") return new Response("old reference") + // route /.well-known/skills/* to the fixture directory if (url.pathname.startsWith("/.well-known/skills/")) { const filePath = url.pathname.replace("/.well-known/skills/", "") @@ -136,4 +149,37 @@ describe("Discovery.pull", () => { expect(downloadCount).toBe(firstCount) }), ) + + it.live("refreshes a remote skill when its version changes", () => + Effect.gen(function* () { + yield* Effect.promise(() => rm(cacheDir, { recursive: true, force: true })) + mutableVersion = "1" + mutableContent = "# Old" + mutableDownloadCount = 0 + mutableFiles = ["SKILL.md", "old.md"] + const discovery = yield* Discovery.Service + const url = `http://localhost:${server.port}/mutable/` + + const first = yield* discovery.pull(url) + expect(yield* Effect.promise(() => Bun.file(path.join(first[0], "SKILL.md")).text())).toBe("# Old") + + mutableVersion = "2" + mutableContent = "# Partial" + mutableFiles = ["SKILL.md", "missing.md"] + const second = yield* discovery.pull(url) + expect(yield* Effect.promise(() => Bun.file(path.join(second[0], "SKILL.md")).text())).toBe("# Old") + expect(yield* Effect.promise(() => Bun.file(path.join(second[0], "old.md")).text())).toBe("old reference") + + mutableVersion = "3" + mutableContent = "# New" + mutableFiles = ["SKILL.md"] + yield* discovery.pull(url) + expect(yield* Effect.promise(() => Bun.file(path.join(second[0], "SKILL.md")).text())).toBe("# New") + expect(yield* Effect.promise(() => Bun.file(path.join(second[0], "old.md")).exists())).toBe(false) + expect(mutableDownloadCount).toBe(3) + + yield* discovery.pull(url) + expect(mutableDownloadCount).toBe(3) + }), + ) }) diff --git a/packages/tui/src/clipboard.ts b/packages/tui/src/clipboard.ts index 08f86f9f7a..2ae29da888 100644 --- a/packages/tui/src/clipboard.ts +++ b/packages/tui/src/clipboard.ts @@ -23,7 +23,8 @@ function command(command: string, args: string[] = [], input?: string) { function writeOsc52(text: string) { if (!process.stdout.isTTY) return const sequence = `\x1b]52;c;${Buffer.from(text).toString("base64")}\x07` - process.stdout.write(process.env.TMUX || process.env.STY ? `\x1bPtmux;\x1b${sequence}\x1b\\` : sequence) + const passthrough = `\x1bPtmux;\x1b${sequence}\x1b\\` + process.stdout.write(process.env.TMUX ? sequence + passthrough : process.env.STY ? passthrough : sequence) } export async function read() { diff --git a/specs/v2/config.md b/specs/v2/config.md index 9804b14be6..9254af2094 100644 --- a/specs/v2/config.md +++ b/specs/v2/config.md @@ -304,23 +304,25 @@ Rename legacy `permission` to `permissions` and expose the normalized ordered ru External protocol and server integration configuration. -| Field | Current Purpose | Status | Notes | -| ----- | ------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | -| `mcp` | MCP server definitions and enablement | redesign | Keep opencode's explicit local/remote server entry format, nested under `mcp.servers`; use `disabled` for inactive entries and move timeout here. | +| Field | Current Purpose | Status | Notes | +| ----- | ------------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `mcp` | MCP server definitions and enablement | redesign | Keep opencode's explicit local/remote server entry format, nested under `mcp.servers`; use `disabled` for inactive entries and move timeout defaults here. | -Keep the opencode MCP server entry format instead of adopting the common `mcpServers` copy/paste shape. Local servers remain explicit `type: "local"` entries with command arrays and `environment`; remote servers remain explicit `type: "remote"` entries with `url`, `headers`, and optional `oauth`. Nest the server map under `mcp.servers` so protocol-wide settings such as default timeout can live under the same subsystem. +Keep the opencode MCP server entry format instead of adopting the common `mcpServers` copy/paste shape. Local servers remain explicit `type: "local"` entries with command arrays and `environment`; remote servers remain explicit `type: "remote"` entries with `url`, `headers`, and optional `oauth`. Nest the server map under `mcp.servers` so protocol-wide settings such as timeout defaults can live under the same subsystem. + +MCP timeouts have separate startup and request budgets, expressed in milliseconds. `startup` covers establishing the transport and completing MCP initialization. `request` applies independently to each post-initialization MCP request. A server may override either default without repeating the other. ```jsonc { "mcp": { - "timeout": 5000, + "timeout": { "startup": 30000, "request": 300000 }, "servers": { "github": { "type": "local", "command": ["npx", "-y", "@github/github-mcp-server"], "environment": { "GITHUB_TOKEN": "{env:GITHUB_TOKEN}" }, "disabled": false, - "timeout": 10000, + "timeout": { "startup": 60000 }, }, "docs": { "type": "remote", @@ -334,6 +336,7 @@ Keep the opencode MCP server entry format instead of adopting the common `mcpSer "redirect_uri": "http://127.0.0.1:19876/mcp/oauth/callback", }, "disabled": false, + "timeout": { "request": 600000 }, }, }, }, @@ -375,7 +378,7 @@ Fields that should not be ported by inertia; each needs an explicit justificatio | `experimental.openTelemetry` | Enable AI SDK telemetry spans | remove | Do not port; observability is process-level and should use standard OpenTelemetry environment or declarative configuration. | | `experimental.primary_tools` | Restrict tools to primary agents | remove | Do not port obsolete gating; agent tool access is configured through permissions. | | `experimental.continue_loop_on_deny` | Continue loop after denied tool call | remove | Do not port legacy denied-tool loop behavior. | -| `experimental.mcp_timeout` | MCP request timeout | redesign | Move to `mcp.timeout` for the default and `mcp.servers..timeout` for per-server overrides. | +| `experimental.mcp_timeout` | MCP request timeout | redesign | Move to `mcp.timeout.request` for the default and `mcp.servers..timeout.request` for per-server overrides. | ## Review Order