Merge remote-tracking branch 'upstream/main' into feat/secret-friendly-names
# Conflicts: # packages/coding-agent/test/sdk-session-isolation.test.ts
This commit is contained in:
@@ -44,6 +44,7 @@ packages/ai/test/.temp-images/
|
||||
.pi_config/
|
||||
.opencode/
|
||||
.worktrees/
|
||||
.worktree/
|
||||
compaction-results/
|
||||
changes/
|
||||
__pycache__/
|
||||
|
||||
@@ -164,7 +164,7 @@ To change an entry, fix the source:
|
||||
- **Generator-level fixups** (premium multipliers, codex pricing fallback, fallback models, post-processing) → `packages/catalog/scripts/generate-models.ts`.
|
||||
- **Thinking metadata / generated policies** → `packages/catalog/src/model-thinking.ts` (`applyGeneratedModelPolicies`); model-id classification (family/version parsing) lives in `packages/catalog/src/identity/classify.ts`.
|
||||
|
||||
Regenerate with `bun --cwd=packages/catalog run generate-models` and commit `models.json` alongside the source change. Add a regression test against the **resolver/descriptor**, not the bundled JSON, so it survives upstream metadata shifts.
|
||||
Regenerate with `bun run gen:models` and commit `models.json` alongside the source change. Add a regression test against the **resolver/descriptor**, not the bundled JSON, so it survives upstream metadata shifts.
|
||||
|
||||
## Logging
|
||||
|
||||
|
||||
Generated
+6
-4
@@ -2902,7 +2902,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pi-ast"
|
||||
version = "16.1.22"
|
||||
version = "16.2.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ast-grep-core",
|
||||
@@ -2972,7 +2972,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pi-iso"
|
||||
version = "16.1.22"
|
||||
version = "16.2.2"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"libc",
|
||||
@@ -2984,7 +2984,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pi-natives"
|
||||
version = "16.1.22"
|
||||
version = "16.2.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arboard",
|
||||
@@ -3032,7 +3032,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pi-shell"
|
||||
version = "16.1.22"
|
||||
version = "16.2.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"brush-builtins",
|
||||
@@ -3040,6 +3040,8 @@ dependencies = [
|
||||
"brush-parser 0.3.0",
|
||||
"bytes",
|
||||
"clap",
|
||||
"globset",
|
||||
"ignore",
|
||||
"libc",
|
||||
"os_pipe",
|
||||
"pi-uutils-ctx",
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ exclude = ["crates/vendor/brush-core", "crates/vendor/brush-builtins"]
|
||||
resolver = "3"
|
||||
|
||||
[workspace.package]
|
||||
version = "16.1.22"
|
||||
version = "16.2.2"
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
authors = ["Can Boluk"]
|
||||
|
||||
+1
-1
@@ -186,7 +186,7 @@ COPY . /pi/
|
||||
|
||||
# Regenerate the docs index that `--ignore-scripts` skipped above. The root
|
||||
# package.json's `prepare` script normally handles this on a vanilla install.
|
||||
RUN bun --cwd=packages/coding-agent run generate-docs-index
|
||||
RUN bun --cwd=packages/coding-agent run gen:docs
|
||||
|
||||
ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/omp"]
|
||||
CMD ["--help"]
|
||||
|
||||
@@ -151,7 +151,7 @@ _[Watch the capture ↗](https://omp.sh/clips/collab.mp4)_
|
||||
|
||||
### 08 · Read a pdf on arxiv, why not?
|
||||
|
||||
web_search chains fourteen ranked providers and hands whatever URLs it finds straight to read. Arxiv PDFs, GitHub pages, Stack Overflow threads come back as structured markdown with anchors intact — the same tool surface you use on local files. Cite, follow, quote, never lose where you came from.
|
||||
web_search chains eighteen ranked providers and hands whatever URLs it finds straight to read. Arxiv PDFs, GitHub pages, Stack Overflow threads come back as structured markdown with anchors intact — the same tool surface you use on local files. Cite, follow, quote, never lose where you came from.
|
||||
|
||||

|
||||
|
||||
@@ -309,31 +309,35 @@ Ollama `local` · Ollama Cloud · LM Studio `local` · llama.cpp `local` · vLLM
|
||||
|
||||
Full provider & routing reference at [omp.sh/docs/providers](https://omp.sh/docs/providers).
|
||||
|
||||
## Fourteen backends. _One tool the agent already knows_.
|
||||
## Eighteen backends. _One tool the agent already knows_.
|
||||
|
||||
`web_search` is built in, not bolted on. `auto` walks a fourteen-provider chain; pin one by name if you already pay for it. Behind every hit, site-aware extraction turns GitHub, registries, arXiv, Stack Overflow, and docs into structured markdown — anchors and link targets survive.
|
||||
`web_search` is built in, not bolted on. `auto` walks an eighteen-provider chain; pin one by name if you already pay for it. Behind every hit, site-aware extraction turns GitHub, registries, arXiv, Stack Overflow, and docs into structured markdown — anchors and link targets survive.
|
||||
|
||||
### Search providers
|
||||
|
||||
Fourteen backends. Pin one, or let `auto` walk the chain in order.
|
||||
Eighteen backends. Pin one, or let `auto` walk the chain in order.
|
||||
|
||||
| provider | auth |
|
||||
| ------------ | ---------------------- |
|
||||
| `auto` | chain |
|
||||
| `exa` | `EXA_API_KEY` (or mcp) |
|
||||
| `brave` | `BRAVE_API_KEY` |
|
||||
| `jina` | `JINA_API_KEY` |
|
||||
| `kimi` | `MOONSHOT_API_KEY` |
|
||||
| `zai` | `ZAI_API_KEY` |
|
||||
| `anthropic` | oauth |
|
||||
| `perplexity` | `PERPLEXITY_API_KEY` |
|
||||
| `gemini` | oauth |
|
||||
| `anthropic` | oauth |
|
||||
| `codex` | oauth |
|
||||
| `tavily` | `TAVILY_API_KEY` |
|
||||
| `parallel` | `PARALLEL_API_KEY` |
|
||||
| `xai` | `XAI_API_KEY` |
|
||||
| `zai` | `ZAI_API_KEY` |
|
||||
| `exa` | `EXA_API_KEY` (or mcp) |
|
||||
| `tinyfish` | `TINYFISH_API_KEY` |
|
||||
| `jina` | `JINA_API_KEY` |
|
||||
| `kagi` | `KAGI_API_KEY` |
|
||||
| `tavily` | `TAVILY_API_KEY` |
|
||||
| `firecrawl` | `FIRECRAWL_API_KEY` |
|
||||
| `brave` | `BRAVE_API_KEY` |
|
||||
| `kimi` | `MOONSHOT_API_KEY` |
|
||||
| `parallel` | `PARALLEL_API_KEY` |
|
||||
| `synthetic` | `SYNTHETIC_API_KEY` |
|
||||
| `searxng` | self-hosted |
|
||||
| `duckduckgo` | no key |
|
||||
|
||||
### Specialised handlers
|
||||
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
"": {
|
||||
"name": "omp-monorepo",
|
||||
"dependencies": {
|
||||
"sherpa-onnx": "1.12.37",
|
||||
"sherpa-onnx-darwin-arm64": "1.12.37",
|
||||
"sherpa-onnx-node": "1.12.37",
|
||||
"sherpa-onnx": "1.13.2",
|
||||
"sherpa-onnx-darwin-arm64": "1.13.3",
|
||||
"sherpa-onnx-node": "1.13.2",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "catalog:",
|
||||
@@ -21,7 +21,7 @@
|
||||
},
|
||||
"packages/agent": {
|
||||
"name": "@oh-my-pi/pi-agent-core",
|
||||
"version": "16.1.22",
|
||||
"version": "16.2.2",
|
||||
"dependencies": {
|
||||
"@oh-my-pi/pi-ai": "catalog:",
|
||||
"@oh-my-pi/pi-catalog": "catalog:",
|
||||
@@ -39,7 +39,7 @@
|
||||
},
|
||||
"packages/ai": {
|
||||
"name": "@oh-my-pi/pi-ai",
|
||||
"version": "16.1.22",
|
||||
"version": "16.2.2",
|
||||
"dependencies": {
|
||||
"@bufbuild/protobuf": "catalog:",
|
||||
"@oh-my-pi/pi-catalog": "catalog:",
|
||||
@@ -55,7 +55,7 @@
|
||||
},
|
||||
"packages/catalog": {
|
||||
"name": "@oh-my-pi/pi-catalog",
|
||||
"version": "16.1.22",
|
||||
"version": "16.2.2",
|
||||
"dependencies": {
|
||||
"@bufbuild/protobuf": "catalog:",
|
||||
"@oh-my-pi/pi-utils": "catalog:",
|
||||
@@ -69,7 +69,7 @@
|
||||
},
|
||||
"packages/coding-agent": {
|
||||
"name": "@oh-my-pi/pi-coding-agent",
|
||||
"version": "16.1.22",
|
||||
"version": "16.2.2",
|
||||
"bin": {
|
||||
"omp": "src/cli.ts",
|
||||
},
|
||||
@@ -137,7 +137,7 @@
|
||||
},
|
||||
"packages/hashline": {
|
||||
"name": "@oh-my-pi/hashline",
|
||||
"version": "16.1.22",
|
||||
"version": "16.2.2",
|
||||
"dependencies": {
|
||||
"diff": "catalog:",
|
||||
"lru-cache": "catalog:",
|
||||
@@ -148,7 +148,7 @@
|
||||
},
|
||||
"packages/mnemopi": {
|
||||
"name": "@oh-my-pi/pi-mnemopi",
|
||||
"version": "16.1.22",
|
||||
"version": "16.2.2",
|
||||
"bin": {
|
||||
"mnemopi": "src/cli.ts",
|
||||
},
|
||||
@@ -174,7 +174,7 @@
|
||||
},
|
||||
"packages/natives": {
|
||||
"name": "@oh-my-pi/pi-natives",
|
||||
"version": "16.1.22",
|
||||
"version": "16.2.2",
|
||||
"devDependencies": {
|
||||
"@napi-rs/cli": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
@@ -182,7 +182,7 @@
|
||||
},
|
||||
"packages/snapcompact": {
|
||||
"name": "@oh-my-pi/snapcompact",
|
||||
"version": "16.1.22",
|
||||
"version": "16.2.2",
|
||||
"dependencies": {
|
||||
"@oh-my-pi/pi-ai": "catalog:",
|
||||
"@oh-my-pi/pi-natives": "catalog:",
|
||||
@@ -195,7 +195,7 @@
|
||||
},
|
||||
"packages/stats": {
|
||||
"name": "@oh-my-pi/omp-stats",
|
||||
"version": "16.1.22",
|
||||
"version": "16.2.2",
|
||||
"bin": {
|
||||
"omp-stats": "./src/index.ts",
|
||||
},
|
||||
@@ -221,7 +221,7 @@
|
||||
},
|
||||
"packages/swarm-extension": {
|
||||
"name": "@oh-my-pi/swarm-extension",
|
||||
"version": "16.1.22",
|
||||
"version": "16.2.2",
|
||||
"bin": {
|
||||
"omp-swarm": "src/cli.ts",
|
||||
},
|
||||
@@ -247,7 +247,7 @@
|
||||
},
|
||||
"packages/tui": {
|
||||
"name": "@oh-my-pi/pi-tui",
|
||||
"version": "16.1.22",
|
||||
"version": "16.2.2",
|
||||
"dependencies": {
|
||||
"@oh-my-pi/pi-natives": "catalog:",
|
||||
"@oh-my-pi/pi-utils": "catalog:",
|
||||
@@ -288,7 +288,7 @@
|
||||
},
|
||||
"packages/utils": {
|
||||
"name": "@oh-my-pi/pi-utils",
|
||||
"version": "16.1.22",
|
||||
"version": "16.2.2",
|
||||
"dependencies": {
|
||||
"@oh-my-pi/pi-natives": "catalog:",
|
||||
"handlebars": "catalog:",
|
||||
@@ -301,7 +301,7 @@
|
||||
},
|
||||
"packages/wire": {
|
||||
"name": "@oh-my-pi/pi-wire",
|
||||
"version": "16.1.22",
|
||||
"version": "16.2.2",
|
||||
"devDependencies": {
|
||||
"@types/bun": "catalog:",
|
||||
},
|
||||
@@ -337,18 +337,18 @@
|
||||
"@huggingface/transformers": "^4.2.0",
|
||||
"@mozilla/readability": "^0.6.0",
|
||||
"@napi-rs/cli": "3.7.0",
|
||||
"@oh-my-pi/hashline": "16.1.22",
|
||||
"@oh-my-pi/omp-stats": "16.1.22",
|
||||
"@oh-my-pi/pi-agent-core": "16.1.22",
|
||||
"@oh-my-pi/pi-ai": "16.1.22",
|
||||
"@oh-my-pi/pi-catalog": "16.1.22",
|
||||
"@oh-my-pi/pi-coding-agent": "16.1.22",
|
||||
"@oh-my-pi/pi-mnemopi": "16.1.22",
|
||||
"@oh-my-pi/pi-natives": "16.1.22",
|
||||
"@oh-my-pi/pi-tui": "16.1.22",
|
||||
"@oh-my-pi/pi-utils": "16.1.22",
|
||||
"@oh-my-pi/pi-wire": "16.1.22",
|
||||
"@oh-my-pi/snapcompact": "16.1.22",
|
||||
"@oh-my-pi/hashline": "16.2.2",
|
||||
"@oh-my-pi/omp-stats": "16.2.2",
|
||||
"@oh-my-pi/pi-agent-core": "16.2.2",
|
||||
"@oh-my-pi/pi-ai": "16.2.2",
|
||||
"@oh-my-pi/pi-catalog": "16.2.2",
|
||||
"@oh-my-pi/pi-coding-agent": "16.2.2",
|
||||
"@oh-my-pi/pi-mnemopi": "16.2.2",
|
||||
"@oh-my-pi/pi-natives": "16.2.2",
|
||||
"@oh-my-pi/pi-tui": "16.2.2",
|
||||
"@oh-my-pi/pi-utils": "16.2.2",
|
||||
"@oh-my-pi/pi-wire": "16.2.2",
|
||||
"@oh-my-pi/snapcompact": "16.2.2",
|
||||
"@opentelemetry/api": "^1.9.1",
|
||||
"@opentelemetry/context-async-hooks": "^2.7.1",
|
||||
"@opentelemetry/exporter-trace-otlp-proto": "^0.218.0",
|
||||
@@ -454,23 +454,23 @@
|
||||
|
||||
"@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="],
|
||||
|
||||
"@biomejs/biome": ["@biomejs/biome@2.5.0", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.5.0", "@biomejs/cli-darwin-x64": "2.5.0", "@biomejs/cli-linux-arm64": "2.5.0", "@biomejs/cli-linux-arm64-musl": "2.5.0", "@biomejs/cli-linux-x64": "2.5.0", "@biomejs/cli-linux-x64-musl": "2.5.0", "@biomejs/cli-win32-arm64": "2.5.0", "@biomejs/cli-win32-x64": "2.5.0" }, "bin": { "biome": "bin/biome" } }, "sha512-4kURkd9hAPrdDM3C9n82ycYgx8hvQcW6MjKTEejruj8rK0N8P3OPpdy8BvI8kt3KWY4ycF5XtDOrktetEfhfuw=="],
|
||||
"@biomejs/biome": ["@biomejs/biome@2.5.1", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.5.1", "@biomejs/cli-darwin-x64": "2.5.1", "@biomejs/cli-linux-arm64": "2.5.1", "@biomejs/cli-linux-arm64-musl": "2.5.1", "@biomejs/cli-linux-x64": "2.5.1", "@biomejs/cli-linux-x64-musl": "2.5.1", "@biomejs/cli-win32-arm64": "2.5.1", "@biomejs/cli-win32-x64": "2.5.1" }, "bin": { "biome": "bin/biome" } }, "sha512-IXWLCxKmae+rI7LOHS1B3EbVisQ6GRAWbhN9msa6KjNCyFWrvKZWR4oUdinaNssrV852OrSHuSPa95h1GPJc7Q=="],
|
||||
|
||||
"@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.5.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Mn3Fwi3SA5fgmfCPqmzpWF2DLZnms3BVAhM088nTnGrTZmHS3wwIjcoZPqpXeNgd3DrrLH6xp8vTLIBuJoZiXw=="],
|
||||
"@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.5.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-npqDzvqv7vFaWRiNN1Te71siRgPaqS9MpqgYCdP/CrUbkJ7ApezaeaKjueKHRN/JH/6lRjJQAHi8acQDCAz22w=="],
|
||||
|
||||
"@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.5.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-rg3VPL5P8mYro6pqlXYXuJWph21slVp3SZtAqWSrkZs40d2gTzYmHF8E/X1iTID25btmNKltNDJ926sqVBp7DQ=="],
|
||||
"@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.5.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-RgwTqPAM8g2tn1j+b5oRjF/DbSBX8a4gwojtuG9XuhfK7GgomvZ9+T+tqjXiVbjLEeGJOoL6VEk8mvRTVeSybw=="],
|
||||
|
||||
"@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.5.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-tl+LW8fdD96/xdeWtWwc82LIOc5CoY7N2AsogLTp5R4ECErYt+8Jl/N68ezN9vzSiqPTxw6vjcihoLPYKZHrlw=="],
|
||||
"@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.5.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-yhV35CzZh38VyMvTEXi3JTjxZBs++oCKK9KG8vB6VI5+uvQvZNR3BFWEKKzuOmx9DJJj7sQpZ4LQJcmbGTs3+Q=="],
|
||||
|
||||
"@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.5.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-vQdM4oSGaf7ZNeGO9w5+Y8SBtyser9M6znxYbm7Ec8wInxJu1WiKxFYZW5Auj2d80bcVvefuGGRxoFOE0eee8g=="],
|
||||
"@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.5.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-WMcvMLgByyTqVxGlq918NBBYliq9FRR9GAQVETHb+VjGVqXCZFfHlZHC1FX4ibuYY/Hg6TJE3rHU0xVrdJXNRw=="],
|
||||
|
||||
"@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.5.0", "", { "os": "linux", "cpu": "x64" }, "sha512-zpEGf4RQbFEh8Vt7OmavLyyOzRbtcE9osCqrS1kfvt8jDvxwhKXLSf7n0ebr/ov0RJ9ssP+lhs6C8a9WwFvrQA=="],
|
||||
"@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.5.1", "", { "os": "linux", "cpu": "x64" }, "sha512-J/7uHSX7NfoYDI7HijAkd8lnQIOrRb2W7j3X+tw4R+N5ExvXGsyXFiGdQcfcxfOmNQmZVSQOCDk757fwpzqQcg=="],
|
||||
|
||||
"@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.5.0", "", { "os": "linux", "cpu": "x64" }, "sha512-+9hIcMngJ+yGUahXqZuZ8CoWKJE9SAZsFsM3QDvXpNsLbXZ9lqVzgBhOk/jTSYkOA0GLP9eu3teukqpLUojHMg=="],
|
||||
"@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.5.1", "", { "os": "linux", "cpu": "x64" }, "sha512-ANTowtlLmPYm5yeMckWY8Xzb9Ix+JJP3tgHR/n6xRj1VWyIzzWtfRfih9hv9VmClwadpBvZduISZIbBsIlYG3A=="],
|
||||
|
||||
"@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.5.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-jB0wAvTLI4itx5VidqVUejPQFhRUxiZ9l9FvZ26D5fl6t3qme+ZB4PD3bTSeL1vZ8NI2Rx/zj6H9zcESuGHKGw=="],
|
||||
"@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.5.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-zgXnKNgWPC4iPF7Y1lR3STUeCUuZRpD6IiOrC7TZTlh0Lx6FiVUT05myuMQHQ9D+1cc7uyMldi4forE6lp0ivQ=="],
|
||||
|
||||
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.5.0", "", { "os": "win32", "cpu": "x64" }, "sha512-VT/lF+GId+67j8aDfLkxdxNoVApsPSTbyAtB3jJq0IWTrY77WXfbPfpngxq0bA6JCEv/7k8C9qWjDRKRznDlyw=="],
|
||||
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.5.1", "", { "os": "win32", "cpu": "x64" }, "sha512-6uxpR9hvaglANkZemeSiN/FhYgkGasrEGn267eXIWvjrjJ2LhDlk251IhjVJq6MXzkV2/bcXwLwSroLyPtqRZg=="],
|
||||
|
||||
"@bufbuild/protobuf": ["@bufbuild/protobuf@2.12.1", "", {}, "sha512-BvAMfS6LrgZiryOAZ4pBYucu4wG/Ei/9o9DZ9akbREnMLbPJiom2i8b9C8IsKErQoiKqVhrerzt3kOT/RrzLHg=="],
|
||||
|
||||
@@ -482,11 +482,11 @@
|
||||
|
||||
"@dabh/diagnostics": ["@dabh/diagnostics@2.0.8", "", { "dependencies": { "@so-ric/colorspace": "^1.1.6", "enabled": "2.0.x", "kuler": "^2.0.0" } }, "sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q=="],
|
||||
|
||||
"@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="],
|
||||
"@emnapi/core": ["@emnapi/core@1.11.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="],
|
||||
|
||||
"@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="],
|
||||
"@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="],
|
||||
|
||||
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="],
|
||||
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="],
|
||||
|
||||
"@huggingface/blake3-jit": ["@huggingface/blake3-jit@0.0.2", "", {}, "sha512-Bq7B5qabyjrJfhBsl85Jd2QBtf+HzRD7h7A9GfN2lzrrsABhOa5evVPgzoCTxR7Ub0QFj7YDK1YkYRWBU25+2w=="],
|
||||
|
||||
@@ -676,7 +676,7 @@
|
||||
|
||||
"@napi-rs/tar-win32-x64-msvc": ["@napi-rs/tar-win32-x64-msvc@1.1.0", "", { "os": "win32", "cpu": "x64" }, "sha512-L6Ed1DxXK9YSCMyvpR8MiNAyKNkQLjsHsHK9E0qnHa8NzLFqzDKhvs5LfnWxM2kJ+F7m/e5n9zPm24kHb3LsVw=="],
|
||||
|
||||
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.5", "", { "dependencies": { "@tybys/wasm-util": "^0.10.2" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q=="],
|
||||
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.6", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg=="],
|
||||
|
||||
"@napi-rs/wasm-tools": ["@napi-rs/wasm-tools@1.0.1", "", { "optionalDependencies": { "@napi-rs/wasm-tools-android-arm-eabi": "1.0.1", "@napi-rs/wasm-tools-android-arm64": "1.0.1", "@napi-rs/wasm-tools-darwin-arm64": "1.0.1", "@napi-rs/wasm-tools-darwin-x64": "1.0.1", "@napi-rs/wasm-tools-freebsd-x64": "1.0.1", "@napi-rs/wasm-tools-linux-arm64-gnu": "1.0.1", "@napi-rs/wasm-tools-linux-arm64-musl": "1.0.1", "@napi-rs/wasm-tools-linux-x64-gnu": "1.0.1", "@napi-rs/wasm-tools-linux-x64-musl": "1.0.1", "@napi-rs/wasm-tools-wasm32-wasi": "1.0.1", "@napi-rs/wasm-tools-win32-arm64-msvc": "1.0.1", "@napi-rs/wasm-tools-win32-ia32-msvc": "1.0.1", "@napi-rs/wasm-tools-win32-x64-msvc": "1.0.1" } }, "sha512-enkZYyuCdo+9jneCPE/0fjIta4wWnvVN9hBo2HuiMpRF0q3lzv1J6b/cl7i0mxZUKhBrV3aCKDBQnCOhwKbPmQ=="],
|
||||
|
||||
@@ -790,7 +790,7 @@
|
||||
|
||||
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.41.1", "", {}, "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA=="],
|
||||
|
||||
"@oxc-project/types": ["@oxc-project/types@0.133.0", "", {}, "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA=="],
|
||||
"@oxc-project/types": ["@oxc-project/types@0.137.0", "", {}, "sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA=="],
|
||||
|
||||
"@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="],
|
||||
|
||||
@@ -812,35 +812,35 @@
|
||||
|
||||
"@puppeteer/browsers": ["@puppeteer/browsers@3.0.5", "", { "dependencies": { "modern-tar": "^0.7.6", "yargs": "^18.0.0" }, "peerDependencies": { "proxy-agent": ">=8.0.1" }, "optionalPeers": ["proxy-agent"], "bin": { "browsers": "lib/main-cli.js" } }, "sha512-xYXNuEQmHNIPWWcbL/skf2KF7seyp7c1xmKFRk3wmdFx7VwBsKVrtOLKs8ecaezsKPsWeF1YsgwIiElAscaryA=="],
|
||||
|
||||
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.3", "", { "os": "android", "cpu": "arm64" }, "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw=="],
|
||||
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.1.3", "", { "os": "android", "cpu": "arm64" }, "sha512-DT6Z3PhvioeHMvxo+xHc3KtqggrI7CCTXCmC2h/5zUlp5jVitv7XEy+9q5/7v8IolhlioawpMo8Kg0EEBy7J0g=="],
|
||||
|
||||
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA=="],
|
||||
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.1.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-0NwgwsjM7LrsuVnXMK3koTpagBNOhloc/BNjKqZjv4V5zI5r13qx69uVhRx+o5Z0yy4Hzq+lpy7TAgUG/ocvrw=="],
|
||||
|
||||
"@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg=="],
|
||||
"@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.1.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-YtiBp4disu6V560loT6PjMdiRaWmVvDNrUunAalbiFx2ggeJwxdAsgZMcoGP17uyAsTwAj5V1niksxlHnVQ1Sw=="],
|
||||
|
||||
"@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g=="],
|
||||
"@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.1.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-yD3EkEdXk2LypPxnf/kSZHirarsI8gcPzc62SukhR9VJTyvV+F9Q/GxWNuCojc7sXyuVC4DxRGhdDK4X8VSsbw=="],
|
||||
|
||||
"@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.3", "", { "os": "linux", "cpu": "arm" }, "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw=="],
|
||||
"@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.1.3", "", { "os": "linux", "cpu": "arm" }, "sha512-c+8vieQbsD7HNAHKIA34w0GJ9FedFFuJGD+7E6vz7Q3uqAIugL5p45fhlsj4UaAsHpcmlqugBWMhA0/j7o0sIg=="],
|
||||
|
||||
"@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw=="],
|
||||
"@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.1.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-50jD0uUwLvur7Zz9LHz17kaAdTPjn5wN93hEgjvmYFRZwiR7ZJYovTd5ipyWJDAnXKvZ+wgc+/Ika6dwSF5OcA=="],
|
||||
|
||||
"@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q=="],
|
||||
"@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.1.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-BO9+oPL8K9poZJBfYPsXNtYjPE5uM3qeehT3aFcW4LITOl+iSqhp0abzjR2nWBUNjIZeKXjAEWBZ64WjNoHd6w=="],
|
||||
|
||||
"@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg=="],
|
||||
"@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.1.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-f3VpLB1vQ0Eo6ecr/6cekLnvYMFF4YBFoVGkfkvPLq1bAkbAwHYQPZKoAmG6OJyTcxxoC+AvezGx/S1obNC0Mw=="],
|
||||
|
||||
"@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg=="],
|
||||
"@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.1.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-AmurZ26Pqx/RI9N1gzEOCklkKXl927yjfXWUUS0O7Puh8ARM/Ob8qfrD3qnWksScdw6cSrW5PSHE9DyLu7+PtA=="],
|
||||
|
||||
"@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.3", "", { "os": "linux", "cpu": "x64" }, "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg=="],
|
||||
"@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.1.3", "", { "os": "linux", "cpu": "x64" }, "sha512-JJpqs8bRGITDOdbkNKnlojzBabbOHrqjSvDr0IVsZObE1lBcPjxItUEY9eWIDbxaJ3cGrXPWGfGkIxFijg/URg=="],
|
||||
|
||||
"@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.3", "", { "os": "linux", "cpu": "x64" }, "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow=="],
|
||||
"@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.1.3", "", { "os": "linux", "cpu": "x64" }, "sha512-rSJcdjPxzA/by/6/rYs+v+bXU7UjvnbUWz8MJb6kh6+knqB1dCrtHg0uu7C/4haqJvqdkYHQ5IGn+tCH9GLW/g=="],
|
||||
|
||||
"@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.3", "", { "os": "none", "cpu": "arm64" }, "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg=="],
|
||||
"@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.1.3", "", { "os": "none", "cpu": "arm64" }, "sha512-hQ3/PYkDJICgevvyNcVrihVeqq7k1Pp3VZ9lY+dauAYUJKO+auqApvANhvR1An9BhmqYKvW2Mu1F9u4DXSMLxQ=="],
|
||||
|
||||
"@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.3", "", { "dependencies": { "@emnapi/core": "1.10.0", "@emnapi/runtime": "1.10.0", "@napi-rs/wasm-runtime": "^1.1.4" }, "cpu": "none" }, "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg=="],
|
||||
"@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.1.3", "", { "dependencies": { "@emnapi/core": "1.11.1", "@emnapi/runtime": "1.11.1", "@napi-rs/wasm-runtime": "^1.1.6" }, "cpu": "none" }, "sha512-Elcv/BtML9lXrV6JuKITc/grN2kYV9gjsQpW8Jfw4ioK0TOkjBjye0nnyqQNy9STNaI20lXNaQBRrD5gSgR0Yg=="],
|
||||
|
||||
"@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g=="],
|
||||
"@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.1.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-2DrEfhluH9yhiaFApmsjsjwrSYbNcY1oFTzYSP1a535jDbV98zCFanA/96TBUd0iDFcxGmw9QRExwGCXz3U+/g=="],
|
||||
|
||||
"@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.3", "", { "os": "win32", "cpu": "x64" }, "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA=="],
|
||||
"@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.1.3", "", { "os": "win32", "cpu": "x64" }, "sha512-OL4OMk7UPXOeVGGd3qo5zJyPIljf4AFgk5QAkPPS+OoLuOOozhuaQGC18MxVTnw/06q93gShAJzlwnSCY9YtqA=="],
|
||||
|
||||
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="],
|
||||
|
||||
@@ -878,7 +878,7 @@
|
||||
|
||||
"@ts-morph/common": ["@ts-morph/common@0.29.0", "", { "dependencies": { "minimatch": "^10.0.1", "path-browserify": "^1.0.1", "tinyglobby": "^0.2.14" } }, "sha512-35oUmphHbJvQ/+UTwFNme/t2p3FoKiGJ5auTjjpNTop2dyREspirjMy82PLSC1pnDJ8ah1GU98hwpVt64YXQsg=="],
|
||||
|
||||
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="],
|
||||
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="],
|
||||
|
||||
"@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
|
||||
|
||||
@@ -1310,7 +1310,7 @@
|
||||
|
||||
"robomp-web": ["robomp-web@workspace:python/robomp/web"],
|
||||
|
||||
"rolldown": ["rolldown@1.0.3", "", { "dependencies": { "@oxc-project/types": "=0.133.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.3", "@rolldown/binding-darwin-arm64": "1.0.3", "@rolldown/binding-darwin-x64": "1.0.3", "@rolldown/binding-freebsd-x64": "1.0.3", "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", "@rolldown/binding-linux-arm64-gnu": "1.0.3", "@rolldown/binding-linux-arm64-musl": "1.0.3", "@rolldown/binding-linux-ppc64-gnu": "1.0.3", "@rolldown/binding-linux-s390x-gnu": "1.0.3", "@rolldown/binding-linux-x64-gnu": "1.0.3", "@rolldown/binding-linux-x64-musl": "1.0.3", "@rolldown/binding-openharmony-arm64": "1.0.3", "@rolldown/binding-wasm32-wasi": "1.0.3", "@rolldown/binding-win32-arm64-msvc": "1.0.3", "@rolldown/binding-win32-x64-msvc": "1.0.3" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g=="],
|
||||
"rolldown": ["rolldown@1.1.3", "", { "dependencies": { "@oxc-project/types": "=0.137.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.1.3", "@rolldown/binding-darwin-arm64": "1.1.3", "@rolldown/binding-darwin-x64": "1.1.3", "@rolldown/binding-freebsd-x64": "1.1.3", "@rolldown/binding-linux-arm-gnueabihf": "1.1.3", "@rolldown/binding-linux-arm64-gnu": "1.1.3", "@rolldown/binding-linux-arm64-musl": "1.1.3", "@rolldown/binding-linux-ppc64-gnu": "1.1.3", "@rolldown/binding-linux-s390x-gnu": "1.1.3", "@rolldown/binding-linux-x64-gnu": "1.1.3", "@rolldown/binding-linux-x64-musl": "1.1.3", "@rolldown/binding-openharmony-arm64": "1.1.3", "@rolldown/binding-wasm32-wasi": "1.1.3", "@rolldown/binding-win32-arm64-msvc": "1.1.3", "@rolldown/binding-win32-x64-msvc": "1.1.3" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-1F1eEtUBtFvcGm1HQ9TiUIUHPQG7mSAODrhIzjxoUEFuo8OcbrGLiVLkevNgj84TE4lnHvnumwFjhJO5Eu135g=="],
|
||||
|
||||
"safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
|
||||
|
||||
@@ -1334,9 +1334,9 @@
|
||||
|
||||
"sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="],
|
||||
|
||||
"sherpa-onnx": ["sherpa-onnx@1.12.37", "", {}, "sha512-3luwSdHwR8BtJiiFwqHfb15FE2FX0KsN4aOBbfq9Ma23r3w9C3bprFc/WBusXk56nUbzcEN5YczN7t9w1JwdtQ=="],
|
||||
"sherpa-onnx": ["sherpa-onnx@1.13.2", "", {}, "sha512-hheOLl4JlOzERco73u+1Q/LaNDdFChDe4r3+IlMYIO3wggBZrReHDSxWwRUIW+YLw3eLslyiXw/hfb75aOylEA=="],
|
||||
|
||||
"sherpa-onnx-darwin-arm64": ["sherpa-onnx-darwin-arm64@1.12.37", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zpqbH+2TI6dvg7mxGm30Mnv17aJL3ZfRGshMiBK85dHBfhzqMbKUHNXCzsHhiKBQTzti6JqG1YoRbYrJwvdUjA=="],
|
||||
"sherpa-onnx-darwin-arm64": ["sherpa-onnx-darwin-arm64@1.13.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-9x86Cbf+BDFONdtCPM3cnjvtAW0ER8tMaHK5pVfz+SHPt8GeuwRXaiR/BzcByFBUyxCgmceO09/WMZOCi44P/g=="],
|
||||
|
||||
"sherpa-onnx-darwin-x64": ["sherpa-onnx-darwin-x64@1.13.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-TVQ35g7JIpDPB1lUDdcog+JtI0cI45ZzOnvHXm0DtWs/dgxnJXtWMY3uLRtBbLnysV9j5ljffwZ1IX9VDHsCzQ=="],
|
||||
|
||||
@@ -1344,7 +1344,7 @@
|
||||
|
||||
"sherpa-onnx-linux-x64": ["sherpa-onnx-linux-x64@1.13.3", "", { "os": "linux", "cpu": "x64" }, "sha512-OFVK0GYwKwKNsjxbPmfcLQm/dfA0IwAoiIQJ96s+eFYcDqhlapcY06ocdb7SNluGBcM7xgU5jEW2QXBkMIOEvQ=="],
|
||||
|
||||
"sherpa-onnx-node": ["sherpa-onnx-node@1.12.37", "", { "optionalDependencies": { "sherpa-onnx-darwin-arm64": "^1.12.37", "sherpa-onnx-darwin-x64": "^1.12.37", "sherpa-onnx-linux-arm64": "^1.12.37", "sherpa-onnx-linux-x64": "^1.12.37", "sherpa-onnx-win-ia32": "^1.12.37", "sherpa-onnx-win-x64": "^1.12.37" } }, "sha512-SpblPUl/ODliBk4WzKLRa0VPyc30I3HU9U/qRUmhya7eTNLkJE8sQOmj2kvRL8k2cWrHES2pyvRwwq1jT/MPpw=="],
|
||||
"sherpa-onnx-node": ["sherpa-onnx-node@1.13.2", "", { "optionalDependencies": { "sherpa-onnx-darwin-arm64": "^1.13.2", "sherpa-onnx-darwin-x64": "^1.13.2", "sherpa-onnx-linux-arm64": "^1.13.2", "sherpa-onnx-linux-x64": "^1.13.2", "sherpa-onnx-win-ia32": "^1.13.2", "sherpa-onnx-win-x64": "^1.13.2" } }, "sha512-uIH6SA5Or4pb8HlCYWB3K54XkMtzdef4/tkw1amtIf8GB1tt6hQLpur9p2jSFNfTYRyzZ8XrXofxefXQ0A7EUA=="],
|
||||
|
||||
"sherpa-onnx-win-ia32": ["sherpa-onnx-win-ia32@1.13.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-VDZh1M7Ccx/bkP3WwBCFoJzwAwq+b5nR1KRkYRz5p1w5bfhzfa3ACBGr7vpUt5AGUge4qSLe0MSKXyKtSmy1uA=="],
|
||||
|
||||
@@ -1420,7 +1420,7 @@
|
||||
|
||||
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
|
||||
|
||||
"vite": ["vite@8.0.16", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.15", "rolldown": "1.0.3", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.18", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw=="],
|
||||
"vite": ["vite@8.1.0", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.15", "rolldown": "~1.1.2", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.3.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-BuJcQK/56NQTWDGn4ABea3q4SSBdNPWwNZKTkkUpcMPnLoquSYH8llRtSUIgoL1KSCpHt5eghLShn50mH36y7Q=="],
|
||||
|
||||
"vite-plugin-solid": ["vite-plugin-solid@2.11.12", "", { "dependencies": { "@babel/core": "^7.23.3", "@types/babel__core": "^7.20.4", "babel-preset-solid": "^1.8.4", "merge-anything": "^5.1.7", "solid-refresh": "^0.6.3", "vitefu": "^1.0.4" }, "peerDependencies": { "@testing-library/jest-dom": "^5.16.6 || ^5.17.0 || ^6.*", "solid-js": "^1.7.2", "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["@testing-library/jest-dom"] }, "sha512-FgjPcx2OwX9h6f28jli7A4bG7PP3te8uyakE5iqsmpq3Jqi1TWLgSroC9N6cMfGRU2zXsl4Q6ISvTr2VL0QHpA=="],
|
||||
|
||||
@@ -1468,8 +1468,6 @@
|
||||
|
||||
"@isaacs/fs-minipass/minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="],
|
||||
|
||||
"@oh-my-pi/pi-coding-agent/sherpa-onnx-node": ["sherpa-onnx-node@1.13.2", "", { "optionalDependencies": { "sherpa-onnx-darwin-arm64": "^1.13.2", "sherpa-onnx-darwin-x64": "^1.13.2", "sherpa-onnx-linux-arm64": "^1.13.2", "sherpa-onnx-linux-x64": "^1.13.2", "sherpa-onnx-win-ia32": "^1.13.2", "sherpa-onnx-win-x64": "^1.13.2" } }, "sha512-uIH6SA5Or4pb8HlCYWB3K54XkMtzdef4/tkw1amtIf8GB1tt6hQLpur9p2jSFNfTYRyzZ8XrXofxefXQ0A7EUA=="],
|
||||
|
||||
"@opentelemetry/exporter-trace-otlp-proto/@opentelemetry/core": ["@opentelemetry/core@2.7.1", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-QAqIj32AtK6+pEVNG7EOVxHdE06RP+FM5qpiEJ4RtDcFIqKUZHYhl7/7UY5efhwmwNAg7j8QbJVBLxMerc0+gw=="],
|
||||
|
||||
"@opentelemetry/exporter-trace-otlp-proto/@opentelemetry/resources": ["@opentelemetry/resources@2.7.1", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-DeT6KKolmC4e/dRQvMQ/RwlnzhaqeiFOXY5ngoOPJ07GgVVKxZOg9EcrNZb5aTzUn+iCrJldAgOfQm1O/QfPAQ=="],
|
||||
@@ -1492,15 +1490,15 @@
|
||||
|
||||
"@opentelemetry/sdk-metrics/@opentelemetry/resources": ["@opentelemetry/resources@2.7.1", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-DeT6KKolmC4e/dRQvMQ/RwlnzhaqeiFOXY5ngoOPJ07GgVVKxZOg9EcrNZb5aTzUn+iCrJldAgOfQm1O/QfPAQ=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="],
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" }, "bundled": true }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="],
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="],
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.5", "", { "dependencies": { "@tybys/wasm-util": "^0.10.2" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" }, "bundled": true }, "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q=="],
|
||||
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.6", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" }, "bundled": true }, "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="],
|
||||
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
@@ -1546,8 +1544,6 @@
|
||||
|
||||
"@huggingface/transformers/onnxruntime-node/onnxruntime-common": ["onnxruntime-common@1.24.3", "", {}, "sha512-GeuPZO6U/LBJXvwdaqHbuUmoXiEdeCjWi/EG7Y1HNnDwJYuk6WUbNXpF6luSUY8yASul3cmUlLGrCCL1ZgVXqA=="],
|
||||
|
||||
"@oh-my-pi/pi-coding-agent/sherpa-onnx-node/sherpa-onnx-darwin-arm64": ["sherpa-onnx-darwin-arm64@1.13.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-9x86Cbf+BDFONdtCPM3cnjvtAW0ER8tMaHK5pVfz+SHPt8GeuwRXaiR/BzcByFBUyxCgmceO09/WMZOCi44P/g=="],
|
||||
|
||||
"cli-progress/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
||||
|
||||
"cli-progress/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
|
||||
|
||||
@@ -172,7 +172,7 @@ fn create_windows_napi_tokio_runtime() -> Option<tokio::runtime::Runtime> {
|
||||
/// MUST stay in sync with `VERSION_SENTINEL_EXPORT` in
|
||||
/// `packages/natives/native/index.js` (which derives the name from
|
||||
/// `package.json#version`).
|
||||
#[napi(js_name = "__piNativesV16_1_22")]
|
||||
#[napi(js_name = "__piNativesV16_2_2")]
|
||||
pub const fn pi_natives_version_sentinel() {}
|
||||
|
||||
/// Native module entry point: install crash diagnostics before any tool can
|
||||
|
||||
@@ -19,6 +19,8 @@ brush-core.workspace = true
|
||||
brush-parser.workspace = true
|
||||
clap.workspace = true
|
||||
os_pipe.workspace = true
|
||||
globset.workspace = true
|
||||
ignore.workspace = true
|
||||
regex.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
||||
pub mod cancel;
|
||||
mod coreutils;
|
||||
mod fd;
|
||||
pub mod fixup;
|
||||
pub mod minimizer;
|
||||
pub mod process;
|
||||
|
||||
@@ -534,6 +534,7 @@ async fn create_session(config: &ShellConfig) -> Result<ShellSessionCore> {
|
||||
shell.register_builtin("find", crate::coreutils::find_builtin());
|
||||
shell.register_builtin("grep", crate::coreutils::grep_builtin());
|
||||
shell.register_builtin("rg", crate::coreutils::rg_builtin());
|
||||
shell.register_builtin("fd", crate::fd::fd_builtin());
|
||||
shell.register_builtin("cat", crate::coreutils::cat_builtin());
|
||||
shell.register_builtin("uniq", crate::coreutils::uniq_builtin());
|
||||
if !uutils_env_disabled(config, "PI_DISABLE_UUTILS_DESTRUCTIVE") {
|
||||
@@ -2389,6 +2390,122 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
}
|
||||
|
||||
/// `fd` recurses from the shell working directory, respects hidden and
|
||||
/// ignore filters (including `.fdignore`), preserves explicit search-path
|
||||
/// prefixes, and renders help to stdout with a success status.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn fd_builtin_uses_fd_defaults() {
|
||||
let tmp = std::env::temp_dir().join(format!("pi-fd-defaults-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
std::fs::create_dir_all(tmp.join("sub")).expect("sub dir");
|
||||
std::fs::create_dir_all(tmp.join(".git/info")).expect("git info dir");
|
||||
std::fs::write(tmp.join("needle.txt"), "visible\n").expect("visible");
|
||||
std::fs::write(tmp.join("sub/needle.rs"), "nested\n").expect("nested");
|
||||
std::fs::write(tmp.join(".hidden-needle.txt"), "hidden\n").expect("hidden");
|
||||
std::fs::write(tmp.join("ignored-needle.log"), "ignored\n").expect("ignored");
|
||||
std::fs::write(tmp.join("excluded-needle.vcs"), "excluded\n").expect("excluded");
|
||||
std::fs::write(tmp.join("fdignored-needle.tmp"), "fdignored\n").expect("fdignored");
|
||||
std::fs::write(tmp.join(".gitignore"), "ignored-needle.log\n").expect("gitignore");
|
||||
std::fs::write(tmp.join(".git/info/exclude"), "excluded-needle.vcs\n").expect("exclude");
|
||||
std::fs::write(tmp.join(".fdignore"), "fdignored-needle.tmp\n").expect("fdignore");
|
||||
let tmp_str = tmp.to_str().expect("utf8");
|
||||
|
||||
let config = ShellConfig { session_env: None, snapshot_path: None, minimizer: None };
|
||||
let mut session = create_session(&config).await.expect("create_session");
|
||||
session.shell.set_working_dir(tmp_str).expect("cwd");
|
||||
let mut params = session.shell.default_exec_params();
|
||||
params.set_fd(OpenFiles::STDIN_FD, null_file().expect("null"));
|
||||
params.set_fd(OpenFiles::STDOUT_FD, null_file().expect("null"));
|
||||
params.set_fd(OpenFiles::STDERR_FD, null_file().expect("null"));
|
||||
let si = SourceInfo::from("pi-natives:test");
|
||||
let read = |name: &str| std::fs::read_to_string(tmp.join(name)).unwrap_or_default();
|
||||
|
||||
let exec = session
|
||||
.shell
|
||||
.run_string("fd needle > fd.txt", &si, ¶ms)
|
||||
.await
|
||||
.expect("fd");
|
||||
assert_eq!(exit_code(&exec), 0, "fd should match visible files");
|
||||
let out = read("fd.txt");
|
||||
assert!(out.contains("needle.txt"), "fd missed visible file: {out:?}");
|
||||
assert!(out.contains("sub/needle.rs"), "fd missed nested file: {out:?}");
|
||||
assert!(!out.contains(".hidden-needle.txt"), "fd searched hidden file: {out:?}");
|
||||
assert!(!out.contains("ignored-needle.log"), "fd ignored .gitignore: {out:?}");
|
||||
assert!(!out.contains("fdignored-needle.tmp"), "fd ignored .fdignore: {out:?}");
|
||||
assert!(!out.contains("excluded-needle.vcs"), "fd ignored .git/info/exclude: {out:?}");
|
||||
|
||||
session
|
||||
.shell
|
||||
.run_string("fd -u needle > unrestricted.txt", &si, ¶ms)
|
||||
.await
|
||||
.expect("fd -u");
|
||||
let unrestricted = read("unrestricted.txt");
|
||||
assert!(unrestricted.contains(".hidden-needle.txt"), "-u should include hidden files");
|
||||
assert!(unrestricted.contains("ignored-needle.log"), "-u should include gitignored files");
|
||||
assert!(unrestricted.contains("fdignored-needle.tmp"), "-u should include fdignored files");
|
||||
|
||||
session
|
||||
.shell
|
||||
.run_string("fd --no-ignore-vcs needle > no-ignore-vcs.txt", &si, ¶ms)
|
||||
.await
|
||||
.expect("fd --no-ignore-vcs");
|
||||
let no_ignore_vcs = read("no-ignore-vcs.txt");
|
||||
assert!(
|
||||
no_ignore_vcs.contains("ignored-needle.log"),
|
||||
"--no-ignore-vcs should include .gitignore matches"
|
||||
);
|
||||
assert!(
|
||||
no_ignore_vcs.contains("excluded-needle.vcs"),
|
||||
"--no-ignore-vcs should include .git/info/exclude matches"
|
||||
);
|
||||
assert!(
|
||||
!no_ignore_vcs.contains("fdignored-needle.tmp"),
|
||||
"--no-ignore-vcs must still respect .fdignore"
|
||||
);
|
||||
|
||||
session
|
||||
.shell
|
||||
.run_string("fd --glob '*.rs' sub > glob.txt", &si, ¶ms)
|
||||
.await
|
||||
.expect("fd glob");
|
||||
assert_eq!(read("glob.txt"), "sub/needle.rs\n");
|
||||
|
||||
let no_match = session
|
||||
.shell
|
||||
.run_string("fd definitely-absent > no-match.txt", &si, ¶ms)
|
||||
.await
|
||||
.expect("fd no match");
|
||||
assert_eq!(exit_code(&no_match), 0, "ordinary fd no-match should still succeed");
|
||||
assert_eq!(read("no-match.txt"), "");
|
||||
|
||||
let quiet_miss = session
|
||||
.shell
|
||||
.run_string("fd -q definitely-absent > quiet-miss.txt", &si, ¶ms)
|
||||
.await
|
||||
.expect("fd quiet miss");
|
||||
assert_eq!(exit_code(&quiet_miss), 1, "quiet fd no-match should fail");
|
||||
assert_eq!(read("quiet-miss.txt"), "");
|
||||
|
||||
let quiet_hit = session
|
||||
.shell
|
||||
.run_string("fd -q needle > quiet-hit.txt", &si, ¶ms)
|
||||
.await
|
||||
.expect("fd quiet hit");
|
||||
assert_eq!(exit_code(&quiet_hit), 0, "quiet fd match should succeed");
|
||||
assert_eq!(read("quiet-hit.txt"), "");
|
||||
|
||||
let help = session
|
||||
.shell
|
||||
.run_string("fd --help > help.txt 2> help.err", &si, ¶ms)
|
||||
.await
|
||||
.expect("fd help");
|
||||
assert_eq!(exit_code(&help), 0, "fd help should succeed");
|
||||
assert!(read("help.txt").contains("A program to find entries in your filesystem"));
|
||||
assert_eq!(read("help.err"), "");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
}
|
||||
|
||||
/// Plain `rg PATTERN` uses the shell working directory when the host wired
|
||||
/// stdin to null, but a real pipeline remains stdin input. Pattern stdin
|
||||
/// (`-f -`) must not consume the implicit search path decision.
|
||||
|
||||
@@ -8,6 +8,7 @@ The advisor is not a second executor. It cannot edit files, run commands, approv
|
||||
|
||||
- [`src/advisor/runtime.ts`](../packages/coding-agent/src/advisor/runtime.ts)
|
||||
- [`src/advisor/advise-tool.ts`](../packages/coding-agent/src/advisor/advise-tool.ts)
|
||||
- [`src/advisor/emission-guard.ts`](../packages/coding-agent/src/advisor/emission-guard.ts)
|
||||
- [`src/advisor/watchdog.ts`](../packages/coding-agent/src/advisor/watchdog.ts)
|
||||
- [`src/advisor/transcript-recorder.ts`](../packages/coding-agent/src/advisor/transcript-recorder.ts)
|
||||
- [`src/prompts/advisor/system.md`](../packages/coding-agent/src/prompts/advisor/system.md)
|
||||
@@ -100,6 +101,19 @@ When you deliberately interrupt the agent (Esc, or a cancel from collab, ACP, RP
|
||||
|
||||
`advisor.immuneTurns` limits interruption frequency. After the advisor successfully delivers a `concern` or `blocker` through the steering channel, later concerns/blockers are routed as non-interrupting asides until the configured number of primary turns has completed. The default is `3`. `nit` notes are unchanged, and advice raised while user-interrupt auto-resume suppression is active is still preserved instead of restarting a stopped run.
|
||||
|
||||
### Emission guard
|
||||
|
||||
`AdvisorEmissionGuard` (in `src/advisor/emission-guard.ts`) sits on the `enqueueAdvice` boundary in `AgentSession` and enforces — in code — the advisor system prompt's "at most one `advise` per update" and "NEVER send the same advice twice" rules. Each call to the advisor's `advise` tool runs through the guard before it routes to the YieldQueue / steer channel:
|
||||
|
||||
1. **Normalization.** Lowercase, NFKC, collapse every run of non-alphanumeric characters to a single space, trim. `"Stop."`, `"*Stop*"`, and `" stop "` all key to `stop`.
|
||||
2. **Content-free phrase filter.** A small allowlist of normalized phrases the advisor occasionally emits but that carry no concrete reason — `stop`, `done`, `complete`, `no issue continue`, `lgtm`, `nothing to add`, `no further input`, and similar — is suppressed silently. Silence is the correct expression of "no concerns".
|
||||
3. **Exact-text dedupe.** Any normalized note already accepted in this session is dropped. The dedupe history is bounded by a FIFO ring (default 4096 entries).
|
||||
4. **Per-update rate limit.** At most one note per advisor model `prompt()` cycle is accepted; the runtime calls `host.beginAdvisorUpdate?.()` before each cycle to reset the gate. Suppressed calls never consume the budget — a noise call doesn't displace a real concern that follows in the same update.
|
||||
|
||||
Suppression is invisible to the advisor model: `AdviseTool` still returns `Recorded.` for a dropped call. Surfacing "suppressed" back into advisor context risks the model rephrasing the same useless note to bypass the dedupe.
|
||||
|
||||
The guard's full state — dedupe history and per-update gate — clears on every advisor reset (compaction, session switch, `/new`), so a re-primed reviewer can re-raise issues it already raised against the rewritten transcript.
|
||||
|
||||
## Bounded catch-up with `advisor.syncBacklog`
|
||||
|
||||
`advisor.syncBacklog` is not lockstep turn execution. It is a bounded catch-up delay for the primary agent when the advisor falls behind.
|
||||
|
||||
@@ -116,7 +116,7 @@ Current callers:
|
||||
- `@`-mention fuzzy file autocomplete enables cache (`fuzzyFind` with `cache: true`):
|
||||
- `packages/tui/src/autocomplete.ts`
|
||||
- Mutation flows invalidate through `packages/coding-agent/src/tools/fs-cache-invalidation.ts`.
|
||||
- Tool-level search integration (`packages/coding-agent/src/tools/search.ts`) currently calls native `grep` with `cache: false`.
|
||||
- Tool-level grep integration (`packages/coding-agent/src/tools/grep.ts`) currently calls native `grep` with `cache: false`.
|
||||
|
||||
## Invalidation contract
|
||||
|
||||
|
||||
+19
-3
@@ -104,7 +104,7 @@ providers:
|
||||
### Allowed auth/discovery values
|
||||
|
||||
- `auth`: `apiKey` (default), `none`, or `oauth`; for `models.yml` custom models, `oauth` is accepted by schema but does not waive the `apiKey` requirement
|
||||
- `discovery.type`: `ollama`, `llama.cpp`, `lm-studio`, `openai-models-list`, or `proxy`
|
||||
- `discovery.type`: `ollama`, `llama.cpp`, `lm-studio`, `openai-models-list`, `proxy`, or `litellm`
|
||||
- `transport`: `pi-native` only. When set, every model under that provider is sent to an `omp auth-gateway` compatible `baseUrl` via `POST /v1/pi/stream`; `apiKey` is the gateway bearer.
|
||||
|
||||
## Validation rules (current)
|
||||
@@ -299,7 +299,7 @@ When `litellm` is active (for example through `LITELLM_API_KEY` or stored auth),
|
||||
- base URL: explicit provider `baseUrl` / `models.yml` config, otherwise `LITELLM_BASE_URL`, otherwise `http://localhost:4000/v1`
|
||||
- auth mode: `LITELLM_API_KEY` or stored LiteLLM auth when the proxy requires a key
|
||||
|
||||
Runtime discovery fetches models (`GET /models`) from the proxy and enriches bare LiteLLM model ids against bundled reference metadata when available.
|
||||
Runtime discovery probes LiteLLM management metadata first: `GET /model_group/info`, then `GET /v2/model/info`, then falls back to the OpenAI-compatible `GET /models` list. Rich metadata maps `max_input_tokens`, `max_output_tokens`, `supports_vision`, and `supports_reasoning`; bare fallback ids are enriched against bundled reference metadata when available.
|
||||
|
||||
### Explicit provider discovery
|
||||
|
||||
@@ -322,6 +322,20 @@ providers:
|
||||
type: llama.cpp
|
||||
```
|
||||
|
||||
Custom LiteLLM gateways can use the same rich discovery path:
|
||||
|
||||
```yaml
|
||||
providers:
|
||||
litellm-gateway:
|
||||
baseUrl: http://gateway.example:4000/v1
|
||||
apiKey: LITELLM_API_KEY
|
||||
api: openai-completions
|
||||
discovery:
|
||||
type: litellm
|
||||
```
|
||||
|
||||
LiteLLM metadata endpoints use the configured base URL with a trailing `/v1` stripped for discovery only, preserving any preceding proxy path. Runtime model calls keep the configured OpenAI-compatible `/v1` base URL.
|
||||
|
||||
### Proxy discovery (`discovery.type: proxy`)
|
||||
|
||||
For Anthropic+OpenAI-compatible proxies (new-api / one-api / similar)
|
||||
@@ -430,7 +444,9 @@ Resolution precedence for exact selectors:
|
||||
|
||||
Supported model roles:
|
||||
|
||||
- `default`, `smol`, `slow`, `vision`, `plan`, `designer`, `commit`, `title`, `task`, `advisor`
|
||||
- `default`, `smol`, `slow`, `vision`, `plan`, `designer`, `commit`, `tiny`, `task`, `advisor`
|
||||
|
||||
The `tiny` role overrides the online model used for lightweight background tasks (session titles, memory, `auto`-thinking difficulty classification, unexpected-stop detection); when unset, these fall back to `pi/smol`. Pick one in `/models`.
|
||||
|
||||
Role aliases like `pi/smol` expand through `settings.modelRoles`. Each role value can also append a thinking selector such as `:minimal`, `:low`, `:medium`, or `:high`.
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ It follows the architecture terms from `docs/natives-architecture.md`:
|
||||
`packages/natives/package.json` scripts:
|
||||
|
||||
- `bun scripts/build-native.ts` (`build`) → N-API build, addon install, generated declarations install, explicit ESM export and enum runtime patch.
|
||||
- `bun scripts/embed-native.ts` (`embed:native`) → generate `native/embedded-addon.js` plus `native/embedded-addons.<tag>.tar.gz` from built files.
|
||||
- `bun scripts/embed-native.ts` (`gen:native`) → generate `native/embedded-addon.js` plus `native/embedded-addons.<tag>.tar.gz` from built files.
|
||||
- `bun scripts/gen-npm-packages.ts` (`gen:npm`) → generate per-platform npm leaf packages (`@oh-my-pi/pi-natives-<platform>-<arch>`, installed as optional dependencies of the core package) under `npm/` from built addon files.
|
||||
|
||||
Root scripts include `build:native` as `bun --cwd=packages/natives run build`.
|
||||
@@ -201,7 +201,7 @@ Generated declarations currently include exports from these Rust modules:
|
||||
| x64 machine loads baseline when modern expected | `PI_NATIVE_VARIANT=baseline`, no AVX2 detected, or modern file unavailable | Check env and filenames in `native/` | Build modern variant (`TARGET_VARIANT=modern ... build`) and ship it |
|
||||
| Cross-build produces wrong-labeled binary | Mismatch between `CROSS_TARGET` and `TARGET_PLATFORM`/`TARGET_ARCH`, or missing x64 variant | Confirm env tuple and output filename | Re-run with consistent env values and explicit x64 `TARGET_VARIANT` |
|
||||
| Compiled binary fails after upgrade | Stale extracted cache, embedded archive mismatch, or embedded manifest version mismatch | Inspect `<getNativesDir()>/<version>` and loader error list | Delete versioned cache for the package version; regenerate embedded archive/manifest during packaging |
|
||||
| `embed:native` fails with `No native addons found` | Required platform artifact was not built before embedding | Check expected list in error text | Build at least one expected artifact for the target, then rerun `embed:native` |
|
||||
| `gen:native` fails with `No native addons found` | Required platform artifact was not built before embedding | Check expected list in error text | Build at least one expected artifact for the target, then rerun `gen:native` |
|
||||
|
||||
## Operational commands
|
||||
|
||||
@@ -214,11 +214,11 @@ TARGET_VARIANT=modern bun --cwd=packages/natives run build
|
||||
TARGET_VARIANT=baseline bun --cwd=packages/natives run build
|
||||
|
||||
# Generate embedded addon manifest from built native files
|
||||
bun --cwd=packages/natives run embed:native
|
||||
bun run gen:native
|
||||
# Output archive: packages/natives/native/embedded-addons.<platform>-<arch>.tar.gz
|
||||
|
||||
# Reset embedded manifest to null stub
|
||||
bun --cwd=packages/natives run embed:native -- --reset
|
||||
bun run gen:native:reset
|
||||
```
|
||||
|
||||
## Orchestrator-side content-addressed build cache (robomp)
|
||||
|
||||
@@ -100,7 +100,7 @@ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "myapp-"));
|
||||
|
||||
## 5) Prefer Bun embeds (no copying)
|
||||
|
||||
Do not add new runtime asset copy steps. Keep assets in repo and prefer Bun embeds/imports; preserve existing explicit generation workflows such as `packages/coding-agent/src/export/html/tool-views.generated.js` (built from collab-web sources via `bun run build-tool-views`).
|
||||
Do not add new runtime asset copy steps. Keep assets in repo and prefer Bun embeds/imports; preserve existing explicit generation workflows such as `packages/coding-agent/src/export/html/tool-views.generated.js` (built from collab-web sources via `bun run gen:tool-views`).
|
||||
|
||||
- If upstream copies assets into a dist folder, replace with Bun-friendly embeds.
|
||||
- Prompts are static `.md` files; use Bun text imports (`with { type: "text" }`) and Handlebars instead of inline prompt strings.
|
||||
|
||||
@@ -43,7 +43,7 @@ Behavior details:
|
||||
- `--copy`, `clipboard`, and `copy` arguments are explicitly rejected with a warning to use `/dump`.
|
||||
- Export embeds session header/entries/leaf plus current `systemPrompt` and tool descriptions from agent state.
|
||||
- Subagent transcripts stored next to the session file (`<session>/<AgentId>.jsonl`, recursively for nested spawns) are embedded as `subSessions` (`collectSubSessions` in `src/export/html/index.ts`; disable with `includeSubSessions: false` in `ExportOptions`). In the page, agent ids in task tool cards open a breadcrumbed sub-session overlay.
|
||||
- Tool calls render through the `<omp-tool-view>` web component — the React per-tool renderers shared with collab-web (`packages/collab-web/src/tool-render/`), prebuilt into `src/export/html/tool-views.generated.js` by `bun --cwd=packages/collab-web run build:tool-views`.
|
||||
- Tool calls render through the `<omp-tool-view>` web component — the React per-tool renderers shared with collab-web (`packages/collab-web/src/tool-render/`), prebuilt into `src/export/html/tool-views.generated.js` by `bun run gen:tool-views`.
|
||||
- No session entries are appended during export.
|
||||
|
||||
Caveat:
|
||||
|
||||
+2
-2
@@ -308,7 +308,7 @@ enabledModels:
|
||||
|
||||
| Key | Type | Default | Notes |
|
||||
|---|---|---|---|
|
||||
| `modelRoles` | record | `{}` | Map of role name -> model id. Built-in roles: `default`, `smol`, `slow`, `vision`, `plan`, `designer`, `commit`, `title`, `task`, `advisor`. Per-role env/flags exist only for `--model`/`--smol`/`--slow`/`--plan`; configure the advisor with `modelRoles.advisor`. |
|
||||
| `modelRoles` | record | `{}` | Map of role name -> model id. Built-in roles: `default`, `smol`, `slow`, `vision`, `plan`, `designer`, `commit`, `tiny`, `task`, `advisor`. The `tiny` role overrides the online model for lightweight background tasks (titles, memory, auto-thinking, unexpected-stop), else `pi/smol`. Per-role env/flags exist only for `--model`/`--smol`/`--slow`/`--plan`; configure the advisor with `modelRoles.advisor`. |
|
||||
| `modelTags` | record | `{}` | Custom role/tag metadata; can introduce additional roles. |
|
||||
| `modelProviderOrder` | array | `[]` | Preferred provider order when a model id is ambiguous. |
|
||||
| `cycleOrder` | array | `["smol","default","slow"]` | Roles cycled by the model switcher. |
|
||||
@@ -418,7 +418,7 @@ tools:
|
||||
| `tools.artifactTailBytes` | number | `20` | KB of tail kept inline on spill. |
|
||||
| `tools.artifactTailLines` | number | `500` | Max tail lines kept inline on spill. |
|
||||
|
||||
Individual built-in tools are toggled by their own keys, e.g. `bash.enabled`, `eval.py`, `eval.js`, `find.enabled`, `search.enabled`, `fetch.enabled`, `browser.enabled`, `astEdit.enabled`, `astGrep.enabled`, `web_search.enabled`, `inspect_image.enabled`.
|
||||
Individual built-in tools are toggled by their own keys, e.g. `bash.enabled`, `eval.py`, `eval.js`, `glob.enabled`, `grep.enabled`, `fetch.enabled`, `browser.enabled`, `astEdit.enabled`, `astGrep.enabled`, `web_search.enabled`, `inspect_image.enabled`.
|
||||
|
||||
### Shell, eval, and LSP
|
||||
|
||||
|
||||
+3
-3
@@ -96,7 +96,7 @@ Stdout and stderr are merged before the model sees them. Definite non-zero exit
|
||||
- Starts like a foreground managed job, then backgrounds it when it outlives the wait window.
|
||||
6. Intercepted command
|
||||
- No subprocess created.
|
||||
- Returns a `ToolError` pointing the model at `read`, `search`, `find`, `edit`, or `write`.
|
||||
- Returns a `ToolError` pointing the model at `read`, `grep`, `glob`, `edit`, or `write`.
|
||||
|
||||
## Side Effects
|
||||
- Filesystem
|
||||
@@ -153,8 +153,8 @@ Stdout and stderr are merged before the model sees them. Definite non-zero exit
|
||||
- `checkBashInterception()` blocks only when the matching rule's `tool` name is present in `ctx.toolNames`; missing tools disable their corresponding rule.
|
||||
- Default interceptor rules come from `DEFAULT_BASH_INTERCEPTOR_RULES` in `packages/coding-agent/src/config/settings-schema.ts`:
|
||||
- `cat|head|tail|less|more` -> `read`
|
||||
- `grep|rg|ripgrep|ag|ack` -> `search`
|
||||
- `find|fd|locate` with name/type/glob flags -> `find`
|
||||
- `grep|rg|ripgrep|ag|ack` -> `grep`
|
||||
- `find|fd|locate` with name/type/glob flags -> `glob`
|
||||
- `sed -i`, `perl -i`, `awk -i inplace` -> `edit`
|
||||
- `echo|printf|cat <<` with redirection -> `write`
|
||||
- PTY mode is ignored in non-UI contexts and when `PI_NO_PTY=1` (gated by `canUseInteractiveBashPty()`); the tool falls back to non-PTY execution and appends a `pty requested but unavailable in this environment; ran without a terminal` notice.
|
||||
|
||||
@@ -155,6 +155,44 @@ Side-channel artifacts outside the model tool result:
|
||||
- **Adapter selection**
|
||||
- `launch`: explicit `adapter` wins; otherwise `selectLaunchAdapter()` ranks available adapters by extension match, root-marker match, then native-debugger preference (`gdb`, `lldb-dap`) for extensionless binaries.
|
||||
- `attach`: explicit `adapter` wins; otherwise remote `port` prefers `debugpy`, then native debuggers, then first available adapter.
|
||||
- **Custom adapter config**
|
||||
- Debug adapters can be added or overridden with `dap.json`, `.dap.json`, `dap.yaml`, `.dap.yaml`, `dap.yml`, or `.dap.yml`.
|
||||
- Search order mirrors LSP config: project root, project config dirs (`.omp/`, `.pi/`, `.claude/`), user config dirs, plugin roots, then home-root fallback. Files are merged from lowest to highest priority.
|
||||
- Config shape may be either `{ "adapters": { ... } }` or a top-level adapter map.
|
||||
- Adapter fields:
|
||||
- `command`: executable name or path. Required.
|
||||
- `args`: adapter argv.
|
||||
- `languages`: display/filter metadata.
|
||||
- `fileTypes`: file extensions or filenames used for launch auto-selection.
|
||||
- `rootMarkers`: files/directories used to rank adapters for a project.
|
||||
- `launchDefaults`: default DAP launch arguments merged before the selected program/cwd/args.
|
||||
- `attachDefaults`: default DAP attach arguments merged before pid/port/host/cwd.
|
||||
- `connectMode`: `"stdio"` (default) or `"socket"`.
|
||||
- `acceptsDirectoryProgram`: set `true` for adapters such as `dlv` that can launch a package/project directory.
|
||||
|
||||
Example `.omp/dap.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"adapters": {
|
||||
"custom-jvm": {
|
||||
"command": "kotlin-debug-adapter",
|
||||
"args": ["--stdio"],
|
||||
"languages": ["java", "kotlin"],
|
||||
"fileTypes": [".java", ".kt", ".kts"],
|
||||
"rootMarkers": ["pom.xml", "build.gradle", "build.gradle.kts"],
|
||||
"launchDefaults": {
|
||||
"request": "launch",
|
||||
"projectRoot": "."
|
||||
},
|
||||
"attachDefaults": {
|
||||
"request": "attach",
|
||||
"host": "127.0.0.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
- **Transport**
|
||||
- stdio adapters: direct `stdin`/`stdout` framing.
|
||||
- socket adapters: Unix domain socket on Linux; TCP callback on macOS/other.
|
||||
|
||||
+2
-2
@@ -22,7 +22,7 @@
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `input` | `string` | Yes | One or more file sections. Anchored sections must start with `[PATH#TAG]`; `TAG` is the four-hex snapshot tag emitted by the latest `read`/`search`/`write`/successful `edit`. Optional `*** Begin Patch` / `*** End Patch` envelope is ignored if present. |
|
||||
| `input` | `string` | Yes | One or more file sections. Anchored sections must start with `[PATH#TAG]`; `TAG` is the four-hex snapshot tag emitted by the latest `read`/`grep`/`write`/successful `edit`. Optional `*** Begin Patch` / `*** End Patch` envelope is ignored if present. |
|
||||
|
||||
Patch language inside `input`:
|
||||
|
||||
@@ -45,7 +45,7 @@ Patch language inside `input`:
|
||||
- There is no repeat row kind. To keep a line, leave it out of every range; split edits into multiple hunks when needed.
|
||||
- `-` rows are invalid. Literal text beginning with `-` or `+` must be written as `+-text` / `++text`.
|
||||
|
||||
Anchors come from `read`/`search` output. `read` emits a `[PATH#TAG]` header from the session snapshot store and lines as `LINE:TEXT`; copy the header into the edit section and copy only the line number into hunk headers.
|
||||
Anchors come from `read`/`grep` output. `read` emits a `[PATH#TAG]` header from the session snapshot store and lines as `LINE:TEXT`; copy the header into the edit section and copy only the line number into hunk headers.
|
||||
|
||||
### Tolerated input shapes (lenient parsing)
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# find
|
||||
# glob
|
||||
|
||||
> Find filesystem paths by glob; use `search` when you need content matches instead of path matches.
|
||||
> Find filesystem paths by glob; use `grep` when you need content matches instead of path matches.
|
||||
|
||||
## Source
|
||||
- Entry: `packages/coding-agent/src/tools/find.ts`
|
||||
- Model-facing prompt: `packages/coding-agent/src/prompts/tools/find.md`
|
||||
- Entry: `packages/coding-agent/src/tools/glob.ts`
|
||||
- Model-facing prompt: `packages/coding-agent/src/prompts/tools/glob.md`
|
||||
- Key collaborators:
|
||||
- `packages/coding-agent/src/tools/path-utils.ts` — normalize inputs; split base path vs glob.
|
||||
- `packages/coding-agent/src/tools/list-limit.ts` — apply result-count caps.
|
||||
@@ -41,8 +41,8 @@ The tool returns a single text block plus structured `details`.
|
||||
|
||||
## Flow
|
||||
|
||||
1. `FindTool.execute()` expands delimiter-flattened local `paths` entries with `expandDelimitedPathEntries(..., parseFindPattern)` unless custom operations are injected. The splitter validates candidate parts by statting their parsed base paths, keeps existing delimiter-containing paths intact, accepts comma/semicolon splits when at least one part resolves, and accepts whitespace splits only when every part resolves.
|
||||
2. The tool normalizes each resulting entry with `normalizePathLikeInput()` and `/\\/g -> "/"` (`packages/coding-agent/src/tools/find.ts`). Empty normalized entries fail with `` `paths` must contain non-empty globs or paths ``.
|
||||
1. `GlobTool.execute()` expands delimiter-flattened local `paths` entries with `expandDelimitedPathEntries(..., parseFindPattern)` unless custom operations are injected. The splitter validates candidate parts by statting their parsed base paths, keeps existing delimiter-containing paths intact, accepts comma/semicolon splits when at least one part resolves, and accepts whitespace splits only when every part resolves.
|
||||
2. The tool normalizes each resulting entry with `normalizePathLikeInput()` and `/\\/g -> "/"` (`packages/coding-agent/src/tools/glob.ts`). Empty normalized entries fail with `` `paths` must contain non-empty globs or paths ``.
|
||||
3. For multi-path local calls, `partitionExistingPaths(..., parseFindPattern)` (`packages/coding-agent/src/tools/path-utils.ts`) stats each base path. Missing entries are skipped; if all are missing, the tool throws `Path not found: ...`. Single missing paths still hard-fail.
|
||||
4. The tool calls `resolveExplicitFindPatterns()` for multi-entry calls; it parses each entry into its own `(basePath, globPattern, hasGlob)` target so every path is walked as its own root (collapsing to a shared ancestor would scan unrelated siblings). Single-entry calls parse with `parseFindPattern()` directly.
|
||||
5. `parseFindPattern()` determines `(basePath, globPattern, hasGlob)`:
|
||||
@@ -52,7 +52,7 @@ The tool returns a single text block plus structured `details`.
|
||||
6. `resolveToCwd()` converts the base path to an absolute path under the session cwd. A resolved `/` is rejected with `Searching from root directory '/' is not allowed`.
|
||||
7. `limit` defaults to `DEFAULT_LIMIT` (`200`), must be positive and finite, is floored, then clamped to `MAX_LIMIT` (`200`). `hidden` and `gitignore` both default to `true`. An internal timeout of `5` seconds (`5000` ms) is built via `AbortSignal.timeout(...)`.
|
||||
8. Execution then branches:
|
||||
- **Custom operations branch**: if `FindToolOptions.operations.glob` exists, the tool checks existence with `operations.exists()`, short-circuits exact-file inputs via `operations.stat()` when available, then calls `operations.glob(globPattern, searchPath, { ignore: ["**/node_modules/**", "**/.git/**"], limit })`.
|
||||
- **Custom operations branch**: if `GlobToolOptions.operations.glob` exists, the tool checks existence with `operations.exists()`, short-circuits exact-file inputs via `operations.stat()` when available, then calls `operations.glob(globPattern, searchPath, { ignore: ["**/node_modules/**", "**/.git/**"], limit })`.
|
||||
- **Built-in local branch**: the tool stats each target's `searchPath`. Exact-file inputs return immediately. Directory inputs call `natives.glob()` with `hidden`, `maxResults: effectiveLimit`, `sortByMtime: true`, `gitignore: useGitignore`, `recursive: false` (recursion comes from the `**/` prefix `parseFindPattern()` adds), and the combined abort signal; multi-target calls run their globs concurrently.
|
||||
9. In the local branch, optional `onMatch` callbacks convert each match to a cwd-relative display path and emit throttled progress updates.
|
||||
10. After native glob returns, JS merges per-target results, deduplicates repeated display paths, and sorts the merged list by `mtime` descending before formatting paths.
|
||||
@@ -66,7 +66,7 @@ The tool returns a single text block plus structured `details`.
|
||||
- **Multi-path search**: multiple inputs resolved by `resolveExplicitFindPatterns()` into per-entry targets, each walked as its own root concurrently and merged afterwards.
|
||||
- **Partial multi-path search with missing inputs**: local multi-path calls skip missing base paths and surface them as `missingPaths` / `Skipped missing paths: ...`.
|
||||
- **Internal URL input**: supported when the internal router resolves the URL to a backing file. Internal URL globs are rejected.
|
||||
- **Custom delegated search**: uses injected `FindOperations` instead of local fs + native glob.
|
||||
- **Custom delegated search**: uses injected `GlobOperations` instead of local fs + native glob.
|
||||
|
||||
## Side Effects
|
||||
- Filesystem
|
||||
@@ -81,31 +81,31 @@ The tool returns a single text block plus structured `details`.
|
||||
- Local globbing is cancellable through the caller abort signal plus the internal timeout.
|
||||
|
||||
## Limits & Caps
|
||||
- Default result limit: `200` (`DEFAULT_LIMIT` in `packages/coding-agent/src/tools/find.ts`).
|
||||
- Default result limit: `200` (`DEFAULT_LIMIT` in `packages/coding-agent/src/tools/glob.ts`).
|
||||
- Maximum result limit: `200` (`MAX_LIMIT`); larger inputs are clamped.
|
||||
- Local glob timeout: fixed at `5000` ms.
|
||||
- Output byte cap: `50 * 1024` bytes (`DEFAULT_MAX_BYTES` in `packages/coding-agent/src/session/streaming-output.ts`).
|
||||
- Default generic line cap in `truncateHead()` is `3000`, but `find` overrides `maxLines` to `Number.MAX_SAFE_INTEGER`, so byte size — not line count — is the practical output truncation cap.
|
||||
- Default generic line cap in `truncateHead()` is `3000`, but `glob` overrides `maxLines` to `Number.MAX_SAFE_INTEGER`, so byte size — not line count — is the practical output truncation cap.
|
||||
- Streaming update throttle: `200` ms between `onUpdate` emissions.
|
||||
- Sort order: most recent `mtime` first in the built-in local branch and promised in the prompt. The tool re-sorts in JS even though native glob receives `sortByMtime: true` so native code can still stop early at `maxResults`.
|
||||
|
||||
## Errors
|
||||
- User-facing `ToolError`s from `FindTool.execute()` include:
|
||||
- User-facing `ToolError`s from `GlobTool.execute()` include:
|
||||
- `` `paths` must contain non-empty globs or paths ``
|
||||
- `Path not found: ...`
|
||||
- `Searching from root directory '/' is not allowed`
|
||||
- `Limit must be a positive number`
|
||||
- `Path is not a directory: ...`
|
||||
- timeout result text is `find timed out after <seconds>s; returning <N> partial matches — narrow the pattern instead of retrying blindly` and is returned as a successful, truncated partial result rather than an error.
|
||||
- timeout result text is `glob timed out after <seconds>s; returning <N> partial matches — narrow the pattern instead of retrying blindly` and is returned as a successful, truncated partial result rather than an error.
|
||||
- If the caller aborts, the local branch converts `AbortError` into `ToolAbortError`.
|
||||
- Non-`ENOENT` stat failures and other unexpected errors are rethrown.
|
||||
- Empty matches are not errors; they return the no-files text result.
|
||||
|
||||
## Notes
|
||||
- Reach for `find` for filename / path discovery. Reach for `search` when the selection criterion is file contents or regex matches; `search` takes a `pattern` and returns anchored content matches, while `find` only returns matching paths (`packages/coding-agent/src/prompts/tools/find.md`, `packages/coding-agent/src/prompts/tools/search.md`).
|
||||
- Reach for `glob` for filename / path discovery. Reach for `grep` when the selection criterion is file contents or regex matches; `grep` takes a `pattern` and returns anchored content matches, while `glob` only returns matching paths (`packages/coding-agent/src/prompts/tools/glob.md`, `packages/coding-agent/src/prompts/tools/grep.md`).
|
||||
- Bare top-level globs are made recursive. `*.ts` is parsed as base `.` plus glob `**/*.ts`; `src/*.ts` stays rooted at `src` with a non-recursive `*.ts` segment; `src/**/*.ts` preserves explicit recursion.
|
||||
- `.gitignore` defaults to enabled in the built-in local branch. Use `gitignore: false` to disable it for native traversal.
|
||||
- `hidden` defaults to `true`; hidden-file exclusion is opt-out, not opt-in.
|
||||
- Multi-path missing-input tolerance applies in both branches, but only the built-in local branch surfaces `missingPaths` / `Skipped missing paths: ...`. The custom-operations branch hard-fails a missing `searchPath` only for single-input calls; in multi-input calls a missing target silently contributes no results.
|
||||
- The custom `FindOperations.glob()` hook receives `ignore` and `limit`, but not the `hidden` flag or an explicit `.gitignore` toggle. A remote delegate must account for that itself if it wants parity with the local branch.
|
||||
- The custom `GlobOperations.glob()` hook receives `ignore` and `limit`, but not the `hidden` flag or an explicit `.gitignore` toggle. A remote delegate must account for that itself if it wants parity with the local branch.
|
||||
- Built-in local globbing does not force `fileType: File`; it can return files and directories from native glob. Directory outputs also occur through exact-path passthrough or custom delegates that return them.
|
||||
@@ -1,10 +1,10 @@
|
||||
# search
|
||||
# grep
|
||||
|
||||
> Search file contents with a regex across files, directories, globs, and internal URLs.
|
||||
> Grep file contents with a regex across files, directories, globs, and internal URLs.
|
||||
|
||||
## Source
|
||||
- Entry: `packages/coding-agent/src/tools/search.ts`
|
||||
- Model-facing prompt: `packages/coding-agent/src/prompts/tools/search.md`
|
||||
- Entry: `packages/coding-agent/src/tools/grep.ts`
|
||||
- Model-facing prompt: `packages/coding-agent/src/prompts/tools/grep.md`
|
||||
- Key collaborators:
|
||||
- `packages/coding-agent/src/tools/match-line-format.ts` — model-facing anchor formatting.
|
||||
- `packages/coding-agent/src/tools/path-utils.ts` — path normalization, glob splitting, internal URL resolution.
|
||||
@@ -20,11 +20,11 @@
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `pattern` | `string` | Yes | Regex pattern. `search.ts` rejects whitespace-only input but otherwise preserves the pattern verbatim (leading/trailing whitespace is meaningful in regexes). The native matcher enables multiline only when the pattern text contains a literal newline or the two-character sequence `\\n`. The native layer auto-escapes braces that cannot be valid repetition quantifiers, so patterns like `${platform}` stay searchable (see Notes). |
|
||||
| `pattern` | `string` | Yes | Regex pattern. `grep.ts` rejects whitespace-only input but otherwise preserves the pattern verbatim (leading/trailing whitespace is meaningful in regexes). The native matcher enables multiline only when the pattern text contains a literal newline or the two-character sequence `\\n`. The native layer auto-escapes braces that cannot be valid repetition quantifiers, so patterns like `${platform}` stay searchable (see Notes). |
|
||||
| `paths` | `string \| string[]` | No | One file path, directory path, glob-like path, archive member, internal URL, or an array of those. Omitted or empty defaults to `.` (the workspace root). Append a line-range selector such as `:50-100` or `:5-16,960-973` to a single file/archive/internal-resource input to constrain matches. Empty strings are rejected after trimming/quote stripping. Single entries accidentally joined with comma, semicolon, or whitespace are expanded only after existence validation; existing paths containing delimiters stay intact. Filesystem-backed internal URLs search their backing file; virtual internal resources search resolved text in memory. Internal URLs cannot contain glob characters. |
|
||||
| `case` | `boolean` | No | Case-sensitive search. Defaults to `true`. Passed to native `ignoreCase` or JS `RegExp` flags for virtual resources. |
|
||||
| `gitignore` | `boolean` | No | Respect `.gitignore` during directory scans. Defaults to `true`. Passed to native `gitignore`. |
|
||||
| `skip` | `number` | No | File-page offset for multi-file results. Defaults to `0`; `search.ts` floors finite numbers and rejects negative or non-finite values. Single-file searches ignore it because they do not paginate by file. |
|
||||
| `skip` | `number` | No | File-page offset for multi-file results. Defaults to `0`; `grep.ts` floors finite numbers and rejects negative or non-finite values. Single-file searches ignore it because they do not paginate by file. |
|
||||
|
||||
## Outputs
|
||||
The tool returns a single text block in `content[0].text` plus structured `details`.
|
||||
@@ -45,13 +45,13 @@ The tool returns a single text block in `content[0].text` plus structured `detai
|
||||
- No-match result text is `No matches found` (or `No more results (...)` when `skip` points past the last file page), optionally followed by skipped missing-path, unreadable-archive, or oversized-file notes.
|
||||
|
||||
## Flow
|
||||
1. `SearchTool.execute()` validates and normalizes input in `packages/coding-agent/src/tools/search.ts`:
|
||||
1. `GrepTool.execute()` validates and normalizes input in `packages/coding-agent/src/tools/grep.ts`:
|
||||
- rejects whitespace-only patterns while preserving the pattern verbatim;
|
||||
- defaults omitted or empty `paths` to `["."]` (the workspace root);
|
||||
- normalizes `skip` to a non-negative integer;
|
||||
- expands delimiter-flattened `paths` entries with `expandDelimitedPathEntries()`, keeping existing delimiter-containing paths intact, accepting comma/semicolon splits when at least one part resolves, and accepting whitespace splits only when every part resolves;
|
||||
- peels any line-range selector from each resulting entry;
|
||||
- reads `search.contextBefore` and `search.contextAfter` from session settings (`1` and `3` by default);
|
||||
- reads `grep.contextBefore` and `grep.contextAfter` from session settings (`1` and `3` by default);
|
||||
- enables multiline only when `pattern` contains `\n` or an actual newline.
|
||||
2. Each `paths` entry is normalized with `normalizePathLikeInput()` again during shared scope resolution; this is a no-op for entries already normalized by delimiter expansion.
|
||||
3. Archive member paths such as `bundle.zip:src/foo.ts` are materialized to temporary UTF-8 scratch files before native grep. Binary or non-UTF-8 archive members are reported as skipped/unreadable.
|
||||
@@ -66,7 +66,7 @@ The tool returns a single text block in `content[0].text` plus structured `detai
|
||||
- one entry: `parseSearchPath()` splits `basePath` and optional glob;
|
||||
- multiple entries: `resolveExplicitSearchPaths()` (via `resolveToolSearchScope()`) computes a common base directory, brace-union glob, exact-file list, or per-entry target list. Targets fan out when the common ancestor is not itself a requested scope, or when a plain-file entry would otherwise be demoted into a directory walk's glob union (`fanOutFileTargets`).
|
||||
7. Line-range selectors are validated after path/archive/internal resolution. They are allowed only for single files, archive members, or virtual resources; glob/directory line-range selectors error.
|
||||
8. `search.ts` stats the resolved base path to decide file vs directory behavior.
|
||||
8. `grep.ts` stats the resolved base path to decide file vs directory behavior.
|
||||
9. It calls native `grep()` from `@oh-my-pi/pi-natives` with:
|
||||
- `pattern`, `ignoreCase`, `multiline`, `gitignore`;
|
||||
- `hidden: true`;
|
||||
@@ -81,7 +81,7 @@ The tool returns a single text block in `content[0].text` plus structured `detai
|
||||
- `build_matcher()` sanitizes non-quantifier braces before regex compile;
|
||||
- if compile fails with unopened/unclosed-group errors, it retries after escaping previously unescaped parentheses;
|
||||
- directory scans use the grep pipeline described in `docs/natives-text-search-pipeline.md`.
|
||||
11. Search dispatch differs by resolved path set:
|
||||
11. Grep dispatch differs by resolved path set:
|
||||
- exact explicit files or fanned-out multi-targets: JS loops over targets, merges `grep()` results itself, and deduplicates overlapping targets by absolute path + line number;
|
||||
- single file/directory base: one `grep()` call handles native scanning.
|
||||
12. Virtual internal resources are searched in JS with `RegExp`; archive scratch paths and virtual paths are remapped back to user-facing selectors before rendering.
|
||||
@@ -127,19 +127,19 @@ The tool returns a single text block in `content[0].text` plus structured `detai
|
||||
- Populates tool `details.meta` with truncation/limit metadata.
|
||||
- Background work / cancellation
|
||||
- Wrapped in `untilAborted(signal, ...)` at the JS level.
|
||||
- `search.ts` passes the abort `signal` and `timeoutMs: SEARCH_GREP_TIMEOUT_MS` (`30_000`) into native `grep()`, so native scans are cancellable and time-bounded.
|
||||
- `grep.ts` passes the abort `signal` and `timeoutMs: SEARCH_GREP_TIMEOUT_MS` (`30_000`) into native `grep()`, so native scans are cancellable and time-bounded.
|
||||
|
||||
## Limits & Caps
|
||||
- File page limit: `20` files (`DEFAULT_FILE_LIMIT` in `packages/coding-agent/src/tools/search.ts`).
|
||||
- File page limit: `20` files (`DEFAULT_FILE_LIMIT` in `packages/coding-agent/src/tools/grep.ts`).
|
||||
- Per-file match caps: `20` for multi-file scopes (`MULTI_FILE_PER_FILE_MATCHES`), `200` for single-file scopes (`SINGLE_FILE_MATCHES`).
|
||||
- Native/JS preselection cap: `2000` matches (`INTERNAL_TOTAL_CAP`).
|
||||
- Line truncation: `512` characters per emitted line (`DEFAULT_MAX_COLUMN` in `packages/coding-agent/src/session/streaming-output.ts`). Native grep marks truncated lines; JS reports `linesTruncated`.
|
||||
- Final text truncation: `truncateHead()` default byte cap `50 * 1024` bytes (`DEFAULT_MAX_BYTES` in `packages/coding-agent/src/session/streaming-output.ts`). `search.ts` overrides `maxLines` to `Number.MAX_SAFE_INTEGER`, so normal search output is byte-capped, not line-capped.
|
||||
- Context defaults: `search.contextBefore = 1`, `search.contextAfter = 3` in `packages/coding-agent/src/config/settings-schema.ts`.
|
||||
- Final text truncation: `truncateHead()` default byte cap `50 * 1024` bytes (`DEFAULT_MAX_BYTES` in `packages/coding-agent/src/session/streaming-output.ts`). `grep.ts` overrides `maxLines` to `Number.MAX_SAFE_INTEGER`, so normal grep output is byte-capped, not line-capped.
|
||||
- Context defaults: `grep.contextBefore = 1`, `grep.contextAfter = 3` in `packages/coding-agent/src/config/settings-schema.ts`.
|
||||
- Pagination: `skip` is a file-page offset for multi-file scopes. The result text says `Use skip=<N> for the next page` when more files remain.
|
||||
- Native directory-scan cache: available in `grep.rs`, but this tool always sets `cache: false`.
|
||||
- Native grep wall-clock budget: `30_000ms` per invocation (`SEARCH_GREP_TIMEOUT_MS` in `packages/coding-agent/src/tools/search.ts`); hitting it raises `Search timed out after 30s; ...`.
|
||||
- Native per-file size cap: `4 * 1024 * 1024` bytes (`MAX_FILE_BYTES` in `crates/pi-natives/src/grep.rs`, mirrored as `NATIVE_GREP_MAX_FILE_BYTES` in `search.ts`). Oversized files are silently skipped by native grep; `search.ts` surfaces a `Skipped oversized file(s)` note (with names for explicit file targets, a count for directory scans).
|
||||
- Native grep wall-clock budget: `30_000ms` per invocation (`SEARCH_GREP_TIMEOUT_MS` in `packages/coding-agent/src/tools/grep.ts`); hitting it raises `Grep timed out after 30s; ...`.
|
||||
- Native per-file size cap: `4 * 1024 * 1024` bytes (`MAX_FILE_BYTES` in `crates/pi-natives/src/grep.rs`, mirrored as `NATIVE_GREP_MAX_FILE_BYTES` in `grep.ts`). Oversized files are silently skipped by native grep; `grep.ts` surfaces a `Skipped oversized file(s)` note (with names for explicit file targets, a count for directory scans).
|
||||
|
||||
## Errors
|
||||
- `Pattern must not be empty` when trimmed `pattern` is empty.
|
||||
@@ -151,14 +151,14 @@ The tool returns a single text block in `content[0].text` plus structured `detai
|
||||
- `Path not found: ...; pass each path as its own array element` when a filesystem-backed resolved base path is missing, or when every multi-path filesystem entry is missing (with an archive hint when unreadable archive members contributed).
|
||||
- Virtual internal URL regex compile failures are reported as `Invalid regex: ...` from JavaScript `RegExp`; filesystem-backed regex failures beginning with `regex` or `regex parse error` are normalized to `Invalid regex: ...`.
|
||||
- Multi-file native scans skip per-file open/search failures inside `grep.rs`; the scan continues with surviving files.
|
||||
- ``Search timed out after 30s; narrow paths or pattern, or scope with `find` first`` when native grep hits `SEARCH_GREP_TIMEOUT_MS`.
|
||||
- ``Grep timed out after 30s; narrow paths or pattern, or scope with `glob` first`` when native grep hits `SEARCH_GREP_TIMEOUT_MS`.
|
||||
|
||||
## Notes
|
||||
- The model-facing prompt documents Rust regex syntax (RE2-style; no lookaround or backreferences). Filesystem-backed searches use that native engine; virtual internal URL content is searched with JavaScript `RegExp`.
|
||||
- Native `build_matcher()` already auto-escapes braces that cannot be valid quantifiers, so patterns like `${platform}` become searchable instead of failing. Valid quantifiers like `a{2,4}` remain unchanged.
|
||||
- Native compile retry also escapes unescaped literal parentheses only after an unopened/unclosed-group parse error. It is a fallback, not a general parser mode.
|
||||
- Internal URLs are resolved before path existence checks. Backed resources become ordinary filesystem paths; virtual resources stay in memory and do not mint editable hashline anchors.
|
||||
- `hidden:true` is hard-coded in `search.ts`; there is no model-facing flag to exclude dotfiles.
|
||||
- `hidden:true` is hard-coded in `grep.ts`; there is no model-facing flag to exclude dotfiles.
|
||||
- `gitignore:false` only affects native directory traversal. It does not disable the tool's own path normalization or explicit-file handling.
|
||||
- When `paths` resolves to multiple exact files, each target uses the `2000` internal cap before JS grouping.
|
||||
- The section tag in hashline mode is a four-hex opaque snapshot tag from the session snapshot store; `search` records whole-file snapshots when possible and prints bare line numbers beneath the header.
|
||||
- The section tag in hashline mode is a four-hex opaque snapshot tag from the session snapshot store; `grep` records whole-file snapshots when possible and prints bare line numbers beneath the header.
|
||||
@@ -113,6 +113,6 @@
|
||||
- Built-in entries appear only in `"all"` mode and only for registry tools whose `loadMode === "discoverable"` and are not currently active.
|
||||
- Hidden/internal built-ins are intentionally excluded from the built-in corpus: `resolve`, `yield`, `report_finding`, `report_tool_issue` are called out in the `#collectDiscoverableBuiltinTools()` comment.
|
||||
- `DiscoverableToolSource` includes `"extension"` and `"custom"`, but `AgentSession.getDiscoverableTools()` currently assembles only built-in and MCP sources.
|
||||
- On startup, `packages/coding-agent/src/sdk.ts` resolves `"auto"` after the full registry exists and injects `search_tool_bm25` when the count exceeds 40. It hides non-essential discoverable built-ins only in `tools.discoveryMode = "all"`. Tools whose class is marked as `loadMode === "essential"` (defaults are `read`, `bash`, `edit`, `write`, and `find`) are always active; they survive hiding regardless of configuration. `tools.essentialOverride` can be used to treat additional discoverable tools as essential (active on startup) or to explicitly specify the active essential list.
|
||||
- On startup, `packages/coding-agent/src/sdk.ts` resolves `"auto"` after the full registry exists and injects `search_tool_bm25` when the count exceeds 40. It hides non-essential discoverable built-ins only in `tools.discoveryMode = "all"`. Tools whose class is marked as `loadMode === "essential"` (defaults are `read`, `bash`, `edit`, `write`, and `glob`) are always active; they survive hiding regardless of configuration. `tools.essentialOverride` can be used to treat additional discoverable tools as essential (active on startup) or to explicitly specify the active essential list.
|
||||
- Query tokenization is simple and deterministic: Unicode is NFKD-normalized, combining marks are dropped, acronym/camelCase and digit-to-capital boundaries are split, non-letter/non-number characters become spaces, tokens are lowercased, and only non-empty tokens survive.
|
||||
- Scores are rounded differently by surface: `details.tools[].score` keeps 6 decimals; the TUI line renders 3.
|
||||
|
||||
+69
-43
@@ -14,7 +14,9 @@
|
||||
- `packages/coding-agent/src/web/search/providers/anthropic.ts` — Claude web-search provider.
|
||||
- `packages/coding-agent/src/web/search/providers/brave.ts` — Brave Search API adapter.
|
||||
- `packages/coding-agent/src/web/search/providers/codex.ts` — OpenAI Codex SSE adapter.
|
||||
- `packages/coding-agent/src/web/search/providers/duckduckgo.ts` — DuckDuckGo Instant Answer API adapter.
|
||||
- `packages/coding-agent/src/web/search/providers/exa.ts` — Exa API or MCP adapter.
|
||||
- `packages/coding-agent/src/web/search/providers/firecrawl.ts` — Firecrawl search adapter.
|
||||
- `packages/coding-agent/src/web/search/providers/gemini.ts` — Gemini grounding SSE adapter.
|
||||
- `packages/coding-agent/src/web/search/providers/jina.ts` — Jina Reader search adapter.
|
||||
- `packages/coding-agent/src/web/search/providers/kagi.ts` — Kagi provider wrapper.
|
||||
@@ -24,6 +26,8 @@
|
||||
- `packages/coding-agent/src/web/search/providers/searxng.ts` — self-hosted SearXNG adapter.
|
||||
- `packages/coding-agent/src/web/search/providers/synthetic.ts` — Synthetic search adapter.
|
||||
- `packages/coding-agent/src/web/search/providers/tavily.ts` — Tavily search adapter.
|
||||
- `packages/coding-agent/src/web/search/providers/tinyfish.ts` — TinyFish search adapter.
|
||||
- `packages/coding-agent/src/web/search/providers/xai.ts` — xAI Responses web-search adapter.
|
||||
- `packages/coding-agent/src/web/search/providers/zai.ts` — Z.AI remote MCP adapter.
|
||||
- `packages/coding-agent/src/web/parallel.ts` — Parallel search/extract HTTP client.
|
||||
- `packages/coding-agent/src/web/kagi.ts` — Kagi HTTP client.
|
||||
@@ -34,11 +38,11 @@
|
||||
| Field | Type | Required | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `query` | `string` | Yes | Search query, passed to providers unchanged. |
|
||||
| `recency` | `"day" \| "week" \| "month" \| "year"` | No | Time filter. Only providers that implement it use it; code maps it for Brave, Perplexity, Tavily, SearXNG, and Kagi. |
|
||||
| `limit` | `number` | No | Max results to return. Usually becomes the provider request's result-count parameter when `num_search_results` is absent. |
|
||||
| `max_tokens` | `number` | No | Passed through as `maxOutputTokens` / `max_tokens` only by Anthropic, Gemini, and Perplexity API-key mode. Ignored by the other providers. |
|
||||
| `temperature` | `number` | No | Passed through only by Anthropic, Gemini, and Perplexity API-key mode. Ignored by the other providers. |
|
||||
| `num_search_results` | `number` | No | Requested upstream search breadth. For most providers this is the same count used for returned sources. Perplexity is the only adapter that keeps it distinct from `limit`. |
|
||||
| `recency` | `"day" \| "week" \| "month" \| "year"` | No | Time filter. Only providers that implement it use it; code maps it for Brave, Perplexity, Tavily, SearXNG, Kagi, TinyFish, Firecrawl, and xAI. |
|
||||
| `limit` | `number` | No | Max results to return. Usually becomes the provider request's result-count parameter when `num_search_results` is absent. TinyFish uses it for paginated fetches before slicing; xAI sends it as `search_parameters.max_search_results` when `num_search_results` is absent and also caps parsed sources/citations locally, defaulting to `10` and max `30`. |
|
||||
| `max_tokens` | `number` | No | Passed through as provider token caps (`maxOutputTokens`, `max_tokens`, or xAI `max_output_tokens`) only by Anthropic, Gemini, xAI, and Perplexity API-key mode. Ignored by the other providers. |
|
||||
| `temperature` | `number` | No | Passed through only by Anthropic, Gemini, xAI, and Perplexity API-key mode. Ignored by the other providers. |
|
||||
| `num_search_results` | `number` | No | Requested search breadth or local result cap. Most providers send it upstream. TinyFish clamps to `1..20` with default `10`, sends it as `num_results` per page, and uses paginated fetches before slicing. xAI sends it as `search_parameters.max_search_results` and caps parsed sources/citations locally with default `10` and max `30`. |
|
||||
|
||||
## Outputs
|
||||
The tool returns a single text content block plus structured `details`.
|
||||
@@ -73,7 +77,7 @@ Streaming: none. `WebSearchTool.execute()` forwards its `AbortSignal` into `exec
|
||||
- if `params.provider` is set and not `"auto"`, it loads that provider with `getSearchProvider()`; if `isExplicitlyAvailable()` returns true, the list is `[that provider]`, otherwise it falls back to `resolveProviderChain(authStorage, "auto")`.
|
||||
- otherwise it calls `resolveProviderChain()` with the module-global preferred provider from `packages/coding-agent/src/web/search/provider.ts`.
|
||||
3. `resolveProviderChain()` lazily loads each provider module on demand and returns only available providers. If a preferred provider is set, it is tried first (gated by `isExplicitlyAvailable()`), then the static `SEARCH_PROVIDER_ORDER` excluding that provider, each gated by `isAvailable()`. Providers in the excluded set (`setExcludedSearchProviders()`) are skipped entirely, including as the preferred candidate.
|
||||
4. If no providers are available, `executeSearch()` returns `Error: No web search provider configured.` with `details.response.provider = "none"`.
|
||||
4. If no providers are available (for example, after excluding DuckDuckGo and lacking configured keyed/OAuth providers), `executeSearch()` returns `Error: No web search provider configured.` with `details.response.provider = "none"`.
|
||||
5. For each provider in order, `executeSearch()` calls `provider.search()` with:
|
||||
- `query`,
|
||||
- `limit`, `recency`, `temperature`, `maxOutputTokens`, `numSearchResults`,
|
||||
@@ -91,36 +95,20 @@ Streaming: none. `WebSearchTool.execute()` forwards its `AbortSignal` into `exec
|
||||
- **Forced provider**: internal callers may pass `provider`; unavailable forced providers fall back to the auto chain instead of hard-failing (`packages/coding-agent/src/web/search/index.ts`). This field is not in the model-facing schema.
|
||||
- **Preferred provider**: `setPreferredSearchProvider()` sets a module-global default used by `resolveProviderChain()`. `packages/coding-agent/src/sdk.ts` and `packages/coding-agent/src/modes/controllers/selector-controller.ts` wire this from settings.
|
||||
- **Excluded providers**: `setExcludedSearchProviders()` records providers `resolveProviderChain()` must never return, including as fallbacks. Wired from the `providers.webSearchExclude` setting (`providers.webSearch` drives the preferred provider) in `packages/coding-agent/src/sdk.ts`, `packages/coding-agent/src/modes/interactive-mode.ts`, and `packages/coding-agent/src/modes/controllers/selector-controller.ts`.
|
||||
- **Auto chain order**: `perplexity`, `gemini`, `anthropic`, `codex`, `zai`, `exa`, `jina`, `kagi`, `tavily`, `brave`, `kimi`, `parallel`, `synthetic`, `searxng` (`SEARCH_PROVIDER_ORDER` in `packages/coding-agent/src/web/search/types.ts`).
|
||||
- **Auto chain order** (18 providers): `perplexity`, `gemini`, `anthropic`, `codex`, `xai`, `zai`, `exa`, `tinyfish`, `jina`, `kagi`, `tavily`, `firecrawl`, `brave`, `kimi`, `parallel`, `synthetic`, `searxng`, `duckduckgo` (`SEARCH_PROVIDER_ORDER` in `packages/coding-agent/src/web/search/types.ts`).
|
||||
- **Provider adapters**
|
||||
- **Tavily** — `packages/coding-agent/src/web/search/providers/tavily.ts`
|
||||
- Availability: API key from env or `agent.db` via `findCredential()`.
|
||||
- Querying: POST `https://api.tavily.com/search`.
|
||||
- `recency` maps to Tavily `time_range`; code explicitly keeps `topic` at default general scope instead of narrowing to news.
|
||||
- `limit` / `num_search_results`: adapter uses `params.numSearchResults ?? params.limit`, clamped to `5..20` with default `5`.
|
||||
- Output: `answer`, `sources`, `requestId`, `authMode: "api_key"`.
|
||||
- **Perplexity** — `packages/coding-agent/src/web/search/providers/perplexity.ts`
|
||||
- Availability: auth precedence is `PERPLEXITY_COOKIES` -> OAuth token in `agent.db` -> `PERPLEXITY_API_KEY` / `PPLX_API_KEY` -> anonymous ask-endpoint fallback. `isAvailable()` gates the auto chain on credentials, but `isExplicitlyAvailable()` is always true, so explicit selection works unauthenticated.
|
||||
- OAuth/cookie/anonymous mode: POSTs to `https://www.perplexity.ai/rest/sse/perplexity_ask`, consumes SSE, merges partial events, extracts answer and source URLs, sets `authMode: "oauth"` (`"anonymous"` for the unauthenticated fallback).
|
||||
- API-key mode: POSTs to `https://api.perplexity.ai/chat/completions` with `model: "sonar-pro"`, `search_mode: "web"`, `num_search_results`, optional `search_recency_filter`, `max_tokens`, `temperature`.
|
||||
- `num_search_results` controls upstream API breadth only in API-key mode. `limit` is preserved separately as `num_results` and slices returned `sources` after parsing in both auth modes.
|
||||
- Output may include `answer`, `sources`, `citations`, `usage`, `model`, `requestId`, `authMode`.
|
||||
- **Brave** — `packages/coding-agent/src/web/search/providers/brave.ts`
|
||||
- Availability: `BRAVE_API_KEY` only.
|
||||
- Querying: GET `https://api.search.brave.com/res/v1/web/search` with `count`, `extra_snippets=true`, and `freshness=pd|pw|pm|py` for `recency`.
|
||||
- `limit` / `num_search_results`: `params.numSearchResults ?? params.limit`, clamped to `1..20`, default `10`.
|
||||
- Output: `sources`, `requestId`.
|
||||
- **Jina** — `packages/coding-agent/src/web/search/providers/jina.ts`
|
||||
- Availability: `JINA_API_KEY` only.
|
||||
- Querying: GET-like fetch to `https://s.jina.ai/<encoded query>` with bearer auth.
|
||||
- Ignores `recency`, `max_tokens`, and `temperature`.
|
||||
- `limit` / `num_search_results`: adapter slices sources to `params.numSearchResults ?? params.limit` when provided; otherwise returns all payload items.
|
||||
- Output: `sources` only.
|
||||
- **Kimi** — `packages/coding-agent/src/web/search/providers/kimi.ts`
|
||||
- Availability: `MOONSHOT_SEARCH_API_KEY`, `KIMI_SEARCH_API_KEY`, `MOONSHOT_API_KEY`, or `agent.db` credentials for `moonshot` / `kimi-code`.
|
||||
- Querying: POST to `MOONSHOT_SEARCH_BASE_URL` / `KIMI_SEARCH_BASE_URL` / default `https://api.kimi.com/coding/v1/search` with `text_query`, `limit`, `enable_page_crawling`, `timeout_seconds: 30`.
|
||||
- `limit` / `num_search_results`: `params.numSearchResults ?? params.limit`, clamped to `1..20`, default `10`.
|
||||
- Output: `sources`, `requestId`.
|
||||
- **Gemini** — `packages/coding-agent/src/web/search/providers/gemini.ts`
|
||||
- Availability: OAuth credentials in `agent.db` for `google-gemini-cli` or `google-antigravity`.
|
||||
- Querying: SSE `streamGenerateContent` call with Google Search grounding enabled. Antigravity auth tries two fallback endpoints and retries `401/403/400 invalid auth` once after token refresh; `429/5xx` retry with exponential backoff and server-provided retry delay, capped by a `5 * 60 * 1000` ms rate-limit budget.
|
||||
- `max_tokens` and `temperature` pass through as `generationConfig.maxOutputTokens` / `generationConfig.temperature`.
|
||||
- `limit` and `num_search_results` are collapsed together before dispatch.
|
||||
- Output may include `answer`, `sources`, `citations`, `searchQueries`, `usage`, `model`.
|
||||
- **Anthropic** — `packages/coding-agent/src/web/search/providers/anthropic.ts`
|
||||
- Availability: `ANTHROPIC_SEARCH_API_KEY` env var, otherwise `authStorage.hasAuth("anthropic")`; search credentials come from `authStorage.getApiKey("anthropic")` when no search-specific key is set.
|
||||
- Env overrides specific to search (do not affect chat completions):
|
||||
@@ -131,18 +119,17 @@ Streaming: none. `WebSearchTool.execute()` forwards its `AbortSignal` into `exec
|
||||
- `max_tokens` and `temperature` pass through.
|
||||
- `limit` and `num_search_results` are collapsed together before dispatch: `num_results = params.numSearchResults ?? params.limit`.
|
||||
- Output may include `answer`, `sources`, `citations`, `searchQueries`, `usage.searchRequests`, `model`, `requestId`.
|
||||
- **Gemini** — `packages/coding-agent/src/web/search/providers/gemini.ts`
|
||||
- Availability: OAuth credentials in `agent.db` for `google-gemini-cli` or `google-antigravity`.
|
||||
- Querying: SSE `streamGenerateContent` call with Google Search grounding enabled. Antigravity auth tries two fallback endpoints and retries `401/403/400 invalid auth` once after token refresh; `429/5xx` retry with exponential backoff and server-provided retry delay, capped by a `5 * 60 * 1000` ms rate-limit budget.
|
||||
- `max_tokens` and `temperature` pass through as `generationConfig.maxOutputTokens` / `generationConfig.temperature`.
|
||||
- `limit` and `num_search_results` are collapsed together before dispatch.
|
||||
- Output may include `answer`, `sources`, `citations`, `searchQueries`, `usage`, `model`.
|
||||
- **Codex** — `packages/coding-agent/src/web/search/providers/codex.ts`
|
||||
- Availability: OAuth credential for `openai-codex` in `agent.db` (`hasOAuth()`; expiry is not checked here — refresh is lazy in `searchCodex`).
|
||||
- Querying: SSE POST to `https://chatgpt.com/backend-api/codex/responses` with `tool_choice: { type: "web_search" }` and `search_context_size: "high"` by default.
|
||||
- Ignores `recency`, `max_tokens`, and `temperature` in this tool path.
|
||||
- `limit` and `num_search_results` are collapsed together before dispatch.
|
||||
- Output may include `answer`, `sources`, `usage`, `model`, `requestId`. If the streamed response has no `url_citation` annotations, the adapter falls back to scraping markdown links and bare URLs from the answer text.
|
||||
- **xAI** — `packages/coding-agent/src/web/search/providers/xai.ts`
|
||||
- Availability: `XAI_API_KEY` or `agent.db` credential for `xai`.
|
||||
- Querying: POST `https://api.x.ai/v1/responses` with model `grok-4.3` and `tools: [{ type: "web_search" }]` using the `/v1/responses` Agent Tools API.
|
||||
- `max_tokens` and `temperature` pass through. `recency` is sent as `search_parameters.from_date`/`to_date`; `num_search_results` (or `limit` when absent) is sent as `search_parameters.max_search_results`. Because xAI citations may include every encountered URL, the adapter also locally caps returned `sources` and `citations` after parsing. The local cap uses `num_search_results` before `limit`, defaults to `10` when omitted/invalid/zero, and is capped at `30`.
|
||||
- Output may include `answer`, `sources`, `citations`, `usage`, `model`, `requestId`, `authMode: "api_key"`.
|
||||
- **Z.AI** — `packages/coding-agent/src/web/search/providers/zai.ts`
|
||||
- Availability: env or `agent.db` credential for `zai`.
|
||||
- Querying: JSON-RPC `tools/call` against `https://api.z.ai/api/mcp/web_search_prime/mcp` for remote MCP tool `web_search_prime`.
|
||||
@@ -154,17 +141,47 @@ Streaming: none. `WebSearchTool.execute()` forwards its `AbortSignal` into `exec
|
||||
- Querying: POST `https://api.exa.ai/search` with the resolved Exa API key, otherwise JSON-RPC `tools/call` against `https://mcp.exa.ai/mcp` for remote MCP tool `web_search_exa`.
|
||||
- `limit` and `num_search_results` are collapsed together before dispatch.
|
||||
- Output: synthesized `answer` from up to 3 result summaries, `sources`, `requestId`.
|
||||
- **TinyFish** — `packages/coding-agent/src/web/search/providers/tinyfish.ts`
|
||||
- Availability: `TINYFISH_API_KEY` or `agent.db` credential for `tinyfish`.
|
||||
- Querying: GET `https://api.search.tinyfish.ai` with `X-API-Key` and `query`; `recency` maps to `recency_minutes`.
|
||||
- `limit` / `num_search_results`: collapsed as `params.numSearchResults ?? params.limit`, clamped to `1..20`, default `10`. TinyFish has no count parameter and returns at most 10 results per page; for counts above the first page, the adapter fetches documented `page` values (`0`, then `1` when needed) before slicing locally. Output `sources`, `authMode: "api_key"`.
|
||||
- **Jina** — `packages/coding-agent/src/web/search/providers/jina.ts`
|
||||
- Availability: `JINA_API_KEY` only.
|
||||
- Querying: GET-like fetch to `https://s.jina.ai/<encoded query>` with bearer auth.
|
||||
- Ignores `recency`, `max_tokens`, and `temperature`.
|
||||
- `limit` / `num_search_results`: adapter slices sources to `params.numSearchResults ?? params.limit` when provided; otherwise returns all payload items.
|
||||
- Output: `sources` only.
|
||||
- **Kagi** — `packages/coding-agent/src/web/search/providers/kagi.ts`, `packages/coding-agent/src/web/kagi.ts`
|
||||
- Availability: env or `agent.db` credential for `kagi`.
|
||||
- Querying: POST `https://kagi.com/api/v1/search` with `Authorization: Bearer <key>` and JSON body `{ query, workflow: "search", limit, filters?: { after } }`. `recency` maps to `filters.after` as a UTC `YYYY-MM-DD` string (`day`/`week`/`month`/`year`).
|
||||
- `limit` and `num_search_results` are collapsed together before dispatch, clamped to `1..40`, default `10`.
|
||||
- Output: `sources` (concatenated `data.search` + `data.video` + `data.news` + `data.infobox`, with video/news/infobox results tagged in the title), `relatedQuestions` (`data.adjacent_question` + `data.related_search` `props.question`), `answer` (`data.direct_answer[0].snippet ?? title`), `requestId` (`meta.trace`).
|
||||
- **Tavily** — `packages/coding-agent/src/web/search/providers/tavily.ts`
|
||||
- Availability: API key from env or `agent.db` via `findCredential()`.
|
||||
- Querying: POST `https://api.tavily.com/search`.
|
||||
- `recency` maps to Tavily `time_range`; code explicitly keeps `topic` at default general scope instead of narrowing to news.
|
||||
- `limit` / `num_search_results`: adapter uses `params.numSearchResults ?? params.limit`, clamped to `5..20` with default `5`.
|
||||
- Output: `answer`, `sources`, `requestId`, `authMode: "api_key"`.
|
||||
- **Firecrawl** — `packages/coding-agent/src/web/search/providers/firecrawl.ts`
|
||||
- Availability: `FIRECRAWL_API_KEY` or `agent.db` credential for `firecrawl`.
|
||||
- Querying: POST `https://api.firecrawl.dev/v2/search` with `sources: [{ type: "web" }]`; `recency` maps to Google-style `tbs`.
|
||||
- `limit` / `num_search_results`: collapsed and clamped to `1..100`, default `10`; output `sources`, `requestId`, `authMode: "api_key"`.
|
||||
- **Brave** — `packages/coding-agent/src/web/search/providers/brave.ts`
|
||||
- Availability: `BRAVE_API_KEY` only.
|
||||
- Querying: GET `https://api.search.brave.com/res/v1/web/search` with `count`, `extra_snippets=true`, and `freshness=pd|pw|pm|py` for `recency`.
|
||||
- `limit` / `num_search_results`: `params.numSearchResults ?? params.limit`, clamped to `1..20`, default `10`.
|
||||
- Output: `sources`, `requestId`.
|
||||
- **Kimi** — `packages/coding-agent/src/web/search/providers/kimi.ts`
|
||||
- Availability: `MOONSHOT_SEARCH_API_KEY`, `KIMI_SEARCH_API_KEY`, `MOONSHOT_API_KEY`, or `agent.db` credentials for `moonshot` / `kimi-code`.
|
||||
- Querying: POST to `MOONSHOT_SEARCH_BASE_URL` / `KIMI_SEARCH_BASE_URL` / default `https://api.kimi.com/coding/v1/search` with `text_query`, `limit`, `enable_page_crawling`, `timeout_seconds: 30`.
|
||||
- `limit` / `num_search_results`: `params.numSearchResults ?? params.limit`, clamped to `1..20`, default `10`.
|
||||
- Output: `sources`, `requestId`.
|
||||
- **Parallel** — `packages/coding-agent/src/web/search/providers/parallel.ts`, `packages/coding-agent/src/web/parallel.ts`
|
||||
- Availability: env or `agent.db` credential for `parallel`.
|
||||
- Querying: POST `https://api.parallel.ai/v1beta/search` with `objective=query`, `search_queries=[query]`, `mode:"fast"`, `max_chars_per_result: 10000`, beta header `search-extract-2025-10-10`.
|
||||
- There is no provider fan-out here despite the name; the current adapter always sends a one-element `search_queries` array.
|
||||
- `limit` and `num_search_results` are collapsed together before dispatch, clamped to `1..40`, default `10`.
|
||||
- Output: `sources`, `requestId`.
|
||||
- **Kagi** — `packages/coding-agent/src/web/search/providers/kagi.ts`, `packages/coding-agent/src/web/kagi.ts`
|
||||
- Availability: env or `agent.db` credential for `kagi`.
|
||||
- Querying: POST `https://kagi.com/api/v1/search` with `Authorization: Bearer <key>` and JSON body `{ query, workflow: "search", limit, filters?: { after } }`. `recency` maps to `filters.after` as a UTC `YYYY-MM-DD` string (`day`/`week`/`month`/`year`).
|
||||
- `limit` and `num_search_results` are collapsed together before dispatch, clamped to `1..40`, default `10`.
|
||||
- Output: `sources` (concatenated `data.search` + `data.video` + `data.news` + `data.infobox`, with video/news/infobox results tagged in the title), `relatedQuestions` (`data.adjacent_question` + `data.related_search` `props.question`), `answer` (`data.direct_answer[0].snippet ?? title`), `requestId` (`meta.trace`).
|
||||
- **Synthetic** — `packages/coding-agent/src/web/search/providers/synthetic.ts`
|
||||
- Availability: env or `agent.db` credential for `synthetic`.
|
||||
- Querying: POST `https://api.synthetic.new/v2/search` with `{ query }`.
|
||||
@@ -178,6 +195,10 @@ Streaming: none. `WebSearchTool.execute()` forwards its `AbortSignal` into `exec
|
||||
- `recency` maps to `time_range`; `week` is downgraded to `month` because SearXNG does not support week.
|
||||
- `limit` and `num_search_results` are collapsed together before dispatch, clamped to `1..20`, default `10`.
|
||||
- Output: `sources`, `relatedQuestions` from `suggestions`.
|
||||
- **DuckDuckGo** — `packages/coding-agent/src/web/search/providers/duckduckgo.ts`
|
||||
- Availability: always available; no API key.
|
||||
- Querying: GET official Instant Answer API `https://api.duckduckgo.com/` with JSON/no-HTML flags; no scraped HTML.
|
||||
- `limit` / `num_search_results`: collapsed and clamped to `1..20`, default `10`; output may include `answer` and `sources` from abstracts/results/topics.
|
||||
|
||||
## Side Effects
|
||||
- Network
|
||||
@@ -193,15 +214,19 @@ Streaming: none. `WebSearchTool.execute()` forwards its `AbortSignal` into `exec
|
||||
- Many provider adapters accept `AbortSignal`; `WebSearchTool.execute()` passes the tool call signal into `executeSearch()`, which forwards it as `params.signal` to providers and rethrows cancellation during fallback.
|
||||
|
||||
## Limits & Caps
|
||||
- Provider auto-order length: 14 providers (`SEARCH_PROVIDER_ORDER` in `packages/coding-agent/src/web/search/types.ts`).
|
||||
- Provider auto-order length: 18 providers (`SEARCH_PROVIDER_ORDER` in `packages/coding-agent/src/web/search/types.ts`).
|
||||
- `formatForLLM()` truncates source snippets and citation text to 240 chars (`packages/coding-agent/src/web/search/index.ts`).
|
||||
- `formatForLLM()` emits at most 3 search queries, each truncated to 120 chars (`packages/coding-agent/src/web/search/index.ts`).
|
||||
- Brave result count: default `10`, max `20` (`DEFAULT_NUM_RESULTS`, `MAX_NUM_RESULTS` in `packages/coding-agent/src/web/search/providers/brave.ts`).
|
||||
- TinyFish local result count: default `10`, max `20`; the API has no count parameter and returns at most 10 results per page, so the adapter fetches documented pages (`page=0`, then `page=1` when needed) and slices locally (`packages/coding-agent/src/web/search/providers/tinyfish.ts`).
|
||||
- DuckDuckGo result count: default `10`, max `20` (`packages/coding-agent/src/web/search/providers/duckduckgo.ts`).
|
||||
- Tavily result count: default `5`, max `20` (`packages/coding-agent/src/web/search/providers/tavily.ts`).
|
||||
- Firecrawl result count: default `10`, max `100` (`packages/coding-agent/src/web/search/providers/firecrawl.ts`).
|
||||
- Kimi result count: default `10`, max `20`; request timeout field fixed to `30` seconds (`packages/coding-agent/src/web/search/providers/kimi.ts`).
|
||||
- Parallel result count: default `10`, max `40`; per-result excerpt cap `10_000` chars (`packages/coding-agent/src/web/search/providers/parallel.ts`, `packages/coding-agent/src/web/parallel.ts`).
|
||||
- Kagi result count: default `10`, max `40` (`packages/coding-agent/src/web/search/providers/kagi.ts`).
|
||||
- SearXNG result count: default `10`, max `20` (`packages/coding-agent/src/web/search/providers/searxng.ts`).
|
||||
- xAI local sources/citations cap and upstream `max_search_results`: `num_search_results` before `limit`, omitted/invalid/zero => local default `10`, max `30` (`packages/coding-agent/src/web/search/providers/xai.ts`).
|
||||
- Perplexity API-key mode defaults: `max_tokens = 8192`, `temperature = 0.2`, `num_search_results = 20` (`packages/coding-agent/src/web/search/providers/perplexity.ts`).
|
||||
- Anthropic defaults: model `claude-haiku-4-5`, `DEFAULT_MAX_TOKENS = 4096` when the provider omits `max_tokens` (`packages/coding-agent/src/web/search/providers/anthropic.ts`).
|
||||
- Gemini retries: up to `3` retries per endpoint, base delay `1000` ms, rate-limit delay budget `5 * 60 * 1000` ms (`packages/coding-agent/src/web/search/providers/gemini.ts`).
|
||||
@@ -222,7 +247,8 @@ Streaming: none. `WebSearchTool.execute()` forwards its `AbortSignal` into `exec
|
||||
## Notes
|
||||
- The model-facing schema does not expose `provider`, but internal callers can force one through `SearchQueryParams`.
|
||||
- `resolveProviderChain()` lazily imports provider modules and caches singleton instances. Just asking for labels via `getSearchProviderLabel()` does not trigger those imports.
|
||||
- Most providers treat `limit` and `num_search_results` as the same number because adapters pass `params.numSearchResults ?? params.limit`. Perplexity is the only implementation that preserves both concepts.
|
||||
- `recency` is implemented by Brave, Perplexity, Tavily, SearXNG, and Kagi; the model-facing prompt does not name specific providers.
|
||||
- Most providers treat `limit` and `num_search_results` as the same number because adapters pass `params.numSearchResults ?? params.limit`. Perplexity preserves both concepts. TinyFish uses the collapsed value as a local cap, serializes `num_results` per page, and paginates with `page` when more results are needed. xAI sends that collapsed value as `search_parameters.max_search_results` and applies the same precedence locally after parsing to cap returned sources/citations (`10` default, `30` max).
|
||||
- `recency` is implemented by Brave, Perplexity, Tavily, SearXNG, Kagi, TinyFish, Firecrawl, and xAI. The model-facing prompt does not name specific providers.
|
||||
- `packages/coding-agent/src/config/settings-schema.ts` uses the shared `SEARCH_PROVIDER_PREFERENCES` / `SEARCH_PROVIDER_OPTIONS` metadata, so the settings selector and setup wizard expose `auto` plus every provider in the auto chain.
|
||||
- DuckDuckGo is intentionally last in the auto chain because it is always available without credentials.
|
||||
- Exa uses `authStorage.getApiKey("exa")`, then `EXA_API_KEY`, then unauthenticated `https://mcp.exa.ai/mcp` fallback.
|
||||
|
||||
+28
-19
@@ -25,18 +25,18 @@
|
||||
"@huggingface/transformers": "^4.2.0",
|
||||
"@mozilla/readability": "^0.6.0",
|
||||
"@napi-rs/cli": "3.7.0",
|
||||
"@oh-my-pi/hashline": "16.1.22",
|
||||
"@oh-my-pi/omp-stats": "16.1.22",
|
||||
"@oh-my-pi/pi-agent-core": "16.1.22",
|
||||
"@oh-my-pi/pi-ai": "16.1.22",
|
||||
"@oh-my-pi/pi-catalog": "16.1.22",
|
||||
"@oh-my-pi/pi-coding-agent": "16.1.22",
|
||||
"@oh-my-pi/pi-mnemopi": "16.1.22",
|
||||
"@oh-my-pi/pi-natives": "16.1.22",
|
||||
"@oh-my-pi/pi-tui": "16.1.22",
|
||||
"@oh-my-pi/pi-utils": "16.1.22",
|
||||
"@oh-my-pi/pi-wire": "16.1.22",
|
||||
"@oh-my-pi/snapcompact": "16.1.22",
|
||||
"@oh-my-pi/hashline": "16.2.2",
|
||||
"@oh-my-pi/omp-stats": "16.2.2",
|
||||
"@oh-my-pi/pi-agent-core": "16.2.2",
|
||||
"@oh-my-pi/pi-ai": "16.2.2",
|
||||
"@oh-my-pi/pi-catalog": "16.2.2",
|
||||
"@oh-my-pi/pi-coding-agent": "16.2.2",
|
||||
"@oh-my-pi/pi-mnemopi": "16.2.2",
|
||||
"@oh-my-pi/pi-natives": "16.2.2",
|
||||
"@oh-my-pi/pi-tui": "16.2.2",
|
||||
"@oh-my-pi/pi-utils": "16.2.2",
|
||||
"@oh-my-pi/pi-wire": "16.2.2",
|
||||
"@oh-my-pi/snapcompact": "16.2.2",
|
||||
"@opentelemetry/api": "^1.9.1",
|
||||
"@opentelemetry/context-async-hooks": "^2.7.1",
|
||||
"@opentelemetry/exporter-trace-otlp-proto": "^0.218.0",
|
||||
@@ -171,13 +171,22 @@
|
||||
"lint:py": "ruff check python && ruff format --check python",
|
||||
"fix:py": "ruff check --fix python && ruff format python",
|
||||
"prepublishOnly": "bun run check",
|
||||
"prepare": "bun run build-tool-views",
|
||||
"prepare": "bun run gen:tool-views",
|
||||
"publish": "bun run prepublishOnly && npm publish -ws --access public",
|
||||
"publish:dry": "bun run prepublishOnly && npm publish -ws --access public --dry-run",
|
||||
"release": "bun scripts/release.ts",
|
||||
"generate-models": "bun --cwd=packages/catalog run generate-models",
|
||||
"generate-docs-index": "bun --cwd=packages/coding-agent run generate-docs-index",
|
||||
"build-tool-views": "bun --cwd=packages/collab-web run build:tool-views",
|
||||
"gen:models": "bun --cwd=packages/catalog run gen:models",
|
||||
"gen:stats": "bun --cwd=packages/stats run gen:stats",
|
||||
"gen:stats:reset": "bun --cwd=packages/stats run gen:stats:reset",
|
||||
"gen:docs": "bun --cwd=packages/coding-agent run gen:docs",
|
||||
"gen:docs:reset": "bun --cwd=packages/coding-agent run gen:docs:reset",
|
||||
"gen:changelog": "bun scripts/rewrite-changelog.ts",
|
||||
"gen:tool-views": "bun --cwd=packages/collab-web run gen:tool-views",
|
||||
"gen:bundle": "bun --cwd=packages/coding-agent run gen:bundle",
|
||||
"gen:mupdf": "bun --cwd=packages/coding-agent run gen:mupdf",
|
||||
"gen:mupdf:reset": "bun --cwd=packages/coding-agent run gen:mupdf:reset",
|
||||
"gen:native": "bun --cwd=packages/natives run gen:native",
|
||||
"gen:native:reset": "bun --cwd=packages/natives run gen:native:reset",
|
||||
"check-spoofed-versions": "bun scripts/check-spoofed-versions.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -193,8 +202,8 @@
|
||||
"*.{js,ts,jsx,tsx,json,jsonc,css}": "biome check --write --no-errors-on-unmatched"
|
||||
},
|
||||
"dependencies": {
|
||||
"sherpa-onnx": "1.12.37",
|
||||
"sherpa-onnx-darwin-arm64": "1.12.37",
|
||||
"sherpa-onnx-node": "1.12.37"
|
||||
"sherpa-onnx": "1.13.2",
|
||||
"sherpa-onnx-darwin-arm64": "1.13.3",
|
||||
"sherpa-onnx-node": "1.13.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,31 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [16.2.2] - 2026-06-27
|
||||
|
||||
### Added
|
||||
|
||||
- Added optional AgentTool.matcherPaths(args) and AgentTool.matcherEntries(args) hooks to allow tools to surface target file paths and isolate file evaluations for path-scoped stream matchers (e.g., when handling multi-file payloads or embedded paths in streamed arguments).
|
||||
|
||||
### Removed
|
||||
|
||||
- Removed support for Pi dialect integration.
|
||||
|
||||
## [16.2.0] - 2026-06-27
|
||||
|
||||
### Added
|
||||
|
||||
- Added an optional `cwdResolver` to `Agent` and `getCwd` to `AgentLoopConfig` to dynamically resolve the working directory per LLM call, allowing workspace-scoped provider discovery (such as GitLab Duo Agent) to follow live directory changes without reconstructing the agent.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed an issue where API-level provider refusals were replayed as assistant dialogue on subsequent requests, preventing repeated refusals after a single blocked turn.
|
||||
- Fixed a bug where internal streaming state (`partialJson`) could leak onto the final `AssistantMessage` if a stream ended without a `toolcall_end` event.
|
||||
- Fixed `Agent` to correctly forward the working directory (`cwd`) into provider stream options, enabling providers like GitLab Duo Agent to scope local tool execution to the workspace.
|
||||
- Enabled custom OpenAI-compatible providers to use native remote compaction instead of falling back to local summarization.
|
||||
|
||||
## [16.1.23] - 2026-06-26
|
||||
|
||||
### Changed
|
||||
|
||||
- Changed `AgentLoopConfig.onTurnEnd` and `Agent.setOnTurnEnd` callbacks to receive whether the loop will continue with another provider request.
|
||||
|
||||
+75
-75
@@ -1,77 +1,77 @@
|
||||
{
|
||||
"type": "module",
|
||||
"name": "@oh-my-pi/pi-agent-core",
|
||||
"version": "16.1.22",
|
||||
"description": "General-purpose agent with transport abstraction, state management, and attachment support",
|
||||
"homepage": "https://omp.sh",
|
||||
"author": "Can Boluk",
|
||||
"contributors": [
|
||||
"Mario Zechner"
|
||||
],
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/can1357/oh-my-pi.git",
|
||||
"directory": "packages/agent"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/can1357/oh-my-pi/issues"
|
||||
},
|
||||
"keywords": [
|
||||
"ai",
|
||||
"agent",
|
||||
"llm",
|
||||
"transport",
|
||||
"state-management"
|
||||
],
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"scripts": {
|
||||
"check": "biome check . && bun run check:types",
|
||||
"check:types": "tsgo -p tsconfig.json --noEmit",
|
||||
"lint": "biome lint .",
|
||||
"test": "bun test --parallel",
|
||||
"fix": "biome check --write --unsafe .",
|
||||
"fmt": "biome format --write ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@oh-my-pi/pi-ai": "catalog:",
|
||||
"@oh-my-pi/pi-catalog": "catalog:",
|
||||
"@oh-my-pi/pi-natives": "catalog:",
|
||||
"@oh-my-pi/pi-utils": "catalog:",
|
||||
"@oh-my-pi/pi-wire": "catalog:",
|
||||
"@oh-my-pi/snapcompact": "catalog:",
|
||||
"@opentelemetry/api": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@opentelemetry/context-async-hooks": "catalog:",
|
||||
"@opentelemetry/sdk-trace-base": "catalog:",
|
||||
"@types/bun": "catalog:"
|
||||
},
|
||||
"engines": {
|
||||
"bun": ">=1.3.14"
|
||||
},
|
||||
"files": [
|
||||
"src",
|
||||
"README.md",
|
||||
"CHANGELOG.md"
|
||||
],
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"import": "./src/index.ts"
|
||||
},
|
||||
"./compaction": {
|
||||
"types": "./src/compaction.ts",
|
||||
"import": "./src/compaction.ts"
|
||||
},
|
||||
"./compaction/*": {
|
||||
"types": "./src/compaction/*.ts",
|
||||
"import": "./src/compaction/*.ts"
|
||||
},
|
||||
"./*": {
|
||||
"types": "./src/*.ts",
|
||||
"import": "./src/*.ts"
|
||||
}
|
||||
}
|
||||
"type": "module",
|
||||
"name": "@oh-my-pi/pi-agent-core",
|
||||
"version": "16.2.2",
|
||||
"description": "General-purpose agent with transport abstraction, state management, and attachment support",
|
||||
"homepage": "https://omp.sh",
|
||||
"author": "Can Boluk",
|
||||
"contributors": [
|
||||
"Mario Zechner"
|
||||
],
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/can1357/oh-my-pi.git",
|
||||
"directory": "packages/agent"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/can1357/oh-my-pi/issues"
|
||||
},
|
||||
"keywords": [
|
||||
"ai",
|
||||
"agent",
|
||||
"llm",
|
||||
"transport",
|
||||
"state-management"
|
||||
],
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"scripts": {
|
||||
"check": "biome check . && bun run check:types",
|
||||
"check:types": "tsgo -p tsconfig.json --noEmit",
|
||||
"lint": "biome lint .",
|
||||
"test": "bun test --parallel",
|
||||
"fix": "biome check --write --unsafe .",
|
||||
"fmt": "biome format --write ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@oh-my-pi/pi-ai": "catalog:",
|
||||
"@oh-my-pi/pi-catalog": "catalog:",
|
||||
"@oh-my-pi/pi-natives": "catalog:",
|
||||
"@oh-my-pi/pi-utils": "catalog:",
|
||||
"@oh-my-pi/pi-wire": "catalog:",
|
||||
"@oh-my-pi/snapcompact": "catalog:",
|
||||
"@opentelemetry/api": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@opentelemetry/context-async-hooks": "catalog:",
|
||||
"@opentelemetry/sdk-trace-base": "catalog:",
|
||||
"@types/bun": "catalog:"
|
||||
},
|
||||
"engines": {
|
||||
"bun": ">=1.3.14"
|
||||
},
|
||||
"files": [
|
||||
"src",
|
||||
"README.md",
|
||||
"CHANGELOG.md"
|
||||
],
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"import": "./src/index.ts"
|
||||
},
|
||||
"./compaction": {
|
||||
"types": "./src/compaction.ts",
|
||||
"import": "./src/compaction.ts"
|
||||
},
|
||||
"./compaction/*": {
|
||||
"types": "./src/compaction/*.ts",
|
||||
"import": "./src/compaction/*.ts"
|
||||
},
|
||||
"./*": {
|
||||
"types": "./src/*.ts",
|
||||
"import": "./src/*.ts"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
renderToolExamples,
|
||||
wrapInbandToolStream,
|
||||
} from "@oh-my-pi/pi-ai/dialect";
|
||||
import * as AIError from "@oh-my-pi/pi-ai/error";
|
||||
import {
|
||||
createHarmonyAuditEvent,
|
||||
detectHarmonyLeakInAssistantMessage,
|
||||
@@ -134,7 +135,6 @@ export function resolveOwnedDialectFromEnv(value: string | undefined): Dialect |
|
||||
case "anthropic":
|
||||
case "deepseek":
|
||||
case "harmony":
|
||||
case "pi":
|
||||
case "qwen3":
|
||||
case "gemini":
|
||||
case "gemma":
|
||||
@@ -1225,6 +1225,9 @@ async function streamAssistantResponse(
|
||||
const effectiveToolChoice = ownedDialect ? undefined : (hostToolChoice ?? forcedToolChoice ?? config.toolChoice);
|
||||
const effectiveReasoning = dynamicReasoning ?? config.reasoning;
|
||||
const effectiveDisableReasoning = dynamicDisableReasoning ?? config.disableReasoning;
|
||||
// `getCwd` is read once per LLM call so a mid-run session move (`/move`) reaches
|
||||
// workspace-scoped provider discovery; falls back to the static `cwd` when unset.
|
||||
const effectiveCwd = config.getCwd?.() ?? config.cwd;
|
||||
|
||||
const chatStepNumber = stepCounter.count;
|
||||
stepCounter.count += 1;
|
||||
@@ -1276,6 +1279,7 @@ async function streamAssistantResponse(
|
||||
disableReasoning: effectiveDisableReasoning,
|
||||
temperature: effectiveTemperature,
|
||||
serviceTier: effectiveServiceTier,
|
||||
cwd: effectiveCwd,
|
||||
signal: finalRequestSignal,
|
||||
onResponse: captureOnResponse,
|
||||
});
|
||||
@@ -1553,8 +1557,12 @@ function emitAbortedAssistantMessage(
|
||||
requestSignal: AbortSignal | undefined,
|
||||
): AssistantMessage {
|
||||
const errorMessage = abortReasonText(requestSignal);
|
||||
const errorId =
|
||||
errorMessage === "Request was aborted"
|
||||
? AIError.create(AIError.Flag.Abort)
|
||||
: AIError.classify(requestSignal?.reason) || undefined;
|
||||
const base: AssistantMessage = partialMessage
|
||||
? { ...partialMessage, stopReason: "aborted", errorMessage }
|
||||
? { ...partialMessage, stopReason: "aborted", errorMessage, errorId }
|
||||
: {
|
||||
role: "assistant",
|
||||
content: [],
|
||||
@@ -1571,6 +1579,7 @@ function emitAbortedAssistantMessage(
|
||||
},
|
||||
stopReason: "aborted",
|
||||
errorMessage,
|
||||
errorId,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
// Only tool calls that reached `toolcall_end` survive abort/error replay. A
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
resolveOwnedDialectFromEnv,
|
||||
} from "./agent-loop";
|
||||
import type { AppendOnlyContextManager } from "./append-only-context";
|
||||
import { isProviderRefusalMessage } from "./replay-policy";
|
||||
import type {
|
||||
AgentContext,
|
||||
AgentEvent,
|
||||
@@ -54,10 +55,13 @@ import { isSoftToolRequirement } from "./types";
|
||||
import { EventLoopKeepalive } from "./utils/yield";
|
||||
|
||||
/**
|
||||
* Default convertToLlm: Keep only LLM-compatible messages, convert attachments.
|
||||
* Default convertToLlm: Keep only LLM-compatible replay messages.
|
||||
*/
|
||||
function defaultConvertToLlm(messages: AgentMessage[]): Message[] {
|
||||
return messages.filter((m): m is Message => m.role === "user" || m.role === "assistant" || m.role === "toolResult");
|
||||
return messages.filter((m): m is Message => {
|
||||
if (m.role === "assistant") return !isProviderRefusalMessage(m);
|
||||
return m.role === "user" || m.role === "toolResult";
|
||||
});
|
||||
}
|
||||
|
||||
const ANTHROPIC_OUTPUT_BLOCKED_PREFIX = "Output blocked by conten";
|
||||
@@ -269,6 +273,17 @@ export interface AgentOptions {
|
||||
*/
|
||||
cursorOnToolResult?: CursorToolResultHandler;
|
||||
|
||||
/** Current working directory used by local tool execution. */
|
||||
cwd?: string;
|
||||
/**
|
||||
* Resolver for the live working directory, re-read on every turn. When set, it
|
||||
* overrides the static {@link cwd} at config-build time so a session move
|
||||
* (`/move`, which updates the host's cwd without reconstructing the Agent) is
|
||||
* reflected in provider options — e.g. GitLab Duo Agent namespace/project
|
||||
* discovery keys off this cwd's git remote. Falls back to `cwd` when it returns
|
||||
* `undefined`.
|
||||
*/
|
||||
cwdResolver?: () => string | undefined;
|
||||
/**
|
||||
* Called after a tool call has been validated and is about to execute.
|
||||
* See {@link AgentLoopConfig.beforeToolCall} for full semantics.
|
||||
@@ -355,6 +370,9 @@ export class Agent {
|
||||
#getToolContext?: (toolCall?: ToolCallContext) => AgentToolContext | undefined;
|
||||
#cursorExecHandlers?: CursorExecHandlers;
|
||||
#cursorOnToolResult?: CursorToolResultHandler;
|
||||
#cwd?: string;
|
||||
#cwdResolver?: () => string | undefined;
|
||||
|
||||
#runningPrompt?: Promise<void>;
|
||||
#resolveRunningPrompt?: () => void;
|
||||
#kimiApiFormat?: "openai" | "anthropic";
|
||||
@@ -430,6 +448,8 @@ export class Agent {
|
||||
this.#getToolContext = opts.getToolContext;
|
||||
this.#cursorExecHandlers = opts.cursorExecHandlers;
|
||||
this.#cursorOnToolResult = opts.cursorOnToolResult;
|
||||
this.#cwd = opts.cwd;
|
||||
this.#cwdResolver = opts.cwdResolver;
|
||||
this.#kimiApiFormat = opts.kimiApiFormat;
|
||||
this.#preferWebsockets = opts.preferWebsockets;
|
||||
this.#transformToolCallArguments = opts.transformToolCallArguments;
|
||||
@@ -1134,6 +1154,8 @@ export class Agent {
|
||||
},
|
||||
cursorExecHandlers: this.#cursorExecHandlers,
|
||||
cursorOnToolResult,
|
||||
cwd: this.#cwd,
|
||||
getCwd: this.#cwdResolver,
|
||||
transformToolCallArguments: this.#transformToolCallArguments,
|
||||
intentTracing: this.#intentTracing,
|
||||
pruneToolDescriptions: this.#pruneToolDescriptions,
|
||||
|
||||
@@ -14,12 +14,12 @@ import {
|
||||
type Message,
|
||||
type MessageAttribution,
|
||||
type Model,
|
||||
ProviderHttpError,
|
||||
type SimpleStreamOptions,
|
||||
type Tool,
|
||||
type Usage,
|
||||
withAuth,
|
||||
} from "@oh-my-pi/pi-ai";
|
||||
import { ProviderHttpError } from "@oh-my-pi/pi-ai/error";
|
||||
import { preferredDialect } from "@oh-my-pi/pi-catalog/identity";
|
||||
import { clampThinkingLevelForModel } from "@oh-my-pi/pi-catalog/model-thinking";
|
||||
import { logger, prompt } from "@oh-my-pi/pi-utils";
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type {
|
||||
AssistantMessage,
|
||||
ImageContent,
|
||||
Message,
|
||||
MessageAttribution,
|
||||
@@ -214,7 +213,7 @@ export function convertMessageToLlm(message: AgentMessage): Message | undefined
|
||||
case "developer":
|
||||
return { ...message, attribution: message.attribution ?? "agent" };
|
||||
case "assistant":
|
||||
return message as AssistantMessage;
|
||||
return message;
|
||||
case "toolResult":
|
||||
return {
|
||||
...message,
|
||||
|
||||
@@ -12,10 +12,10 @@
|
||||
* with `{ summary, shortSummary? }`.
|
||||
*/
|
||||
|
||||
import { ProviderHttpError } from "@oh-my-pi/pi-ai/errors";
|
||||
import { parseTextSignature } from "@oh-my-pi/pi-ai/providers/openai-shared";
|
||||
import { ProviderHttpError } from "@oh-my-pi/pi-ai/error";
|
||||
import { parseAzureDeploymentNameMap, parseTextSignature } from "@oh-my-pi/pi-ai/providers/openai-shared";
|
||||
import { transformMessages } from "@oh-my-pi/pi-ai/providers/transform-messages";
|
||||
import type { AssistantMessage, FetchImpl, Message, Model } from "@oh-my-pi/pi-ai/types";
|
||||
import type { Api, AssistantMessage, FetchImpl, Message, Model } from "@oh-my-pi/pi-ai/types";
|
||||
import {
|
||||
getOpenAIResponsesHistoryItems,
|
||||
getOpenAIResponsesHistoryPayload,
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
OPENAI_HEADER_VALUES,
|
||||
OPENAI_HEADERS,
|
||||
} from "@oh-my-pi/pi-catalog/wire/codex";
|
||||
import { logger } from "@oh-my-pi/pi-utils";
|
||||
import { $env, logger } from "@oh-my-pi/pi-utils";
|
||||
|
||||
// ============================================================================
|
||||
// Public types
|
||||
@@ -45,6 +45,8 @@ export const OPENAI_REMOTE_COMPACTION_PRESERVE_KEY = "openaiRemoteCompaction";
|
||||
*/
|
||||
export const REMOTE_COMPACTION_TIMEOUT_MS = 180_000;
|
||||
|
||||
const DEFAULT_AZURE_API_VERSION = "v1";
|
||||
|
||||
/** Race the caller's signal against the request timeout; `timeoutMs <= 0` disables the watchdog. */
|
||||
function withRequestTimeout(signal: AbortSignal | undefined, timeoutMs: number): AbortSignal | undefined {
|
||||
if (timeoutMs <= 0) return signal;
|
||||
@@ -86,12 +88,25 @@ export interface RemoteCompactionResponse {
|
||||
// OpenAI provider gating + endpoint resolution
|
||||
// ============================================================================
|
||||
|
||||
function isOpenAiRemoteCompactionApi(api: Api | undefined): boolean {
|
||||
return api === "openai-responses" || api === "azure-openai-responses" || api === "openai-codex-responses";
|
||||
}
|
||||
|
||||
export function shouldUseOpenAiRemoteCompaction(model: Model): boolean {
|
||||
return model.provider === "openai" || model.provider === "openai-codex";
|
||||
if (model.remoteCompaction?.enabled === false) return false;
|
||||
if (model.provider === "openai" || model.provider === "openai-codex") return true;
|
||||
if (model.remoteCompaction?.enabled !== true) return false;
|
||||
return isOpenAiRemoteCompactionApi(model.remoteCompaction.api ?? model.api);
|
||||
}
|
||||
|
||||
function resolveOpenAiCompactEndpoint(model: Model): string {
|
||||
if (model.provider === "openai-codex") {
|
||||
const configuredEndpoint = model.remoteCompaction?.endpoint;
|
||||
const compactionApi = model.remoteCompaction?.api ?? model.api;
|
||||
if (compactionApi === "azure-openai-responses") {
|
||||
return resolveAzureOpenAiCompactEndpoint(model, configuredEndpoint);
|
||||
}
|
||||
if (configuredEndpoint && configuredEndpoint.length > 0) return configuredEndpoint;
|
||||
if (model.provider === "openai-codex" || compactionApi === "openai-codex-responses") {
|
||||
return resolveOpenAiCodexCompactEndpoint(model.baseUrl);
|
||||
}
|
||||
|
||||
@@ -102,6 +117,41 @@ function resolveOpenAiCompactEndpoint(model: Model): string {
|
||||
return `${normalizedBase}/v1/responses/compact`;
|
||||
}
|
||||
|
||||
function resolveAzureOpenAiCompactEndpoint(model: Model, configuredEndpoint: string | undefined): string {
|
||||
const endpoint =
|
||||
configuredEndpoint && configuredEndpoint.length > 0
|
||||
? configuredEndpoint
|
||||
: `${resolveAzureOpenAiBaseUrl(model)}/responses/compact`;
|
||||
return appendAzureApiVersion(endpoint);
|
||||
}
|
||||
|
||||
function resolveAzureOpenAiBaseUrl(model: Model): string {
|
||||
const baseUrl = $env.AZURE_OPENAI_BASE_URL?.trim() || undefined;
|
||||
const resourceName = $env.AZURE_OPENAI_RESOURCE_NAME;
|
||||
const resolvedBaseUrl =
|
||||
baseUrl ?? (resourceName ? `https://${resourceName}.openai.azure.com/openai/v1` : undefined) ?? model.baseUrl;
|
||||
if (!resolvedBaseUrl) {
|
||||
throw new Error(
|
||||
"Azure OpenAI base URL is required. Set AZURE_OPENAI_BASE_URL or AZURE_OPENAI_RESOURCE_NAME, or configure model.baseUrl.",
|
||||
);
|
||||
}
|
||||
return resolvedBaseUrl.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
function appendAzureApiVersion(endpoint: string): string {
|
||||
if (/[?&]api-version=/.test(endpoint)) return endpoint;
|
||||
const separator = endpoint.includes("?") ? "&" : "?";
|
||||
return `${endpoint}${separator}api-version=${encodeURIComponent($env.AZURE_OPENAI_API_VERSION || DEFAULT_AZURE_API_VERSION)}`;
|
||||
}
|
||||
|
||||
function resolveOpenAiCompactModel(model: Model): string {
|
||||
const requestModel = model.remoteCompaction?.model ?? model.requestModelId ?? model.id;
|
||||
const compactionApi = model.remoteCompaction?.api ?? model.api;
|
||||
if (compactionApi !== "azure-openai-responses") return requestModel;
|
||||
const mappedDeployment = parseAzureDeploymentNameMap($env.AZURE_OPENAI_DEPLOYMENT_NAME_MAP).get(requestModel);
|
||||
return mappedDeployment ?? requestModel;
|
||||
}
|
||||
|
||||
function resolveOpenAiCodexCompactEndpoint(baseUrl: string | undefined): string {
|
||||
const rawBase = baseUrl && baseUrl.length > 0 ? baseUrl : CODEX_BASE_URL;
|
||||
const normalizedBase = rawBase.endsWith("/") ? rawBase.slice(0, -1) : rawBase;
|
||||
@@ -444,7 +494,6 @@ export function buildOpenAiNativeHistory(
|
||||
// ============================================================================
|
||||
// Endpoint requests
|
||||
// ============================================================================
|
||||
|
||||
export async function requestOpenAiRemoteCompaction(
|
||||
model: Model,
|
||||
apiKey: string,
|
||||
@@ -454,16 +503,24 @@ export async function requestOpenAiRemoteCompaction(
|
||||
opts?: { fetch?: FetchImpl; timeoutMs?: number },
|
||||
): Promise<OpenAiRemoteCompactionResponse> {
|
||||
const endpoint = resolveOpenAiCompactEndpoint(model);
|
||||
const requestModel = resolveOpenAiCompactModel(model);
|
||||
const request: OpenAiRemoteCompactionRequest = {
|
||||
model: model.id,
|
||||
model: requestModel,
|
||||
input: trimOpenAiCompactInput(compactInput, model.contextWindow ?? Number.POSITIVE_INFINITY, instructions),
|
||||
instructions,
|
||||
};
|
||||
const headers: Record<string, string> = {
|
||||
"content-type": "application/json",
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
...(model.headers ?? {}),
|
||||
};
|
||||
const isAzureOpenAiResponses = (model.remoteCompaction?.api ?? model.api) === "azure-openai-responses";
|
||||
const headers: Record<string, string> = isAzureOpenAiResponses
|
||||
? {
|
||||
"content-type": "application/json",
|
||||
"api-key": apiKey,
|
||||
...(model.headers ?? {}),
|
||||
}
|
||||
: {
|
||||
"content-type": "application/json",
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
...(model.headers ?? {}),
|
||||
};
|
||||
|
||||
// Codex endpoints require additional auth headers
|
||||
if (model.provider === "openai-codex") {
|
||||
|
||||
@@ -8,6 +8,8 @@ export * from "./append-only-context";
|
||||
export * from "./compaction";
|
||||
// Proxy utilities
|
||||
export * from "./proxy";
|
||||
// Replay policy
|
||||
export * from "./replay-policy";
|
||||
// Run-level telemetry collector + aggregators
|
||||
export * from "./run-collector";
|
||||
// Telemetry
|
||||
|
||||
+44
-12
@@ -13,9 +13,14 @@ import {
|
||||
type StopReason,
|
||||
type ToolCall,
|
||||
} from "@oh-my-pi/pi-ai";
|
||||
import { parseStreamingJson } from "@oh-my-pi/pi-ai/utils/json-parse";
|
||||
import {
|
||||
clearStreamingPartialJson,
|
||||
kStreamingPartialJson,
|
||||
type StreamingPartialJsonCarrier,
|
||||
setStreamingPartialJson,
|
||||
} from "@oh-my-pi/pi-ai/utils/block-symbols";
|
||||
import { calculateCost } from "@oh-my-pi/pi-catalog/models";
|
||||
import { readSseJson } from "@oh-my-pi/pi-utils";
|
||||
import { parseStreamingJson, readSseJson } from "@oh-my-pi/pi-utils";
|
||||
|
||||
// Event stream adapter for proxy SSE events
|
||||
export class ProxyMessageEventStream extends EventStream<AssistantMessageEvent, AssistantMessage> {
|
||||
@@ -157,11 +162,12 @@ export function streamProxy(model: Model, context: Context, options: ProxyStream
|
||||
}
|
||||
|
||||
let sawTerminalEvent = false;
|
||||
const partialJsonByIndex = new Map<number, string>();
|
||||
for await (const event of readSseJson<ProxyAssistantMessageEvent>(
|
||||
response.body as ReadableStream<Uint8Array>,
|
||||
options.signal,
|
||||
)) {
|
||||
const parsedEvent = processProxyEvent(model, event, partial);
|
||||
const parsedEvent = processProxyEvent(model, event, partial, partialJsonByIndex);
|
||||
if (parsedEvent) {
|
||||
if (parsedEvent.type === "done" || parsedEvent.type === "error") {
|
||||
sawTerminalEvent = true;
|
||||
@@ -184,6 +190,7 @@ export function streamProxy(model: Model, context: Context, options: ProxyStream
|
||||
const reason = options.signal?.aborted ? "aborted" : "error";
|
||||
partial.stopReason = reason;
|
||||
partial.errorMessage = errorMessage;
|
||||
scrubPartialJson(partial);
|
||||
stream.push({
|
||||
type: "error",
|
||||
reason,
|
||||
@@ -200,13 +207,32 @@ export function streamProxy(model: Model, context: Context, options: ProxyStream
|
||||
return stream;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the `partialJson` streaming symbol from any tool-call content blocks
|
||||
* that still carry it (e.g. when the stream ended without a `toolcall_end`), so
|
||||
* the finalized `AssistantMessage` no longer reads as still-streaming.
|
||||
*/
|
||||
function scrubPartialJson(partial: AssistantMessage): void {
|
||||
for (const block of partial.content) {
|
||||
if (block?.type === "toolCall") clearStreamingPartialJson(block);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a proxy event and update the partial message.
|
||||
*
|
||||
* Streaming `partialJson` for in-progress tool calls is accumulated in a
|
||||
* side-channel map keyed by `contentIndex` and also written onto the content
|
||||
* object as a symbol-keyed field so downstream renderers can read it
|
||||
* during streaming. The field is cleared at `toolcall_end` and scrubbed from any
|
||||
* remaining blocks at `done`/`error` so the finalized `AssistantMessage` never
|
||||
* reads as still-streaming.
|
||||
*/
|
||||
function processProxyEvent(
|
||||
model: Model,
|
||||
proxyEvent: ProxyAssistantMessageEvent,
|
||||
partial: AssistantMessage,
|
||||
partialJsonByIndex: Map<number, string>,
|
||||
): AssistantMessageEvent | undefined {
|
||||
switch (proxyEvent.type) {
|
||||
case "start":
|
||||
@@ -219,9 +245,10 @@ function processProxyEvent(
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
};
|
||||
delete (partial as { stopReason?: string }).stopReason;
|
||||
delete (partial as { errorMessage?: string }).errorMessage;
|
||||
delete (partial as { duration?: number }).duration;
|
||||
partial.errorMessage = undefined;
|
||||
partial.errorId = undefined;
|
||||
partial.duration = undefined;
|
||||
(partial as { stopReason?: string }).stopReason = undefined;
|
||||
return { type: "start", partial };
|
||||
|
||||
case "text_start":
|
||||
@@ -294,15 +321,17 @@ function processProxyEvent(
|
||||
id: proxyEvent.id,
|
||||
name: proxyEvent.toolName,
|
||||
arguments: {},
|
||||
partialJson: "",
|
||||
} satisfies ToolCall & { partialJson: string } as ToolCall;
|
||||
[kStreamingPartialJson]: "",
|
||||
} as ToolCall & StreamingPartialJsonCarrier;
|
||||
partialJsonByIndex.set(proxyEvent.contentIndex, "");
|
||||
return { type: "toolcall_start", contentIndex: proxyEvent.contentIndex, partial };
|
||||
|
||||
case "toolcall_delta": {
|
||||
const content = partial.content[proxyEvent.contentIndex];
|
||||
if (content?.type === "toolCall") {
|
||||
(content as any).partialJson += proxyEvent.delta;
|
||||
content.arguments = parseStreamingJson((content as any).partialJson) || {};
|
||||
const acc = (partialJsonByIndex.get(proxyEvent.contentIndex) ?? "") + proxyEvent.delta;
|
||||
partialJsonByIndex.set(proxyEvent.contentIndex, acc);
|
||||
content.arguments = parseStreamingJson(acc) || {};
|
||||
setStreamingPartialJson(content, acc);
|
||||
partial.content[proxyEvent.contentIndex] = { ...content }; // Trigger reactivity
|
||||
return {
|
||||
type: "toolcall_delta",
|
||||
@@ -317,7 +346,8 @@ function processProxyEvent(
|
||||
case "toolcall_end": {
|
||||
const content = partial.content[proxyEvent.contentIndex];
|
||||
if (content?.type === "toolCall") {
|
||||
delete (content as any).partialJson;
|
||||
partialJsonByIndex.delete(proxyEvent.contentIndex);
|
||||
clearStreamingPartialJson(content);
|
||||
return {
|
||||
type: "toolcall_end",
|
||||
contentIndex: proxyEvent.contentIndex,
|
||||
@@ -332,6 +362,7 @@ function processProxyEvent(
|
||||
partial.stopReason = proxyEvent.reason;
|
||||
partial.usage = proxyEvent.usage;
|
||||
calculateCost(model, partial.usage);
|
||||
scrubPartialJson(partial);
|
||||
return { type: "done", reason: proxyEvent.reason, message: partial };
|
||||
|
||||
case "error":
|
||||
@@ -339,6 +370,7 @@ function processProxyEvent(
|
||||
partial.errorMessage = proxyEvent.errorMessage;
|
||||
partial.usage = proxyEvent.usage;
|
||||
calculateCost(model, partial.usage);
|
||||
scrubPartialJson(partial);
|
||||
return { type: "error", reason: proxyEvent.reason, error: partial };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { AssistantMessage, Message } from "@oh-my-pi/pi-ai";
|
||||
|
||||
/** Detects API-level provider refusals that are terminal errors, not dialogue to replay. */
|
||||
export function isProviderRefusalMessage(message: AssistantMessage): boolean {
|
||||
if (message.stopReason !== "error") return false;
|
||||
const stopType = message.stopDetails?.type;
|
||||
return stopType === "refusal" || stopType === "sensitive";
|
||||
}
|
||||
|
||||
/** Removes API-level provider refusals from live provider replay while preserving other messages. */
|
||||
export function filterProviderReplayMessages(messages: readonly Message[]): Message[] {
|
||||
return messages.filter(message => message.role !== "assistant" || !isProviderRefusalMessage(message));
|
||||
}
|
||||
@@ -336,6 +336,17 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
|
||||
*/
|
||||
getServiceTier?: (model: Model) => ServiceTier | undefined;
|
||||
|
||||
/**
|
||||
* Per-call working-directory resolver, read once per LLM call. When set, its
|
||||
* return value overrides the static {@link SimpleStreamOptions.cwd} for the
|
||||
* request (falling back to that static `cwd` when it returns `undefined`).
|
||||
* Lets the host reflect a session move (`/move`, which updates the working
|
||||
* directory without reconstructing the loop config) into provider options —
|
||||
* e.g. GitLab Duo Agent namespace/project discovery keys off this cwd's git
|
||||
* remote, so a stale value would strand discovery on the original repo.
|
||||
*/
|
||||
getCwd?: () => string | undefined;
|
||||
|
||||
/**
|
||||
* Called after a tool call has been validated and is about to execute.
|
||||
*
|
||||
@@ -622,6 +633,27 @@ export interface AgentTool<TParameters extends TSchema = TSchema, TDetails = any
|
||||
*/
|
||||
matcherDigest?: (args: unknown) => string | undefined;
|
||||
|
||||
/**
|
||||
* Surface the target file paths a (potentially partial) streamed call would
|
||||
* touch, so path-scoped stream matchers (e.g. TTSR `tool:edit(*.ts)` globs)
|
||||
* can match without a top-level `path`/`paths` argument. Used for tools whose
|
||||
* wire grammar embeds paths inside the streamed payload (hashline section
|
||||
* headers, apply_patch envelope markers). Return `undefined` (or an empty
|
||||
* array) to fall back to the caller's top-level argument scan.
|
||||
*/
|
||||
matcherPaths?: (args: unknown) => readonly string[] | undefined;
|
||||
|
||||
/**
|
||||
* Per-file projection of a (potentially partial) streamed call, pairing each
|
||||
* touched file path with the digest of only the lines added to that file.
|
||||
* Path-scoped stream matchers (TTSR) evaluate each entry in isolation, so a
|
||||
* scoped rule like `tool:edit(*.ts)` never fires on text that actually
|
||||
* belongs to a sibling Markdown hunk in a multi-file payload. Takes
|
||||
* precedence over {@link matcherDigest} + {@link matcherPaths} when present;
|
||||
* returns `undefined` (or empty) to fall back to the combined hooks.
|
||||
*/
|
||||
matcherEntries?: (args: unknown) => readonly { path: string; digest: string }[] | undefined;
|
||||
|
||||
/** Capability tier declaration used by approval gates. Omitted means "exec". */
|
||||
approval?: ToolApproval;
|
||||
|
||||
|
||||
@@ -112,6 +112,33 @@ describe("Agent", () => {
|
||||
expect(agent.state.messages[agent.state.messages.length - 1].role).toBe("assistant");
|
||||
});
|
||||
|
||||
it("keeps Anthropic refusal errors out of the next provider context", async () => {
|
||||
const mock = createMockModel({
|
||||
responses: [
|
||||
{
|
||||
content: ["I can't assist with that request."],
|
||||
stopReason: "error",
|
||||
stopDetails: { type: "refusal", category: "bio", explanation: "policy refusal" },
|
||||
errorMessage: "Refusal (bio): policy refusal",
|
||||
},
|
||||
{ content: ["recovered"] },
|
||||
],
|
||||
});
|
||||
const agent = new Agent({
|
||||
initialState: { model: mock.model, systemPrompt: ["Test"], tools: [], messages: [] },
|
||||
streamFn: mock.stream,
|
||||
});
|
||||
|
||||
await agent.prompt("trigger refusal");
|
||||
await agent.prompt("next request");
|
||||
|
||||
expect(mock.calls).toHaveLength(2);
|
||||
const replayedMessages = mock.calls[1].context.messages;
|
||||
expect(replayedMessages.map(message => message.role)).toEqual(["user", "user"]);
|
||||
expect(JSON.stringify(replayedMessages)).not.toContain("Refusal (bio)");
|
||||
expect(JSON.stringify(replayedMessages)).not.toContain("I can't assist");
|
||||
});
|
||||
|
||||
it("prompt() emits assistant error lifecycle for Anthropic output-blocked stream errors before assistant start", async () => {
|
||||
const mock = createMockModel({ responses: [] });
|
||||
const errorText = "Output blocked by content filtering policy";
|
||||
@@ -496,6 +523,78 @@ describe("Agent", () => {
|
||||
expect(mock.calls[0]?.options?.promptCacheKey).toBe("parent-cache");
|
||||
});
|
||||
|
||||
it("forwards the live cwd from cwdResolver to the stream, overriding the static cwd", async () => {
|
||||
const mock = createMockModel({ responses: [{ content: ["ok"] }] });
|
||||
const agent = new Agent({
|
||||
initialState: { model: mock.model, messages: [] },
|
||||
streamFn: mock.stream,
|
||||
cwd: "/static/repo-a",
|
||||
cwdResolver: () => "/live/repo-b",
|
||||
});
|
||||
|
||||
await agent.prompt("run");
|
||||
|
||||
// The resolver wins over the constructor-time `cwd`: provider workspace
|
||||
// discovery (e.g. GitLab Duo namespace/project) must key off the live dir.
|
||||
expect(mock.calls[0]?.options?.cwd).toBe("/live/repo-b");
|
||||
});
|
||||
|
||||
it("falls back to the static cwd when cwdResolver returns undefined", async () => {
|
||||
const mock = createMockModel({ responses: [{ content: ["ok"] }] });
|
||||
const agent = new Agent({
|
||||
initialState: { model: mock.model, messages: [] },
|
||||
streamFn: mock.stream,
|
||||
cwd: "/static/repo-a",
|
||||
cwdResolver: () => undefined,
|
||||
});
|
||||
|
||||
await agent.prompt("run");
|
||||
|
||||
expect(mock.calls[0]?.options?.cwd).toBe("/static/repo-a");
|
||||
});
|
||||
|
||||
it("re-reads cwd from cwdResolver for each model call within a run (a /move mid-run is seen)", async () => {
|
||||
const toolSchema = z.object({ value: z.string() });
|
||||
type Details = { value: string };
|
||||
const alphaTool: AgentTool<typeof toolSchema, Details> = {
|
||||
name: "alpha",
|
||||
label: "Alpha",
|
||||
description: "Alpha tool",
|
||||
parameters: toolSchema,
|
||||
async execute(_toolCallId, params) {
|
||||
return { content: [{ type: "text", text: `alpha:${params.value}` }], details: { value: params.value } };
|
||||
},
|
||||
};
|
||||
|
||||
const mock = createMockModel({
|
||||
responses: [
|
||||
{ content: [{ type: "toolCall", id: "tool-1", name: "alpha", arguments: { value: "hello" } }] },
|
||||
{ content: ["done"] },
|
||||
],
|
||||
});
|
||||
|
||||
// The host owns the live cwd; `cwdResolver` reads it on every config build.
|
||||
let liveCwd = "/live/repo-a";
|
||||
const agent = new Agent({
|
||||
initialState: { model: mock.model, tools: [alphaTool], messages: [] },
|
||||
streamFn: mock.stream,
|
||||
cwdResolver: () => liveCwd,
|
||||
});
|
||||
|
||||
// Simulate `/move` between the tool-call turn and the continuation request.
|
||||
const unsubscribe = agent.subscribe(event => {
|
||||
if (event.type === "message_end" && event.message.role === "toolResult") {
|
||||
liveCwd = "/live/repo-b";
|
||||
}
|
||||
});
|
||||
|
||||
await agent.prompt("run");
|
||||
unsubscribe();
|
||||
|
||||
const cwdPerCall = mock.calls.map(call => call.options?.cwd);
|
||||
expect(cwdPerCall).toEqual(["/live/repo-a", "/live/repo-b"]);
|
||||
});
|
||||
|
||||
it("returns static metadata via the plain setter", () => {
|
||||
const agent = new Agent();
|
||||
expect(agent.metadata).toBeUndefined();
|
||||
|
||||
@@ -66,7 +66,7 @@ describe("agentLoop with owned in-band tool calls", () => {
|
||||
const promptSection = sys0.join("\n");
|
||||
expect(promptSection).toContain("<tools>");
|
||||
expect(promptSection).toContain('"name":"echo"');
|
||||
expect(promptSection).toContain("YOU MUST EMIT THE STOP SEQUENCE AND HALT");
|
||||
expect(promptSection).toContain("<arg_key>name</arg_key>");
|
||||
|
||||
// Second request: the wire carries NO native tool blocks — prior call/result
|
||||
// are plain <tool_call> / <tool_response> text, and tools are still stripped.
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import type { ProxyAssistantMessageEvent } from "@oh-my-pi/pi-agent-core/proxy";
|
||||
import { type ProxyMessageEventStream, streamProxy } from "@oh-my-pi/pi-agent-core/proxy";
|
||||
import type { AssistantMessageEvent, Context, FetchImpl, Model } from "@oh-my-pi/pi-ai";
|
||||
import type { AssistantMessageEvent, Context, FetchImpl, Model, ToolCall } from "@oh-my-pi/pi-ai";
|
||||
import { getStreamingPartialJson } from "@oh-my-pi/pi-ai/utils/block-symbols";
|
||||
import { buildModel } from "@oh-my-pi/pi-catalog/build";
|
||||
|
||||
const mockModel: Model = buildModel({
|
||||
@@ -224,4 +225,34 @@ describe("streamProxy — server disconnect without terminal event", () => {
|
||||
expect(result.stopReason).toBe("error");
|
||||
expect(result.errorMessage).toBe("rate_limit_exceeded");
|
||||
});
|
||||
|
||||
it("does not leak partialJson when server disconnects mid-tool-call", async () => {
|
||||
// Stream sends toolcall_start + partial toolcall_delta, then disconnects
|
||||
// without toolcall_end, done, or error. The catch-block error path must
|
||||
// scrub partialJson from the content before pushing the error event.
|
||||
const events: ProxyAssistantMessageEvent[] = [
|
||||
{ type: "start" },
|
||||
{ type: "toolcall_start", contentIndex: 0, id: "call_1", toolName: "bash" },
|
||||
{ type: "toolcall_delta", contentIndex: 0, delta: '{"comm' },
|
||||
];
|
||||
const body = buildSseBody(events);
|
||||
const fetchMock: FetchImpl = () => Promise.resolve(new Response(body, { status: 200 }));
|
||||
|
||||
const stream = streamProxy(mockModel, mockContext, {
|
||||
proxyUrl: "http://localhost:0",
|
||||
authToken: "test",
|
||||
fetch: fetchMock,
|
||||
});
|
||||
|
||||
const collected = await collectEvents(stream);
|
||||
expect(collected.some(e => e.type === "error")).toBe(true);
|
||||
|
||||
const result = await stream.result();
|
||||
expect(result.stopReason).toBe("error");
|
||||
const toolCall = result.content.find((c): c is ToolCall => c.type === "toolCall");
|
||||
expect(toolCall).toBeDefined();
|
||||
if (toolCall) {
|
||||
expect(getStreamingPartialJson(toolCall)).toBeUndefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
/**
|
||||
* Tests for proxy stream tool-call parsing.
|
||||
*
|
||||
* Contract: `streamProxy` MUST parse streaming tool-call arguments from
|
||||
* `toolcall_delta` events and MUST NOT leak internal `partialJson` state
|
||||
* into the final `AssistantMessage` content blocks — even when the stream
|
||||
* ends without a `toolcall_end` event.
|
||||
*/
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import type { ProxyAssistantMessageEvent } from "@oh-my-pi/pi-agent-core/proxy";
|
||||
import { type ProxyMessageEventStream, streamProxy } from "@oh-my-pi/pi-agent-core/proxy";
|
||||
import type { AssistantMessage, AssistantMessageEvent, Context, FetchImpl, Model, ToolCall } from "@oh-my-pi/pi-ai";
|
||||
import { getStreamingPartialJson } from "@oh-my-pi/pi-ai/utils/block-symbols";
|
||||
import { buildModel } from "@oh-my-pi/pi-catalog/build";
|
||||
|
||||
const mockModel: Model = buildModel({
|
||||
id: "test-model",
|
||||
name: "Test Model",
|
||||
api: "openai",
|
||||
provider: "test",
|
||||
baseUrl: "http://localhost:0",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 4096,
|
||||
maxTokens: 1024,
|
||||
});
|
||||
|
||||
const mockContext: Context = {
|
||||
messages: [{ role: "user", content: "hello", timestamp: Date.now() }],
|
||||
};
|
||||
|
||||
function buildSseBody(events: ProxyAssistantMessageEvent[]): ReadableStream<Uint8Array> {
|
||||
const parts: string[] = [];
|
||||
for (const event of events) {
|
||||
parts.push(`data: ${JSON.stringify(event)}\n\n`);
|
||||
}
|
||||
const text = parts.join("");
|
||||
return new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode(text));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function collectEvents(stream: ProxyMessageEventStream, timeoutMs = 2000): Promise<AssistantMessageEvent[]> {
|
||||
const events: AssistantMessageEvent[] = [];
|
||||
const iterator = stream[Symbol.asyncIterator]();
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
const { promise: timeoutPromise, resolve: timeoutResolve } =
|
||||
Promise.withResolvers<IteratorResult<AssistantMessageEvent>>();
|
||||
const timer = setTimeout(
|
||||
() => timeoutResolve({ value: undefined, done: true } as IteratorResult<AssistantMessageEvent>),
|
||||
timeoutMs,
|
||||
);
|
||||
const result = await Promise.race([iterator.next(), timeoutPromise]);
|
||||
clearTimeout(timer);
|
||||
if (result.done) break;
|
||||
events.push(result.value);
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
const baseUsage = {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
};
|
||||
|
||||
function extractToolCall(result: AssistantMessage): ToolCall {
|
||||
const toolCall = result.content.find((c): c is ToolCall => c.type === "toolCall");
|
||||
expect(toolCall).toBeDefined();
|
||||
return toolCall!;
|
||||
}
|
||||
|
||||
describe("streamProxy — tool-call streaming and partialJson isolation", () => {
|
||||
it("parses complete tool-call arguments from streamed deltas", async () => {
|
||||
const events: ProxyAssistantMessageEvent[] = [
|
||||
{ type: "start" },
|
||||
{ type: "toolcall_start", contentIndex: 0, id: "call_1", toolName: "bash" },
|
||||
{ type: "toolcall_delta", contentIndex: 0, delta: '{"comm' },
|
||||
{ type: "toolcall_delta", contentIndex: 0, delta: 'and":"ls"}' },
|
||||
{ type: "toolcall_end", contentIndex: 0 },
|
||||
{ type: "done", reason: "toolUse", usage: { ...baseUsage } },
|
||||
];
|
||||
const body = buildSseBody(events);
|
||||
const fetchMock: FetchImpl = () => Promise.resolve(new Response(body, { status: 200 }));
|
||||
|
||||
const stream = streamProxy(mockModel, mockContext, {
|
||||
proxyUrl: "http://localhost:0",
|
||||
authToken: "test",
|
||||
fetch: fetchMock,
|
||||
});
|
||||
|
||||
await collectEvents(stream);
|
||||
const result = await stream.result();
|
||||
const toolCall = extractToolCall(result);
|
||||
expect(toolCall.id).toBe("call_1");
|
||||
expect(toolCall.name).toBe("bash");
|
||||
expect(toolCall.arguments).toEqual({ command: "ls" });
|
||||
});
|
||||
|
||||
it("exposes partialJson on content during streaming for renderers", async () => {
|
||||
// Downstream renderers (event-controller.ts) read getStreamingPartialJson(content)
|
||||
// during toolcall_delta to pace streaming previews. The field must be
|
||||
// present on the partial snapshot while streaming is in progress.
|
||||
// Note: partial is a shared mutable reference, so we snapshot the
|
||||
// partialJson value during iteration — by the time the stream completes,
|
||||
// scrubPartialJson will have deleted it.
|
||||
const events: ProxyAssistantMessageEvent[] = [
|
||||
{ type: "start" },
|
||||
{ type: "toolcall_start", contentIndex: 0, id: "call_1", toolName: "bash" },
|
||||
{ type: "toolcall_delta", contentIndex: 0, delta: '{"comm' },
|
||||
{ type: "toolcall_delta", contentIndex: 0, delta: 'and":"ls"}' },
|
||||
{ type: "toolcall_end", contentIndex: 0 },
|
||||
{ type: "done", reason: "toolUse", usage: { ...baseUsage } },
|
||||
];
|
||||
const body = buildSseBody(events);
|
||||
const fetchMock: FetchImpl = () => Promise.resolve(new Response(body, { status: 200 }));
|
||||
|
||||
const stream = streamProxy(mockModel, mockContext, {
|
||||
proxyUrl: "http://localhost:0",
|
||||
authToken: "test",
|
||||
fetch: fetchMock,
|
||||
});
|
||||
|
||||
// Collect delta events and snapshot partialJson during iteration,
|
||||
// before the done event scrubs it from the shared partial reference.
|
||||
const deltaSnapshots: Array<{ hasPartialJson: boolean; value: string | undefined }> = [];
|
||||
const iterator = stream[Symbol.asyncIterator]();
|
||||
const deadline = Date.now() + 2000;
|
||||
while (Date.now() < deadline) {
|
||||
const { promise: timeoutPromise, resolve: timeoutResolve } =
|
||||
Promise.withResolvers<IteratorResult<AssistantMessageEvent>>();
|
||||
const timer = setTimeout(
|
||||
() => timeoutResolve({ value: undefined, done: true } as IteratorResult<AssistantMessageEvent>),
|
||||
2000,
|
||||
);
|
||||
const result = await Promise.race([iterator.next(), timeoutPromise]);
|
||||
clearTimeout(timer);
|
||||
if (result.done) break;
|
||||
if (result.value.type === "toolcall_delta") {
|
||||
const content = result.value.partial.content[0];
|
||||
deltaSnapshots.push({
|
||||
hasPartialJson: getStreamingPartialJson(content) !== undefined,
|
||||
value: getStreamingPartialJson(content),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
expect(deltaSnapshots.length).toBe(2);
|
||||
for (const snap of deltaSnapshots) {
|
||||
expect(snap.hasPartialJson).toBe(true);
|
||||
expect(snap.value).toBeTruthy();
|
||||
}
|
||||
|
||||
// After completion, partialJson must be gone
|
||||
const result = await stream.result();
|
||||
const toolCall = extractToolCall(result);
|
||||
expect(getStreamingPartialJson(toolCall)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not leak partialJson field into the final ToolCall object", async () => {
|
||||
const events: ProxyAssistantMessageEvent[] = [
|
||||
{ type: "start" },
|
||||
{ type: "toolcall_start", contentIndex: 0, id: "call_1", toolName: "read" },
|
||||
{ type: "toolcall_delta", contentIndex: 0, delta: '{"path' },
|
||||
{ type: "toolcall_delta", contentIndex: 0, delta: '":"/tmp/x"}' },
|
||||
{ type: "toolcall_end", contentIndex: 0 },
|
||||
{ type: "done", reason: "toolUse", usage: { ...baseUsage } },
|
||||
];
|
||||
const body = buildSseBody(events);
|
||||
const fetchMock: FetchImpl = () => Promise.resolve(new Response(body, { status: 200 }));
|
||||
|
||||
const stream = streamProxy(mockModel, mockContext, {
|
||||
proxyUrl: "http://localhost:0",
|
||||
authToken: "test",
|
||||
fetch: fetchMock,
|
||||
});
|
||||
|
||||
await collectEvents(stream);
|
||||
const result = await stream.result();
|
||||
const toolCall = extractToolCall(result);
|
||||
// partialJson is internal streaming state that must never appear on the
|
||||
// typed ToolCall — its presence would corrupt downstream serialization.
|
||||
expect(getStreamingPartialJson(toolCall)).toBeUndefined();
|
||||
expect(toolCall.arguments).toEqual({ path: "/tmp/x" });
|
||||
});
|
||||
|
||||
it("does not leak partialJson when stream ends without toolcall_end", async () => {
|
||||
// Stream ends abruptly after toolcall_delta — no toolcall_end, then
|
||||
// a done event. The partialJson state must not leak into the result.
|
||||
const events: ProxyAssistantMessageEvent[] = [
|
||||
{ type: "start" },
|
||||
{ type: "toolcall_start", contentIndex: 0, id: "call_1", toolName: "edit" },
|
||||
{ type: "toolcall_delta", contentIndex: 0, delta: '{"path' },
|
||||
{ type: "toolcall_delta", contentIndex: 0, delta: '":"/a"}' },
|
||||
// Missing toolcall_end — stream goes straight to done
|
||||
{ type: "done", reason: "toolUse", usage: { ...baseUsage } },
|
||||
];
|
||||
const body = buildSseBody(events);
|
||||
const fetchMock: FetchImpl = () => Promise.resolve(new Response(body, { status: 200 }));
|
||||
|
||||
const stream = streamProxy(mockModel, mockContext, {
|
||||
proxyUrl: "http://localhost:0",
|
||||
authToken: "test",
|
||||
fetch: fetchMock,
|
||||
});
|
||||
|
||||
await collectEvents(stream);
|
||||
const result = await stream.result();
|
||||
const toolCall = extractToolCall(result);
|
||||
expect(getStreamingPartialJson(toolCall)).toBeUndefined();
|
||||
expect(toolCall.arguments).toEqual({ path: "/a" });
|
||||
});
|
||||
|
||||
it("handles multiple concurrent tool calls with independent partialJson", async () => {
|
||||
const events: ProxyAssistantMessageEvent[] = [
|
||||
{ type: "start" },
|
||||
{ type: "toolcall_start", contentIndex: 0, id: "call_1", toolName: "read" },
|
||||
{ type: "toolcall_delta", contentIndex: 0, delta: '{"path":"' },
|
||||
{ type: "toolcall_start", contentIndex: 1, id: "call_2", toolName: "bash" },
|
||||
{ type: "toolcall_delta", contentIndex: 1, delta: '{"command":"' },
|
||||
{ type: "toolcall_delta", contentIndex: 0, delta: 'a"}' },
|
||||
{ type: "toolcall_delta", contentIndex: 1, delta: 'ls"}' },
|
||||
{ type: "toolcall_end", contentIndex: 0 },
|
||||
{ type: "toolcall_end", contentIndex: 1 },
|
||||
{ type: "done", reason: "toolUse", usage: { ...baseUsage } },
|
||||
];
|
||||
const body = buildSseBody(events);
|
||||
const fetchMock: FetchImpl = () => Promise.resolve(new Response(body, { status: 200 }));
|
||||
|
||||
const stream = streamProxy(mockModel, mockContext, {
|
||||
proxyUrl: "http://localhost:0",
|
||||
authToken: "test",
|
||||
fetch: fetchMock,
|
||||
});
|
||||
|
||||
await collectEvents(stream);
|
||||
const result = await stream.result();
|
||||
const toolCalls = result.content.filter((c): c is ToolCall => c.type === "toolCall");
|
||||
expect(toolCalls.length).toBe(2);
|
||||
expect(toolCalls[0].arguments).toEqual({ path: "a" });
|
||||
expect(toolCalls[1].arguments).toEqual({ command: "ls" });
|
||||
for (const tc of toolCalls) {
|
||||
expect(getStreamingPartialJson(tc)).toBeUndefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,11 @@ import {
|
||||
createFileOps,
|
||||
DEFAULT_COMPACTION_SETTINGS,
|
||||
} from "@oh-my-pi/pi-agent-core/compaction";
|
||||
import { buildOpenAiNativeHistory, requestOpenAiRemoteCompaction } from "@oh-my-pi/pi-agent-core/compaction/openai";
|
||||
import {
|
||||
buildOpenAiNativeHistory,
|
||||
requestOpenAiRemoteCompaction,
|
||||
shouldUseOpenAiRemoteCompaction,
|
||||
} from "@oh-my-pi/pi-agent-core/compaction/openai";
|
||||
import * as ai from "@oh-my-pi/pi-ai";
|
||||
import type { AssistantMessage, FetchImpl, Model, ToolResultMessage } from "@oh-my-pi/pi-ai/types";
|
||||
import { buildModel } from "@oh-my-pi/pi-catalog/build";
|
||||
@@ -27,6 +31,22 @@ function makeOpenAiModel(overrides: Partial<ModelSpec<"openai-responses">> = {})
|
||||
});
|
||||
}
|
||||
|
||||
function makeAzureModel(overrides: Partial<ModelSpec<"azure-openai-responses">> = {}): Model<"azure-openai-responses"> {
|
||||
return buildModel({
|
||||
id: "gpt-5",
|
||||
name: "GPT-5 Azure",
|
||||
api: "azure-openai-responses",
|
||||
provider: "azure-openai",
|
||||
baseUrl: "https://example-resource.openai.azure.com/openai/v1",
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 400000,
|
||||
maxTokens: 128000,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
describe("buildOpenAiNativeHistory custom tool calls", () => {
|
||||
test("serializes customWireName tool calls as custom_tool_call + custom_tool_call_output", () => {
|
||||
const patch = "*** Begin Patch\n*** End Patch\n";
|
||||
@@ -231,6 +251,97 @@ describe("remote compaction input trimming", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("uses configured OpenAI-compatible compaction for custom providers", async () => {
|
||||
const model = makeOpenAiModel({
|
||||
provider: "cliproxy-codex",
|
||||
baseUrl: "http://127.0.0.1:8317/v1",
|
||||
remoteCompaction: {
|
||||
enabled: true,
|
||||
api: "openai-responses",
|
||||
endpoint: "http://127.0.0.1:8317/v1/responses/compact",
|
||||
model: "gpt-5.5",
|
||||
},
|
||||
});
|
||||
let requestBody: unknown;
|
||||
const fetchMock: FetchImpl = async (input, init) => {
|
||||
expect(String(input)).toBe("http://127.0.0.1:8317/v1/responses/compact");
|
||||
requestBody = JSON.parse(String(init?.body));
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
output: [{ type: "compaction_summary", summary: "native compacted" }],
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
expect(shouldUseOpenAiRemoteCompaction(model)).toBe(true);
|
||||
await requestOpenAiRemoteCompaction(
|
||||
model,
|
||||
"test-key",
|
||||
[{ type: "message", role: "user", content: [{ type: "input_text", text: "hi" }] }],
|
||||
"instructions",
|
||||
undefined,
|
||||
{ fetch: fetchMock },
|
||||
);
|
||||
expect(requestBody).toMatchObject({ model: "gpt-5.5" });
|
||||
});
|
||||
|
||||
test("uses Azure request shape for Azure Responses remote compaction", async () => {
|
||||
const previousDeploymentMap = Bun.env.AZURE_OPENAI_DEPLOYMENT_NAME_MAP;
|
||||
Bun.env.AZURE_OPENAI_DEPLOYMENT_NAME_MAP = "gpt-5-compact=azure-gpt-5-compact";
|
||||
const model = makeAzureModel({
|
||||
headers: { "x-custom-header": "custom" },
|
||||
remoteCompaction: {
|
||||
enabled: true,
|
||||
api: "azure-openai-responses",
|
||||
model: "gpt-5-compact",
|
||||
},
|
||||
});
|
||||
let requestBody: unknown;
|
||||
let requestApiKey: string | undefined;
|
||||
let requestAuthorization: string | undefined;
|
||||
let requestContentType: string | undefined;
|
||||
let requestCustomHeader: string | undefined;
|
||||
const stringHeader = (value: string | readonly string[] | undefined): string | undefined =>
|
||||
typeof value === "string" ? value : undefined;
|
||||
const fetchMock: FetchImpl = async (input, init) => {
|
||||
expect(String(input)).toBe(
|
||||
"https://example-resource.openai.azure.com/openai/v1/responses/compact?api-version=v1",
|
||||
);
|
||||
if (!init?.headers || init.headers instanceof Headers || Array.isArray(init.headers)) {
|
||||
throw new Error("Expected remote compaction to send headers as a plain object");
|
||||
}
|
||||
requestApiKey = stringHeader(init.headers["api-key"]);
|
||||
requestAuthorization = stringHeader(init.headers.Authorization);
|
||||
requestContentType = stringHeader(init.headers["content-type"]);
|
||||
requestCustomHeader = stringHeader(init.headers["x-custom-header"]);
|
||||
requestBody = JSON.parse(String(init.body));
|
||||
return Response.json({
|
||||
output: [{ type: "compaction_summary", summary: "azure compacted" }],
|
||||
});
|
||||
};
|
||||
|
||||
expect(shouldUseOpenAiRemoteCompaction(model)).toBe(true);
|
||||
await requestOpenAiRemoteCompaction(
|
||||
model,
|
||||
"azure-key",
|
||||
[{ type: "message", role: "user", content: [{ type: "input_text", text: "hi" }] }],
|
||||
"instructions",
|
||||
undefined,
|
||||
{ fetch: fetchMock },
|
||||
);
|
||||
|
||||
expect(requestApiKey).toBe("azure-key");
|
||||
expect(requestAuthorization).toBeUndefined();
|
||||
expect(requestContentType).toBe("application/json");
|
||||
expect(requestCustomHeader).toBe("custom");
|
||||
expect(requestBody).toMatchObject({ model: "azure-gpt-5-compact" });
|
||||
if (previousDeploymentMap === undefined) {
|
||||
delete Bun.env.AZURE_OPENAI_DEPLOYMENT_NAME_MAP;
|
||||
} else {
|
||||
Bun.env.AZURE_OPENAI_DEPLOYMENT_NAME_MAP = previousDeploymentMap;
|
||||
}
|
||||
});
|
||||
|
||||
describe("requestOpenAiRemoteCompaction abort", () => {
|
||||
test("rejects when the abort signal is aborted mid-fetch", async () => {
|
||||
const controller = new AbortController();
|
||||
|
||||
@@ -2,6 +2,70 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Changed
|
||||
|
||||
- Default reasoning context to `all_turns` for all Codex requests
|
||||
|
||||
## [16.2.2] - 2026-06-27
|
||||
|
||||
### Added
|
||||
|
||||
- Added a comprehensive, public-facing error module exported via the "./error" path, featuring structured error classification, provider-specific HTTP error classes (e.g., Anthropic, OpenAI, Gemini), OAuth/Auth-specific errors, rate-limit utilities, and retryability predicates.
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated OpenAI Codex defaults to increase default text verbosity to medium, enable detailed reasoning summaries by default, and include all turns in the reasoning context by default.
|
||||
- Updated the OpenAI Codex WebSocket transport to resolve its configuration (via PI_CODEX_WEBSOCKET_* environment variables) once at startup rather than re-parsing on every request.
|
||||
- Enhanced cross-model reasoning recovery and preservation to render demoted reasoning in the target model's canonical inline thinking dialect (such as Gemini's thinking fence or standard think tags) to prevent leaking inert context or control tokens into history.
|
||||
- Broadened the leaked-thinking stream healer to recover reasoning emitted in any dialect's canonical idiom (including Gemini, Gemma, Harmony, and scratchpads) and route them to thinking events instead of raw markup.
|
||||
- Implemented automatic retry logic for detected thinking-loop stalls to improve response reliability.
|
||||
- Hardened stateful delta chaining to ignore transient streaming bookkeeping symbols during structural equality checks, preventing unnecessary full-transcript replays.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed preservation of OpenAI Responses assistant message phase values across auth-gateway parsing, streaming, and history replay, ensuring GPT-5.4/GPT-5.5 intermediate updates and final answers retain their original phase labels.
|
||||
|
||||
### Removed
|
||||
|
||||
- Removed Pi dialect support and related serialization/parsing logic.
|
||||
|
||||
## [16.2.0] - 2026-06-27
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- Removed the `@oh-my-pi/pi-ai/utils/json-parse` module. The JSON repair and parsing helpers (`repairJson`, `parseJsonWithRepair`, `parseStreamingJson`, `parseStreamingJsonThrottled`) have been moved to `@oh-my-pi/pi-utils` to be shared across utilities.
|
||||
|
||||
### Added
|
||||
|
||||
- Added the GitLab Duo Agent provider (`gitlab-duo-agent`) and built-in implementation, renaming the existing AI Gateway proxy provider to "GitLab Duo Non-Agentic" (`gitlab-duo`).
|
||||
- Added GitLab Duo Workflow provider support, featuring OAuth login via the official VS Code OAuth application, automatic project discovery, and automatic session-time namespace Duo settings enablement.
|
||||
- Added runaway detection for Gemini models to interrupt streams stuck in excessive planning steps.
|
||||
- Added a per-provider in-flight request limiter for LLM streams, shared across local OMP processes and configurable via `maxInFlightRequests`.
|
||||
- Added a `credits` field to `UsageResetCredits` to display when banked rate-limit resets expire, with support for OpenAI Codex usage details.
|
||||
|
||||
### Changed
|
||||
|
||||
- Optimized GitLab Duo Agent and Workflow providers to use an inline custom "ambient" flow with MCP-only agent privileges, registering MCP tools under their bare names.
|
||||
- Improved GitLab Duo Agent context management and auto-compaction by lowering the soft overflow threshold to 1 MB and stripping redundant bytes (such as tool-call UUIDs and escaped JSON) from the goal transcript.
|
||||
- Enhanced GitLab Duo Agent prompt engineering to render replayed tool calls as past-tense records, reducing model confusion and preventing the model from mimicking historical markers.
|
||||
- Added caching for discovered GitLab Duo Agent root namespaces per account to avoid redundant discovery requests.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed various GitLab Duo Agent and Workflow stability issues, including infinite tool-call loops, connection hangs on half-open WebSockets, and unhandled step-limit or generic server-side failures.
|
||||
- Improved GitLab Duo Workflow routing, namespace resolution, and project-path handling, ensuring correct numeric ID resolution and support for self-managed GitLab relative install base paths.
|
||||
- Fixed GitLab Duo Workflow checkpoint streaming to correctly map reasoning entries to thinking blocks, preserve tool boundaries, and accurately report token usage.
|
||||
- Fixed `AuthStorage.login` to only synthesize manual-code paste prompts for paste-code providers, preventing terminal-blocking races on loopback OAuth flows.
|
||||
- Fixed llama.cpp compatibility by downgrading named forced `tool_choice` objects to the string `"required"` in the chat-completions encoder.
|
||||
- Fixed `omp usage` omitting Ollama and Ollama Cloud accounts by registering placeholder usage providers.
|
||||
- Fixed Gemini reasoning-runaway detection to expose a dedicated thought-summary header guard to interrupt streams stuck in planning loops.
|
||||
|
||||
### Removed
|
||||
|
||||
- Removed legacy GitLab Duo Workflow `chat` and `software_development` flow paths and the non-MCP action bridge in favor of the inline custom `ambient` flow.
|
||||
|
||||
## [16.1.23] - 2026-06-26
|
||||
|
||||
### Added
|
||||
|
||||
- Added a third streaming thinking-loop detection heuristic to catch "progress-lexicon stalls" where models endlessly reshuffle motivational filler without introducing new vocabulary or concrete technical references
|
||||
|
||||
+136
-132
@@ -1,134 +1,138 @@
|
||||
{
|
||||
"type": "module",
|
||||
"name": "@oh-my-pi/pi-ai",
|
||||
"version": "16.1.22",
|
||||
"description": "Unified LLM API with automatic model discovery and provider configuration",
|
||||
"homepage": "https://omp.sh",
|
||||
"author": "Can Boluk",
|
||||
"contributors": [
|
||||
"Mario Zechner"
|
||||
],
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/can1357/oh-my-pi.git",
|
||||
"directory": "packages/ai"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/can1357/oh-my-pi/issues"
|
||||
},
|
||||
"keywords": [
|
||||
"ai",
|
||||
"llm",
|
||||
"openai",
|
||||
"anthropic",
|
||||
"gemini",
|
||||
"unified",
|
||||
"api"
|
||||
],
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"scripts": {
|
||||
"check": "biome check . && bun run check:types",
|
||||
"check:types": "tsgo -p tsconfig.json --noEmit",
|
||||
"lint": "biome lint .",
|
||||
"test": "bun test --parallel",
|
||||
"fix": "biome check --write --unsafe .",
|
||||
"fmt": "biome format --write ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@bufbuild/protobuf": "catalog:",
|
||||
"@oh-my-pi/pi-catalog": "catalog:",
|
||||
"@oh-my-pi/pi-utils": "catalog:",
|
||||
"@oh-my-pi/pi-wire": "catalog:",
|
||||
"arktype": "catalog:",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@bufbuild/protoc-gen-es": "catalog:",
|
||||
"@types/bun": "catalog:"
|
||||
},
|
||||
"engines": {
|
||||
"bun": ">=1.3.14"
|
||||
},
|
||||
"files": [
|
||||
"src",
|
||||
"README.md",
|
||||
"CHANGELOG.md"
|
||||
],
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"import": "./src/index.ts"
|
||||
},
|
||||
"./*": {
|
||||
"types": "./src/*.ts",
|
||||
"import": "./src/*.ts"
|
||||
},
|
||||
"./auth-broker": {
|
||||
"types": "./src/auth-broker/index.ts",
|
||||
"import": "./src/auth-broker/index.ts"
|
||||
},
|
||||
"./auth-broker/*": {
|
||||
"types": "./src/auth-broker/*.ts",
|
||||
"import": "./src/auth-broker/*.ts"
|
||||
},
|
||||
"./auth-gateway": {
|
||||
"types": "./src/auth-gateway/index.ts",
|
||||
"import": "./src/auth-gateway/index.ts"
|
||||
},
|
||||
"./auth-gateway/*": {
|
||||
"types": "./src/auth-gateway/*.ts",
|
||||
"import": "./src/auth-gateway/*.ts"
|
||||
},
|
||||
"./providers/*": {
|
||||
"types": "./src/providers/*.ts",
|
||||
"import": "./src/providers/*.ts"
|
||||
},
|
||||
"./providers/openai-codex/*": {
|
||||
"types": "./src/providers/openai-codex/*.ts",
|
||||
"import": "./src/providers/openai-codex/*.ts"
|
||||
},
|
||||
"./usage/*": {
|
||||
"types": "./src/usage/*.ts",
|
||||
"import": "./src/usage/*.ts"
|
||||
},
|
||||
"./utils/harmony-leak": {
|
||||
"types": "./src/utils/harmony-leak.ts",
|
||||
"import": "./src/utils/harmony-leak.ts"
|
||||
},
|
||||
"./dialect": {
|
||||
"types": "./src/dialect/index.ts",
|
||||
"import": "./src/dialect/index.ts"
|
||||
},
|
||||
"./utils/*": {
|
||||
"types": "./src/utils/*.ts",
|
||||
"import": "./src/utils/*.ts"
|
||||
},
|
||||
"./oauth": {
|
||||
"types": "./src/registry/oauth/index.ts",
|
||||
"import": "./src/registry/oauth/index.ts"
|
||||
},
|
||||
"./oauth/*": {
|
||||
"types": "./src/registry/oauth/*.ts",
|
||||
"import": "./src/registry/oauth/*.ts"
|
||||
},
|
||||
"./registry": {
|
||||
"types": "./src/registry/index.ts",
|
||||
"import": "./src/registry/index.ts"
|
||||
},
|
||||
"./registry/oauth": {
|
||||
"types": "./src/registry/oauth/index.ts",
|
||||
"import": "./src/registry/oauth/index.ts"
|
||||
},
|
||||
"./utils/schema": {
|
||||
"types": "./src/utils/schema/index.ts",
|
||||
"import": "./src/utils/schema/index.ts"
|
||||
},
|
||||
"./utils/schema/*": {
|
||||
"types": "./src/utils/schema/*.ts",
|
||||
"import": "./src/utils/schema/*.ts"
|
||||
},
|
||||
"./*.js": "./src/*.ts"
|
||||
}
|
||||
"type": "module",
|
||||
"name": "@oh-my-pi/pi-ai",
|
||||
"version": "16.2.2",
|
||||
"description": "Unified LLM API with automatic model discovery and provider configuration",
|
||||
"homepage": "https://omp.sh",
|
||||
"author": "Can Boluk",
|
||||
"contributors": [
|
||||
"Mario Zechner"
|
||||
],
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/can1357/oh-my-pi.git",
|
||||
"directory": "packages/ai"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/can1357/oh-my-pi/issues"
|
||||
},
|
||||
"keywords": [
|
||||
"ai",
|
||||
"llm",
|
||||
"openai",
|
||||
"anthropic",
|
||||
"gemini",
|
||||
"unified",
|
||||
"api"
|
||||
],
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"scripts": {
|
||||
"check": "biome check . && bun run check:types",
|
||||
"check:types": "tsgo -p tsconfig.json --noEmit",
|
||||
"lint": "biome lint .",
|
||||
"test": "bun test --parallel",
|
||||
"fix": "biome check --write --unsafe .",
|
||||
"fmt": "biome format --write ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@bufbuild/protobuf": "catalog:",
|
||||
"@oh-my-pi/pi-catalog": "catalog:",
|
||||
"@oh-my-pi/pi-utils": "catalog:",
|
||||
"@oh-my-pi/pi-wire": "catalog:",
|
||||
"arktype": "catalog:",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@bufbuild/protoc-gen-es": "catalog:",
|
||||
"@types/bun": "catalog:"
|
||||
},
|
||||
"engines": {
|
||||
"bun": ">=1.3.14"
|
||||
},
|
||||
"files": [
|
||||
"src",
|
||||
"README.md",
|
||||
"CHANGELOG.md"
|
||||
],
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"import": "./src/index.ts"
|
||||
},
|
||||
"./error": {
|
||||
"types": "./src/error/index.ts",
|
||||
"import": "./src/error/index.ts"
|
||||
},
|
||||
"./*": {
|
||||
"types": "./src/*.ts",
|
||||
"import": "./src/*.ts"
|
||||
},
|
||||
"./auth-broker": {
|
||||
"types": "./src/auth-broker/index.ts",
|
||||
"import": "./src/auth-broker/index.ts"
|
||||
},
|
||||
"./auth-broker/*": {
|
||||
"types": "./src/auth-broker/*.ts",
|
||||
"import": "./src/auth-broker/*.ts"
|
||||
},
|
||||
"./auth-gateway": {
|
||||
"types": "./src/auth-gateway/index.ts",
|
||||
"import": "./src/auth-gateway/index.ts"
|
||||
},
|
||||
"./auth-gateway/*": {
|
||||
"types": "./src/auth-gateway/*.ts",
|
||||
"import": "./src/auth-gateway/*.ts"
|
||||
},
|
||||
"./providers/*": {
|
||||
"types": "./src/providers/*.ts",
|
||||
"import": "./src/providers/*.ts"
|
||||
},
|
||||
"./providers/openai-codex/*": {
|
||||
"types": "./src/providers/openai-codex/*.ts",
|
||||
"import": "./src/providers/openai-codex/*.ts"
|
||||
},
|
||||
"./usage/*": {
|
||||
"types": "./src/usage/*.ts",
|
||||
"import": "./src/usage/*.ts"
|
||||
},
|
||||
"./utils/harmony-leak": {
|
||||
"types": "./src/utils/harmony-leak.ts",
|
||||
"import": "./src/utils/harmony-leak.ts"
|
||||
},
|
||||
"./dialect": {
|
||||
"types": "./src/dialect/index.ts",
|
||||
"import": "./src/dialect/index.ts"
|
||||
},
|
||||
"./utils/*": {
|
||||
"types": "./src/utils/*.ts",
|
||||
"import": "./src/utils/*.ts"
|
||||
},
|
||||
"./oauth": {
|
||||
"types": "./src/registry/oauth/index.ts",
|
||||
"import": "./src/registry/oauth/index.ts"
|
||||
},
|
||||
"./oauth/*": {
|
||||
"types": "./src/registry/oauth/*.ts",
|
||||
"import": "./src/registry/oauth/*.ts"
|
||||
},
|
||||
"./registry": {
|
||||
"types": "./src/registry/index.ts",
|
||||
"import": "./src/registry/index.ts"
|
||||
},
|
||||
"./registry/oauth": {
|
||||
"types": "./src/registry/oauth/index.ts",
|
||||
"import": "./src/registry/oauth/index.ts"
|
||||
},
|
||||
"./utils/schema": {
|
||||
"types": "./src/utils/schema/index.ts",
|
||||
"import": "./src/utils/schema/index.ts"
|
||||
},
|
||||
"./utils/schema/*": {
|
||||
"types": "./src/utils/schema/*.ts",
|
||||
"import": "./src/utils/schema/*.ts"
|
||||
},
|
||||
"./*.js": "./src/*.ts"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
* Allows extensions to register streaming functions for custom API types
|
||||
* (e.g., "vertex-claude-api") that are not built into stream.ts.
|
||||
*/
|
||||
|
||||
import * as AIError from "./error";
|
||||
import type {
|
||||
Api,
|
||||
AssistantMessageEventStream,
|
||||
@@ -27,6 +29,7 @@ const BUILTIN_API_IDS = [
|
||||
"google-vertex",
|
||||
"ollama-chat",
|
||||
"cursor-agent",
|
||||
"gitlab-duo-agent",
|
||||
"devin-agent",
|
||||
] as const satisfies readonly KnownApi[];
|
||||
|
||||
@@ -59,7 +62,7 @@ const customApiRegistry = new Map<string, RegisteredCustomApi>();
|
||||
|
||||
function assertCustomApiName(api: string): void {
|
||||
if (BUILTIN_APIS.has(api as KnownApi)) {
|
||||
throw new Error(`Cannot register custom API "${api}": built-in API names are reserved.`);
|
||||
throw new AIError.ConfigurationError(`Cannot register custom API "${api}": built-in API names are reserved.`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from "@oh-my-pi/pi-utils";
|
||||
import { YAML } from "bun";
|
||||
import { AuthStorage } from "../auth-storage";
|
||||
import * as AIError from "../error";
|
||||
import { AuthBrokerClient } from "./client";
|
||||
import { RemoteAuthCredentialStore } from "./remote-store";
|
||||
import { readAuthBrokerSnapshotCache, writeAuthBrokerSnapshotCache } from "./snapshot-cache";
|
||||
@@ -137,7 +138,8 @@ export async function resolveAuthBrokerConfig(
|
||||
const token =
|
||||
(envToken && envToken.length > 0 ? envToken : undefined) ?? configToken ?? (await readTokenFile()) ?? undefined;
|
||||
if (!token) {
|
||||
throw new Error(
|
||||
throw new AIError.MissingApiKeyError(
|
||||
undefined,
|
||||
`OMP_AUTH_BROKER_URL is set (${url}) but no bearer token is available. ` +
|
||||
`Set OMP_AUTH_BROKER_TOKEN, the \`auth.broker.token\` config entry, or place one at ${getAuthBrokerTokenFilePath()}.`,
|
||||
);
|
||||
@@ -190,7 +192,10 @@ export async function discoverAuthStorage(options: DiscoverAuthStorageOptions =
|
||||
}
|
||||
if (!initialSnapshot) {
|
||||
const initialResult = await client.fetchSnapshot();
|
||||
if (initialResult.status !== 200) throw new Error("Auth broker returned no initial snapshot");
|
||||
if (initialResult.status !== 200)
|
||||
throw new AIError.AuthBrokerError("Auth broker returned no initial snapshot", {
|
||||
status: initialResult.status,
|
||||
});
|
||||
initialSnapshot = initialResult.snapshot;
|
||||
persist?.(initialSnapshot);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
REMOTE_REFRESH_SENTINEL,
|
||||
type StoredAuthCredential,
|
||||
} from "../auth-storage";
|
||||
import * as AIError from "../error";
|
||||
import type { OAuthCredentials } from "../registry/oauth/types";
|
||||
import type { Provider } from "../types";
|
||||
import type { UsageReport } from "../usage";
|
||||
@@ -313,26 +314,26 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
|
||||
async markCredentialSuspect(credentialId: number, opts: { signal?: AbortSignal } = {}): Promise<void> {
|
||||
const { entry } = await this.#client.refreshCredential(credentialId, opts.signal);
|
||||
if (entry.credential.type !== "oauth") {
|
||||
throw new Error(`Broker returned non-OAuth credential for id=${credentialId}`);
|
||||
throw new AIError.AuthBrokerError(`Broker returned non-OAuth credential for id=${credentialId}`);
|
||||
}
|
||||
this.#applyCredentialEntry(entry);
|
||||
this.#maybeRefreshSnapshot("suspect credential refresh");
|
||||
}
|
||||
|
||||
replaceAuthCredentialsForProvider(_provider: string, _credentials: AuthCredential[]): StoredAuthCredential[] {
|
||||
throw new Error(
|
||||
throw new AIError.AuthBrokerError(
|
||||
"RemoteAuthCredentialStore is read-only on the client. Use `omp auth-broker login <provider>` to mutate credentials.",
|
||||
);
|
||||
}
|
||||
|
||||
upsertAuthCredentialForProvider(_provider: string, _credential: AuthCredential): StoredAuthCredential[] {
|
||||
throw new Error(
|
||||
throw new AIError.AuthBrokerError(
|
||||
"RemoteAuthCredentialStore is read-only on the client. Use `omp auth-broker login <provider>` to mutate credentials.",
|
||||
);
|
||||
}
|
||||
|
||||
deleteAuthCredentialsForProvider(_provider: string, _disabledCause: string): void {
|
||||
throw new Error(
|
||||
throw new AIError.AuthBrokerError(
|
||||
"RemoteAuthCredentialStore is read-only on the client. Use `omp auth-broker logout <provider>` to mutate credentials.",
|
||||
);
|
||||
}
|
||||
@@ -487,7 +488,7 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
|
||||
});
|
||||
}
|
||||
if (entry.credential.type !== "oauth") {
|
||||
throw new Error(`Broker returned non-OAuth credential for id=${credentialId}`);
|
||||
throw new AIError.AuthBrokerError(`Broker returned non-OAuth credential for id=${credentialId}`);
|
||||
}
|
||||
const refreshed = entry.credential;
|
||||
return {
|
||||
@@ -538,11 +539,11 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
|
||||
*/
|
||||
#raceWithSignal<T>(promise: Promise<T>, signal?: AbortSignal): Promise<T> {
|
||||
if (!signal) return promise;
|
||||
if (signal.aborted) return Promise.reject(new Error("auth-broker request aborted"));
|
||||
if (signal.aborted) return Promise.reject(new AIError.AbortError("auth-broker request aborted"));
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const onAbort = (): void => {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
reject(new Error("auth-broker request aborted"));
|
||||
reject(new AIError.AbortError("auth-broker request aborted"));
|
||||
};
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
promise.then(
|
||||
|
||||
@@ -183,8 +183,15 @@ const usageLimitSchema = type({
|
||||
"notes?": "string[]",
|
||||
});
|
||||
|
||||
const usageResetCreditDetailSchema = type({
|
||||
"grantedAt?": "string",
|
||||
"expiresAt?": "string",
|
||||
"status?": "string",
|
||||
});
|
||||
|
||||
const usageResetCreditsSchema = type({
|
||||
availableCount: "number",
|
||||
"credits?": usageResetCreditDetailSchema.array(),
|
||||
});
|
||||
|
||||
const arkUsageReportSchema = type({
|
||||
|
||||
@@ -22,12 +22,13 @@ import { Effort } from "@oh-my-pi/pi-catalog/effort";
|
||||
import { extractHttpStatusFromError, extractRetryHint, logger } from "@oh-my-pi/pi-utils";
|
||||
import type { ApiKeyResolver } from "../auth-retry";
|
||||
import type { AuthStorage } from "../auth-storage";
|
||||
import { classifyGatewayError } from "../error/gateway";
|
||||
import { isUsageLimitOutcome } from "../error/rate-limit";
|
||||
import * as anthropicMessages from "../providers/anthropic-messages-server";
|
||||
import * as openaiChat from "../providers/openai-chat-server";
|
||||
import * as openaiResponses from "../providers/openai-responses-server";
|
||||
import * as piNative from "../providers/pi-native-server";
|
||||
import { isUsageLimitError, isUsageLimitOutcome } from "../rate-limit-utils";
|
||||
import { streamSimple } from "../stream";
|
||||
import { completeSimple, streamSimple } from "../stream";
|
||||
import type { Api, AssistantMessageEventStream, Context, Model, SimpleStreamOptions } from "../types";
|
||||
import { deterministicUuid } from "../utils/deterministic-id";
|
||||
import { parseBind } from "../utils/parse-bind";
|
||||
@@ -192,95 +193,6 @@ function buildStreamOptions(parsed: ParsedFormatRequest, api: Api, signal: Abort
|
||||
return opts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify an upstream / gateway-internal error into a status code and a
|
||||
* format-neutral type. The order is intentional:
|
||||
*
|
||||
* 1. Honour an explicit numeric `status` property on the thrown error.
|
||||
* 2. Parse a status code embedded in the message string. Provider errors
|
||||
* virtually always carry one (`Google API error (400): …`, `HTTP 429`,
|
||||
* `status=503`) and the embedded value is authoritative.
|
||||
* 3. Fall through to **word-boundaried** substring heuristics. The old
|
||||
* `lower.includes("rate")` test famously matched
|
||||
* `GenerateContentRequest`, surfacing every Google 400 as a 429
|
||||
* `rate_limit_error`. The patterns here all require boundaries so they
|
||||
* don't collide with provider field names.
|
||||
*/
|
||||
export function classifyGatewayError(err: unknown): { status: number; type: string; message: string } {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
|
||||
// 1. Custom pi-ai errors may attach a numeric `status` property.
|
||||
const statusProp =
|
||||
typeof err === "object" && err !== null && typeof (err as { status?: unknown }).status === "number"
|
||||
? (err as { status: number }).status | 0
|
||||
: undefined;
|
||||
if (statusProp !== undefined) return bucketStatus(statusProp, message);
|
||||
|
||||
if (err instanceof Error && err.name === "AbortError") return { status: 499, type: "request_aborted", message };
|
||||
|
||||
// 2. Status code embedded in the message. Requires a contextual keyword
|
||||
// (`HTTP`, `API error`, `status`, …) or a leading `(NNN)` token so we
|
||||
// don't trip on incidental three-digit numbers ("took 200ms").
|
||||
const embedded = extractEmbeddedStatus(message);
|
||||
if (embedded !== undefined) return bucketStatus(embedded, message);
|
||||
|
||||
// 3. Word-boundaried substring heuristics.
|
||||
if (/\baborted\b|\babort signal\b/i.test(message)) {
|
||||
return { status: 499, type: "request_aborted", message };
|
||||
}
|
||||
if (
|
||||
// Match rate-limit phrasings before auth wording: some providers
|
||||
// describe throttling as "unauthorized due to rate limit".
|
||||
// Keep boundaries so this does not collide with
|
||||
// `GenerateContentRequest`, `accelerate`, `iterate`, `deprecated`, etc.
|
||||
/\brate[- _]?limit(?:s|ed|ing)?\b|\bquota(?:_exceeded| exceeded)?\b|\btoo[- _]many[- _]requests\b/i.test(
|
||||
message,
|
||||
) ||
|
||||
// Usage-limit phrasings emit no embedded status. Codex friendly text
|
||||
// reads "You have hit your ChatGPT usage limit … Try again in ~158
|
||||
// min."; pi-ai's central `isUsageLimitError` already encodes every
|
||||
// known provider variant, so reuse it instead of forking the regex.
|
||||
// Without this branch the classifier falls through to the default
|
||||
// 502/upstream_error, which is what callers were seeing when their
|
||||
// account hit its cap.
|
||||
isUsageLimitError(message)
|
||||
) {
|
||||
return { status: 429, type: "rate_limit_error", message };
|
||||
}
|
||||
if (/\b(?:unauthorized|forbidden)\b/i.test(message)) {
|
||||
return { status: 401, type: "authentication_error", message };
|
||||
}
|
||||
if (/\b(?:unsupported|invalid_request|invalid request|bad request|malformed)\b/i.test(message)) {
|
||||
return { status: 400, type: "invalid_request_error", message };
|
||||
}
|
||||
return { status: 502, type: "upstream_error", message };
|
||||
}
|
||||
|
||||
function bucketStatus(status: number, message: string): { status: number; type: string; message: string } {
|
||||
if (status === 401 || status === 403) return { status, type: "authentication_error", message };
|
||||
if (status === 429) return { status, type: "rate_limit_error", message };
|
||||
if (status >= 400 && status < 500) return { status, type: "invalid_request_error", message };
|
||||
if (status >= 500) return { status, type: "upstream_error", message };
|
||||
return { status: 502, type: "upstream_error", message };
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull a status code from common error-message shapes. Returns undefined when
|
||||
* no contextual keyword is present, so we never guess at incidental numbers.
|
||||
*/
|
||||
function extractEmbeddedStatus(message: string): number | undefined {
|
||||
// `Google API error (400)`, `OpenAI API error (429): …`, `(503)`
|
||||
// `HTTP 429: too many requests`
|
||||
// `status: 503`, `status_code=429`, `status=400`
|
||||
const re = /(?:\bHTTP\b|\bAPI error\b|\bstatus(?:[- _]?code)?\b)\s*[:=]?\s*\(?\s*(\d{3})\b|\((\d{3})\)/i;
|
||||
const m = message.match(re);
|
||||
if (!m) return undefined;
|
||||
const raw = m[1] ?? m[2];
|
||||
if (!raw) return undefined;
|
||||
const code = Number.parseInt(raw, 10);
|
||||
return Number.isFinite(code) && code >= 100 && code < 600 ? code : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook fired by {@link streamSimple} when the upstream request fails in a
|
||||
* way that's rotatable — today that's HTTP 401 (credential is bad) and
|
||||
@@ -525,20 +437,10 @@ async function handleFormatEndpoint(
|
||||
peer,
|
||||
});
|
||||
|
||||
let events: AssistantMessageEventStream;
|
||||
try {
|
||||
if (controller.signal.aborted) return clientClosedResponse(route);
|
||||
events = streamSimple(model, parsed.context, streamOpts);
|
||||
} catch (error) {
|
||||
const classified = classifyGatewayError(error);
|
||||
logger.warn("auth-gateway streamSimple threw", { format: route.label, error: classified.message, peer });
|
||||
return route.module.formatError(classified.status, classified.type, classified.message);
|
||||
}
|
||||
|
||||
if (!parsed.stream) {
|
||||
try {
|
||||
if (controller.signal.aborted) return clientClosedResponse(route);
|
||||
const message = await events.result();
|
||||
const message = await completeSimple(model, parsed.context, streamOpts);
|
||||
if (message.stopReason === "aborted" || message.stopReason === "error") {
|
||||
const errorMessage =
|
||||
message.errorMessage ??
|
||||
@@ -552,7 +454,7 @@ async function handleFormatEndpoint(
|
||||
if (message.stopReason === "aborted") {
|
||||
return route.module.formatError(499, "request_aborted", errorMessage);
|
||||
}
|
||||
const classified = classifyGatewayError(new Error(errorMessage));
|
||||
const classified = classifyGatewayError(errorMessage);
|
||||
return route.module.formatError(classified.status, classified.type, errorMessage);
|
||||
}
|
||||
return json(200, route.module.encodeResponse(message, parsed.modelId));
|
||||
@@ -567,6 +469,16 @@ async function handleFormatEndpoint(
|
||||
return route.module.formatError(classified.status, classified.type, classified.message);
|
||||
}
|
||||
}
|
||||
|
||||
let events: AssistantMessageEventStream;
|
||||
try {
|
||||
if (controller.signal.aborted) return clientClosedResponse(route);
|
||||
events = streamSimple(model, parsed.context, streamOpts);
|
||||
} catch (error) {
|
||||
const classified = classifyGatewayError(error);
|
||||
logger.warn("auth-gateway streamSimple threw", { format: route.label, error: classified.message, peer });
|
||||
return route.module.formatError(classified.status, classified.type, classified.message);
|
||||
}
|
||||
if (controller.signal.aborted) return clientClosedResponse(route);
|
||||
|
||||
const sseStream = route.module.encodeStream(events, parsed.modelId, parsed.options, {
|
||||
@@ -700,20 +612,10 @@ async function handlePiNative(bootOpts: AuthGatewayBootOptions, req: Request, pe
|
||||
peer,
|
||||
});
|
||||
|
||||
let events: AssistantMessageEventStream;
|
||||
try {
|
||||
if (controller.signal.aborted) return aborted();
|
||||
events = streamSimple(model, parsed.context, streamOpts);
|
||||
} catch (error) {
|
||||
const classified = classifyGatewayError(error);
|
||||
logger.warn("auth-gateway streamSimple threw", { format: "pi-native", error: classified.message, peer });
|
||||
return piNative.formatError(classified.status, classified.type, classified.message);
|
||||
}
|
||||
|
||||
if (!parsed.stream) {
|
||||
try {
|
||||
if (controller.signal.aborted) return aborted();
|
||||
const message = await events.result();
|
||||
const message = await completeSimple(model, parsed.context, streamOpts);
|
||||
if (message.stopReason === "aborted" || message.stopReason === "error") {
|
||||
const errorMessage =
|
||||
message.errorMessage ??
|
||||
@@ -727,7 +629,7 @@ async function handlePiNative(bootOpts: AuthGatewayBootOptions, req: Request, pe
|
||||
if (message.stopReason === "aborted") {
|
||||
return piNative.formatError(499, "request_aborted", errorMessage);
|
||||
}
|
||||
const classified = classifyGatewayError(new Error(errorMessage));
|
||||
const classified = classifyGatewayError(errorMessage);
|
||||
return piNative.formatError(classified.status, classified.type, errorMessage);
|
||||
}
|
||||
return json(200, { message });
|
||||
@@ -738,6 +640,16 @@ async function handlePiNative(bootOpts: AuthGatewayBootOptions, req: Request, pe
|
||||
return piNative.formatError(classified.status, classified.type, classified.message);
|
||||
}
|
||||
}
|
||||
|
||||
let events: AssistantMessageEventStream;
|
||||
try {
|
||||
if (controller.signal.aborted) return aborted();
|
||||
events = streamSimple(model, parsed.context, streamOpts);
|
||||
} catch (error) {
|
||||
const classified = classifyGatewayError(error);
|
||||
logger.warn("auth-gateway streamSimple threw", { format: "pi-native", error: classified.message, peer });
|
||||
return piNative.formatError(classified.status, classified.type, classified.message);
|
||||
}
|
||||
if (controller.signal.aborted) return aborted();
|
||||
|
||||
const sseStream = piNative.encodeStream(events, parsed.modelId, parsed.options, {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { extractHttpStatusFromError } from "@oh-my-pi/pi-utils";
|
||||
import type { OAuthAccess } from "./auth-storage";
|
||||
import { isUsageLimitOutcome } from "./rate-limit-utils";
|
||||
import * as AIError from "./error";
|
||||
import { isAuthRetryableError } from "./error/auth-classify";
|
||||
|
||||
/**
|
||||
* Context passed to an {@link ApiKeyResolver} on each resolution attempt.
|
||||
@@ -70,23 +70,8 @@ export function seedApiKeyResolver(seed: string | undefined, resolver: ApiKeyRes
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Classifies whether an error should trigger a credential refresh/rotation
|
||||
* retry: a hard `401`, body-classified usage limit (Codex
|
||||
* `usage_limit_reached`, Anthropic account rate-limit, Google
|
||||
* `resource_exhausted`, OpenAI `insufficient_quota`, …), or a bare `429`
|
||||
* whose payload did not preserve a richer quota code. Transient 429s
|
||||
* (`Too many requests`, per-minute caps) classify as `RATE_LIMIT_EXCEEDED`
|
||||
* via {@link parseRateLimitReason} and stay in the upstream-backoff lane.
|
||||
*/
|
||||
export function isAuthRetryableError(error: unknown): boolean {
|
||||
const status = extractHttpStatusFromError(error);
|
||||
if (status === 401) return true;
|
||||
const message = error instanceof Error ? error.message : typeof error === "string" ? error : undefined;
|
||||
const embeddedStatus = message ? extractHttpStatusFromError({ message }) : undefined;
|
||||
if (embeddedStatus === 401) return true;
|
||||
return isUsageLimitOutcome(status ?? embeddedStatus, message);
|
||||
}
|
||||
// Re-exported from the error module (its new home); see error/auth-classify.ts.
|
||||
export { isAuthRetryableError };
|
||||
|
||||
/**
|
||||
* The ordered `lastChance` values for the retry steps after the initial
|
||||
@@ -130,7 +115,7 @@ export async function withAuth<T>(
|
||||
opts?: { isAuthError?: (error: unknown) => boolean; signal?: AbortSignal; missingKeyMessage?: string },
|
||||
): Promise<T> {
|
||||
const isAuthError = opts?.isAuthError ?? isAuthRetryableError;
|
||||
const missingKey = (): Error => new Error(opts?.missingKeyMessage ?? "No API key available");
|
||||
const missingKey = (): Error => new AIError.MissingApiKeyError(undefined, opts?.missingKeyMessage);
|
||||
|
||||
if (!isApiKeyResolver(key)) {
|
||||
if (key === undefined) throw missingKey();
|
||||
@@ -225,7 +210,10 @@ export async function withOAuthAccess<T>(
|
||||
|
||||
let lastAccess = opts?.seed ?? (await storage.getOAuthAccess(provider, sessionId, { signal }));
|
||||
if (!lastAccess) {
|
||||
throw new Error(opts?.missingAccessMessage ?? `No OAuth credential available for provider: ${provider}`);
|
||||
throw new AIError.MissingApiKeyError(
|
||||
provider,
|
||||
opts?.missingAccessMessage ?? `No OAuth credential available for provider: ${provider}`,
|
||||
);
|
||||
}
|
||||
|
||||
const resolveStep = async (lastChance: boolean, error: unknown): Promise<OAuthAccess | undefined> => {
|
||||
|
||||
@@ -10,10 +10,11 @@
|
||||
import { Database, type Statement } from "bun:sqlite";
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as path from "node:path";
|
||||
import { extractHttpStatusFromError, getAgentDbPath, logger } from "@oh-my-pi/pi-utils";
|
||||
import { getAgentDbPath, logger } from "@oh-my-pi/pi-utils";
|
||||
import type { ApiKeyResolver } from "./auth-retry";
|
||||
import { isUsageLimitOutcome } from "./rate-limit-utils";
|
||||
import { getProviderDefinition } from "./registry";
|
||||
import * as AIError from "./error";
|
||||
import { isUsageLimitOutcome } from "./error/rate-limit";
|
||||
import { getProviderDefinition, PASTE_CODE_LOGIN_PROVIDERS } from "./registry";
|
||||
import { getOAuthApiKey, getOAuthProvider, refreshOAuthToken } from "./registry/oauth";
|
||||
import type { OAuthController, OAuthCredentials, OAuthProvider, OAuthProviderId } from "./registry/oauth/types";
|
||||
import { getEnvApiKey, getEnvApiKeyName } from "./stream";
|
||||
@@ -39,6 +40,7 @@ import { googleGeminiCliUsageProvider } from "./usage/gemini";
|
||||
import { githubCopilotUsageProvider } from "./usage/github-copilot";
|
||||
import { antigravityRankingStrategy, antigravityUsageProvider } from "./usage/google-antigravity";
|
||||
import { kimiUsageProvider } from "./usage/kimi";
|
||||
import { ollamaCloudUsageProvider, ollamaUsageProvider } from "./usage/ollama";
|
||||
import { codexRankingStrategy, openaiCodexUsageProvider } from "./usage/openai-codex";
|
||||
import {
|
||||
type CodexResetConsumeCode,
|
||||
@@ -496,6 +498,8 @@ const DEFAULT_USAGE_PROVIDERS: UsageProvider[] = [
|
||||
kimiUsageProvider,
|
||||
antigravityUsageProvider,
|
||||
googleGeminiCliUsageProvider,
|
||||
ollamaUsageProvider,
|
||||
ollamaCloudUsageProvider,
|
||||
claudeUsageProvider,
|
||||
zaiUsageProvider,
|
||||
opencodeGoUsageProvider,
|
||||
@@ -555,36 +559,9 @@ const OAUTH_REFRESH_SKEW_MS = 60_000;
|
||||
*/
|
||||
const MAX_PENDING_DISABLED_EVENTS = 32;
|
||||
|
||||
/**
|
||||
* Classify an OAuth refresh error as a definitive credential failure (the
|
||||
* refresh token is dead — re-login required) versus a transient blip
|
||||
* (network/5xx — retry next sweep).
|
||||
*
|
||||
* Anchored at module scope so all three refresh sites — in-stream
|
||||
* {@link AuthStorage.getApiKey}, the usage probe in
|
||||
* {@link AuthStorage.fetchUsageReports}, and the auth-broker background
|
||||
* refresher — disable rows on the same criteria. A drifting classifier
|
||||
* between sites would let stale last-good usage reports surface indefinitely
|
||||
* while streaming requests correctly tear the row down.
|
||||
*/
|
||||
const OAUTH_DEFINITIVE_FAILURE_REGEX =
|
||||
/invalid_grant|invalid_token|unauthorized_client|\brevoked\b|refresh[\s_]?token.*expired/i;
|
||||
// Transient: network blips, rate limits, gateway/5xx, and infra denials
|
||||
// (WAF / egress 403, permission / account-verification) — block-and-retry,
|
||||
// never tear the credential down for these.
|
||||
const OAUTH_TRANSIENT_FAILURE_REGEX =
|
||||
/timeout|network|fetch failed|ECONN(?:REFUSED|RESET)|ETIMEDOUT|EAI_AGAIN|socket hang up|\b(?:408|425|429|5\d{2})\b|rate.?limit|too many requests|temporar|unavailable|forbidden|permission_denied|cloudflare|captcha/i;
|
||||
// A bare 401 from an OAuth token endpoint means the stored grant/client is
|
||||
// dead. 403 is deliberately excluded: it is overwhelmingly WAF / egress
|
||||
// rate-limit / permission / account-verification — none of which mean the
|
||||
// refresh token itself is invalid.
|
||||
const OAUTH_HTTP_AUTH_REGEX = /\b401\b/;
|
||||
|
||||
export function isDefinitiveOAuthFailure(errorMsg: string): boolean {
|
||||
if (OAUTH_DEFINITIVE_FAILURE_REGEX.test(errorMsg)) return true;
|
||||
if (OAUTH_HTTP_AUTH_REGEX.test(errorMsg) && !OAUTH_TRANSIENT_FAILURE_REGEX.test(errorMsg)) return true;
|
||||
return false;
|
||||
}
|
||||
// Re-exported from the error module (its new home) to preserve the public
|
||||
// `@oh-my-pi/pi-ai` entrypoint and the in-module call sites below.
|
||||
export { isDefinitiveOAuthFailure } from "./error/auth-classify";
|
||||
|
||||
/**
|
||||
* Outcome of {@link AuthStorage.markUsageLimitReached}.
|
||||
@@ -808,11 +785,11 @@ function parseUsageCacheEntry<T>(raw: string): UsageCacheEntry<T> | undefined {
|
||||
*/
|
||||
function raceUsageWithSignal<T>(promise: Promise<T>, signal: AbortSignal | undefined): Promise<T> {
|
||||
if (!signal) return promise;
|
||||
if (signal.aborted) return Promise.reject(new Error("usage fetch aborted"));
|
||||
if (signal.aborted) return Promise.reject(new AIError.AbortError("usage fetch aborted"));
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const onAbort = (): void => {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
reject(new Error("usage fetch aborted"));
|
||||
reject(new AIError.AbortError("usage fetch aborted"));
|
||||
};
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
promise.then(
|
||||
@@ -834,9 +811,9 @@ function raceCredentialRefreshWithSignal<T>(
|
||||
message = "credential refresh aborted",
|
||||
): Promise<T> {
|
||||
if (!signal) return promise;
|
||||
if (signal.aborted) return Promise.reject(new Error(message));
|
||||
if (signal.aborted) return Promise.reject(new AIError.AbortError(message));
|
||||
const abort = Promise.withResolvers<never>();
|
||||
const onAbort = (): void => abort.reject(new Error(message));
|
||||
const onAbort = (): void => abort.reject(new AIError.AbortError(message));
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
return Promise.race([promise, abort.promise]).finally(() => {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
@@ -1884,11 +1861,21 @@ export class AuthStorage {
|
||||
onPrompt: (prompt: { message: string; placeholder?: string }) => Promise<string>;
|
||||
},
|
||||
): Promise<void> {
|
||||
const manualCodeInput = () => ctrl.onPrompt({ message: "Paste the authorization code (or full redirect URL):" });
|
||||
// Only paste-code providers (fixed non-loopback redirect, e.g. GitLab Duo
|
||||
// Agent's vscode:// URI) get a default manual-code prompt. For loopback OAuth
|
||||
// providers the `OAuthCallbackFlow` would otherwise race this readline prompt
|
||||
// against the HTTP callback and, when the callback wins, leave the prompt
|
||||
// outstanding — a dirty/blocked terminal. Synthesizing the default only for
|
||||
// paste-code providers is the authoritative gate (it covers every caller, not
|
||||
// just the CLI); an explicit caller-supplied `onManualCodeInput` is still
|
||||
// honored for any provider as an escape hatch.
|
||||
const manualCodeInput = PASTE_CODE_LOGIN_PROVIDERS.has(provider)
|
||||
? () => ctrl.onPrompt({ message: "Paste the authorization code (or full redirect URL):" })
|
||||
: undefined;
|
||||
// Built-in registry first, then runtime-registered extension providers.
|
||||
const def = getProviderDefinition(provider) ?? getOAuthProvider(provider);
|
||||
if (!def?.login) {
|
||||
throw new Error(`Unknown OAuth provider: ${provider}`);
|
||||
throw new AIError.ConfigurationError(`Unknown OAuth provider: ${provider}`);
|
||||
}
|
||||
const result = await def.login({
|
||||
onAuth: ctrl.onAuth,
|
||||
@@ -2164,7 +2151,7 @@ export class AuthStorage {
|
||||
// (including its already-elapsed `resetsAt`). CAS-disable the row and
|
||||
// clear the cache so the credential drops out of the report instead of
|
||||
// freezing in place until the user notices and re-logs in.
|
||||
if (isDefinitiveOAuthFailure(errorMsg)) {
|
||||
if (AIError.isDefinitiveOAuthFailure(errorMsg)) {
|
||||
const credentialId = this.#findStoredCredentialIdForUsageCredential(
|
||||
request.provider,
|
||||
request.credential,
|
||||
@@ -3451,7 +3438,10 @@ export class AuthStorage {
|
||||
const customProvider = getOAuthProvider(provider);
|
||||
if (customProvider) {
|
||||
if (!customProvider.refreshToken) {
|
||||
throw new Error(`OAuth provider "${provider}" does not support token refresh`);
|
||||
throw new AIError.OAuthError(`OAuth provider "${provider}" does not support token refresh`, {
|
||||
kind: "configuration",
|
||||
provider,
|
||||
});
|
||||
}
|
||||
refreshPromise = customProvider.refreshToken(credential);
|
||||
} else {
|
||||
@@ -3465,14 +3455,20 @@ export class AuthStorage {
|
||||
let onAbort: (() => void) | undefined;
|
||||
const cancellation = Promise.withResolvers<never>();
|
||||
timeout = setTimeout(
|
||||
() => cancellation.reject(new Error(`OAuth token refresh timed out for provider: ${provider}`)),
|
||||
() =>
|
||||
cancellation.reject(
|
||||
new AIError.OAuthError(`OAuth token refresh timed out for provider: ${provider}`, {
|
||||
kind: "timeout",
|
||||
provider,
|
||||
}),
|
||||
),
|
||||
DEFAULT_OAUTH_REFRESH_TIMEOUT_MS,
|
||||
);
|
||||
if (signal) {
|
||||
if (signal.aborted) {
|
||||
cancellation.reject(new Error("OAuth token refresh aborted by caller"));
|
||||
cancellation.reject(new AIError.AbortError("OAuth token refresh aborted by caller"));
|
||||
} else {
|
||||
onAbort = () => cancellation.reject(new Error("OAuth token refresh aborted by caller"));
|
||||
onAbort = () => cancellation.reject(new AIError.AbortError("OAuth token refresh aborted by caller"));
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
}
|
||||
}
|
||||
@@ -3678,7 +3674,7 @@ export class AuthStorage {
|
||||
const errorMsg = String(error);
|
||||
// Only remove credentials for definitive auth failures
|
||||
// Keep credentials for transient errors (network, 5xx) and block temporarily
|
||||
const isDefinitiveFailure = isDefinitiveOAuthFailure(errorMsg);
|
||||
const isDefinitiveFailure = AIError.isDefinitiveOAuthFailure(errorMsg);
|
||||
|
||||
logger.warn("OAuth token refresh failed", {
|
||||
provider,
|
||||
@@ -4238,7 +4234,7 @@ export class AuthStorage {
|
||||
if (!sessionCredential) return false;
|
||||
|
||||
const error = options?.error;
|
||||
const status = extractHttpStatusFromError(error);
|
||||
const status = AIError.status(error);
|
||||
const message = error instanceof Error ? error.message : typeof error === "string" ? error : undefined;
|
||||
if (isUsageLimitOutcome(status, message)) {
|
||||
return (
|
||||
@@ -4375,7 +4371,9 @@ export class AuthStorage {
|
||||
if (index === -1) continue;
|
||||
const target = entries[index];
|
||||
if (target.credential.type !== "oauth") {
|
||||
throw new Error(`Credential ${id} is not OAuth (provider=${provider}, type=${target.credential.type})`);
|
||||
throw new AIError.ValidationError(
|
||||
`Credential ${id} is not OAuth (provider=${provider}, type=${target.credential.type})`,
|
||||
);
|
||||
}
|
||||
// The exact credential we are about to refresh — captured before the
|
||||
// await so a definitive failure can CAS-disable the row against the
|
||||
@@ -4391,7 +4389,7 @@ export class AuthStorage {
|
||||
// A definitively-dead grant tears the row down here, where the
|
||||
// attempted credential is known. CAS on the persisted credential so a
|
||||
// peer/login rotation in flight leaves the freshly-rotated row intact.
|
||||
if (isDefinitiveOAuthFailure(String(error))) {
|
||||
if (AIError.isDefinitiveOAuthFailure(String(error))) {
|
||||
// CAS-loss (false) means a peer/login rotated the row mid-refresh, so
|
||||
// our #data copy is stale — reload so the next caller serves the
|
||||
// freshly-rotated credential rather than the dead token we attempted.
|
||||
@@ -4424,7 +4422,7 @@ export class AuthStorage {
|
||||
// -1 means the row was disabled/removed mid-refresh — surface that as a
|
||||
// miss rather than implying a live row the snapshot won't contain.
|
||||
if (this.#replaceCredentialById(provider, id, updated) === -1) {
|
||||
throw new Error(`No credential with id=${id}`);
|
||||
throw new AIError.ValidationError(`No credential with id=${id}`);
|
||||
}
|
||||
return {
|
||||
id,
|
||||
@@ -4433,7 +4431,7 @@ export class AuthStorage {
|
||||
identityKey: resolveCredentialIdentityKey(provider, updated),
|
||||
};
|
||||
}
|
||||
throw new Error(`No credential with id=${id}`);
|
||||
throw new AIError.ValidationError(`No credential with id=${id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -4856,7 +4854,7 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new Error(
|
||||
throw new AIError.ConfigurationError(
|
||||
`Failed to open auth database at '${dbPath}' after ${maxAttempts} attempts: ${lastBusyError?.message}`,
|
||||
{ cause: lastBusyError },
|
||||
);
|
||||
|
||||
@@ -22,10 +22,10 @@ Results arrive later in a `<function_results>` block, one `<result>` per call (f
|
||||
## Rules
|
||||
|
||||
- `name` MUST match a listed function.
|
||||
- String/scalar parameters: exact text, spaces preserved. Lists/objects: JSON.
|
||||
- String/scalar parameters: exact text, spaces preserved — bodies are read by regex (delimiter matching), NOT a real XML parser, so never HTML-escape them (emit `a & b`, not `a & b`; `<`/`>` stay literal); only the body's own `</parameter>` closing tag is reserved. Lists/objects: JSON.
|
||||
- Multiple calls: multiple `<invoke>` blocks in one `<function_calls>`.
|
||||
- You MAY write visible text before the calls.
|
||||
- NEVER emit `tool_calls` JSON.
|
||||
- NEVER use the legacy `<tool_name>`/`<parameters>` call syntax.
|
||||
- Read each `<result>`/`<error>` in call order. NEVER emit `<function_results>` yourself.
|
||||
- After emitting your tool calls, YOU MUST EMIT THE STOP SEQUENCE AND HALT.
|
||||
- Emit the stop sequence ONLY after the call is fully written — NEVER announce a tool then stop (e.g. halting at "Let's run `cargo clippy`" with no `<invoke>` emitted). Write the complete call, THEN the stop sequence, THEN halt.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { parseJsonWithRepair } from "@oh-my-pi/pi-utils";
|
||||
import type { Message, ToolCall } from "../types";
|
||||
import { parseJsonWithRepair } from "../utils/json-parse";
|
||||
import dialectPrompt from "./anthropic.md" with { type: "text" };
|
||||
import { buildArgShapes, buildStringArgsResolver, mintToolCallId, type ToolArgShape } from "./coercion";
|
||||
import {
|
||||
|
||||
@@ -16,8 +16,9 @@ Results arrive as output tokens:
|
||||
|
||||
- Use `|` (U+FF5C) and `▁` (U+2581) exactly.
|
||||
- Tool name MUST match an available function; arguments are one valid JSON object.
|
||||
- Argument string values use only normal JSON string escaping (`\"`, `\\`, `\n`); never HTML-escape their contents — write `a & b`, not `a & b`.
|
||||
- NEVER wrap arguments in Markdown fences; NEVER emit a `type` field or `function` prefix.
|
||||
- Multiple calls chain `<|tool▁call▁begin|>...<|tool▁call▁end|>` directly — no separators, spaces, or newlines between them.
|
||||
- Private reasoning, when needed, goes in `<think>...</think>` before the tokens.
|
||||
- Read each output token in call order. NEVER emit output tokens yourself.
|
||||
- After emitting your tool calls, YOU MUST EMIT THE STOP SEQUENCE AND HALT.
|
||||
- Emit the stop sequence ONLY after the call is fully written — NEVER announce a tool then stop (e.g. halting at "Let's run `cargo clippy`" with no `<|tool▁call▁begin|>` emitted). Write the complete call, THEN the stop sequence, THEN halt.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { parseJsonWithRepair } from "@oh-my-pi/pi-utils";
|
||||
import type { Message, ToolCall } from "../types";
|
||||
import { parseJsonWithRepair } from "../utils/json-parse";
|
||||
import { asRecord, mintToolCallId, partialSuffixOverlapAny } from "./coercion";
|
||||
import dialectPrompt from "./deepseek.md" with { type: "text" };
|
||||
import { assistantTranscriptParts, collectToolResultRun, messageContentText, stringifyJson } from "./rendering";
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { preferredDialect } from "@oh-my-pi/pi-catalog/identity";
|
||||
import { getDialectDefinition } from "./factory";
|
||||
|
||||
/**
|
||||
* Wrap a prior-turn reasoning string for demotion into native conversation
|
||||
* history — the cross-provider / cross-model case where the target cannot replay
|
||||
* it as a structured thinking block (verified end-to-end against Gemini 3: a
|
||||
* replayed unsigned `thought` part is schema-accepted but silently discarded —
|
||||
* neither recalled nor influencing generation).
|
||||
*
|
||||
* The reasoning is rendered in the TARGET model's canonical inline thinking
|
||||
* delimiters so it reads as reasoning in that model's own idiom instead of bare
|
||||
* prose the model might continue. Harmony and Gemma are the exception: their
|
||||
* `renderThinking` emits chat-template control tokens (`<|channel|>analysis`,
|
||||
* `<|channel>thought`) that must not appear inside a structured native message,
|
||||
* so they fall back to a plain `<think>` block. Every other dialect's thinking
|
||||
* form is inline-safe XML tags or a markdown fence.
|
||||
*
|
||||
* The result ends with a trailing newline so the block stays separated from the
|
||||
* turn's reply text when the wire encoder concatenates parts.
|
||||
*
|
||||
* Distinct from {@link DialectDefinition.renderThinking}, which targets the
|
||||
* owned-dialect *text transport* where those control tokens are legal.
|
||||
*/
|
||||
export function renderDemotedThinking(modelId: string, text: string): string {
|
||||
if (!text) return "";
|
||||
text = text.toWellFormed();
|
||||
const dialect = preferredDialect(modelId);
|
||||
if (dialect === "harmony" || dialect === "gemma") return `<think>\n${text}\n</think>\n`;
|
||||
return `${getDialectDefinition(dialect).renderThinking(text)}\n`;
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import harmonyDefinition from "./harmony";
|
||||
import hermesDefinition from "./hermes";
|
||||
import kimiDefinition from "./kimi";
|
||||
import minimaxDefinition from "./minimax";
|
||||
import piDefinition from "./pi";
|
||||
import qwen3Definition from "./qwen3";
|
||||
import type { Dialect, DialectDefinition, InbandScanner, InbandScannerOptions } from "./types";
|
||||
import xmlDefinition from "./xml";
|
||||
@@ -21,7 +20,6 @@ const DIALECT_DEFINITIONS: Record<Dialect, DialectDefinition> = {
|
||||
deepseek: deepseekDefinition,
|
||||
minimax: minimaxDefinition,
|
||||
harmony: harmonyDefinition,
|
||||
pi: piDefinition,
|
||||
qwen3: qwen3Definition,
|
||||
gemini: geminiDefinition,
|
||||
gemma: gemmaDefinition,
|
||||
|
||||
@@ -37,7 +37,8 @@ brief reasoning
|
||||
## Rules
|
||||
|
||||
- The function name MUST match a listed function; arguments are keyword form (`name=value`).
|
||||
- Argument string values use only normal Python string escaping; never HTML-escape their contents — write `"a & b"`, not `"a & b"`.
|
||||
- Multiple calls = a single `[...]` list (or one `default_api...` call per line) inside one ` ```tool_code ` block.
|
||||
- Put private reasoning in a ` ```thinking ` block before the ` ```tool_code ` block, never inside ` ```tool_code `.
|
||||
- Read each ` ```tool_outputs ` block in call order. NEVER write a ` ```tool_outputs ` block yourself.
|
||||
- After emitting the ` ```tool_code ` block, YOU MUST STOP AND HALT.
|
||||
- Emit the ` ```tool_code ` block in full, THEN stop and halt — NEVER announce a tool then stop (e.g. halting at "Let's run `cargo clippy`" with no ` ```tool_code ` block emitted).
|
||||
|
||||
@@ -25,8 +25,9 @@ brief reasoning
|
||||
## Rules
|
||||
|
||||
- `NAME` MUST match a listed function; arguments are `key:value` pairs separated by commas.
|
||||
- String values between `<|"|>` tokens are raw literal text (no escaping); never HTML-escape them — write `a & b`, not `a & b`.
|
||||
- Multiple calls = consecutive `<|tool_call>...<tool_call|>` blocks; keep prose outside them.
|
||||
- The closer is `<tool_call|>` (pipe on the right), not `</tool_call>` or `<|tool_call>`.
|
||||
- Private reasoning goes in a `<|channel>thought…<channel|>` block before any call; NEVER put tool calls inside it.
|
||||
- Read each `<|tool_response>` block in call order. NEVER write a `<|tool_response>` block yourself.
|
||||
- After emitting your tool calls, YOU MUST STOP AND HALT.
|
||||
- Write each call in full, THEN stop and halt — NEVER announce a tool then stop (e.g. halting at "Let's run `cargo clippy`" with no `<|tool_call>` block emitted).
|
||||
|
||||
@@ -25,8 +25,8 @@ verbatim tool result
|
||||
|
||||
- The name after `<tool_call>` must match a listed function and sit on the same line.
|
||||
- Emit one `<arg_key>name</arg_key>` + `<arg_value>value</arg_value>` pair per argument; omit unset optional args.
|
||||
- String values are raw text (no quotes, no escaping); non-string values are valid JSON.
|
||||
- `<arg_value>` bodies are read by regex (delimiter matching), NOT a real XML parser: write string values as raw literal text and never HTML-escape them (emit `a & b`, not `a & b`; `<`/`>` stay literal); only the body's own `</arg_value>` closing tag is reserved. Non-string values are valid JSON.
|
||||
- Multiple calls are consecutive `<tool_call>…</tool_call>` blocks.
|
||||
- Private reasoning goes in `<think>…</think>`; NEVER put tool calls inside `<think>`.
|
||||
- Read each `<tool_response>` in call order. NEVER emit `<tool_response>` yourself.
|
||||
- After emitting your tool calls, YOU MUST EMIT THE STOP SEQUENCE AND HALT.
|
||||
- Emit the stop sequence ONLY after the call is fully written — NEVER announce a tool then stop (e.g. halting at "Let's run `cargo clippy`" with no `<tool_call>` emitted). Write the complete call, THEN the stop sequence, THEN halt.
|
||||
|
||||
@@ -22,9 +22,10 @@ Tool results arrive as messages authored by the function, addressed back to the
|
||||
|
||||
- Recipient is `functions.` + a listed function name.
|
||||
- Body is one JSON object matching the schema; omit optional arguments you are not setting.
|
||||
- Argument string values use only normal JSON string escaping (`\"`, `\\`, `\n`); never HTML-escape their contents — write `a & b`, not `a & b`.
|
||||
- Multiple calls = consecutive call messages.
|
||||
- An optional visible preamble is a `commentary` message ending `<|end|>`.
|
||||
- NEVER put tool calls in `analysis`.
|
||||
- NEVER wrap calls in Markdown/code fences.
|
||||
- Read each tool-result message in call order. NEVER emit tool-result messages yourself.
|
||||
- After emitting your tool calls, YOU MUST EMIT THE STOP SEQUENCE AND HALT.
|
||||
- Emit the stop sequence ONLY after the call is fully written — NEVER announce a tool then stop (e.g. halting at "Let's run `cargo clippy`" with no `<|call|>` message emitted). Write the complete call, THEN the stop sequence, THEN halt.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { parseJsonWithRepair } from "@oh-my-pi/pi-utils";
|
||||
import type { Message, ToolCall } from "../types";
|
||||
import { parseJsonWithRepair } from "../utils/json-parse";
|
||||
import { asRecord, mintToolCallId, partialSuffixOverlapAny } from "./coercion";
|
||||
import dialectPrompt from "./harmony.md" with { type: "text" };
|
||||
import {
|
||||
|
||||
@@ -19,6 +19,7 @@ verbatim tool result
|
||||
## Rules
|
||||
|
||||
- `name` MUST match a listed function; `arguments` is a JSON object, never a stringified JSON.
|
||||
- Argument string values use only normal JSON string escaping (`\"`, `\\`, `\n`); never HTML-escape their contents — write `a & b`, not `a & b`.
|
||||
- Emit multiple calls as consecutive `<tool_call>` blocks; keep any prose outside them.
|
||||
- Read each `<tool_response>` in call order. NEVER emit `<tool_response>` yourself.
|
||||
- After emitting your tool calls, YOU MUST EMIT THE STOP SEQUENCE AND HALT.
|
||||
- Emit the stop sequence ONLY after the call is fully written — NEVER announce a tool then stop (e.g. halting at "Let's run `cargo clippy`" with no `<tool_call>` emitted). Write the complete call, THEN the stop sequence, THEN halt.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { parseJsonWithRepair, parseStreamingJson } from "@oh-my-pi/pi-utils";
|
||||
import type { Message, ToolCall } from "../types";
|
||||
import { parseJsonWithRepair, parseStreamingJson } from "../utils/json-parse";
|
||||
import { asRecord, mintToolCallId, partialSuffixOverlapAny } from "./coercion";
|
||||
import dialectPrompt from "./hermes.md" with { type: "text" };
|
||||
import { renderChatMlTranscript, renderDelimitedThinking, renderToolResponseResults, stringifyJson } from "./rendering";
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export * from "./catalog";
|
||||
export * from "./coercion";
|
||||
export * from "./demotion";
|
||||
export * from "./examples";
|
||||
export * from "./factory";
|
||||
export * from "./history";
|
||||
@@ -10,4 +11,5 @@ export * from "./owned-stream";
|
||||
// consumer needs (the legacy markdown `/dump` reuses its `<thinking>` envelope
|
||||
// unwrap), so re-export only that symbol rather than `export *`-ing the rest.
|
||||
export { renderDelimitedThinking } from "./rendering";
|
||||
export * from "./thinking";
|
||||
export * from "./types";
|
||||
|
||||
@@ -17,7 +17,8 @@ verbatim tool result<|im_end|>
|
||||
|
||||
- `NAME` MUST match a listed function exactly.
|
||||
- Arguments MUST be one JSON object with double-quoted keys.
|
||||
- Argument string values use only normal JSON string escaping (`\"`, `\\`, `\n`); never HTML-escape their contents — write `a & b`, not `a & b`.
|
||||
- Multiple calls = consecutive `<|tool_call_begin|>…<|tool_call_end|>` blocks in the same section; `INDEX` increments from `0`.
|
||||
- Private reasoning, when supported, goes in `<think>…</think>` before the tool-call section; NEVER put tool calls inside `<think>`.
|
||||
- Read each result turn in call order. NEVER emit result turns yourself.
|
||||
- After emitting your tool calls, YOU MUST EMIT THE STOP SEQUENCE AND HALT.
|
||||
- Emit the stop sequence ONLY after the call is fully written — NEVER announce a tool then stop (e.g. halting at "Let's run `cargo clippy`" with no `<|tool_call_begin|>` emitted). Write the complete call, THEN the stop sequence, THEN halt.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { parseJsonWithRepair } from "@oh-my-pi/pi-utils";
|
||||
import type { Message, ToolCall } from "../types";
|
||||
import { parseJsonWithRepair } from "../utils/json-parse";
|
||||
import { asRecord, normalizeKimiFunctionName, partialSuffixOverlapAny } from "./coercion";
|
||||
import dialectPrompt from "./kimi.md" with { type: "text" };
|
||||
import { assistantTranscriptParts, collectToolResultRun, messageContentText, stringifyJson } from "./rendering";
|
||||
|
||||
@@ -22,10 +22,10 @@ Results arrive later in a `<function_results>` block, one `<result>` per call (f
|
||||
## Rules
|
||||
|
||||
- `name` MUST match a listed function.
|
||||
- String/scalar parameters: exact text, spaces preserved. Lists/objects: JSON.
|
||||
- String/scalar parameters: exact text, spaces preserved — bodies are read by regex (delimiter matching), NOT a real XML parser, so never HTML-escape them (emit `a & b`, not `a & b`; `<`/`>` stay literal); only the body's own `</parameter>` closing tag is reserved. Lists/objects: JSON.
|
||||
- Multiple calls: multiple `<invoke>` blocks in one `<minimax:tool_call>`.
|
||||
- You MAY write visible text before the calls.
|
||||
- NEVER emit `tool_calls` JSON.
|
||||
- NEVER use `<function_calls>` or the legacy `<tool_name>`/`<parameters>` call syntax.
|
||||
- Read each `<result>`/`<error>` in call order. NEVER emit `<function_results>` yourself.
|
||||
- After emitting your tool calls, YOU MUST EMIT THE STOP SEQUENCE AND HALT.
|
||||
- Emit the stop sequence ONLY after the call is fully written — NEVER announce a tool then stop (e.g. halting at "Let's run `cargo clippy`" with no `<invoke>` emitted). Write the complete call, THEN the stop sequence, THEN halt.
|
||||
|
||||
@@ -19,7 +19,6 @@ const RESPONSE_OPEN_TOKENS: Record<Dialect, readonly string[]> = {
|
||||
minimax: ["<function_results>", "<tool_response>"],
|
||||
deepseek: ["<|tool▁outputs▁begin|>", "<|tool▁output▁begin|>"],
|
||||
harmony: ["<|start|>functions."],
|
||||
pi: ["‡‡"],
|
||||
qwen3: ["<tool_response>"],
|
||||
gemini: ["```tool_outputs"],
|
||||
gemma: ["<|tool_response>"],
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
## Format guide
|
||||
|
||||
A tool call begins with `§` immediately followed by the function NAME (start each call on its own line). Scalar arguments follow on the same line as `key=value` pairs; a single large or multi-line string argument goes in a verbatim body fenced by `«…»` right after the header.
|
||||
|
||||
Scalar-only call (the line ends the call):
|
||||
|
||||
```text
|
||||
§read path=src/a.ts offset=50 limit=200
|
||||
```
|
||||
|
||||
Call with a verbatim body — everything between `«` and `»` is taken literally, no quoting or escaping:
|
||||
|
||||
```text
|
||||
§edit path=src/server/auth.ts«
|
||||
*** Begin Patch
|
||||
*** Update File: src/server/auth.ts
|
||||
@@ class AuthService
|
||||
- login(user) {
|
||||
+ async login(user, opts) {
|
||||
*** End Patch
|
||||
»
|
||||
```
|
||||
|
||||
Argument values:
|
||||
|
||||
- Strings are written bare and verbatim (`path=src/a.ts`). Quote with `"…"` only when the value contains spaces or starts with `"`, `[`, or `{` (`i="run the tests"`).
|
||||
- Numbers, booleans, and `null` are JSON literals (`offset=50`, `force=true`).
|
||||
- Arrays and objects are inline JSON (`paths=["src","test"]`).
|
||||
- The body fence holds the call's first long/multi-line string parameter; its key is implied, never written.
|
||||
|
||||
Private reasoning goes in a `¤…¤` block before your calls:
|
||||
|
||||
```text
|
||||
¤
|
||||
brief reasoning
|
||||
¤
|
||||
```
|
||||
|
||||
Tool results arrive in `‡‡…‡‡` blocks, read in call order:
|
||||
|
||||
```text
|
||||
‡‡
|
||||
verbatim tool result
|
||||
‡‡
|
||||
```
|
||||
|
||||
## Rules
|
||||
|
||||
- `NAME` MUST match a listed function; never wrap calls in JSON or fences.
|
||||
- Put each scalar argument once as `key=value`; reserve the `«…»` body for the one dominant string argument (file contents, patches, commands, queries).
|
||||
- Body text is verbatim — include no surrounding quotes. If the body itself contains `»`, widen BOTH guillemet fences equally (`««…»»`, `«««…»»»`).
|
||||
- Emit parallel calls as consecutive `§…` blocks. NEVER invent call ids; results are positional.
|
||||
- Private reasoning goes in a `¤…¤` block before your calls; NEVER put calls inside it, and keep a literal `¤` out of the reasoning text.
|
||||
- Read each `‡‡…‡‡` result in call order. NEVER emit a `‡‡` block yourself.
|
||||
- After emitting your tool calls, YOU MUST EMIT THE STOP SEQUENCE AND HALT.
|
||||
@@ -1,600 +0,0 @@
|
||||
import type { Message, ToolCall } from "../types";
|
||||
import type { ToolArgShape } from "./coercion";
|
||||
import { buildArgShapes, coerceValue, isStringOnlySchema, mintToolCallId, partialSuffixOverlapAny } from "./coercion";
|
||||
import dialectPrompt from "./pi.md" with { type: "text" };
|
||||
import { renderChatMlTranscript, stringifyJson } from "./rendering";
|
||||
import type {
|
||||
DialectDefinition,
|
||||
DialectRenderOptions,
|
||||
DialectToolResult,
|
||||
InbandScanEvent,
|
||||
InbandScanner,
|
||||
InbandScannerOptions,
|
||||
} from "./types";
|
||||
|
||||
// Pi — a sigil-delimited, token-frugal owned dialect.
|
||||
//
|
||||
// §read path=src/a.ts offset=50 ← scalar-only call (newline-terminated)
|
||||
// §edit path=src/a.ts« ← header + verbatim body fence
|
||||
// *** Begin Patch
|
||||
// ...
|
||||
// *** End Patch»
|
||||
//
|
||||
// Design goals vs the XML-ish `pi` dialect:
|
||||
// - one-token structural sigils (`§` call, `«»` body fence, `¤` thinking, `‡‡`
|
||||
// tool result — each a single o200k token that never occurs in source code)
|
||||
// instead of `<call:NAME>` / `</call:NAME>` (3 tokens + the repeated name);
|
||||
// - scalar arguments inline as `key=value` (the key appears once) rather than
|
||||
// `<key>value</key>` (key twice + four bracket tokens);
|
||||
// - the dominant string argument fills a verbatim body fence, dropping its key
|
||||
// entirely and needing no escaping for code/patches.
|
||||
//
|
||||
// Verbatim fences (body `«»`, result `‡‡`) escalate Markdown/raw-string style:
|
||||
// when the content contains the closer, the renderer widens the fence (`««…»»`,
|
||||
// `‡‡‡…‡‡‡`) so re-rendered history can never collide with payload content.
|
||||
|
||||
const CALL_SIGIL = "§";
|
||||
const FENCE_OPEN = "«";
|
||||
const FENCE_CLOSE = "»";
|
||||
const THINK_SIGIL = "¤";
|
||||
const RESULT_FENCE = "‡";
|
||||
const OUTSIDE_TAGS = [CALL_SIGIL, THINK_SIGIL] as const;
|
||||
const CALL_TAGS = [CALL_SIGIL] as const;
|
||||
const NAME_START = /[A-Za-z_]/;
|
||||
const NAME_CHAR = /[A-Za-z0-9_-]/;
|
||||
const EMPTY_STRING_ARGS: ReadonlySet<string> = new Set<string>();
|
||||
|
||||
type ScannerState = "outside" | "body" | "thinking";
|
||||
|
||||
type HeaderEnd =
|
||||
| { kind: "fence"; index: number }
|
||||
| { kind: "newline"; index: number }
|
||||
| { kind: "eof"; index: number }
|
||||
| { kind: "incomplete" };
|
||||
|
||||
export class PiNativeInbandScanner implements InbandScanner {
|
||||
#buffer = "";
|
||||
#state: ScannerState = "outside";
|
||||
#id = "";
|
||||
#name = "";
|
||||
#args: Record<string, unknown> = {};
|
||||
#bodyKey = "";
|
||||
#bodyValue = "";
|
||||
#bodyLeading = false;
|
||||
#closeMarker = "";
|
||||
#rawBlock = "";
|
||||
#thinking = "";
|
||||
readonly #argShapes: Map<string, ToolArgShape>;
|
||||
readonly #stringArgs: (toolName: string) => ReadonlySet<string>;
|
||||
readonly #knowsTools: boolean;
|
||||
readonly #parseThinking: boolean;
|
||||
|
||||
constructor(options: InbandScannerOptions = {}) {
|
||||
this.#argShapes = buildArgShapes(options.tools);
|
||||
this.#knowsTools = this.#argShapes.size > 0;
|
||||
this.#stringArgs =
|
||||
options.stringArgs ?? (toolName => this.#argShapes.get(toolName)?.stringArgs ?? EMPTY_STRING_ARGS);
|
||||
this.#parseThinking = options.parseThinking !== false;
|
||||
}
|
||||
|
||||
feed(text: string): InbandScanEvent[] {
|
||||
if (text.length === 0) return [];
|
||||
this.#buffer += text;
|
||||
return this.#consume(false);
|
||||
}
|
||||
|
||||
flush(): InbandScanEvent[] {
|
||||
return this.#consume(true);
|
||||
}
|
||||
|
||||
#consume(final: boolean): InbandScanEvent[] {
|
||||
const events: InbandScanEvent[] = [];
|
||||
while (this.#buffer.length > 0) {
|
||||
if (this.#state === "outside") {
|
||||
if (!this.#consumeOutside(events, final)) break;
|
||||
continue;
|
||||
}
|
||||
if (this.#state === "thinking") {
|
||||
if (!this.#consumeThinking(events, final)) break;
|
||||
continue;
|
||||
}
|
||||
if (!this.#consumeBody(events, final)) break;
|
||||
}
|
||||
if (final && this.#state === "thinking") this.#endThinking(events);
|
||||
return events;
|
||||
}
|
||||
|
||||
#consumeOutside(events: InbandScanEvent[], final: boolean): boolean {
|
||||
const call = this.#buffer.indexOf(CALL_SIGIL);
|
||||
const think = this.#parseThinking ? this.#buffer.indexOf(THINK_SIGIL) : -1;
|
||||
let start = call;
|
||||
let isThink = false;
|
||||
if (think !== -1 && (start === -1 || think < start)) {
|
||||
start = think;
|
||||
isThink = true;
|
||||
}
|
||||
if (start === -1) {
|
||||
const tags = this.#parseThinking ? OUTSIDE_TAGS : CALL_TAGS;
|
||||
const hold = final ? 0 : partialSuffixOverlapAny(this.#buffer, tags);
|
||||
const emit = this.#buffer.slice(0, this.#buffer.length - hold);
|
||||
if (emit.length > 0) events.push({ type: "text", text: emit });
|
||||
this.#buffer = this.#buffer.slice(this.#buffer.length - hold);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (start > 0) {
|
||||
events.push({ type: "text", text: this.#buffer.slice(0, start) });
|
||||
this.#buffer = this.#buffer.slice(start);
|
||||
}
|
||||
|
||||
if (isThink) {
|
||||
this.#buffer = this.#buffer.slice(THINK_SIGIL.length);
|
||||
this.#thinking = "";
|
||||
events.push({ type: "thinkingStart" });
|
||||
this.#state = "thinking";
|
||||
return true;
|
||||
}
|
||||
|
||||
return this.#beginCall(events, final);
|
||||
}
|
||||
|
||||
// Buffer starts with `§`. Resolve the tool name, then the header terminator.
|
||||
// Returns false to wait for more input (call still streaming in).
|
||||
#beginCall(events: InbandScanEvent[], final: boolean): boolean {
|
||||
const nameStart = CALL_SIGIL.length;
|
||||
if (nameStart >= this.#buffer.length && !final) return false; // just `§` so far
|
||||
let nameEnd = nameStart;
|
||||
if (!isNameStart(this.#buffer[nameEnd])) return this.#rejectSigil(events);
|
||||
nameEnd++;
|
||||
while (nameEnd < this.#buffer.length && isNameChar(this.#buffer[nameEnd])) nameEnd++;
|
||||
if (nameEnd >= this.#buffer.length && !final) return false; // name may continue
|
||||
|
||||
const name = this.#buffer.slice(CALL_SIGIL.length, nameEnd);
|
||||
// Guard against `§` in prose: only claim a known tool when schemas exist.
|
||||
if (this.#knowsTools && !this.#argShapes.has(name)) return this.#rejectSigil(events);
|
||||
|
||||
const header = findHeaderEnd(this.#buffer, nameEnd);
|
||||
if (header.kind === "fence") {
|
||||
let runEnd = header.index;
|
||||
while (runEnd < this.#buffer.length && this.#buffer[runEnd] === FENCE_OPEN) runEnd++;
|
||||
if (runEnd >= this.#buffer.length && !final) return false; // fence run may grow
|
||||
return this.#startCall(events, name, nameEnd, header.index, "fence", runEnd - header.index);
|
||||
}
|
||||
if (header.kind === "newline") {
|
||||
return this.#startCall(events, name, nameEnd, header.index, "newline", 0);
|
||||
}
|
||||
// "eof"/"incomplete": the header may still be streaming in — only a scalar
|
||||
// call with no trailing newline at true end-of-stream finalizes here.
|
||||
if (!final) return false;
|
||||
return this.#startCall(events, name, nameEnd, this.#buffer.length, "eof", 0);
|
||||
}
|
||||
|
||||
// `§` not followed by a known tool name is prose — surface it as literal text.
|
||||
#rejectSigil(events: InbandScanEvent[]): boolean {
|
||||
events.push({ type: "text", text: CALL_SIGIL });
|
||||
this.#buffer = this.#buffer.slice(CALL_SIGIL.length);
|
||||
return true;
|
||||
}
|
||||
|
||||
#startCall(
|
||||
events: InbandScanEvent[],
|
||||
name: string,
|
||||
argsStart: number,
|
||||
headerEnd: number,
|
||||
kind: "fence" | "newline" | "eof",
|
||||
fenceLen: number,
|
||||
): boolean {
|
||||
const shape = this.#argShapes.get(name);
|
||||
this.#id = mintToolCallId();
|
||||
this.#name = name;
|
||||
this.#args = parseHeaderArgs(this.#buffer.slice(argsStart, headerEnd), shape?.properties ?? {});
|
||||
events.push({ type: "toolStart", id: this.#id, name: this.#name });
|
||||
|
||||
if (kind === "fence") {
|
||||
const fenceEnd = headerEnd + fenceLen;
|
||||
this.#rawBlock = this.#buffer.slice(0, fenceEnd);
|
||||
this.#closeMarker = FENCE_CLOSE.repeat(fenceLen);
|
||||
this.#bodyKey = this.#inlineTargetKey() ?? "input";
|
||||
this.#bodyValue = "";
|
||||
this.#bodyLeading = true;
|
||||
this.#buffer = this.#buffer.slice(fenceEnd);
|
||||
this.#state = "body";
|
||||
return true;
|
||||
}
|
||||
|
||||
this.#rawBlock = this.#buffer.slice(0, headerEnd);
|
||||
events.push({
|
||||
type: "toolEnd",
|
||||
id: this.#id,
|
||||
name: this.#name,
|
||||
arguments: this.#args,
|
||||
rawBlock: this.#rawBlock,
|
||||
});
|
||||
let next = headerEnd;
|
||||
if (kind === "newline") {
|
||||
if (this.#buffer[next] === "\r") next++;
|
||||
if (this.#buffer[next] === "\n") next++;
|
||||
}
|
||||
this.#buffer = this.#buffer.slice(next);
|
||||
this.#reset();
|
||||
return true;
|
||||
}
|
||||
|
||||
#consumeBody(events: InbandScanEvent[], final: boolean): boolean {
|
||||
this.#stripBodyLeading(final);
|
||||
const close = this.#buffer.indexOf(this.#closeMarker);
|
||||
if (close === -1) {
|
||||
if (final) {
|
||||
this.#reset();
|
||||
this.#buffer = "";
|
||||
return false;
|
||||
}
|
||||
const overlap = partialSuffixOverlapAny(this.#buffer, [this.#closeMarker]);
|
||||
let hold = Math.max(this.#closeMarker.length, overlap);
|
||||
if (overlap > 0) {
|
||||
const beforeOverlap = this.#buffer.length - overlap - 1;
|
||||
if (this.#buffer[beforeOverlap] === "\n") {
|
||||
hold = Math.max(hold, overlap + 1);
|
||||
if (this.#buffer[beforeOverlap - 1] === "\r") hold = Math.max(hold, overlap + 2);
|
||||
}
|
||||
}
|
||||
const emitLength = this.#buffer.length - hold;
|
||||
if (emitLength > 0) {
|
||||
const delta = this.#buffer.slice(0, emitLength);
|
||||
this.#rawBlock += delta;
|
||||
this.#emitBodyDelta(delta, events);
|
||||
this.#buffer = this.#buffer.slice(emitLength);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const rawDelta = this.#buffer.slice(0, close);
|
||||
this.#rawBlock += rawDelta + this.#closeMarker;
|
||||
let delta = rawDelta;
|
||||
if (delta.endsWith("\r\n")) delta = delta.slice(0, -2);
|
||||
else if (delta.endsWith("\n")) delta = delta.slice(0, -1);
|
||||
this.#emitBodyDelta(delta, events);
|
||||
this.#args[this.#bodyKey] = this.#bodyValue;
|
||||
events.push({
|
||||
type: "toolEnd",
|
||||
id: this.#id,
|
||||
name: this.#name,
|
||||
arguments: this.#args,
|
||||
rawBlock: this.#rawBlock,
|
||||
});
|
||||
this.#buffer = this.#buffer.slice(close + this.#closeMarker.length);
|
||||
this.#reset();
|
||||
return true;
|
||||
}
|
||||
|
||||
#stripBodyLeading(final: boolean): void {
|
||||
if (!this.#bodyLeading) return;
|
||||
if (this.#buffer.length === 0) return;
|
||||
if (this.#buffer[0] === "\r") {
|
||||
if (this.#buffer.length === 1 && !final) return;
|
||||
if (this.#buffer[1] === "\n") {
|
||||
this.#rawBlock += this.#buffer.slice(0, 2);
|
||||
this.#buffer = this.#buffer.slice(2);
|
||||
}
|
||||
this.#bodyLeading = false;
|
||||
return;
|
||||
}
|
||||
if (this.#buffer[0] === "\n") {
|
||||
this.#rawBlock += this.#buffer[0];
|
||||
this.#buffer = this.#buffer.slice(1);
|
||||
}
|
||||
this.#bodyLeading = false;
|
||||
}
|
||||
|
||||
#emitBodyDelta(delta: string, events: InbandScanEvent[]): void {
|
||||
if (delta.length === 0) return;
|
||||
this.#bodyValue += delta;
|
||||
events.push({ type: "toolArgDelta", id: this.#id, name: this.#name, key: this.#bodyKey, delta });
|
||||
}
|
||||
|
||||
#consumeThinking(events: InbandScanEvent[], final: boolean): boolean {
|
||||
const close = this.#buffer.indexOf(THINK_SIGIL);
|
||||
if (close === -1) {
|
||||
const hold = final ? 0 : partialSuffixOverlapAny(this.#buffer, [THINK_SIGIL]);
|
||||
this.#emitThinking(this.#buffer.slice(0, this.#buffer.length - hold), events);
|
||||
this.#buffer = this.#buffer.slice(this.#buffer.length - hold);
|
||||
if (final) {
|
||||
this.#endThinking(events);
|
||||
this.#state = "outside";
|
||||
}
|
||||
return false;
|
||||
}
|
||||
this.#emitThinking(this.#buffer.slice(0, close), events);
|
||||
this.#buffer = this.#buffer.slice(close + THINK_SIGIL.length);
|
||||
this.#endThinking(events);
|
||||
this.#state = "outside";
|
||||
return true;
|
||||
}
|
||||
|
||||
#emitThinking(delta: string, events: InbandScanEvent[]): void {
|
||||
if (delta.length === 0) return;
|
||||
this.#thinking += delta;
|
||||
events.push({ type: "thinkingDelta", delta });
|
||||
}
|
||||
|
||||
#endThinking(events: InbandScanEvent[]): void {
|
||||
events.push({ type: "thinkingEnd", thinking: this.#thinking });
|
||||
this.#thinking = "";
|
||||
this.#state = "outside";
|
||||
}
|
||||
|
||||
#inlineTargetKey(): string | undefined {
|
||||
const shape = this.#argShapes.get(this.#name);
|
||||
if (shape) {
|
||||
for (const key of shape.parameterOrder) {
|
||||
if (Object.hasOwn(this.#args, key)) continue;
|
||||
return isStringOnlySchema(shape.properties[key]) ? key : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
for (const key of this.#stringArgs(this.#name)) {
|
||||
if (!Object.hasOwn(this.#args, key)) return key;
|
||||
}
|
||||
return "input";
|
||||
}
|
||||
|
||||
#reset(): void {
|
||||
this.#state = "outside";
|
||||
this.#id = "";
|
||||
this.#name = "";
|
||||
this.#args = {};
|
||||
this.#bodyKey = "";
|
||||
this.#bodyValue = "";
|
||||
this.#bodyLeading = false;
|
||||
this.#closeMarker = "";
|
||||
this.#rawBlock = "";
|
||||
}
|
||||
}
|
||||
|
||||
function parseHeaderArgs(text: string, properties: Record<string, unknown>): Record<string, unknown> {
|
||||
const args: Record<string, unknown> = {};
|
||||
let index = skipWhitespace(text, 0);
|
||||
while (index < text.length) {
|
||||
if (!isNameStart(text[index])) {
|
||||
index++;
|
||||
continue;
|
||||
}
|
||||
const nameStart = index;
|
||||
index++;
|
||||
while (index < text.length && isNameChar(text[index])) index++;
|
||||
const key = text.slice(nameStart, index);
|
||||
index = skipWhitespace(text, index);
|
||||
if (text[index] !== "=") {
|
||||
args[key] = true;
|
||||
continue;
|
||||
}
|
||||
index = skipWhitespace(text, index + 1);
|
||||
const parsed = readInlineValue(text, index, properties[key]);
|
||||
args[key] = parsed.value;
|
||||
index = skipWhitespace(text, parsed.next);
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
type InlineValue = { value: unknown; next: number };
|
||||
|
||||
function readInlineValue(text: string, start: number, schema: unknown): InlineValue {
|
||||
const ch = text[start];
|
||||
if (ch === '"') {
|
||||
let index = start + 1;
|
||||
while (index < text.length) {
|
||||
const c = text[index];
|
||||
if (c === "\\") {
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
if (c === '"') {
|
||||
index++;
|
||||
break;
|
||||
}
|
||||
index++;
|
||||
}
|
||||
const raw = text.slice(start, index);
|
||||
try {
|
||||
return { value: JSON.parse(raw) as unknown, next: index };
|
||||
} catch {
|
||||
return { value: raw.slice(1, raw.endsWith('"') ? -1 : undefined), next: index };
|
||||
}
|
||||
}
|
||||
if (ch === "[" || ch === "{") {
|
||||
const end = matchBracket(text, start);
|
||||
const raw = text.slice(start, end);
|
||||
try {
|
||||
return { value: JSON.parse(raw) as unknown, next: end };
|
||||
} catch {
|
||||
return { value: raw, next: end };
|
||||
}
|
||||
}
|
||||
let index = start;
|
||||
while (index < text.length && !isWhitespace(text[index])) index++;
|
||||
return { value: coerceValue(text.slice(start, index), schema), next: index };
|
||||
}
|
||||
|
||||
function matchBracket(text: string, start: number): number {
|
||||
let depth = 0;
|
||||
let inString = false;
|
||||
for (let index = start; index < text.length; index++) {
|
||||
const ch = text[index];
|
||||
if (inString) {
|
||||
if (ch === "\\") {
|
||||
index++;
|
||||
continue;
|
||||
}
|
||||
if (ch === '"') inString = false;
|
||||
continue;
|
||||
}
|
||||
if (ch === '"') {
|
||||
inString = true;
|
||||
continue;
|
||||
}
|
||||
if (ch === "[" || ch === "{") depth++;
|
||||
else if (ch === "]" || ch === "}") {
|
||||
depth--;
|
||||
if (depth === 0) return index + 1;
|
||||
}
|
||||
}
|
||||
return text.length;
|
||||
}
|
||||
|
||||
// Locate where a call header ends: the first top-level body fence, the first
|
||||
// literal newline (scalar-only call), end-of-input, or "incomplete" when a
|
||||
// quoted/bracketed value is still mid-stream.
|
||||
function findHeaderEnd(text: string, start: number): HeaderEnd {
|
||||
let inString = false;
|
||||
let depth = 0;
|
||||
for (let index = start; index < text.length; index++) {
|
||||
const ch = text[index];
|
||||
if (inString) {
|
||||
if (ch === "\\") {
|
||||
index++;
|
||||
continue;
|
||||
}
|
||||
if (ch === '"') inString = false;
|
||||
continue;
|
||||
}
|
||||
if (ch === '"') {
|
||||
inString = true;
|
||||
continue;
|
||||
}
|
||||
if (ch === "[" || ch === "{") {
|
||||
depth++;
|
||||
continue;
|
||||
}
|
||||
if (ch === "]" || ch === "}") {
|
||||
if (depth > 0) depth--;
|
||||
continue;
|
||||
}
|
||||
if (depth > 0) continue;
|
||||
if (ch === FENCE_OPEN) return { kind: "fence", index };
|
||||
if (ch === "\n" || ch === "\r") return { kind: "newline", index };
|
||||
}
|
||||
if (inString || depth > 0) return { kind: "incomplete" };
|
||||
return { kind: "eof", index: text.length };
|
||||
}
|
||||
|
||||
function skipWhitespace(text: string, index: number): number {
|
||||
while (index < text.length && isWhitespace(text[index])) index++;
|
||||
return index;
|
||||
}
|
||||
|
||||
function isWhitespace(ch: string | undefined): boolean {
|
||||
return ch === " " || ch === "\n" || ch === "\r" || ch === "\t" || ch === "\f";
|
||||
}
|
||||
|
||||
function isNameStart(ch: string | undefined): boolean {
|
||||
return ch !== undefined && NAME_START.test(ch);
|
||||
}
|
||||
|
||||
function isNameChar(ch: string | undefined): boolean {
|
||||
return ch !== undefined && NAME_CHAR.test(ch);
|
||||
}
|
||||
|
||||
function renderToolCall(call: ToolCall, options: DialectRenderOptions = {}): string {
|
||||
return renderInvocation(call, buildArgShapes(options.tools).get(call.name));
|
||||
}
|
||||
|
||||
function renderAssistantToolCalls(calls: readonly ToolCall[], options: DialectRenderOptions = {}): string {
|
||||
const shapes = buildArgShapes(options.tools);
|
||||
return calls.map(call => renderInvocation(call, shapes.get(call.name))).join("\n");
|
||||
}
|
||||
|
||||
function renderInvocation(call: ToolCall, shape: ToolArgShape | undefined): string {
|
||||
const properties = shape?.properties ?? {};
|
||||
const bodyKey = selectBodyKey(call.arguments, shape);
|
||||
let header = `${CALL_SIGIL}${call.name}`;
|
||||
for (const key in call.arguments) {
|
||||
if (key === bodyKey) continue;
|
||||
header += ` ${key}=${renderInlineValue(call.arguments[key], properties[key])}`;
|
||||
}
|
||||
if (bodyKey === undefined) return header;
|
||||
const body = String(call.arguments[bodyKey]);
|
||||
const fence = 1 + maxRun(body, FENCE_CLOSE);
|
||||
return `${header}${FENCE_OPEN.repeat(fence)}\n${body}\n${FENCE_CLOSE.repeat(fence)}`;
|
||||
}
|
||||
|
||||
// The body holds a single dominant string argument: the first string-only
|
||||
// parameter whose value contains a newline. Single-line strings stay inline
|
||||
// (quoted when needed) so the verbatim fence is reserved for genuine blocks.
|
||||
function selectBodyKey(args: Record<string, unknown>, shape: ToolArgShape | undefined): string | undefined {
|
||||
// Round-trip requires renderer and scanner to agree on the omitted body key.
|
||||
// The scanner assigns the body to the first string-only parameter missing from
|
||||
// the header, so a body is only safe when no earlier parameter is also absent
|
||||
// (no schema → keep everything inline).
|
||||
if (!shape) return undefined;
|
||||
for (const key of shape.parameterOrder) {
|
||||
if (!Object.hasOwn(args, key)) return undefined;
|
||||
const value = args[key];
|
||||
if (typeof value === "string" && value.includes("\n") && isStringOnlySchema(shape.properties[key])) return key;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function renderInlineValue(value: unknown, schema: unknown): string {
|
||||
if (typeof value === "string") {
|
||||
return needsQuote(value) ? JSON.stringify(value) : value;
|
||||
}
|
||||
if (isStringOnlySchema(schema) && value === null) return '""';
|
||||
return stringifyJson(value);
|
||||
}
|
||||
|
||||
function needsQuote(value: string): boolean {
|
||||
if (value.length === 0) return true;
|
||||
const first = value[0];
|
||||
if (first === '"' || first === "[" || first === "{") return true;
|
||||
return /[\s«»]/.test(value);
|
||||
}
|
||||
|
||||
function maxRun(text: string, ch: string): number {
|
||||
let best = 0;
|
||||
let run = 0;
|
||||
for (let index = 0; index < text.length; index++) {
|
||||
if (text[index] === ch) {
|
||||
run++;
|
||||
if (run > best) best = run;
|
||||
} else {
|
||||
run = 0;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function renderToolResults(results: readonly DialectToolResult[], _options?: DialectRenderOptions): string {
|
||||
return results
|
||||
.map(result => {
|
||||
const fence = RESULT_FENCE.repeat(Math.max(2, 1 + maxRun(result.text, RESULT_FENCE)));
|
||||
return `${fence}\n${result.text}\n${fence}`;
|
||||
})
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function renderThinking(text: string): string {
|
||||
if (!text) return "";
|
||||
return `${THINK_SIGIL}\n${text}\n${THINK_SIGIL}`;
|
||||
}
|
||||
|
||||
function renderTranscript(messages: readonly Message[], options: DialectRenderOptions = {}): string {
|
||||
return renderChatMlTranscript(messages, options, {
|
||||
toolResultRole: "tool",
|
||||
renderThinking,
|
||||
renderCalls: renderAssistantToolCalls,
|
||||
renderResultsBody: renderToolResults,
|
||||
});
|
||||
}
|
||||
|
||||
const definition: DialectDefinition = {
|
||||
dialect: "pi",
|
||||
prompt: dialectPrompt,
|
||||
createScanner: options => new PiNativeInbandScanner(options),
|
||||
renderToolCall,
|
||||
renderAssistantToolCalls,
|
||||
renderToolResults,
|
||||
renderThinking,
|
||||
renderTranscript,
|
||||
};
|
||||
|
||||
export default definition;
|
||||
@@ -21,7 +21,8 @@ verbatim tool result
|
||||
## Rules
|
||||
|
||||
- `name` MUST match a listed function; `arguments` is a JSON object, never a JSON string.
|
||||
- Argument string values use only normal JSON string escaping (`\"`, `\\`, `\n`); never HTML-escape their contents — write `a & b`, not `a & b`.
|
||||
- Multiple calls = consecutive `<tool_call>...</tool_call>` blocks; keep prose outside them.
|
||||
- NEVER put tool calls inside `<think>`.
|
||||
- Read each `<tool_response>` in call order. NEVER emit `<tool_response>` yourself.
|
||||
- After emitting your tool calls, YOU MUST EMIT THE STOP SEQUENCE AND HALT.
|
||||
- Emit the stop sequence ONLY after the call is fully written — NEVER announce a tool then stop (e.g. halting at "Let's run `cargo clippy`" with no `<tool_call>` emitted). Write the complete call, THEN the stop sequence, THEN halt.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { parseJsonWithRepair } from "@oh-my-pi/pi-utils";
|
||||
import type { Message, ToolCall } from "../types";
|
||||
import { parseJsonWithRepair } from "../utils/json-parse";
|
||||
import { asRecord, mintToolCallId, partialSuffixOverlapAny } from "./coercion";
|
||||
import dialectPrompt from "./qwen3.md" with { type: "text" };
|
||||
import { renderChatMlTranscript, renderToolResponseResults, stringifyJson } from "./rendering";
|
||||
|
||||
@@ -1,18 +1,30 @@
|
||||
import { partialSuffixOverlapAny } from "./coercion";
|
||||
import type { InbandScanEvent, InbandScanner } from "./types";
|
||||
|
||||
const THINK_OPEN = "<think>";
|
||||
const THINK_CLOSE = "</think>";
|
||||
const THINKING_OPEN = "<thinking>";
|
||||
const THINKING_CLOSE = "</thinking>";
|
||||
const TAGS = [
|
||||
{ open: THINK_OPEN, close: THINK_CLOSE },
|
||||
{ open: THINKING_OPEN, close: THINKING_CLOSE },
|
||||
] as const;
|
||||
const OPENS = [THINK_OPEN, THINKING_OPEN] as const;
|
||||
|
||||
type Tag = { readonly open: string; readonly close: string };
|
||||
|
||||
/**
|
||||
* Every dialect's in-band thinking section in its canonical `renderThinking`
|
||||
* form (see the sibling `./*.ts` scanners). {@link ThinkingInbandScanner} heals
|
||||
* reasoning a model leaked into its visible text channel back into thinking
|
||||
* events, whichever dialect idiom the leak used.
|
||||
*
|
||||
* Plain (attribute-free) delimiters only — matching what `renderThinking`
|
||||
* emits and what models leak in practice. Attributed or namespaced XML thinking
|
||||
* tags (`<thinking signature="…">`, `antml:thinking`) are recovered by the owned
|
||||
* anthropic-dialect parser, not this text-channel healing fallback.
|
||||
*/
|
||||
const TAGS: readonly Tag[] = [
|
||||
{ open: "<think>", close: "</think>" }, // deepseek, glm, hermes, kimi, qwen3 (and anthropic/minimax/xml)
|
||||
{ open: "<thinking>", close: "</thinking>" }, // anthropic, minimax, xml
|
||||
{ open: "<scratchpad>", close: "</scratchpad>" }, // anthropic
|
||||
{ open: "```thinking\n", close: "```" }, // gemini fenced thinking
|
||||
{ open: "<|channel>thought\n", close: "<channel|>" }, // gemma reasoning channel
|
||||
{ open: "<|start|>assistant<|channel|>analysis<|message|>", close: "<|end|>" }, // harmony analysis (rendered)
|
||||
{ open: "<|channel|>analysis<|message|>", close: "<|end|>" }, // harmony analysis (bare leak)
|
||||
];
|
||||
const OPENS = TAGS.map(tag => tag.open);
|
||||
|
||||
export class ThinkingInbandScanner implements InbandScanner {
|
||||
#buffer = "";
|
||||
#closeTag = "";
|
||||
|
||||
@@ -17,6 +17,6 @@ verbatim tool result
|
||||
## Rules
|
||||
|
||||
- `name` MUST match a listed function.
|
||||
- String values are literal text (no JSON quotes or escaping); non-string values are JSON. Add `string="false"` to a parameter only to force JSON parsing of a value the schema treats as a string.
|
||||
- Parameter values are read literally by regex (delimiter matching), NOT a real XML parser: write them verbatim and never HTML-escape (emit `a & b`, never `a & b`; `<`/`>` stay literal too). Only the body's own `</parameter>` closing tag is reserved. Non-string values are JSON; add `string="false"` to a parameter only to force JSON parsing of a value the schema treats as a string.
|
||||
- Read each `<tool_response>` in call order. NEVER emit `<tool_response>` yourself.
|
||||
- After emitting your tool calls, YOU MUST EMIT THE STOP SEQUENCE AND HALT.
|
||||
- Emit the stop sequence ONLY after the call is fully written — NEVER announce a tool then stop (e.g. halting at "Let's run `cargo clippy`" with no `<invoke>` emitted). Write the complete call, THEN the stop sequence, THEN halt.
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { attach, create, Flag } from "./flags";
|
||||
|
||||
/**
|
||||
* A request was cancelled — by the caller's `AbortSignal` or a provider-local
|
||||
* watchdog. Carries the {@link Flag.Abort} classification structurally so retry
|
||||
* logic does not have to regex the message text.
|
||||
*
|
||||
* The default message is kept byte-identical to the historical
|
||||
* `"Request was aborted"` string so any remaining text-based matchers keep
|
||||
* working through the migration.
|
||||
*/
|
||||
export class AbortError extends Error {
|
||||
constructor(message = "Request was aborted", options?: { cause?: unknown }) {
|
||||
super(message, options?.cause === undefined ? undefined : { cause: options.cause });
|
||||
this.name = "AbortError";
|
||||
attach(this, create(Flag.Abort));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { extractHttpStatusFromError } from "@oh-my-pi/pi-utils";
|
||||
import { isOAuthExpiry } from "./flags";
|
||||
import { isUsageLimitOutcome } from "./rate-limit";
|
||||
|
||||
/**
|
||||
* Whether an OAuth refresh failure is definitive (the credential must be
|
||||
* disabled) versus transient. Thin alias over the {@link Flag.OAuthExpiry}
|
||||
* text classifier {@link isOAuthExpiry}; retained as the public
|
||||
* `@oh-my-pi/pi-ai` entrypoint name used by the coding agent and auth-broker.
|
||||
*/
|
||||
export function isDefinitiveOAuthFailure(errorMsg: string): boolean {
|
||||
return isOAuthExpiry(errorMsg);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an upstream failure should rotate to a sibling credential: a hard
|
||||
* `401`, a body-classified usage limit (Codex `usage_limit_reached`, Anthropic
|
||||
* account rate-limit, Google `resource_exhausted`, OpenAI `insufficient_quota`,
|
||||
* …), or a bare `429` whose payload did not preserve a richer quota code.
|
||||
* Transient 429s (`Too many requests`, per-minute caps) stay in the
|
||||
* upstream-backoff lane.
|
||||
*/
|
||||
export function isAuthRetryableError(error: unknown): boolean {
|
||||
const httpStatus = extractHttpStatusFromError(error);
|
||||
if (httpStatus === 401) return true;
|
||||
const message = error instanceof Error ? error.message : typeof error === "string" ? error : undefined;
|
||||
const embeddedStatus = message ? extractHttpStatusFromError({ message }) : undefined;
|
||||
if (embeddedStatus === 401) return true;
|
||||
return isUsageLimitOutcome(httpStatus ?? embeddedStatus, message);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { attach, create, Flag } from "./flags";
|
||||
|
||||
/**
|
||||
* No API key / credential was available to dispatch a request.
|
||||
*
|
||||
* The default message preserves the historical `"No API key for provider: X"`
|
||||
* wording, which {@link Flag.AuthFailed}'s regex (`no api key`) keys off — but
|
||||
* the flag is also attached structurally so classification never depends on the
|
||||
* exact phrasing.
|
||||
*/
|
||||
export class MissingApiKeyError extends Error {
|
||||
readonly provider: string | undefined;
|
||||
|
||||
constructor(provider?: string, message?: string) {
|
||||
super(message ?? (provider ? `No API key for provider: ${provider}` : "No API key available"));
|
||||
this.name = "MissingApiKeyError";
|
||||
this.provider = provider;
|
||||
attach(this, create(Flag.AuthFailed));
|
||||
}
|
||||
}
|
||||
|
||||
/** A user-facing login flow required an `onPrompt` callback that was not supplied. */
|
||||
export class OnPromptRequiredError extends Error {
|
||||
constructor(providerLabel: string) {
|
||||
super(`${providerLabel} login requires onPrompt callback`);
|
||||
this.name = "OnPromptRequiredError";
|
||||
}
|
||||
}
|
||||
|
||||
/** An interactive login asked for an API key but the user supplied an empty value. */
|
||||
export class ApiKeyRequiredError extends Error {
|
||||
constructor(message = "API key is required") {
|
||||
super(message);
|
||||
this.name = "ApiKeyRequiredError";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A user cancelled an interactive login / device flow. Classified as an abort
|
||||
* so it is never surfaced as a retryable transient failure.
|
||||
*/
|
||||
export class LoginCancelledError extends Error {
|
||||
constructor(message = "Login cancelled") {
|
||||
super(message);
|
||||
this.name = "LoginCancelledError";
|
||||
attach(this, create(Flag.Abort));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/** Which AWS credential-resolution path failed. */
|
||||
export type AwsCredentialsErrorKind =
|
||||
/** No usable credential source resolved (chain exhausted). */
|
||||
| "resolution"
|
||||
/** SSO cache token missing (`aws sso login` not run). */
|
||||
| "sso-token-missing"
|
||||
/** SSO cache token present but expired. */
|
||||
| "sso-token-expired"
|
||||
/** SSO `GetRoleCredentials` call failed or returned no role. */
|
||||
| "sso-role"
|
||||
/** External `credential_process` failed, timed out, or emitted bad output. */
|
||||
| "credential-process";
|
||||
|
||||
/** A failure resolving AWS credentials for the Bedrock provider. */
|
||||
export class AwsCredentialsError extends Error {
|
||||
readonly kind: AwsCredentialsErrorKind;
|
||||
|
||||
constructor(message: string, kind: AwsCredentialsErrorKind, options?: { cause?: unknown }) {
|
||||
super(message, options?.cause === undefined ? undefined : { cause: options.cause });
|
||||
this.name = "AwsCredentialsError";
|
||||
this.kind = kind;
|
||||
}
|
||||
}
|
||||
|
||||
/** A malformed AWS event-stream frame (bad length, CRC mismatch, unknown header type). */
|
||||
export class EventStreamFrameError extends Error {
|
||||
constructor(detail: string) {
|
||||
super(`eventstream: ${detail}`);
|
||||
this.name = "EventStreamFrameError";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import type { CapturedHttpErrorResponse } from "../utils/http-inspector";
|
||||
|
||||
/** Prefix on errors raised when an Anthropic SSE stream envelope is malformed. */
|
||||
export const STREAM_ENVELOPE_ERROR_PREFIX = "Anthropic stream envelope error:";
|
||||
|
||||
/** Structured HTTP errors thrown by provider clients. */
|
||||
export interface ProviderHttpErrorOptions {
|
||||
/** Response headers; enables `retry-after`/rate-limit extraction downstream. */
|
||||
headers?: Headers;
|
||||
/** Machine-readable error code from the response body (`error.code` / `error.type`). */
|
||||
code?: string;
|
||||
cause?: unknown;
|
||||
}
|
||||
|
||||
/** Non-2xx HTTP response from a provider. */
|
||||
export class ProviderHttpError extends Error {
|
||||
readonly status: number;
|
||||
readonly headers: Headers | undefined;
|
||||
readonly code: string | undefined;
|
||||
|
||||
constructor(message: string, status: number, options?: ProviderHttpErrorOptions) {
|
||||
super(message, options?.cause === undefined ? undefined : { cause: options.cause });
|
||||
this.name = "ProviderHttpError";
|
||||
this.status = status;
|
||||
this.headers = options?.headers;
|
||||
this.code = options?.code;
|
||||
}
|
||||
}
|
||||
|
||||
/** Non-2xx response from an OpenAI-wire endpoint, with the decoded body attached. */
|
||||
export class OpenAIHttpError extends ProviderHttpError {
|
||||
readonly captured: CapturedHttpErrorResponse;
|
||||
|
||||
constructor(message: string, captured: CapturedHttpErrorResponse, code?: string, cause?: unknown) {
|
||||
super(message, captured.status, { headers: captured.headers, code, cause });
|
||||
this.name = "OpenAIHttpError";
|
||||
this.captured = captured;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull a human-readable message and machine code out of an OpenAI-style error
|
||||
* envelope (`{ error: { message, code, type } }`), tolerating the flat shapes
|
||||
* compat hosts return (`{ error: "..." }`, `{ message: "..." }`) and falling
|
||||
* back to the raw body text.
|
||||
*/
|
||||
static parseEnvelope(
|
||||
bodyJson: unknown,
|
||||
bodyText: string | undefined,
|
||||
): { detail: string | undefined; code: string | undefined } {
|
||||
if (typeof bodyJson === "object" && bodyJson !== null) {
|
||||
const envelope = bodyJson as { error?: unknown; message?: unknown };
|
||||
const error = envelope.error;
|
||||
if (typeof error === "object" && error !== null) {
|
||||
const { message, code, type } = error as { message?: unknown; code?: unknown; type?: unknown };
|
||||
return {
|
||||
detail: typeof message === "string" && message.length > 0 ? message : bodyText,
|
||||
code: typeof code === "string" ? code : typeof type === "string" ? type : undefined,
|
||||
};
|
||||
}
|
||||
if (typeof error === "string" && error.length > 0) {
|
||||
return { detail: error, code: undefined };
|
||||
}
|
||||
if (typeof envelope.message === "string" && envelope.message.length > 0) {
|
||||
return { detail: envelope.message, code: undefined };
|
||||
}
|
||||
}
|
||||
return { detail: bodyText, code: undefined };
|
||||
}
|
||||
}
|
||||
|
||||
/** Non-2xx response from the Anthropic API. */
|
||||
export class AnthropicApiError extends ProviderHttpError {
|
||||
declare readonly headers: Headers;
|
||||
readonly requestId: string | null;
|
||||
|
||||
constructor(status: number, message: string, headers: Headers) {
|
||||
super(message, status, { headers });
|
||||
this.name = "AnthropicApiError";
|
||||
this.requestId = headers.get("request-id");
|
||||
}
|
||||
|
||||
static async fromResponse(response: Response): Promise<AnthropicApiError> {
|
||||
const body = await response.text().catch(() => "");
|
||||
const detail = body.trim() || "status code (no body)";
|
||||
return new AnthropicApiError(response.status, `${response.status} ${detail}`, response.headers);
|
||||
}
|
||||
}
|
||||
|
||||
/** Network-level failure (DNS, TLS, socket reset) after retries were exhausted. */
|
||||
export class AnthropicConnectionError extends Error {
|
||||
constructor(cause: unknown) {
|
||||
super("Connection error.", { cause });
|
||||
this.name = "AnthropicConnectionError";
|
||||
}
|
||||
}
|
||||
|
||||
/** No response headers arrived within the configured request timeout. */
|
||||
export class AnthropicConnectionTimeoutError extends Error {
|
||||
constructor() {
|
||||
super("Request timed out.");
|
||||
this.name = "AnthropicConnectionTimeoutError";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A malformed Anthropic SSE stream envelope — events arriving out of order
|
||||
* (before `message_start`) or otherwise violating the message-event grammar.
|
||||
* The message is prefixed with {@link STREAM_ENVELOPE_ERROR_PREFIX} so the
|
||||
* shared envelope predicates classify it.
|
||||
*/
|
||||
export class AnthropicStreamEnvelopeError extends Error {
|
||||
constructor(detail: string) {
|
||||
super(`${STREAM_ENVELOPE_ERROR_PREFIX} ${detail}`);
|
||||
this.name = "AnthropicStreamEnvelopeError";
|
||||
}
|
||||
}
|
||||
|
||||
/** Non-2xx response (or in-stream exception event) from the Bedrock runtime API. */
|
||||
export class BedrockApiError extends ProviderHttpError {
|
||||
override readonly name = "BedrockApiError";
|
||||
}
|
||||
|
||||
/** Non-2xx response (or in-stream error chunk) from the Cloud Code Assist API. */
|
||||
export class GeminiCliApiError extends ProviderHttpError {
|
||||
override readonly name = "GeminiCliApiError";
|
||||
}
|
||||
|
||||
/** Non-2xx response (or in-stream error chunk) from the Google Generative Language / Vertex API. */
|
||||
export class GoogleApiError extends ProviderHttpError {
|
||||
override readonly name = "GoogleApiError";
|
||||
}
|
||||
|
||||
/** Non-2xx response from the Ollama `/api/chat` endpoint. */
|
||||
export class OllamaApiError extends ProviderHttpError {
|
||||
override readonly name = "OllamaApiError";
|
||||
}
|
||||
|
||||
/** Auth gateway HTTP failure. */
|
||||
export class AuthGatewayError extends ProviderHttpError {
|
||||
constructor(message: string, status: number, headers?: Headers, code?: string) {
|
||||
super(message, status, { headers, code });
|
||||
this.name = "AuthGatewayError";
|
||||
}
|
||||
}
|
||||
|
||||
export class CodexWebSocketTransportError extends Error {
|
||||
constructor(detail: string) {
|
||||
super(`Codex websocket transport failure: ${detail}`);
|
||||
this.name = "CodexWebSocketTransportError";
|
||||
}
|
||||
}
|
||||
|
||||
export class CodexWhitespaceToolCallLoopError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "CodexWhitespaceToolCallLoopError";
|
||||
}
|
||||
}
|
||||
|
||||
export class CodexProviderStreamError extends Error {
|
||||
readonly retryable: boolean;
|
||||
|
||||
constructor(message: string, options?: { retryable?: boolean; cause?: unknown }) {
|
||||
super(message, { cause: options?.cause });
|
||||
this.name = "CodexProviderStreamError";
|
||||
this.retryable = options?.retryable !== false;
|
||||
}
|
||||
}
|
||||
|
||||
export class AuthBrokerError extends Error {
|
||||
readonly status: number | undefined;
|
||||
readonly body: string | undefined;
|
||||
constructor(message: string, opts: { status?: number; body?: string; cause?: unknown } = {}) {
|
||||
super(message, { cause: opts.cause });
|
||||
this.name = "AuthBrokerError";
|
||||
this.status = opts.status;
|
||||
this.body = opts.body;
|
||||
}
|
||||
}
|
||||
|
||||
export class AuthBrokerStreamUnsupportedError extends AuthBrokerError {
|
||||
constructor(message = "Auth broker does not support /v1/snapshot/stream") {
|
||||
super(message, { status: 404 });
|
||||
this.name = "AuthBrokerStreamUnsupportedError";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { Api } from "../types";
|
||||
import type { AbortSourceTracker } from "../utils/abort";
|
||||
import type { CapturedHttpErrorResponse, RawHttpRequestDump } from "../utils/http-inspector";
|
||||
import { classify, classifyMessage, status } from "./flags";
|
||||
import { formatMessage } from "./format";
|
||||
|
||||
/** Context a provider catch block hands to {@link finalize}. */
|
||||
export interface FinalizeOptions {
|
||||
/** Wire API, for api-specific text classification (e.g. stale-responses items). */
|
||||
api?: Api;
|
||||
/** Provider id; forwarded to the message formatter for copilot rewrites. */
|
||||
provider?: string;
|
||||
/** Caller signal, for providers that don't run an abort tracker. */
|
||||
signal?: AbortSignal;
|
||||
/** Abort tracker, preferred over `signal`: distinguishes caller vs. local aborts. */
|
||||
abortTracker?: AbortSourceTracker;
|
||||
/** Raw request, dumped into the message for 400-class failures. */
|
||||
rawRequestDump?: RawHttpRequestDump;
|
||||
/** Captured non-2xx response body, used for status fallback and message detail. */
|
||||
capturedErrorResponse?: CapturedHttpErrorResponse;
|
||||
}
|
||||
|
||||
/** The full bundle a provider assigns onto its `AssistantMessage` error fields. */
|
||||
export interface FinalizeResult {
|
||||
/** Structured flag id from {@link classify}. */
|
||||
id: number;
|
||||
/** HTTP status, from the error or the captured response. */
|
||||
status: number | undefined;
|
||||
/** `"aborted"` when the caller cancelled, otherwise `"error"`. */
|
||||
stopReason: "aborted" | "error";
|
||||
/** User-facing message from {@link formatMessage}, or a local abort reason. */
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the complete error bundle for a provider catch block, replacing the
|
||||
* `stopReason` / `errorStatus` / `errorId` / `errorMessage` boilerplate.
|
||||
*
|
||||
* `stopReason` comes from the abort tracker (caller intent dominates) or, when
|
||||
* no tracker is supplied, the raw `signal.aborted`. A local abort reason (e.g. a
|
||||
* first-event timeout) supersedes the formatted message. Message formatting is
|
||||
* wrapped so a formatter throw can never skip the caller's `stream.end()`.
|
||||
*/
|
||||
export async function finalize(error: unknown, opts: FinalizeOptions = {}): Promise<FinalizeResult> {
|
||||
const aborted = opts.abortTracker ? opts.abortTracker.wasCallerAbort() : opts.signal?.aborted === true;
|
||||
const currentStatus = status(error) ?? opts.capturedErrorResponse?.status;
|
||||
|
||||
let message: string;
|
||||
try {
|
||||
const localReason = opts.abortTracker?.getLocalAbortReason();
|
||||
message = localReason?.message ?? (await formatMessage(error, opts));
|
||||
} catch {
|
||||
message = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
const id = classifyMessage({
|
||||
api: opts.api,
|
||||
errorId: classify(error, opts.api),
|
||||
errorMessage: message,
|
||||
errorStatus: currentStatus,
|
||||
});
|
||||
|
||||
return {
|
||||
id,
|
||||
status: currentStatus,
|
||||
stopReason: aborted ? "aborted" : "error",
|
||||
message,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,486 @@
|
||||
import { isUnexpectedSocketCloseMessage } from "@oh-my-pi/pi-utils";
|
||||
import type { Api, AssistantMessage } from "../types";
|
||||
import {
|
||||
AnthropicConnectionError,
|
||||
AnthropicConnectionTimeoutError,
|
||||
ProviderHttpError,
|
||||
STREAM_ENVELOPE_ERROR_PREFIX,
|
||||
} from "./classes";
|
||||
import { isOpaqueStatusBody, matchesUsageLimitText, parseRateLimitReason } from "./rate-limit";
|
||||
|
||||
export const Flag = {
|
||||
Class: 0x1000,
|
||||
ThinkingLoop: 0x0001_0000,
|
||||
Transient: 0x0002_0000,
|
||||
Timeout: 0x0004_0000,
|
||||
UsageLimit: 0x0008_0000,
|
||||
StaleResponsesItem: 0x0010_0000,
|
||||
MalformedFunctionCall: 0x0020_0000,
|
||||
ProviderFinishError: 0x0040_0000,
|
||||
ContextOverflow: 0x0080_0000,
|
||||
AuthFailed: 0x0100_0000,
|
||||
SilentAbort: 0x0200_0000,
|
||||
UserInterrupt: 0x0400_0000,
|
||||
Abort: 0x0800_0000,
|
||||
/** Anthropic strict-tool grammar too large / schema too complex to compile (400). */
|
||||
Grammar: 0x1000_0000,
|
||||
/** Anthropic model/account does not support fast mode / the `speed` parameter. */
|
||||
FastModeUnsupported: 0x2000_0000,
|
||||
/** OAuth refresh failed definitively — the stored grant is dead, re-login required. */
|
||||
OAuthExpiry: 0x4000_0000,
|
||||
} as const;
|
||||
|
||||
export type Flag = (typeof Flag)[keyof typeof Flag];
|
||||
|
||||
const KIND_MASK =
|
||||
Flag.ThinkingLoop |
|
||||
Flag.Transient |
|
||||
Flag.Timeout |
|
||||
Flag.UsageLimit |
|
||||
Flag.StaleResponsesItem |
|
||||
Flag.MalformedFunctionCall |
|
||||
Flag.ProviderFinishError |
|
||||
Flag.ContextOverflow |
|
||||
Flag.AuthFailed |
|
||||
Flag.SilentAbort |
|
||||
Flag.UserInterrupt |
|
||||
Flag.Abort |
|
||||
Flag.Grammar |
|
||||
Flag.FastModeUnsupported |
|
||||
Flag.OAuthExpiry;
|
||||
|
||||
const RETRIABLE_KINDS =
|
||||
Flag.Transient | Flag.UsageLimit | Flag.ThinkingLoop | Flag.StaleResponsesItem | Flag.ProviderFinishError;
|
||||
|
||||
const OVERFLOW_PATTERNS = [
|
||||
/prompt is too long/i, // Anthropic
|
||||
/input is too long for requested model/i, // Amazon Bedrock
|
||||
/exceeds the context window/i, // OpenAI (Completions & Responses API)
|
||||
/input token count.*exceeds the maximum/i, // Google (Gemini)
|
||||
/maximum prompt length is \d+/i, // xAI (Grok)
|
||||
/reduce the length of the messages/i, // Groq
|
||||
/maximum context length is \d+ tokens/i, // OpenRouter (all backends)
|
||||
/exceeds the limit of \d+/i, // GitHub Copilot
|
||||
/exceeds the available context size/i, // llama.cpp server
|
||||
/requested tokens?.*exceed.*context (window|length|size)/i, // llama.cpp / OpenAI-compatible local servers
|
||||
/context (window|length|size).*(exceeded|overflow|too small)/i, // Generic local server variants
|
||||
/(prompt|input).*(too long|too large).*(context|n_ctx)/i, // llama.cpp phrasing variants
|
||||
/requested tokens?.*(exceeds?|greater than).*(n_ctx|context)/i, // llama.cpp n_ctx variants
|
||||
/greater than the context length/i, // LM Studio
|
||||
/context window exceeds limit/i, // MiniMax
|
||||
/exceeded model token limit/i, // Kimi For Coding
|
||||
/context[_ ]length[_ ]exceeded/i, // Generic fallback
|
||||
/too many tokens/i, // Generic fallback
|
||||
/token limit exceeded/i, // Generic fallback
|
||||
/request_too_large/i, // Anthropic 413 (request body too large)
|
||||
/request exceeds the maximum size/i, // Anthropic 413 variant
|
||||
/payload too large/i, // Generic HTTP 413 variant
|
||||
/entity too large/i, // Generic HTTP 413 variant
|
||||
/\b413\b.*\b(request|payload|entity)\b.*\btoo large\b/i, // "413 Request Entity Too Large" variants
|
||||
/model_context_window_exceeded/i, // z.ai non-standard finish_reason surfaced as error text
|
||||
/prompt filled the context window/i, // Ollama OpenAI-compatible empty length completion
|
||||
];
|
||||
|
||||
const OVERFLOW_NO_BODY_PATTERN = /\b4(00|13)\s*(status code)?\s*\(no body\)/i;
|
||||
const TIMEOUT_PATTERN = /\b(?:operation\s+)?timed?\s*out\b|\btimeout\b|\bstream stall\b/i;
|
||||
const TRANSIENT_ENVELOPE_PATTERN = /anthropic stream envelope error:/i;
|
||||
const TRANSIENT_ENVELOPE_BEFORE_START_PATTERN = /before message_start/i;
|
||||
export const TRANSIENT_TRANSPORT_PATTERN =
|
||||
/overloaded|provider.?returned.?error|rate.?limit|too many requests|429|500|502|503|504|service.?unavailable|server.?error|internal.?error|retry your request|network.?error|connection.?error|connection.?refused|other side closed|fetch failed|upstream.?connect|upstream.?request.?failed|reset before headers|socket hang up|timed? out|timeout|terminated|retry delay|stream stall|no error details in response|HTTP2(?:StreamReset|RefusedStream|EnhanceYourCalm)|malformed.?function.?call/i;
|
||||
const AUTH_FAILURE_PATTERN =
|
||||
/\b(?:401|403|unauthorized|forbidden|authentication|auth[_ ]?unavailable|no auth available|(?:invalid|no)[_ ]?api[_ ]?key)\b/i;
|
||||
const MALFORMED_FUNCTION_CALL_PATTERN = /\bmalformed.?function.?call\b/i;
|
||||
const PROVIDER_FINISH_ERROR_PATTERN = /\bProvider (?:returned error finish_reason|finish_reason:\s*error)\b/i;
|
||||
const STALE_RESPONSE_ITEM_PATTERNS = [/\bItem with id ['"][^'"]+['"] not found\.?/i, /previous[ _]?response/i] as const;
|
||||
const STALE_RESPONSE_ITEM_DETAIL_PATTERN = /not[ _]?found|invalid|expired|stale|zero[ _-]?data[ _-]?retention/i;
|
||||
|
||||
// Copilot routing flap: HTTP 400 `model_not_supported` (structural code on the
|
||||
// error, also surfaced in text). Treated as transient — a retry usually lands
|
||||
// on a backend that has the model.
|
||||
const COPILOT_MODEL_NOT_SUPPORTED_PATTERN = /model_not_supported/i;
|
||||
// Anthropic strict-tool grammar too large / schema too complex (400 invalid_request_error).
|
||||
const GRAMMAR_TOO_LARGE_PATTERN = /compiled grammar/i;
|
||||
const GRAMMAR_TOO_LARGE_DETAIL_PATTERN = /too large/i;
|
||||
const SCHEMA_TOO_COMPLEX_PATTERN = /schema/i;
|
||||
const SCHEMA_TOO_COMPLEX_DETAIL_PATTERN = /too complex/i;
|
||||
const SCHEMA_COMPILE_PATTERN = /compil/i;
|
||||
const INVALID_REQUEST_PATTERN = /invalid_request_error/i;
|
||||
// Anthropic fast-mode unsupported: 400 rejecting `speed`, or 429 rate_limit_error
|
||||
// because the account lacks the extra-usage entitlement fast mode requires.
|
||||
const FAST_MODE_SPEED_PARAM_PATTERN = /\bspeed\b/i;
|
||||
const FAST_MODE_NOT_SUPPORTED_PATTERN = /not support/i;
|
||||
const FAST_MODE_RATE_LIMIT_PATTERN = /rate_limit_error/i;
|
||||
const FAST_MODE_ENTITLEMENT_PATTERN = /fast mode/i;
|
||||
// Definitive OAuth refresh failure — the stored grant/client is dead.
|
||||
const OAUTH_DEFINITIVE_FAILURE_PATTERN =
|
||||
/invalid_grant|invalid_token|unauthorized_client|\brevoked\b|refresh[\s_]?token.*expired/i;
|
||||
const OAUTH_TRANSIENT_FAILURE_PATTERN =
|
||||
/timeout|network|fetch failed|ECONN(?:REFUSED|RESET)|ETIMEDOUT|EAI_AGAIN|socket hang up|\b(?:408|425|429|5\d{2})\b|rate.?limit|too many requests|temporar|unavailable|forbidden|permission_denied|cloudflare|captcha/i;
|
||||
const OAUTH_HTTP_AUTH_PATTERN = /\b401\b/;
|
||||
|
||||
function matchesGrammarTooLarge(message: string, errorStatus: number | undefined): boolean {
|
||||
if (errorStatus !== 400) return false;
|
||||
if (!INVALID_REQUEST_PATTERN.test(message)) return false;
|
||||
const grammarTooLarge = GRAMMAR_TOO_LARGE_PATTERN.test(message) && GRAMMAR_TOO_LARGE_DETAIL_PATTERN.test(message);
|
||||
const schemaTooComplex =
|
||||
SCHEMA_TOO_COMPLEX_PATTERN.test(message) &&
|
||||
SCHEMA_TOO_COMPLEX_DETAIL_PATTERN.test(message) &&
|
||||
SCHEMA_COMPILE_PATTERN.test(message);
|
||||
return grammarTooLarge || schemaTooComplex;
|
||||
}
|
||||
|
||||
function matchesFastModeUnsupported(message: string, errorStatus: number | undefined): boolean {
|
||||
if (errorStatus !== 400 && errorStatus !== 429) return false;
|
||||
if (
|
||||
errorStatus === 400 &&
|
||||
INVALID_REQUEST_PATTERN.test(message) &&
|
||||
FAST_MODE_SPEED_PARAM_PATTERN.test(message) &&
|
||||
FAST_MODE_NOT_SUPPORTED_PATTERN.test(message)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
errorStatus === 429 && FAST_MODE_RATE_LIMIT_PATTERN.test(message) && FAST_MODE_ENTITLEMENT_PATTERN.test(message)
|
||||
);
|
||||
}
|
||||
|
||||
/** Whether an OAuth refresh error message means the grant is definitively dead. */
|
||||
export function isOAuthExpiry(errorMessage: string): boolean {
|
||||
if (OAUTH_DEFINITIVE_FAILURE_PATTERN.test(errorMessage)) return true;
|
||||
return OAUTH_HTTP_AUTH_PATTERN.test(errorMessage) && !OAUTH_TRANSIENT_FAILURE_PATTERN.test(errorMessage);
|
||||
}
|
||||
|
||||
const ERROR_KIND_LABELS: readonly [Flag, string][] = [
|
||||
[Flag.ThinkingLoop, "thinking-loop"],
|
||||
[Flag.Transient, "transient"],
|
||||
[Flag.Timeout, "timeout"],
|
||||
[Flag.UsageLimit, "usage-limit"],
|
||||
[Flag.StaleResponsesItem, "stale-responses-item"],
|
||||
[Flag.MalformedFunctionCall, "malformed-function-call"],
|
||||
[Flag.ProviderFinishError, "provider-finish-error"],
|
||||
[Flag.ContextOverflow, "context-overflow"],
|
||||
[Flag.AuthFailed, "auth-failed"],
|
||||
[Flag.SilentAbort, "silent-abort"],
|
||||
[Flag.UserInterrupt, "user-interrupt"],
|
||||
[Flag.Abort, "abort"],
|
||||
];
|
||||
|
||||
const STATUS_MESSAGE_PATTERNS = [
|
||||
/\bstatus(?:_code)?[:=]\s*(\d{3})\b/i,
|
||||
/\bstatus\s+(\d{3})\b/i,
|
||||
/\bHTTP\s+(\d{3})\b/i,
|
||||
/\b(?:error|failed)\s*[:=]?\s*(\d{3})\b/i,
|
||||
/(?:^|\s)(\d{3})\s+(?:[A-Z][a-z]+(?:\s+[A-Z][a-z]+)*)/,
|
||||
] as const;
|
||||
|
||||
export function create(...flags: number[]): number {
|
||||
let bits = 0;
|
||||
for (const f of flags) bits |= f;
|
||||
return bits | Flag.Class;
|
||||
}
|
||||
|
||||
export function is(id: number | undefined, flag: Flag): boolean {
|
||||
return ((id ?? 0) & flag) !== 0;
|
||||
}
|
||||
|
||||
export function retriable(id: number | undefined, opts?: { replayUnsafe?: boolean }): boolean {
|
||||
if (is(id, Flag.MalformedFunctionCall)) return true;
|
||||
if (opts?.replayUnsafe) return false;
|
||||
return ((id ?? 0) & RETRIABLE_KINDS) !== 0;
|
||||
}
|
||||
|
||||
function isClassified(id: number | undefined): boolean {
|
||||
return ((id ?? 0) & Flag.Class) !== 0;
|
||||
}
|
||||
|
||||
function statusFromId(id: number | undefined): number | undefined {
|
||||
return id && !isClassified(id) ? id : undefined;
|
||||
}
|
||||
|
||||
export function status(error: unknown): number | undefined {
|
||||
return statusInternal(error, 0);
|
||||
}
|
||||
|
||||
function statusInternal(error: unknown, depth: number): number | undefined {
|
||||
if (depth > 2 || error === undefined || error === null) return undefined;
|
||||
if (typeof error === "object") {
|
||||
const errObj = error as Record<string, unknown>;
|
||||
|
||||
if (typeof errObj.status === "number" && errObj.status >= 100 && errObj.status <= 599) {
|
||||
return errObj.status;
|
||||
}
|
||||
if (typeof errObj.statusCode === "number" && errObj.statusCode >= 100 && errObj.statusCode <= 599) {
|
||||
return errObj.statusCode;
|
||||
}
|
||||
if (typeof errObj.response === "object" && errObj.response !== null) {
|
||||
const resp = errObj.response as Record<string, unknown>;
|
||||
if (typeof resp.status === "number" && resp.status >= 100 && resp.status <= 599) {
|
||||
return resp.status;
|
||||
}
|
||||
}
|
||||
|
||||
if ("cause" in errObj) {
|
||||
const nested = statusInternal(errObj.cause, depth + 1);
|
||||
if (nested !== undefined) return nested;
|
||||
}
|
||||
}
|
||||
|
||||
if (error instanceof Error || (typeof error === "object" && error !== null && "message" in error)) {
|
||||
const message = (error as { message: string }).message;
|
||||
if (typeof message === "string") {
|
||||
for (const pattern of STATUS_MESSAGE_PATTERNS) {
|
||||
const match = pattern.exec(message);
|
||||
if (match) {
|
||||
const code = parseInt(match[1], 10);
|
||||
if (code >= 100 && code <= 599) return code;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function isTransientErrorText(text: string): boolean {
|
||||
return (
|
||||
isUnexpectedSocketCloseMessage(text) ||
|
||||
(TRANSIENT_ENVELOPE_PATTERN.test(text) && TRANSIENT_ENVELOPE_BEFORE_START_PATTERN.test(text)) ||
|
||||
TRANSIENT_TRANSPORT_PATTERN.test(text)
|
||||
);
|
||||
}
|
||||
|
||||
function isTimeoutText(text: string): boolean {
|
||||
return TIMEOUT_PATTERN.test(text);
|
||||
}
|
||||
|
||||
function isAuthFailureText(text: string): boolean {
|
||||
return AUTH_FAILURE_PATTERN.test(text);
|
||||
}
|
||||
|
||||
function isStaleResponsesText(text: string): boolean {
|
||||
return (
|
||||
STALE_RESPONSE_ITEM_PATTERNS[0].test(text) ||
|
||||
(STALE_RESPONSE_ITEM_PATTERNS[1].test(text) && STALE_RESPONSE_ITEM_DETAIL_PATTERN.test(text))
|
||||
);
|
||||
}
|
||||
|
||||
function isMalformedFunctionCallText(text: string): boolean {
|
||||
return MALFORMED_FUNCTION_CALL_PATTERN.test(text);
|
||||
}
|
||||
|
||||
function isProviderFinishErrorText(text: string): boolean {
|
||||
return PROVIDER_FINISH_ERROR_PATTERN.test(text);
|
||||
}
|
||||
|
||||
function matchesOverflowText(text: string): boolean {
|
||||
return OVERFLOW_PATTERNS.some(p => p.test(text)) || OVERFLOW_NO_BODY_PATTERN.test(text);
|
||||
}
|
||||
|
||||
function classifyText(errorMessage: string | undefined, errorStatus: number | undefined, api?: Api): number {
|
||||
let kinds = 0;
|
||||
if (errorMessage) {
|
||||
if (matchesOverflowText(errorMessage)) kinds |= Flag.ContextOverflow;
|
||||
if (isMalformedFunctionCallText(errorMessage)) kinds |= Flag.MalformedFunctionCall;
|
||||
if (isProviderFinishErrorText(errorMessage)) kinds |= Flag.ProviderFinishError;
|
||||
if (isAuthFailureText(errorMessage)) kinds |= Flag.AuthFailed;
|
||||
|
||||
const statusClean = errorStatus ? errorStatus : (status({ message: errorMessage }) ?? undefined);
|
||||
const cleanMessage = errorMessage;
|
||||
const isOpaque = isOpaqueStatusBody(cleanMessage);
|
||||
|
||||
const isLimitStatus = statusClean === 429;
|
||||
if (
|
||||
matchesUsageLimitText(cleanMessage) ||
|
||||
(isLimitStatus && (isOpaque || parseRateLimitReason(cleanMessage) === "QUOTA_EXHAUSTED"))
|
||||
) {
|
||||
kinds |= Flag.UsageLimit;
|
||||
}
|
||||
|
||||
if (isTimeoutText(errorMessage)) kinds |= Flag.Transient | Flag.Timeout;
|
||||
else if (isTransientErrorText(errorMessage)) kinds |= Flag.Transient;
|
||||
if ((api === "openai-responses" || api === "openai-codex-responses") && isStaleResponsesText(errorMessage)) {
|
||||
kinds |= Flag.StaleResponsesItem;
|
||||
}
|
||||
|
||||
// Copilot per-client routing flap is transient.
|
||||
if (statusClean === 400 && COPILOT_MODEL_NOT_SUPPORTED_PATTERN.test(cleanMessage)) kinds |= Flag.Transient;
|
||||
if (matchesGrammarTooLarge(cleanMessage, statusClean)) kinds |= Flag.Grammar;
|
||||
if (matchesFastModeUnsupported(cleanMessage, statusClean)) kinds |= Flag.FastModeUnsupported;
|
||||
}
|
||||
if (kinds !== 0) return create(kinds);
|
||||
const fallbackStatus = errorStatus ?? (errorMessage ? status({ message: errorMessage }) : undefined);
|
||||
if (fallbackStatus === 401 || fallbackStatus === 403) return create(Flag.AuthFailed);
|
||||
return fallbackStatus ?? 0;
|
||||
}
|
||||
|
||||
export function classify(error: unknown, api?: Api): number {
|
||||
let kinds = 0;
|
||||
const seen = new Set<object>();
|
||||
let link: unknown = error;
|
||||
while (link !== undefined && link !== null) {
|
||||
if (typeof link === "object") {
|
||||
if (seen.has(link)) break;
|
||||
seen.add(link);
|
||||
|
||||
if ("errorId" in link && typeof (link as { errorId: unknown }).errorId === "number") {
|
||||
kinds |= (link as { errorId: number }).errorId & KIND_MASK;
|
||||
}
|
||||
}
|
||||
|
||||
if (link instanceof AnthropicConnectionTimeoutError) {
|
||||
kinds |= Flag.Timeout | Flag.Transient;
|
||||
} else if (link instanceof AnthropicConnectionError) {
|
||||
kinds |= Flag.Transient;
|
||||
} else if (
|
||||
typeof link === "object" &&
|
||||
"name" in link &&
|
||||
(link as { name: string }).name === "CodexWebSocketTransportError"
|
||||
) {
|
||||
kinds |= Flag.Transient;
|
||||
} else if (
|
||||
link instanceof Error &&
|
||||
link.name === "CodexProviderStreamError" &&
|
||||
"retryable" in link &&
|
||||
(link as { retryable: unknown }).retryable === true
|
||||
) {
|
||||
kinds |= Flag.Transient;
|
||||
} else if (link instanceof ProviderHttpError) {
|
||||
let linkKinds = 0;
|
||||
const { status: codeStatus, code } = link;
|
||||
if (code === "usage_limit_reached" || code === "insufficient_quota") {
|
||||
linkKinds |= Flag.UsageLimit;
|
||||
}
|
||||
if (code === "overloaded_error" || code === "rate_limit_error") {
|
||||
linkKinds |= Flag.Transient;
|
||||
}
|
||||
if (codeStatus === 401 || codeStatus === 403) {
|
||||
linkKinds |= Flag.AuthFailed;
|
||||
} else if (codeStatus === 429) {
|
||||
if ((linkKinds & Flag.UsageLimit) === 0) {
|
||||
linkKinds |= Flag.Transient;
|
||||
}
|
||||
} else if (codeStatus >= 500) {
|
||||
linkKinds |= Flag.Transient;
|
||||
}
|
||||
kinds |= linkKinds;
|
||||
}
|
||||
|
||||
let linkMessage: string | undefined;
|
||||
if (link instanceof Error) {
|
||||
linkMessage = link.message;
|
||||
} else if (typeof link === "string") {
|
||||
linkMessage = link;
|
||||
} else if (
|
||||
typeof link === "object" &&
|
||||
"message" in link &&
|
||||
typeof (link as { message: unknown }).message === "string"
|
||||
) {
|
||||
linkMessage = (link as { message: string }).message;
|
||||
}
|
||||
|
||||
const textId = classifyText(linkMessage, status(link), api);
|
||||
kinds |= textId & KIND_MASK;
|
||||
|
||||
link = typeof link === "object" && "cause" in link ? (link as { cause: unknown }).cause : undefined;
|
||||
}
|
||||
|
||||
return kinds !== 0 ? create(kinds) : (status(error) ?? 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an error (or message string) classifies as an account usage/quota
|
||||
* limit — the persistent, credential-rotation-worthy kind. This is the public
|
||||
* accessor for {@link Flag.UsageLimit}; prefer it over re-running message
|
||||
* regexes at call sites.
|
||||
*/
|
||||
export function isUsageLimit(error: unknown, api?: Api): boolean {
|
||||
return is(classify(error, api), Flag.UsageLimit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Anthropic strict-tool grammar too large / schema too complex to compile.
|
||||
* Accessor for {@link Flag.Grammar}.
|
||||
*/
|
||||
export function isGrammarError(error: unknown): boolean {
|
||||
return is(classify(error), Flag.Grammar);
|
||||
}
|
||||
|
||||
/**
|
||||
* Anthropic model/account does not support fast mode / the `speed` parameter.
|
||||
* Accessor for {@link Flag.FastModeUnsupported}.
|
||||
*/
|
||||
export function isFastModeUnsupported(error: unknown): boolean {
|
||||
return is(classify(error), Flag.FastModeUnsupported);
|
||||
}
|
||||
|
||||
/**
|
||||
* GitHub Copilot 400 `model_not_supported` routing flap — transient. Reads the
|
||||
* structural `code` (and falls back to {@link Flag.Transient} text classification).
|
||||
*/
|
||||
export function isCopilotTransientModelError(error: unknown): boolean {
|
||||
if (status(error) === 400 && error && typeof error === "object") {
|
||||
const info = error as { code?: unknown; error?: { code?: unknown } | null };
|
||||
const code = typeof info.code === "string" ? info.code : info.error?.code;
|
||||
if (code === "model_not_supported") return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function classifyMessage(message: {
|
||||
api?: Api;
|
||||
errorId?: number;
|
||||
errorMessage?: string;
|
||||
errorStatus?: number;
|
||||
}): number {
|
||||
const existingId = message.errorId;
|
||||
const currentStatus = message.errorStatus ?? statusFromId(existingId);
|
||||
const textId = classifyText(message.errorMessage, currentStatus, message.api);
|
||||
|
||||
const kinds = ((existingId ?? 0) | textId) & KIND_MASK;
|
||||
const id = kinds !== 0 ? create(kinds) : (statusFromId(textId) ?? statusFromId(existingId) ?? currentStatus ?? 0);
|
||||
|
||||
message.errorId = id;
|
||||
return id;
|
||||
}
|
||||
|
||||
export function attach<E extends object>(error: E, id: number): E {
|
||||
Object.defineProperty(error, "errorId", { value: id, enumerable: false, configurable: true });
|
||||
return error;
|
||||
}
|
||||
|
||||
export function isContextOverflow(message: AssistantMessage, contextWindow?: number): boolean {
|
||||
if (is(message.errorId, Flag.ContextOverflow)) return true;
|
||||
if (contextWindow) {
|
||||
const inputTokens = message.usage.input + message.usage.cacheRead + message.usage.cacheWrite;
|
||||
if (inputTokens > contextWindow) return true;
|
||||
}
|
||||
return message.stopReason === "error" && !!message.errorMessage && matchesOverflowText(message.errorMessage);
|
||||
}
|
||||
|
||||
export function stringify(id: number | undefined): string {
|
||||
if (!id) return "none";
|
||||
if (!isClassified(id)) return `status:${id}`;
|
||||
const labels = ERROR_KIND_LABELS.filter(([kind]) => is(id, kind)).map(([, label]) => label);
|
||||
return labels.length > 0 ? labels.join("|") : `classified:0x${id.toString(16)}`;
|
||||
}
|
||||
|
||||
const STREAM_PARSE_TRUNCATION_PATTERN =
|
||||
/unterminated string|unexpected end of json input|unexpected end of data|unexpected eof|end of file|eof while parsing|truncated/i;
|
||||
const STREAM_EVENT_ORDER_PATTERN = /stream event order|before message_start/i;
|
||||
|
||||
/** Transient stream corruption where the response was truncated mid-JSON. */
|
||||
export function isTransientStreamParseError(error: unknown): boolean {
|
||||
return error instanceof Error && STREAM_PARSE_TRUNCATION_PATTERN.test(error.message);
|
||||
}
|
||||
|
||||
/** Any malformed stream-envelope error (prefix-tagged or out-of-order events). */
|
||||
export function isStreamEnvelopeError(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof Error &&
|
||||
(error.message.includes(STREAM_ENVELOPE_ERROR_PREFIX) || STREAM_EVENT_ORDER_PATTERN.test(error.message))
|
||||
);
|
||||
}
|
||||
|
||||
/** Stream-envelope errors safe to retry against the provider (event ordering only). */
|
||||
export function isRetryableStreamEnvelopeError(error: unknown): boolean {
|
||||
return error instanceof Error && STREAM_EVENT_ORDER_PATTERN.test(error.message);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import {
|
||||
type CapturedHttpErrorResponse,
|
||||
finalizeErrorMessage,
|
||||
type RawHttpRequestDump,
|
||||
rewriteCopilotError,
|
||||
} from "../utils/http-inspector";
|
||||
import { formatErrorMessageWithRetryAfter } from "../utils/retry-after";
|
||||
|
||||
/** Inputs that steer {@link formatMessage}'s formatter selection. */
|
||||
export interface FormatMessageOptions {
|
||||
/** When present, the raw request is dumped into the message for 400-class failures. */
|
||||
rawRequestDump?: RawHttpRequestDump;
|
||||
/** Captured non-2xx response body, appended to the message when available. */
|
||||
capturedErrorResponse?: CapturedHttpErrorResponse;
|
||||
/** Provider id; `"github-copilot"` triggers the copilot message rewrite. */
|
||||
provider?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a provider error into a user-facing message, unifying the three
|
||||
* formatters: lightweight retry-after extraction, the raw-dump finalizer, and
|
||||
* the copilot rewrite.
|
||||
*
|
||||
* Selection is driven by inputs, not a mode flag: a `rawRequestDump` routes
|
||||
* through {@link finalizeErrorMessage} (retry-after + raw dump + captured body),
|
||||
* otherwise the lightweight {@link formatErrorMessageWithRetryAfter} is used.
|
||||
*/
|
||||
export async function formatMessage(error: unknown, opts: FormatMessageOptions = {}): Promise<string> {
|
||||
let message = opts.rawRequestDump
|
||||
? await finalizeErrorMessage(error, opts.rawRequestDump, opts.capturedErrorResponse)
|
||||
: formatErrorMessageWithRetryAfter(error);
|
||||
if (opts.provider === "github-copilot") {
|
||||
message = rewriteCopilotError(message, error, opts.provider);
|
||||
}
|
||||
return message;
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { isUsageLimit } from "./flags";
|
||||
|
||||
/** A gateway-facing classification of an arbitrary upstream/internal error. */
|
||||
export interface GatewayErrorClassification {
|
||||
status: number;
|
||||
type: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify an upstream / gateway-internal error into a status code and a
|
||||
* format-neutral type. The order is intentional:
|
||||
*
|
||||
* 1. Honour an explicit numeric `status` property on the thrown error.
|
||||
* 2. Parse a status code embedded in the message string. Provider errors
|
||||
* virtually always carry one (`Google API error (400): …`, `HTTP 429`,
|
||||
* `status=503`) and the embedded value is authoritative.
|
||||
* 3. Fall through to **word-boundaried** substring heuristics. The old
|
||||
* `lower.includes("rate")` test famously matched `GenerateContentRequest`,
|
||||
* surfacing every Google 400 as a 429 `rate_limit_error`. The patterns here
|
||||
* all require boundaries so they don't collide with provider field names.
|
||||
*/
|
||||
export function classifyGatewayError(err: unknown): GatewayErrorClassification {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
|
||||
// 1. Custom pi-ai errors may attach a numeric `status` property.
|
||||
const statusProp =
|
||||
typeof err === "object" && err !== null && typeof (err as { status?: unknown }).status === "number"
|
||||
? (err as { status: number }).status | 0
|
||||
: undefined;
|
||||
if (statusProp !== undefined) return bucketStatus(statusProp, message);
|
||||
|
||||
if (err instanceof Error && err.name === "AbortError") return { status: 499, type: "request_aborted", message };
|
||||
|
||||
// 2. Status code embedded in the message. Requires a contextual keyword
|
||||
// (`HTTP`, `API error`, `status`, …) or a leading `(NNN)` token so we
|
||||
// don't trip on incidental three-digit numbers ("took 200ms").
|
||||
const embedded = extractEmbeddedStatus(message);
|
||||
if (embedded !== undefined) return bucketStatus(embedded, message);
|
||||
|
||||
// 3. Word-boundaried substring heuristics.
|
||||
if (/\baborted\b|\babort signal\b/i.test(message)) {
|
||||
return { status: 499, type: "request_aborted", message };
|
||||
}
|
||||
if (
|
||||
// Match rate-limit phrasings before auth wording: some providers
|
||||
// describe throttling as "unauthorized due to rate limit".
|
||||
// Keep boundaries so this does not collide with
|
||||
// `GenerateContentRequest`, `accelerate`, `iterate`, `deprecated`, etc.
|
||||
/\brate[- _]?limit(?:s|ed|ing)?\b|\bquota(?:_exceeded| exceeded)?\b|\btoo[- _]many[- _]requests\b/i.test(
|
||||
message,
|
||||
) ||
|
||||
// Usage-limit phrasings emit no embedded status. Codex friendly text
|
||||
// reads "You have hit your ChatGPT usage limit … Try again in ~158
|
||||
// min."; the central usage-limit classifier already encodes every known
|
||||
// provider variant, so reuse it instead of forking the regex. Without
|
||||
// this branch the classifier falls through to the default
|
||||
// 502/upstream_error, which is what callers saw when their account
|
||||
// hit its cap.
|
||||
isUsageLimit(message)
|
||||
) {
|
||||
return { status: 429, type: "rate_limit_error", message };
|
||||
}
|
||||
if (/\b(?:unauthorized|forbidden)\b/i.test(message)) {
|
||||
return { status: 401, type: "authentication_error", message };
|
||||
}
|
||||
if (/\b(?:unsupported|invalid_request|invalid request|bad request|malformed)\b/i.test(message)) {
|
||||
return { status: 400, type: "invalid_request_error", message };
|
||||
}
|
||||
return { status: 502, type: "upstream_error", message };
|
||||
}
|
||||
|
||||
function bucketStatus(status: number, message: string): GatewayErrorClassification {
|
||||
if (status === 401 || status === 403) return { status, type: "authentication_error", message };
|
||||
if (status === 429) return { status, type: "rate_limit_error", message };
|
||||
if (status >= 400 && status < 500) return { status, type: "invalid_request_error", message };
|
||||
if (status >= 500) return { status, type: "upstream_error", message };
|
||||
return { status: 502, type: "upstream_error", message };
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull a status code from common error-message shapes. Returns undefined when
|
||||
* no contextual keyword is present, so we never guess at incidental numbers.
|
||||
*/
|
||||
function extractEmbeddedStatus(message: string): number | undefined {
|
||||
// `Google API error (400)`, `OpenAI API error (429): …`, `(503)`
|
||||
// `HTTP 429: too many requests`
|
||||
// `status: 503`, `status_code=429`, `status=400`
|
||||
const re = /(?:\bHTTP\b|\bAPI error\b|\bstatus(?:[- _]?code)?\b)\s*[:=]?\s*\(?\s*(\d{3})\b|\((\d{3})\)/i;
|
||||
const m = message.match(re);
|
||||
if (!m) return undefined;
|
||||
const raw = m[1] ?? m[2];
|
||||
if (!raw) return undefined;
|
||||
const code = Number.parseInt(raw, 10);
|
||||
return Number.isFinite(code) && code >= 100 && code < 600 ? code : undefined;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export * from "./abort";
|
||||
export * from "./auth";
|
||||
export * from "./auth-classify";
|
||||
export * from "./aws";
|
||||
export * from "./classes";
|
||||
export * from "./finalize";
|
||||
export * from "./flags";
|
||||
export * from "./format";
|
||||
export * from "./gateway";
|
||||
export * from "./oauth";
|
||||
export * from "./provider";
|
||||
export * from "./retryable";
|
||||
export * from "./validation";
|
||||
@@ -0,0 +1,58 @@
|
||||
import { attach, create, Flag } from "./flags";
|
||||
|
||||
/**
|
||||
* What stage of an OAuth / device-code login flow failed. Discriminates the
|
||||
* single {@link OAuthError} class so login flows don't each mint a bespoke
|
||||
* error type.
|
||||
*/
|
||||
export type OAuthErrorKind =
|
||||
/** Token-exchange / refresh / discovery HTTP response was non-2xx or unparseable. */
|
||||
| "http"
|
||||
/** Response body was missing required fields (token, account id, endpoints, …). */
|
||||
| "validation"
|
||||
/** Authorization-code → token exchange failed. */
|
||||
| "token-exchange"
|
||||
/** Refresh-token grant failed. */
|
||||
| "token-refresh"
|
||||
/** Device-code / authorization polling failed (server error, too many retries). */
|
||||
| "polling"
|
||||
/** The flow exceeded its deadline (device-code expiry, polling timeout). */
|
||||
| "timeout"
|
||||
/** Device authorization was denied or cancelled by the user/provider. */
|
||||
| "device-auth"
|
||||
/** Misconfiguration (bad redirect URI, missing projectId, callback bind, …). */
|
||||
| "configuration"
|
||||
/** Cloud project provisioning / onboarding (loadCodeAssist, onboardUser). */
|
||||
| "provisioning"
|
||||
/** OIDC / endpoint discovery failed. */
|
||||
| "discovery";
|
||||
|
||||
export interface OAuthErrorOptions {
|
||||
kind?: OAuthErrorKind;
|
||||
provider?: string;
|
||||
status?: number;
|
||||
cause?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* A failure inside an interactive OAuth / device-code login flow. The `kind`
|
||||
* pinpoints the stage. Timeout/polling are classified transient; everything
|
||||
* else is a hard auth failure so the credential layer does not silently retry.
|
||||
*/
|
||||
export class OAuthError extends Error {
|
||||
readonly kind: OAuthErrorKind;
|
||||
readonly provider: string | undefined;
|
||||
readonly status: number | undefined;
|
||||
|
||||
constructor(message: string, options: OAuthErrorOptions = {}) {
|
||||
super(message, options.cause === undefined ? undefined : { cause: options.cause });
|
||||
this.name = "OAuthError";
|
||||
this.kind = options.kind ?? "http";
|
||||
this.provider = options.provider;
|
||||
this.status = options.status;
|
||||
attach(
|
||||
this,
|
||||
this.kind === "timeout" || this.kind === "polling" ? create(Flag.Transient) : create(Flag.AuthFailed),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { ProviderHttpError } from "./classes";
|
||||
import { attach, create, Flag } from "./flags";
|
||||
|
||||
/** Which part of a provider exchange produced a non-HTTP error. */
|
||||
export type ProviderResponseErrorKind =
|
||||
/** Stream closed before a terminal completion/response event. */
|
||||
| "incomplete-stream"
|
||||
/** Terminal event carried an error / unexpected stop reason. */
|
||||
| "output"
|
||||
/** Response body was empty/missing when content was required. */
|
||||
| "empty-body"
|
||||
/** Malformed wire envelope (unexpected message ordering / shape). */
|
||||
| "envelope"
|
||||
/** Content was blocked by a provider safety filter. */
|
||||
| "content-blocked"
|
||||
/** Runtime/namespace resolution or other provider-internal failure. */
|
||||
| "runtime";
|
||||
|
||||
export interface ProviderResponseErrorOptions {
|
||||
provider?: string;
|
||||
kind?: ProviderResponseErrorKind;
|
||||
cause?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* A non-HTTP provider failure: a truncated stream, an error stop reason, an
|
||||
* empty body, a malformed envelope, or a runtime fault. For non-2xx HTTP
|
||||
* responses use {@link ProviderHttpError} (or a provider subclass) instead.
|
||||
*/
|
||||
export class ProviderResponseError extends Error {
|
||||
readonly provider: string | undefined;
|
||||
readonly kind: ProviderResponseErrorKind;
|
||||
|
||||
constructor(message: string, options: ProviderResponseErrorOptions = {}) {
|
||||
super(message, options.cause === undefined ? undefined : { cause: options.cause });
|
||||
this.name = "ProviderResponseError";
|
||||
this.provider = options.provider;
|
||||
this.kind = options.kind ?? "output";
|
||||
if (this.kind === "content-blocked") attach(this, create(Flag.ProviderFinishError));
|
||||
}
|
||||
}
|
||||
|
||||
/** Non-2xx response from the Devin API. */
|
||||
export class DevinApiError extends ProviderHttpError {
|
||||
override readonly name = "DevinApiError";
|
||||
}
|
||||
|
||||
/** Non-2xx response from the GitLab Duo direct-access API. */
|
||||
export class GitLabDuoApiError extends ProviderHttpError {
|
||||
override readonly name = "GitLabDuoApiError";
|
||||
}
|
||||
|
||||
/** Non-2xx response from the GitLab Duo Workflow API. */
|
||||
export class GitLabDuoWorkflowApiError extends ProviderHttpError {
|
||||
override readonly name = "GitLabDuoWorkflowApiError";
|
||||
}
|
||||
@@ -131,7 +131,7 @@ export function isUsageLimitStatus(status: number | undefined): boolean {
|
||||
* credentials.
|
||||
*/
|
||||
export function isUsageLimitOutcome(status: number | undefined, message: string | undefined): boolean {
|
||||
if (message && isUsageLimitError(message)) return true;
|
||||
if (message && matchesUsageLimitText(message)) return true;
|
||||
if (!isUsageLimitStatus(status)) return false;
|
||||
if (!message || isOpaqueStatusBody(message)) return true;
|
||||
return parseRateLimitReason(message) === "QUOTA_EXHAUSTED";
|
||||
@@ -143,13 +143,19 @@ export function isUsageLimitOutcome(status: number | undefined, message: string
|
||||
* generic punctuation. Anything else (retry hints, capacity wording, error
|
||||
* descriptions) is informative enough to defer to the classifier.
|
||||
*/
|
||||
function isOpaqueStatusBody(message: string): boolean {
|
||||
export function isOpaqueStatusBody(message: string): boolean {
|
||||
const cleaned = message
|
||||
.replace(/\b429\b/g, "")
|
||||
.replace(/\b(?:http|https|status|error|code|response|message)\b/gi, "");
|
||||
return !/[a-z\d]{3,}/i.test(cleaned);
|
||||
}
|
||||
|
||||
export function isUsageLimitError(errorMessage: string): boolean {
|
||||
/**
|
||||
* Internal text matcher for usage/quota-limit phrasing. NOT part of the public
|
||||
* API — callers classify through {@link import("./flags").isUsageLimit} (the
|
||||
* flag accessor). `flags.ts` consumes this to populate `Flag.UsageLimit`, and
|
||||
* {@link isUsageLimitOutcome} uses it for the account-rotation decision.
|
||||
*/
|
||||
export function matchesUsageLimitText(errorMessage: string): boolean {
|
||||
return USAGE_LIMIT_PATTERN.test(errorMessage) || ACCOUNT_RATE_LIMIT_PATTERN.test(errorMessage);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { isRetryableError, isUnexpectedSocketCloseMessage } from "@oh-my-pi/pi-utils";
|
||||
import {
|
||||
isRetryableStreamEnvelopeError,
|
||||
isTransientStreamParseError,
|
||||
isUsageLimit,
|
||||
status,
|
||||
TRANSIENT_TRANSPORT_PATTERN,
|
||||
} from "./flags";
|
||||
|
||||
/**
|
||||
* Whether a numeric HTTP status is in the canonical transient/retryable set:
|
||||
* 408 (Request Timeout), 429 (Too Many Requests), and any 5xx.
|
||||
*
|
||||
* This is a pure predicate over a status code already in hand — distinct from
|
||||
* {@link classify}, which inspects a whole error (including message text) and
|
||||
* may match more. Use this when you only have a `status: number`.
|
||||
*/
|
||||
export function isTransientStatus(status: number | undefined): boolean {
|
||||
return status !== undefined && (status === 408 || status === 429 || status >= 500);
|
||||
}
|
||||
|
||||
// Provider-stream transient phrasings not covered by the shared
|
||||
// TRANSIENT_TRANSPORT_PATTERN (TLS record corruption, HTTP/2 peer stream
|
||||
// errors, upstream code 1302). The shared pattern already covers rate-limit /
|
||||
// overloaded / 5xx / timeout / first-event wording.
|
||||
const PROVIDER_TRANSIENT_EXTRA_PATTERN = /bad record mac|stream error.*received from peer|1302/i;
|
||||
|
||||
function isTransientTransportMessage(message: string): boolean {
|
||||
return message.includes("tls: bad record mac") || message.includes("type=server_error");
|
||||
}
|
||||
|
||||
/** Hook for provider-specific transient detection that the error module must not import directly. */
|
||||
export interface ProviderRetryableHooks {
|
||||
/** Provider id of the failing request, used to gate provider-specific checks. */
|
||||
provider?: string;
|
||||
/** Provider-specific transient predicate (e.g. Copilot `model_not_supported`). */
|
||||
isProviderTransient?: (error: Error) => boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a provider stream error should be retried against the same credential.
|
||||
*
|
||||
* Account-level usage/quota limits are deliberately treated as **non**-retryable
|
||||
* here — they are owned by the credential-rotation layer (auth-gateway /
|
||||
* `streamSimple` a/b/c policy), not this seconds-scale provider backoff.
|
||||
*
|
||||
* Provider-specific transient cases are injected via {@link ProviderRetryableHooks}
|
||||
* so this stays free of provider imports.
|
||||
*/
|
||||
export function isProviderRetryableError(error: unknown, hooks: ProviderRetryableHooks = {}): boolean {
|
||||
if (!(error instanceof Error)) return false;
|
||||
if (hooks.isProviderTransient?.(error)) return true;
|
||||
if (isUsageLimit(error)) return false;
|
||||
const httpStatus = status(error);
|
||||
if (httpStatus !== undefined && httpStatus >= 400 && httpStatus < 500 && httpStatus !== 408 && httpStatus !== 429) {
|
||||
return false;
|
||||
}
|
||||
const msg = error.message.toLowerCase();
|
||||
if (
|
||||
isUnexpectedSocketCloseMessage(msg) ||
|
||||
isTransientTransportMessage(msg) ||
|
||||
TRANSIENT_TRANSPORT_PATTERN.test(msg) ||
|
||||
PROVIDER_TRANSIENT_EXTRA_PATTERN.test(msg) ||
|
||||
isTransientStreamParseError(error) ||
|
||||
isRetryableStreamEnvelopeError(error)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return isRetryableError(error);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { attach, create, Flag } from "./flags";
|
||||
|
||||
/**
|
||||
* Caller-supplied input failed validation before/while building a provider
|
||||
* request: bad request body, malformed tool arguments, unsupported content
|
||||
* type, a schema that cannot be normalized, an unknown tool, etc.
|
||||
*
|
||||
* This is a programmer/config/contract error, not a transient provider fault —
|
||||
* it is never retried.
|
||||
*/
|
||||
export class ValidationError extends Error {
|
||||
constructor(message: string, options?: { cause?: unknown }) {
|
||||
super(message, options?.cause === undefined ? undefined : { cause: options.cause });
|
||||
this.name = "ValidationError";
|
||||
}
|
||||
}
|
||||
|
||||
/** A referenced tool was not found in the active tool set. */
|
||||
export class ToolNotFoundError extends ValidationError {
|
||||
constructor(toolName: string) {
|
||||
super(`Tool "${toolName}" not found`);
|
||||
this.name = "ToolNotFoundError";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider/auth configuration was missing or malformed (env var pointing at a
|
||||
* missing file, missing projectId, bad bind string, mTLS half-configured, …).
|
||||
*/
|
||||
export class ConfigurationError extends Error {
|
||||
constructor(message: string, options?: { cause?: unknown }) {
|
||||
super(message, options?.cause === undefined ? undefined : { cause: options.cause });
|
||||
this.name = "ConfigurationError";
|
||||
}
|
||||
}
|
||||
|
||||
/** A request was abandoned because it exceeded a stream/idle/first-event deadline. */
|
||||
export class StreamTimeoutError extends Error {
|
||||
constructor(message = "Request timed out.", options?: { cause?: unknown }) {
|
||||
super(message, options?.cause === undefined ? undefined : { cause: options.cause });
|
||||
this.name = "StreamTimeoutError";
|
||||
attach(this, create(Flag.Transient, Flag.Timeout));
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
/**
|
||||
* Structured HTTP errors thrown by provider clients.
|
||||
*
|
||||
* Downstream classification reads these fields structurally rather than via
|
||||
* `instanceof`: `extractHttpStatusFromError` (pi-utils) reads `status`,
|
||||
* `getHeadersFromError` (retry-after extraction) reads `headers`, and retry
|
||||
* policies such as `isCopilotTransientModelError` read `code`. Per-provider
|
||||
* subclasses exist so call sites can narrow with `instanceof` and logs carry
|
||||
* a meaningful `error.name`.
|
||||
*/
|
||||
export interface ProviderHttpErrorOptions {
|
||||
/** Response headers; enables `retry-after`/rate-limit extraction downstream. */
|
||||
headers?: Headers;
|
||||
/** Machine-readable error code from the response body (`error.code` / `error.type`). */
|
||||
code?: string;
|
||||
cause?: unknown;
|
||||
}
|
||||
|
||||
/** Non-2xx HTTP response from a provider endpoint. */
|
||||
export class ProviderHttpError extends Error {
|
||||
readonly status: number;
|
||||
readonly headers: Headers | undefined;
|
||||
readonly code: string | undefined;
|
||||
|
||||
constructor(message: string, status: number, options?: ProviderHttpErrorOptions) {
|
||||
super(message, options?.cause === undefined ? undefined : { cause: options.cause });
|
||||
this.name = "ProviderHttpError";
|
||||
this.status = status;
|
||||
this.headers = options?.headers;
|
||||
this.code = options?.code;
|
||||
}
|
||||
}
|
||||
@@ -6,13 +6,14 @@ export type { AuthGatewayBootOptions, ModelResolver } from "./auth-gateway/serve
|
||||
export * from "./auth-gateway/types";
|
||||
export * from "./auth-retry";
|
||||
export * from "./auth-storage";
|
||||
export * from "./errors";
|
||||
export * from "./error/rate-limit";
|
||||
export * from "./provider-details";
|
||||
export * from "./providers/anthropic";
|
||||
export * from "./providers/anthropic-client";
|
||||
export * from "./providers/azure-openai-responses";
|
||||
export type * from "./providers/cursor";
|
||||
export * from "./providers/gitlab-duo";
|
||||
export * from "./providers/gitlab-duo-workflow";
|
||||
export type * from "./providers/google";
|
||||
export type * from "./providers/google-gemini-cli";
|
||||
export type * from "./providers/google-vertex";
|
||||
@@ -23,7 +24,6 @@ export * from "./providers/openai-codex-responses";
|
||||
export * from "./providers/openai-completions";
|
||||
export * from "./providers/openai-responses";
|
||||
export * from "./providers/synthetic";
|
||||
export * from "./rate-limit-utils";
|
||||
export * from "./registry";
|
||||
export * from "./stream";
|
||||
export * from "./types";
|
||||
@@ -34,6 +34,7 @@ export * from "./usage/github-copilot";
|
||||
export * from "./usage/google-antigravity";
|
||||
export * from "./usage/kimi";
|
||||
export * from "./usage/minimax-code";
|
||||
export * from "./usage/ollama";
|
||||
export * from "./usage/openai-codex";
|
||||
export * from "./usage/openai-codex-reset";
|
||||
export * from "./usage/opencode-go";
|
||||
@@ -41,7 +42,6 @@ export * from "./usage/zai";
|
||||
export * from "./utils/anthropic-auth";
|
||||
export * from "./utils/event-stream";
|
||||
export * from "./utils/openrouter-headers";
|
||||
export * from "./utils/overflow";
|
||||
export * from "./utils/retry";
|
||||
export * from "./utils/schema";
|
||||
export * from "./utils/thinking-loop";
|
||||
|
||||
@@ -10,8 +10,9 @@
|
||||
import type { Effort } from "@oh-my-pi/pi-catalog/effort";
|
||||
import { mapEffortToAnthropicAdaptiveEffort, requireSupportedEffort } from "@oh-my-pi/pi-catalog/model-thinking";
|
||||
import { calculateCost } from "@oh-my-pi/pi-catalog/models";
|
||||
import { $env, $flag, extractHttpStatusFromError, fetchWithRetry } from "@oh-my-pi/pi-utils";
|
||||
import { ProviderHttpError } from "../errors";
|
||||
import { $env, $flag, fetchWithRetry, parseStreamingJson, parseStreamingJsonThrottled } from "@oh-my-pi/pi-utils";
|
||||
import { renderDemotedThinking } from "../dialect/demotion";
|
||||
import * as AIError from "../error";
|
||||
import type {
|
||||
Api,
|
||||
AssistantMessage,
|
||||
@@ -29,21 +30,21 @@ import type {
|
||||
ToolResultMessage,
|
||||
} from "../types";
|
||||
import { normalizeToolCallId, resolveCacheRetention } from "../utils";
|
||||
import {
|
||||
clearStreamingPartialJson,
|
||||
kStreamingBlockIndex,
|
||||
kStreamingLastParseLen,
|
||||
kStreamingPartialJson,
|
||||
} from "../utils/block-symbols";
|
||||
import { AssistantMessageEventStream } from "../utils/event-stream";
|
||||
import { appendRawHttpRequestDumpFor400, type RawHttpRequestDump } from "../utils/http-inspector";
|
||||
import type { RawHttpRequestDump } from "../utils/http-inspector";
|
||||
import { armPreResponseTimeout, getStreamFirstEventTimeoutMs } from "../utils/idle-iterator";
|
||||
import { parseStreamingJson, parseStreamingJsonThrottled } from "../utils/json-parse";
|
||||
import { toolWireSchema } from "../utils/schema/wire";
|
||||
import { invalidateAwsCredentialCache, resolveAwsCredentials } from "./aws-credentials";
|
||||
import { decodeEventStream } from "./aws-eventstream";
|
||||
import { signRequest } from "./aws-sigv4";
|
||||
import { transformMessages } from "./transform-messages";
|
||||
|
||||
/** Non-2xx response (or in-stream exception event) from the Bedrock runtime API. */
|
||||
export class BedrockApiError extends ProviderHttpError {
|
||||
override readonly name = "BedrockApiError";
|
||||
}
|
||||
|
||||
export type BedrockThinkingDisplay = "summarized" | "omitted";
|
||||
|
||||
export interface BedrockOptions extends StreamOptions {
|
||||
@@ -158,9 +159,9 @@ function resolveBedrockRegion(modelId: string, options: BedrockOptions): string
|
||||
}
|
||||
|
||||
type Block = (TextContent | ThinkingContent | ToolCall) & {
|
||||
index?: number;
|
||||
partialJson?: string;
|
||||
lastParseLen?: number;
|
||||
[kStreamingBlockIndex]?: number;
|
||||
[kStreamingPartialJson]?: string;
|
||||
[kStreamingLastParseLen]?: number;
|
||||
};
|
||||
|
||||
// ---------- Bedrock wire-format types ----------
|
||||
@@ -282,7 +283,7 @@ export const streamBedrock: StreamFunction<"bedrock-converse-stream"> = (
|
||||
const stream = new AssistantMessageEventStream();
|
||||
|
||||
(async () => {
|
||||
const startTime = Date.now();
|
||||
const startTime = performance.now();
|
||||
let firstTokenTime: number | undefined;
|
||||
|
||||
const output: AssistantMessage = {
|
||||
@@ -415,11 +416,15 @@ export const streamBedrock: StreamFunction<"bedrock-converse-stream"> = (
|
||||
invalidateAwsCredentialCache({ profile: options.profile, region });
|
||||
}
|
||||
const errBody = await response.text().catch(() => "");
|
||||
throw new BedrockApiError(`Bedrock HTTP ${response.status}: ${errBody.slice(0, 1000)}`, response.status, {
|
||||
headers: response.headers,
|
||||
});
|
||||
throw new AIError.BedrockApiError(
|
||||
`Bedrock HTTP ${response.status}: ${errBody.slice(0, 1000)}`,
|
||||
response.status,
|
||||
{
|
||||
headers: response.headers,
|
||||
},
|
||||
);
|
||||
}
|
||||
if (!response.body) throw new Error("Bedrock response has no body");
|
||||
if (!response.body) throw new AIError.BedrockApiError("Bedrock response has no body", response.status);
|
||||
|
||||
// Track first event for the abort/diagnostic path (currently informational).
|
||||
for await (const message of decodeEventStream(response.body)) {
|
||||
@@ -431,14 +436,12 @@ export const streamBedrock: StreamFunction<"bedrock-converse-stream"> = (
|
||||
const payload = safeParsePayload(message.payload) as { message?: string } | undefined;
|
||||
const errorMessage = payload?.message || new TextDecoder().decode(message.payload);
|
||||
const text = `${exceptionType}: ${errorMessage}`;
|
||||
throw exceptionType === "validationException"
|
||||
? new BedrockApiError(text, 400, { code: exceptionType })
|
||||
: new Error(text);
|
||||
throw new AIError.BedrockApiError(text, 400, { code: exceptionType });
|
||||
}
|
||||
if (messageType === "error") {
|
||||
const code = message.headers[":error-code"] || "UnknownError";
|
||||
const errorMessage = message.headers[":error-message"] || new TextDecoder().decode(message.payload);
|
||||
throw new Error(`${code}: ${errorMessage}`);
|
||||
throw new AIError.BedrockApiError(`${code}: ${errorMessage}`, 400, { code });
|
||||
}
|
||||
if (messageType !== "event") continue;
|
||||
|
||||
@@ -450,18 +453,21 @@ export const streamBedrock: StreamFunction<"bedrock-converse-stream"> = (
|
||||
// no-op: first event marker is implicit by stream entry.
|
||||
const ev = payload as MessageStartEvent;
|
||||
if (ev.role !== "assistant") {
|
||||
throw new Error("Unexpected assistant message start but got user message start instead");
|
||||
throw new AIError.BedrockApiError(
|
||||
"Unexpected assistant message start but got user message start instead",
|
||||
0,
|
||||
);
|
||||
}
|
||||
stream.push({ type: "start", partial: output });
|
||||
break;
|
||||
}
|
||||
case "contentBlockStart": {
|
||||
if (!firstTokenTime) firstTokenTime = Date.now();
|
||||
if (!firstTokenTime) firstTokenTime = performance.now();
|
||||
handleContentBlockStart(payload as ContentBlockStartEvent, blocks, output, stream, sentinelInjected);
|
||||
break;
|
||||
}
|
||||
case "contentBlockDelta": {
|
||||
if (!firstTokenTime) firstTokenTime = Date.now();
|
||||
if (!firstTokenTime) firstTokenTime = performance.now();
|
||||
handleContentBlockDelta(payload as ContentBlockDeltaEvent, blocks, output, stream);
|
||||
break;
|
||||
}
|
||||
@@ -490,23 +496,20 @@ export const streamBedrock: StreamFunction<"bedrock-converse-stream"> = (
|
||||
}
|
||||
}
|
||||
|
||||
if (options.signal?.aborted) throw new Error("Request was aborted");
|
||||
if (options.signal?.aborted) throw new AIError.AbortError();
|
||||
|
||||
if (output.stopReason === "error" || output.stopReason === "aborted") {
|
||||
throw new Error(output.errorMessage ?? "An unknown error occurred");
|
||||
throw new AIError.BedrockApiError(output.errorMessage ?? "An unknown error occurred", 0);
|
||||
}
|
||||
|
||||
output.duration = Date.now() - startTime;
|
||||
output.duration = performance.now() - startTime;
|
||||
if (firstTokenTime) output.ttft = firstTokenTime - startTime;
|
||||
stream.push({ type: "done", reason: output.stopReason, message: output });
|
||||
stream.end();
|
||||
} catch (error) {
|
||||
for (const block of output.content) {
|
||||
delete (block as Block).index;
|
||||
delete (block as Block).partialJson;
|
||||
if (block.type === "toolCall") clearStreamingPartialJson(block);
|
||||
}
|
||||
output.stopReason = options.signal?.aborted ? "aborted" : "error";
|
||||
output.errorStatus = extractHttpStatusFromError(error);
|
||||
const baseMessage = error instanceof Error ? error.message : JSON.stringify(error);
|
||||
// Enrich error with thinking block diagnostics for signature-related failures
|
||||
let diagnostics = "";
|
||||
@@ -528,8 +531,12 @@ export const streamBedrock: StreamFunction<"bedrock-converse-stream"> = (
|
||||
diagnostics = `\n[thinking-diag] ${JSON.stringify(thinkingBlocks)}`;
|
||||
}
|
||||
}
|
||||
output.errorMessage = await appendRawHttpRequestDumpFor400(baseMessage + diagnostics, error, rawRequestDump);
|
||||
output.duration = Date.now() - startTime;
|
||||
const result = await AIError.finalize(error, { api: model.api, signal: options.signal, rawRequestDump });
|
||||
output.stopReason = result.stopReason;
|
||||
output.errorStatus = result.status;
|
||||
output.errorId = result.id;
|
||||
output.errorMessage = result.message + diagnostics;
|
||||
output.duration = performance.now() - startTime;
|
||||
if (firstTokenTime) output.ttft = firstTokenTime - startTime;
|
||||
stream.push({ type: "error", reason: output.stopReason, error: output });
|
||||
stream.end();
|
||||
@@ -569,8 +576,8 @@ function handleContentBlockStart(
|
||||
id: normalizeToolCallId(start.toolUse.toolUseId || ""),
|
||||
name: start.toolUse.name || "",
|
||||
arguments: {},
|
||||
partialJson: "",
|
||||
index,
|
||||
[kStreamingPartialJson]: "",
|
||||
[kStreamingBlockIndex]: index,
|
||||
};
|
||||
output.content.push(block);
|
||||
stream.push({ type: "toolcall_start", contentIndex: blocks.length - 1, partial: output });
|
||||
@@ -585,13 +592,13 @@ function handleContentBlockDelta(
|
||||
): void {
|
||||
const contentBlockIndex = event.contentBlockIndex;
|
||||
const delta = event.delta;
|
||||
let index = blocks.findIndex(b => b.index === contentBlockIndex);
|
||||
let index = blocks.findIndex(b => b[kStreamingBlockIndex] === contentBlockIndex);
|
||||
let block = blocks[index];
|
||||
|
||||
if (delta?.text !== undefined) {
|
||||
// If no text block exists yet, create one — `handleContentBlockStart` is not sent for text blocks
|
||||
if (!block) {
|
||||
const newBlock: Block = { type: "text", text: "", index: contentBlockIndex };
|
||||
const newBlock: Block = { type: "text", text: "", [kStreamingBlockIndex]: contentBlockIndex };
|
||||
output.content.push(newBlock);
|
||||
index = blocks.length - 1;
|
||||
block = blocks[index];
|
||||
@@ -602,11 +609,11 @@ function handleContentBlockDelta(
|
||||
stream.push({ type: "text_delta", contentIndex: index, delta: delta.text, partial: output });
|
||||
}
|
||||
} else if (delta?.toolUse && block?.type === "toolCall") {
|
||||
block.partialJson = (block.partialJson || "") + (delta.toolUse.input || "");
|
||||
const throttled = parseStreamingJsonThrottled(block.partialJson, block.lastParseLen ?? 0);
|
||||
block[kStreamingPartialJson] = (block[kStreamingPartialJson] || "") + (delta.toolUse.input || "");
|
||||
const throttled = parseStreamingJsonThrottled(block[kStreamingPartialJson], block[kStreamingLastParseLen] ?? 0);
|
||||
if (throttled) {
|
||||
block.arguments = throttled.value;
|
||||
block.lastParseLen = throttled.parsedLen;
|
||||
block[kStreamingLastParseLen] = throttled.parsedLen;
|
||||
}
|
||||
stream.push({ type: "toolcall_delta", contentIndex: index, delta: delta.toolUse.input || "", partial: output });
|
||||
} else if (delta?.reasoningContent) {
|
||||
@@ -614,7 +621,12 @@ function handleContentBlockDelta(
|
||||
let thinkingIndex = index;
|
||||
|
||||
if (!thinkingBlock) {
|
||||
const newBlock: Block = { type: "thinking", thinking: "", thinkingSignature: "", index: contentBlockIndex };
|
||||
const newBlock: Block = {
|
||||
type: "thinking",
|
||||
thinking: "",
|
||||
thinkingSignature: "",
|
||||
[kStreamingBlockIndex]: contentBlockIndex,
|
||||
};
|
||||
output.content.push(newBlock);
|
||||
thinkingIndex = blocks.length - 1;
|
||||
thinkingBlock = blocks[thinkingIndex];
|
||||
@@ -656,10 +668,9 @@ function handleContentBlockStop(
|
||||
output: AssistantMessage,
|
||||
stream: AssistantMessageEventStream,
|
||||
): void {
|
||||
const index = blocks.findIndex(b => b.index === event.contentBlockIndex);
|
||||
const index = blocks.findIndex(b => b[kStreamingBlockIndex] === event.contentBlockIndex);
|
||||
const block = blocks[index];
|
||||
if (!block) return;
|
||||
delete (block as Block).index;
|
||||
|
||||
switch (block.type) {
|
||||
case "text":
|
||||
@@ -669,9 +680,8 @@ function handleContentBlockStop(
|
||||
stream.push({ type: "thinking_end", contentIndex: index, content: block.thinking, partial: output });
|
||||
break;
|
||||
case "toolCall":
|
||||
block.arguments = parseStreamingJson(block.partialJson);
|
||||
delete (block as Block).partialJson;
|
||||
delete (block as Block).lastParseLen;
|
||||
block.arguments = parseStreamingJson(block[kStreamingPartialJson]);
|
||||
clearStreamingPartialJson(block);
|
||||
stream.push({ type: "toolcall_end", contentIndex: index, toolCall: block, partial: output });
|
||||
break;
|
||||
}
|
||||
@@ -766,7 +776,7 @@ function convertMessages(
|
||||
contentBlocks.push({ image: createImageBlock(c.mimeType, c.data) });
|
||||
break;
|
||||
default:
|
||||
throw new Error("Unknown user content type");
|
||||
throw new AIError.ValidationError("Unknown user content type");
|
||||
}
|
||||
}
|
||||
// Skip message if all blocks filtered out
|
||||
@@ -814,11 +824,11 @@ function convertMessages(
|
||||
});
|
||||
} else {
|
||||
// Model requires signature but we don't have one — demote to text
|
||||
contentBlocks.push({ text: `[Thinking]: ${c.thinking.toWellFormed()}` });
|
||||
contentBlocks.push({ text: renderDemotedThinking(model.id, c.thinking) });
|
||||
}
|
||||
break;
|
||||
default:
|
||||
throw new Error("Unknown assistant content type");
|
||||
throw new AIError.ValidationError("Unknown assistant content type");
|
||||
}
|
||||
}
|
||||
// Skip if all content blocks were filtered out
|
||||
@@ -864,7 +874,7 @@ function convertMessages(
|
||||
break;
|
||||
}
|
||||
default:
|
||||
throw new Error("Unknown message role");
|
||||
throw new AIError.ValidationError("Unknown message role");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1026,7 +1036,7 @@ function createImageBlock(mimeType: string, data: string): ImageBlockWire["image
|
||||
format = "webp";
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown image type: ${mimeType}`);
|
||||
throw new AIError.ValidationError(`Unknown image type: ${mimeType}`);
|
||||
}
|
||||
return { source: { bytes: data }, format };
|
||||
}
|
||||
|
||||
@@ -21,7 +21,11 @@
|
||||
* with up to 25% jitter).
|
||||
*/
|
||||
import { scheduler } from "node:timers/promises";
|
||||
import { ProviderHttpError } from "../errors";
|
||||
import * as AIError from "../error";
|
||||
import { AnthropicApiError, AnthropicConnectionError, AnthropicConnectionTimeoutError } from "../error";
|
||||
|
||||
export { AnthropicApiError, AnthropicConnectionError, AnthropicConnectionTimeoutError };
|
||||
|
||||
import type { FetchImpl } from "../types";
|
||||
import type { MessageCreateParamsStreaming } from "./anthropic-wire";
|
||||
|
||||
@@ -77,42 +81,8 @@ export interface AnthropicClientOptions {
|
||||
fetchOptions?: AnthropicFetchOptions;
|
||||
}
|
||||
|
||||
/** Non-2xx response from the Anthropic API. */
|
||||
export class AnthropicApiError extends ProviderHttpError {
|
||||
declare readonly headers: Headers;
|
||||
readonly requestId: string | null;
|
||||
|
||||
constructor(status: number, message: string, headers: Headers) {
|
||||
super(message, status, { headers });
|
||||
this.name = "AnthropicApiError";
|
||||
this.requestId = headers.get("request-id");
|
||||
}
|
||||
|
||||
static async fromResponse(response: Response): Promise<AnthropicApiError> {
|
||||
const body = await response.text().catch(() => "");
|
||||
const detail = body.trim() || "status code (no body)";
|
||||
return new AnthropicApiError(response.status, `${response.status} ${detail}`, response.headers);
|
||||
}
|
||||
}
|
||||
|
||||
/** Network-level failure (DNS, TLS, socket reset) after retries were exhausted. */
|
||||
export class AnthropicConnectionError extends Error {
|
||||
constructor(cause: unknown) {
|
||||
super("Connection error.", { cause });
|
||||
this.name = "AnthropicConnectionError";
|
||||
}
|
||||
}
|
||||
|
||||
/** No response headers arrived within the configured request timeout. */
|
||||
export class AnthropicConnectionTimeoutError extends Error {
|
||||
constructor() {
|
||||
super("Request timed out.");
|
||||
this.name = "AnthropicConnectionTimeoutError";
|
||||
}
|
||||
}
|
||||
|
||||
function createAbortError(): Error {
|
||||
return new Error("Request was aborted.");
|
||||
return new AIError.AbortError("Request was aborted.");
|
||||
}
|
||||
|
||||
/** `x-should-retry` override, then 408/409/429/5xx. */
|
||||
@@ -121,7 +91,9 @@ function shouldRetryResponse(response: Response): boolean {
|
||||
if (shouldRetryHeader === "true") return true;
|
||||
if (shouldRetryHeader === "false") return false;
|
||||
const status = response.status;
|
||||
return status === 408 || status === 409 || status === 429 || status >= 500;
|
||||
// Canonical transient set (408/429/5xx) plus 409, which Anthropic's client
|
||||
// also retries.
|
||||
return AIError.isTransientStatus(status) || status === 409;
|
||||
}
|
||||
|
||||
/** Server-suggested delay (`retry-after-ms`, then `retry-after` seconds or HTTP date). */
|
||||
@@ -260,8 +232,8 @@ export class AnthropicMessagesClient implements AnthropicMessagesClientLike {
|
||||
await this.#backoff(attempt, undefined, callerSignal);
|
||||
continue;
|
||||
}
|
||||
if (error instanceof AnthropicConnectionTimeoutError) throw error;
|
||||
throw new AnthropicConnectionError(error);
|
||||
if (error instanceof AIError.AnthropicConnectionTimeoutError) throw error;
|
||||
throw new AIError.AnthropicConnectionError(error);
|
||||
}
|
||||
|
||||
if (response.ok) return response;
|
||||
@@ -271,7 +243,7 @@ export class AnthropicMessagesClient implements AnthropicMessagesClientLike {
|
||||
await this.#backoff(attempt, response.headers, callerSignal);
|
||||
continue;
|
||||
}
|
||||
throw await AnthropicApiError.fromResponse(response);
|
||||
throw await AIError.AnthropicApiError.fromResponse(response);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -300,7 +272,7 @@ export class AnthropicMessagesClient implements AnthropicMessagesClientLike {
|
||||
signal: controller.signal,
|
||||
});
|
||||
} catch (error) {
|
||||
if (timedOut && !callerSignal?.aborted) throw new AnthropicConnectionTimeoutError();
|
||||
if (timedOut && !callerSignal?.aborted) throw new AIError.AnthropicConnectionTimeoutError();
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
|
||||
@@ -135,12 +135,17 @@ export const userMessageSchema = type({
|
||||
content: type("string").or(userContentBlockSchema.array()),
|
||||
});
|
||||
|
||||
export const systemMessageSchema = type({
|
||||
role: "'system'",
|
||||
content: type("string").or(systemBlockSchema.array()),
|
||||
});
|
||||
|
||||
export const assistantMessageSchema = type({
|
||||
role: "'assistant'",
|
||||
content: type("string").or(assistantContentBlockSchema.array()),
|
||||
});
|
||||
|
||||
export const messageSchema = userMessageSchema.or(assistantMessageSchema);
|
||||
export const messageSchema = userMessageSchema.or(assistantMessageSchema).or(systemMessageSchema);
|
||||
|
||||
// ─── Tools ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { logger } from "@oh-my-pi/pi-utils";
|
||||
import { type } from "arktype";
|
||||
import { captureRequestHeaders, resolvePromptCacheKey } from "../auth-gateway/http";
|
||||
import * as AIError from "../error";
|
||||
import type {
|
||||
AssistantMessage,
|
||||
AssistantMessageEventStream,
|
||||
@@ -293,7 +294,7 @@ function deriveCacheRetention(data: {
|
||||
export function parseRequest(body: unknown, headers?: Headers): ParsedRequest {
|
||||
const data = anthropicMessagesRequestSchema(body);
|
||||
if (data instanceof type.errors) {
|
||||
throw new Error(`anthropic-messages: ${data.summary}`);
|
||||
throw new AIError.ValidationError(`anthropic-messages: ${data.summary}`);
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
@@ -457,7 +458,13 @@ function encodeUsage(message: AssistantMessage): Record<string, unknown> {
|
||||
|
||||
export function encodeResponse(message: AssistantMessage, requestedModelId: string): Record<string, unknown> {
|
||||
if (message.stopReason === "error" || message.stopReason === "aborted") {
|
||||
throw new Error(message.errorMessage ?? `anthropic-messages: upstream ${message.stopReason}`);
|
||||
throw new AIError.ProviderResponseError(
|
||||
message.errorMessage ?? `anthropic-messages: upstream ${message.stopReason}`,
|
||||
{
|
||||
provider: "anthropic",
|
||||
kind: "output",
|
||||
},
|
||||
);
|
||||
}
|
||||
return {
|
||||
id: message.responseId ?? newMessageId(),
|
||||
|
||||
@@ -9,15 +9,15 @@ import { isAnthropicOAuthToken } from "@oh-my-pi/pi-catalog/utils";
|
||||
import { parseGitHubCopilotApiKey } from "@oh-my-pi/pi-catalog/wire/github-copilot";
|
||||
import {
|
||||
$env,
|
||||
extractHttpStatusFromError,
|
||||
getInstallId,
|
||||
isEnoent,
|
||||
isRetryableError,
|
||||
isUnexpectedSocketCloseMessage,
|
||||
logger,
|
||||
parseJsonWithRepair,
|
||||
parseStreamingJsonThrottled,
|
||||
readSseEvents,
|
||||
} from "@oh-my-pi/pi-utils";
|
||||
import { isUsageLimitError } from "../rate-limit-utils";
|
||||
import { renderDemotedThinking } from "../dialect/demotion";
|
||||
import * as AIError from "../error";
|
||||
import { getEnvApiKey, OUTPUT_FALLBACK_BUFFER } from "../stream";
|
||||
import type {
|
||||
Api,
|
||||
@@ -46,14 +46,18 @@ import type {
|
||||
import { resolveServiceTier } from "../types";
|
||||
import { isRecord, normalizeSystemPrompts, normalizeToolCallId, resolveCacheRetention } from "../utils";
|
||||
import { createAbortSourceTracker } from "../utils/abort";
|
||||
import {
|
||||
clearStreamingPartialJson,
|
||||
kStreamingBlockIndex,
|
||||
kStreamingLastParseLen,
|
||||
kStreamingPartialJson,
|
||||
} from "../utils/block-symbols";
|
||||
import { withEmptyCompletionRetry } from "../utils/empty-completion-retry";
|
||||
import { AssistantMessageEventStream } from "../utils/event-stream";
|
||||
import { isFoundryEnabled } from "../utils/foundry";
|
||||
import { finalizeErrorMessage, type RawHttpRequestDump, rewriteCopilotError } from "../utils/http-inspector";
|
||||
import { finalizeErrorMessage, type RawHttpRequestDump } from "../utils/http-inspector";
|
||||
import { getStreamFirstEventTimeoutMs, getStreamIdleTimeoutMs, iterateWithIdleTimeout } from "../utils/idle-iterator";
|
||||
import { parseJsonWithRepair, parseStreamingJsonThrottled } from "../utils/json-parse";
|
||||
import { notifyProviderResponse } from "../utils/provider-response";
|
||||
import { isCopilotTransientModelError } from "../utils/retry";
|
||||
import { COMBINATOR_KEYS, NO_STRICT, toolWireSchema } from "../utils/schema";
|
||||
import { spillToDescription } from "../utils/schema/spill";
|
||||
import { createSdkStreamRequestOptions } from "../utils/sdk-stream-timeout";
|
||||
@@ -383,38 +387,6 @@ export function clearAnthropicFastModeFallback(
|
||||
}
|
||||
}
|
||||
|
||||
function isAnthropicStrictGrammarTooLargeError(error: unknown): boolean {
|
||||
if (extractHttpStatusFromError(error) !== 400) return false;
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const isStrictGrammarTooLarge = /compiled grammar/i.test(message) && /too large/i.test(message);
|
||||
const isSchemaCompilationTooComplex =
|
||||
/schema/i.test(message) && /too complex/i.test(message) && /compil/i.test(message);
|
||||
return /invalid_request_error/i.test(message) && (isStrictGrammarTooLarge || isSchemaCompilationTooComplex);
|
||||
}
|
||||
|
||||
export function isAnthropicFastModeUnsupportedError(error: unknown): boolean {
|
||||
const status = extractHttpStatusFromError(error);
|
||||
if (status !== 400 && status !== 429) return false;
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
// 400 invalid_request_error — model doesn't accept `speed` at all.
|
||||
// Observed: "'claude-opus-4-5-20251101' does not support the `speed` parameter."
|
||||
// Stay tolerant of phrasing drift ("is not supported", quoted vs backticked field).
|
||||
if (
|
||||
status === 400 &&
|
||||
/invalid_request_error/i.test(message) &&
|
||||
/\bspeed\b/i.test(message) &&
|
||||
/not support/i.test(message)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
// 429 rate_limit_error — account lacks the extra-usage entitlement fast mode requires.
|
||||
// Observed: "Extra usage is required for fast mode."
|
||||
if (status === 429 && /rate_limit_error/i.test(message) && /fast mode/i.test(message)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function hasStrictAnthropicTools(params: MessageCreateParamsStreaming): boolean {
|
||||
return params.tools?.some(tool => tool.strict === true) ?? false;
|
||||
}
|
||||
@@ -1227,7 +1199,7 @@ function resolvePemValue(value: string | undefined, name: string): string | unde
|
||||
return fs.readFileSync(trimmed, "utf8");
|
||||
} catch (error) {
|
||||
if (isEnoent(error)) {
|
||||
throw new Error(`${name} path does not exist: ${trimmed}`);
|
||||
throw new AIError.ValidationError(`${name} path does not exist: ${trimmed}`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
@@ -1248,7 +1220,9 @@ function resolveFoundryTlsOptions(model: Model<"anthropic-messages">): FoundryTl
|
||||
const key = resolvePemValue($env.CLAUDE_CODE_CLIENT_KEY, "CLAUDE_CODE_CLIENT_KEY");
|
||||
|
||||
if ((cert && !key) || (!cert && key)) {
|
||||
throw new Error("Both CLAUDE_CODE_CLIENT_CERT and CLAUDE_CODE_CLIENT_KEY must be set for mTLS.");
|
||||
throw new AIError.ConfigurationError(
|
||||
"Both CLAUDE_CODE_CLIENT_CERT and CLAUDE_CODE_CLIENT_KEY must be set for mTLS.",
|
||||
);
|
||||
}
|
||||
|
||||
const options: FoundryTlsOptions = {};
|
||||
@@ -1338,14 +1312,15 @@ function createAnthropicSseStreamError(data: string): Error {
|
||||
const errorType = typeof parsed?.error?.type === "string" ? parsed.error.type : undefined;
|
||||
const message = typeof parsed?.error?.message === "string" ? parsed.error.message : undefined;
|
||||
if (message) {
|
||||
return new Error(
|
||||
return new AIError.ProviderResponseError(
|
||||
errorType ? `Anthropic stream error (${errorType}): ${message}` : `Anthropic stream error: ${message}`,
|
||||
{ provider: "anthropic", kind: "output" },
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// Not a JSON envelope; fall through to the raw payload.
|
||||
}
|
||||
return new Error(data);
|
||||
return new AIError.ProviderResponseError(data, { provider: "anthropic", kind: "output" });
|
||||
}
|
||||
|
||||
async function* iterateAnthropicEvents(
|
||||
@@ -1354,7 +1329,7 @@ async function* iterateAnthropicEvents(
|
||||
onSseEvent?: AnthropicOptions["onSseEvent"],
|
||||
): AsyncGenerator<AnthropicStreamEvent> {
|
||||
if (!response.body) {
|
||||
throw new Error("Attempted to iterate over an Anthropic response with no body");
|
||||
throw new AIError.AnthropicStreamEnvelopeError("Attempted to iterate over an Anthropic response with no body");
|
||||
}
|
||||
|
||||
let sawMessageStart = false;
|
||||
@@ -1443,7 +1418,7 @@ async function getAnthropicStreamResponse(
|
||||
const { data, response, request_id } = await request.withResponse();
|
||||
return { events: data, response, requestId: request_id, recordsRawSseEvents: false };
|
||||
}
|
||||
throw new Error("Anthropic SDK request did not expose a stream response");
|
||||
throw new AIError.AnthropicStreamEnvelopeError("Anthropic SDK request did not expose a stream response");
|
||||
}
|
||||
|
||||
async function* observeDecodedAnthropicSdkEvents(
|
||||
@@ -1460,23 +1435,9 @@ async function* observeDecodedAnthropicSdkEvents(
|
||||
|
||||
const PROVIDER_MAX_RETRIES = 10;
|
||||
|
||||
/** Transient stream corruption errors where the response was truncated mid-JSON. */
|
||||
function isTransientStreamParseError(error: unknown): boolean {
|
||||
if (!(error instanceof Error)) return false;
|
||||
return /unterminated string|unexpected end of json input|unexpected end of data|unexpected eof|end of file|eof while parsing|truncated/i.test(
|
||||
error.message,
|
||||
);
|
||||
}
|
||||
|
||||
const ANTHROPIC_STREAM_ENVELOPE_ERROR_PREFIX = "Anthropic stream envelope error:";
|
||||
|
||||
function createAnthropicStreamEnvelopeError(message: string): Error {
|
||||
return new Error(`${ANTHROPIC_STREAM_ENVELOPE_ERROR_PREFIX} ${message}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a malformed-stream-envelope anomaly without aborting the turn. The strict
|
||||
* parser would `throw createAnthropicStreamEnvelopeError(...)` here; we instead
|
||||
* parser would `throw new AnthropicStreamEnvelopeError(...)` here; we instead
|
||||
* surface a warning and let the caller skip the offending event (or finalize what
|
||||
* already streamed) so a non-conforming endpoint degrades to best-effort content
|
||||
* rather than failing the request.
|
||||
@@ -1491,49 +1452,18 @@ function shouldIgnoreAnthropicPreambleEvent(eventType: unknown): boolean {
|
||||
return !ANTHROPIC_MESSAGE_EVENTS.has(eventType);
|
||||
}
|
||||
|
||||
function isTransientStreamEnvelopeError(error: unknown): boolean {
|
||||
if (!(error instanceof Error)) return false;
|
||||
return (
|
||||
error.message.includes(ANTHROPIC_STREAM_ENVELOPE_ERROR_PREFIX) ||
|
||||
/stream event order|before message_start/i.test(error.message)
|
||||
);
|
||||
}
|
||||
|
||||
function isProviderRetryableStreamEnvelopeError(error: unknown): boolean {
|
||||
if (!(error instanceof Error)) return false;
|
||||
return /stream event order|before message_start/i.test(error.message);
|
||||
}
|
||||
|
||||
function isAnthropicTransientTransportMessage(message: string): boolean {
|
||||
return message.includes("tls: bad record mac") || message.includes("type=server_error");
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an Anthropic (or Copilot-over-Anthropic) stream error should be
|
||||
* retried. The classification lives in {@link AIError.isProviderRetryableError};
|
||||
* this wrapper injects the Copilot-specific `model_not_supported` transient
|
||||
* check, which the error module must not import directly.
|
||||
*/
|
||||
export function isProviderRetryableError(error: unknown, provider?: string): boolean {
|
||||
if (!(error instanceof Error)) return false;
|
||||
if (provider === "github-copilot" && isCopilotTransientModelError(error)) return true;
|
||||
// Account-level usage/quota limits ("usage_limit_reached", "exceed your
|
||||
// account's rate limit", "quota exceeded") are persistent — the server
|
||||
// parks the credential for minutes-to-hours (see the long `retry-after`).
|
||||
// Retrying the same key with the provider's seconds-scale backoff never
|
||||
// helps; these are owned by the credential-rotation layer (auth-gateway /
|
||||
// `streamSimple` a/b/c policy), so surface them immediately instead of
|
||||
// burning the retry budget here.
|
||||
if (isUsageLimitError(error.message)) return false;
|
||||
const status = extractHttpStatusFromError(error);
|
||||
if (status !== undefined && status >= 400 && status < 500 && status !== 408 && status !== 429) return false;
|
||||
const msg = error.message.toLowerCase();
|
||||
if (
|
||||
isUnexpectedSocketCloseMessage(msg) ||
|
||||
isAnthropicTransientTransportMessage(msg) ||
|
||||
/rate.?limit|too many requests|overloaded|service.?unavailable|internal_error|server_error|bad record mac|stream error.*received from peer|1302|timed?\s*out while waiting for the first event|timeout waiting for first/i.test(
|
||||
msg,
|
||||
) ||
|
||||
isTransientStreamParseError(error) ||
|
||||
isProviderRetryableStreamEnvelopeError(error)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return isRetryableError(error);
|
||||
return AIError.isProviderRetryableError(error, {
|
||||
provider,
|
||||
isProviderTransient:
|
||||
provider === "github-copilot" ? (err): boolean => AIError.isCopilotTransientModelError(err) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
const THINKING_ENVELOPE_OPEN = "<thinking>";
|
||||
@@ -1608,7 +1538,7 @@ const streamAnthropicOnce = (
|
||||
const stream = new AssistantMessageEventStream();
|
||||
|
||||
(async () => {
|
||||
const startTime = Date.now();
|
||||
const startTime = performance.now();
|
||||
let firstTokenTime: number | undefined;
|
||||
|
||||
const output: AssistantMessage = {
|
||||
@@ -1771,15 +1701,14 @@ const streamAnthropicOnce = (
|
||||
| ThinkingContent
|
||||
| RedactedThinkingContent
|
||||
| TextContent
|
||||
| (ToolCall & { partialJson: string; lastParseLen?: number })
|
||||
) & { index: number };
|
||||
| (ToolCall & { [kStreamingPartialJson]: string; [kStreamingLastParseLen]?: number })
|
||||
) & { [kStreamingBlockIndex]: number };
|
||||
const idleTimeoutMs = options?.streamIdleTimeoutMs ?? getStreamIdleTimeoutMs();
|
||||
const firstEventTimeoutMs = options?.streamFirstEventTimeoutMs ?? getStreamFirstEventTimeoutMs(idleTimeoutMs);
|
||||
const requestTimeoutMs =
|
||||
firstEventTimeoutMs !== undefined && firstEventTimeoutMs > 0 ? firstEventTimeoutMs : undefined;
|
||||
const blocks = output.content as Block[];
|
||||
const finalizeStreamBlock = (block: Block, contentIndex: number): void => {
|
||||
delete (block as { index?: number }).index;
|
||||
if (block.type === "text") {
|
||||
stream.push({ type: "text_end", contentIndex, content: block.text, partial: output });
|
||||
} else if (block.type === "thinking") {
|
||||
@@ -1791,7 +1720,9 @@ const streamAnthropicOnce = (
|
||||
stream.push({ type: "thinking_end", contentIndex, content: block.thinking, partial: output });
|
||||
} else if (block.type === "toolCall") {
|
||||
const finalJson =
|
||||
block.partialJson.length > 0 ? block.partialJson : JSON.stringify(block.arguments ?? {});
|
||||
block[kStreamingPartialJson].length > 0
|
||||
? block[kStreamingPartialJson]
|
||||
: JSON.stringify(block.arguments ?? {});
|
||||
try {
|
||||
block.arguments = parseJsonWithRepair(finalJson) as ToolCall["arguments"];
|
||||
} catch (parseError) {
|
||||
@@ -1813,8 +1744,7 @@ const streamAnthropicOnce = (
|
||||
};
|
||||
}
|
||||
}
|
||||
delete (block as { partialJson?: string }).partialJson;
|
||||
delete (block as { lastParseLen?: number }).lastParseLen;
|
||||
clearStreamingPartialJson(block);
|
||||
stream.push({ type: "toolcall_end", contentIndex, toolCall: block, partial: output });
|
||||
}
|
||||
};
|
||||
@@ -1823,8 +1753,12 @@ const streamAnthropicOnce = (
|
||||
// Provider-level transport/rate-limit failures: only before any streamed content starts.
|
||||
// Malformed envelopes/JSON: only before replay-unsafe text/tool events are visible on this stream.
|
||||
let providerRetryAttempt = 0;
|
||||
const firstEventTimeoutAbortError = new Error("Anthropic stream timed out while waiting for the first event");
|
||||
const idleTimeoutAbortError = new Error("Anthropic stream stalled while waiting for the next event");
|
||||
const firstEventTimeoutAbortError = new AIError.StreamTimeoutError(
|
||||
"Anthropic stream timed out while waiting for the first event",
|
||||
);
|
||||
const idleTimeoutAbortError = new AIError.StreamTimeoutError(
|
||||
"Anthropic stream stalled while waiting for the next event",
|
||||
);
|
||||
while (true) {
|
||||
activeAbortTracker = createAbortSourceTracker(options?.signal);
|
||||
const { requestSignal } = activeAbortTracker;
|
||||
@@ -1944,7 +1878,7 @@ const streamAnthropicOnce = (
|
||||
if (shouldIgnoreAnthropicPreambleEvent(event.type)) {
|
||||
continue;
|
||||
}
|
||||
throw createAnthropicStreamEnvelopeError(`received ${event.type} before message_start`);
|
||||
throw new AIError.AnthropicStreamEnvelopeError(`received ${event.type} before message_start`);
|
||||
}
|
||||
|
||||
if (event.type === "content_block_start") {
|
||||
@@ -1970,13 +1904,13 @@ const streamAnthropicOnce = (
|
||||
reportAnthropicEnvelopeAnomaly("content_block_start missing content_block payload");
|
||||
continue;
|
||||
}
|
||||
if (!firstTokenTime) firstTokenTime = Date.now();
|
||||
if (!firstTokenTime) firstTokenTime = performance.now();
|
||||
if (event.content_block.type === "text") {
|
||||
streamedReplayUnsafeContent = true;
|
||||
const block: Block = {
|
||||
type: "text",
|
||||
text: "",
|
||||
index: event.index,
|
||||
[kStreamingBlockIndex]: event.index,
|
||||
};
|
||||
output.content.push(block);
|
||||
const contentIndex = output.content.length - 1;
|
||||
@@ -1992,7 +1926,7 @@ const streamAnthropicOnce = (
|
||||
type: "thinking",
|
||||
thinking: "",
|
||||
thinkingSignature: "",
|
||||
index: event.index,
|
||||
[kStreamingBlockIndex]: event.index,
|
||||
};
|
||||
output.content.push(block);
|
||||
const contentIndex = output.content.length - 1;
|
||||
@@ -2007,7 +1941,7 @@ const streamAnthropicOnce = (
|
||||
const block: Block = {
|
||||
type: "redactedThinking",
|
||||
data: event.content_block.data,
|
||||
index: event.index,
|
||||
[kStreamingBlockIndex]: event.index,
|
||||
};
|
||||
output.content.push(block);
|
||||
openBlocks.set(event.index, {
|
||||
@@ -2025,8 +1959,8 @@ const streamAnthropicOnce = (
|
||||
model.compat.escapeBuiltinToolNames,
|
||||
),
|
||||
arguments: event.content_block.input ?? {},
|
||||
partialJson: "",
|
||||
index: event.index,
|
||||
[kStreamingPartialJson]: "",
|
||||
[kStreamingBlockIndex]: event.index,
|
||||
};
|
||||
output.content.push(block);
|
||||
const contentIndex = output.content.length - 1;
|
||||
@@ -2089,11 +2023,14 @@ const streamAnthropicOnce = (
|
||||
continue;
|
||||
}
|
||||
streamedReplayUnsafeContent = true;
|
||||
block.partialJson += event.delta.partial_json;
|
||||
const throttled = parseStreamingJsonThrottled(block.partialJson, block.lastParseLen ?? 0);
|
||||
block[kStreamingPartialJson] += event.delta.partial_json;
|
||||
const throttled = parseStreamingJsonThrottled(
|
||||
block[kStreamingPartialJson],
|
||||
block[kStreamingLastParseLen] ?? 0,
|
||||
);
|
||||
if (throttled) {
|
||||
block.arguments = throttled.value;
|
||||
block.lastParseLen = throttled.parsedLen;
|
||||
block[kStreamingLastParseLen] = throttled.parsedLen;
|
||||
}
|
||||
stream.push({
|
||||
type: "toolcall_delta",
|
||||
@@ -2196,10 +2133,10 @@ const streamAnthropicOnce = (
|
||||
throw firstEventTimeoutError;
|
||||
}
|
||||
if (activeAbortTracker.wasCallerAbort()) {
|
||||
throw new Error("Request was aborted");
|
||||
throw new AIError.AbortError();
|
||||
}
|
||||
if (!sawEvent || !sawMessageStart) {
|
||||
throw createAnthropicStreamEnvelopeError("stream ended before message_start");
|
||||
throw new AIError.AnthropicStreamEnvelopeError("stream ended before message_start");
|
||||
}
|
||||
if (!sawMessageStop) {
|
||||
reportAnthropicEnvelopeAnomaly("stream ended before message_stop");
|
||||
@@ -2217,7 +2154,10 @@ const streamAnthropicOnce = (
|
||||
}
|
||||
|
||||
if (output.stopReason === "aborted" || output.stopReason === "error") {
|
||||
throw new Error(output.errorMessage ?? "An unknown error occurred");
|
||||
throw new AIError.ProviderResponseError(output.errorMessage ?? "An unknown error occurred", {
|
||||
provider: model.provider,
|
||||
kind: "output",
|
||||
});
|
||||
}
|
||||
break;
|
||||
} catch (streamError) {
|
||||
@@ -2226,7 +2166,7 @@ const streamAnthropicOnce = (
|
||||
!disableStrictTools &&
|
||||
firstTokenTime === undefined &&
|
||||
hasStrictAnthropicTools(params) &&
|
||||
isAnthropicStrictGrammarTooLargeError(streamFailure)
|
||||
AIError.isGrammarError(streamFailure)
|
||||
) {
|
||||
// Log-only: the retried turn must not carry an errorMessage on
|
||||
// success (consumers treat its presence as failure).
|
||||
@@ -2253,7 +2193,7 @@ const streamAnthropicOnce = (
|
||||
!dropFastMode &&
|
||||
resolveServiceTier(options?.serviceTier, model.provider) === "priority" &&
|
||||
firstTokenTime === undefined &&
|
||||
isAnthropicFastModeUnsupportedError(streamFailure)
|
||||
AIError.isFastModeUnsupported(streamFailure)
|
||||
) {
|
||||
logger.debug("anthropic: fast mode unsupported, retrying without speed", {
|
||||
model: model.id,
|
||||
@@ -2275,7 +2215,7 @@ const streamAnthropicOnce = (
|
||||
continue;
|
||||
}
|
||||
const isTransientEnvelopeFailure =
|
||||
isTransientStreamParseError(streamFailure) || isTransientStreamEnvelopeError(streamFailure);
|
||||
AIError.isTransientStreamParseError(streamFailure) || AIError.isStreamEnvelopeError(streamFailure);
|
||||
const isLocalIdleTimeout =
|
||||
streamFailure === idleTimeoutAbortError ||
|
||||
(streamFailure instanceof Error && streamFailure.message === idleTimeoutAbortError.message);
|
||||
@@ -2298,7 +2238,9 @@ const streamAnthropicOnce = (
|
||||
// 429/529-style failures: retrying sooner than the server asked is a
|
||||
// guaranteed failure that just burns the retry budget.
|
||||
const headerDelayMs =
|
||||
streamFailure instanceof AnthropicApiError ? retryDelayFromHeaders(streamFailure.headers) : undefined;
|
||||
streamFailure instanceof Error && streamFailure instanceof AnthropicApiError
|
||||
? retryDelayFromHeaders(streamFailure.headers)
|
||||
: undefined;
|
||||
const delayMs = headerDelayMs !== undefined ? Math.max(headerDelayMs, backoffDelayMs) : backoffDelayMs;
|
||||
if (options?.providerRetryWait) {
|
||||
await options.providerRetryWait(delayMs, options.signal);
|
||||
@@ -2315,7 +2257,7 @@ const streamAnthropicOnce = (
|
||||
firstTokenTime = undefined;
|
||||
}
|
||||
}
|
||||
output.duration = Date.now() - startTime;
|
||||
output.duration = performance.now() - startTime;
|
||||
if (firstTokenTime) output.ttft = firstTokenTime - startTime;
|
||||
if (dropFastMode && resolveServiceTier(options?.serviceTier, model.provider) === "priority") {
|
||||
output.disabledFeatures = [...(output.disabledFeatures ?? []), "priority"];
|
||||
@@ -2324,23 +2266,19 @@ const streamAnthropicOnce = (
|
||||
stream.end();
|
||||
} catch (error) {
|
||||
for (const block of output.content) {
|
||||
delete (block as { index?: number }).index;
|
||||
delete (block as { partialJson?: string }).partialJson;
|
||||
delete (block as { lastParseLen?: number }).lastParseLen;
|
||||
if (block.type === "toolCall") clearStreamingPartialJson(block);
|
||||
}
|
||||
const firstEventTimeoutError = activeAbortTracker.getLocalAbortReason();
|
||||
output.stopReason = activeAbortTracker.wasCallerAbort() ? "aborted" : "error";
|
||||
output.errorStatus = extractHttpStatusFromError(error);
|
||||
try {
|
||||
output.errorMessage =
|
||||
firstEventTimeoutError?.message ?? (await finalizeErrorMessage(error, rawRequestDump));
|
||||
output.errorMessage = rewriteCopilotError(output.errorMessage, error, model.provider);
|
||||
} catch {
|
||||
// finalizeErrorMessage must never take the stream down with it — a
|
||||
// throw here would skip stream.end() and hang result() forever.
|
||||
output.errorMessage = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
output.duration = Date.now() - startTime;
|
||||
const result = await AIError.finalize(error, {
|
||||
api: model.api,
|
||||
provider: model.provider,
|
||||
abortTracker: activeAbortTracker,
|
||||
rawRequestDump,
|
||||
});
|
||||
output.stopReason = result.stopReason;
|
||||
output.errorStatus = result.status;
|
||||
output.errorId = result.id;
|
||||
output.errorMessage = result.message;
|
||||
output.duration = performance.now() - startTime;
|
||||
if (firstTokenTime) output.ttft = firstTokenTime - startTime;
|
||||
stream.push({ type: "error", reason: output.stopReason, error: output });
|
||||
stream.end();
|
||||
@@ -2630,7 +2568,7 @@ function ensureMaxTokensForThinking(params: MessageCreateParamsStreaming, maxAll
|
||||
|
||||
const clampedBudget = raisedMaxTokens - OUTPUT_FALLBACK_BUFFER;
|
||||
if (clampedBudget <= 0) {
|
||||
throw new Error(
|
||||
throw new AIError.ConfigurationError(
|
||||
`Anthropic thinking budget requires max_tokens greater than ${OUTPUT_FALLBACK_BUFFER}; got ${raisedMaxTokens}`,
|
||||
);
|
||||
}
|
||||
@@ -3237,7 +3175,7 @@ export function convertAnthropicMessages(
|
||||
if (block.thinking.trim().length === 0) continue;
|
||||
blocks.push({
|
||||
type: "text",
|
||||
text: block.thinking.toWellFormed(),
|
||||
text: renderDemotedThinking(model.id, block.thinking),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
@@ -3259,7 +3197,7 @@ export function convertAnthropicMessages(
|
||||
} else {
|
||||
blocks.push({
|
||||
type: "text",
|
||||
text: block.thinking.toWellFormed(),
|
||||
text: renderDemotedThinking(model.id, block.thinking),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -23,6 +23,7 @@ import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { $env, isEnoent, logger } from "@oh-my-pi/pi-utils";
|
||||
import * as AIError from "../error";
|
||||
import type { FetchImpl } from "../types";
|
||||
import { raceWithSignal } from "../utils/abort";
|
||||
import type { AwsCredentials } from "./aws-sigv4";
|
||||
@@ -111,9 +112,10 @@ async function resolveFresh(
|
||||
if (imdsCreds) return imdsCreds;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
throw new AIError.AwsCredentialsError(
|
||||
`Unable to resolve AWS credentials. Set AWS_ACCESS_KEY_ID+AWS_SECRET_ACCESS_KEY, ` +
|
||||
`or configure profile '${profile}' in ~/.aws/credentials (or ~/.aws/config for SSO).`,
|
||||
"resolution",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -245,11 +247,17 @@ async function readSsoCredentials(
|
||||
|
||||
const token = await loadSsoCachedToken(startUrl, sessionName);
|
||||
if (!token?.accessToken) {
|
||||
throw new Error(`AWS SSO token for ${startUrl} not found in ~/.aws/sso/cache. Run 'aws sso login' first.`);
|
||||
throw new AIError.AwsCredentialsError(
|
||||
`AWS SSO token for ${startUrl} not found in ~/.aws/sso/cache. Run 'aws sso login' first.`,
|
||||
"sso-token-missing",
|
||||
);
|
||||
}
|
||||
const expiresAt = token.expiresAt ? Date.parse(token.expiresAt) : Number.POSITIVE_INFINITY;
|
||||
if (Number.isFinite(expiresAt) && expiresAt <= Date.now()) {
|
||||
throw new Error(`AWS SSO token for ${startUrl} has expired. Run 'aws sso login' to refresh.`);
|
||||
throw new AIError.AwsCredentialsError(
|
||||
`AWS SSO token for ${startUrl} has expired. Run 'aws sso login' to refresh.`,
|
||||
"sso-token-expired",
|
||||
);
|
||||
}
|
||||
|
||||
const url =
|
||||
@@ -263,13 +271,20 @@ async function readSsoCredentials(
|
||||
});
|
||||
if (!response.ok) {
|
||||
const body = await response.text().catch(() => "");
|
||||
throw new Error(`AWS SSO GetRoleCredentials failed: ${response.status} ${body.slice(0, 200)}`);
|
||||
throw new AIError.AwsCredentialsError(
|
||||
`AWS SSO GetRoleCredentials failed: ${response.status} ${body.slice(0, 200)}`,
|
||||
"sso-role",
|
||||
);
|
||||
}
|
||||
const json = (await response.json()) as {
|
||||
roleCredentials?: { accessKeyId: string; secretAccessKey: string; sessionToken: string; expiration: number };
|
||||
};
|
||||
const role = json.roleCredentials;
|
||||
if (!role) throw new Error("AWS SSO GetRoleCredentials: missing roleCredentials in response");
|
||||
if (!role)
|
||||
throw new AIError.AwsCredentialsError(
|
||||
"AWS SSO GetRoleCredentials: missing roleCredentials in response",
|
||||
"sso-role",
|
||||
);
|
||||
|
||||
// region is honored at the caller; we only consume defaultRegion to keep the
|
||||
// param wired for symmetry with other resolution paths.
|
||||
@@ -359,23 +374,32 @@ async function readCredentialProcess(
|
||||
]);
|
||||
if (exitCode !== 0) {
|
||||
const tail = stderr.trim().slice(-512) || stdout.trim().slice(-512) || "(no output)";
|
||||
throw new Error(`AWS credential_process for profile '${profile}' exited ${exitCode}: ${tail}`);
|
||||
throw new AIError.AwsCredentialsError(
|
||||
`AWS credential_process for profile '${profile}' exited ${exitCode}: ${tail}`,
|
||||
"credential-process",
|
||||
);
|
||||
}
|
||||
|
||||
let parsed: CredentialProcessEnvelope;
|
||||
try {
|
||||
parsed = JSON.parse(stdout) as CredentialProcessEnvelope;
|
||||
} catch (err) {
|
||||
throw new Error(`AWS credential_process for profile '${profile}' did not emit valid JSON: ${String(err)}`);
|
||||
throw new AIError.AwsCredentialsError(
|
||||
`AWS credential_process for profile '${profile}' did not emit valid JSON: ${String(err)}`,
|
||||
"credential-process",
|
||||
{ cause: err },
|
||||
);
|
||||
}
|
||||
if (parsed.Version !== 1) {
|
||||
throw new Error(
|
||||
throw new AIError.AwsCredentialsError(
|
||||
`AWS credential_process for profile '${profile}' returned unsupported Version ${parsed.Version ?? "<missing>"}; expected 1.`,
|
||||
"credential-process",
|
||||
);
|
||||
}
|
||||
if (!parsed.AccessKeyId || !parsed.SecretAccessKey) {
|
||||
throw new Error(
|
||||
throw new AIError.AwsCredentialsError(
|
||||
`AWS credential_process for profile '${profile}' returned envelope without AccessKeyId/SecretAccessKey.`,
|
||||
"credential-process",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -397,7 +421,10 @@ async function readCredentialProcess(
|
||||
function buildCredentialProcessArgv(profile: string, command: string): string[] {
|
||||
const tokens = tokenizeCredentialProcessCommand(command);
|
||||
if (tokens.length === 0) {
|
||||
throw new Error(`AWS credential_process for profile '${profile}' is empty.`);
|
||||
throw new AIError.AwsCredentialsError(
|
||||
`AWS credential_process for profile '${profile}' is empty.`,
|
||||
"credential-process",
|
||||
);
|
||||
}
|
||||
if (process.platform === "win32" && isBatchScript(tokens[0])) {
|
||||
return ["cmd.exe", "/d", "/s", "/c", command];
|
||||
@@ -479,7 +506,10 @@ export function tokenizeCredentialProcessCommand(cmd: string): string[] {
|
||||
current += ch;
|
||||
}
|
||||
if (mode !== "normal") {
|
||||
throw new Error("AWS credential_process command has an unterminated quote.");
|
||||
throw new AIError.AwsCredentialsError(
|
||||
"AWS credential_process command has an unterminated quote.",
|
||||
"credential-process",
|
||||
);
|
||||
}
|
||||
if (hasToken) tokens.push(current);
|
||||
return tokens;
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
* practice (`:event-type`, `:message-type`, `:content-type`, `:exception-type`).
|
||||
*/
|
||||
|
||||
import * as AIError from "../error";
|
||||
|
||||
const PRELUDE_LEN = 8;
|
||||
const PRELUDE_CRC_LEN = 4;
|
||||
const MESSAGE_CRC_LEN = 4;
|
||||
@@ -30,20 +32,8 @@ export interface EventStreamMessage {
|
||||
}
|
||||
|
||||
/** CRC32 (IEEE / zlib polynomial 0xEDB88320), matches `@aws-crypto/crc32`. */
|
||||
const CRC_TABLE = (() => {
|
||||
const t = new Uint32Array(256);
|
||||
for (let i = 0; i < 256; i++) {
|
||||
let c = i;
|
||||
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
||||
t[i] = c >>> 0;
|
||||
}
|
||||
return t;
|
||||
})();
|
||||
|
||||
export function crc32(bytes: Uint8Array, seed = 0): number {
|
||||
let c = (seed ^ 0xffffffff) >>> 0;
|
||||
for (let i = 0; i < bytes.length; i++) c = (CRC_TABLE[(c ^ bytes[i]) & 0xff] ^ (c >>> 8)) >>> 0;
|
||||
return (c ^ 0xffffffff) >>> 0;
|
||||
export function crc32(bytes: Uint8Array): number {
|
||||
return Bun.hash.crc32(bytes) >>> 0;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -53,17 +43,18 @@ export function crc32(bytes: Uint8Array, seed = 0): number {
|
||||
* frames.
|
||||
*/
|
||||
export function decodeMessage(frame: Uint8Array): EventStreamMessage {
|
||||
if (frame.length < MIN_MESSAGE_LEN) throw new Error("eventstream: frame too short");
|
||||
if (frame.length < MIN_MESSAGE_LEN) throw new AIError.EventStreamFrameError("frame too short");
|
||||
const view = new DataView(frame.buffer, frame.byteOffset, frame.byteLength);
|
||||
const total = view.getUint32(0, false);
|
||||
if (total !== frame.length) throw new Error(`eventstream: framed length ${total} != buffer ${frame.length}`);
|
||||
if (total !== frame.length)
|
||||
throw new AIError.EventStreamFrameError(`framed length ${total} != buffer ${frame.length}`);
|
||||
const headersLen = view.getUint32(4, false);
|
||||
const preludeCrc = view.getUint32(8, false);
|
||||
const computedPreludeCrc = crc32(frame.subarray(0, PRELUDE_LEN));
|
||||
if (computedPreludeCrc !== preludeCrc) throw new Error("eventstream: prelude CRC mismatch");
|
||||
if (computedPreludeCrc !== preludeCrc) throw new AIError.EventStreamFrameError("prelude CRC mismatch");
|
||||
const msgCrc = view.getUint32(total - MESSAGE_CRC_LEN, false);
|
||||
const computedMsgCrc = crc32(frame.subarray(0, total - MESSAGE_CRC_LEN));
|
||||
if (computedMsgCrc !== msgCrc) throw new Error("eventstream: message CRC mismatch");
|
||||
if (computedMsgCrc !== msgCrc) throw new AIError.EventStreamFrameError("message CRC mismatch");
|
||||
|
||||
const headersBytes = frame.subarray(HEADER_BLOCK_OFFSET, HEADER_BLOCK_OFFSET + headersLen);
|
||||
const payload = frame.subarray(HEADER_BLOCK_OFFSET + headersLen, total - MESSAGE_CRC_LEN);
|
||||
@@ -136,7 +127,7 @@ function parseHeaders(buf: Uint8Array): Record<string, string> {
|
||||
break;
|
||||
}
|
||||
default:
|
||||
throw new Error(`eventstream: unknown header value type ${type}`);
|
||||
throw new AIError.EventStreamFrameError(`unknown header value type ${type}`);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
@@ -170,7 +161,7 @@ export async function* decodeEventStream(source: ReadableStream<Uint8Array>): As
|
||||
while (buf.length - offset >= 4) {
|
||||
const dv = new DataView(buf.buffer, buf.byteOffset + offset, buf.length - offset);
|
||||
const total = dv.getUint32(0, false);
|
||||
if (total < MIN_MESSAGE_LEN) throw new Error(`eventstream: total length ${total} below minimum`);
|
||||
if (total < MIN_MESSAGE_LEN) throw new AIError.EventStreamFrameError(`total length ${total} below minimum`);
|
||||
if (buf.length - offset < total) break;
|
||||
const frame = buf.subarray(offset, offset + total);
|
||||
yield decodeMessage(frame);
|
||||
@@ -179,7 +170,7 @@ export async function* decodeEventStream(source: ReadableStream<Uint8Array>): As
|
||||
if (offset > 0) buf = buf.slice(offset);
|
||||
if (done) break;
|
||||
}
|
||||
if (buf.length > 0) throw new Error("eventstream: truncated message at end of stream");
|
||||
if (buf.length > 0) throw new AIError.EventStreamFrameError("truncated message at end of stream");
|
||||
completed = true;
|
||||
} finally {
|
||||
// On abnormal exit (consumer threw/broke, decode error) cancel the body so the
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user