Merge remote-tracking branch 'origin/main' into acp-initialize-startup
# Conflicts: # packages/coding-agent/src/main.ts
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/
|
||||
|
||||
|
||||
@@ -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/
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
// ============================================================================
|
||||
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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`
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)] });
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from "./client";
|
||||
export * from "./refresher";
|
||||
export * from "./remote-store";
|
||||
export * from "./server";
|
||||
export * from "./types";
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./http";
|
||||
export * from "./server";
|
||||
export * from "./types";
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
+819
-57
File diff suppressed because it is too large
Load Diff
@@ -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) {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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">, {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" },
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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 +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,5 +1,4 @@
|
||||
export * from "./antigravity";
|
||||
export * from "./codex";
|
||||
export * from "./cursor";
|
||||
export * from "./gemini";
|
||||
export * from "./openai-compatible";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) };
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user