Merge remote-tracking branch 'origin/main' into acp-initialize-startup

# Conflicts:
#	packages/coding-agent/src/main.ts
This commit is contained in:
jiwangyihao
2026-05-17 11:53:04 +08:00
404 changed files with 52293 additions and 5260 deletions
+7
View File
@@ -53,6 +53,13 @@ packages/natives/native/pi_natives.dev.node
packages/ai/test/.temp-images/
python/omp-rpc/src/omp_rpc.egg-info/
# robomp runtime state — robomp has its own Dockerfile/build context;
# keep these out of the monorepo image too.
python/robomp/data/
python/robomp/.cache/
python/robomp/src/robomp/static/
python/robomp/web/dist/
# Scratch files the repo creates ad-hoc.
syntax.jsonl
out.jsonl
+4
View File
@@ -12,6 +12,10 @@
"packages/*/bench/**/*.{ts,tsx}",
"packages/*/scripts/**/*.ts"
],
"ignoreDependencies": [
// Used via `node_modules/.bin/napi` from packages/natives/scripts/build-native.ts.
"@napi-rs/cli"
],
"duplicates": {
"ignore": [
// Generated from `packages/natives/scripts/native-index.template.js` via gen-enums.ts.
+96
View File
@@ -0,0 +1,96 @@
name: Build native addon
description: Build the pi_natives cdylib for one platform/arch/variant and upload it as a hash-tagged artifact.
inputs:
hash:
description: Rust source hash used in the artifact name
required: true
platform:
description: Target platform (linux, darwin, win32)
required: true
arch:
description: Target arch (x64, arm64)
required: true
variant:
description: Optional build variant (baseline, modern)
required: false
default: ""
target:
description: Optional rustc target triple for cross-compilation
required: false
default: ""
rust_checks:
description: Run clippy/rustfmt checks (only one matrix entry should set this)
required: false
default: "false"
save_cache:
description: Whether Swatinem/rust-cache should write a cache entry
required: false
default: "false"
runs:
using: composite
steps:
- uses: dtolnay/rust-toolchain@nightly
with:
toolchain: nightly-2026-04-29
components: ${{ inputs.rust_checks == 'true' && 'clippy, rustfmt' || '' }}
targets: ${{ inputs.target }}
- name: Prepend rustup toolchain bin to PATH
shell: bash
run: |
# Homebrew on macOS runners ships rustup-init with shadow proxies
# for `cargo`/`rustc`/etc. that error out as the installer
# ("unexpected argument 'metadata' found"). Force the real
# toolchain binaries to win on PATH.
toolchain_bin="$(dirname "$(rustup which cargo)")"
echo "$toolchain_bin" >> "$GITHUB_PATH"
echo "Prepended $toolchain_bin to PATH"
- uses: Swatinem/rust-cache@v2
with:
shared-key: native-${{ inputs.platform }}-${{ inputs.arch }}-${{ inputs.variant || 'default' }}
cache-on-failure: true
save-if: ${{ inputs.save_cache == 'true' }}
cache-workspace-crates: true
- uses: taiki-e/install-action@v2
if: inputs.target == ''
with:
tool: nextest
- uses: oven-sh/setup-bun@v2
with:
bun-version: "1.3"
- shell: bash
run: bun install --frozen-lockfile
- name: Install cross-compilation toolchain
if: inputs.target == 'aarch64-unknown-linux-gnu'
shell: bash
run: |
sudo apt-get update
sudo apt-get install -y gcc-aarch64-linux-gnu
- name: Rust checks
if: inputs.rust_checks == 'true'
shell: bash
run: bun run check:rs
- name: Test workspace (Rust)
if: inputs.target == ''
shell: bash
run: bun run test:rs
- name: Build native addon(s)
shell: bash
env:
CROSS_TARGET: ${{ inputs.target }}
TARGET_PLATFORM: ${{ inputs.platform }}
TARGET_ARCH: ${{ inputs.arch }}
TARGET_VARIANTS: ${{ inputs.variant }}
CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: aarch64-linux-gnu-gcc
run: bun run ci:build:native
- 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 }}
path: packages/natives/native/pi_natives.${{ inputs.platform }}-${{ inputs.arch }}*.node
if-no-files-found: error
# Explicit so the rust-hash canary lookup keeps working even if org
# defaults shift; bump if Rust source ever stays stable for >90 days
# of main pushes and you want to avoid rebuilds.
retention-days: 90
+55 -116
View File
@@ -19,9 +19,11 @@ concurrency:
jobs:
# Compute a stable hash of every input that affects the native cdylib output,
# then look for a prior successful main build that already produced artifacts
# with this hash. If found, downstream consumers reuse those artifacts and
# the native job is skipped entirely.
# then look for any prior successful main run that already uploaded the linux-x64
# artifacts for this hash. If found, test jobs reuse those artifacts instead of
# rebuilding them on non-release commits. The non-tag native_linux job is skipped
# in that case, so the canary's retention window (see build-native action) is the
# effective TTL of a cache hit before main rebuilds anyway.
rust-hash:
runs-on: ubuntu-22.04
outputs:
@@ -50,8 +52,9 @@ jobs:
shell: bash
run: |
hash="${{ steps.compute.outputs.hash }}"
# Canary artifact: main always builds linux-x64-modern, so its presence
# implies the run has the full multi-platform set we need.
# Canary artifact: native_linux builds baseline + modern together,
# so the modern artifact's presence on any prior main run implies
# both linux x64 test artifacts are cached and downloadable.
canary="pi-natives-linux-x64-modern-h${hash}"
run_id=""
for candidate in $(gh run list \
@@ -88,98 +91,57 @@ jobs:
- name: Type check workspace
run: bun run ci:check:full
native:
# Linux x64 baseline + modern: required by `test`, so it runs on every PR
# unless rust-hash found a cached run. Tags always rebuild for fresh artifacts.
native_linux:
needs: [rust-hash]
if: ${{ needs.rust-hash.outputs.run-id == '' }}
if: ${{ startsWith(github.ref, 'refs/tags/v') || needs.rust-hash.outputs.run-id == '' }}
runs-on: ubuntu-22.04
strategy:
fail-fast: false
matrix:
# Tag and main pushes build the full multi-platform set (so the cache
# has every artifact a future tag could need). PRs only build linux-x64.
include: ${{ (startsWith(github.ref, 'refs/tags/v') || github.ref ==
'refs/heads/main') && fromJSON('[
{"os":"ubuntu-22.04","platform":"linux","arch":"x64","variant":"baseline","rust_checks":true},
{"os":"ubuntu-22.04","platform":"linux","arch":"x64","variant":"modern"},
{"os":"ubuntu-22.04","platform":"linux","arch":"arm64","target":"aarch64-unknown-linux-gnu"},
{"os":"macos-15-intel","platform":"darwin","arch":"x64","variant":"baseline"},
{"os":"macos-14","platform":"darwin","arch":"arm64"},
{"os":"windows-latest","platform":"win32","arch":"x64","variant":"baseline"}
]') || fromJSON('[
{"os":"ubuntu-22.04","platform":"linux","arch":"x64","variant":"baseline","rust_checks":true},
{"os":"ubuntu-22.04","platform":"linux","arch":"x64","variant":"modern"}
]') }}
include:
- { variant: baseline, rust_checks: true }
- { variant: modern }
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/build-native
with:
hash: ${{ needs.rust-hash.outputs.hash }}
platform: linux
arch: x64
variant: ${{ matrix.variant }}
rust_checks: ${{ matrix.rust_checks && 'true' || 'false' }}
save_cache: ${{ github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')) }}
# Remaining platforms only ship in release tags; PRs and main never build them.
native_release:
needs: [rust-hash]
if: ${{ startsWith(github.ref, 'refs/tags/v') }}
strategy:
fail-fast: false
matrix:
include:
- { os: ubuntu-22.04, platform: linux, arch: arm64, target: aarch64-unknown-linux-gnu }
- { os: macos-15-intel, platform: darwin, arch: x64, variant: baseline }
- { os: macos-14, platform: darwin, arch: arm64 }
- { os: windows-latest, platform: win32, arch: x64, variant: baseline }
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@nightly
- uses: ./.github/actions/build-native
with:
toolchain: nightly-2026-04-29
components: ${{ matrix.rust_checks && 'clippy, rustfmt' || '' }}
targets: ${{ matrix.target }}
- name: Ensure cross-compilation target is installed
if: matrix.target
run: rustup target add ${{ matrix.target }}
- name: Prepend rustup toolchain bin to PATH
shell: bash
run: |
# Homebrew on macOS runners ships rustup-init with shadow proxies
# for `cargo`/`rustc`/etc. that error out as the installer
# ("unexpected argument 'metadata' found"). Force the real
# toolchain binaries to win on PATH.
toolchain_bin="$(dirname "$(rustup which cargo)")"
echo "$toolchain_bin" >> "$GITHUB_PATH"
echo "Prepended $toolchain_bin to PATH"
- uses: Swatinem/rust-cache@v2
with:
shared-key: native-${{ matrix.platform }}-${{ matrix.arch }}-${{ matrix.variant
|| 'default' }}
cache-on-failure: true
save-if: ${{ github.event_name == 'push' && (github.ref == 'refs/heads/main' ||
startsWith(github.ref, 'refs/tags/v')) }}
cache-workspace-crates: true
- uses: taiki-e/install-action@v2
if: ${{ !matrix.target }}
with:
tool: nextest
- uses: oven-sh/setup-bun@v2
with:
bun-version: "1.3"
- run: bun install --frozen-lockfile
- name: Install cross-compilation toolchain
if: matrix.target == 'aarch64-unknown-linux-gnu'
run: |
sudo apt-get update
sudo apt-get install -y gcc-aarch64-linux-gnu
- name: Rust checks
if: matrix.rust_checks
run: bun run check:rs
- name: Test workspace (Rust)
if: ${{ !matrix.target }}
run: bun run test:rs
- name: Build native addon(s)
env:
CROSS_TARGET: ${{ matrix.target }}
TARGET_PLATFORM: ${{ matrix.platform }}
TARGET_ARCH: ${{ matrix.arch }}
TARGET_VARIANTS: ${{ matrix.variant }}
CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: aarch64-linux-gnu-gcc
shell: bash
run: |
bun run ci:build:native
- name: Upload native addon(s)
uses: actions/upload-artifact@v4
with:
name: pi-natives-${{ matrix.platform }}-${{ matrix.arch }}${{ matrix.variant &&
format('-{0}', matrix.variant) || '' }}-h${{
needs.rust-hash.outputs.hash }}
path: packages/natives/native/pi_natives.${{ matrix.platform }}-${{ matrix.arch
}}*.node
if-no-files-found: error
hash: ${{ needs.rust-hash.outputs.hash }}
platform: ${{ matrix.platform }}
arch: ${{ matrix.arch }}
variant: ${{ matrix.variant }}
target: ${{ matrix.target }}
save_cache: ${{ github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')) }}
test:
runs-on: ubuntu-22.04
needs: [native, rust-hash]
if: ${{ !cancelled() && needs.native.result != 'failure' }}
needs: [native_linux, rust-hash]
if: ${{ !cancelled() && needs.native_linux.result != 'failure' }}
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
@@ -202,7 +164,7 @@ jobs:
id: source
shell: bash
run: |
if [ "${{ needs.native.result }}" = "success" ]; then
if [ "${{ needs.native_linux.result }}" = "success" ]; then
echo "run-id=${{ github.run_id }}" >> "$GITHUB_OUTPUT"
else
echo "run-id=${{ needs.rust-hash.outputs.run-id }}" >> "$GITHUB_OUTPUT"
@@ -254,10 +216,10 @@ jobs:
release_binary:
if: ${{ startsWith(github.ref, 'refs/tags/v') && !cancelled() &&
needs.native.result != 'failure' && needs.test.result == 'success' &&
needs.check.result == 'success' && needs.install_methods.result ==
'success' }}
needs: [check, native, test, install_methods, rust-hash]
needs.native_linux.result == 'success' && needs.native_release.result ==
'success' && needs.test.result == 'success' && needs.check.result ==
'success' && needs.install_methods.result == 'success' }}
needs: [check, native_linux, native_release, test, install_methods, rust-hash]
strategy:
fail-fast: false
matrix:
@@ -311,24 +273,12 @@ jobs:
path: ~/.bun/install/cache
key: bun-${{ runner.os }}-${{ hashFiles('**/bun.lock') }}
- run: bun install --frozen-lockfile
- name: Resolve native source run
id: source
shell: bash
run: |
if [ "${{ needs.native.result }}" = "success" ]; then
echo "run-id=${{ github.run_id }}" >> "$GITHUB_OUTPUT"
else
echo "run-id=${{ needs.rust-hash.outputs.run-id }}" >> "$GITHUB_OUTPUT"
fi
- name: Download native addon(s)
uses: actions/download-artifact@v4
with:
pattern: pi-natives-${{ matrix.platform }}-${{ matrix.arch }}*-h${{
needs.rust-hash.outputs.hash }}
pattern: pi-natives-${{ matrix.platform }}-${{ matrix.arch }}*-h${{ needs.rust-hash.outputs.hash }}
path: packages/natives/native
merge-multiple: true
run-id: ${{ steps.source.outputs.run-id }}
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Build release binary
env:
RELEASE_TARGETS: ${{ matrix.target_id }}
@@ -378,7 +328,7 @@ jobs:
release-npm:
if: ${{ startsWith(github.ref, 'refs/tags/v') && !cancelled() &&
needs.release_binary.result == 'success' && !inputs.skip_npm }}
needs: [release_binary, native, rust-hash]
needs: [release_binary, rust-hash]
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
@@ -395,23 +345,12 @@ jobs:
path: ~/.bun/install/cache
key: bun-${{ runner.os }}-${{ hashFiles('**/bun.lock') }}
- run: bun install --frozen-lockfile
- name: Resolve native source run
id: source
shell: bash
run: |
if [ "${{ needs.native.result }}" = "success" ]; then
echo "run-id=${{ github.run_id }}" >> "$GITHUB_OUTPUT"
else
echo "run-id=${{ needs.rust-hash.outputs.run-id }}" >> "$GITHUB_OUTPUT"
fi
- name: Download native addons
uses: actions/download-artifact@v4
with:
pattern: pi-natives-*-h${{ needs.rust-hash.outputs.hash }}
path: packages/natives/native
merge-multiple: true
run-id: ${{ steps.source.outputs.run-id }}
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Publish to npm
env:
NPM_CONFIG_TOKEN: ${{ secrets.NPM_TOKEN }}
+7
View File
@@ -62,3 +62,10 @@ python/omp-rpc/src/omp_rpc.egg-info/
.wt/
CPU*.md
packages/coding-agent/binaries/
# robomp runtime state
python/robomp/data/
python/robomp/.cache/
python/robomp/src/robomp/static/
python/robomp/web/dist/
python/robomp/.env
+1
View File
@@ -52,6 +52,7 @@ COPY --parents \
Cargo.toml Cargo.lock rust-toolchain.toml \
packages/*/package.json \
packages/tsconfig.workspace.json \
python/robomp/web/package.json \
crates/*/Cargo.toml \
/pi/
+79
View File
@@ -0,0 +1,79 @@
# Pi-artifacts build context (this file shadows `.dockerignore` only for the
# pi-root `Dockerfile`). Robomp builds with `dockerfile: python/robomp/Dockerfile`
# still fall back to the shared `.dockerignore` next door because they don't
# have their own ignore file.
#
# Keep this file in sync with `.dockerignore` for the shared rules; everything
# below the divider is the artifacts-only addendum.
# ─── Shared with .dockerignore ────────────────────────────────────────────────
# Heavy build outputs — must never reach the build context. `target/` alone is
# >100 GB on a dev machine.
target/
node_modules/
dist/
runs/
# Per-host scratch the pi codebase uses for parallel agents / worktrees.
.fallow/
.worktrees/
.wt/
.opencode/
.pi_config/
.omp/plugins/
# VCS, editors, IDEs — irrelevant to the build, churn on every IDE keystroke.
.git/
.npm/
.vscode/
.zed/
.idea/
# OS + transient noise.
.DS_Store
*.swp
*.swo
*~
*.tmp
# Logs + profiling artifacts.
*.log
*.cpuprofile
*.heapprofile
*.heapsnapshot
CPU.*
# Build / test side outputs.
*.tsbuildinfo
coverage/
.nyc_output/
__pycache__/
compaction-results/
changes/
# Generated files (the in-image build regenerates them).
packages/coding-agent/src/internal-urls/docs-index.generated.ts
packages/natives/native/.build/
packages/natives/native/pi_natives.darwin-*.node
packages/natives/native/pi_natives.dev.node
packages/ai/test/.temp-images/
python/omp-rpc/src/omp_rpc.egg-info/
# Scratch files the repo creates ad-hoc.
syntax.jsonl
out.jsonl
out.html
pi-*.html
# Secrets. Should never be in the image regardless.
.env
# ─── Pi-artifacts only ────────────────────────────────────────────────────────
# Robomp's source tree is unused by the artifacts image — pi-natives + omp-rpc
# are the only outputs, and `python/omp-rpc/` is reached explicitly by the
# python-builder stage (`COPY python/omp-rpc /src`). Everything under
# `python/robomp/` (orchestrator source, web bundle, tests, container scripts)
# would otherwise be transferred as part of the `COPY . /pi/` layer and bake
# uselessly into the natives-builder cache.
python/robomp/
+223 -245
View File
@@ -36,16 +36,10 @@
},
"dependencies": {
"@anthropic-ai/sdk": "catalog:",
"@aws-sdk/client-bedrock-runtime": "catalog:",
"@aws-sdk/credential-provider-node": "catalog:",
"@bufbuild/protobuf": "catalog:",
"@google/genai": "catalog:",
"@oh-my-pi/pi-natives": "catalog:",
"@oh-my-pi/pi-utils": "catalog:",
"@smithy/node-http-handler": "catalog:",
"openai": "catalog:",
"partial-json": "catalog:",
"proxy-agent": "catalog:",
"zod": "catalog:",
},
"devDependencies": {
@@ -180,22 +174,35 @@
"name": "@oh-my-pi/pi-utils",
"version": "15.1.2",
"dependencies": {
"@oh-my-pi/pi-natives": "catalog:",
"beautiful-mermaid": "catalog:",
"handlebars": "catalog:",
"winston": "catalog:",
"winston-daily-rotate-file": "catalog:",
},
"devDependencies": {
"@oh-my-pi/pi-natives": "catalog:",
"@types/bun": "catalog:",
},
},
"python/robomp/web": {
"name": "robomp-web",
"version": "0.1.0",
"dependencies": {
"solid-js": "catalog:",
},
"devDependencies": {
"@tailwindcss/vite": "catalog:",
"@types/bun": "catalog:",
"tailwindcss": "catalog:",
"typescript": "^5.7.3",
"vite": "catalog:",
"vite-plugin-solid": "catalog:",
},
},
},
"catalog": {
"@agentclientprotocol/sdk": "0.21.0",
"@anthropic-ai/sdk": "^0.94.0",
"@aws-sdk/client-bedrock-runtime": "^3.1043.0",
"@aws-sdk/credential-provider-node": "^3.972.39",
"@babel/generator": "^7.29.1",
"@babel/parser": "^7.29.3",
"@babel/traverse": "^7.29.0",
@@ -203,7 +210,6 @@
"@biomejs/biome": "^2.4.14",
"@bufbuild/protobuf": "^2.12.0",
"@bufbuild/protoc-gen-es": "^2.12.0",
"@google/genai": "^1.52.0",
"@mozilla/readability": "^0.6.0",
"@napi-rs/cli": "3.6.2",
"@oh-my-pi/omp-stats": "15.1.2",
@@ -217,8 +223,8 @@
"@opentelemetry/context-async-hooks": "^2.0.0",
"@opentelemetry/sdk-trace-base": "^2.0.0",
"@puppeteer/browsers": "^2.13.0",
"@smithy/node-http-handler": "^4.6.1",
"@tailwindcss/node": "^4.2.4",
"@tailwindcss/vite": "^4.2.4",
"@types/babel__generator": "^7.27.0",
"@types/babel__traverse": "^7.28.0",
"@types/bun": "^1.3.14",
@@ -244,16 +250,18 @@
"partial-json": "^0.1.7",
"postcss": "^8.5.14",
"prettier": "^3.8.3",
"proxy-agent": "^8.0.1",
"puppeteer-core": "^24.42.0",
"react": "19.2.5",
"react-chartjs-2": "^5.3.1",
"react-dom": "19.2.5",
"regexp-tree": "^0.1.27",
"solid-js": "^1.9.12",
"tailwindcss": "^4.2.4",
"turndown": "7.2.4",
"turndown-plugin-gfm": "1.0.2",
"typescript": "^6.0.3",
"vite": "^5.4.14",
"vite-plugin-solid": "^2.11.6",
"winston": "^3.19.0",
"winston-daily-rotate-file": "^5.0.0",
"zod": "4.4.3",
@@ -263,90 +271,36 @@
"@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.94.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-OVlCttk5MyeTGtrWX5+F3MJOfEMDuEjK8+rm9aQMDfRPWndVMbhk37QG8WLnVbcc7huyUGngVMjT7iMN2llySA=="],
"@aws-crypto/crc32": ["@aws-crypto/crc32@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg=="],
"@aws-crypto/sha256-browser": ["@aws-crypto/sha256-browser@5.2.0", "", { "dependencies": { "@aws-crypto/sha256-js": "^5.2.0", "@aws-crypto/supports-web-crypto": "^5.2.0", "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "@aws-sdk/util-locate-window": "^3.0.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw=="],
"@aws-crypto/sha256-js": ["@aws-crypto/sha256-js@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA=="],
"@aws-crypto/supports-web-crypto": ["@aws-crypto/supports-web-crypto@5.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg=="],
"@aws-crypto/util": ["@aws-crypto/util@5.2.0", "", { "dependencies": { "@aws-sdk/types": "^3.222.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ=="],
"@aws-sdk/client-bedrock-runtime": ["@aws-sdk/client-bedrock-runtime@3.1045.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.8", "@aws-sdk/credential-provider-node": "^3.972.39", "@aws-sdk/eventstream-handler-node": "^3.972.14", "@aws-sdk/middleware-eventstream": "^3.972.10", "@aws-sdk/middleware-host-header": "^3.972.10", "@aws-sdk/middleware-logger": "^3.972.10", "@aws-sdk/middleware-recursion-detection": "^3.972.11", "@aws-sdk/middleware-user-agent": "^3.972.38", "@aws-sdk/middleware-websocket": "^3.972.16", "@aws-sdk/region-config-resolver": "^3.972.13", "@aws-sdk/token-providers": "3.1045.0", "@aws-sdk/types": "^3.973.8", "@aws-sdk/util-endpoints": "^3.996.8", "@aws-sdk/util-user-agent-browser": "^3.972.10", "@aws-sdk/util-user-agent-node": "^3.973.24", "@smithy/config-resolver": "^4.4.17", "@smithy/core": "^3.23.17", "@smithy/eventstream-serde-browser": "^4.2.14", "@smithy/eventstream-serde-config-resolver": "^4.3.14", "@smithy/eventstream-serde-node": "^4.2.14", "@smithy/fetch-http-handler": "^5.3.17", "@smithy/hash-node": "^4.2.14", "@smithy/invalid-dependency": "^4.2.14", "@smithy/middleware-content-length": "^4.2.14", "@smithy/middleware-endpoint": "^4.4.32", "@smithy/middleware-retry": "^4.5.7", "@smithy/middleware-serde": "^4.2.20", "@smithy/middleware-stack": "^4.2.14", "@smithy/node-config-provider": "^4.3.14", "@smithy/node-http-handler": "^4.6.1", "@smithy/protocol-http": "^5.3.14", "@smithy/smithy-client": "^4.12.13", "@smithy/types": "^4.14.1", "@smithy/url-parser": "^4.2.14", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.49", "@smithy/util-defaults-mode-node": "^4.2.54", "@smithy/util-endpoints": "^3.4.2", "@smithy/util-middleware": "^4.2.14", "@smithy/util-retry": "^4.3.6", "@smithy/util-stream": "^4.5.25", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-aPC6gAz9uKRiwfnKB7peTs6yD0FpSzmVnSkx0f2QtJfosFM6J6KtBvR1lMKby050K4C4PAyEScwA5YTsGfTcGA=="],
"@aws-sdk/core": ["@aws-sdk/core@3.974.8", "", { "dependencies": { "@aws-sdk/types": "^3.973.8", "@aws-sdk/xml-builder": "^3.972.22", "@smithy/core": "^3.23.17", "@smithy/node-config-provider": "^4.3.14", "@smithy/property-provider": "^4.2.14", "@smithy/protocol-http": "^5.3.14", "@smithy/signature-v4": "^5.3.14", "@smithy/smithy-client": "^4.12.13", "@smithy/types": "^4.14.1", "@smithy/util-base64": "^4.3.2", "@smithy/util-middleware": "^4.2.14", "@smithy/util-retry": "^4.3.6", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-njR2qoG6ZuB0kvAS2FyICsFZJ6gmCcf2X/7JcD14sUvGDm26wiZ5BrA6LOiUxKFEF+IVe7kdroxyE00YlkiYsw=="],
"@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.34", "", { "dependencies": { "@aws-sdk/core": "^3.974.8", "@aws-sdk/types": "^3.973.8", "@smithy/property-provider": "^4.2.14", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-XT0jtf8Fw9JE6ppsQeoNnZRiG+jqRixMT1v1ZR17G60UvVdsQmTG8nbEyHuEPfMxDXEhfdARaM/XiEhca4lGHQ=="],
"@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.36", "", { "dependencies": { "@aws-sdk/core": "^3.974.8", "@aws-sdk/types": "^3.973.8", "@smithy/fetch-http-handler": "^5.3.17", "@smithy/node-http-handler": "^4.6.1", "@smithy/property-provider": "^4.2.14", "@smithy/protocol-http": "^5.3.14", "@smithy/smithy-client": "^4.12.13", "@smithy/types": "^4.14.1", "@smithy/util-stream": "^4.5.25", "tslib": "^2.6.2" } }, "sha512-DPoGWfy7J7RKxvbf5kOKIGQkD2ek3dbKgzKIGrnLuvZBz5myU+Im/H6pmc14QcnFbqHMqxvtWSgRDSJW3qXLQg=="],
"@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.972.38", "", { "dependencies": { "@aws-sdk/core": "^3.974.8", "@aws-sdk/credential-provider-env": "^3.972.34", "@aws-sdk/credential-provider-http": "^3.972.36", "@aws-sdk/credential-provider-login": "^3.972.38", "@aws-sdk/credential-provider-process": "^3.972.34", "@aws-sdk/credential-provider-sso": "^3.972.38", "@aws-sdk/credential-provider-web-identity": "^3.972.38", "@aws-sdk/nested-clients": "^3.997.6", "@aws-sdk/types": "^3.973.8", "@smithy/credential-provider-imds": "^4.2.14", "@smithy/property-provider": "^4.2.14", "@smithy/shared-ini-file-loader": "^4.4.9", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-oDzUBu2MGJFgoar05sPMCwSrhw44ASyccrHzj66vO69OZqi7I6hZZxXfuPLC8OCzW7C+sU+bI73XHij41yekgQ=="],
"@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.38", "", { "dependencies": { "@aws-sdk/core": "^3.974.8", "@aws-sdk/nested-clients": "^3.997.6", "@aws-sdk/types": "^3.973.8", "@smithy/property-provider": "^4.2.14", "@smithy/protocol-http": "^5.3.14", "@smithy/shared-ini-file-loader": "^4.4.9", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-g1NosS8qe4OF++G2UFCM5ovSkgipC7YYor5KCWatG0UoMSO5YFj9C8muePlyVmOBV/WTI16Jo3/s1NUo/o1Bww=="],
"@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.39", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.34", "@aws-sdk/credential-provider-http": "^3.972.36", "@aws-sdk/credential-provider-ini": "^3.972.38", "@aws-sdk/credential-provider-process": "^3.972.34", "@aws-sdk/credential-provider-sso": "^3.972.38", "@aws-sdk/credential-provider-web-identity": "^3.972.38", "@aws-sdk/types": "^3.973.8", "@smithy/credential-provider-imds": "^4.2.14", "@smithy/property-provider": "^4.2.14", "@smithy/shared-ini-file-loader": "^4.4.9", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-HEswDQyxUtadoZ/bJsPPENHg7R0Lzym5LuMksJeHvqhCOpP+rtkDLKI4/ZChH4w3cf5kG8n6bZuI8PzajoiqMg=="],
"@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.34", "", { "dependencies": { "@aws-sdk/core": "^3.974.8", "@aws-sdk/types": "^3.973.8", "@smithy/property-provider": "^4.2.14", "@smithy/shared-ini-file-loader": "^4.4.9", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-T3IFs4EVmVi1dVN5RciFnklCANSzvrQd/VuHY9ThHSQmYkTogjcGkoJEr+oNUPQZnso52183088NqysMPji1/Q=="],
"@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.972.38", "", { "dependencies": { "@aws-sdk/core": "^3.974.8", "@aws-sdk/nested-clients": "^3.997.6", "@aws-sdk/token-providers": "3.1041.0", "@aws-sdk/types": "^3.973.8", "@smithy/property-provider": "^4.2.14", "@smithy/shared-ini-file-loader": "^4.4.9", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-5ZxG+t0+3Q3QPh8KEjX6syskhgNf7I0MN7oGioTf6Lm1NTjfP7sIcYGNsthXC2qR8vcD3edNZwCr2ovfSSWuRA=="],
"@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.38", "", { "dependencies": { "@aws-sdk/core": "^3.974.8", "@aws-sdk/nested-clients": "^3.997.6", "@aws-sdk/types": "^3.973.8", "@smithy/property-provider": "^4.2.14", "@smithy/shared-ini-file-loader": "^4.4.9", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-lYHFF30DGI20jZcYX8cm6Ns0V7f1dDN6g/MBDLTyD/5iw+bXs3yBr2iAiHDkx4RFU5JgsnZvCHYKiRVPRdmOgw=="],
"@aws-sdk/eventstream-handler-node": ["@aws-sdk/eventstream-handler-node@3.972.14", "", { "dependencies": { "@aws-sdk/types": "^3.973.8", "@smithy/eventstream-codec": "^4.2.14", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-m4X56gxG76/CKfxNVbOFuYwnAZcHgS6HOH8lgp15HoGHIAVTcZfZrXvcYzJFOMLEJgVn+JHBu6EiNV+xSNXXFg=="],
"@aws-sdk/middleware-eventstream": ["@aws-sdk/middleware-eventstream@3.972.10", "", { "dependencies": { "@aws-sdk/types": "^3.973.8", "@smithy/protocol-http": "^5.3.14", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-QUqLs7Af1II9X4fCRAu+EGHG3KHyOp4RkuLhRKoA3NuFlh6TL8i+zXBl8w2LUxqm44B/Kom45hgSlwA1SpTsXQ=="],
"@aws-sdk/middleware-host-header": ["@aws-sdk/middleware-host-header@3.972.10", "", { "dependencies": { "@aws-sdk/types": "^3.973.8", "@smithy/protocol-http": "^5.3.14", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-IJSsIMeVQ8MMCPbuh1AbltkFhLBLXn7aejzfX5YKT/VLDHn++Dcz8886tXckE+wQssyPUhaXrJhdakO2VilRhg=="],
"@aws-sdk/middleware-logger": ["@aws-sdk/middleware-logger@3.972.10", "", { "dependencies": { "@aws-sdk/types": "^3.973.8", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-OOuGvvz1Dm20SjZo5oEBePFqxt5nf8AwkNDSyUHvD9/bfNASmstcYxFAHUowy4n6Io7mWUZ04JURZwSBvyQanQ=="],
"@aws-sdk/middleware-recursion-detection": ["@aws-sdk/middleware-recursion-detection@3.972.11", "", { "dependencies": { "@aws-sdk/types": "^3.973.8", "@aws/lambda-invoke-store": "^0.2.2", "@smithy/protocol-http": "^5.3.14", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-+zz6f79Kj9V5qFK2P+D8Ehjnw4AhphAlCAsPjUqEcInA9umtSSKMrHbSagEeOIsDNuvVrH98bjRHcyQukTrhaQ=="],
"@aws-sdk/middleware-sdk-s3": ["@aws-sdk/middleware-sdk-s3@3.972.37", "", { "dependencies": { "@aws-sdk/core": "^3.974.8", "@aws-sdk/types": "^3.973.8", "@aws-sdk/util-arn-parser": "^3.972.3", "@smithy/core": "^3.23.17", "@smithy/node-config-provider": "^4.3.14", "@smithy/protocol-http": "^5.3.14", "@smithy/signature-v4": "^5.3.14", "@smithy/smithy-client": "^4.12.13", "@smithy/types": "^4.14.1", "@smithy/util-config-provider": "^4.2.2", "@smithy/util-middleware": "^4.2.14", "@smithy/util-stream": "^4.5.25", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-Km7M+i8DrLArVzrid1gfxeGhYHBd3uxvE77g0s5a52zPSVosxzQBnJ0gwWb6NIp/DOk8gsBMhi7V+cpJG0ndTA=="],
"@aws-sdk/middleware-user-agent": ["@aws-sdk/middleware-user-agent@3.972.38", "", { "dependencies": { "@aws-sdk/core": "^3.974.8", "@aws-sdk/types": "^3.973.8", "@aws-sdk/util-endpoints": "^3.996.8", "@smithy/core": "^3.23.17", "@smithy/protocol-http": "^5.3.14", "@smithy/types": "^4.14.1", "@smithy/util-retry": "^4.3.6", "tslib": "^2.6.2" } }, "sha512-iz+B29TXcAZsJpwB+AwG/TTGA5l/VnmMZ2UxtiySOZjI6gCdmviXPwdgzcmuazMy16rXoPY4mYCGe7zdNKfx5A=="],
"@aws-sdk/middleware-websocket": ["@aws-sdk/middleware-websocket@3.972.16", "", { "dependencies": { "@aws-sdk/types": "^3.973.8", "@aws-sdk/util-format-url": "^3.972.10", "@smithy/eventstream-codec": "^4.2.14", "@smithy/eventstream-serde-browser": "^4.2.14", "@smithy/fetch-http-handler": "^5.3.17", "@smithy/protocol-http": "^5.3.14", "@smithy/signature-v4": "^5.3.14", "@smithy/types": "^4.14.1", "@smithy/util-base64": "^4.3.2", "@smithy/util-hex-encoding": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-86+S9oCyRVGzoMRpQhxkArp7kD2K75GPmaNevd9B6EyNhWoNvnCZZ3WbgN4j7ZT+jvtvBCGZvI2XHsWZJ+BRIg=="],
"@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.6", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.8", "@aws-sdk/middleware-host-header": "^3.972.10", "@aws-sdk/middleware-logger": "^3.972.10", "@aws-sdk/middleware-recursion-detection": "^3.972.11", "@aws-sdk/middleware-user-agent": "^3.972.38", "@aws-sdk/region-config-resolver": "^3.972.13", "@aws-sdk/signature-v4-multi-region": "^3.996.25", "@aws-sdk/types": "^3.973.8", "@aws-sdk/util-endpoints": "^3.996.8", "@aws-sdk/util-user-agent-browser": "^3.972.10", "@aws-sdk/util-user-agent-node": "^3.973.24", "@smithy/config-resolver": "^4.4.17", "@smithy/core": "^3.23.17", "@smithy/fetch-http-handler": "^5.3.17", "@smithy/hash-node": "^4.2.14", "@smithy/invalid-dependency": "^4.2.14", "@smithy/middleware-content-length": "^4.2.14", "@smithy/middleware-endpoint": "^4.4.32", "@smithy/middleware-retry": "^4.5.7", "@smithy/middleware-serde": "^4.2.20", "@smithy/middleware-stack": "^4.2.14", "@smithy/node-config-provider": "^4.3.14", "@smithy/node-http-handler": "^4.6.1", "@smithy/protocol-http": "^5.3.14", "@smithy/smithy-client": "^4.12.13", "@smithy/types": "^4.14.1", "@smithy/url-parser": "^4.2.14", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.49", "@smithy/util-defaults-mode-node": "^4.2.54", "@smithy/util-endpoints": "^3.4.2", "@smithy/util-middleware": "^4.2.14", "@smithy/util-retry": "^4.3.6", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-WBDnqatJl+kGObpfmfSxqnXeYTu3Me8wx8WCtvoxX3pfWrrTv8I4WTMSSs7PZqcRcVh8WeUKMgGFjMG+52SR1w=="],
"@aws-sdk/region-config-resolver": ["@aws-sdk/region-config-resolver@3.972.13", "", { "dependencies": { "@aws-sdk/types": "^3.973.8", "@smithy/config-resolver": "^4.4.17", "@smithy/node-config-provider": "^4.3.14", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-CvJ2ZIjK/jVD/lbOpowBVElJyC1YxLTIJ13yM0AEo0t2v7swOzGjSA6lJGH+DwZXQhcjUjoYwc8bVYCX5MDr1A=="],
"@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.25", "", { "dependencies": { "@aws-sdk/middleware-sdk-s3": "^3.972.37", "@aws-sdk/types": "^3.973.8", "@smithy/protocol-http": "^5.3.14", "@smithy/signature-v4": "^5.3.14", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-+CMIt3e1VzlklAECmG+DtP1sV8iKq25FuA0OKpnJ4KA0kxUtd7CgClY7/RU6VzJBQwbN4EJ9Ue6plvqx1qGadw=="],
"@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1045.0", "", { "dependencies": { "@aws-sdk/core": "^3.974.8", "@aws-sdk/nested-clients": "^3.997.6", "@aws-sdk/types": "^3.973.8", "@smithy/property-provider": "^4.2.14", "@smithy/shared-ini-file-loader": "^4.4.9", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-/o4qcty0DmQola0DBniRVeBakYY6ALOvKEFo1AtJpTmMn/cJ+Fk3RWGe5ieT/f/eYbHG9k5E7poKge/E+WGv4Q=="],
"@aws-sdk/types": ["@aws-sdk/types@3.973.8", "", { "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw=="],
"@aws-sdk/util-arn-parser": ["@aws-sdk/util-arn-parser@3.972.3", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-HzSD8PMFrvgi2Kserxuff5VitNq2sgf3w9qxmskKDiDTThWfVteJxuCS9JXiPIPtmCrp+7N9asfIaVhBFORllA=="],
"@aws-sdk/util-endpoints": ["@aws-sdk/util-endpoints@3.996.8", "", { "dependencies": { "@aws-sdk/types": "^3.973.8", "@smithy/types": "^4.14.1", "@smithy/url-parser": "^4.2.14", "@smithy/util-endpoints": "^3.4.2", "tslib": "^2.6.2" } }, "sha512-oOZHcRDihk5iEe5V25NVWg45b3qEA8OpHWVdU/XQh8Zj4heVPAJqWvMphQnU7LkufmUo10EpvFPZuQMiFLJK3g=="],
"@aws-sdk/util-format-url": ["@aws-sdk/util-format-url@3.972.10", "", { "dependencies": { "@aws-sdk/types": "^3.973.8", "@smithy/querystring-builder": "^4.2.14", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-DEKiHNJVtNxdyTeQspzY+15Po/kHm6sF0Cs4HV9Q2+lplB63+DrvdeiSoOSdWEWAoO2RcY1veoXVDz2tWxWCgQ=="],
"@aws-sdk/util-locate-window": ["@aws-sdk/util-locate-window@3.965.5", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ=="],
"@aws-sdk/util-user-agent-browser": ["@aws-sdk/util-user-agent-browser@3.972.10", "", { "dependencies": { "@aws-sdk/types": "^3.973.8", "@smithy/types": "^4.14.1", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-FAzqXvfEssGdSIz8ejatan0bOdx1qefBWKF/gWmVBXIP1HkS7v/wjjaqrAGGKvyihrXTXW00/2/1nTJtxpXz7g=="],
"@aws-sdk/util-user-agent-node": ["@aws-sdk/util-user-agent-node@3.973.24", "", { "dependencies": { "@aws-sdk/middleware-user-agent": "^3.972.38", "@aws-sdk/types": "^3.973.8", "@smithy/node-config-provider": "^4.3.14", "@smithy/types": "^4.14.1", "@smithy/util-config-provider": "^4.2.2", "tslib": "^2.6.2" }, "peerDependencies": { "aws-crt": ">=1.0.0" }, "optionalPeers": ["aws-crt"] }, "sha512-ZWwlkjcIp7cEL8ZfTpTAPNkwx25p7xol0xlKoWVVf22+nsjwmLcHYtTPjIV1cSpmB/b6DaK4cb1fSkvCXHgRdw=="],
"@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.22", "", { "dependencies": { "@nodable/entities": "2.1.0", "@smithy/types": "^4.14.1", "fast-xml-parser": "5.7.2", "tslib": "^2.6.2" } }, "sha512-PMYKKtJd70IsSG0yHrdAbxBr+ZWBKLvzFZfD3/urxgf6hXVMzuU5M+3MJ5G67RpOmLBu1fAUN65SbWuKUCOlAA=="],
"@aws/lambda-invoke-store": ["@aws/lambda-invoke-store@0.2.4", "", {}, "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ=="],
"@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="],
"@babel/compat-data": ["@babel/compat-data@7.29.3", "", {}, "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg=="],
"@babel/core": ["@babel/core@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA=="],
"@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="],
"@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="],
"@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="],
"@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="],
"@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="],
"@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="],
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="],
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
"@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="],
"@babel/helpers": ["@babel/helpers@7.29.2", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.29.0" } }, "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw=="],
"@babel/parser": ["@babel/parser@7.29.3", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA=="],
"@babel/plugin-syntax-jsx": ["@babel/plugin-syntax-jsx@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w=="],
"@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="],
"@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="],
@@ -387,7 +341,51 @@
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="],
"@google/genai": ["@google/genai@1.52.0", "", { "dependencies": { "google-auth-library": "^10.3.0", "p-retry": "^4.6.2", "protobufjs": "^7.5.4", "ws": "^8.18.0" }, "peerDependencies": { "@modelcontextprotocol/sdk": "^1.25.2" }, "optionalPeers": ["@modelcontextprotocol/sdk"] }, "sha512-gwSvbpiN/17O9TbsqSsE/OzZcpv5Fo4RQjdngGgogtuB9RsyJ8ZHhX5KjHj1bp5N9snN2eK8LDGXSaWW2hof8Q=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.21.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.21.5", "", { "os": "android", "cpu": "arm" }, "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg=="],
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.21.5", "", { "os": "android", "cpu": "arm64" }, "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A=="],
"@esbuild/android-x64": ["@esbuild/android-x64@0.21.5", "", { "os": "android", "cpu": "x64" }, "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA=="],
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.21.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ=="],
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.21.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw=="],
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.21.5", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g=="],
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.21.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ=="],
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.21.5", "", { "os": "linux", "cpu": "arm" }, "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA=="],
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.21.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q=="],
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.21.5", "", { "os": "linux", "cpu": "ia32" }, "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg=="],
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg=="],
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg=="],
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.21.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w=="],
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA=="],
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.21.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A=="],
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.21.5", "", { "os": "linux", "cpu": "x64" }, "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ=="],
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.21.5", "", { "os": "none", "cpu": "x64" }, "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg=="],
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.21.5", "", { "os": "openbsd", "cpu": "x64" }, "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow=="],
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.21.5", "", { "os": "sunos", "cpu": "x64" }, "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg=="],
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.21.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A=="],
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.21.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA=="],
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.21.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw=="],
"@inquirer/ansi": ["@inquirer/ansi@2.0.5", "", {}, "sha512-doc2sWgJpbFQ64UflSVd17ibMGDuxO1yKgOgLMwavzESnXjFWJqUeG8saYosqKpHp4kWiM5x1nXvEjbpx90gzw=="],
@@ -597,110 +595,90 @@
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.41.1", "", {}, "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA=="],
"@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="],
"@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="],
"@protobufjs/codegen": ["@protobufjs/codegen@2.0.5", "", {}, "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g=="],
"@protobufjs/eventemitter": ["@protobufjs/eventemitter@1.1.0", "", {}, "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q=="],
"@protobufjs/fetch": ["@protobufjs/fetch@1.1.0", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.1", "@protobufjs/inquire": "^1.1.0" } }, "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ=="],
"@protobufjs/float": ["@protobufjs/float@1.0.2", "", {}, "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="],
"@protobufjs/inquire": ["@protobufjs/inquire@1.1.1", "", {}, "sha512-mnzgDV26ueAvk7rsbt9L7bE0SuAoqyuys/sMMrmVcN5x9VsxpcG3rqAUSgDyLp0UZlmNfIbQ4fHfCtreVBk8Ew=="],
"@protobufjs/path": ["@protobufjs/path@1.1.2", "", {}, "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="],
"@protobufjs/pool": ["@protobufjs/pool@1.1.0", "", {}, "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="],
"@protobufjs/utf8": ["@protobufjs/utf8@1.1.1", "", {}, "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg=="],
"@puppeteer/browsers": ["@puppeteer/browsers@2.13.2", "", { "dependencies": { "debug": "^4.4.3", "extract-zip": "^2.0.1", "progress": "^2.0.3", "proxy-agent": "^6.5.0", "semver": "^7.7.4", "tar-fs": "^3.1.1", "yargs": "^17.7.2" }, "bin": { "browsers": "lib/cjs/main-cli.js" } }, "sha512-5EUZSUIc37H6aIXyWO0Z4y8NlF8NnjgmqeQgOGiswAU7pY0HOo16ho4+alIWmSfdZnjqBRawMsP3I5YqLSn6kw=="],
"@smithy/config-resolver": ["@smithy/config-resolver@4.5.0", "", { "dependencies": { "@smithy/core": "^3.24.0", "tslib": "^2.6.2" } }, "sha512-m5PNfr7xKdIegNG8DlLz+Gf/DlAhHWFGmFbe0DZo9pnvBwuZ3P/9OMtQU0UyWMYy8zjl+HDFVS7rdD9p2xEFjQ=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.60.3", "", { "os": "android", "cpu": "arm" }, "sha512-x35CNW/ANXG3hE/EZpRU8MXX1JDN86hBb2wMGAtltkz7pc6cxgjpy1OMMfDosOQ+2hWqIkag/fGok1Yady9nGw=="],
"@smithy/core": ["@smithy/core@3.24.0", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-rZ5YfycIXX6puoGjthnDiMpUgtKNOq3c7CndQYkCNYQTv26AiCrZQOJPy7ANSfZ6Okk3UvCRnmO1OYWlLnYZgg=="],
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.60.3", "", { "os": "android", "cpu": "arm64" }, "sha512-xw3xtkDApIOGayehp2+Rz4zimfkaX65r4t47iy+ymQB2G4iJCBBfj0ogVg5jpvjpn8UWn/+q9tprxleYeNp3Hw=="],
"@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.3.0", "", { "dependencies": { "@smithy/core": "^3.24.0", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-5gi+28FH+RurB2+tcRH1CK7KiLJ0dVnabjWLY3DgeFLiU45dbyrsq7NOYvMUcHgu9LVZH5F7G+Qk1GdXF0y6jg=="],
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.60.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-vo6Y5Qfpx7/5EaamIwi0WqW2+zfiusVihKatLvtN1VFVy3D13uERk/6gZLU1UiHRL6fDXqj/ELIeVRGnvcTE1g=="],
"@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.3.0", "", { "dependencies": { "@smithy/core": "^3.24.0", "tslib": "^2.6.2" } }, "sha512-vBxRIMKUGxS6sifVJOhV50PY1w+4esgSgS6cgEa/EB0lJL3BuRP1oP6A1yTOX9j9eEwHi4bRHC94A2yhG/l0+Q=="],
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.60.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-D+0QGcZhBzTN82weOnsSlY7V7+RMmPuF1CkbxyMAGE8+ZHeUjyb76ZiWmBlCu//AQQONvxcqRbwZTajZKqjuOw=="],
"@smithy/eventstream-serde-browser": ["@smithy/eventstream-serde-browser@4.3.0", "", { "dependencies": { "@smithy/core": "^3.24.0", "tslib": "^2.6.2" } }, "sha512-JlY17/ZwBJ2O7FK/bKt8PZR+HBkyFwvgssgT6LiB0xYtz5/E5XG/HeKr5q2NMaVm8u8xjFfGk/6DVlbBe1qNkA=="],
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.60.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-6HnvHCT7fDyj6R0Ph7A6x8dQS/S38MClRWeDLqc0MdfWkxjiu1HSDYrdPhqSILzjTIC/pnXbbJbo+ft+gy/9hQ=="],
"@smithy/eventstream-serde-config-resolver": ["@smithy/eventstream-serde-config-resolver@4.4.0", "", { "dependencies": { "@smithy/core": "^3.24.0", "tslib": "^2.6.2" } }, "sha512-1Pg7aqxIdMilTbGJKCHTx0toIkKSrHdO6VHCh9oCncWJG+1wkJa90O/xb9mmRPuoOFCg2DLZAqnRyuBiUQnNIA=="],
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.60.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-KHLgC3WKlUYW3ShFKnnosZDOJ0xjg9zp7au3sIm2bs/tGBeC2ipmvRh/N7JKi0t9Ue20C0dpEshi8WUubg+cnA=="],
"@smithy/eventstream-serde-node": ["@smithy/eventstream-serde-node@4.3.0", "", { "dependencies": { "@smithy/core": "^3.24.0", "tslib": "^2.6.2" } }, "sha512-Xte1Td6CQpc/D0WnPZ2k98CvF7y1GopylMoGY/r26a9wbRHV5xusRbT6O9vouSeZlvtxoVb4ON/1fLRofO7m4Q=="],
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.60.3", "", { "os": "linux", "cpu": "arm" }, "sha512-DV6fJoxEYWJOvaZIsok7KrYl0tPvga5OZ2yvKHNNYyk/2roMLqQAbGhr78EQ5YhHpnhLKJD3S1WFusAkmUuV5g=="],
"@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.4.0", "", { "dependencies": { "@smithy/core": "^3.24.0", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-yxurumLvHfgYgM0FVtjOVIyBSJXfno4xKKOgD43wOk9Qh+2lTKfP9Qhu4JHU7IUwrqVPa888byUzomHMgvKVMg=="],
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.60.3", "", { "os": "linux", "cpu": "arm" }, "sha512-mQKoJAzvuOs6F+TZybQO4GOTSMUu7v0WdxEk24krQ/uUxXoPTtHjuaUuPmFhtBcM4K0ons8nrE3JyhTuCFtT/w=="],
"@smithy/hash-node": ["@smithy/hash-node@4.3.0", "", { "dependencies": { "@smithy/core": "^3.24.0", "tslib": "^2.6.2" } }, "sha512-4a+KoVqr1SZtw7cZvY24XU1S5OL+c23MdDQ3jFmMCQ5s9diBFdMG/UIgp5dNqlwvDrWA0U5KO+z3Gzq1ize+LA=="],
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.60.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-Whjj2qoiJ6+OOJMGptTYazaJvjOJm+iKHpXQM1P3LzGjt7Ff++Tp7nH4N8J/BUA7R9IHfDyx4DJIflifwnbmIA=="],
"@smithy/invalid-dependency": ["@smithy/invalid-dependency@4.3.0", "", { "dependencies": { "@smithy/core": "^3.24.0", "tslib": "^2.6.2" } }, "sha512-TaoGtqi2ZNdGzxUgYcLczjW8rb/h5DQ8vlCMYDSdZ4LRzGQrrEYgUjlZVM9dAagTsLK5gZx1f7+44sFTjz5vuQ=="],
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.60.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-4YTNHKqGng5+yiZt3mg77nmyuCfmNfX4fPmyUapBcIk+BdwSwmCWGXOUxhXbBEkFHtoN5boLj/5NON+u5QC9tg=="],
"@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="],
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.60.3", "", { "os": "linux", "cpu": "none" }, "sha512-SU3kNlhkpI4UqlUc2VXPGK9o886ZsSeGfMAX2ba2b8DKmMXq4AL7KUrkSWVbb7koVqx41Yczx6dx5PNargIrEA=="],
"@smithy/middleware-content-length": ["@smithy/middleware-content-length@4.3.0", "", { "dependencies": { "@smithy/core": "^3.24.0", "tslib": "^2.6.2" } }, "sha512-IbSiS/3nOxsimCthzElEoBrjQo+Na4bsQ63qyC8qSI8lkMjOv9+VlosDQd8gfNolAD9XmC5tLqYTI0bJGJsscg=="],
"@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.60.3", "", { "os": "linux", "cpu": "none" }, "sha512-6lDLl5h4TXpB1mTf2rQWnAk/LcXrx9vBfu/DT5TIPhvMhRWaZ5MxkIc8u4lJAmBo6klTe1ywXIUHFjylW505sg=="],
"@smithy/middleware-endpoint": ["@smithy/middleware-endpoint@4.5.0", "", { "dependencies": { "@smithy/core": "^3.24.0", "tslib": "^2.6.2" } }, "sha512-ux8LgN/m/X7ET2ISRc8G4aKFI1QhINZtkKpoayNPTrhwpsCVxb47mlpYFuWceTlesc0Wmb0S9y6DP195ReQoXA=="],
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.60.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-BMo8bOw8evlup/8G+cj5xWtPyp93xPdyoSN16Zy90Q2QZ0ZYRhCt6ZJSwbrRzG9HApFabjwj2p25TUPDWrhzqQ=="],
"@smithy/middleware-retry": ["@smithy/middleware-retry@4.6.0", "", { "dependencies": { "@smithy/core": "^3.24.0", "tslib": "^2.6.2" } }, "sha512-8CtxY9aHT4f3UvZUbU2O0bccRckqTDfTKk3t1DawUZa5DWRZdV2AMABLsdMTdj7KE1uumhzEaT0X7/jTcOtoBw=="],
"@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.60.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-E0L8X1dZN1/Rph+5VPF6Xj2G7JJvMACVXtamTJIDrVI44Y3K+G8gQaMEAavbqCGTa16InptiVrX6eM6pmJ+7qA=="],
"@smithy/middleware-serde": ["@smithy/middleware-serde@4.3.0", "", { "dependencies": { "@smithy/core": "^3.24.0", "tslib": "^2.6.2" } }, "sha512-c+V02hZlIStscI4ie2VllJjM4DLxdI2SymIBvXmqCqicrNb0NAbgDXDTBiwcMiruaBOqEFYxpKXbz6JjsNEN3Q=="],
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.60.3", "", { "os": "linux", "cpu": "none" }, "sha512-oZJ/WHaVfHUiRAtmTAeo3DcevNsVvH8mbvodjZy7D5QKvCefO371SiKRpxoDcCxB3PTRTLayWBkvmDQKTcX/sw=="],
"@smithy/middleware-stack": ["@smithy/middleware-stack@4.3.0", "", { "dependencies": { "@smithy/core": "^3.24.0", "tslib": "^2.6.2" } }, "sha512-KtYcs+sJn7AiT0YdM53/6MT0dKsaW2MSAr9MpprRVSfwN9qyKQf2dBIuCXt18/nEZaWerol/bGaQ63G949aovw=="],
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.60.3", "", { "os": "linux", "cpu": "none" }, "sha512-Dhbyh7j9FybM3YaTgaHmVALwA8AkUwTPccyCQ79TG9AJUsMQqgN1DDEZNr4+QUfwiWvLDumW5vdwzoeUF+TNxQ=="],
"@smithy/node-config-provider": ["@smithy/node-config-provider@4.4.0", "", { "dependencies": { "@smithy/core": "^3.24.0", "tslib": "^2.6.2" } }, "sha512-5RutFJsYoqK4tWYZOjGQrPLowGf2Ku8rbNuVeGkNJ5axIDO4LV/fydBojPtwcDz2zf87YNCOXfNyuEyAwYgI7A=="],
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.60.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-cJd1X5XhHHlltkaypz1UcWLA8AcoIi1aWhsvaWDskD1oz2eKCypnqvTQ8ykMNI0RSmm7NkTdSqSSD7zM0xa6Ig=="],
"@smithy/node-http-handler": ["@smithy/node-http-handler@4.7.0", "", { "dependencies": { "@smithy/core": "^3.24.0", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-PxF57Jr3dPm+RgZWekOL+o96FPdaT62xZUyDfi47uMRFi5rHpwO/ewFbrztrASQ/7H8moNi1sspIHihHpfoKsQ=="],
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.60.3", "", { "os": "linux", "cpu": "x64" }, "sha512-DAZDBHQfG2oQuhY7mc6I3/qB4LU2fQCjRvxbDwd/Jdvb9fypP4IJ4qmtu6lNjes6B531AI8cg1aKC2di97bUxA=="],
"@smithy/property-provider": ["@smithy/property-provider@4.3.0", "", { "dependencies": { "@smithy/core": "^3.24.0", "tslib": "^2.6.2" } }, "sha512-/YBWtO2SdvPSAUk/Ke1Xpdg1E1lfaNGblla7mnIVGtaGkSQ5bK7KBZqpuj5IokHlU9UcLDvt2QwTLV7oRzBUTA=="],
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.60.3", "", { "os": "linux", "cpu": "x64" }, "sha512-cRxsE8c13mZOh3vP+wLDxpQBRrOHDIGOWyDL93Sy0Ga8y515fBcC2pjUfFwUe5T7tqvTvWbCpg1URM/AXdWIXA=="],
"@smithy/protocol-http": ["@smithy/protocol-http@5.4.0", "", { "dependencies": { "@smithy/core": "^3.24.0", "tslib": "^2.6.2" } }, "sha512-WG0LgSZg+WbvWYD04uwIYVyMEpyd0cPx1lkqx61JxunxiFti+wGoFiDKr6wswun1r25Z2f8yUoMQWyxjMnnXtw=="],
"@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.60.3", "", { "os": "openbsd", "cpu": "x64" }, "sha512-QaWcIgRxqEdQdhJqW4DJctsH6HCmo5vHxY0krHSX4jMtOqfzC+dqDGuHM87bu4H8JBeibWx7jFz+h6/4C8wA5Q=="],
"@smithy/querystring-builder": ["@smithy/querystring-builder@4.3.0", "", { "dependencies": { "@smithy/core": "^3.24.0", "tslib": "^2.6.2" } }, "sha512-w1EVgJXg1R/f5iJlQatMBt7sP9tHhEscvK0lv62j/esnqRgdoQqlkcgHotfOJpg1CTtY8eUvze3v3EU91631IQ=="],
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.60.3", "", { "os": "none", "cpu": "arm64" }, "sha512-AaXwSvUi3QIPtroAUw1t5yHGIyqKEXwH54WUocFolZhpGDruJcs8c+xPNDRn4XiQsS7MEwnYsHW2l0MBLDMkWg=="],
"@smithy/shared-ini-file-loader": ["@smithy/shared-ini-file-loader@4.5.0", "", { "dependencies": { "@smithy/core": "^3.24.0", "tslib": "^2.6.2" } }, "sha512-xATpw6gcurFztdsUrMNaKb2ugqk3545Whhqg7ZD4sxTg+zI27THjg3IY+InXsVWturOWdCdV+UHQx11g9Sp5Kw=="],
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.60.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-65LAKM/bAWDqKNEelHlcHvm2V+Vfb8C6INFxQXRHCvaVN1rJfwr4NvdP4FyzUaLqWfaCGaadf6UbTm8xJeYfEg=="],
"@smithy/signature-v4": ["@smithy/signature-v4@5.4.0", "", { "dependencies": { "@smithy/core": "^3.24.0", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-nkdB9T8JS6iD5PukE5TB8KqcvMEPVPHVUY7J0odYJgyIM40Du2msUhBdoPNRqRArDDcGQqVQcbzu0CZA7b+Nkw=="],
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.60.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-EEM2gyhBF5MFnI6vMKdX1LAosE627RGBzIoGMdLloPZkXrUN0Ckqgr2Qi8+J3zip/8NVVro3/FjB+tjhZUgUHA=="],
"@smithy/smithy-client": ["@smithy/smithy-client@4.13.0", "", { "dependencies": { "@smithy/core": "^3.24.0", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-lysfoRCr7PdD9CsPp9VQuJYRGI5mWYb8FRkbdBSQttxpQmW7tZsFgmpBNKVcgvBsAgBCkYX/UQs0NmznuBcZQQ=="],
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.60.3", "", { "os": "win32", "cpu": "x64" }, "sha512-E5Eb5H/DpxaoXH++Qkv28RcUJboMopmdDUALBczvHMf7hNIxaDZqwY5lK12UK1BHacSmvupoEWGu+n993Z0y1A=="],
"@smithy/types": ["@smithy/types@4.14.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-59b5HtSVrVR/eYNei3BUj3DCPKD/G7EtDDe7OEJE7i7FtQFugYo6MxbotS8mVJkLNVf8gYaAlEBwwtJ9HzhWSg=="],
"@smithy/url-parser": ["@smithy/url-parser@4.3.0", "", { "dependencies": { "@smithy/core": "^3.24.0", "tslib": "^2.6.2" } }, "sha512-I5tCWs/ndLrJrbvlnsN1cOt8PVAbQEqg0nNeQqebD5ynQcbhgch9uA7KmpX9vfq/vEudq0iVYAOxt+4aBkUlWA=="],
"@smithy/util-base64": ["@smithy/util-base64@4.4.0", "", { "dependencies": { "@smithy/core": "^3.24.0", "tslib": "^2.6.2" } }, "sha512-puJITyefgQ9a5F+wKylCLkf0VCwesWbaN4O3YCEalRin4N0CTPQu/XA3kz/QsMOTgd3knhd0BQwGCBm/tv0Y1A=="],
"@smithy/util-body-length-browser": ["@smithy/util-body-length-browser@4.3.0", "", { "dependencies": { "@smithy/core": "^3.24.0", "tslib": "^2.6.2" } }, "sha512-83U8xa8EmdExGzFuqBzgXvtmbLQIYcCuCNm5no4rlPqpGdOPGUufzMvLdlw+sPTb01qHIsDDNwOecm4s8ROOPw=="],
"@smithy/util-body-length-node": ["@smithy/util-body-length-node@4.3.0", "", { "dependencies": { "@smithy/core": "^3.24.0", "tslib": "^2.6.2" } }, "sha512-Ok2v9zPFfd6uOJMTIIJ8HFdCpARD77q4OHYhwhG9y5X1Y9oeQ0CHUQVJD6LhT6l8FUkFYisqcUaZSg7SArFUTA=="],
"@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="],
"@smithy/util-config-provider": ["@smithy/util-config-provider@4.3.0", "", { "dependencies": { "@smithy/core": "^3.24.0", "tslib": "^2.6.2" } }, "sha512-kAC6/UB9qW9r2xQAOko2iDxAXmRD2VGMZjnXSEacAhQySdJs58CwvoOE0tHWdtc/lWF4g78X6Z9ucLanJnuVUw=="],
"@smithy/util-defaults-mode-browser": ["@smithy/util-defaults-mode-browser@4.4.0", "", { "dependencies": { "@smithy/core": "^3.24.0", "tslib": "^2.6.2" } }, "sha512-jKezW5Taa+N2gbkB02UVijH1rFlEJC+cskZzwasFqFJMBBi/bcVgHqcYOX0WOnUk6MDZfHf0gEsr5Br4XMHiAg=="],
"@smithy/util-defaults-mode-node": ["@smithy/util-defaults-mode-node@4.3.0", "", { "dependencies": { "@smithy/core": "^3.24.0", "tslib": "^2.6.2" } }, "sha512-xYRuNHHIztu5AzruMJ8kTyA1JsBL/yZKvX5z/A7OHUxsf+rkEESZFZWJDcAj5dDWSu6brWFe5KH6qJNTVztX/w=="],
"@smithy/util-endpoints": ["@smithy/util-endpoints@3.5.0", "", { "dependencies": { "@smithy/core": "^3.24.0", "tslib": "^2.6.2" } }, "sha512-pcvTCp9Wch/9UnWWfRGoG5GJogDXFPjevE+CqALxtPFGA4GqFQRD6eUtgJhHN+NPtohcozI12u1skF2/iubGrQ=="],
"@smithy/util-hex-encoding": ["@smithy/util-hex-encoding@4.3.0", "", { "dependencies": { "@smithy/core": "^3.24.0", "tslib": "^2.6.2" } }, "sha512-ZkAHu0SAsXPkVpaP6dhzu+DO/i4mlAMmwa4tejbGv9shozy/m4a2vIAk6HjPy7fKuGpANE1tZczGfCSLgyw5jA=="],
"@smithy/util-middleware": ["@smithy/util-middleware@4.3.0", "", { "dependencies": { "@smithy/core": "^3.24.0", "tslib": "^2.6.2" } }, "sha512-X/DNQxgUCbjjs3HosLmt5Yi1NocxjRFiiOgHml4tVV3w4mIbqZxPR8kq7apGPEMnhIpyxeTgFyypMrfxfn2DlQ=="],
"@smithy/util-retry": ["@smithy/util-retry@4.4.0", "", { "dependencies": { "@smithy/core": "^3.24.0", "tslib": "^2.6.2" } }, "sha512-pV/Kq4jUuP9raOqwSPeBiut2IWmwbc9vM+nE3ly4YUkzPHbBZvfhikwMOyudER+KHPjakuc8r4TecEPMsI7nVg=="],
"@smithy/util-stream": ["@smithy/util-stream@4.6.0", "", { "dependencies": { "@smithy/core": "^3.24.0", "tslib": "^2.6.2" } }, "sha512-BlWg46UASokl3O5YqWmbLpINE5stmAxynXlyOe1nE4dx+tvwgqtT4ug/rPcRg0xVcBnj68XlcOqbXeaGGcH0DA=="],
"@smithy/util-utf8": ["@smithy/util-utf8@4.3.0", "", { "dependencies": { "@smithy/core": "^3.24.0", "tslib": "^2.6.2" } }, "sha512-5hrmCc+dTgZkiFhX72Q16LemYPkvZ1M4pFMOhk0X9tQnLY7dn7zC1+C+aAJn0dw6CXldbqY/KMbMYCwm8yw14g=="],
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.60.3", "", { "os": "win32", "cpu": "x64" }, "sha512-hPt/bgL5cE+Qp+/TPHBqptcAgPzgj46mPcg/16zNUmbQk0j+mOEQV/+Lqu8QRtDV3Ek95Q6FeFITpuhl6OTsAA=="],
"@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.0", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.21.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.0" } }, "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g=="],
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.3.0", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.3.0", "@tailwindcss/oxide-darwin-arm64": "4.3.0", "@tailwindcss/oxide-darwin-x64": "4.3.0", "@tailwindcss/oxide-freebsd-x64": "4.3.0", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.0", "@tailwindcss/oxide-linux-arm64-gnu": "4.3.0", "@tailwindcss/oxide-linux-arm64-musl": "4.3.0", "@tailwindcss/oxide-linux-x64-gnu": "4.3.0", "@tailwindcss/oxide-linux-x64-musl": "4.3.0", "@tailwindcss/oxide-wasm32-wasi": "4.3.0", "@tailwindcss/oxide-win32-arm64-msvc": "4.3.0", "@tailwindcss/oxide-win32-x64-msvc": "4.3.0" } }, "sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg=="],
"@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.3.0", "", { "os": "android", "cpu": "arm64" }, "sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng=="],
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.3.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ=="],
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.3.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA=="],
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.3.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ=="],
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.3.0", "", { "os": "linux", "cpu": "arm" }, "sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA=="],
"@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.3.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg=="],
"@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.3.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ=="],
"@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.3.0", "", { "os": "linux", "cpu": "x64" }, "sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ=="],
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.3.0", "", { "os": "linux", "cpu": "x64" }, "sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg=="],
"@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.3.0", "", { "dependencies": { "@emnapi/core": "^1.10.0", "@emnapi/runtime": "^1.10.0", "@emnapi/wasi-threads": "^1.2.1", "@napi-rs/wasm-runtime": "^1.1.4", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA=="],
"@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.3.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ=="],
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.3.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA=="],
"@tailwindcss/vite": ["@tailwindcss/vite@4.3.0", "", { "dependencies": { "@tailwindcss/node": "4.3.0", "@tailwindcss/oxide": "4.3.0", "tailwindcss": "4.3.0" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-t6J3OrB5Fc0ExuhohouH0fWUGMYL6PTLhW+E7zIk/pdbnJARZDCwjBznFnkh5ynRnIRSI4YjtTH0t6USjJISrw=="],
"@tokenizer/inflate": ["@tokenizer/inflate@0.4.1", "", { "dependencies": { "debug": "^4.4.3", "token-types": "^6.1.1" } }, "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA=="],
"@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="],
@@ -709,20 +687,24 @@
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="],
"@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
"@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="],
"@types/babel__template": ["@types/babel__template@7.4.4", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="],
"@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
"@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/node": ["@types/node@25.6.2", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw=="],
"@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="],
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
"@types/retry": ["@types/retry@0.12.0", "", {}, "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA=="],
"@types/triple-beam": ["@types/triple-beam@1.3.5", "", {}, "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw=="],
"@types/turndown": ["@types/turndown@5.0.6", "", {}, "sha512-ru00MoyeeouE5BX4gRL+6m/BsDfbRayOskWqUvh7CLGW+UXxHQItqALa38kKnOiZPqJrtzJUgAC2+F0rL1S4Pg=="],
@@ -749,7 +731,7 @@
"@xterm/headless": ["@xterm/headless@6.0.0", "", {}, "sha512-5Yj1QINYCyzrZtf8OFIHi47iQtI+0qYFPHmouEfG8dHNxbZ9Tb9YGSuLcsEwj9Z+OL75GJqPyJbyoFer80a2Hw=="],
"agent-base": ["agent-base@9.0.0", "", {}, "sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA=="],
"agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
"ansi-escapes": ["ansi-escapes@7.3.0", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg=="],
@@ -765,6 +747,10 @@
"b4a": ["b4a@1.8.1", "", { "peerDependencies": { "react-native-b4a": "*" }, "optionalPeers": ["react-native-b4a"] }, "sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw=="],
"babel-plugin-jsx-dom-expressions": ["babel-plugin-jsx-dom-expressions@0.40.6", "", { "dependencies": { "@babel/helper-module-imports": "7.18.6", "@babel/plugin-syntax-jsx": "^7.18.6", "@babel/types": "^7.20.7", "html-entities": "2.3.3", "parse5": "^7.1.2" }, "peerDependencies": { "@babel/core": "^7.20.12" } }, "sha512-v3P1MW46Lm7VMpAkq0QfyzLWWkC8fh+0aE5Km4msIgDx5kjenHU0pF2s+4/NH8CQn/kla6+Hvws+2AF7bfV5qQ=="],
"babel-preset-solid": ["babel-preset-solid@1.9.12", "", { "dependencies": { "babel-plugin-jsx-dom-expressions": "^0.40.6" }, "peerDependencies": { "@babel/core": "^7.0.0", "solid-js": "^1.9.12" }, "optionalPeers": ["solid-js"] }, "sha512-LLqnuKVDlKpyBlMPcH6qEvs/wmS9a+NczppxJ3ryS/c0O5IiSFOIBQi9GzyiGDSbcJpx4Gr87jyFTos1MyEuWg=="],
"bare-events": ["bare-events@2.8.2", "", { "peerDependencies": { "bare-abort-controller": "*" }, "optionalPeers": ["bare-abort-controller"] }, "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ=="],
"bare-fs": ["bare-fs@4.7.1", "", { "dependencies": { "bare-events": "^2.5.4", "bare-path": "^3.0.0", "bare-stream": "^2.6.4", "bare-url": "^2.2.2", "fast-fifo": "^1.3.2" }, "peerDependencies": { "bare-buffer": "*" }, "optionalPeers": ["bare-buffer"] }, "sha512-WDRsyVN52eAx/lBamKD6uyw8H4228h/x0sGGGegOamM2cd7Pag88GfMQalobXI+HaEUxpCkbKQUDOQqt9wawRw=="],
@@ -779,26 +765,26 @@
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.29", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-Asa2krT+XTPZINCS+2QcyS8WTkObE77RwkydwF7h6DmnKqbvlalz93m/dnphUyCa6SWSP51VgtEUf2FN+gelFQ=="],
"basic-ftp": ["basic-ftp@5.3.1", "", {}, "sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw=="],
"beautiful-mermaid": ["beautiful-mermaid@1.1.3", "", { "dependencies": { "elkjs": "^0.11.0", "entities": "^7.0.1" } }, "sha512-TItrtrAyHp1vwFfFVYauWGrquouk/6SS21Aq3RsxindSYZODcN4xYrPZD6BiZRU+o5mKJzDPz9MUSMvELdylyg=="],
"before-after-hook": ["before-after-hook@4.0.0", "", {}, "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ=="],
"bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="],
"bluebird": ["bluebird@3.4.7", "", {}, "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA=="],
"boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="],
"bowser": ["bowser@2.14.1", "", {}, "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg=="],
"browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="],
"buffer-crc32": ["buffer-crc32@0.2.13", "", {}, "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ=="],
"buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="],
"bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
"caniuse-lite": ["caniuse-lite@1.0.30001792", "", {}, "sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw=="],
"chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
"chardet": ["chardet@2.1.1", "", {}, "sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ=="],
@@ -831,6 +817,8 @@
"content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="],
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
"core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="],
"css-select": ["css-select@5.2.2", "", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.1.0", "domhandler": "^5.0.2", "domutils": "^3.0.1", "nth-check": "^2.0.1" } }, "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw=="],
@@ -841,13 +829,13 @@
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"data-uri-to-buffer": ["data-uri-to-buffer@8.0.0", "", {}, "sha512-6UHfyCux51b8PTGDgveqtz1tvphBku5DrMKKJbFAZAJOI2zsjDpDoYE1+QGj7FOMS4BdTFNJsJiR3zEB0xH0yQ=="],
"data-uri-to-buffer": ["data-uri-to-buffer@6.0.2", "", {}, "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw=="],
"date-fns": ["date-fns@4.1.0", "", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"degenerator": ["degenerator@7.0.1", "", { "dependencies": { "ast-types": "^0.13.4", "escodegen": "^2.1.0", "esprima": "^4.0.1" }, "peerDependencies": { "quickjs-wasi": "^2.2.0" } }, "sha512-ABErK0IefDSyHjlPH7WUEenIAX2rPPnrDcDM+TS3z3+zu9TfyKKi07BQM+8rmxpdE2y1v5fjjdoAS/x4D2U60w=="],
"degenerator": ["degenerator@5.0.1", "", { "dependencies": { "ast-types": "^0.13.4", "escodegen": "^2.1.0", "esprima": "^4.0.1" } }, "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
@@ -867,7 +855,7 @@
"duck": ["duck@0.1.12", "", { "dependencies": { "underscore": "^1.13.1" } }, "sha512-wkctla1O6VfP89gQ+J/yDesM0S7B7XLXjKGzXxMDVFg7uEn706niAtyYovKbyq1oT9YwDcly721/iUWoc8MVRg=="],
"ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="],
"electron-to-chromium": ["electron-to-chromium@1.5.353", "", {}, "sha512-kOrWphBi8TOZyiJZqsgqIle0lw+tzmnQK83pV9dZUd01Nm2POECSyFQMAuarzZdYqQW7FH9RaYOuaRo3h+bQ3w=="],
"elkjs": ["elkjs@0.11.1", "", {}, "sha512-zxxR9k+rx5ktMwT/FwyLdPCrq7xN6e4VGGHH8hA01vVYKjTFik7nHOxBnAYtrgYUB1RpAiLvA1/U2YraWxyKKg=="],
@@ -887,6 +875,8 @@
"es-toolkit": ["es-toolkit@1.46.1", "", {}, "sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ=="],
"esbuild": ["esbuild@0.21.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="],
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"escodegen": ["escodegen@2.1.0", "", { "dependencies": { "esprima": "^4.0.1", "estraverse": "^5.2.0", "esutils": "^2.0.2" }, "optionalDependencies": { "source-map": "~0.6.1" }, "bin": { "esgenerate": "bin/esgenerate.js", "escodegen": "bin/escodegen.js" } }, "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w=="],
@@ -903,8 +893,6 @@
"exifr": ["exifr@7.1.3", "", {}, "sha512-g/aje2noHivrRSLbAUtBPWFbxKdKhgj/xr1vATDdUXPOFYJlQ62Ft0oy+72V6XLIpDJfHs6gXLbBLAolqOXYRw=="],
"extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
"extract-zip": ["extract-zip@2.0.1", "", { "dependencies": { "debug": "^4.1.1", "get-stream": "^5.1.0", "yauzl": "^2.10.0" }, "optionalDependencies": { "@types/yauzl": "^2.9.1" }, "bin": { "extract-zip": "cli.js" } }, "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg=="],
"fast-content-type-parse": ["fast-content-type-parse@3.0.0", "", {}, "sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg=="],
@@ -925,8 +913,6 @@
"fecha": ["fecha@4.2.3", "", {}, "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw=="],
"fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="],
"fflate": ["fflate@0.8.2", "", {}, "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A=="],
"file-stream-rotator": ["file-stream-rotator@0.6.1", "", { "dependencies": { "moment": "^2.29.1" } }, "sha512-u+dBid4PvZw17PmDeRcNOtCP9CCK/9lRN2w+r1xIS7yOL9JFrIBKTvrYsxT4P0pGtThYTn++QS5ChHaUov3+zQ=="],
@@ -935,11 +921,9 @@
"fn.name": ["fn.name@1.1.0", "", {}, "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw=="],
"formdata-polyfill": ["formdata-polyfill@4.0.10", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"gaxios": ["gaxios@7.1.4", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2" } }, "sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA=="],
"gcp-metadata": ["gcp-metadata@8.1.2", "", { "dependencies": { "gaxios": "^7.0.0", "google-logging-utils": "^1.0.0", "json-bigint": "^1.0.0" } }, "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg=="],
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
"get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
@@ -947,23 +931,21 @@
"get-stream": ["get-stream@5.2.0", "", { "dependencies": { "pump": "^3.0.0" } }, "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA=="],
"get-uri": ["get-uri@8.0.0", "", { "dependencies": { "basic-ftp": "^5.2.0", "data-uri-to-buffer": "8.0.0", "debug": "^4.3.4" } }, "sha512-CqtZlMKvfJeY0Zxv8wazDwXmSKmnMnsmNy8j8+wudi8EyG/pMUB1NqHc+Tv1QaNtpYsK9nOYjb7r7Ufu32RPSw=="],
"google-auth-library": ["google-auth-library@10.6.2", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^7.1.4", "gcp-metadata": "8.1.2", "google-logging-utils": "1.1.3", "jws": "^4.0.0" } }, "sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw=="],
"google-logging-utils": ["google-logging-utils@1.1.3", "", {}, "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA=="],
"get-uri": ["get-uri@6.0.5", "", { "dependencies": { "basic-ftp": "^5.0.2", "data-uri-to-buffer": "^6.0.2", "debug": "^4.3.4" } }, "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg=="],
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
"handlebars": ["handlebars@4.7.9", "", { "dependencies": { "minimist": "^1.2.5", "neo-async": "^2.6.2", "source-map": "^0.6.1", "wordwrap": "^1.0.0" }, "optionalDependencies": { "uglify-js": "^3.1.4" }, "bin": { "handlebars": "bin/handlebars" } }, "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ=="],
"html-entities": ["html-entities@2.3.3", "", {}, "sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA=="],
"html-escaper": ["html-escaper@3.0.3", "", {}, "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ=="],
"htmlparser2": ["htmlparser2@10.1.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "entities": "^7.0.1" } }, "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ=="],
"http-proxy-agent": ["http-proxy-agent@9.0.0", "", { "dependencies": { "agent-base": "9.0.0", "debug": "^4.3.4" } }, "sha512-FcF8VhXYLQcxWCnt/cCpT2apKsRDUGeVEeMqGu4HSTu29U8Yw0TLOjdYIlDsYk3IkUh+taX4IDWpPcCqKDhCjA=="],
"http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="],
"https-proxy-agent": ["https-proxy-agent@9.0.0", "", { "dependencies": { "agent-base": "9.0.0", "debug": "^4.3.4" } }, "sha512-/MVmHp58WkOypgFhCLk4fzpPcFQvTJ/e6LBI7irpIO2HfxUbpmYoHF+KzipzJpxxzJu7aJNWQ0xojJ/dzV2G5g=="],
"https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
"iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
@@ -979,6 +961,8 @@
"is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="],
"is-what": ["is-what@4.1.16", "", {}, "sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A=="],
"isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="],
"jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="],
@@ -989,18 +973,14 @@
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
"json-bigint": ["json-bigint@1.0.0", "", { "dependencies": { "bignumber.js": "^9.0.0" } }, "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ=="],
"json-schema-to-ts": ["json-schema-to-ts@3.1.1", "", { "dependencies": { "@babel/runtime": "^7.18.3", "ts-algebra": "^2.0.0" } }, "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g=="],
"json-with-bigint": ["json-with-bigint@3.5.8", "", {}, "sha512-eq/4KP6K34kwa7TcFdtvnftvHCD9KvHOGGICWwMFc4dOOKF5t4iYqnfLK8otCRCRv06FXOzGGyqE8h8ElMvvdw=="],
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
"jszip": ["jszip@3.10.1", "", { "dependencies": { "lie": "~3.3.0", "pako": "~1.0.2", "readable-stream": "~2.3.6", "setimmediate": "^1.0.5" } }, "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g=="],
"jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="],
"jws": ["jws@4.0.1", "", { "dependencies": { "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA=="],
"kuler": ["kuler@2.0.0", "", {}, "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A=="],
"lie": ["lie@3.3.0", "", { "dependencies": { "immediate": "~3.0.5" } }, "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ=="],
@@ -1039,8 +1019,6 @@
"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=="],
"lop": ["lop@0.4.2", "", { "dependencies": { "duck": "^0.1.12", "option": "~0.2.1", "underscore": "^1.13.1" } }, "sha512-RefILVDQ4DKoRZsJ4Pj22TxE3omDO47yFpkIBoDKzkqPRISs5U1cnAdg/5583YPkWPaLIYHOKRMQSvjFsO26cw=="],
"lru-cache": ["lru-cache@11.3.6", "", {}, "sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A=="],
@@ -1057,6 +1035,8 @@
"media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="],
"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=="],
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
@@ -1079,9 +1059,7 @@
"netmask": ["netmask@2.1.1", "", {}, "sha512-eonl3sLUha+S1GzTPxychyhnUzKyeQkZ7jLjKrBagJgPla13F+uQ71HgpFefyHgqrjEbCPkDArxYsjY8/+gLKA=="],
"node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="],
"node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="],
"node-releases": ["node-releases@2.0.44", "", {}, "sha512-5WUyunoPMsvvEhS8AxHtRzP+oA8UCkJ7YRxatWKjngndhDGLiqEVAQKWjFAiAiuL8zMRGzGSJxFnLetoa43qGQ=="],
"nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="],
@@ -1099,14 +1077,14 @@
"option": ["option@0.2.4", "", {}, "sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A=="],
"p-retry": ["p-retry@4.6.2", "", { "dependencies": { "@types/retry": "0.12.0", "retry": "^0.13.1" } }, "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ=="],
"pac-proxy-agent": ["pac-proxy-agent@7.2.0", "", { "dependencies": { "@tootallnate/quickjs-emscripten": "^0.23.0", "agent-base": "^7.1.2", "debug": "^4.3.4", "get-uri": "^6.0.1", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.6", "pac-resolver": "^7.0.1", "socks-proxy-agent": "^8.0.5" } }, "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA=="],
"pac-proxy-agent": ["pac-proxy-agent@9.0.1", "", { "dependencies": { "agent-base": "9.0.0", "debug": "^4.3.4", "get-uri": "8.0.0", "http-proxy-agent": "9.0.0", "https-proxy-agent": "9.0.0", "pac-resolver": "9.0.1", "quickjs-wasi": "^2.2.0", "socks-proxy-agent": "10.0.0" } }, "sha512-3ZOSpLboOlpW4yp8Cuv21KlTULRqyJ5Uuad3wXpSKFrxdNgcHEyoa22GRaZ2UlgCVuR6z+5BiavtYVvbajL/Yw=="],
"pac-resolver": ["pac-resolver@9.0.1", "", { "dependencies": { "degenerator": "7.0.1", "netmask": "^2.0.2" }, "peerDependencies": { "quickjs-wasi": "^2.2.0" } }, "sha512-lJbS008tmkj08VhoM8Hzuv/VE5tK9MS0OIQ/7+s0lIF+BYhiQWFYzkSpML7lXs9iBu2jfmzBTLzhe9n6BX+dYw=="],
"pac-resolver": ["pac-resolver@7.0.1", "", { "dependencies": { "degenerator": "^5.0.0", "netmask": "^2.0.2" } }, "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg=="],
"pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="],
"parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
"partial-json": ["partial-json@0.1.7", "", {}, "sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA=="],
"path-expression-matcher": ["path-expression-matcher@1.5.0", "", {}, "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ=="],
@@ -1127,18 +1105,14 @@
"progress": ["progress@2.0.3", "", {}, "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA=="],
"protobufjs": ["protobufjs@7.5.8", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.1", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-dvpCIeLPbXZS/Ete7yLaO7RenOdken2NHKykBXbsaGxZT0UTltcarBciw+A78SRQs9iMAAVpsYA+l8b1hTePIA=="],
"proxy-agent": ["proxy-agent@6.5.0", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", "http-proxy-agent": "^7.0.1", "https-proxy-agent": "^7.0.6", "lru-cache": "^7.14.1", "pac-proxy-agent": "^7.1.0", "proxy-from-env": "^1.1.0", "socks-proxy-agent": "^8.0.5" } }, "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A=="],
"proxy-agent": ["proxy-agent@8.0.1", "", { "dependencies": { "agent-base": "9.0.0", "debug": "^4.3.4", "http-proxy-agent": "9.0.0", "https-proxy-agent": "9.0.0", "lru-cache": "^7.14.1", "pac-proxy-agent": "9.0.1", "proxy-from-env": "^2.0.0", "socks-proxy-agent": "10.0.0" } }, "sha512-kccqGBqHZXR8onQhY/ganJjoO8QIKKRiFBhPOzbTZK16attzSZ/0XSmp9H7jrRxPKHjhGyx1q32lMPrJ3uLFgA=="],
"proxy-from-env": ["proxy-from-env@2.1.0", "", {}, "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA=="],
"proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="],
"pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="],
"puppeteer-core": ["puppeteer-core@24.43.1", "", { "dependencies": { "@puppeteer/browsers": "2.13.2", "chromium-bidi": "14.0.0", "debug": "^4.4.3", "devtools-protocol": "0.0.1608973", "typed-query-selector": "^2.12.2", "webdriver-bidi-protocol": "0.4.1", "ws": "^8.20.0" } }, "sha512-T5ScUMAsmhdNbgDR41AGESYeS6V9MSgetkSnVhhW+gXvzC42VesKCn5ld87gAZDJ6vLHL9GkRvY9WtQWSnwFbw=="],
"quickjs-wasi": ["quickjs-wasi@2.2.0", "", {}, "sha512-zQxXmQMrEoD3S+jQdYsloq4qAuaxKFHZj6hHqOYGwB2iQZH+q9e/lf5zQPXCKOk0WJuAjzRFbO4KwHIp2D05Iw=="],
"react": ["react@19.2.5", "", {}, "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA=="],
"react-chartjs-2": ["react-chartjs-2@5.3.1", "", { "peerDependencies": { "chart.js": "^4.1.1", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-h5IPXKg9EXpjoBzUfyWJvllMjG2mQ4EiuHQFhms/AjUm0XSZHhyRy2xVmLXHKrtcdrPO4mnGqRtYoD0vp95A0A=="],
@@ -1153,13 +1127,15 @@
"restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="],
"retry": ["retry@0.13.1", "", {}, "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg=="],
"rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="],
"robomp-web": ["robomp-web@workspace:python/robomp/web"],
"rollup": ["rollup@4.60.3", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.3", "@rollup/rollup-android-arm64": "4.60.3", "@rollup/rollup-darwin-arm64": "4.60.3", "@rollup/rollup-darwin-x64": "4.60.3", "@rollup/rollup-freebsd-arm64": "4.60.3", "@rollup/rollup-freebsd-x64": "4.60.3", "@rollup/rollup-linux-arm-gnueabihf": "4.60.3", "@rollup/rollup-linux-arm-musleabihf": "4.60.3", "@rollup/rollup-linux-arm64-gnu": "4.60.3", "@rollup/rollup-linux-arm64-musl": "4.60.3", "@rollup/rollup-linux-loong64-gnu": "4.60.3", "@rollup/rollup-linux-loong64-musl": "4.60.3", "@rollup/rollup-linux-ppc64-gnu": "4.60.3", "@rollup/rollup-linux-ppc64-musl": "4.60.3", "@rollup/rollup-linux-riscv64-gnu": "4.60.3", "@rollup/rollup-linux-riscv64-musl": "4.60.3", "@rollup/rollup-linux-s390x-gnu": "4.60.3", "@rollup/rollup-linux-x64-gnu": "4.60.3", "@rollup/rollup-linux-x64-musl": "4.60.3", "@rollup/rollup-openbsd-x64": "4.60.3", "@rollup/rollup-openharmony-arm64": "4.60.3", "@rollup/rollup-win32-arm64-msvc": "4.60.3", "@rollup/rollup-win32-ia32-msvc": "4.60.3", "@rollup/rollup-win32-x64-gnu": "4.60.3", "@rollup/rollup-win32-x64-msvc": "4.60.3", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-pAQK9HalE84QSm4Po3EmWIZPd3FnjkShVkiMlz1iligWYkWQ7wHYd1PF/T7QZ5TVSD6uSTon5gBVMSM4JfBV+A=="],
"rss-parser": ["rss-parser@3.13.0", "", { "dependencies": { "entities": "^2.0.3", "xml2js": "^0.5.0" } }, "sha512-7jWUBV5yGN3rqMMj7CZufl/291QAhvrrGpDNE4k/02ZchL0npisiYYqULF71jCEKoIiHvK/Q2e6IkDwPziT7+w=="],
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
"safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
"safe-stable-stringify": ["safe-stable-stringify@2.5.0", "", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="],
@@ -1171,6 +1147,10 @@
"semver": ["semver@7.8.0", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA=="],
"seroval": ["seroval@1.5.4", "", {}, "sha512-46uFvgrXTVxZcUorgSSRZ4y+ieqLLQRMlG4bnCZKW3qI6BZm7Rg4ntMW4p1mILEEBZWrFlcpp0AyIIlM6jD9iw=="],
"seroval-plugins": ["seroval-plugins@1.5.4", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-S0xQPhUTefAhNvNWFg0c1J8qJArHt5KdtJ/cFAofo06KD1MVSeFWyl4iiu+ApDIuw0WhjpOfCdgConOfAnLgkw=="],
"setimmediate": ["setimmediate@1.0.5", "", {}, "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA=="],
"signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
@@ -1181,7 +1161,11 @@
"socks": ["socks@2.8.9", "", { "dependencies": { "ip-address": "^10.1.1", "smart-buffer": "^4.2.0" } }, "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw=="],
"socks-proxy-agent": ["socks-proxy-agent@10.0.0", "", { "dependencies": { "agent-base": "9.0.0", "debug": "^4.3.4", "socks": "^2.8.3" } }, "sha512-pyp2YR3mNxAMu0mGLtzs4g7O3uT4/9sQOLAKcViAkaS9fJWkud7nmaf6ZREFqQEi24IPkBcjfHjXhPTUWjo3uA=="],
"socks-proxy-agent": ["socks-proxy-agent@8.0.5", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", "socks": "^2.8.3" } }, "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw=="],
"solid-js": ["solid-js@1.9.12", "", { "dependencies": { "csstype": "^3.1.0", "seroval": "~1.5.0", "seroval-plugins": "~1.5.0" } }, "sha512-QzKaSJq2/iDrWR1As6MHZQ8fQkdOBf8GReYb7L5iKwMGceg7HxDcaOHk0at66tNgn9U2U7dXo8ZZpLIAmGMzgw=="],
"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=="],
"source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
@@ -1251,9 +1235,15 @@
"universal-user-agent": ["universal-user-agent@7.0.3", "", {}, "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A=="],
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
"web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="],
"vite": ["vite@5.4.21", "", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "optionalPeers": ["@types/node", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": { "vite": "bin/vite.js" } }, "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw=="],
"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=="],
"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=="],
"webdriver-bidi-protocol": ["webdriver-bidi-protocol@0.4.1", "", {}, "sha512-ARrjNjtWRRs2w4Tk7nqrf2gBI0QXWuOmMCx2hU+1jUt6d00MjMxURrhxhGbrsoiZKJrhTSTzbIrc554iKI10qw=="],
@@ -1281,6 +1271,8 @@
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="],
"yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
@@ -1291,17 +1283,27 @@
"zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
"@aws-crypto/sha256-browser/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="],
"@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"@aws-crypto/util/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="],
"@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"@aws-sdk/credential-provider-sso/@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1041.0", "", { "dependencies": { "@aws-sdk/core": "^3.974.8", "@aws-sdk/nested-clients": "^3.997.6", "@aws-sdk/types": "^3.973.8", "@smithy/property-provider": "^4.2.14", "@smithy/shared-ini-file-loader": "^4.4.9", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-Th7kPI6YPtvJUcdznooXJMy+9rQWjmEF81LxaJssngBzuysK4a/x+l8kjm1zb7nYsUPbndnBdUnwng/3PLvtGw=="],
"@aws-sdk/xml-builder/fast-xml-parser": ["fast-xml-parser@5.7.2", "", { "dependencies": { "@nodable/entities": "^2.1.0", "fast-xml-builder": "^1.1.5", "path-expression-matcher": "^1.5.0", "strnum": "^2.2.3" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-P7oW7tLbYnhOLQk/Gv7cZgzgMPP/XN03K02/Jy6Y/NHzyIAIpxuZIM/YqAkfiXFPxA2CTm7NtCijK9EDu09u2w=="],
"@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"@octokit/request/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="],
"@puppeteer/browsers/proxy-agent": ["proxy-agent@6.5.0", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", "http-proxy-agent": "^7.0.1", "https-proxy-agent": "^7.0.6", "lru-cache": "^7.14.1", "pac-proxy-agent": "^7.1.0", "proxy-from-env": "^1.1.0", "socks-proxy-agent": "^8.0.5" } }, "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="],
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" }, "bundled": true }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="],
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="],
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"babel-plugin-jsx-dom-expressions/@babel/helper-module-imports": ["@babel/helper-module-imports@7.18.6", "", { "dependencies": { "@babel/types": "^7.18.6" } }, "sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA=="],
"chromium-bidi/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
@@ -1313,50 +1315,34 @@
"dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
"gaxios/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
"js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
"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=="],
"node-fetch/data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="],
"parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
"proxy-agent/lru-cache": ["lru-cache@7.18.3", "", {}, "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA=="],
"robomp-web/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"rss-parser/entities": ["entities@2.2.0", "", {}, "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A=="],
"slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="],
"string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"string_decoder/safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
"wrap-ansi/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
"xml2js/xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="],
"@puppeteer/browsers/proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
"@puppeteer/browsers/proxy-agent/http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="],
"@puppeteer/browsers/proxy-agent/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
"@puppeteer/browsers/proxy-agent/lru-cache": ["lru-cache@7.18.3", "", {}, "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA=="],
"@puppeteer/browsers/proxy-agent/pac-proxy-agent": ["pac-proxy-agent@7.2.0", "", { "dependencies": { "@tootallnate/quickjs-emscripten": "^0.23.0", "agent-base": "^7.1.2", "debug": "^4.3.4", "get-uri": "^6.0.1", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.6", "pac-resolver": "^7.0.1", "socks-proxy-agent": "^8.0.5" } }, "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA=="],
"@puppeteer/browsers/proxy-agent/proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="],
"@puppeteer/browsers/proxy-agent/socks-proxy-agent": ["socks-proxy-agent@8.0.5", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", "socks": "^2.8.3" } }, "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw=="],
"cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"cliui/wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"gaxios/https-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
"jszip/readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
"jszip/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
"log-update/slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="],
@@ -1365,16 +1351,8 @@
"wrap-ansi/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
"@puppeteer/browsers/proxy-agent/pac-proxy-agent/get-uri": ["get-uri@6.0.5", "", { "dependencies": { "basic-ftp": "^5.0.2", "data-uri-to-buffer": "^6.0.2", "debug": "^4.3.4" } }, "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg=="],
"@puppeteer/browsers/proxy-agent/pac-proxy-agent/pac-resolver": ["pac-resolver@7.0.1", "", { "dependencies": { "degenerator": "^5.0.0", "netmask": "^2.0.2" } }, "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg=="],
"cliui/wrap-ansi/ansi-styles/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
"@puppeteer/browsers/proxy-agent/pac-proxy-agent/get-uri/data-uri-to-buffer": ["data-uri-to-buffer@6.0.2", "", {}, "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw=="],
"@puppeteer/browsers/proxy-agent/pac-proxy-agent/pac-resolver/degenerator": ["degenerator@5.0.1", "", { "dependencies": { "ast-types": "^0.13.4", "escodegen": "^2.1.0", "esprima": "^4.0.1" } }, "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ=="],
"cliui/wrap-ansi/ansi-styles/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
}
}
+10
View File
@@ -12,5 +12,15 @@ saveTextLockfile = true
".py" = "text"
".lark" = "text"
[test]
# bun test does NOT honor .gitignore; prune robomp's repo clones and
# scratch dirs so a root-level `bun test` doesn't walk into them.
pathIgnorePatterns = [
"**/node_modules/**",
"python/robomp/data/**",
".wt/**",
".worktrees/**",
]
[run]
bun = true
+17 -12
View File
@@ -217,7 +217,8 @@ fn run_pty_sync(
ct: task::CancelToken,
) -> Result<PtyRunResult> {
let pty_system = native_pty_system();
ct.heartbeat().map_err(|err| Error::from_reason(format!("PTY setup cancelled before openpty: {err}")))?;
ct.heartbeat()
.map_err(|err| Error::from_reason(format!("PTY setup cancelled before openpty: {err}")))?;
const PTY_STARTUP_TIMEOUT: Duration = Duration::from_secs(5);
let pair = if cfg!(windows) {
@@ -227,9 +228,9 @@ fn run_pty_sync(
let (tx, rx) = mpsc::channel();
std::thread::spawn(move || {
let result = pty_system.openpty(PtySize {
rows: config.rows,
cols: config.cols,
pixel_width: 0,
rows: config.rows,
cols: config.cols,
pixel_width: 0,
pixel_height: 0,
});
let _ = tx.send(result);
@@ -237,16 +238,18 @@ fn run_pty_sync(
match rx.recv_timeout(PTY_STARTUP_TIMEOUT) {
Ok(Ok(pair)) => pair,
Ok(Err(e)) => return Err(Error::from_reason(format!("Failed to open PTY: {e}"))),
Err(_) => return Err(Error::from_reason(
"PTY creation timed out (5s). ConPTY may be unavailable on this system.",
)),
Err(_) => {
return Err(Error::from_reason(
"PTY creation timed out (5s). ConPTY may be unavailable on this system.",
));
},
}
} else {
pty_system
.openpty(PtySize {
rows: config.rows,
cols: config.cols,
pixel_width: 0,
rows: config.rows,
cols: config.cols,
pixel_width: 0,
pixel_height: 0,
})
.map_err(|err| Error::from_reason(format!("Failed to open PTY: {err}")))?
@@ -273,14 +276,16 @@ fn run_pty_sync(
cmd.env(key, value);
}
}
ct.heartbeat().map_err(|err| Error::from_reason(format!("PTY setup cancelled before spawn: {err}")))?;
ct.heartbeat()
.map_err(|err| Error::from_reason(format!("PTY setup cancelled before spawn: {err}")))?;
let mut child = pair
.slave
.spawn_command(cmd)
.map_err(|err| Error::from_reason(format!("Failed to spawn PTY command: {err}")))?;
drop(pair.slave);
ct.heartbeat().map_err(|err| Error::from_reason(format!("PTY setup cancelled before reader: {err}")))?;
ct.heartbeat()
.map_err(|err| Error::from_reason(format!("PTY setup cancelled before reader: {err}")))?;
let master = pair.master;
let mut writer = master
-83
View File
@@ -1263,89 +1263,6 @@ pub fn extract_segments(
})
}
// ============================================================================
// sanitizeText
// ============================================================================
/// Strip ANSI escape sequences, remove control characters / lone surrogates,
/// and normalize line endings.
#[napi]
pub fn sanitize_text(text: JsString<'_>) -> Result<Either<JsString<'_>, Utf16String>> {
let original = text;
let text_u16 = text.into_utf16()?;
let data = text_u16.as_slice();
let mut did_change = false;
let mut out: Vec<u16> = Vec::new();
let mut last = 0usize;
let mut i = 0usize;
let len = data.len();
while i < len {
let u = data[i];
// Allow tab + newline; normalize CR by removing it.
if u == 0x09 || u == 0x0a {
i += 1;
continue;
}
let mut remove_len = if u == ESC
&& let Some(seq_len) = ansi_seq_len_u16(data, i)
{
seq_len
} else {
0usize
};
if remove_len == 0 {
// Drop CR to normalize line endings.
if u == 0x0d {
remove_len = 1;
} else if u <= 0x1f || u == 0x7f || (0x80..=0x9f).contains(&u) {
// C0 + DEL + C1 controls.
remove_len = 1;
} else if (0xd800..=0xdbff).contains(&u) {
// High surrogate: keep only if followed by a valid low surrogate.
if i + 1 < len {
let lo = data[i + 1];
if (0xdc00..=0xdfff).contains(&lo) {
i += 2;
continue;
}
}
remove_len = 1;
} else if (0xdc00..=0xdfff).contains(&u) {
// Lone low surrogate.
remove_len = 1;
}
}
if remove_len == 0 {
i += 1;
continue;
}
if !did_change {
did_change = true;
out = Vec::with_capacity(len);
}
if last != i {
out.extend_from_slice(&data[last..i]);
}
i += remove_len;
last = i;
}
if !did_change {
return Ok(Either::A(original));
}
if last < len {
out.extend_from_slice(&data[last..]);
}
Ok(Either::B(build_utf16_string(out)))
}
// ============================================================================
// visibleWidth
// ============================================================================
+171
View File
@@ -0,0 +1,171 @@
# AI tool-schema normalization
`@oh-my-pi/pi-ai` exposes one unified schema normalizer that providers consume
before tools are sent on the wire. All walkers live in
`packages/ai/src/utils/schema/normalize.ts`; the operational contract is
`packages/ai/src/utils/schema/CONSTRAINTS.md`.
There is no separate `strict-mode.ts` module any more — OpenAI strict-mode
sanitization, OpenAI Responses `oneOf` rewriting, Google/Vertex/Gemini-CLI
sanitization, Cloud Code Assist Claude sanitization, and MCP sanitization all
share the same option-driven walk.
## Entry points
All exports live under `@oh-my-pi/pi-ai/utils/schema`:
- `normalizeSchema(value, options)` — generic option-driven walker.
- `normalizeSchemaForGoogle(value)` — Gemini / Vertex / Gemini CLI.
- `normalizeSchemaForCCA(value)` — Cloud Code Assist Claude (Antigravity + GCA).
- `normalizeSchemaForMCP(value)` — MCP inputSchemas before they enter the
custom-tool registry. `tool-bridge.ts` runs every MCP `inputSchema` through
this dispatcher.
- `normalizeSchemaForOpenAIResponses(schema)` (alias
`sanitizeSchemaForOpenAIResponses`) — rewrites `oneOf` → `anyOf` for the
Responses family.
- `sanitizeSchemaForStrictMode(schema)` and
`enforceStrictSchema(schema)` / `tryEnforceStrictSchema(schema)` — the
OpenAI strict-mode pipeline (sanitize → enforce). All three are exported
from `normalize.ts`.
- `adaptSchemaForStrict(schema, strict)` from `./adapt` — thin composer that
wraps `tryEnforceStrictSchema` for provider call sites and consults
`PI_NO_STRICT` (env `PI_NO_STRICT`) for the global bypass.
Removed in the unified-flow refactor:
- `strict-mode.ts` (merged into `normalize.ts`).
- `sanitize-google.ts` and `normalize-cca.ts` (replaced by
`normalizeSchemaFor*` dispatchers).
- `StringEnum` helper — use `z.enum([...])` directly; Zod's emitted JSON
Schema is already wire-compatible with Google and other providers.
- `sanitizeSchemaFor{Google,CCA,MCP}` / `prepareSchemaForCCA` — renamed to
`normalizeSchemaFor{Google,CCA,MCP}`.
## Dispatcher mapping
| Provider transport(s) | Dispatcher |
| -------------------------------------------------------------------- | -------------------------------------------- |
| `openai-completions`, `openai-responses`, `openai-codex-responses` | `adaptSchemaForStrict` (sanitize + enforce) |
| `openai-responses` family (`oneOf` → `anyOf` only) | `normalizeSchemaForOpenAIResponses` |
| `google-generative-ai`, `google-vertex`, Gemini CLI | `normalizeSchemaForGoogle` |
| Cloud Code Assist Claude (Antigravity + GCA, `claude-*` model ids) | `normalizeSchemaForCCA` |
| MCP `inputSchema` ingestion | `normalizeSchemaForMCP` |
| `anthropic-messages` (native, not CCA) | per-provider whitelist in `anthropic.ts` |
Gemini CLI / Antigravity CCA MUST run the full `normalizeSchemaForCCA`
pipeline (not just the first keyword-stripping pass) to keep parity with the
shared Google Claude path.
## Walk semantics
`normalizeSchema` first upgrades the input to JSON Schema 2020-12, then
walks the tree with the option set pinned by the dispatcher. Each node:
1. Inlines `$ref` (see "Edge cases" below).
2. Renames `snake_case` combinator/property keys to camelCase
(`any_of` → `anyOf`, etc.; collisions follow python-genai
`pop(from)`/`set(to)` semantics — snake_case wins).
3. Applies the `handle_null_fields` collapse for nullable unions before
recursing into children.
4. Strips keys the target provider does not support, optionally lifting
human-meaningful keys (`pattern`, `format`, min/max, `default`,
`examples`, ...) into the sibling `description` via the spill formatter
(`spill.ts`). Structural/meta keys (`$ref`, `$defs`,
`additionalProperties`) are not spilled.
5. Normalizes type unions (`type: ["T", "null"]` → `type: "T"` + nullable
marker on Google, plain `type: "T"` on CCA).
6. Collapses object-only / same-type combiners, optionally lossy-collapses
mixed-type combiners (CCA only), and runs the residual-combiner fixpoint.
7. Validates against AJV 2020 when `validateAndFallback` is set (CCA path)
and emits the per-tool fallback `{ "type": "object", "properties": {} }`
on residual incompatibility — `type` array, `type: "null"`, `nullable`
key, or any remaining `anyOf`/`oneOf`/`allOf`.
## OpenAI strict-mode pipeline
`adaptSchemaForStrict(schema, strict)` runs `tryEnforceStrictSchema`,
which composes:
1. **Sanitize** (`sanitizeSchemaForStrictMode`): strips non-structural
keywords (`format`, `pattern`, min/max, `examples`, `default`,
`if`/`then`/`else`, `not`, `unevaluated*`, `patternProperties`,
`dependent*`, `content*`, `min/maxProperties`, `$dynamicRef`, etc.). The
`default` value is inlined into the sibling `description` as
` (default: X)` before being dropped, unless `description` already
contains `(default:` or no `description` exists.
2. **Enforce** (`enforceStrictSchema`): every object node gets
`additionalProperties: false`, every property goes into `required`, and
optional properties become nullable unions
(`anyOf: [<original>, { "type": "null" }]`). Tuple `prefixItems` are
strictified recursively.
The two passes share node-level caches and the same epoch-based cycle
guard, so a single walk on the wire path normalizes refs, allOf, and
nullable wrapping consistently. `tryEnforceStrictSchema` is fail-open:
if anything throws, it returns `{ strict: false, schema: original }` so
callers MUST emit `strict: true` only when enforcement actually succeeded.
### Edge cases the strict-mode normalizer handles
- **Local `$ref` inlining.** OpenAI strict mode rejects
`{ "$ref": "...", "description": "..." }` with sibling keys. The
sanitizer pre-resolves local `#/...` refs against the root and merges
with **sibling keys winning** over the resolved def — same precedence
as `openai-python`'s `_ensure_strict_json_schema`. Recursive refs are
guarded by the per-walk epoch.
- **Single-item `allOf`.** A `{ "allOf": [X], ...siblings }` collapses to
`{ ...X, ...siblings }` with the inlined entry's keys winning over the
original siblings (matches `openai-python`'s `_pydantic.py:79-83`). Multi-
item `allOf` is left intact for the downstream validator to reject if
needed.
- **Type-array branches and nullable unions.** When a node has
`type: ["T", "U"]`, the sanitizer emits one variant schema per type,
pruning type-specific keywords (e.g. `properties`/`required` only stay on
the `object` variant, `items` only on the `array` variant). The shared
`description` is **hoisted onto the `anyOf` wrapper** instead of being
duplicated on every branch — so a strict nullable union becomes
`{ anyOf: [T, { type: "null" }], description: "..." }`, not
`anyOf: [{ ..., description }, { ..., description }]`.
- **Enum/const without a `type`.** Both sanitize and enforce paths call
`inferStrictPrimitiveTypeFromEnumOrConst` to infer the primitive `type`
from `enum` / `const` values. Mixed-primitive enums (`[1, "two", null]`),
enums containing objects/arrays, and non-primitive `const` values
(`{a:1}`, `[1,2,3]`) cannot be described by a single `type` keyword and
trigger the strict-mode fail-open path — emitting a typeless schema
would just be rejected on the wire by OpenAI.
## Performance: static fingerprint cache
`resolveProviderModels` in `packages/ai/src/model-manager.ts` and
`readModelCache`/`writeModelCache` in `model-cache.ts` cooperate via a
schema-v3 `static_fingerprint` column on the `model_cache` SQLite table.
- `fingerprintStatic(staticModels)` hashes the static catalog slice
(`Bun.hash(JSON.stringify(models))` in base36) and memoizes the result
in a per-process `WeakMap` keyed by the array reference. Multiple
cold-start arms calling `resolveProviderModels` with the same
`staticModels` array pay the JSON+hash cost once.
- On cache read, if the network fetch is being skipped, the cached row is
fresh + authoritative, and the cached `static_fingerprint` matches the
current one, `resolveProviderModels` returns the cached models verbatim
— the cache already incorporates the same static state, so re-running
`mergeDynamicModels(static, cache)` would just rebuild the same objects.
- `mergeModelSources` and `mergeDynamicModels` short-circuit on
empty-source inputs (the common shape after `(static, [])` or for
providers without a static catalog), avoiding Map churn entirely.
Cache rows written before schema v3 are dropped by the cache-version
check; the column defaults to `''` for any row that survives a version
upgrade so the fingerprint-equality check naturally fails closed and the
full merge re-runs.
## Related
- `docs/models.md` — registry, equivalence, compat flags
(`supportsStrictMode`, `toolStrictMode`, `disableStrictTools`).
- `docs/provider-streaming-internals.md` — how the normalized schemas are
used downstream during the provider stream loop.
- `docs/mcp-server-tool-authoring.md` — MCP `inputSchema` ingestion via
`normalizeSchemaForMCP`.
- `packages/ai/src/utils/schema/CONSTRAINTS.md` — operational contract for
every normalization rule.
+181
View File
@@ -0,0 +1,181 @@
# Auth Broker and Auth Gateway
The auth broker and auth gateway are two cooperating HTTP services that move OAuth refresh tokens and provider access tokens off developer laptops and into a single broker host.
- **`omp auth-broker serve`** holds the canonical SQLite credential vault, performs OAuth refreshes, and exposes a small REST API (`/v1/snapshot`, `/v1/credential/:id/refresh`, `/v1/credential/:id/disable`, `/v1/credential`, `/v1/usage`, `/v1/healthz`).
- **`omp auth-gateway serve`** is a forward-proxy. It accepts OpenAI Chat Completions, Anthropic Messages, and OpenAI Responses requests, injects the broker-resolved access token, and forwards the bytes to the real provider. Clients (containerised omp, llm-git, the macOS usage widget, …) never see the access token.
Transport security between operator, broker, and gateway is delegated to the operator (Tailscale / Wireguard / reverse proxy + TLS). Every endpoint except `/v1/healthz` (broker) and `/healthz` (gateway) requires a bearer token.
Source: `packages/ai/src/auth-broker/`, `packages/ai/src/auth-gateway/`, `packages/coding-agent/src/cli/auth-broker-cli.ts`, `packages/coding-agent/src/cli/auth-gateway-cli.ts`, `packages/coding-agent/src/session/auth-broker-config.ts`.
## Data flow
```
┌────────────────────────────────────────────────────────────┐
│ broker host │
│ │
developer ──▶ │ ┌──────────────────────────┐ ┌────────────────────┐ │
laptop / │ │ omp auth-broker serve │◀──▶│ SQLite agent.db │ │
CI / robomp │ │ - holds refresh tokens │ │ (canonical writer)│ │
│ │ - background refresher │ └────────────────────┘ │
│ │ /v1/{snapshot,refresh,…}│ │
│ └─────────┬────────────────┘ │
│ │ bearer ($CONFIG_DIR/auth-broker.token) │
│ ▼ │
│ ┌──────────────────────────┐ │
│ │ omp auth-gateway serve │ RemoteAuthCredentialStore │
│ │ /v1/{chat,messages,…} │ pulls /v1/snapshot at boot, │
│ │ /v1/usage, /v1/models │ refreshes credentials by id │
│ └─────────┬────────────────┘ via the broker on expiry │
└────────────┼───────────────────────────────────────────────┘
│ bearer ($CONFIG_DIR/auth-gateway.token)
▼
unauthenticated clients
(llm-git, macOS widget, robomp containers, IDE plugins, …)
│
▼ same path is forwarded with Authorization
api.anthropic.com / api.openai.com / …
```
The broker is the only writer of OAuth refresh tokens. Clients (including the gateway itself) load a redacted snapshot in which every `refresh` field has been replaced with `REMOTE_REFRESH_SENTINEL`; when an access token expires the client calls `POST /v1/credential/:id/refresh` and the broker performs the refresh server-side. `RemoteAuthCredentialStore` rejects any local code path that tries to write through it, with an error pointing at `omp auth-broker login` / `omp auth-broker logout`.
## auth-broker
### CLI
```
omp auth-broker serve [--bind=host:port] # boot the broker
omp auth-broker token [--regenerate] [--json] # print or rotate the bearer token
omp auth-broker login <provider> [--via=user@host] [--dry-run]
omp auth-broker logout <provider>
omp auth-broker import <file|dir> [--provider=<id>] [--include-disabled] [--dry-run] [--json]
omp auth-broker migrate --from-local [--dry-run] [--json]
omp auth-broker status [--json]
```
- `serve` opens the local SQLite store at `getAgentDbPath()` and binds an HTTP listener (default `127.0.0.1:8765`). On startup a token is ensured at `<config-dir>/auth-broker.token` (mode `0600`, `0700` parent dir). The background refresher refreshes any OAuth credential whose `expires - Date.now() < refreshSkewMs` (default 5 min) every `refreshIntervalMs` (default 60 s).
- `token` prints the cached bearer or generates a new one. `--regenerate` rotates it.
- `login <provider>` runs the per-provider OAuth flow locally, or — with `--via=user@host` — `ssh -L <callback-port>:127.0.0.1:<callback-port> user@host omp auth-broker login <provider>` so the OAuth callback hits the local browser but the credential is written on the broker host. Built-in callback ports: `anthropic:54545`, `openai-codex:1455`, `google-gemini-cli:8085`, `google-antigravity:51121`, `gitlab-duo:8080`.
- `logout <provider>` deletes every credential row for `<provider>`.
- `import <file|dir>` imports CLIProxyAPI-style JSON credentials into the local SQLite store. Maps `type` field → omp provider (`claude → anthropic`, `codex → openai-codex`, `gemini → google-gemini-cli`, `antigravity → google-antigravity`, `gemini-cli → google-gemini-cli`).
- `migrate --from-local` walks the local SQLite store + env-derived credentials and idempotently uploads them to the configured broker (`POST /v1/credential`).
- `status` health-pings the configured remote broker.
### Endpoints
| Method | Path | Auth | Purpose |
| ------ | ---- | ---- | ------- |
| `GET` | `/v1/healthz` | none | Liveness + version |
| `GET` | `/v1/snapshot` | bearer | Redacted snapshot (refresh tokens replaced by sentinel) |
| `POST` | `/v1/credential` | bearer | Upsert one OAuth or API-key credential |
| `POST` | `/v1/credential/:id/refresh` | bearer | Force-refresh one OAuth credential |
| `POST` | `/v1/credential/:id/disable` | bearer | Disable one credential with a recorded cause |
| `GET` | `/v1/usage` | bearer | Aggregate `UsageReport[]` across credentials |
Requests use `Authorization: Bearer <token>`. The server compares against an in-memory token allow-list; the gateway’s implementation uses a timing-safe comparison.
### Background refresher
`AuthBrokerRefresher` iterates active OAuth credentials at `refreshIntervalMs` cadence and refreshes any within `refreshSkewMs` of expiry. Refreshes are single-flighted per credential id so a slow refresh cannot be retriggered. The refresher distinguishes:
- **definitive failures** (`invalid_grant`, `invalid_token`, `revoked`, unauthorized refresh-token, 401/403 not from a network blip) — credentials are passed to `AuthStorage.disableCredentialById(id, cause)` so the next snapshot pull surfaces a clean delete on the client;
- **transient failures** (timeout / ECONNREFUSED / fetch failed) — left in place for the next sweep.
## auth-gateway
### CLI
```
omp auth-gateway serve [--bind=host:port] [--no-auth]
omp auth-gateway token [--regenerate] [--json]
omp auth-gateway status [--json]
```
- `serve` requires `OMP_AUTH_BROKER_URL` (or `auth.broker.url` in `config.yml`) — the gateway is itself a broker client. It calls `AuthBrokerClient.fetchSnapshot()`, wraps it in `RemoteAuthCredentialStore`, and constructs an `AuthStorage` that resolves access tokens through the broker. Default bind is `127.0.0.1:4000`. The gateway token is stored at `<config-dir>/auth-gateway.token` (`0600`); `--no-auth` disables the bearer check entirely (loopback-only use).
- `token` / `status` mirror the broker’s equivalents.
### Endpoints
| Method | Path | Auth | Purpose |
| ------ | ---- | ---- | ------- |
| `GET` | `/healthz` | none | Liveness + version |
| `GET` | `/v1/usage` | bearer | Aggregate `UsageReport[]` (proxied through `AuthStorage`) |
| `GET` | `/v1/models` | bearer | Bundled-model catalog filtered to providers with credentials |
| `POST` | `/v1/chat/completions` | bearer | OpenAI Chat Completions wire format |
| `POST` | `/v1/messages` | bearer | Anthropic Messages wire format |
| `POST` | `/v1/responses` | bearer | OpenAI Responses wire format |
The model id is read from the top-level `model` field. The gateway picks the first bundled `Model<Api>` matching that id and:
- **Passthrough fast-path** — when the inbound wire format matches the model’s native API (`openai-chat → openai-completions`, `anthropic-messages → anthropic-messages`, `openai-responses → openai-responses`), the request body is forwarded byte-for-byte with the client `Authorization`/`x-api-key` stripped and replaced by `Authorization: Bearer <resolved-access-token>`. Provider-specific fields (`cache_control`, `service_tier`, tool-choice extensions, …) flow through unmodified. Hop-by-hop headers (RFC 7230) plus `Content-Encoding`/`Content-Length` are stripped from the upstream response.
- **Translate path** — when the inbound format and the resolved model’s API differ (e.g. `/v1/chat/completions` targeting an Anthropic model, or `/v1/responses` targeting `openai-codex-responses` which runs over a websocket transport), the request is parsed against the wire schema, rebuilt into an omp `Context`, dispatched through `streamSimple()`, and re-encoded back to the inbound format (SSE for streamed responses).
`idleTimeout` on the underlying `Bun.serve` is set to `255 s` so long thinking-budget calls do not get killed by Bun’s default idle timeout.
## Usage cache: server-side 5-min jitter + client-side 15 s single-flight
Two layers cache the aggregate provider-usage report. Both are intentional and stacked.
### Server-side cache (broker `AuthStorage`)
`AuthStorage` caches each credential’s `UsageReport` in the broker’s SQLite store at a **5-minute per-credential TTL with ±25 % jitter**. Anthropic and OpenAI rate-limit `/usage` aggressively per source IP, and a synchronized 5-credential fan-out trips 429s every cycle; the jitter decorrelates refresh times within a few cycles. On fetch failure the store keeps the **last-good** report for up to 24 h with a short jittered re-poll window — so a transient upstream blip never blanks out the widget.
Constants: `USAGE_REPORT_TTL_MS = 5 * 60_000`, `USAGE_LAST_GOOD_RETENTION_MS = 24 * 60 * 60_000` (`packages/ai/src/auth-storage.ts`).
### Client-side single-flight (`RemoteAuthCredentialStore`)
When the gateway (or any other broker client) calls `fetchUsageReports()` / `getUsageReport(provider, credential)`, `RemoteAuthCredentialStore` coalesces concurrent calls into a single `GET /v1/usage` round-trip and caches the result for **15 s** in memory.
- `USAGE_CACHE_TTL_MS = 15_000` (`packages/ai/src/auth-broker/remote-store.ts`).
- A single `#usageInflight` promise is shared across all callers; a per-caller `AbortSignal` is **raced** against the shared promise, not threaded into it, so one caller’s abort never cascades into a peer’s in-flight request.
- On fetch failure the rejected promise is logged and the awaited value is `null` — callers (`AuthStorage.fetchUsageReports`, `#getUsageReport`) treat a `null` report as "no usage signal for this cycle" and proceed without it. **This is the 15 s TTL fallback**: the client absorbs transient broker outages by suppressing the error, returning `null` to ranking, and re-attempting after the 15 s window.
The 15 s client window deliberately sits below the broker’s 5 min server cache, so almost every client poll is served from the broker’s already-cached value; the client cache exists to absorb the parallel fan-out generated by `AuthStorage.#rankOAuthSelections` into a single broker round-trip.
## Operator opt-in
The broker is **off** unless `OMP_AUTH_BROKER_URL` (or `auth.broker.url` in `config.yml`) is set. When set, `discoverAuthStorage` in `packages/coding-agent/src/sdk.ts` swaps the local SQLite credential store for `RemoteAuthCredentialStore` and every API call resolves credentials through the broker.
### Environment variables
| Variable | Purpose | Required when |
| -------- | ------- | ------------- |
| `OMP_AUTH_BROKER_URL` | Base URL of the remote auth-broker (e.g. `https://broker.tailnet:8765`). Selecting this puts the client in broker mode — local SQLite is bypassed. | Any time the omp client should resolve credentials through a broker (and required by `omp auth-gateway serve`). |
| `OMP_AUTH_BROKER_TOKEN` | Bearer token used for every broker endpoint except `/v1/healthz`. | When `OMP_AUTH_BROKER_URL` is set and no token is available from `auth.broker.token` or `<config-dir>/auth-broker.token`. |
Resolution order in `resolveAuthBrokerConfig()`:
1. `OMP_AUTH_BROKER_URL` env (else `auth.broker.url` from `config.yml`, with `$ENV_NAME` resolution);
2. `OMP_AUTH_BROKER_TOKEN` env (else `auth.broker.token` from `config.yml`, else `<config-dir>/auth-broker.token`);
3. URL set but no token resolvable → hard error pointing at the token file path.
The gateway has no dedicated env vars — it inherits `OMP_AUTH_BROKER_*` because it is itself a broker client.
### `config.yml` keys
| Key | Default | Purpose |
| --- | ------- | ------- |
| `auth.broker.url` | unset | Same as `OMP_AUTH_BROKER_URL`; env wins. Hidden from the settings UI. |
| `auth.broker.token` | unset | Same as `OMP_AUTH_BROKER_TOKEN`; env wins. Values may be the literal token or `$ENV_NAME` to indirect through env. |
### Token files
| Path | Owner | Mode |
| ---- | ----- | ---- |
| `<config-dir>/auth-broker.token` | `omp auth-broker serve` (created at first start) | `0600` in a `0700` parent dir |
| `<config-dir>/auth-gateway.token` | `omp auth-gateway serve` (skipped under `--no-auth`) | `0600` in a `0700` parent dir |
`<config-dir>` resolves to `~/.omp/` (respecting `PI_CONFIG_DIR`).
## Interaction with the local API-key resolution order
The broker only owns OAuth credentials and provider-API-key credentials that were uploaded to it. The standard credential ladder in `models.md` (`Auth and API key resolution order`) is preserved, with one addition committed alongside the gateway:
- `AuthStorage.setConfigApiKey / removeConfigApiKey / clearConfigApiKeys` let a `models.yml` `apiKey` beat a stored OAuth token **without** overriding an explicit `--api-key`. This is what allows a broker-resolved OAuth credential to be reliably shadowed by a per-environment `models.yml` config key when both are present.
## See also
- [`secrets.md`](./secrets.md) — secret obfuscation around tokens that *do* leak through (e.g. `OMP_AUTH_BROKER_TOKEN` in shell output).
- [`models.md`](./models.md) — provider auth resolution order; the broker plugs in at layers 2–3 (stored credentials).
- [`environment-variables.md`](./environment-variables.md) — full env reference including `OMP_AUTH_BROKER_URL` / `OMP_AUTH_BROKER_TOKEN`.
+1 -1
View File
@@ -119,7 +119,7 @@ Supported formats:
Behavior:
- Validates parsed data with AJV against a provided TypeBox schema.
- Validates parsed data against a provided Zod schema.
- Caches load result until `invalidate()`.
- Returns tri-state result via `tryLoad()`:
- `ok`
+4 -5
View File
@@ -6,7 +6,7 @@ A custom tool is a TypeScript/JavaScript module that exports a factory. The fact
## What this is (and is not)
- **Custom tool**: callable by the model during a turn (`execute` + Zod parameter schema; legacy TypeBox is still accepted and lifted to Zod at registration).
- **Custom tool**: callable by the model during a turn (`execute` + Zod parameter schema).
- **Extension**: lifecycle/event framework that can register tools and intercept/modify events.
- **Hook**: external pre/post command scripts.
- **Skill**: static guidance/context package, not executable tool code.
@@ -105,7 +105,7 @@ const factory: CustomToolFactory = (pi) => ({
export default factory;
```
Legacy TypeBox-authored factories can still call `pi.typebox` — it's now a small Zod-backed shim (`Type.Object`, `Type.String`, etc.) baked into the host, not the real `@sinclair/typebox` package. Schemas flow through the same Zod pipeline as `pi.zod` and need no separate normalization.
Schemas are authored with Zod (`pi.zod`) and flow through the shared validation/wire pipeline.
Factory return type:
@@ -122,8 +122,7 @@ From `types.ts` and `loader.ts`:
- `ui`: UI context (can be no-op in headless modes)
- `hasUI`: `false` in non-interactive flows
- `logger`: shared file logger
- `zod`: injected `zod` module (**preferred** for new tool schemas; use `pi.zod.object`, `pi.zod.string`, …)
- `typebox`: injected zod-backed `Type.*` shim (legacy extension compatibility)
- `zod`: injected `zod` module (use `pi.zod.object`, `pi.zod.string`, …)
- `pi`: injected `@oh-my-pi/pi-coding-agent` exports
- `pushPendingAction(action)`: register a preview action for hidden `resolve` tool (`docs/resolve-tool-runtime.md`)
@@ -137,7 +136,7 @@ Loader starts with a no-op UI context and requires host code to call `setUIConte
execute(toolCallId, params, onUpdate, ctx, signal);
```
- `params` is statically typed from your Zod schema via `z.infer<typeof schema>` (`Static<TParams>` in API types). Legacy TypeBox schemas are lifted to Zod internally.
- `params` is statically typed from your Zod schema via `z.infer<typeof schema>` (`Static<TParams>` in API types).
- Runtime argument validation happens before execution in the agent loop.
- `onUpdate` emits partial results for UI streaming.
- `ctx` includes session/model state and an `abort()` helper.
+12 -1
View File
@@ -84,6 +84,17 @@ These are consumed via `getEnvApiKey()` (`packages/ai/src/stream.ts`) unless not
| `GH_TOKEN` | Copilot fallback; GitHub API auth in web scraper | In web scraper: `GITHUB_TOKEN` → `GH_TOKEN` |
| `GITHUB_TOKEN` | Copilot fallback; GitHub API auth in web scraper | In web scraper: checked before `GH_TOKEN` |
### Auth broker / auth gateway (remote credential vault)
When the broker is enabled, the local SQLite credential store is bypassed and all OAuth refresh / access tokens live on the broker host. See [`auth-broker-gateway.md`](./auth-broker-gateway.md) for the full protocol, CLI surface, and 5-min/15-s usage cache layering.
| Variable | Used for | Required when | Notes / precedence |
| ----------------------- | ------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `OMP_AUTH_BROKER_URL` | Base URL of the remote auth-broker (e.g. `https://broker.tailnet:8765`); selects broker mode | Resolving credentials through a broker; also required by `omp auth-gateway serve` (the gateway is itself a broker client) | Wins over `auth.broker.url` in `config.yml`. When set with no resolvable token, `resolveAuthBrokerConfig()` hard-errors instead of falling back to local SQLite. |
| `OMP_AUTH_BROKER_TOKEN` | Bearer token sent on every broker endpoint except `/v1/healthz` | `OMP_AUTH_BROKER_URL` is set and no token is available from `auth.broker.token` or `<config-dir>/auth-broker.token` | Resolution: this env → `auth.broker.token` (`$ENV_NAME` indirection supported) → `<config-dir>/auth-broker.token` (mode `0600`). `<config-dir>` is `~/.omp/` (respecting `PI_CONFIG_DIR`). |
The gateway has no dedicated env vars — it inherits `OMP_AUTH_BROKER_*`. Its own inbound bearer token lives at `<config-dir>/auth-gateway.token` and is managed via `omp auth-gateway token`.
---
## 2) Provider-specific runtime configuration
@@ -277,7 +288,7 @@ Extra conditional behavior:
| `PI_SUBPROCESS_CMD` | Overrides subagent spawn command (`omp` / `omp.cmd` resolution bypass) |
| `PI_TASK_MAX_OUTPUT_BYTES` | Max captured output bytes per subagent (default `500000`) |
| `PI_TASK_MAX_OUTPUT_LINES` | Max captured output lines per subagent (default `5000`) |
| `PI_TIMING` | If `1`, enables startup/tool timing instrumentation logs |
| `PI_TIMING` | If set (any non-empty value), prints a hierarchical timing-span tree to **stderr** via `logger.printTimings()`. In interactive mode the tree prints once the agent is ready (before the TUI starts); in print mode it prints after the whole prompt batch completes. Print-mode prompts are wrapped in `print:prompt:initial` / `print:prompt:next` spans so each user message shows up as its own row. `PI_TIMING=x` exits the process with code 0 right after printing in interactive mode (use to measure cold startup only). `PI_TIMING=full` lists every module-load entry instead of just the top N. |
| `PI_PACKAGE_DIR` | Overrides package asset base dir resolution (docs/examples/changelog path lookup) |
| `PI_DISABLE_LSPMUX` | If `1`, disables lspmux detection/integration and forces direct LSP server spawning |
| `PI_RPC_EMIT_TITLE` | Boolean-like flag enabling title events in RPC mode |
+1 -2
View File
@@ -125,8 +125,7 @@ In interactive mode, `input` handlers run before the built-in first-message auto
Also exposed:
- `pi.logger`
- `pi.zod` (injected `zod` module — **preferred** for new tool schemas)
- `pi.typebox` (zod-backed `Type.*` shim — retained for legacy extension compat)
- `pi.zod` (injected `zod` module — use for tool parameter schemas)
- `pi.pi` (package exports)
### Message delivery semantics
+41
View File
@@ -0,0 +1,41 @@
# Install ID
A persistent per-install UUID that identifies a single oh-my-pi installation across sessions. Used as a stable correlation key for server-side dedup of telemetry-style pushes (currently the auto-QA grievance flush from `report_tool_issue`).
## API
Exported from `@oh-my-pi/pi-utils` (`packages/utils/src/dirs.ts`):
| Symbol | Purpose |
| --- | --- |
| `getInstallId(): string` | Returns the install ID, generating and persisting one on first call. Result is cached in-process for the lifetime of the runtime. |
| `__resetInstallIdCacheForTests(): void` | Clears the in-process cache. Test-only — MUST NOT be called from production code. |
The returned value is a canonical lowercase RFC 4122 UUID matching `^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`.
## Storage
- Path: `<config-root>/install-id` — i.e. `~/.omp/install-id` by default, respecting `PI_CONFIG_DIR` via `getConfigRootDir()`.
- Format: a single UUID line (trailing `\n`).
- Permissions: file is created with mode `0o600`.
- Lifecycle: independent of `~/.omp/agent/`. Wiping agent state (sessions, settings, DB) does NOT regenerate the install ID; only deleting the `install-id` file itself does.
## Generation and lifecycle
1. First call to `getInstallId()` reads the file. If contents parse as a valid UUID, that value is cached and returned.
2. Otherwise the helper calls `crypto.randomUUID()` (Node's CSPRNG-backed UUID v4) to mint a new ID.
3. The new value is written via `open(O_WRONLY | O_CREAT | O_EXCL, 0o600)`. The exclusive-create guard means two processes hitting first-call simultaneously cannot both succeed — the loser sees `EEXIST`, re-reads the winner's file, and adopts that ID.
4. If the existing file contained non-empty garbage (failed UUID regex), it is `unlink`ed before the exclusive create so `O_EXCL` does not trip on stale data.
5. Any other write failure (read-only FS, permission error) is swallowed: the freshly generated UUID is still cached in-memory so the rest of the process sees a stable value, and subsequent process launches will retry persistence.
6. Subsequent in-process calls return the cached value without touching disk. Mutating the file on disk after the first call has no effect until the process restarts (or tests call `__resetInstallIdCacheForTests`).
## Consumers
- `packages/coding-agent/src/tools/report-tool-issue.ts` — included as `installId` in the auto-QA grievance push body so the backend can deduplicate repeated reports from the same install. See `dev.autoqaPush.*` settings and `PI_AUTO_QA_PUSH_*` env vars.
New consumers MUST treat the value as opaque and MUST NOT derive PII from it; the helper does not mix in hostname, username, or any other host-identifying entropy.
## See also
- [environment-variables.md](environment-variables.md) — `PI_CONFIG_DIR` controls where `install-id` lives.
- [config-usage.md](config-usage.md) — broader config-root layout.
+25
View File
@@ -148,6 +148,17 @@ ModelRegistry pipeline (on refresh):
- otherwise append
6. Load cached/runtime-discovered models (Ollama, llama.cpp, LM Studio, plus built-in provider managers), then re-apply model overrides.
### Provider-model cache and static fingerprint
Cached per-provider model lists are persisted in the model-cache SQLite
database (schema v3) with a `static_fingerprint` column that hashes the
static catalog slice merged into the row. When `resolveProviderModels`
skips the network fetch and the fingerprint of the in-memory static
catalog matches the cached one, the cached rows are returned verbatim —
the static + dynamic merge is bypassed entirely. The fingerprint is
memoized per process via a WeakMap keyed by the static-models array
reference, so repeated cold-start calls do not re-hash.
## Canonical model equivalence and coalescing
The registry keeps every concrete provider model and then builds a canonical layer above them.
@@ -309,6 +320,12 @@ Keyless providers:
- Providers marked `auth: none` are treated as available without credentials.
- `getApiKey*` returns `kNoAuth` for them.
### Broker mode
When `OMP_AUTH_BROKER_URL` (or `auth.broker.url`) is set, the local SQLite credential store is replaced by `RemoteAuthCredentialStore`. Layers 2 and 3 above (stored API key / OAuth in `agent.db`) are served from a broker-supplied snapshot whose `refresh` tokens are redacted; expiry triggers `POST /v1/credential/:id/refresh` on the broker rather than a local refresh.
`AuthStorage.setConfigApiKey` lets a `models.yml` `apiKey` win over a broker-resolved OAuth token without overriding a runtime `--api-key`. See [`auth-broker-gateway.md`](./auth-broker-gateway.md) for the full broker / gateway design and env surface (`OMP_AUTH_BROKER_URL`, `OMP_AUTH_BROKER_TOKEN`, `auth.broker.url`, `auth.broker.token`).
## Model availability vs all models
- `getAll()` returns the loaded model registry (built-in + merged custom + discovered).
@@ -530,6 +547,14 @@ providers:
```
`disableStrictTools` is a provider-level flag that applies to all models in the provider.
Tool schemas going on the wire are normalized by the unified flow in
`packages/ai/src/utils/schema/normalize.ts` (Google/CCA/MCP dispatchers
plus the OpenAI strict-mode sanitize+enforce pipeline). See
[`ai-schema-normalize.md`](./ai-schema-normalize.md) for the strict-mode
edge cases (local `$ref` inlining, single-item `allOf` collapse,
`anyOf`-wrapper description hoist, enum/const primitive-type inference)
and the per-provider dispatcher mapping.
## Practical examples
### Local OpenAI-compatible endpoint (no auth)
+1 -1
View File
@@ -68,7 +68,7 @@ Consumers in `packages/coding-agent` and `packages/tui` import directly from `@o
| PTY | `new PtySession()`, `start/write/resize/kill` | `pty.rs` | class / promises |
| Process | `killTree(pid, signal)`, `listDescendants(pid)` | `ps.rs` | sync |
| Keys | `parseKey`, `matchesKey`, Kitty/legacy helpers | `keys.rs` | sync |
| Text | `wrapTextWithAnsi`, `truncateToWidth`, `sliceWithWidth`, `extractSegments`, `sanitizeText`, `visibleWidth` | `text.rs` | sync |
| Text | `wrapTextWithAnsi`, `truncateToWidth`, `sliceWithWidth`, `extractSegments`, `visibleWidth` | `text.rs` | sync |
| Highlight | `highlightCode`, `supportsLanguage`, `getSupportedLanguages` | `highlight.rs` | sync |
| HTML | `htmlToMarkdown(html, options?)` | `html.rs` | `Promise<string>` |
| Image | `PhotonImage`, `encodeSixel` | `image.rs` | class / sync / promises |
+71
View File
@@ -215,3 +215,74 @@ bun --cwd=packages/natives run embed:native
# Reset embedded manifest to null stub
bun --cwd=packages/natives run embed:native -- --reset
```
## Orchestrator-side content-addressed build cache (robomp)
When `pi-natives` is built inside the robomp orchestrator (`python/robomp/`), workspaces share built artifacts through a content-addressed cache instead of rebuilding from scratch in every per-issue worktree. The cache is **orchestrator-side only** — `bun --cwd=packages/natives run build` itself is unchanged; the cache lives outside the build pipeline and is populated/captured around `ensure_workspace` and post-task success in `python/robomp/src/natives_cache.py`.
### What is cached
The complete set of files in `packages/natives/native/` that are pure functions of the cache-key inputs:
- `pi_natives.<platform>-<arch>[-variant].node` (glob `pi_natives.*.node`)
- `index.d.ts`
- `index.js`
- `embedded-addon.js`
- `manifest.json` (cache metadata: key, target triple, capture timestamp, source workspace, commit)
An entry is only considered a hit when the `.node` glob matches AND every companion plus the manifest is present. Partial entries are evicted on GC.
### Cache key
The key is `sha256` over `(path \t git-tree-hash \n)` pairs for the following inputs, in this order (order is significant), followed by the target triple:
1. `crates` (whole subtree — pi-natives transitively depends on other workspace crates)
2. `Cargo.lock`
3. `Cargo.toml`
4. `rust-toolchain.toml`
5. `packages/natives` (whole subtree — build script, `scripts/*`, package.json with napi config)
Tree hashes come from one `git cat-file --batch-check` invocation against `HEAD`; paths missing from `HEAD` fold in as a fixed null hash so the key stays deterministic across repos that don't ship every input. The target-triple suffix matches the napi addon basename convention (`<platform>-<arch>` for non-x64, `<platform>-<arch>-<variant>` for x64). When `TARGET_VARIANT` is unset on an x64 host the variant component is `host` rather than autodetected — the key is stable on a given machine but a `modern`/`baseline` build with an explicit `TARGET_VARIANT` gets a different key.
Anything outside this input set (Rust toolchain auto-installed delta, host glibc, env vars other than `TARGET_VARIANT`) is **not** in the key. If you need to invalidate after such a change, delete the cache directory by hand or bump one of the input files.
### Layout and ownership
- Root: `/data/cache/pi-natives` (provisioned by `entrypoint.sh` alongside the cargo caches, owned `root:omp`, mode `02770` setgid so cached files inherit `gid=omp` and stay readable by every slot user).
- Per-repo subdirectory: `<root>/<repo-slug>/` where the slug is `owner__repo` (mirrors `SandboxManager.pool_path`).
- Per-entry directory: `<root>/<repo-slug>/<sha256-key>/` containing the cached files plus `manifest.json`.
- Per-repo lockfile: `<root>/<repo-slug>/.lock` (advisory `fcntl.flock`, exclusive on capture and GC).
- Staging dirs (`.<key>.tmp.<pid>`) during capture; renamed atomically into the final entry path. Stale staging dirs from crashed captures are swept on GC.
### Populate and capture semantics
- **Populate** (workspace ← cache) runs inside `ensure_workspace`. On a key hit the `.node` is **hardlinked** into the workspace (zero-copy, shared inode); the companion `index.d.ts` / `index.js` / `embedded-addon.js` are **copied** (independent inodes) because the napi build's `installGeneratedBindings` and `gen-enums.ts` rewrite those files via `open(..., 'w')` — an in-place truncate that would otherwise propagate through a hardlink and corrupt the cache. Cross-device hardlink failures (`EXDEV`) fall back to copy.
- **Capture** (cache ← workspace) runs from the post-task success path when the build produced a complete artifact set. Capture uses **copy**, not hardlink: hardlinking a slot-owned workspace file would preserve slot UID ownership on the cached inode and defeat the shared-group model. Copying creates a fresh root-owned, `gid=omp` inode via the setgid cache root. Capture is idempotent under the per-repo flock: a concurrent capture for the same key returns the existing entry.
### Garbage collection
A periodic GC loop runs in `WorkerPool` with two caps per repo. When either cap is exceeded, oldest entries (by `manifest.json.captured_at`) are dropped first:
- entry count cap (`max_entries_per_repo`, default 8)
- byte cap (`max_bytes`, default 4 GiB)
Workspaces that hardlinked a `.node` before GC retain access via the kernel inode refcount — `rmtree` of the cache entry does not delete the file from the workspace.
### Configuration (settings on `robomp.config.Settings`)
| Env var | Default | Effect |
| -------------------------------------------- | ------------------------ | ------------------------------------------------------------- |
| `ROBOMP_NATIVES_CACHE_ENABLED` | `true` | Master switch. When false the populate/capture hooks no-op and every workspace builds from scratch. |
| `ROBOMP_NATIVES_CACHE_ROOT` | `/data/cache/pi-natives` | Cache root directory. Must be `root:omp 02770` for cross-slot reads. |
| `ROBOMP_NATIVES_CACHE_MAX_ENTRIES_PER_REPO` | `8` | LRU entry-count cap, per repo slug. |
| `ROBOMP_NATIVES_CACHE_MAX_BYTES` | `4294967296` (4 GiB) | LRU byte cap, per repo slug. |
| `ROBOMP_NATIVES_CACHE_GC_INTERVAL_SECONDS` | `3600` | Period of the background GC loop in `WorkerPool`. |
### Manual invalidation
- One key: `rm -rf /data/cache/pi-natives/<repo-slug>/<sha256>`.
- One repo: `rm -rf /data/cache/pi-natives/<repo-slug>`.
- Everything: `rm -rf /data/cache/pi-natives/*` (preserve the root so its setgid mode survives).
- Stuck lock: `rm /data/cache/pi-natives/<repo-slug>/.lock` (only when no orchestrator process is touching the repo).
Trigger an automatic miss by editing any path in the key set: a single touched byte under `crates/`, `Cargo.lock`, `Cargo.toml`, `rust-toolchain.toml`, or `packages/natives/` shifts the tree hash and forces a fresh build at the next populate.
+1 -2
View File
@@ -37,7 +37,6 @@ Terminology follows `docs/natives-architecture.md`:
| `truncateToWidth(text, maxWidth, ellipsis, pad, tabWidth)` | `truncateToWidth` | `text.rs` |
| `sliceWithWidth(line, startCol, length, strict, tabWidth)` | `sliceWithWidth` | `text.rs` |
| `extractSegments(line, beforeEnd, afterStart, afterLen, strictAfter, tabWidth)` | `extractSegments` | `text.rs` |
| `sanitizeText(text)` | `sanitizeText` | `text.rs` |
| `visibleWidth(text, tabWidth)` | `visibleWidth` | `text.rs` |
| `highlightCode(code, lang, colors)` | `highlightCode` | `highlight.rs` |
| `supportsLanguage(lang)` | `supportsLanguage` | `highlight.rs` |
@@ -206,7 +205,7 @@ These are pure, in-memory utilities.
- `truncateToWidth`: visible-cell truncation with ellipsis policy (`Unicode`, `Ascii`, `Omit`), optional right padding.
- `sliceWithWidth`: column slicing with optional strict width enforcement.
- `extractSegments`: extracts before/after segments around an overlay while restoring ANSI state for the `after` segment.
- `sanitizeText`: strips ANSI escapes + control chars, drops lone surrogates, normalizes line endings.
- `sanitizeText` (ANSI/control/surrogate stripping with line-ending normalization) no longer lives in `text.rs`; it moved to `@oh-my-pi/pi-utils` as a pure-JS implementation in `packages/utils/src/sanitize-text.ts`. The native binding was removed in the same change because the JS version was competitive on the benchmarked workloads, and keeping a Rust copy forced every caller (including `pi-utils`) to pull in `@oh-my-pi/pi-natives`.
- `visibleWidth`: counts visible terminal cells using caller-supplied tab width.
### Failure behavior
+12
View File
@@ -308,6 +308,18 @@ type CreateAgentSessionResult = {
Use `setToolUIContext(...)` only if your embedder provides UI capabilities that tools/extensions should call into.
## Startup performance
`createAgentSession()` runs two background optimizations to overlap I/O with the rest of session setup:
- **Model-host preconnect.** As soon as the model is resolved, the SDK fires a best-effort `fetch.preconnect(model.baseUrl)` so DNS + TCP + TLS + HTTP/2 to the provider's host happens in parallel with extension/skill load, tool registry build, and system-prompt assembly. The first real `fetch(...)` then reuses the warm connection, saving 100–300 ms on transcontinental hops (e.g. residential IP → `api.anthropic.com`). Implementation lives in `preconnectModelHost()` in `packages/coding-agent/src/sdk.ts`. If `fetch.preconnect` is unavailable (non-Bun runtime) or the call throws, the optimization is silently skipped — never a hard dependency. Applies to every mode (interactive, print, RPC, ACP).
- **Conditional LSP warmup.** Startup LSP servers (those returned by `discoverStartupLspServers(cwd)`) are only warmed when **all** of these hold:
- `enableLsp !== false` on the session options, **and**
- `options.hasUI === true` (interactive TUI), **and**
- the `lsp.diagnosticsOnWrite` setting is enabled.
Print / script / RPC / ACP invocations (`hasUI=false`) skip the warmup entirely: they don't render the warmup status indicator and typically finish before the language servers would stabilize, so warming them just spends CPU parsing big `initialize` responses concurrently with the LLM stream consumer and jitters perceived latency. Tools that actually need an LSP server still spin one up on demand through `getOrCreateClient()` — only the *startup* warmup is skipped. The returned `lspServers` field in `CreateAgentSessionResult` is therefore `undefined` (not an empty array) whenever the warmup branch was bypassed.
## Minimal controlled embed example
```ts
+4
View File
@@ -106,3 +106,7 @@ Environment variables are collected first, then file-defined entries are appende
- `packages/coding-agent/src/secrets/obfuscator.ts` -- `SecretObfuscator` class, placeholder generation, message obfuscation
- `packages/coding-agent/src/secrets/regex.ts` -- regex literal parsing and compilation
- `packages/coding-agent/src/config/settings-schema.ts` -- `secrets.enabled` setting definition
## See also
- [`auth-broker-gateway.md`](./auth-broker-gateway.md) -- remote credential vault and forward-proxy that keep provider OAuth refresh tokens and access tokens off developer hosts entirely (complementary to in-process obfuscation).
+27
View File
@@ -181,6 +181,33 @@ Adjacent but related lifecycle hooks:
- In-memory sessions never return a branch file path from `createBranchedSession`.
- Tree context reconstruction includes service-tier and MCP tool-selection state, but those entries do not become LLM messages.
## Plan approval session naming
When a user approves a plan from plan mode (`InteractiveMode.#approvePlan`), the approval handler seeds the session name from the plan's title so the resulting (fresh or compacted) session does not stay unnamed.
Trigger:
- Plan approval reaches `#approvePlan(...)` with `options.title` populated from the plan-approval details.
- This runs for every approval choice (`Approve and execute`, `Approve and compact context`, plain `Approve`); the synthetic `plan-approved` prompt is what otherwise bypasses the input-controller's title-generation path.
Naming source:
- The normalized plan title is humanized via `humanizePlanTitle(title)` (`packages/coding-agent/src/plan-mode/approved-plan.ts`):
- replaces runs of `-`/`_` with a single space
- trims whitespace
- capitalizes the first character
- returns `""` for whitespace-only / separator-only input
- The humanized name is applied with `sessionManager.setSessionName(name, "auto")`. Because `setSessionName` is a no-op when `titleSource === "user"`, the seeded name never overrides a name the user already chose (e.g. on the `preserveContext` path where the session continues with prior naming).
- On successful apply, the terminal title (`setSessionTerminalTitle`) and the editor border color are refreshed to reflect the new name.
Examples (from `humanizePlanTitle`):
- `migrate-mcp-loader` → `Migrate mcp loader`
- `fix_session_naming` → `Fix session naming`
- `foo--bar__baz` → `Foo bar baz`
- `RefactorRouter` → `RefactorRouter` (no separators to expand)
- `""` / `"---"` → `""` (no name applied)
## Legacy compatibility still present
Session migrations still run on load:
+1 -1
View File
@@ -341,6 +341,6 @@ Use this workflow:
- All `colors` tokens are required for custom themes.
- `export` and `symbols` are optional.
- `$schema` in theme JSON is informational; runtime validation is enforced by compiled TypeBox schema in code.
- `$schema` in theme JSON is informational; runtime validation is enforced by a Zod schema in code.
- `setTheme` failure falls back to `dark`; `previewTheme` failure does not replace current theme.
- File watcher reload errors or temporary missing files keep the current loaded theme until a successful reload or explicit theme switch.
+1 -1
View File
@@ -15,7 +15,7 @@
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `goal` | `string` | Yes | Investigation goal. Required by the TypeBox schema and echoed in the tool result. |
| `goal` | `string` | Yes | Investigation goal. Required by the schema and echoed in the tool result. |
## Outputs
The tool returns a single text result plus structured details:
+58 -70
View File
@@ -8,8 +8,6 @@
- Entry: `packages/coding-agent/src/tools/eval.ts`
- Model-facing prompt: `packages/coding-agent/src/prompts/tools/eval.md`
- Key collaborators:
- `packages/coding-agent/src/eval/parse.ts` — lenient cell parser
- `packages/coding-agent/src/eval/sniff.ts` — language sniffing heuristics
- `packages/coding-agent/src/eval/backend.ts` — backend execution contract
- `packages/coding-agent/src/eval/js/index.ts` — JS backend adapter
- `packages/coding-agent/src/eval/js/executor.ts` — JS execution + output sink
@@ -24,36 +22,33 @@
## Inputs
Tool parameters are a JSON object with a single `cells` field — an ordered array of cell objects. Each cell is a structured record; there is no `*** Cell` header parsing, no language sniffing, and no implicit single-cell fallback. Cells run in array order; state persists within each language across cells and across tool calls.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `input` | `string` | Yes | Cell program text. Parsed by `parseEvalInput()` in `packages/coding-agent/src/eval/parse.ts`, not by JSON subfields. |
| `cells` | `EvalCellInput[]` | Yes | Cells executed in order. At least one cell is required (`.min(1)`). |
`input` syntax accepted at runtime:
Each `EvalCellInput` (from `evalCellSchema` in `packages/coding-agent/src/tools/eval.ts`):
- Cell header: `*** Cell <attrs...>`. Attributes are space-separated tokens with quoted titles (`"..."` or `'...'`).
- Canonical tokens (advertised in the prompt):
- `<lang>:"<title>"` — language + title shorthand. `lang` is `py` or `js` (lenient: also `ts`, plus the long-form aliases `python`, `javascript`, `typescript`, `ipy`, `ipython`).
- `t:<n>[ms|s|m]` — per-cell timeout (default 30s).
- `rst` — wipe this cell's language kernel before running.
- Lenient additional tokens (accepted by the parser, not advertised):
- bare language token (`py`, `js`)
- `id:"..."` / `title:"..."` / `name:"..."` / `cell:"..."` / `file:"..."` / `label:"..."` — title aliases
- `timeout:` / `duration:` / `time:` — `t:` aliases
- `reset` — `rst` alias
- `rst:true|false|1|0|yes|no|on|off` — explicit boolean form
- a bare positional duration token (`30s`, `2m`, `500ms`)
- any unclassified bare token folds into a positional title fragment
- Cell body: every following line until the next `*** Cell ...`, the optional `*** End`, or `*** Abort`. `*** End` is a quirk fix for GPT-trained models that emit terminators and is not documented in the prompt.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `language` | `"py" \| "js"` | Yes | Backend selector. `"py"` maps to the IPython/Jupyter kernel (`python` backend); `"js"` maps to the persistent JavaScript VM. |
| `code` | `string` | Yes | Cell body, verbatim. JSON-encoded — embed newlines, quotes, and indentation directly; no fences, no headers. |
| `title` | `string` | No | Short label rendered in the transcript (e.g. `"imports"`, `"load config"`). |
| `timeout` | `integer` | No | Per-cell timeout in seconds, clamped to `1..600`. Defaults to 30 when omitted. |
| `reset` | `boolean` | No | Wipe this cell's language kernel before running. Reset is per-language: a `py` cell's reset does not touch the JS VM and vice versa. Defaults to `false`. |
Leniencies in `packages/coding-agent/src/eval/parse.ts`:
Minimal example matching the live schema:
- Markers accept two or more leading `*` and flexible whitespace.
- `*** End` is optional everywhere; the parser silently consumes trailing tokens (e.g. `*** End py`).
- Missing terminators between adjacent cells are tolerated; the next `*** Cell` closes the prior cell, and stray non-marker lines between cells fold into the prior cell's body without crashing.
- Bare code or a single markdown fence such as ```` ```py ```` is treated as one implicit cell.
- If `*** Abort` appears, the in-progress cell is dropped and the result carries an abort warning. To preserve a completed cell before `*** Abort`, emit `*** End` first.
The tool also exposes a custom Lark grammar from `packages/coding-agent/src/eval/eval.lark` for constrained sampling. That grammar is stricter than the runtime parser: it requires the canonical `*** Cell <lang>:"title"` header form with a fixed attribute order, advertises only `py` / `js`, and pins the trailing `*** End` so GPT-trained models' natural terminator habit aligns with the constrained output.
```json
{
"cells": [
{ "language": "py", "title": "imports", "timeout": 10, "code": "import json\nfrom pathlib import Path" },
{ "language": "py", "title": "load config", "code": "data = json.loads(read('package.json'))\ndisplay(data)" },
{ "language": "js", "title": "summary", "reset": true, "code": "const data = JSON.parse(await read('package.json'));\ndisplay(data);\nreturn data.name;" }
]
}
```
## Outputs
@@ -69,17 +64,17 @@ Returned shape:
- `jsonOutputs`: structured values emitted via `display(...)`
- `images`: image payloads emitted by Python rich display or JS `display({ type: "image", ... })`
- `statusEvents`: aggregated helper/tool status events
- `notice`: backend fallback notice
- `notice`: backend fallback notice (currently unused; reserved for future per-cell notices)
- `meta`: truncation metadata
- `isError`: set on cell failure or cancellation
Renderer behavior in `packages/coding-agent/src/tools/eval.ts`:
- call preview renders parsed code cells with syntax highlighting
- call preview renders each cell's `code` with syntax highlighting based on its declared `language`
- result view renders each cell separately, including status, duration, and output
- markdown outputs are rendered with the Markdown component instead of plain text
- `jsonOutputs` render as a tree, collapsed or expanded depending on UI state
- timeout / fallback / truncation notices render as dim metadata lines
- timeout / truncation notices render as dim metadata lines
- images are carried in `details.images`; generic tool UI image handling renders them outside the text block
Side-channel artifacts:
@@ -89,54 +84,48 @@ Side-channel artifacts:
## Flow
1. `EvalTool.execute()` in `packages/coding-agent/src/tools/eval.ts` parses `params.input` with `parseEvalInput()`.
2. `parseEvalInput()` normalizes newlines, collects cells, parses attributes, and assigns each cell a language from the header, language sniffing, or the default `python`.
3. Back in `execute()`, each parsed cell is resolved to a backend with `resolveBackend()`:
- explicit `python`/`js` requests are validated against session settings and backend availability
- otherwise `sniffEvalLanguage()` in `packages/coding-agent/src/eval/sniff.ts` tries shebangs and language markers
- if no explicit language was present, later cells prefer the previous runtime language before re-sniffing
- Python is preferred when available; JS is the fallback when Python is unavailable or disabled
4. The tool allocates an `OutputSink`, a `TailBuffer`, per-cell result objects, and a `sessionAbortController`. `session.trackEvalExecution?.(...)` can wrap the whole run for external cancellation tracking.
5. Cells execute sequentially. For each cell, `execute()`:
- clamps the cell timeout through `clampTimeout("eval", ...)`
1. `EvalTool.execute()` in `packages/coding-agent/src/tools/eval.ts` receives `params.cells` already validated by the Zod schema — no string parsing step.
2. For each cell, `execute()` maps `cell.language` to an `EvalLanguage` (`"py"` → `"python"`, `"js"` → `"js"`) and calls `resolveBackend(session, language)`:
- `python` is gated on `eval.py !== false` and `pythonBackend.isAvailable(session)`.
- `js` is gated on `eval.js !== false`.
- A disabled or unavailable requested backend throws `ToolError`; there is no auto-fallback or sniffing.
3. The tool allocates an `OutputSink`, a `TailBuffer`, per-cell result objects, and a `sessionAbortController`. `session.trackEvalExecution?.(...)` can wrap the whole run for external cancellation tracking.
4. Cells execute sequentially. For each cell, `execute()`:
- clamps `(cell.timeout ?? 30) * 1000` ms through `clampTimeout("eval", ...)`
- builds a combined abort signal from the tool signal, the timeout, and the session abort controller
- marks the cell `running` and emits an update
- calls the backend’s `execute()` with `cwd`, `sessionId`, `sessionFile`, `kernelOwnerId`, `deadlineMs`, `reset`, artifact info, and chunk callback
6. JS cells dispatch through `packages/coding-agent/src/eval/js/index.ts` into `executeJs()`; Python cells dispatch through `packages/coding-agent/src/eval/py/index.ts` into `executePython()`.
7. Backend text chunks stream into the shared `OutputSink`; rich outputs are accumulated separately as JSON, images, markdown markers, and status events.
8. After each cell:
- calls the backend’s `execute()` with `cwd`, `sessionId`, `sessionFile`, `kernelOwnerId`, `deadlineMs`, `reset` (defaults to `false`), artifact info, and chunk callback
5. JS cells dispatch through `packages/coding-agent/src/eval/js/index.ts` into `executeJs()`; Python cells dispatch through `packages/coding-agent/src/eval/py/index.ts` into `executePython()`.
6. Backend text chunks stream into the shared `OutputSink`; rich outputs are accumulated separately as JSON, images, markdown markers, and status events.
7. After each cell:
- text output is trimmed and stored on that cell result
- multi-cell runs prefix text with `[i/n]` and the optional title
- cancellations return early with `isError: true` and a cell-specific abort message
- non-zero exit codes return early with `isError: true` and a message naming the failed cell
- later cells are skipped after the first error, but earlier cell state persists in the underlying runtime
9. On success, the tool joins all cell outputs, synthesizes `(no text output)` or `(no output)` when needed, and attaches truncation metadata from `summarizeFinal()`.
10. The renderer uses `details.cells`, `details.jsonOutputs`, and `details.statusEvents` to build notebook-style output. `mergeCallAndResult = true` and `inline = true`, so call and result render together in the transcript.
8. On success, the tool joins all cell outputs, synthesizes `(no text output)` or `(no output)` when needed, and attaches truncation metadata from `summarizeFinal()`.
9. The renderer uses `details.cells`, `details.jsonOutputs`, and `details.statusEvents` to build notebook-style output. `mergeCallAndResult = true` and `inline = true`, so call and result render together in the transcript.
## Modes / Variants
### Parsing modes
- Explicit multi-cell format with `*** Cell ...` headers
- Implicit single-cell fallback for bare code or a single fenced block
- Abort-recovery parse path when `*** Abort` is present
### Backend selection
- Explicit Python backend
- Explicit JavaScript backend
- Auto-detected backend via `sniffEvalLanguage()`
- Fallback from requested/inferred Python to JS when Python is unavailable
- Fallback notice when JS markers are seen but `eval.js` is disabled and Python is used instead
Backend choice is **explicit per cell** — there is no auto-detection.
- `language: "py"` → Python (IPython/Jupyter) backend
- `language: "js"` → JavaScript VM backend
If the requested backend is disabled or unavailable, the tool throws `ToolError` for that cell. The caller chooses; the tool does not silently substitute.
### JavaScript runtime
Implemented in `packages/coding-agent/src/eval/js/context-manager.ts` and `packages/coding-agent/src/eval/js/prelude.txt`.
- Persistent `vm.Context` instances keyed by `js:${sessionId}` in `vmContexts`
- `rst` calls `resetVmContext(sessionKey)` before the cell executes
- `reset: true` calls `resetVmContext(sessionKey)` before the cell executes
- Top-level `await` and bare `return` are supported by wrapping code in an async IIFE when `wrapCode()` sees `await` or `return`
- Top-level static `import ... from ...` and dynamic `import(...)` calls are routed through `rewriteImports()`, which sends them via `__omp_import__` so the specifier resolves against the session cwd
- Module cache is busted for **local** imports between cells so edits to source files are picked up without restarting the runtime. `__omp_import__` deletes `require.cache[absPath]` before re-importing whenever the original specifier is a filesystem path: relative (`./x`, `../x`, `.`, `..`), POSIX-absolute (`/...`), home-prefixed (`~/...`), or Windows drive-letter (`C:\...` / `C:/...`). Bare specifiers (`react`, `lodash/x`) and URL/scheme specifiers (`node:fs`, `file://...`, `https://...`) are left in cache so package identity stays stable across cells. The cache-bust only fires when the resolved target is an absolute path — unresolved bare-package fallbacks (`resolveImportSpecifier()` returning the original specifier) skip it.
- The prelude installs globals:
- `display`, `print`
- `read`, `write`, `append`, `sort`, `uniq`, `counter`, `diff`, `tree`, `env`, `output`
@@ -155,7 +144,7 @@ Implemented in `packages/coding-agent/src/eval/py/executor.ts`, `packages/coding
- Default mode is retained `session` kernels keyed by `python:${sessionId}`
- Optional `python.kernelMode = "per-call"` creates a fresh kernel for each cell and shuts it down afterward
- `rst` disposes the retained kernel for that session before the cell runs; later Python cells in the same tool call reuse the fresh kernel
- `reset: true` disposes the retained kernel for that session before the cell runs; later Python cells in the same tool call reuse the fresh kernel
- Startup path:
- availability check
- create/connect kernel
@@ -177,8 +166,8 @@ Implemented in `packages/coding-agent/src/eval/py/executor.ts`, `packages/coding
A single tool call can mix Python and JS cells. Persistence is per language runtime:
- resetting Python does not touch JS state
- resetting JS does not touch Python state
- `reset: true` on a Python cell does not touch JS state
- `reset: true` on a JS cell does not touch Python state
- each backend keeps its own retained session keyed from the same session-derived ID
## Side Effects
@@ -206,8 +195,9 @@ A single tool call can mix Python and JS cells. Persistence is per language runt
## Limits & Caps
- Per-cell timeout default: 30s (`DEFAULT_TIMEOUT_MS` in `packages/coding-agent/src/eval/parse.ts`; `TOOL_TIMEOUTS.eval.default` in `packages/coding-agent/src/tools/tool-timeouts.ts`)
- Timeout clamp: 1s minimum, 600s maximum (`TOOL_TIMEOUTS.eval` in `packages/coding-agent/src/tools/tool-timeouts.ts`)
- Per-cell timeout default: 30s (applied when `timeout` is omitted in `EvalTool.execute()`; clamped through `TOOL_TIMEOUTS.eval.default` in `packages/coding-agent/src/tools/tool-timeouts.ts`)
- Schema-level `timeout` range: integer `1..600` seconds (enforced by Zod on the cell schema)
- Timeout clamp at runtime: 1s minimum, 600s maximum (`TOOL_TIMEOUTS.eval` in `packages/coding-agent/src/tools/tool-timeouts.ts`)
- Transcript code/output preview: 10 lines by default (`EVAL_DEFAULT_PREVIEW_LINES` in `packages/coding-agent/src/tools/eval.ts`)
- Output truncation window: 50KB default (`DEFAULT_MAX_BYTES` in `packages/coding-agent/src/session/streaming-output.ts`)
- Output line cap inside truncation helpers: 3000 lines (`DEFAULT_MAX_LINES` in `packages/coding-agent/src/session/streaming-output.ts`)
@@ -222,24 +212,22 @@ A single tool call can mix Python and JS cells. Persistence is per language runt
## Errors
- Parse errors from `parseEvalInput()` throw immediately, for example invalid timeout strings.
- Zod validation rejects malformed `cells` arrays before `execute()` runs (missing `language`/`code`, out-of-range `timeout`, empty `cells`).
- Missing session without proxy executor throws `ToolError("Eval tool requires a session when not using proxy executor")`.
- Disabled/unavailable backends throw `ToolError` from `resolveBackend()`:
- `eval.py = false`
- `eval.js = false`
- Python kernel unavailable
- no backend available
- `eval.py = false` and a `py` cell is requested
- `eval.js = false` and a `js` cell is requested
- Python kernel unavailable and a `py` cell is requested
- JS runtime exceptions are converted into text output plus `exitCode: 1`; cancellations return `cancelled: true` and may append `Command timed out`.
- Python execution errors from the kernel become text output and `exitCode: 1`; later cells are skipped.
- Python stdin requests are treated as errors with the message `Kernel requested stdin; interactive input is not supported.`
- Cancellation is returned, not thrown, once backend execution has started. The tool formats it as a cell failure and sets `details.isError = true`.
- If parsing encountered `*** Abort`, the final text appends `ABORT_WARNING`, explicitly telling the model that earlier cells ran and state persists.
- If output truncates, the tool still succeeds; truncation is surfaced through `details.meta` and artifact-backed full output when available.
## Notes
- The runtime parser is intentionally more permissive than `packages/coding-agent/src/eval/eval.lark`; maintain both when changing syntax.
- Cell language in `ParsedEvalCell` is not the last word: `EvalTool.execute()` may override backend selection for cells without an explicit header by inheriting the previous runtime language.
- Backend selection is now strictly explicit per cell: `language` must be `"py"` or `"js"`. The previous `*** Cell` header parser, the `eval.lark` constrained grammar, and the sniffer-based fallback have all been removed.
- `EvalTool.customFormat` no longer exists. Tool calls flow through the standard JSON schema; there is no Lark-constrained sampling path.
- `tool.<name>()` exists only in JS. Python prelude helpers do not call back into the full tool registry.
- JS helper paths reject protocol URIs (`://`) in `resolvePath()`; the JS prelude is filesystem-only unless the code calls `tool.read(...)` or another tool explicitly.
- Python helper `output(...)` depends on `PI_SESSION_FILE`; it fails outside a session-backed run.
+1
View File
@@ -310,4 +310,5 @@ Same as `definition`, but sends `textDocument/implementation` and reports `imple
- `reload` does not recreate a client immediately after killing it; the next request triggers reinitialization.
- `workspace/applyEdit` can apply edits initiated by the server outside the direct tool action result path.
- `detectLspmux()` can be disabled with `PI_DISABLE_LSPMUX=1`; only `rust-analyzer` is in `DEFAULT_SUPPORTED_SERVERS`.
- Startup LSP warmup (`discoverStartupLspServers(cwd)` in `sdk.ts`) is gated on `enableLsp && options.hasUI && settings.get("lsp.diagnosticsOnWrite")` — print/RPC/ACP/script sessions skip it and let `getOrCreateClient()` cold-start servers on demand. See `docs/sdk.md` § Startup performance.
- `configCache` is per-process and never auto-invalidated; config changes require a fresh process to be observed by `getConfig()` callers.
+2 -2
View File
@@ -10,7 +10,7 @@
- `packages/coding-agent/src/tools/archive-reader.ts` — detect `archive.ext:inner/path`, index archives, list/read entries.
- `packages/coding-agent/src/tools/sqlite-reader.ts` — detect SQLite targets, parse selectors, render tables.
- `packages/coding-agent/src/tools/fetch.ts` — URL parsing, fetch/render pipeline, URL cache/artifacts.
- `packages/coding-agent/src/internal-urls/router.ts` — resolve `agent://`, `artifact://`, `local://`, `mcp://`, `memory://`, `pi://`, `rule://`, `skill://`.
- `packages/coding-agent/src/internal-urls/router.ts` — resolve `agent://`, `artifact://`, `local://`, `mcp://`, `memory://`, `omp://`, `rule://`, `skill://`.
- `packages/coding-agent/src/edit/notebook.ts` — convert `.ipynb` to editable `# %% [...] cell:N` text.
- `packages/coding-agent/src/utils/file-display-mode.ts` — decide hashline vs line-number vs raw display.
- `packages/coding-agent/src/workspace-tree.ts` — render directory trees.
@@ -194,7 +194,7 @@ URL selectors are parsed separately in `packages/coding-agent/src/tools/fetch.ts
### Internal URLs
- `read` does not resolve these itself; it delegates to `session.internalRouter.resolve()`.
- Registered protocols are outside this file, but the router in `packages/coding-agent/src/internal-urls/router.ts` is built for `agent://`, `artifact://`, `issue://`, `local://`, `mcp://`, `memory://`, `pi://`, `pr://`, `rule://`, and `skill://`.
- Registered protocols are outside this file, but the router in `packages/coding-agent/src/internal-urls/router.ts` is built for `agent://`, `artifact://`, `issue://`, `local://`, `mcp://`, `memory://`, `omp://`, `pr://`, `rule://`, and `skill://`.
- `#handleInternalUrl()` behavior:
- parses the URL with `parseInternalUrl()` so colons inside the host segment are legal
- for `agent://`, treats non-root path extraction or `?q=` extraction as a special no-pagination mode
+25 -3
View File
@@ -108,7 +108,27 @@ Pending injections are cleared after content generation.
### Non-interrupting matches
If matched rules do not permit interruption (`interruptMode: "never"`, or source-specific `prose-only`/`tool-only` mismatch), they are still queued. After a successful non-error, non-aborted assistant message, `AgentSession` injects the hidden `ttsr-injection` custom message as a follow-up and schedules continuation.
Non-interrupting matches split by `matchContext.source`:
- **`source === "tool"` (tool-source match).** The rule is bucketed into `#perToolTtsrInjections`, keyed by the matched tool call's `id`. There is **no** deferred follow-up turn and the stream is not aborted. When the tool actually produces a result, the `afterToolCall` hook prepends a rendered `ttsr-tool-reminder.md` block to `ctx.result.content` (a single `text` block inserted ahead of the tool's own content), and persists a `ttsr_injection` entry with the consumed rule names. The template payload is:
```xml
<system-reminder reason="rule_violation" rule="{{name}}" path="{{path}}">
...
{{content}}
</system-reminder>
```
- **`source === "text"` / `"thinking"` (prose-source match).** Behavior is unchanged: the rule is queued in `#pendingTtsrInjections` and, after a successful non-error, non-aborted assistant message, `AgentSession` injects the hidden `ttsr-injection` custom message as a follow-up and schedules continuation.
Within a single matching batch, each rule is attached to exactly one sibling tool call — if multiple sibling tool calls would satisfy the same rule, deduplication picks one and the others are left untouched. Multiple distinct rules can still fold onto the same tool call.
#### Implications for tool authors and transcript readers
- The tool's own `toolResult` content is preserved verbatim; the reminder is **prepended** as an additional leading text block. Renderers that assume `content[0]` is the tool's primary output must scan past any block whose text begins with `<system-reminder reason="rule_violation"` (or filter on the wrapper tag) to find the real payload.
- The reminder is in-band on the tool result, not a separate `custom_message`/`ttsr-injection` entry. Transcript readers looking for non-interrupting TTSR activity on tool-source rules MUST inspect tool results (and the persisted `ttsr_injection` entry list), not just synthetic injection entries.
- A single tool result may carry reminders for several rules concatenated with a blank line between rendered templates.
- If the assistant message ends with `stopReason === "aborted"` or `"error"` before the matched tools run, the pending per-tool buckets are cleared — those rules are **not** persisted as injected and remain eligible to re-trigger on a future turn (subject to repeat policy).
## 5. Repeat policy and gap logic
@@ -169,7 +189,8 @@ Interactive mode uses `session.isTtsrAbortPending` to suppress showing the abort
In the current runtime path:
- interrupted injections append a hidden `custom_message` with `customType: "ttsr-injection"` and append a `ttsr_injection` entry via `appendTtsrInjection(...)`
- deferred non-interrupting injections are marked/persisted when their queued custom message reaches `message_end`
- deferred non-interrupting prose-source injections are marked/persisted when their queued custom message reaches `message_end`
- non-interrupting tool-source injections are marked at match time and persisted via `appendTtsrInjection(...)` from the `afterToolCall` hook when the matched tool's result is produced
- `createAgentSession()` restores `existingSession.injectedTtsrRules` into `ttsrManager`
Net effect: injected-rule suppression is persisted/restored across session reload/resume for the current branch path.
@@ -196,5 +217,6 @@ During the timer window, state can change (user interruption, mode actions, addi
- Duplicate rule names at capability layer: lower-priority duplicates are shadowed before registration.
- Duplicate names at manager layer: second registration is ignored.
- `contextMode: "keep"`: partial violating output can remain in context before reminder retry.
- `interruptMode: "never"` queues a deferred hidden injection after a successful assistant message rather than aborting mid-stream.
- `interruptMode: "never"`: prose-source matches queue a deferred hidden injection after a successful assistant message; tool-source matches fold an in-band `<system-reminder>` into the matched tool call's `toolResult` content via the `afterToolCall` hook (no mid-stream abort, no separate follow-up turn).
- Tool-source non-interrupting buckets are cleared when the parent assistant message ends with `stopReason === "aborted"` or `"error"`, so rules whose target tool never produced a result remain eligible to re-trigger.
- Repeat-after-gap depends on turn count increments at `turn_end`; mid-turn chunks do not advance gap counters.
+23 -6
View File
@@ -5,13 +5,12 @@
"packageManager": "bun@1.3.14",
"workspaces": {
"packages": [
"packages/*"
"packages/*",
"python/robomp/web"
],
"catalog": {
"@agentclientprotocol/sdk": "0.21.0",
"@anthropic-ai/sdk": "^0.94.0",
"@aws-sdk/client-bedrock-runtime": "^3.1043.0",
"@aws-sdk/credential-provider-node": "^3.972.39",
"@babel/generator": "^7.29.1",
"@babel/parser": "^7.29.3",
"@babel/traverse": "^7.29.0",
@@ -19,7 +18,6 @@
"@biomejs/biome": "^2.4.14",
"@bufbuild/protobuf": "^2.12.0",
"@bufbuild/protoc-gen-es": "^2.12.0",
"@google/genai": "^1.52.0",
"@mozilla/readability": "^0.6.0",
"@napi-rs/cli": "3.6.2",
"@oh-my-pi/omp-stats": "15.1.2",
@@ -33,8 +31,8 @@
"@opentelemetry/context-async-hooks": "^2.0.0",
"@opentelemetry/sdk-trace-base": "^2.0.0",
"@puppeteer/browsers": "^2.13.0",
"@smithy/node-http-handler": "^4.6.1",
"@tailwindcss/node": "^4.2.4",
"@tailwindcss/vite": "^4.2.4",
"@types/babel__generator": "^7.27.0",
"@types/babel__traverse": "^7.28.0",
"@types/bun": "^1.3.14",
@@ -60,16 +58,18 @@
"partial-json": "^0.1.7",
"postcss": "^8.5.14",
"prettier": "^3.8.3",
"proxy-agent": "^8.0.1",
"puppeteer-core": "^24.42.0",
"react": "19.2.5",
"react-chartjs-2": "^5.3.1",
"react-dom": "19.2.5",
"regexp-tree": "^0.1.27",
"solid-js": "^1.9.12",
"tailwindcss": "^4.2.4",
"turndown": "7.2.4",
"turndown-plugin-gfm": "1.0.2",
"typescript": "^6.0.3",
"vite": "^5.4.14",
"vite-plugin-solid": "^2.11.6",
"winston": "^3.19.0",
"winston-daily-rotate-file": "^5.0.0",
"zod": "4.4.3"
@@ -117,6 +117,23 @@
"stats:tools": "python3 scripts/session-stats/analyze.py tools",
"stats:edits": "python3 scripts/session-stats/analyze.py edits",
"stats:followups": "python3 scripts/session-stats/analyze.py followups",
"test:py": "python3 -m pytest -x python/omp-rpc/tests python/robomp/tests",
"robomp:install": "pip install -e 'python/robomp[dev]'",
"robomp:serve": "python3 -m robomp serve",
"robomp:test:integration": "ROBOMP_INTEGRATION=1 python3 -m pytest -x python/robomp/tests/test_worker_smoke.py",
"robomp:pi-artifacts": "docker build -t \"${PI_ARTIFACTS_IMAGE:-oh-my-pi/artifacts:dev}\" .",
"robomp:build": "bun run robomp:pi-artifacts && docker compose --project-directory python/robomp build",
"robomp:rebuild": "bun run robomp:pi-artifacts && docker compose --project-directory python/robomp build --no-cache",
"robomp:up": "docker compose --project-directory python/robomp up -d",
"robomp:down": "docker compose --project-directory python/robomp down",
"robomp:restart": "docker compose --project-directory python/robomp restart robomp",
"robomp:logs": "docker compose --project-directory python/robomp logs -f robomp",
"robomp:dev": "bun run robomp:build && bun run robomp:up && bun run robomp:logs",
"robomp:reset": "docker compose --project-directory python/robomp down -v && (docker image rm \"${PI_ARTIFACTS_IMAGE:-oh-my-pi/artifacts:dev}\" || true)",
"robomp:web:dev": "bun --cwd=python/robomp/web run dev",
"robomp:web:build": "bun --cwd=python/robomp/web run build",
"lint:py": "ruff check python && ruff format --check python",
"fix:py": "ruff check --fix python && ruff format python",
"prepublishOnly": "bun run check",
"prepare": "bun --cwd=packages/coding-agent run generate-docs-index",
"publish": "bun run prepublishOnly && npm publish -ws --access public",
+1 -1
View File
@@ -279,7 +279,7 @@ const agent = new Agent({
## Tools
Define tools using `AgentTool` with a Zod parameter schema (via `z` from `@oh-my-pi/pi-ai`). Legacy TypeBox-authored schemas are still accepted at runtime and are lifted to Zod internally.
Define tools using `AgentTool` with a Zod parameter schema (via `z` from `@oh-my-pi/pi-ai`).
```typescript
import { z } from "@oh-my-pi/pi-ai";
+1 -1
View File
@@ -14,7 +14,7 @@ import {
validateToolArguments,
zodToWireSchema,
} from "@oh-my-pi/pi-ai";
import { sanitizeText } from "@oh-my-pi/pi-natives";
import { sanitizeText } from "@oh-my-pi/pi-utils";
import {
createHarmonyAuditEvent,
type HarmonyDetection,
+15 -12
View File
@@ -139,9 +139,12 @@ interface ToolStart {
* begin (provider crash, tracer swap mid-run), the corresponding record is
* still emitted with `latencyMs: 0` rather than throwing.
*/
const kChatStart = Symbol("agent.run-collector.chatStart");
const kToolStart = Symbol("agent.run-collector.toolStart");
type SpanWithChatStart = Span & { [kChatStart]?: ChatStart };
type SpanWithToolStart = Span & { [kToolStart]?: ToolStart };
export class AgentRunCollector {
readonly #chatStarts = new WeakMap<Span, ChatStart>();
readonly #toolStarts = new WeakMap<Span, ToolStart>();
readonly #chats: ChatRecord[] = [];
readonly #tools: ToolRecord[] = [];
readonly #availableTools = new Set<string>();
@@ -179,12 +182,12 @@ export class AgentRunCollector {
init: { readonly stepNumber: number; readonly model: Model; readonly provider?: string },
): void {
const provider = init.provider ?? init.model.provider;
this.#chatStarts.set(span, {
(span as SpanWithChatStart)[kChatStart] = {
stepNumber: init.stepNumber,
startedAtMs: performance.now(),
model: init.model.id,
provider,
});
};
this.#modelsUsed.add(init.model.id);
if (provider) this.#providersUsed.add(provider);
}
@@ -197,8 +200,8 @@ export class AgentRunCollector {
readonly costUnavailableReason: string | undefined;
},
): void {
const start = this.#chatStarts.get(span);
this.#chatStarts.delete(span);
const start = (span as SpanWithChatStart)[kChatStart];
(span as SpanWithChatStart)[kChatStart] = undefined;
const usage = message.usage;
// Public surface: `inputTokens` is the total cost-bearing input the
// provider charged for, so it must include cache_read + cache_write.
@@ -237,8 +240,8 @@ export class AgentRunCollector {
* appear in the run summary.
*/
failChat(span: Span, fields: { readonly errorType: string }): void {
const start = this.#chatStarts.get(span);
this.#chatStarts.delete(span);
const start = (span as SpanWithChatStart)[kChatStart];
(span as SpanWithChatStart)[kChatStart] = undefined;
this.#chats.push({
stepNumber: start?.stepNumber ?? -1,
model: start?.model ?? "",
@@ -258,17 +261,17 @@ export class AgentRunCollector {
}
beginTool(span: Span, init: { readonly toolCallId: string; readonly toolName: string }): void {
this.#toolStarts.set(span, {
(span as SpanWithToolStart)[kToolStart] = {
toolCallId: init.toolCallId,
toolName: init.toolName,
startedAtMs: performance.now(),
});
};
this.#invokedTools.add(init.toolName);
}
endTool(span: Span, fields: { readonly status: ToolStatus; readonly errorType: string | undefined }): void {
const start = this.#toolStarts.get(span);
this.#toolStarts.delete(span);
const start = (span as SpanWithToolStart)[kToolStart];
(span as SpanWithToolStart)[kToolStart] = undefined;
this.#tools.push({
toolCallId: start?.toolCallId ?? "",
toolName: start?.toolName ?? "",
@@ -54,6 +54,8 @@ let provider: BasicTracerProvider;
let contextManager: AsyncLocalStorageContextManager;
beforeAll(() => {
trace.disable();
context.disable();
contextManager = new AsyncLocalStorageContextManager().enable();
context.setGlobalContextManager(contextManager);
provider = new BasicTracerProvider({ spanProcessors: [new SimpleSpanProcessor(exporter)] });
+3
View File
@@ -42,6 +42,8 @@ let provider: BasicTracerProvider;
let contextManager: AsyncLocalStorageContextManager;
beforeAll(() => {
trace.disable();
context.disable();
contextManager = new AsyncLocalStorageContextManager().enable();
context.setGlobalContextManager(contextManager);
provider = new BasicTracerProvider({ spanProcessors: [new SimpleSpanProcessor(exporter)] });
@@ -55,6 +57,7 @@ afterEach(() => {
afterAll(async () => {
await provider.shutdown();
context.disable();
trace.disable();
});
function identityConverter(messages: AgentMessage[]): Message[] {
+83
View File
@@ -1,6 +1,89 @@
# Changelog
## [Unreleased]
### Breaking Changes
- Changed `AuthBrokerClient.fetchSnapshot()` to return status-based results (`200` or `304`) instead of always returning a raw snapshot body, so callers now need to branch on `status`
- Renamed public schema utilities in `@oh-my-pi/pi-ai/utils/schema` by replacing `sanitizeSchemaForGoogle`, `sanitizeSchemaForCCA`, `prepareSchemaForCCA`, and `sanitizeSchemaForMCP` with `normalizeSchemaForGoogle`, `normalizeSchemaForCCA`, and `normalizeSchemaForMCP`
- Added MCP schema normalization via `normalizeSchemaForMCP` for compatibility checks
- Removed the `StringEnum` helper from `@oh-my-pi/pi-ai/utils/schema`. Use `z.enum([...])` directly; Zod's emitted JSON Schema is already wire-compatible with Google and other providers.
- Renamed the concrete SQLite credential store class from `AuthCredentialStore` to `SqliteAuthCredentialStore`. `AuthCredentialStore` is now the persistence interface implemented by both the SQLite store and the new `RemoteAuthCredentialStore`. Update `new AuthCredentialStore(db)` / `AuthCredentialStore.open(...)` call-sites to `SqliteAuthCredentialStore`; type-position uses (`store: AuthCredentialStore`) continue to work unchanged.
### Added
- Added `onAuthError` to `StreamOptions` and wired `streamSimple()` to retry once with a replacement API key when the first provider response is a 401 before any assistant events are emitted
- Added generation-aware snapshot metadata (`generation`, `serverNowMs`, `refresher`, and `rotatesInMs`) to auth-broker snapshot responses to support client-side credential-rotation planning
- Added `transport: "pi-native"` on `Model` and the matching `streamPiNative` client. When `model.transport === "pi-native"`, `streamSimple` short-circuits the per-provider dispatch and POSTs the canonical `Context` to the auth-gateway's `POST /v1/pi/stream` endpoint. The response is SSE-framed `AssistantMessageEvent`s parsed by `readSseJson` and pushed verbatim into the local `AssistantMessageEventStream` — no wire-format translation, no partial-stripping reconstruction. Used by containerized omp installs (robomp slots, swarm extension, etc.) to route every LLM call through a credential-holding sidecar; the slot itself never sees the real provider tokens. Server-controlled fields (`apiKey`, `signal`, `fetch`, lifecycle callbacks, the provider-session map) are stripped from the wire body — `apiKey` rides in the `Authorization` header as the gateway bearer.
- Added `POST /v1/pi/stream` to the auth-gateway. Same auth + abort + model-resolution + codex-compat + prefix-cache plumbing as the foreign-wire routes; only the wire-format translation is skipped. Request body is `{ modelId, context, options?, stream? }` where `context` is the canonical pi-ai `Context` and `options` is `SimpleStreamOptions` with non-serializable fields stripped. Response is SSE-framed `AssistantMessageEvent` (terminated by `data: [DONE]`) when streaming, or `{ message: AssistantMessage }` JSON when `stream: false`.
- Added Vertex AI authentication via Google Application Default Credentials from `GOOGLE_APPLICATION_CREDENTIALS`, `~/.config/gcloud/application_default_credentials.json`, or metadata server tokens, with token caching and refresh skew control via `GOOGLE_VERTEX_REFRESH_SKEW_MS`
- Added support for Anthropic image message parts with `type: "url"` and `type: "file"` sources
- Added `stopSequences` and `frequencyPenalty` to shared stream options and wired them through to OpenAI request translation
- Added optional request cancellation support to auth-broker interactions by propagating `AbortSignal` into health, snapshot, usage, and refresh calls
- Added `AuthStorage.setConfigApiKey` / `removeConfigApiKey` / `clearConfigApiKeys` for config-sourced per-provider bearers (e.g. `models.yml` `providers.<name>.apiKey`). The new tier sits between runtime `--api-key` and stored credentials in `getApiKey`/`peekApiKey` resolution, so a bearer pinned in config now beats the broker's OAuth access token. Also suppresses OAuth `account_uuid` attribution when active, since outbound auth is the explicit config bearer, not OAuth. `describeCredentialSource` reports `"config override (models.yml)"` for visibility.
- Added per-model `additional_rate_limits` parsing to `openaiCodexUsageProvider`. The Codex `wham/usage` endpoint surfaces a separate `GPT-5.3-Codex-Spark` rate limit (`metered_feature: codex_bengalfox`) on Pro accounts; these now emit dedicated `openai-codex:spark:{primary,secondary}` `UsageLimit` entries with `scope.tier = "spark"`, mirroring how Anthropic exposes `anthropic:7d:sonnet` separately from the umbrella `anthropic:7d` bucket. The osx-widgets client already keyed spark detection off `limit.id.includes("spark")`; this populates that contract end-to-end.
- Added `GET /v1/usage` to the auth-broker API to expose aggregated usage reports from `AuthStorage.fetchUsageReports`
- Added auth-broker usage polling response handling that returns normalized usage reports plus generation timestamp for clients (5-min per-credential cache via `AuthStorage`)
- Added the auth-broker subsystem (`@oh-my-pi/pi-ai/auth-broker`) for sharing OAuth credentials across machines without leaking refresh tokens.
- `startAuthBroker(...)` boots a `Bun.serve` HTTP server exposing `GET /v1/healthz`, `GET /v1/snapshot`, `POST /v1/credential` (upsert), `POST /v1/credential/:id/refresh`, and `POST /v1/credential/:id/disable`.
- `AuthBrokerClient` is the matching HTTP client used by remote clients.
- `RemoteAuthCredentialStore` is a client-side `AuthCredentialStore` that mirrors a broker snapshot in memory; mutating methods (`replace*`, `upsert*`, `delete*ForProvider`) throw because writes are server-side only.
- `AuthBrokerRefresher` is the background refresh loop that pre-refreshes credentials within `refreshSkewMs` and disables on definitive failure (`invalid_grant` / non-network 401-403).
- Added `AuthStorage.exportSnapshot()`, `AuthStorage.upsertCredential(provider, credential)`, `AuthStorage.forceRefreshCredentialById(id)`, and `AuthStorage.disableCredentialById(id, cause)` public methods consumed by the auth-broker server.
- Added `AuthStorageOptions.refreshOAuthCredential` override so a remote-store client can route every OAuth refresh through the broker instead of the local OAuth endpoint.
- Added `REMOTE_REFRESH_SENTINEL` (`"__remote__"`) — the wire placeholder substituted for OAuth refresh tokens in broker snapshots; clients never see the real refresh token.
- Exposed the OAuth provider catalog (`getOAuthProviders`, `OAuthProvider`, `OAuthProviderInfo`) and `refreshOAuthToken` through the package barrel so the coding-agent CLI can target them without reaching into `utils/oauth`.
- Added the auth-gateway subsystem (`@oh-my-pi/pi-ai/auth-gateway`) — a forward-proxy that sits between unauthenticated clients (the macOS usage widget, llm-git, robomp containers, …) and the broker. Clients send standard provider-format requests; the gateway parses them into omp's canonical `Context`, dispatches through pi-ai's `streamSimple()`, and translates the canonical event stream back to the matching wire format. `Authorization` is injected server-side so access tokens never leave the gateway host. Wire surface:
- `GET /healthz` — unauth liveness.
- `GET /v1/usage` — aggregated provider usage; 5-min per-credential cache via `AuthStorage.fetchUsageReports`.
- `GET /v1/models` — model catalog (scoped to providers with credentials).
- `POST /v1/chat/completions` — OpenAI chat-completions in/out.
- `POST /v1/messages` — Anthropic messages in/out (text + thinking + tool_use blocks, SSE event taxonomy preserved).
- `POST /v1/responses` — OpenAI Responses in/out (reasoning items + function_call output items, SSE pass-through).
- Added exports from `@oh-my-pi/pi-ai/auth-gateway`: `startAuthGateway`, `AuthGatewayServerOptions`, `AuthGatewayBootOptions`, `AuthGatewayServerHandle`, `ModelResolver`, `DEFAULT_AUTH_GATEWAY_BIND`. Per-format `parseRequest` / `encodeResponse` / `encodeStream` triples are reachable via the `./providers/*` subpath as `openai-chat-server`, `anthropic-messages-server`, and `openai-responses-server`.
- Added `listProvidersWithEnvKey()` to enumerate every provider with an env-var fallback (used by the new migrate command in coding-agent).
### Changed
- Changed `GET /v1/snapshot` to support generation-based polling with `If-None-Match` and `wait` for long-poll updates and to return `304` when no snapshot changes are available
- Changed Bedrock credential resolution for streaming calls to prefer environment keys, AWS profile/SSO credentials, and IMDSv2 fallback when available
- Changed auth-gateway parsing for OpenAI chat-completions and Responses to ignore unsupported SDK-only fields instead of rejecting requests
- Changed auth-gateway protocol handling to include CORS headers on responses and support browser-origin requests
- Changed prompt-cache handling to resolve cache keys from request metadata and headers and preserve them through protocol translation
- Changed Anthropic messages parsing to forward request `metadata` through to downstream execution
- Changed usage report caching to use a 5-minute per-credential TTL with jittered refresh timing to reduce usage endpoint rate-limit collisions
- Changed usage polling failure handling so transient errors continue serving the last known report instead of returning null and dropping the credential from usage aggregates after cache expiry
- Changed `sanitizeSchemaForGoogle` to normalize snake_case schema keys (such as `any_of` and `additional_properties`) to camelCase and auto-generate `propertyOrdering` for multi-property objects
- Changed strict-mode sanitization to resolve `$ref` nodes with sibling keys by inlining and merging referenced local definitions
- Changed strict-mode sanitization to flatten single-entry `allOf` nodes and remove the `allOf` wrapper
- Changed Anthropic tool schema normalization to preserve supported metadata keywords such as `$ref`, `$defs`, `$schema`, `enum`, `const`, `default`, `title`, and `nullable` instead of stripping them
- Changed string schema processing to retain only supported `format` values (`date-time`, `time`, `date`, `duration`, `email`, `hostname`, `uri`, `ipv4`, `ipv6`, `uuid`) and demote unsupported `format` values to `description` hints
### Fixed
- Fixed OAuth credential refresh flow so concurrent manual and background refreshes now share one in-flight attempt per credential, and `RemoteAuthCredentialStore` now re-synchronizes before using near-expiring OAuth credentials
- Fixed stale-credential handling after auth failures by waiting for updated broker snapshots and refreshing suspect credentials through broker endpoints before continuing
- Fixed Google Generative AI startup behavior to throw a clear API-key-required error when no key is configured
- Fixed AWS Bedrock image message serialization to preserve base64 `source.bytes` payloads instead of decoding and rebuilding them
- Fixed Google provider error handling to extract the API-reported `error.message` from JSON response bodies when available
- Fixed `RemoteAuthCredentialStore.getUsageReport` to return the matching credential-specific usage report and coalesce parallel callers into one broker `/v1/usage` fetch
- Fixed auth-broker credential upload validation to reject the remote refresh-token sentinel and prevent storing a non-refresh value
- Fixed OpenAI Responses streaming output to emit `reasoning_summary_text` events and parse/send `summary_text` reasoning payloads
- Fixed Anthropic stop-sequence handling by trimming requests to the API limit of four entries before forwarding
- Fixed prompt caching behavior across protocol translations so cached-token usage is preserved when Anthropic and OpenAI requests are routed through each other
- Fixed Claude usage fetching to retry transient `429` and `5xx` responses with exponential backoff, respecting `Retry-After` before returning failure
- Fixed auth-gateway request translation to preserve OpenAI Responses string/system message content, reasoning replay payloads, completed item text in stream item-done events, Anthropic tool-result ordering, and OpenAI Chat/Responses cached-token usage totals
- Fixed auth-gateway failure handling so unsupported request controls, upstream terminal errors, non-streaming aborts, and already-aborted client requests fail explicitly instead of being accepted, ignored, or encoded as successful HTTP 200 responses
- Fixed Gemini CLI / Antigravity tool schema normalization to run the full Cloud Code Assist pipeline, matching shared Google schema handling for union/object merging and nullable extraction
- Fixed stripped validation hints to be preserved as description spill text (`{key: value}` blocks) when `normalizeSchemaForGoogle` and `normalizeSchemaForCCA` drop unsupported schema keywords
- Fixed `sanitizeSchemaForGoogle` to collapse nullability forms (`type:'null'` and null-bearing `anyOf` variants) into `nullable` while preserving remaining variants
- Fixed `sanitizeSchemaForGoogle` to inline local `$defs` references instead of dropping `$ref`/`$defs` structure during Google schema sanitization
- Fixed `normalizeAnthropicToolSchema` to handle self-referential schemas without infinite recursion
- Fixed object schema normalization so explicit open-map declarations (`additionalProperties: true` and schema-valued `additionalProperties`) are preserved instead of being converted to closed objects
- Fixed unsupported schema constraints on arrays and strings (`maxItems`, `uniqueItems`, `pattern`, `minLength`, `maxLength`, and `minItems` when greater than 1) by demoting them into `description` rather than dropping them
### Security
- Hardened auth-gateway bearer-token checks with constant-time comparison to avoid timing-side-channel leaks
## [15.1.2] - 2026-05-15
### Breaking Changes
+3 -6
View File
@@ -89,7 +89,7 @@ npm install @oh-my-pi/pi-ai
## Quick Start
```typescript
import { z, getModel, stream, complete, Context, Tool, StringEnum } from "@oh-my-pi/pi-ai";
import { z, getModel, stream, complete, Context, Tool } from "@oh-my-pi/pi-ai";
// Fully typed with auto-complete support for both providers and models
const model = getModel("openai", "gpt-4o-mini");
@@ -221,7 +221,7 @@ Tools enable LLMs to interact with external systems. This library uses **Zod** s
### Defining Tools
```typescript
import { z, Tool, StringEnum } from "@oh-my-pi/pi-ai";
import { z, Tool } from "@oh-my-pi/pi-ai";
// Define tool parameters with Zod
const weatherTool: Tool = {
@@ -229,13 +229,10 @@ const weatherTool: Tool = {
description: "Get current weather for a location",
parameters: z.object({
location: z.string().describe("City name or coordinates"),
units: StringEnum(["celsius", "fahrenheit"], { default: "celsius" }),
units: z.enum(["celsius", "fahrenheit"]).default("celsius"),
}),
};
// Note: For Google API compatibility, use the StringEnum helper instead of z.enum alone
// when you need wire-compatible { type: "string", enum: [...] } shapes.
const bookMeetingTool: Tool = {
name: "book_meeting",
description: "Schedule a meeting",
+16 -6
View File
@@ -42,16 +42,10 @@
},
"dependencies": {
"@anthropic-ai/sdk": "catalog:",
"@aws-sdk/client-bedrock-runtime": "catalog:",
"@aws-sdk/credential-provider-node": "catalog:",
"@bufbuild/protobuf": "catalog:",
"@google/genai": "catalog:",
"@oh-my-pi/pi-natives": "catalog:",
"@oh-my-pi/pi-utils": "catalog:",
"@smithy/node-http-handler": "catalog:",
"openai": "catalog:",
"partial-json": "catalog:",
"proxy-agent": "catalog:",
"zod": "catalog:"
},
"devDependencies": {
@@ -74,6 +68,22 @@
"types": "./src/*.ts",
"import": "./src/*.ts"
},
"./auth-broker": {
"types": "./src/auth-broker/index.ts",
"import": "./src/auth-broker/index.ts"
},
"./auth-broker/*": {
"types": "./src/auth-broker/*.ts",
"import": "./src/auth-broker/*.ts"
},
"./auth-gateway": {
"types": "./src/auth-gateway/index.ts",
"import": "./src/auth-gateway/index.ts"
},
"./auth-gateway/*": {
"types": "./src/auth-gateway/*.ts",
"import": "./src/auth-gateway/*.ts"
},
"./models.json": {
"types": "./src/models.json.d.ts",
"import": "./src/models.json"
+3 -3
View File
@@ -11,7 +11,7 @@ const COPILOT_PREMIUM_MULTIPLIERS: Record<string, number> = {
import * as path from "node:path";
import { $env } from "@oh-my-pi/pi-utils";
import { AuthCredentialStore } from "../src/auth-storage";
import { SqliteAuthCredentialStore } from "../src/auth-storage";
import { createModelManager } from "../src/model-manager";
import {
applyGeneratedModelPolicies,
@@ -51,7 +51,7 @@ async function resolveProviderApiKey(providerId: string, catalog: CatalogDiscove
}
try {
const storage = await AuthCredentialStore.open();
const storage = await SqliteAuthCredentialStore.open();
try {
const storedApiKey = storage.getApiKey(providerId);
if (storedApiKey) {
@@ -214,7 +214,7 @@ const ANTIGRAVITY_ENDPOINT = "https://daily-cloudcode-pa.sandbox.googleapis.com"
async function getOAuthCredentialsFromStorage(provider: OAuthProvider): Promise<OAuthCredentials | null> {
try {
const storage = await AuthCredentialStore.open();
const storage = await SqliteAuthCredentialStore.open();
try {
const creds = storage.getOAuth(provider);
if (!creds) {
+261
View File
@@ -0,0 +1,261 @@
/**
* HTTP client for the omp auth-broker server.
*
* Used by {@link RemoteAuthCredentialStore} (snapshot pulls) and by
* `omp auth-broker status` (liveness checks). All endpoints except
* `/v1/healthz` require a bearer token.
*/
import type { ZodType, infer as zInfer } from "zod/v4";
import type { AuthCredential } from "../auth-storage";
import type {
CredentialDisableRequest,
CredentialDisableResponse,
CredentialRefreshResponse,
CredentialUploadRequest,
CredentialUploadResponse,
HealthzResponse,
SnapshotResponse,
UsageResponse,
} from "./types";
import {
credentialDisableResponseSchema,
credentialRefreshResponseSchema,
credentialUploadResponseSchema,
healthzResponseSchema,
snapshotResponseSchema,
usageResponseSchema,
} from "./wire-schemas";
export interface AuthBrokerClientOptions {
/** Base URL (e.g. `https://broker.tailnet:8765`). Trailing slashes are trimmed. */
url: string;
/** Bearer token used for everything except `healthz`. */
token: string;
/** Per-request timeout in milliseconds. Default 10s. */
timeoutMs?: number;
/** Retry connection errors this many times. Default 1. */
maxRetries?: number;
/** Override fetch (used in tests). Default global `fetch`. */
fetchImpl?: typeof fetch;
}
export class AuthBrokerError extends Error {
readonly status: number | undefined;
readonly body: string | undefined;
constructor(message: string, opts: { status?: number; body?: string; cause?: unknown } = {}) {
super(message, { cause: opts.cause });
this.name = "AuthBrokerError";
this.status = opts.status;
this.body = opts.body;
}
}
export interface FetchSnapshotOptions {
ifGenerationGt?: number;
waitMs?: number;
signal?: AbortSignal;
}
export type FetchSnapshotResult =
| { status: 200; snapshot: SnapshotResponse; generation: number }
| { status: 304; generation: number };
function parseGenerationTag(header: string | null): number | undefined {
if (!header) return undefined;
let value = header.trim();
if (value.startsWith("W/")) value = value.slice(2).trim();
if (value.startsWith('"') && value.endsWith('"') && value.length >= 2) {
value = value.slice(1, -1);
}
const generation = Number(value);
if (!Number.isInteger(generation) || generation < 0) return undefined;
return generation;
}
const DEFAULT_TIMEOUT_MS = 10_000;
const DEFAULT_MAX_RETRIES = 1;
export class AuthBrokerClient {
readonly #baseUrl: string;
readonly #token: string;
readonly #timeoutMs: number;
readonly #maxRetries: number;
readonly #fetch: typeof fetch;
constructor(opts: AuthBrokerClientOptions) {
this.#baseUrl = opts.url.replace(/\/+$/, "");
this.#token = opts.token;
this.#timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
this.#maxRetries = opts.maxRetries ?? DEFAULT_MAX_RETRIES;
this.#fetch = opts.fetchImpl ?? fetch;
}
healthz(signal?: AbortSignal): Promise<HealthzResponse> {
return this.#request("GET", "/v1/healthz", { schema: healthzResponseSchema, auth: false, signal });
}
async fetchSnapshot(opts: FetchSnapshotOptions = {}): Promise<FetchSnapshotResult> {
return this.#fetchSnapshotResult(opts);
}
async #fetchSnapshotResult(opts: FetchSnapshotOptions): Promise<FetchSnapshotResult> {
const query = new URLSearchParams();
if (opts.waitMs !== undefined) query.set("wait", String(opts.waitMs));
const path = `/v1/snapshot${query.size > 0 ? `?${query.toString()}` : ""}`;
const headers: Record<string, string> = {};
if (opts.ifGenerationGt !== undefined) headers["If-None-Match"] = `"${opts.ifGenerationGt}"`;
const timeoutMs =
opts.waitMs !== undefined && opts.waitMs > 0 ? Math.max(this.#timeoutMs, opts.waitMs + 1000) : undefined;
const response = await this.#fetchRaw("GET", path, {
auth: true,
headers,
signal: opts.signal,
timeoutMs,
});
const etagGeneration = parseGenerationTag(response.headers.get("etag"));
if (response.status === 304) {
return { status: 304, generation: etagGeneration ?? opts.ifGenerationGt ?? 0 };
}
const text = await response.text();
const raw = this.#parseJson(text, response.status);
const validated = snapshotResponseSchema.safeParse(raw);
if (!validated.success) {
throw new AuthBrokerError("Auth broker response failed schema validation", {
status: response.status,
body: validated.error.message,
});
}
const snapshot = validated.data as SnapshotResponse;
return { status: 200, snapshot, generation: etagGeneration ?? snapshot.generation };
}
fetchUsage(signal?: AbortSignal): Promise<UsageResponse> {
// Validates the envelope (`generatedAt`, `reports[].provider`, `limits`,
// `metadata`) but leaves provider-specific extension fields permissive so
// the broker can ship new shapes ahead of the client. `raw` is accepted
// but normally stripped by the broker before send.
return this.#request("GET", "/v1/usage", { schema: usageResponseSchema, signal }) as Promise<UsageResponse>;
}
async refreshCredential(id: number, signal?: AbortSignal): Promise<CredentialRefreshResponse> {
return this.#request("POST", `/v1/credential/${id}/refresh`, {
schema: credentialRefreshResponseSchema,
signal,
}) as Promise<CredentialRefreshResponse>;
}
async disableCredential(id: number, cause: string, signal?: AbortSignal): Promise<CredentialDisableResponse> {
const body: CredentialDisableRequest = { cause };
return this.#request("POST", `/v1/credential/${id}/disable`, {
body,
schema: credentialDisableResponseSchema,
signal,
});
}
async uploadCredential(
provider: string,
credential: AuthCredential,
signal?: AbortSignal,
): Promise<CredentialUploadResponse> {
const body: CredentialUploadRequest = { provider, credential };
return this.#request("POST", "/v1/credential", {
body,
schema: credentialUploadResponseSchema,
signal,
}) as Promise<CredentialUploadResponse>;
}
async #request<TSchema extends ZodType>(
method: "GET" | "POST",
path: string,
opts: { schema: TSchema; auth?: boolean; body?: unknown; signal?: AbortSignal },
): Promise<zInfer<TSchema>> {
const response = await this.#fetchRaw(method, path, opts);
const text = await response.text();
const raw = this.#parseJson(text, response.status);
const validated = opts.schema.safeParse(raw);
if (!validated.success) {
throw new AuthBrokerError("Auth broker response failed schema validation", {
status: response.status,
body: validated.error.message,
});
}
return validated.data;
}
#parseJson(text: string, status: number): unknown {
try {
return text.length === 0 ? null : JSON.parse(text);
} catch (parseError) {
throw new AuthBrokerError("Auth broker returned malformed JSON", {
status,
body: text,
cause: parseError,
});
}
}
async #fetchRaw(
method: "GET" | "POST",
path: string,
opts: {
auth?: boolean;
body?: unknown;
signal?: AbortSignal;
headers?: Record<string, string>;
timeoutMs?: number;
},
): Promise<Response> {
const auth = opts.auth ?? true;
const url = `${this.#baseUrl}${path}`;
const headers: Record<string, string> = { Accept: "application/json", ...(opts.headers ?? {}) };
if (auth) headers.Authorization = `Bearer ${this.#token}`;
let payload: string | undefined;
if (opts.body !== undefined) {
payload = JSON.stringify(opts.body);
headers["Content-Type"] = "application/json";
}
// Fast-fail when the caller's signal is already aborted — avoids spinning
// up a fetch + timer that the first `await` would just abort anyway.
if (opts.signal?.aborted) {
throw new AuthBrokerError("Auth broker request aborted", { cause: opts.signal.reason });
}
let lastError: unknown;
for (let attempt = 0; attempt <= this.#maxRetries; attempt += 1) {
const timeoutSignal = AbortSignal.timeout(opts.timeoutMs ?? this.#timeoutMs);
const signal = opts.signal ? AbortSignal.any([opts.signal, timeoutSignal]) : timeoutSignal;
try {
const response = await this.#fetch(url, {
method,
headers,
body: payload,
signal,
});
if (!response.ok && response.status !== 304) {
const text = await response.text();
throw new AuthBrokerError(`Auth broker request failed: ${response.status} ${response.statusText}`, {
status: response.status,
body: text,
});
}
return response;
} catch (error) {
lastError = error;
// Caller-driven abort wins over retry — the caller said stop.
if (opts.signal?.aborted) {
throw new AuthBrokerError("Auth broker request aborted", { cause: opts.signal.reason });
}
if (error instanceof AuthBrokerError && error.status !== undefined) {
// HTTP errors (4xx/5xx) don't retry — caller knows what to do.
throw error;
}
if (attempt >= this.#maxRetries) break;
}
}
throw new AuthBrokerError(`Auth broker request failed after ${this.#maxRetries + 1} attempt(s)`, {
cause: lastError,
});
}
}
+5
View File
@@ -0,0 +1,5 @@
export * from "./client";
export * from "./refresher";
export * from "./remote-store";
export * from "./server";
export * from "./types";
+127
View File
@@ -0,0 +1,127 @@
/**
* Background OAuth refresh loop for the auth-broker server.
*
* Iterates active OAuth credentials at `refreshIntervalMs` cadence, refreshing
* any whose `expires - Date.now() < refreshSkewMs`. Refresh single-flight
* lives in {@link AuthStorage} so manual and background refreshes share the
* same upstream attempt.
* Definitively-failed credentials (invalid_grant / 401 not from network blip)
* are disabled via {@link AuthStorage.disableCredentialById} so the next
* snapshot pull surfaces a clean delete on the client.
*/
import { logger } from "@oh-my-pi/pi-utils";
import type { AuthStorage } from "../auth-storage";
import { DEFAULT_REFRESH_INTERVAL_MS, DEFAULT_REFRESH_SKEW_MS } from "./types";
export interface AuthBrokerRefresherOptions {
storage: AuthStorage;
/** Refresh credentials expiring within this window. Default 5 min. */
refreshSkewMs?: number;
/** Loop cadence. Default 60s. */
refreshIntervalMs?: number;
/** Override clock (tests). */
now?: () => number;
}
const INVALID_GRANT_REGEX = /invalid_grant|invalid_token|revoked|unauthorized|expired.*refresh|refresh.*expired/i;
const TRANSIENT_REGEX = /timeout|network|fetch failed|ECONNREFUSED/i;
const HTTP_401_403_REGEX = /\b(401|403)\b/;
function isDefinitiveFailure(errorMsg: string): boolean {
if (INVALID_GRANT_REGEX.test(errorMsg)) return true;
if (HTTP_401_403_REGEX.test(errorMsg) && !TRANSIENT_REGEX.test(errorMsg)) return true;
return false;
}
export interface AuthBrokerRefresherSchedule {
enabled: boolean;
intervalMs: number;
skewMs: number;
nextSweepAt: number;
}
export class AuthBrokerRefresher {
readonly #storage: AuthStorage;
readonly #refreshSkewMs: number;
readonly #refreshIntervalMs: number;
readonly #now: () => number;
#timer: NodeJS.Timeout | undefined;
#running = false;
#nextSweepAt: number;
constructor(opts: AuthBrokerRefresherOptions) {
this.#storage = opts.storage;
this.#refreshSkewMs = opts.refreshSkewMs ?? DEFAULT_REFRESH_SKEW_MS;
this.#refreshIntervalMs = opts.refreshIntervalMs ?? DEFAULT_REFRESH_INTERVAL_MS;
this.#now = opts.now ?? Date.now;
this.#nextSweepAt = this.#now();
}
start(): void {
if (this.#timer !== undefined) return;
// Refresh sweep is best-effort; kick once immediately so freshly-booted
// brokers don't hand out near-expired tokens for the first interval.
this.#nextSweepAt = this.#now();
void this.tick();
this.#timer = setInterval(() => {
void this.tick();
}, this.#refreshIntervalMs);
}
stop(): void {
if (this.#timer !== undefined) {
clearInterval(this.#timer);
this.#timer = undefined;
}
}
getSchedule(): AuthBrokerRefresherSchedule {
return {
enabled: true,
intervalMs: this.#refreshIntervalMs,
skewMs: this.#refreshSkewMs,
nextSweepAt: this.#nextSweepAt,
};
}
/** Run one sweep. Exposed for tests. */
async tick(): Promise<void> {
if (this.#running) return;
this.#running = true;
this.#nextSweepAt = this.#now();
try {
await this.#storage.reload();
const snapshot = this.#storage.exportSnapshot();
const now = this.#now();
const deadline = now + this.#refreshSkewMs;
const targets: number[] = [];
for (const entry of snapshot.credentials) {
if (entry.credential.type !== "oauth") continue;
const expires = entry.credential.expires;
if (typeof expires !== "number" || !Number.isFinite(expires)) continue;
if (expires > deadline) continue;
targets.push(entry.id);
}
await Promise.all(targets.map(id => this.#refreshOne(id)));
} finally {
this.#running = false;
this.#nextSweepAt = this.#now() + this.#refreshIntervalMs;
}
}
async #refreshOne(id: number): Promise<void> {
try {
await this.#storage.refreshCredentialById(id);
} catch (error) {
const errorMsg = String(error);
if (isDefinitiveFailure(errorMsg)) {
logger.warn("auth-broker refresh failed definitively; disabling credential", {
id,
error: errorMsg,
});
this.#storage.disableCredentialById(id, `auth-broker refresh failed: ${errorMsg}`);
} else {
logger.debug("auth-broker refresh failed (transient)", { id, error: errorMsg });
}
}
}
}
+409
View File
@@ -0,0 +1,409 @@
/**
* Client-side {@link AuthCredentialStore} that mirrors a remote broker's
* snapshot. Refresh tokens never leave the broker; mutating methods (`replace*`,
* `upsert*`, `delete*ForProvider`) throw because login flows are server-side.
*
* Cache (`getCache`/`setCache`/`cleanExpiredCache`) is in-memory and ephemeral —
* usage reports cache TTL is 5 minutes per credential, so durability across
* runs isn't required.
*/
import { scheduler } from "node:timers/promises";
import { logger } from "@oh-my-pi/pi-utils";
import {
type AuthCredential,
type AuthCredentialStore,
type OAuthCredential,
REMOTE_REFRESH_SENTINEL,
type StoredAuthCredential,
} from "../auth-storage";
import type { Provider } from "../types";
import type { UsageReport } from "../usage";
import type { OAuthCredentials } from "../utils/oauth/types";
import type { AuthBrokerClient } from "./client";
import type { SnapshotResponse } from "./types";
/**
* Client-side TTL for the aggregate `/v1/usage` response. Set below the
* broker server's own 30s usage cache so we typically pick up the broker's
* cached value instead of re-walking the network — but high enough to absorb
* the parallel fan-out from `#rankOAuthSelections` into a single round-trip.
*/
const USAGE_CACHE_TTL_MS = 15_000;
const WAIT_THRESHOLD_MS = 1_000;
const MAX_WAIT_MS = 5_000;
const BACKGROUND_WAIT_MS = 30_000;
const BACKGROUND_BACKOFF_INITIAL_MS = 500;
const BACKGROUND_BACKOFF_MAX_MS = 30_000;
function emptySnapshot(): SnapshotResponse {
return {
generation: 0,
generatedAt: 0,
serverNowMs: 0,
refresher: {
enabled: false,
intervalMs: 0,
skewMs: 0,
nextSweepInMs: Number.MAX_SAFE_INTEGER,
},
credentials: [],
};
}
interface CacheEntry {
value: string;
expiresAtSec: number;
}
interface UsageCacheEntry {
reports: UsageReport[];
fetchedAt: number;
}
export interface RemoteAuthCredentialStoreOptions {
client: AuthBrokerClient;
/**
* Initial snapshot. When omitted, callers must call
* {@link RemoteAuthCredentialStore.refreshSnapshot} before the first read.
*/
initialSnapshot?: SnapshotResponse;
}
export class RemoteAuthCredentialStore implements AuthCredentialStore {
readonly #client: AuthBrokerClient;
#snapshot: SnapshotResponse = emptySnapshot();
#snapshotReceivedAt = Date.now();
#generation = 0;
#backgroundAbort = new AbortController();
#cache: Map<string, CacheEntry> = new Map();
#usageCache?: UsageCacheEntry;
#usageInflight?: Promise<UsageReport[] | null>;
#closed = false;
constructor(opts: RemoteAuthCredentialStoreOptions) {
this.#client = opts.client;
this.#applySnapshot(opts.initialSnapshot ?? emptySnapshot(), opts.initialSnapshot?.generation ?? 0);
void this.#runBackgroundLongPoll();
}
get client(): AuthBrokerClient {
return this.#client;
}
get snapshot(): SnapshotResponse {
return this.#snapshot;
}
#applySnapshot(snapshot: SnapshotResponse, generation: number): void {
this.#snapshot = snapshot;
this.#generation = generation;
this.#snapshotReceivedAt = Date.now();
}
async #runBackgroundLongPoll(): Promise<void> {
let backoffMs = BACKGROUND_BACKOFF_INITIAL_MS;
while (!this.#closed && !this.#backgroundAbort.signal.aborted) {
try {
const result = await this.#client.fetchSnapshot({
ifGenerationGt: this.#generation,
waitMs: BACKGROUND_WAIT_MS,
signal: this.#backgroundAbort.signal,
});
if (result.status === 200) this.#applySnapshot(result.snapshot, result.generation);
backoffMs = BACKGROUND_BACKOFF_INITIAL_MS;
} catch (error) {
if (this.#closed || this.#backgroundAbort.signal.aborted) break;
logger.debug("auth-broker background snapshot sync failed", { error: String(error) });
await scheduler.wait(backoffMs, { signal: this.#backgroundAbort.signal }).catch(() => {});
backoffMs = Math.min(BACKGROUND_BACKOFF_MAX_MS, backoffMs * 2);
}
}
}
/** Re-hydrate the in-memory snapshot from the broker. */
async refreshSnapshot(): Promise<SnapshotResponse> {
const result = await this.#client.fetchSnapshot();
if (result.status === 200) this.#applySnapshot(result.snapshot, result.generation);
return this.#snapshot;
}
listAuthCredentials(provider?: string): StoredAuthCredential[] {
const out: StoredAuthCredential[] = [];
for (const entry of this.#snapshot.credentials) {
if (provider !== undefined && entry.provider !== provider) continue;
out.push({
id: entry.id,
provider: entry.provider,
credential: entry.credential as AuthCredential,
disabledCause: null,
});
}
return out;
}
/**
* In-memory update from a successful refresh through the broker. AuthStorage
* calls this after `#replaceCredentialAt`; the broker already persisted the
* authoritative row, so we just mirror it.
*/
updateAuthCredential(id: number, credential: AuthCredential): void {
for (const entry of this.#snapshot.credentials) {
if (entry.id !== id) continue;
entry.credential = credential as typeof entry.credential;
return;
}
}
deleteAuthCredential(id: number, disabledCause: string): void {
const next = this.#snapshot.credentials.filter(entry => entry.id !== id);
this.#snapshot = { ...this.#snapshot, credentials: next };
// Fire-and-forget: tell the broker to persist the disable.
this.#client.disableCredential(id, disabledCause).catch(error => {
logger.warn("auth-broker disable propagation failed", { id, error: String(error) });
});
}
tryDisableAuthCredentialIfMatches(id: number, _expectedData: string, disabledCause: string): boolean {
const found = this.#snapshot.credentials.find(entry => entry.id === id);
if (!found) return false;
this.deleteAuthCredential(id, disabledCause);
return true;
}
async waitForFreshSnapshot(maxWaitMs: number, opts: { signal?: AbortSignal } = {}): Promise<boolean> {
const previousGeneration = this.#generation;
const result = await this.#client.fetchSnapshot({
ifGenerationGt: this.#generation,
waitMs: maxWaitMs,
signal: opts.signal,
});
if (result.status === 200) this.#applySnapshot(result.snapshot, result.generation);
return this.#generation !== previousGeneration;
}
async prepareForRequest(credentialId: number, opts: { signal?: AbortSignal } = {}): Promise<boolean> {
const entry = this.#snapshot.credentials.find(candidate => candidate.id === credentialId);
if (!entry || entry.credential.type !== "oauth" || entry.rotatesInMs === null) return false;
const remainingMs = this.#snapshotReceivedAt + entry.rotatesInMs - Date.now();
if (remainingMs > WAIT_THRESHOLD_MS) return false;
return this.waitForFreshSnapshot(MAX_WAIT_MS, opts);
}
async markCredentialSuspect(credentialId: number, opts: { signal?: AbortSignal } = {}): Promise<void> {
await this.#client.refreshCredential(credentialId, opts.signal);
await this.waitForFreshSnapshot(MAX_WAIT_MS, opts);
}
replaceAuthCredentialsForProvider(_provider: string, _credentials: AuthCredential[]): StoredAuthCredential[] {
throw new Error(
"RemoteAuthCredentialStore is read-only on the client. Use `omp auth-broker login <provider>` to mutate credentials.",
);
}
upsertAuthCredentialForProvider(_provider: string, _credential: AuthCredential): StoredAuthCredential[] {
throw new Error(
"RemoteAuthCredentialStore is read-only on the client. Use `omp auth-broker login <provider>` to mutate credentials.",
);
}
deleteAuthCredentialsForProvider(_provider: string, _disabledCause: string): void {
throw new Error(
"RemoteAuthCredentialStore is read-only on the client. Use `omp auth-broker logout <provider>` to mutate credentials.",
);
}
getCache(key: string): string | null {
const entry = this.#cache.get(key);
if (!entry) return null;
if (entry.expiresAtSec * 1000 <= Date.now()) {
this.#cache.delete(key);
return null;
}
return entry.value;
}
setCache(key: string, value: string, expiresAtSec: number): void {
this.#cache.set(key, { value, expiresAtSec });
}
cleanExpiredCache(): void {
const nowSec = Math.floor(Date.now() / 1000);
for (const [key, entry] of this.#cache) {
if (entry.expiresAtSec <= nowSec) this.#cache.delete(key);
}
}
/**
* Store-level hook consumed by `AuthStorage` — routes refresh through the
* broker so the actual refresh token never leaves the broker host. Returns
* the broker-redacted credential with {@link REMOTE_REFRESH_SENTINEL} in
* the `refresh` slot.
*/
async refreshOAuthCredential(
_provider: Provider,
credentialId: number,
_credential: OAuthCredential,
signal?: AbortSignal,
): Promise<OAuthCredentials> {
const { entry } = await this.#client.refreshCredential(credentialId, signal);
await this.refreshSnapshot().catch(error => {
logger.debug("auth-broker snapshot refresh after credential refresh failed", { error: String(error) });
});
if (entry.credential.type !== "oauth") {
throw new Error(`Broker returned non-OAuth credential for id=${credentialId}`);
}
const refreshed = entry.credential;
return {
access: refreshed.access,
refresh: REMOTE_REFRESH_SENTINEL,
expires: refreshed.expires,
accountId: refreshed.accountId,
email: refreshed.email,
projectId: refreshed.projectId,
enterpriseUrl: refreshed.enterpriseUrl,
};
}
/**
* Store-level hook consumed by `AuthStorage.fetchUsageReports()` — proxies
* to the broker's `/v1/usage` endpoint. The broker's egress IP isn't
* rate-limited by Anthropic's per-IP `/usage` cap the way a heavy
* residential laptop is, so all credentials surface every cycle.
*/
async fetchUsageReports(signal?: AbortSignal): Promise<UsageReport[] | null> {
return this.#raceWithSignal(this.#loadUsageReports(), signal);
}
/**
* Per-credential usage hook consumed by `AuthStorage.#getUsageReport`. Pulls
* the aggregate broker `/v1/usage` once and serves all callers from the
* same response (coalesced + cached), then matches the credential to a
* report by provider + identity (accountId / email / projectId).
*
* The broker already aggregates with its own 30s TTL on the server side; our
* 15s client TTL is below that so we usually re-use the broker's cache too.
*/
async getUsageReport(
provider: Provider,
credential: OAuthCredential,
signal?: AbortSignal,
): Promise<UsageReport | null> {
const reports = await this.#raceWithSignal(this.#loadUsageReports(), signal);
if (!reports) return null;
return matchUsageReport(reports, provider, credential);
}
/**
* Reject the awaited promise when the caller's signal aborts, without
* affecting the shared upstream fetch. Used to give each caller their
* own cancel without one caller's abort cascading into a peer's in-flight
* request through the single-flight `#usageInflight`.
*/
#raceWithSignal<T>(promise: Promise<T>, signal?: AbortSignal): Promise<T> {
if (!signal) return promise;
if (signal.aborted) return Promise.reject(new Error("auth-broker request aborted"));
return new Promise<T>((resolve, reject) => {
const onAbort = (): void => {
signal.removeEventListener("abort", onAbort);
reject(new Error("auth-broker request aborted"));
};
signal.addEventListener("abort", onAbort, { once: true });
promise.then(
value => {
signal.removeEventListener("abort", onAbort);
resolve(value);
},
err => {
signal.removeEventListener("abort", onAbort);
reject(err);
},
);
});
}
#loadUsageReports(): Promise<UsageReport[] | null> {
const cached = this.#usageCache;
if (cached && Date.now() - cached.fetchedAt < USAGE_CACHE_TTL_MS) {
return Promise.resolve(cached.reports);
}
if (this.#usageInflight) return this.#usageInflight;
const inflight = this.#client
.fetchUsage()
.then(body => {
this.#usageCache = { reports: body.reports, fetchedAt: Date.now() };
return body.reports;
})
.catch(error => {
logger.warn("auth-broker usage fetch failed", { error: String(error) });
return null;
})
.finally(() => {
this.#usageInflight = undefined;
});
this.#usageInflight = inflight;
return inflight;
}
close(): void {
if (this.#closed) return;
this.#closed = true;
this.#backgroundAbort.abort();
this.#cache.clear();
}
}
/**
* Match a broker-supplied usage report to a specific OAuth credential. The
* broker returns aggregate reports across all credentials it manages, so we
* pick the one whose identity (accountId / email / projectId) lines up with
* the credential the caller is asking about.
*
* Falls back to the lone candidate when only one matches the provider; falls
* through to `null` when nothing matches, which `AuthStorage` treats as "no
* usage data" (ranking proceeds without a usage signal for this credential).
*/
function matchUsageReport(reports: UsageReport[], provider: Provider, credential: OAuthCredential): UsageReport | null {
const candidates = reports.filter(report => report.provider === provider);
if (candidates.length === 0) return null;
if (candidates.length === 1) return candidates[0];
const accountId = credential.accountId?.trim().toLowerCase();
const email = credential.email?.trim().toLowerCase();
const projectId = credential.projectId?.trim().toLowerCase();
for (const report of candidates) {
if (reportMatchesIdentity(report, accountId, email, projectId)) return report;
}
return null;
}
function reportMatchesIdentity(
report: UsageReport,
accountId: string | undefined,
email: string | undefined,
projectId: string | undefined,
): boolean {
const metadata = (report.metadata ?? {}) as Record<string, unknown>;
if (accountId) {
const metaAccount = readMetadataString(metadata, "accountId") ?? readMetadataString(metadata, "account_id");
if (metaAccount && metaAccount.toLowerCase() === accountId) return true;
for (const limit of report.limits) {
if (limit.scope.accountId?.toLowerCase() === accountId) return true;
}
}
if (email) {
const metaEmail = readMetadataString(metadata, "email");
if (metaEmail && metaEmail.toLowerCase() === email) return true;
}
if (projectId) {
const metaProject = readMetadataString(metadata, "projectId") ?? readMetadataString(metadata, "project_id");
if (metaProject && metaProject.toLowerCase() === projectId) return true;
for (const limit of report.limits) {
if (limit.scope.projectId?.toLowerCase() === projectId) return true;
}
}
return false;
}
function readMetadataString(metadata: Record<string, unknown>, key: string): string | undefined {
const value = metadata[key];
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
}
+454
View File
@@ -0,0 +1,454 @@
/**
* Auth broker HTTP server.
*
* Wraps an {@link AuthStorage} (backed by a SQLite store on the broker host)
* and exposes a minimal REST API for snapshot pulls and explicit refresh /
* disable operations. Background refresh of expiring credentials lives in
* {@link AuthBrokerRefresher}.
*
* Transport security is delegated to the operator (Tailscale / Wireguard);
* the server only checks a bearer token against an allow-list per request.
*/
import { logger } from "@oh-my-pi/pi-utils";
import type { AuthStorage } from "../auth-storage";
import { parseBind } from "../utils/parse-bind";
import { AuthBrokerRefresher, type AuthBrokerRefresherSchedule } from "./refresher";
import type {
CredentialDisableResponse,
CredentialRefreshResponse,
CredentialUploadResponse,
HealthzResponse,
RefresherSchedule,
SnapshotEntry,
SnapshotResponse,
} from "./types";
import { DEFAULT_AUTH_BROKER_BIND, DEFAULT_REFRESH_INTERVAL_MS, DEFAULT_REFRESH_SKEW_MS } from "./types";
import { credentialDisableRequestSchema, credentialUploadRequestSchema } from "./wire-schemas";
export interface AuthBrokerServerOptions {
/** Underlying credential storage (wraps the local SQLite store on the broker). */
storage: AuthStorage;
/** Listen address; accepts `host:port` or just `port`. */
bind?: string;
/** Accept any of these bearer tokens. Empty disables auth (loopback only). */
bearerTokens: string[];
/** Broker version string surfaced on `/v1/healthz`. */
version?: string;
/** Refresh credentials expiring within this window. Default 5 min. */
refreshSkewMs?: number;
/** Background refresh cadence. Default 60s. */
refreshIntervalMs?: number;
/** Disable the background refresher (e.g. for tests). */
disableRefresher?: boolean;
}
export interface AuthBrokerServerHandle {
/** Bound URL (`http://host:port`). */
url: string;
port: number;
hostname: string;
close(): Promise<void>;
}
function json(status: number, body: unknown, headers?: Record<string, string>): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json", ...(headers ?? {}) },
});
}
function empty(status: number, headers?: Record<string, string>): Response {
return new Response(null, { status, headers });
}
function isAuthorized(req: Request, tokens: ReadonlySet<string>): boolean {
if (tokens.size === 0) return true;
const header = req.headers.get("authorization");
if (!header) return false;
const match = header.match(/^Bearer\s+(.+)$/i);
if (!match) return false;
return tokens.has(match[1].trim());
}
/**
* Parse + validate a JSON request body against a Zod schema. Returns a
* `Response` (400) on parse/validation failure so handlers can early-return.
* When `allowEmpty` is set, an empty request body is validated against `{}`.
*/
async function parseBody<T>(
req: Request,
schema: { safeParse(input: unknown): { success: true; data: T } | { success: false; error: { message: string } } },
options: { allowEmpty?: boolean } = {},
): Promise<{ ok: true; data: T } | { ok: false; response: Response }> {
let raw: string;
try {
raw = await req.text();
} catch (error) {
return { ok: false, response: json(400, { error: `Invalid request body: ${String(error)}` }) };
}
if (raw.length === 0 && !options.allowEmpty) {
return { ok: false, response: json(400, { error: "Request body required" }) };
}
let parsed: unknown;
try {
parsed = raw.length === 0 ? {} : JSON.parse(raw);
} catch (error) {
return { ok: false, response: json(400, { error: `Invalid JSON body: ${String(error)}` }) };
}
const result = schema.safeParse(parsed);
if (!result.success) {
return { ok: false, response: json(400, { error: result.error.message }) };
}
return { ok: true, data: result.data };
}
const REFRESH_ROUTE = /^\/v1\/credential\/(\d+)\/refresh$/;
const DISABLE_ROUTE = /^\/v1\/credential\/(\d+)\/disable$/;
const MAX_SNAPSHOT_WAIT_MS = 30_000;
const DISABLED_NEXT_SWEEP_IN_MS = Number.MAX_SAFE_INTEGER;
function snapshotHeaders(generation: number): Record<string, string> {
return {
ETag: `"${generation}"`,
"Cache-Control": "no-store",
};
}
function parseGenerationTag(header: string | null): number | undefined {
if (!header) return undefined;
let value = header.trim();
if (value.startsWith("W/")) value = value.slice(2).trim();
if (value.startsWith('"') && value.endsWith('"') && value.length >= 2) {
value = value.slice(1, -1);
}
const generation = Number(value);
if (!Number.isInteger(generation) || generation < 0) return undefined;
return generation;
}
function parseWaitMs(url: URL): number {
const raw = url.searchParams.get("wait");
if (raw === null) return 0;
const parsed = Number(raw);
if (!Number.isFinite(parsed)) return 0;
return Math.max(0, Math.min(MAX_SNAPSHOT_WAIT_MS, Math.trunc(parsed)));
}
function delayResult(ms: number): { promise: Promise<"timeout">; cancel: () => void } {
const done = Promise.withResolvers<"timeout">();
const timer = setTimeout(() => done.resolve("timeout"), ms);
timer.unref?.();
return {
promise: done.promise,
cancel: () => clearTimeout(timer),
};
}
class GenerationGate {
readonly #storage: AuthStorage;
readonly #unsubscribe: () => void;
#waiters: Map<number, Set<() => void>> = new Map();
constructor(storage: AuthStorage) {
this.#storage = storage;
this.#unsubscribe = storage.onGenerationChanged(generation => this.#wake(generation));
}
waitForChange(afterGeneration: number, signal: AbortSignal): Promise<"changed" | "aborted"> {
if (this.#storage.getGeneration() !== afterGeneration) return Promise.resolve("changed");
if (signal.aborted) return Promise.resolve("aborted");
const done = Promise.withResolvers<"changed" | "aborted">();
let settled = false;
const waiters = this.#waiters.get(afterGeneration) ?? new Set<() => void>();
this.#waiters.set(afterGeneration, waiters);
const cleanup = (): void => {
signal.removeEventListener("abort", onAbort);
waiters.delete(resolveChanged);
if (waiters.size === 0) this.#waiters.delete(afterGeneration);
};
const settle = (result: "changed" | "aborted"): void => {
if (settled) return;
settled = true;
cleanup();
done.resolve(result);
};
const resolveChanged = (): void => settle("changed");
const onAbort = (): void => settle("aborted");
waiters.add(resolveChanged);
signal.addEventListener("abort", onAbort, { once: true });
return done.promise;
}
close(): void {
this.#unsubscribe();
for (const waiters of this.#waiters.values()) {
for (const resolve of waiters) resolve();
}
this.#waiters.clear();
}
#wake(generation: number): void {
for (const [waitingFor, waiters] of [...this.#waiters]) {
if (generation <= waitingFor) continue;
for (const resolve of [...waiters]) resolve();
}
}
}
function resolveRefresherSchedule(
refresher: AuthBrokerRefresher | undefined,
serverNowMs: number,
): { wire: RefresherSchedule; nextSweepAt: number } {
if (!refresher) {
return {
wire: {
enabled: false,
intervalMs: 0,
skewMs: 0,
nextSweepInMs: DISABLED_NEXT_SWEEP_IN_MS,
},
nextSweepAt: DISABLED_NEXT_SWEEP_IN_MS,
};
}
const schedule: AuthBrokerRefresherSchedule = refresher.getSchedule();
return {
wire: {
enabled: schedule.enabled,
intervalMs: schedule.intervalMs,
skewMs: schedule.skewMs,
nextSweepInMs: Math.max(0, schedule.nextSweepAt - serverNowMs),
},
nextSweepAt: schedule.nextSweepAt,
};
}
function computeRotatesInMs(
entry: { credential: { type: string; expires?: number } },
schedule: RefresherSchedule,
nextSweepAt: number,
serverNowMs: number,
): number | null {
if (!schedule.enabled || entry.credential.type !== "oauth") return null;
const expires = entry.credential.expires;
if (typeof expires !== "number" || !Number.isFinite(expires)) return null;
if (!Number.isFinite(nextSweepAt) || !Number.isFinite(schedule.intervalMs) || schedule.intervalMs <= 0) return null;
const dueAt = expires - schedule.skewMs;
const eligibleAt = Math.max(serverNowMs, dueAt);
if (dueAt <= serverNowMs && nextSweepAt <= serverNowMs) return 0;
if (nextSweepAt >= eligibleAt) return Math.max(0, nextSweepAt - serverNowMs);
const steps = Math.ceil((eligibleAt - nextSweepAt) / schedule.intervalMs);
const rotatesAt = nextSweepAt + steps * schedule.intervalMs;
return Math.max(0, rotatesAt - serverNowMs);
}
function buildSnapshot(storage: AuthStorage, refresher: AuthBrokerRefresher | undefined): SnapshotResponse {
const serverNowMs = Date.now();
const base = storage.exportSnapshot();
const { wire, nextSweepAt } = resolveRefresherSchedule(refresher, serverNowMs);
const credentials: SnapshotEntry[] = base.credentials.map(entry => ({
...entry,
rotatesInMs: computeRotatesInMs(entry, wire, nextSweepAt, serverNowMs),
}));
return {
generation: base.generation,
generatedAt: base.generatedAt,
serverNowMs,
refresher: wire,
credentials,
};
}
async function serveSnapshot(
req: Request,
url: URL,
storage: AuthStorage,
gate: GenerationGate,
refresher: AuthBrokerRefresher | undefined,
peer: string,
): Promise<Response> {
await storage.reload();
let currentGeneration = storage.getGeneration();
const clientGeneration = parseGenerationTag(req.headers.get("if-none-match"));
const waitMs = parseWaitMs(url);
if (clientGeneration === undefined || currentGeneration !== clientGeneration || waitMs <= 0) {
const body = buildSnapshot(storage, refresher);
logger.info("auth-broker snapshot served", {
peer,
credentials: body.credentials.length,
generation: body.generation,
});
return json(200, body, snapshotHeaders(body.generation));
}
const delay = delayResult(waitMs);
const waitController = new AbortController();
const waitSignal = AbortSignal.any([req.signal, waitController.signal]);
const result = await Promise.race([gate.waitForChange(clientGeneration, waitSignal), delay.promise]);
delay.cancel();
waitController.abort();
if (result === "aborted" || req.signal.aborted) return empty(499, snapshotHeaders(currentGeneration));
await storage.reload();
currentGeneration = storage.getGeneration();
if (currentGeneration !== clientGeneration) {
const body = buildSnapshot(storage, refresher);
logger.info("auth-broker snapshot long-poll changed", {
peer,
credentials: body.credentials.length,
generation: body.generation,
});
return json(200, body, snapshotHeaders(body.generation));
}
logger.info("auth-broker snapshot long-poll unchanged", { peer, generation: currentGeneration });
return empty(304, snapshotHeaders(currentGeneration));
}
/** Boot the broker. Caller owns lifecycle; `handle.close()` to stop. */
export function startAuthBroker(opts: AuthBrokerServerOptions): AuthBrokerServerHandle {
const bind = parseBind(opts.bind ?? DEFAULT_AUTH_BROKER_BIND);
const tokens = new Set<string>(opts.bearerTokens);
const version = opts.version;
const refresher = opts.disableRefresher
? undefined
: new AuthBrokerRefresher({
storage: opts.storage,
refreshSkewMs: opts.refreshSkewMs ?? DEFAULT_REFRESH_SKEW_MS,
refreshIntervalMs: opts.refreshIntervalMs ?? DEFAULT_REFRESH_INTERVAL_MS,
});
refresher?.start();
const generationGate = new GenerationGate(opts.storage);
const server = Bun.serve({
hostname: bind.hostname,
port: bind.port,
fetch: async (req): Promise<Response> => {
const url = new URL(req.url);
const pathname = url.pathname;
const peer =
req.headers.get("x-forwarded-for")?.split(",")[0].trim() || req.headers.get("x-real-ip") || "unknown";
try {
if (req.method === "GET" && pathname === "/v1/healthz") {
const body: HealthzResponse = { ok: true, version };
return json(200, body);
}
if (!isAuthorized(req, tokens)) {
logger.info("auth-broker request unauthorized", { method: req.method, path: pathname, peer });
return json(401, { error: "unauthorized" });
}
if (req.method === "GET" && pathname === "/v1/snapshot") {
return serveSnapshot(req, url, opts.storage, generationGate, refresher, peer);
}
if (req.method === "GET" && pathname === "/v1/usage") {
try {
// AuthStorage caches usage reports internally with a 5-minute per-credential
// TTL (USAGE_REPORT_TTL_MS) so back-to-back widget polls re-use the
// last fetch instead of hitting provider endpoints repeatedly.
// `req.signal` propagates HTTP-client disconnects all the way to the
// per-caller cancel without touching the shared upstream fetch.
const reports = (await opts.storage.fetchUsageReports?.({ signal: req.signal })) ?? [];
// Drop the `raw` field — it's the provider-specific upstream body,
// large and unstable. Everything UI-relevant lives in `limits` and
// `metadata`.
const trimmed = reports.map(({ raw: _raw, ...rest }) => rest);
logger.info("auth-broker usage served", { peer, reports: trimmed.length });
return json(200, { generatedAt: Date.now(), reports: trimmed });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
logger.warn("auth-broker usage fetch failed", { peer, error: message });
return json(502, { error: message });
}
}
const refreshMatch = req.method === "POST" ? pathname.match(REFRESH_ROUTE) : null;
if (refreshMatch) {
const id = Number.parseInt(refreshMatch[1], 10);
try {
const entry = await opts.storage.refreshCredentialById(id, req.signal);
const body: CredentialRefreshResponse = { entry };
logger.info("auth-broker credential refreshed", {
id,
provider: entry.provider,
peer,
expires: entry.credential.type === "oauth" ? entry.credential.expires : undefined,
});
return json(200, body);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
logger.warn("auth-broker refresh failed", { id, peer, error: message });
const status = message.includes("No credential with id") ? 404 : 500;
return json(status, { error: message });
}
}
const disableMatch = req.method === "POST" ? pathname.match(DISABLE_ROUTE) : null;
if (disableMatch) {
const id = Number.parseInt(disableMatch[1], 10);
const parsed = await parseBody(req, credentialDisableRequestSchema, { allowEmpty: true });
if (!parsed.ok) return parsed.response;
const cause =
parsed.data.cause && parsed.data.cause.length > 0 ? parsed.data.cause : "disabled via auth-broker";
const ok = opts.storage.disableCredentialById(id, cause);
if (!ok) {
logger.info("auth-broker disable miss", { id, peer, cause });
return json(404, { error: `No credential with id=${id}` });
}
logger.info("auth-broker credential disabled", { id, peer, cause });
const response: CredentialDisableResponse = { ok: true };
return json(200, response);
}
if (req.method === "POST" && pathname === "/v1/credential") {
const parsed = await parseBody(req, credentialUploadRequestSchema);
if (!parsed.ok) return parsed.response;
const { provider, credential } = parsed.data;
try {
const entries = opts.storage.upsertCredential(provider, credential);
const identity =
credential.type === "oauth"
? (credential.email ?? credential.accountId ?? credential.projectId ?? "(no identity)")
: "(api key)";
logger.info("auth-broker credential upserted", {
provider,
type: credential.type,
identity,
peer,
providerTotal: entries.length,
});
const response: CredentialUploadResponse = { entries };
return json(200, response);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
logger.warn("auth-broker upload failed", { provider, peer, error: message });
return json(500, { error: message });
}
}
return json(404, { error: `No route: ${req.method} ${pathname}` });
} catch (error) {
logger.error("auth-broker handler crashed", {
method: req.method,
path: pathname,
error: String(error),
});
return json(500, { error: "internal error" });
}
},
});
const boundHost = server.hostname ?? bind.hostname;
const boundPort = server.port ?? bind.port;
return {
url: `http://${boundHost}:${boundPort}`,
port: boundPort,
hostname: boundHost,
close: async () => {
refresher?.stop();
generationGate.close();
server.stop(true);
},
};
}
+84
View File
@@ -0,0 +1,84 @@
/**
* Wire types shared between the auth-broker server and clients.
*
* The broker holds OAuth refresh tokens and exposes a redacted snapshot;
* clients use `access` tokens directly and call back to the broker when a
* credential expires or a 401 surfaces on a supposedly-fresh credential.
*/
import type { AuthCredential, AuthCredentialSnapshot, AuthCredentialSnapshotEntry } from "../auth-storage";
import type { UsageReport } from "../usage";
/** GET /v1/healthz response body. */
export interface HealthzResponse {
ok: boolean;
version?: string;
}
export interface RefresherSchedule {
enabled: boolean;
intervalMs: number;
skewMs: number;
nextSweepInMs: number;
}
export type SnapshotEntry = AuthCredentialSnapshotEntry & {
rotatesInMs: number | null;
};
/** GET /v1/snapshot response body. */
export interface SnapshotResponse extends Omit<AuthCredentialSnapshot, "credentials"> {
serverNowMs: number;
refresher: RefresherSchedule;
credentials: SnapshotEntry[];
}
/** GET /v1/usage response body — matches the local `AuthStorage.fetchUsageReports` shape. */
export interface UsageResponse {
generatedAt: number;
reports: UsageReport[];
}
/** POST /v1/credential/:id/refresh response body. */
export interface CredentialRefreshResponse {
entry: AuthCredentialSnapshotEntry;
}
/** POST /v1/credential/:id/disable request body. */
export interface CredentialDisableRequest {
cause: string;
}
/** POST /v1/credential/:id/disable response body. */
export interface CredentialDisableResponse {
ok: boolean;
}
/**
* POST /v1/credential request body. The OAuth `refresh` must be the *real*
* refresh token (not the sentinel) — the broker is the canonical writer.
*/
export interface CredentialUploadRequest {
provider: string;
credential: AuthCredential;
}
/** POST /v1/credential response body — redacted snapshot of the provider's rows after upsert. */
export interface CredentialUploadResponse {
entries: AuthCredentialSnapshotEntry[];
}
/**
* Default bearer-protected route prefix. The broker exposes `/v1/healthz`
* unauthenticated for liveness probes; everything else requires a bearer.
*/
export const AUTH_BROKER_API_PREFIX = "/v1";
/** Default port when none is configured. Loopback-only, no external exposure. */
export const DEFAULT_AUTH_BROKER_BIND = "127.0.0.1:8765";
/** Default broker→provider refresh skew. Refresh credentials this close to expiry. */
export const DEFAULT_REFRESH_SKEW_MS = 5 * 60_000;
/** Default broker refresh-loop cadence. */
export const DEFAULT_REFRESH_INTERVAL_MS = 60_000;
+162
View File
@@ -0,0 +1,162 @@
/**
* Zod schemas for the auth-broker wire protocol.
*
* Shared between the server (validates inbound request bodies) and the client
* (validates responses from the broker). Schemas mirror the TypeScript types
* in `./types.ts` 1:1; the types remain the source of truth for static typing,
* and `z.infer<typeof Schema>` is asserted-compatible with them where possible.
*
* Schemas use `.strict()` on objects with a closed set of fields so unknown
* keys are rejected — the previous implementation used a hand-rolled
* `hasOnlyFields` allowlist for the same effect.
*/
import * as z from "zod/v4";
import { REMOTE_REFRESH_SENTINEL } from "../auth-storage";
import { usageReportSchema } from "../usage";
// ─── Credential payloads ───────────────────────────────────────────────────
/** Real OAuth credential (broker-side) — refresh token is the actual upstream value. */
export const oauthCredentialSchema = z
.object({
type: z.literal("oauth"),
refresh: z
.string()
.min(1)
// Reject the sentinel literal on writes: if a client somehow round-trips
// a snapshot back into POST /v1/credential, accepting the sentinel as a
// real refresh token would silently break that credential's refresh
// forever (the broker would store `"__remote__"` and try to use it as
// the upstream refresh token).
.refine(value => value !== REMOTE_REFRESH_SENTINEL, {
message: `refresh token must not equal the remote sentinel (${REMOTE_REFRESH_SENTINEL})`,
}),
access: z.string().min(1),
expires: z.number(),
enterpriseUrl: z.string().optional(),
projectId: z.string().optional(),
email: z.string().optional(),
accountId: z.string().optional(),
})
.strict();
/** OAuth credential as it appears in broker snapshots — refresh replaced with sentinel. */
export const remoteOauthCredentialSchema = oauthCredentialSchema.extend({
refresh: z.literal(REMOTE_REFRESH_SENTINEL),
});
export const apiKeyCredentialSchema = z
.object({
type: z.literal("api_key"),
key: z.string().min(1),
})
.strict();
/** Discriminated union accepted on POST /v1/credential (writes). */
export const writableAuthCredentialSchema = z.discriminatedUnion("type", [
oauthCredentialSchema,
apiKeyCredentialSchema,
]);
/** Discriminated union returned in snapshots (refresh is sentinel for OAuth). */
export const snapshotCredentialSchema = z.discriminatedUnion("type", [
remoteOauthCredentialSchema,
apiKeyCredentialSchema,
]);
// ─── Snapshot ──────────────────────────────────────────────────────────────
export const credentialSnapshotEntrySchema = z
.object({
id: z.number().int(),
provider: z.string().min(1),
credential: snapshotCredentialSchema,
identityKey: z.string().nullable(),
})
.strict();
export const snapshotEntrySchema = credentialSnapshotEntrySchema
.extend({
rotatesInMs: z.number().nullable(),
})
.strict();
export const refresherScheduleSchema = z
.object({
enabled: z.boolean(),
intervalMs: z.number(),
skewMs: z.number(),
nextSweepInMs: z.number(),
})
.strict();
export const snapshotResponseSchema = z
.object({
generation: z.number().int(),
generatedAt: z.number(),
serverNowMs: z.number(),
refresher: refresherScheduleSchema,
credentials: z.array(snapshotEntrySchema),
})
.strict();
// ─── Healthz ────────────────────────────────────────────────────────────────
export const healthzResponseSchema = z
.object({
ok: z.boolean(),
version: z.string().optional(),
})
.strict();
// ─── Usage ─────────────────────────────────────────────────────────────────
/**
* Broker `/v1/usage` response. Reports are full {@link UsageReport}s minus the
* heavy provider-specific `raw` field (the server strips it before send) — we
* keep `raw` optional in the underlying schema so a misconfigured broker that
* forgot to strip still validates.
*/
export const usageResponseSchema = z
.object({
generatedAt: z.number(),
reports: z.array(usageReportSchema),
})
.strict();
// ─── Refresh ───────────────────────────────────────────────────────────────
export const credentialRefreshResponseSchema = z
.object({
entry: credentialSnapshotEntrySchema,
})
.strict();
// ─── Disable ───────────────────────────────────────────────────────────────
export const credentialDisableRequestSchema = z
.object({
cause: z.string().optional(),
})
.strict();
export const credentialDisableResponseSchema = z
.object({
ok: z.boolean(),
})
.strict();
// ─── Upload ────────────────────────────────────────────────────────────────
export const credentialUploadRequestSchema = z
.object({
provider: z.string().min(1),
credential: writableAuthCredentialSchema,
})
.strict();
export const credentialUploadResponseSchema = z
.object({
entries: z.array(credentialSnapshotEntrySchema),
})
.strict();
+194
View File
@@ -0,0 +1,194 @@
/**
* Shared HTTP helpers for the auth-gateway routes.
*
* Centralized so we share the same JSON shape, auth check,
* and peer-resolution logic.
*/
import { timingSafeEqual as nodeTimingSafeEqual } from "node:crypto";
const JSON_HEADERS = {
"Content-Type": "application/json",
"X-Content-Type-Options": "nosniff",
} as const;
export function json(status: number, body: unknown): Response {
return new Response(JSON.stringify(body) ?? "null", {
status,
headers: JSON_HEADERS,
});
}
export function resolvePeer(req: Request): string {
const fwd = req.headers.get("x-forwarded-for");
if (fwd) return fwd.split(",")[0].trim();
return req.headers.get("x-real-ip") ?? "unknown";
}
/**
* Constant-time byte comparison. Falls back to a manual XOR accumulator if
* `node:crypto.timingSafeEqual` isn't available. Always processes every byte
* of the longer input so length itself doesn't leak via timing.
*/
export function timingSafeEqual(a: Uint8Array, b: Uint8Array): boolean {
if (a.length === b.length && typeof nodeTimingSafeEqual === "function") {
return nodeTimingSafeEqual(a, b);
}
const len = Math.max(a.length, b.length);
let diff = a.length ^ b.length;
for (let i = 0; i < len; i++) {
// Out-of-range reads return undefined → coerce to 0 via `| 0`.
const av = (i < a.length ? a[i] : 0) | 0;
const bv = (i < b.length ? b[i] : 0) | 0;
diff |= av ^ bv;
}
return diff === 0;
}
const TOKEN_ENCODER = new TextEncoder();
export function isAuthorized(req: Request, tokens: ReadonlySet<string>): boolean {
if (tokens.size === 0) return true;
const header = req.headers.get("authorization");
if (!header) return false;
const match = header.match(/^Bearer\s+(.+)$/i);
if (!match) return false;
const presented = TOKEN_ENCODER.encode(match[1].trim());
// Iterate every allowed token regardless of early hits so the result
// timing reflects the full set, not the position of the match.
let ok = false;
for (const tok of tokens) {
const expected = TOKEN_ENCODER.encode(tok);
if (timingSafeEqual(presented, expected)) ok = true;
}
return ok;
}
/**
* Allow-list of inbound request headers that the gateway captures and forwards
* to the underlying parsers (which decide whether to surface them to the
* provider). Case-insensitive; `x-stainless-` is a prefix match.
*/
const PASSTHROUGH_HEADER_NAMES: Record<string, true> = {
"anthropic-beta": true,
"anthropic-version": true,
"openai-organization": true,
"openai-project": true,
"openai-beta": true,
// Codex / ChatGPT-OAuth backend headers (see openai-codex/constants.ts).
// `session_id` and `conversation_id` thread the upstream session so prompt
// caching and per-conversation rate limiting work; `chatgpt-account-id` and
// `originator` identify the calling account and client surface.
"chatgpt-account-id": true,
originator: true,
session_id: true,
conversation_id: true,
// Vendor-neutral cache-identity headers. The gateway also reads these to
// populate `options.promptCacheKey` (see `resolvePromptCacheKey` below)
// so explicit client hints win over the derived fallback.
"x-prompt-cache-key": true,
"x-session-id": true,
"x-conversation-id": true,
};
/**
* Extract allow-listed passthrough headers from an inbound request. Keys are
* lowercased; empty values are dropped. Called once per request in
* `handleFormatEndpoint`; parsers then read `options.headers`.
*/
export function captureRequestHeaders(headers: Headers): Record<string, string> {
const out: Record<string, string> = {};
headers.forEach((value, key) => {
if (!value) return;
const lower = key.toLowerCase();
if (PASSTHROUGH_HEADER_NAMES[lower] || lower.startsWith("x-stainless-")) {
out[lower] = value;
}
});
return out;
}
/**
* Priority order for resolving a client-supplied prompt-cache identity. The
* first non-empty value wins. When none are present, the gateway derives a
* stable UUID from the request's stable parts.
*/
const CACHE_KEY_HEADERS: readonly string[] = [
"x-prompt-cache-key",
"session_id",
"conversation_id",
"x-session-id",
"x-conversation-id",
];
function readBodyCacheKey(body: unknown): string | undefined {
if (body === null || typeof body !== "object") return undefined;
const root = body as Record<string, unknown>;
// Explicit body fields (OpenAI Responses / Chat).
const direct = root.prompt_cache_key;
if (typeof direct === "string" && direct.length > 0) return direct;
// Nested `metadata` (Codex CLI / Anthropic clients that route a session
// identifier through the metadata bag).
const metadata = root.metadata;
if (metadata === null || typeof metadata !== "object") return undefined;
const meta = metadata as Record<string, unknown>;
for (const field of ["prompt_cache_key", "session_id", "conversation_id"] as const) {
const v = meta[field];
if (typeof v === "string" && v.length > 0) return v;
}
return undefined;
}
/**
* Resolve a prompt-cache identity from inbound request body + headers.
* Order of precedence (first wins):
* 1. Body `prompt_cache_key`
* 2. Body `metadata.{prompt_cache_key,session_id,conversation_id}`
* 3. Header `x-prompt-cache-key`
* 4. Header `session_id` / `conversation_id` (Codex / ChatGPT-OAuth surface)
* 5. Header `x-session-id` / `x-conversation-id` (common informal)
* Returns undefined when none present; the gateway then derives a stable
* UUID from the request's stable parts.
*/
export function resolvePromptCacheKey(body: unknown, headers?: Headers): string | undefined {
const fromBody = readBodyCacheKey(body);
if (fromBody) return fromBody;
if (!headers) return undefined;
for (const name of CACHE_KEY_HEADERS) {
const v = headers.get(name);
if (v && v.length > 0) return v;
}
return undefined;
}
const CORS_HEADERS: Record<string, string> = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers":
"authorization, content-type, anthropic-version, anthropic-beta, openai-organization, openai-project, x-stainless-*, x-api-key",
"Access-Control-Max-Age": "86400",
};
/**
* CORS headers for the auth-gateway. Currently echoes a wildcard origin; the
* request is accepted so future tightening can mirror `Origin` without
* threading the request through every caller.
*/
export function corsHeaders(_req: Request): Record<string, string> {
return { ...CORS_HEADERS };
}
/**
* Re-emit `response` with CORS headers merged. The original response body is
* passed through unchanged. Used by the gateway wrapper so every outbound
* format-endpoint response carries the same CORS surface as the preflight.
*/
export function withCors(response: Response, req: Request): Response {
const headers = new Headers(response.headers);
const cors = corsHeaders(req);
for (const k in cors) headers.set(k, cors[k]);
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers,
});
}
+3
View File
@@ -0,0 +1,3 @@
export * from "./http";
export * from "./server";
export * from "./types";
+651
View File
@@ -0,0 +1,651 @@
/**
* omp auth-gateway HTTP server.
*
* Accepts any provider-format request (OpenAI chat-completions, Anthropic
* messages, OpenAI Responses) and dispatches through pi-ai's `streamSimple()`
* — which handles credential injection, anthropic-beta headers, codex
* websocket transport, and all the per-provider intricacies. The gateway is
* pure protocol translation: foreign wire → omp Context → pi-ai stream() →
* omp events → foreign wire.
*
* Endpoints:
* GET /healthz → unauth; ok + version
* GET /v1/usage → aggregated provider usage (5-min per-credential cache via AuthStorage)
* GET /v1/models → list known models from the registry
* POST /v1/chat/completions → OpenAI chat-completions in/out
* POST /v1/messages → Anthropic messages in/out
* POST /v1/responses → OpenAI Responses in/out
*/
import { logger } from "@oh-my-pi/pi-utils";
import type { AuthStorage } from "../auth-storage";
import { Effort } from "../model-thinking";
import * as anthropicMessages from "../providers/anthropic-messages-server";
import * as openaiChat from "../providers/openai-chat-server";
import * as openaiResponses from "../providers/openai-responses-server";
import * as piNative from "../providers/pi-native-server";
import { streamSimple } from "../stream";
import type { Api, AssistantMessageEventStream, Context, Model, SimpleStreamOptions } from "../types";
import { parseBind } from "../utils/parse-bind";
import { captureRequestHeaders, corsHeaders, isAuthorized, json, resolvePeer, withCors } from "./http";
import type {
AuthGatewayServerHandle,
AuthGatewayServerOptions,
AuthGatewayFormatModule as FormatModule,
AuthGatewayParsedRequest as ParsedFormatRequest,
} from "./types";
import { DEFAULT_AUTH_GATEWAY_BIND } from "./types";
// ParsedFormatRequest / ParsedFormatOptions / FormatModule come from ./types.
export type ModelResolver = (modelId: string) => Model<Api> | undefined;
export interface AuthGatewayBootOptions extends AuthGatewayServerOptions {
/** Source of credentials. Caller wires this to a broker-backed AuthStorage. */
storage: AuthStorage;
/**
* Resolve a client-requested model id to a pi-ai Model. Caller supplies
* this from a ModelRegistry (lives in `coding-agent` to avoid an inverse
* dependency in `pi-ai`).
*/
resolveModel: ModelResolver;
/** Optional supplier for `/v1/models` listing. Returns the full model array. */
listModels?: () => Iterable<Model<Api>>;
}
// `parseBind` lives in ../utils/parse-bind so the gateway and broker can't
// drift on accepted inputs (e.g. empty hostname, IPv6 brackets).
const FORMAT_ROUTES: Record<string, { module: FormatModule; label: string }> = {
"/v1/chat/completions": { module: openaiChat, label: "openai-chat" },
"/v1/messages": { module: anthropicMessages, label: "anthropic-messages" },
"/v1/responses": { module: openaiResponses, label: "openai-responses" },
};
// (passthrough fast-path removed — it bypassed pi-ai provider logic, in
// particular the Anthropic Claude-Code OAuth system-prompt prefix injection.
// Every request now takes the translate path so credential-specific request
// shaping always applies.)
// Options the caller's wire format may carry but the resolved provider can't
// honour are dropped silently in `buildStreamOptions`. We used to 400 here
// (`Unsupported option: temperature for openai-codex-responses`), but every
// realistic client (llm-git, openai SDK, anthropic SDK) bakes some of these
// defaults in without knowing which model they'll resolve to. Failing loudly
// just turned that into per-call config hell. Silent strip is what the
// upstream provider would do anyway when it ignores extra fields.
/**
* Derive a stable cache identity from the parts of the request that don't
* change turn-to-turn within a logical conversation: model id, system prompt,
* tool definitions, and the first message (the conversation seed). Codex-class
* backends only cache prefixes when an explicit `prompt_cache_key` is set;
* without one, two requests with the same prefix but different trailing
* messages don't coalesce. This bridges Anthropic-style clients (which signal
* caching via `cache_control` markers rather than an opaque key) to Codex's
* keyed model so cross-protocol caching "just works".
*
* Including the first message scopes the key to one logical conversation:
* two different chats with the same system prompt no longer share a cache
* bucket and can't trample each other's prefix-tree entries.
*
* Anthropic-backed requests ignore `sessionId`; the key is harmless there.
*/
function deriveSessionId(modelId: string, context: Context): string {
const parts: string[] = [modelId];
if (context.systemPrompt && context.systemPrompt.length > 0) {
parts.push(context.systemPrompt.join("\n\n"));
}
if (context.tools && context.tools.length > 0) {
parts.push(JSON.stringify(context.tools));
}
const first = context.messages?.[0];
if (first) {
// Strip timestamp / provider metadata so the hash is stable across turns
// of the same conversation (omp re-stamps every parsed Message). role +
// content is what's actually on the wire.
parts.push(JSON.stringify({ role: first.role, content: first.content }));
}
const seed = parts.join("\u0000");
const hex = new Bun.CryptoHasher("sha256").update(seed).digest("hex");
// Format the leading 128 bits as a v4-shape UUID (8-4-4-4-12). Codex's
// `normalizeOpenAIResponsesPromptCacheKey` accepts ≤64 chars verbatim, so
// the 36-char UUID flows through unchanged.
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
}
function buildStreamOptions(parsed: ParsedFormatRequest, api: Api, signal: AbortSignal): SimpleStreamOptions {
const opts: SimpleStreamOptions = { signal };
const { options } = parsed;
// Codex backend rejects `temperature` / `top_p` (per-model defaults only),
// so we drop them silently for that one provider. Every other unsupported
// option is just ignored by `streamSimple` if the underlying provider
// doesn't honour it.
const isCodex = api === "openai-codex-responses";
if (options.maxOutputTokens !== undefined) opts.maxTokens = options.maxOutputTokens;
if (options.temperature !== undefined && !isCodex) opts.temperature = options.temperature;
if (options.topP !== undefined && !isCodex) opts.topP = options.topP;
if (options.topK !== undefined) opts.topK = options.topK;
if (options.minP !== undefined) opts.minP = options.minP;
if (options.stopSequences !== undefined) opts.stopSequences = options.stopSequences;
if (options.presencePenalty !== undefined) opts.presencePenalty = options.presencePenalty;
if (options.frequencyPenalty !== undefined) opts.frequencyPenalty = options.frequencyPenalty;
if (options.repetitionPenalty !== undefined) opts.repetitionPenalty = options.repetitionPenalty;
if (options.metadata !== undefined) opts.metadata = options.metadata;
if (options.headers !== undefined) opts.headers = { ...(opts.headers ?? {}), ...options.headers };
if (options.toolChoice !== undefined) {
opts.toolChoice =
typeof options.toolChoice === "object" ? { type: "tool", name: options.toolChoice.name } : options.toolChoice;
}
if (options.reasoning !== undefined) opts.reasoning = options.reasoning;
if (options.disableReasoning !== undefined) opts.disableReasoning = options.disableReasoning;
if (options.hideThinkingSummary !== undefined) opts.hideThinkingSummary = options.hideThinkingSummary;
if (options.serviceTier !== undefined) opts.serviceTier = options.serviceTier;
if (options.cacheRetention !== undefined) opts.cacheRetention = options.cacheRetention;
// Client-supplied `prompt_cache_key` wins; otherwise derive a stable
// key from the model + system + tools so prefix caching engages on
// Codex-class backends across turns of the same logical conversation.
opts.sessionId = options.promptCacheKey ?? deriveSessionId(parsed.modelId, parsed.context);
if (options.thinkingBudgets) {
opts.thinkingBudgets = { ...(opts.thinkingBudgets ?? {}), ...options.thinkingBudgets };
}
if (options.explicitThinkingBudgetTokens !== undefined) {
// Mirror Rust's `resolve_thinking_budget`: explicit budget pins onto
// whichever effort the client requested (or High when unspecified) and
// ALSO sets the effort so providers that gate on `reasoning` actually
// surface the budget.
const effort = options.reasoning ?? Effort.High;
opts.thinkingBudgets = {
...(opts.thinkingBudgets ?? {}),
[effort]: options.explicitThinkingBudgetTokens,
};
opts.reasoning ??= effort;
}
// Fields that don't yet have a matching pi-ai `SimpleStreamOptions` slot.
// Surfaced once in debug logs so they show up when wiring a new provider,
// but NEVER widened into `options.extra` — every consumer would have to
// re-implement the typed parse to read them back out.
// TODO(pi-ai): land first-class fields and replace these blocks.
if (
options.parallelToolCalls !== undefined ||
options.previousResponseId !== undefined ||
options.seed !== undefined ||
options.logitBias !== undefined ||
options.user !== undefined ||
options.responseFormat !== undefined
) {
logger.debug("auth-gateway dropped unsupported typed options", {
api,
parallelToolCalls: options.parallelToolCalls,
previousResponseId: options.previousResponseId,
seed: options.seed,
hasLogitBias: options.logitBias !== undefined,
user: options.user,
hasResponseFormat: options.responseFormat !== undefined,
});
}
return opts;
}
/**
* Classify an upstream / gateway-internal error into a status code and a
* provider-style error type tag. Used by `handleFormatEndpoint` /
* `handlePassthrough` to drive `route.module.formatError` so every wire
* format emits its native envelope shape.
*/
function classifyGatewayError(err: unknown): { status: number; type: string; message: string } {
const message = err instanceof Error ? err.message : String(err);
const lower = message.toLowerCase();
// Custom pi-ai errors may attach a numeric `status` property; honor it
// when present and pick the matching tag.
const statusProp =
typeof err === "object" && err !== null && typeof (err as { status?: unknown }).status === "number"
? (err as { status: number }).status | 0
: undefined;
if (statusProp !== undefined) {
if (statusProp === 401 || statusProp === 403)
return { status: statusProp, type: "authentication_error", message };
if (statusProp === 429) return { status: 429, type: "rate_limit_error", message };
if (statusProp >= 400 && statusProp < 500) return { status: statusProp, type: "invalid_request_error", message };
if (statusProp >= 500) return { status: statusProp, type: "upstream_error", message };
}
if (err instanceof Error && err.name === "AbortError") return { status: 499, type: "request_aborted", message };
if (lower.includes("aborted") || lower.includes("abortsignal")) {
return { status: 499, type: "request_aborted", message };
}
if (
lower.includes("401") ||
lower.includes("403") ||
lower.includes("unauthorized") ||
lower.includes("forbidden")
) {
return { status: 401, type: "authentication_error", message };
}
if (lower.includes("429") || lower.includes("rate") || lower.includes("quota")) {
return { status: 429, type: "rate_limit_error", message };
}
if (lower.includes("unsupported") || lower.includes("invalid")) {
return { status: 400, type: "invalid_request_error", message };
}
return { status: 502, type: "upstream_error", message };
}
function clientClosedResponse(route: { module: FormatModule }): Response {
return route.module.formatError(499, "request_aborted", "client closed request");
}
function mirrorRequestAbort(req: Request): AbortController {
const controller = new AbortController();
if (req.signal.aborted) {
controller.abort(req.signal.reason);
} else {
req.signal.addEventListener("abort", () => controller.abort(req.signal.reason), { once: true });
}
return controller;
}
// (handlePassthrough removed — see note above.)
async function handleFormatEndpoint(
route: { module: FormatModule; label: string },
bootOpts: AuthGatewayBootOptions,
req: Request,
peer: string,
): Promise<Response> {
const controller = mirrorRequestAbort(req);
if (controller.signal.aborted) return clientClosedResponse(route);
let body: unknown;
try {
body = await req.json();
} catch (error) {
if (controller.signal.aborted) return clientClosedResponse(route);
return route.module.formatError(400, "invalid_request_error", `Invalid JSON body: ${String(error)}`);
}
if (controller.signal.aborted) return clientClosedResponse(route);
// All three supported wire formats put the model id on a top-level `model`
// field. Read it without running the full strict schema so the route can
// produce a coherent error envelope when the model id is missing.
const modelId =
typeof body === "object" && body !== null && typeof (body as { model?: unknown }).model === "string"
? (body as { model: string }).model
: undefined;
if (!modelId) {
return route.module.formatError(400, "invalid_request_error", "Missing top-level `model` field");
}
const model = bootOpts.resolveModel(modelId);
if (!model) {
return route.module.formatError(404, "invalid_request_error", `Unknown model: ${modelId}`);
}
// pi-ai's stream() does NOT consult AuthStorage — the caller (us) is
// expected to resolve the credential and pass it as `options.apiKey`.
// For OAuth providers this returns the access token (refreshed via the
// broker override on AuthStorage when needed).
let apiKey: string | undefined;
try {
apiKey = await bootOpts.storage.getApiKey(model.provider, undefined, {
modelId: model.id,
signal: controller.signal,
});
} catch (error) {
if (controller.signal.aborted) return clientClosedResponse(route);
const classified = classifyGatewayError(error);
logger.warn("auth-gateway getApiKey threw", { provider: model.provider, peer, error: classified.message });
return route.module.formatError(classified.status, classified.type, classified.message);
}
if (controller.signal.aborted) return clientClosedResponse(route);
if (!apiKey) {
return route.module.formatError(
401,
"authentication_error",
`No credential available for provider ${model.provider}`,
);
}
// Parse + validate against the strict format schema, rebuild as omp's
// canonical Context, dispatch through pi-ai's streamSimple, encode the
// canonical event stream back to the inbound format. There is no
// passthrough fast-path — every request flows through pi-ai so that
// credential-specific request shaping (OAuth Claude-Code prefix, beta
// headers, codex websocket transport, …) always applies.
let parsed: ParsedFormatRequest;
try {
parsed = route.module.parseRequest(body, req.headers);
} catch (error) {
if (controller.signal.aborted) return clientClosedResponse(route);
const message = error instanceof Error ? error.message : String(error);
return route.module.formatError(400, "invalid_request_error", message);
}
// Merge gateway-captured passthrough headers under the parser's own
// captures. Parsers that set `options.headers` themselves win (they may
// have stripped or normalized values); the gateway's allow-list fills in
// anything they didn't touch.
{
const captured = captureRequestHeaders(req.headers);
parsed.options.headers = { ...captured, ...(parsed.options.headers ?? {}) };
}
if (controller.signal.aborted) return clientClosedResponse(route);
const streamOpts = buildStreamOptions(parsed, model.api, controller.signal);
streamOpts.apiKey = apiKey;
logger.info("auth-gateway request", {
format: route.label,
model: parsed.modelId,
resolvedProvider: model.provider,
resolvedModel: model.id,
stream: parsed.stream,
peer,
});
let events: AssistantMessageEventStream;
try {
if (controller.signal.aborted) return clientClosedResponse(route);
events = streamSimple(model, parsed.context, streamOpts);
} catch (error) {
const classified = classifyGatewayError(error);
logger.warn("auth-gateway streamSimple threw", { format: route.label, error: classified.message, peer });
return route.module.formatError(classified.status, classified.type, classified.message);
}
if (!parsed.stream) {
try {
if (controller.signal.aborted) return clientClosedResponse(route);
const message = await events.result();
if (message.stopReason === "aborted" || message.stopReason === "error") {
const errorMessage =
message.errorMessage ??
(message.stopReason === "aborted" ? "Request was aborted" : "Upstream request failed");
logger.warn("auth-gateway non-streaming failed", {
format: route.label,
reason: message.stopReason,
error: errorMessage,
peer,
});
if (message.stopReason === "aborted") {
return route.module.formatError(499, "request_aborted", errorMessage);
}
const classified = classifyGatewayError(new Error(errorMessage));
return route.module.formatError(classified.status, classified.type, errorMessage);
}
return json(200, route.module.encodeResponse(message, parsed.modelId));
} catch (error) {
if (controller.signal.aborted) return clientClosedResponse(route);
const classified = classifyGatewayError(error);
logger.warn("auth-gateway non-streaming aborted", {
format: route.label,
error: classified.message,
peer,
});
return route.module.formatError(classified.status, classified.type, classified.message);
}
}
if (controller.signal.aborted) return clientClosedResponse(route);
const sseStream = route.module.encodeStream(events, parsed.modelId, parsed.options);
return new Response(sseStream, {
status: 200,
headers: {
"Content-Type": "text/event-stream; charset=utf-8",
"Cache-Control": "no-cache",
Connection: "keep-alive",
// Disable proxy buffering (nginx and ingress controllers honor this).
// Without it the SSE stream gets held until the buffer flushes, which
// stalls the long-thinking-budget calls we exist to support.
"X-Accel-Buffering": "no",
},
});
}
/**
* Pi-native fast path: `POST /v1/pi/stream`. Accepts the canonical pi-ai
* `Context` directly (no wire-format round-trip) and emits a bandwidth-shrunk
* event stream matching `pi-agent`'s `streamProxy`. Skips the OpenAI /
* Anthropic / Responses translation layers — those exist to bridge foreign
* SDKs (llm-git, anthropic-sdk, openai-sdk), and bridging back to pi-native
* just to bridge forward again is wasted work.
*
* Every other gateway concern (bearer auth, model resolve, credential fetch,
* abort mirroring, codex temperature/topP strip, prefix-cache key derivation,
* Claude-Code OAuth shaping inside `streamSimple`) still applies — only
* `parseRequest`/`encodeResponse`/`encodeStream` differ from the format-endpoint
* path.
*/
async function handlePiNative(bootOpts: AuthGatewayBootOptions, req: Request, peer: string): Promise<Response> {
const controller = mirrorRequestAbort(req);
const aborted = (): Response => piNative.formatError(499, "request_aborted", "client closed request");
if (controller.signal.aborted) return aborted();
let body: unknown;
try {
body = await req.json();
} catch (error) {
if (controller.signal.aborted) return aborted();
return piNative.formatError(400, "invalid_request_error", `Invalid JSON body: ${String(error)}`);
}
if (controller.signal.aborted) return aborted();
let parsed: piNative.PiNativeParsedRequest;
try {
parsed = piNative.parseRequest(body, req.headers);
} catch (error) {
if (controller.signal.aborted) return aborted();
const message = error instanceof Error ? error.message : String(error);
return piNative.formatError(400, "invalid_request_error", message);
}
const model = bootOpts.resolveModel(parsed.modelId);
if (!model) {
return piNative.formatError(404, "invalid_request_error", `Unknown model: ${parsed.modelId}`);
}
let apiKey: string | undefined;
try {
apiKey = await bootOpts.storage.getApiKey(model.provider, undefined, {
modelId: model.id,
signal: controller.signal,
});
} catch (error) {
if (controller.signal.aborted) return aborted();
const classified = classifyGatewayError(error);
logger.warn("auth-gateway getApiKey threw", { provider: model.provider, peer, error: classified.message });
return piNative.formatError(classified.status, classified.type, classified.message);
}
if (controller.signal.aborted) return aborted();
if (!apiKey) {
return piNative.formatError(
401,
"authentication_error",
`No credential available for provider ${model.provider}`,
);
}
// Build the SimpleStreamOptions actually handed to `streamSimple`. We
// trust the client's options (already allow-listed by `parseRequest`) and
// only inject server-controlled fields. The codex temperature/topP strip
// matches `buildStreamOptions` — Codex rejects them with a 400.
const streamOpts: SimpleStreamOptions = { ...parsed.options, apiKey, signal: controller.signal };
if (model.api === "openai-codex-responses") {
delete streamOpts.temperature;
delete streamOpts.topP;
}
// Merge gateway-captured passthrough headers under the client's own
// headers — the client's values win when they collide.
const captured = captureRequestHeaders(req.headers);
streamOpts.headers = { ...captured, ...(streamOpts.headers ?? {}) };
// Cache identity: explicit `sessionId` wins, then derive a stable key
// from model + system + tools + first message so Codex prefix caching
// engages on the same logical conversation across turns.
streamOpts.sessionId ??= deriveSessionId(parsed.modelId, parsed.context);
logger.info("auth-gateway request", {
format: "pi-native",
model: parsed.modelId,
resolvedProvider: model.provider,
resolvedModel: model.id,
stream: parsed.stream,
peer,
});
let events: AssistantMessageEventStream;
try {
if (controller.signal.aborted) return aborted();
events = streamSimple(model, parsed.context, streamOpts);
} catch (error) {
const classified = classifyGatewayError(error);
logger.warn("auth-gateway streamSimple threw", { format: "pi-native", error: classified.message, peer });
return piNative.formatError(classified.status, classified.type, classified.message);
}
if (!parsed.stream) {
try {
if (controller.signal.aborted) return aborted();
const message = await events.result();
if (message.stopReason === "aborted" || message.stopReason === "error") {
const errorMessage =
message.errorMessage ??
(message.stopReason === "aborted" ? "Request was aborted" : "Upstream request failed");
logger.warn("auth-gateway non-streaming failed", {
format: "pi-native",
reason: message.stopReason,
error: errorMessage,
peer,
});
if (message.stopReason === "aborted") {
return piNative.formatError(499, "request_aborted", errorMessage);
}
const classified = classifyGatewayError(new Error(errorMessage));
return piNative.formatError(classified.status, classified.type, errorMessage);
}
return json(200, { message });
} catch (error) {
if (controller.signal.aborted) return aborted();
const classified = classifyGatewayError(error);
logger.warn("auth-gateway non-streaming aborted", { format: "pi-native", error: classified.message, peer });
return piNative.formatError(classified.status, classified.type, classified.message);
}
}
if (controller.signal.aborted) return aborted();
const sseStream = piNative.encodeStream(events);
return new Response(sseStream, {
status: 200,
headers: {
"Content-Type": "text/event-stream; charset=utf-8",
"Cache-Control": "no-cache",
Connection: "keep-alive",
"X-Accel-Buffering": "no",
},
});
}
/**
* Snapshot of `GET /v1/usage` — `fetchUsageReports` already caches reports at
* a 5-minute per-credential TTL (with jitter, plus last-good fallback on
* failure) inside `AuthStorage`, so this handler is a thin wrapper that
* surfaces the same data to HTTP callers (notably the macOS usage widget).
*/
async function handleUsage(storage: AuthStorage, signal: AbortSignal): Promise<Response> {
const reports = (await storage.fetchUsageReports?.({ signal })) ?? [];
// Drop the heavy provider-specific `raw` payload — UI consumers only need
// `limits` + `metadata`. Match the broker's `/v1/usage` shape so a single
// client struct (Swift widget, llm-git, ...) works against either endpoint.
const trimmed = reports.map(({ raw: _raw, ...rest }) => rest);
return json(200, { generatedAt: Date.now(), reports: trimmed });
}
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,
}));
return json(200, { object: "list", data });
}
export function startAuthGateway(opts: AuthGatewayBootOptions): AuthGatewayServerHandle {
const bind = parseBind(opts.bind ?? DEFAULT_AUTH_GATEWAY_BIND);
const tokens = new Set<string>(opts.bearerTokens);
const version = opts.version;
const server = Bun.serve({
hostname: bind.hostname,
port: bind.port,
fetch: async (req): Promise<Response> => {
const url = new URL(req.url);
const pathname = url.pathname;
const peer = resolvePeer(req);
// CORS preflight is always answered without auth — browsers send
// preflights pre-authentication and a 401 here breaks the actual
// request before the bearer is ever attached.
if (req.method === "OPTIONS") {
return new Response(null, { status: 204, headers: corsHeaders(req) });
}
try {
if (req.method === "GET" && pathname === "/healthz") {
return withCors(json(200, { ok: true, version }), req);
}
if (!isAuthorized(req, tokens)) {
logger.info("auth-gateway request unauthorized", { method: req.method, path: pathname, peer });
return withCors(json(401, { error: "unauthorized" }), req);
}
// Aggregated usage — backed by AuthStorage's 5-min per-credential cache.
// Same shape as the broker's `/v1/usage`, so widget/llm-git speak to either with the
// same client struct.
if (req.method === "GET" && pathname === "/v1/usage") {
return withCors(await handleUsage(opts.storage, req.signal), req);
}
// Provider-format dispatch.
const formatRoute = FORMAT_ROUTES[pathname];
if (formatRoute && req.method === "POST") {
return withCors(await handleFormatEndpoint(formatRoute, opts, req, peer), req);
}
// Pi-native fast path. Same auth + provider plumbing as the
// foreign-wire routes, just without the wire-format translation.
if (req.method === "POST" && pathname === "/v1/pi/stream") {
return withCors(await handlePiNative(opts, req, peer), req);
}
// Model catalog.
if (req.method === "GET" && pathname === "/v1/models") {
return withCors(handleModelsList(opts), req);
}
// Route-table miss: no format module to defer to, so we emit a
// plain JSON 404 rather than guessing at a protocol-specific envelope.
return withCors(json(404, { error: `No route: ${req.method} ${pathname}` }), req);
} catch (error) {
logger.error("auth-gateway handler crashed", {
method: req.method,
path: pathname,
peer,
error: String(error),
});
return withCors(json(500, { error: "internal error" }), req);
}
},
// Max-out Bun's idle timeout. Long thinking-budget calls can sit idle
// for minutes before the first token arrives; the default kills them.
idleTimeout: 255,
});
const boundHost = server.hostname ?? bind.hostname;
const boundPort = server.port ?? bind.port;
return {
url: `http://${boundHost}:${boundPort}`,
port: boundPort,
hostname: boundHost,
close: async () => {
server.stop(true);
},
};
}
+134
View File
@@ -0,0 +1,134 @@
import type { Effort } from "../model-thinking";
import type { AssistantMessage, AssistantMessageEventStream, CacheRetention, Context, ServiceTier } from "../types";
/**
* Wire types for the omp auth-gateway.
*
* The gateway sits between unauthenticated clients (containerized omp,
* llm-git, …) and the broker. It accepts provider-format HTTP requests
* (OpenAI chat-completions / Anthropic messages / OpenAI Responses),
* dispatches them through pi-ai's `streamSimple()`, and translates the
* canonical event stream back to the matching wire format. The gateway
* injects `Authorization` server-side so clients never see access tokens.
*/
/** Default bind. Loopback-only — front with reverse proxy for remote access. */
export const DEFAULT_AUTH_GATEWAY_BIND = "127.0.0.1:4000";
export type AuthGatewayToolChoice = "auto" | "none" | "required" | { name: string };
export interface AuthGatewayParsedRequestOptions {
// ── Sampling ──────────────────────────────────────────────────────────
maxOutputTokens?: number;
temperature?: number;
topP?: number;
topK?: number;
/** OpenAI nucleus-min sampling (`min_p`). */
minP?: number;
/** Anthropic `stop_sequences` / OpenAI `stop`. */
stopSequences?: string[];
/** OpenAI `presence_penalty`. */
presencePenalty?: number;
/** OpenAI `frequency_penalty`. */
frequencyPenalty?: number;
/** OpenRouter / vLLM `repetition_penalty`. */
repetitionPenalty?: number;
/** OpenAI deterministic-sampling `seed`. */
seed?: number;
/** OpenAI `logit_bias` map (token id → bias). */
logitBias?: Record<string, number>;
/** OpenAI `response_format` (text | json_object | json_schema). Opaque passthrough. */
responseFormat?: unknown;
// ── Tools ─────────────────────────────────────────────────────────────
toolChoice?: AuthGatewayToolChoice;
/** OpenAI `parallel_tool_calls`. */
parallelToolCalls?: boolean;
// ── Reasoning ─────────────────────────────────────────────────────────
/** Effort-level reasoning request (OpenAI Responses / Chat `reasoning_effort`). */
reasoning?: Effort;
/** Force-disable reasoning (Anthropic `thinking: { type: "disabled" }`). */
disableReasoning?: boolean;
/**
* Explicit Anthropic `thinking.budget_tokens`. Mirrors Rust's
* `resolve_thinking_budget`: pins onto whichever effort the client
* requested (defaulting to High when unspecified). Preferred over the
* removed legacy single-number `thinkingBudget` for new code.
*/
explicitThinkingBudgetTokens?: number;
/** Per-effort thinking budget map. */
thinkingBudgets?: Partial<Record<Effort, number>>;
/** Suppress the provider's reasoning summary stream. */
hideThinkingSummary?: boolean;
// ── Service / routing ─────────────────────────────────────────────────
/** OpenAI service tier (auto|default|flex|scale|priority). */
serviceTier?: ServiceTier;
/** Cache retention hint derived from inbound `cache_control` markers. */
cacheRetention?: CacheRetention;
/** OpenAI Responses `prompt_cache_key`; bridges to pi-ai `sessionId`. */
promptCacheKey?: string;
/** OpenAI Responses `previous_response_id` for response chaining. */
previousResponseId?: string;
/** OpenAI / abuse-tracking `user` field. */
user?: string;
// ── Passthrough ───────────────────────────────────────────────────────
/**
* Provider-specific metadata. Anthropic uses `metadata.user_id`; OpenRouter
* carries routing hints; xAI uses `search_parameters`; OpenAI accepts a
* free-form bag. The gateway forwards as-is.
*/
metadata?: Record<string, unknown>;
/**
* Captured allow-listed passthrough headers (anthropic-beta,
* anthropic-version, openai-organization, openai-project, openai-beta,
* x-stainless-*). Keys are lowercased.
*/
headers?: Record<string, string>;
/**
* Escape hatch for provider-specific request controls that don't yet have a
* first-class field. Prefer adding a typed field over widening this.
*/
extra?: Record<string, unknown>;
}
export interface AuthGatewayParsedRequest {
modelId: string;
context: Context;
stream: boolean;
options: AuthGatewayParsedRequestOptions;
}
export interface AuthGatewayFormatModule {
parseRequest(body: unknown, headers?: Headers): AuthGatewayParsedRequest;
encodeResponse(message: AssistantMessage, requestedModelId: string): Record<string, unknown>;
encodeStream(
events: AssistantMessageEventStream,
requestedModelId: string,
options?: AuthGatewayParsedRequestOptions,
): ReadableStream<Uint8Array>;
/**
* Emit a protocol-specific error envelope. OpenAI returns
* `{ error: { message, type } }`; Anthropic returns
* `{ type: "error", error: { type, message } }`.
*/
formatError(status: number, type: string, message: string): Response;
}
export interface AuthGatewayServerOptions {
/** Listen address. Default `127.0.0.1:4000`. */
bind?: string;
/** Accept any of these bearer tokens. Empty allows unauthenticated calls. */
bearerTokens: string[];
/** Version surfaced on `/healthz`. */
version?: string;
}
export interface AuthGatewayServerHandle {
url: string;
port: number;
hostname: string;
close(): Promise<void>;
}
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env bun
import * as readline from "node:readline";
import { AuthCredentialStore } from "./auth-storage";
import { SqliteAuthCredentialStore } from "./auth-storage";
import { getOAuthProviders } from "./utils/oauth";
import type { OAuthCredentials, OAuthProvider } from "./utils/oauth/types";
@@ -60,7 +60,7 @@ async function login(provider: OAuthProvider): Promise<void> {
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
const promptFn = (msg: string) => prompt(rl, `${msg} `);
const storage = await AuthCredentialStore.open();
const storage = await SqliteAuthCredentialStore.open();
try {
let credentials: OAuthCredentials;
@@ -387,7 +387,7 @@ Examples:
}
if (command === "status") {
const storage = await AuthCredentialStore.open();
const storage = await SqliteAuthCredentialStore.open();
try {
const providers = storage.listProviders();
if (providers.length === 0) {
@@ -426,7 +426,7 @@ Examples:
if (command === "logout") {
let provider = args[1] as OAuthProvider | undefined;
const storage = await AuthCredentialStore.open();
const storage = await SqliteAuthCredentialStore.open();
try {
if (!provider) {
+10
View File
@@ -1,5 +1,8 @@
export { type ZodType, z } from "zod/v4";
export * from "./api-registry";
export * from "./auth-broker";
export { type AuthGatewayBootOptions, type ModelResolver, startAuthGateway } from "./auth-gateway/server";
export * from "./auth-gateway/types";
export * from "./auth-storage";
export * from "./model-cache";
export * from "./model-manager";
@@ -38,6 +41,13 @@ export * from "./utils/anthropic-auth";
export * from "./utils/discovery";
export * from "./utils/event-stream";
export * from "./utils/h2-fetch";
export * from "./utils/oauth";
export type {
OAuthCredentials,
OAuthProvider,
OAuthProviderId,
OAuthProviderInfo,
} from "./utils/oauth/types";
export * from "./utils/overflow";
export * from "./utils/retry";
export * from "./utils/schema";
+22 -4
View File
@@ -6,13 +6,14 @@ import { Database } from "bun:sqlite";
import { getModelDbPath } from "@oh-my-pi/pi-utils";
import type { Api, Model } from "./types";
const CACHE_SCHEMA_VERSION = 2;
const CACHE_SCHEMA_VERSION = 3;
interface CacheRow {
provider_id: string;
version: number;
updated_at: number;
authoritative: number;
static_fingerprint: string;
models: string;
}
@@ -21,6 +22,13 @@ interface CacheEntry<TApi extends Api = Api> {
fresh: boolean;
authoritative: boolean;
updatedAt: number;
/**
* Hash of the static catalog slice that was merged into `models` when this
* row was written. `resolveProviderModels` compares against the current
* static fingerprint and bypasses the static+cache re-merge when they
* match — the cache already incorporates the same static state.
*/
staticFingerprint: string;
}
let sharedDb: Database | null = null;
@@ -43,6 +51,7 @@ function getDb(dbPath?: string): Database {
version INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
authoritative INTEGER NOT NULL DEFAULT 0,
static_fingerprint TEXT NOT NULL DEFAULT '',
models TEXT NOT NULL
)
`);
@@ -71,6 +80,7 @@ export function readModelCache<TApi extends Api>(
fresh,
authoritative: row.authoritative === 1,
updatedAt: row.updated_at,
staticFingerprint: row.static_fingerprint ?? "",
};
} catch {
return null;
@@ -82,14 +92,22 @@ export function writeModelCache<TApi extends Api>(
updatedAt: number,
models: Model<TApi>[],
authoritative: boolean,
staticFingerprint: string,
dbPath?: string,
): void {
try {
const db = getDb(dbPath);
db.run(
`INSERT OR REPLACE INTO model_cache (provider_id, version, updated_at, authoritative, models)
VALUES (?, ?, ?, ?, ?)`,
[providerId, CACHE_SCHEMA_VERSION, updatedAt, authoritative ? 1 : 0, JSON.stringify(models)],
`INSERT OR REPLACE INTO model_cache (provider_id, version, updated_at, authoritative, static_fingerprint, models)
VALUES (?, ?, ?, ?, ?, ?)`,
[
providerId,
CACHE_SCHEMA_VERSION,
updatedAt,
authoritative ? 1 : 0,
staticFingerprint,
JSON.stringify(models),
],
);
} catch {
// Cache writes are best-effort; failures should not break model resolution.
+128 -27
View File
@@ -75,6 +75,24 @@ export function createModelManager<TApi extends Api = Api, TModelsDevPayload = u
};
}
/**
* Cheap fast path for trusted model sources (bundled literals, our own cache rows).
* Skips per-field validation; only guards against catastrophically corrupt rows.
*/
function passModelList<TApi extends Api>(value: unknown): Model<TApi>[] {
if (!Array.isArray(value)) {
return [];
}
const out: Model<TApi>[] = [];
for (const item of value) {
if (item === null || typeof item !== "object" || typeof (item as { id: unknown }).id !== "string") {
continue;
}
out.push(enrichModelThinking(item as Model<TApi>));
}
return out;
}
/**
* Resolves provider models with source precedence:
* static -> models.dev -> cache -> dynamic.
@@ -88,7 +106,7 @@ export async function resolveProviderModels<TApi extends Api = Api, TModelsDevPa
const now = options.now ?? Date.now;
const ttlMs = options.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS;
const dbPath = options.cacheDbPath;
const staticModels = normalizeModelList<TApi>(
const staticModels = passModelList<TApi>(
options.staticModels ?? getBundledModels(options.providerId as GeneratedProvider),
);
const cache = readModelCache<TApi>(options.providerId, ttlMs, now, dbPath);
@@ -102,6 +120,23 @@ export async function resolveProviderModels<TApi extends Api = Api, TModelsDevPa
hasAuthoritativeCache,
cacheAgeMs,
);
const staticFingerprint = fingerprintStatic(staticModels);
// Cold-start fast path: when a fresh, authoritative cache exists, the network
// fetch is skipped, AND the static catalog slice is byte-identical to what
// was merged in last time, the cache row IS the authoritative merge result.
// Re-running `mergeDynamicModels(static, cache)` would just rebuild the same
// objects (~800ms in the steady-state cold-start profile for `omp -p hi`).
if (
!shouldFetchFromNetwork &&
cache?.fresh &&
hasAuthoritativeCache &&
cache.staticFingerprint === staticFingerprint &&
cache.staticFingerprint.length > 0
) {
return { models: passModelList<TApi>(cache.models), stale: false };
}
const [fetchedModelsDevModels, fetchedDynamicModels] = shouldFetchFromNetwork
? await Promise.all([fetchModelsDev(options), dynamicFetcher ? fetchDynamicModels(dynamicFetcher) : null])
: [null, null];
@@ -117,7 +152,7 @@ export async function resolveProviderModels<TApi extends Api = Api, TModelsDevPa
if (shouldFetchFromNetwork) {
if (dynamicFetchSucceeded) {
const snapshotModels = mergeDynamicModels(mergeModelSources(staticModels, modelsDevModels), dynamicModels);
writeModelCache(options.providerId, now(), snapshotModels, true, dbPath);
writeModelCache(options.providerId, now(), snapshotModels, true, staticFingerprint, dbPath);
} else {
// Dynamic fetch failed — update cache with a non-authoritative snapshot so
// stale state remains visible while retry backoff still applies.
@@ -130,6 +165,7 @@ export async function resolveProviderModels<TApi extends Api = Api, TModelsDevPa
normalizeModelList<TApi>(latestCache?.models ?? cache?.models ?? []),
),
false,
staticFingerprint,
dbPath,
);
}
@@ -194,12 +230,16 @@ function shouldFetchRemoteSources(
}
function mergeModelSources<TApi extends Api>(...sources: readonly (readonly Model<TApi>[])[]): Model<TApi>[] {
// Strip out empty/missing sources up front. The hot path is `(static, [])`
// (modelsDev disabled / failed) — a single non-empty source means we can
// skip the Map churn entirely and just hand back the array.
const nonEmpty = sources.filter(source => source.length > 0);
if (nonEmpty.length === 0) return [];
if (nonEmpty.length === 1) return [...nonEmpty[0]];
const merged = new Map<string, Model<TApi>>();
for (const source of sources) {
for (const source of nonEmpty) {
for (const model of source) {
if (!model?.id) {
continue;
}
if (!model?.id) continue;
merged.set(model.id, model);
}
}
@@ -210,6 +250,11 @@ function mergeDynamicModels<TApi extends Api>(
baseModels: readonly Model<TApi>[],
dynamicModels: readonly Model<TApi>[],
): Model<TApi>[] {
// Empty-side fast paths: `mergeDynamicModels(base, [])` is the common shape
// after we've already merged the first pair, and `(...)` with no base
// happens for providers without static catalogs.
if (dynamicModels.length === 0) return baseModels.length === 0 ? [] : [...baseModels];
if (baseModels.length === 0) return [...dynamicModels];
const merged = new Map<string, Model<TApi>>(baseModels.map(model => [model.id, model]));
for (const dynamicModel of dynamicModels) {
if (!dynamicModel?.id) {
@@ -225,6 +270,26 @@ function mergeDynamicModels<TApi extends Api>(
return Array.from(merged.values());
}
/**
* Stable, low-collision fingerprint of a static catalog slice. Cached by
* reference so repeat calls in the same process (e.g. multiple cold-start
* arms calling `resolveProviderModels` with the same `staticModels` array)
* skip the JSON+hash work after the first call.
*/
const kStaticFingerprint = Symbol("model-manager.staticFingerprint");
type ModelArrayWithFingerprint = readonly Model<Api>[] & { [kStaticFingerprint]?: string };
function fingerprintStatic<TApi extends Api>(models: readonly Model<TApi>[]): string {
if (models.length === 0) return "empty";
const tagged = models as ModelArrayWithFingerprint;
const cached = tagged[kStaticFingerprint];
if (cached !== undefined) return cached;
// `Bun.hash` returns a `bigint`; base36 keeps the string short for the
// SQLite column without sacrificing distinguishability.
const fingerprint = Bun.hash(JSON.stringify(models)).toString(36);
tagged[kStaticFingerprint] = fingerprint;
return fingerprint;
}
function mergeDynamicModel<TApi extends Api>(existingModel: Model<TApi>, dynamicModel: Model<TApi>): Model<TApi> {
const supportsImage = existingModel.input.includes("image") || dynamicModel.input.includes("image");
return enrichModelThinking({
@@ -292,34 +357,49 @@ function isModelLike(value: unknown): value is Model<Api> {
if (!isRecord(value)) {
return false;
}
if (typeof value.id !== "string" || value.id.length === 0) {
const v = value as {
id?: unknown;
name?: unknown;
api?: unknown;
provider?: unknown;
baseUrl?: unknown;
reasoning?: unknown;
input?: unknown;
cost?: unknown;
contextWindow?: unknown;
maxTokens?: unknown;
};
if (typeof v.id !== "string" || v.id.length === 0) {
return false;
}
if (typeof value.name !== "string" || value.name.length === 0) {
if (typeof v.name !== "string" || v.name.length === 0) {
return false;
}
if (typeof value.api !== "string" || value.api.length === 0) {
if (typeof v.api !== "string" || v.api.length === 0) {
return false;
}
if (typeof value.provider !== "string" || value.provider.length === 0) {
if (typeof v.provider !== "string" || v.provider.length === 0) {
return false;
}
if (typeof value.baseUrl !== "string" || value.baseUrl.length === 0) {
if (typeof v.baseUrl !== "string" || v.baseUrl.length === 0) {
return false;
}
if (typeof value.reasoning !== "boolean") {
if (typeof v.reasoning !== "boolean") {
return false;
}
if (!isModelInputArray(value.input)) {
if (!isModelInputArray(v.input)) {
return false;
}
if (!isModelCost(value.cost)) {
if (!isModelCost(v.cost)) {
return false;
}
if (typeof value.contextWindow !== "number" || !Number.isFinite(value.contextWindow) || value.contextWindow <= 0) {
// Finite positive: NaN > 0 is false, +Infinity < Infinity is false.
const cw = v.contextWindow;
if (typeof cw !== "number" || !(cw > 0 && cw < Infinity)) {
return false;
}
if (typeof value.maxTokens !== "number" || !Number.isFinite(value.maxTokens) || value.maxTokens <= 0) {
const mt = v.maxTokens;
if (typeof mt !== "number" || !(mt > 0 && mt < Infinity)) {
return false;
}
return true;
@@ -329,21 +409,42 @@ function isModelInputArray(value: unknown): value is ("text" | "image")[] {
if (!Array.isArray(value) || value.length === 0) {
return false;
}
return value.every(item => item === "text" || item === "image");
for (let i = 0; i < value.length; i++) {
const item = value[i];
if (item !== "text" && item !== "image") {
return false;
}
}
return true;
}
function isModelCost(value: unknown): value is Model<Api>["cost"] {
if (!isRecord(value)) {
return false;
}
return (
typeof value.input === "number" &&
Number.isFinite(value.input) &&
typeof value.output === "number" &&
Number.isFinite(value.output) &&
typeof value.cacheRead === "number" &&
Number.isFinite(value.cacheRead) &&
typeof value.cacheWrite === "number" &&
Number.isFinite(value.cacheWrite)
);
const c = value as {
input?: unknown;
output?: unknown;
cacheRead?: unknown;
cacheWrite?: unknown;
};
// Finite (NaN-safe): -Infinity < x < Infinity rejects NaN and both infinities.
// Preserves original behavior: 0 and negatives remain valid.
const ci = c.input;
if (typeof ci !== "number" || !(ci > -Infinity && ci < Infinity)) {
return false;
}
const co = c.output;
if (typeof co !== "number" || !(co > -Infinity && co < Infinity)) {
return false;
}
const cr = c.cacheRead;
if (typeof cr !== "number" || !(cr > -Infinity && cr < Infinity)) {
return false;
}
const cw = c.cacheWrite;
if (typeof cw !== "number" || !(cw > -Infinity && cw < Infinity)) {
return false;
}
return true;
}
+26 -9
View File
@@ -104,6 +104,9 @@ export const CLOUDFLARE_FALLBACK_MODEL: ApiModel<"anthropic-messages"> = {
maxTokens: 64000,
};
const kEnrichedModel = Symbol("model-thinking.enrichedModel");
type ModelWithEnriched = ApiModel<Api> & { [kEnrichedModel]?: ApiModel<Api> };
/**
* Returns a copy of the model with canonical thinking metadata attached.
*
@@ -111,18 +114,32 @@ export const CLOUDFLARE_FALLBACK_MODEL: ApiModel<"anthropic-messages"> = {
* trust `model.thinking` and avoid inferring capabilities on demand.
*/
export function enrichModelThinking<TApi extends Api>(model: ApiModel<TApi>): ApiModel<TApi> {
const tagged = model as ModelWithEnriched;
const cached = tagged[kEnrichedModel];
if (cached !== undefined) {
return cached as ApiModel<TApi>;
}
const normalizedThinking = normalizeThinkingConfig(model.thinking);
let result: ApiModel<TApi>;
if (!model.reasoning) {
return normalizedThinking === undefined && model.thinking === undefined
? model
: { ...model, thinking: undefined };
result =
normalizedThinking === undefined && model.thinking === undefined ? model : { ...model, thinking: undefined };
} else {
const thinking = normalizedThinking ?? inferModelThinking(model);
result = thinkingsEqual(normalizedThinking, thinking) ? model : { ...model, thinking };
}
const thinking = normalizedThinking ?? inferModelThinking(model);
if (thinkingsEqual(normalizedThinking, thinking)) {
return model;
}
return { ...model, thinking };
// Stash the enriched copy on a non-enumerable slot so callers that hand us
// the same reference twice skip the work. `enumerable: false` is critical:
// many call sites build derived models via `{ ...model, ...overrides }`,
// which would otherwise copy this cache slot and trick us into returning
// the *original* enriched model — silently discarding the overrides.
Object.defineProperty(tagged, kEnrichedModel, {
value: result,
enumerable: false,
configurable: true,
writable: true,
});
return result;
}
/**
+9
View File
@@ -16,6 +16,12 @@ export interface ProviderDetailsContext {
model: Model<Api>;
sessionId?: string;
authMode?: string;
/**
* Human-readable description of the active credential, e.g.
* `"broker http://can.internal:8765 · oauth #5 (foo@bar.com)"`.
* Rendered as a `Source` field; omitted when undefined.
*/
credentialSource?: string;
preferWebsockets?: boolean;
providerSessionState?: Map<string, ProviderSessionState>;
}
@@ -28,6 +34,9 @@ export function getProviderDetails(context: ProviderDetailsContext): ProviderDet
{ label: "Auth", value: context.authMode ?? "auto" },
{ label: "Endpoint", value: endpoint },
];
if (context.credentialSource) {
fields.push({ label: "Source", value: context.credentialSource });
}
if (context.model.api === "openai-codex-responses") {
const codexDetails = getOpenAICodexTransportDetails(context.model as Model<"openai-codex-responses">, {
+6 -45
View File
@@ -1,6 +1,6 @@
import { once } from "@oh-my-pi/pi-utils";
import type { ModelManagerOptions } from "../model-manager";
import { fetchCodexModels } from "../utils/discovery/codex";
import { fetchCursorUsableModels } from "../utils/discovery/cursor";
// ---------------------------------------------------------------------------
// OpenAI Codex
@@ -45,55 +45,16 @@ export function cursorModelManagerOptions(config: CursorModelManagerConfig = {})
providerId: "cursor",
...(apiKey
? {
fetchDynamicModels: () => fetchCursorUsableModels({ apiKey, baseUrl, clientVersion }),
fetchDynamicModels: async () => {
const { fetchCursorUsableModels } = await cursorDiscovery();
return fetchCursorUsableModels({ apiKey, baseUrl, clientVersion });
},
}
: undefined),
};
}
// ---------------------------------------------------------------------------
// Amazon Bedrock
// ---------------------------------------------------------------------------
// Dynamic discovery requires AWS SDK auth (ListFoundationModels). Not yet implemented.
export interface AmazonBedrockModelManagerConfig {}
export function amazonBedrockModelManagerOptions(
_config: AmazonBedrockModelManagerConfig = {},
): ModelManagerOptions<"bedrock-converse-stream"> {
return { providerId: "amazon-bedrock" };
}
// ---------------------------------------------------------------------------
// MiniMax variants (subscription-based, no model listing endpoint)
// ---------------------------------------------------------------------------
export interface MinimaxModelManagerConfig {}
export function minimaxModelManagerOptions(
_config: MinimaxModelManagerConfig = {},
): ModelManagerOptions<"anthropic-messages"> {
return { providerId: "minimax" };
}
export function minimaxCnModelManagerOptions(
_config: MinimaxModelManagerConfig = {},
): ModelManagerOptions<"anthropic-messages"> {
return { providerId: "minimax-cn" };
}
export function minimaxCodeModelManagerOptions(
_config: MinimaxModelManagerConfig = {},
): ModelManagerOptions<"openai-completions"> {
return { providerId: "minimax-code" };
}
export function minimaxCodeCnModelManagerOptions(
_config: MinimaxModelManagerConfig = {},
): ModelManagerOptions<"openai-completions"> {
return { providerId: "minimax-code-cn" };
}
const cursorDiscovery = once(() => import("../utils/discovery/cursor"));
// ---------------------------------------------------------------------------
// Zai
+277 -253
View File
@@ -1,28 +1,13 @@
import {
BedrockRuntimeClient,
type BedrockRuntimeClientConfig,
StopReason as BedrockStopReason,
type Tool as BedrockTool,
CachePointType,
CacheTTL,
type ContentBlock,
type ContentBlockDeltaEvent,
type ContentBlockStartEvent,
type ContentBlockStopEvent,
ConversationRole,
ConverseStreamCommand,
type ConverseStreamMetadataEvent,
ImageFormat,
type Message,
type SystemContentBlock,
type ToolChoice,
type ToolConfiguration,
ToolResultStatus,
} from "@aws-sdk/client-bedrock-runtime";
import { type DefaultProviderInit, defaultProvider } from "@aws-sdk/credential-provider-node";
import { $env, $flag } from "@oh-my-pi/pi-utils";
import { NodeHttpHandler } from "@smithy/node-http-handler";
import { ProxyAgent } from "proxy-agent";
/**
* Amazon Bedrock Converse Stream provider.
*
* Talks directly to `bedrock-runtime.{region}.amazonaws.com` over HTTPS with
* SigV4 signing and decodes the `application/vnd.amazon.eventstream` response.
* No `@aws-sdk/*`, no `@smithy/*`, no `proxy-agent`. Proxies are honored via
* Bun's native `HTTPS_PROXY` support.
*/
import { $env, $flag, fetchWithRetry } from "@oh-my-pi/pi-utils";
import type { Effort } from "../model-thinking";
import { mapEffortToAnthropicAdaptiveEffort, requireSupportedEffort } from "../model-thinking";
import { calculateCost } from "../models";
@@ -47,6 +32,9 @@ import { AssistantMessageEventStream } from "../utils/event-stream";
import { appendRawHttpRequestDumpFor400, type RawHttpRequestDump, withHttpStatus } from "../utils/http-inspector";
import { parseStreamingJson } from "../utils/json-parse";
import { toolWireSchema } from "../utils/schema/wire";
import { resolveAwsCredentials } from "./aws-credentials";
import { decodeEventStream } from "./aws-eventstream";
import { signRequest } from "./aws-sigv4";
import { transformMessages } from "./transform-messages";
export interface BedrockOptions extends StreamOptions {
@@ -63,49 +51,93 @@ export interface BedrockOptions extends StreamOptions {
type Block = (TextContent | ThinkingContent | ToolCall) & { index?: number; partialJson?: string };
const BEDROCK_PROXY_ENV_KEYS = ["HTTPS_PROXY", "HTTP_PROXY", "ALL_PROXY", "https_proxy", "http_proxy", "all_proxy"];
// ---------- Bedrock wire-format types ----------
// Mirrors only what we actually consume from `ConverseStreamRequest` /
// `ConverseStreamOutput`. Keeps us decoupled from `@aws-sdk/client-bedrock-runtime`.
function hasBedrockProxyEnvironment(): boolean {
return BEDROCK_PROXY_ENV_KEYS.some(key => Boolean($env[key]?.trim()));
interface CachePoint {
cachePoint: { type: "default"; ttl?: "5m" | "1h" };
}
interface TextBlockWire {
text: string;
}
interface ImageBlockWire {
image: { format: "jpeg" | "png" | "gif" | "webp"; source: { bytes: string } };
}
interface ToolUseBlockWire {
toolUse: { toolUseId: string; name: string; input: unknown };
}
interface ToolResultBlockWire {
toolResult: {
toolUseId: string;
content: Array<TextBlockWire | ImageBlockWire>;
status: "success" | "error";
};
}
interface ReasoningBlockWire {
reasoningContent: { reasoningText: { text: string; signature?: string } };
}
function installBedrockHttp1Transport(config: BedrockRuntimeClientConfig): void {
const requestHandler = createBedrockHttp1RequestHandler();
config.requestHandler = requestHandler;
type UserContent = TextBlockWire | ImageBlockWire | ToolResultBlockWire | CachePoint;
type AssistantContent = TextBlockWire | ToolUseBlockWire | ReasoningBlockWire;
type SystemContent = TextBlockWire | CachePoint;
if (hasBedrockProxyEnvironment()) {
config.credentialDefaultProvider = createBedrockCredentialDefaultProvider(requestHandler);
}
interface WireMessage {
role: "user" | "assistant";
content: Array<UserContent | AssistantContent>;
}
function createBedrockHttp1RequestHandler(): NodeHttpHandler {
if (!hasBedrockProxyEnvironment()) {
return new NodeHttpHandler();
}
const agent = new ProxyAgent();
return new NodeHttpHandler({
httpAgent: agent,
httpsAgent: agent,
});
interface WireToolSpec {
toolSpec: { name: string; description: string; inputSchema: { json: unknown } };
}
interface WireToolChoice {
auto?: Record<string, never>;
any?: Record<string, never>;
tool?: { name: string };
}
interface WireToolConfig {
tools: WireToolSpec[];
toolChoice?: WireToolChoice;
}
function createBedrockCredentialDefaultProvider(
requestHandler: NodeHttpHandler,
): NonNullable<BedrockRuntimeClientConfig["credentialDefaultProvider"]> {
return (init?: DefaultProviderInit) =>
defaultProvider({
...init,
clientConfig: {
...init?.clientConfig,
requestHandler,
},
});
interface ConverseStreamRequest {
messages: WireMessage[];
system?: SystemContent[];
inferenceConfig?: { maxTokens?: number; temperature?: number; topP?: number };
toolConfig?: WireToolConfig;
additionalModelRequestFields?: Record<string, unknown>;
}
function isHttp2ResponseError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error);
return /\bhttp2\b|http\/2/i.test(message);
// Streaming events (snake_case matches the JSON envelope key, but Bedrock uses camelCase).
interface MessageStartEvent {
role: "user" | "assistant";
}
interface ContentBlockStartEvent {
contentBlockIndex: number;
start?: { toolUse?: { toolUseId?: string; name?: string } };
}
interface ContentBlockDeltaEvent {
contentBlockIndex: number;
delta?: {
text?: string;
toolUse?: { input?: string };
reasoningContent?: { text?: string; signature?: string };
};
}
interface ContentBlockStopEvent {
contentBlockIndex: number;
}
interface MessageStopEvent {
stopReason?: string;
}
interface MetadataEvent {
usage?: {
inputTokens?: number;
outputTokens?: number;
cacheReadInputTokens?: number;
cacheWriteInputTokens?: number;
totalTokens?: number;
};
}
export const streamBedrock: StreamFunction<"bedrock-converse-stream"> = (
@@ -139,37 +171,10 @@ export const streamBedrock: StreamFunction<"bedrock-converse-stream"> = (
const blocks = output.content as Block[];
let rawRequestDump: RawHttpRequestDump | undefined;
const config: BedrockRuntimeClientConfig = {
region: options.region,
profile: options.profile,
};
let usesHttp1RequestHandler = false;
let messageStarted = false;
// in Node.js/Bun environment only
if (typeof process !== "undefined" && (process.versions?.node || process.versions?.bun)) {
config.region = config.region || $env.AWS_REGION || $env.AWS_DEFAULT_REGION;
// Support proxies that don't need authentication
if ($flag("AWS_BEDROCK_SKIP_AUTH")) {
config.credentials = {
accessKeyId: "dummy-access-key",
secretAccessKey: "dummy-secret-key",
};
}
if ($flag("AWS_BEDROCK_FORCE_HTTP1") || hasBedrockProxyEnvironment()) {
usesHttp1RequestHandler = true;
installBedrockHttp1Transport(config);
}
}
config.region = config.region || "us-east-1";
const region = options.region || $env.AWS_REGION || $env.AWS_DEFAULT_REGION || "us-east-1";
try {
const cacheRetention = resolveCacheRetention(options.cacheRetention);
const toolConfig = convertToolConfig(context.tools, options.toolChoice);
let additionalModelRequestFields = buildAdditionalModelRequestFields(model, options);
@@ -177,87 +182,142 @@ export const streamBedrock: StreamFunction<"bedrock-converse-stream"> = (
// When tool_choice forces tool use, disable thinking to avoid API errors.
if (toolConfig?.toolChoice && additionalModelRequestFields) {
const tc = toolConfig.toolChoice;
if ("any" in tc || "tool" in tc) {
additionalModelRequestFields = undefined;
}
if (tc.any || tc.tool) additionalModelRequestFields = undefined;
}
const commandInput = {
modelId: model.id,
const commandInput: ConverseStreamRequest = {
messages: convertMessages(context, model, cacheRetention),
system: buildSystemPrompt(context.systemPrompt, model, cacheRetention),
inferenceConfig: { maxTokens: options.maxTokens, temperature: options.temperature, topP: options.topP },
inferenceConfig: {
maxTokens: options.maxTokens,
temperature: options.temperature,
topP: options.topP,
},
toolConfig,
additionalModelRequestFields,
};
options?.onPayload?.(commandInput);
const host = `bedrock-runtime.${region}.amazonaws.com`;
const url = `https://${host}/model/${encodeURIComponent(model.id)}/converse-stream`;
const urlPath = `/model/${encodeURIComponent(model.id)}/converse-stream`;
rawRequestDump = {
provider: model.provider,
api: output.api,
model: model.id,
method: "POST",
url: `https://bedrock-runtime.${config.region}.amazonaws.com/model/${model.id}/converse-stream`,
url,
body: commandInput,
};
while (true) {
const client = new BedrockRuntimeClient(config);
try {
const command = new ConverseStreamCommand(commandInput);
const response = await client.send(command, { abortSignal: options.signal });
let credentials: { accessKeyId: string; secretAccessKey: string; sessionToken?: string };
if ($flag("AWS_BEDROCK_SKIP_AUTH")) {
credentials = { accessKeyId: "dummy-access-key", secretAccessKey: "dummy-secret-key" };
} else {
credentials = await resolveAwsCredentials({
profile: options.profile,
region,
signal: options.signal,
});
}
for await (const item of response.stream!) {
if (item.messageStart) {
messageStarted = true;
if (item.messageStart.role !== ConversationRole.ASSISTANT) {
throw new Error("Unexpected assistant message start but got user message start instead");
}
stream.push({ type: "start", partial: output });
} else if (item.contentBlockStart) {
if (!firstTokenTime) firstTokenTime = Date.now();
handleContentBlockStart(item.contentBlockStart, blocks, output, stream);
} else if (item.contentBlockDelta) {
if (!firstTokenTime) firstTokenTime = Date.now();
handleContentBlockDelta(item.contentBlockDelta, blocks, output, stream);
} else if (item.contentBlockStop) {
handleContentBlockStop(item.contentBlockStop, blocks, output, stream);
} else if (item.messageStop) {
output.stopReason = mapStopReason(item.messageStop.stopReason);
} else if (item.metadata) {
handleMetadata(item.metadata, model, output);
} else if (item.internalServerException) {
throw new Error(`Internal server error: ${item.internalServerException.message}`);
} else if (item.modelStreamErrorException) {
throw new Error(`Model stream error: ${item.modelStreamErrorException.message}`);
} else if (item.validationException) {
throw withHttpStatus(new Error(`Validation error: ${item.validationException.message}`), 400);
} else if (item.throttlingException) {
throw new Error(`Throttling error: ${item.throttlingException.message}`);
} else if (item.serviceUnavailableException) {
throw new Error(`Service unavailable: ${item.serviceUnavailableException.message}`);
const bodyText = JSON.stringify(commandInput);
const body = new TextEncoder().encode(bodyText);
const baseHeaders: Record<string, string> = {
"content-type": "application/json",
accept: "application/vnd.amazon.eventstream",
};
const signed = await signRequest({
method: "POST",
host,
path: urlPath,
body,
region,
service: "bedrock",
credentials,
headers: baseHeaders,
});
const requestHeaders: Record<string, string> = { ...baseHeaders, ...signed };
const response = await fetchWithRetry(url, {
method: "POST",
headers: requestHeaders,
body,
signal: options.signal,
});
if (!response.ok) {
const errBody = await response.text().catch(() => "");
throw withHttpStatus(
new Error(`Bedrock HTTP ${response.status}: ${errBody.slice(0, 1000)}`),
response.status,
);
}
if (!response.body) throw new Error("Bedrock response has no body");
// Track first event for the abort/diagnostic path (currently informational).
for await (const message of decodeEventStream(response.body)) {
const messageType = message.headers[":message-type"];
const eventType = message.headers[":event-type"];
if (messageType === "exception") {
const exceptionType = message.headers[":exception-type"] || "Exception";
const payload = safeParsePayload(message.payload) as { message?: string } | undefined;
const errorMessage = payload?.message || new TextDecoder().decode(message.payload);
const status = exceptionType === "validationException" ? 400 : 0;
const err = new Error(`${exceptionType}: ${errorMessage}`);
throw status ? withHttpStatus(err, status) : err;
}
if (messageType === "error") {
const code = message.headers[":error-code"] || "UnknownError";
const errorMessage = message.headers[":error-message"] || new TextDecoder().decode(message.payload);
throw new Error(`${code}: ${errorMessage}`);
}
if (messageType !== "event") continue;
const payload = safeParsePayload(message.payload);
if (!payload) continue;
switch (eventType) {
case "messageStart": {
// no-op: first event marker is implicit by stream entry.
const ev = payload as MessageStartEvent;
if (ev.role !== "assistant") {
throw new Error("Unexpected assistant message start but got user message start instead");
}
stream.push({ type: "start", partial: output });
break;
}
break;
} catch (error) {
if (
!usesHttp1RequestHandler &&
!messageStarted &&
output.content.length === 0 &&
isHttp2ResponseError(error)
) {
usesHttp1RequestHandler = true;
installBedrockHttp1Transport(config);
continue;
case "contentBlockStart": {
if (!firstTokenTime) firstTokenTime = Date.now();
handleContentBlockStart(payload as ContentBlockStartEvent, blocks, output, stream);
break;
}
throw error;
} finally {
client.destroy();
case "contentBlockDelta": {
if (!firstTokenTime) firstTokenTime = Date.now();
handleContentBlockDelta(payload as ContentBlockDeltaEvent, blocks, output, stream);
break;
}
case "contentBlockStop": {
handleContentBlockStop(payload as ContentBlockStopEvent, blocks, output, stream);
break;
}
case "messageStop": {
const ev = payload as MessageStopEvent;
output.stopReason = mapStopReason(ev.stopReason);
break;
}
case "metadata": {
handleMetadata(payload as MetadataEvent, model, output);
break;
}
default:
// Unknown event types (Bedrock may add new ones) — ignore.
break;
}
}
if (options.signal?.aborted) {
throw new Error("Request was aborted");
}
if (options.signal?.aborted) throw new Error("Request was aborted");
if (output.stopReason === "error" || output.stopReason === "aborted") {
throw new Error(output.errorMessage ?? "An unknown error occurred");
@@ -305,13 +365,22 @@ export const streamBedrock: StreamFunction<"bedrock-converse-stream"> = (
return stream;
};
function safeParsePayload(payload: Uint8Array): unknown {
if (payload.length === 0) return {};
try {
return JSON.parse(new TextDecoder().decode(payload));
} catch {
return undefined;
}
}
function handleContentBlockStart(
event: ContentBlockStartEvent,
blocks: Block[],
output: AssistantMessage,
stream: AssistantMessageEventStream,
): void {
const index = event.contentBlockIndex!;
const index = event.contentBlockIndex;
const start = event.start;
if (start?.toolUse) {
@@ -334,13 +403,13 @@ function handleContentBlockDelta(
output: AssistantMessage,
stream: AssistantMessageEventStream,
): void {
const contentBlockIndex = event.contentBlockIndex!;
const contentBlockIndex = event.contentBlockIndex;
const delta = event.delta;
let index = blocks.findIndex(b => b.index === contentBlockIndex);
let block = blocks[index];
if (delta?.text !== undefined) {
// If no text block exists yet, create one, as `handleContentBlockStart` is not sent for text blocks
// If no text block exists yet, create one — `handleContentBlockStart` is not sent for text blocks
if (!block) {
const newBlock: Block = { type: "text", text: "", index: contentBlockIndex };
output.content.push(newBlock);
@@ -386,11 +455,7 @@ function handleContentBlockDelta(
}
}
function handleMetadata(
event: ConverseStreamMetadataEvent,
model: Model<"bedrock-converse-stream">,
output: AssistantMessage,
): void {
function handleMetadata(event: MetadataEvent, model: Model<"bedrock-converse-stream">, output: AssistantMessage): void {
if (event.usage) {
output.usage.input = event.usage.inputTokens || 0;
output.usage.output = event.usage.outputTokens || 0;
@@ -468,16 +533,16 @@ function buildSystemPrompt(
systemPrompt: readonly string[] | undefined,
model: Model<"bedrock-converse-stream">,
cacheRetention: CacheRetention,
): SystemContentBlock[] | undefined {
): SystemContent[] | undefined {
const prompts = systemPrompt?.map(prompt => prompt.toWellFormed()).filter(prompt => prompt.length > 0) ?? [];
if (prompts.length === 0) return undefined;
const blocks: SystemContentBlock[] = prompts.map(prompt => ({ text: prompt }));
const blocks: SystemContent[] = prompts.map(prompt => ({ text: prompt }));
// Add cache point for supported Claude models
if (cacheRetention !== "none" && supportsPromptCaching(model)) {
blocks.push({
cachePoint: { type: CachePointType.DEFAULT, ...(cacheRetention === "long" ? { ttl: CacheTTL.ONE_HOUR } : {}) },
cachePoint: { type: "default", ...(cacheRetention === "long" ? { ttl: "1h" } : {}) },
});
}
@@ -488,8 +553,8 @@ function convertMessages(
context: Context,
model: Model<"bedrock-converse-stream">,
cacheRetention: CacheRetention,
): Message[] {
const result: Message[] = [];
): WireMessage[] {
const result: WireMessage[] = [];
const transformedMessages = transformMessages(context.messages, model, normalizeToolCallId);
for (let i = 0; i < transformedMessages.length; i++) {
@@ -501,44 +566,34 @@ function convertMessages(
if (typeof m.content === "string") {
// Skip empty user messages
if (!m.content || m.content.trim() === "") continue;
result.push({
role: ConversationRole.USER,
content: [{ text: m.content.toWellFormed() }],
});
result.push({ role: "user", content: [{ text: m.content.toWellFormed() }] });
} else {
const contentBlocks = m.content
.map(c => {
switch (c.type) {
case "text":
return { text: c.text.toWellFormed() };
case "image":
return { image: createImageBlock(c.mimeType, c.data) };
default:
throw new Error("Unknown user content type");
const contentBlocks: UserContent[] = [];
for (const c of m.content) {
switch (c.type) {
case "text": {
const text = c.text.toWellFormed();
if (text.trim().length === 0) continue;
contentBlocks.push({ text });
break;
}
})
.filter(block => {
// Filter out empty text blocks
if ("text" in block && block.text) {
return block.text.trim().length > 0;
}
return true; // Keep non-text blocks (images)
});
case "image":
contentBlocks.push({ image: createImageBlock(c.mimeType, c.data) });
break;
default:
throw new Error("Unknown user content type");
}
}
// Skip message if all blocks filtered out
if (contentBlocks.length === 0) continue;
result.push({
role: ConversationRole.USER,
content: contentBlocks,
});
result.push({ role: "user", content: contentBlocks });
}
break;
case "assistant": {
// Skip assistant messages with empty content (e.g., from aborted requests)
// Bedrock rejects messages with empty content arrays
if (m.content.length === 0) {
continue;
}
const contentBlocks: ContentBlock[] = [];
if (m.content.length === 0) continue;
const contentBlocks: AssistantContent[] = [];
for (const c of m.content) {
switch (c.type) {
case "text":
@@ -570,9 +625,7 @@ function convertMessages(
} else if (!supportsThinkingSignature(model)) {
// Model doesn't support signatures at all — send as unsigned reasoning
contentBlocks.push({
reasoningContent: {
reasoningText: { text: c.thinking.toWellFormed() },
},
reasoningContent: { reasoningText: { text: c.thinking.toWellFormed() } },
});
} else {
// Model requires signature but we don't have one — demote to text
@@ -584,21 +637,14 @@ function convertMessages(
}
}
// Skip if all content blocks were filtered out
if (contentBlocks.length === 0) {
continue;
}
result.push({
role: ConversationRole.ASSISTANT,
content: contentBlocks,
});
if (contentBlocks.length === 0) continue;
result.push({ role: "assistant", content: contentBlocks });
break;
}
case "toolResult": {
// Collect all consecutive toolResult messages into a single user message
// Bedrock requires all tool results to be in one message
const toolResults: ContentBlock.ToolResultMember[] = [];
// Add current tool result with all content blocks combined
// Collect all consecutive toolResult messages into a single user message —
// Bedrock requires all tool results to be in one message.
const toolResults: ToolResultBlockWire[] = [];
toolResults.push({
toolResult: {
toolUseId: normalizeToolCallId(m.toolCallId),
@@ -607,11 +653,10 @@ function convertMessages(
? { image: createImageBlock(c.mimeType, c.data) }
: { text: c.text.toWellFormed() },
),
status: m.isError ? ToolResultStatus.ERROR : ToolResultStatus.SUCCESS,
status: m.isError ? "error" : "success",
},
});
// Look ahead for consecutive toolResult messages
let j = i + 1;
while (j < transformedMessages.length && transformedMessages[j].role === "toolResult") {
const nextMsg = transformedMessages[j] as ToolResultMessage;
@@ -623,19 +668,14 @@ function convertMessages(
? { image: createImageBlock(c.mimeType, c.data) }
: { text: c.text.toWellFormed() },
),
status: nextMsg.isError ? ToolResultStatus.ERROR : ToolResultStatus.SUCCESS,
status: nextMsg.isError ? "error" : "success",
},
});
j++;
}
// Skip the messages we've already processed
i = j - 1;
result.push({
role: ConversationRole.USER,
content: toolResults,
});
result.push({ role: "user", content: toolResults });
break;
}
default:
@@ -646,12 +686,9 @@ function convertMessages(
// Add cache point to the last user message for supported Claude models
if (cacheRetention !== "none" && supportsPromptCaching(model) && result.length > 0) {
const lastMessage = result[result.length - 1];
if (lastMessage.role === ConversationRole.USER && lastMessage.content) {
(lastMessage.content as ContentBlock[]).push({
cachePoint: {
type: CachePointType.DEFAULT,
...(cacheRetention === "long" ? { ttl: CacheTTL.ONE_HOUR } : {}),
},
if (lastMessage.role === "user" && lastMessage.content) {
(lastMessage.content as UserContent[]).push({
cachePoint: { type: "default", ...(cacheRetention === "long" ? { ttl: "1h" } : {}) },
});
}
}
@@ -662,23 +699,18 @@ function convertMessages(
function convertToolConfig(
tools: Tool[] | undefined,
toolChoice: BedrockOptions["toolChoice"],
): ToolConfiguration | undefined {
): WireToolConfig | undefined {
if (!tools?.length || toolChoice === "none") return undefined;
const bedrockTools: BedrockTool[] = tools.map(tool => ({
const bedrockTools: WireToolSpec[] = tools.map(tool => ({
toolSpec: {
name: tool.name,
description: tool.description || "",
// Wire schema is structurally a JSON Schema document; the Bedrock SDK
// types it as the recursive `DocumentType` from `@smithy/types`, which
// `Record<string, unknown>` does not directly satisfy at the type
// level. Cast through `unknown` so the actual JSON value passes the
// type checker without changing runtime behavior.
inputSchema: { json: toolWireSchema(tool) as unknown as Record<string, never> },
inputSchema: { json: toolWireSchema(tool) },
},
}));
let bedrockToolChoice: ToolChoice | undefined;
let bedrockToolChoice: WireToolChoice | undefined;
switch (toolChoice) {
case "auto":
bedrockToolChoice = { auto: {} };
@@ -697,13 +729,13 @@ function convertToolConfig(
function mapStopReason(reason: string | undefined): StopReason {
switch (reason) {
case BedrockStopReason.END_TURN:
case BedrockStopReason.STOP_SEQUENCE:
case "end_turn":
case "stop_sequence":
return "stop";
case BedrockStopReason.MAX_TOKENS:
case BedrockStopReason.MODEL_CONTEXT_WINDOW_EXCEEDED:
case "max_tokens":
case "model_context_window_exceeded":
return "length";
case BedrockStopReason.TOOL_USE:
case "tool_use":
return "toolUse";
default:
return "error";
@@ -713,11 +745,9 @@ function mapStopReason(reason: string | undefined): StopReason {
function buildAdditionalModelRequestFields(
model: Model<"bedrock-converse-stream">,
options: BedrockOptions,
): Record<string, any> | undefined {
): Record<string, unknown> | undefined {
const reasoning = options.reasoning;
if (!reasoning || !model.reasoning) {
return undefined;
}
if (!reasoning || !model.reasoning) return undefined;
const mode = model.thinking?.mode;
if (mode === "anthropic-adaptive") {
@@ -738,11 +768,8 @@ function buildAdditionalModelRequestFields(
};
const budget = options.thinkingBudgets?.[level] ?? defaultBudgets[level];
const result: Record<string, any> = {
thinking: {
type: "enabled",
budget_tokens: budget,
},
const result: Record<string, unknown> = {
thinking: { type: "enabled", budget_tokens: budget },
};
if (options.interleavedThinking) {
@@ -752,31 +779,28 @@ function buildAdditionalModelRequestFields(
return result;
}
function createImageBlock(mimeType: string, data: string) {
let format: ImageFormat;
/**
* Bedrock's wire format expects the image as `{ source: { bytes: <base64-string> }, format }`.
* The caller already passes base64-encoded data, so no decode/re-encode round-trip is needed.
*/
function createImageBlock(mimeType: string, data: string): ImageBlockWire["image"] {
let format: "jpeg" | "png" | "gif" | "webp";
switch (mimeType) {
case "image/jpeg":
case "image/jpg":
format = ImageFormat.JPEG;
format = "jpeg";
break;
case "image/png":
format = ImageFormat.PNG;
format = "png";
break;
case "image/gif":
format = ImageFormat.GIF;
format = "gif";
break;
case "image/webp":
format = ImageFormat.WEBP;
format = "webp";
break;
default:
throw new Error(`Unknown image type: ${mimeType}`);
}
const binaryString = atob(data);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return { source: { bytes }, format };
return { source: { bytes: data }, format };
}
@@ -0,0 +1,229 @@
/**
* Zod schemas for the Anthropic Messages API request shape we accept on the
* gateway. Mirrors https://docs.anthropic.com/en/api/messages — only the
* shapes the gateway actually understands; unsupported fields are caught with
* `.refine(...)` so the error mentions them explicitly.
*
* Used by `anthropic-messages.ts:parseRequest` to validate the inbound JSON
* before walking it into pi-ai's canonical `Context`.
*/
import type {
ContentBlockParam,
ImageBlockParam,
MessageCreateParams,
MessageParam,
TextBlockParam,
Tool,
ToolChoice,
} from "@anthropic-ai/sdk/resources/messages";
import * as z from "zod/v4";
// `cache_control` is accepted and translated to pi-ai's per-request
// `cacheRetention` (any `ttl: "1h"` marker upgrades the request to "long";
// any other ephemeral marker maps to "short"). The walker doesn't try to
// preserve per-block breakpoints — pi-ai's anthropic provider re-applies them
// against the rebuilt outbound request anyway.
export const cacheControlSchema = z
.object({
type: z.literal("ephemeral"),
ttl: z.union([z.literal("1h"), z.literal("5m")]).optional(),
})
.loose();
// ─── Sources / inner shapes ─────────────────────────────────────────────────
export const base64ImageSourceSchema = z.object({
type: z.literal("base64"),
data: z.string().min(1),
media_type: z.string().min(1),
});
export const urlImageSourceSchema = z.object({
type: z.literal("url"),
url: z.url(),
});
export const fileImageSourceSchema = z.object({
type: z.literal("file"),
file_id: z.string().min(1),
});
export const imageSourceSchema = z.discriminatedUnion("type", [
base64ImageSourceSchema,
urlImageSourceSchema,
fileImageSourceSchema,
]);
const textBlockSchema = z.object({
type: z.literal("text"),
text: z.string(),
cache_control: cacheControlSchema.optional(),
});
const imageBlockSchema = z.object({
type: z.literal("image"),
source: imageSourceSchema,
cache_control: cacheControlSchema.optional(),
});
const thinkingBlockSchema = z.object({
type: z.literal("thinking"),
thinking: z.string(),
signature: z.string().optional(),
cache_control: cacheControlSchema.optional(),
});
const redactedThinkingBlockSchema = z.object({
type: z.literal("redacted_thinking"),
data: z.string(),
cache_control: cacheControlSchema.optional(),
});
const toolUseBlockSchema = z.object({
type: z.literal("tool_use"),
id: z.string().min(1),
name: z.string().min(1),
input: z.record(z.string(), z.unknown()).optional(),
cache_control: cacheControlSchema.optional(),
});
const toolResultContentBlockSchema = z.discriminatedUnion("type", [textBlockSchema, imageBlockSchema]);
const toolResultBlockSchema = z.object({
type: z.literal("tool_result"),
tool_use_id: z.string().min(1),
content: z.union([z.string(), z.array(toolResultContentBlockSchema)]).optional(),
is_error: z.boolean().optional(),
cache_control: cacheControlSchema.optional(),
});
// Catch-all for content block variants Anthropic ships that the gateway doesn't
// natively understand (server_tool_use, web_search_tool_result, mcp_*,
// container_upload, code_execution_*, document, …). The walker flattens these
// to a text placeholder so legitimate Anthropic clients don't get rejected.
const unknownContentBlockSchema = z.object({ type: z.string() }).loose();
// ─── System ────────────────────────────────────────────────────────────────
const systemBlockSchema = z.object({
type: z.literal("text"),
text: z.string(),
cache_control: cacheControlSchema.optional(),
});
export const systemSchema = z.union([z.string(), z.array(systemBlockSchema)]).optional();
// ─── Messages ──────────────────────────────────────────────────────────────
const userContentBlockSchema = z.union([
z.discriminatedUnion("type", [textBlockSchema, imageBlockSchema, toolResultBlockSchema]),
unknownContentBlockSchema,
]);
const assistantContentBlockSchema = z.union([
z.discriminatedUnion("type", [
textBlockSchema,
thinkingBlockSchema,
redactedThinkingBlockSchema,
toolUseBlockSchema,
]),
unknownContentBlockSchema,
]);
export const userMessageSchema = z.object({
role: z.literal("user"),
content: z.union([z.string(), z.array(userContentBlockSchema)]),
});
export const assistantMessageSchema = z.object({
role: z.literal("assistant"),
content: z.union([z.string(), z.array(assistantContentBlockSchema)]),
});
export const messageSchema = z.discriminatedUnion("role", [userMessageSchema, assistantMessageSchema]);
// ─── Tools ─────────────────────────────────────────────────────────────────
export const toolSchema = z.object({
name: z.string().min(1),
description: z.string().optional(),
input_schema: z.record(z.string(), z.unknown()),
cache_control: cacheControlSchema.optional(),
});
// ─── Tool choice ───────────────────────────────────────────────────────────
// `disable_parallel_tool_use` is accepted on every variant; the walker maps it
// onto `options.parallelToolCalls = !disable_parallel_tool_use`.
export const toolChoiceSchema = z.discriminatedUnion("type", [
z.object({ type: z.literal("auto"), disable_parallel_tool_use: z.boolean().optional() }),
z.object({ type: z.literal("any"), disable_parallel_tool_use: z.boolean().optional() }),
z.object({ type: z.literal("none"), disable_parallel_tool_use: z.boolean().optional() }),
z.object({
type: z.literal("tool"),
name: z.string().min(1),
disable_parallel_tool_use: z.boolean().optional(),
}),
]);
// ─── Thinking ──────────────────────────────────────────────────────────────
// Anthropic's three thinking shapes. `enabled` requires a budget; `disabled`
// suppresses reasoning even on models that default it on; `adaptive` lets the
// provider pick the budget on the fly. Extra hints (`display: "omitted"`, …)
// are accepted but ignored on the translate path.
export const thinkingConfigSchema = z.discriminatedUnion("type", [
z.object({
type: z.literal("enabled"),
budget_tokens: z.number(),
display: z.unknown().optional(),
}),
z.object({
type: z.literal("disabled"),
display: z.unknown().optional(),
}),
z.object({
type: z.literal("adaptive"),
budget_tokens: z.number().optional(),
display: z.unknown().optional(),
}),
]);
// ─── Top-level request ─────────────────────────────────────────────────────
export const anthropicMessagesRequestSchema = z.object({
model: z.string().min(1),
messages: z.array(messageSchema),
max_tokens: z.number(),
system: systemSchema,
tools: z.array(toolSchema).optional(),
tool_choice: toolChoiceSchema.optional(),
temperature: z.number().optional(),
top_p: z.number().optional(),
top_k: z.number().optional(),
stop_sequences: z.array(z.string()).optional(),
stream: z.boolean().optional(),
thinking: thinkingConfigSchema.optional(),
// Anthropic clients commonly send `metadata: { user_id }`; the walker
// surfaces it on `options.metadata` for downstream provider forwarding.
metadata: z.record(z.string(), z.unknown()).optional(),
// Spec fields that the gateway tolerates but doesn't translate yet.
container: z.unknown().optional(),
context_management: z.unknown().optional(),
mcp_servers: z.unknown().optional(),
service_tier: z.unknown().optional(),
});
/**
* Public types are sourced from the upstream Anthropic SDK so the gateway
* stays in lock-step with the canonical API surface; the schemas above are
* runtime validators for the subset we actually accept.
*/
export type AnthropicMessagesRequest = MessageCreateParams;
export type AnthropicSystem = MessageCreateParams["system"];
export type AnthropicMessage = MessageParam;
export type AnthropicUserContentBlock = ContentBlockParam;
export type AnthropicAssistantContentBlock = ContentBlockParam;
export type AnthropicTool = Tool;
export type AnthropicToolChoice = ToolChoice;
export type AnthropicToolResultContent = TextBlockParam | ImageBlockParam;
@@ -0,0 +1,677 @@
import { logger } from "@oh-my-pi/pi-utils";
import { captureRequestHeaders, resolvePromptCacheKey } from "../auth-gateway/http";
import type {
AssistantMessage,
AssistantMessageEventStream,
Message,
RedactedThinkingContent,
StopReason,
TextContent,
ThinkingContent,
Tool,
ToolCall,
ToolResultMessage,
UserMessage,
} from "../types";
import {
type AnthropicAssistantContentBlock,
type AnthropicMessage,
type AnthropicSystem,
type AnthropicTool,
type AnthropicToolChoice,
type AnthropicToolResultContent,
type AnthropicUserContentBlock,
anthropicMessagesRequestSchema,
} from "./anthropic-messages-server-schema";
/**
* Anthropic Messages API (https://docs.anthropic.com/en/api/messages) ↔ pi-ai
* gateway translation. Inbound: foreign HTTP body → omp Context. Outbound:
* omp AssistantMessage[Stream] → Anthropic-shaped JSON / SSE.
*/
import type { AuthGatewayParsedRequest as ParsedRequest } from "../auth-gateway/types";
export type { ParsedRequest };
// ---------------------------------------------------------------------------
// Inbound parsing
// ---------------------------------------------------------------------------
type ImageContentPart = { type: "image"; data: string; mimeType: string };
// Dedup noise from unknown-block-type warnings. Module-scoped so the warn
// fires once per (category, type) pair across the lifetime of the process.
const WARNED_UNKNOWN_BLOCK_TYPES = new Set<string>();
function warnUnknownBlockType(category: "user" | "assistant", blockType: string): void {
const key = `${category}:${blockType}`;
if (WARNED_UNKNOWN_BLOCK_TYPES.has(key)) return;
WARNED_UNKNOWN_BLOCK_TYPES.add(key);
logger.warn("anthropic-messages: unknown content block flattened to text placeholder", {
category,
blockType,
});
}
// pi-ai's `ImageContent` only carries base64 + mimeType. When the inbound
// uses `url` or `file_id` sources we surface a text placeholder so the
// downstream provider still sees a sane history; warn once per source kind.
const WARNED_NON_BASE64_IMAGE_SOURCES = new Set<string>();
function warnNonBase64ImageSource(sourceType: string): void {
if (WARNED_NON_BASE64_IMAGE_SOURCES.has(sourceType)) return;
WARNED_NON_BASE64_IMAGE_SOURCES.add(sourceType);
logger.warn("anthropic-messages: image source surfaced as text placeholder (pi-ai ImageContent lacks URL channel)", {
sourceType,
});
}
// Compact, log-safe stringification for unknown content blocks. Keeps the
// placeholder informative without dumping multi-KB structures into history.
function describeUnknownBlock(block: { type: string }): string {
try {
const json = JSON.stringify(block);
if (json !== undefined && json.length <= 200) return `[${block.type}: ${json}]`;
} catch {
// fall through
}
return `[${block.type}]`;
}
function buildSystemPrompt(raw: AnthropicSystem): string[] | undefined {
if (raw === undefined) return undefined;
if (typeof raw === "string") return raw.length > 0 ? [raw] : undefined;
const parts = raw.map(block => block.text).filter(text => text.length > 0);
return parts.length > 0 ? [parts.join("\n\n")] : undefined;
}
function makeUserMessage(parts: (TextContent | ImageContentPart)[], timestamp: number): UserMessage {
return {
role: "user",
content: parts.length === 1 && parts[0].type === "text" ? parts[0].text : parts,
timestamp,
};
}
function toolResultPartsFromBlocks(
content: AnthropicToolResultContent[] | string | undefined,
): (TextContent | ImageContentPart)[] {
if (content === undefined) return [];
if (typeof content === "string") return [{ type: "text", text: content }];
const out: (TextContent | ImageContentPart)[] = [];
for (const block of content) {
if (block.type === "text") {
out.push({ type: "text", text: block.text });
continue;
}
// block.type === "image" — schema only accepts base64 sources.
if (block.source.type === "base64") {
out.push({ type: "image", data: block.source.data, mimeType: block.source.media_type });
}
}
return out;
}
function walkUserContent(
blocks: string | AnthropicUserContentBlock[],
timestamp: number,
): (UserMessage | ToolResultMessage)[] {
const messages: (UserMessage | ToolResultMessage)[] = [];
const userParts: (TextContent | ImageContentPart)[] = [];
const flush = () => {
if (userParts.length === 0) return;
messages.push(makeUserMessage(userParts.splice(0), timestamp));
};
if (typeof blocks === "string") {
if (blocks.length > 0) userParts.push({ type: "text", text: blocks });
flush();
return messages;
}
for (const block of blocks) {
if (block.type === "text") {
userParts.push({ type: "text", text: block.text });
} else if (block.type === "image") {
// SDK's typed source covers base64+url; our schema also accepts the
// forward-compat `file` variant. Narrow against a widened shape so
// every variant is handled at runtime regardless of SDK lag.
const source = block.source as {
type: string;
data?: string;
media_type?: string;
url?: string;
file_id?: string;
};
if (source.type === "base64" && source.data && source.media_type) {
userParts.push({ type: "image", data: source.data, mimeType: source.media_type });
} else {
warnNonBase64ImageSource(source.type);
const ref =
source.type === "url" ? (source.url ?? "") : source.type === "file" ? (source.file_id ?? "") : "";
userParts.push({ type: "text", text: `[image: ${ref}]` });
}
} else if (block.type === "tool_result") {
// Anthropic permits tool_result blocks to follow plain text/image
// siblings in the same user message. pi-ai's history is a flat
// sequence of typed messages, so flush the accumulated parts as a
// separate UserMessage before emitting the ToolResultMessage.
flush();
messages.push({
role: "toolResult",
toolCallId: block.tool_use_id,
// Anthropic tool_results don't carry the tool name; downstream can rehydrate.
toolName: "",
content: toolResultPartsFromBlocks(block.content as AnthropicToolResultContent[] | string | undefined),
isError: block.is_error === true,
timestamp,
});
} else {
// Unknown variant (server_tool_use, mcp_*, document, web_search_tool_result,
// container_upload, code_execution_*, …). Flatten to a text placeholder
// so the downstream provider still gets a coherent transcript.
const unknown = block as { type: string };
warnUnknownBlockType("user", unknown.type);
userParts.push({ type: "text", text: describeUnknownBlock(unknown) });
}
}
flush();
return messages;
}
function walkAssistantContent(
blocks: string | AnthropicAssistantContentBlock[],
): (TextContent | ThinkingContent | RedactedThinkingContent | ToolCall)[] {
const out: (TextContent | ThinkingContent | RedactedThinkingContent | ToolCall)[] = [];
if (typeof blocks === "string") {
if (blocks.length > 0) out.push({ type: "text", text: blocks });
return out;
}
for (const block of blocks) {
switch (block.type) {
case "text":
out.push({ type: "text", text: block.text });
break;
case "thinking": {
const tc: ThinkingContent = { type: "thinking", thinking: block.thinking };
if (block.signature !== undefined) tc.thinkingSignature = block.signature;
out.push(tc);
break;
}
case "redacted_thinking":
out.push({ type: "redactedThinking", data: block.data });
break;
case "tool_use":
out.push({
type: "toolCall",
id: block.id,
name: block.name,
arguments: block.input ?? {},
});
break;
default: {
// Unknown assistant variant (server_tool_use, mcp_tool_use, …).
// Flatten to a text placeholder; warn once per unknown type.
const unknown = block as { type: string };
warnUnknownBlockType("assistant", unknown.type);
out.push({ type: "text", text: describeUnknownBlock(unknown) });
break;
}
}
}
return out;
}
function walkTools(tools: AnthropicTool[] | undefined): Tool[] | undefined {
if (!tools) return undefined;
return tools.map(tool => ({
name: tool.name,
description: tool.description ?? "",
parameters: tool.input_schema as Record<string, unknown>,
}));
}
function mapToolChoice(choice: AnthropicToolChoice | undefined): ParsedRequest["options"]["toolChoice"] {
if (!choice) return undefined;
switch (choice.type) {
case "auto":
return "auto";
case "any":
return "required";
case "none":
return "none";
case "tool":
return { name: choice.name };
}
}
type AnthropicCacheControl = { type: "ephemeral"; ttl?: "1h" | "5m" };
type HasCacheControl = { cache_control?: AnthropicCacheControl };
function readCacheControl(value: unknown): AnthropicCacheControl | undefined {
if (value === null || typeof value !== "object") return undefined;
const cc = (value as HasCacheControl).cache_control;
if (!cc || typeof cc !== "object" || cc.type !== "ephemeral") return undefined;
return cc;
}
/**
* Anthropic clients annotate caching breakpoints per block via
* `cache_control: { type: "ephemeral", ttl?: "1h"|"5m" }`. pi-ai's
* `cacheRetention` is per-request, not per-block, and its anthropic provider
* re-applies breakpoints itself on the rebuilt outbound wire. Scan every
* block once and return the strongest retention requested: any `ttl: "1h"`
* promotes the request to "long", anything else ephemeral maps to "short".
*/
function deriveCacheRetention(data: {
system?: unknown;
messages: readonly unknown[];
tools?: readonly unknown[];
}): "short" | "long" | undefined {
let strongest: "short" | "long" | undefined;
const visit = (cc: AnthropicCacheControl | undefined): void => {
if (!cc) return;
if (cc.ttl === "1h") strongest = "long";
else strongest ??= "short";
};
if (Array.isArray(data.system)) {
for (const block of data.system) visit(readCacheControl(block));
}
for (const message of data.messages) {
if (message === null || typeof message !== "object") continue;
const content = (message as { content?: unknown }).content;
if (!Array.isArray(content)) continue;
for (const block of content) visit(readCacheControl(block));
}
if (data.tools) {
for (const tool of data.tools) visit(readCacheControl(tool));
}
return strongest;
}
export function parseRequest(body: unknown, headers?: Headers): ParsedRequest {
const parsed = anthropicMessagesRequestSchema.safeParse(body);
if (!parsed.success) {
throw new Error(`anthropic-messages: ${parsed.error.message}`);
}
const data = parsed.data;
const now = Date.now();
const messages: Message[] = [];
for (const message of data.messages as AnthropicMessage[]) {
if (message.role === "user") {
for (const m of walkUserContent(message.content, now)) messages.push(m);
} else {
const assistant: AssistantMessage = {
role: "assistant",
content: walkAssistantContent(message.content),
api: "anthropic-messages",
provider: "anthropic",
model: data.model,
usage: emptyUsage(),
stopReason: "stop",
timestamp: now,
};
messages.push(assistant);
}
}
const options: ParsedRequest["options"] = {
maxOutputTokens: data.max_tokens,
};
if (data.temperature !== undefined) options.temperature = data.temperature;
if (data.top_p !== undefined) options.topP = data.top_p;
if (data.top_k !== undefined) options.topK = data.top_k;
if (data.stop_sequences) options.stopSequences = data.stop_sequences;
const toolChoice = mapToolChoice(data.tool_choice as AnthropicToolChoice | undefined);
if (toolChoice !== undefined) options.toolChoice = toolChoice;
// `disable_parallel_tool_use === true` means the client wants the model to
// emit at most one tool call per turn; map to pi-ai's negated boolean.
// Leave undefined when the field is absent or explicitly `false` so we
// don't override provider defaults.
if (data.tool_choice?.disable_parallel_tool_use === true) {
options.parallelToolCalls = false;
}
if (data.thinking) {
switch (data.thinking.type) {
case "enabled":
options.explicitThinkingBudgetTokens = data.thinking.budget_tokens;
break;
case "disabled":
options.disableReasoning = true;
break;
case "adaptive":
if (data.thinking.budget_tokens !== undefined) {
options.explicitThinkingBudgetTokens = data.thinking.budget_tokens;
}
break;
}
}
const cacheRetention = deriveCacheRetention(data);
if (cacheRetention !== undefined) options.cacheRetention = cacheRetention;
// Anthropic clients commonly send `metadata: { user_id }`; forward verbatim
// so downstream providers (and our anthropic-passthrough fast-path) can
// preserve abuse-tracking signal.
if (data.metadata !== undefined) {
options.metadata = data.metadata as Record<string, unknown>;
}
const cacheKey = resolvePromptCacheKey(body, headers);
if (cacheKey !== undefined) options.promptCacheKey = cacheKey;
// Allow-listed header capture. The gateway's `handleFormatEndpoint`
// already merges its own pre-capture under whatever the parser sets, but
// we populate here too so direct callers of `parseRequest` (tests, custom
// wrappers) see the same surface. `anthropic-version` is the most
// load-bearing — some downstream Anthropic-API targets reject requests
// missing it.
if (headers) {
const captured = captureRequestHeaders(headers);
if (Object.keys(captured).length > 0) options.headers = captured;
}
return {
modelId: data.model,
context: {
systemPrompt: buildSystemPrompt(data.system as AnthropicSystem),
messages,
tools: walkTools(data.tools as AnthropicTool[] | undefined),
},
stream: data.stream === true,
options,
};
}
function emptyUsage(): AssistantMessage["usage"] {
return {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
};
}
// ---------------------------------------------------------------------------
// Outbound encoding
// ---------------------------------------------------------------------------
function newMessageId(): string {
const hex = (globalThis.crypto?.randomUUID?.() ?? randomFallback()).replace(/-/g, "").slice(0, 24);
return `msg_${hex}`;
}
function randomFallback(): string {
// Sufficient for tests / environments without crypto.randomUUID
const buf = new Uint8Array(16);
for (let i = 0; i < 16; i++) buf[i] = Math.floor(Math.random() * 256);
const hex = Array.from(buf, b => b.toString(16).padStart(2, "0")).join("");
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
}
function mapStopReasonOut(reason: StopReason): "end_turn" | "max_tokens" | "tool_use" {
switch (reason) {
case "length":
return "max_tokens";
case "toolUse":
return "tool_use";
default:
return "end_turn";
}
}
function encodeContentBlocks(message: AssistantMessage): Record<string, unknown>[] {
const blocks: Record<string, unknown>[] = [];
for (const c of message.content) {
switch (c.type) {
case "text":
blocks.push({ type: "text", text: c.text });
break;
case "thinking": {
const b: Record<string, unknown> = { type: "thinking", thinking: c.thinking };
if (c.thinkingSignature) b.signature = c.thinkingSignature;
blocks.push(b);
break;
}
case "redactedThinking":
blocks.push({ type: "redacted_thinking", data: c.data });
break;
case "toolCall":
blocks.push({ type: "tool_use", id: c.id, name: c.name, input: c.arguments ?? {} });
break;
}
}
return blocks;
}
function encodeUsage(message: AssistantMessage): Record<string, unknown> {
return {
input_tokens: message.usage.input,
output_tokens: message.usage.output,
cache_read_input_tokens: message.usage.cacheRead,
cache_creation_input_tokens: message.usage.cacheWrite,
};
}
export function encodeResponse(message: AssistantMessage, requestedModelId: string): Record<string, unknown> {
if (message.stopReason === "error" || message.stopReason === "aborted") {
throw new Error(message.errorMessage ?? `anthropic-messages: upstream ${message.stopReason}`);
}
return {
id: message.responseId ?? newMessageId(),
type: "message",
role: "assistant",
model: requestedModelId,
content: encodeContentBlocks(message),
stop_reason: mapStopReasonOut(message.stopReason),
// TODO: surface the matched stop sequence once pi-ai's
// `AssistantMessage.stopReason` carries the matched string. Intentionally
// `null` for now (Anthropic schema allows it).
stop_sequence: null,
usage: encodeUsage(message),
};
}
// ---------------------------------------------------------------------------
// Streaming encoder
// ---------------------------------------------------------------------------
const ENCODER = new TextEncoder();
function sseFrame(event: string, data: Record<string, unknown>): Uint8Array {
return ENCODER.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
}
type BlockKind = "text" | "thinking" | "tool_use";
interface OpenBlock {
index: number;
kind: BlockKind;
}
export function encodeStream(
events: AssistantMessageEventStream,
requestedModelId: string,
): ReadableStream<Uint8Array> {
return new ReadableStream<Uint8Array>({
async start(controller) {
const messageId = newMessageId();
let started = false;
const open = new Map<number, OpenBlock>();
const ensureStart = (partial: AssistantMessage) => {
if (started) return;
started = true;
controller.enqueue(
sseFrame("message_start", {
type: "message_start",
message: {
id: messageId,
type: "message",
role: "assistant",
model: requestedModelId,
content: [],
stop_reason: null,
// TODO: same as encodeResponse — surface matched stop sequence
// once pi-ai propagates it.
stop_sequence: null,
usage: encodeUsage(partial),
},
}),
);
};
const closeBlock = (index: number) => {
if (!open.has(index)) return;
controller.enqueue(sseFrame("content_block_stop", { type: "content_block_stop", index }));
open.delete(index);
};
try {
for await (const ev of events) {
switch (ev.type) {
case "start":
ensureStart(ev.partial);
break;
case "text_start": {
ensureStart(ev.partial);
open.set(ev.contentIndex, { index: ev.contentIndex, kind: "text" });
controller.enqueue(
sseFrame("content_block_start", {
type: "content_block_start",
index: ev.contentIndex,
content_block: { type: "text", text: "" },
}),
);
break;
}
case "text_delta":
controller.enqueue(
sseFrame("content_block_delta", {
type: "content_block_delta",
index: ev.contentIndex,
delta: { type: "text_delta", text: ev.delta },
}),
);
break;
case "text_end":
closeBlock(ev.contentIndex);
break;
case "thinking_start": {
ensureStart(ev.partial);
open.set(ev.contentIndex, { index: ev.contentIndex, kind: "thinking" });
controller.enqueue(
sseFrame("content_block_start", {
type: "content_block_start",
index: ev.contentIndex,
content_block: { type: "thinking", thinking: "" },
}),
);
break;
}
case "thinking_delta":
controller.enqueue(
sseFrame("content_block_delta", {
type: "content_block_delta",
index: ev.contentIndex,
delta: { type: "thinking_delta", thinking: ev.delta },
}),
);
break;
case "thinking_end": {
const c = ev.partial.content[ev.contentIndex];
if (c?.type === "thinking" && c.thinkingSignature) {
controller.enqueue(
sseFrame("content_block_delta", {
type: "content_block_delta",
index: ev.contentIndex,
delta: { type: "signature_delta", signature: c.thinkingSignature },
}),
);
}
closeBlock(ev.contentIndex);
break;
}
case "toolcall_start": {
ensureStart(ev.partial);
const tc = ev.partial.content[ev.contentIndex] as ToolCall | undefined;
open.set(ev.contentIndex, { index: ev.contentIndex, kind: "tool_use" });
controller.enqueue(
sseFrame("content_block_start", {
type: "content_block_start",
index: ev.contentIndex,
content_block: {
type: "tool_use",
id: tc?.id ?? "",
name: tc?.name ?? "",
input: {},
},
}),
);
break;
}
case "toolcall_delta":
controller.enqueue(
sseFrame("content_block_delta", {
type: "content_block_delta",
index: ev.contentIndex,
delta: { type: "input_json_delta", partial_json: ev.delta },
}),
);
break;
case "toolcall_end":
closeBlock(ev.contentIndex);
break;
case "done": {
for (const idx of [...open.keys()]) closeBlock(idx);
controller.enqueue(
sseFrame("message_delta", {
type: "message_delta",
// TODO: surface matched stop sequence once pi-ai
// propagates it on the `done` event.
delta: { stop_reason: mapStopReasonOut(ev.reason), stop_sequence: null },
usage: encodeUsage(ev.message),
}),
);
controller.enqueue(sseFrame("message_stop", { type: "message_stop" }));
controller.close();
return;
}
case "error": {
const msg = ev.error.errorMessage ?? "stream error";
controller.enqueue(
sseFrame("error", { type: "error", error: { type: "api_error", message: msg } }),
);
controller.close();
return;
}
}
}
// stream ended without explicit done; close gracefully
for (const idx of [...open.keys()]) closeBlock(idx);
controller.enqueue(sseFrame("message_stop", { type: "message_stop" }));
controller.close();
} catch (err) {
controller.enqueue(
sseFrame("error", {
type: "error",
error: { type: "api_error", message: err instanceof Error ? err.message : String(err) },
}),
);
controller.close();
}
},
});
}
// ---------------------------------------------------------------------------
// Error envelope
// ---------------------------------------------------------------------------
/**
* Anthropic error envelope: `{ type: "error", error: { type, message } }`.
* See https://docs.anthropic.com/en/api/errors. Returned as a `Response` so
* the gateway can hand it straight back to the client without extra wrapping.
*/
export function formatError(status: number, type: string, message: string): Response {
return new Response(JSON.stringify({ type: "error", error: { type, message } }), {
status,
headers: { "Content-Type": "application/json" },
});
}
+172 -107
View File
@@ -15,6 +15,7 @@ import {
isEnoent,
isRetryableError,
isUnexpectedSocketCloseMessage,
logger,
readSseEvents,
} from "@oh-my-pi/pi-utils";
import { hasOpus47ApiRestrictions, mapEffortToAnthropicAdaptiveEffort } from "../model-thinking";
@@ -59,6 +60,7 @@ import { parseGitHubCopilotApiKey } from "../utils/oauth/github-copilot";
import { notifyProviderResponse } from "../utils/provider-response";
import { isCopilotTransientModelError } from "../utils/retry";
import { COMBINATOR_KEYS, NO_STRICT, toolWireSchema } from "../utils/schema";
import { spillToDescription } from "../utils/schema/spill";
import { notifyRawSseEvent, wrapFetchForSseDebug } from "../utils/sse-debug";
import {
buildCopilotDynamicHeaders,
@@ -203,6 +205,9 @@ type AnthropicSamplingParams = MessageCreateParamsStreaming & {
top_k?: number;
};
const ANTHROPIC_STOP_SEQUENCES_MAX = 4;
let warnedStopSequencesTrim = false;
/**
* Adaptive thinking `display` is supported starting with Claude Opus 4.7.
* Older adaptive-thinking models (Opus 4.6, Sonnet 4.6+) reject the field.
@@ -1293,7 +1298,11 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
}
providerRetryAttempt++;
const delayMs = PROVIDER_BASE_DELAY_MS * 2 ** (providerRetryAttempt - 1);
await scheduler.wait(delayMs, { signal: options?.signal });
if (options?.providerRetryWait) {
await options.providerRetryWait(delayMs, options.signal);
} else {
await scheduler.wait(delayMs, { signal: options?.signal });
}
output.content.length = 0;
output.responseId = undefined;
output.errorMessage = strictFallbackErrorMessage;
@@ -1780,6 +1789,18 @@ function buildParams(
if (options?.topK !== undefined) {
params.top_k = options.topK;
}
if (options?.stopSequences?.length) {
const seqs = options.stopSequences;
if (seqs.length > ANTHROPIC_STOP_SEQUENCES_MAX && !warnedStopSequencesTrim) {
warnedStopSequencesTrim = true;
logger.warn("anthropic: stop_sequences exceeds 4; extra entries dropped", {
received: seqs.length,
kept: ANTHROPIC_STOP_SEQUENCES_MAX,
});
}
params.stop_sequences =
seqs.length > ANTHROPIC_STOP_SEQUENCES_MAX ? seqs.slice(0, ANTHROPIC_STOP_SEQUENCES_MAX) : seqs;
}
// Opus 4.7+ rejects non-default sampling parameters with 400 error.
if (hasOpus47ApiRestrictions(model.id)) {
@@ -2073,28 +2094,56 @@ export function convertAnthropicMessages(
}
/**
* JSON Schema keywords Anthropic's tool-schema validator rejects on every node type.
* Mirrors the keys that fall through to the description-spill branch in the Anthropic
* Python SDK's `lib/_parse/_transform.py::transform_schema`.
* JSON Schema whitelist for Anthropic tool `input_schema` nodes.
*
* We use `Set` here (not `Record<string, true>`) because membership is probed against
* arbitrary user/Zod-derived schema keys: with a literal Record, lookups for prototype
* names like `"toString"` would falsely match and silently strip valid properties.
* Mirrors the Anthropic Python SDK's `lib/_parse/_transform.py::transform_schema`:
* we keep only structural/metadata keywords Anthropic's validator honors, and demote
* anything else into the node's `description` as `\n\n{key: value, ...}` so the model
* still sees the constraint as a natural-language hint.
*
* `Set` (not `Record<string, true>`) because membership is probed against arbitrary
* user/Zod-derived schema keys: a literal Record would falsely match prototype names
* like `"toString"` and silently strip valid properties.
*/
const ANTHROPIC_UNSUPPORTED_TOOL_SCHEMA_FIELDS = new Set(["maxItems", "patternProperties", "propertyNames"]);
const ANTHROPIC_TOOL_SCHEMA_UNIVERSAL_KEEP = new Set([
"$ref",
"$defs",
"$schema",
"definitions",
"type",
"anyOf",
"oneOf",
"allOf",
"enum",
"const",
"description",
"title",
"default",
"nullable",
]);
/** Keys preserved on `type: "object"` nodes (in addition to the universal set). */
const ANTHROPIC_TOOL_SCHEMA_OBJECT_KEEP = new Set(["properties", "required", "additionalProperties"]);
/** Keys preserved on `type: "array"` nodes; `minItems` only when its value is 0 or 1. */
const ANTHROPIC_TOOL_SCHEMA_ARRAY_KEEP = new Set(["items", "prefixItems", "minItems"]);
/** Keys preserved on `type: "string"` nodes; `format` only when its value is in the supported list. */
const ANTHROPIC_TOOL_SCHEMA_STRING_KEEP = new Set(["format"]);
/**
* JSON Schema keywords Anthropic rejects specifically on `number`/`integer` nodes
* ("For 'number' type, properties maximum, minimum are not supported"). These are
* still useful hints for the model, so callers demote them into the node's
* `description` rather than dropping them outright.
* String `format` values Anthropic accepts; everything else (including `pattern`-style
* format hints) gets demoted into `description`. Matches `SupportedStringFormats` in the
* Anthropic SDK's `_transform.py`.
*/
const ANTHROPIC_UNSUPPORTED_NUMERIC_FIELDS = [
"minimum",
"maximum",
"exclusiveMinimum",
"exclusiveMaximum",
"multipleOf",
] as const;
const ANTHROPIC_TOOL_SCHEMA_STRING_FORMATS = new Set([
"date-time",
"time",
"date",
"duration",
"email",
"hostname",
"uri",
"ipv4",
"ipv6",
"uuid",
]);
const ANTHROPIC_STRICT_TOOL_ALLOWLIST = new Set(["bash", "python", "edit", "find"]);
const MAX_ANTHROPIC_STRICT_TOOLS = 20;
const MAX_ANTHROPIC_STRICT_OPTIONAL_PARAMETERS = 24;
@@ -2117,132 +2166,148 @@ function isJsonSchemaObjectNode(schema: Record<string, unknown>): boolean {
}
/**
* Demote unsupported JSON Schema keywords into the node's `description` so the model
* still gets the constraint as a natural-language hint after we strip it from the wire
* schema. Mirrors the trailing description-spill in the Anthropic Python SDK's
* `lib/_parse/_transform.py::transform_schema`, formatted as `{key: value, ...}`.
*
* `entries` are applied in order and only when the value is not `undefined`; an empty
* input is a no-op so callers can pass the same set unconditionally.
* Pick the principal non-null scalar type from a `type` keyword. Anthropic accepts
* `type` as either a single string or an array (e.g. `["number", "null"]` for a
* nullable value); the SDK whitelist is keyed off the scalar type, with `"null"`
* ignored so nullable variants are normalized as their underlying type.
*/
function spillToDescription(node: Record<string, unknown>, entries: Array<[string, unknown]>): void {
const spilled = entries.filter(([, value]) => value !== undefined);
if (spilled.length === 0) return;
const formatted = `{${spilled.map(([key, value]) => `${key}: ${JSON.stringify(value)}`).join(", ")}}`;
const existing = typeof node.description === "string" ? node.description : "";
node.description = existing ? `${existing}\n\n${formatted}` : formatted;
function pickAnthropicScalarType(type: unknown): string | undefined {
if (typeof type === "string") return type;
if (Array.isArray(type)) {
for (const entry of type) {
if (typeof entry === "string" && entry !== "null") return entry;
}
}
return undefined;
}
function anthropicPerTypeKeep(scalarType: string | undefined): Set<string> | undefined {
switch (scalarType) {
case "object":
return ANTHROPIC_TOOL_SCHEMA_OBJECT_KEEP;
case "array":
return ANTHROPIC_TOOL_SCHEMA_ARRAY_KEEP;
case "string":
return ANTHROPIC_TOOL_SCHEMA_STRING_KEEP;
default:
return undefined;
}
}
/**
* Strip `keys` off `node` and spill the removed values into its `description`.
* Per-schema-object memoization slot for the normalized Anthropic tool form. We stamp
* the result onto the host via a `Symbol` property (mirroring `utils/schema/stamps.ts`)
* instead of using a `WeakMap`: it's a single hidden-class slot, so warm reads are
* direct property access and write-once cycles resolve to the in-progress result.
*/
function spillKeysToDescription(node: Record<string, unknown>, keys: readonly string[]): void {
const entries: Array<[string, unknown]> = [];
for (const key of keys) {
const value = node[key];
if (value === undefined) continue;
entries.push([key, value]);
delete node[key];
}
spillToDescription(node, entries);
}
const kAnthropicToolNormal = Symbol("pi.schema.anthropic.toolNormal");
export function normalizeAnthropicToolSchema(
schema: unknown,
cache: WeakMap<Record<string, unknown>, Record<string, unknown>> = new WeakMap(),
): unknown {
/**
* Normalize a JSON Schema node for Anthropic tool `input_schema`.
*
* Applies the full whitelist semantics from the Anthropic Python SDK's
* `lib/_parse/_transform.py::transform_schema`:
*
* 1. Universal keys (`$ref`, `$defs`, `type`, `anyOf`/`oneOf`/`allOf`, `enum`, `const`,
* `description`, `title`, `default`, `nullable`) are preserved on every node.
* 2. Per-type keys are kept additively (object → `properties`/`required`/`additionalProperties`,
* array → `items`/`prefixItems` plus `minItems` only when 0 or 1, string → `format`
* only when in the supported value set).
* 3. Everything else is demoted into the node's `description` as `\n\n{key: value, ...}`
* so the model still sees the constraint as a natural-language hint.
*
* Object nodes default to `additionalProperties: false`, but explicit open-map
* declarations (`additionalProperties: true` or a schema literal — Zod's
* `z.record(z.string(), z.unknown())` produces `{}`) are preserved. The strict-mode
* pass downstream demotes those shapes to non-strict instead of fabricating a closed
* object, so callers like the resolve tool keep working open-map semantics.
*/
export function normalizeAnthropicToolSchema(schema: unknown): unknown {
if (Array.isArray(schema)) return schema.map(entry => normalizeAnthropicToolSchema(entry));
if (!isRecord(schema)) return schema;
const cached = cache.get(schema);
if (cached) return cached;
const slot = schema as Record<symbol, Record<string, unknown> | undefined>;
const existing = slot[kAnthropicToolNormal];
if (existing !== undefined) return existing;
const result: Record<string, unknown> = {};
cache.set(schema, result);
const universalSpill: Array<[string, unknown]> = [];
// Pre-stamp before recursion so cyclic schemas resolve to the in-progress object
// (mirrors the WeakMap-set-before-recurse pattern the original implementation used).
Object.defineProperty(schema, kAnthropicToolNormal, { value: result, writable: true, configurable: true });
const scalarType = pickAnthropicScalarType(schema.type);
const perTypeKeep = anthropicPerTypeKeep(scalarType);
const spill: Array<[string, unknown]> = [];
for (const key in schema) {
if (!Object.hasOwn(schema, key)) continue;
const value = schema[key];
if (ANTHROPIC_UNSUPPORTED_TOOL_SCHEMA_FIELDS.has(key)) {
universalSpill.push([key, value]);
continue;
if (ANTHROPIC_TOOL_SCHEMA_UNIVERSAL_KEEP.has(key) || perTypeKeep?.has(key)) {
result[key] = value;
} else {
spill.push([key, value]);
}
result[key] = value;
}
if (isJsonSchemaObjectNode(result)) {
// `minItems` is meaningless on objects; Anthropic rejects it even for 0/1.
if (result.minItems !== undefined) universalSpill.push(["minItems", result.minItems]);
delete result.minItems;
} else {
// Per-type conditional keys: prune within the kept set.
if (scalarType === "string") {
const format = result.format;
if (typeof format === "string" && !ANTHROPIC_TOOL_SCHEMA_STRING_FORMATS.has(format)) {
spill.push(["format", format]);
delete result.format;
}
}
if (scalarType === "array" && result.minItems !== undefined) {
const minItems = result.minItems;
if (typeof minItems === "number" && minItems !== 0 && minItems !== 1) {
universalSpill.push(["minItems", minItems]);
if (!(typeof minItems === "number" && (minItems === 0 || minItems === 1))) {
spill.push(["minItems", minItems]);
delete result.minItems;
}
}
spillToDescription(result, universalSpill);
const nodeType = result.type;
const isNumericNode =
nodeType === "number" ||
nodeType === "integer" ||
(Array.isArray(nodeType) && nodeType.some(t => t === "number" || t === "integer"));
if (isNumericNode) spillKeysToDescription(result, ANTHROPIC_UNSUPPORTED_NUMERIC_FIELDS);
const type = result.type;
const canBeObject =
type === "object" || (Array.isArray(type) && type.includes("object")) || isRecord(result.properties);
if (canBeObject) {
// Preserve explicit open-map declarations: `additionalProperties: true`
// and schema values such as `{}` (Zod's
// `z.record(z.string(), z.unknown())` output). Only close objects that
// left the field unspecified, so we don't silently strip a valid
// open-map declaration along with unsupported `patternProperties` /
// `propertyNames` keywords. Without this, fields like the resolve tool's
// `extra` are flattened to `{ type: "object", additionalProperties: false }`,
// which forbids every key and breaks plan approval (`extra: { title }`).
if (result.additionalProperties === undefined) {
result.additionalProperties = false;
} else if (isRecord(result.additionalProperties)) {
result.additionalProperties = normalizeAnthropicToolSchema(result.additionalProperties, cache);
}
if (scalarType === "object" && result.additionalProperties === undefined) {
result.additionalProperties = false;
}
// Recurse on structural keys.
if (isRecord(result.properties)) {
result.properties = Object.fromEntries(
Object.entries(result.properties).map(([propertyName, propertySchema]) => [
propertyName,
normalizeAnthropicToolSchema(propertySchema, cache),
]),
);
const normalizedProperties: Record<string, unknown> = {};
const sourceProperties = result.properties as Record<string, unknown>;
for (const propName in sourceProperties) {
if (!Object.hasOwn(sourceProperties, propName)) continue;
normalizedProperties[propName] = normalizeAnthropicToolSchema(sourceProperties[propName]);
}
result.properties = normalizedProperties;
}
if (isRecord(result.additionalProperties)) {
result.additionalProperties = normalizeAnthropicToolSchema(result.additionalProperties);
}
if (Array.isArray(result.items)) {
result.items = result.items.map(item => normalizeAnthropicToolSchema(item, cache));
result.items = result.items.map(item => normalizeAnthropicToolSchema(item));
} else if (isRecord(result.items)) {
result.items = normalizeAnthropicToolSchema(result.items, cache);
result.items = normalizeAnthropicToolSchema(result.items);
}
if (Array.isArray(result.prefixItems)) {
result.prefixItems = result.prefixItems.map(item => normalizeAnthropicToolSchema(item, cache));
result.prefixItems = result.prefixItems.map(item => normalizeAnthropicToolSchema(item));
}
for (const key of COMBINATOR_KEYS) {
const variants = result[key];
if (Array.isArray(variants)) {
result[key] = variants.map(variant => normalizeAnthropicToolSchema(variant, cache));
result[key] = variants.map(variant => normalizeAnthropicToolSchema(variant));
}
}
for (const defsKey of ["$defs", "definitions"] as const) {
const definitions = result[defsKey];
if (!isRecord(definitions)) continue;
result[defsKey] = Object.fromEntries(
Object.entries(definitions).map(([definitionName, definitionSchema]) => [
definitionName,
normalizeAnthropicToolSchema(definitionSchema, cache),
]),
);
const normalizedDefs: Record<string, unknown> = {};
const sourceDefs = definitions as Record<string, unknown>;
for (const name in sourceDefs) {
if (!Object.hasOwn(sourceDefs, name)) continue;
normalizedDefs[name] = normalizeAnthropicToolSchema(sourceDefs[name]);
}
result[defsKey] = normalizedDefs;
}
spillToDescription(result, spill);
return result;
}
@@ -0,0 +1,334 @@
/**
* AWS credential resolution for the Bedrock provider.
*
* Chain (first hit wins):
* 1. Static credentials from the environment
* (`AWS_ACCESS_KEY_ID` + `AWS_SECRET_ACCESS_KEY` [+ `AWS_SESSION_TOKEN`]).
* 2. Profile in `~/.aws/credentials` (and `~/.aws/config` for SSO):
* - static `aws_access_key_id` / `aws_secret_access_key` / `aws_session_token`
* - SSO profile referencing a cached token in `~/.aws/sso/cache/*.json`,
* which we exchange for short-lived role credentials via
* `https://portal.sso.{region}.amazonaws.com/federation/credentials`.
* 3. EC2 IMDSv2 (only when `AWS_EC2_METADATA_DISABLED` is unset / falsey and
* `169.254.169.254` is reachable within a 1 s timeout).
*
* Resolved credentials are cached process-wide per profile and refreshed
* 60 s before `Expiration` to absorb clock skew.
*/
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { $env, isEnoent, logger } from "@oh-my-pi/pi-utils";
import type { AwsCredentials } from "./aws-sigv4";
export interface ResolvedCredentials extends AwsCredentials {
/** Absolute expiration timestamp in ms. `undefined` for non-expiring static creds. */
expiresAt?: number;
}
export interface CredentialResolveOptions {
/** Named profile from `~/.aws/credentials` / `~/.aws/config`. */
profile?: string;
/** Falls back to env (`AWS_REGION` / `AWS_DEFAULT_REGION`) and finally `us-east-1`. */
region?: string;
signal?: AbortSignal;
}
const REFRESH_SKEW_MS = 60_000;
interface CacheEntry {
creds: ResolvedCredentials;
expiresAt: number;
}
const cache: Map<string, CacheEntry> = new Map();
export async function resolveAwsCredentials(opts: CredentialResolveOptions = {}): Promise<ResolvedCredentials> {
const profile = opts.profile || $env.AWS_PROFILE || "default";
const region = opts.region || $env.AWS_REGION || $env.AWS_DEFAULT_REGION || "us-east-1";
const cacheKey = `${profile}\x00${region}`;
const hit = cache.get(cacheKey);
if (hit && hit.expiresAt - REFRESH_SKEW_MS > Date.now()) return hit.creds;
const creds = await resolveFresh(profile, region, opts.signal);
cache.set(cacheKey, { creds, expiresAt: creds.expiresAt ?? Number.POSITIVE_INFINITY });
return creds;
}
async function resolveFresh(profile: string, region: string, signal?: AbortSignal): Promise<ResolvedCredentials> {
// 1. Environment first — matches the AWS SDK chain order.
const envCreds = readEnvCredentials();
if (envCreds) return envCreds;
// 2. Profile (static or SSO).
const profileCreds = await readProfileCredentials(profile, region, signal);
if (profileCreds) return profileCreds;
// 3. EC2 IMDSv2.
if ($env.AWS_EC2_METADATA_DISABLED?.toLowerCase() !== "true") {
const imdsCreds = await readImdsCredentials(signal);
if (imdsCreds) return imdsCreds;
}
throw new Error(
`Unable to resolve AWS credentials. Set AWS_ACCESS_KEY_ID+AWS_SECRET_ACCESS_KEY, ` +
`or configure profile '${profile}' in ~/.aws/credentials (or ~/.aws/config for SSO).`,
);
}
function readEnvCredentials(): ResolvedCredentials | undefined {
const ak = $env.AWS_ACCESS_KEY_ID;
const sk = $env.AWS_SECRET_ACCESS_KEY;
if (!ak || !sk) return undefined;
const token = $env.AWS_SESSION_TOKEN;
return token
? { accessKeyId: ak, secretAccessKey: sk, sessionToken: token }
: { accessKeyId: ak, secretAccessKey: sk };
}
// ---------- INI parsing ----------
/** Map of section name -> map of key -> value. Section names are stripped of
* any leading `profile ` (so `~/.aws/config` aligns with `~/.aws/credentials`). */
type IniFile = Record<string, Record<string, string>>;
function parseIni(text: string): IniFile {
const out: IniFile = {};
let current: Record<string, string> | null = null;
for (const rawLine of text.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line || line.startsWith("#") || line.startsWith(";")) continue;
if (line.startsWith("[") && line.endsWith("]")) {
let name = line.slice(1, -1).trim();
if (name.startsWith("profile ")) name = name.slice(8).trim();
if (name.startsWith("sso-session ")) name = `sso-session:${name.slice(12).trim()}`;
let section = out[name];
if (!section) {
section = {};
out[name] = section;
}
current = section;
continue;
}
if (!current) continue;
const eq = line.indexOf("=");
if (eq === -1) continue;
current[line.slice(0, eq).trim()] = line.slice(eq + 1).trim();
}
return out;
}
async function readIniFile(p: string): Promise<IniFile | undefined> {
try {
const text = await fs.promises.readFile(p, "utf8");
return parseIni(text);
} catch (err) {
if (isEnoent(err)) return undefined;
throw err;
}
}
// ---------- Profile / SSO ----------
async function readProfileCredentials(
profile: string,
region: string,
signal: AbortSignal | undefined,
): Promise<ResolvedCredentials | undefined> {
const home = os.homedir();
const credentialsPath = $env.AWS_SHARED_CREDENTIALS_FILE || path.join(home, ".aws", "credentials");
const configPath = $env.AWS_CONFIG_FILE || path.join(home, ".aws", "config");
const credentialsIni = await readIniFile(credentialsPath);
const configIni = await readIniFile(configPath);
// Static credentials live in ~/.aws/credentials; SSO config lives in
// ~/.aws/config under `[profile foo]`. Merge into a single view.
const merged: Record<string, string> = { ...(configIni?.[profile] ?? {}), ...(credentialsIni?.[profile] ?? {}) };
if (Object.keys(merged).length === 0) return undefined;
if (merged.aws_access_key_id && merged.aws_secret_access_key) {
const out: ResolvedCredentials = {
accessKeyId: merged.aws_access_key_id,
secretAccessKey: merged.aws_secret_access_key,
};
if (merged.aws_session_token) out.sessionToken = merged.aws_session_token;
return out;
}
if (merged.sso_account_id && merged.sso_role_name) {
return readSsoCredentials(merged, configIni, region, signal);
}
return undefined;
}
interface SsoCachedToken {
accessToken?: string;
expiresAt?: string;
startUrl?: string;
region?: string;
}
async function readSsoCredentials(
profileCfg: Record<string, string>,
configIni: IniFile | undefined,
defaultRegion: string,
signal: AbortSignal | undefined,
): Promise<ResolvedCredentials | undefined> {
// Two SSO profile shapes:
// - legacy: `sso_start_url` + `sso_region` directly on the profile
// - sso-session: `sso_session = my-session` references a `[sso-session my-session]` block
let startUrl = profileCfg.sso_start_url;
let ssoRegion = profileCfg.sso_region;
const sessionName = profileCfg.sso_session;
if (sessionName && configIni) {
const session = configIni[`sso-session:${sessionName}`];
if (session) {
startUrl = startUrl || session.sso_start_url;
ssoRegion = ssoRegion || session.sso_region;
}
}
if (!startUrl || !ssoRegion) return undefined;
const token = await loadSsoCachedToken(startUrl, sessionName);
if (!token?.accessToken) {
throw new Error(`AWS SSO token for ${startUrl} not found in ~/.aws/sso/cache. Run 'aws sso login' first.`);
}
const expiresAt = token.expiresAt ? Date.parse(token.expiresAt) : Number.POSITIVE_INFINITY;
if (Number.isFinite(expiresAt) && expiresAt <= Date.now()) {
throw new Error(`AWS SSO token for ${startUrl} has expired. Run 'aws sso login' to refresh.`);
}
const url =
`https://portal.sso.${ssoRegion}.amazonaws.com/federation/credentials` +
`?account_id=${encodeURIComponent(profileCfg.sso_account_id)}` +
`&role_name=${encodeURIComponent(profileCfg.sso_role_name)}`;
const response = await fetch(url, {
method: "GET",
headers: { "x-amz-sso_bearer_token": token.accessToken },
signal,
});
if (!response.ok) {
const body = await response.text().catch(() => "");
throw new Error(`AWS SSO GetRoleCredentials failed: ${response.status} ${body.slice(0, 200)}`);
}
const json = (await response.json()) as {
roleCredentials?: { accessKeyId: string; secretAccessKey: string; sessionToken: string; expiration: number };
};
const role = json.roleCredentials;
if (!role) throw new Error("AWS SSO GetRoleCredentials: missing roleCredentials in response");
// region is honored at the caller; we only consume defaultRegion to keep the
// param wired for symmetry with other resolution paths.
void defaultRegion;
return {
accessKeyId: role.accessKeyId,
secretAccessKey: role.secretAccessKey,
sessionToken: role.sessionToken,
expiresAt: role.expiration,
};
}
async function loadSsoCachedToken(
startUrl: string,
sessionName: string | undefined,
): Promise<SsoCachedToken | undefined> {
const cacheDir = path.join(os.homedir(), ".aws", "sso", "cache");
let entries: string[];
try {
entries = await fs.promises.readdir(cacheDir);
} catch (err) {
if (isEnoent(err)) return undefined;
throw err;
}
// Prefer the deterministic hash for legacy `sso_start_url` profiles or the
// session name for the newer `sso-session` shape; otherwise scan.
const candidates: string[] = [];
const hash = await sha1Hex(sessionName || startUrl);
candidates.push(`${hash}.json`);
for (const entry of entries) {
if (entry.endsWith(".json") && !candidates.includes(entry)) candidates.push(entry);
}
for (const file of candidates) {
if (!entries.includes(file)) continue;
try {
const text = await fs.promises.readFile(path.join(cacheDir, file), "utf8");
const parsed = JSON.parse(text) as SsoCachedToken;
if (parsed.startUrl === startUrl || (sessionName && file === `${hash}.json`)) {
return parsed;
}
} catch (err) {
logger.debug("aws-credentials: failed to read SSO cache", { file, err: String(err) });
}
}
return undefined;
}
async function sha1Hex(input: string): Promise<string> {
const digest = await globalThis.crypto.subtle.digest("SHA-1", new TextEncoder().encode(input));
const bytes = new Uint8Array(digest);
let out = "";
for (let i = 0; i < bytes.length; i++) out += bytes[i].toString(16).padStart(2, "0");
return out;
}
// ---------- IMDSv2 ----------
const IMDS_HOST = "169.254.169.254";
const IMDS_TIMEOUT_MS = 1000;
async function readImdsCredentials(parentSignal: AbortSignal | undefined): Promise<ResolvedCredentials | undefined> {
const timeout = AbortSignal.timeout(IMDS_TIMEOUT_MS);
const signal = parentSignal ? AbortSignal.any([parentSignal, timeout]) : timeout;
try {
const tokenRes = await fetch(`http://${IMDS_HOST}/latest/api/token`, {
method: "PUT",
headers: { "x-aws-ec2-metadata-token-ttl-seconds": "21600" },
signal,
});
if (!tokenRes.ok) return undefined;
const token = await tokenRes.text();
const roleRes = await fetch(`http://${IMDS_HOST}/latest/meta-data/iam/security-credentials/`, {
headers: { "x-aws-ec2-metadata-token": token },
signal,
});
if (!roleRes.ok) return undefined;
const role = (await roleRes.text()).trim();
if (!role) return undefined;
const credsRes = await fetch(
`http://${IMDS_HOST}/latest/meta-data/iam/security-credentials/${encodeURIComponent(role)}`,
{
headers: { "x-aws-ec2-metadata-token": token },
signal,
},
);
if (!credsRes.ok) return undefined;
const body = (await credsRes.json()) as {
AccessKeyId?: string;
SecretAccessKey?: string;
Token?: string;
Expiration?: string;
};
if (!body.AccessKeyId || !body.SecretAccessKey) return undefined;
const out: ResolvedCredentials = {
accessKeyId: body.AccessKeyId,
secretAccessKey: body.SecretAccessKey,
};
if (body.Token) out.sessionToken = body.Token;
if (body.Expiration) out.expiresAt = Date.parse(body.Expiration);
return out;
} catch {
return undefined;
}
}
/** Test/diagnostic helper — drops cached credentials. */
export function clearAwsCredentialCache(): void {
cache.clear();
}
@@ -0,0 +1,185 @@
/**
* `application/vnd.amazon.eventstream` decoder.
*
* Wire format (all integers big-endian):
*
* [total length u32]
* [headers length u32]
* [prelude CRC32 u32] <- CRC over the first 8 bytes
* [headers headers_length]
* [payload total_length - headers_length - 16]
* [message CRC32 u32] <- CRC over the entire message minus the trailing 4 bytes
*
* Headers: a sequence of `[name_len u8][name utf8][value_type u8][value …]`.
* We only need the typed values Bedrock emits (boolean true/false, byte, short,
* integer, long, byte-array, string, timestamp, uuid). All are surfaced as
* strings for ease of consumption — Bedrock only sets string-valued headers in
* practice (`:event-type`, `:message-type`, `:content-type`, `:exception-type`).
*/
const PRELUDE_LEN = 8;
const PRELUDE_CRC_LEN = 4;
const MESSAGE_CRC_LEN = 4;
const HEADER_BLOCK_OFFSET = PRELUDE_LEN + PRELUDE_CRC_LEN;
const MIN_MESSAGE_LEN = HEADER_BLOCK_OFFSET + MESSAGE_CRC_LEN;
export interface EventStreamMessage {
/** Lower-cased copy is *not* applied — Bedrock uses casing like `:event-type` verbatim. */
headers: Record<string, string>;
payload: Uint8Array;
}
/** CRC32 (IEEE / zlib polynomial 0xEDB88320), matches `@aws-crypto/crc32`. */
const CRC_TABLE = (() => {
const t = new Uint32Array(256);
for (let i = 0; i < 256; i++) {
let c = i;
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
t[i] = c >>> 0;
}
return t;
})();
export function crc32(bytes: Uint8Array, seed = 0): number {
let c = (seed ^ 0xffffffff) >>> 0;
for (let i = 0; i < bytes.length; i++) c = (CRC_TABLE[(c ^ bytes[i]) & 0xff] ^ (c >>> 8)) >>> 0;
return (c ^ 0xffffffff) >>> 0;
}
/**
* Decode a single, fully buffered eventstream message. Throws if the framing is
* malformed or either CRC mismatches. Used by both `decodeEventStream` (the
* streaming entry point) and the unit tests, which exercise it with hand-built
* frames.
*/
export function decodeMessage(frame: Uint8Array): EventStreamMessage {
if (frame.length < MIN_MESSAGE_LEN) throw new Error("eventstream: frame too short");
const view = new DataView(frame.buffer, frame.byteOffset, frame.byteLength);
const total = view.getUint32(0, false);
if (total !== frame.length) throw new Error(`eventstream: framed length ${total} != buffer ${frame.length}`);
const headersLen = view.getUint32(4, false);
const preludeCrc = view.getUint32(8, false);
const computedPreludeCrc = crc32(frame.subarray(0, PRELUDE_LEN));
if (computedPreludeCrc !== preludeCrc) throw new Error("eventstream: prelude CRC mismatch");
const msgCrc = view.getUint32(total - MESSAGE_CRC_LEN, false);
const computedMsgCrc = crc32(frame.subarray(0, total - MESSAGE_CRC_LEN));
if (computedMsgCrc !== msgCrc) throw new Error("eventstream: message CRC mismatch");
const headersBytes = frame.subarray(HEADER_BLOCK_OFFSET, HEADER_BLOCK_OFFSET + headersLen);
const payload = frame.subarray(HEADER_BLOCK_OFFSET + headersLen, total - MESSAGE_CRC_LEN);
return { headers: parseHeaders(headersBytes), payload };
}
function parseHeaders(buf: Uint8Array): Record<string, string> {
const out: Record<string, string> = {};
const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
const decoder = new TextDecoder();
let p = 0;
while (p < buf.length) {
const nameLen = view.getUint8(p);
p += 1;
const name = decoder.decode(buf.subarray(p, p + nameLen));
p += nameLen;
const type = view.getUint8(p);
p += 1;
switch (type) {
case 0: // bool true
out[name] = "true";
break;
case 1: // bool false
out[name] = "false";
break;
case 2: // byte
out[name] = String(view.getInt8(p));
p += 1;
break;
case 3: // short
out[name] = String(view.getInt16(p, false));
p += 2;
break;
case 4: // integer
out[name] = String(view.getInt32(p, false));
p += 4;
break;
case 5: // long — surface as decimal string to avoid precision loss
out[name] = bigIntFromBytes(buf.subarray(p, p + 8)).toString();
p += 8;
break;
case 6: {
// byte array — base64 for safe transport
const len = view.getUint16(p, false);
p += 2;
out[name] = Buffer.from(buf.buffer, buf.byteOffset + p, len).toString("base64");
p += len;
break;
}
case 7: {
// string
const len = view.getUint16(p, false);
p += 2;
out[name] = decoder.decode(buf.subarray(p, p + len));
p += len;
break;
}
case 8: // timestamp (ms since epoch as i64)
out[name] = new Date(Number(bigIntFromBytes(buf.subarray(p, p + 8)))).toISOString();
p += 8;
break;
case 9: {
// uuid
const u = buf.subarray(p, p + 16);
const hex: string[] = [];
for (let i = 0; i < 16; i++) hex.push(u[i].toString(16).padStart(2, "0"));
out[name] =
`${hex.slice(0, 4).join("")}-${hex.slice(4, 6).join("")}-${hex.slice(6, 8).join("")}-${hex.slice(8, 10).join("")}-${hex.slice(10, 16).join("")}`;
p += 16;
break;
}
default:
throw new Error(`eventstream: unknown header value type ${type}`);
}
}
return out;
}
function bigIntFromBytes(b: Uint8Array): bigint {
let v = 0n;
for (let i = 0; i < b.length; i++) v = (v << 8n) | BigInt(b[i]);
// sign-extend (two's complement)
if (b.length === 8 && b[0] & 0x80) v -= 1n << 64n;
return v;
}
/**
* Async generator that consumes a `ReadableStream<Uint8Array>` (e.g. a fetch
* response body) and yields fully-framed messages. Handles arbitrary chunk
* boundaries: messages may span multiple chunks, and a single chunk may carry
* many messages.
*/
export async function* decodeEventStream(source: ReadableStream<Uint8Array>): AsyncGenerator<EventStreamMessage> {
const reader = source.getReader();
// Single growable buffer; we slide a read cursor along it and compact when a
// complete prefix has been consumed. Avoids per-message Uint8Array copies.
let buf: Uint8Array<ArrayBufferLike> = new Uint8Array(0);
try {
while (true) {
const { value, done } = await reader.read();
if (value && value.length > 0) buf = buf.length === 0 ? value : Buffer.concat([buf, value]);
let offset = 0;
while (buf.length - offset >= 4) {
const dv = new DataView(buf.buffer, buf.byteOffset + offset, buf.length - offset);
const total = dv.getUint32(0, false);
if (total < MIN_MESSAGE_LEN) throw new Error(`eventstream: total length ${total} below minimum`);
if (buf.length - offset < total) break;
const frame = buf.subarray(offset, offset + total);
yield decodeMessage(frame);
offset += total;
}
if (offset > 0) buf = buf.slice(offset);
if (done) break;
}
if (buf.length > 0) throw new Error("eventstream: truncated message at end of stream");
} finally {
reader.releaseLock();
}
}
+218
View File
@@ -0,0 +1,218 @@
/**
* AWS Signature V4 signing for HTTP requests. WebCrypto-only — no node:crypto.
*
* Matches `@smithy/signature-v4` for our usage: header-based signing with a
* full SHA-256 payload hash (Bedrock requires `applyChecksum: true`).
*
* Returns the set of headers to attach to the request:
* - `host`
* - `x-amz-date`
* - `x-amz-content-sha256`
* - `x-amz-security-token` (only when credentials carry a sessionToken)
* - `authorization`
*/
export interface AwsCredentials {
accessKeyId: string;
secretAccessKey: string;
sessionToken?: string;
}
export interface SignParams {
method: string;
/** Hostname only — used to build the `host` header and the canonical request. */
host: string;
/** URI path component, e.g. `/model/anthropic.claude/converse-stream`. */
path: string;
/** Optional pre-built query string (without leading `?`). */
query?: string;
/** Extra headers to sign in addition to `host`/`x-amz-*`. Names are case-insensitive. */
headers?: Record<string, string>;
body: Uint8Array;
region: string;
service: string;
credentials: AwsCredentials;
/** Override clock for deterministic tests. */
date?: Date;
}
const ALGORITHM = "AWS4-HMAC-SHA256";
const KEY_TYPE = "aws4_request";
// Headers the SDK never includes in the signature. Lowercased.
const UNSIGNABLE: Record<string, true> = {
authorization: true,
"cache-control": true,
connection: true,
expect: true,
from: true,
"keep-alive": true,
"max-forwards": true,
pragma: true,
referer: true,
te: true,
trailer: true,
"transfer-encoding": true,
upgrade: true,
"user-agent": true,
"x-amzn-trace-id": true,
};
/** Coerce a possibly-ArrayBufferLike-backed `Uint8Array` into one over a fresh
* `ArrayBuffer`, which is what `crypto.subtle.{digest,sign,importKey}` requires
* under the strict TS DOM typings. No-op when already strict.
*/
function asStrict(bytes: Uint8Array): Uint8Array<ArrayBuffer> {
if (bytes.buffer instanceof ArrayBuffer && bytes.byteOffset === 0 && bytes.byteLength === bytes.buffer.byteLength) {
return bytes as Uint8Array<ArrayBuffer>;
}
const copy = new Uint8Array(bytes.byteLength);
copy.set(bytes);
return copy;
}
const subtle = globalThis.crypto.subtle;
const HEX = "0123456789abcdef";
export function toHex(bytes: Uint8Array): string {
let out = "";
for (let i = 0; i < bytes.length; i++) {
const b = bytes[i];
out += HEX[b >> 4] + HEX[b & 15];
}
return out;
}
export async function sha256(data: Uint8Array | string): Promise<Uint8Array> {
const bytes = typeof data === "string" ? new TextEncoder().encode(data) : asStrict(data);
const digest = await subtle.digest("SHA-256", bytes);
return new Uint8Array(digest);
}
export async function sha256Hex(data: Uint8Array | string): Promise<string> {
return toHex(await sha256(data));
}
async function hmac(key: Uint8Array, data: string | Uint8Array): Promise<Uint8Array> {
const cryptoKey = await subtle.importKey("raw", asStrict(key), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
const bytes = typeof data === "string" ? new TextEncoder().encode(data) : asStrict(data);
const sig = await subtle.sign("HMAC", cryptoKey, bytes);
return new Uint8Array(sig);
}
/**
* Derive a signing key: HMAC chain `kSecret → kDate → kRegion → kService → kSigning`.
*/
export async function getSigningKey(
secretAccessKey: string,
shortDate: string,
region: string,
service: string,
): Promise<Uint8Array> {
const kDate = await hmac(new TextEncoder().encode(`AWS4${secretAccessKey}`), shortDate);
const kRegion = await hmac(kDate, region);
const kService = await hmac(kRegion, service);
return hmac(kService, KEY_TYPE);
}
/** `YYYYMMDDTHHMMSSZ` + 8-char `YYYYMMDD`. */
export function formatAmzDate(d: Date): { longDate: string; shortDate: string } {
const iso = d.toISOString();
// `2025-05-17T12:34:56.789Z` -> `20250517T123456Z`
const longDate = `${iso.slice(0, 4)}${iso.slice(5, 7)}${iso.slice(8, 10)}T${iso.slice(11, 13)}${iso.slice(14, 16)}${iso.slice(17, 19)}Z`;
return { longDate, shortDate: longDate.slice(0, 8) };
}
/**
* Canonicalize a request path per RFC 3986: each segment is %-encoded but `/`
* stays literal. Matches the smithy default (`uriEscapePath: true`, then revert
* the double-encoding of `/`). Bedrock paths use no reserved characters in
* practice, but model IDs can include `:` and `.`.
*/
function canonicalPath(path: string): string {
const segments = path.split("/");
const escaped = segments.map(seg => (seg.length === 0 ? "" : encodeRfc3986(seg)));
return escaped.join("/");
}
function encodeRfc3986(str: string): string {
return encodeURIComponent(str).replace(/[!'()*]/g, c => `%${c.charCodeAt(0).toString(16).toUpperCase()}`);
}
function canonicalQuery(query: string | undefined): string {
if (!query) return "";
const pairs: Array<[string, string]> = [];
for (const part of query.split("&")) {
if (!part) continue;
const eq = part.indexOf("=");
const k = eq === -1 ? part : part.slice(0, eq);
const v = eq === -1 ? "" : part.slice(eq + 1);
pairs.push([decodeURIComponent(k), decodeURIComponent(v)]);
}
pairs.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : a[1] < b[1] ? -1 : a[1] > b[1] ? 1 : 0));
return pairs.map(([k, v]) => `${encodeRfc3986(k)}=${encodeRfc3986(v)}`).join("&");
}
export interface SignedHeaders {
host: string;
"x-amz-date": string;
"x-amz-content-sha256": string;
authorization: string;
"x-amz-security-token"?: string;
}
export async function signRequest(params: SignParams): Promise<SignedHeaders> {
const { method, host, path, query, body, region, service, credentials } = params;
const date = params.date ?? new Date();
const { longDate, shortDate } = formatAmzDate(date);
const payloadHash = await sha256Hex(body);
// Assemble the headers that will be signed. Always include host, x-amz-date,
// x-amz-content-sha256, plus x-amz-security-token when present, plus
// caller-provided signable headers (e.g. content-type, accept).
const signed: Record<string, string> = {
host,
"x-amz-date": longDate,
"x-amz-content-sha256": payloadHash,
};
if (credentials.sessionToken) signed["x-amz-security-token"] = credentials.sessionToken;
const extraHeaders = params.headers;
if (extraHeaders) {
for (const k in extraHeaders) {
const lk = k.toLowerCase();
if (UNSIGNABLE[lk]) continue;
if (lk.startsWith("proxy-") || lk.startsWith("sec-")) continue;
signed[lk] = extraHeaders[k].trim().replace(/\s+/g, " ");
}
}
const sortedNames = Object.keys(signed).sort();
const canonicalHeaders = `${sortedNames.map(n => `${n}:${signed[n]}`).join("\n")}\n`;
const signedHeadersStr = sortedNames.join(";");
const canonicalRequest = [
method.toUpperCase(),
canonicalPath(path),
canonicalQuery(query),
canonicalHeaders,
signedHeadersStr,
payloadHash,
].join("\n");
const scope = `${shortDate}/${region}/${service}/${KEY_TYPE}`;
const stringToSign = [ALGORITHM, longDate, scope, await sha256Hex(canonicalRequest)].join("\n");
const signingKey = await getSigningKey(credentials.secretAccessKey, shortDate, region, service);
const signature = toHex(await hmac(signingKey, stringToSign));
const authorization =
`${ALGORITHM} Credential=${credentials.accessKeyId}/${scope}, ` +
`SignedHeaders=${signedHeadersStr}, Signature=${signature}`;
const out: SignedHeaders = {
host,
"x-amz-date": longDate,
"x-amz-content-sha256": payloadHash,
authorization,
};
if (credentials.sessionToken) out["x-amz-security-token"] = credentials.sessionToken;
return out;
}
@@ -243,6 +243,7 @@ function createClient(model: Model<"azure-openai-responses">, apiKey: string, op
const { baseUrl, apiVersion } = resolveAzureConfig(model, options);
const baseFetch = options?.fetch ?? fetch;
const onSseEvent = options?.onSseEvent;
return new AzureOpenAI({
apiKey,
apiVersion,
@@ -250,9 +251,7 @@ function createClient(model: Model<"azure-openai-responses">, apiKey: string, op
maxRetries: 5,
defaultHeaders: headers,
baseURL: baseUrl,
fetch: options?.onSseEvent
? wrapFetchForSseDebug(baseFetch, event => options.onSseEvent?.(event, model))
: baseFetch,
fetch: onSseEvent ? wrapFetchForSseDebug(baseFetch, event => onSseEvent(event, model)) : baseFetch,
});
}
+1 -2
View File
@@ -3,8 +3,7 @@ import * as fs from "node:fs/promises";
import http2 from "node:http2";
import { create, fromBinary, fromJson, type JsonValue, toBinary, toJson } from "@bufbuild/protobuf";
import { ValueSchema } from "@bufbuild/protobuf/wkt";
import { sanitizeText } from "@oh-my-pi/pi-natives";
import { $env } from "@oh-my-pi/pi-utils";
import { $env, sanitizeText } from "@oh-my-pi/pi-utils";
import { calculateCost } from "../models";
import type {
Api,
+252
View File
@@ -0,0 +1,252 @@
/**
* Application Default Credentials (ADC) resolution for Vertex AI.
*
* Replaces `google-auth-library` with a direct WebCrypto + REST implementation.
* Sources, in priority order:
* 1. `GOOGLE_APPLICATION_CREDENTIALS` env → file with `type: "service_account"` (RS256 JWT exchange)
* or `type: "authorized_user"` (refresh-token exchange).
* 2. `~/.config/gcloud/application_default_credentials.json` (user ADC, same authorized_user flow).
* 3. GCE / Cloud Run metadata server (`metadata.google.internal`).
*
* Tokens are cached per source key and refreshed `GOOGLE_VERTEX_REFRESH_SKEW_MS` before expiry
* (default 60s). Concurrent callers waiting on a refresh share the same in-flight promise.
*/
import { Buffer } from "node:buffer";
import * as os from "node:os";
import * as path from "node:path";
import { $envpos, isEnoent, logger } from "@oh-my-pi/pi-utils";
import type { FetchImpl } from "../types";
const OAUTH_TOKEN_URL = "https://oauth2.googleapis.com/token";
const METADATA_TOKEN_URL = "http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/token";
const CLOUD_PLATFORM_SCOPE = "https://www.googleapis.com/auth/cloud-platform";
const JWT_BEARER_GRANT = "urn:ietf:params:oauth:grant-type:jwt-bearer";
interface CachedToken {
token: string;
expiresAtMs: number;
}
interface ServiceAccountCredentials {
type: "service_account";
client_email: string;
private_key: string;
private_key_id?: string;
}
interface AuthorizedUserCredentials {
type: "authorized_user";
client_id: string;
client_secret: string;
refresh_token: string;
}
type AdcFileCredentials = ServiceAccountCredentials | AuthorizedUserCredentials;
interface TokenResponse {
access_token: string;
expires_in: number;
token_type?: string;
}
const tokenCache = new Map<string, CachedToken>();
const inflight = new Map<string, Promise<string>>();
function getRefreshSkewMs(): number {
return $envpos("GOOGLE_VERTEX_REFRESH_SKEW_MS", 60_000);
}
function userAdcPath(): string {
return path.join(os.homedir(), ".config", "gcloud", "application_default_credentials.json");
}
async function readJsonFile<T>(filePath: string): Promise<T | undefined> {
try {
return (await Bun.file(filePath).json()) as T;
} catch (err) {
if (isEnoent(err)) return undefined;
throw err;
}
}
async function loadAdcCredentials(): Promise<{ source: string; creds: AdcFileCredentials } | undefined> {
const gacPath = Bun.env.GOOGLE_APPLICATION_CREDENTIALS;
if (gacPath) {
const creds = await readJsonFile<AdcFileCredentials>(gacPath);
if (!creds) {
throw new Error(`GOOGLE_APPLICATION_CREDENTIALS points to a missing file: ${gacPath}`);
}
return { source: `gac:${gacPath}`, creds };
}
const userPath = userAdcPath();
const creds = await readJsonFile<AdcFileCredentials>(userPath);
if (creds) return { source: `user:${userPath}`, creds };
return undefined;
}
function base64UrlEncode(bytes: Uint8Array | string): string {
const buf = typeof bytes === "string" ? Buffer.from(bytes, "utf8") : bytes;
return Buffer.from(buf.buffer, buf.byteOffset, buf.byteLength).toString("base64url");
}
function pemToPkcs8(pem: string): Uint8Array<ArrayBuffer> {
const body = pem
.replace(/-----BEGIN [^-]+-----/g, "")
.replace(/-----END [^-]+-----/g, "")
.replace(/\s+/g, "");
if (!body) throw new Error("Invalid PEM: empty body");
return Uint8Array.fromBase64(body);
}
async function signJwtRs256(claims: Record<string, unknown>, privateKeyPem: string, keyId?: string): Promise<string> {
const header: Record<string, unknown> = { alg: "RS256", typ: "JWT" };
if (keyId) header.kid = keyId;
const payload = `${base64UrlEncode(JSON.stringify(header))}.${base64UrlEncode(JSON.stringify(claims))}`;
const key = await globalThis.crypto.subtle.importKey(
"pkcs8",
pemToPkcs8(privateKeyPem),
{ name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
false,
["sign"],
);
const signature = new Uint8Array(
await globalThis.crypto.subtle.sign("RSASSA-PKCS1-v1_5", key, new TextEncoder().encode(payload)),
);
return `${payload}.${base64UrlEncode(signature)}`;
}
async function exchangeJwtForToken(
creds: ServiceAccountCredentials,
signal: AbortSignal | undefined,
fetchImpl: FetchImpl,
): Promise<TokenResponse> {
const now = Math.floor(Date.now() / 1000);
const assertion = await signJwtRs256(
{
iss: creds.client_email,
scope: CLOUD_PLATFORM_SCOPE,
aud: OAUTH_TOKEN_URL,
exp: now + 3600,
iat: now,
},
creds.private_key,
creds.private_key_id,
);
const body = new URLSearchParams({ grant_type: JWT_BEARER_GRANT, assertion });
return postForToken(OAUTH_TOKEN_URL, body, signal, fetchImpl);
}
async function exchangeRefreshToken(
creds: AuthorizedUserCredentials,
signal: AbortSignal | undefined,
fetchImpl: FetchImpl,
): Promise<TokenResponse> {
const body = new URLSearchParams({
client_id: creds.client_id,
client_secret: creds.client_secret,
refresh_token: creds.refresh_token,
grant_type: "refresh_token",
});
return postForToken(OAUTH_TOKEN_URL, body, signal, fetchImpl);
}
async function fetchMetadataToken(
signal: AbortSignal | undefined,
fetchImpl: FetchImpl,
): Promise<TokenResponse | undefined> {
const timeout = AbortSignal.timeout(2000);
const combined = signal ? AbortSignal.any([signal, timeout]) : timeout;
try {
const response = await fetchImpl(METADATA_TOKEN_URL, {
method: "GET",
headers: { "Metadata-Flavor": "Google" },
signal: combined,
});
if (!response.ok) return undefined;
return (await response.json()) as TokenResponse;
} catch {
return undefined;
}
}
async function postForToken(
url: string,
body: URLSearchParams,
signal: AbortSignal | undefined,
fetchImpl: FetchImpl,
): Promise<TokenResponse> {
const response = await fetchImpl(url, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: body.toString(),
signal,
});
if (!response.ok) {
const detail = await response.text().catch(() => "");
throw new Error(`Google OAuth token exchange failed (${response.status}): ${detail}`);
}
return (await response.json()) as TokenResponse;
}
async function resolveAccessTokenUncached(
signal: AbortSignal | undefined,
fetchImpl: FetchImpl,
): Promise<{ source: string; token: TokenResponse }> {
const adc = await loadAdcCredentials();
if (adc) {
const token =
adc.creds.type === "service_account"
? await exchangeJwtForToken(adc.creds, signal, fetchImpl)
: await exchangeRefreshToken(adc.creds, signal, fetchImpl);
return { source: adc.source, token };
}
const metadata = await fetchMetadataToken(signal, fetchImpl);
if (metadata) return { source: "metadata", token: metadata };
throw new Error(
"Vertex AI requires Application Default Credentials. Set GOOGLE_APPLICATION_CREDENTIALS, run `gcloud auth application-default login`, or run on a GCE/Cloud Run instance with a service account.",
);
}
/**
* Returns a Bearer access token suitable for the `Authorization` header on Vertex AI calls.
* The token is cached in module scope and refreshed `GOOGLE_VERTEX_REFRESH_SKEW_MS` ms before it expires.
*/
export async function getVertexAccessToken(options?: { signal?: AbortSignal; fetch?: FetchImpl }): Promise<string> {
const fetchImpl = options?.fetch ?? globalThis.fetch.bind(globalThis);
const skew = getRefreshSkewMs();
const now = Date.now();
// Best-effort cache key probe: we don't know the source until we resolve, but cached entries
// are keyed by their resolved source. Try every cached source first.
for (const [source, cached] of tokenCache) {
if (cached.expiresAtMs - skew > now) return cached.token;
// expired entry — drop and re-resolve
tokenCache.delete(source);
}
const cacheKey = "vertex-adc";
const existing = inflight.get(cacheKey);
if (existing) return existing;
const promise = (async () => {
try {
const { source, token } = await resolveAccessTokenUncached(options?.signal, fetchImpl);
const expiresAtMs = Date.now() + Math.max(0, token.expires_in * 1000);
tokenCache.set(source, { token: token.access_token, expiresAtMs });
logger.debug("vertex.adc acquired access token", { source, expiresInSec: token.expires_in });
return token.access_token;
} finally {
inflight.delete(cacheKey);
}
})();
inflight.set(cacheKey, promise);
return promise;
}
/** Test seam: clears every cached token. */
export function __resetVertexTokenCache(): void {
tokenCache.clear();
inflight.clear();
}
@@ -5,7 +5,6 @@
*/
import { createHash, randomBytes, randomUUID } from "node:crypto";
import { scheduler } from "node:timers/promises";
import type { Content, FunctionCallingConfigMode, ThinkingConfig } from "@google/genai";
import { fetchWithRetry, readSseJson } from "@oh-my-pi/pi-utils";
import { calculateCost } from "../models";
import type {
@@ -24,8 +23,9 @@ import { AssistantMessageEventStream } from "../utils/event-stream";
import { appendRawHttpRequestDumpFor400, type RawHttpRequestDump, withHttpStatus } from "../utils/http-inspector";
import { refreshAntigravityToken } from "../utils/oauth/google-antigravity";
import { refreshGoogleCloudToken } from "../utils/oauth/google-gemini-cli";
import { sanitizeSchemaForCCA } from "../utils/schema";
import { normalizeSchemaForCCA } from "../utils/schema";
import { ANTIGRAVITY_SYSTEM_INSTRUCTION, getAntigravityUserAgent, getGeminiCliHeaders } from "./google-gemini-headers";
import type { Content, FunctionCallingConfigMode, ThinkingConfig } from "./google-shared";
import {
convertMessages,
convertTools,
@@ -688,7 +688,7 @@ function normalizeAntigravityTools(
const { parametersJsonSchema, ...rest } = declaration;
return {
...rest,
parameters: sanitizeSchemaForCCA(parametersJsonSchema),
parameters: normalizeSchemaForCCA(parametersJsonSchema),
};
}),
}));
+138 -42
View File
@@ -1,23 +1,14 @@
/**
* Shared utilities for Google Generative AI and Google Cloud Code Assist providers.
*/
import {
type Content,
FinishReason,
FunctionCallingConfigMode,
type GenerateContentConfig,
type GenerateContentParameters,
type GenerateContentResponse,
type GoogleGenAI,
type Part,
type ThinkingConfig,
type ThinkingLevel,
} from "@google/genai";
import { readSseJson } from "@oh-my-pi/pi-utils";
import { calculateCost } from "../models";
import type {
Api,
AssistantMessage,
Context,
FetchImpl,
ImageContent,
Model,
StopReason,
@@ -29,12 +20,30 @@ import type {
} from "../types";
import { normalizeSystemPrompts } from "../utils";
import { AssistantMessageEventStream } from "../utils/event-stream";
import { finalizeErrorMessage, type RawHttpRequestDump } from "../utils/http-inspector";
import { prepareSchemaForCCA, sanitizeSchemaForGoogle, toolWireSchema } from "../utils/schema";
import { finalizeErrorMessage, type RawHttpRequestDump, withHttpStatus } from "../utils/http-inspector";
import { normalizeSchemaForCCA, normalizeSchemaForGoogle, toolWireSchema } from "../utils/schema";
import type {
Content,
FinishReason,
FunctionCallingConfigMode,
GenerateContentConfig,
GenerateContentParameters,
GenerateContentResponse,
Part,
ThinkingConfig,
ThinkingLevel,
} from "./google-types";
import { transformMessages } from "./transform-messages";
import { NON_VISION_IMAGE_PLACEHOLDER } from "./vision-guard";
export { sanitizeSchemaForGoogle };
export type {
Content,
FunctionCallingConfigMode,
GenerateContentParameters,
GenerateContentResponse,
ThinkingConfig,
} from "./google-types";
export { normalizeSchemaForGoogle };
type GoogleApiType = "google-generative-ai" | "google-gemini-cli" | "google-vertex";
@@ -340,7 +349,7 @@ export function convertTools(
name: tool.name,
description: tool.description || "",
...(useParameters
? { parameters: prepareSchemaForCCA(toolWireSchema(tool)) }
? { parameters: normalizeSchemaForCCA(toolWireSchema(tool)) }
: { parametersJsonSchema: toolWireSchema(tool) }),
})),
},
@@ -353,13 +362,13 @@ export function convertTools(
export function mapToolChoice(choice: string): FunctionCallingConfigMode {
switch (choice) {
case "auto":
return FunctionCallingConfigMode.AUTO;
return "AUTO";
case "none":
return FunctionCallingConfigMode.NONE;
return "NONE";
case "any":
return FunctionCallingConfigMode.ANY;
return "ANY";
default:
return FunctionCallingConfigMode.AUTO;
return "AUTO";
}
}
@@ -368,25 +377,25 @@ export function mapToolChoice(choice: string): FunctionCallingConfigMode {
*/
export function mapStopReason(reason: FinishReason): StopReason {
switch (reason) {
case FinishReason.STOP:
case "STOP":
return "stop";
case FinishReason.MAX_TOKENS:
case "MAX_TOKENS":
return "length";
case FinishReason.BLOCKLIST:
case FinishReason.PROHIBITED_CONTENT:
case FinishReason.SPII:
case FinishReason.SAFETY:
case FinishReason.IMAGE_SAFETY:
case FinishReason.IMAGE_PROHIBITED_CONTENT:
case FinishReason.IMAGE_RECITATION:
case FinishReason.IMAGE_OTHER:
case FinishReason.RECITATION:
case FinishReason.FINISH_REASON_UNSPECIFIED:
case FinishReason.OTHER:
case FinishReason.LANGUAGE:
case FinishReason.MALFORMED_FUNCTION_CALL:
case FinishReason.UNEXPECTED_TOOL_CALL:
case FinishReason.NO_IMAGE:
case "BLOCKLIST":
case "PROHIBITED_CONTENT":
case "SPII":
case "SAFETY":
case "IMAGE_SAFETY":
case "IMAGE_PROHIBITED_CONTENT":
case "IMAGE_RECITATION":
case "IMAGE_OTHER":
case "RECITATION":
case "FINISH_REASON_UNSPECIFIED":
case "OTHER":
case "LANGUAGE":
case "MALFORMED_FUNCTION_CALL":
case "UNEXPECTED_TOOL_CALL":
case "NO_IMAGE":
return "error";
default: {
throw new Error(`Unhandled stop reason: ${reason satisfies never}`);
@@ -723,12 +732,19 @@ export function buildGoogleGenerateContentParams<T extends "google-generative-ai
* Caller-supplied `prepare()` runs inside the try-block so any failure (missing project,
* bad auth, etc.) is funneled through the same error path as a streaming failure.
*/
export interface GoogleGenAIRequestPlan {
params: GenerateContentParameters;
url: string;
headers: Record<string, string>;
fetch?: FetchImpl;
}
export function streamGoogleGenAI<T extends "google-generative-ai" | "google-vertex">(args: {
model: Model<T>;
options: GoogleSharedStreamOptions | undefined;
api: T;
retainTextSignature?: boolean;
prepare: () => { client: GoogleGenAI; params: GenerateContentParameters; url: string | undefined };
prepare: () => GoogleGenAIRequestPlan | Promise<GoogleGenAIRequestPlan>;
}): AssistantMessageEventStream {
const { model, options, api, retainTextSignature, prepare } = args;
const stream = new AssistantMessageEventStream();
@@ -757,17 +773,44 @@ export function streamGoogleGenAI<T extends "google-generative-ai" | "google-ver
let rawRequestDump: RawHttpRequestDump | undefined;
try {
const { client, params, url } = prepare();
options?.onPayload?.(params);
const plan = await prepare();
let params = plan.params;
const replacement = await options?.onPayload?.(params, model);
if (replacement !== undefined) {
params = replacement as GenerateContentParameters;
}
rawRequestDump = {
provider: model.provider,
api: output.api,
model: model.id,
method: "POST",
url,
url: plan.url,
body: params,
headers: plan.headers,
};
const googleStream = await client.models.generateContentStream(params);
const wireBody = paramsToWireBody(params);
const fetchImpl = plan.fetch ?? options?.fetch ?? (globalThis.fetch.bind(globalThis) as FetchImpl);
const response = await fetchImpl(plan.url, {
method: "POST",
headers: { ...plan.headers, "Content-Type": "application/json", Accept: "text/event-stream" },
body: JSON.stringify(wireBody),
signal: options?.signal,
});
if (!response.ok) {
const errorText = await response.text().catch(() => "");
throw withHttpStatus(
new Error(`Google API error (${response.status}): ${extractGoogleErrorMessage(errorText)}`),
response.status,
);
}
if (!response.body) {
throw new Error("Google API returned an empty response body");
}
const googleStream = readSseJson<GenerateContentResponse>(response.body, options?.signal, event =>
options?.onSseEvent?.({ event: event.event, data: event.data, raw: [...event.raw] }, model),
);
stream.push({ type: "start", partial: output });
await consumeGoogleStream({
@@ -803,3 +846,56 @@ export function streamGoogleGenAI<T extends "google-generative-ai" | "google-ver
return stream;
}
/**
* Lift the SDK's `params.config` fields out of `config` and place them where the
* Gemini / Vertex AI REST API expects them on the request body. Mirrors the
* generateContentParametersTo{Mldev,Vertex} transformation in @google/genai
* for the subset of fields this codebase actually sets.
*
* `abortSignal` is intentionally dropped — the SDK propagates it via `fetch.signal`,
* which our caller already wires up through `options.signal`.
*/
function paramsToWireBody(params: GenerateContentParameters): Record<string, unknown> {
const body: Record<string, unknown> = { contents: params.contents };
const config = params.config;
if (!config) return body;
if (config.systemInstruction !== undefined) body.systemInstruction = config.systemInstruction;
if (config.tools !== undefined) body.tools = config.tools;
if (config.toolConfig !== undefined) body.toolConfig = config.toolConfig;
if (config.safetySettings !== undefined) body.safetySettings = config.safetySettings;
if (config.cachedContent !== undefined) body.cachedContent = config.cachedContent;
const gen: Record<string, unknown> = {};
if (config.temperature !== undefined) gen.temperature = config.temperature;
if (config.maxOutputTokens !== undefined) gen.maxOutputTokens = config.maxOutputTokens;
if (config.topP !== undefined) gen.topP = config.topP;
if (config.topK !== undefined) gen.topK = config.topK;
if (config.candidateCount !== undefined) gen.candidateCount = config.candidateCount;
if (config.stopSequences !== undefined) gen.stopSequences = config.stopSequences;
if (config.presencePenalty !== undefined) gen.presencePenalty = config.presencePenalty;
if (config.frequencyPenalty !== undefined) gen.frequencyPenalty = config.frequencyPenalty;
if (config.seed !== undefined) gen.seed = config.seed;
if (config.responseMimeType !== undefined) gen.responseMimeType = config.responseMimeType;
if (config.responseSchema !== undefined) gen.responseSchema = config.responseSchema;
if (config.responseJsonSchema !== undefined) gen.responseJsonSchema = config.responseJsonSchema;
if (config.responseModalities !== undefined) gen.responseModalities = config.responseModalities;
if (config.thinkingConfig !== undefined) gen.thinkingConfig = config.thinkingConfig;
const generationConfig = config as unknown as { minP?: number; repetitionPenalty?: number };
if (generationConfig.minP !== undefined) gen.minP = generationConfig.minP;
if (generationConfig.repetitionPenalty !== undefined) gen.repetitionPenalty = generationConfig.repetitionPenalty;
if (Object.keys(gen).length > 0) body.generationConfig = gen;
return body;
}
function extractGoogleErrorMessage(errorText: string): string {
if (!errorText) return "Unknown error";
try {
const parsed = JSON.parse(errorText) as { error?: { message?: string } };
if (parsed.error?.message) return parsed.error.message;
} catch {
// fall through to raw text
}
return errorText;
}
+167
View File
@@ -0,0 +1,167 @@
/**
* Local mirror of the subset of `@google/genai` types this package consumes.
*
* Field shapes match Gemini / Vertex AI wire format 1:1. Enum-shaped values are
* modelled as string literal unions so they pass through `JSON.stringify` and
* `JSON.parse` unchanged.
*
* Keep this file in sync with the actual request/response surface of:
* - `POST {generativelanguage,aiplatform}.googleapis.com/.../models/{model}:streamGenerateContent?alt=sse`
* - The Cloud Code Assist endpoint used by `google-gemini-cli.ts`
*/
/** Mirror of `@google/genai`'s `FinishReason` string enum. */
export type FinishReason =
| "FINISH_REASON_UNSPECIFIED"
| "STOP"
| "MAX_TOKENS"
| "SAFETY"
| "RECITATION"
| "LANGUAGE"
| "OTHER"
| "BLOCKLIST"
| "PROHIBITED_CONTENT"
| "SPII"
| "MALFORMED_FUNCTION_CALL"
| "IMAGE_SAFETY"
| "IMAGE_PROHIBITED_CONTENT"
| "IMAGE_RECITATION"
| "IMAGE_OTHER"
| "UNEXPECTED_TOOL_CALL"
| "NO_IMAGE";
/** Mirror of `@google/genai`'s `FunctionCallingConfigMode` string enum. */
export type FunctionCallingConfigMode = "MODE_UNSPECIFIED" | "AUTO" | "NONE" | "ANY" | "VALIDATED";
/** Mirror of `@google/genai`'s `ThinkingLevel` string enum. */
export type ThinkingLevel = "THINKING_LEVEL_UNSPECIFIED" | "MINIMAL" | "LOW" | "MEDIUM" | "HIGH";
/** Inline base64-encoded data part. */
export interface InlineDataPart {
mimeType: string;
data: string;
}
/** Function call emitted by the model. */
export interface FunctionCallPart {
name?: string;
args?: Record<string, unknown>;
id?: string;
}
/** Tool execution result fed back to the model. */
export interface FunctionResponsePart {
name: string;
response: Record<string, unknown>;
parts?: Part[];
id?: string;
}
/**
* A single piece of a `Content` message. Mirrors the SDK's union by keeping
* every optional field — the model and the wire treat shape as discriminator.
*/
export interface Part {
text?: string;
thought?: boolean;
thoughtSignature?: string;
inlineData?: InlineDataPart;
functionCall?: FunctionCallPart;
functionResponse?: FunctionResponsePart;
}
/** Conversation turn. Roles: `"user"`, `"model"`, optionally absent for system instructions. */
export interface Content {
role?: string;
parts?: Part[];
}
/** Thinking/reasoning configuration shared by Gemini 2.x and 3.x models. */
export interface ThinkingConfig {
includeThoughts?: boolean;
thinkingBudget?: number;
thinkingLevel?: ThinkingLevel;
}
/** Function declaration entry inside `tools[].functionDeclarations`. */
export interface FunctionDeclaration {
name: string;
description?: string;
parameters?: Record<string, unknown>;
parametersJsonSchema?: Record<string, unknown>;
}
/** Tool group as accepted at the request top level. */
export interface ToolDeclaration {
functionDeclarations: Record<string, unknown>[];
}
/** Tool selection mode container. */
export interface ToolConfig {
functionCallingConfig?: {
mode: FunctionCallingConfigMode;
allowedFunctionNames?: string[];
};
}
/**
* Generation/sampling and request-shape options passed via the SDK's `config`.
*
* Fields that the wire format places at the request body root (systemInstruction,
* tools, toolConfig, safetySettings, cachedContent) live here too — the
* transformer in `google-shared.ts` lifts them out when serializing.
*/
export interface GenerateContentConfig {
temperature?: number;
maxOutputTokens?: number;
topP?: number;
topK?: number;
candidateCount?: number;
stopSequences?: string[];
presencePenalty?: number;
frequencyPenalty?: number;
seed?: number;
responseMimeType?: string;
responseSchema?: Record<string, unknown>;
responseJsonSchema?: Record<string, unknown>;
responseModalities?: string[];
systemInstruction?: Content | { role?: string; parts: { text: string }[] };
tools?: ToolDeclaration[];
toolConfig?: ToolConfig;
safetySettings?: Array<Record<string, unknown>>;
cachedContent?: string;
thinkingConfig?: ThinkingConfig;
abortSignal?: AbortSignal;
}
/** Top-level argument to `generateContentStream`. */
export interface GenerateContentParameters {
model: string;
contents: Content[];
config?: GenerateContentConfig;
}
/** Per-stream candidate envelope. */
export interface Candidate {
content?: Content;
finishReason?: FinishReason;
index?: number;
}
/** Cumulative token accounting attached to the trailing chunk. */
export interface UsageMetadata {
promptTokenCount?: number;
candidatesTokenCount?: number;
thoughtsTokenCount?: number;
totalTokenCount?: number;
cachedContentTokenCount?: number;
}
/** Single SSE chunk's parsed JSON body. */
export interface GenerateContentResponse {
candidates?: Candidate[];
usageMetadata?: UsageMetadata;
modelVersion?: string;
responseId?: string;
promptFeedback?: Record<string, unknown>;
}
+34 -55
View File
@@ -1,8 +1,13 @@
import { GoogleGenAI } from "@google/genai";
import { $env } from "@oh-my-pi/pi-utils";
import type { Context, FetchImpl, Model, StreamFunction } from "../types";
import type { Context, Model, StreamFunction } from "../types";
import type { AssistantMessageEventStream } from "../utils/event-stream";
import { buildGoogleGenerateContentParams, type GoogleSharedStreamOptions, streamGoogleGenAI } from "./google-shared";
import { getVertexAccessToken } from "./google-auth";
import {
buildGoogleGenerateContentParams,
type GoogleGenAIRequestPlan,
type GoogleSharedStreamOptions,
streamGoogleGenAI,
} from "./google-shared";
export interface GoogleVertexOptions extends GoogleSharedStreamOptions {
project?: string;
@@ -21,63 +26,37 @@ export const streamGoogleVertex: StreamFunction<"google-vertex"> = (
options,
api: "google-vertex",
retainTextSignature: true,
prepare: () => {
prepare: async (): Promise<GoogleGenAIRequestPlan> => {
const apiKey = resolveApiKey(options);
const project = apiKey ? undefined : resolveProject(options);
const location = apiKey ? undefined : resolveLocation(options);
const client = apiKey
? createClientWithApiKey(model, apiKey, options?.fetch)
: createClient(model, project!, location!, options?.fetch);
const params = buildGoogleGenerateContentParams(model, context, options ?? {});
const url = apiKey
? `https://aiplatform.googleapis.com/${API_VERSION}/publishers/google/models/${model.id}:streamGenerateContent`
: `https://${location}-aiplatform.googleapis.com/${API_VERSION}/projects/${project}/locations/${location}/publishers/google/models/${model.id}:streamGenerateContent`;
return { client, params, url };
const baseHeaders: Record<string, string> = {
...(model.headers ?? {}),
...(options?.headers ?? {}),
};
if (apiKey) {
const url = `https://aiplatform.googleapis.com/${API_VERSION}/publishers/google/models/${model.id}:streamGenerateContent?alt=sse`;
return {
params,
url,
headers: { ...baseHeaders, "x-goog-api-key": apiKey },
fetch: options?.fetch,
};
}
const project = resolveProject(options);
const location = resolveLocation(options);
const accessToken = await getVertexAccessToken({ signal: options?.signal, fetch: options?.fetch });
const url = `https://${location}-aiplatform.googleapis.com/${API_VERSION}/projects/${project}/locations/${location}/publishers/google/models/${model.id}:streamGenerateContent?alt=sse`;
return {
params,
url,
headers: { ...baseHeaders, Authorization: `Bearer ${accessToken}` },
fetch: options?.fetch,
};
},
});
function buildHttpOptions(
model: Model<"google-vertex">,
fetchOverride: FetchImpl | undefined,
): { headers?: Record<string, string>; fetch?: FetchImpl } | undefined {
const options: { headers?: Record<string, string>; fetch?: FetchImpl } = {};
if (model.headers) {
options.headers = { ...model.headers };
}
if (fetchOverride) {
options.fetch = fetchOverride;
}
return Object.keys(options).length > 0 ? options : undefined;
}
function createClient(
model: Model<"google-vertex">,
project: string,
location: string,
fetchOverride: FetchImpl | undefined,
): GoogleGenAI {
return new GoogleGenAI({
vertexai: true,
project,
location,
apiVersion: API_VERSION,
httpOptions: buildHttpOptions(model, fetchOverride),
});
}
function createClientWithApiKey(
model: Model<"google-vertex">,
apiKey: string,
fetchOverride: FetchImpl | undefined,
): GoogleGenAI {
return new GoogleGenAI({
vertexai: true,
apiKey,
apiVersion: API_VERSION,
httpOptions: buildHttpOptions(model, fetchOverride),
});
}
function resolveApiKey(options?: GoogleVertexOptions): string | undefined {
// options.apiKey may contain sentinel values like "<authenticated>" or "N/A"
// leaked from the agent loop — only use it if it looks like a real API key.
+23 -31
View File
@@ -1,11 +1,17 @@
import { GoogleGenAI } from "@google/genai";
import { getEnvApiKey } from "../stream";
import type { Context, FetchImpl, Model, StreamFunction } from "../types";
import type { Context, Model, StreamFunction } from "../types";
import type { AssistantMessageEventStream } from "../utils/event-stream";
import { buildGoogleGenerateContentParams, type GoogleSharedStreamOptions, streamGoogleGenAI } from "./google-shared";
import {
buildGoogleGenerateContentParams,
type GoogleGenAIRequestPlan,
type GoogleSharedStreamOptions,
streamGoogleGenAI,
} from "./google-shared";
export type GoogleOptions = GoogleSharedStreamOptions;
const DEFAULT_GENERATIVE_LANGUAGE_BASE = "https://generativelanguage.googleapis.com/v1beta";
export const streamGoogle: StreamFunction<"google-generative-ai"> = (
model: Model<"google-generative-ai">,
context: Context,
@@ -15,35 +21,21 @@ export const streamGoogle: StreamFunction<"google-generative-ai"> = (
model,
options,
api: "google-generative-ai",
prepare: () => {
prepare: (): GoogleGenAIRequestPlan => {
const apiKey = options?.apiKey || getEnvApiKey(model.provider);
const client = createClient(model, apiKey, options?.fetch);
if (!apiKey) {
throw new Error("Google Generative AI requires an API key (GEMINI_API_KEY or options.apiKey).");
}
const params = buildGoogleGenerateContentParams(model, context, options ?? {});
const url = model.baseUrl ? `${model.baseUrl}/models/${model.id}:streamGenerateContent` : undefined;
return { client, params, url };
// `model.baseUrl` already includes the API version segment when set (mirrors the
// `apiVersion: ""` reset that the SDK relied on for custom base URLs).
const base = model.baseUrl?.trim() || DEFAULT_GENERATIVE_LANGUAGE_BASE;
const url = `${base}/models/${model.id}:streamGenerateContent?alt=sse`;
const headers: Record<string, string> = {
"x-goog-api-key": apiKey,
...(model.headers ?? {}),
...(options?.headers ?? {}),
};
return { params, url, headers, fetch: options?.fetch };
},
});
function createClient(model: Model<"google-generative-ai">, apiKey?: string, fetchOverride?: FetchImpl): GoogleGenAI {
const httpOptions: {
baseUrl?: string;
apiVersion?: string;
headers?: Record<string, string>;
fetch?: FetchImpl;
} = {};
if (model.baseUrl) {
httpOptions.baseUrl = model.baseUrl;
httpOptions.apiVersion = ""; // baseUrl already includes version path, don't append
}
if (model.headers) {
httpOptions.headers = model.headers;
}
if (fetchOverride) {
httpOptions.fetch = fetchOverride;
}
return new GoogleGenAI({
apiKey,
httpOptions: Object.keys(httpOptions).length > 0 ? httpOptions : undefined,
});
}
+83 -82
View File
@@ -145,35 +145,6 @@ export interface MockModelOptions {
reasoning?: boolean;
}
/** Returned by `createMockModel`. */
export interface MockModelHandle {
/** The `Model<"mock">` object to pass to `stream()` or agent config. */
readonly model: Model<MockApi>;
/** Recorded calls in invocation order. */
readonly calls: ReadonlyArray<MockCall>;
/** A streamFn-compatible callable. Forward to `agentLoop` or pi `stream()`. */
readonly stream: (model: Model<Api>, context: Context, options?: SimpleStreamOptions) => AssistantMessageEventStream;
/**
* Append a handler to the internal queue consumed AFTER the constructor
* `responses` source is exhausted (but before the fallback). Use this for
* interactive tests that decide responses after the model is created.
*/
push(response: MockHandler): void;
/** Reset recorded calls AND the extras queue. The constructor `responses` are NOT reset. */
reset(): void;
}
interface MockState {
iterator?: Iterator<MockHandler> | AsyncIterator<MockHandler>;
exhausted: boolean;
readonly extras: MockHandler[];
fallback?: MockHandler;
readonly calls: MockCall[];
toolCallCounter: number;
}
const STATE_BY_MODEL = new WeakMap<Model<Api>, MockState>();
const ZERO_COST: Model["cost"] = {
input: 0,
output: 0,
@@ -181,49 +152,82 @@ const ZERO_COST: Model["cost"] = {
cacheWrite: 0,
};
/** Check whether `model` was produced by `createMockModel`. */
export function isMockModel(model: Model<Api>): model is Model<MockApi> {
return STATE_BY_MODEL.has(model);
/**
* A `Model<"mock">` that carries its own scripted state. Pass instances to
* `stream()` or agent configs, and use the same instance to inspect calls
* and feed additional handlers.
*/
export class MockModel implements Model<MockApi> {
readonly id: string;
readonly name: string;
readonly api: MockApi = MOCK_API;
readonly provider: string;
readonly baseUrl = "mock://";
readonly reasoning: boolean;
readonly input: ("text" | "image")[] = ["text"];
readonly cost: Model["cost"];
readonly contextWindow: number;
readonly maxTokens: number;
/** Recorded calls in invocation order. */
readonly calls: MockCall[] = [];
iterator?: Iterator<MockHandler> | AsyncIterator<MockHandler>;
exhausted: boolean;
readonly extras: MockHandler[] = [];
fallback?: MockHandler;
toolCallCounter = 0;
constructor(options: MockModelOptions = {}) {
this.id = options.id ?? "mock-model";
this.name = options.id ?? "mock-model";
this.provider = options.provider ?? "mock";
this.reasoning = options.reasoning ?? false;
this.cost = options.cost ?? ZERO_COST;
this.contextWindow = options.contextWindow ?? 200_000;
this.maxTokens = options.maxTokens ?? 32_768;
this.iterator = options.responses === undefined ? undefined : iteratorOf(options.responses);
this.exhausted = options.responses === undefined;
this.fallback = options.handler;
}
/** Back-compat alias: the model is its own handle. */
get model(): this {
return this;
}
/** A streamFn-compatible callable. Forward to `agentLoop` or pi `stream()`. */
stream = (_model: Model<Api>, context: Context, options?: SimpleStreamOptions): AssistantMessageEventStream =>
streamMock(this, context, options);
/**
* Append a handler to the internal queue consumed AFTER the constructor
* `responses` source is exhausted (but before the fallback). Use this for
* interactive tests that decide responses after the model is created.
*/
push(response: MockHandler): void {
this.extras.push(response);
}
/** Reset recorded calls AND the extras queue. The constructor `responses` are NOT reset. */
reset(): void {
this.extras.length = 0;
this.calls.length = 0;
this.toolCallCounter = 0;
}
}
/** Construct a mock model + handle. */
export function createMockModel(options: MockModelOptions = {}): MockModelHandle {
const model: Model<MockApi> = {
id: options.id ?? "mock-model",
name: options.id ?? "mock-model",
api: MOCK_API,
provider: options.provider ?? "mock",
baseUrl: "mock://",
reasoning: options.reasoning ?? false,
input: ["text"],
cost: options.cost ?? ZERO_COST,
contextWindow: options.contextWindow ?? 200_000,
maxTokens: options.maxTokens ?? 32_768,
};
/** @deprecated Use {@link MockModel}; the class IS the handle. */
export type MockModelHandle = MockModel;
const state: MockState = {
iterator: options.responses === undefined ? undefined : iteratorOf(options.responses),
exhausted: options.responses === undefined,
extras: [],
fallback: options.handler,
calls: [],
toolCallCounter: 0,
};
STATE_BY_MODEL.set(model, state);
/** Check whether `model` was produced by `createMockModel`. */
export function isMockModel(model: Model<Api>): model is MockModel {
return model instanceof MockModel;
}
return {
model,
calls: state.calls,
stream: (_model, context, opts) => streamMock(model, context, opts),
push(response) {
state.extras.push(response);
},
reset() {
state.extras.length = 0;
state.calls.length = 0;
state.toolCallCounter = 0;
},
};
/** Construct a mock model. */
export function createMockModel(options: MockModelOptions = {}): MockModel {
return new MockModel(options);
}
/** Stream function for `Model<"mock">`. Matches the pi-ai per-provider stream signature. */
@@ -233,21 +237,19 @@ export function streamMock(
options?: SimpleStreamOptions,
): AssistantMessageEventStream {
const stream = new AssistantMessageEventStream();
const state = STATE_BY_MODEL.get(model);
if (!state) {
if (!isMockModel(model)) {
queueMicrotask(() => {
stream.fail(
new Error(
"streamMock called with a model not produced by createMockModel(). " +
"Pass the `model` field of a MockModelHandle.",
"streamMock called with a model not produced by createMockModel(). " + "Pass a MockModel instance.",
),
);
});
return stream;
}
state.calls.push({ context, options });
void runMock(stream, model, context, options, state);
model.calls.push({ context, options });
void runMock(stream, model, context, options);
return stream;
}
@@ -267,7 +269,7 @@ function iteratorOf(source: MockResponseSource): Iterator<MockHandler> | AsyncIt
return (source as Iterable<MockHandler>)[Symbol.iterator]();
}
async function pullHandler(state: MockState): Promise<MockHandler | undefined> {
async function pullHandler(state: MockModel): Promise<MockHandler | undefined> {
if (state.iterator && !state.exhausted) {
const result = await Promise.resolve(state.iterator.next());
if (!result.done) return result.value;
@@ -279,16 +281,15 @@ async function pullHandler(state: MockState): Promise<MockHandler | undefined> {
async function runMock(
stream: AssistantMessageEventStream,
model: Model<Api>,
model: MockModel,
context: Context,
options: SimpleStreamOptions | undefined,
state: MockState,
): Promise<void> {
const startedAt = Date.now();
let handler: MockHandler | undefined;
try {
handler = await pullHandler(state);
handler = await pullHandler(model);
} catch (err) {
stream.fail(err);
return;
@@ -297,7 +298,7 @@ async function runMock(
if (handler === undefined) {
stream.fail(
new Error(
`Mock model "${model.id}" received call ${state.calls.length} but no response or handler is configured.`,
`Mock model "${model.id}" received call ${model.calls.length} but no response or handler is configured.`,
),
);
return;
@@ -367,7 +368,7 @@ async function runMock(
stream.push({ type: "start", partial });
for (const input of response.content ?? []) {
const block = normalizeContent(input, state);
const block = normalizeContent(input, model);
blocks.push(block);
const contentIndex = blocks.length - 1;
@@ -405,7 +406,7 @@ async function runMock(
stream.push({ type: "done", reason: reason as "stop" | "length" | "toolUse", message: partial });
}
function normalizeContent(input: MockContent, state: MockState): TextContent | ThinkingContent | ToolCall {
function normalizeContent(input: MockContent, state: MockModel): TextContent | ThinkingContent | ToolCall {
if (typeof input === "string") {
return { type: "text", text: input };
}
@@ -493,7 +494,7 @@ function sleep(ms: number, signal?: AbortSignal): Promise<void> {
return promise;
}
function generateToolCallId(state: MockState): string {
function generateToolCallId(state: MockModel): string {
state.toolCallCounter += 1;
return `mock-tc-${state.toolCallCounter}`;
}
@@ -0,0 +1,243 @@
/**
* Zod schemas for the OpenAI chat-completions request shape we accept on the
* gateway. Mirrors https://platform.openai.com/docs/api-reference/chat — only
* the shapes the gateway translation layer understands. Unknown fields on
* permissive objects are accepted-and-stripped (via `z.unknown()` passthroughs
* or `.loose()`) so the official OpenAI SDK — which sends a growing pile of
* non-strict defaults (e.g. `stream_options.include_obfuscation`) — does not
* trip 400s on shapes we simply ignore.
*/
import type {
ChatCompletionContentPart,
ChatCompletionCreateParams,
ChatCompletionMessageParam,
ChatCompletionMessageToolCall,
ChatCompletionTool,
ChatCompletionToolChoiceOption,
} from "openai/resources/chat/completions";
import * as z from "zod/v4";
// ─── User-message content parts ─────────────────────────────────────────────
export const textPartSchema = z.object({
type: z.literal("text"),
text: z.string(),
});
/**
* OpenAI documents `image_url` as either `{ url: string, detail?: ... }` or —
* older clients — a bare string. Accept both shapes; downstream we extract a
* URL. `detail` is accepted for forward-compat but currently dropped (pi-ai's
* `ImageContent` has no detail field — TODO: plumb through if/when added).
*/
export const imagePartSchema = z.object({
type: z.literal("image_url"),
image_url: z.union([
z.string(),
z.object({
url: z.string(),
detail: z.enum(["auto", "low", "high"]).optional(),
}),
]),
});
/** OpenAI audio input block (gpt-4o-audio). Accepted; currently dropped downstream. */
export const inputAudioPartSchema = z.object({
type: z.literal("input_audio"),
input_audio: z.object({
data: z.string(),
format: z.enum(["wav", "mp3"]),
}),
});
/** OpenAI file input block (file_search / vision-document). Accepted; currently dropped downstream. */
export const filePartSchema = z.object({
type: z.literal("file"),
file: z.object({
file_id: z.string().optional(),
filename: z.string().optional(),
file_data: z.string().optional(),
}),
});
/** Replayed assistant refusal block. Accepted; currently dropped downstream. */
export const refusalPartSchema = z.object({
type: z.literal("refusal"),
refusal: z.string(),
});
/**
* Forward-compat catch-all for unknown content-part types. Matches every other
* `{ type: string, ... }` object so a new OpenAI block kind does not 400 the
* whole request; the walker ignores parts whose `type` it does not know.
*/
export const unknownPartSchema = z.object({ type: z.string() }).loose();
export const userContentPartSchema = z.union([
textPartSchema,
imagePartSchema,
inputAudioPartSchema,
filePartSchema,
refusalPartSchema,
unknownPartSchema,
]);
// ─── Tool calls / tools ─────────────────────────────────────────────────────
export const toolCallSchema = z.object({
id: z.string(),
type: z.literal("function").optional(),
function: z.object({
name: z.string(),
arguments: z.string(),
}),
});
export const toolSchema = z.object({
type: z.literal("function"),
function: z.object({
name: z.string().min(1),
description: z.string().optional(),
parameters: z.record(z.string(), z.unknown()).optional(),
/** OpenAI structured-output strict mode. Accepted, not enforced upstream. */
strict: z.boolean().optional(),
}),
});
// ─── Tool choice ────────────────────────────────────────────────────────────
export const toolChoiceSchema = z.union([
z.literal("auto"),
z.literal("none"),
z.literal("required"),
z.object({
type: z.literal("function"),
function: z.object({ name: z.string().min(1) }),
}),
// Anthropic-style `{ type: 'tool', name }` — translated to the OpenAI
// function shape in the walker.
z.object({
type: z.literal("tool"),
name: z.string().min(1),
}),
]);
// ─── Messages ───────────────────────────────────────────────────────────────
const baseContent = z.union([z.string(), z.array(userContentPartSchema)]);
export const systemMessageSchema = z.object({
role: z.literal("system"),
content: baseContent,
});
export const developerMessageSchema = z.object({
role: z.literal("developer"),
content: baseContent,
});
export const userMessageSchema = z.object({
role: z.literal("user"),
content: baseContent,
});
export const assistantMessageSchema = z.object({
role: z.literal("assistant"),
content: baseContent.optional(),
tool_calls: z.array(toolCallSchema).optional(),
});
export const toolMessageSchema = z.object({
role: z.literal("tool"),
content: baseContent.optional(),
tool_call_id: z.string().optional(),
});
/**
* Legacy `function` role (pre-tools API). Translated to a `tool` role
* canonical message in the walker so downstream providers see one shape.
*/
export const functionMessageSchema = z.object({
role: z.literal("function"),
name: z.string(),
content: z.string().nullable(),
});
export const messageSchema = z.discriminatedUnion("role", [
systemMessageSchema,
developerMessageSchema,
userMessageSchema,
assistantMessageSchema,
toolMessageSchema,
functionMessageSchema,
]);
// ─── Stream options ─────────────────────────────────────────────────────────
/**
* Permissive: the official OpenAI SDK sets `include_obfuscation: false` by
* default. We only consume `include_usage`, so unknown keys are silently
* stripped rather than 400'd.
*/
export const streamOptionsSchema = z.object({
include_usage: z.boolean().optional(),
});
// ─── Stop sequences ─────────────────────────────────────────────────────────
// OpenAI rejects > 4 stop strings; mirror that at the gateway.
export const stopSchema = z.union([z.string(), z.array(z.string()).max(4)]);
// ─── Top-level request ──────────────────────────────────────────────────────
export const openaiChatRequestSchema = z.object({
model: z.string().min(1),
messages: z.array(messageSchema),
tools: z.array(toolSchema).optional(),
tool_choice: toolChoiceSchema.optional(),
max_tokens: z.number().optional(),
max_completion_tokens: z.number().optional(),
temperature: z.number().optional(),
top_p: z.number().optional(),
stop: stopSchema.optional(),
stream: z.boolean().optional(),
stream_options: streamOptionsSchema.optional(),
// ── Typed first-class passthroughs (now consumed by the walker) ────────
response_format: z.unknown().optional(),
seed: z.number().optional(),
presence_penalty: z.number().optional(),
frequency_penalty: z.number().optional(),
logit_bias: z.record(z.string(), z.number()).optional(),
user: z.string().optional(),
reasoning_effort: z.enum(["minimal", "low", "medium", "high", "xhigh"]).optional(),
parallel_tool_calls: z.boolean().optional(),
service_tier: z.enum(["auto", "default", "flex", "scale", "priority"]).optional(),
metadata: z.record(z.string(), z.unknown()).optional(),
// ── Accept-and-ignore passthroughs ─────────────────────────────────────
// Forward acceptance only: validating these would 400 on shapes the
// gateway has no opinion on. The downstream provider does the real check.
logprobs: z.unknown().optional(),
top_logprobs: z.unknown().optional(),
prediction: z.unknown().optional(),
modalities: z.unknown().optional(),
audio: z.unknown().optional(),
store: z.unknown().optional(),
prompt_cache_key: z.unknown().optional(),
safety_identifier: z.unknown().optional(),
n: z.unknown().optional(),
web_search_options: z.unknown().optional(),
});
/**
* Public types are sourced from the OpenAI SDK so the gateway stays in
* lock-step with the canonical API surface; the schemas above are runtime
* validators for the subset we actually accept.
*/
export type OpenAIChatRequest = ChatCompletionCreateParams;
export type OpenAIChatMessage = ChatCompletionMessageParam;
export type OpenAIChatToolCall = ChatCompletionMessageToolCall;
export type OpenAIChatTool = ChatCompletionTool;
export type OpenAIChatToolChoice = ChatCompletionToolChoiceOption;
export type OpenAIChatContentPart = ChatCompletionContentPart;
@@ -0,0 +1,628 @@
import { randomUUID } from "node:crypto";
import { resolvePromptCacheKey } from "../auth-gateway/http";
/**
* Parsed inbound OpenAI chat-completions request, ready to feed into pi-ai
* `stream(model, context, options)`.
*/
import type { AuthGatewayParsedRequest as ParsedRequest } from "../auth-gateway/types";
import type {
AssistantMessage,
AssistantMessageEventStream,
Context,
ImageContent,
Message,
ServiceTier,
StopReason,
TextContent,
Tool,
ToolCall,
ToolResultMessage,
TSchema,
} from "../types";
import {
type OpenAIChatContentPart,
type OpenAIChatMessage,
type OpenAIChatTool,
type OpenAIChatToolCall,
type OpenAIChatToolChoice,
openaiChatRequestSchema,
} from "./openai-chat-server-schema";
export type { ParsedRequest };
type ReasoningEffort = NonNullable<ParsedRequest["options"]["reasoning"]>;
function isReasoningEffort(value: unknown): value is ReasoningEffort {
return value === "minimal" || value === "low" || value === "medium" || value === "high" || value === "xhigh";
}
function isServiceTier(value: unknown): value is ServiceTier {
return value === "auto" || value === "default" || value === "flex" || value === "scale" || value === "priority";
}
// ---------------------------------------------------------------------------
// parseRequest
// ---------------------------------------------------------------------------
export function parseRequest(body: unknown, headers?: Headers): ParsedRequest {
// Header capture is centralized in `auth-gateway/server.ts` (allow-listed
// headers like openai-organization/openai-project/openai-beta/x-stainless-*
// land on `options.headers` automatically). We consult `headers` here too
// for `resolvePromptCacheKey` to pull a cache identity out of inbound
// vendor-neutral headers when the body doesn't carry one.
const parsed = openaiChatRequestSchema.safeParse(body);
if (!parsed.success) {
throw new Error(`openai-chat: ${parsed.error.message}`);
}
const data = parsed.data;
const now = Date.now();
const systemParts: string[] = [];
const messages: Message[] = [];
for (const m of data.messages as OpenAIChatMessage[]) {
switch (m.role) {
case "system": {
const text = stringifyContent(m.content);
if (text.length > 0) systemParts.push(text);
break;
}
case "developer":
messages.push({ role: "developer", content: parseUserLikeContent(m.content), timestamp: now });
break;
case "user":
messages.push({ role: "user", content: parseUserLikeContent(m.content), timestamp: now });
break;
case "assistant":
messages.push(
buildAssistantMessage(
(m.content ?? undefined) as string | OpenAIChatContentPart[] | undefined,
m.tool_calls,
data.model,
now,
),
);
break;
case "tool":
pushToolResultMessages(messages, m.content, m.tool_call_id, undefined, now);
break;
case "function": {
// Legacy `function` role (pre-tools API): the message carries the tool's
// name on `name` and its output on `content`. Translate to a canonical
// `toolResult` with a synthetic id (no original id on the wire).
const fn = m as { role: "function"; name: string; content: string | null };
pushToolResultMessages(messages, fn.content ?? "", undefined, fn.name, now);
break;
}
}
}
const tools = data.tools ? buildTools(data.tools as OpenAIChatTool[]) : undefined;
const context: Context = {
messages,
...(systemParts.length > 0 ? { systemPrompt: [systemParts.join("\n\n")] } : {}),
...(tools ? { tools } : {}),
};
// Prefer max_completion_tokens (newer) over max_tokens.
const maxOutputTokens = data.max_completion_tokens ?? data.max_tokens;
const stopSequences = normalizeStop(data.stop);
// Schema accepts the Anthropic-style {type:'tool', name} variant that the SDK
// union doesn't model; the normalizer collapses it to a plain name lookup.
const toolChoice = normalizeToolChoice(data.tool_choice as Parameters<typeof normalizeToolChoice>[0]);
const includeStreamingUsage = data.stream_options?.include_usage === true;
// `includeStreamingUsage` is the one genuinely-opaque flag — the streaming
// encoder reads it later off `options.extra`. Everything else now lives on
// a typed field; `extra` stays undefined when only typed values are set.
const extra: Record<string, unknown> = {};
let hasExtra = false;
if (includeStreamingUsage) {
extra.includeStreamingUsage = true;
hasExtra = true;
}
const options: ParsedRequest["options"] = {};
if (maxOutputTokens !== undefined) options.maxOutputTokens = maxOutputTokens;
if (data.temperature !== undefined) options.temperature = data.temperature;
if (data.top_p !== undefined) options.topP = data.top_p;
if (stopSequences) options.stopSequences = stopSequences;
if (toolChoice !== undefined) options.toolChoice = toolChoice;
if (data.presence_penalty !== undefined) options.presencePenalty = data.presence_penalty;
if (data.frequency_penalty !== undefined) options.frequencyPenalty = data.frequency_penalty;
if (data.seed !== undefined) options.seed = data.seed;
if (data.logit_bias !== undefined) options.logitBias = data.logit_bias;
if (data.user !== undefined) options.user = data.user;
if (data.response_format !== undefined) options.responseFormat = data.response_format;
if (data.parallel_tool_calls !== undefined) options.parallelToolCalls = data.parallel_tool_calls;
if (data.reasoning_effort !== undefined && isReasoningEffort(data.reasoning_effort)) {
options.reasoning = data.reasoning_effort;
}
if (data.service_tier !== undefined && isServiceTier(data.service_tier)) {
options.serviceTier = data.service_tier;
}
if (data.metadata !== undefined) options.metadata = data.metadata;
const cacheKey = resolvePromptCacheKey(body, headers);
if (cacheKey !== undefined) options.promptCacheKey = cacheKey;
if (hasExtra) options.extra = extra;
return {
modelId: data.model,
context,
stream: data.stream === true,
options,
};
}
function stringifyContent(content: string | OpenAIChatContentPart[] | undefined): string {
if (content === undefined) return "";
if (typeof content === "string") return content;
const out: string[] = [];
for (const part of content) {
if (part.type === "text") out.push(part.text);
}
return out.join("");
}
function parseUserLikeContent(
content: string | OpenAIChatContentPart[] | undefined,
): string | (TextContent | ImageContent)[] {
if (content === undefined) return "";
if (typeof content === "string") return content;
const parts: (TextContent | ImageContent)[] = [];
for (const part of content) {
if (part.type === "text") {
parts.push({ type: "text", text: part.text });
continue;
}
if (part.type !== "image_url") continue;
// input_audio / file / refusal / unknown-type parts are accepted by the
// schema for forward-compat but dropped here — pi-ai's canonical user
// content only models text and image today.
const url = typeof part.image_url === "string" ? part.image_url : part.image_url.url;
const decoded = decodeDataUri(url);
if (decoded) {
parts.push({ type: "image", data: decoded.data, mimeType: decoded.mimeType });
} else {
// No image fetcher available in the gateway; surface as a text placeholder so
// downstream providers still receive a coherent message.
parts.push({ type: "text", text: `[image: ${url}]` });
}
}
return parts;
}
function decodeDataUri(url: string): { data: string; mimeType: string } | undefined {
if (!url.startsWith("data:")) return undefined;
const comma = url.indexOf(",");
if (comma < 0) return undefined;
const header = url.slice(5, comma);
const payload = url.slice(comma + 1);
const isBase64 = header.endsWith(";base64");
const mimeType = (isBase64 ? header.slice(0, -";base64".length) : header) || "application/octet-stream";
const data = isBase64 ? payload : Buffer.from(decodeURIComponent(payload), "utf8").toString("base64");
return { data, mimeType };
}
function buildAssistantMessage(
content: string | OpenAIChatContentPart[] | undefined,
toolCalls: OpenAIChatToolCall[] | undefined,
modelId: string,
now: number,
): AssistantMessage {
const parts: AssistantMessage["content"] = [];
const text = stringifyContent(content);
if (text.length > 0) parts.push({ type: "text", text });
if (toolCalls) {
for (const raw of toolCalls) {
// Schema only accepts type:"function" (or omitted); narrow the SDK
// union here so the custom-tool variant doesn't trip TS.
if (raw.type !== undefined && raw.type !== "function") continue;
const fn = (raw as { function: { name: string; arguments: string } }).function;
const argsStr = fn.arguments;
let args: Record<string, unknown> = {};
if (argsStr.length > 0) {
try {
const v: unknown = JSON.parse(argsStr);
args =
v && typeof v === "object" && !Array.isArray(v) ? (v as Record<string, unknown>) : { __raw: argsStr };
} catch {
args = { __raw: argsStr };
}
}
const call: ToolCall = { type: "toolCall", id: raw.id, name: fn.name, arguments: args };
parts.push(call);
}
}
return {
role: "assistant",
content: parts,
api: "openai-completions",
provider: "openai",
model: modelId,
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop",
timestamp: now,
};
}
/**
* Walk a wire `tool` (or legacy `function`) message into canonical messages.
* Tool-result content may carry images alongside text; pi-ai's
* `ToolResultMessage` accepts both, but most downstream providers ignore
* images on tool results. To mirror Rust's `encode_messages` behavior we
* keep text inside the tool-result message and hoist any image parts into a
* follow-up `user` message so they still reach the model.
*/
function pushToolResultMessages(
messages: Message[],
content: string | OpenAIChatContentPart[] | undefined | null,
toolCallId: string | undefined,
toolName: string | undefined,
now: number,
): void {
const textParts: TextContent[] = [];
const imageParts: ImageContent[] = [];
if (typeof content === "string") {
if (content.length > 0) textParts.push({ type: "text", text: content });
} else if (Array.isArray(content)) {
for (const part of content) {
if (part.type === "text") {
textParts.push({ type: "text", text: part.text });
continue;
}
if (part.type !== "image_url") continue;
const url = typeof part.image_url === "string" ? part.image_url : part.image_url.url;
const decoded = decodeDataUri(url);
if (decoded) {
imageParts.push({ type: "image", data: decoded.data, mimeType: decoded.mimeType });
} else {
// No fetcher available; degrade gracefully to a text placeholder.
textParts.push({ type: "text", text: `[image: ${url}]` });
}
}
}
const toolMsg: ToolResultMessage = {
role: "toolResult",
toolCallId: toolCallId ?? "",
// OpenAI's `tool` role omits the tool name on the wire; the legacy
// `function` role supplies it. Downstream providers tolerate empty.
toolName: toolName ?? "",
content: textParts.length > 0 ? textParts : [{ type: "text", text: "" }],
isError: false,
timestamp: now,
};
messages.push(toolMsg);
if (imageParts.length > 0) {
messages.push({
role: "user",
content: imageParts,
timestamp: now,
});
}
}
function buildTools(tools: OpenAIChatTool[]): Tool[] | undefined {
if (tools.length === 0) return undefined;
const out: Tool[] = [];
for (const t of tools) {
if (t.type !== "function") continue;
out.push({
name: t.function.name,
description: t.function.description ?? "",
parameters: (t.function.parameters ?? {}) as Record<string, unknown> as TSchema,
});
}
return out;
}
function normalizeStop(value: string | string[] | undefined): string[] | undefined {
if (value === undefined) return undefined;
if (typeof value === "string") return [value];
return value.length > 0 ? value : undefined;
}
function normalizeToolChoice(value: OpenAIChatToolChoice | undefined): ParsedRequest["options"]["toolChoice"] {
if (value === undefined) return undefined;
if (value === "auto" || value === "none" || value === "required") return value;
if (typeof value === "object" && value !== null) {
// OpenAI canonical: { type: 'function', function: { name } }
if ("function" in value && value.function) return { name: value.function.name };
// Anthropic-style passthrough (schema-allowed): { type: 'tool', name }
const anthropicLike = value as unknown as { type?: string; name?: string };
if (anthropicLike.type === "tool" && typeof anthropicLike.name === "string") {
return { name: anthropicLike.name };
}
}
return undefined;
}
// ---------------------------------------------------------------------------
// encodeResponse (non-streaming)
// ---------------------------------------------------------------------------
export function encodeResponse(message: AssistantMessage, requestedModelId: string): Record<string, unknown> {
const { text, reasoning, toolCalls } = flattenAssistant(message);
const responseMessage: Record<string, unknown> = {
role: "assistant",
content: text.length > 0 ? text : null,
// pi-ai does not surface real refusals yet; emit `null` so SDKs that
// probe `.refusal` see the documented field shape rather than missing.
refusal: null,
};
if (reasoning.length > 0) {
// DeepSeek-style / o-series reasoning channel.
responseMessage.reasoning_content = reasoning;
}
if (toolCalls.length > 0) {
responseMessage.tool_calls = toolCalls.map(tc => ({
id: tc.id,
type: "function",
function: { name: tc.name, arguments: stringifyArgs(tc.arguments) },
}));
}
return {
id: makeId(),
object: "chat.completion",
created: Math.floor(Date.now() / 1000),
model: requestedModelId,
// Real OpenAI always emits this key, even when the value is null. Mirror
// the contract so probing SDKs do not throw on a missing field.
system_fingerprint: null,
choices: [
{
index: 0,
message: responseMessage,
finish_reason: mapFinishReason(message.stopReason, toolCalls.length > 0),
logprobs: null,
},
],
usage: buildUsage(message),
};
}
function buildUsage(message: AssistantMessage): Record<string, unknown> {
const promptTokens = message.usage.input + message.usage.cacheRead + message.usage.cacheWrite;
const usage: Record<string, unknown> = {
prompt_tokens: promptTokens,
completion_tokens: message.usage.output,
total_tokens: promptTokens + message.usage.output,
prompt_tokens_details: { cached_tokens: message.usage.cacheRead },
};
if (message.usage.reasoningTokens !== undefined) {
usage.completion_tokens_details = { reasoning_tokens: message.usage.reasoningTokens };
}
return usage;
}
function flattenAssistant(message: AssistantMessage): {
text: string;
reasoning: string;
toolCalls: ToolCall[];
} {
let text = "";
let reasoning = "";
const toolCalls: ToolCall[] = [];
for (const part of message.content) {
switch (part.type) {
case "text":
text += part.text;
break;
case "thinking":
reasoning += part.thinking;
break;
case "redactedThinking":
// Opaque blob — surface verbatim on the reasoning channel so the
// concatenation round-trips through clients that just echo it.
reasoning += part.data;
break;
case "toolCall":
toolCalls.push(part);
break;
}
}
return { text, reasoning, toolCalls };
}
function isOnlyRaw(args: Record<string, unknown>): boolean {
for (const k in args) {
if (k !== "__raw") return false;
}
return true;
}
function stringifyArgs(args: Record<string, unknown>): string {
// `__raw` is our fallback marker for un-parseable inbound args; preserve it verbatim on the way out.
if (typeof args.__raw === "string" && isOnlyRaw(args)) return args.__raw;
try {
return JSON.stringify(args);
} catch {
return "{}";
}
}
function mapFinishReason(reason: StopReason, hasToolCalls: boolean): string {
if (reason === "toolUse" || (hasToolCalls && reason === "stop")) return "tool_calls";
if (reason === "length") return "length";
// pi-ai's StopReason does not currently carry a content-filter signal;
// when it does, map it to "content_filter" here.
return "stop";
}
function makeId(): string {
return `chatcmpl-${randomUUID()}`;
}
// ---------------------------------------------------------------------------
// encodeStream (SSE)
// ---------------------------------------------------------------------------
export function encodeStream(
events: AssistantMessageEventStream,
requestedModelId: string,
options?: ParsedRequest["options"],
): ReadableStream<Uint8Array> {
const encoder = new TextEncoder();
const id = makeId();
const created = Math.floor(Date.now() / 1000);
const includeUsage = options?.extra?.includeStreamingUsage === true;
const baseChunk = (delta: Record<string, unknown>, finishReason: string | null) => ({
id,
object: "chat.completion.chunk",
created,
model: requestedModelId,
system_fingerprint: null,
choices: [{ index: 0, delta, finish_reason: finishReason, logprobs: null }],
...(includeUsage ? { usage: null } : {}),
});
const writeSse = (controller: ReadableStreamDefaultController<Uint8Array>, payload: unknown): void => {
controller.enqueue(encoder.encode(`data: ${JSON.stringify(payload)}\n\n`));
};
const writeUsage = (controller: ReadableStreamDefaultController<Uint8Array>, message: AssistantMessage): void => {
writeSse(controller, {
id,
object: "chat.completion.chunk",
created,
model: requestedModelId,
system_fingerprint: null,
choices: [],
usage: buildUsage(message),
});
};
return new ReadableStream<Uint8Array>({
async start(controller) {
// contentIndex (from pi-ai events) -> tool_calls index on the wire.
const toolIndexByContentIndex = new Map<number, number>();
let nextToolIndex = 0;
let hasToolCalls = false;
let finishReason: string = "stop";
try {
// Initial role chunk.
writeSse(controller, baseChunk({ role: "assistant" }, null));
for await (const event of events) {
switch (event.type) {
case "text_delta":
if (event.delta.length > 0) {
writeSse(controller, baseChunk({ content: event.delta }, null));
}
break;
case "thinking_delta":
// DeepSeek-style / o-series reasoning channel. Clients that don't
// understand it ignore the unknown delta key.
if (event.delta.length > 0) {
writeSse(controller, baseChunk({ reasoning_content: event.delta }, null));
}
break;
case "toolcall_start": {
hasToolCalls = true;
const idx = nextToolIndex++;
toolIndexByContentIndex.set(event.contentIndex, idx);
const partial = event.partial.content[event.contentIndex];
const call = partial && partial.type === "toolCall" ? partial : undefined;
writeSse(
controller,
baseChunk(
{
tool_calls: [
{
index: idx,
id: call?.id ?? "",
type: "function",
function: { name: call?.name ?? "", arguments: "" },
},
],
},
null,
),
);
break;
}
case "toolcall_delta": {
const idx = toolIndexByContentIndex.get(event.contentIndex);
if (idx === undefined) break;
writeSse(
controller,
baseChunk({ tool_calls: [{ index: idx, function: { arguments: event.delta } }] }, null),
);
break;
}
case "done":
finishReason =
event.reason === "toolUse"
? "tool_calls"
: event.reason === "length"
? "length"
: hasToolCalls
? "tool_calls"
: "stop";
writeSse(controller, baseChunk({}, finishReason));
if (includeUsage) writeUsage(controller, event.message);
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
controller.close();
return;
case "error": {
const msg = event.error.errorMessage ?? "stream error";
writeSse(controller, { error: { message: msg, type: "upstream_error" } });
controller.close();
return;
}
// Drop start / *_start / *_end — chat-completions wire only
// surfaces deltas and the terminal finish_reason.
default:
break;
}
}
// Stream ended without a terminal `done` (defensive). Close gracefully.
writeSse(controller, baseChunk({}, hasToolCalls ? "tool_calls" : "stop"));
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
controller.close();
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
writeSse(controller, { error: { message: msg, type: "upstream_error" } });
controller.close();
}
},
});
}
// ---------------------------------------------------------------------------
// formatError
// ---------------------------------------------------------------------------
/**
* OpenAI chat-completions error envelope:
* `{ error: { message, type } }`
* Matches the shape the official SDK auto-parses into `APIError`.
*/
export function formatError(status: number, type: string, message: string): Response {
return new Response(JSON.stringify({ error: { message, type } }), {
status,
headers: { "Content-Type": "application/json" },
});
}
@@ -1084,6 +1084,13 @@ function buildParams(
if (options?.repetitionPenalty !== undefined) {
params.repetition_penalty = options.repetitionPenalty;
}
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;
}
if (shouldSendServiceTier(options?.serviceTier, model.provider)) {
params.service_tier = options.serviceTier;
}
@@ -0,0 +1,290 @@
/**
* Zod schemas for the OpenAI Responses API request shape we accept on the
* gateway. Mirrors https://platform.openai.com/docs/api-reference/responses.
*
* Unsupported / opaque controls (background/include/metadata/prompt/…) are
* accepted as `z.unknown().optional()` so we silently ignore rather than 400.
* Real clients (codex, openai-python, llm-git) routinely send these and a 400
* is a worse outcome than dropping them on the floor.
*/
import type {
EasyInputMessage,
ResponseCreateParams,
ResponseFunctionToolCall,
ResponseInputContent,
ResponseInputItem,
ResponseOutputMessage,
ResponseReasoningItem,
Tool as ResponsesTool,
} from "openai/resources/responses/responses";
import * as z from "zod/v4";
// ─── Input content blocks ───────────────────────────────────────────────────
const inputTextSchema = z.object({
type: z.literal("input_text"),
text: z.string(),
});
const plainTextSchema = z.object({
type: z.literal("text"),
text: z.string(),
});
const inputImageBlockSchema = z
.object({
type: z.literal("input_image"),
detail: z.enum(["auto", "low", "high"]).optional(),
image_url: z.string().optional(),
file_id: z.string().optional(),
})
.refine(v => typeof v.image_url === "string" || typeof v.file_id === "string", {
message: "input_image requires at least one of `image_url` or `file_id`",
});
const inputFileBlockSchema = z.object({
type: z.literal("input_file"),
file_id: z.string().optional(),
filename: z.string().optional(),
file_data: z.string().optional(),
});
const outputTextSchema = z.object({
type: z.literal("output_text"),
text: z.string(),
});
const outputRefusalSchema = z.object({
type: z.literal("refusal"),
refusal: z.string(),
});
const summaryTextSchema = z.object({
type: z.literal("summary_text"),
text: z.string(),
});
const reasoningTextSchema = z.object({
type: z.literal("reasoning_text"),
text: z.string(),
});
const inputContentBlockSchema = z.union([
inputTextSchema,
plainTextSchema,
inputImageBlockSchema,
inputFileBlockSchema,
]);
const outputContentBlockSchema = z.union([outputTextSchema, plainTextSchema, outputRefusalSchema]);
// ─── Input items ────────────────────────────────────────────────────────────
const userMessageItemSchema = z.object({
type: z.literal("message").optional(),
role: z.union([z.literal("user"), z.literal("developer")]),
content: z.union([z.string(), z.array(inputContentBlockSchema)]).optional(),
});
const systemMessageItemSchema = z.object({
type: z.literal("message").optional(),
role: z.literal("system"),
content: z.union([z.string(), z.array(inputContentBlockSchema)]).optional(),
});
const assistantMessageItemSchema = z.object({
type: z.literal("message").optional(),
role: z.literal("assistant"),
content: z.union([z.string(), z.array(outputContentBlockSchema)]).optional(),
});
const reasoningItemSchema = z.object({
type: z.literal("reasoning"),
id: z.string().optional(),
summary: z.array(summaryTextSchema).optional(),
content: z.array(reasoningTextSchema).optional(),
});
const functionCallItemSchema = z.object({
type: z.literal("function_call"),
id: z.string().optional(),
call_id: z.string().min(1),
name: z.string().min(1),
arguments: z.string().optional(),
});
const functionCallOutputItemSchema = z.object({
type: z.literal("function_call_output"),
call_id: z.string().min(1),
// Codex CLI replays multimodal tool results in array form (text + refusal).
output: z.union([z.string(), z.array(outputContentBlockSchema)]).optional(),
});
const customToolCallItemSchema = z.object({
type: z.literal("custom_tool_call"),
id: z.string().optional(),
call_id: z.string().min(1),
name: z.string().min(1),
// Raw input string — NOT JSON.stringified. apply_patch flow streams a
// freeform body and reading it as JSON would corrupt it.
input: z.string(),
});
const customToolCallOutputItemSchema = z.object({
type: z.literal("custom_tool_call_output"),
call_id: z.string().min(1),
output: z.string(),
});
/**
* An input item is one of the union members below. The convenience shape
* `{role, content}` (no `type`) is mapped to "message" before validation in
* the walker — schemas here only handle the canonical {type, ...} forms.
*/
export const inputItemSchema = z.union([
userMessageItemSchema,
systemMessageItemSchema,
assistantMessageItemSchema,
reasoningItemSchema,
functionCallItemSchema,
functionCallOutputItemSchema,
customToolCallItemSchema,
customToolCallOutputItemSchema,
// Tolerated but not bridged (file_search_call, web_search_call, …).
z.object({ type: z.string() }).loose(),
]);
// Variant types alias the canonical SDK union members so the walker can
// narrow them cleanly. The convenience "message" shape (no `type` field) maps
// to EasyInputMessage; the explicit form maps to ResponseInputItem.Message.
export type OpenAIResponsesUserItem = EasyInputMessage | ResponseInputItem.Message;
export type OpenAIResponsesSystemItem = EasyInputMessage | ResponseInputItem.Message;
export type OpenAIResponsesAssistantItem = EasyInputMessage | ResponseOutputMessage;
export type OpenAIResponsesReasoningItem = ResponseReasoningItem;
export type OpenAIResponsesFunctionCallItem = ResponseFunctionToolCall;
export type OpenAIResponsesFunctionCallOutputItem = ResponseInputItem.FunctionCallOutput;
/** Inferred shape of the custom tool call input item (no canonical SDK alias). */
export type OpenAIResponsesCustomToolCallItem = z.infer<typeof customToolCallItemSchema>;
export type OpenAIResponsesCustomToolCallOutputItem = z.infer<typeof customToolCallOutputItemSchema>;
export type OpenAIResponsesInputImageBlock = z.infer<typeof inputImageBlockSchema>;
export type OpenAIResponsesInputFileBlock = z.infer<typeof inputFileBlockSchema>;
export type OpenAIResponsesOutputRefusalBlock = z.infer<typeof outputRefusalSchema>;
// ─── Tools ──────────────────────────────────────────────────────────────────
export const toolSchema = z.object({
type: z.literal("function"),
name: z.string().min(1),
description: z.string().optional(),
parameters: z.record(z.string(), z.unknown()).optional(),
strict: z.boolean().optional(),
});
// Built-in / hosted tool entries (web_search_preview, file_search, …) — accepted
// but skipped by the walker.
const builtinToolSchema = z
.object({
type: z.string(),
})
.loose();
// ─── Tool choice ────────────────────────────────────────────────────────────
const hostedToolType = z.enum([
"web_search_preview",
"file_search",
"computer_use_preview",
"code_interpreter",
"image_generation",
"mcp",
]);
const allowedToolEntrySchema = z.object({
type: z.string(),
name: z.string().optional(),
});
export const toolChoiceSchema = z.union([
z.literal("auto"),
z.literal("none"),
z.literal("required"),
z.object({
type: z.literal("function"),
name: z.string().min(1),
}),
// Codex apply_patch flow.
z.object({
type: z.literal("custom"),
name: z.string().min(1),
}),
// Hosted-tool selection (no extra fields).
z.object({
type: hostedToolType,
}),
// `allowed_tools` — walker treats as auto.
z.object({
type: z.literal("allowed_tools"),
mode: z.enum(["auto", "required"]),
tools: z.array(allowedToolEntrySchema),
}),
]);
// ─── Reasoning config ───────────────────────────────────────────────────────
export const reasoningConfigSchema = z.object({
effort: z.string().optional(),
// `none` maps to hideThinkingSummary; auto/concise/detailed mean "show
// summary". pi-ai has no per-level plumbing for the latter — walker logs
// once and treats them as default.
summary: z.enum(["auto", "concise", "detailed", "none"]).optional(),
});
// ─── Stop ───────────────────────────────────────────────────────────────────
export const stopSchema = z.union([z.string(), z.array(z.string()), z.null()]);
// ─── Top-level request ──────────────────────────────────────────────────────
export const openaiResponsesRequestSchema = z.object({
model: z.string().min(1),
input: z.union([z.string(), z.array(inputItemSchema)]).optional(),
instructions: z.union([z.string(), z.null()]).optional(),
tools: z.array(z.union([toolSchema, builtinToolSchema])).optional(),
tool_choice: toolChoiceSchema.optional(),
max_output_tokens: z.number().optional(),
temperature: z.number().optional(),
top_p: z.number().optional(),
stop: stopSchema.optional(),
stream: z.boolean().optional(),
reasoning: reasoningConfigSchema.optional(),
store: z.boolean().optional(),
previous_response_id: z.string().optional(),
parallel_tool_calls: z.boolean().optional(),
prompt_cache_key: z.string().optional(),
metadata: z.unknown().optional(),
user: z.string().optional(),
service_tier: z.string().optional(),
presence_penalty: z.number().optional(),
frequency_penalty: z.number().optional(),
// Accepted-but-ignored: include `reasoning.encrypted_content` is the canonical
// way to request reasoning replay — silently accept and drop.
background: z.unknown().optional(),
include: z.unknown().optional(),
prompt: z.unknown().optional(),
safety_identifier: z.unknown().optional(),
text: z.unknown().optional(),
top_logprobs: z.unknown().optional(),
truncation: z.unknown().optional(),
});
/**
* Public types are sourced from the OpenAI SDK so the gateway stays in
* lock-step with the canonical API surface; the schemas above are runtime
* validators for the subset we actually accept.
*/
export type OpenAIResponsesRequest = ResponseCreateParams;
export type OpenAIResponsesInputItem = ResponseInputItem;
export type OpenAIResponsesTool = ResponsesTool;
export type OpenAIResponsesToolChoice = NonNullable<ResponseCreateParams["tool_choice"]>;
export type OpenAIResponsesInputContent = ResponseInputContent;
export type OpenAIResponsesOutputContent = ResponseOutputMessage["content"][number];
File diff suppressed because it is too large Load Diff
@@ -171,6 +171,7 @@ type OpenAIResponsesSamplingParams = ResponseCreateParamsStreaming & {
min_p?: number;
presence_penalty?: number;
repetition_penalty?: number;
stream_options?: { include_obfuscation?: boolean };
};
/**
@@ -404,9 +405,14 @@ function buildParams(
prompt_cache_key: promptCacheKey,
prompt_cache_retention: promptCacheKey ? getPromptCacheRetention(model.baseUrl, cacheRetention) : undefined,
store: false,
stream_options: model.provider === "openai" ? { include_obfuscation: false } : undefined,
};
applyCommonResponsesSamplingParams(params, options, model.provider);
// TODO: openai responses has no top-level `stop`/`stop_sequences`; surface via reasoning.stop?
// `StreamOptions.stopSequences` is intentionally dropped for this provider.
// TODO: openai responses has no top-level `frequency_penalty` field as of the current SDK;
// `StreamOptions.frequencyPenalty` is intentionally dropped for this provider.
if (context.tools) {
params.tools = convertTools(context.tools, supportsStrictMode(model), model);
@@ -0,0 +1,228 @@
/**
* Client half of the pi-native auth-gateway protocol.
*
* Dispatches a {@link streamSimple}-shaped request to an `omp auth-gateway`
* via `POST /v1/pi/stream`, reads the SSE event stream back, and pushes the
* parsed events into a local {@link AssistantMessageEventStream} — the same
* stream type every other provider client produces. Callers downstream of
* `streamSimple` cannot tell whether the events came from a real provider
* SDK or from a gateway hop; they consume `AssistantMessageEvent`s either
* way.
*
* Activated when a {@link Model} has `transport: "pi-native"` set; the
* dispatch hook lives in `streamSimple()` (see `../stream.ts`). Used by
* containerized omp deployments (robomp slots, the swarm extension) that
* route every LLM call through a credential-holding sidecar so the slot
* itself stays credential-free.
*/
import { readSseJson } from "@oh-my-pi/pi-utils";
import type {
Api,
AssistantMessage,
AssistantMessageEvent,
AssistantMessageEventStream as AssistantMessageEventStreamType,
Context,
Model,
SimpleStreamOptions,
} from "../types";
import { AssistantMessageEventStream } from "../utils/event-stream";
/**
* Fields that must not cross the wire — either non-serializable (functions,
* `AbortSignal`, the provider-session `Map`) or server-controlled
* (`apiKey`, which the gateway injects from its own credential store; the
* client's `apiKey` is the gateway *bearer*, sent in the `Authorization`
* header rather than the request body).
*/
const NON_WIRE_KEYS = new Set<keyof SimpleStreamOptions>([
"signal",
"apiKey",
"fetch",
"onPayload",
"onResponse",
"onSseEvent",
"execHandlers",
"cursorExecHandlers",
"cursorOnToolResult",
"providerSessionState",
]);
function buildWireOptions(options: SimpleStreamOptions | undefined): Record<string, unknown> {
if (!options) return {};
const wire: Record<string, unknown> = {};
for (const [k, v] of Object.entries(options)) {
if (v === undefined) continue;
if (NON_WIRE_KEYS.has(k as keyof SimpleStreamOptions)) continue;
wire[k] = v;
}
return wire;
}
async function decodeGatewayError(response: Response): Promise<Error> {
const status = response.status;
let body: unknown;
try {
body = await response.json();
} catch {
body = await response.text().catch(() => "");
}
if (typeof body === "object" && body !== null && "error" in body) {
const err = (body as { error: unknown }).error;
if (typeof err === "object" && err !== null) {
const message = (err as { message?: unknown }).message;
const type = (err as { type?: unknown }).type;
const out = new Error(typeof message === "string" ? message : `auth-gateway ${status}`);
(out as { status?: number; type?: string }).status = status;
if (typeof type === "string") (out as { type?: string }).type = type;
return out;
}
}
const text = typeof body === "string" ? body : JSON.stringify(body);
const err = new Error(`auth-gateway ${status}: ${text || response.statusText}`);
(err as { status?: number }).status = status;
return err;
}
/**
* Resolve the `/v1/pi/stream` endpoint URL from the model's `baseUrl`.
* Trims a trailing slash so concatenation can't double-slash; throws when
* the baseUrl is missing (transport=pi-native without a gateway target is
* a configuration error, not a runtime recoverable one).
*/
function resolveStreamUrl(model: Model<Api>): string {
if (!model.baseUrl) {
throw new Error(
`pi-native transport requires \`baseUrl\` on model ${model.id} (set it on the provider config in models.yml)`,
);
}
return `${model.baseUrl.replace(/\/+$/, "")}/v1/pi/stream`;
}
function buildHeaders(model: Model<Api>, apiKey: string | undefined): Record<string, string> {
const headers: Record<string, string> = {
"Content-Type": "application/json",
Accept: "text/event-stream",
...(model.headers ?? {}),
};
if (apiKey && !headers.Authorization) {
headers.Authorization = `Bearer ${apiKey}`;
}
return headers;
}
/**
* Stream a turn through an `omp auth-gateway` over the pi-native protocol.
*
* The returned {@link AssistantMessageEventStream} receives each parsed
* `AssistantMessageEvent` verbatim from the gateway; the terminal `done` /
* `error` event resolves `.result()` automatically via the base class's
* completion check. Non-streaming consumers just call `.result()` and pay
* for SSE framing they don't use — that overhead is dominated by provider
* latency, so we always stream rather than maintaining a parallel
* non-streaming path.
*/
export function streamPiNative<TApi extends Api>(
model: Model<TApi>,
context: Context,
options?: SimpleStreamOptions,
): AssistantMessageEventStreamType {
const stream = new AssistantMessageEventStream();
void (async () => {
const signal = options?.signal;
// Abort propagation: cancel the response body when the caller's signal
// fires. Mirror `streamProxy`'s shape — explicit listener + finally
// cleanup — so we don't leak listeners on the long-running case.
let response: Response | null = null;
const onAbort = (): void => {
const body = response?.body;
if (body) body.cancel("Request aborted by caller").catch(() => {});
};
if (signal) {
if (signal.aborted) {
stream.fail(signal.reason instanceof Error ? signal.reason : new Error(String(signal.reason ?? "aborted")));
return;
}
signal.addEventListener("abort", onAbort, { once: true });
}
try {
const url = resolveStreamUrl(model as Model<Api>);
const fetchImpl = options?.fetch ?? globalThis.fetch;
const headers = buildHeaders(model as Model<Api>, options?.apiKey);
const body = JSON.stringify({
modelId: model.id,
context,
options: buildWireOptions(options),
stream: true,
});
response = await fetchImpl(url, { method: "POST", headers, body, signal });
if (!response.ok) {
stream.fail(await decodeGatewayError(response));
return;
}
if (!response.body) {
stream.fail(new Error("auth-gateway returned empty body"));
return;
}
let sawTerminal = false;
for await (const event of readSseJson<AssistantMessageEvent>(
response.body as ReadableStream<Uint8Array>,
signal,
)) {
if (event.type === "done" || event.type === "error") sawTerminal = true;
stream.push(event);
// `stream.push` resolves `.result()` on `done`/`error`; subsequent
// pushes are silently dropped by the base class. We still iterate
// to drain any trailing bytes from the wire so the underlying TCP
// stream closes cleanly.
}
if (!sawTerminal) {
// SSE closed before a terminal event reached us — synthesize one
// so awaiters of `.result()` resolve instead of hanging forever.
// Matches the gateway's own defensive fallback in
// `pi-native-server.encodeStream`.
const aborted = signal?.aborted === true;
const partial = makeSyntheticAssistant(model as Model<Api>);
if (aborted) {
partial.stopReason = "aborted";
partial.errorMessage = "stream closed without terminal event";
stream.push({ type: "error", reason: "aborted", error: partial });
} else {
partial.stopReason = "stop";
stream.push({ type: "done", reason: "stop", message: partial });
}
}
stream.end();
} catch (err) {
stream.fail(err);
} finally {
if (signal) signal.removeEventListener("abort", onAbort);
}
})();
return stream;
}
function makeSyntheticAssistant(model: Model<Api>): AssistantMessage {
return {
role: "assistant",
content: [],
api: model.api,
provider: model.provider,
model: model.id,
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop",
timestamp: Date.now(),
};
}
@@ -0,0 +1,210 @@
/**
* Pi-native wire format for the auth-gateway.
*
* Where the OpenAI / Anthropic / Responses route modules translate foreign
* wire shapes through pi-ai's canonical {@link Context}, this module accepts
* the canonical shape *directly* — for clients that already speak pi-ai
* (containerized omp, the swarm extension, robomp's sidecar auth-gateway).
* Skipping the wire-format → Context → wire-format round-trip cuts
* per-request CPU but, more importantly, avoids the quantization that those
* translations impose on first-class pi-ai fields (service tier, cache
* markers, thinking budgets, tool-choice variants, …).
*
* The streaming wire is {@link AssistantMessageEvent} serialized verbatim and
* SSE-framed. Same type pi-ai already produces internally; the client feeds
* each parsed event straight into `AssistantMessageEventStream.push()` with
* no translation. Including `partial: AssistantMessage` on every delta is
* O(N²) in turn length on the wire — acceptable for the loopback / sidecar
* topology this transport is designed for; provider latency dominates the
* actual cost.
*
* Endpoint contract:
* POST /v1/pi/stream
* body: { modelId, context, options?, stream? } // `stream` defaults to true
* 200 SSE: stream of `AssistantMessageEvent` (terminated by `data: [DONE]`)
* 200 JSON (stream=false): { message: AssistantMessage }
* 4xx/5xx: { error: { type, message } }
*/
import type { AssistantMessageEventStream, Context, SimpleStreamOptions } from "../types";
export interface PiNativeParsedRequest {
modelId: string;
context: Context;
options: SimpleStreamOptions;
stream: boolean;
}
/**
* Subset of {@link SimpleStreamOptions} accepted from the wire. Function-valued
* fields (`fetch`, `onPayload`, `onResponse`, `onSseEvent`, exec handlers, the
* provider-session map) and gateway-owned controls (`apiKey`, `signal`) are
* intentionally absent — those are server-side concerns. Anything outside this
* allow-list is dropped silently rather than 400ing, so clients can forward
* `SimpleStreamOptions` from older / newer omp builds without per-version
* conditionals.
*/
const ALLOWED_OPTION_KEYS: ReadonlySet<keyof SimpleStreamOptions> = new Set([
"temperature",
"topP",
"topK",
"minP",
"presencePenalty",
"frequencyPenalty",
"repetitionPenalty",
"stopSequences",
"maxTokens",
"cacheRetention",
"headers",
"initiatorOverride",
"maxRetryDelayMs",
"metadata",
"sessionId",
"streamFirstEventTimeoutMs",
"streamIdleTimeoutMs",
"reasoning",
"disableReasoning",
"hideThinkingSummary",
"thinkingBudgets",
"toolChoice",
"serviceTier",
"kimiApiFormat",
"syntheticApiFormat",
"preferWebsockets",
] as const satisfies readonly (keyof SimpleStreamOptions)[]);
// ---------------------------------------------------------------------------
// parseRequest
// ---------------------------------------------------------------------------
/**
* Parse a pi-native request body. Validation is intentionally minimal — only
* the shape the gateway itself reads is checked (`modelId`, `context.messages`
* array, options is an object). Everything downstream is the canonical pi-ai
* type surface; mis-shaped values surface as a `502 upstream_error` from
* `streamSimple` rather than being re-validated here.
*
* Accepts both `{ modelId: string }` and `{ model: { id: string } }` so the
* existing `streamProxy` client (which sends the full Model object) can target
* the gateway with only a URL swap.
*/
export function parseRequest(body: unknown, _headers?: Headers): PiNativeParsedRequest {
if (typeof body !== "object" || body === null || Array.isArray(body)) {
throw new Error("Request body must be a JSON object");
}
const obj = body as Record<string, unknown>;
let modelId: string | undefined;
if (typeof obj.modelId === "string" && obj.modelId.length > 0) {
modelId = obj.modelId;
} else if (typeof obj.model === "string" && obj.model.length > 0) {
modelId = obj.model;
} else if (typeof obj.model === "object" && obj.model !== null) {
const m = obj.model as Record<string, unknown>;
if (typeof m.id === "string" && m.id.length > 0) modelId = m.id;
}
if (!modelId) throw new Error("Missing `modelId` (or `model.id`) field");
const context = obj.context;
if (typeof context !== "object" || context === null || Array.isArray(context)) {
throw new Error("Missing `context` object");
}
const ctxObj = context as Record<string, unknown>;
if (!Array.isArray(ctxObj.messages)) {
throw new Error("`context.messages` must be an array");
}
if (ctxObj.systemPrompt !== undefined && !Array.isArray(ctxObj.systemPrompt)) {
throw new Error("`context.systemPrompt` must be an array of strings when present");
}
if (ctxObj.tools !== undefined && !Array.isArray(ctxObj.tools)) {
throw new Error("`context.tools` must be an array when present");
}
const options: SimpleStreamOptions = {};
const rawOpts = obj.options;
if (typeof rawOpts === "object" && rawOpts !== null && !Array.isArray(rawOpts)) {
const optsBag = options as Record<string, unknown>;
for (const [k, v] of Object.entries(rawOpts)) {
if (v === undefined || v === null) continue;
if (!ALLOWED_OPTION_KEYS.has(k as keyof SimpleStreamOptions)) continue;
optsBag[k] = v;
}
}
// `stream` defaults to true — pi-native clients overwhelmingly stream, and
// matching `streamProxy`'s implicit-stream behavior avoids a one-flag papercut.
const stream = typeof obj.stream === "boolean" ? obj.stream : true;
return {
modelId,
context: context as Context,
options,
stream,
};
}
// ---------------------------------------------------------------------------
// encodeStream (SSE)
// ---------------------------------------------------------------------------
const SSE_ENCODER = new TextEncoder();
const SSE_DONE = SSE_ENCODER.encode("data: [DONE]\n\n");
/**
* Ship every {@link AssistantMessageEvent} verbatim, SSE-framed.
*
* No per-event re-shaping: the pi-native client is pi-ai itself, so the
* canonical event type IS the wire type. Including the rolling
* `partial: AssistantMessage` on every delta is quadratic in turn length
* on the wire, but for the loopback / sidecar topology this transport
* targets (containerized omp → host gateway, robomp slot → omp-auth-gateway
* sidecar) the bandwidth cost is negligible compared to provider latency —
* and the client gets to feed the events straight into its existing
* `AssistantMessageEventStream.push()` plumbing with zero translation.
*/
export function encodeStream(events: AssistantMessageEventStream): ReadableStream<Uint8Array> {
return new ReadableStream<Uint8Array>({
async start(controller) {
try {
for await (const event of events) {
controller.enqueue(SSE_ENCODER.encode(`data: ${JSON.stringify(event)}\n\n`));
if (event.type === "done" || event.type === "error") break;
}
controller.enqueue(SSE_DONE);
controller.close();
} catch (err) {
// Best-effort error envelope so the client iterator resolves
// instead of hanging on the dropped connection. Shape matches the
// canonical `error` event minus the unrecoverable `error:
// AssistantMessage` payload (we don't have a usable one here).
const message = err instanceof Error ? err.message : String(err);
controller.enqueue(
SSE_ENCODER.encode(
`data: ${JSON.stringify({ type: "error", reason: "error", errorMessage: message })}\n\n`,
),
);
controller.enqueue(SSE_DONE);
controller.close();
}
},
});
}
// ---------------------------------------------------------------------------
// formatError
// ---------------------------------------------------------------------------
/**
* Pi-native error envelope:
* `{ error: { type, message } }`
*
* Mirrors OpenAI's outer shape (which clients/SDKs already parse) without the
* provider-specific status taxonomy — pi-native callers consume `type`
* directly.
*/
export function formatError(status: number, type: string, message: string): Response {
return new Response(JSON.stringify({ error: { type, message } }), {
status,
headers: {
"Content-Type": "application/json; charset=utf-8",
"Cache-Control": "no-store",
},
});
}
+67 -3
View File
@@ -1,7 +1,7 @@
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { $env, $pickenv } from "@oh-my-pi/pi-utils";
import { $env, $pickenv, extractHttpStatusFromError } from "@oh-my-pi/pi-utils";
import { getCustomApi } from "./api-registry";
import type { Effort } from "./model-thinking";
import {
@@ -19,6 +19,7 @@ import type { GoogleVertexOptions } from "./providers/google-vertex";
import { isKimiModel, streamKimi } from "./providers/kimi";
import type { OllamaChatOptions } from "./providers/ollama";
import type { OpenAICompletionsOptions } from "./providers/openai-completions";
import { streamPiNative } from "./providers/pi-native-client";
// Heavy provider stream functions are imported lazily via register-builtins,
// which wraps each provider module in a dynamic import. This keeps the
// AWS SDK, google-auth-library, @google/genai, @bufbuild/protobuf, and
@@ -44,7 +45,6 @@ import { isSyntheticModel, streamSynthetic } from "./providers/synthetic";
import type {
Api,
AssistantMessage,
AssistantMessageEventStream,
Context,
Model,
OptionsForApi,
@@ -53,6 +53,7 @@ import type {
ThinkingBudgets,
ToolChoice,
} from "./types";
import { AssistantMessageEventStream } from "./utils/event-stream";
import { isFoundryEnabled } from "./utils/foundry";
let cachedVertexAdcCredentialsExists: boolean | null = null;
@@ -176,6 +177,15 @@ export function getEnvApiKey(provider: string): string | undefined {
return resolver?.();
}
/**
* Enumerate every provider that has an env-var fallback for `getEnvApiKey`.
* Used by `omp auth-broker migrate --include-env` to discover env-sourced keys
* that should be uploaded to the broker.
*/
export function listProvidersWithEnvKey(): string[] {
return Object.keys(serviceProviderMap);
}
export function stream<TApi extends Api>(
model: Model<TApi>,
context: Context,
@@ -269,7 +279,61 @@ export function streamSimple<TApi extends Api>(
context: Context,
options?: SimpleStreamOptions,
): AssistantMessageEventStream {
// Check custom API registry first (extension-provided APIs)
const retryApiKey = options?.onAuthError ? (options.apiKey ?? getEnvApiKey(model.provider)) : undefined;
if (retryApiKey) {
const outer = new AssistantMessageEventStream();
const onAuthError = options!.onAuthError!;
let emitted = false;
void (async () => {
try {
const inner = streamSimple(model, context, { ...options, apiKey: retryApiKey, onAuthError: undefined });
for await (const event of inner) {
emitted = true;
outer.push(event);
if (outer.done) return;
}
if (!outer.done) outer.end(await inner.result());
} catch (error) {
if (emitted || extractHttpStatusFromError(error) !== 401) {
outer.fail(error);
return;
}
let nextKey: string | undefined;
try {
nextKey = await onAuthError(model.provider, retryApiKey, error);
} catch {
nextKey = undefined;
}
if (!nextKey || nextKey === retryApiKey) {
outer.fail(error);
return;
}
try {
const retried = streamSimple(model, context, { ...options, apiKey: nextKey, onAuthError: undefined });
for await (const event of retried) {
outer.push(event);
if (outer.done) return;
}
if (!outer.done) outer.end(await retried.result());
} catch (retryError) {
outer.fail(retryError);
}
}
})();
return outer;
}
// Pi-native transport short-circuits the per-provider dispatch entirely:
// the gateway resolves provider + credential server-side, so we don't
// need an `apiKey` from `getEnvApiKey` here — `options.apiKey` carries
// the gateway bearer instead. Comes BEFORE the custom-API check so
// extension-registered APIs can't accidentally override a configured
// pi-native transport.
if (model.transport === "pi-native") {
return streamPiNative(model, context, options);
}
// Check custom API registry (extension-provided APIs)
const customApiProvider = getCustomApi(model.api);
if (customApiProvider) {
return customApiProvider.streamSimple(model, context, options);
+36
View File
@@ -220,9 +220,26 @@ export interface StreamOptions {
minP?: number;
presencePenalty?: number;
repetitionPenalty?: number;
/**
* Stop sequences. Anthropic encodes as `stop_sequences` (array, max 4);
* OpenAI chat-completions encodes as `stop` (string or array of up to 4);
* OpenAI Responses API has no `stop` field today (silently dropped by the
* provider when present).
*/
stopSequences?: string[];
/**
* Frequency penalty (OpenAI). Penalizes new tokens based on existing frequency
* in the text so far. Range -2.0 to 2.0. Parallel to {@link presencePenalty}.
*/
frequencyPenalty?: number;
maxTokens?: number;
signal?: AbortSignal;
apiKey?: string;
/**
* Called when a provider returns 401 before any assistant event has been
* emitted. Returning a different key retries the provider request once.
*/
onAuthError?: (provider: string, apiKey: string, error: unknown) => Promise<string | undefined>;
cacheRetention?: CacheRetention;
/**
* Additional headers to include in provider requests.
@@ -284,6 +301,10 @@ export interface StreamOptions {
* Set to 0 to disable the inter-event idle watchdog for this request.
*/
streamIdleTimeoutMs?: number;
/**
* Optional retry delay hook for tests and transports that need custom scheduling.
*/
providerRetryWait?: (delayMs: number, signal?: AbortSignal) => Promise<void>;
/**
* Optional `fetch` implementation override. Providers route every HTTP
* request — direct calls, SDK clients, and retry helpers — through this
@@ -755,6 +776,21 @@ export interface Model<TApi extends Api = any> {
contextWindow: number;
maxTokens: number;
headers?: Record<string, string>;
/**
* Streaming transport override. When `"pi-native"`, `streamSimple` routes
* the request to the model's `baseUrl` via the auth-gateway's
* `POST /v1/pi/stream` endpoint instead of dispatching the per-API
* provider client. The `baseUrl` must point at an `omp auth-gateway`
* (or compatible) host; `headers.Authorization` (or `apiKey` resolved by
* the registry) carries the gateway bearer.
*
* Used by containerized omp installs (e.g. robomp slots) to route every
* LLM call through a sidecar gateway that holds the real provider
* credentials. The model's other metadata (pricing, context window,
* thinking config, …) still resolves locally; only the streaming
* dispatch is redirected.
*/
transport?: "pi-native";
/** Hint that websocket transport should be preferred when supported by the provider implementation. */
preferWebsockets?: boolean;
/** Preferred model to switch to when context promotion is triggered (model id or provider/id). */
+54 -1
View File
@@ -4,8 +4,8 @@
* Provides a normalized schema to represent multiple limit windows, model tiers,
* and shared quotas across providers.
*/
import * as z from "zod/v4";
import type { Provider } from "./types";
export type UsageUnit = "percent" | "tokens" | "requests" | "usd" | "minutes" | "bytes" | "unknown";
export type UsageStatus = "ok" | "warning" | "exhausted" | "unknown";
@@ -72,6 +72,58 @@ export interface UsageReport {
raw?: unknown;
}
// ─── Zod schemas (wire-shape validation for the broker `/v1/usage` endpoint) ─
export const usageUnitSchema = z.enum(["percent", "tokens", "requests", "usd", "minutes", "bytes", "unknown"]);
export const usageStatusSchema = z.enum(["ok", "warning", "exhausted", "unknown"]);
export const usageWindowSchema = z.object({
id: z.string(),
label: z.string(),
durationMs: z.number().optional(),
resetsAt: z.number().optional(),
});
export const usageAmountSchema = z.object({
used: z.number().optional(),
limit: z.number().optional(),
remaining: z.number().optional(),
usedFraction: z.number().optional(),
remainingFraction: z.number().optional(),
unit: usageUnitSchema,
});
export const usageScopeSchema = z.object({
provider: z.string(),
accountId: z.string().optional(),
projectId: z.string().optional(),
orgId: z.string().optional(),
modelId: z.string().optional(),
tier: z.string().optional(),
windowId: z.string().optional(),
shared: z.boolean().optional(),
});
export const usageLimitSchema = z.object({
id: z.string(),
label: z.string(),
scope: usageScopeSchema,
window: usageWindowSchema.optional(),
amount: usageAmountSchema,
status: usageStatusSchema.optional(),
notes: z.array(z.string()).optional(),
});
export const usageReportSchema = z.object({
provider: z.string(),
fetchedAt: z.number(),
limits: z.array(usageLimitSchema),
metadata: z.record(z.string(), z.unknown()).optional(),
// `raw` is provider-specific and may be anything; the broker strips it before
// sending the report over the wire, so accept-but-ignore here.
raw: z.unknown().optional(),
});
/** Optional logger for usage fetchers. */
export interface UsageLogger {
debug(message: string, meta?: Record<string, unknown>): void;
@@ -104,6 +156,7 @@ export interface UsageFetchParams {
export interface UsageFetchContext {
fetch: typeof fetch;
logger?: UsageLogger;
retryWait?: (delayMs: number, signal?: AbortSignal) => Promise<void>;
}
/** Provider implementation for fetching usage information. */
+131 -37
View File
@@ -1,3 +1,4 @@
import { scheduler } from "node:timers/promises";
import type {
CredentialRankingStrategy,
UsageAmount,
@@ -14,7 +15,7 @@ import { isRecord, toNumber } from "../utils";
const DEFAULT_ENDPOINT = "https://api.anthropic.com/api/oauth";
const FIVE_HOURS_MS = 5 * 60 * 60 * 1000;
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
const MAX_RETRIES = 3;
const MAX_ATTEMPTS = 3;
const BASE_RETRY_DELAY_MS = 500;
const CLAUDE_HEADERS = {
@@ -90,6 +91,11 @@ function getPayloadString(payload: Record<string, unknown>, key: string): string
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function getNestedPayloadString(payload: Record<string, unknown>, key: string, nestedKey: string): string | undefined {
const nested = payload[key];
return isRecord(nested) ? getPayloadString(nested, nestedKey) : undefined;
}
function extractUsageIdentity(payload: ClaudeUsageResponse, orgId?: string): { accountId?: string; email?: string } {
if (!isRecord(payload)) return { accountId: orgId };
const accountId =
@@ -99,16 +105,70 @@ function extractUsageIdentity(payload: ClaudeUsageResponse, orgId?: string): { a
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;
const email =
getPayloadString(payload, "email") ??
getPayloadString(payload, "user_email") ??
getPayloadString(payload, "userEmail");
getPayloadString(payload, "userEmail") ??
getNestedPayloadString(payload, "account", "email") ??
getNestedPayloadString(payload, "user", "email");
return { accountId, email };
}
function hasUsageData(payload: ClaudeUsageResponse): boolean {
return Boolean(payload.five_hour || payload.seven_day || payload.seven_day_opus || payload.seven_day_sonnet);
return (
parseBucket(payload.five_hour)?.utilization !== undefined ||
parseBucket(payload.seven_day)?.utilization !== undefined ||
parseBucket(payload.seven_day_opus)?.utilization !== undefined ||
parseBucket(payload.seven_day_sonnet)?.utilization !== undefined
);
}
function isRetryableStatus(status: number): boolean {
return status === 429 || (status >= 500 && status < 600);
}
function isAbortError(error: unknown, signal?: AbortSignal): boolean {
if (signal?.aborted) return true;
if (!isRecord(error)) return false;
return error.name === "AbortError" || error.name === "TimeoutError";
}
function retryDelayMs(attempt: number, retryAfter: string | null): number {
const baseline = BASE_RETRY_DELAY_MS * 2 ** attempt;
if (!retryAfter?.trim()) return baseline;
const seconds = Number.parseFloat(retryAfter);
if (Number.isFinite(seconds)) return Math.max(baseline, Math.max(0, seconds * 1000));
const dateDelay = Date.parse(retryAfter) - Date.now();
return Number.isFinite(dateDelay) ? Math.max(baseline, Math.max(0, dateDelay)) : baseline;
}
async function waitBeforeRetry(
attempt: number,
retryAfter: string | null,
signal?: AbortSignal,
retryWait?: UsageFetchContext["retryWait"],
): Promise<boolean> {
if (signal?.aborted) return false;
if (attempt >= MAX_ATTEMPTS - 1) return false;
try {
const delayMs = retryDelayMs(attempt, retryAfter);
if (retryWait) {
await retryWait(delayMs, signal);
} else {
await scheduler.wait(delayMs, { signal });
}
return !signal?.aborted;
} catch (error) {
if (isAbortError(error, signal)) return false;
throw error;
}
}
async function fetchUsagePayload(
@@ -117,29 +177,50 @@ async function fetchUsagePayload(
ctx: UsageFetchContext,
signal?: AbortSignal,
): Promise<ClaudeUsagePayload | null> {
if (signal?.aborted) return null;
let lastPayload: ClaudeUsageResponse | null = null;
let lastOrgId: string | undefined;
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
try {
const response = await ctx.fetch(url, { headers, signal });
if (!response.ok) {
ctx.logger?.warn("Claude usage fetch failed", { status: response.status, statusText: response.statusText });
return null;
}
const payload = (await response.json()) as ClaudeUsageResponse;
lastPayload = payload;
const orgId = response.headers.get("anthropic-organization-id")?.trim() || undefined;
lastOrgId = orgId ?? lastOrgId;
if (payload && isRecord(payload) && hasUsageData(payload)) {
return { payload, orgId };
}
} catch (error) {
ctx.logger?.warn("Claude usage fetch error", { error: String(error) });
return null;
}
if (attempt < MAX_RETRIES - 1) {
await Bun.sleep(BASE_RETRY_DELAY_MS * 2 ** attempt);
if (!response.ok) {
const retryable = isRetryableStatus(response.status);
ctx.logger?.warn("Claude usage fetch failed", {
status: response.status,
statusText: response.statusText,
attempt,
willRetry: retryable && attempt < MAX_ATTEMPTS - 1,
});
if (!retryable) return null;
const retryAfter = response.headers.get("retry-after");
if (!(await waitBeforeRetry(attempt, retryAfter, signal, ctx.retryWait))) break;
continue;
}
const parsed = (await response.json()) as unknown;
if (isRecord(parsed)) {
const payload = parsed as ClaudeUsageResponse;
lastPayload = payload;
if (hasUsageData(payload)) return { payload, orgId };
}
ctx.logger?.warn("Claude usage response missing usage data", {
attempt,
willRetry: attempt < MAX_ATTEMPTS - 1,
});
if (!(await waitBeforeRetry(attempt, null, signal, ctx.retryWait))) break;
} catch (error) {
if (isAbortError(error, signal)) return null;
ctx.logger?.warn("Claude usage fetch error", {
error: String(error),
attempt,
willRetry: attempt < MAX_ATTEMPTS - 1,
});
if (!(await waitBeforeRetry(attempt, null, signal, ctx.retryWait))) break;
}
}
@@ -147,40 +228,47 @@ async function fetchUsagePayload(
}
interface ClaudeProfile {
uuid?: string;
email?: string;
account?: {
uuid?: string;
email?: string;
};
}
function extractProfileIdentity(profile: ClaudeProfile | null): { accountId?: string; email?: string } {
if (!profile || !isRecord(profile)) return {};
const account = isRecord(profile.account) ? profile.account : undefined;
return {
accountId:
(typeof profile.uuid === "string" && profile.uuid.trim() ? profile.uuid.trim() : undefined) ??
(typeof account?.uuid === "string" && account.uuid.trim() ? account.uuid.trim() : undefined),
email:
(typeof profile.email === "string" && profile.email.trim() ? profile.email.trim() : undefined) ??
(typeof account?.email === "string" && account.email.trim() ? account.email.trim() : undefined),
};
}
async function fetchProfile(
baseUrl: string,
headers: Record<string, string>,
ctx: UsageFetchContext,
signal?: AbortSignal,
): Promise<ClaudeProfile | null> {
if (signal?.aborted) return null;
const url = `${baseUrl}/profile`;
try {
const response = await ctx.fetch(url, { headers, signal });
if (!response.ok) return null;
return (await response.json()) as ClaudeProfile;
} catch {
const payload = (await response.json()) as unknown;
return isRecord(payload) ? (payload as ClaudeProfile) : null;
} catch (error) {
if (isAbortError(error, signal)) return null;
ctx.logger?.debug("Claude profile fetch error", { error: String(error) });
return null;
}
}
async function resolveEmail(
params: UsageFetchParams,
ctx: UsageFetchContext,
baseUrl: string,
headers: Record<string, string>,
): Promise<string | undefined> {
if (params.credential.email) return params.credential.email;
const profile = await fetchProfile(baseUrl, headers, ctx, params.signal);
return profile?.account?.email;
}
function buildUsageAmount(utilization: number | undefined): UsageAmount | undefined {
if (utilization === undefined) return undefined;
const clamped = Math.min(Math.max(utilization, 0), 100);
@@ -303,17 +391,23 @@ async function fetchClaudeUsage(params: UsageFetchParams, ctx: UsageFetchContext
if (limits.length === 0) return null;
const identity = extractUsageIdentity(payload, orgId);
const accountId = identity.accountId ?? credential.accountId;
const email = identity.email ?? (await resolveEmail(params, ctx, baseUrl, headers));
let accountId = identity.accountId ?? credential.accountId;
let email = identity.email ?? credential.email;
if ((!accountId || !email) && !params.signal?.aborted) {
const profileIdentity = extractProfileIdentity(await fetchProfile(baseUrl, headers, ctx, params.signal));
accountId = accountId ?? profileIdentity.accountId;
email = email ?? profileIdentity.email;
}
const report: UsageReport = {
provider: params.provider,
fetchedAt: Date.now(),
limits,
metadata: {
accountId,
email,
endpoint: url,
...(accountId ? { accountId } : {}),
...(email ? { email } : {}),
...(orgId ? { orgId } : {}),
},
raw: payload,
};
+132 -6
View File
@@ -31,9 +31,16 @@ interface CodexUsageRateLimitPayload {
secondary_window?: CodexUsageWindowPayload | null;
}
interface CodexUsageAdditionalRateLimitPayload {
limit_name?: string;
metered_feature?: string;
rate_limit?: CodexUsageRateLimitPayload | null;
}
interface CodexUsagePayload {
plan_type?: string;
rate_limit?: CodexUsageRateLimitPayload | null;
additional_rate_limits?: CodexUsageAdditionalRateLimitPayload[] | null;
}
interface ParsedUsageWindow {
@@ -43,12 +50,22 @@ interface ParsedUsageWindow {
resetAt?: number;
}
interface ParsedAdditionalUsage {
limitName?: string;
meteredFeature?: string;
allowed?: boolean;
limitReached?: boolean;
primary?: ParsedUsageWindow;
secondary?: ParsedUsageWindow;
}
interface ParsedUsage {
planType?: string;
allowed?: boolean;
limitReached?: boolean;
primary?: ParsedUsageWindow;
secondary?: ParsedUsageWindow;
additional: ParsedAdditionalUsage[];
raw: CodexUsagePayload;
}
@@ -124,20 +141,45 @@ function parseUsageWindow(payload: unknown): ParsedUsageWindow | undefined {
};
}
function parseAdditionalRateLimit(payload: unknown): ParsedAdditionalUsage | null {
if (!isRecord(payload)) return null;
const limitName = typeof payload.limit_name === "string" ? payload.limit_name : undefined;
const meteredFeature = typeof payload.metered_feature === "string" ? payload.metered_feature : undefined;
const rateLimit = isRecord(payload.rate_limit) ? payload.rate_limit : undefined;
if (!rateLimit) return null;
const primary = parseUsageWindow(rateLimit.primary_window);
const secondary = parseUsageWindow(rateLimit.secondary_window);
const allowed = toBoolean(rateLimit.allowed);
const limitReached = toBoolean(rateLimit.limit_reached);
if (!primary && !secondary && allowed === undefined && limitReached === undefined) return null;
return { limitName, meteredFeature, allowed, limitReached, primary, secondary };
}
function parseUsagePayload(payload: unknown): ParsedUsage | null {
if (!isRecord(payload)) return null;
const planType = typeof payload.plan_type === "string" ? payload.plan_type : undefined;
const rateLimit = isRecord(payload.rate_limit) ? payload.rate_limit : undefined;
if (!rateLimit) return null;
const additionalRaw = Array.isArray(payload.additional_rate_limits) ? payload.additional_rate_limits : [];
const additional = additionalRaw
.map(parseAdditionalRateLimit)
.filter((value): value is ParsedAdditionalUsage => value !== null);
if (!rateLimit && additional.length === 0) return null;
const parsed: ParsedUsage = {
planType,
allowed: toBoolean(rateLimit.allowed),
limitReached: toBoolean(rateLimit.limit_reached),
primary: parseUsageWindow(rateLimit.primary_window),
secondary: parseUsageWindow(rateLimit.secondary_window),
allowed: rateLimit ? toBoolean(rateLimit.allowed) : undefined,
limitReached: rateLimit ? toBoolean(rateLimit.limit_reached) : undefined,
primary: rateLimit ? parseUsageWindow(rateLimit.primary_window) : undefined,
secondary: rateLimit ? parseUsageWindow(rateLimit.secondary_window) : undefined,
additional,
raw: payload as CodexUsagePayload,
};
if (!parsed.primary && !parsed.secondary && parsed.allowed === undefined && parsed.limitReached === undefined) {
if (
!parsed.primary &&
!parsed.secondary &&
parsed.allowed === undefined &&
parsed.limitReached === undefined &&
parsed.additional.length === 0
) {
return null;
}
return parsed;
@@ -251,6 +293,56 @@ function buildUsageLimit(args: {
status: buildUsageStatus(amount.usedFraction, args.limitReached),
};
}
function additionalLimitSlug(args: { limitName?: string; meteredFeature?: string }): string {
const probe = `${args.limitName ?? ""} ${args.meteredFeature ?? ""}`.toLowerCase();
if (probe.includes("spark") || probe.includes("bengalfox")) return "spark";
const source = (args.meteredFeature ?? args.limitName ?? "extra").toLowerCase();
return (
source
.replace(/^codex[-_]/, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "") || "extra"
);
}
function additionalDisplayName(slug: string, limitName?: string): string {
if (slug === "spark") return "Spark";
if (limitName) return limitName;
return slug.replace(
/(^|-)([a-z])/g,
(_match, sep: string, ch: string) => `${sep === "-" ? " " : ""}${ch.toUpperCase()}`,
);
}
function buildAdditionalUsageLimit(args: {
key: "primary" | "secondary";
slug: string;
displayName: string;
window: ParsedUsageWindow;
accountId?: string;
limitReached?: boolean;
limitName?: string;
meteredFeature?: string;
nowMs: number;
}): UsageLimit {
const usageWindow = buildUsageWindow(args.window, args.key, args.nowMs);
const amount = buildUsageAmount(args.window);
return {
id: `openai-codex:${args.slug}:${args.key}`,
label: `${usageWindow.label} (${args.displayName})`,
scope: {
provider: "openai-codex",
accountId: args.accountId,
tier: args.slug,
modelId: args.limitName,
windowId: usageWindow.id,
shared: true,
},
window: usageWindow,
amount,
status: buildUsageStatus(amount.usedFraction, args.limitReached),
};
}
export const openaiCodexUsageProvider: UsageProvider = {
id: "openai-codex",
@@ -327,6 +419,40 @@ export const openaiCodexUsageProvider: UsageProvider = {
}),
);
}
for (const extra of parsed?.additional ?? []) {
const slug = additionalLimitSlug({ limitName: extra.limitName, meteredFeature: extra.meteredFeature });
const displayName = additionalDisplayName(slug, extra.limitName);
if (extra.primary) {
limits.push(
buildAdditionalUsageLimit({
key: "primary",
slug,
displayName,
window: extra.primary,
accountId,
limitReached: extra.limitReached,
limitName: extra.limitName,
meteredFeature: extra.meteredFeature,
nowMs,
}),
);
}
if (extra.secondary) {
limits.push(
buildAdditionalUsageLimit({
key: "secondary",
slug,
displayName,
window: extra.secondary,
accountId,
limitReached: extra.limitReached,
limitName: extra.limitName,
meteredFeature: extra.meteredFeature,
nowMs,
}),
);
}
}
const report: UsageReport = {
provider: "openai-codex",
+9 -7
View File
@@ -9,7 +9,7 @@
* 5. Generic Anthropic fallback (ANTHROPIC_API_KEY / ANTHROPIC_BASE_URL)
*/
import { $env, getAgentDbPath } from "@oh-my-pi/pi-utils";
import { type AuthCredential, AuthCredentialStore } from "../auth-storage";
import { type AuthCredential, type AuthCredentialStore, SqliteAuthCredentialStore } from "../auth-storage";
import {
buildAnthropicHeaders as buildProviderAnthropicHeaders,
normalizeAnthropicBaseUrl,
@@ -80,7 +80,7 @@ function toAnthropicOAuthCredential(credential: AuthCredential): AnthropicOAuthC
*/
async function readAnthropicOAuthCredentials(store?: AuthCredentialStore): Promise<AnthropicOAuthCredential[]> {
const ownsStore = !store;
const effectiveStore = store ?? (await AuthCredentialStore.open(getAgentDbPath()));
const effectiveStore = store ?? (await SqliteAuthCredentialStore.open(getAgentDbPath()));
try {
const records = effectiveStore.listAuthCredentials("anthropic");
const credentials: AnthropicOAuthCredential[] = [];
@@ -133,7 +133,7 @@ export async function findAnthropicAuth(store?: AuthCredentialStore): Promise<An
// Tiers 3-4 use the credential store; manage lifecycle once
const ownsStore = !store;
const effectiveStore = store ?? (await AuthCredentialStore.open(getAgentDbPath()));
const effectiveStore = store ?? (await SqliteAuthCredentialStore.open(getAgentDbPath()));
try {
// 3. OAuth credentials in agent.db (with 5-minute expiry buffer)
const expiryBuffer = 5 * 60 * 1000; // 5 minutes
@@ -151,12 +151,14 @@ export async function findAnthropicAuth(store?: AuthCredentialStore): Promise<An
}
// 4. API key credentials in agent.db
const storedApiKey = effectiveStore.getApiKey("anthropic");
if (storedApiKey) {
const apiKeyRecord = effectiveStore
.listAuthCredentials("anthropic")
.find(record => record.credential.type === "api_key");
if (apiKeyRecord && apiKeyRecord.credential.type === "api_key") {
return {
apiKey: storedApiKey,
apiKey: apiKeyRecord.credential.key,
baseUrl: resolveAnthropicBaseUrlFromEnv() ?? DEFAULT_BASE_URL,
isOAuth: isOAuthToken(storedApiKey),
isOAuth: isOAuthToken(apiKeyRecord.credential.key),
};
}
} finally {
-1
View File
@@ -1,5 +1,4 @@
export * from "./antigravity";
export * from "./codex";
export * from "./cursor";
export * from "./gemini";
export * from "./openai-compatible";
+10 -2
View File
@@ -165,10 +165,12 @@ async function pollForGitHubAccessToken(
intervalSeconds: number,
expiresIn: number,
signal?: AbortSignal,
pollIntervalFloorMs = 1000,
pollIntervalScaleMs = 1000,
) {
const urls = getUrls(domain);
const deadline = Date.now() + expiresIn * 1000;
let intervalMs = Math.max(1000, Math.floor(intervalSeconds * 1000));
let intervalMs = Math.max(pollIntervalFloorMs, Math.floor(intervalSeconds * pollIntervalScaleMs));
let intervalMultiplier = INITIAL_POLL_INTERVAL_MULTIPLIER;
let slowDownResponses = 0;
@@ -212,7 +214,9 @@ async function pollForGitHubAccessToken(
if (error === "slow_down") {
slowDownResponses += 1;
intervalMs =
typeof interval === "number" && interval > 0 ? interval * 1000 : Math.max(1000, intervalMs + 5000);
typeof interval === "number" && interval > 0
? Math.max(pollIntervalFloorMs, interval * pollIntervalScaleMs)
: Math.max(pollIntervalFloorMs, intervalMs + 5 * pollIntervalScaleMs);
intervalMultiplier = SLOW_DOWN_POLL_INTERVAL_MULTIPLIER;
continue;
}
@@ -308,6 +312,8 @@ export async function loginGitHubCopilot(options: {
onPrompt: (prompt: { message: string; placeholder?: string; allowEmpty?: boolean }) => Promise<string>;
onProgress?: (message: string) => void;
signal?: AbortSignal;
pollIntervalFloorMs?: number;
pollIntervalScaleMs?: number;
}): Promise<OAuthCredentials> {
const input = await options.onPrompt({
message: "GitHub Enterprise URL/domain (blank for github.com)",
@@ -337,6 +343,8 @@ export async function loginGitHubCopilot(options: {
device.interval,
device.expires_in,
options.signal,
options.pollIntervalFloorMs,
options.pollIntervalScaleMs,
);
// With opencode OAuth, the GitHub token is used directly for all API requests
+54
View File
@@ -0,0 +1,54 @@
/**
* Shared `host:port` parser used by the auth-broker and auth-gateway boot
* paths. Centralized so the two servers can't drift on what they accept (the
* gateway used to silently allow empty hostnames; this fixes it).
*/
export interface ParsedBind {
hostname: string;
port: number;
}
function parsePort(raw: string, bind: string): number {
if (!/^\d+$/.test(raw)) {
throw new Error(`Invalid bind '${bind}'; port must be an integer.`);
}
const port = Number.parseInt(raw, 10);
if (!Number.isFinite(port) || port < 0 || port > 65535) {
throw new Error(`Invalid bind '${bind}'; port out of range.`);
}
return port;
}
/**
* Parse a `host:port` (or bare `port`, which assumes loopback) string.
*
* Accepts:
* - `"4000"` → `127.0.0.1:4000`
* - `"0.0.0.0:4000"` → as written
* - `"[::1]:4000"` → as written (brackets retained, Bun handles them)
*
* Rejects:
* - empty input
* - empty hostname (`":4000"`)
* - non-integer / out-of-range port
*/
export function parseBind(raw: string): ParsedBind {
const trimmed = raw.trim();
if (trimmed.length === 0) {
throw new Error("Invalid bind; expected 'host:port' or 'port'.");
}
if (/^\d+$/.test(trimmed)) {
return { hostname: "127.0.0.1", port: parsePort(trimmed, raw) };
}
const lastColon = trimmed.lastIndexOf(":");
if (lastColon < 0) {
throw new Error(`Invalid bind '${raw}'; expected 'host:port' or 'port'.`);
}
const hostPart = trimmed.slice(0, lastColon);
const portPart = trimmed.slice(lastColon + 1);
if (hostPart.length === 0) {
throw new Error(`Invalid bind '${raw}'; host must not be empty.`);
}
return { hostname: hostPart, port: parsePort(portPart, raw) };
}
+3 -2
View File
@@ -34,11 +34,12 @@ const COPILOT_MODEL_RETRY_BASE_DELAY_MS = 400;
*/
export async function callWithCopilotModelRetry<T>(
fn: () => Promise<T>,
options: { provider: string; signal?: AbortSignal },
options: { provider: string; signal?: AbortSignal; retryBaseDelayMs?: number },
): Promise<T> {
if (options.provider !== "github-copilot") return fn();
let lastError: unknown;
const retryBaseDelayMs = options.retryBaseDelayMs ?? COPILOT_MODEL_RETRY_BASE_DELAY_MS;
for (let attempt = 0; attempt < COPILOT_MODEL_RETRY_MAX_ATTEMPTS; attempt++) {
try {
return await fn();
@@ -46,7 +47,7 @@ export async function callWithCopilotModelRetry<T>(
lastError = error;
if (!isCopilotTransientModelError(error) && !isRetryableError(error)) throw error;
if (attempt === COPILOT_MODEL_RETRY_MAX_ATTEMPTS - 1) break;
await scheduler.wait(COPILOT_MODEL_RETRY_BASE_DELAY_MS * (attempt + 1), { signal: options.signal });
await scheduler.wait(retryBaseDelayMs * (attempt + 1), { signal: options.signal });
}
}
throw lastError;
+20 -18
View File
@@ -5,13 +5,10 @@ This document is the operational contract for schema normalization/strictness in
## Scope
- Applies to provider-facing tool schemas produced by:
- `sanitize-google.ts`
- `normalize-cca.ts`
- `strict-mode.ts`
- `adapt.ts`
- `fields.ts`
- Covers OpenAI-style strict mode, Google schema constraints, and Cloud Code Assist Claude constraints.
- `normalize.ts` — Google, CCA, MCP, OpenAI Responses, and OpenAI strict-mode (sanitize + enforce) sanitization. All schema walkers live here.
- `adapt.ts` — thin composer wrapping `tryEnforceStrictSchema` for provider call sites, plus the `PI_NO_STRICT` env flag callers consult to opt out of strict mode.
- `fields.ts` — keyword classification sets used by the walkers.
- Covers OpenAI-style strict mode, OpenAI Responses `oneOf` rejection, Google schema constraints, and Cloud Code Assist Claude constraints.
---
## 1) OpenAI-style strict mode (`adaptSchemaForStrict` / `tryEnforceStrictSchema`)
@@ -57,9 +54,9 @@ When strict mode is requested (`strict=true` at call site), the schema MUST sati
---
## 2) Google Gemini / Vertex / Gemini CLI (`sanitizeSchemaForGoogle`)
## 2) Google Gemini / Vertex / Gemini CLI (`normalizeSchemaForGoogle`)
Schemas sent on Google JSON Schema path MUST follow:
Schemas sent on the Google JSON Schema path MUST follow:
1. **Unsupported JSON Schema keywords are stripped (except property names under `properties`)**
- Unsupported keys (`UNSUPPORTED_SCHEMA_FIELDS`):
@@ -70,6 +67,7 @@ Schemas sent on Google JSON Schema path MUST follow:
- `minimum`, `maximum`, `exclusiveMinimum`, `exclusiveMaximum`
- `pattern`, `format`
- Important: keys inside a `properties` object are treated as property names and MUST NOT be stripped by keyword match.
- Human-meaningful stripped keys (`pattern`, `format`, min/max constraints, `default`, `examples`, etc.) are appended to the sibling `description` as an Anthropic-style spill block: `{pattern: "^foo$", minimum: 0}`. Structural/meta keys such as `$ref`, `$defs`, and `additionalProperties` are not spilled.
2. **`type` arrays are normalized to scalar type + nullable marker**
- `type: ["T", "null"]` becomes `type: "T"` and `nullable: true`.
@@ -78,25 +76,25 @@ Schemas sent on Google JSON Schema path MUST follow:
3. **`const` is converted to `enum`**
- If `const` exists, schema uses/merges `enum` with the const value.
4. **`additionalProperties: false` is removed**
- This value is stripped during sanitization for Google compatibility.
4. **Object schemas get an explicit properties map**
- `{ "type": "object" }` becomes `{ "type": "object", "properties": {} }`.
---
## 3) Claude via Cloud Code Assist (`prepareSchemaForCCA`)
## 3) Claude via Cloud Code Assist (`normalizeSchemaForCCA`)
For Cloud Code Assist Claude tool declarations, schema MUST satisfy stricter constraints than generic Google path.
### 3.1 Transport contract
1. **Use legacy `parameters` field** (not `parametersJsonSchema`) for CCA Claude.
2. CCA path uses `sanitizeSchemaForCCA` + normalization pipeline.
2. CCA path uses the full `normalizeSchemaForCCA` pipeline.
### 3.2 Sanitization contract
1. Start with Google sanitizer behavior.
1. Start with Google unsupported-key stripping behavior.
2. **`nullable` keyword MUST be stripped** in CCA Claude path.
3. `type: ["T", "null"]` becomes `type: "T"` with no `nullable` marker.
4. Human-meaningful stripped keys are appended to `description` with the same spill format used by the Google dispatcher.
### 3.3 Combiner/union normalization contract
@@ -145,10 +143,10 @@ If any remain, schema is incompatible.
- Emit `strict: true` only when effective strict enforcement succeeded.
- **Google Gemini/Vertex/Gemini CLI (non-CCA Claude)**:
- Use Google sanitizer and send schema on `parametersJsonSchema` path.
- Use `normalizeSchemaForGoogle` and send schema on `parametersJsonSchema` path.
- **Cloud Code Assist Claude models (`model.id` starts with `claude-`)**:
- Use CCA preparation pipeline and send sanitized normalized schema in `parameters`.
- Use `normalizeSchemaForCCA` and send sanitized normalized schema in `parameters`.
---
@@ -158,5 +156,9 @@ When adding/changing provider adapters:
1. Any new unsupported keyword MUST be added to the appropriate set in `fields.ts`.
2. Any new normalization rule MUST include regression tests under `packages/ai/test`.
3. Never bypass adapter helpers (`adaptSchemaForStrict`, `sanitizeSchemaForGoogle`, `prepareSchemaForCCA`) in provider code.
3. Never bypass adapter helpers (`adaptSchemaForStrict`, `normalizeSchemaForGoogle`, `normalizeSchemaForCCA`, `normalizeSchemaForMCP`) in provider code.
4. If a provider rejects schema with partial support, prefer deterministic per-tool fallback over request-wide failure.
## 6) Gemini CLI / Antigravity CCA parity
The Gemini CLI / Antigravity Claude path MUST run the same full `normalizeSchemaForCCA` pipeline as the shared Google Claude path. It MUST NOT call only the first keyword-stripping pass, because that leaves object combiners, nullable unions, residual combiners, and fallback gating inconsistent between transports.
+14 -62
View File
@@ -1,6 +1,18 @@
import { $flag } from "@oh-my-pi/pi-utils";
import { upgradeJsonSchemaTo202012 } from "./draft";
import { tryEnforceStrictSchema } from "./strict-mode";
import type { JsonObject } from "./types";
import { tryEnforceStrictSchema } from "./normalize";
/**
* Set when callers want to globally bypass OpenAI strict-mode enforcement
* (e.g. for debugging a provider that misreports strict support, or when
* comparing strict vs non-strict outputs).
*
* Honored by every provider that emits `strict: true` on its function tools —
* see `openai-completions`, `openai-responses`, `openai-codex-responses`, and
* the strict candidate selection in `anthropic`.
*/
export const NO_STRICT = $flag("PI_NO_STRICT");
/**
* Consolidated helper for OpenAI-style strict schema enforcement.
*
@@ -22,63 +34,3 @@ export function adaptSchemaForStrict(
return tryEnforceStrictSchema(upgraded);
}
/**
* OpenAI Responses rejects `oneOf` in tool schemas even when strict mode is
* disabled. Non-strict schemas can still use `anyOf`, so preserve the union
* shape by recursively rewriting `oneOf` branches to `anyOf`.
*/
export function sanitizeSchemaForOpenAIResponses(schema: JsonObject): JsonObject {
return rewriteOneOfToAnyOf(schema) as JsonObject;
}
/**
* Recursively replace every `oneOf` keyword with `anyOf`. Identity-preserving:
* returns the input reference unchanged when no rewrite occurred so callers
* can dedupe via reference equality (and the strict-mode cache stays warm).
* If a node has both `oneOf` and `anyOf`, the two are concatenated (the wire
* payload accepts a single union; preserving both would not survive).
*/
function rewriteOneOfToAnyOf(value: unknown): unknown {
if (Array.isArray(value)) {
let changed = false;
const rewritten = value.map(item => {
const next = rewriteOneOfToAnyOf(item);
if (next !== item) changed = true;
return next;
});
return changed ? rewritten : value;
}
if (!value || typeof value !== "object") {
return value;
}
const input = value as Record<string, unknown>;
let changed = false;
const output: Record<string, unknown> = {};
for (const key in input) {
const child = input[key];
// Skip `oneOf` here; it is re-emitted as `anyOf` after the loop so
// neighboring `anyOf` entries can be folded in.
if (key === "oneOf") {
changed = true;
continue;
}
const next = rewriteOneOfToAnyOf(child);
if (next !== child) changed = true;
output[key] = next;
}
// Re-emit `oneOf` content under `anyOf`, concatenating with any existing
// `anyOf` branches in the original node.
if (Array.isArray(input.oneOf)) {
const rewrittenOneOf = rewriteOneOfToAnyOf(input.oneOf);
const existingAnyOf = output.anyOf;
output.anyOf = Array.isArray(existingAnyOf)
? [...existingAnyOf, ...(rewrittenOneOf as unknown[])]
: rewrittenOneOf;
}
return changed ? output : value;
}
@@ -11,8 +11,8 @@ import { isJsonObject, type JsonObject } from "./types";
* Schema compatibility audits.
*
* Each provider has a different idea of what JSON Schema features it accepts
* for tool definitions. The sanitizers in `normalize-cca`, `sanitize-google`,
* and `strict-mode` rewrite incoming schemas to fit. This module is the
* for tool definitions. The normalizers in `normalize.ts`, `strict-mode`,
* and `adapt.ts` rewrite incoming schemas to fit. This module is the
* *audit* counterpart: it walks a (presumably already-sanitized) schema and
* reports any feature the target provider would reject. Tests use it to lock
* down the contract; the runtime uses it to fail-open with diagnostic logs
+26 -2
View File
@@ -11,7 +11,7 @@
/**
* Google Generative AI unsupported schema fields.
* Stripped during sanitizeSchemaForGoogle / sanitizeSchemaForCCA.
* Stripped during normalizeSchemaForGoogle / normalizeSchemaForCCA.
*/
export const UNSUPPORTED_SCHEMA_FIELDS: Record<string, true> = {
$schema: true,
@@ -38,6 +38,30 @@ export const UNSUPPORTED_SCHEMA_FIELDS: Record<string, true> = {
format: true,
};
/**
* Human-meaningful validation/decorative keywords that can be preserved in a
* sibling description when a provider-specific normalizer strips them from the
* wire schema.
*/
export const LIFTABLE_TO_DESCRIPTION_FIELDS: Record<string, true> = {
pattern: true,
format: true,
minLength: true,
maxLength: true,
minimum: true,
maximum: true,
exclusiveMinimum: true,
exclusiveMaximum: true,
multipleOf: true,
minItems: true,
maxItems: true,
uniqueItems: true,
minProperties: true,
maxProperties: true,
default: true,
examples: true,
};
/**
* Non-structural schema keys stripped during OpenAI strict mode sanitization.
* These are decorative/validation-only keywords that don't affect the structural
@@ -146,7 +170,7 @@ export const CLOUD_CODE_ASSIST_SHARED_SCHEMA_KEYS: Record<string, true> = {
/**
* Combinator keys used across schema sanitization modules.
* Defined once to avoid duplication in strict-mode.ts and normalize-cca.ts.
* Defined once to avoid duplication in strict-mode.ts and normalize.ts.
*/
export const COMBINATOR_KEYS = ["anyOf", "allOf", "oneOf"] as const;
+2 -3
View File
@@ -6,8 +6,7 @@ export * from "./equality";
export * from "./fields";
export * from "./json-schema-validator";
export * from "./meta-validator";
export * from "./normalize-cca";
export * from "./sanitize-google";
export * from "./strict-mode";
export * from "./normalize";
export * from "./spill";
export * from "./types";
export * from "./wire";
@@ -1,490 +0,0 @@
/**
* Cloud Code Assist (CCA) for Claude rejects most JSON Schema combinator and
* nullable shapes. This module is the multi-pass rewriter that turns whatever
* the tool author authored into the narrow subset CCA accepts:
*
* 1. `sanitizeSchemaForCCA` — strip Google-incompatible keywords, normalize
* `type: [..., "null"]` arrays into a scalar + nullable.
* 2. `mergeObjectCombinerVariants` — collapse `anyOf` of object variants
* into a single merged object.
* 3. `collapseMixedTypeCombinerVariants` — `anyOf` of distinct scalar types
* collapses to the first non-null type (lossy, intentional).
* 4. `collapseSameTypeCombinerVariants` — `anyOf` of variants with one
* shared type collapses to that variant (lossy, intentional).
* 5. `stripResidualCombiners` — fixpoint loop applying 3+4 to combiners that
* pass-1 merging produced from inside merged subtrees.
* 6. `normalizeNullablePropertiesForCloudCodeAssist` — extract `nullable: T`
* from `anyOf:[T,null]`-shaped property schemas and demote those keys
* from `required`.
*
* If any incompatibility survives, we ship a stub `{type:"object",properties:{}}`
* fallback for that tool — CCA will accept the call but the model will see no
* arguments documented. Better than rejecting the whole turn.
*/
import { logger } from "@oh-my-pi/pi-utils";
import { areJsonValuesEqual, mergePropertySchemas } from "./equality";
import { CLOUD_CODE_ASSIST_SHARED_SCHEMA_KEYS, CLOUD_CODE_ASSIST_TYPE_SPECIFIC_KEYS } from "./fields";
import { isValidJsonSchema } from "./meta-validator";
import { sanitizeSchemaForCCA } from "./sanitize-google";
import { epochNext, once } from "./stamps";
import type { JsonObject } from "./types";
import { isJsonObject } from "./types";
/** Copy all keys from a schema except the specified combiner key. */
export function copySchemaWithout(schema: JsonObject, combiner: string): JsonObject {
const { [combiner]: _, ...rest } = schema;
return rest;
}
/**
* Claude via Cloud Code Assist (`parameters` path) can reject schemas that keep
* object variant combiners, so flatten object-only unions into one object shape.
*/
function mergeObjectCombinerVariants(schema: JsonObject, combiner: "anyOf" | "oneOf"): JsonObject {
const variantsRaw = schema[combiner];
if (!Array.isArray(variantsRaw) || variantsRaw.length === 0) {
return schema;
}
const variants: JsonObject[] = [];
for (const entry of variantsRaw) {
if (!isJsonObject(entry)) {
return schema;
}
const variantType = entry.type;
const hasObjectShape =
isJsonObject(entry.properties) ||
Array.isArray(entry.required) ||
Object.hasOwn(entry, "additionalProperties");
if (variantType === undefined && !hasObjectShape) {
return schema;
}
if (variantType !== undefined && variantType !== "object") {
return schema;
}
if (entry.properties !== undefined && !isJsonObject(entry.properties)) {
return schema;
}
if (entry.required !== undefined && !Array.isArray(entry.required)) {
return schema;
}
variants.push(entry);
}
const mergedProperties: JsonObject = {};
const ownProperties = isJsonObject(schema.properties) ? schema.properties : {};
for (const name in ownProperties) {
mergedProperties[name] = ownProperties[name];
}
for (const variant of variants) {
const properties = isJsonObject(variant.properties) ? variant.properties : {};
for (const name in properties) {
const propertySchema = properties[name];
const existingSchema = mergedProperties[name];
mergedProperties[name] =
existingSchema === undefined ? propertySchema : mergePropertySchemas(existingSchema, propertySchema);
}
}
const nextSchema = copySchemaWithout(schema, combiner);
nextSchema.type = "object";
nextSchema.properties = mergedProperties;
// Compute the `required` set for the merged object. We intersect each
// variant's required keys (a property is only required if every variant
// required it) and then union in the parent's own required keys for
// properties that lived on the parent. Filter against `mergedProperties`
// so we never reference a key that does not exist on the result.
let requiredIntersection: string[] | undefined;
for (const variant of variants) {
const variantRequired = Array.isArray(variant.required)
? variant.required.filter((r): r is string => typeof r === "string")
: [];
if (requiredIntersection === undefined) {
requiredIntersection = [...variantRequired];
} else {
const reqSet = new Set(variantRequired);
requiredIntersection = requiredIntersection.filter(r => reqSet.has(r));
}
}
const parentRequired = Array.isArray(schema.required)
? schema.required.filter((r): r is string => typeof r === "string")
: [];
const safeRequired = new Set<string>();
for (const name of requiredIntersection ?? []) {
if (name in mergedProperties) safeRequired.add(name);
}
for (const name of parentRequired) {
if (name in ownProperties && name in mergedProperties) {
safeRequired.add(name);
}
}
// Emit required in property-insertion order so the wire payload is stable.
const requiredInPropertyOrder: string[] = [];
for (const name in mergedProperties) {
if (safeRequired.has(name)) requiredInPropertyOrder.push(name);
}
if (requiredInPropertyOrder.length > 0) {
nextSchema.required = requiredInPropertyOrder;
} else {
delete nextSchema.required;
}
return nextSchema;
}
/**
* Collapse anyOf/oneOf with distinct typed variants into a single-type schema.
* Picks the first non-null type as a scalar. This is lossy for multi-type unions
* (e.g., string|number|null narrows to string), but CCA requires a scalar type field
* and an uncollapsed anyOf would be rejected by the CCA API at runtime.
*/
function collapseMixedTypeCombinerVariants(schema: JsonObject, combiner: "anyOf" | "oneOf"): JsonObject {
const variantsRaw = schema[combiner];
if (!Array.isArray(variantsRaw) || variantsRaw.length === 0) {
return schema;
}
const seenTypes = new Set<string>();
const variantTypes: string[] = [];
const mergedVariantFields: JsonObject = {};
for (const entry of variantsRaw) {
if (!isJsonObject(entry) || typeof entry.type !== "string") {
return schema;
}
const variantType = entry.type;
if (seenTypes.has(variantType)) {
return schema;
}
const allowedKeys = CLOUD_CODE_ASSIST_TYPE_SPECIFIC_KEYS[variantType];
if (!allowedKeys) {
return schema;
}
for (const key in entry) {
const variantValue = entry[key];
if (key === "type") continue;
if (!(key in allowedKeys) && !(key in CLOUD_CODE_ASSIST_SHARED_SCHEMA_KEYS)) {
return schema;
}
const existingValue = mergedVariantFields[key];
if (existingValue !== undefined && !areJsonValuesEqual(existingValue, variantValue)) {
return schema;
}
mergedVariantFields[key] = variantValue;
}
seenTypes.add(variantType);
variantTypes.push(variantType);
}
if (variantTypes.length < 2 || variantTypes.every(type => type === "object")) {
return schema;
}
const nextSchema = copySchemaWithout(schema, combiner);
const nonNullTypes = variantTypes.filter(t => t !== "null");
// Lossy: when multiple non-null types exist we pick the first. CCA requires
// a scalar type and keeping the anyOf would cause an API rejection at runtime.
nextSchema.type = nonNullTypes[0] ?? variantTypes[0];
for (const key in mergedVariantFields) {
const value = mergedVariantFields[key];
const existingValue = nextSchema[key];
if (existingValue !== undefined && !areJsonValuesEqual(existingValue, value)) {
return schema;
}
if (existingValue === undefined) {
nextSchema[key] = value;
}
}
return nextSchema;
}
/**
* Collapse anyOf/oneOf where all variants share the same primitive type.
* E.g. anyOf: [{type: "string", desc: "A"}, {type: "string", desc: "B"}] -> {type: "string", desc: "A"}
* Claude via CCA rejects any remaining anyOf/oneOf, so pick first variant.
* Note: constraints from non-first variants are silently dropped.
*/
function collapseSameTypeCombinerVariants(schema: JsonObject, combiner: "anyOf" | "oneOf"): JsonObject {
const variantsRaw = schema[combiner];
if (!Array.isArray(variantsRaw) || variantsRaw.length === 0) return schema;
let commonType: string | undefined;
let firstEntry: JsonObject | undefined;
for (const entry of variantsRaw) {
if (!isJsonObject(entry) || typeof entry.type !== "string") return schema;
if (commonType === undefined) {
commonType = entry.type;
firstEntry = entry;
} else if (entry.type !== commonType) return schema;
}
if (!firstEntry) return schema;
const nextSchema = copySchemaWithout(schema, combiner);
for (const key in firstEntry) {
if (!(key in nextSchema)) nextSchema[key] = firstEntry[key];
}
return nextSchema;
}
/**
* Recursively strip any remaining anyOf/oneOf that collapseSameTypeCombinerVariants can handle.
* This is needed because mergeObjectCombinerVariants can create new anyOf in merged
* properties AFTER the recursive normalization pass has already processed children.
*/
export function stripResidualCombiners(value: unknown, epoch: number = epochNext()): unknown {
if (Array.isArray(value)) {
if (!once(value, epoch)) return [];
return value.map(entry => stripResidualCombiners(entry, epoch));
}
if (!isJsonObject(value)) return value;
if (!once(value, epoch)) return {};
const result: JsonObject = {};
for (const key in value) {
result[key] = stripResidualCombiners(value[key], epoch);
}
let current: JsonObject = result;
let changed = true;
while (changed) {
changed = false;
for (const combiner of ["anyOf", "oneOf"] as const) {
const sameType = collapseSameTypeCombinerVariants(current, combiner);
if (sameType !== current) {
current = sameType;
changed = true;
}
const mixed = collapseMixedTypeCombinerVariants(current, combiner);
if (mixed !== current) {
current = mixed;
changed = true;
}
}
}
return current;
}
function normalizeSchemaForCCA(value: unknown, epoch: number = epochNext()): unknown {
if (Array.isArray(value)) {
if (!once(value, epoch)) return [];
return value.map(entry => normalizeSchemaForCCA(entry, epoch));
}
if (!isJsonObject(value)) {
return value;
}
if (!once(value, epoch)) return {};
const normalized: JsonObject = {};
for (const key in value) {
normalized[key] = normalizeSchemaForCCA(value[key], epoch);
}
const mergedAnyOf = mergeObjectCombinerVariants(normalized, "anyOf");
const collapsedAnyOf = collapseMixedTypeCombinerVariants(mergedAnyOf, "anyOf");
const sameTypeAnyOf = collapseSameTypeCombinerVariants(collapsedAnyOf, "anyOf");
const mergedOneOf = mergeObjectCombinerVariants(sameTypeAnyOf, "oneOf");
const collapsedOneOf = collapseMixedTypeCombinerVariants(mergedOneOf, "oneOf");
return collapseSameTypeCombinerVariants(collapsedOneOf, "oneOf");
}
interface NullableExtractionResult {
schema: unknown;
nullable: boolean;
}
function extractNullableUnionSchema(schema: unknown): NullableExtractionResult {
if (!isJsonObject(schema)) {
return { schema, nullable: false };
}
if (schema.nullable === true) {
const nextSchema = { ...schema };
delete nextSchema.nullable;
return { schema: nextSchema, nullable: true };
}
if (Array.isArray(schema.type)) {
const typeVariants = schema.type.filter((entry): entry is string => typeof entry === "string");
const nonNullTypes = typeVariants.filter(entry => entry !== "null");
if (typeVariants.includes("null") && nonNullTypes.length === 1) {
const nextSchema = { ...schema, type: nonNullTypes[0] };
return { schema: nextSchema, nullable: true };
}
}
for (const combiner of ["anyOf", "oneOf"] as const) {
const variantsRaw = schema[combiner];
if (!Array.isArray(variantsRaw)) continue;
let hasNullVariant = false;
const nonNullVariants: unknown[] = [];
for (const variant of variantsRaw) {
if (isJsonObject(variant) && variant.type === "null") {
let keyCount = 0;
for (const _k in variant) {
if (++keyCount > 1) break;
}
if (keyCount === 1) {
hasNullVariant = true;
continue;
}
}
nonNullVariants.push(variant);
}
if (!hasNullVariant || nonNullVariants.length !== 1 || !isJsonObject(nonNullVariants[0])) {
continue;
}
const nextSchema = copySchemaWithout(schema, combiner);
const nonNullVariant = nonNullVariants[0];
for (const key in nonNullVariant) {
const value = nonNullVariant[key];
const existingValue = nextSchema[key];
if (existingValue !== undefined && !areJsonValuesEqual(existingValue, value)) {
return { schema, nullable: false };
}
if (existingValue === undefined) {
nextSchema[key] = value;
}
}
return { schema: nextSchema, nullable: true };
}
return { schema, nullable: false };
}
interface NullableNormalizationResult {
schema: unknown;
nullable: boolean;
}
function normalizeNullablePropertiesForCloudCodeAssist(
value: unknown,
isPropertySchema = false,
epoch: number = epochNext(),
): NullableNormalizationResult {
if (Array.isArray(value)) {
if (!once(value, epoch)) {
return { schema: [], nullable: false };
}
return {
schema: value.map(entry => normalizeNullablePropertiesForCloudCodeAssist(entry, false, epoch).schema),
nullable: false,
};
}
if (!isJsonObject(value)) {
return { schema: value, nullable: false };
}
if (!once(value, epoch)) {
return { schema: {}, nullable: false };
}
const normalized: JsonObject = {};
for (const key in value) {
normalized[key] = normalizeNullablePropertiesForCloudCodeAssist(value[key], false, epoch).schema;
}
if (isJsonObject(normalized.properties)) {
const properties = normalized.properties;
const required = new Set(
Array.isArray(normalized.required)
? normalized.required.filter((entry): entry is string => typeof entry === "string")
: [],
);
const nextProperties: JsonObject = {};
for (const name in properties) {
const normalizedProperty = normalizeNullablePropertiesForCloudCodeAssist(properties[name], true, epoch);
nextProperties[name] = normalizedProperty.schema;
if (normalizedProperty.nullable) {
required.delete(name);
}
}
normalized.properties = nextProperties;
if (Array.isArray(normalized.required)) {
normalized.required = Array.from(required);
}
}
if (!isPropertySchema) {
return { schema: normalized, nullable: false };
}
return extractNullableUnionSchema(normalized);
}
/**
* Keep validation synchronous in this request path.
* Replaces the previous AJV-based meta-schema check with a tiny
* structural validator that catches the failure modes the CCA pipeline
* actually produces.
*/
function isValidCCASchema(schema: unknown): boolean {
return isValidJsonSchema(schema);
}
/** See COMBINATOR_KEYS in fields.ts — CCA forbids all three combiners. */
const CCA_FORBIDDEN_COMBINERS: Record<string, true> = { anyOf: true, oneOf: true, allOf: true };
function hasResidualCloudCodeAssistIncompatibilities(value: unknown, epoch: number = epochNext()): boolean {
if (Array.isArray(value)) {
if (!once(value, epoch)) return false;
return value.some(entry => hasResidualCloudCodeAssistIncompatibilities(entry, epoch));
}
if (!isJsonObject(value)) {
return false;
}
if (!once(value, epoch)) {
return false;
}
if (Array.isArray(value.type) || value.type === "null") {
return true;
}
if (Object.hasOwn(value, "nullable")) {
return true;
}
for (const combiner in CCA_FORBIDDEN_COMBINERS) {
if (Array.isArray(value[combiner])) {
return true;
}
}
for (const k in value) {
if (hasResidualCloudCodeAssistIncompatibilities(value[k], epoch)) {
return true;
}
}
return false;
}
const CLOUD_CODE_ASSIST_CLAUDE_FALLBACK_SCHEMA = {
type: "object",
properties: {},
} as const;
/**
* Prepare schema for Claude on Cloud Code Assist:
* sanitize -> normalize union objects -> validate -> fallback.
*
* Fallback is per-tool and fail-open to avoid rejecting the entire request when
* one tool schema is invalid.
*/
export function prepareSchemaForCCA(value: unknown): unknown {
const sanitized = sanitizeSchemaForCCA(value);
const pass1 = normalizeSchemaForCCA(sanitized);
// Second pass: strip anyOf/oneOf created by mergeObjectCombinerVariants during pass1
const normalized = stripResidualCombiners(pass1);
const nullableNormalized = normalizeNullablePropertiesForCloudCodeAssist(normalized).schema;
if (hasResidualCloudCodeAssistIncompatibilities(nullableNormalized)) {
logger.debug("CCA schema has residual incompatibilities, using fallback");
return CLOUD_CODE_ASSIST_CLAUDE_FALLBACK_SCHEMA;
}
if (isValidCCASchema(nullableNormalized)) {
return nullableNormalized;
}
logger.debug("CCA schema failed validation, using fallback");
return CLOUD_CODE_ASSIST_CLAUDE_FALLBACK_SCHEMA;
}

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