Merge remote-tracking branch 'origin/main' into pr-6383

This commit is contained in:
black lodge resident
2026-07-23 14:30:30 +02:00
845 changed files with 72081 additions and 12016 deletions
-148
View File
@@ -1,148 +0,0 @@
# The list of vouched (or actively denounced) users for this repository.
#
# Only vouched users can open PRs here; unvouched/denounced PRs are
# auto-closed by .github/workflows/vouch-pr.yml (mitchellh/vouch check-pr).
# Issues are intentionally NOT gated here — robomp triages those.
#
# Write-access collaborators and bots are auto-allowed and need no entry.
# A denounced user ("-handle") is always blocked, even if also listed.
#
# Syntax:
# - One handle per line (without @), sorted alphabetically.
# - Optional platform prefix: `platform:username` (default platform: github).
# - Denounce by prefixing with minus: `-username` / `-platform:username`.
# - Optional free-text reason after a space following the handle.
#
# Maintainers manage this list by commenting `!vouch` / `!denounce [user]`
# on a discussion (see .github/workflows/vouch-manage.yml).
#
# Seed (2026-06-19): authors with >=2 merged PRs in the prior 6 months.
# Audit found 0 denounce-worthy actors; all reverts were maintainer
# technical rollbacks, not abuse. See the PR/commit history for provenance.
0ttik
a-glapinski
ak4153
any-victor
apoc
arg3t
asafmah
azais-corentin
basedcorp99
baylee4
belchetz
bjin
cagedbird043
cexll
chan1103
chuaaron
ckumar1
cyjaysong
daandden
daaximus
danzaio
darkphilosophy
deprecatedluke
djdembeck
dmarsh-gusto
dylanbohlender
elikoga
enieuwy
fettpl
flare576
foreveryoungpp
fryuni
gratefuldave
h4vc
habibpro1999
handlecusion
haosenwang1018
heyitsgilbert
hezhiyang2000
hpost
iacore
igasmi
infernix
inprealpha
insodimension
itertea
itzrnvr
jaaneek
jagravnaik
jasonw22
jchristman
jdavv
jeffscottward
jiwangyihao
joswha
kamafozilov
kamijotoma
kenmege
korri123
kukkerem
lance0
larkinwc
ldx
lederniermagicien
loftiskg
lyc-aon
m3ridian-zero
makomakogo
masonc15
mathews-tom
mattwilkinsonn
maximhar
maxvisionai
metaphorics
mikeei
mokto
moutazhaq
mouyase
mq1n
muness
nnk97
nszceta
ogrodev
oldschoola
paralin
parsifa1
pgupta-git
phanthh
pidevxplay
pppobear
qfrtt
ravshansbox
rburketaylor
renstillmann
riverpilot
romanalexander
rznmkx
scarthread
segmentationf4u1t
serverinspector
shoucandanghehe
shyndman
sit
slact
smileynet
sundbp
superhedge22
tbui17
tc97222
tdiant
tjboudreaux
tsagi2045
turbomolli
unravl
usr-bin-roygbiv
vmcall
voidchecksum
voiys
watzon
wodenjay
wolfiesch
xaviergmail
zakhar-kogan
zamorakpds
zekdevs
zommiommy
+7 -3
View File
@@ -34,6 +34,10 @@ inputs:
cross-arch linux build, or set alone for a host-arch (x64) linux build.
required: false
default: ""
libc:
description: Optional Linux libc artifact qualifier (for example, musl)
required: false
default: ""
rust_checks:
description: Run clippy/rustfmt checks (only one matrix entry should set this)
required: false
@@ -135,7 +139,7 @@ runs:
# --- Rust flags (shared) ------------------------------------------------
- name: Configure native Rust flags
if: inputs.target == ''
if: inputs.target == '' || inputs.arch == 'x64'
shell: bash
env:
TARGET_ARCH: ${{ inputs.arch }}
@@ -178,7 +182,7 @@ runs:
if: steps.detect.outputs.on_infra == 'false'
uses: Swatinem/rust-cache@v2
with:
shared-key: native-${{ inputs.platform }}-${{ inputs.arch }}-${{ inputs.variant || 'default' }}-h${{ inputs.hash }}
shared-key: native-${{ inputs.platform }}-${{ inputs.libc || 'default' }}-${{ inputs.arch }}-${{ inputs.variant || 'default' }}-h${{ inputs.hash }}
cache-on-failure: true
save-if: ${{ inputs.save_cache == 'true' }}
cache-workspace-crates: true
@@ -305,7 +309,7 @@ runs:
- name: Upload native addon(s)
uses: actions/upload-artifact@v4
with:
name: pi-natives-${{ inputs.platform }}-${{ inputs.arch }}${{ inputs.variant && format('-{0}', inputs.variant) || '' }}-h${{ inputs.hash }}
name: pi-natives-${{ inputs.platform }}-${{ inputs.libc && format('{0}-', inputs.libc) || '' }}${{ inputs.arch }}${{ inputs.variant && format('-{0}', inputs.variant) || '' }}-h${{ inputs.hash }}
path: packages/natives/native/pi_natives.${{ inputs.platform }}-${{ inputs.arch }}*.node
if-no-files-found: error
# Explicit so the native_artifact_lookup canary keeps working even if
+52 -12
View File
@@ -3,14 +3,12 @@ name: CI
on:
push:
branches: [main]
# Vouch bookkeeping commits (mitchellh/vouch writes VOUCHED.td back to
# main on !vouch/!denounce/!unvouch) only edit the vouch list and need no
# build. Skip CI when a main push changes nothing but the vouch file; a
# push that also touches anything else still runs the full matrix.
paths-ignore:
- .github/VOUCHED.td
paths:
- "packages/**"
pull_request:
branches: [main]
paths:
- "packages/**"
workflow_dispatch:
inputs:
skip_npm:
@@ -144,6 +142,8 @@ jobs:
# `actions/upload-artifact` `name:` template in build-native action.
cross_platform_required=(
"pi-natives-linux-arm64-h${hash}"
"pi-natives-linux-musl-x64-baseline-h${hash}"
"pi-natives-linux-musl-arm64-h${hash}"
"pi-natives-darwin-x64-baseline-h${hash}"
"pi-natives-darwin-arm64-h${hash}"
"pi-natives-win32-x64-baseline-h${hash}"
@@ -238,7 +238,7 @@ jobs:
# building the artifacts that ship in releases. Skipped on main when
# native_artifact_lookup already found a recent run with all artifacts intact.
native_cross_platform_kata:
name: "Native: ${{ matrix.platform }} ${{ matrix.arch }}"
name: "Native: ${{ matrix.platform }} ${{ matrix.libc || '' }} ${{ matrix.arch }}"
needs: [release_metadata, native_artifact_lookup]
if: ${{ needs.release_metadata.outputs.is-release == 'true' || (github.event_name == 'push' && github.ref == 'refs/heads/main' && needs.native_artifact_lookup.outputs.cross-platform-run-id == '') }}
strategy:
@@ -246,6 +246,8 @@ jobs:
matrix:
include:
- { os: omp-kata, platform: linux, arch: arm64, target: aarch64-unknown-linux-gnu }
- { os: omp-kata, platform: linux, libc: musl, arch: x64, target: x86_64-unknown-linux-musl, variant: baseline }
- { os: omp-kata, platform: linux, libc: musl, arch: arm64, target: aarch64-unknown-linux-musl }
- { os: omp-kata, platform: win32, arch: x64, target: x86_64-pc-windows-msvc, variant: baseline }
runs-on: ${{ matrix.os }}
steps:
@@ -255,9 +257,10 @@ jobs:
hash: ${{ needs.native_artifact_lookup.outputs.source-hash }}
platform: ${{ matrix.platform }}
arch: ${{ matrix.arch }}
libc: ${{ matrix.libc }}
variant: ${{ matrix.variant }}
target: ${{ matrix.target }}
glibc: ${{ matrix.platform == 'linux' && env.GLIBC_FLOOR || '' }}
glibc: ${{ matrix.platform == 'linux' && matrix.libc != 'musl' && env.GLIBC_FLOOR || '' }}
save_cache: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
native_cross_platform_macos:
@@ -575,6 +578,16 @@ jobs:
arch: x64,
target_id: linux-x64,
binary_path: packages/coding-agent/binaries/omp-linux-x64,
native_artifact_pattern: pi-natives-linux-x64-*,
}
- {
os: ubuntu-22.04,
platform: linux,
libc: musl,
arch: x64,
target_id: linux-musl-x64,
binary_path: packages/coding-agent/binaries/omp-linux-musl-x64,
native_artifact_pattern: pi-natives-linux-musl-x64-*,
}
- {
os: ubuntu-24.04-arm,
@@ -582,6 +595,16 @@ jobs:
arch: arm64,
target_id: linux-arm64,
binary_path: packages/coding-agent/binaries/omp-linux-arm64,
native_artifact_pattern: pi-natives-linux-arm64*,
}
- {
os: ubuntu-24.04-arm,
platform: linux,
libc: musl,
arch: arm64,
target_id: linux-musl-arm64,
binary_path: packages/coding-agent/binaries/omp-linux-musl-arm64,
native_artifact_pattern: pi-natives-linux-musl-arm64*,
}
- {
os: macos-15-intel,
@@ -589,6 +612,7 @@ jobs:
arch: x64,
target_id: darwin-x64,
binary_path: packages/coding-agent/binaries/omp-darwin-x64,
native_artifact_pattern: pi-natives-darwin-x64*,
}
- {
os: macos-14,
@@ -596,6 +620,7 @@ jobs:
arch: arm64,
target_id: darwin-arm64,
binary_path: packages/coding-agent/binaries/omp-darwin-arm64,
native_artifact_pattern: pi-natives-darwin-arm64*,
}
- {
os: ubuntu-22.04,
@@ -603,6 +628,7 @@ jobs:
arch: x64,
target_id: win32-x64,
binary_path: packages/coding-agent/binaries/omp-windows-x64.exe,
native_artifact_pattern: pi-natives-win32-x64*,
}
runs-on: ${{ matrix.os }}
permissions:
@@ -636,7 +662,7 @@ jobs:
- name: Download native addon(s)
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
pattern: pi-natives-${{ matrix.platform }}-${{ matrix.arch }}*-h${{ needs.native_artifact_lookup.outputs.source-hash }}
pattern: ${{ matrix.native_artifact_pattern }}-h${{ needs.native_artifact_lookup.outputs.source-hash }}
path: packages/natives/native
merge-multiple: true
- name: Build release binary
@@ -659,15 +685,29 @@ jobs:
# Windows binary is cross-built on Linux, so we have no Windows runner
# to smoke it on. Cross-build correctness is verified via the napi
# entry-point exports (see build-native action) and the bun
# `--compile --target=bun-windows-x64-*` cross-compile.
# `--compile --target=bun-windows-x64-*` cross-compile. Musl binaries
# need the musl loader, which glibc runners lack — they are smoked in
# the Alpine container step below instead.
- name: Smoke release binary
if: matrix.platform != 'win32'
if: matrix.platform != 'win32' && matrix.libc != 'musl'
run: |
runtime_dir="$(mktemp -d)"
HOME="$runtime_dir/home" XDG_DATA_HOME="$runtime_dir/xdg" "${{ matrix.binary_path }}" --version
HOME="$runtime_dir/home" XDG_DATA_HOME="$runtime_dir/xdg" "${{ matrix.binary_path }}" --smoke-test
- name: Smoke musl release binary on Alpine
if: matrix.libc == 'musl'
run: |
binary="$(realpath "${{ matrix.binary_path }}")"
# Bun's musl-target binaries link libstdc++/libgcc dynamically;
# Alpine users install them alongside the binary (same as bun itself).
docker run --rm -v "$binary:/usr/local/bin/omp:ro" alpine:3.22 sh -ec '
apk add --no-cache libstdc++ libgcc >/dev/null
runtime_dir="$(mktemp -d)"
HOME="$runtime_dir/home" XDG_DATA_HOME="$runtime_dir/xdg" omp --version
HOME="$runtime_dir/home" XDG_DATA_HOME="$runtime_dir/xdg" omp --smoke-test
'
- name: Publish native addon package
if: ${{ !inputs.skip_npm }}
if: ${{ !inputs.skip_npm && matrix.libc != 'musl' }}
env:
# Fallback auth: setup-node wrote an .npmrc referencing
# NODE_AUTH_TOKEN; npm uses it only when OIDC has no trusted
-44
View File
@@ -1,44 +0,0 @@
name: Vouch (manage)
# Let maintainers vouch/denounce/unvouch by commenting on a Discussion:
# !vouch vouch the discussion author
# !vouch @user [reason] vouch a specific user
# !denounce [@user] [reason]
# !unvouch [@user]
# Only collaborators with admin/maintain/write are honored (triage EXCLUDED;
# upstream's default `roles` includes triage, which we override below).
#
# Commits the VOUCHED.td change back to the default branch using the stock
# GITHUB_TOKEN (no GitHub App needed). NOTE: this works only while the default
# branch is UNPROTECTED — GITHUB_TOKEN cannot bypass branch protection. If you
# protect the branch later, switch back to a GitHub App token on a bypass list.
on:
discussion_comment:
types: [created]
# Serialize writes to VOUCHED.td so concurrent vouches don't clobber.
concurrency:
group: vouch-manage
cancel-in-progress: false
permissions:
contents: write # commit VOUCHED.td
discussions: write # read the comment / acknowledge
jobs:
manage:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: mitchellh/vouch/action/manage-by-discussion@v1
with:
discussion-number: ${{ github.event.discussion.number }}
comment-node-id: ${{ github.event.comment.node_id }}
vouch-keyword: "!vouch"
denounce-keyword: "!denounce"
unvouch-keyword: "!unvouch"
roles: admin,maintain,write
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-51
View File
@@ -1,51 +0,0 @@
name: Vouch (PR gate)
# Auto-close PRs from unvouched or denounced users. Issues are left alone
# (robomp triages those). Runs under `pull_request_target` so the token can
# act on fork PRs; this job does NO checkout and runs NO PR code — it only
# reads .github/VOUCHED.td from the base repo and calls the GitHub API.
on:
pull_request_target:
types: [opened, reopened, ready_for_review]
permissions:
contents: read # read VOUCHED.td from the base branch
pull-requests: write # close + comment
issues: write # add the `vouched` label (labels use the Issues API)
concurrency:
group: vouch-pr-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
check:
runs-on: ubuntu-latest
steps:
- id: vouch
uses: mitchellh/vouch/action/check-pr@v1
with:
pr-number: ${{ github.event.pull_request.number }}
auto-close: true
require-vouch: true # block unvouched, not only denounced
# vouched-file: .github/VOUCHED.td (default)
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Survivors of the gate (vouched, or auto-allowed collaborators/bots) get
# a FRESH `vouched` label on every (re)open / ready-for-review. robomp
# reviews ONLY on that label event (ROBOMP_PR_REVIEW_TRIGGER=vouched_label),
# so review is always triggered by a just-validated PR, never a stale label.
- name: Label vouched PRs for robomp review
if: ${{ steps.vouch.outputs.status == 'vouched' || steps.vouch.outputs.status == 'allowed' }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
PR: ${{ github.event.pull_request.number }}
run: |
gh label create vouched --repo "$REPO" --color 2da44e --description "Passed the vouch gate" --force
# remove+add so a fresh `labeled` event fires even when the label
# persisted across close/reopen (re-adding an existing label emits no
# event). The check above just re-validated, so trust is never stale.
gh pr edit "$PR" --repo "$REPO" --remove-label vouched || true
gh pr edit "$PR" --repo "$REPO" --add-label vouched
+1 -1
View File
@@ -1,5 +1,5 @@
# Dependencies
node_modules/
node_modules
.npm/
# Build output
+74 -38
View File
@@ -1,54 +1,90 @@
# Contributing to oh-my-pi
Thanks for your interest in contributing. This project uses a lightweight
**vouch** system to decide who can open pull requests. Please read this before
opening a PR.
Pull requests are welcome. Keep them focused, understand the work you submit,
and be prepared to explain and maintain it.
## TL;DR
> [!NOTE]
> Pull requests are **temporarily open to everyone** as a trial. We previously
> required a vouch before accepting PRs; that requirement is lifted for now
> while we evaluate how open contributions go. Depending on the results, the
> vouch system may return.
- **Issues are open to everyone.** File bugs, feature requests, and questions
freely — they are triaged automatically.
- **Pull requests require a vouch.** A PR whose author is not vouched (or is
denounced) is **closed automatically**. If you are not yet vouched, do **not**
open a PR to get noticed — it will be closed on sight. Start a Discussion and
ask to be vouched first (see below).
## Before you start
## Who can open PRs
### Small changes
A pull request is accepted when its author is any of:
Bug fixes, documentation updates, and narrowly scoped improvements can go
straight to a pull request.
- a repository collaborator (write access or above), or a bot; or
- listed — without a leading `-` — in [`.github/VOUCHED.td`](.github/VOUCHED.td).
### Major changes
Anyone **denounced** (prefixed with `-` in that file) is always blocked.
Discuss major features and broad architectural or behavioral changes in
[Discord](https://discord.gg/4NMW9cdXZa) **before writing the implementation**.
This includes new subsystems, large UI changes, new dependencies, and changes
that span several packages. A GitHub issue is not a substitute for this
discussion, and prior discussion does not guarantee that a pull request will be
merged.
## Getting vouched
### Do not open an issue for work you are about to submit
1. Open a [Discussion](../../discussions) (or comment on an existing one)
describing what you'd like to contribute.
2. A maintainer vouches you by commenting **`!vouch`** (vouches the discussion
author) or **`!vouch @your-handle`** on that discussion.
3. Once you appear in `.github/VOUCHED.td`, open your PR — it stays open and is
reviewed.
If you intend to implement a change yourself, **do not create an issue for it
first**. robomp treats actionable issues as work to pick up and may start the
same fix in parallel, wasting compute and maintainer time.
Maintainers may also `!denounce [@user]` and `!unvouch [@user]`. Only
collaborators with admin/maintain/write can run these commands.
Open an issue when you are reporting a problem or proposing work that you are
not already turning into a pull request. If a relevant issue already exists,
link it from your pull request instead of creating another one.
## What happens to your PR
## AI-assisted contributions
| You are… | Result |
| --- | --- |
| Vouched (or a collaborator) | PR stays open → automated review → human review |
| Not vouched | PR closed with a comment — get vouched, then reopen or open a new PR |
| Denounced | PR closed |
AI agents are welcome as tools, not as unattended contributors. Do not give an
agent a vague goal and submit whatever it produces.
Pushing more commits to an open, vouched PR is fine — it remains vouched.
Before opening a pull request, you must:
## The VOUCHED.td file
- constrain the agent to the agreed scope and reject unrelated changes;
- review every changed file and understand the resulting behavior;
- run the relevant checks and exercise the changed behavior yourself; and
- submit the pull request only after that review, rather than letting an agent
publish it autonomously.
[`.github/VOUCHED.td`](.github/VOUCHED.td) is the source of truth: one handle per
line, sorted alphabetically, optionally `platform:handle`, with `-` marking a
denouncement and an optional reason after the handle. The format follows
[mitchellh/vouch](https://github.com/mitchellh/vouch); the denouncement list is
intentionally public so other projects can reuse our prior knowledge of bad
actors.
You are responsible for the code, regardless of who or what generated it.
## Pull request requirements
Every pull request body **MUST include at least one sentence written by you, in
your own words**, explaining what changed and why. A generated summary, pasted
agent transcript, or checklist alone does not satisfy this requirement.
One honest line is enough:
> I reviewed the full diff; this change fixes duplicate PR reviews by reusing
> the existing delivery guard.
You **MUST verify that the change works as intended**. `bun check` and automated
tests are expected where relevant, but they are not proof that the behavior
works. Exercise the changed path yourself and report the exact scenario and
result in the pull request:
- for a bug fix, reproduce the bug and confirm the same reproduction no longer
fails;
- for a feature, launch the product and use the feature end to end; and
- for a UI change, interact with it and inspect the rendered result.
“`bun check` passes” by itself is not sufficient verification. For coding-agent
development commands and repository structure, see
[`packages/coding-agent/DEVELOPMENT.md`](packages/coding-agent/DEVELOPMENT.md).
Keep each pull request to one logical change. Avoid unrelated cleanup,
drive-by refactors, generated noise, or features that were not part of the
agreed scope.
## Review
Maintainers review the submitted behavior and the contributor's understanding
of it—not the volume of generated code. Respond to review feedback yourself,
and only apply suggestions you have checked.
Pull requests may be closed when they skip required prior discussion, lack the
human-written explanation, contain unreviewed agent output, or mix unrelated
changes.
Generated
+241 -228
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -4,7 +4,7 @@ exclude = ["crates/vendor/brush-core", "crates/vendor/brush-builtins"]
resolver = "3"
[workspace.package]
version = "17.0.0"
version = "17.0.8"
edition = "2024"
license = "MIT"
authors = ["Can Boluk"]
+46 -6
View File
@@ -26,6 +26,12 @@ The most capable agent surface that ships. Continuously tuned by real-world use
**40+** providers · **32** built-in tools · **14** lsp ops · **28** dap ops · **~55k** lines of Rust core.
> [!NOTE]
> Pull requests are **temporarily open to everyone** as a trial. We previously
> required a vouch before accepting PRs; that requirement is lifted for now
> while we evaluate how open contributions go. Depending on the results, the
> vouch system may return.
## Install
**macOS · Linux**
@@ -274,6 +280,16 @@ Setting-gated, off by default: `github`, `inspect_image`, `tts`, `checkpoint`, `
[Full reference →](https://omp.sh/docs/tools)
### Prompt controls
Three standalone, lowercase words opt a turn into specialized agent behavior:
- `ultrathink` — request careful multi-step reasoning and the highest supported automatic thinking effort.
- `orchestrate` — run substantial independent work through parallel subagents and verify each phase.
- `workflowz` — build a deterministic multi-subagent workflow with the active `task` tool.
They trigger only in prose, not inside code spans, fenced code blocks, XML/HTML sections, identifiers, or paths. See [Magic keywords](docs/magic-keywords.md) for exact matching rules and configuration.
## Forty-plus providers, hundreds of models, _one /model away_.
Roles route work by intent. `default` for normal turns. `smol` for cheap subagent fan-out. `slow` for deep reasoning. `plan` for plan mode. `commit` for changelogs. Override at launch with `--smol`, `--slow`, or `--plan`; cycle through the configured models for the active role with `Ctrl+P`. Swap the active model mid-session with the `/model` slash command.
@@ -298,6 +314,32 @@ OpenAI-compatible `/v1/models`. Local instances skip the key.
Ollama `local` · Ollama Cloud · LM Studio `local` · llama.cpp `local` · vLLM `local` · LiteLLM
### Custom OpenAI-compatible providers
Define custom providers in `~/.omp/agent/models.yml`:
```yaml
providers:
spark:
baseUrl: http://192.168.10.223:8000/v1
api: openai-completions
apiKey: dummy
models:
- id: minimax-m3
name: MiniMax M3
contextWindow: 100000
maxTokens: 32000
```
Run `omp models spark` to verify discovery. Then run `omp setup` and choose the model in the default-model step, or open `/model` in a session and assign it to the `default` role.
To preconfigure the default without the picker, add the selector to `~/.omp/agent/config.yml`:
```yaml
modelRoles:
default: spark/minimax-m3
```
### Four knobs that make routing useful
- **Custom providers** — Declare anything that speaks `openai-completions`, `openai-responses`, `openai-codex-responses`, `azure-openai-responses`, `anthropic-messages`, `google-generative-ai`, or `google-vertex` in `~/.omp/agent/models.yml`.
@@ -556,12 +598,10 @@ For architecture and contribution guidelines, see [packages/coding-agent/DEVELOP
## Contributing
Issues are open to everyone. **Pull requests require a vouch** — PRs from
unvouched or denounced authors are closed automatically. If you're not yet
vouched, open a [Discussion](https://github.com/can1357/oh-my-pi/discussions)
and ask a maintainer to `!vouch` you rather than opening a PR (which would be
closed on sight). See **[CONTRIBUTING.md](CONTRIBUTING.md)** and
[`.github/VOUCHED.td`](.github/VOUCHED.td) for the full policy.
Issues and pull requests are open to everyone. Open PRs are currently a
**trial** — the previous vouch requirement is lifted while we evaluate how it
goes, and it may return. See **[CONTRIBUTING.md](CONTRIBUTING.md)** for
guidelines on contributing.
---
+131 -143
View File
@@ -21,7 +21,7 @@
},
"packages/agent": {
"name": "@oh-my-pi/pi-agent-core",
"version": "17.0.0",
"version": "17.0.8",
"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": "17.0.0",
"version": "17.0.8",
"dependencies": {
"@bufbuild/protobuf": "catalog:",
"@oh-my-pi/pi-catalog": "catalog:",
@@ -55,7 +55,7 @@
},
"packages/catalog": {
"name": "@oh-my-pi/pi-catalog",
"version": "17.0.0",
"version": "17.0.8",
"dependencies": {
"@bufbuild/protobuf": "catalog:",
"@oh-my-pi/pi-utils": "catalog:",
@@ -69,13 +69,14 @@
},
"packages/coding-agent": {
"name": "@oh-my-pi/pi-coding-agent",
"version": "17.0.0",
"version": "17.0.8",
"bin": {
"omp": "src/cli.ts",
},
"dependencies": {
"@agentclientprotocol/sdk": "catalog:",
"@babel/parser": "catalog:",
"@babel/traverse": "catalog:",
"@mozilla/readability": "catalog:",
"@oh-my-pi/hashline": "catalog:",
"@oh-my-pi/omp-stats": "catalog:",
@@ -89,9 +90,14 @@
"@oh-my-pi/pi-wire": "catalog:",
"@oh-my-pi/snapcompact": "catalog:",
"@opentelemetry/api": "catalog:",
"@opentelemetry/api-logs": "catalog:",
"@opentelemetry/context-async-hooks": "catalog:",
"@opentelemetry/exporter-logs-otlp-proto": "catalog:",
"@opentelemetry/exporter-metrics-otlp-proto": "catalog:",
"@opentelemetry/exporter-trace-otlp-proto": "catalog:",
"@opentelemetry/resources": "catalog:",
"@opentelemetry/sdk-logs": "catalog:",
"@opentelemetry/sdk-metrics": "catalog:",
"@opentelemetry/sdk-trace-base": "catalog:",
"@opentelemetry/sdk-trace-node": "catalog:",
"@puppeteer/browsers": "catalog:",
@@ -99,7 +105,6 @@
"@xterm/headless": "catalog:",
"arktype": "catalog:",
"chalk": "catalog:",
"diff": "catalog:",
"fast-xml-parser": "catalog:",
"handlebars": "catalog:",
"header-generator": "catalog:",
@@ -139,9 +144,9 @@
},
"packages/hashline": {
"name": "@oh-my-pi/hashline",
"version": "17.0.0",
"version": "17.0.8",
"dependencies": {
"diff": "catalog:",
"@oh-my-pi/pi-natives": "catalog:",
"lru-cache": "catalog:",
},
"devDependencies": {
@@ -160,6 +165,7 @@
"@oh-my-pi/pi-ai": "catalog:",
"@oh-my-pi/pi-catalog": "catalog:",
"@oh-my-pi/pi-coding-agent": "catalog:",
"@oh-my-pi/pi-natives": "catalog:",
"@oh-my-pi/pi-utils": "catalog:",
"@oh-my-pi/typescript-edit-benchmark": "workspace:*",
"clsx": "^2.1.1",
@@ -182,13 +188,14 @@
},
"packages/mnemopi": {
"name": "@oh-my-pi/pi-mnemopi",
"version": "17.0.0",
"version": "17.0.8",
"bin": {
"mnemopi": "src/cli.ts",
},
"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:",
"lru-cache": "catalog:",
},
@@ -208,7 +215,7 @@
},
"packages/natives": {
"name": "@oh-my-pi/pi-natives",
"version": "17.0.0",
"version": "17.0.8",
"devDependencies": {
"@napi-rs/cli": "catalog:",
"@types/bun": "catalog:",
@@ -216,7 +223,7 @@
},
"packages/snapcompact": {
"name": "@oh-my-pi/snapcompact",
"version": "17.0.0",
"version": "17.0.8",
"dependencies": {
"@oh-my-pi/pi-ai": "catalog:",
"@oh-my-pi/pi-natives": "catalog:",
@@ -229,7 +236,7 @@
},
"packages/stats": {
"name": "@oh-my-pi/omp-stats",
"version": "17.0.0",
"version": "17.0.8",
"bin": {
"omp-stats": "./src/index.ts",
},
@@ -250,12 +257,13 @@
"@types/bun": "catalog:",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"linkedom": "catalog:",
"postcss": "catalog:",
},
},
"packages/swarm-extension": {
"name": "@oh-my-pi/swarm-extension",
"version": "17.0.0",
"version": "17.0.8",
"bin": {
"omp-swarm": "src/cli.ts",
},
@@ -271,7 +279,7 @@
},
"packages/tui": {
"name": "@oh-my-pi/pi-tui",
"version": "17.0.0",
"version": "17.0.8",
"dependencies": {
"@oh-my-pi/pi-natives": "catalog:",
"@oh-my-pi/pi-utils": "catalog:",
@@ -295,6 +303,7 @@
"@oh-my-pi/pi-agent-core": "catalog:",
"@oh-my-pi/pi-ai": "catalog:",
"@oh-my-pi/pi-coding-agent": "catalog:",
"@oh-my-pi/pi-natives": "catalog:",
"@oh-my-pi/pi-tui": "catalog:",
"@oh-my-pi/pi-utils": "catalog:",
"diff": "catalog:",
@@ -309,7 +318,7 @@
},
"packages/utils": {
"name": "@oh-my-pi/pi-utils",
"version": "17.0.0",
"version": "17.0.8",
"dependencies": {
"@oh-my-pi/pi-natives": "catalog:",
"handlebars": "catalog:",
@@ -322,7 +331,7 @@
},
"packages/wire": {
"name": "@oh-my-pi/pi-wire",
"version": "17.0.0",
"version": "17.0.8",
"devDependencies": {
"@types/bun": "catalog:",
},
@@ -363,24 +372,29 @@
"@huggingface/transformers": "^4.2.0",
"@mozilla/readability": "^0.6.0",
"@napi-rs/cli": "3.7.2",
"@oh-my-pi/hashline": "17.0.0",
"@oh-my-pi/omp-stats": "17.0.0",
"@oh-my-pi/pi-agent-core": "17.0.0",
"@oh-my-pi/pi-ai": "17.0.0",
"@oh-my-pi/pi-catalog": "17.0.0",
"@oh-my-pi/pi-coding-agent": "17.0.0",
"@oh-my-pi/pi-mnemopi": "17.0.0",
"@oh-my-pi/pi-natives": "17.0.0",
"@oh-my-pi/pi-tui": "17.0.0",
"@oh-my-pi/pi-utils": "17.0.0",
"@oh-my-pi/pi-wire": "17.0.0",
"@oh-my-pi/snapcompact": "17.0.0",
"@oh-my-pi/hashline": "17.0.8",
"@oh-my-pi/omp-stats": "17.0.8",
"@oh-my-pi/pi-agent-core": "17.0.8",
"@oh-my-pi/pi-ai": "17.0.8",
"@oh-my-pi/pi-catalog": "17.0.8",
"@oh-my-pi/pi-coding-agent": "17.0.8",
"@oh-my-pi/pi-mnemopi": "17.0.8",
"@oh-my-pi/pi-natives": "17.0.8",
"@oh-my-pi/pi-tui": "17.0.8",
"@oh-my-pi/pi-utils": "17.0.8",
"@oh-my-pi/pi-wire": "17.0.8",
"@oh-my-pi/snapcompact": "17.0.8",
"@opentelemetry/api": "^1.9.1",
"@opentelemetry/context-async-hooks": "^2.7.1",
"@opentelemetry/api-logs": "^0.220.0",
"@opentelemetry/context-async-hooks": "^2.9.0",
"@opentelemetry/exporter-logs-otlp-proto": "^0.220.0",
"@opentelemetry/exporter-metrics-otlp-proto": "^0.220.0",
"@opentelemetry/exporter-trace-otlp-proto": "^0.220.0",
"@opentelemetry/resources": "^2.7.1",
"@opentelemetry/sdk-trace-base": "^2.7.1",
"@opentelemetry/sdk-trace-node": "^2.7.1",
"@opentelemetry/resources": "^2.9.0",
"@opentelemetry/sdk-logs": "^0.220.0",
"@opentelemetry/sdk-metrics": "^2.9.0",
"@opentelemetry/sdk-trace-base": "^2.9.0",
"@opentelemetry/sdk-trace-node": "^2.9.0",
"@puppeteer/browsers": "^3.0.6",
"@tailwindcss/node": "^4.3.2",
"@tailwindcss/vite": "^4.3.2",
@@ -481,23 +495,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.3", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.5.3", "@biomejs/cli-darwin-x64": "2.5.3", "@biomejs/cli-linux-arm64": "2.5.3", "@biomejs/cli-linux-arm64-musl": "2.5.3", "@biomejs/cli-linux-x64": "2.5.3", "@biomejs/cli-linux-x64-musl": "2.5.3", "@biomejs/cli-win32-arm64": "2.5.3", "@biomejs/cli-win32-x64": "2.5.3" }, "bin": { "biome": "bin/biome" } }, "sha512-MrJswFdei9EfDwwUy2tQrPDpK0AO+RmMFvBoaaJ6ayBc3sUbHdCE+XG5N8vp+5So41ZupZJQm0roHFFhMGVD7A=="],
"@biomejs/biome": ["@biomejs/biome@2.5.4", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.5.4", "@biomejs/cli-darwin-x64": "2.5.4", "@biomejs/cli-linux-arm64": "2.5.4", "@biomejs/cli-linux-arm64-musl": "2.5.4", "@biomejs/cli-linux-x64": "2.5.4", "@biomejs/cli-linux-x64-musl": "2.5.4", "@biomejs/cli-win32-arm64": "2.5.4", "@biomejs/cli-win32-x64": "2.5.4" }, "bin": { "biome": "bin/biome" } }, "sha512-xy5FNE5kQJKyK5MR1gJy6ztXYx4WBAbYGlK04lMEgmyPRWKybY9NFwiG9yo0XdzOU8Xvhj41u034J1ywfoWfMw=="],
"@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.5.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-QhYP9muVQ0nUO5zztFuPbEwi4+94sJWVjaZds9aMi1l/KNZBiUjdiSUrGHsTaMGDXrYl+r4AS2sUKfgH3w+V3g=="],
"@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.5.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-4o3NFRobXHynkgcFVrlZsoDAFtF2ldlEGN8sORSws5ZQqyY4PXnPUIylu4ksfyHuwkfvDREuWh3JK+niRwGq3w=="],
"@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.5.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-NC1Ss13UaW7QZX+y8j44bF7AP0jSJdBl6iRhe0MAkvaSqZy+mWg3GaXsrb+eSoHoGDBtaXWEbMVV0iVN2cZ7cQ=="],
"@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.5.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-D32P5HkU2Y6PySuC/WsVDTOgsDwVFmujzhhhOQjajtATpVWFDXuVd3oRbsWNSEA+aaFzyzZm22szsyydBYlSyQ=="],
"@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.5.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-ksx1KWeyYW18ILL04msF/J4ZBtBDN33znYK8Z/aNv/vlBVxL9/g3mGP+omgHJKy4+KWbK87vcmmpmurfNjSgiA=="],
"@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.5.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-pSEfW7B8kTsXUjUxC1xVVK+y85Ht3C5XxZ9gclmC7/3Ku9Vqz8jmI7k0p/BNIjQ6t4sFERI2sFeH73ybiZl6YQ=="],
"@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.5.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-fccix0w6xp6csCXgxeC0dU/3ecgRQal0y+cv2SP9ajNlhe7Yrk2Ug7UDe2j9AT9ZDYitkXpvUKgZjjuoYeP4Vg=="],
"@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.5.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-Rpm5/AT1m+DlJmUoYvS4/vXc+0tXJPJ2NQz25TGPyHVF5JrWy75PE0GH6kVxsKtQDuCH4OgzquZq0R4kj/wCVg=="],
"@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.5.3", "", { "os": "linux", "cpu": "x64" }, "sha512-yMkJtilsgvILDcVkh187aVLTb64xYsrxYajx5kym+r1ULkO5HUOfu9AYKLGQbOVLwJtT2utNw7hhFNg+17mUYA=="],
"@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.5.4", "", { "os": "linux", "cpu": "x64" }, "sha512-FNxojWJkL7EajAuzBgoLe0T2G0y112M4lBrDIFl/DomFTx8yqenYOIdsRLNXvOvBBofE8hJi85LjzLmBDpY7/Q=="],
"@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.5.3", "", { "os": "linux", "cpu": "x64" }, "sha512-O/yU9YKRUiHhmcjF2f38PSjseVk3G4VLWYc0G2HWpzdBVREV6G8IGWIVEFf7MFPfWIzNUIvPsEjeAZQIOgnLcQ=="],
"@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.5.4", "", { "os": "linux", "cpu": "x64" }, "sha512-aby/PohmmgbShcHqFsZVzG8H6D98+P+A6xRWRrQcLW1pCjabcov5UUlke4UqNQBYTkDQav+jB4zyyDDeKB2GaA=="],
"@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.5.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-cX5z+GYwRcqEok0AH3KSfQGgqYd0Nomfp6Fbe1uiTtELE38hdH2k842wQ9wLNaF/JJ7r4rjJQ4VR+ce+fRmQbw=="],
"@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.5.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-emoXexPZIPAZkz2RKmA95WJUqK3I5MJNYtwEbL5ESciRzhmFMMyekDhNG8hpeOaK+ZGRDxAU4wvGuA5IHQ0h0w=="],
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.5.3", "", { "os": "win32", "cpu": "x64" }, "sha512-ExSaJWi4/u6+GXCszlSKpWSjKNbDseAYqqkCznsCsZ/4uidZ/BEqsCc5/3ctlq6dfIubdIIRSVLC/PG9xPl70Q=="],
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.5.4", "", { "os": "win32", "cpu": "x64" }, "sha512-U1jaluLw1qQc2Tx7/CeSoL9N5XcqIH+GWjpUAy1ouB5nVjSCMNO+NNHdY3RAs8zxNurLWAdj6pehQdCA2zyU+Q=="],
"@bufbuild/protobuf": ["@bufbuild/protobuf@2.12.1", "", {}, "sha512-BvAMfS6LrgZiryOAZ4pBYucu4wG/Ei/9o9DZ9akbREnMLbPJiom2i8b9C8IsKErQoiKqVhrerzt3kOT/RrzLHg=="],
@@ -521,7 +535,7 @@
"@huggingface/jinja": ["@huggingface/jinja@0.5.9", "", {}, "sha512-uWTG+l3VJRsl7EXxYizuL3P+cCPoc3cRqbWWRcQN0FhejRfbdq0RNhCmbY/YDtnTcz9icdLYuLDjsnz4d8JMuw=="],
"@huggingface/tasks": ["@huggingface/tasks@0.21.25", "", {}, "sha512-u71rRx80Ynzy2oEDpRTSgOLyrzAV86gtnt4ajGHHI+1SQmsqmIUyzZG4Wi0TavDKdRsTa5QC/REDeJ3cFvkxvA=="],
"@huggingface/tasks": ["@huggingface/tasks@0.21.26", "", {}, "sha512-6QGX2qBFEf3zU+qRz+kSTI8weoUpLtNINnl8i7NUC+VB1UBxKvgPrXGCLEEkUwcRGqC0DBpJBwB0OPFg1Aa25g=="],
"@huggingface/tokenizers": ["@huggingface/tokenizers@0.1.3", "", {}, "sha512-8rF/RRT10u+kn7YuUbUg0OF30K8rjTc78aHpxT+qJ1uWSqxT1MHi8+9ltwYfkFYJzT/oS+qw3JVfHtNMGAdqyA=="],
@@ -633,75 +647,75 @@
"@napi-rs/cross-toolchain": ["@napi-rs/cross-toolchain@1.0.3", "", { "dependencies": { "@napi-rs/lzma": "^1.4.5", "@napi-rs/tar": "^1.1.0", "debug": "^4.4.1" }, "peerDependencies": { "@napi-rs/cross-toolchain-arm64-target-aarch64": "^1.0.3", "@napi-rs/cross-toolchain-arm64-target-armv7": "^1.0.3", "@napi-rs/cross-toolchain-arm64-target-ppc64le": "^1.0.3", "@napi-rs/cross-toolchain-arm64-target-s390x": "^1.0.3", "@napi-rs/cross-toolchain-arm64-target-x86_64": "^1.0.3", "@napi-rs/cross-toolchain-x64-target-aarch64": "^1.0.3", "@napi-rs/cross-toolchain-x64-target-armv7": "^1.0.3", "@napi-rs/cross-toolchain-x64-target-ppc64le": "^1.0.3", "@napi-rs/cross-toolchain-x64-target-s390x": "^1.0.3", "@napi-rs/cross-toolchain-x64-target-x86_64": "^1.0.3" }, "optionalPeers": ["@napi-rs/cross-toolchain-arm64-target-aarch64", "@napi-rs/cross-toolchain-arm64-target-armv7", "@napi-rs/cross-toolchain-arm64-target-ppc64le", "@napi-rs/cross-toolchain-arm64-target-s390x", "@napi-rs/cross-toolchain-arm64-target-x86_64", "@napi-rs/cross-toolchain-x64-target-aarch64", "@napi-rs/cross-toolchain-x64-target-armv7", "@napi-rs/cross-toolchain-x64-target-ppc64le", "@napi-rs/cross-toolchain-x64-target-s390x", "@napi-rs/cross-toolchain-x64-target-x86_64"] }, "sha512-ENPfLe4937bsKVTDA6zdABx4pq9w0tHqRrJHyaGxgaPq03a2Bd1unD5XSKjXJjebsABJ+MjAv1A2OvCgK9yehg=="],
"@napi-rs/lzma": ["@napi-rs/lzma@1.4.5", "", { "optionalDependencies": { "@napi-rs/lzma-android-arm-eabi": "1.4.5", "@napi-rs/lzma-android-arm64": "1.4.5", "@napi-rs/lzma-darwin-arm64": "1.4.5", "@napi-rs/lzma-darwin-x64": "1.4.5", "@napi-rs/lzma-freebsd-x64": "1.4.5", "@napi-rs/lzma-linux-arm-gnueabihf": "1.4.5", "@napi-rs/lzma-linux-arm64-gnu": "1.4.5", "@napi-rs/lzma-linux-arm64-musl": "1.4.5", "@napi-rs/lzma-linux-ppc64-gnu": "1.4.5", "@napi-rs/lzma-linux-riscv64-gnu": "1.4.5", "@napi-rs/lzma-linux-s390x-gnu": "1.4.5", "@napi-rs/lzma-linux-x64-gnu": "1.4.5", "@napi-rs/lzma-linux-x64-musl": "1.4.5", "@napi-rs/lzma-wasm32-wasi": "1.4.5", "@napi-rs/lzma-win32-arm64-msvc": "1.4.5", "@napi-rs/lzma-win32-ia32-msvc": "1.4.5", "@napi-rs/lzma-win32-x64-msvc": "1.4.5" } }, "sha512-zS5LuN1OBPAyZpda2ZZgYOEDC+xecUdAGnrvbYzjnLXkrq/OBC3B9qcRvlxbDR3k5H/gVfvef1/jyUqPknqjbg=="],
"@napi-rs/lzma": ["@napi-rs/lzma@1.5.1", "", { "optionalDependencies": { "@napi-rs/lzma-android-arm-eabi": "1.5.1", "@napi-rs/lzma-android-arm64": "1.5.1", "@napi-rs/lzma-darwin-arm64": "1.5.1", "@napi-rs/lzma-darwin-x64": "1.5.1", "@napi-rs/lzma-freebsd-x64": "1.5.1", "@napi-rs/lzma-linux-arm-gnueabihf": "1.5.1", "@napi-rs/lzma-linux-arm64-gnu": "1.5.1", "@napi-rs/lzma-linux-arm64-musl": "1.5.1", "@napi-rs/lzma-linux-ppc64-gnu": "1.5.1", "@napi-rs/lzma-linux-riscv64-gnu": "1.5.1", "@napi-rs/lzma-linux-s390x-gnu": "1.5.1", "@napi-rs/lzma-linux-x64-gnu": "1.5.1", "@napi-rs/lzma-linux-x64-musl": "1.5.1", "@napi-rs/lzma-wasm32-wasi": "1.5.1", "@napi-rs/lzma-win32-arm64-msvc": "1.5.1", "@napi-rs/lzma-win32-ia32-msvc": "1.5.1", "@napi-rs/lzma-win32-x64-msvc": "1.5.1" } }, "sha512-sgOZ89+y8cDbY+3WbzR8CtIhCuFRWotZ9/2PjPVDJHz6np5KFTAev0DrwiyTJTgFsCRDhfGlbmhMgyhHbWdZ6g=="],
"@napi-rs/lzma-android-arm-eabi": ["@napi-rs/lzma-android-arm-eabi@1.4.5", "", { "os": "android", "cpu": "arm" }, "sha512-Up4gpyw2SacmyKWWEib06GhiDdF+H+CCU0LAV8pnM4aJIDqKKd5LHSlBht83Jut6frkB0vwEPmAkv4NjQ5u//Q=="],
"@napi-rs/lzma-android-arm-eabi": ["@napi-rs/lzma-android-arm-eabi@1.5.1", "", { "os": "android", "cpu": "arm" }, "sha512-sahBe4ko2Z69NPTddaX6ZgbQZu9SDoITxw1S3dWl1gAGynZG34qHHCT8UaUMFxf3h3zMhCJjEzz4basaBxiTuQ=="],
"@napi-rs/lzma-android-arm64": ["@napi-rs/lzma-android-arm64@1.4.5", "", { "os": "android", "cpu": "arm64" }, "sha512-uwa8sLlWEzkAM0MWyoZJg0JTD3BkPknvejAFG2acUA1raXM8jLrqujWCdOStisXhqQjZ2nDMp3FV6cs//zjfuQ=="],
"@napi-rs/lzma-android-arm64": ["@napi-rs/lzma-android-arm64@1.5.1", "", { "os": "android", "cpu": "arm64" }, "sha512-7tkQAJJuBHxAxiEBNFgSTpvrtGpbwZYYJUSOmGEK3OfbdbNeoT2rdBxpM/gY1s+itEVbtOSlpaRPPG19MnwOzA=="],
"@napi-rs/lzma-darwin-arm64": ["@napi-rs/lzma-darwin-arm64@1.4.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-0Y0TQLQ2xAjVabrMDem1NhIssOZzF/y/dqetc6OT8mD3xMTDtF8u5BqZoX3MyPc9FzpsZw4ksol+w7DsxHrpMA=="],
"@napi-rs/lzma-darwin-arm64": ["@napi-rs/lzma-darwin-arm64@1.5.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-XWX8gtF+GHGk3nH3Wm3QUZNcxw9QHsFVZz3MzVLhWWHhceede1J4/vD+3dj3E1iKB9G6mualaZxOoD08R3E+7g=="],
"@napi-rs/lzma-darwin-x64": ["@napi-rs/lzma-darwin-x64@1.4.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-vR2IUyJY3En+V1wJkwmbGWcYiT8pHloTAWdW4pG24+51GIq+intst6Uf6D/r46citObGZrlX0QvMarOkQeHWpw=="],
"@napi-rs/lzma-darwin-x64": ["@napi-rs/lzma-darwin-x64@1.5.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-CfsqUpMTI1z8enrA/b+GcHM6YDI8D0kqCiqPYEnst4rbOABQ9KZ92ybTTNnlnZ7A017WoMZKUEWc36KXDwi0xg=="],
"@napi-rs/lzma-freebsd-x64": ["@napi-rs/lzma-freebsd-x64@1.4.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-XpnYQC5SVovO35tF0xGkbHYjsS6kqyNCjuaLQ2dbEblFRr5cAZVvsJ/9h7zj/5FluJPJRDojVNxGyRhTp4z2lw=="],
"@napi-rs/lzma-freebsd-x64": ["@napi-rs/lzma-freebsd-x64@1.5.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-bTyNfg90FXIgE61U7l14aMmVOqRQ6AyP5JMT3jmCStaZI18apLNPdzZ8i7yqxZfKvRMVfPjE2brXIw27c+RRgA=="],
"@napi-rs/lzma-linux-arm-gnueabihf": ["@napi-rs/lzma-linux-arm-gnueabihf@1.4.5", "", { "os": "linux", "cpu": "arm" }, "sha512-ic1ZZMoRfRMwtSwxkyw4zIlbDZGC6davC9r+2oX6x9QiF247BRqqT94qGeL5ZP4Vtz0Hyy7TEViWhx5j6Bpzvw=="],
"@napi-rs/lzma-linux-arm-gnueabihf": ["@napi-rs/lzma-linux-arm-gnueabihf@1.5.1", "", { "os": "linux", "cpu": "arm" }, "sha512-vNE+D8nrw+eOkBsdKCsmDhowDV3pIMKXEhedvXfbgrWbrO7GlZJH+RXL+X+RYLxGwi8Ym61ZMt15sIOnNmh9Sw=="],
"@napi-rs/lzma-linux-arm64-gnu": ["@napi-rs/lzma-linux-arm64-gnu@1.4.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-asEp7FPd7C1Yi6DQb45a3KPHKOFBSfGuJWXcAd4/bL2Fjetb2n/KK2z14yfW8YC/Fv6x3rBM0VAZKmJuz4tysg=="],
"@napi-rs/lzma-linux-arm64-gnu": ["@napi-rs/lzma-linux-arm64-gnu@1.5.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-csUem4WgoKGTprv/pOPm9UIWbb+hrfUwYXefpTHPAEGVFLl5behEFabisJ7FtihCa3yG2Efcl+yw25rlhhrIYw=="],
"@napi-rs/lzma-linux-arm64-musl": ["@napi-rs/lzma-linux-arm64-musl@1.4.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-yWjcPDgJ2nIL3KNvi4536dlT/CcCWO0DUyEOlBs/SacG7BeD6IjGh6yYzd3/X1Y3JItCbZoDoLUH8iB1lTXo3w=="],
"@napi-rs/lzma-linux-arm64-musl": ["@napi-rs/lzma-linux-arm64-musl@1.5.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-kB/xhlVN1eLvVmDJSKZEjp5Gg2xDYexNrB5jwpSMbOkeGS6N9AasByPBg5VqCpMYC+zZi7DM458DRhtWYhqXTQ=="],
"@napi-rs/lzma-linux-ppc64-gnu": ["@napi-rs/lzma-linux-ppc64-gnu@1.4.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-0XRhKuIU/9ZjT4WDIG/qnX7Xz7mSQHYZo9Gb3MP2gcvBgr6BA4zywQ9k3gmQaPn9ECE+CZg2V7DV7kT+x2pUMQ=="],
"@napi-rs/lzma-linux-ppc64-gnu": ["@napi-rs/lzma-linux-ppc64-gnu@1.5.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-s28RW0W1yBWQc1nbPdF7tp14koqslY3ZWLVI8uaanX292Dc6ezd4NPVwxEoCNBVON/oD7BmUbWGtyFvmm7dQ5A=="],
"@napi-rs/lzma-linux-riscv64-gnu": ["@napi-rs/lzma-linux-riscv64-gnu@1.4.5", "", { "os": "linux", "cpu": "none" }, "sha512-QrqDIPEUUB23GCpyQj/QFyMlr8SGxxyExeZz9OWFnHfb70kXdTLWrHS/hEI1Ru+lSbQ/6xRqeoGyQ4Aqdg+/RA=="],
"@napi-rs/lzma-linux-riscv64-gnu": ["@napi-rs/lzma-linux-riscv64-gnu@1.5.1", "", { "os": "linux", "cpu": "none" }, "sha512-+lGNwYlIN14YPMTNvYtIJJqHFevDTd6Juw/1NmXbWx/iRd/LLrjhlM/yluMX6pxs6NkOGsuuEXJJrbbEUS59OQ=="],
"@napi-rs/lzma-linux-s390x-gnu": ["@napi-rs/lzma-linux-s390x-gnu@1.4.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-k8RVM5aMhW86E9H0QXdquwojew4H3SwPxbRVbl49/COJQWCUjGi79X6mYruMnMPEznZinUiT1jgKbFo2A00NdA=="],
"@napi-rs/lzma-linux-s390x-gnu": ["@napi-rs/lzma-linux-s390x-gnu@1.5.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-PB44FFWWFrLeQowhcep1hPD1YcLqKlnnY60RMU74qrxTlr4YGEyzeMItJqh2uivBfv9kQScOF/B0J9+Vab/oyw=="],
"@napi-rs/lzma-linux-x64-gnu": ["@napi-rs/lzma-linux-x64-gnu@1.4.5", "", { "os": "linux", "cpu": "x64" }, "sha512-6rMtBgnIq2Wcl1rQdZsnM+rtCcVCbws1nF8S2NzaUsVaZv8bjrPiAa0lwg4Eqnn1d9lgwqT+cZgm5m+//K08Kw=="],
"@napi-rs/lzma-linux-x64-gnu": ["@napi-rs/lzma-linux-x64-gnu@1.5.1", "", { "os": "linux", "cpu": "x64" }, "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ=="],
"@napi-rs/lzma-linux-x64-musl": ["@napi-rs/lzma-linux-x64-musl@1.4.5", "", { "os": "linux", "cpu": "x64" }, "sha512-eiadGBKi7Vd0bCArBUOO/qqRYPHt/VQVvGyYvDFt6C2ZSIjlD+HuOl+2oS1sjf4CFjK4eDIog6EdXnL0NE6iyQ=="],
"@napi-rs/lzma-linux-x64-musl": ["@napi-rs/lzma-linux-x64-musl@1.5.1", "", { "os": "linux", "cpu": "x64" }, "sha512-I3nsYrWtrW9JpeCr+mkJIVDt0HY3m6qVUBs5vTtoIvJQxwqf1PBXSy5IS7T53ksQFH2kd2UX8rLxJ7B4WISpZg=="],
"@napi-rs/lzma-wasm32-wasi": ["@napi-rs/lzma-wasm32-wasi@1.4.5", "", { "dependencies": { "@napi-rs/wasm-runtime": "^1.0.3" }, "cpu": "none" }, "sha512-+VyHHlr68dvey6fXc2hehw9gHVFIW3TtGF1XkcbAu65qVXsA9D/T+uuoRVqhE+JCyFHFrO0ixRbZDRK1XJt1sA=="],
"@napi-rs/lzma-wasm32-wasi": ["@napi-rs/lzma-wasm32-wasi@1.5.1", "", { "dependencies": { "@emnapi/core": "1.11.2", "@emnapi/runtime": "1.11.2", "@napi-rs/wasm-runtime": "^1.1.6" }, "cpu": "none" }, "sha512-gy3wwPBa6+XEyA4fUzq6CClrXA1ajXjuVf5zbnHytJRgoHznj+mvpU3+co2fxXwqTCmIpn6KrzqH5bRDztBPhA=="],
"@napi-rs/lzma-win32-arm64-msvc": ["@napi-rs/lzma-win32-arm64-msvc@1.4.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-eewnqvIyyhHi3KaZtBOJXohLvwwN27gfS2G/YDWdfHlbz1jrmfeHAmzMsP5qv8vGB+T80TMHNkro4kYjeh6Deg=="],
"@napi-rs/lzma-win32-arm64-msvc": ["@napi-rs/lzma-win32-arm64-msvc@1.5.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-dK+huOsHiyH6oJjij+cnjqFCakk2HgWmpI12Xm4pLUyPphe4ebYoJBgehaNAxprmjFqBQ7nL95YPVz9BHyqmPg=="],
"@napi-rs/lzma-win32-ia32-msvc": ["@napi-rs/lzma-win32-ia32-msvc@1.4.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-OeacFVRCJOKNU/a0ephUfYZ2Yt+NvaHze/4TgOwJ0J0P4P7X1mHzN+ig9Iyd74aQDXYqc7kaCXA2dpAOcH87Cg=="],
"@napi-rs/lzma-win32-ia32-msvc": ["@napi-rs/lzma-win32-ia32-msvc@1.5.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-dGE8L+0EQ+GyU9ap9InqB/t/PmPG/bLj918q7OsJ29FuTdn8fK4OX3U4IQZhylHIA+/dQ/SXJk5n4yfah2XVvA=="],
"@napi-rs/lzma-win32-x64-msvc": ["@napi-rs/lzma-win32-x64-msvc@1.4.5", "", { "os": "win32", "cpu": "x64" }, "sha512-T4I1SamdSmtyZgDXGAGP+y5LEK5vxHUFwe8mz6D4R7Sa5/WCxTcCIgPJ9BD7RkpO17lzhlaM2vmVvMy96Lvk9Q=="],
"@napi-rs/lzma-win32-x64-msvc": ["@napi-rs/lzma-win32-x64-msvc@1.5.1", "", { "os": "win32", "cpu": "x64" }, "sha512-EKW4t/iqdCT/xnd5t9oXLvVER/PMNAWXKqUAl3fgvUcOILeZIIht77/dVnfFcc9htA/DCBXC/6YQWdW+LusjFA=="],
"@napi-rs/tar": ["@napi-rs/tar@1.1.0", "", { "optionalDependencies": { "@napi-rs/tar-android-arm-eabi": "1.1.0", "@napi-rs/tar-android-arm64": "1.1.0", "@napi-rs/tar-darwin-arm64": "1.1.0", "@napi-rs/tar-darwin-x64": "1.1.0", "@napi-rs/tar-freebsd-x64": "1.1.0", "@napi-rs/tar-linux-arm-gnueabihf": "1.1.0", "@napi-rs/tar-linux-arm64-gnu": "1.1.0", "@napi-rs/tar-linux-arm64-musl": "1.1.0", "@napi-rs/tar-linux-ppc64-gnu": "1.1.0", "@napi-rs/tar-linux-s390x-gnu": "1.1.0", "@napi-rs/tar-linux-x64-gnu": "1.1.0", "@napi-rs/tar-linux-x64-musl": "1.1.0", "@napi-rs/tar-wasm32-wasi": "1.1.0", "@napi-rs/tar-win32-arm64-msvc": "1.1.0", "@napi-rs/tar-win32-ia32-msvc": "1.1.0", "@napi-rs/tar-win32-x64-msvc": "1.1.0" } }, "sha512-7cmzIu+Vbupriudo7UudoMRH2OA3cTw67vva8MxeoAe5S7vPFI7z0vp0pMXiA25S8IUJefImQ90FeJjl8fjEaQ=="],
"@napi-rs/tar": ["@napi-rs/tar@1.1.1", "", { "optionalDependencies": { "@napi-rs/tar-android-arm-eabi": "1.1.1", "@napi-rs/tar-android-arm64": "1.1.1", "@napi-rs/tar-darwin-arm64": "1.1.1", "@napi-rs/tar-darwin-x64": "1.1.1", "@napi-rs/tar-freebsd-x64": "1.1.1", "@napi-rs/tar-linux-arm-gnueabihf": "1.1.1", "@napi-rs/tar-linux-arm64-gnu": "1.1.1", "@napi-rs/tar-linux-arm64-musl": "1.1.1", "@napi-rs/tar-linux-ppc64-gnu": "1.1.1", "@napi-rs/tar-linux-s390x-gnu": "1.1.1", "@napi-rs/tar-linux-x64-gnu": "1.1.1", "@napi-rs/tar-linux-x64-musl": "1.1.1", "@napi-rs/tar-wasm32-wasi": "1.1.1", "@napi-rs/tar-win32-arm64-msvc": "1.1.1", "@napi-rs/tar-win32-ia32-msvc": "1.1.1", "@napi-rs/tar-win32-x64-msvc": "1.1.1" } }, "sha512-p6q2HhUc5vwH1CNwfOcrhLoxfgn8ust8Sqlfx+sA4VzAcp1cMbvbkl99tZZlDqOjCHgQNSiTfk/yWPjl/D42qA=="],
"@napi-rs/tar-android-arm-eabi": ["@napi-rs/tar-android-arm-eabi@1.1.0", "", { "os": "android", "cpu": "arm" }, "sha512-h2Ryndraj/YiKgMV/r5by1cDusluYIRT0CaE0/PekQ4u+Wpy2iUVqvzVU98ZPnhXaNeYxEvVJHNGafpOfaD0TA=="],
"@napi-rs/tar-android-arm-eabi": ["@napi-rs/tar-android-arm-eabi@1.1.1", "", { "os": "android", "cpu": "arm" }, "sha512-cAhnA10cSusAUbcE9HtjQY/tZ9BH/0w2sKtRcQc94TzIlnm7QSr1htJSd/PPrbWNPtrv1orXb2CkrHlVlbnlHA=="],
"@napi-rs/tar-android-arm64": ["@napi-rs/tar-android-arm64@1.1.0", "", { "os": "android", "cpu": "arm64" }, "sha512-DJFyQHr1ZxNZorm/gzc1qBNLF/FcKzcH0V0Vwan5P+o0aE2keQIGEjJ09FudkF9v6uOuJjHCVDdK6S6uHtShAw=="],
"@napi-rs/tar-android-arm64": ["@napi-rs/tar-android-arm64@1.1.1", "", { "os": "android", "cpu": "arm64" }, "sha512-EslUWHCDBY/g5abTPBiHLsMaML4GagV0TXLm5WL9hAjx/DDtlxz9fegMb77RJ+f7nFLOIsUxF/3QWFvgOT0sMQ=="],
"@napi-rs/tar-darwin-arm64": ["@napi-rs/tar-darwin-arm64@1.1.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Zz2sXRzjIX4e532zD6xm2SjXEym6MkvfCvL2RMpG2+UwNVDVscHNcz3d47Pf3sysP2e2af7fBB3TIoK2f6trPw=="],
"@napi-rs/tar-darwin-arm64": ["@napi-rs/tar-darwin-arm64@1.1.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-+A42/6ES5G9CQ35BOwzwA+WBjLID28r2jNPgc0dteD2hhClIhng0mva7D2ujUlXBNmgNOsr1LHn3stA4uTf4NQ=="],
"@napi-rs/tar-darwin-x64": ["@napi-rs/tar-darwin-x64@1.1.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-EI+CptIMNweT0ms9S3mkP/q+J6FNZ1Q6pvpJOEcWglRfyfQpLqjlC0O+dptruTPE8VamKYuqdjxfqD8hifZDOA=="],
"@napi-rs/tar-darwin-x64": ["@napi-rs/tar-darwin-x64@1.1.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-RYtE8w1dkEvj8hSJCDV5Jw0Rz2i13fsM7u893zv5O9n/4Ad5GNsw/f4RQ7/0YGSFaenkVxqPFrjmEvUHlKzsrg=="],
"@napi-rs/tar-freebsd-x64": ["@napi-rs/tar-freebsd-x64@1.1.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-J0PIqX+pl6lBIAckL/c87gpodLbjZB1OtIK+RDscKC9NLdpVv6VGOxzUV/fYev/hctcE8EfkLbgFOfpmVQPg2g=="],
"@napi-rs/tar-freebsd-x64": ["@napi-rs/tar-freebsd-x64@1.1.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-rEepBvCJUwcuvUYkY83e8aot8RsR5Jcnal4PsG3tbWGKW1yAvcXhyMXf0fN6ZGpVRZFnB+FJqDyBxvsCPEXKhw=="],
"@napi-rs/tar-linux-arm-gnueabihf": ["@napi-rs/tar-linux-arm-gnueabihf@1.1.0", "", { "os": "linux", "cpu": "arm" }, "sha512-SLgIQo3f3EjkZ82ZwvrEgFvMdDAhsxCYjyoSuWfHCz0U16qx3SuGCp8+FYOPYCECHN3ZlGjXnoAIt9ERd0dEUg=="],
"@napi-rs/tar-linux-arm-gnueabihf": ["@napi-rs/tar-linux-arm-gnueabihf@1.1.1", "", { "os": "linux", "cpu": "arm" }, "sha512-an1bJdfyhI5FpZYyTQ20mrqwR+a676i8GkaYc4Uy12dH/a7TJIfrK6Qa2Gm46arZvxUvx56qxoRKXbpOjUPvwA=="],
"@napi-rs/tar-linux-arm64-gnu": ["@napi-rs/tar-linux-arm64-gnu@1.1.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-d014cdle52EGaH6GpYTQOP9Py7glMO1zz/+ynJPjjzYFSxvdYx0byrjumZk2UQdIyGZiJO2MEFpCkEEKFSgPYA=="],
"@napi-rs/tar-linux-arm64-gnu": ["@napi-rs/tar-linux-arm64-gnu@1.1.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-w++Vtx36T2yHTKws7GVnmHHcUT1ybB59xLWSh9A8bwEpJVG4dG7Qub9mFe5cpcbfrJ+XP2mKKxC3oUJSunK3iQ=="],
"@napi-rs/tar-linux-arm64-musl": ["@napi-rs/tar-linux-arm64-musl@1.1.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-L/y1/26q9L/uBqiW/JdOb/Dc94egFvNALUZV2WCGKQXc6UByPBMgdiEyW2dtoYxYYYYc+AKD+jr+wQPcvX2vrQ=="],
"@napi-rs/tar-linux-arm64-musl": ["@napi-rs/tar-linux-arm64-musl@1.1.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-Rh6UFhNtj3i4deJHOBINFIeRL0072mgbeyuK5rl1HokKnNoMKx8qKIZNEzBTTqpogMfDHWGvzyTQdnVxes5dpA=="],
"@napi-rs/tar-linux-ppc64-gnu": ["@napi-rs/tar-linux-ppc64-gnu@1.1.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-EPE1K/80RQvPbLRJDJs1QmCIcH+7WRi0F73+oTe1582y9RtfGRuzAkzeBuAGRXAQEjRQw/RjtNqr6UTJ+8UuWQ=="],
"@napi-rs/tar-linux-ppc64-gnu": ["@napi-rs/tar-linux-ppc64-gnu@1.1.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-Cp+AxFbv9zcyAXtnzQi0OzmgDnQgy2w9D4Ubr+iwzMtVgJcztzcEoCcCrN1k2ATdEB01LX2Vb49IaocGOZhC9Q=="],
"@napi-rs/tar-linux-s390x-gnu": ["@napi-rs/tar-linux-s390x-gnu@1.1.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-B2jhWiB1ffw1nQBqLUP1h4+J1ovAxBOoe5N2IqDMOc63fsPZKNqF1PvO/dIem8z7LL4U4bsfmhy3gBfu547oNQ=="],
"@napi-rs/tar-linux-s390x-gnu": ["@napi-rs/tar-linux-s390x-gnu@1.1.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-ZyscC3SYKTBWyDRYjLOKAd5TyJ7q0KACRdQ8bWrb3rgrra1CCIJD66CsGTH6Dh0AVSdfLwZ8MfIIXU6+14BMjQ=="],
"@napi-rs/tar-linux-x64-gnu": ["@napi-rs/tar-linux-x64-gnu@1.1.0", "", { "os": "linux", "cpu": "x64" }, "sha512-tbZDHnb9617lTnsDMGo/eAMZxnsQFnaRe+MszRqHguKfMwkisc9CCJnks/r1o84u5fECI+J/HOrKXgczq/3Oww=="],
"@napi-rs/tar-linux-x64-gnu": ["@napi-rs/tar-linux-x64-gnu@1.1.1", "", { "os": "linux", "cpu": "x64" }, "sha512-LlIv+zg4fiOQge9LQX/ieBdRWE2fhVDjCTHxnunZkbugNmdhdelxWf1RpZb/6ZujWpNF4LPu4N/MW7ygg2oYAQ=="],
"@napi-rs/tar-linux-x64-musl": ["@napi-rs/tar-linux-x64-musl@1.1.0", "", { "os": "linux", "cpu": "x64" }, "sha512-dV6cODlzbO8u6Anmv2N/ilQHq/AWz0xyltuXoLU3yUyXbZcnWYZuB2rL8OBGPmqNcD+x9NdScBNXh7vWN0naSQ=="],
"@napi-rs/tar-linux-x64-musl": ["@napi-rs/tar-linux-x64-musl@1.1.1", "", { "os": "linux", "cpu": "x64" }, "sha512-gZBeoKLjanOVj55qk4EMu13P2i9M0SuINmlGQkOxm1niIJofexzddHUYtqO5o/5QqtyL8lADmAcZplLILMLhHA=="],
"@napi-rs/tar-wasm32-wasi": ["@napi-rs/tar-wasm32-wasi@1.1.0", "", { "dependencies": { "@napi-rs/wasm-runtime": "^1.0.3" }, "cpu": "none" }, "sha512-jIa9nb2HzOrfH0F8QQ9g3WE4aMH5vSI5/1NYVNm9ysCmNjCCtMXCAhlI3WKCdm/DwHf0zLqdrrtDFXODcNaqMw=="],
"@napi-rs/tar-wasm32-wasi": ["@napi-rs/tar-wasm32-wasi@1.1.1", "", { "dependencies": { "@emnapi/core": "1.11.2", "@emnapi/runtime": "1.11.2", "@napi-rs/wasm-runtime": "^1.1.6" }, "cpu": "none" }, "sha512-rwtQ1Mdt/ft6g6I54fJzbUeLspl4yTwj6I3UJ6mitKnrN42soJkcDrdh3Y/FGvlpqZTad2YMQ96fGJl3EtAm2Q=="],
"@napi-rs/tar-win32-arm64-msvc": ["@napi-rs/tar-win32-arm64-msvc@1.1.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-vfpG71OB0ijtjemp3WTdmBKJm9R70KM8vsSExMsIQtV0lVzP07oM1CW6JbNRPXNLhRoue9ofYLiUDk8bE0Hckg=="],
"@napi-rs/tar-win32-arm64-msvc": ["@napi-rs/tar-win32-arm64-msvc@1.1.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-30PVp1AehRpfwxmv5wI4cg0yj3WmWBsZ+1QnLGnvEELu7Eu/+dhNU0nrmhI7VfPgLwSRK2eg9DQTB3tP7Wv9bA=="],
"@napi-rs/tar-win32-ia32-msvc": ["@napi-rs/tar-win32-ia32-msvc@1.1.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-hGPyPW60YSpOSgzfy68DLBHgi6HxkAM+L59ZZZPMQ0TOXjQg+p2EW87+TjZfJOkSpbYiEkULwa/f4a2hcVjsqQ=="],
"@napi-rs/tar-win32-ia32-msvc": ["@napi-rs/tar-win32-ia32-msvc@1.1.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-aI3/rmz+izUChiSeaPxcasAOxhf3FpJNuIHMXlxS/vpW+HIxUsSDR5+XV61PEG5DL4L/75iENVUxmSGM5l2yaw=="],
"@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/tar-win32-x64-msvc": ["@napi-rs/tar-win32-x64-msvc@1.1.1", "", { "os": "win32", "cpu": "x64" }, "sha512-yJsB2IsrODQVLKbm2Fg1nHiVRbEj49mSPbj4x7JPZWJI0jGVPjohE2Sif0FBbx8OxsVoUODvS0BwksZZ8jl/OA=="],
"@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=="],
@@ -733,7 +747,7 @@
"@napi-rs/wasm-tools-win32-x64-msvc": ["@napi-rs/wasm-tools-win32-x64-msvc@1.0.1", "", { "os": "win32", "cpu": "x64" }, "sha512-rEAf05nol3e3eei2sRButmgXP+6ATgm0/38MKhz9Isne82T4rPIMYsCIFj0kOisaGeVwoi2fnm7O9oWp5YVnYQ=="],
"@nodable/entities": ["@nodable/entities@2.2.0", "", {}, "sha512-9uGyhaQavEUMC8AIddIjau4NsnsXhou+j5sBAGojCM1oxmQpVKTWR/9JxABD6UAv12vpIms55fPZKFQEhG6uBg=="],
"@nodable/entities": ["@nodable/entities@3.0.0", "", {}, "sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw=="],
"@octokit/auth-token": ["@octokit/auth-token@6.0.0", "", {}, "sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w=="],
@@ -799,6 +813,12 @@
"@opentelemetry/core": ["@opentelemetry/core@2.9.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-m2nckMT80NnmjTYSPjJQObBJ+8dgkoajEOUbznL8AHZ3T3yHRk2P7gI1PhEBc1+lOnrYE9UWrWHqJDsmqjmNbw=="],
"@opentelemetry/exporter-logs-otlp-proto": ["@opentelemetry/exporter-logs-otlp-proto@0.220.0", "", { "dependencies": { "@opentelemetry/otlp-exporter-base": "0.220.0", "@opentelemetry/otlp-transformer": "0.220.0", "@opentelemetry/sdk-logs": "0.220.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-8LZAxdJ0ENDAFwr4j0oY35mHBltiSzvlhdQAPGiC7p9VnxtuSq4SW1gfBAdW6t6hiQG6OwUl8w7KHaOdJPKHWg=="],
"@opentelemetry/exporter-metrics-otlp-http": ["@opentelemetry/exporter-metrics-otlp-http@0.220.0", "", { "dependencies": { "@opentelemetry/core": "2.9.0", "@opentelemetry/otlp-exporter-base": "0.220.0", "@opentelemetry/otlp-transformer": "0.220.0", "@opentelemetry/resources": "2.9.0", "@opentelemetry/sdk-metrics": "2.9.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-Yqt3RBw/bRVncaE9qIIhk4WfjbAQqXuP9FgAaU+IKPndnLEp/cUqZlSC324+bpmduRz7DoTjig8Ub0PeILWXUA=="],
"@opentelemetry/exporter-metrics-otlp-proto": ["@opentelemetry/exporter-metrics-otlp-proto@0.220.0", "", { "dependencies": { "@opentelemetry/core": "2.9.0", "@opentelemetry/exporter-metrics-otlp-http": "0.220.0", "@opentelemetry/otlp-exporter-base": "0.220.0", "@opentelemetry/otlp-transformer": "0.220.0", "@opentelemetry/resources": "2.9.0", "@opentelemetry/sdk-metrics": "2.9.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-lyO+IQBdSvqHN/ZOW/OzrSWemtfD+HgWngn+HBNLhjy0YrCQQTz0OE/kSekH2Pl340dn9DWzhqHdz5Eftr+HLA=="],
"@opentelemetry/exporter-trace-otlp-proto": ["@opentelemetry/exporter-trace-otlp-proto@0.220.0", "", { "dependencies": { "@opentelemetry/core": "2.9.0", "@opentelemetry/otlp-exporter-base": "0.220.0", "@opentelemetry/otlp-transformer": "0.220.0", "@opentelemetry/resources": "2.9.0", "@opentelemetry/sdk-trace": "2.9.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-voTAD8XgJxlK7zLkXh8EzMB09zrQr3tyY/BsnDTlDiQU/UdK58MZ63A3mUjdEDrxMjCVmBHU3WQJhRmQe+Dvzg=="],
"@opentelemetry/otlp-exporter-base": ["@opentelemetry/otlp-exporter-base@0.220.0", "", { "dependencies": { "@opentelemetry/core": "2.9.0", "@opentelemetry/otlp-transformer": "0.220.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-CXYo8UD5Mn9YbgebO2EL4wejtA+gxLmLiu6HCk2KH2BR7XhFN6/6p1UlCb23DYCjeYkndevLHuejCCN1yx4+OQ=="],
@@ -877,35 +897,35 @@
"@so-ric/colorspace": ["@so-ric/colorspace@1.1.6", "", { "dependencies": { "color": "^5.0.2", "text-hex": "1.0.x" } }, "sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw=="],
"@tailwindcss/node": ["@tailwindcss/node@4.3.2", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "5.21.6", "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.2" } }, "sha512-yWP/sqEcBLaD8JuA6zNwxoYKr75qxTioYwlRwekj5Jr/I5GXnoJfjetH/psLUIv74cYTH2lBUEzBkinthoYcBg=="],
"@tailwindcss/node": ["@tailwindcss/node@4.3.3", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.24.1", "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.3" } }, "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg=="],
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.3.2", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.3.2", "@tailwindcss/oxide-darwin-arm64": "4.3.2", "@tailwindcss/oxide-darwin-x64": "4.3.2", "@tailwindcss/oxide-freebsd-x64": "4.3.2", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.2", "@tailwindcss/oxide-linux-arm64-gnu": "4.3.2", "@tailwindcss/oxide-linux-arm64-musl": "4.3.2", "@tailwindcss/oxide-linux-x64-gnu": "4.3.2", "@tailwindcss/oxide-linux-x64-musl": "4.3.2", "@tailwindcss/oxide-wasm32-wasi": "4.3.2", "@tailwindcss/oxide-win32-arm64-msvc": "4.3.2", "@tailwindcss/oxide-win32-x64-msvc": "4.3.2" } }, "sha512-z8ZgnzX8gdNoWLBLqBPoh/sjnxkwvf9ZuWjnO0l0yIzbLa5/9S+eC5QxGZKRobVHIC3/1BoMWjHblqWjcgFgag=="],
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.3.3", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.3.3", "@tailwindcss/oxide-darwin-arm64": "4.3.3", "@tailwindcss/oxide-darwin-x64": "4.3.3", "@tailwindcss/oxide-freebsd-x64": "4.3.3", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", "@tailwindcss/oxide-linux-x64-musl": "4.3.3", "@tailwindcss/oxide-wasm32-wasi": "4.3.3", "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" } }, "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA=="],
"@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.3.2", "", { "os": "android", "cpu": "arm64" }, "sha512-WHxqIuHpvZ5VtdX6GTl1Ik/Vp2YuN42Et+0CdeaVd/frQ9jAvGmvR8vLT+jk3e8/Q3x8kECB9+R17pgpp2BulA=="],
"@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.3.3", "", { "os": "android", "cpu": "arm64" }, "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw=="],
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.3.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-GZypeUY/IDJW3877KeM+O67vbXr3MBnbtEL4aYhNErv/JWZhye2vGSWWG9tB6iiqR2MqRNkY8IOUy4NdSZV26w=="],
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.3.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw=="],
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.3.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-UIIzmefR6KO1sDU7MzRqAxC8iBpft/VhkGjTjnhoS6k7Z3rQ9wEgA1ODSiyH/tcSYssulNm4Ci3hOeK1jH7ccQ=="],
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.3.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw=="],
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.3.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-GN+uAmcI6DNspnCDwtOAZrTz6oukJnp337qZvxqCGLd3BHBzJpO0ZbTLRvJNdztOeAmTzewewGIMPb0tk2R4WA=="],
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.3.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw=="],
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.3.2", "", { "os": "linux", "cpu": "arm" }, "sha512-4ABn7qSbdHRwTiDiuWNegCyb5+2FJ4vKIKc3DmKrvAFw7MU1Lm11dIkTPwUaFdTzc7IsOpDbqBrlh0x6y36U/w=="],
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3", "", { "os": "linux", "cpu": "arm" }, "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ=="],
"@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.3.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-wDgEIGwoM8w8pufh9LVt1PahDgNdKXrLC2qfAnV3vAmococ9RWbxeAw4pxPttd/TsJfwjyLf90Dg1y9y8I6Emw=="],
"@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w=="],
"@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.3.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-J5Nuk0uZQIiMTJj3LEx4sAA9tMFUoXQZFv1J6An+QGYe53HKRJuFDi0rpq/tuouCZeAbOBY3kQ6g8qeD4TUjtA=="],
"@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA=="],
"@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.3.2", "", { "os": "linux", "cpu": "x64" }, "sha512-kqCZpSKOBEJO4mz7OqWoofBZeXTAwaVGPj0ErAj7CojmhKpWVWVOnrt9dE8odoIraZq4oj3ausM37kXi+Tow8w=="],
"@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w=="],
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.3.2", "", { "os": "linux", "cpu": "x64" }, "sha512-cixpqbh2toJDmkuCRI68nXA8ZxNmdK9Y+9v5h3MC3ZQKy/0BO8AWzlkWyRM7JAFSGBlfig4YVTPsK6MVgqz1uw=="],
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img=="],
"@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.3.2", "", { "dependencies": { "@emnapi/core": "^1.11.1", "@emnapi/runtime": "^1.11.1", "@emnapi/wasi-threads": "^1.2.2", "@napi-rs/wasm-runtime": "^1.1.4", "@tybys/wasm-util": "^0.10.2", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-4ec2Z/LOmRsAgU23CS4xeJfcJlmRg94A/XrbGRCF1gyU/zdDfRLYDVsS+ynSZCmGNxQ1jQriQOKMQeQxBA3Isw=="],
"@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.3.3", "", { "dependencies": { "@emnapi/core": "^1.11.1", "@emnapi/runtime": "^1.11.1", "@emnapi/wasi-threads": "^1.2.2", "@napi-rs/wasm-runtime": "^1.1.4", "@tybys/wasm-util": "^0.10.2", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ=="],
"@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.3.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Zyr/M0+XcYZu3bZrUytc7TXvrk0ftWfl8gN2MwekNDzhqhKRUucMPSeOzM0o0wH5AWOU49BsKRrfKxI2atCPMQ=="],
"@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.3.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ=="],
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.3.2", "", { "os": "win32", "cpu": "x64" }, "sha512-QI9BO7KlNZsp2GuO0jwAAj5jCDABOKXRkCk2XuKTSaNEFSdfzqswYVTtCHBNKHLsqyjFyFkqlDiwkNbTYSssMQ=="],
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.3.3", "", { "os": "win32", "cpu": "x64" }, "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw=="],
"@tailwindcss/vite": ["@tailwindcss/vite@4.3.2", "", { "dependencies": { "@tailwindcss/node": "4.3.2", "@tailwindcss/oxide": "4.3.2", "tailwindcss": "4.3.2" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-eHpMeX4JXfVNJDEcsouTeCBubJBTcTLigeaw/NTUW6PB5ATKKXdyonnXgTBX2VuRbjz1hjfz6C5XAhr52ImQXA=="],
"@tailwindcss/vite": ["@tailwindcss/vite@4.3.3", "", { "dependencies": { "@tailwindcss/node": "4.3.3", "@tailwindcss/oxide": "4.3.3", "tailwindcss": "4.3.3" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw=="],
"@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=="],
@@ -1003,8 +1023,6 @@
"adm-zip": ["adm-zip@0.5.18", "", {}, "sha512-ufJnssQGbxzLNS1Ho9bCtX4rQKCCvoVuDLHoJyc3F9dOGDB4BkWs2Ci0kv53lqocAEQ/Cbi+I2XCsNYGqVYqng=="],
"ansi-escapes": ["ansi-escapes@7.3.0", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg=="],
"ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
"ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
@@ -1045,7 +1063,7 @@
"callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="],
"caniuse-lite": ["caniuse-lite@1.0.30001805", "", {}, "sha512-52noaS3DubycKSXaU30TwPGIp+POyQSUVa5jBEq3vkRkY0kjyb3LQgvhU6WGyCcyXqVLWO0Cw0Q6BSdD0kUfVA=="],
"caniuse-lite": ["caniuse-lite@1.0.30001806", "", {}, "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw=="],
"chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
@@ -1057,12 +1075,8 @@
"chromium-bidi": ["chromium-bidi@16.0.1", "", { "dependencies": { "mitt": "^3.0.1", "zod": "^3.24.1" }, "peerDependencies": { "devtools-protocol": "*" } }, "sha512-J63PGu/9PpeCwLIcKYyzWP6yaVL5pxuBc0shlYCYM8BaAkmlwiQboXO1iNbOgSDbVklEyYFfNEcHD8oOAWacUA=="],
"cli-cursor": ["cli-cursor@5.0.0", "", { "dependencies": { "restore-cursor": "^5.0.0" } }, "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw=="],
"cli-progress": ["cli-progress@3.12.0", "", { "dependencies": { "string-width": "^4.2.3" } }, "sha512-tRkV3HJ1ASwm19THiiLIXLO7Im7wlTuKnvkYaTkyoAPefqjNg7W7DHKUlGRxy9vxDvbyCYQkQozvptuMkGCg8A=="],
"cli-truncate": ["cli-truncate@5.2.0", "", { "dependencies": { "slice-ansi": "^8.0.0", "string-width": "^8.2.0" } }, "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw=="],
"cli-width": ["cli-width@4.1.0", "", {}, "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ=="],
"clipanion": ["clipanion@4.0.0-rc.4", "", { "dependencies": { "typanion": "^3.8.0" } }, "sha512-CXkMQxU6s9GklO/1f714dkKBMu1lopS1WFF0B8o4AxPykR1hpozxSiUZ5ZUeBjfPgCWqbcNOtZVFhB8Lkfp1+Q=="],
@@ -1145,7 +1159,7 @@
"duck": ["duck@0.1.12", "", { "dependencies": { "underscore": "^1.13.1" } }, "sha512-wkctla1O6VfP89gQ+J/yDesM0S7B7XLXjKGzXxMDVFg7uEn706niAtyYovKbyq1oT9YwDcly721/iUWoc8MVRg=="],
"electron-to-chromium": ["electron-to-chromium@1.5.389", "", {}, "sha512-cEto7aeOqBfU1D+c5py5pE+ooscKE75JifxLBdFUZsqAxRS6y7kebtxAZvICszSl05gPjYHDTjY+lXpyGvpJbg=="],
"electron-to-chromium": ["electron-to-chromium@1.5.393", "", {}, "sha512-kiDJdIUawuEIcp9XoICKp1iTYDEbgguIPq526N1Q7jIQDeQ3CqoMx71025PI/7E48Ddtw2HuWsVjY7afEgNxmg=="],
"emnapi": ["emnapi@1.11.2", "", { "peerDependencies": { "node-addon-api": ">= 6.1.0" }, "optionalPeers": ["node-addon-api"] }, "sha512-iMt/XQc69fFn2EvcU6tm14HmXKwyy0lnABugsQlqp6xFuZIUuO+ONVSg2mz+MTVF8WbC+bic65AvRXdoldALKg=="],
@@ -1153,12 +1167,10 @@
"enabled": ["enabled@2.0.0", "", {}, "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ=="],
"enhanced-resolve": ["enhanced-resolve@5.21.6", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ=="],
"enhanced-resolve": ["enhanced-resolve@5.24.2", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-rpsZEGT1jFuve6QlpyRp9ckQ+kN61hvF9BzCPyMdaKTm8UJce96KBn3sorXOFXlzjPrs3Vc4T1NsSroZ3PxlFw=="],
"entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
"environment": ["environment@1.1.0", "", {}, "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q=="],
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
@@ -1171,8 +1183,6 @@
"escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
"eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="],
"fast-string-truncated-width": ["fast-string-truncated-width@3.0.3", "", {}, "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g=="],
"fast-string-width": ["fast-string-width@3.0.2", "", { "dependencies": { "fast-string-truncated-width": "^3.0.2" } }, "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg=="],
@@ -1181,7 +1191,7 @@
"fast-xml-builder": ["fast-xml-builder@1.3.0", "", { "dependencies": { "path-expression-matcher": "^1.6.2", "xml-naming": "^0.3.0" } }, "sha512-F74cZEdCvuw9P41GAC3rod4X04jjWGM1JPEv/GWSqFTWLsdyMSBMBMlm9Hk3GLBgLBbdBNY8yee0pQh2RBVESQ=="],
"fast-xml-parser": ["fast-xml-parser@5.10.0", "", { "dependencies": { "@nodable/entities": "^2.2.0", "fast-xml-builder": "^1.2.0", "is-unsafe": "^2.0.0", "path-expression-matcher": "^1.6.2", "strnum": "^2.4.1", "xml-naming": "^0.3.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-SLhnTEqE5QpJHq/6zl9bsmImEP2adv+y6Wy+cJa7nVTRzQh1OZfCe9k29M5xN74LWnu0xa1zrUrq3KnOKl92Fg=="],
"fast-xml-parser": ["fast-xml-parser@5.10.1", "", { "dependencies": { "@nodable/entities": "^3.0.0", "fast-xml-builder": "^1.2.0", "is-unsafe": "^2.0.0", "path-expression-matcher": "^1.6.2", "strnum": "^2.4.1", "xml-naming": "^0.3.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-IEMIf7298kXuZSRFoGfMYrl7is8LpavODgbNz1cwIudv7KwVFnuU+UsMporfq6PD6aXSlawZlARiA3UywCTfMw=="],
"fastembed": ["fastembed@2.1.0", "", { "dependencies": { "@anush008/tokenizers": "^0.0.0", "@huggingface/hub": "^2.7.1", "onnxruntime-node": "1.21.0", "progress": "^2.0.3", "tar": "^6.2.0" } }, "sha512-oQkpcRHBppJ3+a3w9dU0uytSY0N1cnEa/iVMc8AXEd+tvT529GekOEFhNviJy89R3lvQXF6cdIMTXHj1Gi00xQ=="],
@@ -1243,7 +1253,7 @@
"internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="],
"is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="],
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
"is-obj": ["is-obj@2.0.0", "", {}, "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w=="],
@@ -1301,14 +1311,10 @@
"linkedom": ["linkedom@0.18.13", "", { "dependencies": { "css-select": "^7.0.0", "cssom": "^0.5.0", "html-escaper": "^3.0.3", "htmlparser2": "^10.1.0", "uhyphen": "^0.2.0" }, "peerDependencies": { "canvas": ">= 2" }, "optionalPeers": ["canvas"] }, "sha512-ES/o9qotMpzpN2MHs+Iq/JcVoOj8Fa5wiQYrTdFpvAnwXL0g66XHHUc9WUMk6nAlBtGsFQ24ne+SYnvnaQ2FSw=="],
"lint-staged": ["lint-staged@17.0.8", "", { "dependencies": { "listr2": "^10.2.1", "picomatch": "^4.0.4", "string-argv": "^0.3.2", "tinyexec": "^1.2.4" }, "optionalDependencies": { "yaml": "^2.9.0" }, "bin": { "lint-staged": "bin/lint-staged.js" } }, "sha512-B2P/d+jVW0UXOQ0MVMLrB/9ydA1P+zz6jYfdrbbEd9ur3S2rcbduFWKiUCC02Sm5hbC8nrm7y24WuYMG54HfxA=="],
"listr2": ["listr2@10.2.2", "", { "dependencies": { "cli-truncate": "^5.2.0", "eventemitter3": "^5.0.4", "log-update": "^6.1.0", "rfdc": "^1.4.1", "wrap-ansi": "^10.0.0" } }, "sha512-JtNtbZj8q5BnDMR7trpwvwk3RIrANtIVzEUm8w7amp6xelLgyuq+4WZoTH913XaQAoH/cNdYhaNzBPA2U3xbDw=="],
"lint-staged": ["lint-staged@17.1.0", "", { "dependencies": { "picomatch": "^4.0.5", "string-argv": "^0.3.2", "tinyexec": "^1.2.4" }, "optionalDependencies": { "yaml": "^2.9.0" }, "bin": { "lint-staged": "bin/lint-staged.js" } }, "sha512-d7UQRu/9ZPgfu4+hu/k0wny5GEaIxo+2jb2LJqQDkE7cHRTm1HGqNUDq5UOwsGPpjpaNAFmgAsYo3TR+i9cSJw=="],
"lodash.isequal": ["lodash.isequal@4.5.0", "", {}, "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ=="],
"log-update": ["log-update@6.1.0", "", { "dependencies": { "ansi-escapes": "^7.0.0", "cli-cursor": "^5.0.0", "slice-ansi": "^7.1.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w=="],
"logform": ["logform@2.7.0", "", { "dependencies": { "@colors/colors": "1.6.0", "@types/triple-beam": "^1.3.2", "fecha": "^4.2.0", "ms": "^2.1.1", "safe-stable-stringify": "^2.3.1", "triple-beam": "^1.3.0" } }, "sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ=="],
"long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="],
@@ -1317,7 +1323,7 @@
"lru-cache": ["lru-cache@11.5.2", "", {}, "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g=="],
"lucide-react": ["lucide-react@1.24.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-YT6mBD8lGKkg4nM39enlm94/sfJIiW0YKUT60fBy4YK8tai31ylg1VhGNWxkpSKHo9UagfnZqwIff3HTDQwXeA=="],
"lucide-react": ["lucide-react@1.25.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-/mdJTRbiwcLOQ1NZZK1amZF9rIZyvO18D6r9TngE6TG1NmqHgFuT4eE7Xrkm9UsXMbBJD1NlfwHVltCDWHrOTw=="],
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
@@ -1329,8 +1335,6 @@
"merge-anything": ["merge-anything@5.1.7", "", { "dependencies": { "is-what": "^4.1.8" } }, "sha512-eRtbOb1N5iyH0tkQDAoQ4Ipsp/5qSR79Dzrz8hEPxRX10RWWR/iQXdoKmBSRCThY1Fh5EhISDtpSc93fpxUniQ=="],
"mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="],
"minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="],
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
@@ -1343,7 +1347,7 @@
"mkdirp": ["mkdirp@1.0.4", "", { "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw=="],
"modern-tar": ["modern-tar@0.7.6", "", {}, "sha512-sweCIVXzx1aIGTCdzcMlSZt1h8k5Tmk08VNAuRk3IU28XamGiOH5ypi11g6De2CH7PhYqSSnGy2A/EFhbWnVKg=="],
"modern-tar": ["modern-tar@0.7.7", "", {}, "sha512-t9VmxaqrmANnEOBhpSDI6HD192Ge48k8vmWqQQL7hSFEqHEYwZbbsu49+aKLWZeRvFs3j1pMhXOqqF4kPlvjkQ=="],
"moment": ["moment@2.30.1", "", {}, "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how=="],
@@ -1371,12 +1375,10 @@
"object-keys": ["object-keys@1.1.1", "", {}, "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="],
"obug": ["obug@2.1.3", "", {}, "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg=="],
"obug": ["obug@2.1.4", "", {}, "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA=="],
"one-time": ["one-time@1.0.0", "", { "dependencies": { "fn.name": "1.x.x" } }, "sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g=="],
"onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="],
"onnxruntime-common": ["onnxruntime-common@1.26.0", "", {}, "sha512-qVyMR4lcWgbkc4getFV+GQijsTnbg/siteoqcDwa3sI/LxbrMSNw4ePyvCq/ymdQaRomCA7YuWmhzsswxvymdw=="],
"onnxruntime-node": ["onnxruntime-node@1.26.0", "", { "dependencies": { "adm-zip": "^0.5.16", "global-agent": "^4.1.3", "onnxruntime-common": "1.26.0" }, "os": [ "linux", "win32", "darwin", ] }, "sha512-OHl6PiOEOqxaLHL0N9eFrbzS7IGmu3BtJNH3RTEnRAheCIkfc3gjcjl4sGcjp9C22ZC9YTquDOxSdT/stBQ6BQ=="],
@@ -1403,7 +1405,7 @@
"platform": ["platform@1.3.6", "", {}, "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg=="],
"postcss": ["postcss@8.5.17", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-J7EF+8X+CzRPaJPOv9Ck2wNWJvGnnl3PcNPAdGg6GTLjyVpyQ0yATMSXRFRV01BviT/9Gwuc3rjEyJbDJG9a4w=="],
"postcss": ["postcss@8.5.19", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ=="],
"prettier": ["prettier@3.9.5", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg=="],
@@ -1427,10 +1429,6 @@
"regexp-tree": ["regexp-tree@0.1.27", "", { "bin": { "regexp-tree": "bin/regexp-tree" } }, "sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA=="],
"restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="],
"rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="],
"roarr": ["roarr@2.15.4", "", { "dependencies": { "boolean": "^3.0.1", "detect-node": "^2.0.4", "globalthis": "^1.0.1", "json-stringify-safe": "^5.0.1", "semver-compare": "^1.0.0", "sprintf-js": "^1.1.2" } }, "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A=="],
"robomp-web": ["robomp-web@workspace:python/robomp/web"],
@@ -1477,8 +1475,6 @@
"signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
"slice-ansi": ["slice-ansi@8.0.0", "", { "dependencies": { "ansi-styles": "^6.2.3", "is-fullwidth-code-point": "^5.1.0" } }, "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg=="],
"solid-js": ["solid-js@1.9.14", "", { "dependencies": { "csstype": "^3.1.0", "seroval": "~1.5.4", "seroval-plugins": "~1.5.4" } }, "sha512-sAEXC0Kk0S1EDg+8ysEWJDbYhA3RRoEjwuySUGlKIemeo0I5YZfOyumNjNs9Sv3y2nmhD+0rW66ag2HsMuQiGQ=="],
"solid-refresh": ["solid-refresh@0.6.3", "", { "dependencies": { "@babel/generator": "^7.23.6", "@babel/helper-module-imports": "^7.22.15", "@babel/types": "^7.23.6" }, "peerDependencies": { "solid-js": "^1.3" } }, "sha512-F3aPsX6hVw9ttm5LYlth8Q15x6MlI/J3Dn+o3EQyRTtTxidepSTwAYdozt01/YA+7ObcciagGEyXIopGZzQtbA=="],
@@ -1503,7 +1499,7 @@
"tailwind-merge": ["tailwind-merge@3.6.0", "", {}, "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w=="],
"tailwindcss": ["tailwindcss@4.3.2", "", {}, "sha512-WtctNNSH8A9jlMIqxzuYumOHU5uGZyRv0Q5svQl+oEPy5w84YpBxdb7MdqyiSPQge5jTJ6zFQLq0PFygdccSBA=="],
"tailwindcss": ["tailwindcss@4.3.3", "", {}, "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ=="],
"tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
@@ -1549,9 +1545,9 @@
"vali-date": ["vali-date@1.0.0", "", {}, "sha512-sgECfZthyaCKW10N0fm27cg8HYTFK5qMWgypqkXMQ4Wbl/zZKx7xZICgcoxIIE+WFAP/MBL2EFwC/YvLxw3Zeg=="],
"vite": ["vite@8.1.4", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.5", "postcss": "^8.5.16", "rolldown": "~1.1.4", "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-bTT9PsdWO+MQMNG9ZXIP/qM9wGh37DFxTV/sPq9cFpHr3w4jkgef032PkAL9jAqhk3Nz8NQw3O8n6/xFkqO4QQ=="],
"vite": ["vite@8.1.5", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.5", "postcss": "^8.5.17", "rolldown": "~1.1.5", "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-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw=="],
"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=="],
"vite-plugin-solid": ["vite-plugin-solid@2.11.13", "", { "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 || ^9.0.0" }, "optionalPeers": ["@testing-library/jest-dom"] }, "sha512-YaCNMzwIawUO8K16uj5jaxUJIYCstBEjkUppC5OKElBz/K2+R7Q7MWycHDgqzjBca5WWy/2ZQQ45IHexaabYew=="],
"vitefu": ["vitefu@1.1.3", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["vite"] }, "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg=="],
@@ -1565,9 +1561,9 @@
"wordwrap": ["wordwrap@1.0.0", "", {}, "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q=="],
"wrap-ansi": ["wrap-ansi@10.0.0", "", { "dependencies": { "ansi-styles": "^6.2.3", "string-width": "^8.2.0", "strip-ansi": "^7.1.2" } }, "sha512-SGcvg80f0wUy2/fXES19feHMz8E0JoXv2uNgHOu4Dgi2OrCy1lqwFYEJz1BLbDI0exjPMe/ZdzZ/YpGECBG/aQ=="],
"wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="],
"ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="],
"ws": ["ws@8.21.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw=="],
"xml-naming": ["xml-naming@0.3.0", "", {}, "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ=="],
@@ -1597,6 +1593,10 @@
"@isaacs/fs-minipass/minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="],
"@napi-rs/lzma-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA=="],
"@napi-rs/tar-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA=="],
"@rolldown/binding-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" }, "bundled": true }, "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA=="],
@@ -1617,10 +1617,6 @@
"cli-progress/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"cli-truncate/string-width": ["string-width@8.2.2", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg=="],
"cliui/wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="],
"dom-serializer/domelementtype": ["domelementtype@3.0.0", "", {}, "sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg=="],
"dom-serializer/entities": ["entities@8.0.0", "", {}, "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA=="],
@@ -1641,10 +1637,6 @@
"jszip/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="],
"log-update/slice-ansi": ["slice-ansi@7.1.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w=="],
"log-update/wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="],
"minizlib/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="],
"onnxruntime-web/onnxruntime-common": ["onnxruntime-common@1.24.0-dev.20251116-b39e144322", "", {}, "sha512-BOoomdHYmNRL5r4iQ4bMvsl2t0/hzVQ3OM3PHD0gxeXu1PmggqBv3puZicEUVOA3AtHHYmqZtjMj9FOfGrATTw=="],
@@ -1655,8 +1647,6 @@
"string_decoder/safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
"wrap-ansi/string-width": ["string-width@8.2.2", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg=="],
"@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"@huggingface/transformers/onnxruntime-node/global-agent": ["global-agent@3.0.0", "", { "dependencies": { "boolean": "^3.0.1", "es6-error": "^4.1.1", "matcher": "^3.0.0", "roarr": "^2.15.3", "semver": "^7.3.2", "serialize-error": "^7.0.1" } }, "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q=="],
@@ -1665,8 +1655,6 @@
"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=="],
"cli-progress/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"fastembed/onnxruntime-node/global-agent": ["global-agent@3.0.0", "", { "dependencies": { "boolean": "^3.0.1", "es6-error": "^4.1.1", "matcher": "^3.0.0", "roarr": "^2.15.3", "semver": "^7.3.2", "serialize-error": "^7.0.1" } }, "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q=="],
+3 -1
View File
@@ -80,7 +80,9 @@ mod imp {
use crate::{IsoError, IsoResult};
const FICLONE: libc::c_ulong = 0x4004_9409;
// `libc::Ioctl` is `c_int` on musl and `c_ulong` on glibc; the constant fits
// both.
const FICLONE: libc::Ioctl = 0x4004_9409;
pub fn start(lower: &Path, merged: &Path) -> IsoResult<()> {
let lower = canonical_existing_dir(lower)?;
-1
View File
@@ -45,7 +45,6 @@ rayon.workspace = true
regex.workspace = true
serde.workspace = true
serde_json.workspace = true
similar.workspace = true
smallvec.workspace = true
syntect.workspace = true
tiktoken-rs.workspace = true
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -27,6 +27,7 @@ pub mod ast;
pub mod block;
pub mod clipboard;
pub mod crash_handler;
pub mod diff;
pub mod fd;
pub mod glob;
pub mod glob_util;
@@ -53,6 +54,7 @@ pub(crate) mod testing;
pub mod text;
pub mod tokens;
pub(crate) mod utils;
pub mod vectors;
pub mod workspace;
#[cfg(target_os = "windows")]
@@ -248,7 +250,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 = "__piNativesV17_0_0")]
#[napi(js_name = "__piNativesV17_0_8")]
pub const fn pi_natives_version_sentinel() {}
/// Native module entry point: install crash diagnostics before any tool can
+21 -11
View File
@@ -132,7 +132,8 @@ impl PtySession {
Self { core: Arc::new(Mutex::new(None)) }
}
/// Start a shell command and stream output chunks via callback.
/// Start a shell command, stream output chunks, and report the spawned child
/// PID.
#[napi]
pub fn start<'env>(
&self,
@@ -140,6 +141,8 @@ impl PtySession {
options: PtyStartOptions<'env>,
#[napi(ts_arg_type = "((error: Error | null, chunk: string) => void) | undefined | null")]
on_chunk: Option<ThreadsafeFunction<String>>,
#[napi(ts_arg_type = "((error: Error | null, pid: number) => void) | undefined | null")]
on_start: Option<ThreadsafeFunction<u32>>,
) -> Result<PromiseRaw<'env, PtyRunResult>> {
let run_config = PtyRunConfig {
command: PtyCommand::Shell { command: options.command, shell: options.shell },
@@ -148,11 +151,11 @@ impl PtySession {
cols: options.cols.unwrap_or(120).clamp(20, 400),
rows: options.rows.unwrap_or(40).clamp(5, 200),
};
self.start_config(env, run_config, options.timeout_ms, options.signal, on_chunk)
self.start_config(env, run_config, options.timeout_ms, options.signal, on_chunk, on_start)
}
/// Start an executable with separate arguments and stream output chunks via
/// callback.
/// Start an executable with separate arguments, stream output chunks, and
/// report the spawned child PID.
#[napi]
pub fn start_argv<'env>(
&self,
@@ -160,6 +163,8 @@ impl PtySession {
options: PtyArgvStartOptions<'env>,
#[napi(ts_arg_type = "((error: Error | null, chunk: string) => void) | undefined | null")]
on_chunk: Option<ThreadsafeFunction<String>>,
#[napi(ts_arg_type = "((error: Error | null, pid: number) => void) | undefined | null")]
on_start: Option<ThreadsafeFunction<u32>>,
) -> Result<PromiseRaw<'env, PtyRunResult>> {
let run_config = PtyRunConfig {
command: PtyCommand::Argv { application: options.application, args: options.args },
@@ -168,7 +173,7 @@ impl PtySession {
cols: options.cols.unwrap_or(120).clamp(20, 400),
rows: options.rows.unwrap_or(40).clamp(5, 200),
};
self.start_config(env, run_config, options.timeout_ms, options.signal, on_chunk)
self.start_config(env, run_config, options.timeout_ms, options.signal, on_chunk, on_start)
}
/// Write raw input bytes to PTY stdin.
@@ -201,6 +206,7 @@ impl PtySession {
timeout_ms: Option<u32>,
signal: Option<Unknown<'env>>,
on_chunk: Option<ThreadsafeFunction<String>>,
on_start: Option<ThreadsafeFunction<u32>>,
) -> Result<PromiseRaw<'env, PtyRunResult>> {
let ct = task::CancelToken::new(timeout_ms, signal);
let core = Arc::clone(&self.core);
@@ -215,9 +221,10 @@ impl PtySession {
*guard = Some(PtySessionCore { control_tx });
}
task::future(env, "pty.start", async move {
let run_result =
tokio::task::spawn_blocking(move || run_pty_sync(run_config, on_chunk, control_rx, ct))
.await;
let run_result = tokio::task::spawn_blocking(move || {
run_pty_sync(run_config, on_chunk, on_start, control_rx, ct)
})
.await;
let mut guard = core.lock();
*guard = None;
@@ -262,6 +269,7 @@ fn terminate_pty_processes(
fn run_pty_sync(
config: PtyRunConfig,
on_chunk: Option<ThreadsafeFunction<String>>,
on_start: Option<ThreadsafeFunction<u32>>,
control_rx: flume::Receiver<ControlMessage>,
ct: task::CancelToken,
) -> Result<PtyRunResult> {
@@ -343,6 +351,11 @@ fn run_pty_sync(
.spawn_command(cmd)
.map_err(|err| Error::from_reason(format!("Failed to spawn PTY command: {err}")))?;
drop(pair.slave);
let child_process_id = child.process_id();
let child_pid = child_process_id.and_then(|value| i32::try_from(value).ok());
if let Some(callback) = on_start.as_ref() {
callback.call(Ok(child_process_id.unwrap_or(0)), ThreadsafeFunctionCallMode::NonBlocking);
}
ct.heartbeat()
.map_err(|err| Error::from_reason(format!("PTY setup cancelled before reader: {err}")))?;
@@ -423,9 +436,6 @@ fn run_pty_sync(
let _ = reader_tx.send(ReaderEvent::Done);
});
let child_pid = child
.process_id()
.and_then(|value| i32::try_from(value).ok());
#[cfg(unix)]
let process_group_id = master.process_group_leader().filter(|pgid| *pgid > 0);
#[cfg(not(unix))]
+34
View File
@@ -558,4 +558,38 @@ mod tests {
.expect("shell run should return");
assert!(result.cancelled);
}
#[tokio::test(flavor = "multi_thread")]
async fn timeout_drains_pipeline_output_before_stopping_reader() {
let shell = CoreShell::new(None);
let (tx, rx) = flume::unbounded::<String>();
// `tail` runs as an in-process builtin, so cancellation kills only the
// external `yes`; tail then sees EOF and flushes its final 5 lines into
// the post-cancel reader grace window. The deadline must be generous
// enough that `yes` has demonstrably spawned and produced before the
// timeout fires — a 50ms budget lost that race on cold CI runners and
// tail flushed an empty ring buffer.
const TIMEOUT_MS: u32 = 750;
let result = shell
.run(
CoreShellRunOptions {
command: "yes x | tail -5".to_string(),
cwd: None,
env: None,
timeout_ms: Some(TIMEOUT_MS),
},
Some(tx),
CancelToken::new(Some(TIMEOUT_MS)),
)
.await
.expect("shell run");
let mut output = String::new();
while let Ok(chunk) = rx.recv_async().await {
output.push_str(&chunk);
}
assert!(result.timed_out);
assert_eq!(output.lines().filter(|line| *line == "x").count(), 5);
}
}
+94 -31
View File
@@ -23,6 +23,7 @@ const MIN_TAB_WIDTH: u32 = 1;
const MAX_TAB_WIDTH: u32 = 16;
pub const DEFAULT_TAB_WIDTH: usize = 3;
const ESC: u16 = 0x1b;
const OSC8_CLOSE: [u16; 6] = [ESC, b']' as u16, b'8' as u16, b';' as u16, b';' as u16, 0x07];
#[inline]
fn clamp_tab_width_for_ops(width: u32) -> usize {
@@ -237,6 +238,42 @@ impl AnsiState {
}
}
#[derive(Default)]
struct WrapState {
sgr: AnsiState,
hyperlink: Option<Vec<u16>>,
}
impl WrapState {
#[inline]
const fn new() -> Self {
Self { sgr: AnsiState::new(), hyperlink: None }
}
#[inline]
fn apply_ansi_u16(&mut self, seq: &[u16]) {
if is_sgr_u16(seq) {
self.sgr.apply_sgr_u16(&seq[2..seq.len() - 1]);
} else if let Some(uri) = osc8_uri_u16(seq) {
if uri.is_empty() {
self.hyperlink = None;
} else {
let hyperlink = self.hyperlink.get_or_insert_default();
hyperlink.clear();
hyperlink.extend_from_slice(seq);
}
}
}
#[inline]
fn write_restore_u16(&self, out: &mut Vec<u16>) {
self.sgr.write_restore_u16(out);
if let Some(hyperlink) = &self.hyperlink {
out.extend_from_slice(hyperlink);
}
}
}
#[inline]
fn write_color_u16(out: &mut Vec<u16>, color: ColorVal, base: u32, first: &mut bool) {
if color == COLOR_NONE {
@@ -372,6 +409,28 @@ fn is_sgr_u16(seq: &[u16]) -> bool {
seq.len() >= 3 && seq[1] == b'[' as u16 && *seq.last().unwrap() == b'm' as u16
}
#[inline]
fn osc8_uri_u16(seq: &[u16]) -> Option<&[u16]> {
if seq.len() < OSC8_CLOSE.len()
|| seq[0] != ESC
|| seq[1] != b']' as u16
|| seq[2] != b'8' as u16
|| seq[3] != b';' as u16
{
return None;
}
let body_end = if seq.last() == Some(&0x07_u16) {
seq.len() - 1
} else if seq.ends_with(&[ESC, b'\\' as u16]) {
seq.len() - 2
} else {
return None;
};
let uri_start = seq[4..body_end].iter().position(|&u| u == b';' as u16)? + 5;
Some(&seq[uri_start..body_end])
}
struct Osc66Info<'a> {
payload: &'a [u16],
scale: usize,
@@ -852,43 +911,44 @@ fn flush_pending_ansi(
// ============================================================================
#[inline]
fn write_active_codes(state: &AnsiState, out: &mut Vec<u16>) {
if !state.is_empty() {
state.write_restore_u16(out);
fn write_active_codes(state: &WrapState, out: &mut Vec<u16>) {
state.write_restore_u16(out);
}
#[inline]
fn write_hyperlink_close(state: &WrapState, out: &mut Vec<u16>) {
if state.hyperlink.is_some() {
out.extend_from_slice(&OSC8_CLOSE);
}
}
#[inline]
fn write_line_end_reset(state: &AnsiState, out: &mut Vec<u16>) {
let has_underline = state.attrs & ATTR_UNDERLINE != 0;
let has_strike = state.attrs & ATTR_STRIKE != 0;
if !has_underline && !has_strike {
return;
}
out.extend_from_slice(&[ESC, b'[' as u16]);
if has_underline {
out.extend_from_slice(&[b'2' as u16, b'4' as u16]);
if has_strike {
out.push(b';' as u16);
fn write_line_end_reset(state: &WrapState, out: &mut Vec<u16>) {
let has_underline = state.sgr.attrs & ATTR_UNDERLINE != 0;
let has_strike = state.sgr.attrs & ATTR_STRIKE != 0;
if has_underline || has_strike {
out.extend_from_slice(&[ESC, b'[' as u16]);
if has_underline {
out.extend_from_slice(&[b'2' as u16, b'4' as u16]);
if has_strike {
out.push(b';' as u16);
}
}
if has_strike {
out.extend_from_slice(&[b'2' as u16, b'9' as u16]);
}
out.push(b'm' as u16);
}
if has_strike {
out.extend_from_slice(&[b'2' as u16, b'9' as u16]);
}
out.push(b'm' as u16);
write_hyperlink_close(state, out);
}
fn update_state_from_text(data: &[u16], state: &mut AnsiState) {
fn update_state_from_text(data: &[u16], state: &mut WrapState) {
let mut i = 0usize;
while i < data.len() {
if data[i] == ESC
&& let Some(seq_len) = ansi_seq_len_u16(data, i)
{
let seq = &data[i..i + seq_len];
if is_sgr_u16(seq) {
state.apply_sgr_u16(&seq[2..seq_len - 1]);
}
state.apply_ansi_u16(&data[i..i + seq_len]);
i += seq_len;
continue;
}
@@ -977,7 +1037,7 @@ fn break_long_word(
word: &[u16],
width: usize,
tab_width: usize,
state: &mut AnsiState,
state: &mut WrapState,
) -> SmallVec<[Vec<u16>; 4]> {
let mut lines = SmallVec::<[Vec<u16>; 4]>::new();
let mut current_line = Vec::<u16>::new();
@@ -1004,9 +1064,7 @@ fn break_long_word(
continue;
}
current_line.extend_from_slice(seq);
if is_sgr_u16(seq) {
state.apply_sgr_u16(&seq[2..seq_len - 1]);
}
state.apply_ansi_u16(seq);
i += seq_len;
continue;
}
@@ -1070,14 +1128,18 @@ fn wrap_single_line(line: &[u16], width: usize, tab_width: usize) -> SmallVec<[V
}
if visible_width_u16(line, tab_width) <= width {
return smallvec![line.to_vec()];
let mut only = line.to_vec();
let mut state = WrapState::new();
update_state_from_text(line, &mut state);
write_hyperlink_close(&state, &mut only);
return smallvec![only];
}
let tokens = split_into_tokens_with_ansi(line);
let mut wrapped = SmallVec::<[Vec<u16>; 4]>::new();
let mut current_line = Vec::<u16>::new();
let mut current_width = 0usize;
let mut state = AnsiState::new();
let mut state = WrapState::new();
for token in tokens {
let token_width = visible_width_u16(&token, tab_width);
@@ -1124,6 +1186,7 @@ fn wrap_single_line(line: &[u16], width: usize, tab_width: usize) -> SmallVec<[V
}
if !current_line.is_empty() {
write_hyperlink_close(&state, &mut current_line);
wrapped.push(current_line);
}
@@ -1148,7 +1211,7 @@ fn wrap_text_with_ansi_impl(
}
let mut result = SmallVec::<[Vec<u16>; 4]>::new();
let mut state = AnsiState::new();
let mut state = WrapState::new();
let mut line_start = 0usize;
for i in 0..=text.len() {
+342
View File
@@ -0,0 +1,342 @@
//! Batch numeric vector kernels for mnemopi recall paths.
//!
//! Every export processes an entire candidate batch per N-API crossing so the
//! crossing cost is amortized over the whole recall operation. Semantics
//! mirror the TypeScript reference implementations in
//! `packages/mnemopi/src/core` exactly — same accumulation order, same
//! non-finite handling, same tie-breaking — so float scores are
//! bit-identical to the TS versions and integer results are exactly equal.
use napi::{
Error, Result, Status,
bindgen_prelude::{Float32Array, Float64Array, Uint32Array},
};
use napi_derive::napi;
fn invalid<T>(message: &str) -> Result<T> {
Err(Error::new(Status::InvalidArg, message))
}
#[inline]
const fn finite_or_zero(value: f64) -> f64 {
if value.is_finite() { value } else { 0.0 }
}
/// Cosine similarity with the exact semantics of mnemopi's TS
/// `cosineSimilarity`: iterate `max(len_a, len_b)` elements, treat missing
/// and non-finite entries as `0`, return `0` when either norm is zero.
///
/// Splitting the shared prefix from the tails preserves bit-exactness: tail
/// terms of the shorter side only ever add `±0.0` to `dot` and `+0.0` to its
/// own norm, in the same index order as the TS loop.
#[inline]
#[allow(
clippy::suboptimal_flops,
reason = "mul_add rounds differently; bit-exact with the TS loops is the contract"
)]
fn cosine_one(a: &[f64], b: &[f64]) -> f64 {
if a.is_empty() && b.is_empty() {
return 0.0;
}
let shared = a.len().min(b.len());
let mut dot = 0.0f64;
let mut norm_a = 0.0f64;
let mut norm_b = 0.0f64;
for i in 0..shared {
let av = finite_or_zero(a[i]);
let bv = finite_or_zero(b[i]);
dot += av * bv;
norm_a += av * av;
norm_b += bv * bv;
}
for &raw in &a[shared..] {
let av = finite_or_zero(raw);
norm_a += av * av;
}
for &raw in &b[shared..] {
let bv = finite_or_zero(raw);
norm_b += bv * bv;
}
if norm_a == 0.0 || norm_b == 0.0 {
return 0.0;
}
dot / (norm_a.sqrt() * norm_b.sqrt())
}
/// All pairs `(i, j)` with `i < j` whose cosine similarity meets `threshold`.
///
/// `vectors` is `count` vectors flattened row-major at `dim` `f64` elements
/// per row (zero-padded, which matches the TS `?? 0` missing-element
/// semantics), so the similarity is bit-identical to the TS pairwise loop in
/// `clusterBySimilarity`. Returns pairs flattened as `[i0, j0, i1, j1, ...]`
/// in the same `(i, j)` visit order as the TS nested loop.
#[napi]
pub fn cosine_similarity_pairs(
vectors: Float64Array,
count: u32,
dim: u32,
threshold: f64,
) -> Result<Uint32Array> {
let count = count as usize;
let dim = dim as usize;
let data: &[f64] = &vectors;
if data.len() != count * dim {
return invalid("vectors length must equal count * dim");
}
let widened: &[f64] = data;
let mut pairs: Vec<u32> = Vec::new();
for i in 0..count {
let left = &widened[i * dim..(i + 1) * dim];
for j in (i + 1)..count {
let right = &widened[j * dim..(j + 1) * dim];
if cosine_one(left, right) >= threshold {
pairs.push(i as u32);
pairs.push(j as u32);
}
}
}
Ok(Uint32Array::new(pairs))
}
/// Top-k rows of a normalized vector matrix ranked by dot product with a
/// normalized query.
#[napi(object)]
pub struct VectorTopK {
/// Row indices of the selected hits, best score first.
pub indices: Uint32Array,
/// Scores aligned with `indices`.
pub scores: Float64Array,
}
/// Score every row of a normalized `f32` matrix against `query` and return
/// the top `limit` rows.
///
/// Mirrors the TS `searchExactVectorIndex` loop bit-exactly: the query is
/// normalized by the L2 norm of its *full* length, each row score sums
/// `matrix[row][col] * (query[col] / norm)` over
/// `min(query.len, dimensions)` columns in column order. Ranking matches the
/// TS stable sort: score descending, lower row index first on exact ties
/// (`-0.0` and `+0.0` compare equal). Callers are expected to enforce the TS
/// guards first (finite query with a positive norm, non-empty matrix).
#[napi]
#[allow(
clippy::suboptimal_flops,
reason = "mul_add rounds differently; bit-exact with the TS loops is the contract"
)]
pub fn vector_index_top_k(
matrix: Float32Array,
dimensions: u32,
query: Float64Array,
limit: u32,
) -> Result<VectorTopK> {
let dims = dimensions as usize;
let data: &[f32] = &matrix;
if dims == 0 || !data.len().is_multiple_of(dims) {
return invalid("matrix length must be a positive multiple of dimensions");
}
let count = data.len() / dims;
let q: &[f64] = &query;
let mut norm_sq = 0.0f64;
for &value in q {
norm_sq += value * value;
}
let norm = norm_sq.sqrt();
// Hoisting the per-column division out of the row loop is bitwise
// identical to the TS per-row `query[col] / queryNorm`.
let query_dims = q.len().min(dims);
let normalized: Vec<f64> = q[..query_dims].iter().map(|&v| v / norm).collect();
let mut order: Vec<(f64, u32)> = Vec::with_capacity(count);
for row in 0..count {
let base = row * dims;
let mut score = 0.0f64;
for (col, &qv) in normalized.iter().enumerate() {
score += f64::from(data[base + col]) * qv;
}
order.push((score, row as u32));
}
// JS comparator `(a, b) => b.score - a.score` under a stable sort: strict
// score ordering, otherwise (equal, including ±0.0) original row order.
order.sort_by(|a, b| {
let diff = b.0 - a.0;
if diff > 0.0 {
core::cmp::Ordering::Greater
} else if diff < 0.0 {
core::cmp::Ordering::Less
} else {
a.1.cmp(&b.1)
}
});
let take = (limit as usize).min(order.len());
order.truncate(take);
let indices: Vec<u32> = order.iter().map(|&(_, row)| row).collect();
let scores: Vec<f64> = order.iter().map(|&(score, _)| score).collect();
Ok(VectorTopK { indices: Uint32Array::new(indices), scores: Float64Array::new(scores) })
}
/// ECMA-262 `\s` (`WhiteSpace` ∪ `LineTerminator`), which differs from Rust's
/// `char::is_whitespace` (JS additionally includes U+FEFF).
#[inline]
const fn is_js_whitespace(c: char) -> bool {
matches!(
c,
'\u{0009}'
| '\u{000a}'
| '\u{000b}'
| '\u{000c}'
| '\u{000d}'
| '\u{0020}'
| '\u{00a0}'
| '\u{1680}'
| '\u{2000}'
..='\u{200a}'
| '\u{2028}'
| '\u{2029}'
| '\u{202f}'
| '\u{205f}'
| '\u{3000}'
| '\u{feff}'
)
}
/// Lowercased word set per the TS `jaccardSimilarity` tokenizer:
/// `text.toLowerCase().split(/\s+/).filter(Boolean)` into a `Set`.
/// Returned sorted and deduplicated for merge-based intersection counting.
fn word_set(text: &str) -> Vec<Box<str>> {
let lower = text.to_lowercase();
let mut words: Vec<Box<str>> = lower
.split(is_js_whitespace)
.filter(|w| !w.is_empty())
.map(Box::from)
.collect();
words.sort_unstable();
words.dedup();
words
}
/// Jaccard similarity of two sorted, deduplicated word sets. Matches the TS
/// `jaccardSimilarity`: `0` when either set is empty, otherwise
/// `|A ∩ B| / (|A| + |B| - |A ∩ B|)` with exact integer counts.
fn jaccard_sorted(a: &[Box<str>], b: &[Box<str>]) -> f64 {
if a.is_empty() || b.is_empty() {
return 0.0;
}
let mut intersection = 0usize;
let (mut i, mut j) = (0usize, 0usize);
while i < a.len() && j < b.len() {
match a[i].cmp(&b[j]) {
core::cmp::Ordering::Less => i += 1,
core::cmp::Ordering::Greater => j += 1,
core::cmp::Ordering::Equal => {
intersection += 1;
i += 1;
j += 1;
},
}
}
intersection as f64 / (a.len() + b.len() - intersection) as f64
}
/// MMR selection over pre-sorted candidates using Jaccard word similarity.
///
/// `contents[i]` and `scores[i]` describe candidate `i`, already sorted by
/// relevance exactly as the TS `mmrRerank` sorts them (the JS stable sort
/// stays on the TS side so its tie and NaN semantics are preserved).
/// Replicates the TS selection loop exactly: candidate `0` is always taken
/// first; each round picks the remaining candidate maximizing
/// `lambda * score - (1 - lambda) * maxSimilarity(selected)` with strict
/// `>` comparisons, so ties keep the earliest remaining candidate — and a
/// round where every score is `NaN` picks the first remaining candidate,
/// matching the TS `bestIdx = 0` initialisation. Returns the selected
/// indices into the input order.
///
/// Word tokenization matches `text.toLowerCase().split(/\s+/)` (ECMA `\s`,
/// Unicode default full case conversion). Known divergence: unpaired
/// surrogates arrive here as U+FFFD, while JS keeps the lone surrogate; both
/// tokenize to a single non-whitespace word so Jaccard counts still agree
/// unless a text mixes U+FFFD words with lone-surrogate words.
#[napi]
#[allow(
clippy::suboptimal_flops,
reason = "mul_add rounds differently; bit-exact with the TS loops is the contract"
)]
pub fn mmr_rerank_indices(
contents: Vec<String>,
scores: Float64Array,
lambda_param: f64,
top_k: u32,
) -> Result<Uint32Array> {
if scores.len() != contents.len() {
return invalid("scores length must equal contents length");
}
let limit = top_k as usize;
let count = contents.len();
if limit == 0 || count == 0 {
return Ok(Uint32Array::new(Vec::new()));
}
let sets: Vec<Vec<Box<str>>> = contents.iter().map(|text| word_set(text)).collect();
let mut selected: Vec<u32> = Vec::with_capacity(limit.min(count));
selected.push(0);
let mut remaining: Vec<u32> = (1..count as u32).collect();
while !remaining.is_empty() && selected.len() < limit {
let mut best_idx = 0usize;
let mut best_score = f64::NEG_INFINITY;
for (idx, &candidate) in remaining.iter().enumerate() {
let mut max_similarity = 0.0f64;
for &picked in &selected {
let similarity = jaccard_sorted(&sets[candidate as usize], &sets[picked as usize]);
if similarity > max_similarity {
max_similarity = similarity;
}
}
let relevance = scores[candidate as usize];
let mmr_score = lambda_param * relevance - (1.0 - lambda_param) * max_similarity;
if mmr_score > best_score {
best_score = mmr_score;
best_idx = idx;
}
}
selected.push(remaining.remove(best_idx));
}
if selected.len() < limit {
selected.extend(remaining);
selected.truncate(limit);
}
Ok(Uint32Array::new(selected))
}
#[cfg(test)]
mod tests {
use super::{cosine_one, is_js_whitespace, jaccard_sorted, word_set};
#[test]
fn cosine_matches_reference_semantics() {
assert_eq!(cosine_one(&[], &[]), 0.0);
assert_eq!(cosine_one(&[1.0, 0.0], &[0.0, 0.0]), 0.0);
let same = cosine_one(&[1.0, 2.0, 3.0], &[1.0, 2.0, 3.0]);
assert!((same - 1.0).abs() < 1e-12);
// Non-finite entries are zeroed, mismatched lengths pad with zero.
let sim = cosine_one(&[f64::NAN, 1.0], &[0.5, 1.0, 2.0]);
let expect = 1.0 / (1.0f64.sqrt() * (0.25f64 + 1.0 + 4.0).sqrt());
assert!((sim - expect).abs() < 1e-12);
}
#[test]
fn word_set_matches_js_tokenizer() {
let set = word_set("Hello\u{00a0}WORLD hello\u{feff}world");
assert_eq!(set, vec![Box::from("hello"), Box::from("world")]);
assert!(word_set("").is_empty());
assert!(word_set(" \t\n").is_empty());
assert!(!is_js_whitespace('\u{200b}')); // ZWSP is not JS \s
}
#[test]
fn jaccard_matches_reference() {
let a = word_set("the quick brown fox");
let b = word_set("the lazy brown dog");
let sim = jaccard_sorted(&a, &b);
assert!((sim - 2.0 / 6.0).abs() < 1e-12);
assert_eq!(jaccard_sorted(&a, &word_set("")), 0.0);
}
}
+2 -1
View File
@@ -376,6 +376,7 @@ fn uv_wrapper_tool<'a>(ctx: &'a MinimizerCtx<'_>) -> Option<&'a str> {
/// is already a single flag token and needs no entry here.
const WRAPPER_VALUE_OPTIONS: &[&str] = &[
// uv run
"--extra",
"--with",
"--with-requirements",
"--with-editable",
@@ -633,7 +634,7 @@ mod tests {
#[test]
fn uv_run_pytest_routes_to_python_filter() {
let config = MinimizerConfig::default();
let context = ctx("uv", Some("run"), "uv run pytest", &config);
let context = ctx("uv", Some("run"), "uv run --extra turso pytest", &config);
let input = "============================= test session starts \
==============================\ncollected 2 items\n\na.py .\nb.py \
F\n\n=================================== FAILURES \
+49
View File
@@ -1141,11 +1141,16 @@ async fn run_shell_command_once(
}
}
});
// Let pipeline consumers flush output after cancellation kills their
// producers. The outer run cancellation remains bounded, and this delayed
// fallback still releases readers whose writers never close.
const CANCEL_READER_GRACE: Duration = Duration::from_millis(500);
let cancel_bridge = tokio::spawn({
let cancel_token = cancel_token.clone();
let reader_cancel = reader_cancel.clone();
async move {
cancel_token.cancelled().await;
time::sleep(CANCEL_READER_GRACE).await;
reader_cancel.cancel();
}
});
@@ -2205,6 +2210,50 @@ mod tests {
let _ = std::fs::remove_dir_all(&tmp);
}
/// Regression test for issue #5819: `mkdir -p ~/proj/{a,b}` must create both
/// `a` and `b` under `$HOME/proj`. Brace expansion runs before tilde
/// expansion and previously left every element after the first with a
/// literal `~`, so `b` was created as `./~/proj/b` in the shell cwd instead.
#[tokio::test(flavor = "multi_thread")]
async fn uutils_mkdir_expands_tilde_for_every_brace_element() {
let base = std::env::temp_dir().join(format!("pi-mkdir-brace-{}", std::process::id()));
let home = base.join("home");
let cwd = base.join("cwd");
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(&home).expect("home dir");
std::fs::create_dir_all(&cwd).expect("cwd dir");
let cwd_str = cwd.to_str().expect("utf8 cwd path");
let mut env = HashMap::new();
env.insert("HOME".to_string(), home.to_string_lossy().to_string());
let config =
ShellConfig { session_env: Some(env), snapshot_path: None, minimizer: None };
let mut session = create_session(&config).await.expect("create_session");
session.shell.set_working_dir(cwd_str).expect("set 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 source_info = SourceInfo::from("pi-natives:test");
let exec = session
.shell
.run_string("mkdir -p ~/proj/{a,b}", &source_info, &params)
.await
.expect("run_string");
assert!(matches!(exec.exit_code, ExecutionExitCode::Success), "exit {}", exit_code(&exec));
// Both elements' tildes expanded: dirs land under $HOME/proj.
assert!(home.join("proj/a").is_dir(), "~/proj/a not created under HOME");
assert!(home.join("proj/b").is_dir(), "~/proj/b not created under HOME");
// The buggy path created a literal `~` tree in the shell cwd.
assert!(!cwd.join("~").exists(), "literal ~ tree leaked into cwd");
assert!(!cwd.join("a").exists(), "unexpanded element leaked into cwd");
let _ = std::fs::remove_dir_all(&base);
}
/// `mkdir --help` and an invalid flag must be handled in-process: rendered
/// to the command streams and returned as an exit code. The upstream
/// `uumain` parser calls `std::process::exit`, which would terminate the
+85 -27
View File
@@ -626,30 +626,44 @@ impl<'a, SE: extensions::ShellExtensions> WordExpander<'a, SE> {
return Ok(Expansion::from(ExpansionPiece::Splittable(word.to_owned())));
}
// Apply brace expansion first, before anything else (not applicable to heredoc
// bodies).
let brace_expanded = self.brace_expand_if_needed(word)?;
// Apply brace expansion first, before anything else (not applicable to
// heredoc bodies). Each resulting element is an independent word: bash
// runs tilde/parameter/command/arithmetic expansion on EVERY element, so
// a tilde that begins any element (e.g. `~/{a,b}` -> `~/a` and `~/b`)
// must expand — not only the first. Parsing the space-joined result as a
// single word left every element after the first with a literal leading
// `~` (issue #5819).
let brace_expanded_words = self.brace_expand_words(word)?;
if tracing::enabled!(target: trace_categories::EXPANSION, tracing::Level::DEBUG)
&& brace_expanded != word
&& !(brace_expanded_words.len() == 1 && brace_expanded_words[0] == word)
{
tracing::debug!(target: trace_categories::EXPANSION, " => brace expanded to '{brace_expanded}'");
tracing::debug!(target: trace_categories::EXPANSION, " => brace expanded to {brace_expanded_words:?}");
}
// Expand: tildes, parameters, command substitutions, arithmetic.
let pieces = if self.heredoc_mode {
// Heredoc mode only affects top-level parsing (literal quotes); recursive
// expansion of parameter words (e.g., ${var:-"default"}) uses normal semantics.
self.heredoc_mode = false;
brush_parser::word::parse_heredoc(brace_expanded.as_ref(), &self.parser_options)?
} else {
brush_parser::word::parse(brace_expanded.as_ref(), &self.parser_options)?
};
// Expand each brace element separately (tildes, parameters, command
// substitutions, arithmetic), separating elements with a splittable
// space so downstream field splitting yields one field per element.
let mut expansions = vec![];
for piece in pieces {
let piece_expansion = self.expand_word_piece(piece.piece).await?;
expansions.push(piece_expansion);
for (index, element) in brace_expanded_words.iter().enumerate() {
if index > 0 {
expansions.push(Expansion::from(ExpansionPiece::Splittable(String::from(" "))));
}
let pieces = if self.heredoc_mode {
// Heredoc mode only affects top-level parsing (literal quotes);
// recursive expansion of parameter words (e.g., ${var:-"default"})
// uses normal semantics.
self.heredoc_mode = false;
brush_parser::word::parse_heredoc(element.as_ref(), &self.parser_options)?
} else {
brush_parser::word::parse(element.as_ref(), &self.parser_options)?
};
for piece in pieces {
let piece_expansion = self.expand_word_piece(piece.piece).await?;
expansions.push(piece_expansion);
}
}
let coalesced = coalesce_expansions(expansions);
@@ -695,7 +709,13 @@ impl<'a, SE: extensions::ShellExtensions> WordExpander<'a, SE> {
}
}
fn brace_expand_if_needed(&self, word: &'a str) -> Result<Cow<'a, str>, error::Error> {
/// Perform brace expansion on `word`, returning each expanded element as a
/// separate word. When brace expansion doesn't apply (disabled, no braces,
/// or a parse failure), the original word is returned as the sole element.
///
/// Empty brace elements (e.g. from `{,b}`) are returned as a quoted empty
/// string (`""`) so they survive as empty fields, matching bash.
fn brace_expand_words(&self, word: &'a str) -> Result<Vec<Cow<'a, str>>, error::Error> {
// We perform a non-authoritative check to see if the string *may* contain
// braces to expand. There may be false positives, but must be no false
// negatives.
@@ -703,28 +723,40 @@ impl<'a, SE: extensions::ShellExtensions> WordExpander<'a, SE> {
|| !self.shell.options().perform_brace_expansion
|| !may_contain_braces_to_expand(word)
{
return Ok(word.into());
return Ok(vec![word.into()]);
}
let parse_result = brush_parser::word::parse_brace_expansions(word, &self.parser_options);
if parse_result.is_err() {
tracing::error!("failed to parse for brace expansion: {parse_result:?}");
return Ok(word.into());
return Ok(vec![word.into()]);
}
let brace_expansion_pieces = parse_result?;
let Some(brace_expansion_pieces) = brace_expansion_pieces else {
return Ok(word.into());
return Ok(vec![word.into()]);
};
tracing::debug!(target: trace_categories::EXPANSION, "Brace expansion pieces: {brace_expansion_pieces:?}");
let result = braceexpansion::generate_and_combine_brace_expansions(brace_expansion_pieces)
let words = braceexpansion::generate_and_combine_brace_expansions(brace_expansion_pieces)
.into_iter()
.map(|s| if s.is_empty() { "\"\"".into() } else { s })
.join(" ");
.map(|s| if s.is_empty() { Cow::Borrowed("\"\"") } else { Cow::Owned(s) })
.collect();
Ok(result.into())
Ok(words)
}
/// Convenience wrapper over [`Self::brace_expand_words`] that joins the
/// expanded elements back into a single space-separated string.
#[cfg(test)]
fn brace_expand_if_needed(&self, word: &'a str) -> Result<Cow<'a, str>, error::Error> {
let mut words = self.brace_expand_words(word)?;
if words.len() == 1 {
Ok(words.pop().unwrap())
} else {
Ok(Cow::Owned(words.join(" ")))
}
}
/// Apply tilde-expansion, parameter expansion, command substitution, and
@@ -2082,6 +2114,32 @@ mod tests {
Ok(())
}
/// Regression test for issue #5819: a tilde that begins each element of a
/// brace expansion must expand independently. Brace expansion joins its
/// elements before the tilde/parameter/... pass, so `~/{a,b}` must yield
/// `<HOME>/a` and `<HOME>/b` — not `<HOME>/a` followed by a literal `~/b`.
#[tokio::test]
async fn test_tilde_expands_for_every_brace_element() -> Result<()> {
let mut shell = crate::shell::Shell::builder().build().await?;
shell
.env_mut()
.set_global("HOME", ShellVariable::new(ShellValue::String("/home/user".to_string())))?;
let params = shell.default_exec_params();
assert_eq!(
full_expand_and_split_word(&mut shell, &params, "~/project/{a,b}").await?,
vec!["/home/user/project/a", "/home/user/project/b"],
);
// A bare `~/{a,b}` (tilde immediately followed by the brace) must also
// expand on both elements.
assert_eq!(
full_expand_and_split_word(&mut shell, &params, "~/{a,b}").await?,
vec!["/home/user/a", "/home/user/b"],
);
Ok(())
}
#[tokio::test]
async fn test_field_splitting() -> Result<()> {
let mut shell = crate::shell::Shell::builder().build().await?;
+78
View File
@@ -545,6 +545,12 @@ fn create_progress_bar(files: &[&OsStr], recursive: bool) -> Option<ProgressBar>
fn count_files(paths: &[&OsStr], recursive: bool) -> u64 {
let mut total = 0;
for p in paths {
// Empty operands are rejected by `remove()` before deletion; skip them
// here too so the progress pre-count doesn't resolve "" to the cwd and
// walk the entire working directory into the total.
if p.is_empty() {
continue;
}
let path = Path::new(p);
if let Ok(md) = fs::symlink_metadata(pi_uutils_ctx::resolve(path)) {
if md.is_dir() && !is_symlink_dir(&md) {
@@ -595,6 +601,19 @@ pub fn remove(files: &[&OsStr], options: &Options) -> bool {
for filename in files {
let file = Path::new(filename);
// An empty operand can never name a real file. Guard it before
// `pi_uutils_ctx::resolve`, which joins "" onto the shell's working
// directory — without this, `rm -rf ""` resolves to the cwd and
// recursively deletes it. GNU rm reports ENOENT for an empty operand
// (and `rm -f` stays silent), so mirror that here.
if filename.is_empty() {
if !options.force {
show_error!("{}", RmError::CannotRemoveNoSuchFile(filename.to_os_string()));
had_err = true;
}
continue;
}
// Check if the path (potentially with trailing slash) resolves to root
// This needs to happen before symlink_metadata to catch cases like "rootlink/"
// where rootlink is a symlink to root.
@@ -1106,4 +1125,63 @@ mod tests {
assert_eq!(Path::new("/"), clean_trailing_slashes(path));
}
/// Regression: `rm -rf ""` must never delete the shell working directory.
///
/// An empty operand used to reach `pi_uutils_ctx::resolve`, which joins ""
/// onto the cwd and yields the cwd itself, so `-rf` recursively removed it
/// (issue #6287). The builtin now rejects the empty operand before
/// resolution, leaving the cwd and its contents intact.
#[test]
fn empty_operand_does_not_delete_cwd() {
use std::{
collections::HashMap,
ffi::OsString,
sync::{
Arc,
atomic::{AtomicBool, AtomicU32, Ordering},
},
time::{SystemTime, UNIX_EPOCH},
};
use pi_uutils_ctx::{ScopeIo, scope};
// Unique disposable working directory with a sentinel file inside it.
static COUNTER: AtomicU32 = AtomicU32::new(0);
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
let cwd = std::env::temp_dir().join(format!(
"omp-rm-empty-{}-{}-{}",
std::process::id(),
nanos,
seq
));
std::fs::create_dir_all(&cwd).unwrap();
let sentinel = cwd.join("sentinel");
std::fs::write(&sentinel, b"keep me").unwrap();
let io = ScopeIo {
stdin: Box::new(std::io::empty()),
stdin_fd: None,
stdin_is_search_input: false,
stdout: Box::new(std::io::sink()),
stderr: Box::new(std::io::sink()),
cwd: cwd.clone(),
env: HashMap::new(),
cancel: Arc::new(AtomicBool::new(false)),
};
let args = vec![OsString::from("rm"), OsString::from("-rf"), OsString::new()];
let code = scope(io, || crate::run(args));
// `rm -f` swallows the empty-operand error, matching GNU rm's exit 0.
assert_eq!(code, 0, "rm -rf \"\" should exit 0 under --force");
assert!(cwd.is_dir(), "working directory must survive rm -rf \"\"");
assert!(sentinel.is_file(), "sentinel must survive rm -rf \"\"");
std::fs::remove_dir_all(&cwd).ok();
}
}
+48 -9
View File
@@ -633,16 +633,12 @@ fn unbounded_tail<T: Read>(reader: &mut BufReader<T>, settings: &Settings) -> UR
},
_ => {},
}
#[cfg(not(target_os = "windows"))]
// pi-uutils: upstream emulates Unix SIGPIPE on Windows by calling
// `std::process::exit(13)` on a broken-pipe flush. That would kill the
// long-lived host shell process. An in-process builtin must never
// `process::exit`; let the broken pipe surface as a normal `io::Error` and
// propagate to the caller, matching every other pi-uutils builtin.
writer.flush()?;
// SIGPIPE is not available on Windows.
#[cfg(target_os = "windows")]
writer.flush().inspect_err(|err| {
if err.kind() == ErrorKind::BrokenPipe {
std::process::exit(13);
}
})?;
Ok(())
}
@@ -855,4 +851,47 @@ mod tests {
assert_ne!(code, 0, "broken pipe must surface as a non-zero exit, not a panic");
}
#[test]
fn unbounded_tail_broken_pipe_does_not_abort() {
use std::{
collections::HashMap,
ffi::OsString,
io::{self, Cursor, ErrorKind, Write},
sync::{Arc, atomic::AtomicBool},
};
// Same broken-pipe consumer as above, but here stdin is a plain reader
// so `tail_stdin` always takes the streaming `unbounded_tail` path — the
// one the reported repro (`seq ... | tail -n 3 | head -n 0`) exercises,
// and where the Windows SIGPIPE emulation used to `std::process::exit`.
struct BrokenPipeWriter;
impl Write for BrokenPipeWriter {
fn write(&mut self, _buf: &[u8]) -> io::Result<usize> {
Err(io::Error::new(ErrorKind::BrokenPipe, "Broken pipe"))
}
fn flush(&mut self) -> io::Result<()> {
Err(io::Error::new(ErrorKind::BrokenPipe, "Broken pipe"))
}
}
let input = b"1\n2\n3\n4\n5\n".to_vec();
let io = pi_uutils_ctx::ScopeIo {
stdin: Box::new(Cursor::new(input)),
stdin_fd: None,
stdin_is_search_input: false,
stdout: Box::new(BrokenPipeWriter),
stderr: Box::new(io::sink()),
cwd: std::env::temp_dir(),
env: HashMap::new(),
cancel: Arc::new(AtomicBool::new(false)),
};
let code = pi_uutils_ctx::scope(io, || {
crate::run(vec![OsString::from("tail"), OsString::from("-n"), OsString::from("3")])
});
assert_ne!(code, 0, "broken pipe must surface as a non-zero exit, not process::exit");
}
}
+31 -6
View File
@@ -38,13 +38,24 @@ advisor:
The advisor role uses normal model-role resolution, including provider-prefixed ids, canonical ids, and optional thinking suffixes.
### Headless runs
Use `--advisor` to enable the advisor for one print-mode process without
persisting `advisor.enabled`:
```sh
omp -p --advisor "Review this task."
```
While a primary prompt is running, advisor concerns and blockers continue to steer that live turn. After the final prompt settles, print mode preserves late advisor notes without starting hidden primary turns, then waits up to ten minutes for final reviews before disposing the session. Error exits use a 30-second drain budget so failed automation can terminate. If either deadline expires, OMP logs the reviews that disposal will abandon; completed reviews retain their transcript and token/cost usage.
Slash commands:
| Command | Effect |
|---|---|
| `/advisor` | Toggle the persisted `advisor.enabled` setting. |
| `/advisor on` | Enable the setting and start the runtime when an advisor model is assigned. |
| `/advisor off` | Disable the setting and stop the runtime. |
| `/advisor` | Toggle the advisor for this session (session-scoped override; does not change the persisted `advisor.enabled` setting). |
| `/advisor on` | Enable the advisor for this session and start the runtime when an advisor model is assigned. Session-scoped; not persisted to config. |
| `/advisor off` | Disable the advisor for this session and stop the runtime. Session-scoped; not persisted to config. |
| `/advisor status` | Show active model, context usage, token usage, and cost. |
| `/advisor dump` | Copy the advisor's compact transcript to the clipboard. |
| `/advisor dump raw` | Copy the advisor's full dump (system prompt, tools, thinking, and calls) to the clipboard. |
@@ -89,8 +100,8 @@ The `advise` tool accepts one note and an optional severity:
| Severity | Delivery | Intended use |
|---|---|---|
| omitted / `nit` | Non-interrupting aside, batched into the primary transcript at the next step boundary. | Cleanup, simplification, low-risk edge cases. |
| `concern` | Interrupting steering message. | Material risk, likely wrong direction, missing constraint, hallucinated API. |
| `blocker` | Interrupting steering message. | Continuing would clearly waste work or produce broken output. |
| `concern` | Interrupting steering message when the delivery constraints below permit it. A late terminal-answer `concern` is preserved as a visible card instead. | Material risk, likely wrong direction, missing constraint, hallucinated API. |
| `blocker` | Interrupting steering message when the delivery constraints below permit it. Unlike a `concern`, a terminal answer alone does not prevent it from triggering a turn. | Continuing would clearly waste work or produce broken output. |
Interrupting advice is sent through the steering channel and can abort in-flight tools at the next steering boundary. Each note (interrupting or batched) is rendered into the primary transcript as an `<advisory>` element — severity rides a `severity` attribute, and a `guidance` attribute carries the "weigh, don't blindly obey" framing (the primary agent's system prompt never mentions advisories, so the tag is its only cue). Note bodies are XML-escaped so advice containing `<`, `>`, or `&` can't break the wrapper:
@@ -100,7 +111,21 @@ note text
</advisory>
```
When you deliberately interrupt the agent (Esc, or a cancel from collab, ACP, RPC, the SDK, or an extension), the advisor stops auto-resuming it. An interrupting `concern`/`blocker` raised while the run is stopped is recorded as a visible advisor card instead of restarting the turn, and a concern already in flight when you interrupt is preserved the same way rather than driving a surprise resume. The advice re-enters context the next time you resume — a new message, the `.`/`c` continue shortcut, or a steer/follow-up. A normal yield is unaffected: the advisor can still steer and resume a run the agent ended on its own.
When you deliberately interrupt the agent (Esc, or a cancel from collab, ACP, RPC, the SDK, or an extension), the advisor stops auto-resuming it. An interrupting `concern`/`blocker` raised while the run is stopped is recorded as a visible advisor card instead of restarting the turn, and a concern already in flight when you interrupt is preserved the same way rather than driving a surprise resume. The advice re-enters context the next time you resume — a new message, the `.`/`c` continue shortcut, or a steer/follow-up.
A normal yield the agent drove itself is treated differently from a deliberate interrupt, but it is not a blanket "always steers and resumes". The loop state and completed turn first determine the normal delivery path:
- **While the loop is still streaming** (the raise arrived before the yield, or during a resume you already drove), the note normally steers into the live turn.
- **Once the loop has yielded and gone idle**, delivery keys on how the turn ended:
- If the primary's tail is a **terminal text answer with no queued work**, a late `concern` is preserved as a visible card rather than waking the agent to restate a completed turn (#4840) — it re-enters context on the next resume (a new message, `.`/`c`, or a steer/follow-up), exactly like the interrupt case. A `blocker` is the exception: it normally steers a triggered turn, because it means the agent handed off broken or unexercised work that must be acknowledged before the turn is considered done (#5628).
- Otherwise (the agent yielded mid-work, no terminal answer), an idle `concern`/`blocker` normally triggers a fresh turn so the advice is acted on immediately.
Two session/client constraints can still preserve a note whose normal delivery path is steering:
- **Plan mode:** every would-be advisor steer is preserved as a visible card, even while the primary loop is streaming, because only user-driven turns converge on ask/resolve.
- **ACP with deferred agent-initiated turns:** when `deferAgentInitiatedTurns` is enabled and the bridge has not allowed agent-initiated turns, an idle would-be steer is preserved because the client cannot represent the triggered turn as busy. Advice raised while the primary loop is already streaming can still steer into that live turn.
So the advisor can steer and resume a run the agent ended on its own **while it is running or yielded mid-work and the current mode/client permits steering**. When steering is blocked instead, the note is either preserved as a card (the terminal-answer, plan-mode, and deferred-ACP cases above) or downgraded to a non-interrupting aside (the `advisor.immuneTurns` cooldown below); either way it waits for the next step boundary or resume rather than waking the agent.
`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.
+2 -1
View File
@@ -351,12 +351,13 @@ Extra conditional behavior:
## 6) Storage and config root paths
These are consumed via `@oh-my-pi/pi-utils/dirs` and affect where coding-agent stores data.
These affect where coding-agent stores data and which process-local settings overlays it loads.
| Variable | Default / behavior |
| --------------------- | ----------------------------------------------------------------------------- |
| `PI_CONFIG_DIR` | Config root dirname under home (default `.omp`) |
| `PI_CODING_AGENT_DIR` | Full override for agent directory (default `~/<PI_CONFIG_DIR or .omp>/agent`) |
| `PI_CONFIG_FILES` | Platform path-list of settings overlays (`:` on Unix, `;` on Windows); loaded in order before explicit `--config` overlays |
| `PWD` | Used when matching canonical current working directory in path helpers |
---
+24
View File
@@ -160,6 +160,30 @@ Handlers and tool `execute` receive `ctx` with:
- `shutdown()`
- `getSystemPrompt()`
- `memory` (optional structured memory runtime — status/search/save across the configured backend)
- `setInterval(fn, ms, ...args)` / `setTimeout(fn, ms, ...args)` / `clearTimer(timer)` — managed timers (see below)
### Background work (`ctx.setInterval` / `ctx.setTimeout`)
Extensions run **in-process with no isolation**. A raw `setInterval`/`setTimeout`/detached-promise callback that throws runs outside the handler-dispatch try/catch, surfaces as a process-level `uncaughtException`, and the global postmortem handler treats it as fatal — **the whole session is torn down**, not just the offending extension.
Use `ctx.setInterval` / `ctx.setTimeout` for any periodic or deferred background work. They mirror the platform signatures but:
- run the callback with the same isolation as handler dispatch — a synchronous throw or a rejected promise is logged and reported through the extension error channel, and the session keeps running;
- return a handle you can pass to `ctx.clearTimer(handle)`;
- are `unref`'d (never keep the process alive on their own) and are cleared automatically on `session_shutdown`.
```ts
pi.on("session_start", async (_event, ctx) => {
const timer = ctx.setInterval(() => {
// A throw here is contained — it will not crash the session.
ctx.ui.notify("tick", "info");
}, 60_000);
// Optional: clear it yourself; otherwise it is cleared on shutdown.
pi.on("session_shutdown", () => ctx.clearTimer(timer));
});
```
If you use raw `setInterval`/`setTimeout` or detached promises instead, you own the isolation: wrap the callback body in your own `try/catch` (an unhandled throw will take down the session) and clear the timer on `session_shutdown`.
### Model selection (`ctx.models`)
+46
View File
@@ -0,0 +1,46 @@
# Magic keywords
Magic keywords are standalone words in a user prompt that add a hidden instruction for that turn. They are enabled by default and glow in the editor when `omp` recognizes them.
## Keywords
| Keyword | Effect |
|---|---|
| `ultrathink` | Asks the agent to reason carefully through a multi-step task. When automatic thinking is active, it also selects the highest reasoning effort supported by the current model for that turn. |
| `orchestrate` | Switches the agent to the multi-agent orchestration contract: scope the full task, delegate substantial independent work in parallel, verify each phase, and continue until the request is complete. |
| `workflowz` | Asks the agent to build and run a deterministic multi-subagent workflow with the `task` tool. It is intended for broad research, reviews, migrations, or other work that benefits from parallel coverage. The keyword only adds its instruction when `task` is available in the active tool set. |
Use the keyword anywhere in the prose of the prompt:
```text
ultrathink about the failure modes before changing this API
orchestrate the migration described in docs/plan.md
workflowz an adversarial review of the authentication changes
```
## Matching rules
Matching is deliberate so source code and paths do not accidentally change agent behavior:
- Use the exact lowercase spelling. `Ultrathink`, `Orchestrate`, and `Workflowz` do not trigger.
- The keyword must be standalone. Sentence punctuation may touch it, but identifiers, inflections, paths, and file extensions do not match. For example, `orchestrate,` matches; `orchestrated` and `orchestrate.ts` do not.
- Fenced code blocks, inline code spans, and XML/HTML sections are ignored.
- The instruction applies to the user turn containing the keyword. The highlighted word remains part of the visible prompt; the added instruction is hidden.
## Configuration
Open `/settings` and use **Interaction → Magic Keywords**, or change the settings from a shell:
```bash
# Disable every magic keyword
omp config set magicKeywords.enabled false
# Disable one keyword while leaving the others enabled
omp config set magicKeywords.ultrathink false
omp config set magicKeywords.orchestrate false
omp config set magicKeywords.workflow false
```
All four settings default to `true`. Run `omp config list` to inspect every available setting and its current value. See [Settings](./settings.md) for configuration scopes, precedence, and project-local overrides.
+3 -1
View File
@@ -300,7 +300,9 @@ 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 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.
Runtime discovery probes LiteLLM management metadata in order: `GET /model_group/info`, `GET /v2/model/info`, `GET /model/info`, and `GET /v1/model/info`. The configured key must be authorized to read at least one of these routes; on deployments that restrict management endpoints, grant the route through LiteLLM's `allowed_routes` access controls or use a master/admin key for discovery.
If every metadata route is unavailable, discovery falls back to the OpenAI-compatible `GET /models` list. A forbidden or failed metadata request is logged once with its endpoint and status; `404` is treated as an absent route. Rich metadata maps per-model context and capability fields, while bare fallback ids are enriched against bundled reference metadata when available. Models absent from the bundled catalog can therefore have unknown context and pricing after fallback.
### Explicit provider discovery
+1 -1
View File
@@ -31,7 +31,7 @@ When a provider needs an API key, `omp` resolves it in this order (first match w
1. **Runtime override** — a key supplied for the current process, e.g. CLI `--api-key`. Never persisted.
2. **`models.yml` config key** — an `apiKey` pinned on a custom provider, registered as a config-sourced bearer. This deliberately beats stored OAuth, so a key supplied for a custom `baseUrl`/gateway is honored instead of forwarding an upstream OAuth token the proxy would reject.
3. **Stored API key** — an API-key credential saved in the auth store.
4. **Stored OAuth credential** — refreshed when needed; multiple accounts are ranked/rotated automatically. For Anthropic, each organization counts as its own account: one email holding both a Team seat and a personal plan can log in once per subscription (pick the workspace on the browser consent page) and rotation treats them as two accounts.
4. **Stored OAuth credential** — refreshed when needed; multiple accounts are ranked/rotated automatically. For Anthropic and ChatGPT (Codex), each organization/workspace counts as its own account: one email holding both a Team/Enterprise seat and a personal plan can log in once per subscription (pick the workspace on the browser consent page) and rotation treats them as two accounts.
5. **Provider environment variable** — including values loaded from `.env` files (see [the env-var table](#environment-variables-and-env-files)).
6. **`models.yml` fallback resolver** — keys for custom providers not otherwise registered.
+45 -3
View File
@@ -25,15 +25,48 @@ Behavior notes:
- RPC mode disables automatic session title generation by default to avoid an extra model call.
- RPC mode resets workflow-altering `todo.*`, `task.*`, `memory.backend`/`memories.enabled`, `advisor.*`, `async.*`, and `bash.autoBackground.*` settings to their built-in defaults instead of inheriting user overrides.
- The process reads stdin as JSONL (`readJsonl(Bun.stdin.stream())`).
- At startup it writes `{ "type": "ready" }` before processing commands.
- At startup it writes a `ready` frame before processing commands. The frame advertises supported protocol versions and transport limits.
- When stdin closes, pending host-tool calls and host-URI requests are rejected and the process exits with code `0`.
- Responses/events are written as one JSON object per line.
## Transport and Framing
Each frame is a single JSON object followed by `\n`.
Protocol v1 frames are a single JSON object followed by `\n`. Every physical JSONL frame is limited to 1 MiB.
There is no envelope beyond the object shape itself.
The initial ready frame uses protocol v1 and advertises the opt-in lossless transport:
```json
{
"type": "ready",
"protocolVersion": 1,
"supportedProtocolVersions": [1, 2],
"maxFrameBytes": 1048576,
"maxReassembledFrameBytes": 67108864
}
```
Clients that support protocol v2 SHOULD immediately send:
```json
{ "id": "protocol-1", "type": "negotiate_protocol", "protocolVersion": 2 }
```
After the success response, oversized stdout objects are emitted losslessly as an uninterrupted sequence of `rpc_chunk` frames. Each chunk carries a base64 segment of the original UTF-8 JSON object:
```json
{
"type": "rpc_chunk",
"chunkId": "rpc-1",
"index": 0,
"count": 7,
"byteLength": 1600042,
"data": "eyJ0eXBlIjoicmVzcG9uc2UiLC4uLn0="
}
```
Clients MUST validate `chunkId`, `index`, `count`, and `byteLength`, reject interleaved or interrupted sequences, enforce the advertised reassembly limit, concatenate decoded bytes in index order, decode them as strict UTF-8, and parse the result as one JSON object. The exported TypeScript `RpcFrameDecoder` implements this validation. The bundled TypeScript and Python `RpcClient` implementations negotiate v2 automatically when the ready frame advertises it.
Legacy clients may ignore the added ready fields and remain on v1. V1 retains its bounded fallback behavior for oversized output. Frames above the v2 reassembly ceiling still fail explicitly; large history APIs should use pagination rather than depending on arbitrarily large logical frames.
### Outbound frame categories (stdout)
@@ -84,6 +117,10 @@ Important edge behavior from runtime:
- `{ id?, type: "abort_and_prompt", message: string, images?: ImageContent[] }`
- `{ id?, type: "new_session", parentSession?: string }`
### Protocol
- `{ id?, type: "negotiate_protocol", protocolVersion: 2 }`
### State
- `{ id?, type: "get_state" }`
@@ -148,6 +185,11 @@ correlate it via `id`. Ordering across concurrent commands is not guaranteed
### Messages
- `{ id?, type: "get_messages" }`
- `{ id?, type: "get_messages_page", cursor?: string, limit?: number }`
`get_messages_page` returns a stable chronological page with `messages`, `totalMessages`, and an opaque `nextCursor` when more messages remain. Cursors are bound to the session ID, durable leaf, and message count. The server rejects stale cursors if the session changes between requests, and refuses to start a paging walk while the session is streaming or compacting. Failed page requests carry a machine-readable `code` on the error response — `session_busy` (session is streaming or compacting) or `stale_cursor` (the snapshot behind the cursor changed, e.g. a background bash appended a message between pages) — so clients can react without matching error-message text. Pages contain at most 256 messages and normally stay below the v1 physical-frame ceiling. A v1 caller can page ordinary histories, but an individual message whose response exceeds that ceiling produces an overflow error; retrieving it losslessly requires negotiated v2 framing.
The bundled TypeScript `RpcClient.getMessages()` and Python `RpcClient.get_messages()` drain this paged endpoint automatically after negotiating v2. They retain the legacy monolithic command when connected to a v1 server, and on either `session_busy` or `stale_cursor` they discard partial pages and fall back to the legacy best-effort snapshot. Direct `getMessagesPage()` and `get_messages_page()` calls remain strict so incremental hosts never mix snapshots silently.
### Login
+6
View File
@@ -8,6 +8,7 @@ Settings are stored as plain YAML mappings. Every key, its type, default, and en
- For custom model definitions in `models.yml`, see [Models](./models.md).
- For instruction files discovered into the agent context (`AGENTS.md`, `.omp/`, etc.), see [Context files](./context-files.md).
- For the full catalog of environment variables, see [Environment variables](./environment-variables.md).
- For prompt words that activate specialized per-turn behavior, see [Magic keywords](./magic-keywords.md).
## Where settings live
@@ -121,6 +122,7 @@ Environment variables are **not** a single settings layer. Each is read by the f
| `OMP_AUTH_BROKER_URL` | `auth.broker.url` | Env value takes precedence over config. |
| `OMP_AUTH_BROKER_TOKEN` | `auth.broker.token` | Env value takes precedence over config. |
| `PI_CODING_AGENT_DIR` | (relocates agent dir) | Moves `config.yml`, `agent.db`, and the whole agent base. |
| `PI_CONFIG_FILES` | CLI config overlays | Platform path-list (`:` on Unix, `;` on Windows); files load in order before `--config` overlays. |
Provider API keys are resolved separately (stored auth, OAuth, `models.yml`, environment, and `.env` files); see [Providers](./providers.md) and the full [Environment variables](./environment-variables.md) reference.
@@ -216,6 +218,10 @@ omp --config ./local/ci-settings.yml "check this failure"
omp --config ./base.yml --config ./experiment.yml "try this model"
```
`--config` is accepted by the default launch command, `acp`, and `models`.
Wrappers may instead set `PI_CONFIG_FILES` to a platform-delimited path list (`:` on Unix, `;` on Windows). Environment overlays load in listed order before explicit `--config` overlays.
Overlay paths are resolved relative to the process working directory (and `~` is expanded). Each overlay must parse as a YAML mapping; a missing file, invalid YAML, or a top-level array/scalar is a hard error — it does **not** silently fall back to lower-precedence settings.
## Path-scoped arrays
+1
View File
@@ -246,6 +246,7 @@ The derived name is the filename stem (or directory name for `index.ts`-style en
- **Do not call runtime actions during load.** Methods like `pi.sendMessage()` throw `ExtensionRuntimeNotInitializedError` if called synchronously during module evaluation (before a session is active). Register handlers/tools/commands during load; perform runtime actions only from event handlers, tools, or commands.
- **`tool_call` errors are fail-closed.** If a `tool_call` handler throws, the tool is blocked.
- **Self-scheduled callbacks run in-process with no isolation.** A raw `setInterval`/`setTimeout`/detached-promise callback that throws escapes the handler-dispatch try/catch and crashes the whole session (`uncaughtException`). Use `ctx.setInterval` / `ctx.setTimeout` for background work — they contain callback throws and auto-clear on `session_shutdown`. With raw timers you must add your own `try/catch` and cleanup.
- **Command names must not clash with built-ins.** Conflicts are skipped with a diagnostic log.
- **Reserved shortcuts are ignored** (`ctrl+c`, `ctrl+d`, `ctrl+z`, `ctrl+k`, `ctrl+p`, `ctrl+l`, `ctrl+o`, `ctrl+t`, `ctrl+g`, `ctrl+q`, `alt+m`, `shift+tab`, `shift+ctrl+p`, `alt+enter`, `escape`, `enter`).
+16 -6
View File
@@ -122,16 +122,26 @@ In spawn execution (`TaskTool.#executeSync` → `#runSpawn`):
`TaskTool.create()` builds the tool description from discovery results at initialization time. `#executeSync` rediscovers agents, so the runtime set can differ from what was listed in the earlier tool description if agent files changed mid-session. The async entry path still uses the initialization-time list to decide whether an agent is marked `blocking` before scheduling.
## Structured-output guardrails and schema precedence
## Model and structured-output precedence
Runtime output schema precedence in `TaskTool.#runSpawn`:
Runtime model precedence is resolved by `resolveEffectiveSubagentPolicy()`:
1. agent frontmatter `output`
2. parent session `outputSchema`
1. the task item's explicit `model` selector or fallback chain
2. `task.agentModelOverrides[agentName]`
3. agent frontmatter `model`
4. the parent session model fallback
(`effectiveOutputSchema = effectiveAgent.output ?? this.session.outputSchema` — the task call itself never carries a schema; ad-hoc structured workflows go through the eval bridge's `agent(prompt, schema)`.)
Reasoning suffixes such as `:high` are preserved. The task tool rejects blank, empty-array, and comma-only per-call selectors before policy resolution so they cannot bypass a configured agent override.
The model-facing prompt (`src/prompts/tools/task.md`) no longer carries the old structured-output mismatch warning; it tags read-only agents and warns against offloading reasoning to `explore`/`sonic` instead.
Runtime output schema precedence is:
1. the task item's explicit `outputSchema`
2. agent frontmatter `output`
3. parent session `outputSchema`
The task item's optional `schemaMode` overrides the parent session mode; the default is `permissive`.
The model-facing prompt (`src/prompts/tools/task.md`) no longer carries the old structured-output mismatch warning; it tags read-only agents and warns against offloading reasoning to `scout`/`sonic` instead.
## Command discovery interaction
+17 -16
View File
@@ -122,19 +122,19 @@ Side-channel artifacts outside the model tool result:
1. Tool registration is conditional: `DebugTool.createIf()` in `packages/coding-agent/src/tools/debug.ts` returns `null` unless `session.settings.get("debug.enabled")` is true. `packages/coding-agent/src/tools/index.ts` wires the factory and rechecks the same setting in tool filtering.
2. `DebugTool.execute()` clamps `params.timeout` through `clampTimeout("debug", params.timeout)` and composes the caller `AbortSignal` with `AbortSignal.timeout(...)`.
3. `launch` and `attach` resolve cwd/program paths, select an adapter in `packages/coding-agent/src/dap/config.ts`, then delegate to `dapSessionManager.launch()` / `.attach()`.
4. `DapSessionManager.launch()` / `.attach()` enforce the single-session rule with `#ensureLaunchSlot()`, spawn the adapter through `DapClient.spawn()`, register listeners, send `initialize`, cache capabilities, start listening for an initial stop event before sending `launch`/`attach`, then complete the `initialized` → `configurationDone` handshake in `#completeConfigurationHandshake()`.
5. `DapClient.spawn()` starts the adapter detached with `NON_INTERACTIVE_ENV`. Most adapters use stdio; socket-mode adapters (`dlv`) use `#spawnSocketUnix()` on Linux or `#spawnSocketClientAddr()` on macOS/other.
4. `DapSessionManager.launch()` / `.attach()` enforce one root session, spawn the adapter through `DapClient.spawn()`, register listeners, send `initialize`, cache capabilities, subscribe for tree-wide stop events, send `launch`/`attach`, then complete the `initialized` → `configurationDone` handshake.
5. `DapClient.spawn()` starts adapters detached with `NON_INTERACTIVE_ENV`. Most adapters use stdio; socket-mode adapters (`dlv`) use an adapter-specific Unix/TCP transport, while TCP server adapters start with `${port}` substituted in their args. Child sessions reuse the root TCP server through `DapClient.connect()`.
6. `#registerSession()` in `packages/coding-agent/src/dap/session.ts` installs reverse-request handlers:
- `runInTerminal`: spawns the requested debuggee command detached via `ptree.spawn()` and returns `{ processId }`
- `startDebugging`: logs the child-session request and returns `{}`; it does not create nested sessions
- events: `output`, `initialized`, `stopped`, `continued`, `exited`, `terminated` update cached session state
7. Operational actions (`set_breakpoint`, `evaluate`, `threads`, `read_memory`, `custom_request`, and similar) call `dapSessionManager` methods. Most flow through `#sendRequestWithConfig()`, which first sends `configurationDone` when required, then sends the DAP request, then updates `lastUsedAt`.
8. Breakpoint actions maintain local cached breakpoint sets in `DapSessionManager` and remap adapter responses back onto those cached records.
9. `continue` and the three step actions clear cached stop state, subscribe for `stopped`/`terminated`/`exited` before sending the DAP request, then `#awaitStopOutcome()` either returns the new stopped location or reports that the program is still running after timeout.
- `startDebugging`: connects a child DAP client to the root TCP server, forwards the requested `launch`/`attach` configuration, binds root breakpoints before `configurationDone`, and recursively installs the same handlers
- events: `output`, `initialized`, `stopped`, `continued`, `exited`, and `terminated` update cached session state; stopped children become the active target
7. Operational actions (`set_breakpoint`, `evaluate`, `threads`, `read_memory`, `custom_request`, and similar) call `dapSessionManager` methods. Most flow through `#sendRequestWithConfig()`, which first sends `configurationDone` when required, then sends the DAP request and refreshes the active session plus its ancestors.
8. Breakpoint actions synchronize desired breakpoint sets across the live root/child tree. New children receive those sets before their `configurationDone` request.
9. `continue` and the three step actions clear cached stop state, subscribe for a stop/termination event anywhere in the session tree before sending the DAP request, then `#awaitStopOutcome()` returns the active child’s stopped location or reports that the target remains running after timeout.
10. `pause` sends DAP `pause`, waits for a stopped event if needed, and reuses cached stop state if the program was already stopped.
11. `stack_trace`, `scopes`, `variables`, and `evaluate` default to the current stopped thread/frame when the caller omits ids and cached state is available.
12. `output` reads the in-memory output ring from `DapSessionManager.getOutput()`. `terminate` sends `terminate` when supported, always attempts `disconnect`, marks the session terminated, and disposes the client.
13. `sessions` reads the manager’s current map and formats all summaries. Although the manager stores a map, only one active session can exist because new launch/attach calls are blocked until the active one is terminated or cleaned up.
11. `stack_trace`, `scopes`, `variables`, and `evaluate` default to the current stopped child/thread/frame when the caller omits ids and cached state is available.
12. `output` reads the in-memory output ring from the active `DapSession`. `terminate` walks from the root through every child, sends best-effort `terminate`/`disconnect`, and disposes the complete tree even when an adapter times out.
13. `sessions` reads the manager’s current map and formats root and child summaries. Only one root tree can exist; recursive adapter-requested children are tracked with `parentSessionId` / `childSessionIds`.
14. The interactive selector in `packages/coding-agent/src/debug/index.ts` builds a `SelectList` of fixed values and dispatches each to a handler:
- `performance`: `startCpuProfile()`, wait for Enter/Escape, stop profiling, read a 30-second work profile with `getWorkProfile(30)`, then bundle via `createReportBundle()`
- `work`: read `getWorkProfile(30)`, write a temp SVG, open it externally
@@ -320,12 +320,13 @@ Example `.omp/dap.json`:
- `collectSystemInfo()` is best-effort for CPU probing; failure there falls back to `Unknown CPU`.
## Notes
- `packages/coding-agent/src/prompts/tools/debug.md` tells the model only one active session is supported; that is not advisory, it is enforced in code.
- `configurationDone` is sent automatically both during launch/attach handshake and lazily before later requests if the adapter required it and the initial handshake did not complete.
- `startDebugging` reverse requests are acknowledged but not implemented; child debug sessions are not spawned.
- `output` exposes the merged `output` event stream only; the tool does not distinguish stdout, stderr, and console categories.
- Session summaries expose `needsConfigurationDone`; this is derived from adapter capabilities and whether `configurationDone` has been sent.
- Source breakpoint file paths are normalized with `path.resolve()` before caching and sending to the adapter.
- `packages/coding-agent/src/prompts/tools/debug.md` tells the model only one active root session is supported. Adapter-requested child sessions belong to that root tree.
- The default JavaScript/TypeScript adapter runs vscode-js-debug’s `dapDebugServer.js` over TCP. Install it with Mason or set `JS_DEBUG_DAP_SERVER` to a release-tarball server path.
- `configurationDone` is sent automatically during root and child launch/attach handshakes and lazily before later requests if the initial handshake did not complete.
- `startDebugging` reverse requests create recursive child sessions on the same TCP server; a stopped child becomes the target for thread-level actions.
- `output` exposes the active session’s merged `output` event stream only; the tool does not distinguish stdout, stderr, and console categories.
- Session summaries expose `needsConfigurationDone`, `parentSessionId`, and `childSessionIds`.
- Source breakpoint file paths are normalized with `path.resolve()` before caching and synchronizing across the tree.
- `evaluate` defaults to `repl`, so the tool can forward raw debugger commands when the adapter supports them.
- `disassemble` resolves its target from `memory_reference` first, then the current stopped session's `instructionPointerReference`; it throws if neither is present.
- `RawSseDebugBuffer.recordEvent()` increments `totalEvents` before bounded retention. A snapshot can therefore show fewer retained records than total observed events.
+2 -2
View File
@@ -31,7 +31,7 @@
| `query` | string | No | Workspace symbol query, code-action selector/filter, or LSP method name for `action=request`. |
| `new_name` | string | No | Required for `rename` and `rename_file`. |
| `apply` | boolean | No | For `rename`/`rename_file`, apply unless explicitly `false`. For `code_actions`, list unless explicitly `true`. |
| `timeout` | number | No | Seconds, clamped by `clampTimeout("lsp", ...)` to `5..60`, default `20`. |
| `timeout` | number | No | Seconds, clamped by `clampTimeout("lsp", ...)` to `5..300`, default `20`. |
| `payload` | string | No | JSON string for `action=request`; overrides auto-built params. |
## Outputs
@@ -268,7 +268,7 @@ Same as `definition`, but sends `textDocument/implementation` and reports `imple
- Background message readers persist for each live client until process exit/shutdown.
## Limits & Caps
- Tool timeout clamp: default `20`, min `5`, max `60` seconds — `TOOL_TIMEOUTS.lsp` in `packages/coding-agent/src/tools/tool-timeouts.ts`.
- Tool timeout clamp: default `20`, min `5`, max `300` seconds — `TOOL_TIMEOUTS.lsp` in `packages/coding-agent/src/tools/tool-timeouts.ts`.
- LSP request default timeout inside `sendRequest()`: `30_000ms` — `DEFAULT_REQUEST_TIMEOUT_MS` in `packages/coding-agent/src/lsp/client.ts`.
- Warmup initialize timeout default: `5_000ms` — `WARMUP_TIMEOUT_MS` in `packages/coding-agent/src/lsp/client.ts`.
- Project-load wait fallback: `15_000ms` — `PROJECT_LOAD_TIMEOUT_MS` in `packages/coding-agent/src/lsp/client.ts`.
+10 -7
View File
@@ -26,9 +26,9 @@
## Inputs
The wire schema is shape-swapped by `task.batch` (default on). One unit of work is the task item `{ name?, agent?, task, isolated? }` (`isolated` only when `task.isolation.mode` is not `none`):
The wire schema is shape-swapped by `task.batch` (default on). One unit of work is the task item `{ name?, agent?, task, model?, outputSchema?, schemaMode?, isolated? }` (`isolated` only when `task.isolation.mode` is not `none`):
- **Batch shape** (`task.batch` on): `{ context, tasks: item[] }` — one subagent per item, all run under the same fan-out rules; there is no top-level agent field. `context` is **required** shared background rendered into every spawned subagent's system prompt (`CONTEXT` section); `agent` and `isolated` are per item, so one call may mix agent types.
- **Batch shape** (`task.batch` on): `{ context, tasks: item[] }` — one subagent per item, all run under the same fan-out rules; there is no top-level agent field. `context` is **required** shared background rendered into every spawned subagent's system prompt (`CONTEXT` section); `agent`, `model`, `outputSchema`, `schemaMode`, and `isolated` are per item, so one call may mix agent types, models, and output contracts.
- **Flat shape** (`task.batch` off): `{ ...item }` — exactly one spawn per call. Shared background goes into a `local://` file (e.g. `local://ctx.md`) that each spawn's `task` references; subagents share the parent's `local://` root.
| Field | Type | Required | Description |
@@ -38,13 +38,16 @@ The wire schema is shape-swapped by `task.batch` (default on). One unit of work
| `name` | `string` | No | Stable agent name — becomes the registry/IRC id. Defaults to a generated AdjectiveNoun name. Uniquified per session by `AgentOutputManager`. Item field in batch shape, top-level in flat shape. |
| `agent` | `string` | No | Agent type to run this item (e.g. `scout`). Defaults to the spawn policy's default agent (usually `task`); items in one batch call may use different agent types. Item field in batch shape, top-level in flat shape. |
| `task` | `string` | Yes | The work — complete, self-contained instructions. Empty-after-trim is rejected. Item field in batch shape, top-level in flat shape. |
| `model` | `string \| string[]` | No | Explicit non-empty model selector or non-empty fallback chain for this spawn. Optional `:reasoning` suffixes are preserved. Takes precedence over `task.agentModelOverrides` and agent frontmatter. Item field in batch shape, top-level in flat shape. |
| `outputSchema` | JSON Schema object | No | Invocation-specific structured-output contract. Takes precedence over agent frontmatter `output` and the inherited parent session schema. Item field in batch shape, top-level in flat shape. |
| `schemaMode` | `"permissive" \| "strict"` | No | Validation mode for the effective output schema. Overrides the parent session mode; defaults to `permissive`. Item field in batch shape, top-level in flat shape. |
| `isolated` | `boolean` | No | Run in an isolated workspace and return patches. Exists only when `task.isolation.mode` is not `none`; per item in batch shape, top-level in flat shape. Isolated agents are torn down at completion — not revivable. |
There is no wire label field: the one-line UI label shown in the TUI/registry is generated automatically from the `task` text by the tiny/title model (fire-and-forget), so callers never provide it.
Runtime stays permissive: the flat form is accepted even while `task.batch` is on (internal callers such as the commit flow's `analyze_files`, and stale transcripts). The model only ever sees one shape.
There is no per-call `schema` parameter. Structured output comes from the agent definition's `output` frontmatter, the inherited parent session schema, or — for ad-hoc workflows — the eval bridge's `agent(prompt, schema)`.
There is no legacy per-call `schema` parameter. Use `outputSchema` and optional `schemaMode`; when absent, structured output falls back to the agent definition's `output` frontmatter and then the inherited parent session schema.
## Outputs
@@ -68,13 +71,13 @@ Settled response (`async.enabled=false`, no job manager, every item's agent `blo
Artifacts and side channels:
- Every subagent with an artifacts dir writes `<id>.md`; `agent://<id>` resolves to that file.
- If the output file is JSON, `agent://<id>/<path>` and `agent://<id>?q=<query>` perform JSON extraction.
- A subagent's own children are dot-qualified (`<id>.<child>`); `agent://<id>/<child>` reads that nested output. When the path names no nested output and the file is JSON, `agent://<id>/<path>` and `agent://<id>?q=<query>` perform JSON extraction.
- Each subagent gets `<id>.jsonl` session history when the parent persists artifacts; `history://<id>` renders it as a concise transcript (works for live and parked agents).
- Isolated patch mode writes `<id>.patch` before merge.
## Flow
1. `TaskTool.create(...)` discovers agents once per cwd through a process-level memo (`discoverAgentsForCreate`) to render the dynamic prompt description.
2. `execute(...)` repairs raw params (`repairTaskParams`), then validates: `schema` is always rejected; `tasks`/`context` are rejected unless `task.batch` is on; batch calls need a non-empty `tasks` (a `task` per item, unique provided names), a non-empty shared `context`, and no top-level `task` alongside `tasks`; flat calls need `task`. The call is then normalized into its spawn list (`resolveSpawnItems`).
2. `execute(...)` repairs raw params (`repairTaskParams`), then validates: `schema` is always rejected; model selectors that normalize to no patterns are rejected; `tasks`/`context` are rejected unless `task.batch` is on; batch calls need a non-empty `tasks` (a `task` per item, unique provided names), a non-empty shared `context`, and no top-level `task` alongside `tasks`; flat calls need `task`. The call is then normalized into its spawn list (`resolveSpawnItems`).
3. Per-item execution split: items whose agent type declares `blocking: true` run inline; the rest become background jobs. The whole call runs sync when `async.enabled=false`, the session has no `AsyncJobManager` (orphaned host), or every item is blocking; inline spawns run through `#executeSync(...)` under the session-scoped semaphore.
4. Background execution (any non-blocking item with `async.enabled=true` and an `AsyncJobManager`):
- agent ids are allocated up front via `AgentOutputManager.allocate(...)` — each item's `name`, or a generated AdjectiveNoun name — one per spawn;
@@ -84,7 +87,7 @@ Artifacts and side channels:
- a mixed call registers the async jobs first, then runs its blocking items inline and returns once they settle — the text combines the inline summaries with the spawned-job listing, and the block keeps rendering the still-running background rows beside the inline results.
5. `#executeSync(...)` runs the spawn path (`#runSpawn`), which rediscovers agents from disk, so runtime resolution can differ from the create-time description.
6. It resolves each spawn's requested `agent` type, rejects unknown or settings-disabled agents, and enforces parent spawn policy plus `PI_BLOCKED_AGENT` self-recursion prevention.
7. Output schema priority: agent frontmatter `output` → inherited parent session schema (the call itself never carries one).
7. Model priority: per-call `model` → `task.agentModelOverrides` → agent frontmatter → configured task role/session fallback. Output schema priority: per-call `outputSchema` → agent frontmatter `output` → inherited parent session schema.
8. Plan mode swaps in an `effectiveAgent` with a read-only tool subset and plan-mode prompt; `runSubprocess(...)` receives the effective agent.
9. If `isolated`, it requires a git repo (`getRepoRoot(...)` / `captureBaseline(...)`), maps `task.isolation.mode` to a backend-kind hint (`parseIsolationMode`), and materializes the workspace via the natives PAL (`ensureIsolation` → `isoResolve`/`isoStart`), walking the candidate list when a backend is unavailable.
10. Artifacts dir comes from the parent session file when available, otherwise a temp dir. When the session is executing an approved plan, the plan reference is handed to the subagent.
@@ -103,7 +106,7 @@ Artifacts and side channels:
- Background job — `async.enabled=true`; non-blocking spawns go through `AsyncJobManager`.
- Sync inline — `async.enabled=false`, no job manager, or the item's agent declares `blocking: true` (per item: a mixed call runs both modes).
- Batch mode (`task.batch`, default on)
- on — `{ context, tasks[] }`: one independent spawn per item, required `context` shared across the call's spawns, `agent`/`isolated` per item. Lifecycle, revival, and concurrency semantics match N parallel single calls.
- on — `{ context, tasks[] }`: one independent spawn per item, required `context` shared across the call's spawns, with `agent`, `model`, `outputSchema`, `schemaMode`, and `isolated` per item. Lifecycle, revival, and concurrency semantics match N parallel single calls.
- off — single spawn per call; `tasks`/`context` are rejected and removed from the schema.
- Isolation mode (`task.isolation.mode`): `none`, `auto`, `apfs`, `btrfs`, `zfs`, `reflink`, `overlayfs`, `projfs`, `block-clone`, `rcopy` (legacy `worktree`, `fuse-overlay`, `fuse-projfs` accepted for back-compat); the PAL resolves the actual backend with fallback.
- Isolation merge strategy: patch mode (capture/apply root patches) or branch mode (commit to `omp/task/<id>`, cherry-pick into parent).
+4
View File
@@ -135,6 +135,10 @@ of history:
- `getNativeScrollbackLiveRegionStart()` — first row that may still mutate
(everything below it, including root chrome rendered after it, stays in the
window).
- `isNativeScrollbackLiveRegionPinned()` — optional policy for replacing
dashboards: rows at/after the live boundary stay viewport-local instead of
entering history as frozen snapshots. When the boundary advances or
disappears, newly final rows commit in order.
- `getNativeScrollbackCommitSafeEnd()` — optional **byte-stable** deeper boundary
(B): the append-only prefix of the live region (a streaming assistant message's
settled rows), asserted never to re-layout, so it stays under the audit.
+22 -17
View File
@@ -26,24 +26,29 @@
"@huggingface/transformers": "^4.2.0",
"@mozilla/readability": "^0.6.0",
"@napi-rs/cli": "3.7.2",
"@oh-my-pi/hashline": "17.0.0",
"@oh-my-pi/omp-stats": "17.0.0",
"@oh-my-pi/pi-agent-core": "17.0.0",
"@oh-my-pi/pi-ai": "17.0.0",
"@oh-my-pi/pi-catalog": "17.0.0",
"@oh-my-pi/pi-coding-agent": "17.0.0",
"@oh-my-pi/pi-mnemopi": "17.0.0",
"@oh-my-pi/pi-natives": "17.0.0",
"@oh-my-pi/pi-tui": "17.0.0",
"@oh-my-pi/pi-utils": "17.0.0",
"@oh-my-pi/pi-wire": "17.0.0",
"@oh-my-pi/snapcompact": "17.0.0",
"@oh-my-pi/hashline": "17.0.8",
"@oh-my-pi/omp-stats": "17.0.8",
"@oh-my-pi/pi-agent-core": "17.0.8",
"@oh-my-pi/pi-ai": "17.0.8",
"@oh-my-pi/pi-catalog": "17.0.8",
"@oh-my-pi/pi-coding-agent": "17.0.8",
"@oh-my-pi/pi-mnemopi": "17.0.8",
"@oh-my-pi/pi-natives": "17.0.8",
"@oh-my-pi/pi-tui": "17.0.8",
"@oh-my-pi/pi-utils": "17.0.8",
"@oh-my-pi/pi-wire": "17.0.8",
"@oh-my-pi/snapcompact": "17.0.8",
"@opentelemetry/api": "^1.9.1",
"@opentelemetry/context-async-hooks": "^2.7.1",
"@opentelemetry/api-logs": "^0.220.0",
"@opentelemetry/context-async-hooks": "^2.9.0",
"@opentelemetry/exporter-logs-otlp-proto": "^0.220.0",
"@opentelemetry/exporter-metrics-otlp-proto": "^0.220.0",
"@opentelemetry/exporter-trace-otlp-proto": "^0.220.0",
"@opentelemetry/resources": "^2.7.1",
"@opentelemetry/sdk-trace-base": "^2.7.1",
"@opentelemetry/sdk-trace-node": "^2.7.1",
"@opentelemetry/resources": "^2.9.0",
"@opentelemetry/sdk-logs": "^0.220.0",
"@opentelemetry/sdk-metrics": "^2.9.0",
"@opentelemetry/sdk-trace-base": "^2.9.0",
"@opentelemetry/sdk-trace-node": "^2.9.0",
"@puppeteer/browsers": "^3.0.6",
"@tailwindcss/node": "^4.3.2",
"@tailwindcss/vite": "^4.3.2",
@@ -112,7 +117,7 @@
"build:native": "bun --cwd=packages/natives run build",
"test": "bun scripts/ci-test-ts.ts local",
"test:ts": "bun scripts/ci-test-ts.ts local-ts",
"test:scripts": "bun test scripts/ci-build-native.test.ts scripts/ci-concurrency.test.ts scripts/ci-release-build-binaries.test.ts scripts/ci-release-notes.test.ts scripts/fix-dts-extensions.test.ts scripts/link-omp.test.ts",
"test:scripts": "bun test scripts/ci-build-native.test.ts scripts/ci-concurrency.test.ts scripts/ci-release-build-binaries.test.ts scripts/ci-release-notes.test.ts scripts/ci-release-publish.test.ts scripts/fix-dts-extensions.test.ts scripts/link-omp.test.ts scripts/musl-release.test.ts",
"test:rs": "bun scripts/run-rs-task.ts test:rs",
"check": "bun run --parallel check:ts check:rs",
"check:ts": "bun run check:tools && bun run --workspaces --if-present check",
+23
View File
@@ -2,6 +2,29 @@
## [Unreleased]
## [17.0.8] - 2026-07-22
### Fixed
- Improved resilience against transient stream JSON parse failures by recovering completed tool calls while safely preventing incomplete, unknown, refused, or sensitive calls from executing.
## [17.0.5] - 2026-07-18
### Added
- Added a per-message token estimation cache to optimize performance by reusing token counts for settled message history, with automatic cache invalidation on message mutation.
### Changed
- Improved tool execution control by making tool interruptibility resolvable per call, allowing side-effecting operations to complete while passive waits can yield to queued steering.
## [17.0.2] - 2026-07-17
### Fixed
- Improved error visibility in interactive clients by surfacing provider stream failures through the assistant message lifecycle, preventing silent loading spinners.
- Fixed an issue where Cursor provider contexts omitted host-supplied MCP tools from main and side-channel requests.
## [17.0.0] - 2026-07-15
### Breaking Changes
+1 -1
View File
@@ -1,7 +1,7 @@
{
"type": "module",
"name": "@oh-my-pi/pi-agent-core",
"version": "17.0.0",
"version": "17.0.8",
"description": "General-purpose agent with transport abstraction, state management, and attachment support",
"homepage": "https://omp.sh",
"author": "Can Boluk",
+84 -30
View File
@@ -183,6 +183,7 @@ type AssistantToolCallBlock = Extract<AssistantContentBlock, { type: "toolCall"
function snapshotAssistantContentBlock(block: AssistantContentBlock): AssistantContentBlock {
switch (block.type) {
case "text":
case "image":
return { ...block };
case "thinking":
return { ...block };
@@ -224,6 +225,7 @@ function snapshotAssistantMessageEvent(
case "text_start":
case "text_delta":
case "text_end":
case "image_end":
case "thinking_start":
case "thinking_delta":
case "thinking_end":
@@ -1525,6 +1527,7 @@ async function streamAssistantResponse(
case "text_start":
case "text_delta":
case "text_end":
case "image_end":
case "thinking_start":
case "thinking_delta":
case "thinking_end":
@@ -1626,13 +1629,26 @@ function recoverTransientErrorToolTurn(
if (message.stopReason !== "error") return message;
const toolCalls = message.content.filter(block => block.type === "toolCall");
if (toolCalls.length === 0) return message;
const stopDetailType = message.stopDetails?.type;
const stopDetailCategory = message.stopDetails?.category;
if (
stopDetailType === "refusal" ||
stopDetailType === "sensitive" ||
stopDetailCategory === "refusal" ||
stopDetailCategory === "sensitive"
)
return message;
const availableToolNames = new Set<string>();
for (const tool of availableTools) {
availableToolNames.add(tool.name);
if (tool.customWireName !== undefined) availableToolNames.add(tool.customWireName);
}
if (!toolCalls.every(toolCall => availableToolNames.has(toolCall.name))) return message;
if (!AIError.isStreamReadErrorText(`${message.errorMessage ?? ""}\n${message.stopDetails?.explanation ?? ""}`))
if (
!AIError.isStreamReadErrorText(`${message.errorMessage ?? ""}\n${message.stopDetails?.explanation ?? ""}`) &&
!AIError.isTransientStreamParseError(message.errorMessage) &&
!AIError.isTransientStreamParseError(message.stopDetails?.explanation)
)
return message;
return {
...message,
@@ -1821,11 +1837,25 @@ async function executeToolCalls(
const tool =
tools?.find(t => t.name === toolCall.name) ??
tools?.find(t => t.customWireName !== undefined && t.customWireName === toolCall.name);
const args = toolCall.arguments as Record<string, unknown>;
const interruptibleMode = tool?.interruptible;
let interruptible = false;
if (typeof interruptibleMode === "function") {
try {
interruptible = interruptibleMode(args);
} catch {
// Resolver failures default to preserving the tool's outcome.
interruptible = false;
}
} else {
interruptible = interruptibleMode === true;
}
return {
toolCall,
tool,
args: toolCall.arguments as Record<string, unknown>,
signal: tool?.interruptible ? interruptibleSignal : nonInterruptibleSignal,
args,
interruptible,
signal: interruptible ? interruptibleSignal : nonInterruptibleSignal,
started: false,
result: undefined as AgentToolResult<any> | undefined,
isError: false,
@@ -2207,16 +2237,16 @@ async function executeToolCalls(
}
}
// While an interruptible tool is in flight (e.g. a `job`/`irc` wait
// blocking on external work), queued steering or interrupting IRC would
// otherwise wait out the tool's own window. Poll only non-consuming queues
// and abort the shared tool signal so the boundary dequeue below injects
// the message promptly. Gated on immediate-interrupt mode + an
// interruptible tool; checkSteering is idempotent (no-op once triggered).
// While an interruptible tool call is in flight (e.g. a `hub` wait blocking
// on external work), queued steering or interrupting IRC would otherwise
// wait out the tool's own window. Poll only non-consuming queues and abort
// the shared tool signal so the boundary dequeue below injects the message
// promptly. Gated on immediate-interrupt mode + an interruptible call;
// checkSteering is idempotent (no-op once triggered).
const watchSteeringWhileRunning =
shouldInterruptImmediately &&
(hasSteeringMessages !== undefined || hasIrcInterrupts !== undefined) &&
records.some(r => r.tool?.interruptible === true);
records.some(record => record.interruptible);
const steeringWatchTimer = watchSteeringWhileRunning
? setInterval(() => void checkSteering(), STEERING_INTERRUPT_POLL_MS)
: undefined;
@@ -2268,6 +2298,23 @@ export interface SyntheticToolResultDetails {
upstreamError?: string;
}
/**
* Narrow an {@link AgentMessage} to a synthetic {@link ToolResultMessage} —
* a tool_result emitted for a tool call the assistant never invoked (see
* {@link SyntheticToolResultDetails}). Consumers use this to look past the
* placeholder pairing back to the assistant turn that produced it, e.g.
* `AgentSession.retry()` walking back over the synthetic results a
* stalled/aborted mid-tool-call turn leaves behind.
*/
export function isSyntheticToolResultMessage(
message: AgentMessage | undefined,
): message is ToolResultMessage<SyntheticToolResultDetails> {
return (
message?.role === "toolResult" &&
(message.details as SyntheticToolResultDetails | undefined)?.__synthetic === true
);
}
function syntheticDetailsFor(
reason: "aborted" | "error" | "skipped" | "length",
errorMessage: string | undefined,
@@ -2289,18 +2336,14 @@ function syntheticDetailsFor(
}
/**
* Create a tool result for a tool call that was emitted by the assistant but
* never invoked locally. Maintains the tool_use / tool_result pairing the
* provider API requires, and tags {@link SyntheticToolResultDetails} so
* consumers can distinguish this from a real local tool failure without
* string-matching the content (#4321).
* Create the persisted synthetic result for a tool call that was emitted by
* the assistant but never invoked locally.
*/
function createAbortedToolResult(
export function createSyntheticToolResultMessage(
toolCall: Extract<AssistantMessage["content"][number], { type: "toolCall" }>,
stream: EventStream<AgentEvent, AgentMessage[]>,
reason: "aborted" | "error" | "skipped" | "length",
errorMessage?: string,
): ToolResultMessage {
): ToolResultMessage<SyntheticToolResultDetails> {
const message =
reason === "aborted"
? "Tool execution was aborted"
@@ -2310,9 +2353,31 @@ function createAbortedToolResult(
? "Tool call was not executed because the assistant ended its turn"
: "Tool call was not executed because the provider stream ended with an error before the tool could run";
const details = syntheticDetailsFor(reason, errorMessage);
const result: AgentToolResult<SyntheticToolResultDetails> = {
return {
role: "toolResult",
toolCallId: toolCall.id,
toolName: toolCall.name,
content: [{ type: "text", text: errorMessage ? `${message}: ${errorMessage}` : `${message}.` }],
details,
isError: true,
timestamp: Date.now(),
};
}
/**
* Create and emit a tool result for a tool call that was emitted by the
* assistant but never invoked locally.
*/
function createAbortedToolResult(
toolCall: Extract<AssistantMessage["content"][number], { type: "toolCall" }>,
stream: EventStream<AgentEvent, AgentMessage[]>,
reason: "aborted" | "error" | "skipped" | "length",
errorMessage?: string,
): ToolResultMessage {
const toolResultMessage = createSyntheticToolResultMessage(toolCall, reason, errorMessage);
const result: AgentToolResult<SyntheticToolResultDetails> = {
content: toolResultMessage.content,
details: toolResultMessage.details,
};
stream.push({
@@ -2329,17 +2394,6 @@ function createAbortedToolResult(
result,
isError: true,
});
const toolResultMessage: ToolResultMessage<SyntheticToolResultDetails> = {
role: "toolResult",
toolCallId: toolCall.id,
toolName: toolCall.name,
content: result.content,
details,
isError: true,
timestamp: Date.now(),
};
stream.push({ type: "message_start", message: toolResultMessage });
stream.push({ type: "message_end", message: toolResultMessage });
+75 -14
View File
@@ -31,6 +31,7 @@ import {
abortReasonText,
agentLoop,
agentLoopContinue,
createSyntheticToolResultMessage,
normalizeMessagesForProvider,
normalizeTools,
resolveOwnedDialectFromEnv,
@@ -64,12 +65,6 @@ function defaultConvertToLlm(messages: AgentMessage[]): Message[] {
});
}
const ANTHROPIC_OUTPUT_BLOCKED_PREFIX = "Output blocked by conten";
function isAnthropicOutputBlockedError(message: string): boolean {
return message.includes(ANTHROPIC_OUTPUT_BLOCKED_PREFIX);
}
function refreshToolChoiceForActiveTools(
toolChoice: ToolChoice | undefined,
tools: AgentContext["tools"] = [],
@@ -267,6 +262,8 @@ export interface AgentOptions {
* Cursor exec handlers for local tool execution.
*/
cursorExecHandlers?: CursorExecHandlers;
/** Additional tools Cursor executes through its MCP request-context bridge, resolved before each provider call. */
getCursorTools?: () => AgentTool[];
/**
* Cursor tool result callback for exec tool responses.
@@ -368,6 +365,7 @@ export class Agent {
#maxRetryDelayMs?: number;
#getToolContext?: (toolCall?: ToolCallContext) => AgentToolContext | undefined;
#cursorExecHandlers?: CursorExecHandlers;
#getCursorTools?: () => AgentTool[];
#cursorOnToolResult?: CursorToolResultHandler;
#cwd?: string;
#cwdResolver?: () => string | undefined;
@@ -450,6 +448,7 @@ export class Agent {
this.#onSseEvent = opts.onSseEvent;
this.#getToolContext = opts.getToolContext;
this.#cursorExecHandlers = opts.cursorExecHandlers;
this.#getCursorTools = opts.getCursorTools;
this.#cursorOnToolResult = opts.cursorOnToolResult;
this.#cwd = opts.cwd;
this.#cwdResolver = opts.cwdResolver;
@@ -694,6 +693,22 @@ export class Agent {
this.#appendOnlyContext = manager;
}
#toolsForModel(model: Model): AgentTool[] {
if (model.api !== "cursor-agent" || !this.#getCursorTools) return this.#state.tools;
const cursorTools = this.#getCursorTools();
if (cursorTools.length === 0) return this.#state.tools;
const names = new Set(this.#state.tools.map(tool => tool.name));
let merged: AgentTool[] | undefined;
for (const tool of cursorTools) {
if (names.has(tool.name)) continue;
merged ??= this.#state.tools.slice();
merged.push(tool);
names.add(tool.name);
}
return merged ?? this.#state.tools;
}
/**
* Assemble the provider Context for a side-channel (no-loop) request, mirroring
* the main loop's prefix (system + normalized tools) so it shares the prompt
@@ -718,7 +733,7 @@ export class Agent {
const tools = ownedDialect
? []
: (normalizeTools(
this.#state.tools,
this.#toolsForModel(model),
this.#intentTracing,
preferredDialect(model.id),
this.#pruneToolDescriptions,
@@ -1152,7 +1167,7 @@ export class Agent {
await Bun.sleep(0);
}
context.systemPrompt = this.#state.systemPrompt;
context.tools = this.#state.tools;
context.tools = this.#toolsForModel(this.#state.model ?? model);
},
cursorExecHandlers: this.#cursorExecHandlers,
cursorOnToolResult,
@@ -1205,6 +1220,7 @@ export class Agent {
};
let partial: AgentMessage | null = null;
const completedToolCallIds = new Set<string>();
try {
const stream = messages
@@ -1222,6 +1238,9 @@ export class Agent {
case "message_update":
partial = event.message;
this.#state.streamMessage = event.message;
if (event.assistantMessageEvent.type === "toolcall_end") {
completedToolCallIds.add(event.assistantMessageEvent.toolCall.id);
}
break;
case "message_end":
@@ -1283,12 +1302,22 @@ export class Agent {
: err instanceof Error
? err.message
: String(err);
const shouldEmitVisibleOutputBlockedError = !stoppedForAbort && isAnthropicOutputBlockedError(errorMessage);
const shouldEmitVisibleError = !stoppedForAbort;
const assistantPartial = partial?.role === "assistant" ? partial : undefined;
const hadAssistantStart = assistantPartial !== undefined;
const bufferedCursorResults = this.#cursorToolResultBuffer.map(({ toolResult }) => toolResult);
const retainedToolCallIds = new Set(completedToolCallIds);
for (const { toolCallId } of bufferedCursorResults) retainedToolCallIds.add(toolCallId);
const errorMsg: AssistantMessage =
shouldEmitVisibleOutputBlockedError && assistantPartial
? { ...assistantPartial, stopReason: "error", errorMessage }
shouldEmitVisibleError && assistantPartial
? {
...assistantPartial,
content: assistantPartial.content.filter(
block => block.type !== "toolCall" || retainedToolCallIds.has(block.id),
),
stopReason: "error",
errorMessage,
}
: {
role: "assistant",
content: [{ type: "text", text: "" }],
@@ -1308,7 +1337,7 @@ export class Agent {
timestamp: Date.now(),
};
if (shouldEmitVisibleOutputBlockedError) {
if (shouldEmitVisibleError) {
if (!hadAssistantStart) {
this.#state.streamMessage = errorMsg;
this.#emit({ type: "message_start", message: errorMsg });
@@ -1317,8 +1346,40 @@ export class Agent {
this.appendMessage(errorMsg);
this.#state.error = errorMessage;
this.#emit({ type: "message_end", message: errorMsg });
this.#emit({ type: "turn_end", message: errorMsg, toolResults: [] });
this.#emit({ type: "agent_end", messages: [errorMsg] });
const toolResults: ToolResultMessage[] = [];
this.#cursorToolResultBuffer = [];
const bufferedCursorToolCallIds = new Set(bufferedCursorResults.map(({ toolCallId }) => toolCallId));
for (const toolResult of bufferedCursorResults) {
this.appendMessage(toolResult);
this.#emit({ type: "message_start", message: toolResult });
this.#emit({ type: "message_end", message: toolResult });
toolResults.push(toolResult);
}
for (const block of errorMsg.content) {
if (block.type !== "toolCall") continue;
if (bufferedCursorToolCallIds.has(block.id)) continue;
const toolResult = createSyntheticToolResultMessage(block, "error", errorMessage);
this.#emit({
type: "tool_execution_start",
toolCallId: block.id,
toolName: block.name,
args: block.arguments,
intent: block.intent,
});
this.#emit({
type: "tool_execution_end",
toolCallId: block.id,
toolName: block.name,
result: { content: toolResult.content, details: toolResult.details },
isError: true,
});
this.appendMessage(toolResult);
this.#emit({ type: "message_start", message: toolResult });
this.#emit({ type: "message_end", message: toolResult });
toolResults.push(toolResult);
}
this.#emit({ type: "turn_end", message: errorMsg, toolResults });
this.#emit({ type: "agent_end", messages: [errorMsg, ...toolResults] });
} else {
this.appendMessage(errorMsg);
this.#state.error = errorMessage;
@@ -43,6 +43,7 @@ import {
V2_RETAINED_MESSAGE_TOKEN_BUDGET,
} from "./compaction-v2-streaming";
import type { CompactionEntry, SessionEntry } from "./entries";
import { isEstimateCacheable, readEstimateCache, writeEstimateCache } from "./message-cache";
import { type ConvertToLlm, createBranchSummaryMessage, createCustomMessage, defaultConvertToLlm } from "./messages";
import {
buildOpenAiNativeHistory,
@@ -364,6 +365,21 @@ const IMAGE_TOKEN_ESTIMATE = 1200;
* content) excludes them to avoid false triggers on thinking-heavy turns.
*/
export function estimateTokens(message: AgentMessage, options?: { excludeEncryptedReasoning?: boolean }): number {
// Settled historical messages are counted once and reused until an owner
// (prune/shake/strip-images) invalidates them; streaming assistants bypass
// the cache entirely (see message-cache.ts settle-gate invariant).
const cacheable = isEstimateCacheable(message);
const excludeEncryptedReasoning = options?.excludeEncryptedReasoning === true;
if (cacheable) {
const cached = readEstimateCache(message, excludeEncryptedReasoning);
if (cached !== undefined) return cached;
}
const result = computeMessageTokens(message, options);
if (cacheable) writeEstimateCache(message, excludeEncryptedReasoning, result);
return result;
}
function computeMessageTokens(message: AgentMessage, options?: { excludeEncryptedReasoning?: boolean }): number {
const fragments: string[] = [];
let extra = 0;
if ((message as { role?: string }).role === "bashExecution") {
+1
View File
@@ -6,6 +6,7 @@ export * from "./branch-summarization";
export * from "./compaction";
export * from "./entries";
export * from "./errors";
export * from "./message-cache";
export * from "./messages";
export * from "./openai";
export * from "./pruning";
@@ -0,0 +1,92 @@
/**
* Per-message memoization for the two hot history walks: token estimation
* ({@link estimateTokens}) and LLM conversion (the coding-agent's `convertToLlm`).
*
* Long sessions re-walk a settled `AgentMessage[]` every turn, re-tokenizing and
* re-converting historical objects that only the newest suffix can change. These
* caches key on message *identity* so a settled message is counted/converted once
* and reused until an owner rewrites it.
*
* Correctness rests on two invariants:
*
* 1. **Settle gate.** A streaming assistant is mutated under one identity while
* its `usage`/`stopReason` are provisional (the seed carries zeroed usage and
* a placeholder `stopReason`). Caching it would freeze a mid-stream count, so
* estimation only caches assistants that are settled — real `usage`
* (`totalTokens > 0`) with a terminal `stopReason` that is not `"aborted"` /
* `"error"`. Unsettled assistants never read or insert. Non-assistant roles
* are immutable once appended and cache by identity.
* 2. **Owner invalidation.** `pruneToolOutputs` / `pruneSupersededToolResults`,
* `applyShakeRegion`, and `stripImagesFromMessage` rewrite message content in
* place under a stable identity. Each MUST call {@link invalidateMessageCache}
* on the mutated message before the next convert/estimate pass so both caches
* drop the stale entry. The convert cache lives in another package, so it
* subscribes via {@link registerMessageCacheInvalidator}.
*/
import type { AssistantMessage } from "@oh-my-pi/pi-ai";
import type { AgentMessage } from "../types";
/** External cache invalidators (e.g. the coding-agent `convertToLlm` memo). */
const externalInvalidators = new Set<(message: AgentMessage) => void>();
/**
* Register a cache tied to message identity so owner mutations in this package
* (prune/shake) can invalidate it across the package boundary. Returns an
* unregister function. The coding-agent `convertToLlm` memo registers here.
*/
export function registerMessageCacheInvalidator(invalidate: (message: AgentMessage) => void): () => void {
externalInvalidators.add(invalidate);
return () => {
externalInvalidators.delete(invalidate);
};
}
// Dual option-split estimate caches: the compaction floor passes
// `excludeEncryptedReasoning` (dropping opaque provider reasoning), so a message
// has two distinct estimates that must not collide in one map.
//
// These are WeakMaps, not symbol-tagged properties, deliberately: callers spread
// messages to derive throwaway variants for counting — `estimateBranchSummaryTokens`
// does `estimateTokens({ ...message, content: truncated })`. A symbol-keyed cache
// value rides along an object spread, so the truncated clone would inherit (and
// return) the full-content estimate. Keying strictly on identity keeps the cache
// off spread copies, which get their own fresh count.
const estimateCacheDefault = new WeakMap<AgentMessage, number>();
const estimateCacheFloored = new WeakMap<AgentMessage, number>();
/**
* True when this message's estimate is safe to cache by identity. Non-assistants
* are immutable once appended; assistants are cached only once settled (see the
* settle-gate invariant above).
*/
export function isEstimateCacheable(message: AgentMessage): boolean {
if (message.role !== "assistant") return true;
const assistant = message as AssistantMessage;
return (
assistant.stopReason !== "aborted" &&
assistant.stopReason !== "error" &&
assistant.usage != null &&
assistant.usage.totalTokens > 0
);
}
/** Read a cached estimate for the given option split, or `undefined` on miss. */
export function readEstimateCache(message: AgentMessage, excludeEncryptedReasoning: boolean): number | undefined {
return (excludeEncryptedReasoning ? estimateCacheFloored : estimateCacheDefault).get(message);
}
/** Store an estimate for the given option split. */
export function writeEstimateCache(message: AgentMessage, excludeEncryptedReasoning: boolean, value: number): void {
(excludeEncryptedReasoning ? estimateCacheFloored : estimateCacheDefault).set(message, value);
}
/**
* Drop every cached derivation of `message` after an in-place rewrite. Owners of
* mutation (prune, shake, strip-images) call this at the mutation seam so the
* next convert/estimate pass recomputes from the new content.
*/
export function invalidateMessageCache(message: AgentMessage): void {
estimateCacheDefault.delete(message);
estimateCacheFloored.delete(message);
for (const invalidate of externalInvalidators) invalidate(message);
}
+3
View File
@@ -6,6 +6,7 @@ import type { ToolResultMessage } from "@oh-my-pi/pi-ai";
import type { AgentMessage, AgentToolCall } from "../types";
import { estimateTokens } from "./compaction";
import type { SessionEntry, SessionMessageEntry } from "./entries";
import { invalidateMessageCache } from "./message-cache";
import {
collectToolCallsById,
isProtectedToolResult,
@@ -295,6 +296,7 @@ export function pruneSupersededToolResults(entries: SessionEntry[], config: Supe
for (const candidate of toPrune) {
candidate.message.content = [{ type: "text", text: candidate.notice }];
candidate.message.prunedAt = prunedAt;
invalidateMessageCache(candidate.message as AgentMessage);
tokensSaved += estimatePrunedSavings(candidate.tokens, candidate.notice);
}
return { prunedCount: toPrune.length, tokensSaved };
@@ -398,6 +400,7 @@ export function pruneToolOutputs(entries: SessionEntry[], config: PruneConfig =
: createPrunedNotice(candidate.tokens);
message.content = [{ type: "text", text: notice }];
message.prunedAt = prunedAt;
invalidateMessageCache(message as AgentMessage);
prunedCount++;
}
+7
View File
@@ -15,6 +15,7 @@ import { countTokens } from "../tokenizer";
import type { AgentMessage } from "../types";
import { estimateTokens } from "./compaction";
import type { CustomMessageEntry, SessionEntry, SessionMessageEntry } from "./entries";
import { invalidateMessageCache } from "./message-cache";
import {
collectToolCallsById,
isProtectedToolResult,
@@ -406,12 +407,18 @@ export function applyShakeRegion(region: ShakeRegion, replacement: string): void
const message = region.entry.message as ToolResultMessage;
message.content = [{ type: "text", text: replacement }];
message.prunedAt = Date.now();
invalidateMessageCache(message as AgentMessage);
return;
}
const slot = getBlockTextSlot(region.entry, region.blockIndex);
if (!slot) return;
const text = slot.read();
slot.write(text.slice(0, region.start) + replacement + text.slice(region.end));
// Message entries keep a stable `entry.message` identity across context
// rebuilds, so an in-place block rewrite must drop its cached estimate/convert.
// Custom-message entries are re-materialized into a fresh AgentMessage on every
// buildSessionContext, so they carry no stable cached identity to invalidate.
if (region.entry.type === "message") invalidateMessageCache(region.entry.message);
}
/**
+11
View File
@@ -8,6 +8,7 @@ import {
type Context,
EventStream,
type FetchImpl,
type ImageContent,
type Model,
type SimpleStreamOptions,
type StopReason,
@@ -47,6 +48,7 @@ export type ProxyAssistantMessageEvent =
| { type: "thinking_start"; contentIndex: number }
| { type: "thinking_delta"; contentIndex: number; delta: string }
| { type: "thinking_end"; contentIndex: number; contentSignature?: string }
| { type: "image_end"; contentIndex: number; content: ImageContent }
| { type: "toolcall_start"; contentIndex: number; id: string; toolName: string }
| { type: "toolcall_delta"; contentIndex: number; delta: string }
| { type: "toolcall_end"; contentIndex: number }
@@ -315,6 +317,15 @@ function processProxyEvent(
throw new Error("Received thinking_end for non-thinking content");
}
case "image_end":
partial.content[proxyEvent.contentIndex] = proxyEvent.content;
return {
type: "image_end",
contentIndex: proxyEvent.contentIndex,
content: proxyEvent.content,
partial,
};
case "toolcall_start":
partial.content[proxyEvent.contentIndex] = {
type: "toolCall",
+3
View File
@@ -957,6 +957,9 @@ function assistantContentToOtelParts(content: AssistantMessage["content"]): Otel
case "text":
parts.push({ type: "text", content: part.text });
break;
case "image":
parts.push({ type: "blob", modality: "image", mime_type: part.mimeType, content: part.data });
break;
case "thinking":
parts.push({ type: "reasoning", content: part.thinking });
break;
+7 -5
View File
@@ -657,14 +657,16 @@ export interface AgentTool<TParameters extends TSchema = TSchema, TDetails = any
/** If true, argument validation errors are non-fatal: raw args are passed to execute() instead of returning an error to the LLM. */
lenientArgValidation?: boolean;
/**
* If true, the agent loop may abort this tool mid-execution to deliver a
* queued steering message (instead of waiting for the tool to finish on its
* own). Set only on tools that purely *wait* and observe their abort signal
* cleanly (e.g. the `job` poll), so the abort surfaces the tool's current
* Whether the agent loop may abort this tool mid-execution to deliver a
* queued steering message. A function resolves this per call from the raw,
* pre-validation arguments.
*
* Enable only for calls that purely *wait* and observe their abort signal
* cleanly (e.g. `job` poll), so the abort surfaces the tool's current
* snapshot rather than corrupting a side effect. Honored only when
* `interruptMode` is "immediate".
*/
interruptible?: boolean;
interruptible?: boolean | ((args: Partial<Static<TParameters>>) => boolean);
/**
* Controls how the INTENT_FIELD (`i`) is handled for this tool.
* - `"require"` (default): `i` is injected and required in the parameter schema.
+274 -10
View File
@@ -630,7 +630,7 @@ describe("agentLoop with AgentMessage", () => {
expect(finalTurn.content).toContainEqual({ type: "text", text: "done after recovery" });
});
it("does not recover completed tool calls after non-stream transient errors", async () => {
it("runs completed tool calls after a transient stream JSON parse error", async () => {
const executedParams: Array<{ value: string }> = [];
const toolSchema = type({ value: "string" });
const tool: AgentTool<typeof toolSchema, { value: string }> = {
@@ -652,9 +652,9 @@ describe("agentLoop with AgentMessage", () => {
{
content: [{ type: "toolCall", id: "tool-1", name: "echo", arguments: { value: "hello" } }],
stopReason: "error",
errorMessage: "rate_limit_error",
errorMessage: "JSON Parse error: Unterminated string",
},
{ content: ["should not continue"] },
{ content: ["done after parse recovery"] },
],
});
const config: AgentLoopConfig = { model: mock.model, convertToLlm: identityConverter };
@@ -667,12 +667,275 @@ describe("agentLoop with AgentMessage", () => {
mock.stream,
).result();
expect(executedParams).toEqual([]);
expect(executedParams).toEqual([{ value: "hello" }]);
expect(mock.calls).toHaveLength(2);
expect(messages.map(message => message.role)).toEqual(["user", "assistant", "toolResult", "assistant"]);
const recoveredTurn = messages[1] as AssistantMessage;
expect(recoveredTurn.stopReason).toBe("toolUse");
expect(recoveredTurn.stopDetails?.type).toBe("stream_interrupted_after_content");
const finalTurn = messages[3] as AssistantMessage;
expect(finalTurn.content).toContainEqual({ type: "text", text: "done after parse recovery" });
});
it("recovers only completed calls when a stream parse error interrupts the next call", async () => {
const executedParams: Array<{ value: string }> = [];
const toolSchema = type({ value: "string" });
const tool: AgentTool<typeof toolSchema, { value: string }> = {
name: "echo",
label: "Echo",
description: "Echo tool",
parameters: toolSchema,
async execute(_toolCallId, params) {
executedParams.push(params);
return {
content: [{ type: "text", text: `echoed: ${params.value}` }],
details: { value: params.value },
};
},
};
const context: AgentContext = { systemPrompt: [""], messages: [], tools: [tool] };
const completedCall = {
type: "toolCall" as const,
id: "tool-complete",
name: "echo",
arguments: { value: "complete" },
};
const incompleteCall = {
type: "toolCall" as const,
id: "tool-incomplete",
name: "echo",
arguments: { value: "incomplete" },
};
const mock = createMockModel({ responses: [{ content: ["done after partial parse recovery"] }] });
let streamCalls = 0;
const streamFn: typeof mock.stream = (model, callContext, options) => {
streamCalls++;
if (streamCalls > 1) return mock.stream(model, callContext, options);
const stream = new AssistantMessageEventStream();
queueMicrotask(() => {
const partial: AssistantMessage = {
...createAssistantMessage([completedCall, incompleteCall], "error"),
errorMessage: "provider stream parse failed",
stopDetails: {
type: "parse_error",
explanation: "JSON Parse error: Unterminated string",
},
};
stream.push({ type: "start", partial });
stream.push({ type: "toolcall_end", contentIndex: 0, toolCall: completedCall, partial });
stream.push({ type: "toolcall_start", contentIndex: 1, partial });
stream.push({ type: "toolcall_delta", contentIndex: 1, delta: '{"value":"incomplete', partial });
stream.push({ type: "error", reason: "error", error: partial });
});
return stream;
};
const config: AgentLoopConfig = { model: mock.model, convertToLlm: identityConverter };
const messages = await agentLoop([createUserMessage("run echo")], context, config, undefined, streamFn).result();
expect(executedParams).toEqual([{ value: "complete" }]);
expect(streamCalls).toBe(2);
expect(mock.calls).toHaveLength(1);
expect(messages.map(message => message.role)).toEqual(["user", "assistant", "toolResult"]);
expect(messages.map(message => message.role)).toEqual(["user", "assistant", "toolResult", "assistant"]);
const recoveredTurn = messages[1] as AssistantMessage;
expect(recoveredTurn.stopReason).toBe("toolUse");
expect(recoveredTurn.content.filter(block => block.type === "toolCall").map(block => block.id)).toEqual([
"tool-complete",
]);
expect(messages.some(message => message.role === "toolResult" && message.toolCallId === "tool-incomplete")).toBe(
false,
);
});
it("does not recover a wrapped refusal after dropping an incomplete sibling", async () => {
const executedParams: Array<{ value: string }> = [];
const toolSchema = type({ value: "string" });
const tool: AgentTool<typeof toolSchema, { value: string }> = {
name: "echo",
label: "Echo",
description: "Echo tool",
parameters: toolSchema,
async execute(_toolCallId, params) {
executedParams.push(params);
return { content: [], details: { value: params.value } };
},
};
const context: AgentContext = { systemPrompt: [""], messages: [], tools: [tool] };
const completedCall = {
type: "toolCall" as const,
id: "tool-complete",
name: "echo",
arguments: { value: "complete" },
};
const incompleteCall = {
type: "toolCall" as const,
id: "tool-incomplete",
name: "echo",
arguments: { value: "incomplete" },
};
const mock = createMockModel({ responses: [{ content: ["should not continue"] }] });
let streamCalls = 0;
const streamFn: typeof mock.stream = (model, callContext, options) => {
streamCalls++;
if (streamCalls > 1) return mock.stream(model, callContext, options);
const stream = new AssistantMessageEventStream();
queueMicrotask(() => {
const partial: AssistantMessage = {
...createAssistantMessage([completedCall, incompleteCall], "error"),
errorMessage: "provider refused output",
stopDetails: { type: "refusal", explanation: "Unexpected end of JSON input" },
};
stream.push({ type: "start", partial });
stream.push({ type: "toolcall_end", contentIndex: 0, toolCall: completedCall, partial });
stream.push({ type: "toolcall_start", contentIndex: 1, partial });
stream.push({ type: "toolcall_delta", contentIndex: 1, delta: '{"value":"incomplete', partial });
stream.push({ type: "error", reason: "error", error: partial });
});
return stream;
};
const config: AgentLoopConfig = { model: mock.model, convertToLlm: identityConverter };
const messages = await agentLoop([createUserMessage("run echo")], context, config, undefined, streamFn).result();
expect(executedParams).toEqual([]);
expect(streamCalls).toBe(1);
expect(mock.calls).toHaveLength(0);
const errorTurn = messages[1] as AssistantMessage;
expect(errorTurn.stopReason).toBe("error");
expect(errorTurn.errorMessage).toBe("rate_limit_error");
expect(errorTurn.content.filter(block => block.type === "toolCall").map(block => block.id)).toEqual([
"tool-complete",
]);
expect(errorTurn.stopDetails).toEqual({
type: "stream_interrupted_after_content",
category: "refusal",
explanation: "Unexpected end of JSON input",
});
});
it("does not recover a mixed known and unknown completed tool turn", async () => {
const executedParams: Array<{ value: string }> = [];
const toolSchema = type({ value: "string" });
const tool: AgentTool<typeof toolSchema, { value: string }> = {
name: "echo",
label: "Echo",
description: "Echo tool",
parameters: toolSchema,
async execute(_toolCallId, params) {
executedParams.push(params);
return { content: [], details: { value: params.value } };
},
};
const context: AgentContext = { systemPrompt: [""], messages: [], tools: [tool] };
const mock = createMockModel({
responses: [
{
content: [
{ type: "toolCall", id: "tool-known", name: "echo", arguments: { value: "known" } },
{ type: "toolCall", id: "tool-unknown", name: "missing", arguments: { value: "unknown" } },
],
stopReason: "error",
errorMessage: "JSON Parse error: Unterminated string",
},
{ content: ["should not continue"] },
],
});
const config: AgentLoopConfig = { model: mock.model, convertToLlm: identityConverter };
const messages = await agentLoop(
[createUserMessage("run mixed tools")],
context,
config,
undefined,
mock.stream,
).result();
expect(executedParams).toEqual([]);
expect(mock.calls).toHaveLength(1);
const errorTurn = messages[1] as AssistantMessage;
expect(errorTurn.stopReason).toBe("error");
expect(errorTurn.errorMessage).toBe("JSON Parse error: Unterminated string");
});
it("does not recover content-only transient stream parse errors", async () => {
const context: AgentContext = { systemPrompt: [""], messages: [], tools: [] };
const mock = createMockModel({
responses: [
{
content: ["partial response"],
stopReason: "error",
errorMessage: "JSON Parse error: Unterminated string",
},
{ content: ["should not continue"] },
],
});
const config: AgentLoopConfig = { model: mock.model, convertToLlm: identityConverter };
const messages = await agentLoop(
[createUserMessage("answer once")],
context,
config,
undefined,
mock.stream,
).result();
expect(mock.calls).toHaveLength(1);
expect(messages.map(message => message.role)).toEqual(["user", "assistant"]);
const errorTurn = messages[1] as AssistantMessage;
expect(errorTurn.stopReason).toBe("error");
expect(errorTurn.errorMessage).toBe("JSON Parse error: Unterminated string");
});
it("does not recover ordinary transient errors or terminal stops quoting parse diagnostics", async () => {
for (const stopDetails of [
undefined,
{ type: "refusal", explanation: "Unexpected end of JSON input" },
{ type: "sensitive", explanation: "Unexpected end of JSON input" },
]) {
const executedParams: Array<{ value: string }> = [];
const toolSchema = type({ value: "string" });
const tool: AgentTool<typeof toolSchema, { value: string }> = {
name: "echo",
label: "Echo",
description: "Echo tool",
parameters: toolSchema,
async execute(_toolCallId, params) {
executedParams.push(params);
return {
content: [{ type: "text", text: `echoed: ${params.value}` }],
details: { value: params.value },
};
},
};
const context: AgentContext = { systemPrompt: [""], messages: [], tools: [tool] };
const mock = createMockModel({
responses: [
{
content: [{ type: "toolCall", id: "tool-1", name: "echo", arguments: { value: "hello" } }],
stopReason: "error",
stopDetails,
errorMessage: "rate_limit_error",
},
{ content: ["should not continue"] },
],
});
const config: AgentLoopConfig = { model: mock.model, convertToLlm: identityConverter };
const messages = await agentLoop(
[createUserMessage("run echo")],
context,
config,
undefined,
mock.stream,
).result();
expect(executedParams).toEqual([]);
expect(mock.calls).toHaveLength(1);
expect(messages.map(message => message.role)).toEqual(["user", "assistant", "toolResult"]);
const errorTurn = messages[1] as AssistantMessage;
expect(errorTurn.stopReason).toBe("error");
expect(errorTurn.errorMessage).toBe("rate_limit_error");
expect(errorTurn.stopDetails).toEqual(stopDetails);
}
});
it("labels the synthetic tool result for a provider-error turn as not-executed and preserves the upstream error", async () => {
@@ -1691,8 +1954,8 @@ describe("agentLoop with AgentMessage", () => {
}
});
it("does not abort a non-interruptible tool mid-wait; steering still drains at the boundary", async () => {
const toolSchema = type({});
it("does not abort a tool when its interruptibility resolver rejects the call", async () => {
const toolSchema = type({ op: "'start' | 'wait'" });
let steerReady = false;
let drained = false;
let observedAbort = false;
@@ -1701,8 +1964,9 @@ describe("agentLoop with AgentMessage", () => {
const tool: AgentTool<typeof toolSchema, Record<string, never>> = {
name: "wait",
label: "Wait",
description: "Blocks on its own window (no interruptible flag)",
description: "Blocks on its own window (mimics a side-effecting start)",
parameters: toolSchema,
interruptible: params => params.op === "wait",
async execute(_toolCallId, _params, signal) {
steerReady = true;
const { promise, resolve } = Promise.withResolvers<void>();
@@ -1731,7 +1995,7 @@ describe("agentLoop with AgentMessage", () => {
const context: AgentContext = { systemPrompt: [""], messages: [], tools: [tool] };
const mock = createMockModel({
responses: [
{ content: [{ type: "toolCall", id: "tool-1", name: "wait", arguments: {} }] },
{ content: [{ type: "toolCall", id: "tool-1", name: "wait", arguments: { op: "start" } }] },
{ content: ["done"] },
],
});
@@ -2,6 +2,7 @@ import { describe, expect, it, mock } from "bun:test";
import { type AssistantMessage, type Context, z } from "@oh-my-pi/pi-ai";
import { createMockModel } from "@oh-my-pi/pi-ai/providers/mock";
import { AssistantMessageEventStream } from "@oh-my-pi/pi-ai/utils/event-stream";
import { buildModel } from "@oh-my-pi/pi-catalog/build";
import { Agent } from "../src/agent";
import type { AgentTool } from "../src/types";
@@ -39,6 +40,19 @@ function testAssistantMessage(text: string): AssistantMessage {
};
}
const cursorModel = buildModel({
id: "cursor-test",
name: "Cursor Test",
api: "cursor-agent",
provider: "cursor",
baseUrl: "https://example.invalid",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 8_192,
maxTokens: 2_048,
});
describe("Agent — buildSideRequestContext", () => {
const model = createMockModel({ responses: [] });
const tool: AgentTool = {
@@ -109,6 +123,43 @@ describe("Agent — buildSideRequestContext", () => {
});
});
it("adds mounted Cursor tools to main and side provider contexts", async () => {
await withNativeDialectEnv(async () => {
const mountedTool: AgentTool = {
...tool,
name: "mcp__fixture_report",
label: "Fixture Report",
};
let mainContext: Context | undefined;
const agent = new Agent({
initialState: {
model: cursorModel,
systemPrompt: ["system"],
tools: [tool],
},
getCursorTools: () => [tool, mountedTool],
streamFn: (_model, context) => {
mainContext = context;
const stream = new AssistantMessageEventStream();
queueMicrotask(() => {
const message = testAssistantMessage("ok");
stream.push({ type: "text_delta", contentIndex: 0, delta: "ok", partial: message });
stream.push({ type: "done", reason: "stop", message });
});
return stream;
},
});
await agent.prompt("Q?");
const sideContext = await agent.buildSideRequestContext([
{ role: "user", content: [{ type: "text", text: "Q?" }], timestamp: Date.now() },
]);
expect(mainContext?.tools?.map(entry => entry.name)).toEqual(["test_tool", "mcp__fixture_report"]);
expect(sideContext.tools?.map(entry => entry.name)).toEqual(["test_tool", "mcp__fixture_report"]);
});
});
it("returns empty tools when owned dialect is active", async () => {
const agent = new Agent({
initialState: {
+134 -12
View File
@@ -1,7 +1,8 @@
import { describe, expect, it } from "bun:test";
import { Agent, type AgentEvent, type AgentTool, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
import { type SimpleStreamOptions, z } from "@oh-my-pi/pi-ai";
import { type SimpleStreamOptions, type ToolResultMessage, z } from "@oh-my-pi/pi-ai";
import { createMockModel } from "@oh-my-pi/pi-ai/providers/mock";
import { kCursorExecResolved } from "@oh-my-pi/pi-ai/utils/block-symbols";
import { AssistantMessageEventStream } from "@oh-my-pi/pi-ai/utils/event-stream";
import { createAssistantMessage } from "./helpers";
@@ -184,7 +185,7 @@ describe("Agent", () => {
expect(lastMessage.errorMessage).toBe(errorText);
});
it("prompt() keeps unrelated provider stream failures out of the assistant lifecycle", async () => {
it("prompt() emits assistant error lifecycle for provider stream failures", async () => {
const mock = createMockModel({ responses: [] });
const errorText = "connection reset";
const agent = new Agent({
@@ -201,17 +202,138 @@ describe("Agent", () => {
await agent.prompt("trigger");
unsubscribe();
expect(events.some(event => event.type === "message_start" && event.message.role === "assistant")).toBe(false);
expect(events.some(event => event.type === "message_end" && event.message.role === "assistant")).toBe(false);
const agentEnd = events.find(event => event.type === "agent_end");
if (agentEnd?.type !== "agent_end") {
throw new Error("agent_end not emitted");
const assistantStartIndex = events.findIndex(
event => event.type === "message_start" && event.message.role === "assistant",
);
const assistantEndIndex = events.findIndex(
event => event.type === "message_end" && event.message.role === "assistant",
);
const turnEndIndex = events.findIndex(event => event.type === "turn_end");
const agentEndIndex = events.findIndex(event => event.type === "agent_end");
expect(assistantStartIndex).toBeGreaterThan(-1);
expect(assistantEndIndex).toBeGreaterThan(assistantStartIndex);
expect(turnEndIndex).toBeGreaterThan(assistantEndIndex);
expect(agentEndIndex).toBeGreaterThan(turnEndIndex);
const assistantEnd = events[assistantEndIndex];
if (assistantEnd?.type !== "message_end" || assistantEnd.message.role !== "assistant") {
throw new Error("assistant message_end not emitted");
}
const errorMessage = agentEnd.messages.find(message => message.role === "assistant");
if (errorMessage?.role !== "assistant") {
throw new Error("assistant error was not included in agent_end");
}
expect(errorMessage.errorMessage).toBe(errorText);
expect(assistantEnd.message.stopReason).toBe("error");
expect(assistantEnd.message.errorMessage).toBe(errorText);
});
it("pairs tool calls from failed partial streams with synthetic tool results", async () => {
const mock = createMockModel({ responses: [] });
const errorText = "connection reset after tool call";
const toolCall = { type: "toolCall" as const, id: "tool-1", name: "alpha", arguments: { value: "hello" } };
const started = createAssistantMessage([toolCall]);
const agent = new Agent({
initialState: { model: mock.model, systemPrompt: ["Test"], tools: [], messages: [] },
streamFn: () => {
const stream = new AssistantMessageEventStream();
queueMicrotask(() => {
stream.push({ type: "start", partial: started });
stream.push({ type: "toolcall_end", contentIndex: 0, toolCall, partial: started });
stream.fail(new Error(errorText));
});
return stream;
},
});
const events: AgentEvent[] = [];
const unsubscribe = agent.subscribe(event => events.push(event));
await agent.prompt("trigger");
unsubscribe();
const toolResult = agent.state.messages.find(message => message.role === "toolResult");
expect(toolResult).toMatchObject({
role: "toolResult",
toolCallId: "tool-1",
toolName: "alpha",
isError: true,
details: {
__synthetic: true,
source: "assistant_stop_error",
executed: false,
upstreamError: errorText,
},
});
const turnEnd = events.find(event => event.type === "turn_end");
expect(turnEnd).toMatchObject({
type: "turn_end",
toolResults: [{ role: "toolResult", toolCallId: "tool-1", isError: true }],
});
});
it("drops incomplete tool calls when a partial stream fails before toolcall_end", async () => {
const mock = createMockModel({ responses: [] });
const started = createAssistantMessage([{ type: "toolCall", id: "tool-1", name: "alpha", arguments: {} }]);
const agent = new Agent({
initialState: { model: mock.model, systemPrompt: ["Test"], tools: [], messages: [] },
streamFn: () => {
const stream = new AssistantMessageEventStream();
queueMicrotask(() => {
stream.push({ type: "start", partial: started });
stream.push({ type: "toolcall_start", contentIndex: 0, partial: started });
stream.push({ type: "toolcall_delta", contentIndex: 0, delta: '{"value":', partial: started });
stream.fail(new Error("connection reset during tool arguments"));
});
return stream;
},
});
await agent.prompt("trigger");
const assistant = agent.state.messages.find(message => message.role === "assistant");
expect(assistant?.content.some(block => block.type === "toolCall")).toBe(false);
expect(agent.state.messages.some(message => message.role === "toolResult")).toBe(false);
});
it("preserves buffered Cursor results when a partial stream fails", async () => {
const mock = createMockModel({ responses: [] });
const errorText = "connection reset after Cursor exec";
const toolCall = {
type: "toolCall" as const,
id: "cursor-tool-1",
name: "shell",
arguments: { command: "pwd" },
[kCursorExecResolved]: true,
};
const started = createAssistantMessage([toolCall]);
const realToolResult: ToolResultMessage = {
role: "toolResult",
toolCallId: toolCall.id,
toolName: toolCall.name,
content: [{ type: "text", text: "/workspace" }],
isError: false,
timestamp: Date.now(),
};
const agent = new Agent({
initialState: { model: mock.model, systemPrompt: ["Test"], tools: [], messages: [] },
cursorOnToolResult: message => message,
streamFn: (_model, _context, options) => {
const stream = new AssistantMessageEventStream();
queueMicrotask(async () => {
await options?.cursorOnToolResult?.(realToolResult);
stream.push({ type: "start", partial: started });
stream.fail(new Error(errorText));
});
return stream;
},
});
await agent.prompt("trigger");
const toolResults = agent.state.messages.filter(message => message.role === "toolResult");
expect(toolResults).toHaveLength(1);
expect(toolResults[0]).toMatchObject({
toolCallId: toolCall.id,
toolName: toolCall.name,
content: [{ type: "text", text: "/workspace" }],
isError: false,
});
});
it("prompt() finalizes an existing assistant stream for Anthropic output-blocked stream errors", async () => {
+159
View File
@@ -0,0 +1,159 @@
import { describe, expect, test } from "bun:test";
import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
import type { SessionMessageEntry } from "@oh-my-pi/pi-agent-core/compaction";
import {
applyShakeRegion,
collectShakeRegions,
DEFAULT_PRUNE_CONFIG,
estimateTokens,
invalidateMessageCache,
isEstimateCacheable,
pruneToolOutputs,
} from "@oh-my-pi/pi-agent-core/compaction";
import type { AssistantMessage, ToolResultMessage, Usage } from "@oh-my-pi/pi-ai";
let idCounter = 0;
function nextId(): string {
return `mc-${idCounter++}`;
}
function messageEntry(message: AgentMessage): SessionMessageEntry {
return { type: "message", id: nextId(), parentId: null, timestamp: new Date().toISOString(), message };
}
function usage(totalTokens: number): Usage {
return {
input: totalTokens,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
};
}
function settledAssistant(text: string): AssistantMessage {
return {
role: "assistant",
content: [{ type: "text", text }],
api: "anthropic-messages",
provider: "anthropic",
model: "bench",
usage: usage(120),
stopReason: "stop",
timestamp: 1,
};
}
function toolResult(text: string, extra?: Partial<ToolResultMessage>): ToolResultMessage {
return {
role: "toolResult",
toolCallId: `call-${idCounter++}`,
toolName: "read",
content: [{ type: "text", text }],
isError: false,
timestamp: Date.now(),
...extra,
};
}
describe("estimate cache settle gate", () => {
test("caches settled assistants (terminal stopReason + real usage)", () => {
expect(isEstimateCacheable(settledAssistant("done"))).toBe(true);
});
test("bypasses a streaming assistant (zero usage seed)", () => {
const streaming: AssistantMessage = { ...settledAssistant("partial"), usage: usage(0), stopReason: "stop" };
expect(isEstimateCacheable(streaming)).toBe(false);
});
test("bypasses aborted and error assistants even with usage", () => {
expect(isEstimateCacheable({ ...settledAssistant("x"), stopReason: "aborted" })).toBe(false);
expect(isEstimateCacheable({ ...settledAssistant("x"), stopReason: "error" })).toBe(false);
});
test("caches non-assistant roles unconditionally", () => {
expect(isEstimateCacheable(toolResult("out") as AgentMessage)).toBe(true);
expect(isEstimateCacheable({ role: "user", content: "hi", timestamp: 1 } as AgentMessage)).toBe(true);
});
test("a streaming assistant re-estimates as its content grows", () => {
const streaming: AssistantMessage = {
...settledAssistant("first chunk"),
usage: usage(0),
stopReason: "stop",
};
const before = estimateTokens(streaming as AgentMessage);
streaming.content = [{ type: "text", text: "first chunk plus a much longer continuation of streamed text" }];
const after = estimateTokens(streaming as AgentMessage);
// Unsettled assistants never read the cache, so the grown content is recounted.
expect(after).toBeGreaterThan(before);
});
});
describe("estimate cache option split", () => {
test("default and floored estimates do not collide in one map", () => {
const blob = "blob ".repeat(4000);
const msg: AssistantMessage = {
...settledAssistant("thinking heavy"),
content: [
{ type: "text", text: "answer" },
{ type: "thinking", thinking: "reasoning", thinkingSignature: blob },
],
};
// Prime the default map first, then the floored one; the floored estimate
// (which drops the encrypted-reasoning blob) must not read the default entry.
const withBlob = estimateTokens(msg as AgentMessage);
const floored = estimateTokens(msg as AgentMessage, { excludeEncryptedReasoning: true });
expect(withBlob).toBeGreaterThan(floored + 500);
// Cached reads return the same split values.
expect(estimateTokens(msg as AgentMessage)).toBe(withBlob);
expect(estimateTokens(msg as AgentMessage, { excludeEncryptedReasoning: true })).toBe(floored);
});
});
describe("estimate cache invalidation seams", () => {
test("pruneToolOutputs drops the cached estimate of a pruned result", () => {
const big = toolResult("x".repeat(20_000));
const entries = [messageEntry(big as AgentMessage)];
const before = estimateTokens(big as AgentMessage);
expect(before).toBeGreaterThan(1000);
const result = pruneToolOutputs(entries, { ...DEFAULT_PRUNE_CONFIG, protectTokens: 0, minimumSavings: 0 });
expect(result.prunedCount).toBe(1);
// After the in-place prune the estimate must reflect the short placeholder,
// not the stale full-content count.
const after = estimateTokens(big as AgentMessage);
expect(after).toBeLessThan(before);
});
test("applyShakeRegion drops the cached estimate of a shaken result", () => {
const big = toolResult(`\`\`\`ts\n${"const value = compute(a, b, c, d, e);\n".repeat(400)}\`\`\``);
const entry = messageEntry(big as AgentMessage);
const before = estimateTokens(big as AgentMessage);
const regions = collectShakeRegions([entry], {
protectTokens: 0,
minSavings: 0,
protectedTools: [],
fenceMinTokens: 0,
});
expect(regions.length).toBeGreaterThan(0);
applyShakeRegion(regions[0], "[shaken]");
const after = estimateTokens(big as AgentMessage);
expect(after).toBeLessThan(before);
});
test("explicit invalidateMessageCache forces a recount", () => {
const result = toolResult("original content here");
const before = estimateTokens(result as AgentMessage);
// Mutate content directly (simulating an owner rewrite) then invalidate.
result.content = [{ type: "text", text: "a much longer replacement body that should count higher than before" }];
// Without invalidation the stale cached value would still be returned.
expect(estimateTokens(result as AgentMessage)).toBe(before);
invalidateMessageCache(result as AgentMessage);
expect(estimateTokens(result as AgentMessage)).toBeGreaterThan(before);
});
});
+22 -8
View File
@@ -51,6 +51,16 @@ describe("agentPauseGate", () => {
it("holds tool execution at the tool boundary when paused mid-turn", async () => {
const executed: string[] = [];
// Signal exactly when the loop parks on the gate. A test-local manual
// patch (not vi.spyOn) so a sibling file's restoreAllMocks cannot remove
// it, and a gate regression that never parks hangs this await (test
// timeout) instead of racing past a vacuous assertion.
const toolBoundary = Promise.withResolvers<void>();
const originalWait = agentPauseGate.waitUntilResumed;
agentPauseGate.waitUntilResumed = (signal?: AbortSignal) => {
toolBoundary.resolve();
return originalWait.call(agentPauseGate, signal);
};
const mock = createMockModel({
responses: [
() => {
@@ -65,15 +75,19 @@ describe("agentPauseGate", () => {
const context: AgentContext = { systemPrompt: ["Test"], messages: [], tools: [makeEchoTool(executed)] };
const config: AgentLoopConfig = { model: mock.model, convertToLlm: identityConverter };
const result = agentLoop([createUserMessage("run echo")], context, config, undefined, mock.stream).result();
await Bun.sleep(20);
expect(executed).toEqual([]); // tool parked, not started
expect(mock.calls.length).toBe(1); // and no follow-up model call either
try {
const result = agentLoop([createUserMessage("run echo")], context, config, undefined, mock.stream).result();
await toolBoundary.promise;
expect(executed).toEqual([]); // tool parked, not started
expect(mock.calls.length).toBe(1); // and no follow-up model call either
agentPauseGate.resume();
await result;
expect(executed).toEqual(["frozen"]);
expect(mock.calls.length).toBe(2);
agentPauseGate.resume();
await result;
expect(executed).toEqual(["frozen"]);
expect(mock.calls.length).toBe(2);
} finally {
agentPauseGate.waitUntilResumed = originalWait;
}
});
it("lets an external abort unwind a parked run without releasing the gate", async () => {
+96
View File
@@ -2,6 +2,102 @@
## [Unreleased]
### Added
- Added Synthetic (synthetic.new) usage provider: `/usage` now reports the rolling 5-hour request limit and weekly credit quota via `GET /v2/quotas`, including per-tick regeneration rates in the window labels.
- Added optional `UsageWindow.resetLabel` so rolling windows can render their countdown with an accurate verb (e.g. "tick in 12m" / "regen in 51m" instead of "resets in") — both quota windows on Synthetic regenerate incrementally rather than hard-resetting.
### Fixed
- Fixed GitHub Copilot OpenAI-compatible requests being rejected when the session's native OpenAI service tier was set to `priority` ([#5160](https://github.com/can1357/oh-my-pi/pull/5160) by [@audreyt](https://github.com/audreyt)).
- Fixed OpenAI Responses token-cap truncations suppressing fully streamed function and custom tool calls whose inputs are complete.
- Added SuperGrok (`xai-oauth`) usage tracking for weekly credits, product limits, and positive on-demand caps.
## [17.0.8] - 2026-07-22
### Fixed
- Fixed Gemini Flash Cloud Code Assist empty-response retries when responses contain only intercepted planning-leak JSON.
- Fixed Antigravity auto-routing to correctly fail over to the sandbox endpoint when the daily endpoint exhausts its retries.
- Fixed OpenAI-compatible providers configured with auth: none incorrectly sending an Authorization: Bearer N/A header, which broke custom endpoints using alternative authentication headers.
- Fixed auth-gateway model listings exposing duplicate or ambiguous model IDs by ensuring only provider-qualified routing IDs are advertised.
- Improved connection error handling by classifying generic connection failures as transient, allowing them to be retried, while keeping explicit authentication rejections non-retryable.
- Fixed custom Anthropic base URLs losing native thinking signatures during continuation requests.
- Fixed Alibaba Coding Plan Custom login rejecting valid API keys on endpoints that do not serve the default validation model by validating against the model catalog instead.
## [17.0.6] - 2026-07-20
### Fixed
- Fixed OpenAI Codex credentials limited to one ChatGPT workspace per email: a personal Plus/Pro plan and a Team/Enterprise seat under the same email now coexist in the auth store — with separate rotation and usage pools — instead of the second login silently replacing the first. The workspace (`chatgpt_account_id`) is captured as the credential's org at login with the plan type as its display label, and two members of one workspace keep separate rows ([#2966](https://github.com/can1357/oh-my-pi/issues/2966)).
- Fixed Devin total-token usage omitting cache reads and cache writes.
- Fixed model switches to Devin rejecting foreign provider response IDs, reasoning signatures, and empty interrupted turns as invalid Cascade history.
- Classified zero-output Devin `invalid_argument` trailers as context overflow when the serialized message history is already large, routing cumulative tool-output payload failures through context maintenance—including artifact-backed shake rescue—instead of retrying the same rejected history.
## [17.0.5] - 2026-07-18
### Changed
- Changed Anthropic API-key requests to default to a 1-hour prompt-cache retention (using the extended-cache-ttl-2025-04-11 beta) to prevent cold-misses during idle sessions, with support for PI_CACHE_RETENTION values "short" and "none" to override this behavior.
### Fixed
- Fixed transient OpenAI stream truncations by retrying once before output becomes replay-unsafe, preventing recoverable transport errors from failing the turn.
- Fixed native Kimi Code K3 thinking being disabled during named function selection by utilizing generic required tool choice.
- Fixed /login moonshot validating China-platform API keys against the international host instead of honoring MOONSHOT_BASE_URL.
- Fixed Anthropic session stickiness suppressing usage-based re-ranking indefinitely by gating stickiness on a 1-hour cache warmth window (configurable via ANTHROPIC_SESSION_STICKY_CACHE_WARM_MS) to restore proactive multi-account load balancing after long idle periods.
- Fixed credential ranking where clockless Anthropic usage windows incorrectly outranked clocked sibling credentials.
- Fixed tool request failures (HTTP 400) on local grammar-constrained OpenAI-compatible backends (such as llama.cpp, LM Studio, and vLLM) by widening bare boolean subschemas into a value-accepting primitive union.
- Fixed custom OAuth Anthropic-compatible endpoints receiving generated Claude Code fingerprint headers even when explicit header overrides were provided.
- Fixed active sessions for plan-gated OpenAI Codex models (Sol/Luna) silently re-routing to sibling OAuth accounts when usage headroom changed, ensuring session stickiness is preserved as long as the preferred credential remains usable and eligible.
## [17.0.4] - 2026-07-18
### Fixed
- Fixed Kimi Code usage reports dropping the 5h window reset time (`omp usage` showed no "resets in …" for the 5h limit): the API returns `resetTime` on the limit `detail`, not on `window`, so the parsed row-level reset is now carried onto the window when the window itself has none.
- Made Kimi device-id persistence best-effort: a missing or unwritable `~/.omp/agent` directory no longer throws during Kimi header construction, which silently nulled every `kimi-code` usage probe on fresh installs.
- Coerced boolean tool-schema subschemas to MFJS object forms for native Moonshot/Kimi endpoints, preventing the task tool's `outputSchema` field from causing HTTP 400 responses ([#5952](https://github.com/can1357/oh-my-pi/issues/5952)).
## [17.0.3] - 2026-07-17
### Fixed
- Replaced the opaque `h2 is not supported` failure on the Cursor run transport with an actionable error naming the ALPN-stripping proxy as the cause and pointing at the `providers.cursor.baseUrl` HTTP/2 bridge workaround. The run RPC is HTTP/2-only, so behind a TLS-intercepting proxy that strips ALPN (e.g. Zscaler) bun cannot negotiate `h2` and the completion cannot proceed ([#5828](https://github.com/can1357/oh-my-pi/issues/5828)).
- Restored the `createAssistantMessageEventStream()` root export used by legacy provider extensions ([#5879](https://github.com/can1357/oh-my-pi/issues/5879)).
- Fixed parallel Responses tool-result images interleaving synthetic user messages before all pending outputs, preventing strict OpenRouter/Moonshot backends from rejecting follow-up requests. ([#5850](https://github.com/can1357/oh-my-pi/issues/5850))
- Fixed Kimi Code K3 requests to send native named efforts (`low`, `high`, `max`) and use adaptive effort rather than generic token budgets on explicit Anthropic transport overrides ([#5893](https://github.com/can1357/oh-my-pi/issues/5893)).
- Automatically invalidate and rotate OAuth credentials when an "invalidated oauth token" error occurs
- Fixed Anthropic usage reports treating the organization response header as the account identity, which caused the 5h/7d status-line segment to disappear for OAuth credentials without stored organization metadata. ([#5698](https://github.com/can1357/oh-my-pi/issues/5698))
## [17.0.2] - 2026-07-17
### Fixed
- Automatically invalidate and rotate OAuth credentials when an "invalidated oauth token" error occurs.
- Fixed auth-broker snapshot validation rejecting API keys stored via the `/login` flow, restoring support for gateway/broker setups serving login-sourced keys on custom hosts.
- Fixed an issue where literal reasoning tags (e.g., `<think>`) inside Markdown code blocks or inline code were incorrectly treated as reasoning boundaries, which corrupted the rendered Markdown.
- Classified HTTP 402 and "balance exhausted" quota responses as persistent usage limits, enabling automatic rotation of multi-account requests to a sibling credential.
- Fixed `kimi-code` Anthropic-format requests ignoring custom provider base URLs.
- Fixed an issue where GPT-5.6 Codex Responses-Lite requests failed with an HTTP 400 error due to invalid `tool_choice` parameters after tools were rewritten, by automatically downgrading forced hosted choices to `tool_choice: "auto"` while preserving explicit tool-use constraints.
- Fixed Cursor streams prematurely reporting success before late CONNECT or gRPC terminal failures were observed, and resolved issues rejecting transport ends without a `turnEnded` signal.
## [17.0.1] - 2026-07-16
### Fixed
- Fixed OpenRouter cost reporting to use the provider's authoritative account charge instead of catalog token-price estimates on both Responses and Chat Completions streams.
- Fixed OpenAI Responses and Chat Completions requests forwarding unsupported sampling parameters such as `temperature` to o-series and GPT-5+ models, preventing 400 errors for mnemopi memory calls through GitHub Copilot GPT-5.6 Luna. ([#5606](https://github.com/can1357/oh-my-pi/issues/5606))
- Fixed boolean JSON Schema subschemas (`true`/`false`) in MCP tool inputs triggering `400 INVALID_ARGUMENT` on the Google/Cloud Code Assist (Antigravity) transport by coercing them to their object equivalents (`true` → `{}`, `false` → `{ not: {} }`) before sending ([#5604](https://github.com/can1357/oh-my-pi/issues/5604)).
- Fixed thinking-enabled Claude requests routed to `google-vertex` sending the `effort-2025-11-24` beta as an `anthropic-beta` HTTP header, which Vertex rawPredict rejects with a 400. The effort beta and the `output_config.effort` field are now gated off the Vertex path the same way `context-management-2025-06-27` already is ([#5614](https://github.com/can1357/oh-my-pi/issues/5614)).
- Fixed custom and Foundry-routed Anthropic endpoints receiving first-party eager/legacy tool-streaming controls ([#5572](https://github.com/can1357/oh-my-pi/issues/5572)).
- Parsed Ollama NDJSON response bytes directly instead of decoding and buffering every network chunk as text. ([#5542](https://github.com/can1357/oh-my-pi/issues/5542))
- Fixed Amazon Bedrock stream error handling for non-`Error` values that `JSON.stringify` cannot serialize ([#5539](https://github.com/can1357/oh-my-pi/issues/5539)).
- Fixed concurrent provider OAuth refreshes by serializing rotating-token updates across processes, fencing stale writes, and preventing background usage probes from disabling otherwise usable credentials ([#5396](https://github.com/can1357/oh-my-pi/issues/5396)).
- Fixed OpenAI Codex WebSocket connections ignoring `PI_PROXY`, provider-specific proxy settings, and standard HTTPS/ALL proxy variables ([#5384](https://github.com/can1357/oh-my-pi/issues/5384)).
- Fixed Anthropic account quota exhaustion (`This request would exceed your account's monthly spend limit`) hanging until the local deadline instead of surfacing the error: the `rate_limit_error` "spend limit" wording is now classified as a persistent usage limit, so it fails fast and rotates to a sibling credential rather than looping in the provider retry backoff. ([#4787](https://github.com/can1357/oh-my-pi/issues/4787))
- Fixed OpenRouter daily free-model allowance errors (`free-models-per-day`) being treated as transient rate limits, so requests rotate from an exhausted API key to a healthy sibling credential. ([#4832](https://github.com/can1357/oh-my-pi/issues/4832))
## [17.0.0] - 2026-07-15
### Changed
+1 -1
View File
@@ -1,7 +1,7 @@
{
"type": "module",
"name": "@oh-my-pi/pi-ai",
"version": "17.0.0",
"version": "17.0.8",
"description": "Unified LLM API with automatic model discovery and provider configuration",
"homepage": "https://omp.sh",
"author": "Can Boluk",
@@ -55,6 +55,7 @@ export const apiKeyCredentialSchema = type({
"+": "reject",
type: "'api_key'",
key: type("string").atLeastLength(1),
"source?": "'login'",
});
/** Discriminated union accepted on POST /v1/credential (writes). */
+13 -7
View File
@@ -726,13 +726,19 @@ async function handleCredentialsCheck(storage: AuthStorage, signal: AbortSignal)
}
function handleModelsList(opts: AuthGatewayBootOptions): Response {
const list = opts.listModels ? Array.from(opts.listModels()) : [];
const data = list.map(model => ({
id: model.id,
object: "model" as const,
owned_by: model.provider,
api: model.api,
}));
const seen = new Set<string>();
const data: Array<{ id: string; object: "model"; owned_by: string; api: Api }> = [];
for (const model of opts.listModels?.() ?? []) {
const id = `${model.provider}/${model.id}`;
if (seen.has(id)) continue;
seen.add(id);
data.push({
id,
object: "model",
owned_by: model.provider,
api: model.api,
});
}
return json(200, { object: "list", data });
}
+2 -2
View File
@@ -1,6 +1,6 @@
import type { OAuthAccess } from "./auth-storage";
import * as AIError from "./error";
import { isAuthRetryableError } from "./error/auth-classify";
import { isAuthRetryableError, isInvalidatedOAuthTokenError } from "./error/auth-classify";
import { isUsageLimit } from "./error/flags";
import { isUsageLimitOutcome } from "./error/rate-limit";
@@ -90,7 +90,7 @@ export const AUTH_RETRY_STEPS: readonly boolean[] = [false, true];
export const AUTH_RETRY_MAX_ATTEMPTS = 64;
function isDirectCredentialRotationError(error: unknown): boolean {
if (isUsageLimit(error)) return true;
if (isUsageLimit(error) || isInvalidatedOAuthTokenError(error)) return true;
const status = AIError.status(error);
const message = error instanceof Error ? error.message : typeof error === "string" ? error : undefined;
return isUsageLimitOutcome(status, message);
+276 -134
View File
@@ -11,7 +11,7 @@ import { Database, type Statement } from "bun:sqlite";
import { createHash } from "node:crypto";
import * as fs from "node:fs/promises";
import * as path from "node:path";
import { getAgentDbPath, logger } from "@oh-my-pi/pi-utils";
import { $env, getAgentDbPath, logger } from "@oh-my-pi/pi-utils";
import type { ApiKeyResolver } from "./auth-retry";
import * as AIError from "./error";
import { isUsageLimitOutcome } from "./error/rate-limit";
@@ -57,6 +57,8 @@ import {
listCodexResetCredits,
} from "./usage/openai-codex-reset";
import { opencodeGoUsageProvider } from "./usage/opencode-go";
import { syntheticUsageProvider } from "./usage/synthetic";
import { xaiOauthUsageProvider } from "./usage/xai-oauth";
import { zaiRankingStrategy, zaiUsageProvider } from "./usage/zai";
const USAGE_RANKING_METRIC_EPSILON = 1e-9;
@@ -73,6 +75,15 @@ function fingerprintOAuthBearer(bearer: string): string {
return createHash("sha256").update(bearer).digest("base64url");
}
const SESSION_STICKY_CACHE_PREFIX = "session:sticky:";
/**
* Anthropic-only idle window after which a session's pinned credential no
* longer suppresses usage-based re-ranking. Anthropic caps OAuth prompt-cache
* retention at `ttl: "1h"` (ephemeral ~5min otherwise), so after this long
* without an Anthropic resolve the conversation-prefix cache is no longer
* guaranteed warm. Other providers retain indefinite stickiness until their
* own cache lifetimes are verified.
*/
const ANTHROPIC_SESSION_STICKY_CACHE_WARM_MS = 60 * 60_000;
// ─────────────────────────────────────────────────────────────────────────────
// Credential Types
@@ -177,7 +188,7 @@ export interface CredentialHealthResult {
email?: string;
/** OAuth account id if known. */
accountId?: string;
/** Organization/workspace the credential is scoped to (Anthropic multi-subscription). */
/** Organization/workspace the credential is scoped to (Anthropic/ChatGPT multi-subscription). */
orgId?: string;
orgName?: string;
/** `true` when the refresh token lives on a remote broker (sentinel was present). */
@@ -587,6 +598,8 @@ const DEFAULT_USAGE_PROVIDERS: UsageProvider[] = [
opencodeGoUsageProvider,
githubCopilotUsageProvider,
cursorUsageProvider,
syntheticUsageProvider,
xaiOauthUsageProvider,
];
const DEFAULT_USAGE_PROVIDER_MAP = new Map<Provider, UsageProvider>(
@@ -726,7 +739,7 @@ export interface OAuthAccess {
projectId?: string;
enterpriseUrl?: string;
apiEndpoint?: string;
/** Organization/workspace the credential is scoped to (Anthropic multi-subscription). */
/** Organization/workspace the credential is scoped to (Anthropic/ChatGPT multi-subscription). */
orgId?: string;
orgName?: string;
}
@@ -751,7 +764,7 @@ export interface OAuthAccessFailure {
projectId?: string;
enterpriseUrl?: string;
apiEndpoint?: string;
/** Organization/workspace the credential is scoped to (Anthropic multi-subscription). */
/** Organization/workspace the credential is scoped to (Anthropic/ChatGPT multi-subscription). */
orgId?: string;
orgName?: string;
error: string;
@@ -767,7 +780,7 @@ export interface OAuthAccountIdentity {
accountId?: string;
email?: string;
projectId?: string;
/** Organization/workspace the credential is scoped to (Anthropic multi-subscription). */
/** Organization/workspace the credential is scoped to (Anthropic/ChatGPT multi-subscription). */
orgId?: string;
orgName?: string;
}
@@ -786,7 +799,7 @@ export interface OAuthAccountSummary {
email?: string;
projectId?: string;
enterpriseUrl?: string;
/** Organization/workspace the credential is scoped to (Anthropic multi-subscription). */
/** Organization/workspace the credential is scoped to (Anthropic/ChatGPT multi-subscription). */
orgId?: string;
orgName?: string;
}
@@ -797,6 +810,8 @@ export interface InvalidateCredentialMatchingOptions {
/** Options for refreshing one stored OAuth row through durable ownership. */
export interface StoredOAuthRefreshOptions<T extends OAuthCredential = OAuthCredential> {
/** Stable row id when a provider has multiple OAuth credentials. */
credentialId?: number;
observedCredential?: T;
credentialFromRow: (credential: OAuthCredential) => T | undefined;
forceRefresh?: boolean;
@@ -1135,7 +1150,10 @@ export class AuthStorage {
/** Tracks next credential index per provider:type key for round-robin distribution (non-session use). */
#providerRoundRobinIndex: Map<string, number> = new Map();
/** Tracks the last used credential per provider for a session (used for rate-limit switching). */
#sessionLastCredential: Map<string, Map<string, { type: AuthCredential["type"]; index: number }>> = new Map();
#sessionLastCredential: Map<
string,
Map<string, { type: AuthCredential["type"]; index: number; lastUsedAtMs?: number }>
> = new Map();
/** Recent bearer fingerprints resolved for each durable OAuth row; used only for delayed usage-limit attribution. */
#oauthBearerFingerprints: Map<string, Map<number, string[]>> = new Map();
/** Maps provider:type -> credentialIndex -> blockedUntilMs for temporary backoff. */
@@ -1683,17 +1701,18 @@ export class AuthStorage {
index: number,
): void {
if (!sessionId) return;
const nowMs = Date.now();
const sessionMap = this.#sessionLastCredential.get(provider) ?? new Map();
sessionMap.set(sessionId, { type, index });
sessionMap.set(sessionId, { type, index, lastUsedAtMs: nowMs });
this.#sessionLastCredential.set(provider, sessionMap);
try {
const credentialId = this.#getStoredCredentials(provider)[index]?.id;
if (credentialId !== undefined) {
const cacheKey = `${SESSION_STICKY_CACHE_PREFIX}${provider}:${sessionId}`;
const cacheValue = JSON.stringify({ type, index, credentialId });
const cacheValue = JSON.stringify({ type, index, credentialId, lastUsedAtMs: nowMs });
// Expires in 30 days
const expiresAtSec = Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60;
const expiresAtSec = Math.floor(nowMs / 1000) + 30 * 24 * 60 * 60;
this.#store.setCache(cacheKey, cacheValue, expiresAtSec);
}
} catch (err) {
@@ -1705,7 +1724,7 @@ export class AuthStorage {
#getSessionCredential(
provider: string,
sessionId: string | undefined,
): { type: AuthCredential["type"]; index: number } | undefined {
): { type: AuthCredential["type"]; index: number; lastUsedAtMs?: number } | undefined {
if (!sessionId) return undefined;
let sessionMap = this.#sessionLastCredential.get(provider);
if (sessionMap?.has(sessionId)) {
@@ -1715,7 +1734,12 @@ export class AuthStorage {
const cacheKey = `${SESSION_STICKY_CACHE_PREFIX}${provider}:${sessionId}`;
const raw = this.#store.getCache(cacheKey);
if (raw) {
const val = JSON.parse(raw) as { type: AuthCredential["type"]; index: number; credentialId?: number };
const val = JSON.parse(raw) as {
type: AuthCredential["type"];
index: number;
credentialId?: number;
lastUsedAtMs?: number;
};
if (val.credentialId !== undefined) {
const stored = this.#getStoredCredentials(provider);
@@ -1735,7 +1759,7 @@ export class AuthStorage {
sessionMap = new Map();
this.#sessionLastCredential.set(provider, sessionMap);
}
const sessionVal = { type: val.type, index: val.index };
const sessionVal = { type: val.type, index: val.index, lastUsedAtMs: val.lastUsedAtMs };
sessionMap.set(sessionId, sessionVal);
return sessionVal;
}
@@ -2007,17 +2031,32 @@ export class AuthStorage {
}
/**
* Persist a refreshed credential addressed by id, not a positional index.
* A concurrent disable can reorder/shrink the provider's row array while an
* async refresh is in flight, so a pre-await index is unsafe; resolving the
* row by id at write time lands the rotated token on the correct row. Returns
* the row's current index, or -1 when it was disabled/removed mid-refresh.
* Persist a refreshed credential by id only while the row still matches this
* process's snapshot. A peer rotation wins the CAS and is reloaded instead of
* being overwritten after this process releases its refresh lease.
*
* Returns the row's current index, or -1 when it was disabled or removed.
*/
#replaceCredentialById(provider: string, id: number, credential: AuthCredential): number {
const entries = this.#getStoredCredentials(provider);
const index = entries.findIndex(entry => entry.id === id);
if (index === -1) return -1;
this.#store.updateAuthCredential(id, credential);
const expected = serializeCredential(provider, entries[index]!.credential);
if (
expected &&
this.#store.tryUpdateAuthCredentialIfMatches &&
!this.#store.tryUpdateAuthCredentialIfMatches(id, expected.data, credential)
) {
const latest = this.#store.listAuthCredentials(provider);
this.#setStoredCredentials(
provider,
latest.map(row => ({ id: row.id, credential: row.credential })),
);
return latest.findIndex(row => row.id === id);
}
if (!expected || !this.#store.tryUpdateAuthCredentialIfMatches) {
this.#store.updateAuthCredential(id, credential);
}
const updated = [...entries];
updated[index] = { id, credential };
this.#setStoredCredentials(provider, updated);
@@ -2150,7 +2189,11 @@ export class AuthStorage {
provider,
rows.map(row => ({ id: row.id, credential: row.credential })),
);
const row = rows.find(entry => entry.credential.type === "oauth");
const row = rows.find(
entry =>
entry.credential.type === "oauth" &&
(options.credentialId === undefined || entry.id === options.credentialId),
);
if (row?.credential.type !== "oauth") {
return { credential: undefined, refreshed: false, removed: false };
}
@@ -2189,7 +2232,11 @@ export class AuthStorage {
provider,
rows.map(row => ({ id: row.id, credential: row.credential })),
);
const row = rows.find(entry => entry.credential.type === "oauth");
const row = rows.find(
entry =>
entry.credential.type === "oauth" &&
(options.credentialId === undefined || entry.id === options.credentialId),
);
if (row?.credential.type !== "oauth") {
return { credential: undefined, refreshed: false, removed: false };
}
@@ -2266,7 +2313,7 @@ export class AuthStorage {
return { credential: undefined, refreshed: false, removed: true };
}
await this.reload();
const latest = this.get(provider);
const latest = this.#getStoredCredentials(provider).find(entry => entry.id === row.id)?.credential;
return {
credential: latest?.type === "oauth" ? options.credentialFromRow(latest) : undefined,
refreshed: false,
@@ -2316,7 +2363,7 @@ export class AuthStorage {
)
) {
await this.reload();
const latest = this.get(provider);
const latest = this.#getStoredCredentials(provider).find(entry => entry.id === row.id)?.credential;
return {
credential: latest?.type === "oauth" ? options.credentialFromRow(latest) : undefined,
refreshed: false,
@@ -2808,37 +2855,27 @@ export class AuthStorage {
return match?.id;
}
#persistRefreshedUsageCredential(provider: Provider, previous: UsageCredential, next: UsageCredential): void {
const entries = this.#getStoredCredentials(provider);
// Same sentinel rule as #findStoredCredentialIdForUsageCredential above.
const previousRefresh =
previous.refreshToken && previous.refreshToken !== REMOTE_REFRESH_SENTINEL ? previous.refreshToken : undefined;
const index = entries.findIndex(entry => {
if (entry.credential.type !== "oauth") return false;
if (previousRefresh && entry.credential.refresh === previousRefresh) return true;
if (previous.accessToken && entry.credential.access === previous.accessToken) return true;
return (
entry.credential.accountId === previous.accountId &&
entry.credential.email === previous.email &&
entry.credential.projectId === previous.projectId &&
entry.credential.orgId === previous.orgId
);
});
if (index === -1) return;
const existing = entries[index]!.credential;
if (existing.type !== "oauth") return;
this.#replaceCredentialAt(provider, index, {
#persistRefreshedUsageCredential(
provider: Provider,
previous: UsageCredential,
next: UsageCredential,
credentialId = this.#findStoredCredentialIdForUsageCredential(provider, previous),
): void {
if (credentialId === undefined) return;
const entry = this.#getStoredCredentials(provider).find(candidate => candidate.id === credentialId);
if (entry?.credential.type !== "oauth") return;
this.#replaceCredentialById(provider, credentialId, {
type: "oauth",
access: next.accessToken ?? existing.access,
refresh: next.refreshToken ?? existing.refresh,
expires: next.expiresAt ?? existing.expires,
access: next.accessToken ?? entry.credential.access,
refresh: next.refreshToken ?? entry.credential.refresh,
expires: next.expiresAt ?? entry.credential.expires,
accountId: next.accountId,
projectId: next.projectId,
email: next.email,
enterpriseUrl: next.enterpriseUrl,
apiEndpoint: next.apiEndpoint,
orgId: next.orgId ?? existing.orgId,
orgName: next.orgName ?? existing.orgName,
orgId: next.orgId ?? entry.credential.orgId,
orgName: next.orgName ?? entry.credential.orgName,
});
}
@@ -2878,7 +2915,12 @@ export class AuthStorage {
timeoutSignal,
);
const refreshedCredential = this.#mergeRefreshedUsageCredential(request.credential, refreshed);
this.#persistRefreshedUsageCredential(request.provider, request.credential, refreshedCredential);
this.#persistRefreshedUsageCredential(
request.provider,
request.credential,
refreshedCredential,
refreshableCredentialId,
);
params = {
...request,
credential: refreshedCredential,
@@ -2887,46 +2929,16 @@ export class AuthStorage {
};
} catch (error) {
const errorMsg = String(error);
// Definitive failure (invalid_grant / 401 not from a network blip) means
// the refresh token itself is dead — probing with the original credential
// will 401, the catch below will return null, and #fetchUsageCached's
// last-good fallback will surface yesterday's report indefinitely
// (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 (AIError.isDefinitiveOAuthFailure(errorMsg)) {
const credentialId = this.#findStoredCredentialIdForUsageCredential(
request.provider,
request.credential,
);
if (credentialId !== undefined) {
const entries = this.#getStoredCredentials(request.provider);
const index = entries.findIndex(entry => entry.id === credentialId);
if (index !== -1) {
const disabled = this.#tryDisableCredentialAtIfMatches(
request.provider,
index,
refreshableCredential,
`oauth refresh failed during usage probe: ${errorMsg}`,
);
if (disabled) {
this.#usageLogger?.warn(
"Usage credential refresh failed definitively; credential disabled",
{ provider: request.provider, credentialId, error: errorMsg },
);
// Neutralize last-good for this cache key: write a null
// entry with an immediately-elapsed expiry so a future
// getStale lookup (e.g. on re-login under the same
// account identity) can't replay the stale report.
this.#usageCache.set(this.#buildUsageReportCacheKey(request), {
value: null,
expiresAt: 0,
});
return null;
}
}
}
if (request.credential.expiresAt <= Date.now() && AIError.isDefinitiveOAuthFailure(errorMsg)) {
// The current access token is unusable, so don't replay an
// old usage report after its rotating refresh token is revoked.
// This changes cache state only; usage polling remains
// non-authoritative about the credential lifecycle.
this.#usageCache.set(this.#buildUsageReportCacheKey(request), { value: null, expiresAt: 0 });
}
// Usage polling is advisory. A refresh can fail while the current
// access token remains valid inside the refresh skew, so probe with
// that token and never mutate credential state from this path.
this.#usageLogger?.debug("Usage credential refresh failed, using original credential", {
provider: request.provider,
error: errorMsg,
@@ -3219,6 +3231,29 @@ export class AuthStorage {
entries = dedupedEntries;
}
// SuperGrok billing only accepts OAuth bearers. Catalog envVars for
// xai-oauth are [XAI_OAUTH_TOKEN, XAI_API_KEY], so the generic path
// would (a) build api_key usage requests from stored keys / the paid
// API env var and (b) never fall through to XAI_OAUTH_TOKEN when a
// non-OAuth row is the only stored credential. Skip api_key material
// and only env-fallback to the dedicated OAuth bearer.
if (providerId === "xai-oauth") {
let hasUsableStoredOAuthCredential = false;
for (const entry of entries) {
if (entry.credential.type !== "oauth") continue;
const request = this.#buildUsageRequestForOauth(provider, entry.credential, baseUrl);
if (providerImpl.supports && !providerImpl.supports(request)) continue;
requests.push(request);
hasUsableStoredOAuthCredential = true;
}
const oauthToken = $env.XAI_OAUTH_TOKEN?.trim();
if (!hasUsableStoredOAuthCredential && oauthToken) {
const request = this.#buildUsageRequest(provider, { type: "oauth", accessToken: oauthToken }, baseUrl);
if (!providerImpl.supports || providerImpl.supports(request)) requests.push(request);
}
continue;
}
if (entries.length === 0) {
const runtimeKey = this.#runtimeOverrides.get(providerId);
const envKey = getEnvApiKey(providerId);
@@ -3275,15 +3310,14 @@ export class AuthStorage {
const identifiers: string[] = [];
const email = this.#getUsageReportMetadataValue(report, "email");
if (email) identifiers.push(`email:${email.toLowerCase()}`);
if (report.provider === "anthropic") {
// Anthropic: one account email can hold several organizations
// (Team seat + personal Max). Reports from different orgs must not
// merge — scope every identifier by org when the report carries one.
// When the email could not be recovered, fall back to the account
// (identical across orgs, hence the org qualifier is what keeps two
// subscriptions apart) so no-email reports still merge per org.
// Org-less reports (pre-upgrade caches) keep their bare identifiers
// and only merge among themselves.
if (report.provider === "anthropic" || report.provider === "openai-codex") {
// One account email can hold several org-scoped subscriptions
// (Anthropic organizations, ChatGPT workspaces). Reports from
// different orgs must not merge — scope every identifier by org
// when the report carries one; fall back to the account when the
// email could not be recovered so no-email reports still merge
// per org. Org-less reports (pre-upgrade caches) keep their bare
// identifiers and only merge among themselves.
if (identifiers.length === 0) {
const accountId =
this.#getUsageReportMetadataValue(report, "accountId") ?? this.#getUsageReportScopeAccountId(report);
@@ -3291,12 +3325,11 @@ export class AuthStorage {
}
const orgId = this.#getUsageReportMetadataValue(report, "orgId");
if (orgId) {
if (identifiers.length === 0) return [`anthropic:org:${orgId.toLowerCase()}`];
return identifiers.map(identifier => `anthropic:org:${orgId.toLowerCase()}|${identifier.toLowerCase()}`);
if (identifiers.length === 0) return [`${report.provider}:org:${orgId.toLowerCase()}`];
return identifiers.map(
identifier => `${report.provider}:org:${orgId.toLowerCase()}|${identifier.toLowerCase()}`,
);
}
return identifiers.map(identifier => `anthropic:${identifier.toLowerCase()}`);
}
if (report.provider === "openai-codex") {
return identifiers.map(identifier => `${report.provider}:${identifier.toLowerCase()}`);
}
const projectId =
@@ -3667,6 +3700,7 @@ export class AuthStorage {
row.provider as Provider,
initialRequest.credential,
refreshedCredential,
row.id,
);
params = {
...params,
@@ -3895,16 +3929,15 @@ export class AuthStorage {
* how fast the window's remaining quota must be consumed to fully use it
* before it resets and expires. Higher = more headroom at risk of expiring
* unused = ranked first, so selection chases quota that is about to be
* wasted ("use it or lose it"). Without a reset clock the headroom
* fraction alone is returned, degrading to most-headroom-first.
* wasted ("use it or lose it"). Without a reset clock, the full window
* duration is assumed to remain so clocked and clockless scores stay comparable.
*/
#computeWindowRequiredDrain(limit: UsageLimit | undefined, nowMs: number, fallbackDurationMs: number): number {
const headroom = 1 - this.#normalizeUsageFraction(limit);
if (headroom <= 0) return 0;
const resetAt = this.#resolveWindowResetAt(limit?.window);
if (resetAt === undefined) return headroom;
const durationMs = limit?.window?.durationMs ?? fallbackDurationMs;
let remainingMs = resetAt - nowMs;
let remainingMs = resetAt === undefined ? durationMs : resetAt - nowMs;
if (Number.isFinite(durationMs) && durationMs > 0) {
remainingMs = Math.min(remainingMs, durationMs);
}
@@ -4144,16 +4177,39 @@ export class AuthStorage {
sessionPreferredCredential !== undefined &&
(sessionPreferredCredential.refresh.trim().length > 0 ||
Date.now() + OAUTH_REFRESH_SKEW_MS < sessionPreferredCredential.expires);
// Skip ranking only when the session already has a working preferred credential — re-ranking
// mid-session causes account switches that cold-start the server-side prompt cache. New sessions
// (no preference) and sessions whose preferred is blocked still rank, so we pick the account
// with the most headroom proactively and fall back intelligently when rate-limited.
// Skip ranking when the session already has a working preferred credential and its prompt
// cache may still be warm. Only Anthropic has a verified idle boundary here; unverified
// providers retain indefinite stickiness rather than risk switching while their prompt cache
// remains warm. New Anthropic sessions (no preference), sessions whose preferred is blocked,
// and sessions idle past {@link ANTHROPIC_SESSION_STICKY_CACHE_WARM_MS} still rank. Legacy
// pins predating `lastUsedAtMs` count as warm until the next resolve rewrites the row.
const sessionPreferredLastUsedAtMs =
sessionCredential?.type === "oauth" ? sessionCredential.lastUsedAtMs : undefined;
const sessionPreferredIsWarm =
provider !== "anthropic" ||
sessionPreferredLastUsedAtMs === undefined ||
Date.now() - sessionPreferredLastUsedAtMs < ANTHROPIC_SESSION_STICKY_CACHE_WARM_MS;
const sessionPreferredIsAvailable =
sessionPreferredIndex !== undefined &&
sessionPreferredCanRefreshOrUse &&
!this.#isCredentialBlocked(provider, providerKey, sessionPreferredIndex, blockScope);
const shouldRank = checkUsage && (!sessionPreferredIsAvailable || hasPlanRequirement);
const rankingOrder = shouldRank && sessionId ? credentials.map((_credential, index) => index) : order;
const shouldRank = checkUsage && (!sessionPreferredIsAvailable || !sessionPreferredIsWarm || hasPlanRequirement);
// When ranking, seed the pinned credential first in the evaluation order so it wins genuine
// ties (the ranked comparator falls back to `orderPos`) without overriding a strictly-better
// sibling — this respects the residual value of a same-account shared static prefix that other
// workspace traffic may have kept warm, while still rotating away from a clearly-worse account.
const baseRankingOrder = credentials.map((_credential, index) => index);
let rankingOrder = shouldRank && sessionId ? baseRankingOrder : order;
const sessionPreferredRankingPos =
shouldRank && sessionId && sessionPreferredIndex !== undefined && !hasPlanRequirement
? credentials.findIndex(entry => entry.index === sessionPreferredIndex)
: -1;
if (sessionPreferredRankingPos > 0) {
rankingOrder = [
sessionPreferredRankingPos,
...baseRankingOrder.filter(index => index !== sessionPreferredRankingPos),
];
}
const candidates = shouldRank
? await this.#rankOAuthSelections({
providerKey,
@@ -4171,7 +4227,10 @@ export class AuthStorage {
.filter((selection): selection is { credential: OAuthCredential; index: number } => Boolean(selection))
.map(selection => ({ selection, usage: null, usageChecked: false }));
if (sessionPreferredIndex !== undefined && !hasPlanRequirement) {
// On the warm skip path the candidate list follows the round-robin `order`, not the pin, so
// hoist the pinned credential to the front to actually reuse it. When ranking ran, the pin is
// already a mere tie-break via `rankingOrder`; do not override the ranked result here.
if (!shouldRank && sessionPreferredIndex !== undefined && !hasPlanRequirement) {
const sessionPreferredCandidate = candidates.findIndex(
candidate =>
!this.#isCredentialBlocked(provider, providerKey, candidate.selection.index, blockScope) &&
@@ -4258,6 +4317,29 @@ export class AuthStorage {
hasPlanRequirement &&
candidates.some(candidate => getOpenAICodexPlanEligibility(candidate.usage, planRequirement) === true);
// Plan-gated Codex models rank on every resolve to re-verify account tiers,
// so the drain-urgency order can flip between two eligible accounts as their
// usage headroom shifts. Promote the session-preferred credential back to the
// front while it is unblocked and still plan-eligible (or the requirement is
// unenforced and the pin is not known-ineligible) so an active session never
// silently migrates accounts mid-conversation; blocked, exhausted, or
// known-ineligible pins still fall through to the ranked sibling.
if (hasPlanRequirement && sessionPreferredIndex !== undefined) {
const sessionPreferredCandidate = candidates.findIndex(
candidate =>
!this.#isCredentialBlocked(provider, providerKey, candidate.selection.index, blockScope) &&
candidate.selection.index === sessionPreferredIndex,
);
if (sessionPreferredCandidate > 0) {
const preferred = candidates[sessionPreferredCandidate]!;
const planEligibility = getOpenAICodexPlanEligibility(preferred.usage, planRequirement);
if (planEligibility === true || (!enforcePlanRequirement && planEligibility !== false)) {
candidates.splice(sessionPreferredCandidate, 1);
candidates.unshift(preferred);
}
}
}
const passes: Array<{ allowBlocked: boolean; enforcePlanRequirement: boolean }> = [
{ allowBlocked: false, enforcePlanRequirement },
{ allowBlocked: true, enforcePlanRequirement },
@@ -4317,6 +4399,42 @@ export class AuthStorage {
credential: OAuthCredential,
credentialId: number | undefined,
signal?: AbortSignal,
): Promise<OAuthCredentials> {
const hasDurableLease =
!!this.#store.tryAcquireCredentialRefreshLease &&
!!this.#store.getCredentialRefreshLeaseExpiresAt &&
!!this.#store.releaseCredentialRefreshLease &&
!!this.#store.renewCredentialRefreshLease;
if (credentialId !== undefined && hasDurableLease) {
const forceRefresh = credential.expires === 0;
const result = await this.refreshStoredOAuthCredential(provider, {
credentialId,
observedCredential: forceRefresh ? undefined : credential,
credentialFromRow: row => row,
forceRefresh,
signal,
refresh: (current, refreshSignal) =>
this.#requestOAuthCredentialRefresh(
provider,
current,
credentialId,
signal && refreshSignal ? AbortSignal.any([signal, refreshSignal]) : (signal ?? refreshSignal),
),
});
if (result.credential) return result.credential;
throw new AIError.OAuthError(`OAuth credential no longer exists for provider: ${provider}`, {
kind: "token-refresh",
provider,
});
}
return this.#requestOAuthCredentialRefresh(provider, credential, credentialId, signal);
}
async #requestOAuthCredentialRefresh(
provider: Provider,
credential: OAuthCredential,
credentialId: number | undefined,
signal?: AbortSignal,
): Promise<OAuthCredentials> {
let refreshPromise: Promise<OAuthCredentials>;
// Caller override > store-level hook > local per-provider refresh.
@@ -4343,10 +4461,9 @@ export class AuthStorage {
// Bound the refresh so a slow/hanging token endpoint cannot stall credential selection.
// Caller-driven abort jumps the gun on the timeout — the agent's ESC must
// take priority over the floor timeout.
let timeout: NodeJS.Timeout | undefined;
let onAbort: (() => void) | undefined;
const cancellation = Promise.withResolvers<never>();
timeout = setTimeout(
let onAbort: (() => void) | undefined;
const timeout = setTimeout(
() =>
cancellation.reject(
new AIError.OAuthError(`OAuth token refresh timed out for provider: ${provider}`, {
@@ -4367,7 +4484,7 @@ export class AuthStorage {
try {
return await Promise.race([refreshPromise, cancellation.promise]);
} finally {
if (timeout) clearTimeout(timeout);
clearTimeout(timeout);
if (signal && onAbort) signal.removeEventListener("abort", onAbort);
}
}
@@ -5202,9 +5319,15 @@ export class AuthStorage {
if (credential.type !== "oauth") continue;
const credentialEmail = credential.email?.trim().toLowerCase();
const credentialAccountId = credential.accountId?.trim().toLowerCase();
if ((email && credentialEmail === email) || (accountId && credentialAccountId === accountId)) {
matches.push(entry.id);
}
// Every identity dimension present on BOTH sides must agree — the
// account id is shared workspace-wide and one email can span
// workspaces, so a single-dimension match can cross-link siblings.
const emailComparable = Boolean(email && credentialEmail);
const accountComparable = Boolean(accountId && credentialAccountId);
if (!emailComparable && !accountComparable) continue;
if (emailComparable && credentialEmail !== email) continue;
if (accountComparable && credentialAccountId !== accountId) continue;
matches.push(entry.id);
}
return matches;
}
@@ -5359,6 +5482,21 @@ export class AuthStorage {
Date.now() + AuthStorage.#defaultBackoffMs,
);
if (target && AIError.isInvalidatedOAuthTokenError(error)) {
const disabledCause = message ?? "upstream reported invalidated OAuth token";
const deleted = this.#store.deleteAuthCredentialRemote
? await this.#store.deleteAuthCredentialRemote(target.id, disabledCause)
: this.disableCredentialById(target.id, disabledCause);
if (deleted) {
const latestRows = this.#store.listAuthCredentials(provider);
this.#setStoredCredentials(
provider,
latestRows.map(row => ({ id: row.id, credential: row.credential })),
);
}
return deleted && hasSibling;
}
if (target) {
const markSuspect = this.#store.markCredentialSuspect?.bind(this.#store);
if (markSuspect) {
@@ -5799,16 +5937,15 @@ function toStoredAuthCredential(row: AuthRow, credential: AuthCredential): Store
function resolveProviderCredentialIdentityKey(provider: string, identifiers: string[]): string | null {
const emailIdentifier = identifiers.find(identifier => identifier.startsWith("email:"));
if (provider === "anthropic") {
// One Anthropic account email can hold several organizations (e.g. a
// Team seat plus a personal Max plan), each with its own org-scoped
// token and limit pools. Scope identity by org so both subscriptions
// can be stored side by side. The qualifier rides on whichever base
// identity is available — the account UUID is IDENTICAL across the
// orgs of one login account, so an unqualified account/project
// fallback would still collapse two subscriptions whenever the email
// could not be recovered. Org-less credentials (rows written before
// org capture existed) keep their bare key.
if (provider === "anthropic" || provider === "openai-codex") {
// One account email can hold several organizations/workspaces (e.g. a
// Team seat plus a personal plan), each with its own org-scoped token
// and limit pools. Scope identity by org so both subscriptions can be
// stored side by side. The qualifier rides on whichever base identity
// is available, so an unqualified account/project fallback would
// still collapse two subscriptions whenever the email could not be
// recovered. Org-less credentials (rows written before org capture
// existed) keep their bare key.
const base =
emailIdentifier ??
identifiers.find(identifier => identifier.startsWith("account:")) ??
@@ -5818,7 +5955,6 @@ function resolveProviderCredentialIdentityKey(provider: string, identifiers: str
// No base identity at all: the org alone still distinguishes the row.
return orgIdentifier ?? null;
}
if (provider === "openai-codex" && emailIdentifier) return emailIdentifier;
const accountIdentifier = identifiers.find(identifier => identifier.startsWith("account:"));
if (accountIdentifier) return accountIdentifier;
if (emailIdentifier) return emailIdentifier;
@@ -5855,9 +5991,9 @@ function matchesReplacementCredential(
if (incomingIdentityKey === existingIdentityKey) return true;
if (existingIdentityKey === null) return false;
// One-way upgrade, applied only when the INCOMING identity key carries the
// org qualifier (only anthropic keys do, so other providers never reach the
// checks below). An org-scoped login `org:<o>` claims (and re-keys) any
// existing row that denotes the same subscription:
// org qualifier (only anthropic and openai-codex keys do, so other
// providers never reach the checks below). An org-scoped login `org:<o>`
// claims (and re-keys) any existing row that denotes the same subscription:
// - `org:<o>` — org-only row stored when identity recovery failed, claimed
// once a later same-org login recovers a base identity;
// - `<b>` for any base identity `<b>` (email/account/project) the incoming
@@ -5881,10 +6017,16 @@ function matchesReplacementCredential(
existing.type === "oauth" && existingIdentityKey.endsWith(`|${orgIdentifier}`)
? extractOAuthCredentialIdentifiers(existing)
: null;
// A base identifier that merely repeats the org qualifier's id carries no
// per-user identity (openai-codex stores the ChatGPT workspace id as both
// accountId and orgId, shared by every member) — letting it act as a
// claimable base would re-key another member's same-org row.
const orgQualifierId = orgIdentifier.slice("org:".length);
for (const identifier of incomingIdentifiers) {
const isBase =
identifier.startsWith("email:") || identifier.startsWith("account:") || identifier.startsWith("project:");
if (!isBase) continue;
if (identifier.slice(identifier.indexOf(":") + 1) === orgQualifierId) continue;
if (existingIdentityKey === identifier) return true;
if (existingIdentityKey === `${identifier}|${orgIdentifier}`) return true;
if (existingIdentifiers?.includes(identifier)) return true;
+11
View File
@@ -109,6 +109,9 @@ export function wrapInbandToolStream(
case "thinking_end":
projector?.thinkingEnd();
break;
case "image_end":
projector?.keep(event.content);
break;
case "text_delta":
// `text()` returns true once the model starts fabricating its own
// tool result. In abort mode we cut the turn immediately so the
@@ -206,6 +209,14 @@ class InbandStreamProjector {
this.#closeText();
this.#closeThinking();
this.#partial.content.push(block);
if (this.#emitEvents && block.type === "image") {
this.#out.push({
type: "image_end",
contentIndex: this.#partial.content.length - 1,
content: block,
partial: this.#partial,
});
}
}
// Forward a native tool call's lifecycle live. `source` comes from the inner
+187 -17
View File
@@ -32,6 +32,16 @@ export class ThinkingInbandScanner implements InbandScanner {
#thinking = "";
/** Fence-aware close-matcher while inside a ` ```thinking ` block; undefined otherwise. */
#fenced: FencedThinkingScanner | undefined;
/** Backtick count that opened the Markdown code span/fence we are inside; 0 when not in code. */
#codeTicks = 0;
/** True when {@link #codeTicks} opened a fenced block (closes on a fence line), not an inline span. */
#codeFenced = false;
/**
* Leading-space count on the current output line, or -1 once a non-space
* character has appeared. Starts at 0 (line start) so a fence opening the
* stream — or one indented ≤3 spaces, as CommonMark allows — is recognized.
*/
#lineIndent = 0;
feed(text: string): InbandScanEvent[] {
if (text.length === 0) return [];
@@ -86,25 +96,93 @@ export class ThinkingInbandScanner implements InbandScanner {
this.#closeTag = "";
continue;
}
const tag = findEarliestOpen(this.#buffer);
if (!tag) {
const hold = final ? 0 : partialSuffixOverlapAny(this.#buffer, OPENS);
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);
if (this.#codeTicks > 0) {
if (this.#emitCode(final, events)) continue;
break;
}
if (tag.index > 0) events.push({ type: "text", text: this.#buffer.slice(0, tag.index) });
this.#buffer = this.#buffer.slice(tag.index + tag.open.length);
this.#closeTag = tag.close;
const hit = scanVisible(this.#buffer, final);
if (hit.kind === "none") {
this.#emitText(this.#buffer, events);
this.#buffer = "";
break;
}
if (hit.index > 0) this.#emitText(this.#buffer.slice(0, hit.index), events);
if (hit.kind === "hold") {
this.#buffer = this.#buffer.slice(hit.index);
break;
}
if (hit.kind === "code") {
const fenced = hit.ticks >= 3 && this.#lineIndent >= 0 && this.#lineIndent <= 3;
this.#emitText(this.#buffer.slice(hit.index, hit.index + hit.ticks), events);
this.#buffer = this.#buffer.slice(hit.index + hit.ticks);
this.#codeTicks = hit.ticks;
this.#codeFenced = fenced;
continue;
}
this.#buffer = this.#buffer.slice(hit.index + hit.tag.open.length);
this.#closeTag = hit.tag.close;
this.#thinking = "";
if (tag.fenced) this.#fenced = new FencedThinkingScanner();
if (hit.tag.fenced) this.#fenced = new FencedThinkingScanner();
events.push({ type: "thinkingStart" });
}
return events;
}
/**
* Emit buffered content while inside a Markdown code region, suppressing
* reasoning-tag detection. A fenced block closes only on a fence line (a line
* of backticks ≥ the opener); an inline span closes on the first backtick run
* of exactly the opener length. Returns true when the region closed and the
* loop should continue, false when it held back and should break.
*/
#emitCode(final: boolean, events: InbandScanEvent[]): boolean {
if (this.#codeFenced) {
const end = findFenceCloseEnd(this.#buffer, this.#codeTicks, final);
if (end !== -1) {
this.#emitText(this.#buffer.slice(0, end), events);
this.#buffer = this.#buffer.slice(end);
this.#codeTicks = 0;
this.#codeFenced = false;
return true;
}
if (final) {
this.#emitText(this.#buffer, events);
this.#buffer = "";
this.#codeTicks = 0;
this.#codeFenced = false;
return false;
}
// Stream committed lines; hold only the last (possibly partial) fence line.
const lastNl = this.#buffer.lastIndexOf("\n");
if (lastNl !== -1) {
this.#emitText(this.#buffer.slice(0, lastNl + 1), events);
this.#buffer = this.#buffer.slice(lastNl + 1);
}
return false;
}
const close = findBacktickRun(this.#buffer, 0, this.#codeTicks);
if (close !== -1 && (final || close + this.#codeTicks < this.#buffer.length)) {
this.#emitText(this.#buffer.slice(0, close + this.#codeTicks), events);
this.#buffer = this.#buffer.slice(close + this.#codeTicks);
this.#codeTicks = 0;
return true;
}
// No committed close yet: emit text, holding a trailing backtick run that
// may still grow into — or past — the closing delimiter.
const hold = final ? 0 : trailingBacktickRun(this.#buffer);
this.#emitText(this.#buffer.slice(0, this.#buffer.length - hold), events);
this.#buffer = this.#buffer.slice(this.#buffer.length - hold);
if (final) this.#codeTicks = 0;
return false;
}
#emitText(text: string, events: InbandScanEvent[]): void {
if (text.length === 0) return;
events.push({ type: "text", text });
this.#lineIndent = trailingLineIndent(text, this.#lineIndent);
}
#emitThinking(delta: string, events: InbandScanEvent[]): void {
if (delta.length === 0) return;
this.#thinking += delta;
@@ -112,11 +190,103 @@ export class ThinkingInbandScanner implements InbandScanner {
}
}
function findEarliestOpen(buffer: string): (Tag & { index: number }) | undefined {
let best: (Tag & { index: number }) | undefined;
for (const tag of TAGS) {
const index = buffer.indexOf(tag.open);
if (index !== -1 && (!best || index < best.index)) best = { ...tag, index };
/** Outcome of scanning idle visible text for the next reasoning-tag or code-span boundary. */
type VisibleHit =
| { readonly kind: "tag"; readonly index: number; readonly tag: Tag }
| { readonly kind: "code"; readonly index: number; readonly ticks: number }
| { readonly kind: "hold"; readonly index: number }
| { readonly kind: "none" };
/**
* Walk idle visible text for the earliest boundary: a leaked reasoning-tag open,
* a Markdown code-span/fence opener (a backtick run), or — when more chunks may
* follow — a held partial delimiter at the buffer tail.
*
* Reasoning tags win at any position so the gemini ` ```thinking ` fence is
* healed instead of being read as a code fence. Backtick runs enter code mode so
* a literal `<think>` inside inline code or a fenced block stays visible text.
*/
function scanVisible(buffer: string, final: boolean): VisibleHit {
for (let i = 0; i < buffer.length; i++) {
const tag = TAGS.find(candidate => buffer.startsWith(candidate.open, i));
if (tag) return { kind: "tag", index: i, tag };
if (!final) {
const rest = buffer.slice(i);
if (OPENS.some(open => open.length > rest.length && open.startsWith(rest))) {
return { kind: "hold", index: i };
}
}
if (buffer[i] === "`") {
const ticks = backtickRun(buffer, i);
if (!final && i + ticks === buffer.length) return { kind: "hold", index: i };
return { kind: "code", index: i, ticks };
}
}
return best;
return { kind: "none" };
}
/** Length of the maximal backtick run beginning at `from`. */
function backtickRun(buffer: string, from: number): number {
let end = from;
while (end < buffer.length && buffer[end] === "`") end++;
return end - from;
}
/** Index of the first maximal backtick run of exactly `ticks` at/after `from`, else -1. */
function findBacktickRun(buffer: string, from: number, ticks: number): number {
for (let i = buffer.indexOf("`", from); i !== -1; i = buffer.indexOf("`", i)) {
const run = backtickRun(buffer, i);
if (run === ticks) return i;
i += run;
}
return -1;
}
/** Length of a backtick run that ends at the buffer tail; 0 when the tail is not a backtick. */
function trailingBacktickRun(buffer: string): number {
let start = buffer.length;
while (start > 0 && buffer[start - 1] === "`") start--;
return buffer.length - start;
}
/**
* Leading-space count of the line at the tail of `text`, continuing from the
* prior line's `indent` state (see {@link ThinkingInbandScanner.#lineIndent}).
* Returns -1 once any non-space character has appeared on the current line.
*/
function trailingLineIndent(text: string, prior: number): number {
const lastNl = text.lastIndexOf("\n");
let indent = lastNl === -1 ? prior : 0;
for (let i = lastNl + 1; i < text.length; i++) {
if (indent === -1) break;
indent = text[i] === " " ? indent + 1 : -1;
}
return indent;
}
/**
* Index just past the first closing fence line for a fenced block opened with
* `ticks` backticks, or -1 when none is committed yet. A closing fence is a whole
* line whose trimmed content is only backticks, at least `ticks` of them. A line
* without a terminating newline is committed only when `final` (no more input can
* extend it into a non-fence line).
*/
function findFenceCloseEnd(buffer: string, ticks: number, final: boolean): number {
for (let start = 0; start <= buffer.length; ) {
const nl = buffer.indexOf("\n", start);
const terminated = nl !== -1;
const line = buffer.slice(start, terminated ? nl : buffer.length).trim();
if (line.length >= ticks && isAllBackticks(line) && (terminated || final)) {
return terminated ? nl + 1 : buffer.length;
}
if (!terminated) break;
start = nl + 1;
}
return -1;
}
/** True when `text` is non-empty and every character is a backtick. */
function isAllBackticks(text: string): boolean {
for (let i = 0; i < text.length; i++) if (text[i] !== "`") return false;
return text.length > 0;
}
+13
View File
@@ -12,6 +12,18 @@ export function isDefinitiveOAuthFailure(errorMsg: string): boolean {
return isOAuthExpiry(errorMsg);
}
const INVALIDATED_OAUTH_TOKEN_PATTERN = /\binvalidated oauth token\b/i;
/** Whether an upstream response explicitly says the supplied OAuth bearer was invalidated. */
export function isInvalidatedOAuthTokenError(error: unknown): boolean {
if (typeof error === "object" && error !== null && "errorMessage" in error) {
const errorMessage = error.errorMessage;
if (typeof errorMessage === "string" && INVALIDATED_OAUTH_TOKEN_PATTERN.test(errorMessage)) return true;
}
const message = error instanceof Error ? error.message : typeof error === "string" ? error : undefined;
return message !== undefined && INVALIDATED_OAUTH_TOKEN_PATTERN.test(message);
}
/**
* Whether an upstream failure should rotate to a sibling credential: a hard
* `401`, a body-classified usage limit (Codex `usage_limit_reached`, Anthropic
@@ -22,6 +34,7 @@ export function isDefinitiveOAuthFailure(errorMsg: string): boolean {
*/
export function isAuthRetryableError(error: unknown): boolean {
if (isUsageLimit(error)) return true;
if (isInvalidatedOAuthTokenError(error)) return true;
const httpStatus = extractHttpStatusFromError(error);
if (httpStatus === 401) return true;
const message = error instanceof Error ? error.message : typeof error === "string" ? error : undefined;
+13 -4
View File
@@ -7,7 +7,7 @@ import {
ProviderHttpError,
STREAM_ENVELOPE_ERROR_PREFIX,
} from "./classes";
import { isOpaqueStatusBody, matchesUsageLimitText, parseRateLimitReason } from "./rate-limit";
import { isOpaqueStatusBody, isUsageLimitStatus, matchesUsageLimitText, parseRateLimitReason } from "./rate-limit";
export const Flag = {
Class: 0x1000,
@@ -90,7 +90,7 @@ const TRANSIENT_ENVELOPE_PATTERN = /anthropic stream envelope error:/i;
const TRANSIENT_ENVELOPE_BEFORE_START_PATTERN = /before message_start/i;
export const STREAM_READ_ERROR_PATTERN = /stream[_ -]?read[_ -]?error/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;
/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|unable.?to.?connect\.\s*is the computer able to access the url\?|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;
@@ -318,7 +318,7 @@ function classifyText(errorMessage: string | undefined, errorStatus: number | un
const cleanMessage = errorMessage;
const isOpaque = isOpaqueStatusBody(cleanMessage);
const isLimitStatus = statusClean === 429;
const isLimitStatus = isUsageLimitStatus(statusClean);
if (
matchesUsageLimitText(cleanMessage) ||
(isLimitStatus && (isOpaque || parseRateLimitReason(cleanMessage) === "QUOTA_EXHAUSTED"))
@@ -505,10 +505,19 @@ export function stringify(id: number | undefined): string {
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_PARSE_DIAGNOSTIC_PATTERN =
/(?:json parse error:\s*(?:unterminated string|unexpected end of json input|unexpected end of data|unexpected eof|end of file|eof while parsing|truncated)|json\.parse:\s*(?:unterminated string|unexpected end of data)|unexpected end of json input|unexpected eof|eof while parsing)/i;
const STREAM_EVENT_ORDER_PATTERN = /stream event order|before message_start/i;
/** Transient stream corruption where the response was truncated mid-JSON. */
/**
* Transient stream corruption where the response was truncated mid-JSON.
*
* Strings (persisted `stopDetails.explanation`/`errorMessage` diagnostics) are matched with the
* stricter {@link STREAM_PARSE_DIAGNOSTIC_PATTERN} — bare "truncated"/"end of file" text is too
* low-signal to trust once detached from a live transport `Error`, which keeps the broad pattern.
*/
export function isTransientStreamParseError(error: unknown): boolean {
if (typeof error === "string") return STREAM_PARSE_DIAGNOSTIC_PATTERN.test(error);
return error instanceof Error && STREAM_PARSE_TRUNCATION_PATTERN.test(error.message);
}
+28 -9
View File
@@ -19,6 +19,8 @@ const SERVER_ERROR_BACKOFF_MS = 20 * 1000; // 20s
const ACCOUNT_RATE_LIMIT_PATTERN =
/\baccount(?:'s)?\b[^\n]{0,80}\brate.?limit\b|\brate.?limit\b[^\n]{0,80}\baccount\b/i;
const INSUFFICIENT_BALANCE_PATTERN = /insufficient.?balance/i;
const SPEND_LIMIT_PATTERN = /spend.?limit/i;
const OPENROUTER_DAILY_FREE_LIMIT_PATTERN = /\bfree[-_ ]models[-_ ]per[-_ ]day\b/i;
/**
* Classify a rate-limit error message into a reason category.
@@ -54,6 +56,14 @@ export function parseRateLimitReason(errorMessage: string): RateLimitReason {
return "QUOTA_EXHAUSTED";
}
if (SPEND_LIMIT_PATTERN.test(errorMessage)) {
return "QUOTA_EXHAUSTED";
}
if (OPENROUTER_DAILY_FREE_LIMIT_PATTERN.test(errorMessage)) {
return "QUOTA_EXHAUSTED";
}
if (
lower.includes("per minute") ||
lower.includes("rate limit") ||
@@ -106,16 +116,19 @@ export function calculateRateLimitBackoffMs(reason: RateLimitReason): number {
/** Detect usage/quota limit errors in error messages (persistent, requires credential switch). */
const USAGE_LIMIT_PATTERN =
/usage.?limit|usage_limit_reached|usage_not_included|limit_reached|quota.?(?:exceeded|reached|insufficient)|额度不足|额度耗尽|resource.?exhausted|exhausted your capacity|quota will reset|insufficient.?(?:balance|quota)|run out of credits|out of credits|spending[- _]?limit|personal-team-blocked/i;
/usage.?limit|usage_limit_reached|usage_not_included|limit_reached|quota.?(?:exceeded|reached|insufficient)|额度不足|额度耗尽|resource.?exhausted|exhausted your capacity|quota will reset|insufficient.?(?:balance|quota)|balance.?exhausted|run out of credits|out of credits|spending[- _]?limit|personal-team-blocked/i;
/**
* HTTP status codes that, absent richer body classification, represent an
* account-local usage cap rather than a bad credential or a transient blip.
* HTTP 402 Payment Required is categorically an account-billing cap (xAI
* Grok Build "usage balance exhausted", DeepSeek "Insufficient Balance",
* OpenRouter credit exhaustion) — never a transient blip or bad credential.
* Always combine with {@link isUsageLimitOutcome} when a message is available
* — a 429 carrying transient rate-limit wording is NOT a usage cap.
*/
export function isUsageLimitStatus(status: number | undefined): boolean {
return status === 429;
return status === 429 || status === 402;
}
/**
@@ -125,7 +138,7 @@ export function isUsageLimitStatus(status: number | undefined): boolean {
* 1. Body matches {@link isUsageLimitError} (Codex `usage_limit_reached`,
* Anthropic account rate-limit, Google `resource_exhausted`, OpenAI
* `insufficient_quota`, …) → rotate.
* 2. Status is not 429 → backoff (caller's domain).
* 2. Status is not a usage-limit status (429/402) → backoff (caller's domain).
* 3. Body is absent or {@link isOpaqueStatusBody opaque} (just the status,
* empty JSON, HTTP framing only) → rotate conservatively: the server
* gave us nothing else to go on.
@@ -144,14 +157,15 @@ export function isUsageLimitOutcome(status: number | undefined, message: string
}
/**
* A 429 body is opaque when it carries no signal beyond the status itself —
* empty, whitespace-only, the status digits with HTTP/JSON framing, or
* generic punctuation. Anything else (retry hints, capacity wording, error
* descriptions) is informative enough to defer to the classifier.
* A usage-limit status body is opaque when it carries no signal beyond the
* status itself — empty, whitespace-only, the status digits with HTTP/JSON
* framing, or generic punctuation. Anything else (retry hints, capacity
* wording, error descriptions) is informative enough to defer to the
* classifier.
*/
export function isOpaqueStatusBody(message: string): boolean {
const cleaned = message
.replace(/\b429\b/g, "")
.replace(/\b(?:429|402)\b/g, "")
.replace(/\b(?:http|https|status|error|code|response|message)\b/gi, "");
return !/[a-z\d]{3,}/i.test(cleaned);
}
@@ -163,5 +177,10 @@ export function isOpaqueStatusBody(message: string): boolean {
* {@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);
return (
USAGE_LIMIT_PATTERN.test(errorMessage) ||
SPEND_LIMIT_PATTERN.test(errorMessage) ||
ACCOUNT_RATE_LIMIT_PATTERN.test(errorMessage) ||
OPENROUTER_DAILY_FREE_LIMIT_PATTERN.test(errorMessage)
);
}
+2
View File
@@ -39,6 +39,8 @@ export * from "./usage/ollama";
export * from "./usage/openai-codex";
export * from "./usage/openai-codex-reset";
export * from "./usage/opencode-go";
export * from "./usage/synthetic";
export * from "./usage/xai-oauth";
export * from "./usage/zai";
export * from "./utils/anthropic-auth";
export * from "./utils/event-stream";
@@ -0,0 +1,39 @@
import { describe, expect, test } from "bun:test";
import { NO_AUTH_SENTINEL, resolveOpenAIRequestSetup } from "../openai-shared";
describe("resolveOpenAIRequestSetup keyless auth", () => {
test("omits Authorization for the keyless (auth: none) sentinel, keeping custom headers", () => {
const setup = resolveOpenAIRequestSetup(
{
provider: "qwen",
id: "Qwen3.6-35B-A3B",
baseUrl: "http://localhost:8788",
headers: { "x-api-key": "real-key" },
},
{ apiKey: NO_AUTH_SENTINEL, messages: [] },
);
expect(setup.headers.Authorization).toBeUndefined();
expect(setup.headers["x-api-key"]).toBe("real-key");
});
test("still injects Bearer for a real key", () => {
const setup = resolveOpenAIRequestSetup(
{ provider: "custom", id: "m", baseUrl: "http://localhost:8788" },
{ apiKey: "sk-real", messages: [] },
);
expect(setup.headers.Authorization).toBe("Bearer sk-real");
});
test("caller-supplied Authorization in model.headers is preserved even when keyless", () => {
const setup = resolveOpenAIRequestSetup(
{
provider: "qwen",
id: "m",
baseUrl: "http://localhost:8788",
headers: { Authorization: "Bearer custom-token" },
},
{ apiKey: NO_AUTH_SENTINEL, messages: [] },
);
expect(setup.headers.Authorization).toBe("Bearer custom-token");
});
});
@@ -1,7 +1,13 @@
import { describe, expect, it } from "bun:test";
import { afterEach, describe, expect, it, vi } from "bun:test";
import { getBundledModel } from "@oh-my-pi/pi-catalog";
import type { Context } from "../../types";
import { buildModel } from "@oh-my-pi/pi-catalog/build";
import { Effort } from "@oh-my-pi/pi-catalog/effort";
import type { ModelSpec } from "@oh-my-pi/pi-catalog/types";
import * as kimiOauth from "../../registry/oauth/kimi";
import { streamSimple } from "../../stream";
import type { Context, Model } from "../../types";
import type { MessageCreateParamsStreaming } from "../anthropic-wire";
import { type KimiApiFormat, streamKimi } from "../kimi";
import { streamOpenAIAnthropicShim } from "../openai-anthropic-shim";
import {
applyChatCompletionsCompatPolicy,
@@ -10,6 +16,15 @@ import {
} from "../openai-shared";
const BASE_CHAT_COMPLETIONS_PARAMS: OpenAICompletionsParams = { messages: [], model: "unused", stream: true };
const KIMI_HEADERS = Object.freeze({
"User-Agent": "KimiCLI/test",
"X-Msh-Platform": "kimi_cli",
"X-Msh-Version": "test",
"X-Msh-Device-Name": "test",
"X-Msh-Device-Model": "test",
"X-Msh-Os-Version": "test",
"X-Msh-Device-Id": "test",
});
const TITLE_CONTEXT: Context = {
systemPrompt: ["Generate a title."],
messages: [{ role: "user", content: "Explain the login failure", timestamp: 0 }],
@@ -27,8 +42,172 @@ const TITLE_CONTEXT: Context = {
],
};
const K3_MODEL = buildModel({
id: "k3",
name: "K3",
api: "openai-completions",
provider: "kimi-code",
baseUrl: "https://api.kimi.com/coding/v1",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 1_048_576,
maxTokens: 32_000,
thinking: {
mode: "effort",
efforts: [Effort.Low, Effort.High, Effort.Max],
defaultLevel: Effort.Max,
requiresEffort: true,
},
compat: {
thinkingFormat: "kimi",
kimiApiFormat: "openai",
reasoningContentField: "reasoning_content",
supportsDeveloperRole: false,
},
} satisfies ModelSpec<"openai-completions">);
async function captureKimiPayload(
model: Model<"openai-completions">,
reasoning: Effort,
format?: KimiApiFormat,
): Promise<unknown> {
let payload: unknown;
const stream = streamKimi(
model,
{
systemPrompt: [],
messages: [{ role: "user", content: "Reply OK", timestamp: 0 }],
tools: [],
},
{
...(format ? { format } : {}),
apiKey: "test-key",
reasoning,
onPayload: body => {
payload = body;
throw new Error("stop after payload capture");
},
},
);
await stream.result();
if (payload === undefined) throw new Error("Kimi request payload was not captured");
return payload;
}
afterEach(() => {
vi.restoreAllMocks();
});
describe("Kimi K3 thinking transport", () => {
it("sends every live named effort through Kimi's native thinking object by default", async () => {
vi.spyOn(kimiOauth, "getKimiCommonHeaders").mockReturnValue(KIMI_HEADERS);
for (const effort of [Effort.Low, Effort.High, Effort.Max]) {
const payload = await captureKimiPayload(K3_MODEL, effort);
expect(payload).toMatchObject({ thinking: { type: "enabled", effort } });
expect(payload).not.toHaveProperty("reasoning_effort");
}
});
it("uses adaptive named effort rather than a token budget for an explicit Anthropic override", async () => {
vi.spyOn(kimiOauth, "getKimiCommonHeaders").mockReturnValue(KIMI_HEADERS);
const payload = await captureKimiPayload(K3_MODEL, Effort.Max, "anthropic");
expect(payload).toMatchObject({
thinking: { type: "adaptive" },
output_config: { effort: Effort.Max },
});
expect(payload).not.toHaveProperty("thinking.budget_tokens");
});
it("keeps the legacy K2 default on the Anthropic transport", async () => {
vi.spyOn(kimiOauth, "getKimiCommonHeaders").mockReturnValue(KIMI_HEADERS);
const model = getBundledModel<"openai-completions">("kimi-code", "kimi-for-coding");
const payload = await captureKimiPayload(model, Effort.High);
expect(payload).toMatchObject({ thinking: { type: "enabled" } });
expect(payload).toHaveProperty("thinking.budget_tokens");
});
it("clamps disabled thinking to the lowest effort for a mandatory-thinking K3", async () => {
vi.spyOn(kimiOauth, "getKimiCommonHeaders").mockReturnValue(KIMI_HEADERS);
let payload: unknown;
const stream = streamSimple(
K3_MODEL,
{
systemPrompt: [],
messages: [{ role: "user", content: "Reply OK", timestamp: 0 }],
tools: [],
},
{
apiKey: "test-key",
disableReasoning: true,
onPayload: body => {
payload = body;
throw new Error("stop after payload capture");
},
},
);
await stream.result();
expect(payload).toMatchObject({ thinking: { type: "enabled", effort: Effort.Low } });
expect(payload).not.toMatchObject({ thinking: { type: "disabled" } });
});
it("downgrades named tool choice to required for K3 thinking", async () => {
vi.spyOn(kimiOauth, "getKimiCommonHeaders").mockReturnValue(KIMI_HEADERS);
const bundledModel = getBundledModel<"openai-completions">("kimi-code", "k3");
expect(bundledModel.compat.thinkingFormat).toBe("kimi");
let payload: unknown;
const capturePayload = async (
model: Model<"openai-completions">,
toolChoice: "required" | { type: "tool"; name: string },
tools = TITLE_CONTEXT.tools,
) => {
const stream = streamKimi(
model,
{ ...TITLE_CONTEXT, tools },
{
apiKey: "test-key",
format: "openai",
reasoning: Effort.Max,
toolChoice,
onPayload: body => {
payload = body;
throw new Error("stop after payload capture");
},
},
);
await stream.result();
};
for (const model of [K3_MODEL, bundledModel]) {
await capturePayload(model, { type: "tool", name: "set_title" });
expect(payload).toMatchObject({
thinking: { type: "enabled" },
tool_choice: "required",
tools: [{ type: "function", function: { name: "set_title" } }],
});
await capturePayload(model, "required");
expect(payload).toMatchObject({
thinking: { type: "enabled" },
tool_choice: "required",
tools: [{ type: "function", function: { name: "set_title" } }],
});
}
await capturePayload(K3_MODEL, { type: "tool", name: "missing_tool" }, []);
expect((payload as { tool_choice?: unknown }).tool_choice).toBeUndefined();
});
});
describe("Kimi K2.7 Code thinking policy", () => {
it("omits disabled thinking for title-generator-style Kimi Code requests", () => {
it("expresses disabled thinking explicitly for title-generator-style Kimi Code requests", () => {
const model = getBundledModel<"openai-completions">("kimi-code", "kimi-for-coding");
const policy = resolveOpenAICompatPolicy(model, {
endpoint: "chat-completions",
@@ -39,11 +218,16 @@ describe("Kimi K2.7 Code thinking policy", () => {
applyChatCompletionsCompatPolicy(params, policy);
expect("thinking" in params).toBe(false);
expect(model.compat.supportsForcedToolChoice).toBe(false);
// Kimi's native hosts speak the z.ai binary thinking field: a disabled
// request carries `{ type: "disabled" }` rather than omitting the block.
expect((params as Record<string, unknown>).thinking).toEqual({ type: "disabled" });
// Thinking yields to a forced tool choice (#5758 review): the choice is
// honored and reasoning is turned off, instead of downgrading the choice.
expect(model.compat.supportsForcedToolChoice).toBe(true);
expect(model.compat.disableReasoningOnForcedToolChoice).toBe(true);
});
it("enables thinking and downgrades forced tool choice on Kimi Code's Anthropic endpoint", async () => {
it("keeps the forced tool choice and omits thinking on Kimi Code's Anthropic endpoint", async () => {
const model = getBundledModel<"openai-completions">("kimi-code", "kimi-for-coding");
let payload: MessageCreateParamsStreaming | undefined;
const stream = streamOpenAIAnthropicShim(
@@ -67,8 +251,43 @@ describe("Kimi K2.7 Code thinking policy", () => {
await stream.result();
expect(payload?.thinking?.type).toBe("enabled");
expect(payload?.tool_choice).toEqual({ type: "auto" });
// With reasoning disabled the Anthropic wire carries no thinking block,
// and the forced tool choice survives (thinking yields to the choice).
expect(payload?.thinking).toBeUndefined();
expect(payload?.tool_choice).toEqual({ type: "tool", name: "set_title" });
});
it("uses the configured Kimi base URL for Anthropic requests", async () => {
vi.spyOn(kimiOauth, "getKimiCommonHeaders").mockReturnValue(KIMI_HEADERS);
const bundledModel = getBundledModel<"openai-completions">("kimi-code", "kimi-for-coding");
const model = { ...bundledModel, baseUrl: "https://gateway.example.com/v1" };
let requestedUrl: string | undefined;
const stream = streamKimi(
model,
{
systemPrompt: [],
messages: [{ role: "user", content: "Reply OK", timestamp: 0 }],
tools: [],
},
{
format: "anthropic",
apiKey: "gateway-key",
fetch: async input => {
requestedUrl = String(input);
return new Response(
JSON.stringify({
type: "error",
error: { type: "authentication_error", message: "stop after URL capture" },
}),
{ status: 401, headers: { "content-type": "application/json" } },
);
},
},
);
await stream.result();
expect(requestedUrl).toBe("https://gateway.example.com/v1/messages");
});
it("omits disabled thinking for native Moonshot Kimi K2.7 Code variants", () => {
+1 -1
View File
@@ -334,7 +334,7 @@ export const streamBedrock: StreamFunction<"bedrock-converse-stream"> = (
toolConfig,
additionalModelRequestFields,
};
options?.onPayload?.(commandInput);
options?.onPayload?.(commandInput, model);
const host = `bedrock-runtime.${region}.amazonaws.com`;
const url = `https://${host}/model/${encodeURIComponent(model.id)}/converse-stream`;
+139 -40
View File
@@ -100,6 +100,8 @@ export type AnthropicHeaderOptions = {
isCloudflareAiGateway?: boolean;
claudeCodeSessionId?: string;
claudeCodeBetas?: readonly string[];
/** Allow explicit fingerprint headers to replace OAuth defaults on non-official endpoints. */
allowAnthropicHeaderOverrides?: boolean;
};
export function normalizeAnthropicBaseUrl(baseUrl?: string): string | undefined {
@@ -144,7 +146,8 @@ const claudeCodeAgentBetaDefaults = [
midConversationSystemBeta,
"advanced-tool-use-2025-11-20",
] as const;
const claudeCodeAgentPostEffortBetas = ["extended-cache-ttl-2025-04-11"] as const;
const extendedCacheTtlBeta = "extended-cache-ttl-2025-04-11";
const claudeCodeAgentPostEffortBetas = [extendedCacheTtlBeta] as const;
const fineGrainedToolStreamingBeta = "fine-grained-tool-streaming-2025-05-14";
const interleavedThinkingBeta = "interleaved-thinking-2025-05-14";
// Asks the API to redact thinking blocks from responses. Only sent when the
@@ -225,22 +228,36 @@ export function buildAnthropicHeaders(options: AnthropicHeaderOptions): Record<s
const acceptHeader = oauthToken ? "application/json" : stream ? "text/event-stream" : "application/json";
const isCloudflare = options.isCloudflareAiGateway ?? false;
const honorAuthorization = !oauthToken && !isCloudflare;
const allowAnthropicHeaderOverrides =
oauthToken &&
options.allowAnthropicHeaderOverrides === true &&
!isCloudflare &&
!isOfficialAnthropicApiUrl(options.baseUrl);
const honorApiKey = !isCloudflare;
const modelHeaders: Record<string, string> = {};
const anthropicHeaderOverrides: Record<string, string> = {};
const filteredEnforcedKeys: string[] = [];
for (const [key, value] of Object.entries(options.modelHeaders ?? {})) {
const lowerKey = key.toLowerCase();
if (enforcedHeaderKeys.has(lowerKey)) {
// user-agent is always re-applied explicitly. authorization / x-api-key
// are silently re-applied in honoring branches and dropped + logged
// where the branch enforces its own credential.
if (lowerKey === "user-agent") continue;
if (lowerKey === "authorization" && honorAuthorization) continue;
if (lowerKey === "x-api-key" && honorApiKey) continue;
filteredEnforcedKeys.push(key);
continue;
const headerSource = options.modelHeaders;
if (headerSource) {
for (const key in headerSource) {
const value = headerSource[key];
const lowerKey = key.toLowerCase();
if (enforcedHeaderKeys.has(lowerKey)) {
if (allowAnthropicHeaderOverrides && overridableAnthropicHeaderKeys.has(lowerKey)) {
anthropicHeaderOverrides[key] = value;
continue;
}
// user-agent is always re-applied explicitly. authorization / x-api-key
// are silently re-applied in honoring branches and dropped + logged
// where the branch enforces its own credential.
if (lowerKey === "user-agent") continue;
if (lowerKey === "authorization" && honorAuthorization) continue;
if (lowerKey === "x-api-key" && honorApiKey) continue;
filteredEnforcedKeys.push(key);
continue;
}
modelHeaders[key] = value;
}
modelHeaders[key] = value;
}
if (filteredEnforcedKeys.length > 0) {
// Caller/env-supplied values (options.headers, ANTHROPIC_CUSTOM_HEADERS)
@@ -266,7 +283,7 @@ export function buildAnthropicHeaders(options: AnthropicHeaderOptions): Record<s
const userAgent = isClaudeCodeClientUserAgent(incomingUserAgent)
? incomingUserAgent
: `claude-cli/${claudeCodeVersion} (external, local-agent, agent-sdk/${claudeAgentSdkVersion})`;
return {
const headers = {
...modelHeaders,
...claudeCodeHeaders,
Accept: acceptHeader,
@@ -278,6 +295,7 @@ export function buildAnthropicHeaders(options: AnthropicHeaderOptions): Record<s
"User-Agent": userAgent,
...(incomingApiKey ? { "X-Api-Key": incomingApiKey } : {}),
};
return allowAnthropicHeaderOverrides ? mergeHeaders(headers, anthropicHeaderOverrides) : headers;
} else if (!isOfficialAnthropicApiUrl(options.baseUrl)) {
return {
...modelHeaders,
@@ -424,7 +442,15 @@ function getCacheControl(
cacheRetention: CacheRetention | undefined,
isOAuthToken: boolean,
): { retention: CacheRetention; cacheControl?: AnthropicCacheControl } {
const retention = cacheRetention ?? (isOAuthToken ? "long" : resolveCacheRetention(undefined));
// OAuth mirrors Claude Code and always defaults to 1h retention. API-key
// requests also default to 1h where the endpoint supports it (canonical
// Anthropic API, `compat.supportsLongCacheRetention`): agent sessions
// routinely idle past 5 minutes waiting on background jobs, and a 5m
// breakpoint cold-misses the entire prefix on resume. PI_CACHE_RETENTION
// still overrides the API-key default in either direction.
const retention = isOAuthToken
? (cacheRetention ?? "long")
: resolveCacheRetention(cacheRetention, model.compat.supportsLongCacheRetention ? "long" : "short");
if (retention === "none") {
return { retention };
}
@@ -512,6 +538,10 @@ const enforcedHeaderKeys = new Set(
].map(key => key.toLowerCase()),
);
const overridableAnthropicHeaderKeys = new Set(
[...Object.keys(claudeCodeHeaders), "anthropic-beta", "User-Agent", "x-app"].map(key => key.toLowerCase()),
);
const CLAUDE_BILLING_HEADER_PREFIX = "x-anthropic-billing-header:";
function createClaudeBillingHeader(firstUserMessageText: string): string {
@@ -1167,6 +1197,25 @@ function resolveAnthropicBaseUrl(model: Model<"anthropic-messages">, apiKey?: st
return normalizeAnthropicBaseUrl(model.baseUrl);
}
function resolveEagerToolInputStreamingSupport(
model: Model<"anthropic-messages">,
effectiveBaseUrl: string | undefined,
): boolean {
if (!model.compat.supportsEagerToolInputStreaming) return false;
// First-party Anthropic endpoints accept the per-tool flag.
if (isOfficialAnthropicApiUrl(effectiveBaseUrl)) return true;
// Non-official effective endpoint. `supportsEagerToolInputStreaming` may be
// stale-true here because compat is materialized once at build time and is
// never rebuilt for a baseUrl-only reroute — either a runtime provider
// override (`pi.registerProvider("anthropic", { baseUrl })`) or Foundry
// (`CLAUDE_CODE_USE_FOUNDRY`). Both leave the canonical model's resolved
// compat in place. `officialEndpoint` records whether compat was built for
// the canonical Anthropic URL, so only endpoints whose compat was authored
// for a non-official host (an explicit `compat.supportsEagerToolInputStreaming`
// opt-in on a custom `baseUrl`) still send the field.
return !model.compat.officialEndpoint;
}
function parseAnthropicCustomHeaders(rawHeaders: string | undefined): Record<string, string> | undefined {
const source = rawHeaders?.trim();
if (!source) return undefined;
@@ -1741,6 +1790,7 @@ const streamAnthropicOnce = (
}
const apiKey = options?.apiKey ?? getEnvApiKey(model.provider) ?? "";
const baseUrl = resolveAnthropicBaseUrl(model, apiKey) ?? "https://api.anthropic.com";
const supportsEagerToolInputStreaming = resolveEagerToolInputStreamingSupport(model, baseUrl);
const providerSessionState = getAnthropicProviderSessionState(
options?.providerSessionState,
baseUrl,
@@ -1752,6 +1802,23 @@ const streamAnthropicOnce = (
let forceDemoteUnsignedThinking = providerSessionState?.replayUnsignedThinkingDisabled ?? false;
const mergedCallerHeaders = mergeHeaders(model.headers, options?.headers);
const umansGatewayWebSearchHeader = getUmansWebSearchHeader(model, mergedCallerHeaders);
// Keep fallback payloads aligned with the top-level Vertex effort gate:
// no nested effort field means the fallback scan cannot re-add its beta.
let fallbacks = options?.fallbacks;
if (
model.provider === "google-vertex" &&
fallbacks?.some(entry => entry.output_config?.effort !== undefined)
) {
fallbacks = fallbacks.map(entry => {
const outputConfig = entry.output_config;
if (outputConfig?.effort === undefined) return entry;
return {
...entry,
output_config:
outputConfig.task_budget === undefined ? undefined : { task_budget: outputConfig.task_budget },
};
});
}
let client: AnthropicMessagesClientLike;
let isOAuthToken: boolean;
@@ -1776,6 +1843,10 @@ const streamAnthropicOnce = (
// the toggle cannot 400); the beta must accompany the field in both.
// MiniMax uses `thinking.type:"adaptive"` itself as the control surface,
// so the sentinel "adaptive" value intentionally sends no output_config.
// Skip Vertex rawPredict: that adapter needs betas in the body
// (`anthropic_beta`), not as an `anthropic-beta` HTTP header, so the
// effort field is dropped from the body there too (see buildParams) and
// advertising the beta would only earn a 400 (#5614).
const sendsAdaptiveEffortPin =
options?.thinkingEnabled === false &&
model.thinking?.mode === "anthropic-adaptive" &&
@@ -1783,6 +1854,7 @@ const streamAnthropicOnce = (
!usesAdaptiveThinkingTagOnly(model);
if (
model.reasoning &&
model.provider !== "google-vertex" &&
((options?.thinkingEnabled && options.effort !== "adaptive") || sendsAdaptiveEffortPin) &&
!extraBetas.includes(effortBeta)
) {
@@ -1811,16 +1883,27 @@ const streamAnthropicOnce = (
) {
extraBetas.push(contextManagementBeta);
}
// `ttl: "1h"` requires the extended-cache-ttl beta on API-key
// requests. OAuth requests never add it here: agent requests
// already carry it in the Claude Code beta list, and utility
// requests must not deviate from CC's header fingerprint.
if (
!(options?.isOAuth ?? isAnthropicOAuthToken(apiKey)) &&
getCacheControl(model, options?.cacheRetention, false).cacheControl?.ttl === "1h" &&
!extraBetas.includes(extendedCacheTtlBeta)
) {
extraBetas.push(extendedCacheTtlBeta);
}
// Server-side fallback beta chain: opt-in via `options.fallbacks`.
// Nested overrides (`speed`, `output_config.effort`,
// `output_config.task_budget`) reuse the same top-level betas
// Anthropic requires for the primary request, so scan the chain
// and add every companion beta the fallback entries touch.
if (options?.fallbacks?.length) {
if (fallbacks?.length) {
if (!extraBetas.includes(serverSideFallbackBeta)) {
extraBetas.push(serverSideFallbackBeta);
}
for (const entry of options.fallbacks) {
for (const entry of fallbacks) {
if (entry.speed === "fast" && !extraBetas.includes(fastModeBeta)) {
extraBetas.push(fastModeBeta);
}
@@ -1854,15 +1937,13 @@ const streamAnthropicOnce = (
}
const preparedContext = await prepareAnthropicManyImageContext(context, model.input.includes("image"));
const prepareParams = async (): Promise<MessageCreateParamsStreaming> => {
let nextParams = buildParams(
model,
preparedContext,
isOAuthToken,
options,
let nextParams = buildParams(model, preparedContext, isOAuthToken, options, {
disableStrictTools,
umansGatewayWebSearchHeader !== undefined,
useUmansGatewayWebSearch: umansGatewayWebSearchHeader !== undefined,
forceDemoteUnsignedThinking,
);
supportsEagerToolInputStreaming,
fallbacks,
});
if (disableStrictTools) {
dropAnthropicStrictTools(nextParams);
}
@@ -1888,9 +1969,9 @@ const streamAnthropicOnce = (
// Opt-in flag: the response parser only honors `fallback` content
// blocks and `usage.iterations` when the current request opted into
// the server-side-fallback beta chain. Leaving `options.fallbacks`
// unset preserves the pre-fallback stream shape on every event.
const serverSideFallback = !!options?.fallbacks?.length;
// server-side-fallback beta chain. Leaving `fallbacks` unset preserves
// the pre-fallback stream shape on every event.
const serverSideFallback = !!fallbacks?.length;
type Block = (
| ThinkingContent
| RedactedThinkingContent
@@ -2682,9 +2763,11 @@ export function buildAnthropicClientOptions(args: AnthropicClientOptionsArgs): A
const compat = model.compat;
const disableStrictTools = disableStrictToolsOverride ?? compat.disableStrictTools;
const needsInterleavedBeta = interleavedThinking && !model.thinking?.supportsDisplay;
const needsFineGrainedToolStreamingBeta = hasTools && !compat.supportsEagerToolInputStreaming;
const oauthToken = isOAuth ?? isAnthropicOAuthToken(apiKey);
const baseUrl = resolveAnthropicBaseUrl(model, apiKey);
const supportsEagerToolInputStreaming = resolveEagerToolInputStreamingSupport(model, baseUrl);
const needsFineGrainedToolStreamingBeta =
hasTools && isOfficialAnthropicApiUrl(baseUrl) && !supportsEagerToolInputStreaming;
const foundryCustomHeaders = resolveAnthropicCustomHeaders(model);
const tlsFetchOptions = buildClaudeCodeTlsFetchOptions(model, baseUrl);
// Disable Bun's native ~300s pre-response fetch timeout (issue #2422).
@@ -2699,9 +2782,7 @@ export function buildAnthropicClientOptions(args: AnthropicClientOptionsArgs): A
if (model.provider === "github-copilot") {
const copilotApiKey = parseGitHubCopilotApiKey(apiKey).accessToken;
// The GitHub Copilot Anthropic proxy doesn't accept Anthropic beta
// features (and the catalog already forces `supportsEagerToolInputStreaming
// = false` for this host, so `needsFineGrainedToolStreamingBeta` is true
// whenever tools are present). Forward only caller-supplied betas.
// features. Forward only caller-supplied betas.
const betaFeatures = [...extraBetas];
const defaultHeaders = mergeHeaders(
{
@@ -2751,6 +2832,7 @@ export function buildAnthropicClientOptions(args: AnthropicClientOptionsArgs): A
dynamicHeaders,
),
isCloudflareAiGateway: model.provider === "cloudflare-ai-gateway",
allowAnthropicHeaderOverrides: model.compat.allowAnthropicHeaderOverrides,
claudeCodeSessionId,
claudeCodeBetas: oauthToken
? buildClaudeCodeBetas(
@@ -3129,15 +3211,29 @@ function extractClaudeCodeFirstUserMessageText(messages: readonly Message[]): st
return "";
}
type AnthropicParamBuildOptions = {
disableStrictTools: boolean;
useUmansGatewayWebSearch: boolean;
forceDemoteUnsignedThinking: boolean;
supportsEagerToolInputStreaming: boolean;
/** Sanitized server-side fallback entries; defaults to `options?.fallbacks` when omitted. */
fallbacks?: AnthropicOptions["fallbacks"];
};
function buildParams(
model: Model<"anthropic-messages">,
context: Context,
isOAuthToken: boolean,
options?: AnthropicOptions,
disableStrictTools = false,
useUmansGatewayWebSearch = false,
forceDemoteUnsignedThinking = false,
options: AnthropicOptions | undefined,
buildOptions: AnthropicParamBuildOptions,
): MessageCreateParamsStreaming {
const {
disableStrictTools,
useUmansGatewayWebSearch,
forceDemoteUnsignedThinking,
supportsEagerToolInputStreaming,
fallbacks = options?.fallbacks,
} = buildOptions;
// A session-scoped auto-demote (learned from a live signing 400) clones the
// resolved compat with `replayUnsignedThinking: false` so every subsequent
// downstream read (convertAnthropicMessages, transformMessages) sees the
@@ -3165,7 +3261,7 @@ function buildParams(
context.tools,
isOAuthToken,
disableStrictTools || model.provider === "github-copilot",
model.compat.supportsEagerToolInputStreaming,
supportsEagerToolInputStreaming,
model.compat.escapeBuiltinToolNames,
useUmansGatewayWebSearch,
);
@@ -3255,9 +3351,12 @@ function buildParams(
? { edits: [{ type: "clear_thinking_20251015" as const, keep: "all" as const }] }
: undefined;
// Pre-compute output_config.
// Pre-compute output_config. Skip `effort` on Vertex rawPredict: it requires
// the `effort-2025-11-24` beta, which that adapter can only accept in the body
// (`anthropic_beta`), never as the `anthropic-beta` HTTP header this path sets
// — so the field is dropped alongside the beta to avoid a 400 (#5614).
const outputConfigEntries: AnthropicOutputConfig = {};
if (outputConfigEffort) outputConfigEntries.effort = outputConfigEffort;
if (outputConfigEffort && model.provider !== "google-vertex") outputConfigEntries.effort = outputConfigEffort;
if (options?.taskBudget) outputConfigEntries.task_budget = options.taskBudget;
const outputConfig = Object.keys(outputConfigEntries).length ? outputConfigEntries : undefined;
@@ -3272,7 +3371,7 @@ function buildParams(
const params: MessageCreateParamsStreaming = {
model: options?.requestModelId ?? model.requestModelId ?? model.id,
messages: convertAnthropicMessages(context.messages, effectiveModel, isOAuthToken, {
serverSideFallbackEnabled: !!options?.fallbacks?.length,
serverSideFallbackEnabled: !!fallbacks?.length,
}),
...(systemBlocks && { system: systemBlocks }),
...(tools !== undefined && { tools }),
@@ -3281,7 +3380,7 @@ function buildParams(
...(thinking && { thinking }),
...(contextManagement && { context_management: contextManagement }),
...(outputConfig && { output_config: outputConfig }),
...(options?.fallbacks?.length ? { fallbacks: options.fallbacks } : {}),
...(fallbacks?.length ? { fallbacks } : {}),
stream: true,
};
+83 -31
View File
@@ -213,6 +213,34 @@ function parseConnectEndStream(data: Uint8Array): Error | null {
}
}
/**
* Maps an opaque HTTP/2 negotiation failure into an actionable error.
*
* bun only opens an HTTP/2 session when TLS-ALPN negotiates `h2`. Behind a
* TLS-intercepting proxy that strips ALPN (e.g. Zscaler), the handshake yields
* no `h2` protocol and bun throws `ERR_HTTP2_ERROR: h2 is not supported`. The
* Cursor run RPC is HTTP/2-only (the ALB rejects HTTP/1.1 with 464), so there
* is no h1 fallback the way model discovery has one — the run simply cannot
* proceed. Replace the opaque message with one that names the cause and points
* at the `providers.cursor.baseUrl` workaround.
*
* Non-ALPN errors pass through untouched.
*/
export function mapH2TransportError(error: unknown, baseUrl: string): unknown {
const code = (error as { code?: unknown } | null)?.code;
const message = error instanceof Error ? error.message : String(error);
if (code === "ERR_HTTP2_ERROR" && /h2 is not supported/i.test(message)) {
return new AIError.ProviderResponseError(
`Cursor run transport could not negotiate HTTP/2 with ${baseUrl}: "h2 is not supported". ` +
"This host serves the run RPC over HTTP/2 only, and the TLS handshake did not negotiate " +
"h2 via ALPN — typically an ALPN-stripping TLS-intercepting proxy (e.g. Zscaler). " +
"Front the provider with a local HTTP/2 bridge and set providers.cursor.baseUrl to it.",
{ provider: "cursor", kind: "runtime", cause: error },
);
}
return error;
}
function debugBytes(bytes: Uint8Array, asHex: boolean): string {
if (asHex) {
return Buffer.from(bytes).toString("hex");
@@ -352,7 +380,30 @@ export const streamCursor: StreamFunction<"cursor-agent"> = (
let heartbeatTimer: NodeJS.Timeout | null = null;
let debugResponseLogPromise: Promise<RequestDebugResponseLog | undefined> | undefined;
const h2Completion = Promise.withResolvers<void>();
let resolveH2: (() => void) | undefined = h2Completion.resolve;
let h2Settled = false;
let sawTurnEnded = false;
let endStreamError: Error | null = null;
const settleH2 = (error?: unknown): void => {
if (h2Settled) return;
h2Settled = true;
if (error !== undefined) {
h2Completion.reject(error);
return;
}
if (endStreamError) {
h2Completion.reject(endStreamError);
return;
}
if (!sawTurnEnded) {
h2Completion.reject(
new AIError.ProviderResponseError("Cursor stream ended before turnEnded", {
kind: "incomplete-stream",
}),
);
return;
}
h2Completion.resolve();
};
try {
const apiKey = options?.apiKey;
@@ -408,17 +459,17 @@ export const streamCursor: StreamFunction<"cursor-agent"> = (
} else {
h2Client = http2.connect(baseUrl);
}
h2Client.on("error", h2Completion.reject);
h2Client.on("error", error => settleH2(mapH2TransportError(error, baseUrl)));
h2Request = h2Client.request(requestHeaders);
stream.push({ type: "start", partial: output });
let pendingBuffer = Buffer.alloc(0);
let endStreamError: Error | null = null;
let currentTextBlock: (TextContent & { [kStreamingBlockIndex]: number }) | null = null;
let currentThinkingBlock: (ThinkingContent & { [kStreamingBlockIndex]: number }) | null = null;
let currentToolCall: ToolCallState | null = null;
const resolvedMcpToolCallIds = new Set<string>();
const usageState: UsageState = { sawTokenDelta: false };
const state: BlockState = {
@@ -431,6 +482,7 @@ export const streamCursor: StreamFunction<"cursor-agent"> = (
get currentToolCall() {
return currentToolCall;
},
resolvedMcpToolCallIds,
get firstTokenTime() {
return firstTokenTime;
},
@@ -505,12 +557,9 @@ export const streamCursor: StreamFunction<"cursor-agent"> = (
log("error", "handleServerMessage", { error: String(error) });
});
// Resolve only on explicit turnEnded. stopReason defaults to "stop"
// and is not a reliable signal for stream completion.
if (isTurnEnded && resolveH2) {
const r = resolveH2;
resolveH2 = undefined;
r();
// Application completion is not protocol success; wait for a clean HTTP/2 end.
if (isTurnEnded) {
sawTurnEnded = true;
}
} catch (e) {
log("error", "parseServerMessage", { error: String(e) });
@@ -537,40 +586,30 @@ export const streamCursor: StreamFunction<"cursor-agent"> = (
h2Request.on("trailers", trailers => {
const status = trailers["grpc-status"];
const msg = trailers["grpc-message"];
if (status && status !== "0") {
void closeDebugLog().finally(() => {
h2Completion.reject(
new AIError.ProviderResponseError(
`gRPC error ${status}: ${decodeURIComponent(String(msg || ""))}`,
{ kind: "envelope" },
),
);
});
if (status && status !== "0" && !endStreamError) {
endStreamError = new AIError.ProviderResponseError(
`gRPC error ${status}: ${decodeURIComponent(String(msg || ""))}`,
{ kind: "envelope" },
);
}
});
h2Request.on("end", () => {
resolveH2 = undefined;
void closeDebugLog()
.then(() => {
if (endStreamError) {
h2Completion.reject(endStreamError);
return;
}
h2Completion.resolve();
})
.catch(h2Completion.reject);
.then(() => settleH2())
.catch(error => settleH2(error));
});
h2Request.on("error", error => {
void closeDebugLog().finally(() => h2Completion.reject(error));
const mapped = mapH2TransportError(error, baseUrl);
void closeDebugLog().finally(() => settleH2(mapped));
});
if (options?.signal) {
options.signal.addEventListener("abort", () => {
h2Request?.close();
void closeDebugLog().finally(() => {
h2Completion.reject(new AIError.AbortError());
settleH2(new AIError.AbortError());
});
});
}
@@ -640,6 +679,8 @@ export interface BlockState {
currentTextBlock: (TextContent & { [kStreamingBlockIndex]: number }) | null;
currentThinkingBlock: (ThinkingContent & { [kStreamingBlockIndex]: number }) | null;
currentToolCall: ToolCallState | null;
/** MCP call IDs executed through Cursor's exec channel before their stream block arrives. */
resolvedMcpToolCallIds: Set<string>;
firstTokenTime: number | undefined;
setTextBlock: (b: (TextContent & { [kStreamingBlockIndex]: number }) | null) => void;
setThinkingBlock: (b: (ThinkingContent & { [kStreamingBlockIndex]: number }) | null) => void;
@@ -1292,6 +1333,13 @@ async function handleExecServerMessage(
case "mcpArgs": {
const args = execMsg.message.value;
const mcpCall = decodeMcpCall(args);
if (execHandlers?.mcp) {
if (state.currentToolCall?.id === mcpCall.toolCallId) {
state.currentToolCall[kCursorExecResolved] = true;
} else {
state.resolvedMcpToolCallIds.add(mcpCall.toolCallId);
}
}
const { execResult } = await resolveExecHandler(
mcpCall,
execHandlers?.mcp?.bind(execHandlers),
@@ -2217,15 +2265,19 @@ export function processInteractionUpdate(
const mcpCall = toolCall.mcpToolCall;
if (mcpCall) {
const args = mcpCall.args || {};
const id = args.toolCallId || crypto.randomUUID();
const block: ToolCallState = {
type: "toolCall",
id: args.toolCallId || crypto.randomUUID(),
id,
name: args.name || args.toolName || "",
arguments: {},
[kStreamingBlockIndex]: output.content.length,
[kStreamingPartialJson]: "",
[kStreamingBlockKind]: "mcp",
};
if (state.resolvedMcpToolCallIds.delete(id)) {
block[kCursorExecResolved] = true;
}
output.content.push(block);
state.setToolCall(block);
stream.push({ type: "toolcall_start", contentIndex: output.content.length - 1, partial: output });
@@ -2816,7 +2868,7 @@ function buildGrpcRequest(
conversationId: state.conversationId,
});
options?.onPayload?.(runRequest);
options?.onPayload?.(runRequest, model);
// Tools are sent later via requestContext (exec handshake)
+95 -14
View File
@@ -43,9 +43,11 @@ import type {
Tool,
ToolCall,
} from "../types";
import { isDemotedThinking } from "../utils/block-symbols";
import { deterministicUuid } from "../utils/deterministic-id";
import { AssistantMessageEventStream } from "../utils/event-stream";
import { toolWireSchema } from "../utils/schema/wire";
import { transformMessages } from "./transform-messages";
/** Base host for Codeium/Windsurf's Cascade chat API (Connect protocol over HTTP/1.1). */
export const DEVIN_API_URL = "https://server.codeium.com";
@@ -78,6 +80,13 @@ const CONNECT_END_STREAM_FLAG = 0x02;
* fails fast instead of consuming memory.
*/
const MAX_CONNECT_FRAME_PAYLOAD = 16 * 1024 * 1024;
/**
* Recovery heuristic for opaque Devin `invalid_argument` trailers. This is not
* asserted to be the backend's hard limit: small requests can hit the same
* intermittent error, while compactable message history this large is likely
* to benefit from the existing context-overflow maintenance path.
*/
const LARGE_HISTORY_RECOVERY_BYTES = 512 * 1024;
export const streamDevin: StreamFunction<"devin-agent"> = (
model: Model<"devin-agent">,
@@ -157,10 +166,14 @@ export const streamDevin: StreamFunction<"devin-agent"> = (
const auth = await fetchDevinAuthMetadata(apiKey, baseUrl, fetchImpl, options?.signal);
const chatBaseUrl = auth.baseUrl ?? baseUrl;
const request = buildDevinChatRequest(model, context, options, apiKey, auth.userJwt);
logger.debug("devin: sending chat request", { model: model.id, tools: context.tools?.length ?? 0 });
const reqBytes = toBinary(GetChatMessageRequestSchema, request);
const gz = gzipSync(reqBytes);
logger.debug("devin: sending chat request", {
model: model.id,
tools: context.tools?.length ?? 0,
requestBytes: reqBytes.byteLength,
compressedBytes: gz.byteLength,
});
const frame = Buffer.alloc(5 + gz.length);
frame[0] = CONNECT_COMPRESSED_FLAG;
frame.writeUInt32BE(gz.length, 1);
@@ -223,7 +236,53 @@ export const streamDevin: StreamFunction<"devin-agent"> = (
if (flag & CONNECT_END_STREAM_FLAG) {
const trailerBytes = flag & CONNECT_COMPRESSED_FLAG ? gunzipSync(payload) : payload;
const trailerError = readConnectTrailerError(trailerBytes.toString("utf8").trim());
if (trailerError) throw new AIError.ValidationError(trailerError);
if (trailerError) {
const error = new AIError.ValidationError(trailerError.formatted);
if (
firstTokenTime === undefined &&
trailerError.code.toLowerCase() === "invalid_argument" &&
/\binternal error\b/i.test(trailerError.message)
) {
// The full protobuf also contains the system prompt and tool
// schemas, which history maintenance cannot shrink. Re-encode
// only the repeated history field before choosing recovery.
let activeTailCount = 0;
const lastRole = context.messages.at(-1)?.role;
if (lastRole === "user" || lastRole === "developer") {
activeTailCount = 1;
// A trailing developer message can accompany the current user
// prompt. Earlier user-role records may instead be flushed
// execution history and must remain eligible for compaction.
if (lastRole === "developer") {
for (let i = context.messages.length - 2; i >= 0; i--) {
const role = context.messages[i].role;
if (role !== "user" && role !== "developer") break;
activeTailCount++;
}
}
}
const shrinkablePrompts =
activeTailCount > 0
? request.chatMessagePrompts.slice(0, -activeTailCount)
: request.chatMessagePrompts;
const historyBytes = toBinary(
GetChatMessageRequestSchema,
create(GetChatMessageRequestSchema, {
chatMessagePrompts: shrinkablePrompts,
}),
).byteLength;
if (historyBytes >= LARGE_HISTORY_RECOVERY_BYTES) {
AIError.attach(error, AIError.create(AIError.Flag.ContextOverflow));
logger.warn("devin: treating large-history invalid_argument as context overflow", {
model: model.id,
historyBytes,
requestBytes: reqBytes.byteLength,
compressedBytes: gz.byteLength,
});
}
}
throw error;
}
continue;
}
@@ -322,7 +381,8 @@ export const streamDevin: StreamFunction<"devin-agent"> = (
output.usage.output = Number(msg.usage.outputTokens);
output.usage.cacheRead = Number(msg.usage.cacheReadTokens);
output.usage.cacheWrite = Number(msg.usage.cacheWriteTokens);
output.usage.totalTokens = output.usage.input + output.usage.output;
output.usage.totalTokens =
output.usage.input + output.usage.output + output.usage.cacheRead + output.usage.cacheWrite;
}
}
@@ -442,6 +502,7 @@ function buildDevinChatRequest(
options?.stopSequences && options.stopSequences.length > 0
? [...DEVIN_DEFAULT_STOP_PATTERNS, ...options.stopSequences]
: DEVIN_DEFAULT_STOP_PATTERNS;
const messages = transformMessages(context.messages, model);
return create(GetChatMessageRequestSchema, {
metadata: create(MetadataSchema, {
apiKey,
@@ -453,7 +514,7 @@ function buildDevinChatRequest(
locale: "en",
}),
prompt: (context.systemPrompt ?? []).join("\n\n"),
chatMessagePrompts: buildChatMessagePrompts(context.messages, cascadeId),
chatMessagePrompts: buildChatMessagePrompts(messages, cascadeId, model),
chatModelUid: options?.chatModelUid ?? model.requestModelId ?? model.id,
requestType: ChatMessageRequestType.CASCADE,
plannerMode: ConversationalPlannerMode.DEFAULT,
@@ -485,7 +546,11 @@ function buildDevinChatRequest(
}
/** Map omp `Message` history onto Cascade `ChatMessagePrompt`s (USER / SYSTEM / TOOL channels). */
function buildChatMessagePrompts(messages: Message[], cascadeId: string): ChatMessagePrompt[] {
function buildChatMessagePrompts(
messages: Message[],
cascadeId: string,
model: Model<"devin-agent">,
): ChatMessagePrompt[] {
const prompts: ChatMessagePrompt[] = [];
// messageId seeds are `cascadeId\0index\0role[...]` — prompt text is excluded
// so ids stay stable across content edits / history rebuilds.
@@ -513,16 +578,18 @@ function buildChatMessagePrompts(messages: Message[], cascadeId: string): ChatMe
}),
);
} else if (msg.role === "assistant") {
const isNativeDevinMessage =
msg.api === model.api && msg.provider === model.provider && msg.model === model.id;
let promptText = "";
let thinkingText = "";
let signature = "";
const toolCalls: ChatToolCall[] = [];
for (const part of msg.content) {
if (part.type === "text") {
promptText += part.text;
promptText += `${part.text}${isDemotedThinking(part) ? "\n" : ""}`;
} else if (part.type === "thinking") {
thinkingText += part.thinking;
if (!signature && part.thinkingSignature) signature = part.thinkingSignature;
if (isNativeDevinMessage && !signature && part.thinkingSignature) signature = part.thinkingSignature;
} else if (part.type === "toolCall") {
toolCalls.push(
create(ChatToolCallSchema, {
@@ -533,9 +600,13 @@ function buildChatMessagePrompts(messages: Message[], cascadeId: string): ChatMe
);
}
}
if (!promptText && !thinkingText && !signature && toolCalls.length === 0) continue;
prompts.push(
create(ChatMessagePromptSchema, {
messageId: msg.responseId ?? `bot-${deterministicUuid(`${cascadeId}\0${index}\0assistant`)}`,
messageId:
isNativeDevinMessage && msg.responseId
? msg.responseId
: `bot-${deterministicUuid(`${cascadeId}\0${index}\0assistant`)}`,
source: ChatMessageSource.SYSTEM,
prompt: promptText,
thinking: thinkingText,
@@ -569,12 +640,18 @@ function buildChatMessagePrompts(messages: Message[], cascadeId: string): ChatMe
return prompts;
}
interface ConnectTrailerError {
code: string;
message: string;
formatted: string;
}
/**
* Parse a Connect end-of-stream JSON trailer and return a human-readable error
* string when it carries `{ error: { code, message } }`, else `null`. The trailer
* is untrusted server output, so the shape is checked with guards rather than asserted.
* Parse a Connect end-of-stream JSON trailer and return its structured error
* when it carries `{ error: { code, message } }`, else `null`. The trailer is
* untrusted server output, so the shape is checked with guards rather than asserted.
*/
function readConnectTrailerError(text: string): string | null {
function readConnectTrailerError(text: string): ConnectTrailerError | null {
if (text.length === 0) return null;
let parsed: unknown;
try {
@@ -588,5 +665,9 @@ function readConnectTrailerError(text: string): string | null {
const code = "code" in err && typeof err.code === "string" ? err.code : "";
const message = "message" in err && typeof err.message === "string" ? err.message : "";
if (!code && !message) return null;
return `Devin stream error${code ? ` ${code}` : ""}: ${message}`;
return {
code,
message,
formatted: `Devin stream error${code ? ` ${code}` : ""}: ${message}`,
};
}
@@ -24,6 +24,7 @@ import { normalizeSystemPrompts } from "../utils";
import { AssistantMessageEventStream } from "../utils/event-stream";
import { toolWireSchema } from "../utils/schema/wire";
import chatmlHistoryNote from "./gitlab-duo-workflow-chatml-note.md" with { type: "text" };
import { redactSensitiveCredentials } from "./transform-messages";
export const GITLAB_DUO_WORKFLOW_PROVIDER_ID = "gitlab-duo-agent";
export const GITLAB_DUO_WORKFLOW_API = "gitlab-duo-agent";
@@ -2581,10 +2582,13 @@ function isGitLabDuoWorkflowChatMlGoal(context: Context): boolean {
// conversation sequences the way `Human:`/`Assistant:` are.
function buildGitLabDuoWorkflowGoal(context: Context): string {
const conversation = buildGitLabDuoWorkflowConversationHistory(context.messages);
// The goal transcript bypasses transformMessages, so apply the outbound
// credential redaction here — the same scrub the flow-config system slot
// already receives — before the payload leaves the process.
if (conversation.length <= 1) {
return extractLatestUserPrompt(context.messages);
return redactSensitiveCredentials(extractLatestUserPrompt(context.messages));
}
return renderGitLabDuoWorkflowChatMl(conversation);
return redactSensitiveCredentials(renderGitLabDuoWorkflowChatMl(conversation));
}
const GITLAB_DUO_WORKFLOW_CHATML_START = "<|im_start|>";
+11 -12
View File
@@ -815,9 +815,6 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
if (isBuffering) {
const buffered = consumePlanningBuffer(textBuffer, toolNames);
if (buffered.kind !== "incomplete") {
if (buffered.kind === "leak") {
sawLeak = true;
}
const visibleSignature = bufferedTextSignature;
isBuffering = false;
textBuffer = "";
@@ -896,9 +893,7 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
if (isBuffering && textBuffer !== "") {
const buffered = consumePlanningBuffer(textBuffer, toolNames, true);
if (buffered.kind === "leak") {
sawLeak = true;
}
if (buffered.kind !== "incomplete") {
feedVisibleText(buffered.visibleText, bufferedTextSignature);
}
@@ -910,11 +905,10 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
flushVisibleText(bufferedTextSignature);
endCurrentBlock();
return hasMeaningfulGoogleContent(output) || sawLeak;
return hasMeaningfulGoogleContent(output);
};
let receivedContent = false;
let sawLeak = false;
for (let i = 0; i < endpoints.length; i++) {
const endpoint = endpoints[i];
@@ -1055,10 +1049,15 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
break;
} catch (error) {
const status = extractHttpStatusFromError(error);
if (AIError.isTransientStatus(status)) {
if (!isLastEndpoint && !started) {
continue;
}
if (
!isLastEndpoint &&
!started &&
(AIError.isTransientStatus(status) ||
(status === undefined &&
!(error instanceof AIError.ProviderResponseError && error.kind === "output") &&
AIError.retriable(AIError.classify(error))))
) {
continue;
}
throw error;
}
+6 -8
View File
@@ -5,8 +5,8 @@
* - OpenAI: https://api.kimi.com/coding/v1/chat/completions
* - Anthropic: https://api.kimi.com/coding/v1/messages
*
* The Anthropic API is generally more stable and recommended.
* Note: Kimi calculates TPM rate limits based on max_tokens, not actual output.
* Each discovered model selects its server-declared protocol; legacy models
* without protocol metadata retain the Anthropic-compatible default.
*/
import { getKimiCommonHeaders } from "../registry/oauth/kimi";
@@ -20,11 +20,8 @@ import {
export type KimiApiFormat = OpenAIAnthropicApiFormat;
// Note: Anthropic SDK appends /v1/messages, so base URL should not include /v1
const KIMI_ANTHROPIC_BASE_URL = "https://api.kimi.com/coding";
export interface KimiOptions extends OpenAIAnthropicShimOptions {
/** API format: "openai" or "anthropic". Default: "anthropic" */
/** Explicit API format override. Defaults to the model's discovered protocol. */
format?: KimiApiFormat;
}
@@ -38,8 +35,9 @@ export function streamKimi(
options?: KimiOptions,
): AssistantMessageEventStream {
return streamOpenAIAnthropicShim(model, context, options, {
anthropicBaseUrl: KIMI_ANTHROPIC_BASE_URL,
defaultFormat: "anthropic",
anthropicBaseUrl: model.baseUrl.replace(/\/v1\/?$/, ""),
defaultFormat: model.compat.kimiApiFormat ?? "anthropic",
anthropicThinkingMode: model.compat.thinkingFormat === "kimi" ? "anthropic-adaptive" : undefined,
extraHeaders: getKimiCommonHeaders,
});
}
@@ -10,7 +10,7 @@
import { buildModel } from "@oh-my-pi/pi-catalog/build";
import { ANTHROPIC_THINKING, mapAnthropicToolChoice } from "../stream";
import type { Context, Model, ModelSpec, SimpleStreamOptions } from "../types";
import type { Context, Model, ModelSpec, SimpleStreamOptions, ThinkingControlMode } from "../types";
import { AssistantMessageEventStream } from "../utils/event-stream";
import { createProviderErrorMessage } from "./error-message";
import { streamAnthropic, streamOpenAICompletions } from "./register-builtins";
@@ -29,6 +29,8 @@ export interface OpenAIAnthropicShimConfig {
openaiBaseUrl?: string;
/** Default API format when caller does not specify one. */
defaultFormat: OpenAIAnthropicApiFormat;
/** Thinking transport used when this provider's Anthropic endpoint differs from generic budget semantics. */
anthropicThinkingMode?: ThinkingControlMode;
/** Provider-specific headers (e.g. auth/session) merged ahead of user-supplied headers. */
extraHeaders?: () => Record<string, string>;
}
@@ -67,6 +69,9 @@ export function streamOpenAIAnthropicShim(
contextWindow: model.contextWindow,
maxTokens: model.maxTokens,
reasoning: model.reasoning,
...(config.anthropicThinkingMode && model.thinking
? { thinking: { ...model.thinking, mode: config.anthropicThinkingMode } }
: {}),
input: model.input,
cost: model.cost,
} as ModelSpec<"anthropic-messages">);
@@ -95,6 +100,7 @@ export function streamOpenAIAnthropicShim(
fetch: options?.fetch,
thinkingEnabled,
thinkingBudgetTokens: thinkingBudget,
reasoning: config.anthropicThinkingMode ? reasoningEffort : undefined,
toolChoice: mapAnthropicToolChoice(options?.toolChoice),
serviceTier: options?.serviceTier,
});
@@ -60,6 +60,7 @@ import {
getOpenAIStreamIdleTimeoutMs,
iterateWithIdleTimeout,
} from "../utils/idle-iterator";
import { getProxyForProvider, shouldBypassProxy } from "../utils/proxy";
import { createRequestDebugSession, isRequestDebugEnabled, type RequestDebugResponseLog } from "../utils/request-debug";
import { adaptSchemaForStrict, NO_STRICT, sanitizeSchemaForOpenAIResponses, toolWireSchema } from "../utils/schema";
import { notifyRawSseEvent } from "../utils/sse-debug";
@@ -111,7 +112,7 @@ import {
promoteResponsesToolUseStopReason,
type SequentialCutoffSummaryState,
} from "./openai-shared";
import { transformMessages } from "./transform-messages";
import { redactSensitiveInObject, transformMessages } from "./transform-messages";
export interface OpenAICodexResponsesOptions extends StreamOptions {
reasoning?: "none" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
@@ -1481,6 +1482,7 @@ async function openCodexWebSocketTransport(
websocketState,
toWebSocketUrl(requestContext.url),
websocketHeaders,
model.provider,
requestSetup.requestSignal,
);
const eventStream = websocketConnection.streamRequest(
@@ -2614,6 +2616,7 @@ export async function prewarmOpenAICodexResponses(
state,
toWebSocketUrl(url),
headers,
model.provider,
options?.signal,
);
state.prewarmed = true;
@@ -3076,11 +3079,13 @@ interface CodexWebSocketRequestTimeouts {
interface CodexWebSocketConnectionOptions {
onHandshakeHeaders?: (headers: Headers) => void;
proxy?: string;
}
class CodexWebSocketConnection {
#url: string;
#headers: Record<string, string>;
#proxy?: string;
#onHandshakeHeaders?: (headers: Headers) => void;
#socket: Bun.WebSocket | null = null;
#queue: Array<Record<string, unknown> | Error | null> = [];
@@ -3111,6 +3116,7 @@ class CodexWebSocketConnection {
constructor(url: string, headers: Record<string, string>, options: CodexWebSocketConnectionOptions) {
this.#url = url;
this.#headers = headers;
this.#proxy = options.proxy;
this.#onHandshakeHeaders = options.onHandshakeHeaders;
}
@@ -3172,7 +3178,7 @@ class CodexWebSocketConnection {
this.#connectPromise = promise;
const socket = new (WebSocket as unknown as new (url: string, opts: Bun.WebSocketOptions) => Bun.WebSocket)(
this.#url,
{ headers: this.#headers },
{ headers: this.#headers, proxy: this.#proxy },
);
socket.binaryType = "nodebuffer";
this.#socket = socket;
@@ -3663,8 +3669,18 @@ async function getOrCreateCodexWebSocketConnection(
state: CodexWebSocketSessionState,
url: string,
headers: Headers,
provider: string,
signal?: AbortSignal,
): Promise<CodexWebSocketConnection> {
const targetUrl = new URL(url);
const proxy = shouldBypassProxy(targetUrl)
? undefined
: (getProxyForProvider(provider) ??
(targetUrl.protocol === "wss:"
? Bun.env.HTTPS_PROXY || Bun.env.https_proxy
: Bun.env.HTTP_PROXY || Bun.env.http_proxy) ??
Bun.env.ALL_PROXY ??
Bun.env.all_proxy);
const headerRecord = headersToRecord(headers);
// Join an in-flight handshake instead of tearing it down: closing a
// CONNECTING socket rejects the concurrent caller (prewarm racing the first
@@ -3707,6 +3723,7 @@ async function getOrCreateCodexWebSocketConnection(
onHandshakeHeaders: handshakeHeaders => {
updateCodexSessionMetadataFromHeaders(state, handshakeHeaders);
},
proxy,
});
await state.connection.connect(signal);
return state.connection;
@@ -3896,7 +3913,8 @@ function redactHeaders(headers: Headers): Record<string, string> {
return redacted;
}
function resolveCodexResponsesUrl(baseUrl: string | undefined): string {
/** Resolve a Codex Responses endpoint exactly as the chat and compaction transports do. */
export function resolveCodexResponsesUrl(baseUrl: string | undefined): string {
const raw = baseUrl && baseUrl.trim().length > 0 ? baseUrl : CODEX_BASE_URL;
const normalized = raw.replace(/\/+$/, "");
if (normalized.endsWith("/codex/responses")) return normalized;
@@ -3937,13 +3955,14 @@ function convertMessages(model: Model<"openai-codex-responses">, context: Contex
| Array<ResponseInput[number]>
| undefined;
if (historyItems) {
for (const item of historyItems) {
const redactedHistoryItems = redactSensitiveInObject(historyItems).result as Array<ResponseInput[number]>;
for (const item of redactedHistoryItems) {
const maybe = item as { type?: string; call_id?: string };
if (maybe.type === "custom_tool_call" && typeof maybe.call_id === "string") {
customCallIds.add(maybe.call_id);
}
}
messages.push(...historyItems);
messages.push(...redactedHistoryItems);
msgIndex += 1;
continue;
}
@@ -269,6 +269,7 @@ function stripImageDetails(input: unknown[]): void {
export interface CodexLiteShapedBody {
instructions?: unknown;
tools?: unknown;
tool_choice?: unknown;
input?: unknown;
parallel_tool_calls?: unknown;
}
@@ -278,9 +279,14 @@ export interface CodexLiteShapedBody {
* `build_responses_request` with `use_responses_lite`): strips pinned image
* detail, forces parallel tool calling off, moves tools into a leading
* `additional_tools` developer item and the base instructions into a
* developer message, then omits top-level `instructions`/`tools`. Shared by
* normal turns and both remote-compaction paths — codex-rs routes
* `/responses/compact` through the same builder.
* developer message, then omits top-level `instructions`/`tools`. Because the
* rewrite removes top-level `tools`, a forced hosted-tool choice (e.g.
* `{ type: "web_search" }`) would leave the backend unable to validate the
* choice against a tools collection and it rejects the request with HTTP 400
* (#5771). Such choices must fall back to `"auto"`; explicit string constraints
* such as `"none"` and `"required"` remain valid. Shared by normal turns and
* both remote-compaction paths — codex-rs routes `/responses/compact` through
* the same builder.
*/
export function applyCodexResponsesLiteShape(body: CodexLiteShapedBody): void {
const input = Array.isArray(body.input) ? body.input : [];
@@ -297,6 +303,9 @@ export function applyCodexResponsesLiteShape(body: CodexLiteShapedBody): void {
});
}
body.input = [...prefix, ...input];
if (body.tool_choice !== "none" && body.tool_choice !== "required") {
body.tool_choice = "auto";
}
delete body.instructions;
delete body.tools;
}
+71 -29
View File
@@ -42,7 +42,13 @@ import {
import { OpenAIHttpError, postOpenAIStream } from "../utils/openai-http";
import { notifyProviderResponse } from "../utils/provider-response";
import { callWithCopilotModelRetry } from "../utils/retry";
import { adaptSchemaForStrict, NO_STRICT, normalizeSchemaForMoonshot, toolWireSchema } from "../utils/schema";
import {
adaptSchemaForStrict,
NO_STRICT,
normalizeSchemaForMoonshot,
sanitizeSchemaForGrammar,
toolWireSchema,
} from "../utils/schema";
import {
type HealedToolCall,
StreamMarkupHealing,
@@ -76,6 +82,7 @@ import {
applyOpenAIExtraBody,
applyOpenAIGatewayRouting,
applyOpenAIServiceTier,
applyOpenRouterReportedCost,
applyWireModelIdTransform,
calculateOpenAIUsageAccounting,
clearOpenAIStrictToolsState,
@@ -93,9 +100,9 @@ import {
type OpenAIStrictToolsState,
parseAzureDeploymentNameMap,
resolveOpenAICompatPolicy,
resolveOpenAICompletionsOutputClamp,
resolveOpenAIOutputTokenParam,
resolveOpenAIRequestSetup,
resolveZaiReasoningOutputClamp,
shouldRetryWithoutStrictTools,
} from "./openai-shared";
import { transformMessages } from "./transform-messages";
@@ -670,7 +677,7 @@ const streamOpenAICompletionsOnce = (
}
activeReasoningEffortFallbackKey = reasoningEffortFallbackKey;
activeRequestParams = params;
options?.onPayload?.(params);
options?.onPayload?.(params, model);
rawRequestDump = {
provider: model.provider,
api: output.api,
@@ -1430,6 +1437,20 @@ function dropOpenRouterKimiForcedToolReasoning(
}
}
function hasActiveNativeKimiK3Reasoning(
model: Model<"openai-completions">,
options: OpenAICompletionsOptions | undefined,
): boolean {
if (model.provider !== "kimi-code" || model.id.toLowerCase() !== "k3" || !model.reasoning) return false;
if (options?.reasoning === undefined || options.disableReasoning) return false;
try {
const url = new URL(model.baseUrl);
return url.hostname === "api.kimi.com" && (url.pathname === "/coding" || url.pathname.startsWith("/coding/"));
} catch {
return false;
}
}
function buildParams(
model: Model<"openai-completions">,
context: Context,
@@ -1460,31 +1481,35 @@ function buildParams(
params.store = false;
}
if (options?.temperature !== undefined) {
params.temperature = options.temperature;
}
if (options?.topP !== undefined) {
params.top_p = options.topP;
}
if (options?.topK !== undefined) {
params.top_k = options.topK;
}
if (options?.minP !== undefined) {
params.min_p = options.minP;
}
if (options?.presencePenalty !== undefined) {
params.presence_penalty = options.presencePenalty;
}
if (options?.repetitionPenalty !== undefined) {
params.repetition_penalty = options.repetitionPenalty;
// OpenAI proprietary reasoning models (o-series, gpt-5+) reject explicit
// sampling params with a 400 on every serving host (#5606).
if (initialCompat.supportsSamplingParams) {
if (options?.temperature !== undefined) {
params.temperature = options.temperature;
}
if (options?.topP !== undefined) {
params.top_p = options.topP;
}
if (options?.topK !== undefined) {
params.top_k = options.topK;
}
if (options?.minP !== undefined) {
params.min_p = options.minP;
}
if (options?.presencePenalty !== undefined) {
params.presence_penalty = options.presencePenalty;
}
if (options?.repetitionPenalty !== undefined) {
params.repetition_penalty = options.repetitionPenalty;
}
if (options?.frequencyPenalty !== undefined) {
params.frequency_penalty = options.frequencyPenalty;
}
}
if (options?.stopSequences?.length) {
const seqs = options.stopSequences;
params.stop = seqs.length === 1 ? seqs[0] : seqs.slice(0, 4);
}
if (options?.frequencyPenalty !== undefined) {
params.frequency_penalty = options.frequencyPenalty;
}
applyOpenAIServiceTier(params, options?.serviceTier, model);
if (context.tools?.length) {
@@ -1513,6 +1538,20 @@ function buildParams(
) {
params.tool_choice = "required";
}
const forcedToolName =
typeof params.tool_choice === "object" && params.tool_choice !== null && "function" in params.tool_choice
? params.tool_choice.function.name
: undefined;
if (
forcedToolName !== undefined &&
Array.isArray(params.tools) &&
params.tools.some(tool => tool.type === "function" && tool.function.name === forcedToolName) &&
hasActiveNativeKimiK3Reasoning(model, options)
) {
// Native K3 reasoning is incompatible with selecting a specific function.
// Preserve the hard tool-use contract while letting K3 choose among tools.
params.tool_choice = "required";
}
if (isForcedToolChoice(params.tool_choice) && !initialCompat.supportsForcedToolChoice) {
// Some thinking-required OpenAI-compatible models reject forced
// `tool_choice` while still accepting tools with the default auto
@@ -1532,10 +1571,6 @@ function buildParams(
delete params.tool_choice;
}
const forcedToolName =
typeof params.tool_choice === "object" && params.tool_choice !== null && "function" in params.tool_choice
? params.tool_choice.function.name
: undefined;
if (
forcedToolName !== undefined &&
(!Array.isArray(params.tools) ||
@@ -1566,7 +1601,7 @@ function buildParams(
omitMaxOutputTokens: model.omitMaxOutputTokens ?? false,
isOpenRouterHost: compat.isOpenRouterHost,
alwaysSendMaxTokens: compat.alwaysSendMaxTokens,
providerOutputClamp: resolveZaiReasoningOutputClamp(model, compat),
providerOutputClamp: resolveOpenAICompletionsOutputClamp(model, compat),
});
if (outputToken) {
if (outputToken.field === "max_tokens") {
@@ -1629,6 +1664,7 @@ export function parseChunkUsage(
...(premiumRequests !== undefined ? { premiumRequests } : {}),
};
calculateCost(model, usage);
applyOpenRouterReportedCost(model, usage, rawUsage);
return usage;
}
@@ -2199,10 +2235,16 @@ function convertTools(
description: tool.description || "",
// Moonshot/Kimi native hosts validate against the stricter MFJS subset
// (const→enum, typed enums, no validators) and 400 otherwise.
// Grammar-constrained local backends (llama.cpp, LM Studio, vLLM)
// build a GBNF grammar from the schema and 400 with
// `Unrecognized schema: true` on the bare boolean subschema
// `toolWireSchema` emits for open fields (issue #5914).
parameters:
compat.toolSchemaFlavor === "moonshot-mfjs"
? (normalizeSchemaForMoonshot(wireParameters) as Record<string, unknown>)
: wireParameters,
: compat.toolSchemaFlavor === "grammar"
? sanitizeSchemaForGrammar(wireParameters)
: wireParameters,
// Only include strict if provider supports it. Some reject unknown fields.
...(includeStrict ? { strict: true } : includeExplicitFalse ? { strict: false } : {}),
},
+275 -163
View File
@@ -1,3 +1,4 @@
import { scheduler } from "node:timers/promises";
import { hostMatchesUrl } from "@oh-my-pi/pi-catalog/hosts";
import { $flag, logger, structuredCloneJSON } from "@oh-my-pi/pi-utils";
import * as AIError from "../error";
@@ -38,6 +39,7 @@ import {
adaptSchemaForStrict,
findStrictToolSchemaViolation,
NO_STRICT,
normalizeSchemaForMoonshot,
sanitizeSchemaForOpenAIResponses,
toolWireSchema,
} from "../utils/schema";
@@ -154,6 +156,33 @@ const OPENAI_RESPONSES_FIRST_EVENT_TIMEOUT_MESSAGE =
"OpenAI responses stream timed out while waiting for the first event";
/** Consecutive stale-previous-response failures before chaining is disabled for the session. */
const OPENAI_RESPONSES_CHAIN_STALE_FAILURE_LIMIT = 3;
const OPENAI_RESPONSES_MAX_TRANSIENT_STREAM_RETRIES = 1;
const OPENAI_RESPONSES_TRANSIENT_STREAM_RETRY_DELAY_MS = 500;
function isOpenAIResponsesReplayUnsafeEvent(event: ResponseStreamEvent): boolean {
switch (event.type) {
case "response.output_text.delta":
case "response.refusal.delta":
case "response.reasoning_summary_text.delta":
case "response.reasoning_text.delta":
case "response.function_call_arguments.delta":
case "response.custom_tool_call_input.delta":
return typeof event.delta === "string" && event.delta.length > 0;
case "response.reasoning_summary_part.done":
return true;
case "response.output_item.done":
return true;
default:
return false;
}
}
function isRetryableOpenAIResponsesStreamFailure(error: unknown): boolean {
return (
AIError.isTransientStreamParseError(error) ||
(error instanceof AIError.ProviderResponseError && error.kind === "incomplete-stream")
);
}
interface OpenAIResponsesProviderSessionState
extends ProviderSessionState,
@@ -452,7 +481,7 @@ const streamOpenAIResponsesOnce = (
return payload;
};
chained = { ...chained, params: await applyPayloadReplacement(chained.params) };
rawRequestDump = {
const activeRawRequestDump: RawHttpRequestDump = {
provider: model.provider,
api: output.api,
model: model.id,
@@ -460,6 +489,7 @@ const streamOpenAIResponsesOnce = (
url: requestUrl,
body: chained.params,
};
rawRequestDump = activeRawRequestDump;
const openResponsesStream = (requestParams: OpenAIResponsesSamplingParams) => {
activeReasoningEffortFallbackKey = createOpenAIReasoningEffortFallbackKey(
"responses",
@@ -507,183 +537,257 @@ const streamOpenAIResponsesOnce = (
{ provider: model.provider, signal: requestSignal },
);
};
let openaiStream: AsyncIterable<ResponseStreamEvent>;
let strictRetryAvailable = true;
let activeStrictToolsApplied = builtParams.strictToolsApplied;
let forceDisableStrictTools = false;
while (true) {
try {
openaiStream = await openResponsesStream(chained.params);
if (pendingReasoningEffortFallback) {
rememberOpenAIReasoningEffortFallback(
providerSessionState,
pendingReasoningEffortFallback.key,
pendingReasoningEffortFallback.fallback,
);
pendingReasoningEffortFallback = undefined;
}
break;
} catch (error) {
const capturedErrorResponse = error instanceof OpenAIHttpError ? error.captured : undefined;
const reasoningEffortFallback =
activeReasoningEffortFallbackKey && activeRequestParams && !requestSignal.aborted
? resolveOpenAIReasoningEffortFallback(error, capturedErrorResponse, activeRequestParams, {
explicitDisable: options?.disableReasoning === true && options.reasoning === undefined,
})
: undefined;
if (reasoningEffortFallback !== undefined && activeReasoningEffortFallbackKey) {
const retryMarker = `${activeReasoningEffortFallbackKey}:${String(reasoningEffortFallback)}`;
if (attemptedReasoningEffortFallbacks.has(retryMarker)) throw error;
attemptedReasoningEffortFallbacks.add(retryMarker);
requestReasoningEffortFallbacks.set(activeReasoningEffortFallbackKey, reasoningEffortFallback);
applyOpenAIReasoningEffortFallback(chained.params, reasoningEffortFallback);
applyOpenAIReasoningEffortFallback(activeParams, reasoningEffortFallback);
rawRequestDump.body = chained.params;
pendingReasoningEffortFallback = {
key: activeReasoningEffortFallbackKey,
fallback: reasoningEffortFallback,
};
continue;
}
const compiledGrammarTooLarge =
isOpenRouterAnthropicModel(model) &&
isCompiledGrammarTooLargeStrictError(error, capturedErrorResponse);
const canRetryWithoutStrictTools =
strictRetryAvailable &&
!requestSignal.aborted &&
(compiledGrammarTooLarge ||
shouldRetryWithoutStrictTools(
error,
capturedErrorResponse,
activeStrictToolsApplied,
context.tools,
));
if (canRetryWithoutStrictTools) {
strictRetryAvailable = false;
forceDisableStrictTools = true;
disableStrictToolsForScope(providerSessionState, strictToolsScope);
const fallbackBuilt = buildParams(
const openResponsesStreamWithFallbacks = async (): Promise<AsyncIterable<ResponseStreamEvent>> => {
let openaiStream: AsyncIterable<ResponseStreamEvent>;
while (true) {
try {
openaiStream = await openResponsesStream(chained.params);
if (pendingReasoningEffortFallback) {
rememberOpenAIReasoningEffortFallback(
providerSessionState,
pendingReasoningEffortFallback.key,
pendingReasoningEffortFallback.fallback,
);
pendingReasoningEffortFallback = undefined;
}
break;
} catch (error) {
const capturedErrorResponse = error instanceof OpenAIHttpError ? error.captured : undefined;
const reasoningEffortFallback =
activeReasoningEffortFallbackKey && activeRequestParams && !requestSignal.aborted
? resolveOpenAIReasoningEffortFallback(error, capturedErrorResponse, activeRequestParams, {
explicitDisable: options?.disableReasoning === true && options.reasoning === undefined,
})
: undefined;
if (reasoningEffortFallback !== undefined && activeReasoningEffortFallbackKey) {
const retryMarker = `${activeReasoningEffortFallbackKey}:${String(reasoningEffortFallback)}`;
if (attemptedReasoningEffortFallbacks.has(retryMarker)) throw error;
attemptedReasoningEffortFallbacks.add(retryMarker);
requestReasoningEffortFallbacks.set(activeReasoningEffortFallbackKey, reasoningEffortFallback);
applyOpenAIReasoningEffortFallback(chained.params, reasoningEffortFallback);
applyOpenAIReasoningEffortFallback(activeParams, reasoningEffortFallback);
activeRawRequestDump.body = chained.params;
pendingReasoningEffortFallback = {
key: activeReasoningEffortFallbackKey,
fallback: reasoningEffortFallback,
};
continue;
}
const compiledGrammarTooLarge =
isOpenRouterAnthropicModel(model) &&
isCompiledGrammarTooLargeStrictError(error, capturedErrorResponse);
const canRetryWithoutStrictTools =
strictRetryAvailable &&
!requestSignal.aborted &&
(compiledGrammarTooLarge ||
shouldRetryWithoutStrictTools(
error,
capturedErrorResponse,
activeStrictToolsApplied,
context.tools,
));
if (canRetryWithoutStrictTools) {
strictRetryAvailable = false;
forceDisableStrictTools = true;
disableStrictToolsForScope(providerSessionState, strictToolsScope);
const fallbackBuilt = buildParams(
model,
context,
options,
providerSessionState,
strictToolsScope,
true,
);
const fallbackParams = fallbackBuilt.params;
if (chainState && !chainState.disabled) fallbackParams.store = true;
let fallbackChained: OpenAIResponsesChainedParams =
chainState && !chainState.disabled
? buildOpenAIResponsesChainedParams(fallbackParams, chainState)
: { params: fallbackParams };
sentPreviousResponseId = fallbackChained.previousResponseId;
fallbackChained = {
...fallbackChained,
params: await applyPayloadReplacement(fallbackChained.params),
};
chained = fallbackChained;
activeRawRequestDump.body = chained.params;
activeParams = fallbackParams;
activeStrictToolsApplied = fallbackBuilt.strictToolsApplied;
continue;
}
if (!chainState || !sentPreviousResponseId || requestSignal.aborted) {
throw error;
}
const zdrRejection =
error instanceof Error &&
/previous[ _]?response/i.test(error.message) &&
/zero[ _-]?data[ _-]?retention/i.test(error.message);
const isPromptBlocked =
error instanceof Error &&
((error as { code?: string }).code === "invalid_prompt" ||
/invalid_prompt|Request blocked/i.test(error.message));
if (!zdrRejection && !isPromptBlocked && !isOpenAIResponsesStalePreviousResponseError(error)) {
throw error;
}
// Server rejected the chain baseline: reset, count the failure (or
// disable categorically on ZDR), and retry once with the full
// transcript. Structurally cannot loop — the retry carries no
// previous_response_id.
if (zdrRejection) {
markOpenAIResponsesChainZeroDataRetention(chainState, error);
// ZDR orgs cannot store responses; the retry uses `store: false`.
} else {
registerOpenAIResponsesChainStaleFailure(chainState, error);
}
sentPreviousResponseId = undefined;
const currentBuilt = buildParams(
model,
context,
options,
providerSessionState,
strictToolsScope,
true,
forceDisableStrictTools,
);
const fallbackParams = fallbackBuilt.params;
if (chainState && !chainState.disabled) fallbackParams.store = true;
let fallbackChained: OpenAIResponsesChainedParams =
chainState && !chainState.disabled
? buildOpenAIResponsesChainedParams(fallbackParams, chainState)
: { params: fallbackParams };
sentPreviousResponseId = fallbackChained.previousResponseId;
fallbackChained = {
...fallbackChained,
params: await applyPayloadReplacement(fallbackChained.params),
};
chained = fallbackChained;
rawRequestDump.body = chained.params;
activeParams = fallbackParams;
activeStrictToolsApplied = fallbackBuilt.strictToolsApplied;
continue;
const currentParams = currentBuilt.params;
// Only ZDR forces `store: false` (the org never persists responses). A
// non-ZDR stale baseline is transient, so keep storing: the full-context
// retry must be chainable next turn, and the consecutive stale-failure
// breaker only trips when each retry stores and the next turn re-chains.
currentParams.store = !zdrRejection;
const retryParams = await applyPayloadReplacement(currentParams);
chained = { params: retryParams };
activeRawRequestDump.body = retryParams;
activeParams = currentParams;
activeStrictToolsApplied = currentBuilt.strictToolsApplied;
}
if (!chainState || !sentPreviousResponseId || requestSignal.aborted) {
throw error;
}
const zdrRejection =
error instanceof Error &&
/previous[ _]?response/i.test(error.message) &&
/zero[ _-]?data[ _-]?retention/i.test(error.message);
if (!zdrRejection && !isOpenAIResponsesStalePreviousResponseError(error)) {
throw error;
}
// Server rejected the chain baseline: reset, count the failure (or
// disable categorically on ZDR), and retry once with the full
// transcript. Structurally cannot loop — the retry carries no
// previous_response_id.
if (zdrRejection) {
markOpenAIResponsesChainZeroDataRetention(chainState, error);
// ZDR orgs cannot store responses; the retry uses `store: false`.
} else {
registerOpenAIResponsesChainStaleFailure(chainState, error);
}
sentPreviousResponseId = undefined;
const currentBuilt = buildParams(
model,
context,
options,
providerSessionState,
strictToolsScope,
forceDisableStrictTools,
);
const currentParams = currentBuilt.params;
// Only ZDR forces `store: false` (the org never persists responses). A
// non-ZDR stale baseline is transient, so keep storing: the full-context
// retry must be chainable next turn, and the consecutive stale-failure
// breaker only trips when each retry stores and the next turn re-chains.
currentParams.store = !zdrRejection;
const retryParams = await applyPayloadReplacement(currentParams);
chained = { params: retryParams };
rawRequestDump.body = retryParams;
activeParams = currentParams;
activeStrictToolsApplied = currentBuilt.strictToolsApplied;
}
}
return openaiStream;
};
let openaiStream = await openResponsesStreamWithFallbacks();
if (premiumRequestsTotal !== undefined) output.usage.premiumRequests = premiumRequestsTotal;
stream.push({ type: "start", partial: output });
const nativeOutputItems: Array<Record<string, unknown>> = [];
let sawTerminalResponseEvent = false;
const timedOpenaiStream = iterateWithIdleTimeout(openaiStream, {
idleTimeoutMs,
firstItemTimeoutMs: firstEventTimeoutMs,
firstItemErrorMessage: OPENAI_RESPONSES_FIRST_EVENT_TIMEOUT_MESSAGE,
errorMessage: "OpenAI responses stream stalled while waiting for the next event",
onFirstItemTimeout: () => abortTracker.abortLocally(firstEventTimeoutAbortError),
onIdle: () => requestAbortController.abort(),
abortSignal: options?.signal,
isProgressItem: isOpenAIResponsesProgressEvent,
});
await processResponsesStream(timedOpenaiStream, output, stream, model, {
onFirstToken: () => {
if (!firstTokenTime) firstTokenTime = performance.now();
},
onOutputItemDone: item => {
// `processResponsesStream` hands over a private clone already; no
// second deep copy needed (reasoning items carry multi-KB blobs).
nativeOutputItems.push(item as unknown as Record<string, unknown>);
},
onCompleted: () => {
sawTerminalResponseEvent = true;
},
requestServiceTier: options?.serviceTier,
});
const localAbortReason = abortTracker.getLocalAbortReason();
if (localAbortReason) {
throw localAbortReason;
}
if (abortTracker.wasCallerAbort()) {
throw new AIError.AbortError();
}
// Detect premature stream closure: the HTTP stream ended without the
// provider sending a recognized terminal response event.
// Custom/proxy providers may drop the connection mid-stream; without
// this guard the incomplete output is silently surfaced as a successful
// "stop".
if (!sawTerminalResponseEvent) {
throw new AIError.ProviderResponseError(
"OpenAI responses stream closed before a terminal response event was received",
{ provider: model.provider, kind: "incomplete-stream" },
);
}
if (output.stopReason === "aborted" || output.stopReason === "error") {
throw new AIError.ProviderResponseError(output.errorMessage ?? "An unknown error occurred", {
provider: model.provider,
kind: "runtime",
let transientStreamRetryAttempt = 0;
while (true) {
let sawReplayUnsafeOutput = false;
let sawTerminalResponseEvent = false;
const attemptStream = new AssistantMessageEventStream();
let forwardAttemptLive = false;
const forwardAttemptEvents = () => {
for (const event of attemptStream.queue) stream.push(event);
attemptStream.queue.length = 0;
};
nativeOutputItems.length = 0;
const timedOpenaiStream = iterateWithIdleTimeout(openaiStream, {
idleTimeoutMs,
firstItemTimeoutMs: firstEventTimeoutMs,
firstItemErrorMessage: OPENAI_RESPONSES_FIRST_EVENT_TIMEOUT_MESSAGE,
errorMessage: "OpenAI responses stream stalled while waiting for the next event",
onFirstItemTimeout: () => abortTracker.abortLocally(firstEventTimeoutAbortError),
onIdle: () => requestAbortController.abort(),
abortSignal: options?.signal,
isProgressItem: isOpenAIResponsesProgressEvent,
});
const observedOpenaiStream = (async function* (): AsyncGenerator<ResponseStreamEvent> {
for await (const event of timedOpenaiStream) {
if (isOpenAIResponsesReplayUnsafeEvent(event)) {
sawReplayUnsafeOutput = true;
if (!forwardAttemptLive) {
forwardAttemptEvents();
forwardAttemptLive = true;
}
}
yield event;
if (forwardAttemptLive) forwardAttemptEvents();
}
})();
try {
await processResponsesStream(observedOpenaiStream, output, attemptStream, model, {
onFirstToken: () => {
if (!firstTokenTime) firstTokenTime = performance.now();
},
onOutputItemDone: item => {
// `processResponsesStream` hands over a private clone already; no
// second deep copy needed (reasoning items carry multi-KB blobs).
nativeOutputItems.push(item as unknown as Record<string, unknown>);
},
onCompleted: () => {
sawTerminalResponseEvent = true;
},
requestServiceTier: options?.serviceTier,
});
const localAbortReason = abortTracker.getLocalAbortReason();
if (localAbortReason) throw localAbortReason;
if (abortTracker.wasCallerAbort()) throw new AIError.AbortError();
// Detect premature stream closure: the HTTP stream ended without the
// provider sending a recognized terminal response event.
if (!sawTerminalResponseEvent) {
throw new AIError.ProviderResponseError(
"OpenAI responses stream closed before a terminal response event was received",
{ provider: model.provider, kind: "incomplete-stream" },
);
}
if (output.stopReason === "aborted" || output.stopReason === "error") {
throw new AIError.ProviderResponseError(output.errorMessage ?? "An unknown error occurred", {
provider: model.provider,
kind: "runtime",
});
}
forwardAttemptEvents();
break;
} catch (error) {
const streamFailure = abortTracker.getLocalAbortReason() ?? error;
const canRetry =
!sawReplayUnsafeOutput &&
!requestSignal.aborted &&
!abortTracker.wasCallerAbort() &&
transientStreamRetryAttempt < OPENAI_RESPONSES_MAX_TRANSIENT_STREAM_RETRIES &&
isRetryableOpenAIResponsesStreamFailure(streamFailure);
if (!canRetry) {
forwardAttemptEvents();
throw streamFailure;
}
transientStreamRetryAttempt++;
logger.debug("OpenAI responses stream ended before replay-unsafe output; retrying", {
provider: model.provider,
model: model.id,
attempt: transientStreamRetryAttempt,
error: streamFailure instanceof Error ? streamFailure.message : String(streamFailure),
});
const retryOutput = createInitialResponsesAssistantMessage(model.api, model.provider, model.id);
output.content.length = 0;
output.responseId = undefined;
output.upstreamProvider = undefined;
output.errorMessage = undefined;
output.errorStatus = undefined;
output.errorId = undefined;
output.stopDetails = undefined;
output.providerPayload = undefined;
output.usage = retryOutput.usage;
if (premiumRequestsTotal !== undefined) output.usage.premiumRequests = premiumRequestsTotal;
output.stopReason = "stop";
output.duration = undefined;
output.ttft = undefined;
firstTokenTime = undefined;
nativeOutputItems.length = 0;
if (options?.providerRetryWait) {
await options.providerRetryWait(OPENAI_RESPONSES_TRANSIENT_STREAM_RETRY_DELAY_MS, options.signal);
} else {
await scheduler.wait(OPENAI_RESPONSES_TRANSIENT_STREAM_RETRY_DELAY_MS, { signal: options?.signal });
}
if (abortTracker.wasCallerAbort()) throw new AIError.AbortError();
openaiStream = await openResponsesStreamWithFallbacks();
}
}
output.providerPayload = createOpenAIResponsesHistoryPayload(model.provider, nativeOutputItems);
@@ -995,7 +1099,15 @@ export function convertTools(
}
const strict = !NO_STRICT && strictMode && tool.strict !== false;
const baseParameters = toolWireSchema(tool);
const responseParameters = sanitizeSchemaForOpenAIResponses(baseParameters);
// MFJS must run AFTER the Responses sanitizer: the sanitizer normalizes
// `{}` → `true` (issue #1179), and Moonshot's validator rejects boolean
// subschemas ("property schema … must be an object"), so the Moonshot
// pass re-coerces them last.
const sanitized = sanitizeSchemaForOpenAIResponses(baseParameters);
const responseParameters =
model.compat.toolSchemaFlavor === "moonshot-mfjs"
? (normalizeSchemaForMoonshot(sanitized) as Record<string, unknown>)
: sanitized;
const { schema: parameters, strict: effectiveStrict } = adaptSchemaForStrict(responseParameters, strict);
// Quarantine a tool whose emitted schema carries a provider-rejecting
// enum/const-vs-type contradiction: dropping just that tool keeps the rest
+152 -22
View File
@@ -1,6 +1,6 @@
import type { Effort } from "@oh-my-pi/pi-catalog/effort";
import { toFirepassWireModelId, toFireworksWireModelId } from "@oh-my-pi/pi-catalog/fireworks-model-id";
import { isGlm52ReasoningEffortModelId } from "@oh-my-pi/pi-catalog/identity";
import { isGlm52ReasoningEffortModelId, isKimiK3ModelId } from "@oh-my-pi/pi-catalog/identity";
import { getSupportedEfforts } from "@oh-my-pi/pi-catalog/model-thinking";
import { calculateCost } from "@oh-my-pi/pi-catalog/models";
import type {
@@ -25,6 +25,7 @@ import {
classifyJsonPrefix,
extractHttpStatusFromError,
logger,
parseImageMetadata,
parseStreamingJson,
parseStreamingJsonThrottled,
stringifyJson,
@@ -100,6 +101,16 @@ import type {
import { transformMessages } from "./transform-messages";
import { joinTextWithImagePlaceholder, NON_VISION_IMAGE_PLACEHOLDER, partitionVisionContent } from "./vision-guard";
/**
* Keyless-provider sentinel. Custom providers configured with `auth: none`
* (models.yml) have no credential, so the coding-agent resolves their API key
* to this literal instead of a real secret. Providers must treat it as "no
* credential" and suppress any credential-bearing header (e.g. `Authorization:
* Bearer …`) rather than forwarding the sentinel on the wire. See #6188; the
* google-vertex and amazon-bedrock transports apply the same guard inline.
*/
export const NO_AUTH_SENTINEL = "N/A";
export interface OpenAIModelIdentity {
provider: string;
id: string;
@@ -278,7 +289,15 @@ export function resolveOpenAIRequestSetup(
baseUrl = baseUrl ?? ($env.OPENAI_BASE_URL?.trim() || options.defaultBaseUrl);
}
const requestHeaders = { ...headers };
headers.Authorization ??= `Bearer ${apiKey}`;
// A keyless provider (`auth: none` in models.yml) resolves to the `N/A`
// sentinel rather than a real key. Injecting `Authorization: Bearer N/A`
// breaks custom endpoints that authenticate via their own headers (e.g.
// `headers.x-api-key`) and reject the bogus bearer — mirror the sentinel
// guards in google-vertex / amazon-bedrock and send no Authorization here
// (#6188). A caller-supplied Authorization in `model.headers` still wins.
if (apiKey !== NO_AUTH_SENTINEL) {
headers.Authorization ??= `Bearer ${apiKey}`;
}
return { copilotPremiumRequests, baseUrl, headers, query, requestHeaders };
}
@@ -338,6 +357,29 @@ export function applyOpenAIResponsesServiceTierCost(
usage.cost.total = usage.cost.input + usage.cost.output + usage.cost.cacheRead + usage.cost.cacheWrite;
}
/** Reconcile token-price estimates with OpenRouter's authoritative account charge. */
export function applyOpenRouterReportedCost(model: Pick<Model, "provider">, usage: Usage, rawUsage: unknown): void {
if (model.provider !== "openrouter" || typeof rawUsage !== "object" || rawUsage === null) return;
const reportedCost = Reflect.get(rawUsage, "cost");
if (typeof reportedCost !== "number" || !Number.isFinite(reportedCost) || reportedCost < 0) return;
const estimatedCost = usage.cost.total;
if (Number.isFinite(estimatedCost) && estimatedCost > 0) {
const scale = reportedCost / estimatedCost;
usage.cost.input *= scale;
usage.cost.output *= scale;
usage.cost.cacheRead *= scale;
usage.cost.cacheWrite *= scale;
} else {
// Keep legacy component-only aggregators additive when catalog pricing is unavailable.
usage.cost.input = reportedCost;
usage.cost.output = 0;
usage.cost.cacheRead = 0;
usage.cost.cacheWrite = 0;
}
usage.cost.total = reportedCost;
}
export interface OpenAIUsageAccountingInput {
promptTokens: number;
outputTokens: number;
@@ -630,7 +672,7 @@ export type OpenAICompletionsParams = Omit<ChatCompletionCreateParamsStreaming,
top_k?: number;
min_p?: number;
repetition_penalty?: number;
thinking?: { type: "enabled" | "disabled"; keep?: "all" };
thinking?: { type: "enabled" | "disabled"; effort?: string; keep?: "all" };
enable_thinking?: boolean;
preserve_thinking?: boolean;
chat_template_kwargs?: { enable_thinking?: boolean; preserve_thinking?: boolean };
@@ -920,6 +962,11 @@ export function applyChatCompletionsCompatPolicy(params: OpenAICompletionsParams
encodeChatCompletionsDisabledReasoning(params, reasoning.disableMode);
return;
}
if (reasoning.dialect === "kimi" && reasoning.wireEffort !== undefined) {
params.thinking = { type: "enabled", effort: reasoning.wireEffort };
if (policy.compat.thinkingKeep) params.thinking.keep = policy.compat.thinkingKeep;
break;
}
params.thinking = { type: "enabled" };
if (policy.compat.thinkingKeep) params.thinking.keep = policy.compat.thinkingKeep;
if (policy.compat.supportsReasoningEffort && reasoning.wireEffort !== undefined) {
@@ -1001,16 +1048,24 @@ function isZaiReasoningEffortDialect(model: Model<"openai-completions">, compat:
}
/**
* Output-token clamp for the Z.AI/GLM-5.2 reasoning dialect: these hosts accept
* the full model window on reasoning turns, so clamp to the model cap. Returns
* `undefined` for every other model, leaving {@link resolveOpenAIOutputTokenParam}
* on its default `OPENAI_MAX_OUTPUT_TOKENS` clamp.
* Provider-specific Chat Completions output clamp.
*
* Most OpenAI-compatible endpoints retain the conservative 64k ceiling from
* {@link resolveOpenAIOutputTokenParam}. Z.AI/GLM-5.2 reasoning and native
* Moonshot K3 explicitly accept their full advertised model caps, so those
* routes clamp to `model.maxTokens` instead.
*/
export function resolveZaiReasoningOutputClamp(
export function resolveOpenAICompletionsOutputClamp(
model: Model<"openai-completions">,
compat: ResolvedOpenAICompat,
): number | undefined {
return isZaiReasoningEffortDialect(model, compat) ? (model.maxTokens ?? OPENAI_MAX_OUTPUT_TOKENS) : undefined;
if (isZaiReasoningEffortDialect(model, compat)) {
return model.maxTokens ?? OPENAI_MAX_OUTPUT_TOKENS;
}
if (model.provider === "moonshot" && isKimiK3ModelId(model.id)) {
return model.maxTokens ?? OPENAI_MAX_OUTPUT_TOKENS;
}
return undefined;
}
/**
@@ -1702,6 +1757,21 @@ export function convertResponsesAssistantMessage<TApi extends Api>(
return outputItems;
}
const syntheticToolImageMessages = new WeakSet<object>();
function insertResponsesToolOutput(messages: ResponseInput, output: ResponseInput[number]): void {
let index = messages.length;
while (index > 0) {
const previous = messages[index - 1];
if (typeof previous !== "object" || previous === null || !syntheticToolImageMessages.has(previous)) {
break;
}
index -= 1;
}
messages.splice(index, 0, output);
}
/** Appends one tool result while keeping consecutive outputs ahead of its synthetic image messages. */
export function appendResponsesToolResultMessages<TApi extends Api>(
messages: ResponseInput,
toolResult: ToolResultMessage,
@@ -1748,13 +1818,13 @@ export function appendResponsesToolResultMessages<TApi extends Api>(
return;
}
if (supportsCustomToolCalls && customCallIds?.has(normalized.callId)) {
messages.push({
insertResponsesToolOutput(messages, {
type: "custom_tool_call_output",
call_id: normalized.callId,
output,
} as ResponseInput[number]);
} else {
messages.push({
insertResponsesToolOutput(messages, {
type: "function_call_output",
call_id: normalized.callId,
output,
@@ -1777,7 +1847,9 @@ export function appendResponsesToolResultMessages<TApi extends Api>(
} satisfies ResponseInputImage);
}
}
messages.push({ role: "user", content: contentParts });
const imageMessage = { role: "user", content: contentParts } satisfies ResponseInput[number];
syntheticToolImageMessages.add(imageMessage);
messages.push(imageMessage);
}
/**
@@ -2429,6 +2501,7 @@ export async function processResponsesStream<TApi extends Api>(
const entry = lookupOpenToolCallAlias(event, "custom_tool_call");
if (entry?.item.type === "custom_tool_call" && entry.block.type === "toolCall") {
finalizeCustomToolCallInputDone(entry.block, event.input);
entry.block[kStreamingArgumentsDone] = true;
}
} else if (event.type === "response.output_item.done") {
const item = structuredCloneJSON(event.item);
@@ -2532,15 +2605,33 @@ export async function processResponsesStream<TApi extends Api>(
}
closeOpenItem(event.output_index, item.id, entry, item.call_id, prefixedFunctionCallItemKey(item.call_id));
stream.push({ type: "toolcall_end", contentIndex, toolCall, partial: output });
} else if (item.type === "image_generation_call" && item.status === "completed" && item.result) {
const image: ImageContent = {
type: "image",
data: item.result,
mimeType: parseImageMetadata(Buffer.from(item.result, "base64"))?.mimeType ?? "image/png",
};
output.content.push(image);
stream.push({
type: "image_end",
contentIndex: output.content.length - 1,
content: image,
partial: output,
});
}
} else if (terminalEvent) {
const response = terminalEvent.response;
const shouldPromoteIncompleteToolUse =
response?.status === "incomplete" &&
response.incomplete_details?.reason === "max_output_tokens" &&
hasExecutableIncompleteResponsesToolCalls(output);
finalizePendingResponsesToolCalls(output);
if (response?.id) {
output.responseId = response.id;
}
populateResponsesUsageFromResponse(output, response?.usage);
calculateCost(model, output.usage);
applyOpenRouterReportedCost(model, output.usage, response?.usage);
applyOpenAIResponsesServiceTierCost(
model,
output.usage,
@@ -2570,7 +2661,11 @@ export async function processResponsesStream<TApi extends Api>(
kind: "content-blocked",
});
}
promoteResponsesToolUseStopReason(output, (response as { end_turn?: boolean } | undefined)?.end_turn);
promoteResponsesToolUseStopReason(
output,
(response as { end_turn?: boolean } | undefined)?.end_turn,
shouldPromoteIncompleteToolUse,
);
options?.onCompleted?.();
// `response.completed`/`response.incomplete`/`response.done` is the last event of a
// Responses stream. Stop pulling instead of waiting for the server to
@@ -2626,6 +2721,28 @@ export function mapOpenAIResponsesStopReason(status: ResponseStatus | undefined)
}
}
function hasExecutableIncompleteResponsesToolCalls(output: AssistantMessage): boolean {
let hasToolCall = false;
for (const block of output.content) {
if (block.type !== "toolCall") continue;
hasToolCall = true;
const pending = block as ToolCall & {
[kStreamingPartialJson]?: string;
[kStreamingArgumentsDone]?: boolean;
};
const rawArguments = pending[kStreamingPartialJson];
// `output_item.done` is not positive completion proof: our Responses
// compatibility encoder force-closes still-open calls before forwarding an
// upstream `length` stop. Only an explicit arguments/input-done event sets
// this marker; an open ordinary call can instead prove completion with its
// retained strict-complete JSON.
if (pending[kStreamingArgumentsDone]) continue;
if (pending.customWireName !== undefined || rawArguments === undefined) return false;
if (classifyJsonPrefix(rawArguments) !== "complete") return false;
}
return hasToolCall;
}
/**
* Finalize any streamed toolCall block whose `output_item.done` never arrived
* (lossy proxy, or a terminal event that raced the per-item done): parse the
@@ -2659,8 +2776,15 @@ export function finalizePendingResponsesToolCalls(output: AssistantMessage): voi
* re-samples instead of ending. Callers set `output.stopReason` from the wire
* status first via {@link mapOpenAIResponsesStopReason}.
*/
export function promoteResponsesToolUseStopReason(output: AssistantMessage, endTurn: boolean | undefined): void {
if (output.content.some(block => block.type === "toolCall") && output.stopReason === "stop") {
export function promoteResponsesToolUseStopReason(
output: AssistantMessage,
endTurn: boolean | undefined,
promoteIncompleteToolUse = false,
): void {
if (
output.content.some(block => block.type === "toolCall") &&
(output.stopReason === "stop" || (promoteIncompleteToolUse && output.stopReason === "length"))
) {
output.stopReason = "toolUse";
}
if (endTurn === false && output.stopReason === "stop") {
@@ -2717,7 +2841,9 @@ type CommonSamplingOptions = Pick<
export function applyCommonResponsesSamplingParams<P extends CommonResponsesParams>(
params: P,
options: CommonSamplingOptions | undefined,
model: Pick<Model, "provider" | "api" | "id" | "omitMaxOutputTokens" | "maxTokens">,
model: Pick<Model, "provider" | "api" | "id" | "omitMaxOutputTokens" | "maxTokens"> & {
compat: Pick<ResolvedOpenAISharedCompat, "supportsSamplingParams">;
},
): void {
if (options?.maxTokens && !model.omitMaxOutputTokens) {
params.max_output_tokens = Math.min(
@@ -2726,12 +2852,16 @@ export function applyCommonResponsesSamplingParams<P extends CommonResponsesPara
OPENAI_MAX_OUTPUT_TOKENS,
);
}
if (options?.temperature !== undefined) params.temperature = options.temperature;
if (options?.topP !== undefined) params.top_p = options.topP;
if (options?.topK !== undefined) params.top_k = options.topK;
if (options?.minP !== undefined) params.min_p = options.minP;
if (options?.presencePenalty !== undefined) params.presence_penalty = options.presencePenalty;
if (options?.repetitionPenalty !== undefined) params.repetition_penalty = options.repetitionPenalty;
// OpenAI proprietary reasoning models (o-series, gpt-5+) reject explicit
// sampling params with a 400 on every serving host (#5606).
if (model.compat.supportsSamplingParams) {
if (options?.temperature !== undefined) params.temperature = options.temperature;
if (options?.topP !== undefined) params.top_p = options.topP;
if (options?.topK !== undefined) params.top_k = options.topK;
if (options?.minP !== undefined) params.min_p = options.minP;
if (options?.presencePenalty !== undefined) params.presence_penalty = options.presencePenalty;
if (options?.repetitionPenalty !== undefined) params.repetition_penalty = options.repetitionPenalty;
}
applyOpenAIServiceTier(params, options?.serviceTier, model);
}
+171 -1
View File
@@ -1,5 +1,14 @@
import { renderDemotedThinking } from "../dialect/demotion";
import type { Api, AssistantMessage, Message, Model, ToolCall, ToolResultMessage, UserMessage } from "../types";
import type {
Api,
AssistantMessage,
DeveloperMessage,
Message,
Model,
ToolCall,
ToolResultMessage,
UserMessage,
} from "../types";
import { isDemotedThinking, kDemotedThinking } from "../utils/block-symbols";
const enum ToolCallStatus {
@@ -286,6 +295,156 @@ function normalizeAnthropicTargetToolCallId<TApi extends Api>(
* - Preserves tool call structure (unlike converting to text summaries)
* - Injects synthetic "aborted" tool results
*/
const SENSITIVE_TOKEN_RE =
/(?<![a-zA-Z0-9_*-])(gh[opusr]_[a-zA-Z0-9_*]{36,}|github_pat_[a-zA-Z0-9_*]{36,}|glpat-[a-zA-Z0-9_*-]{20,}|sk-proj-[a-zA-Z0-9_*-]{36,}|sk-ant-[a-zA-Z0-9_*-]{36,}|sk-[a-zA-Z0-9_*-]{48,})(?![a-zA-Z0-9_*-])/gi;
function hasPlausibleCredentialEntropy(token: string): boolean {
const lower = token.toLowerCase();
const prefixLength = lower.startsWith("github_pat_")
? "github_pat_".length
: lower.startsWith("glpat-")
? "glpat-".length
: lower.startsWith("sk-proj-")
? "sk-proj-".length
: lower.startsWith("sk-ant-")
? "sk-ant-".length
: lower.startsWith("gh")
? 4
: 3;
const secret = token.slice(prefixLength);
if (/^\*+$/.test(secret)) return true;
return [/[a-z]/, /[A-Z]/, /\d/, /[_-]/].filter(pattern => pattern.test(secret)).length >= 2;
}
export function redactSensitiveCredentials(text: string): string {
return text.replace(SENSITIVE_TOKEN_RE, match => {
if (!hasPlausibleCredentialEntropy(match)) return match;
const lower = match.toLowerCase();
if (lower.startsWith("gh")) {
return "[github_token_redacted]";
}
if (lower.startsWith("gl")) {
return "[gitlab_token_redacted]";
}
if (lower.startsWith("sk-ant-")) {
return "[anthropic_token_redacted]";
}
if (lower.startsWith("sk")) {
return "[openai_token_redacted]";
}
return "[token_redacted]";
});
}
export function redactSensitiveInObject(val: unknown): { result: unknown; changed: boolean } {
if (typeof val === "string") {
const redacted = redactSensitiveCredentials(val);
return { result: redacted, changed: redacted !== val };
}
if (Array.isArray(val)) {
let changed = false;
const result = val.map(item => {
const res = redactSensitiveInObject(item);
if (res.changed) changed = true;
return res.result;
});
return { result, changed };
}
if (val !== null && typeof val === "object") {
let changed = false;
const res: Record<string, unknown> = {};
for (const [k, v] of Object.entries(val)) {
const sub = redactSensitiveInObject(v);
if (sub.changed) changed = true;
res[k] = sub.result;
}
return { result: res, changed };
}
return { result: val, changed: false };
}
function redactSensitiveCredentialsInMessages(messages: Message[]): Message[] {
return messages.map((msg): Message => {
if (msg.role === "user" || msg.role === "developer") {
const userMsg = msg as UserMessage | DeveloperMessage;
if (typeof userMsg.content === "string") {
const redacted = redactSensitiveCredentials(userMsg.content);
if (redacted === userMsg.content) return msg;
return { ...userMsg, content: redacted } as Message;
}
const contentArray = userMsg.content;
let changed = false;
const content = contentArray.map((block): UserMessage["content"][number] => {
if (block.type === "text") {
const redacted = redactSensitiveCredentials(block.text);
if (redacted !== block.text) {
changed = true;
return { ...block, text: redacted };
}
}
return block;
});
return (changed ? { ...userMsg, content } : userMsg) as Message;
}
if (msg.role === "toolResult") {
const toolResultMsg = msg as ToolResultMessage;
let changed = false;
const content = toolResultMsg.content.map((block): ToolResultMessage["content"][number] => {
if (block.type === "text") {
const redacted = redactSensitiveCredentials(block.text);
if (redacted !== block.text) {
changed = true;
return { ...block, text: redacted };
}
}
return block;
});
return (changed ? { ...toolResultMsg, content } : toolResultMsg) as Message;
}
if (msg.role === "assistant") {
const assistantMsg = msg as AssistantMessage;
let changed = false;
const content = assistantMsg.content.map((block): AssistantMessage["content"][number] => {
if (block.type === "text") {
const redacted = redactSensitiveCredentials(block.text);
if (redacted !== block.text) {
changed = true;
return { ...block, text: redacted };
}
} else if (block.type === "thinking") {
const redacted = redactSensitiveCredentials(block.thinking);
if (redacted !== block.thinking) {
changed = true;
return { ...block, thinking: redacted, thinkingSignature: undefined };
}
} else if (block.type === "toolCall") {
if (block.arguments) {
const { result: redactedArgs, changed: argsChanged } = redactSensitiveInObject(block.arguments);
if (argsChanged) {
changed = true;
const castArgs =
redactedArgs && typeof redactedArgs === "object" && !Array.isArray(redactedArgs)
? (redactedArgs as Record<string, unknown>)
: undefined;
return {
...block,
arguments: castArgs,
thoughtSignature: undefined,
} as AssistantMessage["content"][number];
}
}
}
return block;
});
return (changed ? { ...assistantMsg, content } : assistantMsg) as Message;
}
return msg;
});
}
export function transformMessages<TApi extends Api>(
messages: Message[],
model: Model<TApi>,
@@ -294,6 +453,10 @@ export function transformMessages<TApi extends Api>(
duplicateToolCallIdSuffixPrefix = "_dup",
targetCompat: Model<TApi>["compat"] = model.compat,
): Message[] {
// Redact sensitive credential-like patterns from all outbound messages
// to prevent security block errors from LLM providers (e.g. invalid_prompt).
messages = redactSensitiveCredentialsInMessages(messages);
// Drop assistant `toolCall` blocks with empty/whitespace `id` or `name`
// (and their matched `toolResult` messages) before anything else looks at
// the history. Replays of these would 400 every provider — see
@@ -554,6 +717,13 @@ export function transformMessages<TApi extends Api>(
return [];
}
if (block.type === "image") {
// Assistant images are display artifacts. No provider accepts them
// in an assistant replay turn; the native Responses result remains
// in providerPayload for OpenAI replay.
return [];
}
if (block.type === "text") {
if (isSameModel) return block;
return {
@@ -71,13 +71,22 @@ export async function loginAlibabaCodingPlan(options: OAuthController): Promise<
}
options.onProgress?.("Validating API key...");
await apiKeyValidation.validateOpenAICompatibleApiKey({
provider: "Alibaba Coding Plan",
apiKey: trimmed,
baseUrl,
model: VALIDATION_MODEL,
signal: options.signal,
});
if (choice === "3") {
await apiKeyValidation.validateApiKeyAgainstModelsEndpoint({
provider: "Alibaba Coding Plan",
apiKey: trimmed,
modelsUrl: `${baseUrl}/models`,
signal: options.signal,
});
} else {
await apiKeyValidation.validateOpenAICompatibleApiKey({
provider: "Alibaba Coding Plan",
apiKey: trimmed,
baseUrl,
model: VALIDATION_MODEL,
signal: options.signal,
});
}
return {
access: trimmed,
+5 -2
View File
@@ -31,7 +31,7 @@ type AnthropicMessagesValidation = {
type ModelsEndpointValidation = {
kind: "models-endpoint";
provider: string;
modelsUrl: string;
modelsUrl: string | (() => string);
headers?: Record<string, string> | (() => Record<string, string> | undefined);
};
@@ -99,7 +99,10 @@ export function createApiKeyLogin(config: ApiKeyLoginConfig): (options: OAuthCon
await validateApiKeyAgainstModelsEndpoint({
provider: config.validation.provider,
apiKey: trimmed,
modelsUrl: config.validation.modelsUrl,
modelsUrl:
typeof config.validation.modelsUrl === "function"
? config.validation.modelsUrl()
: config.validation.modelsUrl,
headers: config.validation.headers,
signal: options.signal,
fetch: options.fetch,
+7 -1
View File
@@ -1,7 +1,13 @@
import { $env } from "@oh-my-pi/pi-utils";
import { createApiKeyLogin } from "./api-key-login";
import type { OAuthLoginCallbacks } from "./oauth/types";
import type { ProviderDefinition } from "./types";
function resolveMoonshotModelsUrl(): string {
const baseUrl = $env.MOONSHOT_BASE_URL?.trim() || "https://api.moonshot.ai/v1";
return `${baseUrl.replace(/\/+$/, "")}/models`;
}
export const loginMoonshot = createApiKeyLogin({
providerLabel: "Moonshot",
authUrl: "https://platform.moonshot.ai/console/api-keys",
@@ -11,7 +17,7 @@ export const loginMoonshot = createApiKeyLogin({
validation: {
kind: "models-endpoint",
provider: "moonshot",
modelsUrl: "https://api.moonshot.ai/v1/models",
modelsUrl: resolveMoonshotModelsUrl,
},
});
@@ -1,19 +1,35 @@
import { afterEach, describe, expect, it, vi } from "bun:test";
import { isXAIAccessTokenExpiring, loginXAIOAuth, refreshXAIOAuthToken, validateXAIEndpoint } from "../xai-oauth";
import {
buildXAICliBillingUrl,
extractXAIAccessTokenSubject,
fetchXAIOAuthIdentity,
getXAICliBillingHeaders,
isXAIAccessTokenExpiring,
loginXAIOAuth,
parseXAIAccessTokenPayload,
refreshXAIOAuthToken,
validateXAIBillingEndpoint,
validateXAIEndpoint,
} from "../xai-oauth";
afterEach(() => {
vi.restoreAllMocks();
});
function jwtWithExp(exp: number): string {
return jwtWithPayload({ exp });
}
function jwtWithPayload(payload: Record<string, unknown>): string {
const header = Buffer.from(JSON.stringify({ alg: "HS256", typ: "JWT" })).toString("base64url");
const payload = Buffer.from(JSON.stringify({ exp })).toString("base64url");
return `${header}.${payload}.sig`;
const encodedPayload = Buffer.from(JSON.stringify(payload)).toString("base64url");
return `${header}.${encodedPayload}.sig`;
}
const DISCOVERY_URL = "https://auth.x.ai/.well-known/openid-configuration";
const DEVICE_CODE_URL = "https://auth.x.ai/oauth2/device/code";
const TOKEN_ENDPOINT = "https://auth.x.ai/oauth2/token";
const USERINFO_URL = "https://auth.x.ai/oauth2/userinfo";
const CLIENT_ID = "b1a00492-073a-47ea-816f-4c329264a828";
const SCOPE = "openid profile email offline_access grok-cli:access api:access";
@@ -43,7 +59,10 @@ function jsonResponse(body: unknown, status: number = 200): Response {
});
}
function createDeviceFlowFetch(tokenResponses: readonly TokenResponse[]) {
function createDeviceFlowFetch(
tokenResponses: readonly TokenResponse[],
userinfoResponse: TokenResponse = { body: {} },
) {
const requests: RecordedRequest[] = [];
let tokenResponseIndex = 0;
const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
@@ -64,6 +83,9 @@ function createDeviceFlowFetch(tokenResponses: readonly TokenResponse[]) {
}
return jsonResponse(tokenResponse.body, tokenResponse.status);
}
if (url === USERINFO_URL) {
return jsonResponse(userinfoResponse.body, userinfoResponse.status);
}
throw new Error(`Unexpected xAI OAuth request: ${url}`);
});
@@ -101,6 +123,109 @@ describe("isXAIAccessTokenExpiring", () => {
});
});
describe("xAI OAuth helpers", () => {
it("parses JWT payloads and extracts subjects", () => {
const token = jwtWithPayload({ sub: " subject-123 ", exp: 1_900_000_000 });
expect(parseXAIAccessTokenPayload(token)).toEqual({ sub: " subject-123 ", exp: 1_900_000_000 });
expect(extractXAIAccessTokenSubject(token)).toBe("subject-123");
expect(parseXAIAccessTokenPayload("not-a-jwt")).toBeNull();
expect(extractXAIAccessTokenSubject("not-a-jwt")).toBeUndefined();
});
it("builds the billing URL and CLI-aligned headers", () => {
expect(buildXAICliBillingUrl()).toBe("https://cli-chat-proxy.grok.com/v1/billing?format=credits");
expect(buildXAICliBillingUrl("tokens")).toBe("https://cli-chat-proxy.grok.com/v1/billing?format=tokens");
expect(getXAICliBillingHeaders({ accessToken: "access-token" })).toEqual({
Authorization: "Bearer access-token",
Accept: "application/json",
"X-XAI-Token-Auth": "xai-grok-cli",
});
});
it("pins SuperGrok billing URLs to https grok.com hosts", () => {
expect(validateXAIBillingEndpoint("https://cli-chat-proxy.grok.com/v1/billing")).toBe(
"https://cli-chat-proxy.grok.com/v1/billing",
);
expect(() => validateXAIBillingEndpoint("https://auth.x.ai/v1/billing")).toThrow(/Invalid xAI billing_url/);
expect(() => validateXAIBillingEndpoint("http://cli-chat-proxy.grok.com/v1/billing")).toThrow(
/Invalid xAI billing_url/,
);
expect(() => validateXAIBillingEndpoint("https://evil.com/v1/billing")).toThrow(/Invalid xAI billing_url/);
});
it("normalizes OIDC userinfo identity", async () => {
const requests: RecordedRequest[] = [];
const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
requests.push({
url: typeof input === "string" ? input : input instanceof Request ? input.url : input.toString(),
init,
});
return jsonResponse({ sub: "profile-sub", email: "User@Example.com", name: "User" });
});
await expect(fetchXAIOAuthIdentity("access-token", fetchMock as unknown as typeof fetch)).resolves.toEqual({
accountId: "profile-sub",
email: "user@example.com",
name: "User",
});
expect(requests[0]?.url).toBe(USERINFO_URL);
expect(new Headers(requests[0]?.init?.headers)).toEqual(
new Headers({ Authorization: "Bearer access-token", Accept: "application/json" }),
);
expect(requests[0]?.init?.redirect).toBe("error");
});
it("combines caller cancellation with the 15-second userinfo timeout", async () => {
const timeoutControllers: AbortController[] = [];
const timeoutSpy = vi.spyOn(AbortSignal, "timeout").mockImplementation(timeoutMs => {
expect(timeoutMs).toBe(15_000);
const controller = new AbortController();
timeoutControllers.push(controller);
return controller.signal;
});
const requests: RecordedRequest[] = [];
const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
requests.push({
url: typeof input === "string" ? input : input instanceof Request ? input.url : input.toString(),
init,
});
const { promise, reject } = Promise.withResolvers<Response>();
const requestSignal = init?.signal;
if (!requestSignal) {
reject(new Error("expected userinfo request signal"));
} else if (requestSignal.aborted) {
reject(requestSignal.reason);
} else {
requestSignal.addEventListener("abort", () => reject(requestSignal.reason), { once: true });
}
return promise;
});
const callerController = new AbortController();
const callerCancelled = fetchXAIOAuthIdentity(
"access-token",
fetchMock as unknown as typeof fetch,
callerController.signal,
);
callerController.abort();
await expect(callerCancelled).resolves.toBeNull();
expect(requests[0]?.init?.signal).not.toBe(callerController.signal);
expect(timeoutSpy).toHaveBeenCalledWith(15_000);
const timeoutCancelled = fetchXAIOAuthIdentity(
"access-token",
fetchMock as unknown as typeof fetch,
new AbortController().signal,
);
const timeoutController = timeoutControllers[1];
expect(timeoutController).toBeDefined();
timeoutController?.abort();
await expect(timeoutCancelled).resolves.toBeNull();
expect(requests[1]?.init?.signal).not.toBe(timeoutController?.signal);
});
});
describe("validateXAIEndpoint", () => {
it("rejects non-HTTPS URLs", () => {
expect(() => validateXAIEndpoint("http://x.ai/token", "token_endpoint")).toThrow(/Invalid xAI token_endpoint/);
@@ -131,6 +256,35 @@ describe("refreshXAIOAuthToken", () => {
);
expect(fetchMock).not.toHaveBeenCalled();
});
it("persists refreshed OAuth identity from OIDC userinfo", async () => {
const accessToken = jwtWithPayload({ sub: "jwt-sub" });
const requests: RecordedRequest[] = [];
const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
const url = typeof input === "string" ? input : input instanceof Request ? input.url : input.toString();
requests.push({ url, init });
if (url === DISCOVERY_URL) return jsonResponse({ token_endpoint: TOKEN_ENDPOINT });
if (url === TOKEN_ENDPOINT) {
return jsonResponse({
access_token: accessToken,
expires_in: 3600,
});
}
if (url === USERINFO_URL) return jsonResponse({ sub: "profile-sub", email: "User@Example.com" });
throw new Error(`Unexpected xAI OAuth request: ${url}`);
});
await expect(
refreshXAIOAuthToken("old-refresh-token", fetchMock as unknown as typeof fetch),
).resolves.toMatchObject({
access: accessToken,
refresh: "old-refresh-token",
accountId: "profile-sub",
email: "user@example.com",
});
expect(requests.map(request => request.url)).toEqual([DISCOVERY_URL, TOKEN_ENDPOINT, USERINFO_URL]);
expect(requests.find(request => request.url === TOKEN_ENDPOINT)?.init?.redirect).toBe("error");
});
});
describe("loginXAIOAuth", () => {
@@ -165,7 +319,12 @@ describe("loginXAIOAuth", () => {
onManualCodeInput,
});
expect(requests.map(request => request.url)).toEqual([DISCOVERY_URL, DEVICE_CODE_URL, TOKEN_ENDPOINT]);
expect(requests.map(request => request.url)).toEqual([
DISCOVERY_URL,
DEVICE_CODE_URL,
TOKEN_ENDPOINT,
USERINFO_URL,
]);
const discoveryRequest = requests[0];
expect(discoveryRequest?.init?.method).toBe("GET");
@@ -230,6 +389,7 @@ describe("loginXAIOAuth", () => {
const tokenRequests = requests.filter(request => request.url === TOKEN_ENDPOINT);
expect(tokenRequests).toHaveLength(3);
expect(tokenRequests.every(request => request.init?.redirect === "error")).toBe(true);
expect(tokenRequests.map(request => Object.fromEntries(requestForm(request)))).toEqual([
{
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
@@ -252,6 +412,54 @@ describe("loginXAIOAuth", () => {
expect(credentials.refresh).toBe("eventual-refresh-token");
});
it("retains the JWT subject and succeeds when OIDC userinfo fails", async () => {
const accessToken = jwtWithPayload({ sub: "jwt-sub" });
const controller = new AbortController();
const { fetchMock, requests } = createDeviceFlowFetch(
[
{
body: {
access_token: accessToken,
refresh_token: "refresh-token",
expires_in: 3600,
},
},
],
{ body: { error: "userinfo unavailable" }, status: 503 },
);
const credentials = await loginXAIOAuth({ fetch: fetchMock, signal: controller.signal });
expect(credentials).toMatchObject({
access: accessToken,
refresh: "refresh-token",
accountId: "jwt-sub",
});
expect(requests.at(-1)?.url).toBe(USERINFO_URL);
expect(requests.at(-1)?.init?.signal).not.toBe(controller.signal);
});
it("keeps the JWT subject when userinfo returns only an email", async () => {
const accessToken = jwtWithPayload({ sub: "jwt-sub" });
const { fetchMock } = createDeviceFlowFetch(
[
{
body: {
access_token: accessToken,
refresh_token: "refresh-token",
expires_in: 3600,
},
},
],
{ body: { email: "User@Example.com" } },
);
await expect(loginXAIOAuth({ fetch: fetchMock })).resolves.toMatchObject({
accountId: "jwt-sub",
email: "user@example.com",
});
});
it("rejects a token response that omits access_token", async () => {
const { fetchMock, requests } = createDeviceFlowFetch([
{
+17 -9
View File
@@ -7,7 +7,7 @@ import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { scheduler } from "node:timers/promises";
import { $env, getAgentDir, isEnoent } from "@oh-my-pi/pi-utils";
import { $env, getAgentDir } from "@oh-my-pi/pi-utils";
import packageJson from "../../../package.json" with { type: "json" };
import * as AIError from "../../error";
import type { OAuthController, OAuthCredentials } from "./types";
@@ -57,21 +57,29 @@ function getDeviceModel(): string {
return formatDeviceModel(label, release, arch);
}
// Device id identifies this install to Kimi. Persistence is best-effort: a
// missing/unwritable agent dir must never break header construction (and with
// it every usage probe / request that spreads getKimiCommonHeaders()) — fall
// back to a per-process ephemeral id instead.
let getDeviceId = (): string => {
const deviceIdPath = path.join(getAgentDir(), DEVICE_ID_FILENAME);
try {
const existing = fs.readFileSync(deviceIdPath, "utf-8");
const trimmed = existing.trim();
if (trimmed) {
getDeviceId = () => trimmed;
return trimmed;
const existing = fs.readFileSync(deviceIdPath, "utf-8").trim();
if (existing) {
getDeviceId = () => existing;
return existing;
}
} catch (error) {
if (!isEnoent(error)) throw error;
} catch {
// Unreadable device-id file: regenerate below.
}
const deviceId = crypto.randomUUID().replace(/-/g, "");
fs.writeFileSync(deviceIdPath, `${deviceId}\n`, { mode: 0o600 });
try {
fs.mkdirSync(path.dirname(deviceIdPath), { recursive: true });
fs.writeFileSync(deviceIdPath, `${deviceId}\n`, { mode: 0o600 });
} catch {
// Persist failure → ephemeral id for this process.
}
getDeviceId = () => deviceId;
return deviceId;
};
+22 -2
View File
@@ -31,6 +31,7 @@ const DEVICE_MAX_POLLS = 120;
type JwtPayload = {
[JWT_CLAIM_PATH]?: {
chatgpt_account_id?: string;
chatgpt_plan_type?: string;
};
[JWT_PROFILE_CLAIM]?: {
email?: string;
@@ -50,14 +51,27 @@ export function decodeJwt<T = Record<string, unknown>>(token: string): T | null
}
}
function getTokenProfile(accessToken: string): { accountId?: string; email?: string } {
/**
* Identity slice decoded from the token claims. The ChatGPT workspace
* (`chatgpt_account_id`) is the subscription pool the token draws limits
* from — one account email can hold several (e.g. a personal Pro plan plus a
* Team seat). `chatgpt_plan_type` may only be present on the `id_token`.
*/
function getTokenProfile(
accessToken: string,
idToken?: string,
): { accountId?: string; email?: string; planType?: string } {
const payload = decodeJwt<JwtPayload>(accessToken);
const idPayload = idToken ? decodeJwt<JwtPayload>(idToken) : null;
const auth = payload?.[JWT_CLAIM_PATH];
const idAuth = idPayload?.[JWT_CLAIM_PATH];
const accountId = auth?.chatgpt_account_id;
const email = payload?.[JWT_PROFILE_CLAIM]?.email?.trim().toLowerCase();
const planType = (auth?.chatgpt_plan_type ?? idAuth?.chatgpt_plan_type)?.trim().toLowerCase();
return {
accountId: typeof accountId === "string" && accountId.length > 0 ? accountId : undefined,
email: typeof email === "string" && email.length > 0 ? email : undefined,
planType: typeof planType === "string" && planType.length > 0 ? planType : undefined,
};
}
@@ -185,6 +199,7 @@ async function exchangeCodeForToken(
const tokenData = (await tokenResponse.json()) as {
access_token?: string;
refresh_token?: string;
id_token?: string;
expires_in?: number;
};
@@ -192,7 +207,7 @@ async function exchangeCodeForToken(
throw new AIError.OAuthError("Token response missing required fields", { kind: "validation" });
}
const { accountId, email } = getTokenProfile(tokenData.access_token);
const { accountId, email, planType } = getTokenProfile(tokenData.access_token, tokenData.id_token);
if (!accountId) {
throw new AIError.OAuthError("Failed to extract accountId from token", { kind: "validation" });
}
@@ -203,6 +218,8 @@ async function exchangeCodeForToken(
expires: Date.now() + tokenData.expires_in * 1000,
accountId,
email,
orgId: accountId,
orgName: planType,
};
}
@@ -354,6 +371,9 @@ export async function refreshOpenAICodexToken(refreshToken: string): Promise<OAu
const { accountId, email } = getTokenProfile(tokenData.access_token);
// Deliberately no org fields on the result: the workspace a credential is
// scoped to is fixed at login. Callers merge refresh results over the
// stored credential, so omitting org here preserves it verbatim.
return {
access: tokenData.access_token,
refresh: tokenData.refresh_token || refreshToken,
+3 -2
View File
@@ -12,8 +12,9 @@ export type OAuthCredentials = {
apiEndpoint?: string;
/**
* Organization/workspace the token is scoped to (e.g. an Anthropic org
* UUID). Captured once at login; token refreshes never rewrite it. Lets
* one account email hold credentials for multiple subscriptions.
* UUID or a ChatGPT workspace id). Captured once at login; token refreshes
* never rewrite it. Lets one account email hold credentials for multiple
* subscriptions.
*/
orgId?: string;
/** Human-readable organization name for display (may embed the email). */
+146 -17
View File
@@ -15,8 +15,12 @@ import type { OAuthController, OAuthCredentials } from "./types";
const XAI_OAUTH_ISSUER = "https://auth.x.ai";
const XAI_OAUTH_DISCOVERY_URL = `${XAI_OAUTH_ISSUER}/.well-known/openid-configuration`;
const XAI_OAUTH_DEVICE_CODE_URL = `${XAI_OAUTH_ISSUER}/oauth2/device/code`;
const XAI_OAUTH_USERINFO_URL = `${XAI_OAUTH_ISSUER}/oauth2/userinfo`;
const XAI_OAUTH_CLIENT_ID = "b1a00492-073a-47ea-816f-4c329264a828";
const XAI_OAUTH_SCOPE = "openid profile email offline_access grok-cli:access api:access";
const XAI_CLI_BILLING_BASE_URL = "https://cli-chat-proxy.grok.com";
const XAI_CLI_BILLING_PATH = "/v1/billing";
const XAI_CLI_BILLING_FORMAT = "credits";
// Mirrors the 5-min skew used by anthropic.ts:160 — keeps every provider on the
// same conservative client-side expiry window.
@@ -51,6 +55,15 @@ function isRecord(value: unknown): value is Record<string, unknown> {
* @throws Error with message `Invalid xAI <field>: <url>` when the URL fails
* either scheme or host validation.
*/
function isXaiAuthHostname(host: string): boolean {
return host === "x.ai" || host.endsWith(".x.ai");
}
/** SuperGrok CLI billing proxy host (`cli-chat-proxy.grok.com`), not the OIDC issuer. */
function isXaiBillingHostname(host: string): boolean {
return host === "grok.com" || host.endsWith(".grok.com");
}
export function validateXAIEndpoint(url: string, field: string): string {
let parsed: URL;
try {
@@ -62,7 +75,28 @@ export function validateXAIEndpoint(url: string, field: string): string {
throw new AIError.OAuthError(`Invalid xAI ${field}: ${url}`, { kind: "validation", provider: "xai" });
}
const host = parsed.hostname.toLowerCase();
if (!host || (host !== "x.ai" && !host.endsWith(".x.ai"))) {
if (!host || !isXaiAuthHostname(host)) {
throw new AIError.OAuthError(`Invalid xAI ${field}: ${url}`, { kind: "validation", provider: "xai" });
}
return url;
}
/**
* Pin SuperGrok billing URLs to HTTPS `grok.com` / `*.grok.com`.
* The CLI billing proxy is intentionally not on `*.x.ai`.
*/
export function validateXAIBillingEndpoint(url: string, field: string = "billing_url"): string {
let parsed: URL;
try {
parsed = new URL(url);
} catch {
throw new AIError.OAuthError(`Invalid xAI ${field}: ${url}`, { kind: "validation", provider: "xai" });
}
if (parsed.protocol !== "https:") {
throw new AIError.OAuthError(`Invalid xAI ${field}: ${url}`, { kind: "validation", provider: "xai" });
}
const host = parsed.hostname.toLowerCase();
if (!host || !isXaiBillingHostname(host)) {
throw new AIError.OAuthError(`Invalid xAI ${field}: ${url}`, { kind: "validation", provider: "xai" });
}
return url;
@@ -124,6 +158,22 @@ async function xaiOAuthDiscovery(
return { token_endpoint: tokenEndpoint };
}
/** Decode an xAI access-token JWT payload without verifying its signature. */
export function parseXAIAccessTokenPayload(jwt: string): Record<string, unknown> | null {
try {
if (typeof jwt !== "string" || !jwt.includes(".")) return null;
const parts = jwt.split(".");
if (parts.length < 2) return null;
const payloadPart = parts[1];
if (!payloadPart) return null;
const decoded = Buffer.from(payloadPart, "base64url").toString("utf8");
const payload = JSON.parse(decoded) as unknown;
return isRecord(payload) && !Array.isArray(payload) ? payload : null;
} catch {
return null;
}
}
/**
* Check whether a JWT access token is at or past its `exp` claim (with an
* optional refresh-skew margin).
@@ -132,25 +182,100 @@ async function xaiOAuthDiscovery(
* not token validation.
*/
export function isXAIAccessTokenExpiring(jwt: string, skewSeconds: number = 0): boolean {
const payload = parseXAIAccessTokenPayload(jwt);
if (!payload) return false;
const exp = payload.exp;
if (typeof exp !== "number" || !Number.isFinite(exp)) return false;
const now = Math.floor(Date.now() / 1000);
const skew = Math.max(0, Math.floor(skewSeconds));
return exp <= now + skew;
}
/** Extract the stable xAI subject UUID from an access token. */
export function extractXAIAccessTokenSubject(jwt: string): string | undefined {
const sub = parseXAIAccessTokenPayload(jwt)?.sub;
return typeof sub === "string" && sub.trim() ? sub.trim() : undefined;
}
export interface XAIOAuthIdentity {
accountId?: string;
email?: string;
name?: string;
}
/** Fetch optional OIDC userinfo for a valid xAI access token. */
export async function fetchXAIOAuthIdentity(
accessToken: string,
fetchOverride?: FetchImpl,
signal?: AbortSignal,
): Promise<XAIOAuthIdentity | null> {
const token = accessToken.trim();
if (!token) return null;
const fetchImpl = fetchOverride ?? fetch;
try {
if (typeof jwt !== "string" || !jwt.includes(".")) return false;
const parts = jwt.split(".");
if (parts.length < 2) return false;
const payloadPart = parts[1];
if (!payloadPart) return false;
const decoded = Buffer.from(payloadPart, "base64url").toString("utf8");
const payload: unknown = JSON.parse(decoded);
if (!isRecord(payload)) return false;
const exp = payload.exp;
if (typeof exp !== "number" || !Number.isFinite(exp)) return false;
const now = Math.floor(Date.now() / 1000);
const skew = Math.max(0, Math.floor(skewSeconds));
return exp <= now + skew;
const response = await fetchImpl(XAI_OAUTH_USERINFO_URL, {
method: "GET",
headers: {
Authorization: `Bearer ${token}`,
Accept: "application/json",
},
redirect: "error",
signal: signal
? AbortSignal.any([signal, AbortSignal.timeout(DISCOVERY_TIMEOUT_MS)])
: AbortSignal.timeout(DISCOVERY_TIMEOUT_MS),
});
if (!response.ok) return null;
const payload = (await response.json()) as unknown;
if (!isRecord(payload) || Array.isArray(payload)) return null;
const sub = typeof payload.sub === "string" && payload.sub.trim() ? payload.sub.trim() : undefined;
const email = typeof payload.email === "string" && payload.email.trim() ? payload.email.trim() : undefined;
const name = typeof payload.name === "string" && payload.name.trim() ? payload.name.trim() : undefined;
if (!sub && !email && !name) return null;
return {
...(sub ? { accountId: sub } : {}),
...(email ? { email: email.toLowerCase() } : {}),
...(name ? { name } : {}),
};
} catch {
return false;
return null;
}
}
async function withXAIOAuthIdentity(
credentials: OAuthCredentials,
fetchOverride?: FetchImpl,
signal?: AbortSignal,
): Promise<OAuthCredentials> {
const identity = await fetchXAIOAuthIdentity(credentials.access, fetchOverride, signal);
const accountId = identity?.accountId ?? credentials.accountId ?? extractXAIAccessTokenSubject(credentials.access);
const email = identity?.email ?? credentials.email;
return {
...credentials,
...(accountId ? { accountId } : {}),
...(email ? { email } : {}),
};
}
/** Build the SuperGrok CLI billing URL. */
export function buildXAICliBillingUrl(format: string = XAI_CLI_BILLING_FORMAT): string {
const url = new URL(XAI_CLI_BILLING_PATH, XAI_CLI_BILLING_BASE_URL);
url.searchParams.set("format", format);
return validateXAIBillingEndpoint(url.toString());
}
/**
* Headers for SuperGrok CLI billing (`cli-chat-proxy.grok.com`).
* Official Grok CLI also sends `X-XAI-Token-Auth: xai-grok-cli` on this host;
* include it so billing stays on the same product gate as chat inference.
*/
export function getXAICliBillingHeaders(options: { accessToken: string }): Record<string, string> {
return {
Authorization: `Bearer ${options.accessToken}`,
Accept: "application/json",
"X-XAI-Token-Auth": "xai-grok-cli",
};
}
function parseXAIDeviceAuthorization(payload: unknown): XAIDeviceAuthorization {
if (!isRecord(payload)) {
throw new AIError.OAuthError("xAI device-code response was not a JSON object.", {
@@ -304,6 +429,7 @@ async function pollXAIDeviceToken(
client_id: XAI_OAUTH_CLIENT_ID,
device_code: deviceCode,
}),
redirect: "error",
signal: signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal,
});
} catch (error) {
@@ -364,12 +490,13 @@ export async function loginXAIOAuth(ctrl: OAuthController): Promise<OAuthCredent
});
ctrl.onProgress?.("Waiting for xAI device authorization...");
return pollOAuthDeviceCodeFlow({
const credentials = await pollOAuthDeviceCodeFlow({
poll: () => pollXAIDeviceToken(discovery.token_endpoint, device.deviceCode, fetchImpl, ctrl.signal),
intervalSeconds: device.intervalSeconds,
expiresInSeconds: device.expiresInSeconds,
signal: ctrl.signal,
});
return withXAIOAuthIdentity(credentials, fetchImpl, ctrl.signal);
}
/**
@@ -400,6 +527,7 @@ export async function refreshXAIOAuthToken(refreshToken: string, fetchOverride?:
Accept: "application/json",
},
body,
redirect: "error",
signal: AbortSignal.timeout(TOKEN_REQUEST_TIMEOUT_MS),
});
@@ -426,5 +554,6 @@ export async function refreshXAIOAuthToken(refreshToken: string, fetchOverride?:
{ kind: "validation", provider: "xai", cause: error },
);
}
return parseXAITokenResponse(payload, "xAI token refresh response", refreshToken);
const credentials = parseXAITokenResponse(payload, "xAI token refresh response", refreshToken);
return withXAIOAuthIdentity(credentials, fetchImpl);
}
+11 -4
View File
@@ -20,6 +20,7 @@ import { getCustomApi } from "./api-registry";
import { createAuthRetryKeyState, isApiKeyResolver, resolveNextAuthRetryKey } from "./auth-retry";
import * as AIError from "./error";
import { ProviderHttpError } from "./error";
import { isInvalidatedOAuthTokenError } from "./error/auth-classify";
import { isUsageLimitOutcome } from "./error/rate-limit";
import type { BedrockOptions } from "./providers/amazon-bedrock";
import type { AnthropicOptions } from "./providers/anthropic";
@@ -979,6 +980,7 @@ function isRetryableUpstreamError(error: unknown, status: number | undefined, me
// `parseRateLimitReason` and stay in the provider's own backoff layer
// instead of burning siblings.
if (AIError.isUsageLimit(error)) return true;
if (isInvalidatedOAuthTokenError(error)) return true;
if (status === 401) return true;
return isUsageLimitOutcome(status, message);
}
@@ -1176,12 +1178,17 @@ export function streamSimple<TApi extends Api>(
// Kimi Code - route to dedicated handler that wraps OpenAI or Anthropic API
if (isKimiModel(model)) {
// Pass raw SimpleStreamOptions - streamKimi handles mapping internally
return withProviderInFlightLimit(model, requestOptions, () =>
// streamKimi handles openai/anthropic format mapping internally, but the
// mandatory-reasoning clamp is a request-shaping concern owned here: K3's
// `supports_thinking_type: "only"` endpoint rejects disabled/omitted
// thinking, so clamp disabled requests to the lowest supported effort
// (mirrors the mapOptionsForApi path every other provider takes).
const kimiOptions = normalizeMandatoryReasoningOptions(model, requestOptions);
return withProviderInFlightLimit(model, kimiOptions, () =>
streamKimi(model as Model<"openai-completions">, context, {
...requestOptions,
...kimiOptions,
apiKey,
format: requestOptions?.kimiApiFormat ?? "anthropic",
format: kimiOptions?.kimiApiFormat,
}),
);
}
+19 -6
View File
@@ -141,13 +141,17 @@ function isOpenAIServiceTierApi(api: Api | undefined): boolean {
return api === "openai-completions" || api === "openai-responses" || api === "openai-codex-responses";
}
function hasDedicatedServiceTierControl(provider: Provider | undefined): boolean {
return provider === "fireworks";
function excludesInferredOpenAIServiceTier(provider: Provider | undefined): boolean {
// Fireworks has its own priority-only control. GitHub Copilot proxies OpenAI
// models but rejects OpenAI's `service_tier` request field.
return provider === "fireworks" || provider === "github-copilot";
}
function isOpenAIServiceTierModel(model: ServiceTierModel): boolean {
return (
!hasDedicatedServiceTierControl(model.provider) && isOpenAIServiceTierApi(model.api) && isOpenAIModelId(model.id)
!excludesInferredOpenAIServiceTier(model.provider) &&
isOpenAIServiceTierApi(model.api) &&
isOpenAIModelId(model.id)
);
}
@@ -159,7 +163,8 @@ function isOpenAIServiceTierModel(model: ServiceTierModel): boolean {
* `openai/`); Claude on Bedrock/Vertex (api `anthropic-messages`) is the
* anthropic family even though its provider is `amazon-bedrock`/`google-vertex`.
* Custom OpenAI-compatible relays that serve OpenAI model ids are OpenAI family
* too unless that provider owns a separate tier control such as Fireworks.
* too unless the provider owns a separate tier control (Fireworks) or rejects
* OpenAI's service-tier field (GitHub Copilot).
*/
export function serviceTierFamily(model: ServiceTierModel): ServiceTierFamily | undefined {
const provider = model.provider;
@@ -539,7 +544,7 @@ export interface SimpleStreamOptions extends Omit<StreamOptions, "apiKey"> {
toolChoice?: ToolChoice;
/** OpenAI service tier for processing priority/cost control. Ignored by non-OpenAI providers. */
serviceTier?: ServiceTier;
/** API format for Kimi Code provider: "openai" or "anthropic" (default: "anthropic") */
/** Explicit Kimi Code API format override; omitted uses live per-model protocol metadata. */
kimiApiFormat?: "openai" | "anthropic";
/** API format for Synthetic provider: "openai" or "anthropic" (default: "openai") */
syntheticApiFormat?: "openai" | "anthropic";
@@ -706,7 +711,14 @@ export interface ContextSnapshot {
export interface AssistantMessage {
role: "assistant";
content: (TextContent | ThinkingContent | RedactedThinkingContent | AnthropicFallbackContent | ToolCall)[];
content: (
| TextContent
| ThinkingContent
| RedactedThinkingContent
| AnthropicFallbackContent
| ImageContent
| ToolCall
)[];
api: Api;
provider: Provider;
model: string;
@@ -893,6 +905,7 @@ export type AssistantMessageEvent =
| { type: "thinking_start"; contentIndex: number; partial: AssistantMessage }
| { type: "thinking_delta"; contentIndex: number; delta: string; partial: AssistantMessage }
| { type: "thinking_end"; contentIndex: number; content: string; partial: AssistantMessage }
| { type: "image_end"; contentIndex: number; content: ImageContent; partial: AssistantMessage }
| { type: "toolcall_start"; contentIndex: number; partial: AssistantMessage }
| { type: "toolcall_delta"; contentIndex: number; delta: string; partial: AssistantMessage }
| { type: "toolcall_end"; contentIndex: number; toolCall: ToolCall; partial: AssistantMessage }
+7
View File
@@ -20,6 +20,12 @@ export interface UsageWindow {
durationMs?: number;
/** Absolute reset timestamp in milliseconds since epoch. */
resetsAt?: number;
/**
* Verb rendered before the {@link resetsAt} countdown (e.g. "tick", "regen").
* Defaults to "resets" — override for rolling windows where the timestamp is
* an incremental regeneration step rather than a full window reset.
*/
resetLabel?: string;
}
/** Quantitative usage data. */
@@ -189,6 +195,7 @@ export const usageWindowSchema = type({
label: "string",
"durationMs?": "number",
"resetsAt?": "number",
"resetLabel?": "string",
});
export const usageAmountSchema = type({
+10 -24
View File
@@ -96,11 +96,6 @@ interface ParsedApiLimitEntry {
displayName?: string;
}
type ClaudeUsagePayload = {
payload: ClaudeUsageResponse;
orgId?: string;
};
function parseIsoTime(value: string | undefined): number | undefined {
if (!value) return undefined;
const parsed = Date.parse(value);
@@ -178,22 +173,17 @@ function getNestedPayloadString(payload: Record<string, unknown>, key: string, n
return isRecord(nested) ? getPayloadString(nested, nestedKey) : undefined;
}
function extractUsageIdentity(payload: ClaudeUsageResponse, orgId?: string): { accountId?: string; email?: string } {
if (!isRecord(payload)) return { accountId: orgId };
function extractUsageIdentity(payload: ClaudeUsageResponse): { accountId?: string; email?: string } {
if (!isRecord(payload)) return {};
const accountId =
getPayloadString(payload, "account_id") ??
getPayloadString(payload, "accountId") ??
getPayloadString(payload, "user_id") ??
getPayloadString(payload, "userId") ??
getPayloadString(payload, "org_id") ??
getPayloadString(payload, "orgId") ??
getNestedPayloadString(payload, "account", "uuid") ??
getNestedPayloadString(payload, "account", "id") ??
getNestedPayloadString(payload, "organization", "uuid") ??
getNestedPayloadString(payload, "organization", "id") ??
getNestedPayloadString(payload, "user", "uuid") ??
getNestedPayloadString(payload, "user", "id") ??
orgId;
getNestedPayloadString(payload, "user", "id");
const email =
getPayloadString(payload, "email") ??
getPayloadString(payload, "user_email") ??
@@ -263,16 +253,13 @@ async function fetchUsagePayload(
headers: Record<string, string>,
ctx: UsageFetchContext,
signal?: AbortSignal,
): Promise<ClaudeUsagePayload | null> {
): Promise<ClaudeUsageResponse | null> {
if (signal?.aborted) return null;
let lastPayload: ClaudeUsageResponse | null = null;
let lastOrgId: string | undefined;
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
try {
const response = await ctx.fetch(url, { headers, signal });
const orgId = response.headers.get("anthropic-organization-id")?.trim() || undefined;
lastOrgId = orgId ?? lastOrgId;
if (!response.ok) {
const retryable = isRetryableStatus(response.status);
@@ -292,7 +279,7 @@ async function fetchUsagePayload(
if (isRecord(parsed)) {
const payload = parsed as ClaudeUsageResponse;
lastPayload = payload;
if (hasUsageData(payload)) return { payload, orgId };
if (hasUsageData(payload)) return payload;
}
ctx.logger?.warn("Claude usage response missing usage data", {
@@ -311,7 +298,7 @@ async function fetchUsagePayload(
}
}
return lastPayload ? { payload: lastPayload, orgId: lastOrgId } : null;
return lastPayload;
}
interface ClaudeProfile {
@@ -507,9 +494,8 @@ async function fetchClaudeUsage(params: UsageFetchParams, ctx: UsageFetchContext
authorization: `Bearer ${credential.accessToken}`,
};
const payloadResult = await fetchUsagePayload(url, headers, ctx, params.signal);
if (!payloadResult || !isRecord(payloadResult.payload)) return null;
const { payload, orgId } = payloadResult;
const payload = await fetchUsagePayload(url, headers, ctx, params.signal);
if (!payload || !isRecord(payload)) return null;
const apiLimitEntries = parseApiLimitEntries(payload.limits);
const fiveHour = parseBucket(payload.five_hour) ?? apiLimitEntries.find(entry => entry.kind === "session")?.bucket;
@@ -563,7 +549,7 @@ async function fetchClaudeUsage(params: UsageFetchParams, ctx: UsageFetchContext
].filter((limit): limit is UsageLimit => limit !== null);
if (limits.length === 0) return null;
const identity = extractUsageIdentity(payload, orgId);
const identity = extractUsageIdentity(payload);
let accountId = identity.accountId ?? credential.accountId;
let email = identity.email ?? credential.email;
if ((!accountId || !email) && !params.signal?.aborted) {
@@ -580,7 +566,7 @@ async function fetchClaudeUsage(params: UsageFetchParams, ctx: UsageFetchContext
endpoint: url,
...(accountId ? { accountId } : {}),
...(email ? { email } : {}),
...(orgId ? { orgId } : {}),
...(credential.orgId ? { orgId: credential.orgId } : {}),
},
raw: payload,
};
+10 -4
View File
@@ -144,15 +144,21 @@ function buildUsageStatus(amount: UsageAmount): UsageStatus {
}
function toUsageLimit(row: KimiUsageRow, provider: string, index: number, accountId?: string): UsageLimit {
const window: UsageWindow | undefined =
row.window ??
(row.resetsAt
// Kimi puts `resetTime` on the limit `detail`, not on `window`, so a
// window built from `duration`/`timeUnit` alone carries no resetsAt.
// Fall back to the row-level reset so `omp usage` can render
// "resets in …" for the 5h window too.
const window: UsageWindow | undefined = row.window
? row.window.resetsAt !== undefined || row.resetsAt === undefined
? row.window
: { ...row.window, resetsAt: row.resetsAt }
: row.resetsAt
? {
id: "default",
label: "Usage window",
resetsAt: row.resetsAt,
}
: undefined);
: undefined;
const amount = buildUsageAmount(row);
return {
+180
View File
@@ -0,0 +1,180 @@
import { isRecord } from "@oh-my-pi/pi-utils/type-guards";
import type {
UsageAmount,
UsageFetchContext,
UsageFetchParams,
UsageLimit,
UsageProvider,
UsageReport,
UsageStatus,
UsageWindow,
} from "../usage";
const QUOTAS_URL = "https://api.synthetic.new/v2/quotas";
const FIVE_HOUR_MS = 5 * 60 * 60 * 1000;
const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
function parseDollarAmount(value: unknown): number | undefined {
if (typeof value !== "string") return undefined;
const trimmed = value.replace(/^\$/, "").trim();
const parsed = Number(trimmed);
return Number.isFinite(parsed) ? parsed : undefined;
}
function parseIsoMs(value: unknown): number | undefined {
if (typeof value !== "string" || !value) return undefined;
const ms = Date.parse(value);
return Number.isFinite(ms) ? ms : undefined;
}
function buildUsageAmount(args: {
used: number | undefined;
limit: number | undefined;
remaining: number | undefined;
usedFraction: number | undefined;
unit: UsageAmount["unit"];
}): UsageAmount {
let usedFraction = args.usedFraction;
if (usedFraction === undefined && args.used !== undefined && args.limit !== undefined && args.limit > 0) {
usedFraction = Math.min(args.used / args.limit, 1);
}
const remainingFraction = usedFraction !== undefined ? Math.max(1 - usedFraction, 0) : undefined;
return {
...(args.used !== undefined ? { used: args.used } : {}),
...(args.limit !== undefined ? { limit: args.limit } : {}),
...(args.remaining !== undefined ? { remaining: args.remaining } : {}),
...(usedFraction !== undefined ? { usedFraction } : {}),
...(remainingFraction !== undefined ? { remainingFraction } : {}),
unit: args.unit,
};
}
function getUsageStatus(usedFraction: number | undefined): UsageStatus | undefined {
if (usedFraction === undefined) return undefined;
if (usedFraction >= 1) return "exhausted";
if (usedFraction >= 0.9) return "warning";
return "ok";
}
function parseRollingFiveHourLimit(raw: unknown, provider: UsageFetchParams["provider"]): UsageLimit | null {
if (!isRecord(raw)) return null;
const remaining = typeof raw.remaining === "number" ? raw.remaining : undefined;
const max = typeof raw.max === "number" ? raw.max : undefined;
const limited = raw.limited === true;
const nextTickAt = parseIsoMs(raw.nextTickAt);
const tickPercent = typeof raw.tickPercent === "number" ? raw.tickPercent : undefined;
if (remaining === undefined && max === undefined) return null;
const used = max !== undefined && remaining !== undefined ? max - remaining : undefined;
const regenPercent = tickPercent !== undefined ? Number((tickPercent * 100).toFixed(2)) : undefined;
const window: UsageWindow = {
id: "5h",
label: regenPercent !== undefined ? `5h · regen ${regenPercent}%/tick` : "5h",
durationMs: FIVE_HOUR_MS,
...(nextTickAt !== undefined ? { resetsAt: nextTickAt, resetLabel: "tick" } : {}),
};
const amount = buildUsageAmount({
used,
limit: max,
remaining,
usedFraction: undefined,
unit: "requests",
});
const status: UsageStatus = limited ? "exhausted" : (getUsageStatus(amount.usedFraction) ?? "ok");
return {
id: "synthetic:requests:5h",
label: "Synthetic Requests",
scope: { provider, windowId: "5h", shared: true },
window,
amount,
status,
};
}
function parseWeeklyTokenLimit(raw: unknown, provider: UsageFetchParams["provider"]): UsageLimit | null {
if (!isRecord(raw)) return null;
const remainingCredits = parseDollarAmount(raw.remainingCredits);
const maxCredits = parseDollarAmount(raw.maxCredits);
const percentRemaining = typeof raw.percentRemaining === "number" ? raw.percentRemaining : undefined;
const nextRegenAt = parseIsoMs(raw.nextRegenAt);
if (remainingCredits === undefined && maxCredits === undefined) return null;
const usedFraction =
percentRemaining !== undefined ? Math.min(Math.max(1 - percentRemaining / 100, 0), 1) : undefined;
const used = usedFraction !== undefined && maxCredits !== undefined ? usedFraction * maxCredits : undefined;
const nextRegenCredits = parseDollarAmount(raw.nextRegenCredits);
const window: UsageWindow = {
id: "7d",
label: nextRegenCredits !== undefined ? `7d · regen $${nextRegenCredits.toFixed(2)}/tick` : "7d",
durationMs: WEEK_MS,
...(nextRegenAt !== undefined ? { resetsAt: nextRegenAt, resetLabel: "regen" } : {}),
};
const amount = buildUsageAmount({
used,
limit: maxCredits,
remaining: remainingCredits,
usedFraction,
unit: "usd",
});
return {
id: "synthetic:usd:7d",
label: "Synthetic Credits",
scope: { provider, windowId: "7d", shared: true },
window,
amount,
status: getUsageStatus(amount.usedFraction),
};
}
async function fetchSyntheticUsage(params: UsageFetchParams, ctx: UsageFetchContext): Promise<UsageReport | null> {
if (params.provider !== "synthetic") return null;
const credential = params.credential;
if (credential.type !== "api_key" || !credential.apiKey) return null;
let payload: unknown = null;
try {
const response = await ctx.fetch(QUOTAS_URL, {
headers: {
Authorization: `Bearer ${credential.apiKey}`,
"Content-Type": "application/json",
},
signal: params.signal,
});
if (!response.ok) {
ctx.logger?.warn("Synthetic usage fetch failed", { status: response.status, statusText: response.statusText });
return null;
}
payload = await response.json();
} catch (error) {
ctx.logger?.warn("Synthetic usage fetch error", { error: String(error) });
return null;
}
if (!isRecord(payload)) return null;
const limits: UsageLimit[] = [];
const fiveHour = parseRollingFiveHourLimit(payload.rollingFiveHourLimit, params.provider);
if (fiveHour) limits.push(fiveHour);
const weekly = parseWeeklyTokenLimit(payload.weeklyTokenLimit, params.provider);
if (weekly) limits.push(weekly);
if (limits.length === 0) return null;
return {
provider: params.provider,
fetchedAt: Date.now(),
limits,
metadata: { endpoint: QUOTAS_URL },
raw: payload,
};
}
export const syntheticUsageProvider: UsageProvider = {
id: "synthetic",
fetchUsage: fetchSyntheticUsage,
supports: params => params.provider === "synthetic" && params.credential.type === "api_key",
};
+256
View File
@@ -0,0 +1,256 @@
/**
* SuperGrok (`xai-oauth`) subscription usage provider.
*
* Reads weekly credit and product utilization from the Grok CLI billing
* endpoint. Only OAuth access credentials are accepted; paid API keys are a
* separate product and must never be sent here.
*/
import {
buildXAICliBillingUrl,
extractXAIAccessTokenSubject,
fetchXAIOAuthIdentity,
getXAICliBillingHeaders,
} from "../registry/oauth/xai-oauth";
import type {
UsageAmount,
UsageFetchContext,
UsageFetchParams,
UsageLimit,
UsageProvider,
UsageReport,
UsageStatus,
UsageWindow,
} from "../usage";
import { isRecord } from "../utils";
import { toNumber } from "./shared";
const PROVIDER_ID = "xai-oauth";
const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
interface XaiBillingPeriod {
start: string;
end: string;
type: string;
}
interface XaiProductUsage {
product: string;
usagePercent: number;
}
interface XaiBillingConfig {
currentPeriod: XaiBillingPeriod;
creditUsagePercent: number;
productUsage: XaiProductUsage[];
onDemandCap?: number;
onDemandUsed?: number;
}
function parseIsoMs(value: string): number | undefined {
const parsed = Date.parse(value);
return Number.isFinite(parsed) ? parsed : undefined;
}
function parsePercent(value: unknown): number | undefined {
const percent = toNumber(value);
return percent !== undefined && percent >= 0 && percent <= 100 ? percent : undefined;
}
function parseOnDemandAmount(value: unknown): number | undefined {
if (!isRecord(value)) return undefined;
const amount = toNumber(value.val);
return amount !== undefined && amount >= 0 ? amount : undefined;
}
function buildPercentAmount(usagePercent: number): UsageAmount {
const usedFraction = usagePercent / 100;
return {
used: usagePercent,
limit: 100,
remaining: 100 - usagePercent,
usedFraction,
remainingFraction: 1 - usedFraction,
unit: "percent",
};
}
function buildUsageStatus(usedFraction: number): UsageStatus {
if (usedFraction >= 1) return "exhausted";
if (usedFraction >= 0.9) return "warning";
return "ok";
}
function slugifyProduct(product: string): string {
return product
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
function buildPeriodWindow(period: XaiBillingPeriod): UsageWindow {
return {
id: "1w",
label: "Weekly",
durationMs: WEEK_MS,
resetsAt: parseIsoMs(period.end),
};
}
function parseBillingConfig(payload: unknown): XaiBillingConfig | null {
if (!isRecord(payload) || !isRecord(payload.config)) return null;
const raw = payload.config;
if (!isRecord(raw.currentPeriod)) return null;
const start = typeof raw.currentPeriod.start === "string" ? parseIsoMs(raw.currentPeriod.start) : undefined;
const end = typeof raw.currentPeriod.end === "string" ? parseIsoMs(raw.currentPeriod.end) : undefined;
const type = typeof raw.currentPeriod.type === "string" ? raw.currentPeriod.type : "";
// Keep recently-ended weekly windows so /usage still renders across period
// rollover while the billing API is mid-refresh. Reject only inverted ranges
// and non-weekly period types.
if (start === undefined || end === undefined || end <= start || !type.toUpperCase().includes("WEEK")) {
return null;
}
const creditUsagePercent = parsePercent(raw.creditUsagePercent);
if (creditUsagePercent === undefined) return null;
const productUsage: XaiProductUsage[] = [];
if (raw.productUsage !== undefined) {
if (!Array.isArray(raw.productUsage)) return null;
for (const item of raw.productUsage) {
if (!isRecord(item)) continue;
const product = typeof item.product === "string" ? item.product.trim() : "";
const usagePercent = parsePercent(item.usagePercent);
if (!product || usagePercent === undefined) continue;
productUsage.push({ product, usagePercent });
}
}
return {
currentPeriod: {
start: raw.currentPeriod.start as string,
end: raw.currentPeriod.end as string,
type,
},
creditUsagePercent,
productUsage,
onDemandCap: parseOnDemandAmount(raw.onDemandCap),
onDemandUsed: parseOnDemandAmount(raw.onDemandUsed),
};
}
function buildLimits(config: XaiBillingConfig, accountId: string | undefined): UsageLimit[] {
const window = buildPeriodWindow(config.currentPeriod);
const scope = {
provider: PROVIDER_ID,
...(accountId ? { accountId } : {}),
windowId: window.id,
shared: true as const,
};
const overall = buildPercentAmount(config.creditUsagePercent);
const limits: UsageLimit[] = [
{
id: `${PROVIDER_ID}:credits:1w`,
label: "SuperGrok Weekly Credits",
scope,
window,
amount: overall,
status: buildUsageStatus(overall.usedFraction ?? 0),
},
];
for (const item of config.productUsage) {
const amount = buildPercentAmount(item.usagePercent);
const slug = slugifyProduct(item.product);
if (!slug) continue;
limits.push({
id: `${PROVIDER_ID}:product:${slug}:1w`,
label: `${item.product === "GrokBuild" ? "Grok Build" : item.product === "Api" ? "API" : item.product} (Weekly)`,
scope,
window,
amount,
status: buildUsageStatus(amount.usedFraction ?? 0),
});
}
if (config.onDemandCap !== undefined && config.onDemandCap > 0 && config.onDemandUsed !== undefined) {
const usedFraction = Math.min(config.onDemandUsed / config.onDemandCap, 1);
limits.push({
id: `${PROVIDER_ID}:on-demand`,
label: "On-demand",
scope: {
provider: PROVIDER_ID,
...(accountId ? { accountId } : {}),
shared: true,
},
amount: {
used: config.onDemandUsed,
limit: config.onDemandCap,
remaining: Math.max(0, config.onDemandCap - config.onDemandUsed),
usedFraction,
remainingFraction: 1 - usedFraction,
unit: "unknown",
},
status: buildUsageStatus(usedFraction),
});
}
return limits;
}
export const xaiOauthUsageProvider: UsageProvider = {
id: PROVIDER_ID,
supports(params: UsageFetchParams): boolean {
return params.provider === PROVIDER_ID && params.credential.type === "oauth" && !!params.credential.accessToken;
},
async fetchUsage(params: UsageFetchParams, ctx: UsageFetchContext): Promise<UsageReport | null> {
if (params.provider !== PROVIDER_ID || params.credential.type !== "oauth") return null;
const accessToken = params.credential.accessToken?.trim();
if (!accessToken) return null;
if (params.credential.expiresAt !== undefined && params.credential.expiresAt <= Date.now()) return null;
let accountId = params.credential.accountId?.trim() || extractXAIAccessTokenSubject(accessToken);
let email = params.credential.email?.trim().toLowerCase();
if (!email) {
try {
const identity = await fetchXAIOAuthIdentity(accessToken, ctx.fetch, params.signal);
email = identity?.email?.trim().toLowerCase() || undefined;
accountId ??= identity?.accountId?.trim() || undefined;
} catch {
// Identity enrichment is best effort; billing remains authoritative.
}
}
const url = buildXAICliBillingUrl();
let payload: unknown;
try {
const response = await ctx.fetch(url, {
headers: getXAICliBillingHeaders({ accessToken }),
redirect: "error",
signal: params.signal,
});
if (!response.ok) return null;
payload = await response.json();
} catch {
return null;
}
const config = parseBillingConfig(payload);
if (!config) return null;
return {
provider: PROVIDER_ID,
fetchedAt: Date.now(),
limits: buildLimits(config, accountId),
metadata: {
endpoint: url,
source: "cli-chat-proxy.grok.com/v1/billing",
...(accountId ? { accountId } : {}),
...(email ? { email } : {}),
},
raw: payload,
};
},
};
+14 -6
View File
@@ -1,5 +1,6 @@
import { $env } from "@oh-my-pi/pi-utils";
import type { ResponseInput, ResponseInputItem } from "./providers/openai-responses-wire";
import { redactSensitiveCredentials } from "./providers/transform-messages";
import type { CacheRetention, OpenAIResponsesHistoryPayload, ProviderPayload } from "./types";
type OpenAIResponsesReplayItem = ResponseInput[number];
@@ -9,7 +10,9 @@ export { isRecord } from "@oh-my-pi/pi-utils";
export function normalizeSystemPrompts(systemPrompt: readonly string[] | string | undefined | null): string[] {
if (systemPrompt === undefined || systemPrompt === null) return [];
const prompts = Array.isArray(systemPrompt) ? systemPrompt : typeof systemPrompt === "string" ? [systemPrompt] : [];
return prompts.map(prompt => prompt.toWellFormed()).filter(prompt => prompt.trim().length > 0);
return prompts
.map(prompt => redactSensitiveCredentials(prompt.toWellFormed()))
.filter(prompt => prompt.trim().length > 0);
}
export function normalizeToolCallId(id: string): string {
@@ -284,11 +287,16 @@ export function getOpenAIResponsesHistoryItems(
}
/**
* Resolve cache retention preference.
* Defaults to "short" and uses PI_CACHE_RETENTION for backward compatibility.
* Resolve cache retention preference: explicit request option first, then the
* `PI_CACHE_RETENTION` env override (`long` | `short` | `none`), then the
* provider-supplied fallback.
*/
export function resolveCacheRetention(cacheRetention?: CacheRetention): CacheRetention {
export function resolveCacheRetention(
cacheRetention?: CacheRetention,
fallback: CacheRetention = "short",
): CacheRetention {
if (cacheRetention) return cacheRetention;
if ($env.PI_CACHE_RETENTION === "long") return "long";
return "short";
const env = $env.PI_CACHE_RETENTION;
if (env === "long" || env === "short" || env === "none") return env;
return fallback;
}

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