This commit is contained in:
2026-07-26 14:02:37 +07:00
parent bc56546b49
commit 367ebc1c7f
171 changed files with 4617 additions and 10402 deletions
-291
View File
@@ -1,291 +0,0 @@
# GitHub handles approved to bypass contribution auto-close
# Format: <username> <capability>
# capability:
# issue future issues stay open
# pr future issues and PRs stay open
herrnel pr
julien-c pr
barapa pr
alasano pr
aadishv pr
airtonix pr
aliou pr
aos pr
austinm911 pr
banteg pr
ben-vargas pr
butelo pr
can1357 pr
CarlosGtrz pr
cau1k pr
cmf pr
crcatala pr
Cursivez pr
cv pr
dannote pr
default-anton pr
dnouri pr
DronNick pr
enisdenjo pr
ferologics pr
fightbulc pr
ghoulr pr
gnattu pr
HACKE-RC pr
hewliyang pr
hjanuschka pr
iamd3vil pr
jblwilliams pr
joshp123 pr
jsinge97 pr
justram pr
kaofelix pr
kiliman pr
kim0 pr
lockmeister pr
LukeFost pr
lukele pr
m-box-mr pr
marckrenn pr
markusylisiurunen pr
mcinteerj pr
melihmucuk pr
mitsuhiko pr
mrexodia pr
nathyong pr
nickseelert pr
nicobailon pr
ninlds pr
ogulcancelik pr
patrick-kidger pr
paulbettner pr
Perlence pr
pjtf93 pr
prateekmedia pr
prathamdby pr
ribelo pr
richardgill pr
robinwander pr
ronyrus pr
roshanasingh4 pr
scutifer pr
skuridin pr
steipete pr
svkozak pr
tallshort pr
theBucky pr
thomasmhr pr
tiagoefreitas pr
timolins pr
tmustier pr
tudoroancea pr
unexge pr
vaayne pr
VaclavSynacek pr
vsabavat pr
w-winter pr
Whamp pr
WismutHansen pr
XesGaDeus pr
yevhen pr
badlogictest pr
terrorobe pr
zedrdave pr
mrud pr
toorusr pr
andresaraujo pr
lightningRalf pr
williballenthin pr
masonc15 pr
4h9fbZ pr
haoqixu pr
Graffioh pr
charles-cooper pr
emanuelst pr
juanibiapina pr
liby pr
pasky pr
odysseus0 pr
giuseppeg pr
michaelpersonal pr
academo pr
PriNova pr
semtexzv pr
jasonish pr
markusn pr
SamFold pr
Soleone pr
virtuald pr
NateSmyth pr
7Sageer pr
MatthieuBizien pr
sumeet pr
marchellodev pr
vedang pr
lucemia pr
mcollina pr
lajarre pr
smithbm2316 pr
drewburr pr
gordonhwc pr
deybhayden pr
tintinweb pr
asoules pr
zhahaoyu pr
in0vik pr
jtac pr
yzhg1983 pr
smcllns pr
dmmulroy pr
zmberber pr
andresvi94 pr
sudosubin pr
Mic92 pr
pmateusz pr
wirjo pr
jay-aye-see-kay pr
lucasmeijer pr
Evizero pr
ofa1 pr
crisog issue
mpazik pr
vekexasia pr
Michaelliv pr
cmraible pr
dljsjr pr
drio pr
jlaneve pr
tantara pr
Nutlope pr
xl0 pr
mdsjip pr
Exrun94 pr
marcbloech pr
pidalf pr
injaneity pr
thirtythreeforty pr
justinpbarnett pr
cristinaponcela pr
LooSik pr
mchenco pr
Phoen1xCode pr
louis030195 pr
technocidal pr
pandada8 pr
npupko issue
chrisvariety pr
maximilianzuern pr
brianmichel pr
abhinavmathur-atlan pr
mattiacerutti pr
josephyoung pr
mbazso pr
AJM10565 pr
DanielThomas pr
MichaelYochpaz pr
stephanmck pr
rolfvreijdenberger pr
psoukie pr
vastxie pr
ItsumoSeito pr
davidlifschitz pr
vdxz pr
dangooddd pr
Mearman pr
dodiego pr
any-victor pr
geraschenko pr
skhoroshavin pr
cyzlmh pr
xz-dev pr
rajp152k pr
affanali2k3 pr
ArcadiaLin pr
anilgulecha pr
DeviosLang pr
HarrodRen pr
aaronkyriesenbach pr
farid-fari pr
petrroll pr
vibeinging pr
DivineDominion pr
ananthakumaran pr
andrebreijao pr
anh-chu pr
rsaryev pr
QuintinShaw pr
R-Taneja pr
zaycruz pr
mteam88 pr
-45
View File
@@ -1,45 +0,0 @@
name: Bug Report
description: Report something that's broken
labels: ["bug"]
body:
- type: markdown
attributes:
value: |
**Before you start:** Read [CONTRIBUTING.md](https://github.com/earendil-works/pi/blob/main/CONTRIBUTING.md).
New issues from new contributors are auto-closed by default. Maintainers review auto-closed issues daily. Issues that do not meet the quality bar in [CONTRIBUTING.md](https://github.com/earendil-works/pi/blob/main/CONTRIBUTING.md) will not be reopened or receive a reply.
Keep this short. If it doesn't fit on one screen, it's too long. Write in your own voice.
**Important:** before reporting an issue in core, please validate first with `pi -ne` that this is not caused by an extension you loaded.
- type: textarea
id: description
attributes:
label: What happened?
description: Be specific. Include error messages if any.
validations:
required: true
- type: textarea
id: repro
attributes:
label: Steps to reproduce
description: Minimal steps to trigger the bug.
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected behavior
validations:
required: false
- type: input
id: version
attributes:
label: Version
description: e.g. 0.49.0
validations:
required: false
-5
View File
@@ -1,5 +0,0 @@
blank_issues_enabled: false
contact_links:
- name: Questions
url: https://discord.com/invite/3cU7Bz4UPx
about: Ask questions on Discord instead of opening an issue
-36
View File
@@ -1,36 +0,0 @@
name: Contribution Proposal
description: Propose a change or feature (required for new contributors before submitting a PR)
labels: []
body:
- type: markdown
attributes:
value: |
**Before you start:** Read [CONTRIBUTING.md](https://github.com/earendil-works/pi/blob/main/CONTRIBUTING.md).
New issues from new contributors are auto-closed by default. Maintainers review auto-closed issues daily. Issues that do not meet the quality bar in [CONTRIBUTING.md](https://github.com/earendil-works/pi/blob/main/CONTRIBUTING.md) will not be reopened or receive a reply.
Keep this short. If it doesn't fit on one screen, it's too long. Write in your own voice.
- type: textarea
id: what
attributes:
label: What do you want to change?
description: Be specific and concise.
validations:
required: true
- type: textarea
id: why
attributes:
label: Why?
description: What problem does this solve?
validations:
required: true
- type: textarea
id: how
attributes:
label: How? (optional)
description: Brief technical approach if you have one in mind.
validations:
required: false
-49
View File
@@ -1,49 +0,0 @@
name: Package Report
description: Report a problematic Pi package listed on pi.dev
labels: ["package-report"]
body:
- type: markdown
attributes:
value: |
Use this form to report a package listed on pi.dev. For Pi core bugs, use the bug report template instead.
New issues from new contributors are auto-closed by default. Maintainers review auto-closed issues daily. Issues that do not meet the quality bar in [CONTRIBUTING.md](https://github.com/earendil-works/pi/blob/main/CONTRIBUTING.md) will not be reopened or receive a reply.
Keep this short. If it doesn't fit on one screen, it's too long. Write in your own voice.
- type: input
id: package-name
attributes:
label: Package name
description: The npm package name from pi.dev.
placeholder: "@scope/package"
validations:
required: true
- type: input
id: package-version
attributes:
label: Version
description: The package version shown on pi.dev.
placeholder: "0.1.0"
validations:
required: false
- type: dropdown
id: report-type
attributes:
label: What are you reporting?
options:
- Malicious or unsafe behavior
- Impersonation
- Trademark / TOS Violations
validations:
required: true
- type: textarea
id: details
attributes:
label: Details
description: Describe the concern and include links, logs, or screenshots if helpful.
validations:
required: true
-179
View File
@@ -1,179 +0,0 @@
name: Approve Contributor
on:
issue_comment:
types: [created]
jobs:
approve:
if: ${{ !github.event.issue.pull_request }}
runs-on: ubuntu-latest
permissions:
contents: write
issues: write
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.event.repository.default_branch }}
- name: Update contributor approval
id: update
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const fs = require('fs');
const APPROVED_FILE = '.github/APPROVED_CONTRIBUTORS';
const VALID_CAPABILITIES = new Set(['issue', 'pr']);
const issueAuthor = context.payload.issue.user.login;
const commenter = context.payload.comment.user.login;
const commentBody = (context.payload.comment.body || '').trim();
let targetCapability;
if (/\blgtmi\b/i.test(commentBody)) {
targetCapability = 'issue';
} else if (/\blgtm\b/i.test(commentBody)) {
targetCapability = 'pr';
} else {
console.log('Comment does not match lgtm or lgtmi');
core.setOutput('status', 'skipped');
return;
}
try {
const { data: permissionLevel } = await github.rest.repos.getCollaboratorPermissionLevel({
owner: context.repo.owner,
repo: context.repo.repo,
username: commenter,
});
if (!['admin', 'maintain', 'write'].includes(permissionLevel.permission)) {
console.log(`${commenter} does not have write access`);
core.setOutput('status', 'skipped');
return;
}
} catch {
console.log(`${commenter} does not have collaborator access`);
core.setOutput('status', 'skipped');
return;
}
function parseApprovedUsers(content) {
const lines = content.split('\n');
const entries = [];
const users = new Map();
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) {
entries.push({ type: 'other', line });
continue;
}
const parts = trimmed.split(/\s+/);
if (parts.length !== 2) {
console.log(`Skipping malformed line: ${line}`);
entries.push({ type: 'other', line });
continue;
}
const [username, capability] = parts;
const normalizedCapability = capability.toLowerCase();
if (!VALID_CAPABILITIES.has(normalizedCapability)) {
console.log(`Skipping line with invalid capability: ${line}`);
entries.push({ type: 'other', line });
continue;
}
const normalizedUser = username.toLowerCase();
const entry = { type: 'user', username, normalizedUser, capability: normalizedCapability };
entries.push(entry);
users.set(normalizedUser, entry);
}
return { entries, users };
}
function stringifyApprovedUsers(entries) {
const normalizedEntries = [...entries];
while (normalizedEntries.length > 0) {
const lastEntry = normalizedEntries[normalizedEntries.length - 1];
if (lastEntry.type !== 'other' || lastEntry.line.trim() !== '') {
break;
}
normalizedEntries.pop();
}
return `${normalizedEntries
.map((entry) => (entry.type === 'user' ? `${entry.username} ${entry.capability}` : entry.line))
.join('\n')}\n`;
}
const content = fs.readFileSync(APPROVED_FILE, 'utf8');
const { entries, users } = parseApprovedUsers(content);
const normalizedAuthor = issueAuthor.toLowerCase();
const existingEntry = users.get(normalizedAuthor);
const existingCapability = existingEntry?.capability ?? null;
if (existingCapability === 'pr' || existingCapability === targetCapability) {
core.setOutput('status', 'already');
core.setOutput('capability', existingCapability);
console.log(`${issueAuthor} is already approved for ${existingCapability}`);
return;
}
if (existingEntry) {
existingEntry.capability = targetCapability;
} else {
entries.push({ type: 'user', username: issueAuthor, normalizedUser: normalizedAuthor, capability: targetCapability });
}
fs.writeFileSync(APPROVED_FILE, stringifyApprovedUsers(entries));
core.setOutput('status', existingCapability ? 'updated' : 'added');
core.setOutput('capability', targetCapability);
console.log(`Set ${issueAuthor} capability to ${targetCapability}`);
- name: Commit and push
if: steps.update.outputs.status == 'added' || steps.update.outputs.status == 'updated'
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add .github/APPROVED_CONTRIBUTORS
git diff --staged --quiet || git commit -m "chore: approve contributor ${{ github.event.issue.user.login }}"
git push
- name: Comment on issue
if: steps.update.outputs.status == 'added' || steps.update.outputs.status == 'updated' || steps.update.outputs.status == 'already'
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const issueAuthor = context.payload.issue.user.login;
const capability = '${{ steps.update.outputs.capability }}';
const defaultBranch = context.payload.repository.default_branch;
let body;
if ('${{ steps.update.outputs.status }}' === 'already') {
body = `@${issueAuthor} is already approved.`;
} else if (capability === 'issue') {
body = [
`@${issueAuthor} approved for issues. Your future issues will not be auto-closed. PRs still require \`lgtm\`.`,
'',
`See [CONTRIBUTING.md](https://github.com/${context.repo.owner}/${context.repo.repo}/blob/${defaultBranch}/CONTRIBUTING.md).`,
].join('\n');
} else {
body = [
`@${issueAuthor} approved for issues and PRs. Your future issues and PRs will not be auto-closed.`,
'',
`See [CONTRIBUTING.md](https://github.com/${context.repo.owner}/${context.repo.repo}/blob/${defaultBranch}/CONTRIBUTING.md).`,
].join('\n');
}
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body,
});
-321
View File
@@ -1,321 +0,0 @@
name: Build Binaries
on:
push:
tags:
- 'v*'
workflow_dispatch:
inputs:
tag:
description: 'Tag to build (e.g., v0.12.0)'
required: true
type: string
source_ref:
description: 'Source ref to build/publish (defaults to tag; use only for release recovery)'
required: false
type: string
permissions: {}
concurrency:
group: build-binaries-${{ github.event.inputs.tag || github.ref_name }}
cancel-in-progress: false
jobs:
# Keep the public GitHub Release publication last. Binary assets are staged in
# a draft release first; cleanup removes the draft if later publishing fails.
build:
runs-on: ubuntu-latest
permissions:
contents: read
env:
RELEASE_TAG: ${{ github.event.inputs.tag || github.ref_name }}
SOURCE_REF: ${{ github.event.inputs.source_ref || github.event.inputs.tag || github.ref_name }}
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ env.SOURCE_REF }}
persist-credentials: false
- name: Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: 1.3.14
- name: Setup Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '22'
registry-url: 'https://registry.npmjs.org'
- name: Create source archive
run: |
set -euo pipefail
VERSION="${RELEASE_TAG#v}"
mkdir -p release-assets
./scripts/create-source-archive.sh \
--version "${VERSION}" \
--ref HEAD \
--out "release-assets/pi-${VERSION}-source.tar.gz"
- name: Build binaries from source archive
run: |
set -euo pipefail
VERSION="${RELEASE_TAG#v}"
build_root="$(mktemp -d)"
trap 'rm -rf "${build_root}"' EXIT
tar -xzf "release-assets/pi-${VERSION}-source.tar.gz" -C "${build_root}"
"${build_root}/pi-${VERSION}/scripts/build-binaries.sh" \
--out "${GITHUB_WORKSPACE}/packages/coding-agent/binaries"
- name: Prepare GitHub release payload
run: |
set -euo pipefail
mkdir -p release-assets
VERSION="${RELEASE_TAG}"
VERSION="${VERSION#v}" # Remove 'v' prefix
node scripts/release-notes.mjs extract --version "${VERSION}" --tag "${RELEASE_TAG}" --out release-assets/RELEASE_NOTES.md
node scripts/generate-coding-agent-install-lock.mjs --check
cp packages/coding-agent/install-lock/package.json release-assets/pi-coding-agent-install-package.json
cp packages/coding-agent/install-lock/package-lock.json release-assets/pi-coding-agent-install-package-lock.json
cd packages/coding-agent/binaries
binary_assets=(
pi-darwin-arm64.tar.gz
pi-darwin-x64.tar.gz
pi-linux-x64.tar.gz
pi-linux-arm64.tar.gz
pi-windows-x64.zip
pi-windows-arm64.zip
)
for asset in "${binary_assets[@]}"; do
test -f "${asset}"
done
cp "${binary_assets[@]}" "${GITHUB_WORKSPACE}/release-assets/"
cd "${GITHUB_WORKSPACE}/release-assets"
source_asset="pi-${VERSION}-source.tar.gz"
release_assets=(
"${source_asset}"
pi-darwin-arm64.tar.gz
pi-darwin-x64.tar.gz
pi-linux-x64.tar.gz
pi-linux-arm64.tar.gz
pi-windows-x64.zip
pi-windows-arm64.zip
pi-coding-agent-install-package.json
pi-coding-agent-install-package-lock.json
)
sha256sum "${release_assets[@]}" > SHA256SUMS
- name: Upload GitHub release payload
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: release-assets-${{ env.RELEASE_TAG }}
path: release-assets/*
if-no-files-found: error
retention-days: 14
stage-github-release:
runs-on: ubuntu-latest
needs: build
permissions:
actions: read
contents: write
env:
GH_REPO: ${{ github.repository }}
RELEASE_TAG: ${{ github.event.inputs.tag || github.ref_name }}
steps:
- name: Download GitHub release payload
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
name: release-assets-${{ env.RELEASE_TAG }}
path: release-assets
- name: Validate GitHub release payload
run: |
set -euo pipefail
cd release-assets
VERSION="${RELEASE_TAG#v}"
source_asset="pi-${VERSION}-source.tar.gz"
expected_assets=(
"${source_asset}"
pi-darwin-arm64.tar.gz
pi-darwin-x64.tar.gz
pi-linux-x64.tar.gz
pi-linux-arm64.tar.gz
pi-windows-x64.zip
pi-windows-arm64.zip
pi-coding-agent-install-package.json
pi-coding-agent-install-package-lock.json
SHA256SUMS
RELEASE_NOTES.md
)
for asset in "${expected_assets[@]}"; do
test -f "${asset}"
done
sha256sum -c SHA256SUMS
- name: Create draft GitHub Release and upload assets
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
cd release-assets
VERSION="${RELEASE_TAG#v}"
source_asset="pi-${VERSION}-source.tar.gz"
release_assets=(
"${source_asset}"
pi-darwin-arm64.tar.gz
pi-darwin-x64.tar.gz
pi-linux-x64.tar.gz
pi-linux-arm64.tar.gz
pi-windows-x64.zip
pi-windows-arm64.zip
pi-coding-agent-install-package.json
pi-coding-agent-install-package-lock.json
SHA256SUMS
)
existing_release="$(gh release view "${RELEASE_TAG}" --json isDraft --jq .isDraft 2>/dev/null || true)"
if [[ "${existing_release}" == "false" ]]; then
echo "::error::GitHub Release ${RELEASE_TAG} is already published. Refusing to mutate a public release."
exit 1
fi
if [[ "${existing_release}" == "true" ]]; then
gh release delete "${RELEASE_TAG}" --yes
fi
gh release create "${RELEASE_TAG}" \
--verify-tag \
--draft \
--title "${RELEASE_TAG}" \
--notes-file RELEASE_NOTES.md \
"${release_assets[@]}"
expected_asset_names="$(printf '%s\n' "${release_assets[@]}" | sort)"
actual_asset_names="$(gh release view "${RELEASE_TAG}" --json assets --jq '.assets[].name' | sort)"
if [[ "${actual_asset_names}" != "${expected_asset_names}" ]]; then
echo "::error::Draft GitHub Release asset set does not match expected files."
diff -u <(printf '%s\n' "${expected_asset_names}") <(printf '%s\n' "${actual_asset_names}") || true
exit 1
fi
publish-npm:
runs-on: ubuntu-latest
needs: stage-github-release
environment: npm-publish
permissions:
contents: read
id-token: write
env:
RELEASE_TAG: ${{ github.event.inputs.tag || github.ref_name }}
SOURCE_REF: ${{ github.event.inputs.source_ref || github.event.inputs.tag || github.ref_name }}
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ env.SOURCE_REF }}
persist-credentials: false
- name: Setup Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '22'
registry-url: 'https://registry.npmjs.org'
cache: npm
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y libcairo2-dev libpango1.0-dev libjpeg-dev libgif-dev librsvg2-dev fd-find ripgrep
sudo ln -s $(which fdfind) /usr/local/bin/fd
- name: Install dependencies
run: npm ci --ignore-scripts
- name: Build
run: npm run build
- name: Check
run: npm run check
- name: Test
run: npm test
- name: Upgrade npm for trusted publishing
run: |
npm install -g npm@11.16.0 --ignore-scripts
npm --version
- name: Publish npm packages
run: node scripts/publish.mjs
publish-github-release:
runs-on: ubuntu-latest
needs:
- stage-github-release
- publish-npm
permissions:
contents: write
env:
GH_REPO: ${{ github.repository }}
RELEASE_TAG: ${{ github.event.inputs.tag || github.ref_name }}
steps:
- name: Publish staged GitHub Release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
existing_release="$(gh release view "${RELEASE_TAG}" --json isDraft --jq .isDraft 2>/dev/null || true)"
if [[ "${existing_release}" == "" ]]; then
echo "::error::Draft GitHub Release ${RELEASE_TAG} does not exist."
exit 1
fi
if [[ "${existing_release}" == "false" ]]; then
echo "::error::GitHub Release ${RELEASE_TAG} is already published."
exit 1
fi
gh release edit "${RELEASE_TAG}" --draft=false
cleanup-draft-github-release:
runs-on: ubuntu-latest
needs:
- build
- stage-github-release
- publish-npm
- publish-github-release
if: ${{ always() && needs.stage-github-release.result != 'skipped' && (needs.stage-github-release.result != 'success' || needs.publish-npm.result != 'success' || needs.publish-github-release.result != 'success') }}
permissions:
contents: write
env:
GH_REPO: ${{ github.repository }}
RELEASE_TAG: ${{ github.event.inputs.tag || github.ref_name }}
steps:
- name: Delete draft GitHub Release after failure
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
existing_release="$(gh release view "${RELEASE_TAG}" --json isDraft --jq .isDraft 2>/dev/null || true)"
if [[ "${existing_release}" == "true" ]]; then
gh release delete "${RELEASE_TAG}" --yes
fi
-42
View File
@@ -1,42 +0,0 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
build-check-test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Setup Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 22
cache: npm
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y libcairo2-dev libpango1.0-dev libjpeg-dev libgif-dev librsvg2-dev fd-find ripgrep
sudo ln -s $(which fdfind) /usr/local/bin/fd
- name: Install dependencies
run: npm ci --ignore-scripts
- name: Build
run: npm run build
- name: Check
run: npm run check
- name: Test
run: npm test
-634
View File
@@ -1,634 +0,0 @@
# Runs the repo's /is prompt against an issue when the `pi-analyze` label is added
# or when a staff member comments `@issuron analyze` anywhere on an issue.
#
# Comment triggers can include one `#run-on-*` tag anywhere in the text:
# @issuron analyze #run-on-linux -> ubuntu-latest (default)
# @issuron analyze #run-on-windows -> windows-latest
# @issuron analyze #run-on-mac -> macos-latest
#
# Label triggers always run on the default Linux runner. Runner selection is
# intentionally restricted to hardcoded aliases in the authorization step.
#
# Setup required before this works:
# 1. Create a `pi-analyze` GitHub environment on the repo and add a
# `PI_AUTH_JSON` secret containing the contents of a pi auth.json
# (~/.pi/agent/auth.json).
# 2. Create the `pi-analyze` label.
# 3. Add a repository secret `EARENDIL_ORG_READ_TOKEN` with permission to
# read `earendil-works` org membership. The authorization job uses it to
# verify that the label actor is an active member of `earendil-works/staff`.
# 4. Add an environment secret `PI_GIST_TOKEN` on `pi-analyze` with gist
# creation permission. The analysis job uses it to upload the exported
# session gist.
# 5. Add an environment secret `PI_AUTH_UPDATE_TOKEN` on `pi-analyze` with
# permission to update this repo's environment secrets. The analysis job
# uses it to write back refreshed `PI_AUTH_JSON` contents.
#
# The selected runner must have Node.js support plus gh, fd, and ripgrep. GitHub
# hosted runners are bootstrapped below; future self-hosted aliases should have
# those dependencies preinstalled or installable by the setup steps.
#
# The session runs in a high-entropy checkout directory so the recorded cwd is
# a unique string. Import the session into a local checkout with the
# /ir extension command (.pi/extensions/import-repro.ts):
# pi "/ir <gist-id | gist-url | pi.dev/session URL>"
name: Issue Analysis
on:
issues:
types: [labeled]
issue_comment:
types: [created]
permissions:
contents: read
issues: write
concurrency:
group: issue-analysis-${{ github.event.issue.number }}
cancel-in-progress: false
jobs:
authorize:
runs-on: ubuntu-latest
outputs:
should_run: ${{ steps.verify.outputs.should_run }}
extra_instructions: ${{ steps.verify.outputs.extra_instructions }}
runs_on: ${{ steps.verify.outputs.runs_on }}
runner_os: ${{ steps.verify.outputs.runner_os }}
runner_profile: ${{ steps.verify.outputs.runner_profile }}
steps:
- name: Verify sender permission
id: verify
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
ORG_READ_TOKEN: ${{ secrets.EARENDIL_ORG_READ_TOKEN }}
with:
script: |
const ANALYZE_LABEL = 'pi-analyze';
const TRIGGER_RE = /@issuron\s+analyze\b/i;
const RUN_ON_TAG_RE = /#run-on-([a-z0-9][a-z0-9_-]*)\b/gi;
const RUNNER_PROFILES = {
linux: { runsOn: 'ubuntu-latest', os: 'linux' },
windows: { runsOn: 'windows-latest', os: 'windows' },
mac: { runsOn: 'macos-latest', os: 'macos' },
};
const RUN_ON_ALIASES = {
linux: 'linux',
ubuntu: 'linux',
'ubuntu-latest': 'linux',
windows: 'windows',
win: 'windows',
'windows-latest': 'windows',
mac: 'mac',
macos: 'mac',
darwin: 'mac',
'macos-latest': 'mac',
};
const username = context.payload.sender.login;
let extraInstructions = '';
let runnerProfile = 'linux';
core.setOutput('should_run', 'false');
core.setOutput('extra_instructions', '');
core.setOutput('runs_on', JSON.stringify(RUNNER_PROFILES.linux.runsOn));
core.setOutput('runner_os', RUNNER_PROFILES.linux.os);
core.setOutput('runner_profile', runnerProfile);
if (context.eventName === 'issues') {
if (context.payload.action !== 'labeled' || context.payload.label?.name !== ANALYZE_LABEL) {
console.log('Not a pi-analyze label event');
return;
}
} else if (context.eventName === 'issue_comment') {
if (context.payload.issue.pull_request) {
console.log('Ignoring pull request comment');
return;
}
const body = context.payload.comment.body || '';
if (!TRIGGER_RE.test(body)) {
console.log('Comment does not contain an @issuron analyze trigger');
return;
}
const resolvedProfiles = new Set();
const unknownTags = [];
for (const match of body.matchAll(RUN_ON_TAG_RE)) {
const tag = match[1].toLowerCase();
const resolved = RUN_ON_ALIASES[tag];
if (!resolved) {
unknownTags.push(tag);
} else {
resolvedProfiles.add(resolved);
}
}
if (unknownTags.length > 0) {
core.setFailed(`Unknown issue analysis runner tag(s): ${unknownTags.map((tag) => `#run-on-${tag}`).join(', ')}`);
return;
}
if (resolvedProfiles.size > 1) {
core.setFailed(
`Conflicting issue analysis runner tags: ${Array.from(resolvedProfiles)
.map((profile) => `#run-on-${profile}`)
.join(', ')}`,
);
return;
}
runnerProfile = Array.from(resolvedProfiles)[0] || 'linux';
extraInstructions = body.replace(TRIGGER_RE, ' ').replace(RUN_ON_TAG_RE, ' ').trim();
} else {
console.log(`Unsupported event: ${context.eventName}`);
return;
}
async function removeTriggerLabel() {
if (context.eventName !== 'issues') return;
try {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
name: ANALYZE_LABEL,
});
} catch (error) {
if (error.status !== 404) throw error;
}
}
if (!process.env.ORG_READ_TOKEN) {
await removeTriggerLabel();
core.setFailed('EARENDIL_ORG_READ_TOKEN is not configured; refusing to run issue analysis.');
return;
}
try {
const response = await fetch(
`https://api.github.com/orgs/earendil-works/teams/staff/memberships/${encodeURIComponent(username)}`,
{
headers: {
Accept: 'application/vnd.github+json',
Authorization: `Bearer ${process.env.ORG_READ_TOKEN}`,
'X-GitHub-Api-Version': '2022-11-28',
},
},
);
if (response.status === 404) {
await removeTriggerLabel();
core.setFailed(`@${username} is not an active earendil-works/staff member.`);
return;
}
if (!response.ok) {
const body = await response.text();
await removeTriggerLabel();
core.setFailed(
`Could not verify earendil-works/staff membership for @${username}: HTTP ${response.status} ${body}`,
);
return;
}
const membership = await response.json();
if (membership.state !== 'active') {
await removeTriggerLabel();
core.setFailed(`@${username} is not an active earendil-works/staff member.`);
return;
}
console.log(`earendil-works/staff membership for @${username}: ${membership.state}`);
} catch (error) {
await removeTriggerLabel();
core.setFailed(
`Could not verify earendil-works/staff membership for @${username}: ${
error instanceof Error ? error.message : String(error)
}`,
);
return;
}
const { data } = await github.rest.repos.getCollaboratorPermissionLevel({
owner: context.repo.owner,
repo: context.repo.repo,
username,
});
if (!['admin', 'write'].includes(data.permission)) {
await removeTriggerLabel();
core.setFailed(
`@${username} has '${data.permission}' permission; write or admin is required to trigger issue analysis.`,
);
return;
}
if (context.eventName === 'issue_comment') {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
labels: [ANALYZE_LABEL],
});
}
const profile = RUNNER_PROFILES[runnerProfile];
console.log(`Selected issue analysis runner profile: ${runnerProfile} (${JSON.stringify(profile.runsOn)})`);
core.setOutput('should_run', 'true');
core.setOutput('extra_instructions', extraInstructions);
core.setOutput('runs_on', JSON.stringify(profile.runsOn));
core.setOutput('runner_os', profile.os);
core.setOutput('runner_profile', runnerProfile);
analyze:
needs: authorize
if: needs.authorize.outputs.should_run == 'true'
runs-on: ${{ fromJSON(needs.authorize.outputs.runs_on) }}
environment: pi-analyze
timeout-minutes: 45
concurrency:
group: issue-analysis-pi-auth
cancel-in-progress: false
env:
ISSUE_ANALYSIS_MODEL: openai-codex/gpt-5.5
ISSUE_ANALYSIS_THINKING: high
steps:
- name: Create high-entropy working directory name
id: workdir
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const crypto = require('crypto');
core.setOutput('name', `pi-ci-${crypto.randomBytes(16).toString('hex')}`);
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
path: ${{ steps.workdir.outputs.name }}
- name: Setup Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 22
cache: npm
cache-dependency-path: ${{ steps.workdir.outputs.name }}/package-lock.json
- name: Install system dependencies (Linux)
if: needs.authorize.outputs.runner_os == 'linux'
run: |
sudo apt-get update
sudo apt-get install -y fd-find ripgrep
sudo ln -sf "$(which fdfind)" /usr/local/bin/fd
- name: Install system dependencies (macOS)
if: needs.authorize.outputs.runner_os == 'macos'
run: |
if ! command -v fd >/dev/null 2>&1; then
brew install fd
fi
if ! command -v rg >/dev/null 2>&1; then
brew install ripgrep
fi
- name: Install system dependencies (Windows)
if: needs.authorize.outputs.runner_os == 'windows'
shell: pwsh
run: |
$packages = @()
if (-not (Get-Command fd -ErrorAction SilentlyContinue)) {
$packages += "fd"
}
if (-not (Get-Command rg -ErrorAction SilentlyContinue)) {
$packages += "ripgrep"
}
if ($packages.Count -gt 0) {
if (-not (Get-Command choco -ErrorAction SilentlyContinue)) {
throw "fd and ripgrep must be installed on Windows runners, or Chocolatey must be available to install them."
}
choco install $packages -y --no-progress
}
fd --version
rg --version
- name: Install dependencies
working-directory: ${{ steps.workdir.outputs.name }}
run: npm ci --ignore-scripts
- name: Build
working-directory: ${{ steps.workdir.outputs.name }}
run: npm run build
- name: Write auth.json
id: write_auth
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
PI_AUTH_JSON: ${{ secrets.PI_AUTH_JSON }}
with:
script: |
const fs = require('fs');
const path = require('path');
const authJson = process.env.PI_AUTH_JSON;
if (!authJson) {
throw new Error('PI_AUTH_JSON secret is not configured for the pi-analyze environment');
}
const agentDir = path.join(process.env.RUNNER_TEMP, 'pi-agent');
fs.mkdirSync(agentDir, { recursive: true });
const authPath = path.join(agentDir, 'auth.json');
fs.writeFileSync(authPath, authJson, { mode: 0o600 });
if (process.platform !== 'win32') {
fs.chmodSync(authPath, 0o600);
}
- name: Run pi /is
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
PI_CODING_AGENT_DIR: ${{ runner.temp }}/pi-agent
GH_TOKEN: ${{ github.token }}
ISSUE_URL: ${{ github.event.issue.html_url }}
EXTRA_INSTRUCTIONS: ${{ needs.authorize.outputs.extra_instructions }}
WORKDIR: ${{ steps.workdir.outputs.name }}
with:
script: |
const fs = require('fs');
const path = require('path');
const { spawn } = require('child_process');
const workdir = path.join(process.env.GITHUB_WORKSPACE, process.env.WORKDIR);
const outDir = path.join(process.env.RUNNER_TEMP, 'pi-out');
const sessionDir = path.join(outDir, 'session');
fs.mkdirSync(sessionDir, { recursive: true });
let prompt = `/is ${process.env.ISSUE_URL}`;
if (process.env.EXTRA_INSTRUCTIONS) {
prompt += '\n\nAdditional instructions from @issuron analyze comment:\n';
prompt += process.env.EXTRA_INSTRUCTIONS;
}
const outputPath = path.join(outDir, 'output.md');
const output = fs.createWriteStream(outputPath);
const args = [
'packages/coding-agent/src/cli.ts',
'-p',
'--approve',
'--session-dir',
sessionDir,
'--model',
process.env.ISSUE_ANALYSIS_MODEL,
'--thinking',
process.env.ISSUE_ANALYSIS_THINKING,
prompt,
];
const exitCode = await new Promise((resolve, reject) => {
const child = spawn('node', args, {
cwd: workdir,
env: process.env,
stdio: ['ignore', 'pipe', 'pipe'],
});
child.stdout.on('data', (chunk) => {
process.stdout.write(chunk);
output.write(chunk);
});
child.stderr.on('data', (chunk) => {
process.stderr.write(chunk);
});
child.on('error', reject);
child.on('close', resolve);
});
await new Promise((resolve) => output.end(resolve));
if (exitCode !== 0) {
throw new Error(`pi /is failed with exit code ${exitCode}`);
}
- name: Persist refreshed auth.json
if: always() && steps.write_auth.outcome == 'success'
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
GH_TOKEN: ${{ secrets.PI_AUTH_UPDATE_TOKEN }}
PI_CODING_AGENT_DIR: ${{ runner.temp }}/pi-agent
with:
script: |
const fs = require('fs');
const path = require('path');
const { spawn } = require('child_process');
if (!process.env.GH_TOKEN) {
throw new Error('PI_AUTH_UPDATE_TOKEN is not configured for the pi-analyze environment');
}
const authPath = path.join(process.env.PI_CODING_AGENT_DIR, 'auth.json');
if (!fs.existsSync(authPath)) {
core.warning('auth.json was not created; skipping auth persistence');
return;
}
const authJson = fs.readFileSync(authPath, 'utf8');
let parsed;
try {
parsed = JSON.parse(authJson);
} catch (error) {
throw new Error(`Refusing to persist malformed auth.json: ${error instanceof Error ? error.message : String(error)}`);
}
const codexAuth = parsed['openai-codex'];
if (codexAuth?.type !== 'oauth' || typeof codexAuth.refresh !== 'string' || codexAuth.refresh.length === 0) {
throw new Error('Refusing to persist auth.json without openai-codex OAuth refresh credentials');
}
await new Promise((resolve, reject) => {
const child = spawn(
'gh',
['secret', 'set', 'PI_AUTH_JSON', '--env', 'pi-analyze', '--repo', process.env.GITHUB_REPOSITORY],
{ env: process.env, stdio: ['pipe', 'inherit', 'inherit'] },
);
child.stdin.end(authJson);
child.on('error', reject);
child.on('close', (code) => {
if (code === 0) {
resolve();
} else {
reject(new Error(`gh secret set failed with exit code ${code}`));
}
});
});
- name: Export session files
id: export_session_files
if: always()
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
PI_CODING_AGENT_DIR: ${{ runner.temp }}/pi-agent
WORKDIR: ${{ steps.workdir.outputs.name }}
with:
script: |
const fs = require('fs');
const path = require('path');
const { spawn } = require('child_process');
function findFirstJsonl(dir) {
if (!fs.existsSync(dir)) return undefined;
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const entryPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
const nested = findFirstJsonl(entryPath);
if (nested) return nested;
} else if (entry.isFile() && entry.name.endsWith('.jsonl')) {
return entryPath;
}
}
return undefined;
}
const outDir = path.join(process.env.RUNNER_TEMP, 'pi-out');
const sessionFile = findFirstJsonl(path.join(outDir, 'session'));
if (!sessionFile) {
throw new Error('No session jsonl file found');
}
const sessionJsonl = path.join(outDir, 'session.jsonl');
const sessionHtml = path.join(outDir, 'session.html');
fs.copyFileSync(sessionFile, sessionJsonl);
const workdir = path.join(process.env.GITHUB_WORKSPACE, process.env.WORKDIR);
const exitCode = await new Promise((resolve, reject) => {
const child = spawn(
'node',
['packages/coding-agent/src/cli.ts', '--no-extensions', '--export', sessionJsonl, sessionHtml],
{ cwd: workdir, env: process.env, stdio: 'inherit' },
);
child.on('error', reject);
child.on('close', resolve);
});
if (exitCode !== 0) {
throw new Error(`session export failed with exit code ${exitCode}`);
}
- name: Upload session gist
id: gist
if: always() && steps.export_session_files.outcome == 'success'
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
PI_GIST_TOKEN: ${{ secrets.PI_GIST_TOKEN }}
with:
github-token: ${{ secrets.PI_GIST_TOKEN }}
script: |
const fs = require('fs');
const path = require('path');
if (!process.env.PI_GIST_TOKEN) {
throw new Error('PI_GIST_TOKEN is not configured');
}
const outDir = path.join(process.env.RUNNER_TEMP, 'pi-out');
const files = {};
for (const filename of ['session.html', 'session.jsonl']) {
files[filename] = { content: fs.readFileSync(path.join(outDir, filename), 'utf8') };
}
const response = await github.rest.gists.create({
public: false,
files,
});
const gistUrl = response.data.html_url;
const gistId = response.data.id;
core.setOutput('url', gistUrl);
core.setOutput('id', gistId);
core.setOutput('share_url', `https://pi.dev/session/#${gistId}`);
- name: Comment with session import instructions
if: always() && steps.gist.outcome == 'success'
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
GIST_URL: ${{ steps.gist.outputs.url }}
GIST_ID: ${{ steps.gist.outputs.id }}
SHARE_URL: ${{ steps.gist.outputs.share_url }}
SESSION_JSONL: ${{ runner.temp }}/pi-out/session.jsonl
with:
script: |
const fs = require('fs');
function extractLastAgentMessage(sessionPath) {
const lines = fs.readFileSync(sessionPath, 'utf8').split(/\r?\n/).filter(Boolean);
let lastText = '';
for (const line of lines) {
let entry;
try {
entry = JSON.parse(line);
} catch {
continue;
}
if (entry.type !== 'message' || entry.message?.role !== 'assistant') continue;
const content = entry.message.content;
const parts = [];
if (typeof content === 'string') {
parts.push(content);
} else if (Array.isArray(content)) {
for (const block of content) {
if (block?.type === 'text' && typeof block.text === 'string') {
parts.push(block.text);
}
}
}
const text = parts.join('\n\n').trim();
if (text) lastText = text;
}
if (!lastText) return '_No assistant output found._';
const maxLength = 55000;
if (lastText.length <= maxLength) return lastText;
return `${lastText.slice(0, maxLength)}\n\n_[truncated]_`;
}
const gistUrl = process.env.GIST_URL;
const gistId = process.env.GIST_ID;
const shareUrl = process.env.SHARE_URL;
const lastAgentMessage = extractLastAgentMessage(process.env.SESSION_JSONL);
const body = [
'Pi issue analysis finished.',
'',
`Share URL: ${shareUrl}`,
`Gist: ${gistUrl}`,
'',
'Continue locally from a checkout with:',
'',
'```sh',
`pi "/ir ${gistId}"`,
'```',
'',
'<details>',
'<summary>Agent analysis summary</summary>',
'',
lastAgentMessage,
'',
'</details>',
].join('\n');
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body,
});
- name: Remove trigger label
if: always()
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
try {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
name: 'pi-analyze',
});
} catch (error) {
if (error.status !== 404) throw error;
}
-129
View File
@@ -1,129 +0,0 @@
name: Issue Gate
on:
issues:
types: [opened]
jobs:
check-contributor:
runs-on: ubuntu-latest
permissions:
contents: read
issues: write
steps:
- name: Check issue author
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const APPROVED_FILE = '.github/APPROVED_CONTRIBUTORS';
const VALID_CAPABILITIES = new Set(['issue', 'pr']);
const TRUSTED_BOT_AUTHORS = new Set(['dependabot[bot]', 'sentry[bot]', 'claude[bot]']);
const issueAuthor = context.payload.issue.user.login;
const defaultBranch = context.payload.repository.default_branch;
const isBotAuthor = issueAuthor.endsWith('[bot]');
if (TRUSTED_BOT_AUTHORS.has(issueAuthor)) {
console.log(`Skipping trusted bot: ${issueAuthor}`);
return;
}
async function getPermission(username) {
try {
const { data: permissionLevel } = await github.rest.repos.getCollaboratorPermissionLevel({
owner: context.repo.owner,
repo: context.repo.repo,
username,
});
return permissionLevel.permission;
} catch {
return null;
}
}
async function getTextFile(path) {
const { data: fileContent } = await github.rest.repos.getContent({
owner: context.repo.owner,
repo: context.repo.repo,
path,
ref: defaultBranch,
});
if (!('content' in fileContent) || typeof fileContent.content !== 'string') {
throw new Error(`Expected file content for ${path}`);
}
return Buffer.from(fileContent.content, 'base64').toString('utf8');
}
function parseApprovedUsers(content) {
const users = new Map();
for (const rawLine of content.split('\n')) {
const line = rawLine.trim();
if (!line || line.startsWith('#')) continue;
const parts = line.split(/\s+/);
if (parts.length !== 2) {
console.log(`Skipping malformed line: ${rawLine}`);
continue;
}
const [username, capability] = parts;
const normalizedCapability = capability.toLowerCase();
if (!VALID_CAPABILITIES.has(normalizedCapability)) {
console.log(`Skipping line with invalid capability: ${rawLine}`);
continue;
}
users.set(username.toLowerCase(), normalizedCapability);
}
return users;
}
const permission = await getPermission(issueAuthor);
if (!isBotAuthor && ['admin', 'maintain', 'write'].includes(permission)) {
console.log(`${issueAuthor} is a collaborator with ${permission} access`);
return;
}
const approvedContent = await getTextFile(APPROVED_FILE);
const approvedUsers = parseApprovedUsers(approvedContent);
const capability = approvedUsers.get(issueAuthor.toLowerCase());
if (!isBotAuthor && (capability === 'issue' || capability === 'pr')) {
console.log(`${issueAuthor} is approved for ${capability}`);
return;
}
const message = [
'This issue was auto-closed. All issues from new contributors are auto-closed by default.',
'',
`Maintainers review auto-closed issues daily and reopen worthwhile ones. Issues that do not meet the quality bar in [CONTRIBUTING.md](https://github.com/${context.repo.owner}/${context.repo.repo}/blob/${defaultBranch}/CONTRIBUTING.md) will not be reopened or receive a reply.`,
'',
'If a maintainer replies `lgtmi` on one of your issues, your future issues will stay open. If a maintainer replies `lgtm`, your future issues and PRs will stay open.',
'',
`See [CONTRIBUTING.md](https://github.com/${context.repo.owner}/${context.repo.repo}/blob/${defaultBranch}/CONTRIBUTING.md).`,
].join('\n');
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: message,
});
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
labels: ['untriaged'],
});
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
state: 'closed',
state_reason: 'not_planned',
});
-142
View File
@@ -1,142 +0,0 @@
name: Issue Triage Labels
on:
issues:
types: [reopened, labeled]
jobs:
update-labels:
runs-on: ubuntu-latest
permissions:
issues: write
steps:
- name: Update triage labels
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const UNTRIAGED_LABEL = 'untriaged';
const NO_ACTION_LABEL = 'no-action';
const LAST_READ_LABEL = 'last-read';
const TO_DISCUSS_LABEL = 'to-discuss';
const INPROGRESS_LABEL = 'inprogress';
function issueHasLabel(issue, labelName) {
return (issue.labels ?? []).some((label) => label.name === labelName);
}
async function removeLabelIfPresent(issueNumber, issue, labelName) {
if (!issueHasLabel(issue, labelName)) {
console.log(`Issue #${issueNumber} does not have ${labelName}`);
return;
}
try {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
name: labelName,
});
console.log(`Removed ${labelName} from #${issueNumber}`);
} catch (error) {
if (error.status === 404) {
console.log(`Label ${labelName} was already absent from #${issueNumber}`);
return;
}
throw error;
}
}
if (context.payload.action === 'reopened') {
await removeLabelIfPresent(context.issue.number, context.payload.issue, UNTRIAGED_LABEL);
await removeLabelIfPresent(context.issue.number, context.payload.issue, NO_ACTION_LABEL);
return;
}
if (context.payload.action === 'labeled' && context.payload.label?.name === NO_ACTION_LABEL) {
await removeLabelIfPresent(context.issue.number, context.payload.issue, UNTRIAGED_LABEL);
return;
}
if (context.payload.action !== 'labeled' || context.payload.label?.name !== LAST_READ_LABEL) {
console.log('Not a last-read label event');
return;
}
const currentIssueNumber = context.issue.number;
const lastReadIssues = await github.paginate(github.rest.issues.listForRepo, {
owner: context.repo.owner,
repo: context.repo.repo,
state: 'all',
labels: LAST_READ_LABEL,
per_page: 100,
});
const previousIssueNumbers = lastReadIssues
.filter((issue) => !issue.pull_request)
.map((issue) => issue.number)
.filter((issueNumber) => issueNumber !== currentIssueNumber);
if (previousIssueNumbers.length === 0) {
console.log('No previous last-read issue found');
return;
}
const previousIssueNumber = Math.max(...previousIssueNumbers);
if (currentIssueNumber <= previousIssueNumber) {
console.log(
`Last-read was added to old issue #${currentIssueNumber}; latest last-read is #${previousIssueNumber}`,
);
return;
}
const untriagedIssues = await github.paginate(github.rest.issues.listForRepo, {
owner: context.repo.owner,
repo: context.repo.repo,
state: 'all',
labels: UNTRIAGED_LABEL,
per_page: 100,
});
const issuesToMark = untriagedIssues
.filter((issue) => !issue.pull_request)
.filter((issue) => issue.number >= previousIssueNumber && issue.number <= currentIssueNumber)
.sort((a, b) => a.number - b.number);
if (issuesToMark.length === 0) {
console.log(`No untriaged issues found from #${previousIssueNumber} to #${currentIssueNumber}`);
return;
}
for (const issue of issuesToMark) {
if (issueHasLabel(issue, TO_DISCUSS_LABEL)) {
console.log(`Skipped ${NO_ACTION_LABEL} for #${issue.number} because it has ${TO_DISCUSS_LABEL}`);
} else {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
labels: [NO_ACTION_LABEL],
});
console.log(`Added ${NO_ACTION_LABEL} to #${issue.number}`);
}
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
state: 'closed',
state_reason: 'not_planned',
});
console.log(`Closed #${issue.number} as not planned`);
await removeLabelIfPresent(issue.number, issue, INPROGRESS_LABEL);
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
name: UNTRIAGED_LABEL,
});
console.log(`Removed ${UNTRIAGED_LABEL} from #${issue.number}`);
}
-31
View File
@@ -1,31 +0,0 @@
name: npm audit
on:
schedule:
- cron: '37 7 * * *'
workflow_dispatch:
permissions:
contents: read
jobs:
audit:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Setup Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 22
cache: npm
- name: Install dependencies without lifecycle scripts
run: npm ci --ignore-scripts --no-audit --no-fund
- name: Audit production vulnerabilities
run: npm audit --omit=dev --audit-level=moderate
- name: Verify registry signatures
run: npm audit signatures --omit=dev
-128
View File
@@ -1,128 +0,0 @@
name: PR Gate
on:
pull_request_target:
types: [opened]
jobs:
check-contributor:
runs-on: ubuntu-latest
permissions:
contents: read
issues: write
pull-requests: write
steps:
- name: Check if contributor is approved
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const APPROVED_FILE = '.github/APPROVED_CONTRIBUTORS';
const VALID_CAPABILITIES = new Set(['issue', 'pr']);
const TRUSTED_BOT_AUTHORS = new Set(['dependabot[bot]', 'sentry[bot]', 'claude[bot]']);
const prAuthor = context.payload.pull_request.user.login;
const defaultBranch = context.payload.repository.default_branch;
const isBotAuthor = prAuthor.endsWith('[bot]');
if (TRUSTED_BOT_AUTHORS.has(prAuthor)) {
console.log(`Skipping trusted bot: ${prAuthor}`);
return;
}
async function getPermission(username) {
try {
const { data: permissionLevel } = await github.rest.repos.getCollaboratorPermissionLevel({
owner: context.repo.owner,
repo: context.repo.repo,
username,
});
return permissionLevel.permission;
} catch {
return null;
}
}
async function getTextFile(path) {
const { data: fileContent } = await github.rest.repos.getContent({
owner: context.repo.owner,
repo: context.repo.repo,
path,
ref: defaultBranch,
});
if (!('content' in fileContent) || typeof fileContent.content !== 'string') {
throw new Error(`Expected file content for ${path}`);
}
return Buffer.from(fileContent.content, 'base64').toString('utf8');
}
function parseApprovedUsers(content) {
const users = new Map();
for (const rawLine of content.split('\n')) {
const line = rawLine.trim();
if (!line || line.startsWith('#')) continue;
const parts = line.split(/\s+/);
if (parts.length !== 2) {
console.log(`Skipping malformed line: ${rawLine}`);
continue;
}
const [username, capability] = parts;
const normalizedCapability = capability.toLowerCase();
if (!VALID_CAPABILITIES.has(normalizedCapability)) {
console.log(`Skipping line with invalid capability: ${rawLine}`);
continue;
}
users.set(username.toLowerCase(), normalizedCapability);
}
return users;
}
async function closePullRequest(message) {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.payload.pull_request.number,
body: message,
});
await github.rest.pulls.update({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.payload.pull_request.number,
state: 'closed',
});
}
const permission = await getPermission(prAuthor);
if (!isBotAuthor && ['admin', 'maintain', 'write'].includes(permission)) {
console.log(`${prAuthor} is a collaborator with ${permission} access`);
return;
}
const approvedContent = await getTextFile(APPROVED_FILE);
const approvedUsers = parseApprovedUsers(approvedContent);
const capability = approvedUsers.get(prAuthor.toLowerCase());
if (!isBotAuthor && capability === 'pr') {
console.log(`${prAuthor} is approved for PRs`);
return;
}
console.log(`${prAuthor} is not approved, closing PR`);
const message = [
'This PR was auto-closed. Only contributors approved with `lgtm` can open PRs. Open an issue first.',
'',
`Maintainers review auto-closed issues daily. Issues that do not meet the quality bar in [CONTRIBUTING.md](https://github.com/${context.repo.owner}/${context.repo.repo}/blob/${defaultBranch}/CONTRIBUTING.md) will not be reopened or receive a reply.`,
'',
'If a maintainer replies `lgtmi`, your future issues will stay open. If a maintainer replies `lgtm`, your future issues and PRs will stay open.',
'',
`See [CONTRIBUTING.md](https://github.com/${context.repo.owner}/${context.repo.repo}/blob/${defaultBranch}/CONTRIBUTING.md).`,
].join('\n');
await closePullRequest(message);
-146
View File
@@ -1,146 +0,0 @@
name: Publish Model Catalog
on:
workflow_run:
workflows:
- CI
types:
- completed
pull_request:
paths:
- '.github/workflows/publish-model-catalog.yml'
- '.gitignore'
- 'package.json'
- 'packages/ai/**'
- 'scripts/publish-model-catalog.mjs'
# GitHub schedules use UTC. Run hourly candidates across the CET/CEST
# boundaries; the publish job only uploads at 10:17, 12:17, and 14:17
# Europe/Vienna time.
schedule:
- cron: '17 8-13 * * 1-5'
workflow_dispatch:
inputs:
source_ref:
description: 'Commit, branch, or tag to generate from'
required: false
default: 'main'
type: string
publish:
description: 'Upload the generated catalog to production R2'
required: true
default: false
type: boolean
permissions:
contents: read
jobs:
generate:
if: ${{ github.event_name != 'workflow_run' || (github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.head_branch == 'main') }}
runs-on: ubuntu-latest
env:
SOURCE_REF: ${{ github.event.workflow_run.head_sha || inputs.source_ref || github.sha }}
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ env.SOURCE_REF }}
persist-credentials: false
- name: Setup Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '22'
cache: npm
- name: Install dependencies
run: npm ci --ignore-scripts
- name: Generate model catalog JSON
run: npm run generate:model-catalog
- name: Validate model catalog JSON
run: npm run check:model-catalog
- name: Upload model catalog JSON
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: model-catalog-json
path: .artifacts/model-catalog
if-no-files-found: error
retention-days: 14
publish:
if: ${{ github.event_name == 'schedule' || github.event_name == 'workflow_run' || (github.event_name == 'workflow_dispatch' && inputs.publish) }}
needs: generate
runs-on: ubuntu-latest
environment: pi-model-upload
concurrency:
group: publish-model-catalog-r2
cancel-in-progress: true
env:
SOURCE_REF: ${{ github.event.workflow_run.head_sha || inputs.source_ref || github.sha }}
AWS_ACCESS_KEY_ID: ${{ secrets.PI_ARTIFACTS_R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.PI_ARTIFACTS_R2_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: auto
AWS_EC2_METADATA_DISABLED: 'true'
R2_ENDPOINT: https://67c0d357268b0fca6e0b465bb9d01b84.r2.cloudflarestorage.com
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ env.SOURCE_REF }}
persist-credentials: false
- name: Setup Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '22'
- name: Download model catalog JSON
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
name: model-catalog-json
path: .artifacts/model-catalog
- name: Verify AWS CLI
run: aws --version
- name: Check publication window
id: publication-window
env:
EVENT_NAME: ${{ github.event_name }}
MANUAL_PUBLISH: ${{ inputs.publish || false }}
run: |
set -euo pipefail
local_day="$(TZ=Europe/Vienna date +%u)"
local_hour_text="$(TZ=Europe/Vienna date +%H)"
local_hour="$((10#$local_hour_text))"
local_time="$(TZ=Europe/Vienna date '+%Y-%m-%d %H:%M:%S %Z')"
allowed=false
reason="outside the Monday-Friday 10:00-15:00 Europe/Vienna publication window"
if [[ "$EVENT_NAME" == "workflow_dispatch" && "$MANUAL_PUBLISH" == "true" ]]; then
allowed=true
reason="manual publication"
elif (( local_day <= 5 && local_hour >= 10 && local_hour < 15 )); then
if [[ "$EVENT_NAME" != "schedule" ]] || (( (local_hour - 10) % 2 == 0 )); then
allowed=true
reason="business-hours publication"
else
reason="not a scheduled 10:17, 12:17, or 14:17 Europe/Vienna publication"
fi
fi
echo "allowed=$allowed" >> "$GITHUB_OUTPUT"
echo "R2 publication allowed: $allowed ($reason; local time: $local_time)"
- name: Publish model catalog to R2
if: steps.publication-window.outputs.allowed == 'true'
run: |
node scripts/publish-model-catalog.mjs \
--input .artifacts/model-catalog \
--bucket pi-artifacts \
--endpoint "$R2_ENDPOINT" \
--source-commit "$(git rev-parse HEAD)"
@@ -1,31 +0,0 @@
name: Remove In Progress Label On Close
on:
issues:
types: [closed]
jobs:
remove-label:
runs-on: ubuntu-latest
permissions:
issues: write
steps:
- name: Remove inprogress label
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const labelName = 'inprogress';
const labels = context.payload.issue.labels ?? [];
const hasLabel = labels.some((label) => label.name === labelName);
if (!hasLabel) {
console.log(`Issue does not have ${labelName} label`);
return;
}
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
name: labelName,
});
-45
View File
@@ -1,45 +0,0 @@
#!/bin/sh
# Get list of staged files before running check
STAGED_FILES=$(git diff --cached --name-only)
node scripts/check-lockfile-commit.mjs
if [ $? -ne 0 ]; then
exit 1
fi
# Run the check script (formatting, linting, and type checking)
echo "Running formatting, linting, and type checking..."
npm run check
if [ $? -ne 0 ]; then
echo "❌ Checks failed. Please fix the errors before committing."
exit 1
fi
RUN_BROWSER_SMOKE=0
for file in $STAGED_FILES; do
case "$file" in
packages/ai/*|packages/web-ui/*|package.json|package-lock.json)
RUN_BROWSER_SMOKE=1
break
;;
esac
done
if [ $RUN_BROWSER_SMOKE -eq 1 ]; then
echo "Running browser smoke check..."
npm run check:browser-smoke
if [ $? -ne 0 ]; then
echo "❌ Browser smoke check failed."
exit 1
fi
fi
# Restage files that were previously staged and may have been modified by formatting
for file in $STAGED_FILES; do
if [ -f "$file" ]; then
git add "$file"
fi
done
echo "✅ All pre-commit checks passed!"
-351
View File
@@ -1,351 +0,0 @@
/**
* Import a pi session shared as a gist by the issue-analysis CI workflow
* (.github/workflows/issue-analysis.yml) and switch to it.
*
* The CI job runs in a high-entropy checkout directory; this command rewrites
* the recorded cwd to the local checkout, installs the session file into the
* current session directory, and switches to it.
*
* Usage:
* /ir b4d100022aefb12f25dd2d8485e0a82a
* /ir https://gist.github.com/mitsuhiko/b4d100022aefb12f25dd2d8485e0a82a
* /ir https://pi.dev/session/#b4d100022aefb12f25dd2d8485e0a82a
* /ir https://github.com/earendil-works/pi/issues/123
*
* pi "/ir <gist-id>"
*/
import { Buffer } from "node:buffer";
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { basename, isAbsolute, join, resolve } from "node:path";
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
const GIST_ID_RE = /^[0-9a-fA-F]{20,}$/;
const GIST_URL_RE = /^https:\/\/gist\.github\.com\/(?:[^/]+\/)?([0-9a-fA-F]{20,})(?:[/#?].*)?$/;
const SHARE_URL_RE = /^https:\/\/pi\.dev\/session\/#([0-9a-fA-F]{20,})(?:[/#?].*)?$/;
const ISSUE_URL_RE = /^https:\/\/github\.com\/([^/]+)\/([^/]+)\/issues\/(\d+)(?:[/#?].*)?$/;
const GIST_URL_IN_TEXT_RE = /https:\/\/gist\.github\.com\/(?:[^/\s]+\/)?([0-9a-fA-F]{20,})\b/g;
const SESSION_DATA_RE = /<script id="session-data" type="application\/json">([^<]+)<\/script>/;
interface SessionHeader {
type: "session";
id: string;
cwd: string;
[key: string]: unknown;
}
interface ExportedSessionData {
header: SessionHeader | null;
entries: Array<Record<string, unknown>>;
}
interface GistFile {
filename?: string;
raw_url?: string;
content?: string;
truncated?: boolean;
}
interface GistResponse {
files?: Record<string, GistFile>;
}
interface IssueComment {
body?: string | null;
user?: { login?: string } | null;
}
function parseRef(
ref: string,
cwd: string,
): { type: "gist"; id: string } | { type: "file"; path: string } | { type: "issue"; owner: string; repo: string; issue: string } {
if (ref.endsWith(".html") || ref.endsWith(".jsonl")) {
return { type: "file", path: isAbsolute(ref) ? ref : resolve(cwd, ref) };
}
const shareMatch = ref.match(SHARE_URL_RE);
if (shareMatch) return { type: "gist", id: shareMatch[1] };
const gistMatch = ref.match(GIST_URL_RE);
if (gistMatch) return { type: "gist", id: gistMatch[1] };
const issueMatch = ref.match(ISSUE_URL_RE);
if (issueMatch) return { type: "issue", owner: issueMatch[1], repo: issueMatch[2], issue: issueMatch[3] };
if (GIST_ID_RE.test(ref)) return { type: "gist", id: ref };
throw new Error(`expected a gist ID, gist URL, pi.dev share URL, issue URL, .html file, or .jsonl file: ${ref}`);
}
function parseSessionJsonl(raw: string): { header: SessionHeader; jsonl: string } {
const newlineIndex = raw.indexOf("\n");
const firstLine = newlineIndex === -1 ? raw : raw.slice(0, newlineIndex);
let parsed: unknown;
try {
parsed = JSON.parse(firstLine);
} catch {
throw new Error("first line of session file is not valid JSON");
}
const header = parsed as Partial<SessionHeader>;
if (header.type !== "session" || typeof header.id !== "string" || typeof header.cwd !== "string" || header.cwd === "") {
throw new Error("session file has no valid session header with a cwd");
}
return { header: header as SessionHeader, jsonl: raw };
}
function decodeExportedHtml(html: string): { header: SessionHeader; jsonl: string } {
const match = html.match(SESSION_DATA_RE);
if (!match) throw new Error("HTML does not contain embedded pi session data");
let data: unknown;
try {
data = JSON.parse(Buffer.from(match[1], "base64").toString("utf8"));
} catch {
throw new Error("embedded pi session data is not valid JSON");
}
const sessionData = data as Partial<ExportedSessionData>;
const header = sessionData.header;
if (!header || header.type !== "session" || typeof header.id !== "string" || typeof header.cwd !== "string") {
throw new Error("embedded pi session data has no valid session header");
}
if (!Array.isArray(sessionData.entries)) {
throw new Error("embedded pi session data has no entries array");
}
const lines = [header, ...sessionData.entries].map((entry) => JSON.stringify(entry));
return { header, jsonl: `${lines.join("\n")}\n` };
}
type SessionPlatform = "windows" | "unix" | "unknown";
function escapeJsonString(value: string): string {
return JSON.stringify(value).slice(1, -1);
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function trimTrailingPathSeparators(value: string): string {
return value.replace(/[\\/]+$/, "");
}
function getPathTailName(value: string): string {
const trimmed = trimTrailingPathSeparators(value);
return trimmed.split(/[\\/]/).filter(Boolean).at(-1) ?? "";
}
function getWindowsDrivePathParts(value: string): { drive: string; rest: string } | undefined {
const trimmed = trimTrailingPathSeparators(value);
const driveMatch = trimmed.match(/^([A-Za-z]):[\\/](.*)$/);
if (driveMatch) {
return { drive: driveMatch[1].toUpperCase(), rest: driveMatch[2].replace(/[\\/]+/g, "/") };
}
const msysMatch = trimmed.match(/^\/([A-Za-z])\/(.*)$/);
if (msysMatch) {
return { drive: msysMatch[1].toUpperCase(), rest: msysMatch[2].replace(/[\\/]+/g, "/") };
}
return undefined;
}
function getCwdRewriteVariants(sourceCwd: string): string[] {
const trimmed = trimTrailingPathSeparators(sourceCwd);
const variants = new Set<string>();
if (trimmed) variants.add(trimmed);
const driveParts = getWindowsDrivePathParts(trimmed);
if (driveParts) {
const rest = driveParts.rest.replace(/^\/+|\/+$/g, "");
const backslashRest = rest.replace(/\//g, "\\");
variants.add(`${driveParts.drive}:\\${backslashRest}`);
variants.add(`${driveParts.drive}:/${rest}`);
variants.add(`/${driveParts.drive.toLowerCase()}/${rest}`);
variants.add(`/${driveParts.drive}/${rest}`);
}
return Array.from(variants).filter(Boolean).sort((a, b) => b.length - a.length);
}
function getCiWorkdirName(sourceCwd: string): string | undefined {
const name = getPathTailName(sourceCwd);
return /^pi-ci-[0-9a-f]{32}$/i.test(name) ? name : undefined;
}
function detectSessionPlatform(cwd: string): SessionPlatform {
if (/^[A-Za-z]:[\\/]/.test(cwd) || /^\/[A-Za-z]\//.test(cwd)) return "windows";
if (cwd.startsWith("/")) return "unix";
return "unknown";
}
function getLocalPlatform(): Exclude<SessionPlatform, "unknown"> {
return process.platform === "win32" ? "windows" : "unix";
}
function getPlatformContinuationNotice(sourceCwd: string): string | undefined {
const sourcePlatform = detectSessionPlatform(sourceCwd);
const localPlatform = getLocalPlatform();
if (sourcePlatform === "unknown" || sourcePlatform === localPlatform) return undefined;
if (localPlatform === "unix") {
return "This session was continued on a non-Windows machine; paths are now Unix style.";
}
return "This session was continued on a Windows machine; paths are now Windows style.";
}
/** Rewrite occurrences of the recorded CI cwd (JSON-escaped) to the target cwd. */
function rewriteSessionCwd(raw: string, sourceCwd: string, targetCwd: string): string {
const target = escapeJsonString(targetCwd);
let rewritten = raw;
for (const sourceVariant of getCwdRewriteVariants(sourceCwd)) {
if (sourceVariant === targetCwd) continue;
rewritten = rewritten.split(escapeJsonString(sourceVariant)).join(target);
}
const ciWorkdirName = getCiWorkdirName(sourceCwd);
if (ciWorkdirName) {
const escapedName = escapeRegExp(ciWorkdirName);
const windowsPathPatterns = [
new RegExp(`[A-Za-z]:(?:[^"\\r\\n])*?${escapedName}`, "g"),
new RegExp(`/[A-Za-z]/(?:[^"\\r\\n])*?${escapedName}`, "g"),
];
for (const pattern of windowsPathPatterns) {
rewritten = rewritten.replace(pattern, target);
}
}
return rewritten;
}
async function fetchText(url: string): Promise<string> {
const response = await fetch(url, { headers: { Accept: "application/vnd.github+json" } });
if (!response.ok) {
throw new Error(`failed to fetch ${url}: HTTP ${response.status}`);
}
return await response.text();
}
async function readGistFile(file: GistFile): Promise<string> {
if (file.content && !file.truncated) return file.content;
if (!file.raw_url) throw new Error(`gist file ${file.filename ?? "<unknown>"} has no raw URL`);
return await fetchText(file.raw_url);
}
async function findIssueGistId(owner: string, repo: string, issue: string): Promise<string> {
const gistIds: string[] = [];
let page = 1;
while (true) {
const response = await fetch(
`https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues/${encodeURIComponent(issue)}/comments?per_page=100&page=${page}`,
{ headers: { Accept: "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28" } },
);
if (!response.ok) throw new Error(`failed to fetch issue comments: HTTP ${response.status}`);
const comments = (await response.json()) as IssueComment[];
for (const comment of comments) {
if (comment.user?.login !== "github-actions[bot]") continue;
for (const match of (comment.body ?? "").matchAll(GIST_URL_IN_TEXT_RE)) {
gistIds.push(match[1]);
}
}
if (comments.length < 100) break;
page++;
}
const gistId = gistIds.at(-1);
if (!gistId) throw new Error(`no github-actions gist link found in comments on ${owner}/${repo}#${issue}`);
return gistId;
}
async function fetchGistSession(gistId: string): Promise<{ header: SessionHeader; jsonl: string }> {
const response = await fetch(`https://api.github.com/gists/${gistId}`, {
headers: {
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
},
});
if (!response.ok) throw new Error(`failed to fetch gist ${gistId}: HTTP ${response.status}`);
const gist = (await response.json()) as GistResponse;
const files = Object.values(gist.files ?? {});
const jsonlFile = files.find((file) => file.filename?.endsWith(".jsonl"));
if (jsonlFile) return parseSessionJsonl(await readGistFile(jsonlFile));
const htmlFile = files.find((file) => file.filename?.endsWith(".html"));
if (htmlFile) return decodeExportedHtml(await readGistFile(htmlFile));
throw new Error(`gist ${gistId} has no .jsonl or .html session file`);
}
export default function (pi: ExtensionAPI) {
pi.registerCommand("ir", {
description: "Import a CI issue-analysis session from a gist ID, share URL, or issue URL and switch to it",
handler: async (args: string, ctx: ExtensionCommandContext) => {
const ref = args.trim();
if (!ref) {
ctx.ui.notify("Usage: /ir <gist-id | gist-url | pi.dev/session URL | issue URL>", "error");
return;
}
try {
const targetCwd = ctx.sessionManager.getCwd();
const sessionDir = ctx.sessionManager.getSessionDir();
const parsedRef = parseRef(ref, targetCwd);
ctx.ui.notify(`Importing repro session from ${ref}...`, "info");
let sourceName: string;
let decoded: { header: SessionHeader; jsonl: string };
if (parsedRef.type === "gist") {
decoded = await fetchGistSession(parsedRef.id);
sourceName = `${parsedRef.id}.jsonl`;
} else if (parsedRef.type === "issue") {
const gistId = await findIssueGistId(parsedRef.owner, parsedRef.repo, parsedRef.issue);
decoded = await fetchGistSession(gistId);
sourceName = `${gistId}.jsonl`;
} else {
if (!existsSync(parsedRef.path)) throw new Error(`session file not found: ${parsedRef.path}`);
const raw = readFileSync(parsedRef.path, "utf8");
decoded = parsedRef.path.endsWith(".html") ? decodeExportedHtml(raw) : parseSessionJsonl(raw);
sourceName = basename(parsedRef.path).replace(/\.html$/, ".jsonl");
}
const platformNotice = getPlatformContinuationNotice(decoded.header.cwd);
const rewritten = rewriteSessionCwd(decoded.jsonl, decoded.header.cwd, targetCwd);
const destination = join(sessionDir, sourceName);
if (existsSync(destination)) {
const overwrite = await ctx.ui.confirm(
"Session already imported",
`Overwrite ${destination}? Local changes to that session will be lost.`,
);
if (!overwrite) {
ctx.ui.notify("Import cancelled", "warning");
return;
}
}
writeFileSync(destination, rewritten);
ctx.ui.notify(`Imported session ${decoded.header.id} (cwd ${decoded.header.cwd} -> ${targetCwd})`, "info");
await ctx.switchSession(destination, {
withSession: async (nextCtx) => {
if (!platformNotice) return;
await nextCtx.sendMessage(
{
customType: "import-repro",
content: platformNotice,
display: true,
details: { sourceCwd: decoded.header.cwd, targetCwd },
},
{ triggerTurn: false },
);
},
});
} catch (error) {
ctx.ui.notify(`ir: ${error instanceof Error ? error.message : String(error)}`, "error");
}
},
});
}
-270
View File
@@ -1,270 +0,0 @@
import { readFile } from "node:fs/promises";
import { homedir } from "node:os";
import { resolve } from "node:path";
import { DynamicBorder, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
import { Container, Text } from "@earendil-works/pi-tui";
const PR_PROMPT_PATTERN = /^\s*You are given one or more GitHub PR URLs:\s*(\S+)/im;
const ISSUE_PROMPT_PATTERN = /^\s*Analyze GitHub issue\(s\):\s*(\S+)/im;
const ADVISORY_PROMPT_PATTERN = /^\s*Update a GitHub security advisory for publication:\s*(\S+)/im;
type PromptMatch = {
kind: "pr" | "issue" | "advisory";
target: string;
};
type GhMetadata = {
title?: string;
detail?: string;
displayUrl?: string;
author?: {
login?: string;
name?: string | null;
};
};
type GitHubAdvisoryMetadata = {
ghsa_id?: string;
summary?: string;
severity?: string;
state?: string;
html_url?: string;
cve_id?: string | null;
};
type AdvisoryRef = {
owner: string;
repo: string;
ghsaId: string;
url: string;
};
function extractPromptMatch(prompt: string): PromptMatch | undefined {
const prMatch = prompt.match(PR_PROMPT_PATTERN);
if (prMatch?.[1]) {
return { kind: "pr", target: prMatch[1].trim() };
}
const issueMatch = prompt.match(ISSUE_PROMPT_PATTERN);
if (issueMatch?.[1]) {
return { kind: "issue", target: issueMatch[1].trim() };
}
const advisoryMatch = prompt.match(ADVISORY_PROMPT_PATTERN);
if (advisoryMatch?.[1]) {
return { kind: "advisory", target: advisoryMatch[1].trim() };
}
return undefined;
}
function getPromptLabel(kind: PromptMatch["kind"]): string {
if (kind === "pr") return "PR";
if (kind === "issue") return "Issue";
return "Advisory";
}
function parseAdvisoryUrl(value: string): AdvisoryRef | undefined {
const match = value.match(
/^https:\/\/github\.com\/([^/]+)\/([^/]+)\/security\/advisories\/(GHSA-[A-Za-z0-9-]+)(?:[/?#].*)?$/i,
);
if (!match?.[1] || !match[2] || !match[3]) return undefined;
return {
owner: match[1],
repo: match[2],
ghsaId: match[3],
url: `https://github.com/${match[1]}/${match[2]}/security/advisories/${match[3]}`,
};
}
function unquoteYamlValue(value: string): string {
const trimmed = value.trim();
if (
(trimmed.startsWith('"') && trimmed.endsWith('"')) ||
(trimmed.startsWith("'") && trimmed.endsWith("'"))
) {
return trimmed.slice(1, -1);
}
return trimmed;
}
function resolveDraftPath(cwd: string, target: string): string {
if (target === "~") return homedir();
if (target.startsWith("~/")) return resolve(homedir(), target.slice(2));
return resolve(cwd, target);
}
async function readAdvisoryRefFromDraft(cwd: string, target: string): Promise<AdvisoryRef | undefined> {
try {
const content = await readFile(resolveDraftPath(cwd, target), "utf8");
const frontmatter = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
const body = frontmatter?.[1] ?? content;
const urlMatch = body.match(/^advisory_url:\s*(.+)$/m);
if (!urlMatch?.[1]) return undefined;
return parseAdvisoryUrl(unquoteYamlValue(urlMatch[1]));
} catch {
return undefined;
}
}
function formatAdvisoryDetail(advisory: GitHubAdvisoryMetadata): string | undefined {
const parts = [advisory.ghsa_id, advisory.cve_id ?? undefined, advisory.severity, advisory.state]
.map((part) => part?.trim())
.filter((part): part is string => part !== undefined && part.length > 0);
return parts.length > 0 ? parts.join(" · ") : undefined;
}
async function fetchAdvisoryMetadata(pi: ExtensionAPI, cwd: string, target: string): Promise<GhMetadata | undefined> {
const advisoryRef = parseAdvisoryUrl(target) ?? (await readAdvisoryRefFromDraft(cwd, target));
if (!advisoryRef) return undefined;
try {
const result = await pi.exec("gh", [
"api",
`repos/${advisoryRef.owner}/${advisoryRef.repo}/security-advisories/${advisoryRef.ghsaId}`,
]);
if (result.code !== 0 || !result.stdout) return { displayUrl: advisoryRef.url };
const advisory = JSON.parse(result.stdout) as GitHubAdvisoryMetadata;
return {
title: advisory.summary,
detail: formatAdvisoryDetail(advisory),
displayUrl: advisory.html_url ?? advisoryRef.url,
};
} catch {
return { displayUrl: advisoryRef.url };
}
}
async function fetchGhMetadata(
pi: ExtensionAPI,
kind: PromptMatch["kind"],
target: string,
cwd: string,
): Promise<GhMetadata | undefined> {
if (kind === "advisory") {
return fetchAdvisoryMetadata(pi, cwd, target);
}
const args =
kind === "pr"
? ["pr", "view", target, "--json", "title,author"]
: ["issue", "view", target, "--json", "title,author"];
try {
const result = await pi.exec("gh", args);
if (result.code !== 0 || !result.stdout) return undefined;
return JSON.parse(result.stdout) as GhMetadata;
} catch {
return undefined;
}
}
function formatAuthor(author?: GhMetadata["author"]): string | undefined {
if (!author) return undefined;
const name = author.name?.trim();
const login = author.login?.trim();
if (name && login) return `${name} (@${login})`;
if (login) return `@${login}`;
if (name) return name;
return undefined;
}
export default function promptUrlWidgetExtension(pi: ExtensionAPI) {
const setWidget = (ctx: ExtensionContext, match: PromptMatch, metadata?: GhMetadata) => {
ctx.ui.setWidget("prompt-url", (_tui, thm) => {
const displayTarget = metadata?.displayUrl ?? match.target;
const titleText = metadata?.title
? thm.fg("accent", metadata.title)
: thm.fg("accent", displayTarget);
const detailText = metadata?.detail ?? formatAuthor(metadata?.author);
const detailLine = detailText ? thm.fg("muted", detailText) : undefined;
const urlLine = thm.fg("dim", displayTarget);
const lines = [titleText];
if (detailLine) lines.push(detailLine);
lines.push(urlLine);
const container = new Container();
container.addChild(new DynamicBorder((s: string) => thm.fg("muted", s)));
container.addChild(new Text(lines.join("\n"), 1, 0));
return container;
});
};
const applySessionName = (ctx: ExtensionContext, match: PromptMatch, metadata?: GhMetadata) => {
const label = getPromptLabel(match.kind);
const displayTarget = metadata?.displayUrl ?? match.target;
const trimmedTitle = metadata?.title?.trim();
const fallbackName = `${label}: ${match.target}`;
const desiredFallbackName = `${label}: ${displayTarget}`;
const desiredName = trimmedTitle ? `${label}: ${trimmedTitle} (${displayTarget})` : desiredFallbackName;
const currentName = pi.getSessionName()?.trim();
if (!currentName) {
pi.setSessionName(desiredName);
return;
}
if (currentName === match.target || currentName === fallbackName || currentName === desiredFallbackName) {
pi.setSessionName(desiredName);
}
};
const updatePromptContext = (ctx: ExtensionContext, match: PromptMatch) => {
setWidget(ctx, match);
applySessionName(ctx, match);
void fetchGhMetadata(pi, match.kind, match.target, ctx.cwd).then((meta) => {
setWidget(ctx, match, meta);
applySessionName(ctx, match, meta);
});
};
pi.on("before_agent_start", async (event, ctx) => {
if (!ctx.hasUI) return;
const match = extractPromptMatch(event.prompt);
if (!match) {
return;
}
updatePromptContext(ctx, match);
});
pi.on("session_switch", async (_event, ctx) => {
rebuildFromSession(ctx);
});
const getUserText = (content: string | { type: string; text?: string }[] | undefined): string => {
if (!content) return "";
if (typeof content === "string") return content;
return (
content
.filter((block): block is { type: "text"; text: string } => block.type === "text")
.map((block) => block.text)
.join("\n") ?? ""
);
};
const rebuildFromSession = (ctx: ExtensionContext) => {
if (!ctx.hasUI) return;
const entries = ctx.sessionManager.getEntries();
const lastMatch = [...entries].reverse().find((entry) => {
if (entry.type !== "message" || entry.message.role !== "user") return false;
const text = getUserText(entry.message.content);
return !!extractPromptMatch(text);
});
const content =
lastMatch?.type === "message" && lastMatch.message.role === "user" ? lastMatch.message.content : undefined;
const text = getUserText(content);
const match = text ? extractPromptMatch(text) : undefined;
if (!match) {
ctx.ui.setWidget("prompt-url", undefined);
return;
}
updatePromptContext(ctx, match);
};
pi.on("session_start", async (_event, ctx) => {
rebuildFromSession(ctx);
});
}
-24
View File
@@ -1,24 +0,0 @@
/**
* Redraws Extension
*
* Exposes /tui to show TUI redraw stats.
*/
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { Text } from "@earendil-works/pi-tui";
export default function (pi: ExtensionAPI) {
pi.registerCommand("tui", {
description: "Show TUI stats",
handler: async (_args, ctx) => {
if (!ctx.hasUI) return;
let redraws = 0;
await ctx.ui.custom<void>((tui, _theme, _keybindings, done) => {
redraws = tui.fullRedraws;
done(undefined);
return new Text("", 0, 0);
});
ctx.ui.notify(`TUI full redraws: ${redraws}`, "info");
},
});
}
-47
View File
@@ -1,47 +0,0 @@
import type { AssistantMessage } from "@earendil-works/pi-ai";
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
function isAssistantMessage(message: unknown): message is AssistantMessage {
if (!message || typeof message !== "object") return false;
const role = (message as { role?: unknown }).role;
return role === "assistant";
}
export default function (pi: ExtensionAPI) {
let agentStartMs: number | null = null;
pi.on("agent_start", () => {
agentStartMs = Date.now();
});
pi.on("agent_end", (event, ctx) => {
if (!ctx.hasUI) return;
if (agentStartMs === null) return;
const elapsedMs = Date.now() - agentStartMs;
agentStartMs = null;
if (elapsedMs <= 0) return;
let input = 0;
let output = 0;
let cacheRead = 0;
let cacheWrite = 0;
let totalTokens = 0;
for (const message of event.messages) {
if (!isAssistantMessage(message)) continue;
input += message.usage.input || 0;
output += message.usage.output || 0;
cacheRead += message.usage.cacheRead || 0;
cacheWrite += message.usage.cacheWrite || 0;
totalTokens += message.usage.totalTokens || 0;
}
if (output <= 0) return;
const elapsedSeconds = elapsedMs / 1000;
const tokensPerSecond = output / elapsedSeconds;
const message = `TPS ${tokensPerSecond.toFixed(1)} tok/s. out ${output.toLocaleString()}, in ${input.toLocaleString()}, cache r/w ${cacheRead.toLocaleString()}/${cacheWrite.toLocaleString()}, total ${totalTokens.toLocaleString()}, ${elapsedSeconds.toFixed(1)}s`;
ctx.ui.notify(message, "info");
});
}
-2
View File
@@ -1,2 +0,0 @@
*
!.gitignore
-2
View File
@@ -1,2 +0,0 @@
*
!.gitignore
-54
View File
@@ -1,54 +0,0 @@
---
description: Audit changelog entries before release
---
Audit changelog entries for all commits since the last release.
## Process
1. **Find the last release tag:**
```bash
git tag --sort=-version:refname | head -1
```
2. **List all commits since that tag:**
```bash
git log <tag>..HEAD --oneline
```
3. **Read each package's [Unreleased] section:**
- packages/ai/CHANGELOG.md
- packages/tui/CHANGELOG.md
- packages/coding-agent/CHANGELOG.md
4. **For each commit, check:**
- Skip: changelog updates, doc-only changes, release housekeeping
- Skip: changes to generated model catalogs (for example `packages/ai/src/models.generated.ts`) unless accompanied by an intentional product-facing change in non-generated source/docs.
- Determine which package(s) the commit affects (use `git show <hash> --stat`)
- Verify a changelog entry exists in the affected package(s)
- For external contributions (PRs), verify format: `Description ([#N](url) by [@user](url))`
5. **Cross-package duplication rule:**
Changes in `ai`, `agent` or `tui` that affect end users should be duplicated to `coding-agent` changelog, since coding-agent is the user-facing package that depends on them.
6. **Add New Features section after changelog fixes:**
- Insert a `### New Features` section at the start of `## [Unreleased]` in `packages/coding-agent/CHANGELOG.md`.
- Propose the top new features to the user for confirmation before writing them.
- Link to relevant docs and sections whenever possible.
7. **Report:**
- List commits with missing entries
- List entries that need cross-package duplication
- Add any missing entries directly
## Changelog Format Reference
Sections (in order):
- `### Breaking Changes` - API changes requiring migration
- `### Added` - New features
- `### Changed` - Changes to existing functionality
- `### Fixed` - Bug fixes
- `### Removed` - Removed features
Attribution:
- Internal: `Fixed foo ([#123](https://github.com/earendil-works/pi-mono/issues/123))`
- External: `Added bar ([#456](https://github.com/earendil-works/pi-mono/pull/456) by [@user](https://github.com/user))`
-28
View File
@@ -1,28 +0,0 @@
---
description: Analyze GitHub issues (bugs or feature requests)
argument-hint: "<issue>"
---
Analyze GitHub issue(s): $ARGUMENTS
For each issue:
1. If running under CI (`CI=true`), do not add the `inprogress` label and do not assign the issue. Otherwise, add the `inprogress` label to the issue via GitHub CLI and assign the issue to the local `gh` user before analysis starts. If either action fails, report that explicitly and continue.
2. Read the issue in full, including all comments and linked issues/PRs. Use fields supported by GitHub CLI, for example:
```sh
gh issue view <issue> --json title,body,comments,labels,assignees,state,url,author,createdAt,updatedAt,closedByPullRequestsReferences
```
3. Do not trust analysis written in the issue. Independently verify behavior and derive your own analysis from the code and execution path.
4. **For bugs**:
- Ignore any root cause analysis in the issue (likely wrong)
- Read all related code files in full (no truncation)
- Trace the code path and identify the actual root cause
- Propose a fix
5. **For feature requests**:
- Do not trust implementation proposals in the issue without verification
- Read all related code files in full (no truncation)
- Propose the most concise implementation approach
- List affected files and changes needed
Do NOT implement unless explicitly asked. Analyze and propose only.
-37
View File
@@ -1,37 +0,0 @@
---
description: Review PRs from URLs with structured issue and code analysis
argument-hint: "<PR-URL>"
---
You are given one or more GitHub PR URLs: $@
For each PR URL, do the following in order:
1. Add the `inprogress` label to the PR via GitHub CLI before analysis starts. If adding the label fails, report that explicitly and continue.
2. Read the PR page in full. Include description, all comments, all commits, and all changed files.
3. Identify any linked issues referenced in the PR body, comments, commit messages, or cross links. Read each issue in full, including all comments.
4. Analyze the PR diff without checking out or switching to the PR branch. Use `gh pr diff`, `gh pr view`, `gh api`, and local main-branch files; if PR file contents are needed, use fetched refs with `git show <ref>:<path>` or temporary files. Read all relevant code files in full with no truncation and compare against the diff. Do not fetch PR file blobs unless a file is missing on main or the diff context is insufficient. Include related code paths that are not in the diff but are required to validate behavior.
5. Do not check for a changelog entry. Per CONTRIBUTING.md, contributor PRs must not edit `CHANGELOG.md` — the maintainer adds the entry when merging.
6. Check if packages/coding-agent/README.md, packages/coding-agent/docs/*.md, packages/coding-agent/examples/**/*.md require modification. This is usually the case when existing features have been changed, or new features have been added.
7. Provide a structured review with these sections:
- What it does: one short paragraph describing the change and its intent.
- Good: solid choices or improvements.
- Bad: concrete issues, regressions, missing tests, or risks.
- Ugly: subtle or high impact problems.
- Tests: what is covered, what is missing, and whether existing tests are adequate.
- Open questions for you: only things blocking a merge decision that need the user's input. Omit the section entirely if there are none.
Output format per PR:
PR: <url>
What it does:
- ...
Good:
- ...
Bad:
- ...
Ugly:
- ...
Tests:
- ...
Open questions for you:
- ...
If no issues are found, say so under Bad and Ugly.
-163
View File
@@ -1,163 +0,0 @@
---
description: Update a GitHub security advisory for publication
argument-hint: "<advisory-url-or-draft-path>"
---
Update a GitHub security advisory for publication: $ARGUMENTS
Use `gh` for all GitHub operations. Do not publish the advisory, change its state, or request a CVE unless the user explicitly agrees or the draft markdown explicitly says `request_cve: true`.
GitHub does not expose repository security advisory comments/discussion through the documented REST OpenAPI schema or public GraphQL schema. A 404 from guessed API endpoints such as `api.github.com/repos/.../security-advisories/<GHSA>/comments`, `.../timeline`, or `.../events` is expected and is not, by itself, an auth failure. Do not use a browser session, browser cookies, or cookie extraction to fetch advisory comments. Instead, clearly tell the user that advisory comments were not included and that they can paste any relevant comments if they want them considered.
## Input handling
- If `$ARGUMENTS` is a GitHub security advisory URL, start the investigation and drafting workflow.
- If `$ARGUMENTS` is a path to an existing markdown draft, read it and apply that draft to the advisory.
- In a follow-up message after this prompt, if the user says "update", "apply", "looks good", or similar, treat it as approval to apply the previously written temp markdown draft. Re-read the file from disk before updating GitHub.
- If applying a draft and there is no known draft path, ask the user for the markdown file path.
## Initial advisory workflow
1. Parse the advisory URL into `owner`, `repo`, and `GHSA` id.
2. Fetch the advisory with:
```sh
gh api repos/<owner>/<repo>/security-advisories/<GHSA>
```
Record the advisory's original severity, CVSS vector, and CVSS score exactly as returned before proposing changes.
3. Do not fetch advisory comments/discussion unless the user pasted them into the conversation:
- Inspect the advisory JSON for references, credits, linked issues/PRs, and any discussion fields.
- Do not rely on invented API endpoints such as `/comments`, `/timeline`, or `/events`; they commonly return 404 because GitHub does not expose draft advisory comments through the public API.
- Do not use a browser session, browser cookies, or cookie extraction to fetch comments.
- Explicitly tell the user: `Advisory comments were not included because GitHub does not expose them through the public API. Paste any relevant comments if you want them considered.`
- If the user pasted comments, read and consider them.
- Never pretend comments were read.
4. Investigate independently:
- Read the advisory text, metadata, affected package(s), version ranges, CVSS, CWE, references, and linked issues/PRs/commits.
- Inspect relevant code history, releases, changelogs, package metadata, and tags.
- Determine whether the vulnerability is already fixed.
- If fixed, identify the patched version(s) and the correct affected version range.
- Do not trust the reporter's analysis without verification.
5. Discuss CVSS with the user before drafting the final update:
- Propose a CVSS vector, score, and severity.
- Explain the controversial metrics briefly.
- Ask the user to confirm or adjust it.
6. Ask whether a CVE should be requested from GitHub for this advisory.
7. Draft a publication-ready advisory markdown file under `/tmp`, for example `/tmp/sa-<GHSA>.md`. Include both the original CVSS from the advisory and the proposed/confirmed updated CVSS.
8. Tell the user:
- the path to the temp markdown file
- the original advisory URL
- that they can edit the file and then say "update" or provide the path
## Draft markdown format
The draft file must contain YAML frontmatter followed by the advisory body. Include all fields needed to update GitHub and to decide whether to request a CVE.
```markdown
---
advisory_url: https://github.com/<owner>/<repo>/security/advisories/<GHSA>
owner: <owner>
repo: <repo>
ghsa_id: <GHSA>
summary: <short advisory summary>
original_severity: <low|medium|high|critical|null>
original_cvss_vector: <original CVSS:3.1/... or null>
original_cvss_score: <original number or null>
severity: <proposed/confirmed low|medium|high|critical>
cvss_vector: <proposed/confirmed CVSS:3.1/...>
cvss_score: <proposed/confirmed number>
cwe_ids:
- CWE-...
vulnerabilities:
- package:
ecosystem: npm
name: <package-name>
vulnerable_version_range: <range>
patched_versions: <range-or-version>
request_cve: false
---
# <Advisory title>
<Concise description of the vulnerability and vulnerable behavior.>
## Info
<Technical explanation of the root cause and affected component. Focus on facts needed by defenders and maintainers. Do not include PoC steps, exploit payloads, or copy-pastable exploit strings.>
## Impact
<Who can exploit it, prerequisites, confidentiality/integrity/availability impact, and realistic deployment assumptions.>
## Affected versions
- Affected: `<range>`
- Patched: `<version or range>`
## The solution
<Describe the fix and the patched release.>
## Recommendations
<Upgrade guidance and operational mitigations.>
## Workarounds
<Workarounds if any; otherwise skip this section entirely>
## Timeline
- YYYY-MM-DD: Report received
- YYYY-MM-DD: Fix committed
- YYYY-MM-DD: Fixed version released
- YYYY-MM-DD: Advisory published
## Credits
<Reporter/researcher attribution if appropriate, otherwise skip section.>
## References
- <links to releases, commits, advisories, documentation>
```
Use the curl advisory style as inspiration: clear sections, direct language, affected/fixed version facts, recommendations, timeline, and credits. Do not include a PoC.
## Applying a draft to GitHub
When the user approves with "update"/similar or provides a markdown path:
1. Re-read the markdown file from disk. Never rely on the previously generated content in memory.
2. Parse the YAML frontmatter and body.
3. Build a JSON payload in a temporary file. Map fields as follows:
- `summary` from frontmatter
- `description` from the markdown body after frontmatter
- `severity` from frontmatter if present
- `cvss_vector_string` from `cvss_vector`
- `cwe_ids` from frontmatter
- `vulnerabilities` from frontmatter
- Do not send `original_severity`, `original_cvss_vector`, or `original_cvss_score`; those fields are retained only for audit context.
4. Update the advisory with:
```sh
gh api -X PATCH repos/<owner>/<repo>/security-advisories/<GHSA> --input /tmp/<payload>.json
```
5. If and only if the markdown frontmatter has `request_cve: true`, request a CVE with:
```sh
gh api -X POST repos/<owner>/<repo>/security-advisories/<GHSA>/cve
```
Treat "already requested" or "already assigned" as non-fatal and report it.
6. Report what was updated:
- advisory URL
- summary
- affected range
- patched versions
- original CVSS vector/score/severity
- updated CVSS vector/score/severity
- whether CVE was requested
## Safety rules
- Do not include PoC material in the final advisory body.
- Do not request a CVE unless `request_cve: true` is present in the markdown file.
- Do not publish the advisory or change its state unless the user explicitly asks.
- Do not fetch advisory comments through browser sessions or cookies. State that comments were not included and invite the user to paste relevant comments if they want them considered.
- If there is uncertainty in affected ranges, patched versions, CVSS, or CVE request status, ask the user before applying.
-40
View File
@@ -1,40 +0,0 @@
---
description: Finish the current task end-to-end with changelog, commit, and push
argument-hint: "[instructions]"
---
Wrap it.
Additional instructions: $ARGUMENTS
Determine context from the conversation history first.
Rules for context detection:
- If the conversation already mentions a GitHub issue or PR, use that existing context.
- If the work came from `/is` or `/pr`, assume the issue or PR context is already known from the conversation and from the analysis work already done.
- If there is no GitHub issue or PR in the conversation history, treat this as non-GitHub work.
Unless I explicitly override something in this request, do the following in order:
1. Add or update the relevant package changelog entry under `## [Unreleased]` using the repo changelog rules.
2. If this task is tied to a GitHub issue or PR and a final issue or PR comment has not already been posted in this session, draft it in my tone, preview it, and post exactly one final comment. The comment must end with this exact standalone disclaimer line, with no variations:
```text
This comment is AI-generated by `/wr`
```
3. Commit only files you changed in this session.
4. If this task is tied to exactly one GitHub issue, include `closes #<issue>` in the commit message. If it is tied to multiple issues, stop and ask which one to use. If it is not tied to any issue, do not include `closes #` or `fixes #` in the commit message.
5. Check the current git branch. If it is not `main`, stop and ask what to do. Do not push from another branch unless I explicitly say so.
6. Push the current branch.
7. If this task is tied to exactly one GitHub issue, explicitly close that issue with reason `completed` after the push so the issue-close workflows in `.github/` run. This applies to issues only, not PRs.
- Inspect `gh issue view <issue> --json state,stateReason,labels`.
- If the issue is open, run `gh issue close <issue> --reason completed`.
- If the issue is already closed with any reason other than `COMPLETED`, reopen it first, then close it with `gh issue close <issue> --reason completed` so GitHub emits a fresh close event.
- If the issue is already closed as `COMPLETED`, leave it closed unless the `inprogress` label is still present; in that case reopen it and close it again with reason `completed`.
Constraints:
- Never stage unrelated files.
- Never use `git add .` or `git add -A`.
- Run required checks before committing if code changed.
- Do not open a PR unless I explicitly ask.
- If this is not GitHub issue or PR work, do not post a GitHub comment.
- If a final issue or PR comment was already posted in this session, do not post another one unless I explicitly ask.
-57
View File
@@ -1,57 +0,0 @@
---
name: add-llm-provider
description: Checklist for adding a new LLM provider to packages/ai. Covers core types, provider implementation, lazy registration, model generation, the full test matrix, coding-agent wiring, and docs.
---
# Adding a New LLM Provider (packages/ai)
A new provider touches multiple files. Work through these steps in order.
## 1. Core Types (`packages/ai/src/types.ts`)
- Add API identifier to `Api` type union (e.g. `"bedrock-converse-stream"`).
- Create options interface extending `StreamOptions`.
- Add mapping to `ApiOptionsMap`.
- Add provider name to `KnownProvider` type union.
## 2. Provider Implementation (`packages/ai/src/providers/`)
Create a provider file exporting:
- `stream<Provider>()` returning `AssistantMessageEventStream`.
- `streamSimple<Provider>()` for `SimpleStreamOptions` mapping.
- Provider-specific options interface.
- Message/tool conversion functions.
- Response parsing that emits standardized events (`text`, `tool_call`, `thinking`, `usage`, `stop`).
## 3. Provider Exports and Lazy Registration
- Add a package subpath export in `packages/ai/package.json` pointing at `./dist/providers/<provider>.js`.
- Add `export type` re-exports in `packages/ai/src/index.ts` for provider option types that should remain available from the root entry.
- Register the provider in `packages/ai/src/providers/register-builtins.ts` via lazy loader wrappers; do not statically import provider implementation modules there.
- Add credential detection in `packages/ai/src/env-api-keys.ts`.
## 4. Model Generation (`packages/ai/scripts/generate-models.ts`)
- Add logic to fetch/parse models from the provider source.
- Map to the standardized `Model` interface.
## 5. Tests (`packages/ai/test/`)
- Always add the provider to `stream.test.ts` with at least one representative model, even if it reuses an existing API impl such as `openai-completions`.
- Add the provider to the broader matrix where applicable: `tokens.test.ts`, `abort.test.ts`, `empty.test.ts`, `context-overflow.test.ts`, `unicode-surrogate.test.ts`, `tool-call-without-result.test.ts`, `image-tool-result.test.ts`, `total-tokens.test.ts`, `cross-provider-handoff.test.ts`.
- For `cross-provider-handoff.test.ts`, add at least one provider/model pair. If the provider exposes multiple model families (e.g. GPT and Claude), add at least one pair per family.
- For non-standard auth, create a utility (e.g. `bedrock-utils.ts`) with credential detection.
## 6. Coding Agent (`packages/coding-agent/`)
- `src/core/model-resolver.ts`: add default model ID to `defaultModelPerProvider`.
- `src/core/provider-display-names.ts`: add API-key login display name so `/login` and related UI show the provider for built-in API-key auth.
- `src/cli/args.ts`: add env var documentation.
- `README.md`: add provider setup instructions.
- `docs/providers.md`: add setup instructions, env var, and `auth.json` key.
## 7. Documentation
- `packages/ai/README.md`: add to providers table, document options/auth, add env vars.
- `packages/ai/CHANGELOG.md`: add entry under `## [Unreleased]`.
+3 -3
View File
@@ -31,7 +31,7 @@ Approval happens through maintainer replies on issues:
- `lgtmi`: your future issues will not be auto-closed - `lgtmi`: your future issues will not be auto-closed
- `lgtm`: your future issues and PRs will not be auto-closed - `lgtm`: your future issues and PRs will not be auto-closed
`lgtmi` does not grant rights to submit PRs. Only `lgtm` grants rights to submit PRs. The command must be at the start of the reply (optionally after one or more `@username` mentions) or at the end. `lgtmi` does not grant rights to submit PRs. Only `lgtm` grants rights to submit PRs.
## Quality Bar For Issues ## Quality Bar For Issues
@@ -45,7 +45,7 @@ If you open an issue, keep it short, concrete, and worth reading.
- Explain why it matters. - Explain why it matters.
- If you want to implement the change yourself, say so. - If you want to implement the change yourself, say so.
If the issue is real and written well, a maintainer may reopen it, reply `lgtmi`, or reply `lgtm`. If the issue is real and written well, a maintainer may reopen it or reply with `lgtmi` or `lgtm` in the command position described above.
## Blocking ## Blocking
@@ -55,7 +55,7 @@ If you send a large volume of issues through automation, your GitHub account wil
## Before Submitting a PR ## Before Submitting a PR
Do not open a PR unless you have already been approved with `lgtm`. Do not open a PR unless you have already been approved by a maintainer using `lgtm` in the command position described above.
Before submitting a PR: Before submitting a PR:
+2 -2
View File
@@ -67,10 +67,10 @@ GitHub releases include a versioned source archive covered by the release's `SHA
VERSION="<release-version>" VERSION="<release-version>"
tar -xzf "pi-${VERSION}-source.tar.gz" tar -xzf "pi-${VERSION}-source.tar.gz"
cd "pi-${VERSION}" cd "pi-${VERSION}"
./scripts/build-binaries.sh --platform linux-x64 --out "$PWD/out" ./scripts/build-binaries.sh --offline-model-data --platform linux-x64 --out "$PWD/out"
``` ```
The script installs dependencies, builds the monorepo, compiles the Bun executable, and stages its runtime assets. Package maintainers who provide dependencies separately can pass `--skip-install --skip-deps`. The source archive includes the generated provider model data used for the release. `--offline-model-data` builds with that snapshot instead of refreshing it from live provider catalogs. The script still installs dependencies, builds the monorepo, compiles the Bun executable, and stages its runtime assets. Package maintainers who provide dependencies separately can pass `--skip-install --skip-deps`.
## Supply-chain hardening ## Supply-chain hardening
+376 -794
View File
File diff suppressed because it is too large Load Diff
+4 -1
View File
@@ -26,10 +26,12 @@
"check:model-data": "npm --prefix packages/ai run check:model-data", "check:model-data": "npm --prefix packages/ai run check:model-data",
"generate:model-catalog": "npm --prefix packages/ai run generate-model-catalog", "generate:model-catalog": "npm --prefix packages/ai run generate-model-catalog",
"diff:model-catalog": "node scripts/diff-model-catalog.mjs", "diff:model-catalog": "node scripts/diff-model-catalog.mjs",
"eval": "npm run eval --workspace=@earendil-works/pi-evals --",
"check:model-catalog": "node scripts/publish-model-catalog.mjs --input .artifacts/model-catalog --dry-run", "check:model-catalog": "node scripts/publish-model-catalog.mjs --input .artifacts/model-catalog --dry-run",
"profile:tui": "node scripts/profile-coding-agent-node.mjs --mode tui", "profile:tui": "node scripts/profile-coding-agent-node.mjs --mode tui",
"profile:rpc": "node scripts/profile-coding-agent-node.mjs --mode rpc", "profile:rpc": "node scripts/profile-coding-agent-node.mjs --mode rpc",
"test": "npm run test --workspaces --if-present", "test": "npm run test:scripts && npm run test --workspaces --if-present",
"test:scripts": "node --test scripts/*.test.mjs",
"version:patch": "npm version patch -ws --no-git-tag-version && node scripts/sync-versions.js && npm install --package-lock-only --ignore-scripts", "version:patch": "npm version patch -ws --no-git-tag-version && node scripts/sync-versions.js && npm install --package-lock-only --ignore-scripts",
"version:minor": "npm version minor -ws --no-git-tag-version && node scripts/sync-versions.js && npm install --package-lock-only --ignore-scripts", "version:minor": "npm version minor -ws --no-git-tag-version && node scripts/sync-versions.js && npm install --package-lock-only --ignore-scripts",
"version:major": "npm version major -ws --no-git-tag-version && node scripts/sync-versions.js && npm install --package-lock-only --ignore-scripts", "version:major": "npm version major -ws --no-git-tag-version && node scripts/sync-versions.js && npm install --package-lock-only --ignore-scripts",
@@ -63,6 +65,7 @@
}, },
"version": "0.0.3", "version": "0.0.3",
"overrides": { "overrides": {
"protobufjs": "7.6.5",
"rimraf": "6.1.2", "rimraf": "6.1.2",
"gaxios": { "gaxios": {
"rimraf": "6.1.2" "rimraf": "6.1.2"
+8
View File
@@ -2,6 +2,10 @@
## [Unreleased] ## [Unreleased]
## [0.82.1] - 2026-07-25
## [0.82.0] - 2026-07-24
### Breaking Changes ### Breaking Changes
- Replaced `AgentHarness`'s `ExecutionEnv` dependency and context-free `AgentTool` inputs with application-defined `toolContext` values and context-aware `AgentHarnessTool` definitions. - Replaced `AgentHarness`'s `ExecutionEnv` dependency and context-free `AgentTool` inputs with application-defined `toolContext` values and context-aware `AgentHarnessTool` definitions.
@@ -14,6 +18,10 @@
- Aligned harness tool path handling, edit serialization, shell output capture, explicit non-inherited environments, and cross-platform process cleanup with coding-agent behavior. - Aligned harness tool path handling, edit serialization, shell output capture, explicit non-inherited environments, and cross-platform process cleanup with coding-agent behavior.
### Fixed
- Fixed compaction and branch-summary requests to use fresh routing session IDs with prompt caching disabled where supported ([#6618](https://github.com/earendil-works/pi/pull/6618) by [@tmustier](https://github.com/tmustier)).
## [0.81.1] - 2026-07-21 ## [0.81.1] - 2026-07-21
### Added ### Added
-473
View File
@@ -1,473 +0,0 @@
# Agent Workflow: Message Handling and Response Generation
## Overview
This document explains the complete step-by-step flow of how the agent processes a user message and generates a response, from the moment a user asks "what product do you have in stock" to when the agent responds with an answer.
---
## Architecture Diagram
```
┌─────────────────────────────────────────────────────────────────────────────────────┐
│ USER INPUT │
│ "what product do you have in stock?" │
└─────────────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────────────┐
│ 1. AGENT.PROMPT() ENTRY │
│ File: packages/agent/src/agent.ts:339 │
│ ┌──────────────────────────────────────────────────────────────────────────────┐ │
│ │ • Validate no active run (throws if busy) │ │
│ │ • normalizePromptInput() converts string to AgentMessage[] │ │
│ │ • runPromptMessages() launches execution with lifecycle events │ │
│ └──────────────────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────────────┐
│ 2. AGENT LIFECYCLE INITIALIZATION │
│ File: packages/agent/src/agent.ts:398-412 │
│ ┌──────────────────────────────────────────────────────────────────────────────┐ │
│ │ Events Emitted: │ │
│ │ • agent_start │ │
│ │ • turn_start │ │
│ │ • message_start / message_end (for each prompt message) │ │
│ │ │ │
│ │ Context Snapshot Created: │ │
│ │ • systemPrompt │ │
│ │ • messages (copy) │ │
│ │ • tools (copy) │ │
│ └──────────────────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────────────┐
│ 3. AGENT LOOP STARTS │
│ File: packages/agent/src/agent-loop.ts:95 │
│ ┌──────────────────────────────────────────────────────────────────────────────┐ │
│ │ runPromptMessages() → runAgentLoop() │ │
│ │ • Prompts added to context.messages │ │
│ │ • Lifecycle events emitted for prompts │ │
│ │ • Calls runLoop() (main processing loop) │ │
│ └──────────────────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────────────┐
│ 4. LLM CALL BOUNDARY - MESSAGE TRANSFORMATION │
│ File: packages/agent/src/agent-loop.ts:281-372 (streamAssistantResponse) │
│ ┌──────────────────────────────────────────────────────────────────────────────┐ │
│ │ Step 4.1: Context Transform (optional) │ │
│ │ transformContext(messages) → transformed messages │ │
│ │ (Used for context pruning/injection) │ │
│ │ │ │
│ │ Step 4.2: LLM Conversion │ │
│ │ convertToLlm(messages) → Message[] │ │
│ │ - Filters non-LLM messages (bashExecution, branchSummary, etc.) │ │
│ │ - Converts: user → user, assistant → assistant, toolResult → toolResult │ │
│ │ │ │
│ │ Step 4.3: Build LLM Context │ │
│ │ { │ │
│ │ systemPrompt: context.systemPrompt, │ │
│ │ messages: llmMessages, │ │
│ │ tools: context.tools │ │
│ │ } │ │
│ │ │ │
│ │ Step 4.4: Resolve API Key │ │
│ │ getApiKey(model.provider) → apiKey │ │
│ │ │ │
│ │ Step 4.5: Stream Function Call │ │
│ │ streamFunction(model, llmContext, options) │ │
│ │ - Default: Models.streamSimple() from @earendil-works/pi-ai │ │
│ │ - Makes actual LLM API call │ │
│ └──────────────────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────────────┐
│ 5. LLM RESPONSE STREAMING │
│ File: packages/agent/src/agent-loop.ts:317-371 │
│ ┌──────────────────────────────────────────────────────────────────────────────┐ │
│ │ Stream yields events: │ │
│ │ • start → creates partial AssistantMessage │ │
│ │ • text_start → streaming text begins │ │
│ │ • text_delta → text chunks arrive │ │
│ │ • toolcall_start → tool call block begins │ │
│ │ • toolcall_delta → tool call arguments arrive │ │
│ │ • toolcall_end → tool call block complete │ │
│ │ • text_end → text block complete │ │
│ │ • done → final message complete │ │
│ │ │ │
│ │ State Updates: │ │
│ │ • Partial message pushed to context.messages │ │
│ │ • message_start event emitted │ │
│ │ • message_update events emitted as text/tools stream in │ │
│ │ • Final message committed to context.messages │ │
│ │ • message_end event emitted │ │
│ │ │ │
│ │ Stop Reasons: │ │
│ │ • stop - normal completion │ │
│ │ • toolUse - model requested tool calls │ │
│ │ • length - token limit reached │ │
│ │ • error - failure │ │
│ │ • aborted - operation aborted │ │
│ └──────────────────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────────────┐
│ 6. TOOL CALL PARSING AND EXECUTION │
│ File: packages/agent/src/agent-loop.ts:408-554 │
│ ┌──────────────────────────────────────────────────────────────────────────────┐ │
│ │ Step 6.1: Extract Tool Calls │ │
│ │ toolCalls = message.content.filter(c => c.type === "toolCall") │ │
│ │ │ │
│ │ Step 6.2: Determine Execution Mode │ │
│ │ - Check config.toolExecution: "parallel" or "sequential" │ │
│ │ - Check individual tool executionMode setting │ │
│ │ - Decides how to execute tool batch │ │
│ │ │ │
│ │ Step 6.3: For Each Tool Call │ │
│ │ ┌──────────────────────────────────────────────────────────────────────┐ │ │
│ │ │ 1. Tool Lookup │ │ │
│ │ │ tool = context.tools.find(t => t.name === toolCall.name) │ │ │
│ │ │ │ │ │
│ │ │ 2. Argument Preparation │ │ │
│ │ │ prepared = tool.prepareArguments?(toolCall.arguments) │ │ │
│ │ │ │ │ │
│ │ │ 3. Argument Validation │ │ │
│ │ │ validateToolArguments(tool, preparedToolCall) │ │ │
│ │ │ │ │ │
│ │ │ 4. Before Tool Hook │ │ │
│ │ │ beforeToolCall({ assistantMessage, toolCall, args, context }) │ │ │
│ │ │ - Can block execution by returning { block: true, reason } │ │ │
│ │ │ │ │ │
│ │ │ 5. Execution │ │ │
│ │ │ execute(toolCallId, params, signal, onUpdate, context) │ │ │
│ │ │ │ │ │
│ │ │ - Parallel Mode: Tools execute concurrently │ │ │
│ │ │ - Sequential Mode: Tools execute one-by-one │ │ │
│ │ └──────────────────────────────────────────────────────────────────────┘ │ │
│ └──────────────────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────────────┐
│ 7. TOOL EXECUTION EXAMPLE - READ TOOL │
│ File: packages/agent/src/harness/tools/read.ts │
│ ┌──────────────────────────────────────────────────────────────────────────────┐ │
│ │ User asks: "what product do you have in stock?" │ │
│ │ │ │
│ │ Agent decides to read catalog file "products.json" │ │
│ │ │ │
│ │ Input Arguments: │ │
│ │ { │ │
│ │ "path": "products.json", │ │
│ │ "offset": 1, │ │
│ │ "limit": 100 │ │
│ │ } │ │
│ │ │ │
│ │ Execution Steps: │ │
│ │ 1. resolveReadToolPath(env, path, signal) → absolutePath │ │
│ │ 2. env.readBinaryFile(absolutePath, signal) → bytes │ │
│ │ 3. Detect mimeType (check if image) │ │
│ │ 4. For text files: │ │
│ │ - Decode UTF-8 → textContent │ │
│ │ - Split by lines → allLines │ │
│ │ - Apply offset/limit slicing │ │
│ │ - Truncate if exceeds DEFAULT_MAX_BYTES or DEFAULT_MAX_LINES │ │
│ │ - Add truncation notice to output │ │
│ │ 5. Return result: │ │
│ │ { │ │
│ │ content: [{ type: "text", text: output }], │ │
│ │ details: { truncation: ... } │ │
│ │ } │ │
│ │ │ │
│ │ Output Example: │ │
│ │ "Showing lines 1-50 of 150. [Showing 50 lines of 150. Use offset=51 to │ │
│ │ continue.]" │ │
│ │ │ │
│ │ Tool Result: │ │
│ │ { │ │
│ │ "role": "toolResult", │ │
│ │ "toolCallId": "tool_abc123", │ │
│ │ "toolName": "read", │ │
│ │ "content": [{ "type": "text", "text": "..." }], │ │
│ │ "isError": false, │ │
│ │ "timestamp": 1721721600000 │ │
│ │ } │ │
│ └──────────────────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────────────┐
│ 8. TOOL RESULT HANDLING │
│ File: packages/agent/src/agent-loop.ts:556-792 │
│ ┌──────────────────────────────────────────────────────────────────────────────┐ │
│ │ Step 8.1: After Tool Hook │ │
│ │ afterToolCall({ assistantMessage, toolCall, args, result, isError, ctx }) │ │
│ │ - Can override result content, details, usage, terminate hint │ │
│ │ │ │
│ │ Step 8.2: Create Tool Result Message │ │
│ │ { │ │
│ │ role: "toolResult", │ │
│ │ toolCallId: toolCall.id, │ │
│ │ toolName: toolCall.name, │ │
│ │ content: result.content ?? [], │ │
│ │ details: result.details, │ │
│ │ usage: result.usage, │ │
│ │ isError: false, │ │
│ │ timestamp: Date.now() │ │
│ │ } │ │
│ │ │ │
│ │ Step 8.3: Emit Events │ │
│ │ • tool_execution_start │ │
│ │ • tool_execution_end │ │
│ │ • message_start (toolResult message) │ │
│ │ • message_end (toolResult message) │ │
│ │ │ │
│ │ Step 8.4: Update Context │ │
│ │ • Push tool result message to currentContext.messages │ │
│ │ • Push to newMessages array │ │
│ │ │ │
│ │ Step 8.5: Batch Termination Check │ │
│ │ shouldTerminateToolBatch(finalizedCalls) │ │
│ │ - Returns true if ALL tools set terminate: true │ │
│ │ - If true, agent may stop after this batch │ │
│ └──────────────────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────────────┐
│ 9. NEXT TURN PREPARATION │
│ File: packages/agent/src/agent-loop.ts:224-257 │
│ ┌──────────────────────────────────────────────────────────────────────────────┐ │
│ │ Step 9.1: Turn End Event │ │
│ │ turn_end emitted with message and toolResults │ │
│ │ │ │
│ │ Step 9.2: prepareNextTurn Hook │ │
│ │ prepareNextTurn({ message, toolResults, context, newMessages }) │ │
│ │ - Can return updated context, model, or thinking level │ │
│ │ - Used for dynamic context management │ │
│ │ │ │
│ │ Step 9.3: Queue Polling │ │
│ │ getSteeringMessages() → inject messages for immediate processing │ │
│ │ getFollowUpMessages() → check for queued follow-up messages │ │
│ │ │ │
│ │ Step 9.4: Loop Decision │ │
│ │ • If steering messages exist → process them, continue loop │ │
│ │ • If follow-up messages exist → process them, continue loop │ │
│ │ • If tool calls remain in message → continue inner loop │ │
│ │ • If no messages → emit agent_end, exit loop │ │
│ └──────────────────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────────────┐
│ 10. AGENT RESPONSE GENERATION │
│ File: packages/agent/src/agent-loop.ts:58-163 │
│ ┌──────────────────────────────────────────────────────────────────────────────┐ │
│ │ The agent loop continues until: │ │
│ │ • No tool calls remain in assistant messages │ │
│ │ • No steering/follow-up messages queued │ │
│ │ • shouldStopAfterTurn() returns true (if configured) │ │
│ │ │ │
│ │ Final Response Generation: │ │
│ │ 1. LLM streams text content blocks │ │
│ │ 2. Message committed to context │ │
│ │ 3. turn_end emitted │ │
│ │ 4. agent_end emitted with all new messages │ │
│ │ 5. Agent returns to idle state │ │
│ │ │ │
│ │ Final Agent Response: │ │
│ │ "We have 15 products in stock:" │ │
│ │ • Product A - $29.99 │ │
│ │ • Product B - $49.99 │ │
│ │ • Product C - $19.99 │ │
│ │ (and 2 more products) │ │
│ └──────────────────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────────────┘
```
---
## Complete Event Flow Diagram
```
┌─────────────────────────────────────────────────────────────────────────────────────┐
│ COMPLETE EVENT SEQUENCE FOR A TURN │
│ WITH TOOL USE (Product Catalog Query) │
└─────────────────────────────────────────────────────────────────────────────────────┘
agent_start
turn_start
message_start (user message: "what product do you have in stock?")
message_end (user message)
message_start (assistant message - streaming from LLM)
message_update (text delta: "We have")
message_update (toolcall delta: {"name":"read","arguments":{...}})
message_update (toolcall end)
message_end (assistant message with tool calls)
tool_execution_start (tool call: read products.json)
tool_execution_end (tool call: read complete)
message_start (toolResult message)
message_end (toolResult message)
turn_end
[Inner loop continues: send tool result to LLM]
turn_start
message_start (assistant message - streaming from LLM)
message_update (text delta: "We have 15 products in stock:")
message_update (text delta: "• Product A - $29.99")
message_update (text delta: "• Product B - $49.99")
message_end (final assistant message)
turn_end
agent_end
```
---
## Key Files Summary
| Component | File | Purpose |
|-----------|------|---------|
| Agent class | `packages/agent/src/agent.ts` | Stateful wrapper, event emission, queue management |
| Agent loop | `packages/agent/src/agent-loop.ts` | Core loop, LLM calls, tool execution |
| Message types | `packages/agent/src/types.ts` | AgentMessage, AgentTool, AgentEvent definitions |
| Harness | `packages/agent/src/harness/agent-harness.ts` | Session integration, hooks, persistence |
| Read tool | `packages/agent/src/harness/tools/read.ts` | File reading implementation |
| Stream function | `packages/agent/src/stream-fn.ts` | Default stream function management |
| Types | `packages/agent/src/types.ts` | All type definitions |
---
## Hook Points for Customization
The agent supports multiple extension points:
| Hook | Location | Purpose |
|------|----------|---------|
| `convertToLlm` | `agent.ts:99` | Transform messages before LLM call |
| `transformContext` | `agent.ts:100` | Modify context (pruning, injection) |
| `beforeToolCall` | `agent.ts:105` | Block or modify tool execution |
| `afterToolCall` | `agent.ts:106` | Override tool results |
| `prepareNextTurn` | `agent.ts:107` | Dynamic context/model updates |
| `shouldStopAfterTurn` | `agent-loop.ts` | Graceful termination |
| `getSteeringMessages` | `agent.ts:114` | Inject messages mid-turn |
| `getFollowUpMessages` | `agent.ts:115` | Queue follow-up messages |
---
## Tool Execution Flow
```
Tool Call Received from LLM
1. Tool Lookup (find by name)
2. prepareArguments? (transform if defined)
3. validateToolArguments (JSON Schema)
4. beforeToolCall hook (can block)
5. execute (parallel or sequential)
6. onUpdate (stream partial results)
7. afterToolCall hook (can override)
8. Create toolResult message
9. Emit events (start, end)
10. Add to context.messages
```
---
## Example: "What product do you have in stock?"
### Step-by-Step Execution:
1. **User sends message**
```
"what product do you have in stock?"
```
2. **Agent normalizes input**
```typescript
[{
role: "user",
content: [{ type: "text", text: "what product do you have in stock?" }],
timestamp: Date.now()
}]
```
3. **LLM processes and decides to use `read` tool**
```json
{
"role": "assistant",
"content": [{
"type": "toolCall",
"name": "read",
"arguments": {
"path": "products.json",
"offset": 1,
"limit": 50
},
"id": "tool_abc123"
}]
}
```
4. **Tool execution**
- Read `products.json` (150 lines total)
- Return lines 1-50 with truncation notice
- Add to context as toolResult
5. **LLM generates final response**
```
We have 15 products in stock:
• Product A - $29.99
• Product B - $49.99
• Product C - $19.99
• Product D - $99.99
• Product E - $149.99
(and 10 more products)
Use offset=51 to continue viewing.
```
6. **Agent emits final response to user**
---
## Summary
The agent workflow follows a clear pattern:
1. **Message Input** → Normalize and validate
2. **Context Setup** → Create snapshot with system prompt, messages, tools
3. **LLM Call** → Transform messages, resolve API key, stream response
4. **Tool Detection** → Check for tool calls in assistant message
5. **Tool Execution** → Validate, hook, execute, stream updates
6. **Result Handling** → Create toolResult message, emit events
7. **Next Turn** → Check for steering/follow-up messages, prepare context
8. **Response Generation** → Continue until no more tool calls needed
9. **Completion** → Emit final response to user
The entire flow is event-driven, allowing for real-time updates and hook-based customization at every step.
File diff suppressed because it is too large Load Diff
-624
View File
@@ -1,624 +0,0 @@
# Memory & Context Management in Pi Agent
## 1. Architecture Overview
The agent manages memory at **two layers**:
```
┌─────────────────────────────────────────────────────────────────┐
│ Agent Harness │
│ ┌──────────────┐ ┌──────────────┐ ┌─────────────────────┐ │
│ │ Session │ │ Compaction │ │ Branch Navigation │ │
│ │ (JSONL tree)│ │ (summarize) │ │ (reset + summarize)│ │
│ └──────┬───────┘ └──────────────┘ └─────────────────────┘ │
└─────────┼───────────────────────────────────────────────────────┘
│ builds context
┌─────────▼───────────────────────────────────────────────────────┐
│ Agent Class │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ _state.messages: AgentMessage[] (linear transcript) │ │
│ │ _state.tools, systemPrompt, model │ │
│ └───────────────────────────────────────────────────────────┘ │
│ subscribe() → events → UI updates │
└─────────────────────────────────────────────────────────────────┘
```
**Key separation:**
- **In-memory** (`Agent`): linear transcript for the current run. Cleared on `reset()`.
- **On-disk** (`Session`): persistent tree of entries in JSONL files. Survives restarts.
- **Compaction**: replaces old on-disk history with an LLM-generated summary, controlling context window usage.
---
## 2. Data Flow: From Prompt to LLM Call
```
agent.prompt("Read README.md")
┌──────────────────────────┐
│ normalizePromptInput() │ → { role: "user", content: "..." }
└──────────┬───────────────┘
┌──────────────────────────┐
│ runWithLifecycle() │ → sets isStreaming=true
│ runAgentLoop() │
└──────────┬───────────────┘
┌─────────────────────────────────────────────────────────────┐
│ runLoop() — the main while(true) loop │
│ │
│ Inner loop: │
│ 1. Inject steering/follow-up messages │
│ 2. streamAssistantResponse() │
│ ┌─────────────────────────────────────────────┐ │
│ │ transformContext() │ │
│ │ AgentMessage[] → AgentMessage[] │ │
│ │ (prune, inject external context) │ │
│ └──────────────┬──────────────────────────────┘ │
│ ▼ │
│ ┌─────────────────────────────────────────────┐ │
│ │ convertToLlm() │ │
│ │ AgentMessage[] → Message[] │ │
│ │ (filter to user/assistant/toolResult only) │ │
│ └──────────────┬──────────────────────────────┘ │
│ ▼ │
│ ┌─────────────────────────────────────────────┐ │
│ │ streamFunction() → LLM provider │ │
│ │ { systemPrompt, messages, tools } │ │
│ └──────────────┬──────────────────────────────┘ │
│ ▼ │
│ Stream events: start → delta* → done │
│ ▼ │
│ Return AssistantMessage │
│ 3. Extract toolCall blocks │
│ 4. If toolCalls: executeToolCalls() │
│ → create toolResult messages │
│ → append to context │
│ 5. Check shouldStopAfterTurn / prepareNextTurn │
│ 6. Check steering/follow-up queues │
│ → Loop if more tool calls or queued messages │
│ │
│ Outer loop: │
│ → Check follow-up queue for messages after agent would │
│ stop │
└─────────────────────────────────────────────────────────────┘
```
---
## 3. Context Building (On-Disk → In-Memory)
The `AgentHarness` bridges on-disk session data to the in-memory agent loop.
```
session.buildContext()
┌─────────────────────────────────────────────────────────────┐
│ getBranch() — walk from leaf → root via parentId │
│ │
│ Tree structure: │
│ │
│ ┌──────┐ ┌──────┐ ┌──────────┐ ┌──────────┐ │
│ │msg 1 │───▶│msg 2 │───▶│ msg 3 │───▶│ msg 4 │ │
│ │user │ │assist│ │ toolCall │ │ user │ │
│ └──────┘ └──────┘ └──────────┘ └──────────┘ │
│ │
│ Path to root: [msg1, msg2, msg3, msg4] │
└──────────┬────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ defaultContextEntryTransform() — THE KEY STEP │
│ │
│ Finds latest "compaction" entry in path: │
│ │
│ ┌──────┐ ┌──────────┐ ┌──────┐ ┌──────┐ │
│ │msg 1 │ │compaction│ │msg 3 │ │msg 4 │ │
│ │user │ │summary X │ │msg 2 │ │assist│ │
│ └──────┘ └──────────┘ └──────┘ └──────┘ │
│ │ │ │ │ │
│ ├──────────────┤ │ │ │
│ │ SKIPPED │ │ │ │
│ │ (summarized)│ │ │ │
│ └──────────────┼───────────┘ │ │
│ ▼ ▼ │
│ Include compaction entry Include entries after │
│ + firstKeptEntryId the compaction point │
│ │
│ Result: [compaction, msg3, msg4] │
│ → compaction entry becomes a "compactionSummary" message │
└──────────┬────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ sessionEntryToContextMessages() │
│ │
│ For each entry: │
│ message → [message] │
│ compaction → [compactionSummary, ...retainedTail] │
│ branch_summary → [branchSummaryMessage] │
│ custom_message → [customMessage] │
│ other → [] (omitted from LLM context) │
│ │
│ Flat result: [compactionSummary, msg3, msg4] │
└──────────┬────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ deriveSessionContextState() │
│ │
│ Extracts from entries: │
│ thinkingLevel ← latest thinking_level_change or assistant │
│ model ← latest model_change or assistant │
│ activeTools ← latest active_tools_change │
└─────────────────────────────────────────────────────────────┘
```
---
## 4. Token Estimation
Before compaction can decide whether to trigger, it needs to know how many tokens the context uses.
```
estimateContextTokens(messages)
├── Has provider-reported usage on last assistant msg?
│ ├── YES → use actual usage + estimate tail
│ │ (accurate — avoids compounding error)
│ │
│ └── NO → estimate all messages from scratch
estimateTokens(message) [character heuristic: chars / 4]
├── role === "user"
│ content.length / 4
│ (images ≈ 4800 chars each)
├── role === "assistant"
│ sum of all content blocks:
│ text blocks → text.length
│ thinking blocks → thinking.length
│ toolCall blocks → name.length + JSON.stringify(args).length
├── role === "toolResult" / "custom"
│ content.length / 4
├── role === "bashExecution"
│ (command.length + output.length) / 4
└── role === "compactionSummary" / "branchSummary"
summary.length / 4
```
**Why `chars / 4`?** Rough heuristic: ~4 ASCII characters ≈ 1 token. Conservative estimate to avoid under-counting.
---
## 5. Compaction — The Core Memory Management
### 5.1 Trigger Condition
```
shouldCompact(contextTokens, contextWindow, settings)
→ contextTokens > contextWindow - reserveTokens
Defaults:
reserveTokens: 16384 (~16K tokens for summary prompt + output)
keepRecentTokens: 20000 (~20K tokens of recent history to keep)
Example (Claude with 200K context window):
Triggers when: contextTokens > 200000 - 16384 = 183616
```
### 5.2 Finding the Cut Point
```
findCutPoint(entries, startIndex, endIndex, keepRecentTokens)
│ Walk BACKWARD from endIndex
├── Accumulate estimated tokens per message
├── Stop when accumulated ≥ keepRecentTokens
├── Snap to nearest valid cut point
┌─────────────────────────────────────────────────────────────┐
│ Valid cut points (safe to split): │
│ - user message │
│ - assistant message │
│ - custom message │
│ - branch_summary │
│ │
│ NOT valid (tool results stay with their call): │
│ - toolResult message (skipped) │
│ │
│ Example: │
│ │
│ ┌────┐ ┌────┐ ┌────┐ ┌────┐ ┌────┐ ┌────┐ ┌────┐ │
│ │ u1 │ │ a1 │ │ tr1│ │ u2 │ │ a2 │ │ tr2│ │ u3 │ │
│ └────┘ └────┘ └────┘ └────┘ └────┘ └────┘ └────┘ │
│ ▲ ▲ ▲ │
│ │ │ │ │
│ └── kept └── cut └── discarded │
│ (~20K tokens) point history │
└─────────────────────────────────────────────────────────────┘
```
### 5.3 The Compaction Process
```
prepareCompaction(branchEntries, settings)
├── Find previous compaction (if any) → previousSummary
├── Estimate tokens of current context
├── findCutPoint() → firstKeptEntryId
├── Split into 3 groups:
│ │
│ ├── messagesToSummarize: entries BEFORE cut point
│ │ (these become the summary)
│ │
│ ├── retainedTail: entries AFTER cut point
│ │ (these stay verbatim)
│ │
│ └── turnPrefixMessages: if cut splits a turn
│ (the beginning of an interrupted turn)
└── Extract file operations from messagesToSummarize:
→ readFiles, modifiedFiles
compact(preparation, model, models)
├── If isSplitTurn:
│ ├── generateSummary(messagesToSummarize) → history summary
│ ├── generateTurnPrefixSummary(turnPrefixMessages) → turn context
│ └── Combine: history + "---" + turn prefix
├── Else (normal):
│ ├── Has previousSummary?
│ │ ├── YES → UPDATE_SUMMARIZATION_PROMPT (iterative)
│ │ └── NO → SUMMARIZATION_PROMPT (fresh)
│ └── Call LLM with conversation text + prompt
├── Append file operations:
│ "Files read: [...]\nFiles modified: [...]"
└── Return:
{
summary: "## Goal...\n## Progress...\n...",
firstKeptEntryId: "entry-uuid",
tokensBefore: 185000,
retainedTail: [msg3, msg4, ...],
details: { readFiles: [...], modifiedFiles: [...] }
}
```
### 5.4 Summary Format
```
The LLM generates a structured summary:
## Goal
- [What is the user trying to accomplish?]
## Constraints & Preferences
- [Any constraints, preferences, or requirements]
## Progress
### Done
- [x] [Completed tasks]
### In Progress
- [ ] [Current work]
### Blocked
- [Issues preventing progress]
## Key Decisions
- **[Decision]**: [Brief rationale]
## Next Steps
1. [Ordered list of what should happen next]
## Critical Context
- [Any data, examples, or references needed to continue]
Files read: [src/index.ts, package.json]
Files modified: [src/index.ts]
```
### 5.5 Iterative Compaction
Successive compact calls **update** the existing summary rather than replacing it:
```
Compaction 1 (at ~185K tokens):
Summary: "## Goal: Build a login page..."
firstKeptEntryId: "entry-003"
Compaction 2 (at ~185K tokens again):
previousSummary: "## Goal: Build a login page..."
→ UPDATE_SUMMARIZATION_PROMPT
→ PRESERVES existing information
→ ADDS new progress (move "In Progress" → "Done")
→ NEW summary: "## Goal: Build a login page... Add OAuth..."
firstKeptEntryId: "entry-003" (same boundary)
```
---
## 6. Session Storage — JSONL Format
```
Session file: .pi/sessions/--home-user--/2024-01-15T10-30-00_abc123.jsonl
Line 1 (header):
{"type":"session","version":3,"id":"abc123","timestamp":"2024-01-15T10:30:00.000Z",
"cwd":"/home/user/project","parentSession":"...","metadata":{}}
Line 2+ (entries, one per line):
{"type":"message","id":"e001","parentId":null,"timestamp":"...","message":{...}}
{"type":"message","id":"e002","parentId":"e001","timestamp":"...","message":{...}}
{"type":"compaction","id":"e003","parentId":"e002","timestamp":"...",
"summary":"## Goal: ...\n...","firstKeptEntryId":"e001",
"tokensBefore":185000}
{"type":"leaf","id":"e004","parentId":"e003","timestamp":"...",
"targetId":"e002"}
```
### Entry Types
| Type | LLM Context? | Purpose |
|------|-------------|---------|
| `message` | Yes | User, assistant, toolResult |
| `compaction` | Yes (as summary message) | Replaces compacted history |
| `branch_summary` | Yes (as summary message) | Summary of diverged branch |
| `leaf` | No | Points to current tree leaf |
| `thinking_level_change` | No | Tracking thinking level changes |
| `model_change` | No | Tracking model changes |
| `active_tools_change` | No | Tracking tool enable/disable |
| `custom` | No (unless projector configured) | App-defined data |
| `custom_message` | Yes | App-defined messages |
| `label` | No | Human-readable labels |
| `session_info` | No | Session name history |
---
## 7. Session Tree (Branching)
Sessions form a **tree**, not a linear log. This lets users "go back" and try a different approach.
```
Session tree:
┌───[e01]───┐
│ user: "a" │
└─────┬──────┘
┌──────────┐
│ assist 1 │
└─────┬────┘
┌──────────┐
│ toolCall │
└─────┬────┘
┌──────────┐
│ toolRes 1│
└─────┬────┘
┌──────────┐
│ user: "b"│ ← user goes back here
└─────┬────┘
┌─────┴─────┐
│ │
┌──────────┐ ┌──────────┐
│ user: "c" │ │ user: "d" │ ← branch point
└────┬─────┘ └────┬─────┘
│ │
┌────▼─────┐ ┌────▼─────┐
│ assist 2 │ │ assist 3 │ ← current leaf (d)
└──────────┘ └──────────┘
When user navigates to "user: b":
- Leaf moves from "d" back to "b"
- Branch summary generated for diverged work ("c" → "assist 2")
- New work branches from "b":
┌─────┐
│ user: "e" │ ← new branch
└─────┬─────┘
┌──────────┐
│ assist 4 │
└──────────┘
Context sent to LLM:
[compaction summary, user:b, user:e, assist:4]
→ The old "c"/"assist 2" branch is replaced by its summary
```
---
## 8. Pending Writes — Batching Session Persistence
To avoid writing every message individually during a run:
```
Agent loop events
handleAgentEvent(event)
├── message_end → pendingSessionWrites.push({ type: "message", message })
├── turn_end → flushPendingSessionWrites() (save_point)
├── agent_end → flushPendingSessionWrites()
flushPendingSessionWrites()
├── Iterate pendingSessionWrites[]
│ ├── message → session.appendMessage(msg)
│ ├── model_change → session.appendModelChange(provider, id)
│ ├── thinking_level_change → session.appendThinkingLevelChange(level)
│ ├── active_tools_change → session.appendActiveToolsChange(names)
│ ├── custom → session.appendCustomEntry(type, data)
│ ├── custom_message → session.appendCustomMessageEntry(...)
│ ├── label → session.appendLabel(targetId, label)
│ ├── session_info → session.appendSessionName(name)
│ └── leaf → session.getStorage().setLeafId(targetId)
└── Shift all writes → empty pending list
```
During the run, messages are accumulated in `pendingSessionWrites` and only flushed to disk at `save_point` (end of each turn) or `agent_end`.
---
## 9. Hooks & Extensibility — Context Control Points
The harness exposes hooks at every memory management boundary:
```
┌────────────────────────────────────────────────────────────────┐
│ Hooks │
├────────────────────────┬─────────────────────────────────────┤
│ Hook │ When │
├────────────────────────┼─────────────────────────────────────┤
│ before_agent_start │ Before each prompt, can add │
│ │ messages or modify system prompt │
├────────────────────────┼─────────────────────────────────────┤
│ context │ Before each LLM call, can prune/ │
│ │ modify AgentMessage[] │
├────────────────────────┼─────────────────────────────────────┤
│ before_provider_request│ Before each provider API call, can │
│ │ modify headers, retries, timeout │
├────────────────────────┼─────────────────────────────────────┤
│ before_provider_payload│ Before sending payload to provider,│
│ │ can modify the request body │
├────────────────────────┼─────────────────────────────────────┤
│ after_provider_response│ After receiving response, for │
│ │ logging/metrics │
├────────────────────────┼─────────────────────────────────────┤
│ tool_call │ Before tool execution, can │
│ │ return { block: true } │
├────────────────────────┼─────────────────────────────────────┤
│ tool_result │ After tool execution, can override │
│ │ content, details, isError, terminate│
├────────────────────────┼─────────────────────────────────────┤
│ session_before_compact│ Before compaction, can cancel or │
│ │ provide custom compaction result │
├────────────────────────┼─────────────────────────────────────┤
│ session_before_tree │ Before branch navigation, can │
│ │ cancel or provide custom summary │
├────────────────────────┼─────────────────────────────────────┤
│ prepareNextTurn │ Between turns, can replace context, │
│ │ model, or thinkingLevel │
├────────────────────────┼─────────────────────────────────────┤
│ shouldStopAfterTurn │ After a turn, if true the loop │
│ │ exits (agent_end, no more LLM calls)│
└────────────────────────┴─────────────────────────────────────┘
```
---
## 10. Complete Lifecycle: Long Session
```
Session starts empty
Turn 1: "Create a React component"
Context: [compaction summary (empty)]
Messages exchanged: ~2K tokens
└─ Session: [user1, assist1, toolCall, toolRes1, assist2]
Turn 2-10: Iterative development
Context: growing with each turn
Total context: ~50K tokens
└─ Session: [user1..assist2, user2..assist20]
Turn 15: Context approaching limit (~170K tokens)
shouldCompact() → true
└─ Compaction 1:
- Summarizes turns 1-12
- Keeps turns 13-15 verbatim
- Summary: "## Goal: React component ## Progress: built X, Y"
Turn 20: Context ~180K tokens
shouldCompact() → true
└─ Compaction 2 (iterative update):
- Updates existing summary with new progress
- "## Goal: React component ## Done: built X,Y ## New: added auth"
Turn 25: Context ~186K tokens → triggers compaction
└─ Compaction 3:
- Summary now covers ~22 turns of history
- Retained tail: last 20K tokens (~5 turns)
- Context window freed: ~186K → ~25K tokens
User navigates to Turn 8:
- Branch summary generated for Turns 9-25
- Leaf moves back to Turn 8
- Context: [compaction, branch_summary, turns 1-8]
User continues from Turn 8:
- New branch grows from Turn 8
- Old branch (9-25) replaced by branch_summary
Session ends, JSONL file persists on disk
Next session: loads from JSONL, rebuilds context
```
---
## 11. Token Budget Summary
```
Example: Claude Sonnet (200K context window)
┌─────────────────────────────────────────────────────────────┐
│ Context Window: 200,000 tokens │
├─────────────────────────────────────────────────────────────┤
│ Reserved for summary: 16,384 tokens │
├─────────────────────────────────────────────────────────────┤
│ Keep recent: 20,000 tokens │
├─────────────────────────────────────────────────────────────┤
│ Max context before compaction: 183,616 tokens │
│ (= 200000 - 16384) │
├─────────────────────────────────────────────────────────────┤
│ After compaction: ~25,000 tokens │
│ (20,000 tail + ~5,000 summary) │
│ → ~158,616 tokens freed │
└─────────────────────────────────────────────────────────────┘
```
---
## 12. Key Files Reference
| File | Responsibility |
|------|---------------|
| `agent-loop.ts` | Core loop, tool execution, streaming |
| `agent.ts` | Stateful `Agent` class, event system |
| `agent-harness.ts` | High-level harness, hooks, session management |
| `compaction/compaction.ts` | Token estimation, cut point, LLM summarization |
| `compaction/branch-summarization.ts` | Branch divergence summarization |
| `session/session.ts` | Session tree, entry appending, context building |
| `session/jsonl-storage.ts` | JSONL file read/write |
| `session/jsonl-repo.ts` | Session repo: create/open/list/delete/fork |
| `types.ts` | All type definitions |
| `messages.ts` | `convertToLlm()`, custom message helpers |
| `system-prompt.ts` | System prompt building |
| `skills.ts` | Skill management |
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@earendil-works/pi-agent-core", "name": "@earendil-works/pi-agent-core",
"version": "0.81.1", "version": "0.82.1",
"description": "General-purpose agent with transport abstraction, state management, and attachment support", "description": "General-purpose agent with transport abstraction, state management, and attachment support",
"type": "module", "type": "module",
"main": "./dist/index.js", "main": "./dist/index.js",
@@ -29,7 +29,7 @@
"prepublishOnly": "npm run build" "prepublishOnly": "npm run build"
}, },
"dependencies": { "dependencies": {
"@earendil-works/pi-ai": "^0.81.1", "@earendil-works/pi-ai": "^0.82.1",
"diff": "8.0.4", "diff": "8.0.4",
"ignore": "7.0.5", "ignore": "7.0.5",
"typebox": "1.1.38", "typebox": "1.1.38",
@@ -11,6 +11,7 @@ import {
type SimpleStreamOptions, type SimpleStreamOptions,
type TextContent, type TextContent,
type Usage, type Usage,
uuidv7,
} from "@earendil-works/pi-ai"; } from "@earendil-works/pi-ai";
import type { AgentMessage, ThinkingLevel } from "../../types.ts"; import type { AgentMessage, ThinkingLevel } from "../../types.ts";
import { import {
@@ -122,7 +123,18 @@ export async function completeSimpleWithRetries(
retry?: RetryPolicy, retry?: RetryPolicy,
callbacks?: RetryCallbacks, callbacks?: RetryCallbacks,
): Promise<AssistantMessage> { ): Promise<AssistantMessage> {
return retryAssistantCall(() => models.completeSimple(model, context, options), retry, options.signal, callbacks); // Summaries are standalone requests, so isolate routing and avoid cache writes that cannot be reused.
const requestOptions: SimpleStreamOptions = {
...options,
cacheRetention: "none",
sessionId: uuidv7(),
};
return retryAssistantCall(
() => models.completeSimple(model, context, requestOptions),
retry,
requestOptions.signal,
callbacks,
);
} }
function combineUsage(first: Usage, second: Usage): Usage { function combineUsage(first: Usage, second: Usage): Usage {
@@ -605,6 +605,9 @@ describe("harness compaction", () => {
getOrThrow(await compact(preparation, models, model)); getOrThrow(await compact(preparation, models, model));
expect(seenOptions.map((options) => options?.maxTokens)).toEqual([128000, 128000]); expect(seenOptions.map((options) => options?.maxTokens)).toEqual([128000, 128000]);
expect(seenOptions.map((options) => options?.cacheRetention)).toEqual(["none", "none"]);
const sessionIds = seenOptions.map((options) => options?.sessionId);
expect(sessionIds[0]).not.toBe(sessionIds[1]);
}); });
it("returns compaction error results without throwing", async () => { it("returns compaction error results without throwing", async () => {
+37 -1
View File
@@ -2,8 +2,44 @@
## [Unreleased] ## [Unreleased]
## [0.82.1] - 2026-07-25
### Added
- Added `ModelsStoreEntry.etag` so persisted provider catalogs can carry the remote ETag validator for conditional refreshes.
- Added `ANTHROPIC_AUTH_TOKEN` bearer authentication for Anthropic-compatible gateways ([#5871](https://github.com/earendil-works/pi/issues/5871))
- Added Claude Opus 5 support for Anthropic and Amazon Bedrock with adaptive thinking, inference profiles, prompt caching, and preserved AWS validation messages ([#7081](https://github.com/earendil-works/pi/pull/7081) by [@unexge](https://github.com/unexge), [#7083](https://github.com/earendil-works/pi/pull/7083) by [@davidbrai](https://github.com/davidbrai)).
### Changed
- Changed Radius OAuth device authorization, token exchange, and refresh requests to use the configured gateway directly.
- Changed `ModelsError` messages to append the underlying cause, so auth failures such as `OAuth refresh failed for openai-codex` report the provider response instead of a bare wrapper message.
## [0.82.0] - 2026-07-24
### Breaking Changes
- Replaced `getBuiltinModelDataUrl(provider)` with `getBuiltinModelDataGeneratedAt()` so built-in catalog freshness uses its recorded generation time instead of installation-dependent file metadata ([#7016](https://github.com/earendil-works/pi/pull/7016) by [@davidbrai](https://github.com/davidbrai)).
### Added
- Added Kimi Code subscription OAuth login for the `kimi-coding` provider, with device authorization, token refresh, and OAuth host overrides ([#6935](https://github.com/earendil-works/pi/pull/6935) by [@zaycruz](https://github.com/zaycruz)).
- Added OpenRouter OAuth PKCE login that mints a user-controlled API key for chat and image providers ([#6927](https://github.com/earendil-works/pi/pull/6927) by [@rsaryev](https://github.com/rsaryev)).
- Added `Tool.constrainedSampling` with strict JSON Schema (`prefer`/`require`) and OpenAI Lark/regex grammar variants, enforcing provider-side constrained tool sampling across OpenAI, Anthropic, Amazon Bedrock, Google Gemini, and Mistral. See [Constrained Sampling for Tools](README.md#constrained-sampling-for-tools).
- Added `supportsGrammarTools` and `supportsStrictTools` compatibility flags, expanded `supportsStrictMode` to Responses and Bedrock models, and generated model capability metadata to gate constrained sampling.
### Changed
- Changed generated model catalogs to expose only provider-verified reasoning effort levels from models.dev ([#6928](https://github.com/earendil-works/pi/pull/6928) by [@davidbrai](https://github.com/davidbrai)).
### Fixed ### Fixed
- Fixed OpenAI Codex cached WebSocket continuations after grammar tool calls to send only the real tool-result delta.
- Fixed constrained tool sampling across Google, Amazon Bedrock, Mistral, and Azure OpenAI Responses adapters, including model-aware strict-tool capabilities, grammar configuration validation, and malformed grammar-call replay errors.
- Fixed `cacheRetention: "none"` to disable implicit prompt-cache writes for supported OpenAI models and session-based caching for OpenAI Codex ([#6618](https://github.com/earendil-works/pi/pull/6618) by [@tmustier](https://github.com/tmustier)).
- Fixed DNS lookup failures such as `getaddrinfo`, `ENOTFOUND`, and `EAI_AGAIN` to trigger automatic assistant retries ([#6946](https://github.com/earendil-works/pi/pull/6946) by [@christianklotz](https://github.com/christianklotz)).
- Fixed OpenAI Codex WebSocket sessions to retry once without a missing previous-response continuation after `previous_response_not_found` errors ([#6955](https://github.com/earendil-works/pi/pull/6955) by [@davidbrai](https://github.com/davidbrai)).
- Fixed OpenAI and Anthropic provider retry waits to honor abort signals and configured delay limits ([#6980](https://github.com/earendil-works/pi/pull/6980) by [@petrroll](https://github.com/petrroll)).
- Fixed OpenRouter Anthropic cache breakpoints to advance through tool results and enabled cache control for `~anthropic/*-latest` aliases ([#6941](https://github.com/earendil-works/pi/pull/6941) by [@mteam88](https://github.com/mteam88)). - Fixed OpenRouter Anthropic cache breakpoints to advance through tool results and enabled cache control for `~anthropic/*-latest` aliases ([#6941](https://github.com/earendil-works/pi/pull/6941) by [@mteam88](https://github.com/mteam88)).
## [0.81.1] - 2026-07-21 ## [0.81.1] - 2026-07-21
@@ -24,7 +60,6 @@
- Added `contentText` for extracting joined text from message content ([#6840](https://github.com/earendil-works/pi/pull/6840) by [@xl0](https://github.com/xl0)). - Added `contentText` for extracting joined text from message content ([#6840](https://github.com/earendil-works/pi/pull/6840) by [@xl0](https://github.com/xl0)).
- Added a shared `uuidv7` utility for time-ordered identifiers ([#6834](https://github.com/earendil-works/pi/pull/6834) by [@xl0](https://github.com/xl0)). - Added a shared `uuidv7` utility for time-ordered identifiers ([#6834](https://github.com/earendil-works/pi/pull/6834) by [@xl0](https://github.com/xl0)).
- Added optional usage metadata to tool result messages ([#6671](https://github.com/earendil-works/pi/pull/6671) by [@davidbrai](https://github.com/davidbrai)). - Added optional usage metadata to tool result messages ([#6671](https://github.com/earendil-works/pi/pull/6671) by [@davidbrai](https://github.com/davidbrai)).
- Added Kimi Code subscription OAuth login (device authorization grant) for the `kimi-coding` provider, with token refresh and `KIMI_CODE_OAUTH_HOST`/`KIMI_OAUTH_HOST` host overrides.
### Changed ### Changed
@@ -167,6 +202,7 @@
### Added ### Added
- Added OpenAI GPT-5.6 model metadata for `gpt-5.6`, `gpt-5.6-sol`, `gpt-5.6-terra`, and `gpt-5.6-luna`, plus verified `openai-codex` support for `gpt-5.6-sol`, `gpt-5.6-terra`, and `gpt-5.6-luna`. - Added OpenAI GPT-5.6 model metadata for `gpt-5.6`, `gpt-5.6-sol`, `gpt-5.6-terra`, and `gpt-5.6-luna`, plus verified `openai-codex` support for `gpt-5.6-sol`, `gpt-5.6-terra`, and `gpt-5.6-luna`.
- Added provider-side constrained sampling for tools via `Tool.constrainedSampling`: strict JSON-schema enforcement for OpenAI and Anthropic tool calls, and OpenAI custom grammar tools (Lark/regex). Grammar tool capability comes from the model catalog's `supportsGrammarTools` compat flag, enabled for GPT-5+ models on OpenAI, OpenAI Codex, Azure OpenAI, GitHub Copilot, opencode, and Cloudflare AI Gateway ([#6341](https://github.com/earendil-works/pi/pull/6341)).
- Refreshed generated model catalogs from models.dev, adding newly listed models including Kimi K2.7 Code for GitHub Copilot and Fable 5 to several providers ([#6256](https://github.com/earendil-works/pi/issues/6256)). - Refreshed generated model catalogs from models.dev, adding newly listed models including Kimi K2.7 Code for GitHub Copilot and Fable 5 to several providers ([#6256](https://github.com/earendil-works/pi/issues/6256)).
- Added Claude Sonnet 5 to the GitHub Copilot model catalog ([#6200](https://github.com/earendil-works/pi/issues/6200)). - Added Claude Sonnet 5 to the GitHub Copilot model catalog ([#6200](https://github.com/earendil-works/pi/issues/6200)).
- Added zstd request-body compression for the OpenAI Codex Responses SSE transport. Requests are sent with `Content-Encoding: zstd` when Node/Bun zstd support is available; the WebSocket transport is unchanged. - Added zstd request-body compression for the OpenAI Codex Responses SSE transport. Requests are sent with `Content-Encoding: zstd` when Node/Bun zstd support is available; the WebSocket transport is unchanged.
+38 -1
View File
@@ -478,6 +478,40 @@ const bookMeetingTool: Tool = {
}; };
``` ```
### Constrained Sampling for Tools
Tools can opt in to provider-side constrained sampling. For JSON-schema tools, `strict: 'prefer'` uses provider-side strict schema enforcement when supported and otherwise falls back to normal tool calling. `strict: 'require'` fails the request when the active provider/model cannot honor it. Set `constrainedSampling: false` to explicitly opt out; it behaves the same as omitting the field.
```typescript
const strictTool: Tool = {
name: 'edit_file',
description: 'Edit a file',
parameters: Type.Object({
path: Type.String(),
content: Type.String()
}, { additionalProperties: false }),
constrainedSampling: { type: 'json_schema', strict: 'prefer' }
};
```
Strict JSON-schema constrained sampling is supported for OpenAI, Anthropic, supported Amazon Bedrock Converse models, Mistral, and Gemini 3 tool calls through the Google Generative AI and Vertex adapters. Google uses `VALIDATED` function-calling mode (or `ANY` when explicitly requested); earlier Gemini versions fall back for `strict: 'prefer'` and reject `strict: 'require'` because they do not enforce required parameters. Bedrock strict-tool capability is generated from model structured-output metadata; custom Bedrock models can override `compat.supportsStrictMode`. OpenAI Responses and Chat Completions can also emit grammar-constrained custom tools with OpenAI Lark or regex grammar variants. If multiple OpenAI variants are supplied, Lark is preferred over regex. Grammar constraints are enforced when the active model supports grammar tools; otherwise the tool falls back to normal function/JSON-schema handling. Grammar tool capability is model metadata: the generated catalog sets `compat.supportsOpenAIGrammarTools` for GPT-5+ models on endpoints that pass OpenAI custom tools through (OpenAI, OpenAI Codex, Azure OpenAI Responses, GitHub Copilot, opencode, and Cloudflare AI Gateway). OpenAI rejects `type: "custom"` tools for pre-GPT-5 models, and gateways that normalize tool schemas (e.g. OpenRouter) mangle them, so the flag stays off elsewhere. Custom model definitions can opt in via `compat`. Grammar-capable models reject grammar configurations without a non-empty supported variant. Native grammar tools must have an object parameter schema with exactly one required string property:
```typescript
const patchTool: Tool = {
name: 'apply_patch',
description: 'Apply a patch',
parameters: Type.Object({
input: Type.String()
}, { additionalProperties: false }),
constrainedSampling: {
type: 'grammar',
variants: {
openai_lark: 'start: /.+/s'
}
}
};
```
### Handling Tool Calls ### Handling Tool Calls
Tool results use content blocks and can include both text and images: Tool results use content blocks and can include both text and images:
@@ -1124,6 +1158,7 @@ interface OpenAICompletionsCompat {
supportsReasoningEffort?: boolean; // Whether provider supports `reasoning_effort` (default: true) supportsReasoningEffort?: boolean; // Whether provider supports `reasoning_effort` (default: true)
supportsUsageInStreaming?: boolean; // Whether provider supports `stream_options: { include_usage: true }` (default: true) supportsUsageInStreaming?: boolean; // Whether provider supports `stream_options: { include_usage: true }` (default: true)
supportsStrictMode?: boolean; // Whether provider supports `strict` in tool definitions (default: true) supportsStrictMode?: boolean; // Whether provider supports `strict` in tool definitions (default: true)
supportsOpenAIGrammarTools?: boolean; // Whether to emit OpenAI custom Lark/regex grammar tools; false falls back to normal function tools (default: false; the generated catalog enables it for capable models)
sendSessionAffinityHeaders?: boolean; // Send session-affinity data from `sessionId` (default: false) sendSessionAffinityHeaders?: boolean; // Send session-affinity data from `sessionId` (default: false)
sessionAffinityFormat?: 'openai' | 'openai-nosession' | 'openrouter'; // Format for session affinity: 'openai' uses `prompt_cache_key`, `session_id`, `x-client-request-id`, and `x-session-affinity`; 'openai-nosession' uses `prompt_cache_key`, `x-client-request-id`, and `x-session-affinity`; 'openrouter' uses `x-session-id` (default: auto-detected) sessionAffinityFormat?: 'openai' | 'openai-nosession' | 'openrouter'; // Format for session affinity: 'openai' uses `prompt_cache_key`, `session_id`, `x-client-request-id`, and `x-session-affinity`; 'openai-nosession' uses `prompt_cache_key`, `x-client-request-id`, and `x-session-affinity`; 'openrouter' uses `x-session-id` (default: auto-detected)
maxTokensField?: 'max_completion_tokens' | 'max_tokens'; // Which field name to use (default: max_completion_tokens) maxTokensField?: 'max_completion_tokens' | 'max_tokens'; // Which field name to use (default: max_completion_tokens)
@@ -1142,6 +1177,8 @@ interface OpenAIResponsesCompat {
supportsDeveloperRole?: boolean; // Whether provider supports `developer` role vs `system` (default: true) supportsDeveloperRole?: boolean; // Whether provider supports `developer` role vs `system` (default: true)
sessionAffinityFormat?: 'openai' | 'openai-nosession' | 'openrouter'; // Session-affinity header format: 'openai' sends `session_id` and `x-client-request-id`; 'openai-nosession' sends `x-client-request-id`; 'openrouter' sends `x-session-id`. Does not affect the `prompt_cache_key` body param (default: auto-detected) sessionAffinityFormat?: 'openai' | 'openai-nosession' | 'openrouter'; // Session-affinity header format: 'openai' sends `session_id` and `x-client-request-id`; 'openai-nosession' sends `x-client-request-id`; 'openrouter' sends `x-session-id`. Does not affect the `prompt_cache_key` body param (default: auto-detected)
supportsLongCacheRetention?: boolean; // Whether provider supports `prompt_cache_retention: "24h"` (default: true) supportsLongCacheRetention?: boolean; // Whether provider supports `prompt_cache_retention: "24h"` (default: true)
supportsStrictMode?: boolean; // Whether provider supports strict JSON-schema function tools (default: false; enabled in metadata for built-in OpenAI models)
supportsOpenAIGrammarTools?: boolean; // Whether to emit OpenAI custom Lark/regex grammar tools; false falls back to normal function tools (default: false; the generated catalog enables it for capable models)
} }
``` ```
@@ -1500,7 +1537,7 @@ Built-in login and refresh flows are private provider implementations. Use provi
Provider notes: Provider notes:
**OpenAI Codex**: Requires a ChatGPT Plus or Pro subscription. Provides access to GPT-5.x Codex models with extended context windows and reasoning capabilities. The library automatically handles session-based prompt caching when `sessionId` is provided in stream options. You can set `transport` in stream options to `"sse"`, `"websocket"`, or `"auto"` for Codex Responses transport selection. When using WebSocket with a `sessionId`, connections are reused per session and expire after 5 minutes of inactivity. **OpenAI Codex**: Requires a ChatGPT Plus or Pro subscription. Provides access to GPT-5.x Codex models with extended context windows and reasoning capabilities. The library automatically handles session-based prompt caching when `sessionId` is provided in stream options unless `cacheRetention` is `"none"`. You can set `transport` in stream options to `"sse"`, `"websocket"`, or `"auto"` for Codex Responses transport selection. When using WebSocket with a `sessionId` and cache retention enabled, connections are reused per session and expire after 5 minutes of inactivity.
**Azure OpenAI (Responses)**: Uses the Responses API only. Set `AZURE_OPENAI_API_KEY` and either `AZURE_OPENAI_BASE_URL` or `AZURE_OPENAI_RESOURCE_NAME`. `AZURE_OPENAI_BASE_URL` supports both `https://<resource>.openai.azure.com` and `https://<resource>.cognitiveservices.azure.com`; root endpoints are normalized to `.../openai/v1` automatically. Use `AZURE_OPENAI_API_VERSION` (defaults to `v1`) to override the API version if needed. Deployment names are treated as model IDs by default, override with `azureDeploymentName` or `AZURE_OPENAI_DEPLOYMENT_NAME_MAP` using comma-separated `model-id=deployment` pairs (for example `gpt-4o-mini=my-deployment,gpt-4o=prod`). Legacy deployment-based URLs are intentionally unsupported. **Azure OpenAI (Responses)**: Uses the Responses API only. Set `AZURE_OPENAI_API_KEY` and either `AZURE_OPENAI_BASE_URL` or `AZURE_OPENAI_RESOURCE_NAME`. `AZURE_OPENAI_BASE_URL` supports both `https://<resource>.openai.azure.com` and `https://<resource>.cognitiveservices.azure.com`; root endpoints are normalized to `.../openai/v1` automatically. Use `AZURE_OPENAI_API_VERSION` (defaults to `v1`) to override the API version if needed. Deployment names are treated as model IDs by default, override with `azureDeploymentName` or `AZURE_OPENAI_DEPLOYMENT_NAME_MAP` using comma-separated `model-id=deployment` pairs (for example `gpt-4o-mini=my-deployment,gpt-4o=prod`). Legacy deployment-based URLs are intentionally unsupported.
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@earendil-works/pi-ai", "name": "@earendil-works/pi-ai",
"version": "0.81.1", "version": "0.82.1",
"description": "Unified LLM API with automatic model discovery and provider configuration", "description": "Unified LLM API with automatic model discovery and provider configuration",
"type": "module", "type": "module",
"main": "./dist/index.js", "main": "./dist/index.js",
+70 -3
View File
@@ -83,6 +83,7 @@ interface ModelsDevModel {
id: string; id: string;
name: string; name: string;
tool_call?: boolean; tool_call?: boolean;
structured_output?: boolean;
reasoning?: boolean; reasoning?: boolean;
reasoning_options?: ModelsDevReasoningOption[]; reasoning_options?: ModelsDevReasoningOption[];
limit?: { limit?: {
@@ -291,6 +292,7 @@ const ANT_LING_RING_THINKING_LEVEL_MAP = {
xhigh: "xhigh", xhigh: "xhigh",
} as const; } as const;
const BEDROCK_INFERENCE_PROFILE_ONLY_MODEL_IDS = new Set(["anthropic.claude-opus-5"]);
const MODELS_DEV_OPENAI_UNSUPPORTED_MODEL_IDS = new Set(["gpt-5.6"]); const MODELS_DEV_OPENAI_UNSUPPORTED_MODEL_IDS = new Set(["gpt-5.6"]);
const OPENAI_TOOL_SEARCH_MODEL_IDS = new Set([ const OPENAI_TOOL_SEARCH_MODEL_IDS = new Set([
"gpt-5.4", "gpt-5.4",
@@ -485,6 +487,8 @@ function isAnthropicAdaptiveThinkingModel(modelId: string): boolean {
modelId.includes("opus-4.7") || modelId.includes("opus-4.7") ||
modelId.includes("opus-4-8") || modelId.includes("opus-4-8") ||
modelId.includes("opus-4.8") || modelId.includes("opus-4.8") ||
modelId.includes("opus-5") ||
modelId.includes("opus.5") ||
modelId.includes("sonnet-4-6") || modelId.includes("sonnet-4-6") ||
modelId.includes("sonnet-4.6") || modelId.includes("sonnet-4.6") ||
modelId.includes("sonnet-5") || modelId.includes("sonnet-5") ||
@@ -495,7 +499,14 @@ function isAnthropicAdaptiveThinkingModel(modelId: string): boolean {
function isAnthropicTemperatureUnsupportedModel(modelId: string): boolean { function isAnthropicTemperatureUnsupportedModel(modelId: string): boolean {
const id = modelId.toLowerCase(); const id = modelId.toLowerCase();
return id.includes("opus-4-7") || id.includes("opus-4.7") || id.includes("opus-4-8") || id.includes("opus-4.8"); return (
id.includes("opus-4-7") ||
id.includes("opus-4.7") ||
id.includes("opus-4-8") ||
id.includes("opus-4.8") ||
id.includes("opus-5") ||
id.includes("opus.5")
);
} }
const OPENAI_COMPLETIONS_DEFAULT_COMPAT = { const OPENAI_COMPLETIONS_DEFAULT_COMPAT = {
@@ -514,6 +525,7 @@ const OPENAI_COMPLETIONS_DEFAULT_COMPAT = {
chatTemplateKwargs: {}, chatTemplateKwargs: {},
zaiToolStream: false, zaiToolStream: false,
supportsStrictMode: true, supportsStrictMode: true,
supportsOpenAIGrammarTools: false,
sendSessionAffinityHeaders: false, sendSessionAffinityHeaders: false,
supportsLongCacheRetention: true, supportsLongCacheRetention: true,
} satisfies Required<Omit<OpenAICompletionsCompat, "cacheControlFormat" | "deferredToolsMode">> & { } satisfies Required<Omit<OpenAICompletionsCompat, "cacheControlFormat" | "deferredToolsMode">> & {
@@ -602,6 +614,7 @@ function detectOpenAICompletionsCompat(model: Model<"openai-completions">): Open
chatTemplateKwargs: {}, chatTemplateKwargs: {},
zaiToolStream: false, zaiToolStream: false,
supportsStrictMode: !isMoonshot && !isTogether && !isCloudflareAiGateway && !isNvidia, supportsStrictMode: !isMoonshot && !isTogether && !isCloudflareAiGateway && !isNvidia,
supportsOpenAIGrammarTools: false,
...(cacheControlFormat ? { cacheControlFormat } : {}), ...(cacheControlFormat ? { cacheControlFormat } : {}),
sendSessionAffinityHeaders: false, sendSessionAffinityHeaders: false,
supportsLongCacheRetention: !( supportsLongCacheRetention: !(
@@ -643,6 +656,39 @@ function applyOpenAICompletionsCompatMetadata(model: Model<Api>): void {
} }
} }
function applyStrictToolCompatMetadata(model: Model<Api>): void {
if (model.provider === "openai" && model.api === "openai-responses") {
model.compat = { ...(model.compat as OpenAIResponsesCompat | undefined), supportsStrictMode: true };
} else if (model.provider === "anthropic" && model.api === "anthropic-messages") {
mergeAnthropicMessagesCompat(model, { supportsStrictTools: true });
}
}
// Responses endpoints verified (OpenAI, ChatGPT Codex backend, GitHub Copilot,
// opencode zen) or documented (Azure OpenAI, Cloudflare AI Gateway) to pass
// OpenAI custom grammar tools through. OpenAI rejects `type: "custom"` tools
// for pre-GPT-5 models (gpt-4.x, gpt-4o, o-series).
const OPENAI_GRAMMAR_TOOL_PROVIDERS = new Set([
"openai",
"openai-codex",
"azure-openai-responses",
"github-copilot",
"opencode",
"cloudflare-ai-gateway",
]);
const OPENAI_GRAMMAR_TOOL_APIS = new Set<Api>([
"openai-responses",
"azure-openai-responses",
"openai-codex-responses",
]);
function applyOpenAIGrammarToolCompatMetadata(model: Model<Api>): void {
if (!OPENAI_GRAMMAR_TOOL_APIS.has(model.api) || !OPENAI_GRAMMAR_TOOL_PROVIDERS.has(model.provider)) return;
const match = /^gpt-(\d+)/.exec(model.id);
if (!match || Number(match[1]) < 5) return;
model.compat = { ...(model.compat as OpenAIResponsesCompat | undefined), supportsOpenAIGrammarTools: true };
}
function applyOpenAIToolSearchMetadata(model: Model<Api>): void { function applyOpenAIToolSearchMetadata(model: Model<Api>): void {
const isOpenAIResponses = model.provider === "openai" && model.api === "openai-responses"; const isOpenAIResponses = model.provider === "openai" && model.api === "openai-responses";
const isOpenAICodex = model.provider === "openai-codex" && model.api === "openai-codex-responses"; const isOpenAICodex = model.provider === "openai-codex" && model.api === "openai-codex-responses";
@@ -653,6 +699,18 @@ function applyOpenAIToolSearchMetadata(model: Model<Api>): void {
}; };
} }
// OpenAI charges prompt-cache writes starting with the GPT-5.6 family, and exactly
// those models accept `prompt_cache_options`; older models reject the parameter.
// https://developers.openai.com/api/docs/guides/prompt-caching
function applyOpenAIExplicitPromptCacheMetadata(model: Model<Api>): void {
if (model.provider !== "openai" || model.api !== "openai-responses") return;
if (!(model.cost.cacheWrite > 0)) return;
model.compat = {
...(model.compat as OpenAIResponsesCompat | undefined),
supportsExplicitPromptCacheMode: true,
};
}
function isGemini3ProModel(modelId: string): boolean { function isGemini3ProModel(modelId: string): boolean {
return /gemini-3(?:\.\d+)?-pro/.test(modelId.toLowerCase()); return /gemini-3(?:\.\d+)?-pro/.test(modelId.toLowerCase());
} }
@@ -700,7 +758,7 @@ function applyThinkingLevelMetadata(model: Model<any>): void {
} }
// Anthropic adaptive-thinking effort support (per Anthropic adaptive thinking docs): // Anthropic adaptive-thinking effort support (per Anthropic adaptive thinking docs):
// - "max" is available on all adaptive-thinking Claude models. // - "max" is available on all adaptive-thinking Claude models.
// - "xhigh" is only available on Opus 4.7/4.8, Sonnet 5, and Fable 5. // - "xhigh" is only available on Opus 4.7/4.8/5, Sonnet 5, and Fable 5.
if ( if (
model.id.includes("opus-4-6") || model.id.includes("opus-4-6") ||
model.id.includes("opus-4.6") || model.id.includes("opus-4.6") ||
@@ -714,6 +772,8 @@ function applyThinkingLevelMetadata(model: Model<any>): void {
model.id.includes("opus-4.7") || model.id.includes("opus-4.7") ||
model.id.includes("opus-4-8") || model.id.includes("opus-4-8") ||
model.id.includes("opus-4.8") || model.id.includes("opus-4.8") ||
model.id.includes("opus-5") ||
model.id.includes("opus.5") ||
model.id.includes("sonnet-5") || model.id.includes("sonnet-5") ||
model.id.includes("sonnet.5") model.id.includes("sonnet.5")
) { ) {
@@ -1004,6 +1064,7 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
for (const [modelId, model] of Object.entries(data["amazon-bedrock"].models)) { for (const [modelId, model] of Object.entries(data["amazon-bedrock"].models)) {
const m = model as ModelsDevModel; const m = model as ModelsDevModel;
if (m.tool_call !== true) continue; if (m.tool_call !== true) continue;
if (BEDROCK_INFERENCE_PROFILE_ONLY_MODEL_IDS.has(modelId)) continue;
let id = modelId; let id = modelId;
@@ -1033,6 +1094,7 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
}, },
contextWindow: m.limit?.context || 4096, contextWindow: m.limit?.context || 4096,
maxTokens: m.limit?.output || 4096, maxTokens: m.limit?.output || 4096,
...(m.structured_output === true && { compat: { supportsStrictMode: true } }),
}); });
recordModelsDevReasoningOptions("amazon-bedrock" as const, id, m); recordModelsDevReasoningOptions("amazon-bedrock" as const, id, m);
} }
@@ -2455,7 +2517,10 @@ async function generateModels() {
applyOpenAICompletionsCompatMetadata(model); applyOpenAICompletionsCompatMetadata(model);
applyModelsDevReasoningOptionMetadata(model); applyModelsDevReasoningOptionMetadata(model);
applyThinkingLevelMetadata(model); applyThinkingLevelMetadata(model);
applyStrictToolCompatMetadata(model);
applyOpenAIGrammarToolCompatMetadata(model);
applyOpenAIToolSearchMetadata(model); applyOpenAIToolSearchMetadata(model);
applyOpenAIExplicitPromptCacheMetadata(model);
} }
// Group by provider and deduplicate by model ID // Group by provider and deduplicate by model ID
@@ -2508,6 +2573,8 @@ async function generateModels() {
} }
} }
const generatedAt = new Date().toISOString();
if (!generatorOptions.jsonOnly) { if (!generatorOptions.jsonOnly) {
// Stage and validate all provider values before replacing the current generated data. // Stage and validate all provider values before replacing the current generated data.
const providersDir = join(packageRoot, "src/providers"); const providersDir = join(packageRoot, "src/providers");
@@ -2527,7 +2594,7 @@ async function generateModels() {
} }
writeJson( writeJson(
join(stagedDataDir, MODEL_DATA_MANIFEST_FILE), join(stagedDataDir, MODEL_DATA_MANIFEST_FILE),
createModelDataManifest(modelDataStructure, fileContents), createModelDataManifest(modelDataStructure, fileContents, generatedAt),
); );
validateModelDataDirectory(modelDataStructure, stagedDataDir); validateModelDataDirectory(modelDataStructure, stagedDataDir);
+7 -1
View File
@@ -2,13 +2,14 @@ import { createHash } from "node:crypto";
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
import { join } from "node:path"; import { join } from "node:path";
export const MODEL_DATA_SCHEMA_VERSION = 2; export const MODEL_DATA_SCHEMA_VERSION = 3;
export const MODEL_DATA_MANIFEST_FILE = ".manifest.json"; export const MODEL_DATA_MANIFEST_FILE = ".manifest.json";
export type ModelDataStructure = Record<string, Record<string, string>>; export type ModelDataStructure = Record<string, Record<string, string>>;
export interface ModelDataManifest { export interface ModelDataManifest {
schemaVersion: number; schemaVersion: number;
generatedAt: string;
structureHash: string; structureHash: string;
files: Record<string, string>; files: Record<string, string>;
} }
@@ -119,9 +120,11 @@ export function modelDataStructureHash(structure: ModelDataStructure): string {
export function createModelDataManifest( export function createModelDataManifest(
structure: ModelDataStructure, structure: ModelDataStructure,
fileContents: Readonly<Record<string, string>>, fileContents: Readonly<Record<string, string>>,
generatedAt: string,
): ModelDataManifest { ): ModelDataManifest {
return { return {
schemaVersion: MODEL_DATA_SCHEMA_VERSION, schemaVersion: MODEL_DATA_SCHEMA_VERSION,
generatedAt,
structureHash: modelDataStructureHash(structure), structureHash: modelDataStructureHash(structure),
files: sortedRecord(Object.entries(fileContents).map(([file, content]) => [file, sha256(content)] as const)), files: sortedRecord(Object.entries(fileContents).map(([file, content]) => [file, sha256(content)] as const)),
}; };
@@ -203,6 +206,9 @@ export function validateModelDataDirectory(structure: ModelDataStructure, dataDi
`model data schema is ${JSON.stringify(manifest?.schemaVersion)}, expected ${MODEL_DATA_SCHEMA_VERSION}`, `model data schema is ${JSON.stringify(manifest?.schemaVersion)}, expected ${MODEL_DATA_SCHEMA_VERSION}`,
); );
} }
if (typeof manifest?.generatedAt !== "string" || Number.isNaN(Date.parse(manifest.generatedAt))) {
errors.push("model data manifest has an invalid generation timestamp");
}
const expectedStructureHash = modelDataStructureHash(structure); const expectedStructureHash = modelDataStructureHash(structure);
if (manifest?.structureHash !== expectedStructureHash) { if (manifest?.structureHash !== expectedStructureHash) {
errors.push("model data generation stamp does not match the generated catalog"); errors.push("model data generation stamp does not match the generated catalog");
+37 -8
View File
@@ -34,8 +34,10 @@ import { AssistantMessageEventStream } from "../utils/event-stream.ts";
import { headersToRecord } from "../utils/headers.ts"; import { headersToRecord } from "../utils/headers.ts";
import { parseJsonWithRepair, parseStreamingJson } from "../utils/json-parse.ts"; import { parseJsonWithRepair, parseStreamingJson } from "../utils/json-parse.ts";
import { getProviderEnvValue } from "../utils/provider-env.ts"; import { getProviderEnvValue } from "../utils/provider-env.ts";
import { retryProviderRequest } from "../utils/provider-retry.ts";
import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts"; import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts";
import { resolveJsonSchemaStrictSampling } from "./constrained-sampling.ts";
import { buildCopilotDynamicHeaders, hasCopilotVisionInput } from "./github-copilot-headers.ts"; import { buildCopilotDynamicHeaders, hasCopilotVisionInput } from "./github-copilot-headers.ts";
import { adjustMaxTokensForThinking, buildBaseOptions, clampMaxTokensToContext } from "./simple-options.ts"; import { adjustMaxTokensForThinking, buildBaseOptions, clampMaxTokensToContext } from "./simple-options.ts";
import { transformMessages } from "./transform-messages.ts"; import { transformMessages } from "./transform-messages.ts";
@@ -178,6 +180,7 @@ function getAnthropicCompat(
supportsCacheControlOnTools: model.compat?.supportsCacheControlOnTools ?? true, supportsCacheControlOnTools: model.compat?.supportsCacheControlOnTools ?? true,
supportsTemperature: model.compat?.supportsTemperature ?? true, supportsTemperature: model.compat?.supportsTemperature ?? true,
allowEmptySignature: model.compat?.allowEmptySignature ?? false, allowEmptySignature: model.compat?.allowEmptySignature ?? false,
supportsStrictTools: model.compat?.supportsStrictTools ?? false,
supportsToolReferences: model.compat?.supportsToolReferences ?? defaultSupportsToolReferences(model), supportsToolReferences: model.compat?.supportsToolReferences ?? defaultSupportsToolReferences(model),
}; };
} }
@@ -550,9 +553,16 @@ export const stream: StreamFunction<"anthropic-messages", AnthropicOptions> = (
const requestOptions = { const requestOptions = {
...(options?.signal ? { signal: options.signal } : {}), ...(options?.signal ? { signal: options.signal } : {}),
...(options?.timeoutMs !== undefined ? { timeout: options.timeoutMs } : {}), ...(options?.timeoutMs !== undefined ? { timeout: options.timeoutMs } : {}),
maxRetries: options?.maxRetries ?? 0, maxRetries: 0,
}; };
const response = await client.messages.create({ ...params, stream: true }, requestOptions).asResponse(); const response = await retryProviderRequest(
() => client.messages.create({ ...params, stream: true }, requestOptions).asResponse(),
{
maxRetries: options?.maxRetries,
maxRetryDelayMs: options?.maxRetryDelayMs,
signal: options?.signal,
},
);
await options?.onResponse?.({ status: response.status, headers: headersToRecord(response.headers) }, model); await options?.onResponse?.({ status: response.status, headers: headersToRecord(response.headers) }, model);
stream.push({ type: "start", partial: output }); stream.push({ type: "start", partial: output });
@@ -991,9 +1001,17 @@ function buildParams(
immediateTools, immediateTools,
isOAuthToken, isOAuthToken,
compat.supportsEagerToolInputStreaming, compat.supportsEagerToolInputStreaming,
compat.supportsStrictTools,
compat.supportsCacheControlOnTools ? cacheControl : undefined, compat.supportsCacheControlOnTools ? cacheControl : undefined,
), ),
...convertTools(deferredTools, isOAuthToken, compat.supportsEagerToolInputStreaming, undefined, true), ...convertTools(
deferredTools,
isOAuthToken,
compat.supportsEagerToolInputStreaming,
compat.supportsStrictTools,
undefined,
true,
),
]; ];
} }
@@ -1261,23 +1279,34 @@ function convertTools(
tools: Tool[], tools: Tool[],
isOAuthToken: boolean, isOAuthToken: boolean,
supportsEagerToolInputStreaming: boolean, supportsEagerToolInputStreaming: boolean,
supportsStrictTools: boolean,
cacheControl?: CacheControlEphemeral, cacheControl?: CacheControlEphemeral,
deferLoading = false, deferLoading = false,
): Anthropic.Messages.Tool[] { ): Anthropic.Messages.Tool[] {
if (!tools) return []; if (!tools) return [];
return tools.map((tool, index) => { return tools.map((tool, index) => {
const strict = resolveJsonSchemaStrictSampling(tool, supportsStrictTools);
const schema = tool.parameters as { properties?: unknown; required?: string[] }; const schema = tool.parameters as { properties?: unknown; required?: string[] };
const legacyInputSchema = {
type: "object" as const,
properties: schema.properties ?? {},
required: schema.required ?? [],
};
const inputSchema =
strict === true
? {
...(tool.parameters as Record<string, unknown>),
...legacyInputSchema,
}
: legacyInputSchema;
return { return {
name: isOAuthToken ? toClaudeCodeName(tool.name) : tool.name, name: isOAuthToken ? toClaudeCodeName(tool.name) : tool.name,
description: tool.description, description: tool.description,
...(supportsEagerToolInputStreaming ? { eager_input_streaming: true } : {}), ...(supportsEagerToolInputStreaming ? { eager_input_streaming: true } : {}),
input_schema: { ...(strict === true ? { strict: true } : {}),
type: "object", input_schema: inputSchema,
properties: schema.properties ?? {},
required: schema.required ?? [],
},
...(deferLoading ? { defer_loading: true } : {}), ...(deferLoading ? { defer_loading: true } : {}),
...(cacheControl && index === tools.length - 1 ? { cache_control: cacheControl } : {}), ...(cacheControl && index === tools.length - 1 ? { cache_control: cacheControl } : {}),
}; };
+30 -7
View File
@@ -14,6 +14,8 @@ import { formatProviderError, normalizeProviderError } from "../utils/error-body
import { AssistantMessageEventStream } from "../utils/event-stream.ts"; import { AssistantMessageEventStream } from "../utils/event-stream.ts";
import { headersToRecord } from "../utils/headers.ts"; import { headersToRecord } from "../utils/headers.ts";
import { getProviderEnvValue } from "../utils/provider-env.ts"; import { getProviderEnvValue } from "../utils/provider-env.ts";
import { retryProviderRequest } from "../utils/provider-retry.ts";
import { createGrammarToolInputProperties } from "./constrained-sampling.ts";
import { clampOpenAIPromptCacheKey } from "./openai-prompt-cache.ts"; import { clampOpenAIPromptCacheKey } from "./openai-prompt-cache.ts";
import { convertResponsesMessages, convertResponsesTools, processResponsesStream } from "./openai-responses-shared.ts"; import { convertResponsesMessages, convertResponsesTools, processResponsesStream } from "./openai-responses-shared.ts";
import { buildBaseOptions } from "./simple-options.ts"; import { buildBaseOptions } from "./simple-options.ts";
@@ -99,7 +101,11 @@ export const stream: StreamFunction<"azure-openai-responses", AzureOpenAIRespons
throw new Error(`No API key for provider: ${model.provider}`); throw new Error(`No API key for provider: ${model.provider}`);
} }
const client = createClient(model, apiKey, options); const client = createClient(model, apiKey, options);
let params = buildParams(model, context, options, deploymentName); const grammarToolInputProperties = createGrammarToolInputProperties(
context.tools,
model.compat?.supportsOpenAIGrammarTools ?? false,
);
let params = buildParams(model, context, options, deploymentName, grammarToolInputProperties);
const nextParams = await options?.onPayload?.(params, model); const nextParams = await options?.onPayload?.(params, model);
if (nextParams !== undefined) { if (nextParams !== undefined) {
params = nextParams as ResponseCreateParamsStreaming; params = nextParams as ResponseCreateParamsStreaming;
@@ -107,13 +113,20 @@ export const stream: StreamFunction<"azure-openai-responses", AzureOpenAIRespons
const requestOptions = { const requestOptions = {
...(options?.signal ? { signal: options.signal } : {}), ...(options?.signal ? { signal: options.signal } : {}),
...(options?.timeoutMs !== undefined ? { timeout: options.timeoutMs } : {}), ...(options?.timeoutMs !== undefined ? { timeout: options.timeoutMs } : {}),
maxRetries: options?.maxRetries ?? 0, maxRetries: 0,
}; };
const { data: openaiStream, response } = await client.responses.create(params, requestOptions).withResponse(); const { data: openaiStream, response } = await retryProviderRequest(
() => client.responses.create(params, requestOptions).withResponse(),
{
maxRetries: options?.maxRetries,
maxRetryDelayMs: options?.maxRetryDelayMs,
signal: options?.signal,
},
);
await options?.onResponse?.({ status: response.status, headers: headersToRecord(response.headers) }, model); await options?.onResponse?.({ status: response.status, headers: headersToRecord(response.headers) }, model);
stream.push({ type: "start", partial: output }); stream.push({ type: "start", partial: output });
await processResponsesStream(openaiStream, output, stream, model); await processResponsesStream(openaiStream, output, stream, model, { grammarToolInputProperties });
if (options?.signal?.aborted) { if (options?.signal?.aborted) {
throw new Error("Request was aborted"); throw new Error("Request was aborted");
@@ -128,8 +141,9 @@ export const stream: StreamFunction<"azure-openai-responses", AzureOpenAIRespons
} catch (error) { } catch (error) {
for (const block of output.content) { for (const block of output.content) {
delete (block as { index?: number }).index; delete (block as { index?: number }).index;
// partialJson is only a streaming scratch buffer; never persist it. // Streaming scratch buffers are only used during parsing; never persist them.
delete (block as { partialJson?: string }).partialJson; delete (block as { partialJson?: string }).partialJson;
delete (block as { customInput?: unknown }).customInput;
} }
output.stopReason = options?.signal?.aborted ? "aborted" : "error"; output.stopReason = options?.signal?.aborted ? "aborted" : "error";
output.errorMessage = formatAzureOpenAIError(error); output.errorMessage = formatAzureOpenAIError(error);
@@ -254,8 +268,14 @@ function buildParams(
context: Context, context: Context,
options: AzureOpenAIResponsesOptions | undefined, options: AzureOpenAIResponsesOptions | undefined,
deploymentName: string, deploymentName: string,
grammarToolInputProperties: ReadonlyMap<string, string> = createGrammarToolInputProperties(
context.tools,
model.compat?.supportsOpenAIGrammarTools ?? false,
),
) { ) {
const messages = convertResponsesMessages(model, context, AZURE_TOOL_CALL_PROVIDERS); const messages = convertResponsesMessages(model, context, AZURE_TOOL_CALL_PROVIDERS, {
grammarToolInputProperties,
});
const params: ResponseCreateParamsStreaming = { const params: ResponseCreateParamsStreaming = {
model: deploymentName, model: deploymentName,
@@ -274,7 +294,10 @@ function buildParams(
} }
if (context.tools && context.tools.length > 0) { if (context.tools && context.tools.length > 0) {
params.tools = convertResponsesTools(context.tools); params.tools = convertResponsesTools(context.tools, {
supportsStrictMode: model.compat?.supportsStrictMode ?? true,
supportsOpenAIGrammarTools: model.compat?.supportsOpenAIGrammarTools ?? false,
});
} }
if (model.reasoning) { if (model.reasoning) {
+25 -12
View File
@@ -54,6 +54,7 @@ import { parseStreamingJson } from "../utils/json-parse.ts";
import { resolveHttpProxyUrlForTarget } from "../utils/node-http-proxy.ts"; import { resolveHttpProxyUrlForTarget } from "../utils/node-http-proxy.ts";
import { getProviderEnvValue } from "../utils/provider-env.ts"; import { getProviderEnvValue } from "../utils/provider-env.ts";
import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts"; import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts";
import { resolveJsonSchemaStrictSampling } from "./constrained-sampling.ts";
import { import {
adjustMaxTokensForThinking, adjustMaxTokensForThinking,
buildBaseOptions, buildBaseOptions,
@@ -228,7 +229,7 @@ export const stream: StreamFunction<"bedrock-converse-stream", BedrockOptions> =
...(inferenceMaxTokens !== undefined && { maxTokens: inferenceMaxTokens }), ...(inferenceMaxTokens !== undefined && { maxTokens: inferenceMaxTokens }),
...(options.temperature !== undefined && { temperature: options.temperature }), ...(options.temperature !== undefined && { temperature: options.temperature }),
}, },
toolConfig: convertToolConfig(context.tools, options.toolChoice), toolConfig: convertToolConfig(context.tools, options.toolChoice, model.compat?.supportsStrictMode ?? false),
additionalModelRequestFields: buildAdditionalModelRequestFields(model, options), additionalModelRequestFields: buildAdditionalModelRequestFields(model, options),
...(options.requestMetadata !== undefined && { requestMetadata: options.requestMetadata }), ...(options.requestMetadata !== undefined && { requestMetadata: options.requestMetadata }),
}; };
@@ -581,6 +582,7 @@ function supportsAdaptiveThinking(modelId: string, modelName?: string): boolean
s.includes("opus-4-6") || s.includes("opus-4-6") ||
s.includes("opus-4-7") || s.includes("opus-4-7") ||
s.includes("opus-4-8") || s.includes("opus-4-8") ||
s.includes("opus-5") ||
s.includes("sonnet-4-6") || s.includes("sonnet-4-6") ||
s.includes("sonnet-5") || s.includes("sonnet-5") ||
s.includes("fable-5"), s.includes("fable-5"),
@@ -590,7 +592,12 @@ function supportsAdaptiveThinking(modelId: string, modelName?: string): boolean
function supportsNativeXhighEffort(model: Model<"bedrock-converse-stream">): boolean { function supportsNativeXhighEffort(model: Model<"bedrock-converse-stream">): boolean {
const candidates = getModelMatchCandidates(model.id, model.name); const candidates = getModelMatchCandidates(model.id, model.name);
return candidates.some( return candidates.some(
(s) => s.includes("opus-4-7") || s.includes("opus-4-8") || s.includes("sonnet-5") || s.includes("fable-5"), (s) =>
s.includes("opus-4-7") ||
s.includes("opus-4-8") ||
s.includes("opus-5") ||
s.includes("sonnet-5") ||
s.includes("fable-5"),
); );
} }
@@ -669,8 +676,8 @@ function supportsPromptCaching(model: Model<"bedrock-converse-stream">, env?: Pr
if (getProviderEnvValue("AWS_BEDROCK_FORCE_CACHE", env) === "1") return true; if (getProviderEnvValue("AWS_BEDROCK_FORCE_CACHE", env) === "1") return true;
return false; return false;
} }
// Claude 5 models (fable-5, sonnet-5) // Claude 5 models (fable-5, opus-5, sonnet-5)
if (candidates.some((s) => s.includes("fable-5") || s.includes("sonnet-5"))) return true; if (candidates.some((s) => s.includes("fable-5") || s.includes("opus-5") || s.includes("sonnet-5"))) return true;
// Claude 4.x models (opus-4, sonnet-4, haiku-4) // Claude 4.x models (opus-4, sonnet-4, haiku-4)
if (candidates.some((s) => s.includes("-4-"))) return true; if (candidates.some((s) => s.includes("-4-"))) return true;
// Claude 3.7 Sonnet // Claude 3.7 Sonnet
@@ -908,16 +915,22 @@ function convertMessages(
function convertToolConfig( function convertToolConfig(
tools: Tool[] | undefined, tools: Tool[] | undefined,
toolChoice: BedrockOptions["toolChoice"], toolChoice: BedrockOptions["toolChoice"],
supportsStrictMode: boolean,
): ToolConfiguration | undefined { ): ToolConfiguration | undefined {
if (!tools?.length || toolChoice === "none") return undefined; if (!tools?.length) return undefined;
if (toolChoice === "none") return undefined;
const bedrockTools: BedrockTool[] = tools.map((tool) => ({ const bedrockTools: BedrockTool[] = tools.map((tool) => {
toolSpec: { const strict = resolveJsonSchemaStrictSampling(tool, supportsStrictMode);
name: tool.name, return {
description: tool.description, toolSpec: {
inputSchema: { json: tool.parameters as unknown as DocumentType }, name: tool.name,
}, description: tool.description,
})); inputSchema: { json: tool.parameters as unknown as DocumentType },
...(strict === true ? { strict: true } : {}),
},
};
});
let bedrockToolChoice: ToolChoice | undefined; let bedrockToolChoice: ToolChoice | undefined;
switch (toolChoice) { switch (toolChoice) {
+148
View File
@@ -0,0 +1,148 @@
import type { Tool } from "../types.ts";
interface JsonSchemaObject {
type?: unknown;
properties?: Record<string, JsonSchemaObject | undefined>;
required?: unknown;
}
export interface GrammarConstrainedSampling {
format: "lark" | "regex";
definition: string;
inputProperty: string;
}
export interface GrammarToolInputJsonBuffer {
input: string;
started: boolean;
closed: boolean;
}
export function getGrammarToolInput(
toolName: string,
arguments_: Record<string, unknown>,
inputProperty: string,
): string {
const input = arguments_[inputProperty];
if (typeof input !== "string") {
throw new Error(`Grammar tool call "${toolName}" requires argument "${inputProperty}" to be a string.`);
}
return input;
}
export function appendGrammarToolInputJsonDelta(
buffer: GrammarToolInputJsonBuffer,
inputProperty: string,
nextInput: string,
close: boolean,
): string | undefined {
if (buffer.closed) {
if (close && nextInput === buffer.input) return undefined;
throw new Error(`grammar tool input for property "${inputProperty}" changed after it was closed`);
}
if (!nextInput.startsWith(buffer.input)) {
throw new Error(`grammar tool input for property "${inputProperty}" changed non-monotonically`);
}
const inputDelta = nextInput.slice(buffer.input.length);
if (!close && inputDelta.length === 0) return undefined;
let delta = "";
if (!buffer.started) {
delta += `{${JSON.stringify(inputProperty)}:"`;
buffer.started = true;
}
delta += JSON.stringify(inputDelta).slice(1, -1);
buffer.input = nextInput;
if (close) {
delta += '"}';
buffer.closed = true;
}
return delta;
}
function inferGrammarInputProperty(tool: Tool): string {
const schema = tool.parameters as JsonSchemaObject;
if (schema.type !== "object") {
throw new Error("grammar constrained sampling requires an object parameter schema");
}
if (!Array.isArray(schema.required) || schema.required.length !== 1 || typeof schema.required[0] !== "string") {
throw new Error("grammar constrained sampling requires exactly one required string property");
}
const inputProperty = schema.required[0];
if (!schema.properties?.[inputProperty]) {
throw new Error(`grammar constrained sampling requires a properties entry for ${inputProperty}`);
}
if (schema.properties[inputProperty]?.type !== "string") {
throw new Error(`grammar constrained sampling property ${inputProperty} must have type string`);
}
return inputProperty;
}
export function resolveJsonSchemaStrictSampling(tool: Tool, supportsStrictMode: boolean): boolean | undefined {
const config = tool.constrainedSampling;
if (!config || config.type !== "json_schema") {
return undefined;
}
if (supportsStrictMode) {
return true;
}
if (config.strict === "require") {
throw new Error(
`Tool "${tool.name}" requires JSON-schema constrained sampling, but strict tools are unsupported.`,
);
}
return undefined;
}
export function resolveGrammarConstrainedSampling(
tool: Tool,
supportsOpenAIGrammarTools: boolean,
): GrammarConstrainedSampling | undefined {
const config = tool.constrainedSampling;
if (!config || config.type !== "grammar") {
return undefined;
}
if (!supportsOpenAIGrammarTools) {
return undefined;
}
const larkDefinition = config.variants.openai_lark;
const regexDefinition = config.variants.openai_regex;
const hasLarkDefinition = typeof larkDefinition === "string" && larkDefinition.trim().length > 0;
const hasRegexDefinition = typeof regexDefinition === "string" && regexDefinition.trim().length > 0;
if (!hasLarkDefinition && !hasRegexDefinition) {
throw new Error(
`Tool "${tool.name}" cannot use grammar constrained sampling: no supported grammar variant was provided.`,
);
}
try {
return {
format: hasLarkDefinition ? "lark" : "regex",
definition: hasLarkDefinition ? larkDefinition : regexDefinition!,
inputProperty: inferGrammarInputProperty(tool),
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(`Tool "${tool.name}" cannot use grammar constrained sampling: ${message}.`);
}
}
export function createGrammarToolInputProperties(
tools: Tool[] | undefined,
supportsOpenAIGrammarTools: boolean,
): ReadonlyMap<string, string> {
const properties = new Map<string, string>();
for (const tool of tools ?? []) {
const grammar = resolveGrammarConstrainedSampling(tool, supportsOpenAIGrammarTools);
if (grammar) {
properties.set(tool.name, grammar.inputProperty);
}
}
return properties;
}
+8 -11
View File
@@ -30,8 +30,9 @@ import {
convertTools, convertTools,
isThinkingPart, isThinkingPart,
mapStopReason, mapStopReason,
mapToolChoice, resolveGoogleFunctionCallingMode,
retainThoughtSignature, retainThoughtSignature,
supportsGoogleStrictToolSampling,
} from "./google-shared.ts"; } from "./google-shared.ts";
import { buildBaseOptions } from "./simple-options.ts"; import { buildBaseOptions } from "./simple-options.ts";
@@ -355,22 +356,18 @@ function buildParams(
generationConfig.maxOutputTokens = options.maxTokens; generationConfig.maxOutputTokens = options.maxTokens;
} }
const functionCallingMode = context.tools?.length
? resolveGoogleFunctionCallingMode(context.tools, options.toolChoice, supportsGoogleStrictToolSampling(model.id))
: undefined;
const config: GenerateContentConfig = { const config: GenerateContentConfig = {
...(Object.keys(generationConfig).length > 0 && generationConfig), ...(Object.keys(generationConfig).length > 0 && generationConfig),
...(context.systemPrompt && { systemInstruction: sanitizeSurrogates(context.systemPrompt) }), ...(context.systemPrompt && { systemInstruction: sanitizeSurrogates(context.systemPrompt) }),
...(context.tools && context.tools.length > 0 && { tools: convertTools(context.tools) }), ...(context.tools && context.tools.length > 0 && { tools: convertTools(context.tools) }),
...(functionCallingMode !== undefined && {
toolConfig: { functionCallingConfig: { mode: functionCallingMode } },
}),
}; };
if (context.tools && context.tools.length > 0 && options.toolChoice) {
config.toolConfig = {
functionCallingConfig: {
mode: mapToolChoice(options.toolChoice),
},
};
} else {
config.toolConfig = undefined;
}
if (options.thinking?.enabled && model.reasoning) { if (options.thinking?.enabled && model.reasoning) {
const thinkingConfig: ThinkingConfig = { includeThoughts: true }; const thinkingConfig: ThinkingConfig = { includeThoughts: true };
if (options.thinking.level !== undefined) { if (options.thinking.level !== undefined) {
+23 -3
View File
@@ -5,6 +5,7 @@
import { type Content, FinishReason, FunctionCallingConfigMode, type Part } from "@google/genai"; import { type Content, FinishReason, FunctionCallingConfigMode, type Part } from "@google/genai";
import type { Context, ImageContent, Model, StopReason, TextContent, Tool } from "../types.ts"; import type { Context, ImageContent, Model, StopReason, TextContent, Tool } from "../types.ts";
import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts"; import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts";
import { resolveJsonSchemaStrictSampling } from "./constrained-sampling.ts";
import { transformMessages } from "./transform-messages.ts"; import { transformMessages } from "./transform-messages.ts";
type GoogleApiType = "google-generative-ai" | "google-vertex"; type GoogleApiType = "google-generative-ai" | "google-vertex";
@@ -287,9 +288,13 @@ export function convertTools(
]; ];
} }
/** /** Gemini 3+ enforces required function parameters in validated tool-calling modes. */
* Map tool choice string to Gemini FunctionCallingConfigMode. export function supportsGoogleStrictToolSampling(modelId: string): boolean {
*/ const majorVersion = getGeminiMajorVersion(modelId);
return majorVersion !== undefined && majorVersion >= 3;
}
/** Map tool choice string to Gemini FunctionCallingConfigMode. */
export function mapToolChoice(choice: string): FunctionCallingConfigMode { export function mapToolChoice(choice: string): FunctionCallingConfigMode {
switch (choice) { switch (choice) {
case "auto": case "auto":
@@ -303,6 +308,21 @@ export function mapToolChoice(choice: string): FunctionCallingConfigMode {
} }
} }
export function resolveGoogleFunctionCallingMode(
tools: Tool[],
toolChoice: string | undefined,
supportsStrictMode: boolean,
): FunctionCallingConfigMode | undefined {
const useStrictMode = tools.some((tool) => resolveJsonSchemaStrictSampling(tool, supportsStrictMode) === true);
if (toolChoice === "none" || toolChoice === "any") {
return mapToolChoice(toolChoice);
}
if (useStrictMode) {
return FunctionCallingConfigMode.VALIDATED;
}
return toolChoice ? mapToolChoice(toolChoice) : undefined;
}
/** /**
* Map Gemini FinishReason to our StopReason. * Map Gemini FinishReason to our StopReason.
*/ */
+8 -11
View File
@@ -35,8 +35,9 @@ import {
convertTools, convertTools,
isThinkingPart, isThinkingPart,
mapStopReason, mapStopReason,
mapToolChoice, resolveGoogleFunctionCallingMode,
retainThoughtSignature, retainThoughtSignature,
supportsGoogleStrictToolSampling,
} from "./google-shared.ts"; } from "./google-shared.ts";
import { buildBaseOptions } from "./simple-options.ts"; import { buildBaseOptions } from "./simple-options.ts";
@@ -454,22 +455,18 @@ function buildParams(
generationConfig.maxOutputTokens = options.maxTokens; generationConfig.maxOutputTokens = options.maxTokens;
} }
const functionCallingMode = context.tools?.length
? resolveGoogleFunctionCallingMode(context.tools, options.toolChoice, supportsGoogleStrictToolSampling(model.id))
: undefined;
const config: GenerateContentConfig = { const config: GenerateContentConfig = {
...(Object.keys(generationConfig).length > 0 && generationConfig), ...(Object.keys(generationConfig).length > 0 && generationConfig),
...(context.systemPrompt && { systemInstruction: sanitizeSurrogates(context.systemPrompt) }), ...(context.systemPrompt && { systemInstruction: sanitizeSurrogates(context.systemPrompt) }),
...(context.tools && context.tools.length > 0 && { tools: convertTools(context.tools) }), ...(context.tools && context.tools.length > 0 && { tools: convertTools(context.tools) }),
...(functionCallingMode !== undefined && {
toolConfig: { functionCallingConfig: { mode: functionCallingMode } },
}),
}; };
if (context.tools && context.tools.length > 0 && options.toolChoice) {
config.toolConfig = {
functionCallingConfig: {
mode: mapToolChoice(options.toolChoice),
},
};
} else {
config.toolConfig = undefined;
}
if (options.thinking?.enabled && model.reasoning) { if (options.thinking?.enabled && model.reasoning) {
const thinkingConfig: ThinkingConfig = { includeThoughts: true }; const thinkingConfig: ThinkingConfig = { includeThoughts: true };
if (options.thinking.level !== undefined) { if (options.thinking.level !== undefined) {
+13 -9
View File
@@ -25,6 +25,7 @@ import { AssistantMessageEventStream } from "../utils/event-stream.ts";
import { shortHash } from "../utils/hash.ts"; import { shortHash } from "../utils/hash.ts";
import { parseStreamingJson } from "../utils/json-parse.ts"; import { parseStreamingJson } from "../utils/json-parse.ts";
import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts"; import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts";
import { resolveJsonSchemaStrictSampling } from "./constrained-sampling.ts";
import { buildBaseOptions } from "./simple-options.ts"; import { buildBaseOptions } from "./simple-options.ts";
import { transformMessages } from "./transform-messages.ts"; import { transformMessages } from "./transform-messages.ts";
@@ -483,15 +484,18 @@ async function consumeChatStream(
} }
function toFunctionTools(tools: Tool[]): Array<FunctionTool & { type: "function" }> { function toFunctionTools(tools: Tool[]): Array<FunctionTool & { type: "function" }> {
return tools.map((tool) => ({ return tools.map((tool) => {
type: "function", const strict = resolveJsonSchemaStrictSampling(tool, true);
function: { return {
name: tool.name, type: "function",
description: tool.description, function: {
parameters: stripSymbolKeys(tool.parameters) as Record<string, unknown>, name: tool.name,
strict: false, description: tool.description,
}, parameters: stripSymbolKeys(tool.parameters) as Record<string, unknown>,
})); strict: strict ?? false,
},
};
});
} }
function stripSymbolKeys(value: unknown): unknown { function stripSymbolKeys(value: unknown): unknown {
+61 -20
View File
@@ -47,6 +47,7 @@ import { AssistantMessageEventStream } from "../utils/event-stream.ts";
import { headersToRecord } from "../utils/headers.ts"; import { headersToRecord } from "../utils/headers.ts";
import { resolveHttpProxyUrlForTarget } from "../utils/node-http-proxy.ts"; import { resolveHttpProxyUrlForTarget } from "../utils/node-http-proxy.ts";
import { uuidv7 } from "../utils/uuid.ts"; import { uuidv7 } from "../utils/uuid.ts";
import { createGrammarToolInputProperties } from "./constrained-sampling.ts";
import { clampOpenAIPromptCacheKey } from "./openai-prompt-cache.ts"; import { clampOpenAIPromptCacheKey } from "./openai-prompt-cache.ts";
import { convertResponsesMessages, convertResponsesTools, processResponsesStream } from "./openai-responses-shared.ts"; import { convertResponsesMessages, convertResponsesTools, processResponsesStream } from "./openai-responses-shared.ts";
import { buildBaseOptions } from "./simple-options.ts"; import { buildBaseOptions } from "./simple-options.ts";
@@ -158,9 +159,16 @@ function getRetryAfterDelayMs(headers: Headers): number | undefined {
return undefined; return undefined;
} }
function capRetryDelayMs(delayMs: number, options?: StreamOptions): number { class RetryDelayExceededError extends Error {}
function validateRetryDelayMs(delayMs: number, options?: StreamOptions): number {
const maxRetryDelayMs = options?.maxRetryDelayMs ?? DEFAULT_MAX_RETRY_DELAY_MS; const maxRetryDelayMs = options?.maxRetryDelayMs ?? DEFAULT_MAX_RETRY_DELAY_MS;
return maxRetryDelayMs > 0 ? Math.min(delayMs, maxRetryDelayMs) : delayMs; if (maxRetryDelayMs > 0 && delayMs > maxRetryDelayMs) {
throw new RetryDelayExceededError(
`Server requested ${Math.ceil(delayMs / 1000)}s retry delay (max: ${Math.ceil(maxRetryDelayMs / 1000)}s)`,
);
}
return delayMs;
} }
function sleep(ms: number, signal?: AbortSignal): Promise<void> { function sleep(ms: number, signal?: AbortSignal): Promise<void> {
@@ -255,12 +263,17 @@ export const stream: StreamFunction<"openai-codex-responses", OpenAICodexRespons
} }
const accountId = extractAccountId(apiKey); const accountId = extractAccountId(apiKey);
let body = buildRequestBody(model, context, options); const grammarToolInputProperties = createGrammarToolInputProperties(
context.tools,
model.compat?.supportsOpenAIGrammarTools ?? false,
);
const cacheSessionId = options?.cacheRetention === "none" ? undefined : options?.sessionId;
const codexSessionId = clampOpenAIPromptCacheKey(cacheSessionId);
let body = buildRequestBody(model, context, options, codexSessionId, grammarToolInputProperties);
const nextBody = await options?.onPayload?.(body, model); const nextBody = await options?.onPayload?.(body, model);
if (nextBody !== undefined) { if (nextBody !== undefined) {
body = nextBody as RequestBody; body = nextBody as RequestBody;
} }
const codexSessionId = clampOpenAIPromptCacheKey(options?.sessionId);
const websocketRequestId = codexSessionId || uuidv7(); const websocketRequestId = codexSessionId || uuidv7();
const sseHeaders = buildSSEHeaders(model.headers, options?.headers, accountId, apiKey, codexSessionId); const sseHeaders = buildSSEHeaders(model.headers, options?.headers, accountId, apiKey, codexSessionId);
const websocketHeaders = buildWebSocketHeaders( const websocketHeaders = buildWebSocketHeaders(
@@ -275,9 +288,9 @@ export const stream: StreamFunction<"openai-codex-responses", OpenAICodexRespons
const websocketConnectTimeoutMs = normalizeTimeoutMs(options?.websocketConnectTimeoutMs); const websocketConnectTimeoutMs = normalizeTimeoutMs(options?.websocketConnectTimeoutMs);
const transport = options?.transport || "auto"; const transport = options?.transport || "auto";
let startEmitted = false; let startEmitted = false;
const websocketDisabledForSession = transport !== "sse" && isWebSocketSseFallbackActive(options?.sessionId); const websocketDisabledForSession = transport !== "sse" && isWebSocketSseFallbackActive(cacheSessionId);
if (websocketDisabledForSession) { if (websocketDisabledForSession) {
recordWebSocketSseFallback(options?.sessionId); recordWebSocketSseFallback(cacheSessionId);
} }
if (transport !== "sse" && !websocketDisabledForSession) { if (transport !== "sse" && !websocketDisabledForSession) {
@@ -303,6 +316,8 @@ export const stream: StreamFunction<"openai-codex-responses", OpenAICodexRespons
}, },
httpTimeoutMs, httpTimeoutMs,
websocketConnectTimeoutMs, websocketConnectTimeoutMs,
cacheSessionId,
grammarToolInputProperties,
options, options,
); );
@@ -341,11 +356,11 @@ export const stream: StreamFunction<"openai-codex-responses", OpenAICodexRespons
requestBytes: new TextEncoder().encode(bodyJson).byteLength, requestBytes: new TextEncoder().encode(bodyJson).byteLength,
}), }),
); );
recordWebSocketFailure(options?.sessionId, error); recordWebSocketFailure(cacheSessionId, error);
if (websocketStarted) { if (websocketStarted) {
throw error; throw error;
} }
recordWebSocketSseFallback(options?.sessionId); recordWebSocketSseFallback(cacheSessionId);
break; break;
} }
} }
@@ -404,9 +419,7 @@ export const stream: StreamFunction<"openai-codex-responses", OpenAICodexRespons
const delayMs = const delayMs =
retryAfterDelayMs === undefined retryAfterDelayMs === undefined
? BASE_DELAY_MS * 2 ** attempt ? BASE_DELAY_MS * 2 ** attempt
: response.status === 429 : validateRetryDelayMs(retryAfterDelayMs, options);
? capRetryDelayMs(retryAfterDelayMs, options)
: retryAfterDelayMs;
await sleep(delayMs, options?.signal); await sleep(delayMs, options?.signal);
continue; continue;
@@ -427,7 +440,11 @@ export const stream: StreamFunction<"openai-codex-responses", OpenAICodexRespons
} }
lastError = error instanceof Error ? error : new Error(String(error)); lastError = error instanceof Error ? error : new Error(String(error));
// Network errors are retryable // Network errors are retryable
if (attempt < maxRetries && !lastError.message.includes("usage limit")) { if (
attempt < maxRetries &&
!(lastError instanceof RetryDelayExceededError) &&
!lastError.message.includes("usage limit")
) {
const delayMs = BASE_DELAY_MS * 2 ** attempt; const delayMs = BASE_DELAY_MS * 2 ** attempt;
await sleep(delayMs, options?.signal); await sleep(delayMs, options?.signal);
continue; continue;
@@ -448,7 +465,7 @@ export const stream: StreamFunction<"openai-codex-responses", OpenAICodexRespons
startEmitted = true; startEmitted = true;
stream.push({ type: "start", partial: output }); stream.push({ type: "start", partial: output });
} }
await processStream(response, output, stream, model, options); await processStream(response, output, stream, model, grammarToolInputProperties, options);
if (options?.signal?.aborted) { if (options?.signal?.aborted) {
throw new Error("Request was aborted"); throw new Error("Request was aborted");
@@ -458,8 +475,9 @@ export const stream: StreamFunction<"openai-codex-responses", OpenAICodexRespons
stream.end(); stream.end();
} catch (error) { } catch (error) {
for (const block of output.content) { for (const block of output.content) {
// partialJson is only a streaming scratch buffer; never persist it. // Streaming scratch buffers are only used during parsing; never persist them.
delete (block as { partialJson?: string }).partialJson; delete (block as { partialJson?: string }).partialJson;
delete (block as { customInput?: unknown }).customInput;
} }
output.stopReason = options?.signal?.aborted ? "aborted" : "error"; output.stopReason = options?.signal?.aborted ? "aborted" : "error";
output.errorMessage = formatProviderError(normalizeProviderError(error)); output.errorMessage = formatProviderError(normalizeProviderError(error));
@@ -498,12 +516,25 @@ export const streamSimple: StreamFunction<"openai-codex-responses", SimpleStream
function buildRequestBody( function buildRequestBody(
model: Model<"openai-codex-responses">, model: Model<"openai-codex-responses">,
context: Context, context: Context,
options?: OpenAICodexResponsesOptions, options: OpenAICodexResponsesOptions | undefined,
cacheSessionId: string | undefined,
grammarToolInputProperties: ReadonlyMap<string, string> = createGrammarToolInputProperties(
context.tools,
model.compat?.supportsOpenAIGrammarTools ?? false,
),
): RequestBody { ): RequestBody {
const supportsStrictMode = model.compat?.supportsStrictMode ?? true;
const supportsOpenAIGrammarTools = model.compat?.supportsOpenAIGrammarTools ?? false;
const toolPlacement = splitDeferredTools(context, model.compat?.supportsToolSearch ?? false); const toolPlacement = splitDeferredTools(context, model.compat?.supportsToolSearch ?? false);
const messages = convertResponsesMessages(model, context, CODEX_TOOL_CALL_PROVIDERS, { const messages = convertResponsesMessages(model, context, CODEX_TOOL_CALL_PROVIDERS, {
includeSystemPrompt: false, includeSystemPrompt: false,
grammarToolInputProperties,
deferredTools: toolPlacement.deferred, deferredTools: toolPlacement.deferred,
toolOptions: {
strict: null,
supportsStrictMode,
supportsOpenAIGrammarTools,
},
}); });
const body: RequestBody = { const body: RequestBody = {
@@ -514,7 +545,7 @@ function buildRequestBody(
input: messages, input: messages,
text: { verbosity: options?.textVerbosity || "low" }, text: { verbosity: options?.textVerbosity || "low" },
include: ["reasoning.encrypted_content"], include: ["reasoning.encrypted_content"],
prompt_cache_key: clampOpenAIPromptCacheKey(options?.sessionId), prompt_cache_key: cacheSessionId,
tool_choice: options?.toolChoice ?? "auto", tool_choice: options?.toolChoice ?? "auto",
parallel_tool_calls: true, parallel_tool_calls: true,
}; };
@@ -528,7 +559,11 @@ function buildRequestBody(
} }
if (toolPlacement.immediate.length > 0) { if (toolPlacement.immediate.length > 0) {
body.tools = convertResponsesTools(toolPlacement.immediate, { strict: null }); body.tools = convertResponsesTools(toolPlacement.immediate, {
strict: null,
supportsStrictMode,
supportsOpenAIGrammarTools,
});
} }
if (options?.reasoningEffort !== undefined) { if (options?.reasoningEffort !== undefined) {
@@ -610,10 +645,12 @@ async function processStream(
output: AssistantMessage, output: AssistantMessage,
stream: AssistantMessageEventStream, stream: AssistantMessageEventStream,
model: Model<"openai-codex-responses">, model: Model<"openai-codex-responses">,
grammarToolInputProperties: ReadonlyMap<string, string>,
options?: OpenAICodexResponsesOptions, options?: OpenAICodexResponsesOptions,
): Promise<void> { ): Promise<void> {
await processResponsesStream(mapCodexEvents(parseSSE(response, options?.signal)), output, stream, model, { await processResponsesStream(mapCodexEvents(parseSSE(response, options?.signal)), output, stream, model, {
serviceTier: options?.serviceTier, serviceTier: options?.serviceTier,
grammarToolInputProperties,
resolveServiceTier: resolveCodexServiceTier, resolveServiceTier: resolveCodexServiceTier,
applyServiceTierPricing: (usage, serviceTier) => applyServiceTierPricing(usage, serviceTier, model), applyServiceTierPricing: (usage, serviceTier) => applyServiceTierPricing(usage, serviceTier, model),
}); });
@@ -1399,12 +1436,14 @@ async function processWebSocketStream(
onStart: () => void, onStart: () => void,
idleTimeoutMs: number | undefined, idleTimeoutMs: number | undefined,
websocketConnectTimeoutMs: number | undefined, websocketConnectTimeoutMs: number | undefined,
cacheSessionId: string | undefined,
grammarToolInputProperties: ReadonlyMap<string, string>,
options?: OpenAICodexResponsesOptions, options?: OpenAICodexResponsesOptions,
): Promise<void> { ): Promise<void> {
const { socket, entry, reused, release } = await acquireWebSocket( const { socket, entry, reused, release } = await acquireWebSocket(
url, url,
headers, headers,
options?.sessionId, cacheSessionId,
options?.signal, options?.signal,
websocketConnectTimeoutMs, websocketConnectTimeoutMs,
options?.env, options?.env,
@@ -1415,7 +1454,7 @@ async function processWebSocketStream(
// WebSocket continuation still works via connection-scoped previous_response_id state. // WebSocket continuation still works via connection-scoped previous_response_id state.
const fullBody = body; const fullBody = body;
const requestBody = useCachedContext && entry ? buildCachedWebSocketRequestBody(entry, fullBody) : fullBody; const requestBody = useCachedContext && entry ? buildCachedWebSocketRequestBody(entry, fullBody) : fullBody;
const stats = options?.sessionId ? getOrCreateWebSocketDebugStats(options.sessionId) : undefined; const stats = cacheSessionId ? getOrCreateWebSocketDebugStats(cacheSessionId) : undefined;
if (stats) { if (stats) {
stats.requests++; stats.requests++;
if (reused) stats.connectionsReused++; if (reused) stats.connectionsReused++;
@@ -1445,6 +1484,7 @@ async function processWebSocketStream(
model, model,
{ {
serviceTier: options?.serviceTier, serviceTier: options?.serviceTier,
grammarToolInputProperties,
resolveServiceTier: resolveCodexServiceTier, resolveServiceTier: resolveCodexServiceTier,
applyServiceTierPricing: (usage, serviceTier) => applyServiceTierPricing(usage, serviceTier, model), applyServiceTierPricing: (usage, serviceTier) => applyServiceTierPricing(usage, serviceTier, model),
}, },
@@ -1454,7 +1494,8 @@ async function processWebSocketStream(
} else if (useCachedContext && entry && output.responseId) { } else if (useCachedContext && entry && output.responseId) {
const responseItems = convertResponsesMessages(model, { messages: [output] }, CODEX_TOOL_CALL_PROVIDERS, { const responseItems = convertResponsesMessages(model, { messages: [output] }, CODEX_TOOL_CALL_PROVIDERS, {
includeSystemPrompt: false, includeSystemPrompt: false,
}).filter((item) => item.type !== "function_call_output"); grammarToolInputProperties,
}).filter((item) => item.type !== "function_call_output" && item.type !== "custom_tool_call_output");
entry.continuation = { entry.continuation = {
lastRequestBody: fullBody, lastRequestBody: fullBody,
lastResponseId: output.responseId, lastResponseId: output.responseId,
+169 -33
View File
@@ -7,6 +7,7 @@ import type {
ChatCompletionContentPartText, ChatCompletionContentPartText,
ChatCompletionDeveloperMessageParam, ChatCompletionDeveloperMessageParam,
ChatCompletionMessageParam, ChatCompletionMessageParam,
ChatCompletionMessageToolCall,
ChatCompletionSystemMessageParam, ChatCompletionSystemMessageParam,
ChatCompletionToolMessageParam, ChatCompletionToolMessageParam,
} from "openai/resources/chat/completions.js"; } from "openai/resources/chat/completions.js";
@@ -38,7 +39,16 @@ import { shortHash } from "../utils/hash.ts";
import { headersToRecord } from "../utils/headers.ts"; import { headersToRecord } from "../utils/headers.ts";
import { parseStreamingJson } from "../utils/json-parse.ts"; import { parseStreamingJson } from "../utils/json-parse.ts";
import { getProviderEnvValue } from "../utils/provider-env.ts"; import { getProviderEnvValue } from "../utils/provider-env.ts";
import { retryProviderRequest } from "../utils/provider-retry.ts";
import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts"; import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts";
import {
appendGrammarToolInputJsonDelta,
createGrammarToolInputProperties,
type GrammarToolInputJsonBuffer,
getGrammarToolInput,
resolveGrammarConstrainedSampling,
resolveJsonSchemaStrictSampling,
} from "./constrained-sampling.ts";
import { buildCopilotDynamicHeaders, hasCopilotVisionInput } from "./github-copilot-headers.ts"; import { buildCopilotDynamicHeaders, hasCopilotVisionInput } from "./github-copilot-headers.ts";
import { clampOpenAIPromptCacheKey } from "./openai-prompt-cache.ts"; import { clampOpenAIPromptCacheKey } from "./openai-prompt-cache.ts";
import { buildBaseOptions } from "./simple-options.ts"; import { buildBaseOptions } from "./simple-options.ts";
@@ -129,10 +139,14 @@ function isEncryptedReasoningDetail(detail: unknown): detail is OpenAIEncryptedR
} }
export interface OpenAICompletionsOptions extends StreamOptions { export interface OpenAICompletionsOptions extends StreamOptions {
toolChoice?: "auto" | "none" | "required" | { type: "function"; function: { name: string } }; toolChoice?: OpenAI.Chat.Completions.ChatCompletionToolChoiceOption;
reasoningEffort?: "minimal" | "low" | "medium" | "high" | "xhigh" | "max"; reasoningEffort?: "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
} }
export interface ConvertCompletionsMessagesOptions {
grammarToolInputProperties?: ReadonlyMap<string, string>;
}
interface OpenAICompatCacheControl { interface OpenAICompatCacheControl {
type: "ephemeral"; type: "ephemeral";
ttl?: string; ttl?: string;
@@ -208,10 +222,14 @@ export const stream: StreamFunction<"openai-completions", OpenAICompletionsOptio
try { try {
const apiKey = getClientApiKey(model.provider, options?.apiKey, options?.headers); const apiKey = getClientApiKey(model.provider, options?.apiKey, options?.headers);
const compat = getCompat(model); const compat = getCompat(model);
const grammarToolInputProperties = createGrammarToolInputProperties(
context.tools,
compat.supportsOpenAIGrammarTools,
);
const cacheRetention = resolveCacheRetention(options?.cacheRetention, options?.env); const cacheRetention = resolveCacheRetention(options?.cacheRetention, options?.env);
const cacheSessionId = cacheRetention === "none" ? undefined : options?.sessionId; const cacheSessionId = cacheRetention === "none" ? undefined : options?.sessionId;
const client = createClient(model, context, apiKey, options?.headers, cacheSessionId, compat); const client = createClient(model, context, apiKey, options?.headers, cacheSessionId, compat);
let params = buildParams(model, context, options, compat, cacheRetention); let params = buildParams(model, context, options, compat, cacheRetention, grammarToolInputProperties);
const nextParams = await options?.onPayload?.(params, model); const nextParams = await options?.onPayload?.(params, model);
if (nextParams !== undefined) { if (nextParams !== undefined) {
params = nextParams as OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming; params = nextParams as OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming;
@@ -219,20 +237,35 @@ export const stream: StreamFunction<"openai-completions", OpenAICompletionsOptio
const requestOptions = { const requestOptions = {
...(options?.signal ? { signal: options.signal } : {}), ...(options?.signal ? { signal: options.signal } : {}),
...(options?.timeoutMs !== undefined ? { timeout: options.timeoutMs } : {}), ...(options?.timeoutMs !== undefined ? { timeout: options.timeoutMs } : {}),
maxRetries: options?.maxRetries ?? 0, maxRetries: 0,
}; };
const { data: openaiStream, response } = await client.chat.completions const { data: openaiStream, response } = await retryProviderRequest(
.create(params, requestOptions) () => client.chat.completions.create(params, requestOptions).withResponse(),
.withResponse(); {
maxRetries: options?.maxRetries,
maxRetryDelayMs: options?.maxRetryDelayMs,
signal: options?.signal,
},
);
await options?.onResponse?.({ status: response.status, headers: headersToRecord(response.headers) }, model); await options?.onResponse?.({ status: response.status, headers: headersToRecord(response.headers) }, model);
stream.push({ type: "start", partial: output }); stream.push({ type: "start", partial: output });
interface StreamingToolCallBlock extends ToolCall { interface StreamingToolCallBlock extends ToolCall {
partialArgs?: string; partialArgs?: string;
customInput?: {
property: string;
jsonBuffer: GrammarToolInputJsonBuffer;
};
streamIndex?: number; streamIndex?: number;
} }
type StreamingBlock = TextContent | ThinkingContent | StreamingToolCallBlock; type StreamingBlock = TextContent | ThinkingContent | StreamingToolCallBlock;
type StreamingToolCallDelta = NonNullable<ChatCompletionChunk.Choice.Delta["tool_calls"]>[number]; type StreamingToolCallDelta = {
index?: number;
id?: string;
type?: string;
function?: { name?: string; arguments?: string };
custom?: { name?: string; input?: string };
};
let textBlock: TextContent | null = null; let textBlock: TextContent | null = null;
let thinkingBlock: ThinkingContent | null = null; let thinkingBlock: ThinkingContent | null = null;
@@ -242,6 +275,28 @@ export const stream: StreamFunction<"openai-completions", OpenAICompletionsOptio
const pendingReasoningDetailsByToolCallId = new Map<string, string>(); const pendingReasoningDetailsByToolCallId = new Map<string, string>();
const blocks = output.content as StreamingBlock[]; const blocks = output.content as StreamingBlock[];
const getContentIndex = (block: StreamingBlock) => blocks.indexOf(block); const getContentIndex = (block: StreamingBlock) => blocks.indexOf(block);
const getCustomToolCallInput = (block: StreamingToolCallBlock): string => {
const property = block.customInput?.property;
if (property === undefined) return "";
const value = block.arguments[property];
return typeof value === "string" ? value : "";
};
const appendCustomToolCallInput = (
block: StreamingToolCallBlock,
nextInput: string,
close: boolean,
): string | undefined => {
const customInput = block.customInput;
if (!customInput) return undefined;
const delta = appendGrammarToolInputJsonDelta(
customInput.jsonBuffer,
customInput.property,
nextInput,
close,
);
block.arguments = { [customInput.property]: nextInput };
return delta;
};
const finishBlock = (block: StreamingBlock) => { const finishBlock = (block: StreamingBlock) => {
const contentIndex = getContentIndex(block); const contentIndex = getContentIndex(block);
if (contentIndex === -1) { if (contentIndex === -1) {
@@ -262,10 +317,23 @@ export const stream: StreamFunction<"openai-completions", OpenAICompletionsOptio
partial: output, partial: output,
}); });
} else if (block.type === "toolCall") { } else if (block.type === "toolCall") {
block.arguments = parseStreamingJson(block.partialArgs); if (block.customInput) {
const delta = appendCustomToolCallInput(block, getCustomToolCallInput(block), true);
if (delta !== undefined) {
stream.push({
type: "toolcall_delta",
contentIndex,
delta,
partial: output,
});
}
} else {
block.arguments = parseStreamingJson(block.partialArgs);
}
// Finalize in-place and strip the scratch buffers so replay only // Finalize in-place and strip the scratch buffers so replay only
// carries parsed arguments. // carries parsed arguments.
delete block.partialArgs; delete block.partialArgs;
delete block.customInput;
delete block.streamIndex; delete block.streamIndex;
stream.push({ stream.push({
type: "toolcall_end", type: "toolcall_end",
@@ -307,17 +375,27 @@ export const stream: StreamFunction<"openai-completions", OpenAICompletionsOptio
}; };
const ensureToolCallBlock = (toolCall: StreamingToolCallDelta) => { const ensureToolCallBlock = (toolCall: StreamingToolCallDelta) => {
const streamIndex = typeof toolCall.index === "number" ? toolCall.index : undefined; const streamIndex = typeof toolCall.index === "number" ? toolCall.index : undefined;
const name = toolCall.function?.name ?? toolCall.custom?.name ?? "";
let block = streamIndex !== undefined ? toolCallBlocksByIndex.get(streamIndex) : undefined; let block = streamIndex !== undefined ? toolCallBlocksByIndex.get(streamIndex) : undefined;
if (!block && toolCall.id) { if (!block && toolCall.id) {
block = toolCallBlocksById.get(toolCall.id); block = toolCallBlocksById.get(toolCall.id);
} }
if (!block) { if (!block) {
// Note: the "input" fallback here should/must not be taken. in case the LLM makes up
// a tool we don't knwo about, we at least have a place to stash our stuff.
const customInputProperty = toolCall.custom
? (grammarToolInputProperties.get(name) ?? "input")
: undefined;
const hasCustomInput = customInputProperty !== undefined;
block = { block = {
type: "toolCall", type: "toolCall",
id: toolCall.id || "", id: toolCall.id || "",
name: toolCall.function?.name || "", name,
arguments: {}, arguments: hasCustomInput ? { [customInputProperty]: "" } : {},
partialArgs: "", partialArgs: hasCustomInput ? undefined : "",
customInput: hasCustomInput
? { property: customInputProperty, jsonBuffer: { input: "", started: false, closed: false } }
: undefined,
streamIndex, streamIndex,
}; };
if (streamIndex !== undefined) { if (streamIndex !== undefined) {
@@ -340,6 +418,18 @@ export const stream: StreamFunction<"openai-completions", OpenAICompletionsOptio
if (toolCall.id) { if (toolCall.id) {
toolCallBlocksById.set(toolCall.id, block); toolCallBlocksById.set(toolCall.id, block);
} }
if (!block.name && name) {
block.name = name;
}
if (toolCall.custom && !block.customInput) {
const customInputProperty = grammarToolInputProperties.get(block.name) ?? "input";
block.arguments = { [customInputProperty]: "" };
block.customInput = {
property: customInputProperty,
jsonBuffer: { input: "", started: false, closed: false },
};
delete block.partialArgs;
}
applyPendingReasoningDetail(block); applyPendingReasoningDetail(block);
return block; return block;
}; };
@@ -425,14 +515,15 @@ export const stream: StreamFunction<"openai-completions", OpenAICompletionsOptio
} }
if (choice?.delta?.tool_calls) { if (choice?.delta?.tool_calls) {
for (const toolCall of choice.delta.tool_calls) { for (const toolCall of choice.delta.tool_calls as StreamingToolCallDelta[]) {
const block = ensureToolCallBlock(toolCall); const block = ensureToolCallBlock(toolCall);
if (!block.id && toolCall.id) { if (!block.id && toolCall.id) {
block.id = toolCall.id; block.id = toolCall.id;
toolCallBlocksById.set(toolCall.id, block); toolCallBlocksById.set(toolCall.id, block);
} }
if (!block.name && toolCall.function?.name) { const name = toolCall.function?.name ?? toolCall.custom?.name;
block.name = toolCall.function.name; if (!block.name && name) {
block.name = name;
} }
let delta = ""; let delta = "";
@@ -440,6 +531,9 @@ export const stream: StreamFunction<"openai-completions", OpenAICompletionsOptio
delta = toolCall.function.arguments; delta = toolCall.function.arguments;
block.partialArgs = (block.partialArgs ?? "") + toolCall.function.arguments; block.partialArgs = (block.partialArgs ?? "") + toolCall.function.arguments;
block.arguments = parseStreamingJson(block.partialArgs); block.arguments = parseStreamingJson(block.partialArgs);
} else if (toolCall.custom?.input) {
const nextInput = getCustomToolCallInput(block) + toolCall.custom.input;
delta = appendCustomToolCallInput(block, nextInput, false) ?? "";
} }
stream.push({ stream.push({
type: "toolcall_delta", type: "toolcall_delta",
@@ -491,6 +585,7 @@ export const stream: StreamFunction<"openai-completions", OpenAICompletionsOptio
delete (block as { index?: number }).index; delete (block as { index?: number }).index;
// Streaming scratch buffers are only used during parsing; never persist them. // Streaming scratch buffers are only used during parsing; never persist them.
delete (block as { partialArgs?: string }).partialArgs; delete (block as { partialArgs?: string }).partialArgs;
delete (block as { customInput?: unknown }).customInput;
delete (block as { streamIndex?: number }).streamIndex; delete (block as { streamIndex?: number }).streamIndex;
} }
output.stopReason = options?.signal?.aborted ? "aborted" : "error"; output.stopReason = options?.signal?.aborted ? "aborted" : "error";
@@ -579,8 +674,12 @@ function buildParams(
options?: OpenAICompletionsOptions, options?: OpenAICompletionsOptions,
compat: ResolvedOpenAICompletionsCompat = getCompat(model), compat: ResolvedOpenAICompletionsCompat = getCompat(model),
cacheRetention: CacheRetention = resolveCacheRetention(options?.cacheRetention, options?.env), cacheRetention: CacheRetention = resolveCacheRetention(options?.cacheRetention, options?.env),
grammarToolInputProperties: ReadonlyMap<string, string> = createGrammarToolInputProperties(
context.tools,
compat.supportsOpenAIGrammarTools,
),
) { ) {
const messages = convertMessages(model, context, compat); const messages = convertMessages(model, context, compat, { grammarToolInputProperties });
const cacheControl = getCompatCacheControl(compat, cacheRetention); const cacheControl = getCompatCacheControl(compat, cacheRetention);
const params: OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming = { const params: OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming = {
@@ -889,6 +988,7 @@ export function convertMessages(
model: Model<"openai-completions">, model: Model<"openai-completions">,
context: Context, context: Context,
compat: ResolvedOpenAICompletionsCompat, compat: ResolvedOpenAICompletionsCompat,
options?: ConvertCompletionsMessagesOptions,
): ChatCompletionMessageParam[] { ): ChatCompletionMessageParam[] {
const params: ChatCompletionMessageParam[] = []; const params: ChatCompletionMessageParam[] = [];
@@ -1026,14 +1126,27 @@ export function convertMessages(
const toolCalls = msg.content.filter(isToolCallBlock); const toolCalls = msg.content.filter(isToolCallBlock);
if (toolCalls.length > 0) { if (toolCalls.length > 0) {
assistantMsg.tool_calls = toolCalls.map((tc) => ({ assistantMsg.tool_calls = toolCalls.map((tc): ChatCompletionMessageToolCall => {
id: tc.id, const customInputProperty = options?.grammarToolInputProperties?.get(tc.name);
type: "function" as const, if (customInputProperty !== undefined) {
function: { return {
name: tc.name, id: tc.id,
arguments: JSON.stringify(tc.arguments), type: "custom",
}, custom: {
})); name: tc.name,
input: sanitizeSurrogates(getGrammarToolInput(tc.name, tc.arguments, customInputProperty)),
},
};
}
return {
id: tc.id,
type: "function",
function: {
name: tc.name,
arguments: JSON.stringify(tc.arguments),
},
};
});
const reasoningDetails = toolCalls const reasoningDetails = toolCalls
.filter((tc) => tc.thoughtSignature) .filter((tc) => tc.thoughtSignature)
.map((tc) => { .map((tc) => {
@@ -1166,16 +1279,37 @@ function convertTools(
tools: Tool[], tools: Tool[],
compat: ResolvedOpenAICompletionsCompat, compat: ResolvedOpenAICompletionsCompat,
): OpenAI.Chat.Completions.ChatCompletionTool[] { ): OpenAI.Chat.Completions.ChatCompletionTool[] {
return tools.map((tool) => ({ return tools.map((tool) => {
type: "function", const grammar = resolveGrammarConstrainedSampling(tool, compat.supportsOpenAIGrammarTools);
function: { if (grammar) {
name: tool.name, return {
description: tool.description, type: "custom",
parameters: tool.parameters as any, // TypeBox already generates JSON Schema custom: {
// Only include strict if provider supports it. Some reject unknown fields. name: tool.name,
...(compat.supportsStrictMode !== false && { strict: false }), description: tool.description,
}, format: {
})); type: "grammar",
grammar: {
syntax: grammar.format,
definition: grammar.definition,
},
},
},
};
}
const strict = resolveJsonSchemaStrictSampling(tool, compat.supportsStrictMode !== false);
return {
type: "function",
function: {
name: tool.name,
description: tool.description,
parameters: tool.parameters as Record<string, unknown>, // TypeBox already generates JSON Schema
// Only include strict if provider supports it. Some reject unknown fields.
...(compat.supportsStrictMode !== false && { strict: strict ?? false }),
},
};
});
} }
function parseChunkUsage( function parseChunkUsage(
@@ -1318,6 +1452,7 @@ function detectCompat(model: Model<"openai-completions">): ResolvedOpenAIComplet
chatTemplateKwargs: {}, chatTemplateKwargs: {},
zaiToolStream: false, zaiToolStream: false,
supportsStrictMode: !isMoonshot && !isTogether && !isCloudflareAiGateway && !isNvidia, supportsStrictMode: !isMoonshot && !isTogether && !isCloudflareAiGateway && !isNvidia,
supportsOpenAIGrammarTools: false,
cacheControlFormat, cacheControlFormat,
sendSessionAffinityHeaders: false, sendSessionAffinityHeaders: false,
deferredToolsMode: undefined, deferredToolsMode: undefined,
@@ -1359,6 +1494,7 @@ function getCompat(model: Model<"openai-completions">): ResolvedOpenAICompletion
chatTemplateKwargs: model.compat.chatTemplateKwargs ?? detected.chatTemplateKwargs, chatTemplateKwargs: model.compat.chatTemplateKwargs ?? detected.chatTemplateKwargs,
zaiToolStream: model.compat.zaiToolStream ?? detected.zaiToolStream, zaiToolStream: model.compat.zaiToolStream ?? detected.zaiToolStream,
supportsStrictMode: model.compat.supportsStrictMode ?? detected.supportsStrictMode, supportsStrictMode: model.compat.supportsStrictMode ?? detected.supportsStrictMode,
supportsOpenAIGrammarTools: model.compat.supportsOpenAIGrammarTools ?? detected.supportsOpenAIGrammarTools,
cacheControlFormat: model.compat.cacheControlFormat ?? detected.cacheControlFormat, cacheControlFormat: model.compat.cacheControlFormat ?? detected.cacheControlFormat,
sendSessionAffinityHeaders: model.compat.sendSessionAffinityHeaders ?? detected.sendSessionAffinityHeaders, sendSessionAffinityHeaders: model.compat.sendSessionAffinityHeaders ?? detected.sendSessionAffinityHeaders,
deferredToolsMode: model.compat.deferredToolsMode ?? detected.deferredToolsMode, deferredToolsMode: model.compat.deferredToolsMode ?? detected.deferredToolsMode,
+205 -72
View File
@@ -2,7 +2,6 @@ import type OpenAI from "openai";
import type { import type {
Tool as OpenAITool, Tool as OpenAITool,
ResponseCreateParamsStreaming, ResponseCreateParamsStreaming,
ResponseFunctionCallOutputItemList,
ResponseInput, ResponseInput,
ResponseInputContent, ResponseInputContent,
ResponseInputImage, ResponseInputImage,
@@ -33,6 +32,13 @@ import type { AssistantMessageEventStream } from "../utils/event-stream.ts";
import { shortHash } from "../utils/hash.ts"; import { shortHash } from "../utils/hash.ts";
import { parseStreamingJson } from "../utils/json-parse.ts"; import { parseStreamingJson } from "../utils/json-parse.ts";
import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts"; import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts";
import {
appendGrammarToolInputJsonDelta,
type GrammarToolInputJsonBuffer,
getGrammarToolInput,
resolveGrammarConstrainedSampling,
resolveJsonSchemaStrictSampling,
} from "./constrained-sampling.ts";
import { transformMessages } from "./transform-messages.ts"; import { transformMessages } from "./transform-messages.ts";
// ============================================================================= // =============================================================================
@@ -65,8 +71,40 @@ function parseTextSignature(
return { id: signature }; return { id: signature };
} }
type ToolResultOutputContent = Array<ResponseInputText | ResponseInputImage>;
function convertToolResultOutput<TApi extends Api>(
model: Model<TApi>,
content: readonly (TextContent | ImageContent)[],
): string | ToolResultOutputContent {
const textResult = content
.filter((c): c is TextContent => c.type === "text")
.map((c) => c.text)
.join("\n");
const images = content.filter((c): c is ImageContent => c.type === "image");
const hasText = textResult.length > 0;
if (images.length === 0 || !model.input.includes("image")) {
return sanitizeSurrogates(hasText ? textResult : images.length > 0 ? "(see attached image)" : "(no tool output)");
}
const output: ToolResultOutputContent = [];
if (hasText) {
output.push({ type: "input_text", text: sanitizeSurrogates(textResult) });
}
for (const image of images) {
output.push({
type: "input_image",
detail: "auto",
image_url: `data:${image.mimeType};base64,${image.data}`,
});
}
return output;
}
export interface OpenAIResponsesStreamOptions { export interface OpenAIResponsesStreamOptions {
serviceTier?: ResponseCreateParamsStreaming["service_tier"]; serviceTier?: ResponseCreateParamsStreaming["service_tier"];
grammarToolInputProperties?: ReadonlyMap<string, string>;
resolveServiceTier?: ( resolveServiceTier?: (
responseServiceTier: ResponseCreateParamsStreaming["service_tier"] | undefined, responseServiceTier: ResponseCreateParamsStreaming["service_tier"] | undefined,
requestServiceTier: ResponseCreateParamsStreaming["service_tier"] | undefined, requestServiceTier: ResponseCreateParamsStreaming["service_tier"] | undefined,
@@ -79,16 +117,18 @@ export interface OpenAIResponsesStreamOptions {
export interface ConvertResponsesMessagesOptions { export interface ConvertResponsesMessagesOptions {
includeSystemPrompt?: boolean; includeSystemPrompt?: boolean;
grammarToolInputProperties?: ReadonlyMap<string, string>;
deferredTools?: ReadonlyMap<string, Tool>; deferredTools?: ReadonlyMap<string, Tool>;
toolOptions?: ConvertResponsesToolsOptions;
} }
export interface ConvertResponsesToolsOptions { export interface ConvertResponsesToolsOptions {
strict?: boolean | null; strict?: boolean | null;
supportsStrictMode?: boolean;
supportsOpenAIGrammarTools?: boolean;
deferLoading?: boolean; deferLoading?: boolean;
} }
type OpenAIFunctionTool = Extract<OpenAITool, { type: "function" }>;
// ============================================================================= // =============================================================================
// Message conversion // Message conversion
// ============================================================================= // =============================================================================
@@ -206,67 +246,62 @@ export function convertResponsesMessages<TApi extends Api>(
} else if (block.type === "toolCall") { } else if (block.type === "toolCall") {
const toolCall = block as ToolCall; const toolCall = block as ToolCall;
const [callId, itemIdRaw] = toolCall.id.split("|"); const [callId, itemIdRaw] = toolCall.id.split("|");
const customInputProperty = options?.grammarToolInputProperties?.get(toolCall.name);
let itemId: string | undefined = itemIdRaw; let itemId: string | undefined = itemIdRaw;
// For different-model messages, set id to undefined to avoid pairing validation. // For different-model messages, set id to undefined to avoid pairing validation.
// OpenAI tracks which fc_xxx IDs were paired with rs_xxx reasoning items. // OpenAI tracks which fc_xxx IDs were paired with rs_xxx reasoning items.
// By omitting the id, we avoid triggering that validation (like cross-provider does). // By omitting the id, we avoid triggering that validation (like cross-provider does).
if (isDifferentModel && itemId?.startsWith("fc_")) { // When replaying custom-tool calls as a function_call, also drop non-fc_* ids such as
// ctc_* custom-tool ids because function_call item ids must be fc_*.
if (
(isDifferentModel && itemId?.startsWith("fc_")) ||
(customInputProperty === undefined && !itemId?.startsWith("fc_"))
) {
itemId = undefined; itemId = undefined;
} }
output.push({ if (customInputProperty !== undefined) {
type: "function_call", output.push({
id: itemId, type: "custom_tool_call",
call_id: callId, id: itemId,
name: toolCall.name, call_id: callId,
arguments: JSON.stringify(toolCall.arguments), name: toolCall.name,
}); input: sanitizeSurrogates(
getGrammarToolInput(toolCall.name, toolCall.arguments, customInputProperty),
),
} satisfies ResponseOutputItem);
} else {
output.push({
type: "function_call",
id: itemId,
call_id: callId,
name: toolCall.name,
arguments: JSON.stringify(toolCall.arguments),
});
}
} }
} }
if (output.length === 0) continue; if (output.length === 0) continue;
messages.push(...output); messages.push(...output);
} else if (msg.role === "toolResult") { } else if (msg.role === "toolResult") {
const textResult = msg.content
.filter((c): c is TextContent => c.type === "text")
.map((c) => c.text)
.join("\n");
const hasImages = msg.content.some((c): c is ImageContent => c.type === "image");
const hasText = textResult.length > 0;
const [callId] = msg.toolCallId.split("|"); const [callId] = msg.toolCallId.split("|");
const output = convertToolResultOutput(model, msg.content);
let output: string | ResponseFunctionCallOutputItemList; if (options?.grammarToolInputProperties?.has(msg.toolName)) {
if (hasImages && model.input.includes("image")) { messages.push({
const contentParts: ResponseFunctionCallOutputItemList = []; type: "custom_tool_call_output",
call_id: callId,
if (hasText) { output,
contentParts.push({ });
type: "input_text",
text: sanitizeSurrogates(textResult),
});
}
for (const block of msg.content) {
if (block.type === "image") {
contentParts.push({
type: "input_image",
detail: "auto",
image_url: `data:${block.mimeType};base64,${block.data}`,
});
}
}
output = contentParts;
} else { } else {
output = sanitizeSurrogates(hasText ? textResult : hasImages ? "(see attached image)" : "(no tool output)"); messages.push({
type: "function_call_output",
call_id: callId,
output,
});
} }
messages.push({
type: "function_call_output",
call_id: callId,
output,
});
const deferredTools: Tool[] = []; const deferredTools: Tool[] = [];
for (const name of msg.addedToolNames ?? []) { for (const name of msg.addedToolNames ?? []) {
const tool = options?.deferredTools?.get(name); const tool = options?.deferredTools?.get(name);
@@ -289,7 +324,10 @@ export function convertResponsesMessages<TApi extends Api>(
call_id: searchCallId, call_id: searchCallId,
execution: "client", execution: "client",
status: "completed", status: "completed",
tools: convertResponsesTools(deferredTools, { deferLoading: true }), tools: convertResponsesTools(deferredTools, {
...options?.toolOptions,
deferLoading: true,
}),
} satisfies ResponseToolSearchOutputItemParam); } satisfies ResponseToolSearchOutputItemParam);
} }
} }
@@ -304,30 +342,77 @@ export function convertResponsesMessages<TApi extends Api>(
// ============================================================================= // =============================================================================
export function convertResponsesTools(tools: readonly Tool[], options?: ConvertResponsesToolsOptions): OpenAITool[] { export function convertResponsesTools(tools: readonly Tool[], options?: ConvertResponsesToolsOptions): OpenAITool[] {
const strict = options?.strict === undefined ? false : options.strict; const defaultStrict = options?.strict === undefined ? false : options.strict;
return tools.map( const supportsStrictMode = options?.supportsStrictMode ?? true;
(tool): OpenAIFunctionTool => ({ const supportsOpenAIGrammarTools = options?.supportsOpenAIGrammarTools ?? false;
return tools.map((tool) => {
const grammar = resolveGrammarConstrainedSampling(tool, supportsOpenAIGrammarTools);
if (grammar) {
return {
type: "custom",
name: tool.name,
description: tool.description,
format: {
type: "grammar",
syntax: grammar.format,
definition: grammar.definition,
},
...(options?.deferLoading ? { defer_loading: true } : {}),
} satisfies OpenAITool;
}
const constrainedStrict = resolveJsonSchemaStrictSampling(tool, supportsStrictMode);
const functionTool: Omit<Extract<OpenAITool, { type: "function" }>, "strict"> & {
strict?: Extract<OpenAITool, { type: "function" }>["strict"];
} = {
type: "function", type: "function",
name: tool.name, name: tool.name,
description: tool.description, description: tool.description,
parameters: tool.parameters as Record<string, unknown>, // TypeBox already generates JSON Schema parameters: tool.parameters as Record<string, unknown>, // TypeBox already generates JSON Schema
strict,
...(options?.deferLoading ? { defer_loading: true } : {}), ...(options?.deferLoading ? { defer_loading: true } : {}),
}), };
); if (supportsStrictMode) {
functionTool.strict = constrainedStrict ?? defaultStrict;
}
return functionTool as OpenAITool;
});
} }
// ============================================================================= // =============================================================================
// Stream processing // Stream processing
// ============================================================================= // =============================================================================
type StreamingToolCall = ToolCall & { partialJson: string }; type StreamingToolCall = ToolCall & {
partialJson?: string;
customInput?: {
property: string;
jsonBuffer: GrammarToolInputJsonBuffer;
};
};
function getCustomToolCallInput(block: StreamingToolCall): string {
const property = block.customInput?.property;
if (property === undefined) return "";
const value = block.arguments[property];
return typeof value === "string" ? value : "";
}
function appendCustomToolCallInput(block: StreamingToolCall, nextInput: string, close: boolean): string | undefined {
const customInput = block.customInput;
if (!customInput) return undefined;
const delta = appendGrammarToolInputJsonDelta(customInput.jsonBuffer, customInput.property, nextInput, close);
block.arguments = { [customInput.property]: nextInput };
return delta;
}
type ResponsesOutputSlot = type ResponsesOutputSlot =
| { type: "thinking"; block: ThinkingContent; contentIndex: number } | { type: "thinking"; block: ThinkingContent; contentIndex: number }
| { type: "text"; block: TextContent; contentIndex: number } | { type: "text"; block: TextContent; contentIndex: number }
| { type: "toolCall"; block: StreamingToolCall; contentIndex: number }; | { type: "toolCall"; block: StreamingToolCall; contentIndex: number };
type ToolCallOutputSlot = Extract<ResponsesOutputSlot, { type: "toolCall" }>;
export async function processResponsesStream<TApi extends Api>( export async function processResponsesStream<TApi extends Api>(
openaiStream: AsyncIterable<ResponseStreamEvent>, openaiStream: AsyncIterable<ResponseStreamEvent>,
output: AssistantMessage, output: AssistantMessage,
@@ -345,6 +430,15 @@ export async function processResponsesStream<TApi extends Api>(
const slot = outputSlots.get(outputIndex); const slot = outputSlots.get(outputIndex);
return slot?.type === type ? (slot as Extract<ResponsesOutputSlot, { type: TType }>) : undefined; return slot?.type === type ? (slot as Extract<ResponsesOutputSlot, { type: TType }>) : undefined;
}; };
const pushToolCallDelta = (slot: ToolCallOutputSlot, delta: string | undefined): void => {
if (delta === undefined) return;
stream.push({
type: "toolcall_delta",
contentIndex: slot.contentIndex,
delta,
partial: output,
});
};
const createSlot = (outputIndex: number, item: ResponseOutputItem): ResponsesOutputSlot | undefined => { const createSlot = (outputIndex: number, item: ResponseOutputItem): ResponsesOutputSlot | undefined => {
if (item.type === "reasoning") { if (item.type === "reasoning") {
const block: ThinkingContent = { type: "thinking", thinking: "" }; const block: ThinkingContent = { type: "thinking", thinking: "" };
@@ -384,6 +478,29 @@ export async function processResponsesStream<TApi extends Api>(
stream.push({ type: "toolcall_start", contentIndex: slot.contentIndex, partial: output }); stream.push({ type: "toolcall_start", contentIndex: slot.contentIndex, partial: output });
return slot; return slot;
} }
if (item.type === "custom_tool_call") {
const inputProperty = options?.grammarToolInputProperties?.get(item.name) ?? "input";
const input = item.input || "";
const block: StreamingToolCall = {
type: "toolCall",
id: `${item.call_id}|${item.id}`,
name: item.name,
arguments: { [inputProperty]: input },
customInput: {
property: inputProperty,
jsonBuffer: { input: "", started: false, closed: false },
},
};
output.content.push(block);
const slot = {
type: "toolCall",
block,
contentIndex: output.content.length - 1,
} satisfies ResponsesOutputSlot;
outputSlots.set(outputIndex, slot);
stream.push({ type: "toolcall_start", contentIndex: slot.contentIndex, partial: output });
return slot;
}
return undefined; return undefined;
}; };
const getOrCreateSlot = (outputIndex: number, item: ResponseOutputItem): ResponsesOutputSlot | undefined => { const getOrCreateSlot = (outputIndex: number, item: ResponseOutputItem): ResponsesOutputSlot | undefined => {
@@ -503,33 +620,32 @@ export async function processResponsesStream<TApi extends Api>(
}); });
} else if (event.type === "response.function_call_arguments.delta") { } else if (event.type === "response.function_call_arguments.delta") {
const slot = getSlot(event.output_index, "toolCall"); const slot = getSlot(event.output_index, "toolCall");
if (!slot) continue; if (!slot || slot.block.partialJson === undefined) continue;
slot.block.partialJson += event.delta; slot.block.partialJson += event.delta;
slot.block.arguments = parseStreamingJson(slot.block.partialJson); slot.block.arguments = parseStreamingJson(slot.block.partialJson);
stream.push({ pushToolCallDelta(slot, event.delta);
type: "toolcall_delta",
contentIndex: slot.contentIndex,
delta: event.delta,
partial: output,
});
} else if (event.type === "response.function_call_arguments.done") { } else if (event.type === "response.function_call_arguments.done") {
const slot = getSlot(event.output_index, "toolCall"); const slot = getSlot(event.output_index, "toolCall");
if (!slot) continue; if (!slot || slot.block.partialJson === undefined) continue;
const previousPartialJson = slot.block.partialJson; const previousPartialJson = slot.block.partialJson;
slot.block.partialJson = event.arguments; slot.block.partialJson = event.arguments;
slot.block.arguments = parseStreamingJson(slot.block.partialJson); slot.block.arguments = parseStreamingJson(slot.block.partialJson);
if (event.arguments.startsWith(previousPartialJson)) { if (event.arguments.startsWith(previousPartialJson)) {
const delta = event.arguments.slice(previousPartialJson.length); const delta = event.arguments.slice(previousPartialJson.length);
if (delta.length > 0) { if (delta.length > 0) pushToolCallDelta(slot, delta);
stream.push({
type: "toolcall_delta",
contentIndex: slot.contentIndex,
delta,
partial: output,
});
}
} }
} else if (event.type === "response.custom_tool_call_input.delta") {
const slot = getSlot(event.output_index, "toolCall");
if (!slot || !slot.block.customInput) continue;
pushToolCallDelta(
slot,
appendCustomToolCallInput(slot.block, getCustomToolCallInput(slot.block) + event.delta, false),
);
} else if (event.type === "response.custom_tool_call_input.done") {
const slot = getSlot(event.output_index, "toolCall");
if (!slot || !slot.block.customInput) continue;
pushToolCallDelta(slot, appendCustomToolCallInput(slot.block, event.input, true));
} else if (event.type === "response.output_item.done") { } else if (event.type === "response.output_item.done") {
const item = event.item; const item = event.item;
const slot = getOrCreateSlot(event.output_index, item); const slot = getOrCreateSlot(event.output_index, item);
@@ -557,11 +673,28 @@ export async function processResponsesStream<TApi extends Api>(
partial: output, partial: output,
}); });
outputSlots.delete(event.output_index); outputSlots.delete(event.output_index);
} else if (item.type === "function_call" && slot?.type === "toolCall") { } else if (
item.type === "function_call" &&
slot?.type === "toolCall" &&
slot.block.partialJson !== undefined
) {
slot.block.arguments = parseStreamingJson(item.arguments || slot.block.partialJson || "{}"); slot.block.arguments = parseStreamingJson(item.arguments || slot.block.partialJson || "{}");
// Finalize in-place and strip the scratch buffer so replay only // Finalize in-place and strip the scratch buffer so replay only
// carries parsed arguments. // carries parsed arguments.
delete (slot.block as { partialJson?: string }).partialJson; delete slot.block.partialJson;
stream.push({
type: "toolcall_end",
contentIndex: slot.contentIndex,
toolCall: slot.block,
partial: output,
});
outputSlots.delete(event.output_index);
} else if (item.type === "custom_tool_call" && slot?.type === "toolCall" && slot.block.customInput) {
pushToolCallDelta(
slot,
appendCustomToolCallInput(slot.block, item.input ?? getCustomToolCallInput(slot.block), true),
);
delete slot.block.customInput;
stream.push({ stream.push({
type: "toolcall_end", type: "toolcall_end",
contentIndex: slot.contentIndex, contentIndex: slot.contentIndex,
+45 -8
View File
@@ -20,6 +20,8 @@ import { formatProviderError, normalizeProviderError } from "../utils/error-body
import { AssistantMessageEventStream } from "../utils/event-stream.ts"; import { AssistantMessageEventStream } from "../utils/event-stream.ts";
import { headersToRecord } from "../utils/headers.ts"; import { headersToRecord } from "../utils/headers.ts";
import { getProviderEnvValue } from "../utils/provider-env.ts"; import { getProviderEnvValue } from "../utils/provider-env.ts";
import { retryProviderRequest } from "../utils/provider-retry.ts";
import { createGrammarToolInputProperties } from "./constrained-sampling.ts";
import { buildCopilotDynamicHeaders, hasCopilotVisionInput } from "./github-copilot-headers.ts"; import { buildCopilotDynamicHeaders, hasCopilotVisionInput } from "./github-copilot-headers.ts";
import { clampOpenAIPromptCacheKey } from "./openai-prompt-cache.ts"; import { clampOpenAIPromptCacheKey } from "./openai-prompt-cache.ts";
import { convertResponsesMessages, convertResponsesTools, processResponsesStream } from "./openai-responses-shared.ts"; import { convertResponsesMessages, convertResponsesTools, processResponsesStream } from "./openai-responses-shared.ts";
@@ -67,7 +69,10 @@ function getCompat(model: Model<"openai-responses">): Required<OpenAIResponsesCo
supportsDeveloperRole: model.compat?.supportsDeveloperRole ?? true, supportsDeveloperRole: model.compat?.supportsDeveloperRole ?? true,
sessionAffinityFormat: model.compat?.sessionAffinityFormat ?? detectSessionAffinityFormat(model), sessionAffinityFormat: model.compat?.sessionAffinityFormat ?? detectSessionAffinityFormat(model),
supportsLongCacheRetention: model.compat?.supportsLongCacheRetention ?? true, supportsLongCacheRetention: model.compat?.supportsLongCacheRetention ?? true,
supportsStrictMode: model.compat?.supportsStrictMode ?? false,
supportsOpenAIGrammarTools: model.compat?.supportsOpenAIGrammarTools ?? false,
supportsToolSearch: model.compat?.supportsToolSearch ?? false, supportsToolSearch: model.compat?.supportsToolSearch ?? false,
supportsExplicitPromptCacheMode: model.compat?.supportsExplicitPromptCacheMode ?? false,
}; };
} }
@@ -125,8 +130,13 @@ export const stream: StreamFunction<"openai-responses", OpenAIResponsesOptions>
const apiKey = getClientApiKey(model.provider, options?.apiKey, options?.headers); const apiKey = getClientApiKey(model.provider, options?.apiKey, options?.headers);
const cacheRetention = resolveCacheRetention(options?.cacheRetention, options?.env); const cacheRetention = resolveCacheRetention(options?.cacheRetention, options?.env);
const cacheSessionId = cacheRetention === "none" ? undefined : options?.sessionId; const cacheSessionId = cacheRetention === "none" ? undefined : options?.sessionId;
const compat = getCompat(model);
const grammarToolInputProperties = createGrammarToolInputProperties(
context.tools,
compat.supportsOpenAIGrammarTools,
);
const client = createClient(model, context, apiKey, options?.headers, cacheSessionId); const client = createClient(model, context, apiKey, options?.headers, cacheSessionId);
let params = buildParams(model, context, options); let params = buildParams(model, context, options, compat, grammarToolInputProperties);
const nextParams = await options?.onPayload?.(params, model); const nextParams = await options?.onPayload?.(params, model);
if (nextParams !== undefined) { if (nextParams !== undefined) {
params = nextParams as ResponseCreateParamsStreaming; params = nextParams as ResponseCreateParamsStreaming;
@@ -134,14 +144,22 @@ export const stream: StreamFunction<"openai-responses", OpenAIResponsesOptions>
const requestOptions = { const requestOptions = {
...(options?.signal ? { signal: options.signal } : {}), ...(options?.signal ? { signal: options.signal } : {}),
...(options?.timeoutMs !== undefined ? { timeout: options.timeoutMs } : {}), ...(options?.timeoutMs !== undefined ? { timeout: options.timeoutMs } : {}),
maxRetries: options?.maxRetries ?? 0, maxRetries: 0,
}; };
const { data: openaiStream, response } = await client.responses.create(params, requestOptions).withResponse(); const { data: openaiStream, response } = await retryProviderRequest(
() => client.responses.create(params, requestOptions).withResponse(),
{
maxRetries: options?.maxRetries,
maxRetryDelayMs: options?.maxRetryDelayMs,
signal: options?.signal,
},
);
await options?.onResponse?.({ status: response.status, headers: headersToRecord(response.headers) }, model); await options?.onResponse?.({ status: response.status, headers: headersToRecord(response.headers) }, model);
stream.push({ type: "start", partial: output }); stream.push({ type: "start", partial: output });
await processResponsesStream(openaiStream, output, stream, model, { await processResponsesStream(openaiStream, output, stream, model, {
serviceTier: options?.serviceTier, serviceTier: options?.serviceTier,
grammarToolInputProperties,
applyServiceTierPricing: (usage, serviceTier) => applyServiceTierPricing(usage, serviceTier, model), applyServiceTierPricing: (usage, serviceTier) => applyServiceTierPricing(usage, serviceTier, model),
}); });
@@ -158,8 +176,9 @@ export const stream: StreamFunction<"openai-responses", OpenAIResponsesOptions>
} catch (error) { } catch (error) {
for (const block of output.content) { for (const block of output.content) {
delete (block as { index?: number }).index; delete (block as { index?: number }).index;
// partialJson is only a streaming scratch buffer; never persist it. // Streaming scratch buffers are only used during parsing; never persist them.
delete (block as { partialJson?: string }).partialJson; delete (block as { partialJson?: string }).partialJson;
delete (block as { customInput?: unknown }).customInput;
} }
output.stopReason = options?.signal?.aborted ? "aborted" : "error"; output.stopReason = options?.signal?.aborted ? "aborted" : "error";
output.errorMessage = formatOpenAIResponsesError(error); output.errorMessage = formatOpenAIResponsesError(error);
@@ -230,20 +249,35 @@ function createClient(
}); });
} }
function buildParams(model: Model<"openai-responses">, context: Context, options?: OpenAIResponsesOptions) { function buildParams(
const compat = getCompat(model); model: Model<"openai-responses">,
context: Context,
options: OpenAIResponsesOptions | undefined,
compat: Required<OpenAIResponsesCompat> = getCompat(model),
grammarToolInputProperties: ReadonlyMap<string, string> = createGrammarToolInputProperties(
context.tools,
compat.supportsOpenAIGrammarTools,
),
) {
const toolPlacement = splitDeferredTools(context, compat.supportsToolSearch); const toolPlacement = splitDeferredTools(context, compat.supportsToolSearch);
const messages = convertResponsesMessages(model, context, OPENAI_TOOL_CALL_PROVIDERS, { const messages = convertResponsesMessages(model, context, OPENAI_TOOL_CALL_PROVIDERS, {
grammarToolInputProperties,
deferredTools: toolPlacement.deferred, deferredTools: toolPlacement.deferred,
toolOptions: {
supportsStrictMode: compat.supportsStrictMode,
supportsOpenAIGrammarTools: compat.supportsOpenAIGrammarTools,
},
}); });
const cacheRetention = resolveCacheRetention(options?.cacheRetention, options?.env); const cacheRetention = resolveCacheRetention(options?.cacheRetention, options?.env);
const params: ResponseCreateParamsStreaming = { const disableImplicitPromptCache = cacheRetention === "none" && compat.supportsExplicitPromptCacheMode;
const params: ResponseCreateParamsStreaming & { prompt_cache_options?: { mode: "explicit" } } = {
model: model.id, model: model.id,
input: messages, input: messages,
stream: true, stream: true,
prompt_cache_key: cacheRetention === "none" ? undefined : clampOpenAIPromptCacheKey(options?.sessionId), prompt_cache_key: cacheRetention === "none" ? undefined : clampOpenAIPromptCacheKey(options?.sessionId),
prompt_cache_retention: getPromptCacheRetention(compat, cacheRetention), prompt_cache_retention: getPromptCacheRetention(compat, cacheRetention),
prompt_cache_options: disableImplicitPromptCache ? { mode: "explicit" } : undefined,
store: false, store: false,
}; };
@@ -260,7 +294,10 @@ function buildParams(model: Model<"openai-responses">, context: Context, options
} }
if (toolPlacement.immediate.length > 0) { if (toolPlacement.immediate.length > 0) {
params.tools = convertResponsesTools(toolPlacement.immediate); params.tools = convertResponsesTools(toolPlacement.immediate, {
supportsStrictMode: compat.supportsStrictMode,
supportsOpenAIGrammarTools: compat.supportsOpenAIGrammarTools,
});
} }
if (options?.toolChoice !== undefined) { if (options?.toolChoice !== undefined) {
+13 -4
View File
@@ -18,6 +18,7 @@ import type {
} from "../types.ts"; } from "../types.ts";
import { formatProviderError, normalizeProviderError } from "../utils/error-body.ts"; import { formatProviderError, normalizeProviderError } from "../utils/error-body.ts";
import { headersToRecord, providerHeadersToRecord } from "../utils/headers.ts"; import { headersToRecord, providerHeadersToRecord } from "../utils/headers.ts";
import { retryProviderRequest } from "../utils/provider-retry.ts";
import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts"; import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts";
interface OpenRouterGeneratedImage { interface OpenRouterGeneratedImage {
@@ -64,11 +65,19 @@ export const generateImages: ImagesFunction<"openrouter-images", ImagesOptions>
const requestOptions = { const requestOptions = {
...(options?.signal ? { signal: options.signal } : {}), ...(options?.signal ? { signal: options.signal } : {}),
...(options?.timeoutMs !== undefined ? { timeout: options.timeoutMs } : {}), ...(options?.timeoutMs !== undefined ? { timeout: options.timeoutMs } : {}),
maxRetries: options?.maxRetries ?? 0, maxRetries: 0,
}; };
const { data: response, response: rawResponse } = await client.chat.completions const { data: response, response: rawResponse } = await retryProviderRequest(
.create(params as unknown as ChatCompletionCreateParamsNonStreaming, requestOptions) () =>
.withResponse(); client.chat.completions
.create(params as unknown as ChatCompletionCreateParamsNonStreaming, requestOptions)
.withResponse(),
{
maxRetries: options?.maxRetries,
maxRetryDelayMs: options?.maxRetryDelayMs,
signal: options?.signal,
},
);
await options?.onResponse?.({ status: rawResponse.status, headers: headersToRecord(rawResponse.headers) }, model); await options?.onResponse?.({ status: rawResponse.status, headers: headersToRecord(rawResponse.headers) }, model);
const imageResponse = response as OpenRouterImageGenerationResponse; const imageResponse = response as OpenRouterImageGenerationResponse;
+1 -1
View File
@@ -47,7 +47,7 @@ export const loadOpenRouterOAuth = async (): Promise<OAuthAuth> => {
if (bundledLoaders) return bundledLoaders.openrouter(); if (bundledLoaders) return bundledLoaders.openrouter();
return ((await importOAuthModule("./openrouter.ts")) as { openRouterOAuth: OAuthAuth }).openRouterOAuth; return ((await importOAuthModule("./openrouter.ts")) as { openRouterOAuth: OAuthAuth }).openRouterOAuth;
}; };
export const loadKimiCodingOAuth = async (): Promise<OAuthAuth> => { export const loadKimiCodingOAuth = async (): Promise<OAuthAuth> => {
if (bundledLoaders) return bundledLoaders.kimiCoding(); if (bundledLoaders) return bundledLoaders.kimiCoding();
return ((await importOAuthModule("./kimi-coding.ts")) as { kimiCodingOAuth: OAuthAuth }).kimiCodingOAuth; return ((await importOAuthModule("./kimi-coding.ts")) as { kimiCodingOAuth: OAuthAuth }).kimiCodingOAuth;
+44 -46
View File
@@ -1,8 +1,9 @@
/** /**
* Radius gateway OAuth flow. * Radius gateway OAuth flow.
* *
* Radius is a pi-messages gateway. OAuth endpoints are discovered from the * Radius is a pi-messages gateway. OAuth client APIs live on the configured
* gateway (`/v1/oauth`); model catalog loading is owned by the Radius provider. * gateway; only the interactive browser authorization endpoint is discovered.
* Model catalog loading is owned by the Radius provider.
* *
* NOTE: This module uses node:http for the OAuth callback server. * NOTE: This module uses node:http for the OAuth callback server.
* It is only intended for CLI use, not browser environments. * It is only intended for CLI use, not browser environments.
@@ -29,29 +30,23 @@ const REDIRECT_URI = `http://${CALLBACK_HOST}:${CALLBACK_PORT}${CALLBACK_PATH}`;
const TOKEN_EXPIRY_SKEW_MS = 60_000; const TOKEN_EXPIRY_SKEW_MS = 60_000;
const LOGIN_METHOD_BROWSER = "browser"; const LOGIN_METHOD_BROWSER = "browser";
const LOGIN_METHOD_DEVICE_CODE = "device-code"; const LOGIN_METHOD_DEVICE_CODE = "device-code";
const OAUTH_CLIENT_ID = "pi-gateway";
const OAUTH_SCOPE = "gateway offline_access";
const OAUTH_DEVICE_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code";
type RadiusOAuthConfig = { type RadiusOAuthDiscovery = {
issuer: string;
authorizationEndpoint: string; authorizationEndpoint: string;
tokenEndpoint: string;
deviceAuthorizationEndpoint: string;
deviceAuthorizationEventsEndpoint: string;
verificationEndpoint: string;
clientId: string;
scope: string;
deviceCodeGrantType: string;
}; };
type DeviceAuthorizationResponse = { type DeviceAuthorizationResponse = {
device_code: string; device_code: string;
user_code: string; user_code: string;
verification_uri?: string; verification_uri: string;
verification_uri_complete?: string;
expires_in: number; expires_in: number;
interval?: number; interval?: number;
}; };
async function loadRadiusOAuthConfig(gateway: string): Promise<RadiusOAuthConfig> { async function loadRadiusOAuthDiscovery(gateway: string): Promise<RadiusOAuthDiscovery> {
const response = await fetch(new URL("/v1/oauth", gateway), { const response = await fetch(new URL("/v1/oauth", gateway), {
headers: { accept: "application/json" }, headers: { accept: "application/json" },
}); });
@@ -62,7 +57,11 @@ async function loadRadiusOAuthConfig(gateway: string): Promise<RadiusOAuthConfig
); );
} }
return (await response.json()) as RadiusOAuthConfig; const discovery = (await response.json()) as Partial<RadiusOAuthDiscovery>;
if (typeof discovery.authorizationEndpoint !== "string") {
throw new Error(`Invalid Radius OAuth config from ${gateway}`);
}
return { authorizationEndpoint: discovery.authorizationEndpoint };
} }
class OAuthResponseError extends Error { class OAuthResponseError extends Error {
@@ -100,13 +99,13 @@ async function readOAuthResponseError(response: Response, message: string): Prom
} }
async function requestOAuthToken( async function requestOAuthToken(
oauth: RadiusOAuthConfig, gateway: string,
body: URLSearchParams, body: URLSearchParams,
signal?: AbortSignal, signal?: AbortSignal,
): Promise<OAuthCredential> { ): Promise<OAuthCredential> {
let response: Response; let response: Response;
try { try {
response = await fetch(oauth.tokenEndpoint, { response = await fetch(new URL("/v1/oauth/token", gateway), {
method: "POST", method: "POST",
headers: { accept: "application/json", "content-type": "application/x-www-form-urlencoded" }, headers: { accept: "application/json", "content-type": "application/x-www-form-urlencoded" },
body, body,
@@ -220,15 +219,19 @@ function startOAuthCallbackServer(
}); });
} }
async function loginWithBrowser(oauth: RadiusOAuthConfig, interaction: AuthInteraction): Promise<OAuthCredential> { async function loginWithBrowser(
gateway: string,
authorizationEndpoint: string,
interaction: AuthInteraction,
): Promise<OAuthCredential> {
const { verifier, challenge } = await generatePKCE(); const { verifier, challenge } = await generatePKCE();
const state = crypto.randomUUID(); const state = crypto.randomUUID();
const authorizeUrl = new URL(oauth.authorizationEndpoint); const authorizeUrl = new URL(authorizationEndpoint);
authorizeUrl.search = new URLSearchParams({ authorizeUrl.search = new URLSearchParams({
response_type: "code", response_type: "code",
client_id: oauth.clientId, client_id: OAUTH_CLIENT_ID,
redirect_uri: REDIRECT_URI, redirect_uri: REDIRECT_URI,
scope: oauth.scope, scope: OAUTH_SCOPE,
code_challenge: challenge, code_challenge: challenge,
code_challenge_method: "S256", code_challenge_method: "S256",
handoff: "url", handoff: "url",
@@ -252,10 +255,10 @@ async function loginWithBrowser(oauth: RadiusOAuthConfig, interaction: AuthInter
throw new Error("OAuth callback did not complete."); throw new Error("OAuth callback did not complete.");
} }
return await requestOAuthToken( return await requestOAuthToken(
oauth, gateway,
new URLSearchParams({ new URLSearchParams({
grant_type: "authorization_code", grant_type: "authorization_code",
client_id: oauth.clientId, client_id: OAUTH_CLIENT_ID,
redirect_uri: REDIRECT_URI, redirect_uri: REDIRECT_URI,
code, code,
code_verifier: verifier, code_verifier: verifier,
@@ -268,15 +271,15 @@ async function loginWithBrowser(oauth: RadiusOAuthConfig, interaction: AuthInter
} }
async function requestDeviceAuthorization( async function requestDeviceAuthorization(
oauth: RadiusOAuthConfig, gateway: string,
signal: AbortSignal | undefined, signal: AbortSignal | undefined,
): Promise<DeviceAuthorizationResponse> { ): Promise<DeviceAuthorizationResponse> {
let response: Response; let response: Response;
try { try {
response = await fetch(oauth.deviceAuthorizationEndpoint, { response = await fetch(new URL("/v1/oauth/device", gateway), {
method: "POST", method: "POST",
headers: { accept: "application/json", "content-type": "application/x-www-form-urlencoded" }, headers: { accept: "application/json", "content-type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({ client_id: oauth.clientId, scope: oauth.scope }), body: new URLSearchParams({ client_id: OAUTH_CLIENT_ID, scope: OAUTH_SCOPE }),
signal, signal,
}); });
} catch (error) { } catch (error) {
@@ -291,7 +294,7 @@ async function requestDeviceAuthorization(
} }
const data = (await response.json()) as Partial<DeviceAuthorizationResponse>; const data = (await response.json()) as Partial<DeviceAuthorizationResponse>;
if (!data.device_code || !data.user_code || !data.expires_in) { if (!data.device_code || !data.user_code || !data.verification_uri || !data.expires_in) {
throw new Error("Radius OAuth device authorization response is missing required fields"); throw new Error("Radius OAuth device authorization response is missing required fields");
} }
@@ -299,18 +302,17 @@ async function requestDeviceAuthorization(
device_code: data.device_code, device_code: data.device_code,
user_code: data.user_code, user_code: data.user_code,
verification_uri: data.verification_uri, verification_uri: data.verification_uri,
verification_uri_complete: data.verification_uri_complete,
expires_in: data.expires_in, expires_in: data.expires_in,
interval: data.interval, interval: data.interval,
}; };
} }
async function loginWithDeviceCode(oauth: RadiusOAuthConfig, interaction: AuthInteraction): Promise<OAuthCredential> { async function loginWithDeviceCode(gateway: string, interaction: AuthInteraction): Promise<OAuthCredential> {
const device = await requestDeviceAuthorization(oauth, interaction.signal); const device = await requestDeviceAuthorization(gateway, interaction.signal);
interaction.notify({ interaction.notify({
type: "device_code", type: "device_code",
userCode: device.user_code, userCode: device.user_code,
verificationUri: device.verification_uri || oauth.verificationEndpoint, verificationUri: device.verification_uri,
intervalSeconds: device.interval, intervalSeconds: device.interval,
expiresInSeconds: device.expires_in, expiresInSeconds: device.expires_in,
}); });
@@ -322,10 +324,10 @@ async function loginWithDeviceCode(oauth: RadiusOAuthConfig, interaction: AuthIn
poll: async () => { poll: async () => {
try { try {
const credentials = await requestOAuthToken( const credentials = await requestOAuthToken(
oauth, gateway,
new URLSearchParams({ new URLSearchParams({
grant_type: oauth.deviceCodeGrantType, grant_type: OAUTH_DEVICE_CODE_GRANT_TYPE,
client_id: oauth.clientId, client_id: OAUTH_CLIENT_ID,
device_code: device.device_code, device_code: device.device_code,
}), }),
interaction.signal, interaction.signal,
@@ -364,7 +366,6 @@ export function createRadiusOAuth(options: RadiusOAuthOptions): OAuthAuth {
name: options.name, name: options.name,
async login(interaction): Promise<OAuthCredential> { async login(interaction): Promise<OAuthCredential> {
const oauth = await loadRadiusOAuthConfig(gateway);
const loginMethod = await interaction.prompt({ const loginMethod = await interaction.prompt({
type: "select", type: "select",
message: `Sign in to ${options.name}:`, message: `Sign in to ${options.name}:`,
@@ -377,25 +378,22 @@ export function createRadiusOAuth(options: RadiusOAuthOptions): OAuthAuth {
], ],
}); });
let credential: OAuthCredential;
if (loginMethod === LOGIN_METHOD_DEVICE_CODE) { if (loginMethod === LOGIN_METHOD_DEVICE_CODE) {
credential = await loginWithDeviceCode(oauth, interaction); return loginWithDeviceCode(gateway, interaction);
} else if (loginMethod === LOGIN_METHOD_BROWSER) {
credential = await loginWithBrowser(oauth, interaction);
} else {
throw new Error(`Unknown ${options.name} sign-in method: ${loginMethod}`);
} }
if (loginMethod === LOGIN_METHOD_BROWSER) {
return credential; const discovery = await loadRadiusOAuthDiscovery(gateway);
return loginWithBrowser(gateway, discovery.authorizationEndpoint, interaction);
}
throw new Error(`Unknown ${options.name} sign-in method: ${loginMethod}`);
}, },
async refresh(credential, signal): Promise<OAuthCredential> { async refresh(credential, signal): Promise<OAuthCredential> {
const oauth = await loadRadiusOAuthConfig(gateway);
const refreshed = await requestOAuthToken( const refreshed = await requestOAuthToken(
oauth, gateway,
new URLSearchParams({ new URLSearchParams({
grant_type: "refresh_token", grant_type: "refresh_token",
client_id: oauth.clientId, client_id: OAUTH_CLIENT_ID,
refresh_token: credential.refresh, refresh_token: credential.refresh,
}), }),
signal, signal,
+10 -1
View File
@@ -1,4 +1,5 @@
import type { ProviderEnv } from "../types.ts"; import type { ProviderEnv } from "../types.ts";
import { formatThrownValue } from "../utils/diagnostics.ts";
import type { import type {
ApiKeyAuth, ApiKeyAuth,
ApiKeyCredential, ApiKeyCredential,
@@ -22,12 +23,20 @@ export class ModelsError extends Error {
readonly code: ModelsErrorCode; readonly code: ModelsErrorCode;
constructor(code: ModelsErrorCode, message: string, options?: { cause?: unknown }) { constructor(code: ModelsErrorCode, message: string, options?: { cause?: unknown }) {
super(message, options); super(withCauseDetail(message, options?.cause), options);
this.name = "ModelsError"; this.name = "ModelsError";
this.code = code; this.code = code;
} }
} }
/** Callers surface `error.message` only, so keep the underlying reason in it. */
function withCauseDetail(message: string, cause: unknown): string {
if (cause === undefined || cause === null) return message;
const detail = formatThrownValue(cause).trim();
if (!detail || message.includes(detail)) return message;
return `${message}: ${detail}`;
}
/** /**
* Auth resolution shared by the `Models` and `ImagesModels` collections. * Auth resolution shared by the `Models` and `ImagesModels` collections.
* A stored credential owns the provider: ambient/env is consulted only when * A stored credential owns the provider: ambient/env is consulted only when
+9 -3
View File
@@ -26,6 +26,10 @@ if (typeof process !== "undefined" && (process.versions?.node || process.version
import type { KnownProvider, ProviderEnv } from "./types.ts"; import type { KnownProvider, ProviderEnv } from "./types.ts";
import { getProviderEnvValue } from "./utils/provider-env.ts"; import { getProviderEnvValue } from "./utils/provider-env.ts";
export const ANTHROPIC_AUTH_TOKEN_ENV = "ANTHROPIC_AUTH_TOKEN";
export const ANTHROPIC_OAUTH_TOKEN_ENV = "ANTHROPIC_OAUTH_TOKEN";
export const ANTHROPIC_API_KEY_ENV = "ANTHROPIC_API_KEY";
let cachedVertexAdcCredentialsExists: boolean | null = null; let cachedVertexAdcCredentialsExists: boolean | null = null;
function hasVertexAdcCredentials(env?: ProviderEnv): boolean { function hasVertexAdcCredentials(env?: ProviderEnv): boolean {
@@ -66,9 +70,10 @@ function getApiKeyEnvVars(provider: string): readonly string[] | undefined {
return ["COPILOT_GITHUB_TOKEN"]; return ["COPILOT_GITHUB_TOKEN"];
} }
// ANTHROPIC_OAUTH_TOKEN takes precedence over ANTHROPIC_API_KEY // ANTHROPIC_AUTH_TOKEN participates in env discovery/status, but
// getEnvApiKey() skips it because requests must pass it as Authorization: Bearer.
if (provider === "anthropic") { if (provider === "anthropic") {
return ["ANTHROPIC_OAUTH_TOKEN", "ANTHROPIC_API_KEY"]; return [ANTHROPIC_AUTH_TOKEN_ENV, ANTHROPIC_OAUTH_TOKEN_ENV, ANTHROPIC_API_KEY_ENV];
} }
const envMap: Record<string, string> = { const envMap: Record<string, string> = {
@@ -139,7 +144,8 @@ export function getEnvApiKey(provider: string, env?: ProviderEnv): string | unde
export function getEnvApiKey(provider: string, env?: ProviderEnv): string | undefined { export function getEnvApiKey(provider: string, env?: ProviderEnv): string | undefined {
const envKeys = findEnvKeys(provider, env); const envKeys = findEnvKeys(provider, env);
if (envKeys?.[0]) { if (envKeys?.[0]) {
return getProviderEnvValue(envKeys[0], env); const apiKeyEnv = provider === "anthropic" ? envKeys.find((key) => key !== ANTHROPIC_AUTH_TOKEN_ENV) : envKeys[0];
if (apiKeyEnv) return getProviderEnvValue(apiKeyEnv, env);
} }
// Vertex AI supports either an explicit API key or Application Default Credentials. // Vertex AI supports either an explicit API key or Application Default Credentials.
+15
View File
@@ -230,6 +230,21 @@ export const IMAGE_MODELS = {
cacheWrite: 0, cacheWrite: 0,
}, },
} satisfies ImagesModel<"openrouter-images">, } satisfies ImagesModel<"openrouter-images">,
"microsoft/mai-image-2.5-pro": {
id: "microsoft/mai-image-2.5-pro",
name: "Microsoft: MAI-Image-2.5 Pro",
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["text", "image"],
output: ["image"],
cost: {
input: 5,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
} satisfies ImagesModel<"openrouter-images">,
"openai/gpt-5-image": { "openai/gpt-5-image": {
id: "openai/gpt-5-image", id: "openai/gpt-5-image",
name: "OpenAI: GPT-5 Image", name: "OpenAI: GPT-5 Image",
+5
View File
@@ -6,6 +6,11 @@ export interface ModelsStoreEntry {
lastModified?: number; lastModified?: number;
/** Unix timestamp of the last completed remote check. */ /** Unix timestamp of the last completed remote check. */
checkedAt?: number; checkedAt?: number;
/**
* Opaque validator from the remote catalog's ETag header, stored verbatim
* (quotes included) and echoed back as If-None-Match.
*/
etag?: string;
} }
/** Persistent model catalogs keyed by provider ID. */ /** Persistent model catalogs keyed by provider ID. */
+5 -3
View File
@@ -9,6 +9,7 @@ import { azureOpenAIResponsesProvider } from "./azure-openai-responses.ts";
import { cerebrasProvider } from "./cerebras.ts"; import { cerebrasProvider } from "./cerebras.ts";
import { cloudflareAIGatewayProvider } from "./cloudflare-ai-gateway.ts"; import { cloudflareAIGatewayProvider } from "./cloudflare-ai-gateway.ts";
import { cloudflareWorkersAIProvider } from "./cloudflare-workers-ai.ts"; import { cloudflareWorkersAIProvider } from "./cloudflare-workers-ai.ts";
import modelDataManifest from "./data/.manifest.json" with { type: "json" };
import { deepseekProvider } from "./deepseek.ts"; import { deepseekProvider } from "./deepseek.ts";
import { fireworksProvider } from "./fireworks.ts"; import { fireworksProvider } from "./fireworks.ts";
import { githubCopilotProvider } from "./github-copilot.ts"; import { githubCopilotProvider } from "./github-copilot.ts";
@@ -67,9 +68,10 @@ export function getBuiltinProviders(): BuiltinProvider[] {
return Object.keys(MODELS) as BuiltinProvider[]; return Object.keys(MODELS) as BuiltinProvider[];
} }
/** URL of a generated provider catalog, used to compare its mtime with remote catalogs during development. */ /** Generation timestamp shared by all built-in provider catalogs. */
export function getBuiltinModelDataUrl(provider: BuiltinProvider): URL { export function getBuiltinModelDataGeneratedAt(): number | undefined {
return new URL(`./data/${provider}.json`, import.meta.url); const generatedAt = Date.parse(modelDataManifest.generatedAt);
return Number.isNaN(generatedAt) ? undefined : generatedAt;
} }
export function getBuiltinModels<TProvider extends BuiltinProvider>( export function getBuiltinModels<TProvider extends BuiltinProvider>(
+33 -3
View File
@@ -1,17 +1,47 @@
import { anthropicMessagesApi } from "../api/anthropic-messages.lazy.ts"; import { anthropicMessagesApi } from "../api/anthropic-messages.lazy.ts";
import { envApiKeyAuth, lazyOAuth } from "../auth/helpers.ts"; import { lazyOAuth } from "../auth/helpers.ts";
import { loadAnthropicOAuth } from "../auth/oauth/load.ts"; import { loadAnthropicOAuth } from "../auth/oauth/load.ts";
import type { ApiKeyAuth } from "../auth/types.ts";
import { ANTHROPIC_API_KEY_ENV, ANTHROPIC_AUTH_TOKEN_ENV, ANTHROPIC_OAUTH_TOKEN_ENV } from "../env-api-keys.ts";
import { createProvider, type Provider } from "../models.ts"; import { createProvider, type Provider } from "../models.ts";
import { ANTHROPIC_MODELS } from "./anthropic.models.ts"; import { ANTHROPIC_MODELS } from "./anthropic.models.ts";
function anthropicApiKeyAuth(): ApiKeyAuth {
return {
name: "Anthropic API key",
login: async (interaction) => ({
type: "api_key",
key: await interaction.prompt({ type: "secret", message: "Enter Anthropic API key" }),
}),
resolve: async ({ ctx, credential }) => {
if (credential?.key) {
return { auth: { apiKey: credential.key }, env: credential.env, source: "stored credential" };
}
const authToken = await ctx.env(ANTHROPIC_AUTH_TOKEN_ENV);
if (authToken) {
return {
auth: { headers: { Authorization: `Bearer ${authToken}` } },
source: ANTHROPIC_AUTH_TOKEN_ENV,
};
}
for (const envVar of [ANTHROPIC_OAUTH_TOKEN_ENV, ANTHROPIC_API_KEY_ENV]) {
const apiKey = await ctx.env(envVar);
if (apiKey) return { auth: { apiKey }, source: envVar };
}
return undefined;
},
};
}
export function anthropicProvider(): Provider<"anthropic-messages"> { export function anthropicProvider(): Provider<"anthropic-messages"> {
return createProvider({ return createProvider({
id: "anthropic", id: "anthropic",
name: "Anthropic", name: "Anthropic",
baseUrl: "https://api.anthropic.com", baseUrl: "https://api.anthropic.com",
auth: { auth: {
// ANTHROPIC_OAUTH_TOKEN takes precedence over ANTHROPIC_API_KEY apiKey: anthropicApiKeyAuth(),
apiKey: envApiKeyAuth("Anthropic API key", ["ANTHROPIC_OAUTH_TOKEN", "ANTHROPIC_API_KEY"]),
oauth: lazyOAuth({ name: "Anthropic (Claude Pro/Max)", load: loadAnthropicOAuth }), oauth: lazyOAuth({ name: "Anthropic (Claude Pro/Max)", load: loadAnthropicOAuth }),
}, },
models: Object.values(ANTHROPIC_MODELS), models: Object.values(ANTHROPIC_MODELS),
+43 -2
View File
@@ -445,10 +445,33 @@ export interface AssistantImages {
import type { TSchema } from "typebox"; import type { TSchema } from "typebox";
/** OpenAI grammar variants for constrained sampling. */
export type GrammarFormat = "openai_lark" | "openai_regex";
export type GrammarVariants = Partial<Record<GrammarFormat, string>>;
/**
* Optional provider-side constrained sampling configs for a tool.
*
* The `json_schema` value roughly maps to the concept of `strict` in APIs which is
* implemented as json-schema constrained sampling by APIs. Grammar variants let
* callers provide provider-specific encodings of the same intended language.
*/
export type ConstrainedSamplingConfig =
| {
type: "json_schema";
strict: "prefer" | "require";
}
| {
type: "grammar";
variants: GrammarVariants;
};
export interface Tool<TParameters extends TSchema = TSchema> { export interface Tool<TParameters extends TSchema = TSchema> {
name: string; name: string;
description: string; description: string;
parameters: TParameters; parameters: TParameters;
constrainedSampling?: false | ConstrainedSamplingConfig;
} }
export interface Context { export interface Context {
@@ -522,6 +545,8 @@ export interface OpenAICompletionsCompat {
vercelGatewayRouting?: VercelGatewayRouting; vercelGatewayRouting?: VercelGatewayRouting;
/** Whether z.ai supports top-level `tool_stream: true` for streaming tool call deltas. Default: false. */ /** Whether z.ai supports top-level `tool_stream: true` for streaming tool call deltas. Default: false. */
zaiToolStream?: boolean; zaiToolStream?: boolean;
/** Whether the provider supports OpenAI custom tools with Lark/regex grammar formats. When false, grammar-constrained tools fall back to normal function tools. Default: false; the generated model catalog enables it for capable models. */
supportsOpenAIGrammarTools?: boolean;
/** Whether the provider supports the `strict` field in tool definitions. Default: true. */ /** Whether the provider supports the `strict` field in tool definitions. Default: true. */
supportsStrictMode?: boolean; supportsStrictMode?: boolean;
/** Cache control convention for prompt caching. "anthropic" applies Anthropic-style `cache_control` markers to the system prompt, last tool definition, and last user, assistant, or tool-result text content. */ /** Cache control convention for prompt caching. "anthropic" applies Anthropic-style `cache_control` markers to the system prompt, last tool definition, and last user, assistant, or tool-result text content. */
@@ -544,8 +569,14 @@ export interface OpenAIResponsesCompat {
sessionAffinityFormat?: SessionAffinityFormat; sessionAffinityFormat?: SessionAffinityFormat;
/** Whether the provider supports `prompt_cache_retention: "24h"`. Default: true. */ /** Whether the provider supports `prompt_cache_retention: "24h"`. Default: true. */
supportsLongCacheRetention?: boolean; supportsLongCacheRetention?: boolean;
/** Whether the provider supports strict JSON-schema function tools. Defaults are API-specific; generated OpenAI models enable it explicitly. */
supportsStrictMode?: boolean;
/** Whether to emit OpenAI custom tools with Lark/regex grammar formats. When false, grammar-constrained tools fall back to normal function tools. Default: false; the generated model catalog enables it for capable models. */
supportsOpenAIGrammarTools?: boolean;
/** Whether the model supports client-executed tool search for deferred tools. Default: false. */ /** Whether the model supports client-executed tool search for deferred tools. Default: false. */
supportsToolSearch?: boolean; supportsToolSearch?: boolean;
/** Whether the model accepts `prompt_cache_options` (OpenAI GPT-5.6+ explicit prompt caching). Older OpenAI models reject the parameter. Default: false. */
supportsExplicitPromptCacheMode?: boolean;
} }
/** Compatibility settings for Anthropic Messages-compatible APIs. */ /** Compatibility settings for Anthropic Messages-compatible APIs. */
@@ -594,6 +625,8 @@ export interface AnthropicMessagesCompat {
forceAdaptiveThinking?: boolean; forceAdaptiveThinking?: boolean;
/** Whether to replay empty thinking signatures as `signature: ""` instead of converting thinking to text. Default: false. */ /** Whether to replay empty thinking signatures as `signature: ""` instead of converting thinking to text. Default: false. */
allowEmptySignature?: boolean; allowEmptySignature?: boolean;
/** Whether the provider supports Anthropic strict tool schemas. Default: false; generated Anthropic models enable it explicitly. */
supportsStrictTools?: boolean;
/** /**
* Whether the provider supports deferred tools loaded by `tool_reference` * Whether the provider supports deferred tools loaded by `tool_reference`
* blocks in tool results. Default: true for first-party Anthropic models * blocks in tool results. Default: true for first-party Anthropic models
@@ -602,6 +635,12 @@ export interface AnthropicMessagesCompat {
supportsToolReferences?: boolean; supportsToolReferences?: boolean;
} }
/** Compatibility settings for Amazon Bedrock models. */
export interface BedrockCompat {
/** Whether the model supports Bedrock strict tool schemas. Default: false. */
supportsStrictMode?: boolean;
}
/** /**
* OpenRouter provider routing preferences. * OpenRouter provider routing preferences.
* Controls which upstream providers OpenRouter routes requests to. * Controls which upstream providers OpenRouter routes requests to.
@@ -727,11 +766,13 @@ export interface Model<TApi extends Api> {
/** Compatibility overrides for OpenAI-compatible APIs. If not set, auto-detected from baseUrl. */ /** Compatibility overrides for OpenAI-compatible APIs. If not set, auto-detected from baseUrl. */
compat?: TApi extends "openai-completions" compat?: TApi extends "openai-completions"
? OpenAICompletionsCompat ? OpenAICompletionsCompat
: TApi extends "openai-responses" | "openai-codex-responses" : TApi extends "openai-responses" | "azure-openai-responses" | "openai-codex-responses"
? OpenAIResponsesCompat ? OpenAIResponsesCompat
: TApi extends "anthropic-messages" : TApi extends "anthropic-messages"
? AnthropicMessagesCompat ? AnthropicMessagesCompat
: never; : TApi extends "bedrock-converse-stream"
? BedrockCompat
: never;
} }
export interface ImagesModel<TApi extends ImagesApi> export interface ImagesModel<TApi extends ImagesApi>
+8 -3
View File
@@ -69,9 +69,9 @@ function extractStatus(error: SdkErrorShape): number | undefined {
/** /**
* Probe the raw body reason, first usable hit wins, in SDK-field order: * Probe the raw body reason, first usable hit wins, in SDK-field order:
* `body` string (Mistral) `error` parsed JSON body object (`openai` SDK's * `body` string (Mistral) `error` parsed JSON body object (`openai` SDK's
* `this.error`) `$response.body` (Bedrock). Empty objects are treated as no * `this.error`) `$response.body` (Bedrock). Empty objects and unread response
* body so an empty parsed body does not surface as `"{}"`. The chosen body is * streams are treated as no body so they do not surface as `"{}"` or serialized
* truncated to the cap. * stream internals. The chosen body is truncated to the cap.
*/ */
function extractBody(error: SdkErrorShape): string | undefined { function extractBody(error: SdkErrorShape): string | undefined {
const bodyText = pickBodyText(error); const bodyText = pickBodyText(error);
@@ -86,10 +86,15 @@ function pickBodyText(error: SdkErrorShape): string | undefined {
if (isNonEmptyObject(error.error)) return safeJsonStringify(error.error); if (isNonEmptyObject(error.error)) return safeJsonStringify(error.error);
const responseBody = error.$response?.body; const responseBody = error.$response?.body;
if (typeof responseBody === "string") return responseBody; if (typeof responseBody === "string") return responseBody;
if (isReadableStreamLike(responseBody)) return undefined;
if (isNonEmptyObject(responseBody)) return safeJsonStringify(responseBody); if (isNonEmptyObject(responseBody)) return safeJsonStringify(responseBody);
return undefined; return undefined;
} }
function isReadableStreamLike(value: unknown): boolean {
return typeof value === "object" && value !== null && "pipe" in value && typeof value.pipe === "function";
}
function isNonEmptyObject(value: unknown): boolean { function isNonEmptyObject(value: unknown): boolean {
return typeof value === "object" && value !== null && Object.keys(value).length > 0; return typeof value === "object" && value !== null && Object.keys(value).length > 0;
} }
+125
View File
@@ -0,0 +1,125 @@
const DEFAULT_MAX_RETRY_DELAY_MS = 60_000;
interface ProviderRetryOptions {
maxRetries?: number;
maxRetryDelayMs?: number;
signal?: AbortSignal;
}
interface ProviderError extends Error {
status: number | undefined;
headers: Headers | undefined;
}
function isProviderError(error: unknown): error is ProviderError {
if (!(error instanceof Error) || !("status" in error) || !("headers" in error)) return false;
return (
(error.status === undefined || typeof error.status === "number") &&
(error.headers === undefined || error.headers instanceof Headers)
);
}
/** Mirrors the pinned OpenAI/Anthropic SDK retry policy; review when either SDK is upgraded. */
function isRetryableProviderError(error: ProviderError): boolean {
const shouldRetry = error.headers?.get("x-should-retry");
if (shouldRetry === "true") return true;
if (shouldRetry === "false") return false;
if (error.status === undefined) return true;
return (
error.status === 408 ||
error.status === 409 ||
error.status === 429 ||
(typeof error.status === "number" && error.status >= 500)
);
}
function validateServerRetryDelayMs(
delayMs: number,
maxRetryDelayMs: number | undefined,
providerErrorMessage: string,
): number {
const maxDelayMs = maxRetryDelayMs ?? DEFAULT_MAX_RETRY_DELAY_MS;
if (maxDelayMs > 0 && delayMs > maxDelayMs) {
throw new Error(
`Server requested ${Math.ceil(delayMs / 1000)}s retry delay (max: ${Math.ceil(maxDelayMs / 1000)}s). ${providerErrorMessage}`,
);
}
return delayMs;
}
function getRetryDelayMs(error: ProviderError, retryIndex: number, maxRetryDelayMs: number | undefined): number {
const retryAfterMs = error.headers?.get("retry-after-ms");
if (retryAfterMs) {
const value = Number.parseFloat(retryAfterMs);
if (!Number.isNaN(value)) return validateServerRetryDelayMs(value, maxRetryDelayMs, error.message);
}
const retryAfter = error.headers?.get("retry-after");
if (retryAfter) {
const seconds = Number.parseFloat(retryAfter);
const delayMs = Number.isNaN(seconds) ? Date.parse(retryAfter) - Date.now() : seconds * 1000;
return validateServerRetryDelayMs(delayMs, maxRetryDelayMs, error.message);
}
const exponentialDelay = Math.min(0.5 * 2 ** retryIndex, 8) * 1000;
return exponentialDelay * (1 - Math.random() * 0.25);
}
function createAbortError(): Error {
const error = new Error("Request aborted");
error.name = "AbortError";
return error;
}
function abortableSleep(ms: number, signal?: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
if (signal?.aborted) {
reject(createAbortError());
return;
}
const onAbort = () => {
clearTimeout(timeout);
reject(createAbortError());
};
const timeout = setTimeout(
() => {
signal?.removeEventListener("abort", onAbort);
resolve();
},
Math.max(0, ms),
);
signal?.addEventListener("abort", onAbort, { once: true });
});
}
/**
* Reproduce the retry behavior used by the OpenAI and Anthropic SDKs while making
* their backoff sleep interruptible. Their built-in retry timers ignore the
* request AbortSignal, so callers must invoke the SDK with `maxRetries: 0` and
* wrap the request with this helper. Provider-requested delays above
* `maxRetryDelayMs` fail immediately (60 seconds by default); set it to zero to
* disable the limit.
*/
export async function retryProviderRequest<T>(
request: () => Promise<T>,
options: ProviderRetryOptions = {},
): Promise<T> {
const maxRetries = options.maxRetries ?? 0;
let retriesRemaining = maxRetries;
for (;;) {
try {
// Each retry is a fresh SDK request, so X-Stainless-Retry-Count remains zero.
return await request();
} catch (error) {
if (options.signal?.aborted) throw createAbortError();
if (retriesRemaining <= 0 || !isProviderError(error) || !isRetryableProviderError(error)) throw error;
const retryIndex = maxRetries - retriesRemaining;
retriesRemaining--;
await abortableSleep(getRetryDelayMs(error, retryIndex, options.maxRetryDelayMs), options.signal);
}
}
}
@@ -5,13 +5,17 @@ import type { Api, Model } from "../src/types.ts";
const EXPECTED_CURRENT_ADAPTIVE_THINKING_MODELS = [ const EXPECTED_CURRENT_ADAPTIVE_THINKING_MODELS = [
"anthropic/claude-fable-5", "anthropic/claude-fable-5",
"anthropic/claude-opus-4-8", "anthropic/claude-opus-4-8",
"anthropic/claude-opus-5",
"anthropic/claude-sonnet-5", "anthropic/claude-sonnet-5",
"cloudflare-ai-gateway/claude-fable-5", "cloudflare-ai-gateway/claude-fable-5",
"kimi-coding/kimi-for-coding", "kimi-coding/kimi-for-coding",
"kimi-coding/k3", "kimi-coding/k3",
"kimi-coding/kimi-for-coding-highspeed", "kimi-coding/kimi-for-coding-highspeed",
"opencode/claude-opus-4-8", "opencode/claude-opus-4-8",
"opencode/claude-opus-5",
"vercel-ai-gateway/anthropic/claude-opus-4.8", "vercel-ai-gateway/anthropic/claude-opus-4.8",
"vercel-ai-gateway/anthropic/claude-opus-5",
"vercel-ai-gateway/anthropic/claude-opus-5-fast",
"vercel-ai-gateway/anthropic/claude-sonnet-5", "vercel-ai-gateway/anthropic/claude-sonnet-5",
]; ];
@@ -30,7 +34,7 @@ describe("Anthropic adaptive thinking model metadata", () => {
expect(flaggedModels).toEqual(expect.arrayContaining([...EXPECTED_CURRENT_ADAPTIVE_THINKING_MODELS].sort())); expect(flaggedModels).toEqual(expect.arrayContaining([...EXPECTED_CURRENT_ADAPTIVE_THINKING_MODELS].sort()));
expect(flaggedModels).toEqual( expect(flaggedModels).toEqual(
flaggedModels.filter((modelId) => flaggedModels.filter((modelId) =>
/(opus[-.]4[-.][678]|sonnet[-.]4[-.]6|sonnet[-.]5|fable[-.]5|kimi-coding\/)/.test(modelId), /(opus[-.](4[-.][678]|5)|sonnet[-.]4[-.]6|sonnet[-.]5|fable[-.]5|kimi-coding\/)/.test(modelId),
), ),
); );
}); });
@@ -0,0 +1,183 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { stream as streamAnthropic } from "../src/api/anthropic-messages.ts";
import { ANTHROPIC_AUTH_TOKEN_ENV, ANTHROPIC_OAUTH_TOKEN_ENV } from "../src/env-api-keys.ts";
import { createModels } from "../src/models.ts";
import { anthropicProvider } from "../src/providers/anthropic.ts";
import type { Context, Model } from "../src/types.ts";
const mockState = vi.hoisted(() => ({
constructorOpts: undefined as Record<string, unknown> | undefined,
createParams: undefined as Record<string, unknown> | undefined,
}));
vi.mock("@anthropic-ai/sdk", () => {
function createSseResponse(): Response {
const body = [
`event: message_start\ndata: ${JSON.stringify({
type: "message_start",
message: {
id: "msg_test",
usage: { input_tokens: 1, output_tokens: 0 },
},
})}\n`,
`event: message_delta\ndata: ${JSON.stringify({
type: "message_delta",
delta: { stop_reason: "end_turn" },
usage: { output_tokens: 1 },
})}\n`,
`event: message_stop\ndata: ${JSON.stringify({ type: "message_stop" })}\n`,
].join("\n");
return new Response(body, {
status: 200,
headers: { "content-type": "text/event-stream" },
});
}
class FakeAnthropic {
constructor(opts: Record<string, unknown>) {
mockState.constructorOpts = opts;
}
messages = {
create: (params: Record<string, unknown>) => {
mockState.createParams = params;
return {
asResponse: async () => createSseResponse(),
};
},
};
}
return { default: FakeAnthropic };
});
const context: Context = {
systemPrompt: "System prompt.",
messages: [{ role: "user", content: "Hello", timestamp: Date.now() }],
};
const anthropicModel: Model<"anthropic-messages"> = {
id: "claude-test",
name: "Claude Test",
api: "anthropic-messages",
provider: "anthropic",
baseUrl: "https://api.anthropic.com",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 100000,
maxTokens: 4096,
};
afterEach(() => {
mockState.constructorOpts = undefined;
mockState.createParams = undefined;
});
describe("Anthropic auth token env", () => {
it("resolves ANTHROPIC_AUTH_TOKEN as a bearer Authorization header", async () => {
const provider = anthropicProvider();
const auth = await provider.auth.apiKey?.resolve({
ctx: {
env: async (name) =>
({
ANTHROPIC_AUTH_TOKEN: "auth-token",
ANTHROPIC_OAUTH_TOKEN: "oauth-token",
ANTHROPIC_API_KEY: "api-key",
})[name],
fileExists: async () => false,
},
});
expect(auth).toEqual({
auth: { headers: { Authorization: "Bearer auth-token" } },
source: ANTHROPIC_AUTH_TOKEN_ENV,
});
});
it("preserves ANTHROPIC_OAUTH_TOKEN as OAuth-shaped API auth", async () => {
const provider = anthropicProvider();
const auth = await provider.auth.apiKey?.resolve({
ctx: {
env: async (name) =>
({
ANTHROPIC_OAUTH_TOKEN: "oauth-token",
ANTHROPIC_API_KEY: "api-key",
})[name],
fileExists: async () => false,
},
});
expect(auth).toEqual({
auth: { apiKey: "oauth-token" },
source: ANTHROPIC_OAUTH_TOKEN_ENV,
});
});
it("uses Authorization headers without OAuth-mode request shaping", async () => {
const stream = streamAnthropic(anthropicModel, context, {
headers: { Authorization: "Bearer gateway-token" },
});
await stream.result();
expect(mockState.constructorOpts?.apiKey).toBeNull();
expect(mockState.constructorOpts?.authToken).toBeNull();
const headers = mockState.constructorOpts?.defaultHeaders as Record<string, string | null>;
expect(headers.Authorization).toBe("Bearer gateway-token");
expect(headers["anthropic-beta"] ?? "").not.toContain("oauth-2025-04-20");
expect(mockState.createParams?.system).toEqual([expect.objectContaining({ text: "System prompt." })]);
});
it("threads authContext ANTHROPIC_AUTH_TOKEN through request headers", async () => {
const models = createModels({
authContext: {
env: async (name) => (name === "ANTHROPIC_AUTH_TOKEN" ? "ctx-token" : undefined),
fileExists: async () => false,
},
});
models.setProvider(anthropicProvider());
await models.streamSimple(anthropicModel, context).result();
expect(mockState.constructorOpts?.apiKey).toBeNull();
expect(mockState.constructorOpts?.authToken).toBeNull();
const headers = mockState.constructorOpts?.defaultHeaders as Record<string, string>;
expect(headers.Authorization).toBe("Bearer ctx-token");
expect(headers["anthropic-beta"] ?? "").not.toContain("oauth-2025-04-20");
expect(mockState.createParams?.system).toEqual([expect.objectContaining({ text: "System prompt." })]);
});
it("preserves OAuth request shaping for ANTHROPIC_OAUTH_TOKEN", async () => {
const models = createModels({
authContext: {
env: async (name) => (name === "ANTHROPIC_OAUTH_TOKEN" ? "sk-ant-oat-test" : undefined),
fileExists: async () => false,
},
});
models.setProvider(anthropicProvider());
await models.streamSimple(anthropicModel, context).result();
expect(mockState.constructorOpts?.apiKey).toBeNull();
expect(mockState.constructorOpts?.authToken).toBe("sk-ant-oat-test");
const headers = mockState.constructorOpts?.defaultHeaders as Record<string, string>;
expect(headers["anthropic-beta"]).toContain("oauth-2025-04-20");
});
it("lets explicit request headers override ANTHROPIC_AUTH_TOKEN", async () => {
const models = createModels({
authContext: {
env: async (name) => (name === "ANTHROPIC_AUTH_TOKEN" ? "ctx-token" : undefined),
fileExists: async () => false,
},
});
models.setProvider(anthropicProvider());
await models
.streamSimple(anthropicModel, context, { headers: { Authorization: "Bearer explicit-token" } })
.result();
const headers = mockState.constructorOpts?.defaultHeaders as Record<string, string>;
expect(headers.Authorization).toBe("Bearer explicit-token");
});
});
@@ -32,6 +32,17 @@ const tool: Tool = {
parameters: Type.Object({ value: Type.String() }), parameters: Type.Object({ value: Type.String() }),
}; };
const schemaCompatibilityTool: Tool = {
...tool,
parameters: Type.Object({ value: Type.String() }, { additionalProperties: false, title: "LookupInput" }),
};
const strictTool: Tool = {
...tool,
parameters: Type.Object({ value: Type.String() }, { additionalProperties: false, title: "StrictLookupInput" }),
constrainedSampling: { type: "json_schema", strict: "prefer" },
};
function createContext(tools: Tool[] = [tool]): Context { function createContext(tools: Tool[] = [tool]): Context {
return { return {
messages: [{ role: "user", content: "Use the tool", timestamp: Date.now() }], messages: [{ role: "user", content: "Use the tool", timestamp: Date.now() }],
@@ -98,6 +109,14 @@ function getFirstTool(body: Record<string, unknown>): Record<string, unknown> {
return tools[0] as Record<string, unknown>; return tools[0] as Record<string, unknown>;
} }
function getFirstToolInputSchema(body: Record<string, unknown>): Record<string, unknown> {
const inputSchema = getFirstTool(body).input_schema;
if (typeof inputSchema !== "object" || inputSchema === null || Array.isArray(inputSchema)) {
throw new Error("Expected first tool input schema in request body");
}
return inputSchema as Record<string, unknown>;
}
describe("Anthropic eager tool input streaming compatibility", () => { describe("Anthropic eager tool input streaming compatibility", () => {
it("sends per-tool eager_input_streaming by default", async () => { it("sends per-tool eager_input_streaming by default", async () => {
const request = await captureAnthropicRequest(undefined, createContext()); const request = await captureAnthropicRequest(undefined, createContext());
@@ -119,4 +138,24 @@ describe("Anthropic eager tool input streaming compatibility", () => {
expect(request.body.tools).toBeUndefined(); expect(request.body.tools).toBeUndefined();
expect(request.headers["anthropic-beta"]).toBeUndefined(); expect(request.headers["anthropic-beta"]).toBeUndefined();
}); });
it("only sends the full input schema for strict JSON-schema tools", async () => {
const legacyRequest = await captureAnthropicRequest(
{ supportsStrictTools: true },
createContext([schemaCompatibilityTool]),
);
const parameters = schemaCompatibilityTool.parameters as { properties?: unknown; required?: unknown };
expect(getFirstToolInputSchema(legacyRequest.body)).toEqual({
type: "object",
properties: parameters.properties,
required: parameters.required,
});
const strictRequest = await captureAnthropicRequest({ supportsStrictTools: true }, createContext([strictTool]));
expect(getFirstTool(strictRequest.body).strict).toBe(true);
expect(getFirstToolInputSchema(strictRequest.body)).toMatchObject({
additionalProperties: false,
title: "StrictLookupInput",
});
});
}); });
+29 -1
View File
@@ -1,7 +1,8 @@
import { Type } from "typebox";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { stream as streamAzureOpenAIResponses } from "../src/api/azure-openai-responses.ts"; import { stream as streamAzureOpenAIResponses } from "../src/api/azure-openai-responses.ts";
import { getModel } from "../src/compat.ts"; import { getModel } from "../src/compat.ts";
import type { Context } from "../src/types.ts"; import type { Context, Model } from "../src/types.ts";
interface CapturedAzureClientOptions { interface CapturedAzureClientOptions {
apiKey: string; apiKey: string;
@@ -14,6 +15,7 @@ interface CapturedAzureClientOptions {
interface CapturedAzureResponsesPayload { interface CapturedAzureResponsesPayload {
prompt_cache_key?: string; prompt_cache_key?: string;
store?: boolean; store?: boolean;
tools?: Array<{ strict?: boolean }>;
} }
const azureMock = vi.hoisted(() => ({ const azureMock = vi.hoisted(() => ({
@@ -165,6 +167,32 @@ describe("azure-openai-responses base URL normalization", () => {
expect(azureMock.lastParams?.store).toBe(false); expect(azureMock.lastParams?.store).toBe(false);
}); });
it("honors supportsStrictMode: false", async () => {
const baseModel = getModel("azure-openai-responses", "gpt-4o-mini");
const model: Model<"azure-openai-responses"> = {
...baseModel,
compat: { ...baseModel.compat, supportsStrictMode: false },
};
await streamAzureOpenAIResponses(
model,
{
...context,
tools: [
{
name: "preferred",
description: "Preferred constrained tool",
parameters: Type.Object({ value: Type.String() }),
constrainedSampling: { type: "json_schema", strict: "prefer" },
},
],
},
{ apiKey: "test-api-key", azureBaseUrl: "https://my-resource.openai.azure.com" },
).result();
expect(azureMock.lastParams?.tools?.[0]).not.toHaveProperty("strict");
});
it("builds correct default URL from AZURE_OPENAI_RESOURCE_NAME", async () => { it("builds correct default URL from AZURE_OPENAI_RESOURCE_NAME", async () => {
process.env.AZURE_OPENAI_RESOURCE_NAME = "my-resource"; process.env.AZURE_OPENAI_RESOURCE_NAME = "my-resource";
const model = getModel("azure-openai-responses", "gpt-4o-mini"); const model = getModel("azure-openai-responses", "gpt-4o-mini");
@@ -1,3 +1,4 @@
import { Type } from "typebox";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
const bedrockMock = vi.hoisted(() => ({ const bedrockMock = vi.hoisted(() => ({
@@ -50,9 +51,9 @@ import type { Context, Message } from "../src/types.ts";
const baseModel = getModel("amazon-bedrock", "us.anthropic.claude-sonnet-4-5-20250929-v1:0"); const baseModel = getModel("amazon-bedrock", "us.anthropic.claude-sonnet-4-5-20250929-v1:0");
async function capturePayload(context: Context): Promise<unknown> { async function capturePayload(context: Context, model = baseModel): Promise<unknown> {
let capturedPayload: unknown; let capturedPayload: unknown;
const s = streamBedrock(baseModel, context, { const s = streamBedrock(model, context, {
cacheRetention: "none", cacheRetention: "none",
signal: AbortSignal.abort(), signal: AbortSignal.abort(),
onPayload: (payload) => { onPayload: (payload) => {
@@ -66,6 +67,34 @@ async function capturePayload(context: Context): Promise<unknown> {
return capturedPayload; return capturedPayload;
} }
describe("Bedrock constrained sampling", () => {
it("gates native strict tool use by model capability", async () => {
const context: Context = {
messages: [{ role: "user", content: "Use the tool", timestamp: Date.now() }],
tools: [
{
name: "lookup",
description: "Look up a value",
parameters: Type.Object({ value: Type.String() }),
constrainedSampling: { type: "json_schema", strict: "require" },
},
],
};
const payload = await capturePayload(context);
const toolConfig = (payload as { toolConfig: { tools: Array<{ toolSpec: { strict?: boolean } }> } }).toolConfig;
expect(toolConfig.tools[0].toolSpec.strict).toBe(true);
context.tools![0].constrainedSampling = { type: "json_schema", strict: "prefer" };
const novaPayload = await capturePayload(context, getModel("amazon-bedrock", "amazon.nova-lite-v1:0"));
const novaToolConfig = (
novaPayload as {
toolConfig: { tools: Array<{ toolSpec: { strict?: boolean } }> };
}
).toolConfig;
expect(novaToolConfig.tools[0].toolSpec.strict).toBeUndefined();
});
});
describe("bedrock convertMessages skips unknown content types", () => { describe("bedrock convertMessages skips unknown content types", () => {
it("skips unknown user content blocks instead of throwing", async () => { it("skips unknown user content blocks instead of throwing", async () => {
const messages: Message[] = [ const messages: Message[] = [
+5
View File
@@ -29,6 +29,11 @@ describe("Amazon Bedrock Models", () => {
console.log(`Found ${models.length} Bedrock models`); console.log(`Found ${models.length} Bedrock models`);
}); });
it("exposes Claude Opus 5 through an inference profile only", () => {
expect(models.some((model) => model.id === "global.anthropic.claude-opus-5")).toBe(true);
expect(models.some((model) => model.id === "anthropic.claude-opus-5")).toBe(false);
});
if (hasBedrockCredentials() && process.env.BEDROCK_EXTENSIVE_MODEL_TEST) { if (hasBedrockCredentials() && process.env.BEDROCK_EXTENSIVE_MODEL_TEST) {
for (const model of models) { for (const model of models) {
it(`should make a simple request with ${model.id}`, { timeout: 10_000 }, async () => { it(`should make a simple request with ${model.id}`, { timeout: 10_000 }, async () => {
@@ -103,6 +103,26 @@ describe("Bedrock thinking payload", () => {
expect(payload.additionalModelRequestFields?.anthropic_beta).toBeUndefined(); expect(payload.additionalModelRequestFields?.anthropic_beta).toBeUndefined();
}); });
it("uses adaptive thinking for Claude Opus 5 when reasoning is enabled", async () => {
const model = getModel("amazon-bedrock", "global.anthropic.claude-opus-5");
const payload = await capturePayload(model);
expect(payload.additionalModelRequestFields?.thinking).toEqual({ type: "adaptive", display: "summarized" });
expect(payload.additionalModelRequestFields?.output_config).toEqual({ effort: "high" });
expect(payload.additionalModelRequestFields?.anthropic_beta).toBeUndefined();
});
it("maps xhigh reasoning to effort=xhigh for Claude Opus 5", async () => {
const model = getModel("amazon-bedrock", "global.anthropic.claude-opus-5");
const payload = await capturePayload(model, { reasoning: "xhigh" });
expect(payload.additionalModelRequestFields?.thinking).toEqual({ type: "adaptive", display: "summarized" });
expect(payload.additionalModelRequestFields?.output_config).toEqual({ effort: "xhigh" });
expect(payload.additionalModelRequestFields?.anthropic_beta).toBeUndefined();
});
it("maps xhigh reasoning to effort=xhigh for Claude Fable 5", async () => { it("maps xhigh reasoning to effort=xhigh for Claude Fable 5", async () => {
const model = getModel("amazon-bedrock", "global.anthropic.claude-fable-5"); const model = getModel("amazon-bedrock", "global.anthropic.claude-fable-5");
+38 -7
View File
@@ -18,6 +18,10 @@ interface OpenAICompletionsCachePayload {
prompt_cache_retention?: string; prompt_cache_retention?: string;
} }
interface OpenAIResponsesCachePayload extends OpenAICompletionsCachePayload {
prompt_cache_options?: { mode: "explicit" };
}
function stopAfterPayload<TPayload>(capture: (payload: TPayload) => void): (payload: unknown) => never { function stopAfterPayload<TPayload>(capture: (payload: TPayload) => void): (payload: unknown) => never {
return (payload: unknown): never => { return (payload: unknown): never => {
capture(payload as TPayload); capture(payload as TPayload);
@@ -341,16 +345,16 @@ describe("Cache Retention (PI_CACHE_RETENTION)", () => {
expect(capturedPayload.prompt_cache_retention).toBeUndefined(); expect(capturedPayload.prompt_cache_retention).toBeUndefined();
}); });
it("should omit prompt_cache_key when cacheRetention is none", async () => { it("should omit prompt_cache_key and disable implicit writes when cacheRetention is none", async () => {
const model = getModel("openai", "gpt-4o-mini"); const model = getModel("openai", "gpt-5.6-sol");
let capturedPayload: any = null; let capturedPayload: OpenAIResponsesCachePayload | undefined;
try { try {
const s = streamOpenAIResponses(model, context, { const s = streamOpenAIResponses(model, context, {
apiKey: "fake-key", apiKey: "fake-key",
cacheRetention: "none", cacheRetention: "none",
sessionId: "session-1", sessionId: "session-1",
onPayload: stopAfterPayload((payload) => { onPayload: stopAfterPayload<OpenAIResponsesCachePayload>((payload) => {
capturedPayload = payload; capturedPayload = payload;
}), }),
}); });
@@ -362,9 +366,36 @@ describe("Cache Retention (PI_CACHE_RETENTION)", () => {
// Expected to fail // Expected to fail
} }
expect(capturedPayload).not.toBeNull(); expect(capturedPayload).toBeDefined();
expect(capturedPayload.prompt_cache_key).toBeUndefined(); expect(capturedPayload?.prompt_cache_key).toBeUndefined();
expect(capturedPayload.prompt_cache_retention).toBeUndefined(); expect(capturedPayload?.prompt_cache_retention).toBeUndefined();
expect(capturedPayload?.prompt_cache_options).toEqual({ mode: "explicit" });
});
it("should omit prompt_cache_options for models that reject it", async () => {
const model = getModel("openai", "gpt-4o-mini");
let capturedPayload: OpenAIResponsesCachePayload | undefined;
try {
const s = streamOpenAIResponses(model, context, {
apiKey: "fake-key",
cacheRetention: "none",
sessionId: "session-1",
onPayload: stopAfterPayload<OpenAIResponsesCachePayload>((payload) => {
capturedPayload = payload;
}),
});
for await (const event of s) {
if (event.type === "error") break;
}
} catch {
// Expected to fail
}
expect(capturedPayload).toBeDefined();
expect(capturedPayload?.prompt_cache_key).toBeUndefined();
expect(capturedPayload?.prompt_cache_options).toBeUndefined();
}); });
it("should set prompt_cache_retention when cacheRetention is long", async () => { it("should set prompt_cache_retention when cacheRetention is long", async () => {
@@ -0,0 +1,229 @@
import type { ResponseStreamEvent } from "openai/resources/responses/responses.js";
import { Type } from "typebox";
import { describe, expect, it } from "vitest";
import { appendGrammarToolInputJsonDelta } from "../src/api/constrained-sampling.ts";
import {
convertResponsesMessages,
convertResponsesTools,
processResponsesStream,
} from "../src/api/openai-responses-shared.ts";
import type { AssistantMessage, Context, Model, Tool, ToolCall } from "../src/types.ts";
import { AssistantMessageEventStream } from "../src/utils/event-stream.ts";
function makeModel(): Model<"openai-responses"> {
return {
id: "gpt-test",
name: "GPT Test",
api: "openai-responses",
provider: "openai",
baseUrl: "https://api.openai.com/v1",
reasoning: false,
input: ["text", "image"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128000,
maxTokens: 4096,
};
}
function makeUsage(): AssistantMessage["usage"] {
return {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
};
}
function makeOutput(): AssistantMessage {
return {
role: "assistant",
content: [],
api: "openai-responses",
provider: "openai",
model: "gpt-test",
usage: makeUsage(),
stopReason: "stop",
timestamp: Date.now(),
};
}
async function* iterateEvents(events: ResponseStreamEvent[]): AsyncGenerator<ResponseStreamEvent> {
yield* events;
}
function makeTool(overrides: Partial<Tool> = {}): Tool {
return {
name: "sample_tool",
description: "Sample tool",
parameters: Type.Object({ payload: Type.String() }, { additionalProperties: false }),
...overrides,
};
}
function captureToolCallDeltas(stream: AssistantMessageEventStream): string[] {
const deltas: string[] = [];
const originalPush = stream.push.bind(stream);
stream.push = (event) => {
if (event.type === "toolcall_delta") {
deltas.push(event.delta);
}
originalPush(event);
};
return deltas;
}
describe("constrained tool sampling", () => {
it("converts supported constraints and falls back when unsupported", () => {
expect(
convertResponsesTools([makeTool({ constrainedSampling: { type: "json_schema", strict: "prefer" } })])[0],
).toMatchObject({ type: "function", name: "sample_tool", strict: true });
expect(() =>
convertResponsesTools([makeTool({ constrainedSampling: { type: "json_schema", strict: "require" } })], {
supportsStrictMode: false,
}),
).toThrow('Tool "sample_tool" requires JSON-schema constrained sampling');
const grammarTool = makeTool({
constrainedSampling: { type: "grammar", variants: { openai_lark: "start: /[a-z]+/" } },
});
expect(convertResponsesTools([grammarTool], { supportsOpenAIGrammarTools: true })[0]).toMatchObject({
type: "custom",
name: "sample_tool",
format: { type: "grammar", syntax: "lark", definition: "start: /[a-z]+/" },
});
expect(() =>
convertResponsesTools([makeTool({ constrainedSampling: { type: "grammar", variants: {} } })], {
supportsOpenAIGrammarTools: true,
}),
).toThrow(
'Tool "sample_tool" cannot use grammar constrained sampling: no supported grammar variant was provided',
);
const fallback = convertResponsesTools([grammarTool], {
supportsOpenAIGrammarTools: false,
supportsStrictMode: false,
})[0];
expect(fallback).toMatchObject({ type: "function", name: "sample_tool" });
expect("strict" in (fallback as object)).toBe(false);
expect(convertResponsesTools([makeTool({ constrainedSampling: false })])).toEqual(
convertResponsesTools([makeTool()]),
);
});
it("replays grammar calls as custom Responses items", () => {
const replayedToolCall: ToolCall = {
type: "toolCall",
id: "call_1|ctc_1",
name: "sample_tool",
arguments: { payload: "abc" },
};
const context: Context = {
messages: [
{
role: "assistant",
api: "openai-responses",
provider: "openai",
model: "gpt-test",
content: [replayedToolCall],
usage: makeUsage(),
stopReason: "toolUse",
timestamp: Date.now(),
},
{
role: "toolResult",
toolCallId: "call_1|ctc_1",
toolName: "sample_tool",
content: [{ type: "text", text: "done" }],
isError: false,
timestamp: Date.now(),
},
],
};
for (const invalidArguments of [{}, { payload: 42 }]) {
replayedToolCall.arguments = invalidArguments;
expect(() =>
convertResponsesMessages(makeModel(), context, new Set(["openai"]), {
grammarToolInputProperties: new Map([["sample_tool", "payload"]]),
}),
).toThrow('Grammar tool call "sample_tool" requires argument "payload" to be a string');
}
replayedToolCall.arguments = { payload: "abc" };
const messages = convertResponsesMessages(makeModel(), context, new Set(["openai"]), {
grammarToolInputProperties: new Map([["sample_tool", "payload"]]),
});
expect(messages).toContainEqual({
type: "custom_tool_call",
id: "ctc_1",
call_id: "call_1",
name: "sample_tool",
input: "abc",
});
expect(messages).toContainEqual({
type: "custom_tool_call_output",
call_id: "call_1",
output: "done",
});
});
it("keeps grammar input JSON deltas append-only", () => {
const buffer = { input: "", started: false, closed: false };
const first = appendGrammarToolInputJsonDelta(buffer, "payload", 'a"', false);
const second = appendGrammarToolInputJsonDelta(buffer, "payload", 'a"\nb', true);
expect(JSON.parse(`${first}${second}`)).toEqual({ payload: 'a"\nb' });
expect(appendGrammarToolInputJsonDelta(buffer, "payload", 'a"\nb', true)).toBeUndefined();
expect(() => appendGrammarToolInputJsonDelta(buffer, "payload", "changed", true)).toThrow(
'grammar tool input for property "payload" changed after it was closed',
);
});
it("streams custom Responses tool calls as string arguments", async () => {
const output = makeOutput();
const stream = new AssistantMessageEventStream();
const deltas = captureToolCallDeltas(stream);
const events = [
{
type: "response.output_item.added",
output_index: 0,
item: { type: "custom_tool_call", call_id: "call_1", id: "ctc_1", name: "sample_tool", input: "" },
},
{
type: "response.custom_tool_call_input.delta",
output_index: 0,
item_id: "ctc_1",
delta: "ab",
},
{
type: "response.custom_tool_call_input.done",
output_index: 0,
item_id: "ctc_1",
input: "abc",
},
{
type: "response.output_item.done",
output_index: 0,
item: { type: "custom_tool_call", call_id: "call_1", id: "ctc_1", name: "sample_tool", input: "abc" },
},
{
type: "response.completed",
response: { status: "completed", usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 } },
},
] as ResponseStreamEvent[];
await processResponsesStream(iterateEvents(events), output, stream, makeModel(), {
grammarToolInputProperties: new Map([["sample_tool", "payload"]]),
});
expect(output.stopReason).toBe("toolUse");
expect(output.content).toEqual([
{ type: "toolCall", id: "call_1|ctc_1", name: "sample_tool", arguments: { payload: "abc" } },
]);
expect(JSON.parse(deltas.join(""))).toEqual({ payload: "abc" });
});
});
+1
View File
@@ -369,6 +369,7 @@ describe("deferred tools", () => {
chatTemplateKwargs: {}, chatTemplateKwargs: {},
zaiToolStream: false, zaiToolStream: false,
supportsStrictMode: false, supportsStrictMode: false,
supportsOpenAIGrammarTools: false,
cacheControlFormat: undefined, cacheControlFormat: undefined,
sendSessionAffinityHeaders: false, sendSessionAffinityHeaders: false,
deferredToolsMode: "kimi", deferredToolsMode: "kimi",
+56
View File
@@ -5,6 +5,9 @@ const originalCopilotGitHubToken = process.env.COPILOT_GITHUB_TOKEN;
const originalGhToken = process.env.GH_TOKEN; const originalGhToken = process.env.GH_TOKEN;
const originalGitHubToken = process.env.GITHUB_TOKEN; const originalGitHubToken = process.env.GITHUB_TOKEN;
const originalZaiCodingCnApiKey = process.env.ZAI_CODING_CN_API_KEY; const originalZaiCodingCnApiKey = process.env.ZAI_CODING_CN_API_KEY;
const originalAnthropicAuthToken = process.env.ANTHROPIC_AUTH_TOKEN;
const originalAnthropicOauthToken = process.env.ANTHROPIC_OAUTH_TOKEN;
const originalAnthropicApiKey = process.env.ANTHROPIC_API_KEY;
afterEach(() => { afterEach(() => {
if (originalCopilotGitHubToken === undefined) { if (originalCopilotGitHubToken === undefined) {
@@ -30,6 +33,24 @@ afterEach(() => {
} else { } else {
process.env.ZAI_CODING_CN_API_KEY = originalZaiCodingCnApiKey; process.env.ZAI_CODING_CN_API_KEY = originalZaiCodingCnApiKey;
} }
if (originalAnthropicAuthToken === undefined) {
delete process.env.ANTHROPIC_AUTH_TOKEN;
} else {
process.env.ANTHROPIC_AUTH_TOKEN = originalAnthropicAuthToken;
}
if (originalAnthropicOauthToken === undefined) {
delete process.env.ANTHROPIC_OAUTH_TOKEN;
} else {
process.env.ANTHROPIC_OAUTH_TOKEN = originalAnthropicOauthToken;
}
if (originalAnthropicApiKey === undefined) {
delete process.env.ANTHROPIC_API_KEY;
} else {
process.env.ANTHROPIC_API_KEY = originalAnthropicApiKey;
}
}); });
describe("environment API keys", () => { describe("environment API keys", () => {
@@ -57,4 +78,39 @@ describe("environment API keys", () => {
expect(findEnvKeys("zai-coding-cn")).toEqual(["ZAI_CODING_CN_API_KEY"]); expect(findEnvKeys("zai-coding-cn")).toEqual(["ZAI_CODING_CN_API_KEY"]);
expect(getEnvApiKey("zai-coding-cn")).toBe("zai-coding-cn-token"); expect(getEnvApiKey("zai-coding-cn")).toBe("zai-coding-cn-token");
}); });
it("reports ANTHROPIC_AUTH_TOKEN but preserves OAuth token API key lookup", () => {
process.env.ANTHROPIC_AUTH_TOKEN = "auth-token";
process.env.ANTHROPIC_OAUTH_TOKEN = "oauth-token";
process.env.ANTHROPIC_API_KEY = "api-key";
expect(findEnvKeys("anthropic")).toEqual(["ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_OAUTH_TOKEN", "ANTHROPIC_API_KEY"]);
expect(getEnvApiKey("anthropic")).toBe("oauth-token");
});
it("does not return ANTHROPIC_AUTH_TOKEN as an API key", () => {
process.env.ANTHROPIC_AUTH_TOKEN = "auth-token";
delete process.env.ANTHROPIC_OAUTH_TOKEN;
delete process.env.ANTHROPIC_API_KEY;
expect(findEnvKeys("anthropic")).toEqual(["ANTHROPIC_AUTH_TOKEN"]);
expect(getEnvApiKey("anthropic")).toBeUndefined();
});
it("preserves ANTHROPIC_OAUTH_TOKEN as an API key", () => {
delete process.env.ANTHROPIC_AUTH_TOKEN;
process.env.ANTHROPIC_OAUTH_TOKEN = "oauth-token";
delete process.env.ANTHROPIC_API_KEY;
expect(findEnvKeys("anthropic")).toEqual(["ANTHROPIC_OAUTH_TOKEN"]);
expect(getEnvApiKey("anthropic")).toBe("oauth-token");
});
it("falls back to ANTHROPIC_API_KEY for API key lookup", () => {
delete process.env.ANTHROPIC_AUTH_TOKEN;
delete process.env.ANTHROPIC_OAUTH_TOKEN;
process.env.ANTHROPIC_API_KEY = "api-key";
expect(getEnvApiKey("anthropic")).toBe("api-key");
});
}); });
+21
View File
@@ -64,6 +64,27 @@ describe("normalizeProviderError", () => {
expect(norm.messageCarriesBody).toBe(false); expect(norm.messageCarriesBody).toBe(false);
}); });
it("ignores a Bedrock response stream instead of serializing its internals", () => {
const error = Object.assign(
new Error("Invocation of model ID anthropic.claude-opus-5 with on-demand throughput isn't supported."),
{
name: "ValidationException",
$metadata: { httpStatusCode: 400 },
$response: {
statusCode: 400,
body: { pipe: () => undefined, _events: { close: [null, null] } },
},
},
);
const norm = normalizeProviderError(error);
expect(norm.status).toBe(400);
expect(norm.body).toBeUndefined();
expect(norm.message).toContain("on-demand throughput isn't supported");
expect(norm.messageCarriesBody).toBe(true);
});
it("JSON-stringifies a non-Error thrown value", () => { it("JSON-stringifies a non-Error thrown value", () => {
const norm = normalizeProviderError({ reason: "boom" }); const norm = normalizeProviderError({ reason: "boom" });
@@ -1,5 +1,9 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { convertTools } from "../src/api/google-shared.ts"; import {
convertTools,
resolveGoogleFunctionCallingMode,
supportsGoogleStrictToolSampling,
} from "../src/api/google-shared.ts";
import type { Tool } from "../src/types.ts"; import type { Tool } from "../src/types.ts";
function makeTool(parameters: Record<string, unknown>): Tool { function makeTool(parameters: Record<string, unknown>): Tool {
@@ -180,6 +184,18 @@ describe("google-shared convertTools", () => {
}); });
}); });
it("uses validated function calling for strict tools on Gemini 3", () => {
const tool = makeTool({ type: "object", properties: {} });
tool.constrainedSampling = { type: "json_schema", strict: "require" };
expect(supportsGoogleStrictToolSampling("gemini-3.1-pro-preview")).toBe(true);
expect(supportsGoogleStrictToolSampling("gemini-2.5-pro")).toBe(false);
expect(resolveGoogleFunctionCallingMode([tool], undefined, true)).toBe("VALIDATED");
expect(() => resolveGoogleFunctionCallingMode([tool], undefined, false)).toThrow(
'Tool "test_tool" requires JSON-schema constrained sampling',
);
});
it("returns undefined for empty tool list", () => { it("returns undefined for empty tool list", () => {
expect(convertTools([])).toBeUndefined(); expect(convertTools([])).toBeUndefined();
expect(convertTools([], true)).toBeUndefined(); expect(convertTools([], true)).toBeUndefined();
@@ -9,6 +9,7 @@ interface MistralToolPayload {
function: { function: {
name: string; name: string;
parameters: Record<string, unknown>; parameters: Record<string, unknown>;
strict?: boolean;
}; };
}>; }>;
} }
@@ -31,6 +32,7 @@ describe("Mistral tool schema serialization", () => {
name: "inspect_schema", name: "inspect_schema",
description: "Inspect the schema", description: "Inspect the schema",
parameters, parameters,
constrainedSampling: { type: "json_schema", strict: "require" },
}, },
], ],
}; };
@@ -45,6 +47,7 @@ describe("Mistral tool schema serialization", () => {
}); });
expect(capturedPayload?.tools).toHaveLength(1); expect(capturedPayload?.tools).toHaveLength(1);
expect(capturedPayload?.tools?.[0]?.function.strict).toBe(true);
const payloadParameters = capturedPayload?.tools?.[0]?.function.parameters; const payloadParameters = capturedPayload?.tools?.[0]?.function.parameters;
expect(payloadParameters).toBeDefined(); expect(payloadParameters).toBeDefined();
expect(Object.getOwnPropertySymbols(payloadParameters ?? {})).toHaveLength(0); expect(Object.getOwnPropertySymbols(payloadParameters ?? {})).toHaveLength(0);
+12 -2
View File
@@ -11,6 +11,7 @@ import {
validateModelDataDirectory, validateModelDataDirectory,
} from "../scripts/model-data.ts"; } from "../scripts/model-data.ts";
const GENERATED_AT = "2026-07-23T10:00:00.000Z";
const temporaryRoots: string[] = []; const temporaryRoots: string[] = [];
afterEach(() => { afterEach(() => {
@@ -70,7 +71,7 @@ function writeFixtureData(
const filename = "test-provider.json"; const filename = "test-provider.json";
const content = `${JSON.stringify({ [apiGroup]: values })}\n`; const content = `${JSON.stringify({ [apiGroup]: values })}\n`;
writeFileSync(join(dataDir, filename), content); writeFileSync(join(dataDir, filename), content);
const manifest = createModelDataManifest(structure, { [filename]: content }); const manifest = createModelDataManifest(structure, { [filename]: content }, GENERATED_AT);
manifest.schemaVersion = manifestSchemaVersion; manifest.schemaVersion = manifestSchemaVersion;
writeFileSync(join(dataDir, MODEL_DATA_MANIFEST_FILE), `${JSON.stringify(manifest)}\n`); writeFileSync(join(dataDir, MODEL_DATA_MANIFEST_FILE), `${JSON.stringify(manifest)}\n`);
} }
@@ -120,7 +121,7 @@ describe("generated model data validation", () => {
"anthropic-messages": fixture.values, "anthropic-messages": fixture.values,
})}\n`; })}\n`;
writeFileSync(join(fixture.dataDir, filename), content); writeFileSync(join(fixture.dataDir, filename), content);
const manifest = createModelDataManifest(fixture.structure, { [filename]: content }); const manifest = createModelDataManifest(fixture.structure, { [filename]: content }, GENERATED_AT);
writeFileSync(join(fixture.dataDir, MODEL_DATA_MANIFEST_FILE), `${JSON.stringify(manifest)}\n`); writeFileSync(join(fixture.dataDir, MODEL_DATA_MANIFEST_FILE), `${JSON.stringify(manifest)}\n`);
expect(() => validateModelDataDirectory(fixture.structure, fixture.dataDir)).toThrow("more than one API group"); expect(() => validateModelDataDirectory(fixture.structure, fixture.dataDir)).toThrow("more than one API group");
}); });
@@ -143,6 +144,15 @@ describe("generated model data validation", () => {
expect(() => validateModelDataDirectory(fixture.structure, fixture.dataDir)).toThrow("generation stamp"); expect(() => validateModelDataDirectory(fixture.structure, fixture.dataDir)).toThrow("generation stamp");
}); });
it("rejects an invalid generation timestamp", () => {
const fixture = createFixture();
const manifestPath = join(fixture.dataDir, MODEL_DATA_MANIFEST_FILE);
const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as Record<string, unknown>;
manifest.generatedAt = "invalid";
writeFileSync(manifestPath, `${JSON.stringify(manifest)}\n`);
expect(() => validateModelDataDirectory(fixture.structure, fixture.dataDir)).toThrow("generation timestamp");
});
it("rejects missing provider shards imported by the aggregator", () => { it("rejects missing provider shards imported by the aggregator", () => {
const { packageRoot } = createFixture(); const { packageRoot } = createFixture();
writeFileSync( writeFileSync(
+22
View File
@@ -567,6 +567,28 @@ describe("Models runtime", () => {
await expect(oauthModels.getAuth("p1")).rejects.toMatchObject({ code: "auth" }); await expect(oauthModels.getAuth("p1")).rejects.toMatchObject({ code: "auth" });
}); });
it("keeps the underlying reason in wrapped oauth refresh errors", async () => {
const credentials = new InMemoryCredentialStore();
await credentials.modify("p1", async () => ({ type: "oauth", access: "old", refresh: "r", expires: 0 }));
const models = createModels({ credentials });
models.setProvider(
testProvider({
id: "p1",
auth: {
oauth: testOAuth({
refresh: async () => {
throw new Error("token refresh failed (400): invalid_grant");
},
}),
},
}),
);
await expect(models.getAuth("p1")).rejects.toThrow(
"OAuth refresh failed for p1: token refresh failed (400): invalid_grant",
);
});
it("wraps api-key auth failures in ModelsError", async () => { it("wraps api-key auth failures in ModelsError", async () => {
const failing: ApiKeyAuth = { const failing: ApiKeyAuth = {
name: "Failing", name: "Failing",
+315 -36
View File
@@ -590,6 +590,57 @@ describe("openai-codex streaming", () => {
await streamResult.result(); await streamResult.result();
}); });
it("omits SSE cache affinity when cacheRetention is none", async () => {
const token = mockToken();
const encoder = new TextEncoder();
let capturedHeaders: Headers | undefined;
let capturedBody: Record<string, unknown> | null = null;
vi.stubGlobal(
"fetch",
vi.fn(async (_input: string | URL, init?: RequestInit) => {
capturedHeaders = init?.headers instanceof Headers ? init.headers : undefined;
capturedBody = decodeCodexRequestBody(init?.body);
return new Response(
new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(encoder.encode(buildSSEPayload({ status: "completed" })));
controller.close();
},
}),
{ status: 200, headers: { "content-type": "text/event-stream" } },
);
}),
);
const model: Model<"openai-codex-responses"> = {
id: "gpt-5.1-codex",
name: "GPT-5.1 Codex",
api: "openai-codex-responses",
provider: "openai-codex",
baseUrl: "https://chatgpt.com/backend-api",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 400000,
maxTokens: 128000,
};
const context: Context = {
systemPrompt: "You are a helpful assistant.",
messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }],
};
await streamOpenAICodexResponses(model, context, {
apiKey: token,
cacheRetention: "none",
sessionId: "one-off-summary",
transport: "sse",
}).result();
expect(capturedHeaders?.has("session-id")).toBe(false);
expect(capturedHeaders?.has("x-client-request-id")).toBe(false);
expect(capturedBody).not.toHaveProperty("prompt_cache_key");
});
it("clamps prompt_cache_key to OpenAI's 64-character limit", async () => { it("clamps prompt_cache_key to OpenAI's 64-character limit", async () => {
const token = mockToken(); const token = mockToken();
const sessionId = "x".repeat(67); const sessionId = "x".repeat(67);
@@ -804,6 +855,75 @@ describe("openai-codex streaming", () => {
expect(requestedToolChoice).toBe("required"); expect(requestedToolChoice).toBe("required");
}); });
it("sets Codex strict mode explicitly and honors constrained sampling", async () => {
const token = mockToken();
const encoder = new TextEncoder();
const sse = buildSSEPayload({ status: "completed" });
let requestedTools: Array<{ type?: string; name?: string; strict?: boolean | null }> | undefined;
vi.stubGlobal(
"fetch",
vi.fn(
async () =>
new Response(
new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(encoder.encode(sse));
controller.close();
},
}),
{ status: 200, headers: { "content-type": "text/event-stream" } },
),
),
);
const model: Model<"openai-codex-responses"> = {
id: "gpt-5.5",
name: "GPT-5.5",
api: "openai-codex-responses",
provider: "openai-codex",
baseUrl: "https://chatgpt.com/backend-api",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 400000,
maxTokens: 128000,
};
await streamOpenAICodexResponses(
model,
{
messages: [{ role: "user", content: "Use a tool", timestamp: Date.now() }],
tools: [
{
name: "optional",
description: "Optional constrained sampling",
parameters: Type.Object({ value: Type.String() }),
constrainedSampling: false,
},
{
name: "strict",
description: "Strict constrained sampling",
parameters: Type.Object({ value: Type.String() }, { additionalProperties: false }),
constrainedSampling: { type: "json_schema", strict: "prefer" },
},
],
},
{
apiKey: token,
transport: "sse",
onPayload: (payload) => {
requestedTools = (payload as { tools?: typeof requestedTools }).tools;
},
},
).result();
expect(requestedTools).toMatchObject([
{ type: "function", name: "optional", strict: null },
{ type: "function", name: "strict", strict: true },
]);
});
it.each(["gpt-5.3-codex", "gpt-5.4", "gpt-5.5"])("clamps %s minimal reasoning effort to low", async (modelId) => { it.each(["gpt-5.3-codex", "gpt-5.4", "gpt-5.5"])("clamps %s minimal reasoning effort to low", async (modelId) => {
const tempDir = mkdtempSync(join(tmpdir(), "pi-codex-stream-")); const tempDir = mkdtempSync(join(tmpdir(), "pi-codex-stream-"));
process.env.PI_CODING_AGENT_DIR = tempDir; process.env.PI_CODING_AGENT_DIR = tempDir;
@@ -1214,6 +1334,100 @@ describe("openai-codex streaming", () => {
}); });
}); });
it("closes one-shot websockets when cacheRetention is none", async () => {
const token = mockToken();
const sentBodies: Array<{ prompt_cache_key?: string }> = [];
let connections = 0;
let closedConnections = 0;
class MockWebSocket {
private listeners = new Map<string, Set<(event: unknown) => void>>();
constructor() {
connections++;
queueMicrotask(() => this.dispatch("open", {}));
}
addEventListener(type: string, listener: (event: unknown) => void): void {
let listeners = this.listeners.get(type);
if (!listeners) {
listeners = new Set();
this.listeners.set(type, listeners);
}
listeners.add(listener);
}
removeEventListener(type: string, listener: (event: unknown) => void): void {
this.listeners.get(type)?.delete(listener);
}
send(data: string): void {
sentBodies.push(JSON.parse(data) as { prompt_cache_key?: string });
queueMicrotask(() => {
this.dispatch("message", {
data: JSON.stringify({
type: "response.completed",
response: {
id: `resp_${connections}`,
status: "completed",
usage: { input_tokens: 5, output_tokens: 3, total_tokens: 8 },
},
}),
});
});
}
close(): void {
closedConnections++;
}
private dispatch(type: string, event: unknown): void {
for (const listener of this.listeners.get(type) ?? []) {
listener(event);
}
}
}
vi.stubGlobal("WebSocket", MockWebSocket);
vi.stubGlobal(
"fetch",
vi.fn(async () => new Response("unexpected fetch", { status: 500 })),
);
const model: Model<"openai-codex-responses"> = {
id: "gpt-5.1-codex",
name: "GPT-5.1 Codex",
api: "openai-codex-responses",
provider: "openai-codex",
baseUrl: "https://chatgpt.com/backend-api",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 400000,
maxTokens: 128000,
};
const context: Context = {
systemPrompt: "You are a helpful assistant.",
messages: [{ role: "user", content: "Say hello", timestamp: 1 }],
};
const options = {
apiKey: token,
cacheRetention: "none" as const,
sessionId: "one-off-summary",
transport: "auto" as const,
};
await streamOpenAICodexResponses(model, context, options).result();
await streamOpenAICodexResponses(model, context, options).result();
expect(connections).toBe(2);
expect(closedConnections).toBe(2);
expect(sentBodies).toHaveLength(2);
expect(sentBodies.every((body) => body.prompt_cache_key === undefined)).toBe(true);
expect(getOpenAICodexWebSocketDebugStats("one-off-summary")).toBeUndefined();
expect(global.fetch).not.toHaveBeenCalled();
});
it("falls back to SSE when websocket connect does not open before the connect timeout", async () => { it("falls back to SSE when websocket connect does not open before the connect timeout", async () => {
vi.useFakeTimers(); vi.useFakeTimers();
const token = mockToken(); const token = mockToken();
@@ -1658,10 +1872,6 @@ describe("openai-codex streaming", () => {
it("sends only response input deltas in websocket-cached mode", async () => { it("sends only response input deltas in websocket-cached mode", async () => {
const token = mockToken(); const token = mockToken();
const sentBodies: unknown[] = []; const sentBodies: unknown[] = [];
const responses = [
{ responseId: "resp_1", messageId: "msg_1", text: "Hello" },
{ responseId: "resp_2", messageId: "msg_2", text: "Done" },
];
class MockWebSocket { class MockWebSocket {
static OPEN = 1; static OPEN = 1;
@@ -1687,36 +1897,41 @@ describe("openai-codex streaming", () => {
send(data: string): void { send(data: string): void {
sentBodies.push(JSON.parse(data)); sentBodies.push(JSON.parse(data));
const response = responses.shift(); const responseId = `resp_${sentBodies.length}`;
if (!response) throw new Error("unexpected websocket request"); const outputEvents =
sentBodies.length === 1
? [
{
type: "response.output_item.added",
item: {
type: "custom_tool_call",
id: "ctc_1",
call_id: "call_1",
name: "sample_tool",
input: "",
},
},
{ type: "response.custom_tool_call_input.delta", item_id: "ctc_1", delta: "abc" },
{ type: "response.custom_tool_call_input.done", item_id: "ctc_1", input: "abc" },
{
type: "response.output_item.done",
item: {
type: "custom_tool_call",
id: "ctc_1",
call_id: "call_1",
name: "sample_tool",
input: "abc",
},
},
]
: [];
const events = [ const events = [
{ type: "response.created", response: { id: response.responseId } }, { type: "response.created", response: { id: responseId } },
{ ...outputEvents,
type: "response.output_item.added",
item: {
type: "message",
id: response.messageId,
role: "assistant",
status: "in_progress",
content: [],
},
},
{ type: "response.content_part.added", part: { type: "output_text", text: "" } },
{ type: "response.output_text.delta", delta: response.text },
{
type: "response.output_item.done",
item: {
type: "message",
id: response.messageId,
role: "assistant",
status: "completed",
content: [{ type: "output_text", text: response.text }],
},
},
{ {
type: "response.completed", type: "response.completed",
response: { response: {
id: response.responseId, id: responseId,
status: "completed", status: "completed",
usage: { usage: {
input_tokens: 5, input_tokens: 5,
@@ -1758,10 +1973,19 @@ describe("openai-codex streaming", () => {
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 400000, contextWindow: 400000,
maxTokens: 128000, maxTokens: 128000,
compat: { supportsOpenAIGrammarTools: true },
}; };
const firstContext: Context = { const firstContext: Context = {
systemPrompt: "You are a helpful assistant.", systemPrompt: "You are a helpful assistant.",
messages: [{ role: "user", content: "Say hello", timestamp: 1 }], messages: [{ role: "user", content: "Use the tool", timestamp: 1 }],
tools: [
{
name: "sample_tool",
description: "Sample tool",
parameters: Type.Object({ payload: Type.String() }),
constrainedSampling: { type: "grammar", variants: { openai_lark: "start: /[a-z]+/" } },
},
],
}; };
const first = await streamOpenAICodexResponses(model, firstContext, { const first = await streamOpenAICodexResponses(model, firstContext, {
@@ -1771,8 +1995,20 @@ describe("openai-codex streaming", () => {
}).result(); }).result();
const secondContext: Context = { const secondContext: Context = {
systemPrompt: "You are a helpful assistant.", ...firstContext,
messages: [...firstContext.messages, first, { role: "user", content: "Now finish", timestamp: 2 }], messages: [
...firstContext.messages,
first,
{
role: "toolResult",
toolCallId: "call_1|ctc_1",
toolName: "sample_tool",
content: [{ type: "text", text: "real result" }],
isError: false,
timestamp: 2,
},
{ role: "user", content: "Now finish", timestamp: 3 },
],
}; };
await streamOpenAICodexResponses(model, secondContext, { await streamOpenAICodexResponses(model, secondContext, {
apiKey: token, apiKey: token,
@@ -1785,10 +2021,13 @@ describe("openai-codex streaming", () => {
const secondBody = sentBodies[1] as { input: unknown[]; previous_response_id?: string; store?: boolean }; const secondBody = sentBodies[1] as { input: unknown[]; previous_response_id?: string; store?: boolean };
expect(firstBody.store).toBe(false); expect(firstBody.store).toBe(false);
expect(firstBody.previous_response_id).toBeUndefined(); expect(firstBody.previous_response_id).toBeUndefined();
expect(firstBody.input).toEqual([{ role: "user", content: [{ type: "input_text", text: "Say hello" }] }]); expect(firstBody.input).toEqual([{ role: "user", content: [{ type: "input_text", text: "Use the tool" }] }]);
expect(secondBody.store).toBe(false); expect(secondBody.store).toBe(false);
expect(secondBody.previous_response_id).toBe("resp_1"); expect(secondBody.previous_response_id).toBe("resp_1");
expect(secondBody.input).toEqual([{ role: "user", content: [{ type: "input_text", text: "Now finish" }] }]); expect(secondBody.input).toEqual([
{ type: "custom_tool_call_output", call_id: "call_1", output: "real result" },
{ role: "user", content: [{ type: "input_text", text: "Now finish" }] },
]);
expect(getOpenAICodexWebSocketDebugStats("session-1")).toMatchObject({ expect(getOpenAICodexWebSocketDebugStats("session-1")).toMatchObject({
requests: 2, requests: 2,
connectionsCreated: 1, connectionsCreated: 1,
@@ -1797,7 +2036,7 @@ describe("openai-codex streaming", () => {
storeTrueRequests: 0, storeTrueRequests: 0,
fullContextRequests: 1, fullContextRequests: 1,
deltaRequests: 1, deltaRequests: 1,
lastDeltaInputItems: 1, lastDeltaInputItems: 2,
lastPreviousResponseId: "resp_1", lastPreviousResponseId: "resp_1",
}); });
}); });
@@ -2094,6 +2333,46 @@ describe("openai-codex streaming", () => {
expect(codexRequests).toBe(2); expect(codexRequests).toBe(2);
}); });
it.each([429, 503])("fails immediately when a %i retry delay exceeds the limit", async (status) => {
const token = mockToken();
const fetchMock = vi.fn(
async () =>
new Response(JSON.stringify({ error: { code: "temporarily_unavailable", message: "retry later" } }), {
status,
headers: { "content-type": "application/json", "retry-after": "2" },
}),
);
vi.stubGlobal("fetch", fetchMock);
const model: Model<"openai-codex-responses"> = {
id: "gpt-5.1-codex",
name: "GPT-5.1 Codex",
api: "openai-codex-responses",
provider: "openai-codex",
baseUrl: "https://chatgpt.com/backend-api",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 400000,
maxTokens: 128000,
};
const context: Context = {
systemPrompt: "You are a helpful assistant.",
messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }],
};
const result = await streamOpenAICodexResponses(model, context, {
apiKey: token,
transport: "sse",
maxRetries: 3,
maxRetryDelayMs: 1000,
}).result();
expect(result.stopReason).toBe("error");
expect(result.errorMessage).toBe("Server requested 2s retry delay (max: 1s)");
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("zstd-compresses SSE request bodies", async () => { it("zstd-compresses SSE request bodies", async () => {
const token = mockToken(); const token = mockToken();
const encoder = new TextEncoder(); const encoder = new TextEncoder();
@@ -1,9 +1,10 @@
import { beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { stream as streamOpenAICompletions } from "../src/api/openai-completions.ts"; import { stream as streamOpenAICompletions } from "../src/api/openai-completions.ts";
import type { Context, Model } from "../src/types.ts"; import type { Context, Model } from "../src/types.ts";
const mockState = vi.hoisted(() => ({ const mockState = vi.hoisted(() => ({
requestOptions: [] as unknown[], requestOptions: [] as unknown[],
requestErrors: [] as Error[],
})); }));
vi.mock("openai", () => { vi.mock("openai", () => {
@@ -30,10 +31,14 @@ vi.mock("openai", () => {
response: { status: number; headers: Headers }; response: { status: number; headers: Headers };
}>; }>;
}; };
promise.withResponse = async () => ({ promise.withResponse = async () => {
data: stream, const error = mockState.requestErrors.shift();
response: { status: 200, headers: new Headers() }, if (error) throw error;
}); return {
data: stream,
response: { status: 200, headers: new Headers() },
};
};
return promise; return promise;
}, },
}, },
@@ -61,7 +66,7 @@ const context: Context = {
tools: [], tools: [],
}; };
async function consume(options?: { maxRetries?: number }) { async function consume(options?: { maxRetries?: number; maxRetryDelayMs?: number }) {
const stream = streamOpenAICompletions(model, context, { apiKey: "test", ...options }); const stream = streamOpenAICompletions(model, context, { apiKey: "test", ...options });
for await (const _event of stream) { for await (const _event of stream) {
void _event; void _event;
@@ -72,6 +77,11 @@ async function consume(options?: { maxRetries?: number }) {
describe("openai-completions provider retries", () => { describe("openai-completions provider retries", () => {
beforeEach(() => { beforeEach(() => {
mockState.requestOptions = []; mockState.requestOptions = [];
mockState.requestErrors = [];
});
afterEach(() => {
vi.useRealTimers();
}); });
it("disables SDK retries by default", async () => { it("disables SDK retries by default", async () => {
@@ -79,8 +89,51 @@ describe("openai-completions provider retries", () => {
expect(mockState.requestOptions).toEqual([expect.objectContaining({ maxRetries: 0 })]); expect(mockState.requestOptions).toEqual([expect.objectContaining({ maxRetries: 0 })]);
}); });
it("honors explicit provider retry settings", async () => { it("honors provider retries while keeping SDK retries disabled", async () => {
await consume({ maxRetries: 2 }); vi.useFakeTimers();
expect(mockState.requestOptions).toEqual([expect.objectContaining({ maxRetries: 2 })]); mockState.requestErrors = [
Object.assign(new Error("rate limited"), {
status: 429,
headers: new Headers({ "retry-after-ms": "100" }),
}),
Object.assign(new Error("server error"), {
status: 500,
headers: new Headers({ "retry-after-ms": "100" }),
}),
];
const result = consume({ maxRetries: 2, maxRetryDelayMs: 100 });
await vi.advanceTimersByTimeAsync(0);
expect(mockState.requestOptions).toHaveLength(1);
await vi.advanceTimersByTimeAsync(99);
expect(mockState.requestOptions).toHaveLength(1);
await vi.advanceTimersByTimeAsync(1);
expect(mockState.requestOptions).toHaveLength(2);
await vi.advanceTimersByTimeAsync(99);
expect(mockState.requestOptions).toHaveLength(2);
await vi.advanceTimersByTimeAsync(1);
await result;
expect(mockState.requestOptions).toEqual([
expect.objectContaining({ maxRetries: 0 }),
expect.objectContaining({ maxRetries: 0 }),
expect.objectContaining({ maxRetries: 0 }),
]);
});
it("fails immediately when a provider-requested retry delay exceeds the limit", async () => {
mockState.requestErrors = [
Object.assign(new Error("rate limited"), {
status: 429,
headers: new Headers({ "retry-after": "277403" }),
}),
];
const result = await consume({ maxRetries: 2, maxRetryDelayMs: 1000 });
expect(result.stopReason).toBe("error");
expect(result.errorMessage).toContain("Server requested 277403s retry delay (max: 1s)");
expect(result.errorMessage).toContain("rate limited");
expect(mockState.requestOptions).toEqual([expect.objectContaining({ maxRetries: 0 })]);
}); });
}); });
@@ -37,6 +37,7 @@ const compat = {
chatTemplateKwargs: {}, chatTemplateKwargs: {},
zaiToolStream: false, zaiToolStream: false,
supportsStrictMode: true, supportsStrictMode: true,
supportsOpenAIGrammarTools: false,
cacheControlFormat: undefined, cacheControlFormat: undefined,
sendSessionAffinityHeaders: false, sendSessionAffinityHeaders: false,
sessionAffinityFormat: "openai", sessionAffinityFormat: "openai",
@@ -1258,6 +1258,7 @@ describe("openai-completions tool_choice", () => {
chatTemplateKwargs: {}, chatTemplateKwargs: {},
zaiToolStream: false, zaiToolStream: false,
supportsStrictMode: true, supportsStrictMode: true,
supportsOpenAIGrammarTools: false,
sendSessionAffinityHeaders: false, sendSessionAffinityHeaders: false,
sessionAffinityFormat: "openai", sessionAffinityFormat: "openai",
supportsLongCacheRetention: true, supportsLongCacheRetention: true,
@@ -37,6 +37,7 @@ const compat: Omit<Required<OpenAICompletionsCompat>, "deferredToolsMode"> & {
chatTemplateKwargs: {}, chatTemplateKwargs: {},
zaiToolStream: false, zaiToolStream: false,
supportsStrictMode: true, supportsStrictMode: true,
supportsOpenAIGrammarTools: false,
cacheControlFormat: "anthropic", cacheControlFormat: "anthropic",
sendSessionAffinityHeaders: false, sendSessionAffinityHeaders: false,
sessionAffinityFormat: "openai", sessionAffinityFormat: "openai",
@@ -90,7 +90,7 @@ describe.skipIf(!process.env.OPENAI_API_KEY || !process.env.ANTHROPIC_API_KEY)(
// 6. With fix: tool calls/results converted to text, conversation continues // 6. With fix: tool calls/results converted to text, conversation continues
const modelA = getModel("openai", "gpt-5-mini"); const modelA = getModel("openai", "gpt-5-mini");
const modelB = getModel("openai", "gpt-5.2-codex"); const modelB = getModel("openai", "gpt-5.5");
const apiKey = getEnvApiKey("openai"); const apiKey = getEnvApiKey("openai");
if (!apiKey) { if (!apiKey) {
@@ -189,7 +189,7 @@ describe.skipIf(!process.env.OPENAI_API_KEY || !process.env.ANTHROPIC_API_KEY)(
// 5. Should work because foreign IDs have no pairing expectation // 5. Should work because foreign IDs have no pairing expectation
const anthropicModel = getModel("anthropic", "claude-sonnet-4-5"); const anthropicModel = getModel("anthropic", "claude-sonnet-4-5");
const codexModel = getModel("openai", "gpt-5.2-codex"); const codexModel = getModel("openai", "gpt-5.5");
const anthropicApiKey = getEnvApiKey("anthropic"); const anthropicApiKey = getEnvApiKey("anthropic");
const openaiApiKey = getEnvApiKey("openai"); const openaiApiKey = getEnvApiKey("openai");
@@ -186,4 +186,28 @@ describe("provider error body passthrough (per-tier regression)", () => {
expect(output.errorMessage).toContain("blocked by gateway WAF"); expect(output.errorMessage).toContain("blocked by gateway WAF");
expect(output.errorMessage).not.toContain("Unknown: UnknownError"); expect(output.errorMessage).not.toContain("Unknown: UnknownError");
}); });
it("bedrock preserves the SDK validation message when the response body is a stream", async () => {
bedrockMock.sendError = Object.assign(
new Error(
"Invocation of model ID anthropic.claude-opus-5 with on-demand throughput isn't supported. Retry with an inference profile.",
),
{
name: "ValidationException",
$metadata: { httpStatusCode: 400 },
$response: {
statusCode: 400,
body: { pipe: () => undefined, _readableState: { buffer: [], length: 0 } },
},
},
);
const model = getModel("amazon-bedrock", "global.anthropic.claude-opus-5");
const output = await drainResult(streamSimpleBedrock(model, { messages: context.messages }, {}));
expect(output.stopReason).toBe("error");
expect(output.errorMessage).toContain("on-demand throughput isn't supported");
expect(output.errorMessage).toContain("inference profile");
expect(output.errorMessage).not.toContain("_readableState");
});
}); });
+81
View File
@@ -0,0 +1,81 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { retryProviderRequest } from "../src/utils/provider-retry.ts";
function providerError(status: number | undefined, headers?: Record<string, string>): Error {
return Object.assign(new Error(`Provider error: ${status}`), {
status,
headers: new Headers(headers),
});
}
describe("provider request retries", () => {
afterEach(() => {
vi.useRealTimers();
});
it("retries retryable provider errors", async () => {
vi.useFakeTimers();
const request = vi
.fn<() => Promise<string>>()
.mockRejectedValueOnce(providerError(429, { "retry-after-ms": "1000" }))
.mockResolvedValue("ok");
const result = retryProviderRequest(request, { maxRetries: 1 });
await vi.advanceTimersByTimeAsync(999);
expect(request).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(1);
await expect(result).resolves.toBe("ok");
expect(request).toHaveBeenCalledTimes(2);
});
it("does not retry errors the provider marks as non-retryable", async () => {
const error = providerError(429, { "x-should-retry": "false" });
const request = vi.fn<() => Promise<string>>().mockRejectedValue(error);
await expect(retryProviderRequest(request, { maxRetries: 2 })).rejects.toBe(error);
expect(request).toHaveBeenCalledTimes(1);
});
it("rejects a provider-requested retry delay above the limit", async () => {
const request = vi.fn<() => Promise<string>>().mockRejectedValue(providerError(429, { "retry-after": "277403" }));
await expect(retryProviderRequest(request, { maxRetries: 1, maxRetryDelayMs: 1000 })).rejects.toThrow(
"Server requested 277403s retry delay (max: 1s)",
);
expect(request).toHaveBeenCalledTimes(1);
});
it("allows disabling the provider-requested retry delay cap", async () => {
vi.useFakeTimers();
const request = vi
.fn<() => Promise<string>>()
.mockRejectedValueOnce(providerError(429, { "retry-after": "2" }))
.mockResolvedValue("ok");
const result = retryProviderRequest(request, { maxRetries: 1, maxRetryDelayMs: 0 });
await vi.advanceTimersByTimeAsync(1999);
expect(request).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(1);
await expect(result).resolves.toBe("ok");
expect(request).toHaveBeenCalledTimes(2);
});
it("aborts a provider-requested retry delay", async () => {
vi.useFakeTimers();
const controller = new AbortController();
const request = vi.fn<() => Promise<string>>().mockRejectedValue(providerError(429, { "retry-after": "277403" }));
const result = retryProviderRequest(request, { maxRetries: 2, maxRetryDelayMs: 0, signal: controller.signal });
await vi.advanceTimersByTimeAsync(0);
expect(request).toHaveBeenCalledTimes(1);
expect(vi.getTimerCount()).toBe(1);
controller.abort();
await expect(result).rejects.toMatchObject({ name: "AbortError" });
expect(request).toHaveBeenCalledTimes(1);
expect(vi.getTimerCount()).toBe(0);
});
});
+30 -4
View File
@@ -3,7 +3,7 @@ import { envApiKeyAuth } from "../src/auth/helpers.ts";
import type { AuthContext, AuthEvent } from "../src/auth/types.ts"; import type { AuthContext, AuthEvent } from "../src/auth/types.ts";
import { createModels, createProvider } from "../src/models.ts"; import { createModels, createProvider } from "../src/models.ts";
import { InMemoryModelsStore, type ModelsStoreEntry } from "../src/models-store.ts"; import { InMemoryModelsStore, type ModelsStoreEntry } from "../src/models-store.ts";
import { builtinModels, builtinProviders } from "../src/providers/all.ts"; import { builtinModels, builtinProviders, getBuiltinModel } from "../src/providers/all.ts";
import { amazonBedrockProvider } from "../src/providers/amazon-bedrock.ts"; import { amazonBedrockProvider } from "../src/providers/amazon-bedrock.ts";
import { anthropicProvider } from "../src/providers/anthropic.ts"; import { anthropicProvider } from "../src/providers/anthropic.ts";
import { cloudflareAIGatewayProvider } from "../src/providers/cloudflare-ai-gateway.ts"; import { cloudflareAIGatewayProvider } from "../src/providers/cloudflare-ai-gateway.ts";
@@ -44,6 +44,17 @@ describe("builtin providers", () => {
} }
}); });
it("stores native constrained-sampling capabilities in model metadata", () => {
const gpt4o = getBuiltinModel("openai", "gpt-4o");
expect(gpt4o.compat?.supportsStrictMode).toBe(true);
expect(gpt4o.compat?.supportsOpenAIGrammarTools).toBeUndefined();
expect(getBuiltinModel("openai", "gpt-5.4").compat).toMatchObject({
supportsStrictMode: true,
supportsOpenAIGrammarTools: true,
});
expect(getBuiltinModel("anthropic", "claude-haiku-4-5").compat?.supportsStrictTools).toBe(true);
});
it("uses official Kimi K3 pricing for Moonshot providers", () => { it("uses official Kimi K3 pricing for Moonshot providers", () => {
const models = builtinModels(); const models = builtinModels();
for (const provider of ["moonshotai", "moonshotai-cn"]) { for (const provider of ["moonshotai", "moonshotai-cn"]) {
@@ -68,14 +79,29 @@ describe("builtin providers", () => {
} }
}); });
it("resolves anthropic auth from env with OAuth token precedence", async () => { it("resolves Anthropic bearer auth from env with auth token precedence", async () => {
const models = createModels({
authContext: fakeAuthContext({
ANTHROPIC_AUTH_TOKEN: "auth-token",
ANTHROPIC_OAUTH_TOKEN: "oauth-token",
ANTHROPIC_API_KEY: "api-key",
}),
});
models.setProvider(anthropicProvider());
expect(await models.getAuth("anthropic")).toEqual({
auth: { headers: { Authorization: "Bearer auth-token" } },
source: "ANTHROPIC_AUTH_TOKEN",
});
});
it("preserves Anthropic OAuth token precedence over the API key", async () => {
const models = createModels({ const models = createModels({
authContext: fakeAuthContext({ ANTHROPIC_API_KEY: "key", ANTHROPIC_OAUTH_TOKEN: "oauth-token" }), authContext: fakeAuthContext({ ANTHROPIC_API_KEY: "key", ANTHROPIC_OAUTH_TOKEN: "oauth-token" }),
}); });
models.setProvider(anthropicProvider()); models.setProvider(anthropicProvider());
const model = models.getModel("anthropic", "claude-haiku-4-5")!;
const result = await models.getAuth(model.provider); const result = await models.getAuth("anthropic");
expect(result?.auth.apiKey).toBe("oauth-token"); expect(result?.auth.apiKey).toBe("oauth-token");
expect(result?.source).toBe("ANTHROPIC_OAUTH_TOKEN"); expect(result?.source).toBe("ANTHROPIC_OAUTH_TOKEN");
}); });
+125
View File
@@ -0,0 +1,125 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { createRadiusOAuth } from "../src/auth/oauth/radius.ts";
import type { AuthEvent, AuthInteraction } from "../src/auth/types.ts";
const GATEWAY = "https://radius.example";
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json" },
});
}
function requestUrl(input: unknown): string {
if (typeof input === "string") return input;
if (input instanceof URL) return input.toString();
if (input instanceof Request) return input.url;
throw new Error(`Unsupported request input: ${String(input)}`);
}
function interaction(loginMethod: "browser" | "device-code", events: AuthEvent[] = []): AuthInteraction {
return {
prompt: async () => loginMethod,
notify: (event) => events.push(event),
};
}
describe("Radius OAuth", () => {
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
vi.useRealTimers();
});
it("uses gateway endpoints directly for device login", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-07-24T00:00:00Z"));
const events: AuthEvent[] = [];
const urls: string[] = [];
vi.stubGlobal(
"fetch",
vi.fn(async (input: unknown, init?: RequestInit) => {
const url = requestUrl(input);
urls.push(url);
const form = new URLSearchParams(String(init?.body));
if (url === `${GATEWAY}/v1/oauth/device`) {
expect(form.get("client_id")).toBe("pi-gateway");
expect(form.get("scope")).toBe("gateway offline_access");
return jsonResponse({
device_code: "device-code",
user_code: "ABCD-1234",
verification_uri: "https://radius-ui.example/pair",
expires_in: 600,
interval: 5,
});
}
if (url === `${GATEWAY}/v1/oauth/token`) {
expect(form.get("grant_type")).toBe("urn:ietf:params:oauth:grant-type:device_code");
expect(form.get("client_id")).toBe("pi-gateway");
expect(form.get("device_code")).toBe("device-code");
return jsonResponse({
access_token: "access-token",
refresh_token: "refresh-token",
expires_in: 3600,
scope: "gateway offline_access",
});
}
throw new Error(`Unexpected request: ${url}`);
}),
);
const oauth = createRadiusOAuth({ name: "Radius", gateway: GATEWAY });
await expect(oauth.login(interaction("device-code", events))).resolves.toEqual({
type: "oauth",
access: "access-token",
refresh: "refresh-token",
expires: Date.now() + 3600 * 1000 - 60_000,
scope: "gateway offline_access",
});
expect(events).toEqual([
{
type: "device_code",
userCode: "ABCD-1234",
verificationUri: "https://radius-ui.example/pair",
intervalSeconds: 5,
expiresInSeconds: 600,
},
]);
expect(urls).toEqual([`${GATEWAY}/v1/oauth/device`, `${GATEWAY}/v1/oauth/token`]);
});
it("refreshes directly through the gateway without discovery", async () => {
const fetchMock = vi.fn(async (input: unknown, init?: RequestInit) => {
expect(requestUrl(input)).toBe(`${GATEWAY}/v1/oauth/token`);
const form = new URLSearchParams(String(init?.body));
expect(form.get("grant_type")).toBe("refresh_token");
expect(form.get("client_id")).toBe("pi-gateway");
expect(form.get("refresh_token")).toBe("old-refresh");
return jsonResponse({
access_token: "new-access",
refresh_token: "new-refresh",
expires_in: 3600,
});
});
vi.stubGlobal("fetch", fetchMock);
const oauth = createRadiusOAuth({ name: "Radius", gateway: GATEWAY });
await expect(
oauth.refresh({ type: "oauth", access: "old-access", refresh: "old-refresh", expires: 0 }),
).resolves.toMatchObject({ access: "new-access", refresh: "new-refresh" });
expect(fetchMock).toHaveBeenCalledOnce();
});
it("discovers only the interactive browser authorization endpoint", async () => {
const fetchMock = vi.fn(async (input: unknown) => {
expect(requestUrl(input)).toBe(`${GATEWAY}/v1/oauth`);
return jsonResponse({ issuer: "https://radius-ui.example" });
});
vi.stubGlobal("fetch", fetchMock);
const oauth = createRadiusOAuth({ name: "Radius", gateway: GATEWAY });
await expect(oauth.login(interaction("browser"))).rejects.toThrow(`Invalid Radius OAuth config from ${GATEWAY}`);
expect(fetchMock).toHaveBeenCalledOnce();
});
});
+14
View File
@@ -16,6 +16,13 @@ describe("getSupportedThinkingLevels", () => {
expect(getSupportedThinkingLevels(model!)).toContain("max"); expect(getSupportedThinkingLevels(model!)).toContain("max");
}); });
it("includes xhigh and max for Anthropic Opus 5 on anthropic-messages API", () => {
const model = getModel("anthropic", "claude-opus-5");
expect(model).toBeDefined();
expect(getSupportedThinkingLevels(model!)).toContain("xhigh");
expect(getSupportedThinkingLevels(model!)).toContain("max");
});
it("includes max but not xhigh for Anthropic Sonnet 4.6 on anthropic-messages API", () => { it("includes max but not xhigh for Anthropic Sonnet 4.6 on anthropic-messages API", () => {
const model = getModel("anthropic", "claude-sonnet-4-6"); const model = getModel("anthropic", "claude-sonnet-4-6");
expect(model).toBeDefined(); expect(model).toBeDefined();
@@ -133,6 +140,13 @@ describe("getSupportedThinkingLevels", () => {
expect(getSupportedThinkingLevels(model!)).not.toContain("xhigh"); expect(getSupportedThinkingLevels(model!)).not.toContain("xhigh");
}); });
it("includes xhigh and max for Bedrock Claude Opus 5", () => {
const model = getModel("amazon-bedrock", "global.anthropic.claude-opus-5");
expect(model).toBeDefined();
expect(getSupportedThinkingLevels(model!)).toContain("xhigh");
expect(getSupportedThinkingLevels(model!)).toContain("max");
});
it("includes xhigh and max but not off for Bedrock Claude Fable 5", () => { it("includes xhigh and max but not off for Bedrock Claude Fable 5", () => {
const model = getModel("amazon-bedrock", "global.anthropic.claude-fable-5"); const model = getModel("amazon-bedrock", "global.anthropic.claude-fable-5");
expect(model).toBeDefined(); expect(model).toBeDefined();
+2 -2
View File
@@ -15,10 +15,10 @@ function makeContext(): Context {
} }
describe.skipIf(!process.env.OPENAI_API_KEY)("xhigh reasoning", () => { describe.skipIf(!process.env.OPENAI_API_KEY)("xhigh reasoning", () => {
describe("codex-max (supports xhigh)", () => { describe("gpt 5.5 (supports xhigh)", () => {
// Note: codex models only support the responses API, not chat completions // Note: codex models only support the responses API, not chat completions
it("should work with openai-responses", async () => { it("should work with openai-responses", async () => {
const model = getModel("openai", "gpt-5.1-codex-max"); const model = getModel("openai", "gpt-5.5");
const s = stream(model, makeContext(), { reasoningEffort: "xhigh" }); const s = stream(model, makeContext(), { reasoningEffort: "xhigh" });
let hasThinking = false; let hasThinking = false;
+61 -3
View File
@@ -2,14 +2,73 @@
## [Unreleased] ## [Unreleased]
## [0.82.1] - 2026-07-25
### New Features
- **Claude Opus 5** — Available on Anthropic and Amazon Bedrock with adaptive thinking (including `xhigh`), inference profiles, and prompt caching. See [Providers](docs/providers.md#api-keys).
- **Anthropic gateway bearer auth**`ANTHROPIC_AUTH_TOKEN` authenticates against Anthropic-compatible gateways that require `Authorization: Bearer`, including compaction and branch summaries. See [Environment Variables or Auth File](docs/providers.md#environment-variables-or-auth-file).
- **Faster, more resilient model catalogs** — pi.dev catalogs revalidate with `If-None-Match` so unchanged providers answer with an empty `304`, and llama.cpp models stay listed across restarts. See [llama.cpp](docs/llama-cpp.md).
### Added ### Added
- Exposed `PI_SESSION_ID`, `PI_SESSION_FILE`, `PI_PROVIDER`, `PI_MODEL`, and `PI_REASONING_LEVEL` to commands run by built-in and factory-created bash tools. - Exposed the `outputPad` setting to custom message renderers. See [Extensions](docs/extensions.md) ([#7045](https://github.com/earendil-works/pi/pull/7045) by [@xl0](https://github.com/xl0)).
- Added inherited `ANTHROPIC_AUTH_TOKEN` bearer authentication for Anthropic-compatible gateways. See [Providers](docs/providers.md#environment-variables-or-auth-file) ([#5871](https://github.com/earendil-works/pi/issues/5871)).
- Added inherited Claude Opus 5 support for Anthropic and Amazon Bedrock with adaptive thinking, inference profiles, prompt caching, and preserved AWS validation messages ([#7081](https://github.com/earendil-works/pi/pull/7081) by [@unexge](https://github.com/unexge), [#7083](https://github.com/earendil-works/pi/pull/7083) by [@davidbrai](https://github.com/davidbrai)).
### Changed
- Changed pi.dev model catalog refreshes to revalidate with `If-None-Match`, so unchanged provider catalogs answer with an empty `304` instead of a full download.
- Changed inherited Radius OAuth device authorization, token exchange, and refresh requests to use the configured gateway directly.
- Changed inherited model loading errors to append the underlying cause, so auth failures such as `OAuth refresh failed for openai-codex` report the provider response instead of a bare wrapper message.
### Fixed ### Fixed
- Fixed compaction and branch-summary requests to use fresh routing session IDs with prompt caching disabled where supported. - Fixed compaction and branch summaries for providers whose authentication resolves entirely to request headers ([#5871](https://github.com/earendil-works/pi/issues/5871))
- Fixed unavailable scoped models being hidden from `/models`, allowing them to be removed without editing settings manually ([#6949](https://github.com/earendil-works/pi/issues/6949), [#7032](https://github.com/earendil-works/pi/pull/7032) by [@christianklotz](https://github.com/christianklotz)).
- Fixed startup context file discovery to skip directories that match context file names such as `AGENTS.md`, which produced `EISDIR` warnings ([#7106](https://github.com/earendil-works/pi/pull/7106) by [@mrexodia](https://github.com/mrexodia)).
- Fixed the llama.cpp extension to persist its model catalog, so llama.cpp models stay listed before the first successful refresh. See [llama.cpp](docs/llama-cpp.md) ([#7072](https://github.com/earendil-works/pi/pull/7072) by [@davidbrai](https://github.com/davidbrai)).
## [0.82.0] - 2026-07-24
### New Features
- **Constrained tool sampling** — Tools can prefer or require strict JSON Schema sampling or use OpenAI Lark/regex grammars, with model capability metadata preventing unsupported requests. See [Constrained Sampling for Tools](../ai/README.md#constrained-sampling-for-tools).
- **OpenRouter and Kimi Code sign-in** — Use `/login` to authorize OpenRouter or a Kimi Code subscription without manually configuring API keys. See [OpenRouter](docs/providers.md#openrouter).
- **Session-aware, streaming bash integrations** — Bash tools receive current session/model metadata, while direct RPC bash commands stream correlated output. See [Bash Tool Session Environment](docs/environment-variables.md#bash-tool-session-environment) and [RPC bash events](docs/rpc.md#bash_execution_update).
### Added
- Added inherited `Tool.constrainedSampling` with strict JSON Schema (`prefer`/`require`) and OpenAI Lark/regex grammar variants across OpenAI, Anthropic, Amazon Bedrock, Google Gemini, and Mistral. See [Constrained Sampling for Tools](../ai/README.md#constrained-sampling-for-tools).
- Added inherited `supportsGrammarTools` and `supportsStrictTools` compatibility flags, expanded `supportsStrictMode` coverage, and generated model capability metadata to gate constrained sampling.
- Added inherited Kimi Code subscription OAuth login for the Kimi For Coding provider, including device authorization and automatic token refresh ([#6935](https://github.com/earendil-works/pi/pull/6935) by [@zaycruz](https://github.com/zaycruz)).
- Added inherited OpenRouter OAuth PKCE login through `/login`, minting a user-controlled API key. See [OpenRouter](docs/providers.md#openrouter) ([#6927](https://github.com/earendil-works/pi/pull/6927) by [@rsaryev](https://github.com/rsaryev)).
- Exposed `PI_SESSION_ID`, `PI_SESSION_FILE`, `PI_PROVIDER`, `PI_MODEL`, and `PI_REASONING_LEVEL` to commands run by built-in and factory-created bash tools. See [Bash Tool Session Environment](docs/environment-variables.md#bash-tool-session-environment).
- Added streaming `bash_execution_update` events for direct RPC bash commands, correlated with request IDs. See [RPC bash events](docs/rpc.md#bash_execution_update) ([#6971](https://github.com/earendil-works/pi/pull/6971) by [@ananthakumaran](https://github.com/ananthakumaran)).
### Changed
- Changed inherited generated model catalogs to expose only provider-verified reasoning effort levels from models.dev ([#6928](https://github.com/earendil-works/pi/pull/6928) by [@davidbrai](https://github.com/davidbrai)).
### Fixed
- Fixed inherited DNS lookup failures such as `getaddrinfo`, `ENOTFOUND`, and `EAI_AGAIN` to trigger automatic assistant retries ([#6946](https://github.com/earendil-works/pi/pull/6946) by [@christianklotz](https://github.com/christianklotz)).
- Fixed inherited OpenRouter Anthropic cache breakpoints to advance through tool results and enabled cache control for `~anthropic/*-latest` aliases ([#6941](https://github.com/earendil-works/pi/pull/6941) by [@mteam88](https://github.com/mteam88)).
- Fixed inherited OpenAI Codex WebSocket sessions to retry once without a missing previous-response continuation after `previous_response_not_found` errors ([#6955](https://github.com/earendil-works/pi/pull/6955) by [@davidbrai](https://github.com/davidbrai)).
- Fixed TUI debug and crash logs to respect custom agent directories instead of always writing under `~/.pi/agent` ([#6958](https://github.com/earendil-works/pi/pull/6958) by [@davidbrai](https://github.com/davidbrai)).
- Fixed slow Ctrl+G external-editor startup when the system temporary directory contains many entries ([#6903](https://github.com/earendil-works/pi/pull/6903) by [@christianklotz](https://github.com/christianklotz)).
- Fixed startup resource display to preserve relative paths for sibling npm extensions loaded by a package ([#6964](https://github.com/earendil-works/pi/pull/6964) by [@davidbrai](https://github.com/davidbrai)).
- Fixed compaction and branch-summary requests to use fresh routing session IDs with prompt caching disabled where supported ([#6618](https://github.com/earendil-works/pi/pull/6618) by [@tmustier](https://github.com/tmustier)).
- Fixed explicit self-updates when `PI_SKIP_VERSION_CHECK` is set ([#6977](https://github.com/earendil-works/pi/issues/6977)). - Fixed explicit self-updates when `PI_SKIP_VERSION_CHECK` is set ([#6977](https://github.com/earendil-works/pi/issues/6977)).
- Fixed scoped model IDs containing brackets to resolve as literal exact matches before glob matching ([#6210](https://github.com/earendil-works/pi/issues/6210)).
- Fixed inherited OpenAI and Anthropic provider retry waits to honor abort signals and configured delay limits ([#6980](https://github.com/earendil-works/pi/pull/6980) by [@petrroll](https://github.com/petrroll)).
- Fixed fresh installs from preferring bundled model catalogs over newer remote catalogs because package file mtimes were newer ([#7016](https://github.com/earendil-works/pi/pull/7016) by [@davidbrai](https://github.com/davidbrai)).
- Fixed inherited editor scroll indicators overflowing narrow terminals ([#7015](https://github.com/earendil-works/pi/pull/7015) by [@christianklotz](https://github.com/christianklotz)).
- Fixed llama.cpp models to use the loaded context window as their output token limit instead of capping it at 16K ([#7034](https://github.com/earendil-works/pi/pull/7034) by [@christianklotz](https://github.com/christianklotz)).
- Fixed release source archives to include the generated provider model data used to build standalone binaries.
- Updated the packaged `protobufjs` dependency to 7.6.5 to address GHSA-j3f2-48v5-ccww ([#7005](https://github.com/earendil-works/pi/issues/7005)).
- Fixed `/copy` on Wayland to fall back to X11 or OSC 52 when `wl-copy` fails ([#7009](https://github.com/earendil-works/pi/pull/7009) by [@rkfshakti](https://github.com/rkfshakti)).
- Fixed `/model` to reload updated `models.json` configuration when opening the model picker ([#6999](https://github.com/earendil-works/pi/issues/6999)).
## [0.81.1] - 2026-07-21 ## [0.81.1] - 2026-07-21
@@ -66,7 +125,6 @@
- Fixed llama.cpp router download progress updates and removed redundant wording from model action confirmations. - Fixed llama.cpp router download progress updates and removed redundant wording from model action confirmations.
- Moved automatic model catalog network refresh out of startup initialization and into the running interactive and RPC modes. - Moved automatic model catalog network refresh out of startup initialization and into the running interactive and RPC modes.
- Fixed persisted sessions being read and parsed twice when opened, reducing startup latency for large sessions ([#6793](https://github.com/earendil-works/pi/issues/6793)). - Fixed persisted sessions being read and parsed twice when opened, reducing startup latency for large sessions ([#6793](https://github.com/earendil-works/pi/issues/6793)).
- Fixed slow Ctrl+G external-editor startup when the system temporary directory contains many entries ([#6774](https://github.com/earendil-works/pi/issues/6774)).
- Fixed prompt-template defaults for all arguments (`${@:-default}` and `${ARGUMENTS:-default}`) ([#6695](https://github.com/earendil-works/pi/issues/6695)). - Fixed prompt-template defaults for all arguments (`${@:-default}` and `${ARGUMENTS:-default}`) ([#6695](https://github.com/earendil-works/pi/issues/6695)).
- Fixed obsolete custom UI, custom tool, and custom editor examples in the extension documentation ([#6735](https://github.com/earendil-works/pi/issues/6735)). - Fixed obsolete custom UI, custom tool, and custom editor examples in the extension documentation ([#6735](https://github.com/earendil-works/pi/issues/6735)).
- Fixed Kimi Coding sessions to show API-equivalent implied costs with the subscription indicator. - Fixed Kimi Coding sessions to show API-equivalent implied costs with the subscription indicator.
+1 -1
View File
@@ -20,7 +20,7 @@ Pi has two summarization mechanisms:
| Compaction | Context exceeds threshold, or `/compact` | Summarize old messages to free up context | | Compaction | Context exceeds threshold, or `/compact` | Summarize old messages to free up context |
| Branch summarization | `/tree` navigation | Preserve context when switching branches | | Branch summarization | `/tree` navigation | Preserve context when switching branches |
Both use the same structured summary format and track file operations cumulatively. Both use the same structured summary format and track file operations cumulatively. Compaction and branch-summary requests use fresh routing session IDs and, where supported by the provider, disable prompt-cache writes because these one-off prompts are unlikely to be reused.
## Compaction ## Compaction
@@ -737,6 +737,8 @@ interface ProviderModelConfig {
supportsDeveloperRole?: boolean; supportsDeveloperRole?: boolean;
supportsReasoningEffort?: boolean; supportsReasoningEffort?: boolean;
supportsUsageInStreaming?: boolean; supportsUsageInStreaming?: boolean;
supportsStrictMode?: boolean;
supportsOpenAIGrammarTools?: boolean; // openai-completions/openai-responses; false falls back to normal function tools
maxTokensField?: "max_completion_tokens" | "max_tokens"; maxTokensField?: "max_completion_tokens" | "max_tokens";
requiresToolResultName?: boolean; requiresToolResultName?: boolean;
requiresAssistantAfterToolResult?: boolean; requiresAssistantAfterToolResult?: boolean;
@@ -755,6 +757,7 @@ interface ProviderModelConfig {
supportsCacheControlOnTools?: boolean; supportsCacheControlOnTools?: boolean;
forceAdaptiveThinking?: boolean; forceAdaptiveThinking?: boolean;
allowEmptySignature?: boolean; allowEmptySignature?: boolean;
supportsStrictTools?: boolean;
}; };
} }
``` ```
+2 -2
View File
@@ -2791,7 +2791,7 @@ Register a custom renderer for messages with your `customType`. Use message rend
import { Text } from "@earendil-works/pi-tui"; import { Text } from "@earendil-works/pi-tui";
pi.registerMessageRenderer("my-extension", (message, options, theme) => { pi.registerMessageRenderer("my-extension", (message, options, theme) => {
const { expanded } = options; const { expanded, outputPad } = options;
let text = theme.fg("accent", `[${message.customType}] `); let text = theme.fg("accent", `[${message.customType}] `);
text += message.content; text += message.content;
@@ -2799,7 +2799,7 @@ pi.registerMessageRenderer("my-extension", (message, options, theme) => {
text += "\n" + theme.fg("dim", JSON.stringify(message.details, null, 2)); text += "\n" + theme.fg("dim", JSON.stringify(message.details, null, 2));
} }
return new Text(text, 0, 0); return new Text(text, outputPad, 0);
}); });
``` ```

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