Compare commits

..

No commits in common. "main" and "v0.79.10" have entirely different histories.

578 changed files with 30063 additions and 43805 deletions

View file

@ -243,39 +243,3 @@ 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

View file

@ -17,17 +17,11 @@ on:
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
contents: 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 }}
@ -41,7 +35,7 @@ jobs:
- name: Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: 1.3.14
bun-version: 1.3.10
- name: Setup Node.js
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
@ -52,105 +46,18 @@ jobs:
- name: Build binaries
run: ./scripts/build-binaries.sh
- name: Prepare GitHub release payload
- name: Extract changelog for this version
id: changelog
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
node scripts/release-notes.mjs extract --version "${VERSION}" --tag "${RELEASE_TAG}" --out /tmp/release-notes.md
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"
release_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
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
expected_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
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 binaries
- name: Create GitHub Release and upload binaries
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
cd release-assets
cd packages/coding-agent/binaries
release_assets=(
pi-darwin-arm64.tar.gz
@ -159,39 +66,25 @@ jobs:
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
)
sha256sum "${release_assets[@]}" > SHA256SUMS
release_assets+=(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
if gh release view "${RELEASE_TAG}" >/dev/null 2>&1; then
gh release edit "${RELEASE_TAG}" \
--title "${RELEASE_TAG}" \
--notes-file /tmp/release-notes.md
gh release upload "${RELEASE_TAG}" "${release_assets[@]}" --clobber
else
gh release create "${RELEASE_TAG}" \
--title "${RELEASE_TAG}" \
--notes-file /tmp/release-notes.md \
"${release_assets[@]}"
fi
publish-npm:
runs-on: ubuntu-latest
needs: stage-github-release
needs: build
environment: npm-publish
permissions:
contents: read
@ -231,6 +124,9 @@ jobs:
- name: Test
run: npm test
- name: Verify release artifacts are committed
run: git diff --exit-code
- name: Upgrade npm for trusted publishing
run: |
npm install -g npm@11.16.0 --ignore-scripts
@ -238,57 +134,3 @@ jobs:
- 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

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@v7
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@v7
with:
script: |
const crypto = require('crypto');
core.setOutput('name', `pi-ci-${crypto.randomBytes(16).toString('hex')}`);
- name: Checkout
uses: actions/checkout@v4
with:
path: ${{ steps.workdir.outputs.name }}
- name: Setup Node.js
uses: actions/setup-node@v4
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@v7
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@v7
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@v7
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@v7
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@v7
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@v7
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@v7
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;
}

View file

@ -17,13 +17,11 @@ jobs:
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}`);
if (issueAuthor.endsWith('[bot]') || issueAuthor === 'dependabot[bot]') {
console.log(`Skipping bot: ${issueAuthor}`);
return;
}
@ -82,7 +80,7 @@ jobs:
}
const permission = await getPermission(issueAuthor);
if (!isBotAuthor && ['admin', 'maintain', 'write'].includes(permission)) {
if (['admin', 'maintain', 'write'].includes(permission)) {
console.log(`${issueAuthor} is a collaborator with ${permission} access`);
return;
}
@ -91,7 +89,7 @@ jobs:
const approvedUsers = parseApprovedUsers(approvedContent);
const capability = approvedUsers.get(issueAuthor.toLowerCase());
if (!isBotAuthor && (capability === 'issue' || capability === 'pr')) {
if (capability === 'issue' || capability === 'pr') {
console.log(`${issueAuthor} is approved for ${capability}`);
return;
}

122
.github/workflows/openclaw-gate.yml vendored Normal file
View file

@ -0,0 +1,122 @@
name: OpenClaw Gate
on:
issues:
types: [opened]
pull_request_target:
types: [opened]
jobs:
check-contributor:
runs-on: ubuntu-latest
permissions:
contents: read
issues: write
pull-requests: write
steps:
- name: Check contributor
uses: actions/github-script@v7
with:
script: |
const isPR = !!context.payload.pull_request;
const author = isPR
? context.payload.pull_request.user.login
: context.payload.issue.user.login;
const number = isPR
? context.payload.pull_request.number
: context.payload.issue.number;
const defaultBranch = context.payload.repository.default_branch;
if (author.endsWith('[bot]') || author === 'dependabot[bot]') {
console.log(`Skipping bot: ${author}`);
return;
}
const APPROVED_FILE = '.github/APPROVED_CONTRIBUTORS';
const VALID_CAPABILITIES = new Set(['issue', 'pr']);
// --- Check APPROVED_CONTRIBUTORS ---
async function getTextFile(path) {
const { data } = await github.rest.repos.getContent({
owner: context.repo.owner,
repo: context.repo.repo,
path,
ref: defaultBranch,
});
if (!('content' in data) || typeof data.content !== 'string') {
throw new Error(`Expected file content for ${path}`);
}
return Buffer.from(data.content, 'base64').toString('utf8');
}
try {
const content = await getTextFile(APPROVED_FILE);
const approved = 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) continue;
const [username, capability] = parts;
const normalizedCapability = capability.toLowerCase();
if (!VALID_CAPABILITIES.has(normalizedCapability)) continue;
approved.set(username.toLowerCase(), normalizedCapability);
}
if (approved.has(author.toLowerCase())) {
console.log(`${author} is in APPROVED_CONTRIBUTORS, passing`);
return;
}
} catch (err) {
console.log(`Could not read APPROVED_CONTRIBUTORS: ${err.message}`);
}
// --- Also pass collaborators with write+ access ---
try {
const { data: perm } = await github.rest.repos.getCollaboratorPermissionLevel({
owner: context.repo.owner,
repo: context.repo.repo,
username: author,
});
if (['admin', 'maintain', 'write'].includes(perm.permission)) {
console.log(`${author} is a collaborator (${perm.permission}), passing`);
return;
}
} catch {
// not a collaborator
}
// --- Check if user opened issues/PRs on openclaw/openclaw ---
async function hasOpenClawActivity(username) {
try {
const { data } = await github.rest.search.issuesAndPullRequests({
q: `repo:openclaw/openclaw author:${username}`,
per_page: 1,
});
if (data.total_count > 0) {
console.log(`${username} has opened ${data.total_count} issues/PRs on openclaw/openclaw`);
return true;
}
} catch (err) {
console.log(`Search failed: ${err.message}`);
}
return false;
}
const hasActivity = await hasOpenClawActivity(author);
if (!hasActivity) {
console.log(`${author} has no openclaw/openclaw activity, passing`);
return;
}
// --- Add openclaw label ---
console.log(`${author} has openclaw/openclaw activity, adding label`);
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: number,
labels: ['possibly-openclaw-clanker'],
});

View file

@ -18,13 +18,11 @@ jobs:
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}`);
if (prAuthor.endsWith('[bot]') || prAuthor === 'dependabot[bot]') {
console.log(`Skipping bot: ${prAuthor}`);
return;
}
@ -99,7 +97,7 @@ jobs:
}
const permission = await getPermission(prAuthor);
if (!isBotAuthor && ['admin', 'maintain', 'write'].includes(permission)) {
if (['admin', 'maintain', 'write'].includes(permission)) {
console.log(`${prAuthor} is a collaborator with ${permission} access`);
return;
}
@ -108,7 +106,7 @@ jobs:
const approvedUsers = parseApprovedUsers(approvedContent);
const capability = approvedUsers.get(prAuthor.toLowerCase());
if (!isBotAuthor && capability === 'pr') {
if (capability === 'pr') {
console.log(`${prAuthor} is approved for PRs`);
return;
}

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@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ env.SOURCE_REF }}
persist-credentials: false
- name: Setup Node.js
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.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@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ env.SOURCE_REF }}
persist-credentials: false
- name: Setup Node.js
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.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)"

2
.gitignore vendored
View file

@ -1,6 +1,5 @@
node_modules/
dist/
.artifacts/
*.log
.DS_Store
*.tsbuildinfo
@ -8,7 +7,6 @@ dist/
packages/*/dist/
packages/*/dist-chrome/
packages/*/dist-firefox/
packages/ai/src/providers/data/
*.cpuprofile
# Environment

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");
}
},
});
}

View file

@ -6,7 +6,7 @@ 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.
1. 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

View file

@ -25,11 +25,6 @@ Unless I explicitly override something in this request, do the following in orde
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.

View file

@ -31,7 +31,6 @@
"!**/node_modules/**/*",
"!**/test-sessions.ts",
"!**/models.generated.ts",
"!**/*.models.ts",
"!packages/mom/data/**/*",
"!!**/node_modules"
]

50
package-lock.json generated
View file

@ -773,9 +773,9 @@
}
},
"node_modules/@earendil-works/gondolin/node_modules/undici": {
"version": "6.27.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.27.0.tgz",
"integrity": "sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg==",
"version": "6.26.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.26.0.tgz",
"integrity": "sha512-4yqz8a3n5HmGTlsbADNtr/dJlhkh/55Rq798G6ibiULcXbDtaLpTl1pvdqcbFfeoj3iSi52lePFM7h9H21cw/A==",
"license": "MIT",
"engines": {
"node": ">=18.17"
@ -793,10 +793,6 @@
"resolved": "packages/coding-agent",
"link": true
},
"node_modules/@earendil-works/pi-orchestrator": {
"resolved": "packages/orchestrator",
"link": true
},
"node_modules/@earendil-works/pi-tui": {
"resolved": "packages/tui",
"link": true
@ -5115,10 +5111,10 @@
},
"packages/agent": {
"name": "@earendil-works/pi-agent-core",
"version": "0.80.10",
"version": "0.79.10",
"license": "MIT",
"dependencies": {
"@earendil-works/pi-ai": "^0.80.10",
"@earendil-works/pi-ai": "^0.79.10",
"ignore": "7.0.5",
"typebox": "1.1.38",
"yaml": "2.9.0"
@ -5467,7 +5463,7 @@
},
"packages/ai": {
"name": "@earendil-works/pi-ai",
"version": "0.80.10",
"version": "0.79.10",
"license": "MIT",
"dependencies": {
"@anthropic-ai/sdk": "0.91.1",
@ -5773,12 +5769,12 @@
},
"packages/coding-agent": {
"name": "@earendil-works/pi-coding-agent",
"version": "0.80.10",
"version": "0.79.10",
"license": "MIT",
"dependencies": {
"@earendil-works/pi-agent-core": "^0.80.10",
"@earendil-works/pi-ai": "^0.80.10",
"@earendil-works/pi-tui": "^0.80.10",
"@earendil-works/pi-agent-core": "^0.79.10",
"@earendil-works/pi-ai": "^0.79.10",
"@earendil-works/pi-tui": "^0.79.10",
"@silvia-odwyer/photon-node": "0.3.4",
"chalk": "5.6.2",
"cross-spawn": "7.0.6",
@ -5819,32 +5815,32 @@
},
"packages/coding-agent/examples/extensions/custom-provider-anthropic": {
"name": "pi-extension-custom-provider-anthropic",
"version": "0.80.10",
"version": "0.79.10",
"dependencies": {
"@anthropic-ai/sdk": "0.52.0"
}
},
"packages/coding-agent/examples/extensions/custom-provider-gitlab-duo": {
"name": "pi-extension-custom-provider-gitlab-duo",
"version": "0.80.10"
"version": "0.79.10"
},
"packages/coding-agent/examples/extensions/gondolin": {
"name": "pi-extension-gondolin",
"version": "0.80.10",
"version": "0.79.10",
"dependencies": {
"@earendil-works/gondolin": "0.12.0"
}
},
"packages/coding-agent/examples/extensions/sandbox": {
"name": "pi-extension-sandbox",
"version": "1.10.10",
"version": "1.9.10",
"dependencies": {
"@anthropic-ai/sandbox-runtime": "0.0.26"
}
},
"packages/coding-agent/examples/extensions/with-deps": {
"name": "pi-extension-with-deps",
"version": "0.80.10",
"version": "0.79.10",
"dependencies": {
"ms": "2.1.3"
},
@ -6138,23 +6134,9 @@
}
}
},
"packages/orchestrator": {
"name": "@earendil-works/pi-orchestrator",
"version": "0.80.10",
"license": "MIT",
"dependencies": {
"@earendil-works/pi-coding-agent": "^0.80.10"
},
"devDependencies": {
"shx": "0.4.0"
},
"engines": {
"node": ">=22.19.0"
}
},
"packages/tui": {
"name": "@earendil-works/pi-tui",
"version": "0.80.10",
"version": "0.79.10",
"license": "MIT",
"dependencies": {
"get-east-asian-width": "1.6.0",

View file

@ -12,17 +12,12 @@
],
"scripts": {
"clean": "npm run clean --workspaces",
"build": "cd packages/tui && npm run build && cd ../ai && npm run build && cd ../agent && npm run build && cd ../coding-agent && npm run build && cd ../orchestrator && npm run build",
"check": "biome check --write --error-on-warnings . && npm run check:pinned-deps && npm run check:ts-imports && npm run check:shrinkwrap && npm run check:install-lock:coding-agent && tsgo --noEmit && npm run check:browser-smoke",
"build": "cd packages/tui && npm run build && cd ../ai && npm run build && cd ../agent && npm run build && cd ../coding-agent && npm run build",
"check": "biome check --write --error-on-warnings . && npm run check:pinned-deps && npm run check:ts-imports && npm run check:shrinkwrap && tsgo --noEmit && npm run check:browser-smoke",
"check:browser-smoke": "node scripts/check-browser-smoke.mjs",
"check:pinned-deps": "node scripts/check-pinned-deps.mjs",
"check:shrinkwrap": "node scripts/generate-coding-agent-shrinkwrap.mjs --check",
"check:install-lock:coding-agent": "node scripts/generate-coding-agent-install-lock.mjs --check",
"check:ts-imports": "node scripts/check-ts-relative-imports.mjs",
"generate:models": "npm --prefix packages/ai run generate-models && npm --prefix packages/ai run generate-image-models",
"generate:model-catalog": "npm --prefix packages/ai run generate-model-catalog",
"diff:model-catalog": "node scripts/diff-model-catalog.mjs",
"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:rpc": "node scripts/profile-coding-agent-node.mjs --mode rpc",
"test": "npm run test --workspaces --if-present",
@ -35,7 +30,6 @@
"publish:dry": "npm run prepublishOnly && node scripts/publish.mjs --dry-run",
"release:local": "node scripts/local-release.mjs",
"shrinkwrap:coding-agent": "node scripts/generate-coding-agent-shrinkwrap.mjs",
"install-lock:coding-agent": "node scripts/generate-coding-agent-install-lock.mjs",
"release:patch": "node scripts/release.mjs patch",
"release:minor": "node scripts/release.mjs minor",
"release:major": "node scripts/release.mjs major",

View file

@ -1,75 +1,5 @@
# Changelog
## [Unreleased]
## [0.80.10] - 2026-07-16
## [0.80.9] - 2026-07-16
## [0.80.8] - 2026-07-16
## [0.80.7] - 2026-07-14
### Added
- Added `AgentToolResult.addedToolNames` propagation to `ToolResultMessage` so tools introduced by a result can be loaded from that transcript point onward ([#6474](https://github.com/earendil-works/pi-mono/pull/6474)).
## [0.80.6] - 2026-07-09
### Added
- Added the `max` model thinking level after `xhigh`.
## [0.80.5] - 2026-07-09
## [0.80.4] - 2026-07-09
### Added
- Added configurable harness session context entry transforms and custom-entry message projectors.
- Added custom metadata support in JSONL session headers ([#6417](https://github.com/earendil-works/pi/pull/6417) by [@ArcadiaLin](https://github.com/ArcadiaLin)).
- Exported `InMemorySessionStorage` and `JsonlSessionStorage` ([#6435](https://github.com/earendil-works/pi/issues/6435)).
### Fixed
- Fixed harness split-turn compaction to serialize summary requests so single-concurrency providers are not asked to run overlapping generations ([#5536](https://github.com/earendil-works/pi/issues/5536)).
- Fixed harness tool calls from length-truncated assistant messages to fail instead of waiting for missing tool results ([#6285](https://github.com/earendil-works/pi/pull/6285)).
- Fixed harness session ingestion to normalize `null` message content before context projection, avoiding crashes on lax imported transcripts ([#6343](https://github.com/earendil-works/pi/pull/6343)).
- Fixed non-positive or oversized harness shell execution timeouts to fail with a clear validation error instead of being clamped to an immediate timeout ([#6181](https://github.com/earendil-works/pi/issues/6181)).
- Fixed harness session storage short entry ids to use the random tail of the generated uuidv7 instead of the timestamp prefix, which was nearly constant between calls ([#6242](https://github.com/earendil-works/pi/issues/6242)).
## [0.80.3] - 2026-06-30
### Added
- Added `prepareNextTurnWithContext` for `Agent` users that need the next-turn loop context.
### Fixed
- Fixed `Agent.prepareNextTurn` to keep receiving the run abort signal instead of the next-turn context.
## [0.80.2] - 2026-06-23
### Changed
- Renamed the public harness shell execution options type from `ExecutionEnvExecOptions` to `ShellExecOptions`.
## [0.80.1] - 2026-06-23
## [0.80.0] - 2026-06-23
### Breaking Changes
- `AgentHarnessOptions.models` is required and is the only auth path: the harness streams turns, compaction, and branch summarization through the provided `Models` instance (`models.streamSimple()`/`completeSimple()`), resolving auth through the providers. `AgentHarnessOptions.getApiKeyAndHeaders` is removed — apps that resolved keys per request now express that as provider auth (`ApiKeyAuth`/`OAuthAuth`) on the providers in the `Models` collection. Build one with `createModels()` + provider factories (or `builtinModels()` from `@earendil-works/pi-ai/providers/all`); tests use `fauxProvider()`.
- `compact()`, `generateSummary()`, and `generateBranchSummary()` take a `Models` parameter and no longer accept explicit `apiKey`/`headers`.
- `StreamFn` is defined structurally (`(model, context, options?) => AssistantMessageEventStream | Promise<...>`); `Models.streamSimple` satisfies it.
- Removed the `@earendil-works/pi-agent-core/base` selective-provider entrypoint; use the root package with an explicit `Models` instance instead.
### Fixed
- Fixed harness session names to normalize newline characters before storing labels ([#5999](https://github.com/earendil-works/pi/pull/5999) by [@haoqixu](https://github.com/haoqixu)).
- Fixed harness compaction estimates to ignore malformed all-zero assistant usage after truncated responses ([#5526](https://github.com/earendil-works/pi/pull/5526) by [@dmmulroy](https://github.com/dmmulroy)).
## [0.79.10] - 2026-06-22
## [0.79.9] - 2026-06-20

View file

@ -31,6 +31,24 @@ agent.subscribe((event) => {
await agent.prompt("Hello!");
```
## Base Entry Point
Use `@earendil-works/pi-agent-core/base` with `@earendil-works/pi-ai/base` when bundling applications that should include only selected provider transports:
```typescript
import { Agent } from "@earendil-works/pi-agent-core/base";
import { getModel } from "@earendil-works/pi-ai/base";
import { register } from "@earendil-works/pi-ai/anthropic";
register();
const agent = new Agent({
initialState: { model: getModel("anthropic", "claude-sonnet-4-6") },
});
```
The default `@earendil-works/pi-agent-core` entry point remains batteries-included and registers pi-ai's lazy built-in transports for backward compatibility.
## Core Concepts
### AgentMessage vs LLM Message
@ -164,7 +182,7 @@ const agent = new Agent({
initialState: {
systemPrompt: string,
model: Model<any>,
thinkingLevel: "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max",
thinkingLevel: "off" | "minimal" | "low" | "medium" | "high" | "xhigh",
tools: AgentTool<any>[],
messages: AgentMessage[],
},

View file

@ -79,8 +79,6 @@ Stream options are shallow-copied when a snapshot is created. `headers` and `met
The session contains persisted entries only. Session reads return persisted state and do not include queued writes.
`Session.buildContextEntries()` returns the compaction-aware entry sequence used for model context construction. `Session.buildContext()` derives runtime state from the full active branch, then projects those context entries to `AgentMessage[]`. Custom entries are omitted from model context by default; applications can pass `entryProjectors` to the `Session` constructor or `buildContext()` to project selected custom entries into messages. Applications can also pass stacked `entryTransforms`, which run after the default compaction transform, to filter or reorder context entries before projection.
Session storage implementations must persist leaf changes as `leaf` entries. `setLeafId()` is not an in-memory-only cursor update; it appends a durable entry whose `targetId` is the active tree leaf or `null` for root. Reopening storage must reconstruct the current leaf from the latest persisted leaf-affecting entry.
### Pending session writes

View file

@ -1,964 +0,0 @@
# Models architecture
This document describes the target design for the next `pi-ai` model/provider refactor. It describes the desired shape, not the current implementation. It is intended to be complete enough to start implementing from a fresh session.
Goals:
- `Models` is a dumb runtime collection of providers.
- Concrete providers own metadata, auth, model listing, and stream behavior.
- API implementations live under `src/api/` and are reusable/lazy.
- Concrete provider factories live under `src/providers/`.
- Users can import only the providers they need.
- Importing a provider must not eagerly import heavy SDKs.
- Dynamic model lists are first-class: reads are sync (last-known list), fetching happens in an explicit async `refresh`.
- `models.json` and extensions layer by wrapping providers, not by mutating provider internals ad hoc.
- Old global APIs survive only in an explicit, temporary `/compat` entrypoint.
Non-goals for the immediate `pi-ai` pass:
- Do not migrate coding-agent `ModelRegistry` yet.
- Do not keep the stream/API registry inside `Models`.
- Do not implement web OAuth flows yet.
- Image generation mirrors the chat-side design (`ImagesModels`/`ImagesProvider` in `images-models.ts`); the old global image API (`images.ts`, `images-api-registry.ts`) lives on compat.
## Package layout
Target source layout:
```txt
packages/ai/src/
index.ts # core exports only; no built-in provider imports
models.ts # Models runtime, Provider
images-models.ts # ImagesModels runtime, ImagesProvider (mirrors models.ts)
compat.ts # temporary old-API compatibility entrypoint
auth/ # auth method types, helpers, shared resolveProviderAuth(), login callbacks
api/ # API implementations and lazy wrappers
openai-completions.ts # real implementation, imports SDKs, exports stream/streamSimple
openai-completions.lazy.ts
openai-responses.ts
openai-responses.lazy.ts
openai-codex-responses.ts
openai-codex-responses.lazy.ts
azure-openai-responses.ts
azure-openai-responses.lazy.ts
anthropic-messages.ts
anthropic-messages.lazy.ts
google-generative-ai.ts
google-generative-ai.lazy.ts
google-vertex.ts
google-vertex.lazy.ts
mistral-conversations.ts
mistral-conversations.lazy.ts
bedrock-converse-stream.ts
bedrock-converse-stream.lazy.ts
openrouter-images.ts # image-generation API implementation
openrouter-images.lazy.ts
lazy.ts # lazyStream()/lazyApi() helpers
(shared helpers: openai-responses-shared, google-shared, transform-messages, ...)
providers/ # concrete provider factories and per-provider catalogs
openai.ts
openai.models.ts # generated OpenAI catalog
openai-codex.ts
openai-codex.models.ts
anthropic.ts
anthropic.models.ts
google.ts
google.models.ts
...one pair per built-in provider...
openrouter-images.ts # image-generation provider factory
faux.ts # test provider factory
all.ts # explicit aggregate: builtinModels(), builtinImagesModels(), getBuiltin*()
auth/oauth/ # Canonical OAuth implementations (node), lazy-loaded
```
`src/index.ts` must stay core-only. It must not import:
- generated model catalogs
- built-in provider factories
- provider SDK implementations
- Node-only OAuth modules
- `providers/all`
- `compat`
Provider, API, and compat entrypoints are explicit subpath exports.
## Public usage
Minimal provider usage:
```ts
import { createModels } from "@earendil-works/pi-ai";
import { openaiProvider } from "@earendil-works/pi-ai/providers/openai";
const models = createModels();
models.setProvider(openaiProvider());
const model = models.getModel("openai", "gpt-4o-mini");
if (!model) throw new Error("model not found");
const response = await models.complete(model, context);
```
Multiple providers:
```ts
const models = createModels();
models.setProvider(openaiProvider());
models.setProvider(openrouterProvider());
```
All built-ins, explicitly heavy metadata entrypoint:
```ts
import { builtinModels } from "@earendil-works/pi-ai/providers/all";
const models = builtinModels();
```
`providers/all` may import all provider metadata/catalogs. It still must not eagerly import SDK implementations; provider streams use lazy wrappers.
## Core runtime: Models
`Models` is a provider collection plus auth application and stream convenience. No stream registry, no auth resolver strategy object.
```ts
export function createModels(options?: {
/** App-owned credential storage. Default: in-memory store. */
credentials?: CredentialStore;
/** Environment access for auth resolution (env vars, file existence). Default: process.env/node:fs backed; injectable for tests and non-Node hosts. */
authContext?: AuthContext;
}): MutableModels;
export interface Models {
getProviders(): readonly Provider[];
getProvider(id: string): Provider | undefined;
/** Sync read of last-known models. Best-effort: a provider whose getModels() throws yields no models. */
getModels(provider?: string): readonly Model<Api>[];
/** Dynamic lists are honestly Model<Api>; narrow with the hasApi() guard. */
getModel(provider: string, id: string): Model<Api> | undefined;
/**
* Ask dynamic providers to re-fetch their model lists. With a provider id,
* rejects on that provider's failure; without, refreshes all concurrently
* best-effort. Static providers are no-ops.
*/
refresh(provider?: string): Promise<void>;
/**
* Resolve request auth for a model. Includes source label for status UI.
* Resolves undefined when the provider is unknown or unconfigured. Rejects
* with ModelsError ("oauth" on refresh failure, "auth" on api-key/store
* failure); status/availability UIs catch rejections and render
* "needs re-login" instead of treating them as unconfigured.
*/
getAuth(model: Model<Api>): Promise<AuthResult | undefined>;
stream<TApi extends Api>(
model: Model<TApi>,
context: Context,
options?: ApiStreamOptions<TApi>,
): AssistantMessageEventStream;
complete<TApi extends Api>(
model: Model<TApi>,
context: Context,
options?: ApiStreamOptions<TApi>,
): Promise<AssistantMessage>;
streamSimple(model: Model<Api>, context: Context, options?: SimpleStreamOptions): AssistantMessageEventStream;
completeSimple(model: Model<Api>, context: Context, options?: SimpleStreamOptions): Promise<AssistantMessage>;
}
export interface MutableModels extends Models {
/** Upsert/replace by provider.id. Provider ids are unique. */
setProvider(provider: Provider): void;
deleteProvider(id: string): void;
clearProviders(): void;
}
```
Removed concepts:
```txt
no Models.setStreamFunctions() / getStreamFunctions()
no api-registry as a real dispatch mechanism
no Models.provider(id) builder, no setModel/upsertModel/patchModel lifecycle
no ModelAuthResolver / setAuthResolver — resolution policy is fixed, store is injected
```
If an app needs different auth policy, it wraps providers (wrap auth methods or `getModels`) or passes explicit request auth in stream options.
## Provider
A provider is the concrete runtime unit. It owns id/name/base metadata, auth methods, model listing, and stream behavior.
`Provider` is generic over the APIs its models use. Concrete factories declare what they emit (`openaiProvider(): Provider<"openai-responses" | "openai-completions">`), giving typed model lists to direct factory users. A `Models` collection holds providers as `Provider<Api>`.
```ts
export interface Provider<TApi extends Api = Api> {
readonly id: string;
readonly name: string;
readonly baseUrl?: string;
readonly headers?: Record<string, string>;
/**
* Required: at least one of apiKey/oauth. Even ambient-credential providers
* (env vars, AWS profiles, ADC) and keyless local servers provide apiKey
* auth whose resolve() reports whether the provider is configured.
* getAuth() returning undefined = not configured.
*/
readonly auth: ProviderAuth;
/** Current known models, sync. Static providers: the catalog. Dynamic providers: as of the last refresh (empty before the first). */
getModels(): readonly Model<TApi>[];
/** Dynamic providers only: fetch and update the model list. Concurrent calls share one in-flight fetch. */
refreshModels?(): Promise<void>;
stream<T extends TApi>(model: Model<T>, context: Context, options?: ApiStreamOptions<T>): AssistantMessageEventStream;
streamSimple(model: Model<TApi>, context: Context, options?: SimpleStreamOptions): AssistantMessageEventStream;
}
```
There is no `Provider.api` field. `model.api` carries API identity; the provider dispatches internally (see `createProvider()`).
`Model.api` remains: existing metadata and tests use it, it is useful for diagnostics, and provider construction uses it for API implementation selection. But `Models` never dispatches on it; the provider does.
### Typed stream options
Full stream options are API-specific. `Model<TApi>` pays off by deriving the option type from the API:
```ts
// types.ts — type-only imports from API impl modules are erased, so this is tree-shake safe
export interface ApiOptionsMap {
"anthropic-messages": AnthropicOptions;
"openai-completions": OpenAICompletionsOptions;
"openai-responses": OpenAIResponsesOptions;
"openai-codex-responses": OpenAICodexResponsesOptions;
"azure-openai-responses": AzureOpenAIResponsesOptions;
"google-generative-ai": GoogleOptions;
"google-vertex": GoogleVertexOptions;
"mistral-conversations": MistralOptions;
"bedrock-converse-stream": BedrockOptions;
}
export type ApiStreamOptions<TApi extends Api> = TApi extends keyof ApiOptionsMap
? ApiOptionsMap[TApi]
: StreamOptions & Record<string, unknown>;
```
Custom api strings fall back to the generic shape.
### Typed model narrowing
Runtime model lists are dynamic, so `models.getModel()`/`getModels()` honestly return `Model<Api>`. Typing improves at three points:
1. **`hasApi()` type guard** — runtime-checked narrowing for dynamic lookups (no blind casts):
```ts
export function hasApi<TApi extends Api>(model: Model<Api>, api: TApi): model is Model<TApi>;
const model = models.getModel("anthropic", "claude-opus-4-7");
if (model && hasApi(model, "anthropic-messages")) {
// model: Model<"anthropic-messages">, stream options fully typed
}
```
2. **`getBuiltinModel()`** — sync, generated-catalog lookup with typed overloads: `(provider, id) -> Model<exact-api-literal>`. The path for hardcoded known models.
3. **`Provider<TApi>` factories** — typed model lists when using a provider directly, without a `Models` collection.
Deliberately not done: tying `models.getModel(provider, ...)` to typed provider/model ids would require statically knowing which providers are installed in a mutable runtime collection. The harness path (`streamSimple` + `SimpleStreamOptions`) is API-agnostic and unaffected.
For comparison: Vercel AI SDK attaches the implementation to the model object, which dissolves dispatch typing but makes models non-serializable (no sessions/RPC/catalogs as plain data), and its `providerOptions` bag is `Record<string, JSON>` checked only by `satisfies` convention. Plain-data models + provider-owned behavior keeps stronger typing where it matters.
### Name collision
`types.ts` currently exports `type Provider = KnownProvider | string` (a provider id). Rename that alias to `ProviderId` and fix call sites. The `Provider` interface above takes the name.
## Provider model listing
Reads are sync; fetching is an explicit async verb. `Provider.getModels()` returns the current known list — the full catalog for static providers, the last-refreshed list for dynamic ones (llama.cpp, OpenRouter live listing). `refreshModels()` is where dynamic providers fetch.
This split exists because a sync-or-async union (`Promise<T> | T`) invites latent sync assumptions that detonate on the first async provider, while async-only reads force every consumer (UI lists, extension `find`/`getAll` surfaces) through Promises for data that is almost always static. Sync reads + explicit refresh keeps the staleness visible and the contract single: `getModels()` = last known, `refresh()` = make it current. A fetched list is stale the moment it returns anyway; naming the refresh point is honest about it.
Apps own the refresh lifecycle: startup, registry reload, opening a model selector. Freshness-critical lookups are two-step: `await models.refresh("llamacpp"); models.getModel("llamacpp", id)`.
Dynamic refresh must be side-effect-free discovery:
```txt
OK: fetch /v1/models, enumerate local catalog, refresh cached remote model list
Not OK: load model, download model, mutate server state, run request probe
```
Provider-specific model lifecycle (load/unload) belongs in app/provider-management commands, not in `refreshModels()`.
## Streaming path
`Models.stream()` finds the provider by `model.provider`, resolves auth, merges it into request options, and delegates:
```ts
function stream(model, context, options) {
const provider = this.getProvider(model.provider);
if (!provider) {
// produce an error stream, not a throw — see Error behavior
}
// async setup happens inside the returned stream (lazyStream pattern)
const resolution = await this.getAuth(model);
const requestModel = resolution?.auth.baseUrl ? { ...model, baseUrl: resolution.auth.baseUrl } : model;
const requestOptions = mergeAuth(options, resolution?.auth); // explicit options win per-field
return provider.stream(requestModel, context, requestOptions);
}
```
`stream()` returns `AssistantMessageEventStream` synchronously; async setup (auth resolution, lazy module load) happens inside the returned stream. The forwarding pattern already exists in today's `register-builtins.ts` (`createLazyStream`); extract it as `lazyStream()` in `src/api/lazy.ts`.
No request hot-path model canonicalization: `stream()` uses the supplied model object as-is. If an app wants fresh model metadata, it refreshes the provider and re-reads (`await models.refresh(p); models.getModel(p, id)`) before starting the turn.
## API implementations under `src/api`
An API implementation is reusable stream behavior. It is not a provider.
Uniform export contract — every real implementation module exports exactly:
```ts
// src/api/anthropic-messages.ts — imports SDKs
export function stream(model, context, options) { ... }
export function streamSimple(model, context, options) { ... }
```
This makes the module itself satisfy `ProviderStreams`, so the lazy wrapper is one generic helper instead of bespoke per-API plumbing. `ProviderStreams` is the untyped dispatch shape (implementation modules export concretely typed functions, which would not be assignable to a generic method); per-API option typing lives on the modules themselves and on `Provider.stream()` via `ApiStreamOptions`:
```ts
export interface ProviderStreams {
stream(model: Model<Api>, context: Context, options?: StreamOptions): AssistantMessageEventStream;
streamSimple(model: Model<Api>, context: Context, options?: SimpleStreamOptions): AssistantMessageEventStream;
}
// src/api/lazy.ts
export function lazyApi(load: () => Promise<ProviderStreams>): ProviderStreams;
// src/api/anthropic-messages.lazy.ts
export const anthropicMessagesApi = (): ProviderStreams => lazyApi(() => import("./anthropic-messages.ts"));
```
Import chain:
```txt
provider module -> lazy API wrapper -> dynamic import(real API impl) -> SDK deps
```
Notes:
- Bedrock keeps the node-only dynamic import trick (`importNodeOnlyProvider`, `.ts`/`.js` specifier rewrite) inside its lazy wrapper. `setBedrockProviderModule()` (used by the Bun build) moves into the bedrock lazy wrapper module.
- Shared helper modules (`openai-responses-shared.ts`, `google-shared.ts`, `transform-messages.ts`, prompt-cache, copilot headers) move to `src/api/` alongside the implementations.
## Shared API implementations across concrete providers
Many concrete providers share an API implementation (OpenAI-completions: OpenRouter, Groq, Cerebras, xAI, ZAI, ...). They share lazy API objects by reference:
```ts
import { openAICompletionsApi } from "../api/openai-completions.lazy.ts";
export function openrouterProvider(): Provider {
return createProvider({
id: "openrouter",
name: "OpenRouter",
baseUrl: "https://openrouter.ai/api/v1",
auth: { apiKey: envApiKeyAuth("OpenRouter API key", ["OPENROUTER_API_KEY"]) },
models: OPENROUTER_MODELS,
api: openAICompletionsApi(),
});
}
```
This copies Vercel AI SDK's useful property: users import concrete providers; shared protocol implementation is internal.
## Auth
Request auth output stays small:
```ts
export interface ModelAuth {
apiKey?: string;
headers?: Record<string, string>;
baseUrl?: string;
}
```
If a value cannot be expressed as `apiKey`, `headers`, or `baseUrl`, it is provider config, not auth (Vertex project/location, Bedrock region/profile, Azure apiVersion are provider factory options).
### Provider auth
`Provider.auth` has exactly two slots; real providers have at most one api-key path and at most one OAuth path, and the slot names carry the UI's oauth-vs-api-key split without a `kind` discriminant or method ids:
```ts
export interface ProviderAuth {
apiKey?: ApiKeyAuth; // stored key/provider env + ambient env/files/ADC/IAM
oauth?: OAuthAuth; // login flow + refresh
}
export interface ApiKeyAuth {
name: string; // "Anthropic API key"
/** Interactive setup (prompt for key/provider env). Absent = ambient-only (env, ADC, IAM). */
login?(interaction: AuthInteraction): Promise<ApiKeyCredential>;
/**
* Resolve auth from the stored credential and/or ambient sources, merging
* per field (credential.key ?? env("..."), credential.env?.NAME ?? env("...")).
* undefined = not configured.
*/
resolve(input: {
model: Model<Api>;
ctx: AuthContext;
credential?: ApiKeyCredential;
}): Promise<AuthResult | undefined>;
}
export interface OAuthAuth {
name: string; // "Anthropic (Claude Pro/Max)"
login(interaction: AuthInteraction): Promise<OAuthCredential>;
/** Exchange the refresh token. Network call; throws on failure (invalid_grant etc.). Runs under the store lock. */
refresh(credential: OAuthCredential): Promise<OAuthCredential>;
/** Side-effect-free derivation of request auth from a valid credential. Covers Copilot-style per-credential baseUrl. Async so lazy wrappers can load the implementation. */
toAuth(credential: OAuthCredential): Promise<ModelAuth>;
}
export interface AuthResult {
auth: ModelAuth;
/** Human-readable label for status UI: "ANTHROPIC_API_KEY", "OAuth", "~/.aws/credentials". */
source?: string;
}
export interface AuthContext {
env(name: string): Promise<string | undefined>;
fileExists(path: string): Promise<boolean>; // supports leading ~
}
```
The `refresh`/`toAuth` split lets `Models` own the locked refresh pattern without closure gymnastics: refresh produces a credential, while `toAuth` derives request auth from whatever credential ends up stored.
OAuth implementations use the provider-neutral `AuthInteraction` protocol directly. A callback-server flow issues a `manual_code` prompt racing the server and aborts the prompt when the callback wins, so the UI needs no provider-specific callback or static callback-server flag.
### Credentials
One credential per provider, type-tagged — exactly the shape of today's auth.json (`type: "api_key" | "oauth"` per provider id):
```ts
export interface ApiKeyCredential {
type: "api_key";
key?: string;
env?: ProviderEnv; // e.g. Cloudflare account/gateway ids, Azure/Vertex/Bedrock scoped config
}
export interface OAuthCredential extends OAuthCredentials {
type: "oauth"; // access, refresh, expires from OAuthCredentials
}
export type Credential = ApiKeyCredential | OAuthCredential;
```
`ApiKeyCredential.env` stores provider-scoped environment/config values alongside or instead of a key. `ApiKeyAuth.resolve()` merges per field: `credential.key ?? env("CLOUDFLARE_API_KEY")`, `credential.env?.CLOUDFLARE_ACCOUNT_ID ?? env("CLOUDFLARE_ACCOUNT_ID")`, etc. The credential discriminator intentionally matches today's `auth.json` (`api_key`) so the file-backed store does not need lossy type translation.
### Credential store
The app injects storage; `pi-ai` ships an in-memory default. Keyed by provider id, one credential per provider:
```ts
export interface CredentialStore {
/** Read the stored credential, possibly expired. Display/status use; request auth comes from Models.getAuth(). */
read(providerId: string): Promise<Credential | undefined>;
/**
* Serialized write — the only write path. fn sees the current credential
* because correct writes (refresh, login-during-refresh) depend on it;
* return the new credential, or undefined to leave the entry unchanged.
* Mutual exclusion per provider id, cross-process too where the backing
* store supports it (file lock). Resolves with the post-write credential.
*/
modify(
providerId: string,
fn: (current: Credential | undefined) => Promise<Credential | undefined>,
): Promise<Credential | undefined>;
/** Remove (logout). Serialized against modify. */
delete(providerId: string): Promise<void>;
}
```
There is deliberately no `set`: an unserialized write path invites read-modify-write races (login-during-refresh clobbering a fresh credential, double token refresh). Call sites:
```ts
await store.modify(pid, async () => credential); // login: store this
await store.read(pid); // status UI ("logged in via OAuth")
await store.delete(pid); // logout
// refresh RMW happens inside Models.getAuth
```
Error semantics: `read` resolves `undefined` for missing entries; methods reject only on storage failure, and `Models` wraps such rejections in `ModelsError` code `"auth"`. Best-effort stores that serve an in-memory view and record persistence errors internally (today's AuthStorage behavior) are valid implementations.
### Resolution policy (fixed)
`Models.getAuth(model)` is a decision tree, not a loop. A stored credential owns the provider — ambient/env is consulted only when nothing is stored (AuthStorage parity: no silent env fallback after a failed refresh or for an unmatched credential type):
```ts
const stored = await store.read(provider.id);
if (stored) {
if (stored.type === "oauth" && provider.auth.oauth) {
const oauth = provider.auth.oauth;
let credential = stored;
if (Date.now() >= credential.expires) { // optimistic check, lock-free
const post = await store.modify(provider.id, async (current) => {
if (current?.type !== "oauth") return undefined; // logged out meanwhile
return Date.now() >= current.expires // authoritative check, under lock
? oauth.refresh(current) // throws -> ModelsError("oauth")
: undefined; // another process/request refreshed
});
if (post?.type !== "oauth") return undefined;
credential = post;
}
return { auth: await oauth.toAuth(credential), source: "OAuth" };
}
if (stored.type === "api_key" && provider.auth.apiKey) {
return provider.auth.apiKey.resolve({ model, ctx, credential: stored });
}
return undefined; // stored credential without matching handler blocks ambient
}
return provider.auth.apiKey?.resolve({ model, ctx, credential: undefined }); // ambient
```
Properties:
- Double-checked locking, same as today's `refreshOAuthTokenWithLock`: valid tokens cost one `read` and zero locks; expired tokens lock, re-check under the lock, refresh once globally, persist before release.
- Explicit request auth (stream options `apiKey`/`headers`) is merged per-field on top in `stream()`, winning over everything.
- Refresh failure rejects with `ModelsError("oauth")`; the stored credential is untouched (preserved for retry). Request paths surface this as a stream error with the real cause ("run /login"); status/availability UIs catch the rejection and render "needs re-login" — documented contract on `getAuth`.
### Replacing AuthStorage
The end state for coding-agent: AuthStorage is deleted; its capabilities map onto a `CredentialStore` implementation plus composition.
Today's `getApiKey` priority and its new home:
| AuthStorage today | New design |
|---|---|
| runtime override (CLI `--api-key`) | `withRuntimeOverrides(store, overrides)` decorator: `read` returns the override as an `ApiKeyCredential`; never persisted |
| stored `api_key` (with `$ENV`/`!command` via `resolveConfigValue`) | stored `ApiKeyCredential`; config-value resolution happens at `read` in coding-agent's adapter/decorator (command execution stays app policy) |
| stored `oauth` + locked refresh, undefined on failure | `getAuth` decision tree above; failure rejects with cause instead of silently unconfiguring |
| env var (only when nothing stored) | ambient branch of `apiKey.resolve` |
| `fallbackResolver` (models.json custom providers) | gone — custom providers carry their own `auth.apiKey` |
```txt
FileCredentialStore ports AuthStorage's lock backend: read = memory snapshot,
modify = withLockAsync(re-read, fn, merge-write), delete,
internal error recording (drainErrors equivalent)
└─ withConfigValues $ENV / !command at read
└─ withRuntimeOverrides --api-key
└─ createModels({ credentials: store })
login/logout UI provider.auth.{oauth,apiKey}.login(interaction) + store.modify/delete
status UI store.read(pid) + getAuth try/catch ("needs /login" on rejection)
getOAuthProviders presence of provider.auth.oauth across registered providers
```
### Login callbacks
One interface serves api-key and OAuth login:
```ts
export interface AuthInteraction {
/** Aborts the whole login flow. Per-prompt cancellation uses AuthPrompt.signal. */
signal?: AbortSignal;
prompt(prompt: AuthPrompt): Promise<string>;
notify(event: AuthEvent): void;
}
/** `signal` lets the flow cancel a pending prompt when an out-of-band event resolves the step. */
export type AuthPrompt = { signal?: AbortSignal } & (
| { type: "text"; message: string; placeholder?: string }
| { type: "secret"; message: string; placeholder?: string }
| { type: "select"; message: string; options: readonly { id: string; label: string; description?: string }[] }
| { type: "manual_code"; message: string; placeholder?: string }
);
export type AuthEvent =
| { type: "auth_url"; url: string; instructions?: string }
| { type: "device_code"; userCode: string; verificationUri: string; intervalSeconds?: number; expiresInSeconds?: number }
| { type: "progress"; message: string };
```
`prompt()` returns the entered/selected string (`select` returns the option id). Flows race a `manual_code` prompt against a callback server by setting `AuthPrompt.signal` and aborting the prompt when the callback wins.
### OAuth attachment
Providers that support OAuth always attach it. There is no factory toggle: the flow is lazy-loaded, so advertising OAuth costs nothing until `login()`/`refresh()` actually runs, and a host that never logs in never loads it.
```ts
export function anthropicProvider(): Provider {
return createProvider({
id: "anthropic",
name: "Anthropic",
baseUrl: "https://api.anthropic.com/v1",
auth: {
apiKey: envApiKeyAuth("Anthropic API key", ["ANTHROPIC_API_KEY"]),
oauth: lazyOAuth({
name: "Anthropic (Claude Pro/Max)",
load: () => import("../auth/oauth/anthropic.ts").then((m) => m.anthropicOAuth),
}),
},
models: ANTHROPIC_MODELS,
api: anthropicMessagesApi(),
});
}
```
`lazyOAuth()` wraps a dynamically imported `OAuthAuth` so provider definitions can advertise OAuth without importing the implementation (`toAuth` is async for exactly this reason):
```ts
export function lazyOAuth(input: {
name: string;
load: () => Promise<OAuthAuth>;
}): OAuthAuth;
```
OAuth must not force Node-only code (`node:http`, `node:crypto`) into browser bundles: the dynamic import inside `lazyOAuth()` uses the same bundler-opaque variable-specifier trick as the bedrock lazy wrapper. Browser hosts never trigger the load (no stored node OAuth credentials, no login flow). If web OAuth lands later (sitegeist proved feasibility: Web Crypto PKCE, auth tab, fetch token exchange, device-code polling), it is just a different `OAuthAuth` implementation — no reserved option values.
The built-in flows in `src/auth/oauth/` implement `OAuthAuth` and `AuthInteraction` directly while remaining Node-targeted and lazy-loaded. Copilot derives its credential-specific request endpoint through `toAuth().baseUrl`.
## Provider wrappers and models.json
`models.json` is a provider wrapper layer. It does not mutate providers in place:
```ts
function withProviderOverrides(base: Provider, overrides: ProviderOverrides): Provider {
return {
...base,
name: overrides.name ?? base.name,
baseUrl: overrides.baseUrl ?? base.baseUrl,
headers: mergeHeaders(base.headers, overrides.headers),
getModels: () => applyModelOverrides(base.getModels(), overrides.models),
refreshModels: base.refreshModels?.bind(base),
stream: base.stream,
streamSimple: base.streamSimple,
};
}
```
This composes with dynamic providers because `getModels()` delegates to the base source and `refreshModels()` passes through.
Request-auth config from models.json (`$ENV`, `!command`, inline keys) remains app-owned sidecar state, surfaced either as explicit request auth or as a custom `ApiKeyAuth` the app sets on the wrapped provider's `auth.apiKey`.
## Custom providers: createProvider()
One helper builds providers from parts; it handles both single-API and mixed-API providers:
```ts
export function createProvider(input: {
id: string;
name?: string; // default: id
baseUrl?: string;
headers?: Record<string, string>;
auth: ProviderAuth; // required, at least one of apiKey/oauth (no "no-auth" providers)
/** Initial model list (empty for purely dynamic providers). */
models: readonly Model<Api>[];
/** Dynamic providers: fetch the current list; createProvider stores it and dedupes in-flight calls. */
refreshModels?: () => Promise<readonly Model<Api>[]>;
/** Single implementation, or map keyed by model.api for mixed-API providers. */
api: ProviderStreams | Record<string, ProviderStreams>;
}): Provider;
```
- Single `api`: all models stream through it.
- Map `api`: `stream()`/`streamSimple()` dispatch on `model.api`; unknown api produces a stream error.
Mixed-API custom providers must be supported (opencode Go/Zen-style providers expose models backed by different APIs under one provider id).
Built-in provider factories use `createProvider()` internally. models.json custom providers map onto it directly:
```json
{
"providers": {
"my-openai-proxy": {
"api": "openai-completions",
"baseUrl": "https://proxy.example/v1",
"models": [ ... ]
}
}
}
```
## Compat entrypoint
`@earendil-works/pi-ai/compat` preserves the old global API surface until the coding-agent migration deletes it. New code never imports it.
Old semantics being preserved: global `stream()` can still dispatch by `model.api` through the legacy api-registry for custom providers, mutated models, and tests/extensions that override a built-in API implementation.
- `stream/complete/streamSimple/completeSimple(model, ctx, opts)`: real built-in provider/model/api matches route through a singleton `builtinModels()` collection, so provider auth/env/baseUrl behavior is shared with the new runtime. Unknown providers, mutated models, or overridden API registrations fall back to api-registry dispatch plus `getEnvApiKey` injection.
- The builtin api registration side effect moves from the root barrel into compat. It skips api ids that already have a registration, since compat may load after a test or extension has already registered an override. `registerApiProvider()/unregisterApiProviders()` keep feeding the compat-local registry; `resetApiProviders()` clears and re-registers builtins.
- Sync `getModel/getModels/getProviders` are deprecated aliases of `getBuiltinModel/getBuiltinModels/getBuiltinProviders` from `providers/all` (they were always pure generated-catalog reads — verified: nothing ever mutated the old `modelRegistry`).
- Re-exports the per-API lazy stream wrappers (incl. `setBedrockProviderModule`), `env-api-keys.ts`, and the image-generation registry/catalogs; none of these stay on the root barrel.
- `export * from "./index.ts"`: compat is a strict superset of the core entrypoint, so consumers switch a file's import path wholesale without symbol surgery.
coding-agent (and the interim agent package) switch imports of these symbols from `@earendil-works/pi-ai` to `@earendil-works/pi-ai/compat` (import-path-only change) and are otherwise untouched until the ModelManager migration.
Extension grace period: the coding-agent extension loader (jiti aliases + Bun `virtualModules`) resolves the `@earendil-works/pi-ai` ROOT specifier to the compat entrypoint. Existing user extensions using the old global API (`complete`, `getModel`, `registerApiProvider`, ...) keep working at runtime without changes; they break only when compat is removed at the ModelManager migration, with a migration guide in the changelog. Typechecking is the nudge: editors resolve the root to the slim core types, so extension sources that typecheck must import old globals from `/compat` — which is what the repo example extensions demonstrate.
## Builtin static helpers
Typed, sync, generated-catalog-only helpers live with the catalogs (exported from `providers/all`):
```ts
getBuiltinModel(provider, id) // sync, typed overloads from generated catalog
getBuiltinModels(provider) // sync
getBuiltinProviders() // sync
```
Runtime lookup through a `Models` instance is sync over the last-known provider lists: `models.getModel(...)`. Freshness-critical callers run `await models.refresh(provider)` first.
Generated catalogs are split per provider (`providers/<id>.models.ts`) by updating `packages/ai/scripts/generate-models.ts`. If the generator change turns out too large for this pass, splitting may be deferred; `providers/all` and provider factories may temporarily import the monolithic `models.generated.ts`, relying on `sideEffects: false` for pruning.
## Tree-shaking and lazy imports
Rules:
1. Main `@earendil-works/pi-ai` import is core-only.
2. Provider modules import their catalog, auth helpers, and lazy API wrappers only.
3. Lazy API wrappers dynamically import real API implementations.
4. Real API implementations import SDK dependencies.
5. OAuth implementations are always attached via `lazyOAuth()` and lazy-loaded behind a bundler-opaque dynamic import; provider metadata never eagerly imports Node-only OAuth code.
6. `providers/all` imports every built-in provider factory and all catalogs. It is the explicit heavy entrypoint.
7. Provider modules are side-effect-free; importing a provider does not register anything globally.
8. `package.json` lists only effectful compat/image registration files in `sideEffects`; root and provider modules stay tree-shakeable.
9. With code splitting, provider SDKs stay in lazy chunks. Without code splitting, bundlers fold statically reachable lazy API implementations into the single bundle; `providers/all` then pulls all statically visible SDKs. Bedrock is the exception because its AWS SDK implementation is behind a bundler-opaque Node-only import and needs `setBedrockProviderModule()` for standalone single-file bundles.
Exports map sketch:
```json
{
"exports": {
".": "./dist/index.js",
"./compat": "./dist/compat.js",
"./providers/all": "./dist/providers/all.js",
"./providers/openai": "./dist/providers/openai.js",
"./providers/anthropic": "./dist/providers/anthropic.js",
"./providers/*": "./dist/providers/*.js",
"./api/*": "./dist/api/*.js"
}
}
```
Browser smoke check (`scripts/check-browser-smoke.mjs`) must keep passing: bundling the core entrypoint (and any non-node provider entrypoint) must not pull `node:http`/`node:crypto`.
## AgentHarness integration
`AgentHarness` receives a `Models` instance.
- `AgentHarnessOptions.models` is required.
- The harness does not snapshot `Models` into turn state.
- Request path calls `this.models.streamSimple(model, context, options)`; same for compaction/branch-summarization paths.
- Request path never calls async `models.getModel()` to canonicalize; if model metadata needs refresh, the app updates the selected model before starting a turn.
- Harness tests build `createModels()` and install the faux provider (`fauxProvider()` factory from `providers/faux`).
## coding-agent next phase (not this pass)
coding-agent builds providers in layers and binds them per session:
```txt
built-in providers (builtinModels)
-> models.json provider wrappers / custom providers (createProvider)
-> extension provider wrappers/additions
```
```ts
sessionModels.clearProviders();
for (const provider of layeredProviders) sessionModels.setProvider(provider);
```
coding-agent owns: `FileCredentialStore` + decorators replacing AuthStorage (see "Replacing AuthStorage"), models.json auth sidecar (`$ENV`, `!command`), command execution policy, provider status labels (from `AuthResult.source`), login/logout UI (driving `auth.{apiKey,oauth}.login()` with `prompt()/notify()`), extension lifecycle, provider-management slash commands.
Current interim state:
- `AgentHarness` already accepts a `Models` instance and uses it for turn streaming, compaction, and branch summaries.
- coding-agent does not use `AgentHarness` yet; `AgentSession` still drives the low-level `Agent` with a `streamFn`.
- coding-agent still uses legacy `AuthStorage` + `ModelRegistry` and imports old global pi-ai APIs through `@earendil-works/pi-ai/compat`.
- The extension loader still aliases the pi-ai root to `/compat` as the runtime grace period for old extensions.
## Implementation TODOs
Check items off as they land. Keep this list current; it is the working state for resumed sessions.
### Phase 1 — core types/runtime
- [x] Rename `types.ts` `Provider` alias to `ProviderId`; fix call sites.
- [x] Add `ApiOptionsMap` and `ApiStreamOptions<TApi>` to `types.ts` (type-only imports).
- [x] New `models.ts`: `Provider<TApi>` interface, `hasApi()` guard, `ModelsError` + codes. Auth types live in `src/auth/types.ts` (`ProviderAuth` = `{ apiKey?, oauth? }`, credentials, `CredentialStore` (`read`/`modify`/`delete`, one credential per provider), `AuthResult`, `AuthContext`, `ModelAuth`, login callbacks), in-memory store in `src/auth/credential-store.ts`, default context in `src/auth/context.ts` (browser-safe node:fs trick), `lazyStream()` in `src/api/lazy.ts`.
- [x] `Models`/`MutableModels`/`createModels({ credentials?, authContext? })` with provider map, sync `getModel(s)` (per-provider failure isolation), explicit async `refresh(provider?)`, `getAuth` (decision tree, double-checked locked refresh), `stream/complete/streamSimple/completeSimple` with per-field auth merge. Tests: `packages/ai/test/models-runtime.test.ts`.
- [x] Keep metadata helpers: `calculateCost`, `getSupportedThinkingLevels`, `clampThinkingLevel`, `modelsAreEqual`.
### Phase 2 — `src/api/`
- [x] Move stream implementations from `src/providers/` to `src/api/`, renamed by API id (`anthropic.ts` -> `api/anthropic-messages.ts`, etc.).
- [x] Normalize each implementation module to export exactly `stream` and `streamSimple`.
- [x] Move shared helpers (`openai-responses-shared`, `google-shared`, `transform-messages`, `openai-prompt-cache`, `github-copilot-headers`, `cloudflare`, `simple-options`) to `src/api/`.
- [x] Extract `lazyStream()`/`lazyApi()` into `src/api/lazy.ts`.
- [x] Add `*.lazy.ts` wrappers per API; bedrock keeps node-only import trick and `setBedrockProviderModule()`.
- [x] Delete `providers/register-builtins.ts`. Interim until Phase 5 compat: builtin api-registry registration lives in `stream.ts`; lazy API wrappers are exported from the root barrel.
### Phase 3 — provider factories + catalogs
- [x] Auth helpers in `src/auth/helpers.ts`: `envApiKeyAuth()` (with secret-prompt `login`), `lazyOAuth()`. OAuth flow loads go through `auth/oauth/load.ts` (bundler-opaque dynamic import); the `OAuthAuth` exports it references land in Phase 4.
- [x] `createProvider()` in `models.ts` (single + mixed `api` map, dispatch on `model.api`, unknown api -> stream error).
- [x] Per-provider factories under `src/providers/` for all built-in catalog providers; OAuth attached via `lazyOAuth()` (anthropic, openai-codex, github-copilot); ambient `ApiKeyAuth` for amazon-bedrock (AWS env/profile) and google-vertex (key or ADC+project+location).
- [x] `providers/all.ts`: `builtinProviders()`, `builtinModels()`, `getBuiltinModel/getBuiltinModels/getBuiltinProviders` re-exports.
- [x] Faux provider factory (`fauxProvider()` in `providers/faux.ts`) for tests; legacy `registerFauxProvider()` kept until compat dies.
- [x] Split generated catalogs per provider via `scripts/generate-models.ts` (`providers/<id>.models.ts`); `models.generated.ts` becomes a generated aggregator.
### Phase 4 — OAuth adaptation
- [x] Built-in implementations live under `auth/oauth/` and implement `OAuthAuth` directly through `AuthInteraction.prompt()`/`notify()`. They are private provider implementations loaded lazily by provider factories.
- [x] Callback-server flows race a `manual_code` prompt, aborted through `AuthPrompt.signal` once the flow settles. The public `oauth` subpath retains only coding-agent extension compatibility types.
### Phase 5 — packaging
- [x] `index.ts` core-only and side-effect free (no catalogs, no provider factories, no api-registry, no env-api-keys, no images, no OAuth, no compat). Typed catalog reads (`getBuiltin*`) implemented in `providers/all.ts`; `models.ts` no longer imports `models.generated.ts`.
- [x] `compat.ts`: superset of index + old api-dispatch globals, deprecated `getModel/getModels/getProviders` aliases, lazy api wrappers + `setBedrockProviderModule`, `getEnvApiKey`, images. Registration side effect lives here (skip-if-present).
- [x] Subpath exports map (`./compat`, `./providers/*`, `./api/*`); `sideEffects` array listing the effectful modules (`compat`, images registration) instead of `false`.
- [x] Browser smoke (entry now imports old globals from `/compat`) + shrinkwrap checks green. Internal old-global imports switched to `/compat` already (42 files in agent/coding-agent/examples; vitest configs alias `/compat` to src; spawn-CLI tests resolve workspace dist, so `packages/ai` + `packages/agent` dists were rebuilt).
### Phase 6 — AgentHarness
- [x] `AgentHarnessOptions.models` required (`readonly models` on the harness); the harness stream path uses `models.streamSimple()`. `StreamFn` redefined structurally (no compat type dependency); `Models.streamSimple` satisfies it.
- [x] Compaction/branch-summarization take the harness `Models` instance. `getApiKeyAndHeaders` is removed entirely — `Models` is the only auth path; per-request key resolution becomes provider auth on the collection. `compact()`/`generateSummary()`/`generateBranchSummary()` lose their explicit `apiKey`/`headers` parameters.
- [x] Harness tests use `createModels()` + `fauxProvider()` with unique per-fake provider ids; no global api-registry state, no unregister bookkeeping.
### Phase 7 — coding-agent bridge (minimal)
- [x] Switch old-global imports to `@earendil-works/pi-ai/compat` (landed with Phase 5; compat is a superset so the switch was path-only). Extension loader resolves the pi-ai root to compat as the runtime grace period.
- [x] Everything else originally sketched here is gated on coding-agent actually streaming through a `Models` instance — coding-agent's `AgentSession` drives the low-level `Agent` via `streamFn`, not the harness — and moved to Phase 9.
### Phase 8 — wrap-up
- [x] Update/add tests; run affected suites (tests landed with each phase; `./test.sh` green throughout).
- [x] `packages/ai/CHANGELOG.md`: `### Breaking Changes` with migration guide (compat entrypoint, `Provider` -> `ProviderId`, api module moves) + `### Added` for the new Models/provider/auth API.
- [x] `packages/coding-agent/CHANGELOG.md`: `### Changed` entry for extension authors — runtime unaffected (loader resolves the pi-ai root to compat), typecheck nudges to `/compat` or the new API; removal happens later with a migration guide.
- [x] `packages/agent/CHANGELOG.md`: `### Breaking Changes` for required `AgentHarnessOptions.models`, compaction signature changes, structural `StreamFn`.
- [x] `npm run check` clean.
### Phase 9 — coding-agent on Models + CredentialStore (in scope)
coding-agent replaces AuthStorage and ModelRegistry's internals with `FileCredentialStore` + a `MutableModels` collection. AgentSession itself stays (AgentHarness adoption is pi 2.0); only its model/auth substrate swaps. Layering is strictly one-directional:
```txt
FileCredentialStore (auth.json, locked, $ENV/!command resolution) + explicit --api-key overlay
MutableModels: builtin factories (wrapped per models.json config) + custom providers (models.json extensions)
ModelRegistry: compatibility facade — sync last-known reads delegate to the collection; registerProvider/login/logout/status for extensions + UI
AgentSession / sdk / interactive-mode (stream via models; await only auth/refresh paths)
```
Decisions:
- `AuthStorage` is deleted as a type — it would otherwise depend on provider auth while provider auth depends on its store (circular). Its surface splits: `get`/`set`/`remove` -> `CredentialStore`; `getApiKey` -> `Models.getAuth`; `login`/`logout`/`getAuthStatus` -> ModelRegistry facade methods over `provider.auth.oauth` + the store.
- `FileCredentialStore` is self-contained (path, locking, parse/write, chmod, error buffering) and owns `auth.json` semantics, including `$ENV`/`!command` resolution for stored API-key credentials. Persisted values stay raw; resolution returns copies for auth use.
- Runtime `--api-key` overrides are an explicit store overlay (an override reads as an ephemeral stored api-key credential, masking stored OAuth — matches today's priority). Every registered provider is guaranteed an `apiKey` auth slot so overrides apply to OAuth-only providers too.
- `ModelRegistry.getAll`/`find`/`getAvailable` stay sync for SDK and extension compatibility, delegating to the collection's last-known sync model lists and fast configured-looking status checks. Dynamic providers update through explicit async `refresh()`, and request auth remains async through `getApiKeyAndHeaders()`/`Models.getAuth()`. Extensions also get the collection itself as the forward API.
- models.json keeps FULL feature parity, implemented as provider decoration: builtin factories wrapped so `getModels()` applies provider `baseUrl`/`compat` overlays, `modelOverrides`, and custom-model merges (async-safe); provider `apiKey`/`headers`/`authHeader` configs become that provider's `ApiKeyAuth` (config first, factory auth fallback); parse errors keep `getError()` semantics.
- Extension `ProviderConfig` parity: provider-keyed `streamSimple`, legacy extension OAuth callbacks adapted to `OAuthAuth`, and full model replacement per provider. Legacy `registerApiProvider` writes stay compat-local for consumers that call global `complete()`; they die with compat.
- Copilot: stored-credential baseUrl applied in the wrapped `getModels()` (extension-visible models stay correct) plus per-request `toAuth().baseUrl`.
- Cloudflare: provider-auth substitution (key + `CLOUDFLARE_ACCOUNT_ID`/`CLOUDFLARE_GATEWAY_ID` from credential `env` or ambient `AuthContext.env()` -> `ModelAuth.baseUrl`). Built-in compat calls route through `Models`, so they use the same provider auth path.
Ordering for new sessions:
1. [x] pi-ai rework first: `Provider.getModels()` sync + optional `refreshModels()`; `Models.getModels`/`getModel` sync, `Models.refresh(provider?)` async; `createProvider` takes `models` array + optional `refreshModels` fetcher (in-flight dedupe). Reverses Phase 1's async-listing decision — see "Provider model listing" for rationale (sync-or-async unions breed latent sync assumptions; async-only breaks sync consumer surfaces like extension `find`/`getAll`).
2. [x] Cloudflare provider auth in pi-ai factories: Workers AI and AI Gateway validate their required account/gateway env/config and return resolved `baseUrl`, provider-scoped env, and header suppression/override metadata from provider auth.
3. [ ] Add `FileCredentialStore` in coding-agent.
- Implement the pi-ai `CredentialStore` interface as a self-contained `auth.json` store; do not depend on the old `AuthStorageBackend` abstraction, though its lock/retry semantics may be ported.
- Preserve the existing file format. `ApiKeyCredential` uses `{ type: "api_key", key?, env? }`, matching today's `auth.json`; do not translate `env` into metadata or rewrite discriminators.
- Resolve `$ENV`/`!command` in stored API-key `key` and `env` values out of the box using an injected execution/config environment. `$ENV` lookup should come from that environment, and `!command` should run through the shared shell execution path rather than direct `execSync`.
- Persist raw config values; resolved credentials returned for auth use must be copies and must not rewrite `$ENV`/`!command` strings unless a caller explicitly stores new values.
- `read(provider)` returns the current credential snapshot and records parse/storage errors for status UI parity.
- `modify(provider, fn)` must lock, re-read, run `fn`, merge-write the provider entry, chmod `0600`, and return the post-write credential.
- `delete(provider)` must lock and remove only that provider's entry.
- Add file-backed and in-memory tests covering lock/RMW behavior, `api_key` reads with config-value resolution, OAuth reads, provider `env` preservation, delete, parse errors, and concurrent refresh-style modifications.
4. [ ] Add runtime override overlay for coding-agent policy.
- `withRuntimeOverrides(store, overrides)` implements CLI `--api-key`: read returns an ephemeral `{ type: "api_key", key }` for each overridden provider, masking stored OAuth/API credentials without persisting.
- Runtime overrides must apply even to OAuth-capable providers; every provider registered in coding-agent must retain or gain an `apiKey` auth slot so the overlay is meaningful.
- Tests cover precedence: runtime override > stored credential > models.json config auth > ambient provider env, with stored credential blocking ambient fallback.
5. [ ] Build provider decoration helpers for `models.json`.
- Start from built-in provider factories, not generated model arrays.
- Wrap provider `getModels()` so provider-level `baseUrl`/`headers`/`compat`, per-model `modelOverrides`, and custom model merges apply on every sync read.
- Preserve `refreshModels()` passthrough so dynamic providers compose with decorations.
- Convert provider `apiKey`/`headers`/`authHeader` models.json config into a wrapped `ApiKeyAuth` that resolves config values first and falls back to the base provider auth.
- Custom providers with `models` use `createProvider()` with the appropriate lazy API wrapper or extension-provided stream implementation.
- Parse errors must keep current `ModelRegistry.getError()` behavior: built-ins remain available, and the error is visible.
6. [ ] Copilot `getModels()` baseUrl wrap.
- GitHub Copilot OAuth `toAuth()` already returns per-credential request `baseUrl` for streaming.
- Wrap Copilot's provider `getModels()` when an OAuth credential is present so extension/UI-visible model metadata also carries the authenticated account base URL.
- Keep API-key/env-token Copilot behavior unchanged.
- Add tests for model metadata before login, after OAuth credential, after refresh/baseUrl change, and logout.
7. [x] Extension OAuth adapter.
- Keep only the legacy callback/credential declarations required by coding-agent `ProviderConfig.oauth`.
- `login` maps legacy callbacks/events to `AuthInteraction.prompt()`/`notify()`.
- `refreshToken` maps to `refresh`; `getApiKey` maps to `toAuth`.
- Preserve the type-only pi-ai `oauth` barrel and extension-loader aliases.
8. [ ] Rebuild coding-agent `ModelRegistry` over `MutableModels`.
- It owns a `MutableModels` instance built from decorated built-ins + models.json custom providers + extension providers.
- `getAll()`, `find()`, and `getAvailable()` remain sync compatibility methods over last-known model lists and fast configured-looking auth status. Do not break the extension-facing `modelRegistry` surface for these reads.
- `refresh()` is the explicit async freshness boundary: rebuild provider layers and call `models.refresh()` where needed; no global api-registry reset should be part of the new path except compat-only grace behavior.
- `registerProvider()`/`unregisterProvider()` mutate provider layers and rebuild the collection.
- Facade auth ops (`login`, `logout`, provider status, available OAuth providers) drive `provider.auth.{apiKey,oauth}` and the `CredentialStore`; no `AuthStorage` type remains.
- Legacy `registerApiProvider` writes stay only for `/compat` callers and are removed in Phase 10.
9. [ ] Rewire consumers.
- `AgentSession` stream function resolves through `ModelRegistry`/`Models`, not `getApiKeyAndHeaders()` + compat globals.
- SDK options replace `authStorage` with `credentials?: CredentialStore` or an agent-dir-backed default; update `sdk.md` and examples.
- `model-resolver`, `--list-models`, model selector, login/logout/status UI, and provider attribution use sync last-known model reads and await only explicit refresh/auth operations.
- CLI `--api-key` populates the runtime override decorator instead of mutating `AuthStorage`.
- Keep extension loader root-to-compat alias until Phase 10, but expose the new collection/facade as the forward API.
10. [ ] Test migration and real-provider validation.
- Unit tests for `FileCredentialStore`, runtime override overlay, provider decoration, extension OAuth adapter, Models-backed ModelRegistry facade, and consumer rewiring.
- Regression tests for Cloudflare account/gateway env, Copilot OAuth baseUrl wrapping, runtime `--api-key` precedence, `$ENV`/`!command` resolution, and stored credential blocking ambient fallback.
- Update existing tests for sync last-known `ModelRegistry.getAll/find/getAvailable` plus explicit async refresh behavior.
- Run targeted non-e2e suites plus tmux validation of login flows against real providers (Anthropic OAuth/API key, OpenAI Codex OAuth, GitHub Copilot OAuth, Cloudflare AI Gateway, Bedrock if credentials are available).
### Phase 10 — compat deletion (pi 2.0 era, separate)
- [ ] AgentSession -> AgentHarness; the registry facade dies in favor of harness `Models`.
- [ ] Move ALL internal `/compat` imports to the new API: every package's src, all tests, and the example extensions (examples then demonstrate the new API). Nothing inside the repo may import `/compat` at that point.
- [ ] Delete `/compat`, `env-api-keys.ts`, the extension-loader root-to-compat alias, and the compat-local legacy API registry. The old OAuth registry/provider interface is already gone; the type-only `oauth` barrel remains for extension compatibility.
### Deferred / follow-ups
- [ ] Web OAuth implementations (sitegeist-style) as an alternative `OAuthAuth`.
- [x] Images API redesign: `ImagesModels`/`ImagesProvider`/`createImagesProvider` mirror the chat-side design (sync reads, explicit refresh, never-reject generation); auth resolution shared with the chat side via the free-standing `resolveProviderAuth()` in `auth/resolve.ts` (which also owns `ModelsError`; both collections pass their store/context as arguments — no resolver object). `openrouterImagesProvider()` factory + `builtinImagesProviders()`/`builtinImagesModels()` in `providers/all`; impl moved to `api/openrouter-images.ts` with a lazy wrapper. The old global image API (registry + `getImageModel*` + `generateImages`) stays on compat; `ImagesProvider` id alias in types.ts renamed to `ImagesProviderId` (mirror of `Provider` -> `ProviderId`).
## Error behavior
`undefined` means not found or not configured. Real failures reject or become stream errors.
```ts
export type ModelsErrorCode =
| "model_source" // provider model refresh failed
| "model_validation" // model object invalid
| "provider" // unknown provider, dispatch failure
| "stream" // stream setup failure
| "auth" // auth resolution failure
| "oauth"; // oauth login/refresh failure
```
- `Models.stream()` produces stream errors (error event + error result) for async setup failures; it does not throw after returning the stream.
- `Models.getModels()` is a sync best-effort read: a provider whose `getModels()` throws yields no models. `Models.refresh(provider)` rejects on that provider's fetch failure; `Models.refresh()` (all providers) is concurrent best-effort. Apps that need a concrete listing failure refresh the single provider.
- Auth resolution and credential store failures reject loudly (`ModelsError` codes `auth`/`oauth`); silent fallback to a different auth path after a failure risks billing surprises. A stored credential always blocks ambient/env fallback, including after a failed refresh.
- Status/availability UIs catch `getAuth` rejections and render "needs re-login"; they do not treat rejection as "unconfigured".

View file

@ -1,6 +1,6 @@
{
"name": "@earendil-works/pi-agent-core",
"version": "0.80.10",
"version": "0.79.10",
"description": "General-purpose agent with transport abstraction, state management, and attachment support",
"type": "module",
"main": "./dist/index.js",
@ -10,6 +10,10 @@
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./base": {
"types": "./dist/base.d.ts",
"import": "./dist/base.js"
},
"./node": {
"types": "./dist/node.d.ts",
"import": "./dist/node.js"
@ -29,7 +33,7 @@
"prepublishOnly": "npm run clean && npm run build"
},
"dependencies": {
"@earendil-works/pi-ai": "^0.80.10",
"@earendil-works/pi-ai": "^0.79.10",
"ignore": "7.0.5",
"typebox": "1.1.38",
"yaml": "2.9.0"

View file

@ -10,7 +10,7 @@ import {
streamSimple,
type ToolResultMessage,
validateToolArguments,
} from "@earendil-works/pi-ai/compat";
} from "@earendil-works/pi-ai/base";
import type {
AgentContext,
AgentEvent,
@ -205,13 +205,7 @@ async function runLoop(
const toolResults: ToolResultMessage[] = [];
hasMoreToolCalls = false;
if (toolCalls.length > 0) {
// A "length" stop means the output was cut off by the token limit, so
// every tool call in the message may carry truncated arguments. Fail
// them all instead of executing potentially borked calls.
const executedToolBatch =
message.stopReason === "length"
? await failToolCallsFromTruncatedMessage(toolCalls, emit)
: await executeToolCalls(currentContext, message, config, signal, emit);
const executedToolBatch = await executeToolCalls(currentContext, message, config, signal, emit);
toolResults.push(...executedToolBatch.messages);
hasMoreToolCalls = !executedToolBatch.terminate;
@ -373,40 +367,6 @@ async function streamAssistantResponse(
return finalMessage;
}
/**
* Fail all tool calls from an assistant message that was truncated by the
* output token limit. Streamed tool-call arguments are finalized with a
* best-effort JSON salvage parser, so a truncated message can yield tool calls
* whose arguments parse and validate but are silently incomplete. None of them
* are safe to execute; report each as an error so the model can re-issue them.
*/
async function failToolCallsFromTruncatedMessage(
toolCalls: AgentToolCall[],
emit: AgentEventSink,
): Promise<ExecutedToolCallBatch> {
const messages: ToolResultMessage[] = [];
for (const toolCall of toolCalls) {
await emit({
type: "tool_execution_start",
toolCallId: toolCall.id,
toolName: toolCall.name,
args: toolCall.arguments,
});
const finalized: FinalizedToolCallOutcome = {
toolCall,
result: createErrorToolResult(
`Tool call "${toolCall.name}" was not executed: the response hit the output token limit, so its arguments may be truncated. Re-issue the tool call with complete arguments.`,
),
isError: true,
};
await emitToolExecutionEnd(finalized, emit);
const toolResultMessage = createToolResultMessage(finalized);
await emitToolResultMessage(toolResultMessage, emit);
messages.push(toolResultMessage);
}
return { messages, terminate: false };
}
/**
* Execute tool calls from an assistant message.
*/
@ -734,7 +694,6 @@ async function finalizeExecutedToolCall(
);
if (afterResult) {
result = {
...result,
content: afterResult.content ?? result.content,
details: afterResult.details ?? result.details,
terminate: afterResult.terminate ?? result.terminate,
@ -776,11 +735,8 @@ function createToolResultMessage(finalized: FinalizedToolCallOutcome): ToolResul
role: "toolResult",
toolCallId: finalized.toolCall.id,
toolName: finalized.toolCall.name,
// Untyped tools (JS extensions) can return results without content; normalize
// so the null never enters session history or provider payloads.
content: finalized.result.content ?? [],
content: finalized.result.content,
details: finalized.result.details,
...(finalized.result.addedToolNames?.length ? { addedToolNames: finalized.result.addedToolNames } : {}),
isError: finalized.isError,
timestamp: Date.now(),
};

View file

@ -7,7 +7,7 @@ import {
type TextContent,
type ThinkingBudgets,
type Transport,
} from "@earendil-works/pi-ai/compat";
} from "@earendil-works/pi-ai/base";
import { runAgentLoop, runAgentLoopContinue } from "./agent-loop.ts";
import type {
AfterToolCallContext,
@ -21,7 +21,6 @@ import type {
AgentTool,
BeforeToolCallContext,
BeforeToolCallResult,
PrepareNextTurnContext,
QueueMode,
StreamFn,
ToolExecutionMode,
@ -107,10 +106,6 @@ export interface AgentOptions {
prepareNextTurn?: (
signal?: AbortSignal,
) => Promise<AgentLoopTurnUpdate | undefined> | AgentLoopTurnUpdate | undefined;
prepareNextTurnWithContext?: (
context: PrepareNextTurnContext,
signal?: AbortSignal,
) => Promise<AgentLoopTurnUpdate | undefined> | AgentLoopTurnUpdate | undefined;
steeringMode?: QueueMode;
followUpMode?: QueueMode;
sessionId?: string;
@ -191,10 +186,6 @@ export class Agent {
public prepareNextTurn?: (
signal?: AbortSignal,
) => Promise<AgentLoopTurnUpdate | undefined> | AgentLoopTurnUpdate | undefined;
public prepareNextTurnWithContext?: (
context: PrepareNextTurnContext,
signal?: AbortSignal,
) => Promise<AgentLoopTurnUpdate | undefined> | AgentLoopTurnUpdate | undefined;
private activeRun?: ActiveRun;
/** Session identifier forwarded to providers for cache-aware backends. */
public sessionId?: string;
@ -218,7 +209,6 @@ export class Agent {
this.beforeToolCall = options.beforeToolCall;
this.afterToolCall = options.afterToolCall;
this.prepareNextTurn = options.prepareNextTurn;
this.prepareNextTurnWithContext = options.prepareNextTurnWithContext;
this.steeringQueue = new PendingMessageQueue(options.steeringMode ?? "one-at-a-time");
this.followUpQueue = new PendingMessageQueue(options.followUpMode ?? "one-at-a-time");
this.sessionId = options.sessionId;
@ -443,15 +433,7 @@ export class Agent {
toolExecution: this.toolExecution,
beforeToolCall: this.beforeToolCall,
afterToolCall: this.afterToolCall,
prepareNextTurn:
this.prepareNextTurnWithContext || this.prepareNextTurn
? async (context) => {
if (this.prepareNextTurnWithContext) {
return await this.prepareNextTurnWithContext(context, this.signal);
}
return await this.prepareNextTurn?.(this.signal);
}
: undefined,
prepareNextTurn: this.prepareNextTurn ? async () => await this.prepareNextTurn?.(this.signal) : undefined,
convertToLlm: this.convertToLlm,
transformContext: this.transformContext,
getApiKey: this.getApiKey,

View file

@ -0,0 +1,39 @@
export * from "./agent.ts";
export * from "./agent-loop.ts";
export * from "./harness/agent-harness.ts";
export {
type BranchPreparation,
type BranchSummaryDetails,
type CollectEntriesResult,
collectEntriesForBranchSummary,
generateBranchSummary,
prepareBranchEntries,
} from "./harness/compaction/branch-summarization.ts";
export {
calculateContextTokens,
compact,
DEFAULT_COMPACTION_SETTINGS,
estimateContextTokens,
estimateTokens,
findCutPoint,
findTurnStartIndex,
generateSummary,
getLastAssistantUsage,
prepareCompaction,
serializeConversation,
shouldCompact,
} from "./harness/compaction/compaction.ts";
export * from "./harness/messages.ts";
export * from "./harness/prompt-templates.ts";
export * from "./harness/session/jsonl-repo.ts";
export * from "./harness/session/memory-repo.ts";
export * from "./harness/session/repo-utils.ts";
export * from "./harness/session/session.ts";
export { uuidv7 } from "./harness/session/uuid.ts";
export * from "./harness/skills.ts";
export * from "./harness/system-prompt.ts";
export * from "./harness/types.ts";
export * from "./harness/utils/shell-output.ts";
export * from "./harness/utils/truncate.ts";
export * from "./proxy.ts";
export * from "./types.ts";

View file

@ -1,4 +1,10 @@
import type { AssistantMessage, ImageContent, Model, Models, UserMessage } from "@earendil-works/pi-ai";
import {
type AssistantMessage,
type ImageContent,
type Model,
streamSimple,
type UserMessage,
} from "@earendil-works/pi-ai/base";
import { runAgentLoop } from "../agent-loop.ts";
import type {
AgentContext,
@ -69,6 +75,17 @@ function cloneStreamOptions(streamOptions?: AgentHarnessStreamOptions): AgentHar
};
}
function mergeHeaders(...headers: Array<Record<string, string> | undefined>): Record<string, string> | undefined {
const merged: Record<string, string> = {};
let hasHeaders = false;
for (const entry of headers) {
if (!entry) continue;
Object.assign(merged, entry);
hasHeaders = true;
}
return hasHeaders ? merged : undefined;
}
function findDuplicateNames(names: string[]): string[] {
const seen = new Set<string>();
const duplicates = new Set<string>();
@ -161,7 +178,6 @@ export class AgentHarness<
> {
readonly env: ExecutionEnv;
private session: Session;
readonly models: Models;
private phase: AgentHarnessPhase = "idle";
private runAbortController?: AbortController;
private runPromise?: Promise<void>;
@ -170,6 +186,7 @@ export class AgentHarness<
private thinkingLevel: ThinkingLevel;
private systemPrompt: AgentHarnessOptions<TSkill, TPromptTemplate, TTool>["systemPrompt"];
private streamOptions: AgentHarnessStreamOptions;
private getApiKeyAndHeaders?: AgentHarnessOptions["getApiKeyAndHeaders"];
private resources: AgentHarnessResources<TSkill, TPromptTemplate>;
private tools = new Map<string, TTool>();
private activeToolNames: string[];
@ -183,10 +200,10 @@ export class AgentHarness<
constructor(options: AgentHarnessOptions<TSkill, TPromptTemplate, TTool>) {
this.env = options.env;
this.session = options.session;
this.models = options.models;
this.resources = options.resources ?? {};
this.streamOptions = cloneStreamOptions(options.streamOptions);
this.systemPrompt = options.systemPrompt;
this.getApiKeyAndHeaders = options.getApiKeyAndHeaders;
this.validateUniqueNames(
(options.tools ?? []).map((tool) => tool.name),
"Duplicate tool name(s)",
@ -359,9 +376,13 @@ export class AgentHarness<
private createStreamFn(getTurnState: () => AgentHarnessTurnState<TSkill, TPromptTemplate, TTool>): StreamFn {
return async (model, context, streamOptions) => {
const turnState = getTurnState();
const snapshotOptions: AgentHarnessStreamOptions = { ...turnState.streamOptions };
const auth = await this.getApiKeyAndHeaders?.(model);
const snapshotOptions: AgentHarnessStreamOptions = {
...turnState.streamOptions,
headers: mergeHeaders(turnState.streamOptions.headers, auth?.headers),
};
const requestOptions = await this.emitBeforeProviderRequest(model, turnState.sessionId, snapshotOptions);
return this.models.streamSimple(model, context, {
return streamSimple(model, context, {
cacheRetention: requestOptions.cacheRetention,
headers: requestOptions.headers,
maxRetries: requestOptions.maxRetries,
@ -380,6 +401,7 @@ export class AgentHarness<
sessionId: turnState.sessionId,
timeoutMs: requestOptions.timeoutMs,
transport: requestOptions.transport,
apiKey: auth?.apiKey,
});
};
}
@ -691,6 +713,8 @@ export class AgentHarness<
try {
const model = this.model;
if (!model) throw new AgentHarnessError("invalid_state", "No model set for compaction");
const auth = await this.getApiKeyAndHeaders?.(model);
if (!auth) throw new AgentHarnessError("auth", "No auth available for compaction");
const branchEntries = await this.session.getBranch();
const preparationResult = prepareCompaction(branchEntries, DEFAULT_COMPACTION_SETTINGS);
if (!preparationResult.ok) throw preparationResult.error;
@ -707,7 +731,15 @@ export class AgentHarness<
const provided = hookResult?.compaction;
const compactResult = provided
? { ok: true as const, value: provided }
: await compact(preparation, this.models, model, customInstructions, undefined, this.thinkingLevel);
: await compact(
preparation,
model,
auth.apiKey,
auth.headers,
customInstructions,
undefined,
this.thinkingLevel,
);
if (!compactResult.ok) throw compactResult.error;
const result = compactResult.value;
const entryId = await this.session.appendCompaction(
@ -760,9 +792,12 @@ export class AgentHarness<
if (!summaryText && options?.summarize && entries.length > 0) {
const model = this.model;
if (!model) throw new AgentHarnessError("invalid_state", "No model set for branch summary");
const auth = await this.getApiKeyAndHeaders?.(model);
if (!auth) throw new AgentHarnessError("auth", "No auth available for branch summary");
const branchSummary = await generateBranchSummary(entries, {
models: this.models,
model,
apiKey: auth.apiKey,
headers: auth.headers,
signal: new AbortController().signal,
customInstructions: hookResult?.customInstructions ?? options?.customInstructions,
replaceInstructions: hookResult?.replaceInstructions ?? options?.replaceInstructions,

View file

@ -1,5 +1,4 @@
import type { Model, Models } from "@earendil-works/pi-ai";
import { completeSimple, type Model } from "@earendil-works/pi-ai/base";
import type { AgentMessage } from "../../types.ts";
import {
convertToLlm,
@ -49,10 +48,12 @@ export interface CollectEntriesResult {
/** Options for generating a branch summary. */
export interface GenerateBranchSummaryOptions {
/** Provider collection the summarization request goes through; owns auth resolution. */
models: Models;
/** Model used for summarization. */
model: Model<any>;
/** API key forwarded to the provider. */
apiKey: string;
/** Optional request headers forwarded to the provider. */
headers?: Record<string, string>;
/** Abort signal for the summarization request. */
signal: AbortSignal;
/** Optional instructions appended to or replacing the default prompt. */
@ -200,7 +201,7 @@ export async function generateBranchSummary(
entries: SessionTreeEntry[],
options: GenerateBranchSummaryOptions,
): Promise<Result<BranchSummaryResult, BranchSummaryError>> {
const { models, model, signal, customInstructions, replaceInstructions, reserveTokens = 16384 } = options;
const { model, apiKey, headers, signal, customInstructions, replaceInstructions, reserveTokens = 16384 } = options;
const contextWindow = model.contextWindow || 128000;
const tokenBudget = contextWindow - reserveTokens;
@ -228,10 +229,10 @@ export async function generateBranchSummary(
timestamp: Date.now(),
},
];
const response = await models.completeSimple(
const response = await completeSimple(
model,
{ systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages },
{ signal, maxTokens: 2048 },
{ apiKey, headers, signal, maxTokens: 2048 },
);
if (response.stopReason === "aborted") {
return err(new BranchSummaryError("aborted", response.errorMessage || "Branch summary aborted"));

View file

@ -1,4 +1,11 @@
import type { AssistantMessage, ImageContent, Model, Models, TextContent, Usage } from "@earendil-works/pi-ai";
import {
type AssistantMessage,
completeSimple,
type ImageContent,
type Model,
type TextContent,
type Usage,
} from "@earendil-works/pi-ai/base";
import type { AgentMessage, ThinkingLevel } from "../../types.ts";
import {
convertToLlm,
@ -121,19 +128,14 @@ export function calculateContextTokens(usage: Usage): number {
function getAssistantUsage(msg: AgentMessage): Usage | undefined {
if (msg.role === "assistant" && "usage" in msg) {
const assistantMsg = msg as AssistantMessage;
if (
assistantMsg.stopReason !== "aborted" &&
assistantMsg.stopReason !== "error" &&
assistantMsg.usage &&
calculateContextTokens(assistantMsg.usage) > 0
) {
if (assistantMsg.stopReason !== "aborted" && assistantMsg.stopReason !== "error" && assistantMsg.usage) {
return assistantMsg.usage;
}
}
return undefined;
}
/** Return usage from the last valid assistant message in session entries. */
/** Return usage from the last successful assistant message in session entries. */
export function getLastAssistantUsage(entries: SessionTreeEntry[]): Usage | undefined {
for (let i = entries.length - 1; i >= 0; i--) {
const entry = entries[i];
@ -459,9 +461,10 @@ Keep each section concise. Preserve exact file paths, function names, and error
/** Generate or update a conversation summary for compaction. */
export async function generateSummary(
currentMessages: AgentMessage[],
models: Models,
model: Model<any>,
reserveTokens: number,
apiKey: string,
headers?: Record<string, string>,
signal?: AbortSignal,
customInstructions?: string,
previousSummary?: string,
@ -493,10 +496,10 @@ export async function generateSummary(
const completionOptions =
model.reasoning && thinkingLevel && thinkingLevel !== "off"
? { maxTokens, signal, reasoning: thinkingLevel }
: { maxTokens, signal };
? { maxTokens, signal, apiKey, headers, reasoning: thinkingLevel }
: { maxTokens, signal, apiKey, headers };
const response = await models.completeSimple(
const response = await completeSimple(
model,
{ systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages },
completionOptions,
@ -629,8 +632,9 @@ export { serializeConversation } from "./utils.ts";
/** Generate compaction summary data from prepared session history. */
export async function compact(
preparation: CompactionPreparation,
models: Models,
model: Model<any>,
apiKey: string,
headers?: Record<string, string>,
customInstructions?: string,
signal?: AbortSignal,
thinkingLevel?: ThinkingLevel,
@ -653,36 +657,40 @@ export async function compact(
let summary: string;
if (isSplitTurn && turnPrefixMessages.length > 0) {
const historyResult =
const [historyResult, turnPrefixResult] = await Promise.all([
messagesToSummarize.length > 0
? await generateSummary(
? generateSummary(
messagesToSummarize,
models,
model,
settings.reserveTokens,
apiKey,
headers,
signal,
customInstructions,
previousSummary,
thinkingLevel,
)
: ok<string, CompactionError>("No prior history.");
: Promise.resolve(ok<string, CompactionError>("No prior history.")),
generateTurnPrefixSummary(
turnPrefixMessages,
model,
settings.reserveTokens,
apiKey,
headers,
signal,
thinkingLevel,
),
]);
if (!historyResult.ok) return err(historyResult.error);
const turnPrefixResult = await generateTurnPrefixSummary(
turnPrefixMessages,
models,
model,
settings.reserveTokens,
signal,
thinkingLevel,
);
if (!turnPrefixResult.ok) return err(turnPrefixResult.error);
summary = `${historyResult.value}\n\n---\n\n**Turn Context (split turn):**\n\n${turnPrefixResult.value}`;
} else {
const summaryResult = await generateSummary(
messagesToSummarize,
models,
model,
settings.reserveTokens,
apiKey,
headers,
signal,
customInstructions,
previousSummary,
@ -704,9 +712,10 @@ export async function compact(
}
async function generateTurnPrefixSummary(
messages: AgentMessage[],
models: Models,
model: Model<any>,
reserveTokens: number,
apiKey: string,
headers?: Record<string, string>,
signal?: AbortSignal,
thinkingLevel?: ThinkingLevel,
): Promise<Result<string, CompactionError>> {
@ -725,12 +734,12 @@ async function generateTurnPrefixSummary(
},
];
const response = await models.completeSimple(
const response = await completeSimple(
model,
{ systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages },
model.reasoning && thinkingLevel && thinkingLevel !== "off"
? { maxTokens, signal, reasoning: thinkingLevel }
: { maxTokens, signal },
? { maxTokens, signal, apiKey, headers, reasoning: thinkingLevel }
: { maxTokens, signal, apiKey, headers },
);
if (response.stopReason === "aborted") {
return err(new CompactionError("aborted", response.errorMessage || "Turn prefix summarization aborted"));

View file

@ -1,4 +1,4 @@
import type { Message } from "@earendil-works/pi-ai";
import type { Message } from "@earendil-works/pi-ai/base";
import type { AgentMessage } from "../../types.ts";
/** File paths touched by a session branch or compaction range. */

View file

@ -28,22 +28,6 @@ import {
toError,
} from "../types.ts";
const MAX_TIMEOUT_MS = 2_147_483_647;
const MAX_TIMEOUT_SECONDS = MAX_TIMEOUT_MS / 1000;
function resolveTimeoutMs(timeout: number | undefined): Result<number | undefined, ExecutionError> {
if (timeout === undefined) return ok(undefined);
if (!Number.isFinite(timeout) || timeout <= 0) {
return err(new ExecutionError("timeout", "Invalid timeout: must be a finite number of seconds"));
}
const timeoutMs = timeout * 1000;
if (timeoutMs > MAX_TIMEOUT_MS) {
return err(new ExecutionError("timeout", `Invalid timeout: maximum is ${MAX_TIMEOUT_SECONDS} seconds`));
}
return ok(timeoutMs);
}
function resolvePath(cwd: string, path: string): string {
return isAbsolute(path) ? path : resolve(cwd, path);
}
@ -274,9 +258,6 @@ export class NodeExecutionEnv implements ExecutionEnv {
},
): Promise<Result<{ stdout: string; stderr: string; exitCode: number }, ExecutionError>> {
if (options?.abortSignal?.aborted) return err(new ExecutionError("aborted", "aborted"));
const timeoutMsResult = resolveTimeoutMs(options?.timeout);
if (!timeoutMsResult.ok) return err(timeoutMsResult.error);
const timeoutMs = timeoutMsResult.value;
const cwd = options?.cwd ? resolvePath(this.cwd, options.cwd) : this.cwd;
const shellConfig = await getShellConfig(this.shellPath);
@ -329,13 +310,13 @@ export class NodeExecutionEnv implements ExecutionEnv {
}
timeoutId =
timeoutMs !== undefined
typeof options?.timeout === "number"
? setTimeout(() => {
timedOut = true;
if (child?.pid) {
killProcessTree(child.pid);
}
}, timeoutMs)
}, options.timeout * 1000)
: undefined;
if (options?.abortSignal) {

View file

@ -1,4 +1,4 @@
import type { ImageContent, Message, TextContent } from "@earendil-works/pi-ai";
import type { ImageContent, Message, TextContent } from "@earendil-works/pi-ai/base";
import type { AgentMessage } from "../types.ts";
export const COMPACTION_SUMMARY_PREFIX = `The conversation history before this point was compacted into the following summary:

View file

@ -85,7 +85,6 @@ export class JsonlSessionRepo implements JsonlSessionRepoApi {
cwd: options.cwd,
sessionId: id,
parentSessionPath: options.parentSessionPath,
metadata: options.metadata,
});
return toSession(storage);
}
@ -151,7 +150,6 @@ export class JsonlSessionRepo implements JsonlSessionRepoApi {
cwd: options.cwd,
sessionId: id,
parentSessionPath: options.parentSessionPath ?? sourceMetadata.path,
metadata: options.metadata ?? sourceMetadata.metadata,
},
);
for (const entry of forkedEntries) {

View file

@ -12,7 +12,6 @@ interface SessionHeader {
timestamp: string;
cwd: string;
parentSession?: string;
metadata?: Record<string, unknown>;
}
function updateLabelCache(labelsById: Map<string, string>, entry: SessionTreeEntry): void {
@ -35,14 +34,16 @@ function buildLabelsById(entries: SessionTreeEntry[]): Map<string, string> {
function generateEntryId(byId: { has(id: string): boolean }): string {
for (let i = 0; i < 100; i++) {
// The uuidv7 prefix is timestamp-derived and nearly constant between calls,
// so short ids must come from the random tail.
const id = uuidv7().slice(-8);
const id = uuidv7().slice(0, 8);
if (!byId.has(id)) return id;
}
return uuidv7();
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function invalidSession(filePath: string, message: string, cause?: Error): SessionError {
return new SessionError("invalid_session", `Invalid JSONL session file ${filePath}: ${message}`, cause);
}
@ -62,34 +63,24 @@ function parseHeaderLine(line: string, filePath: string): SessionHeader {
} catch (error) {
throw invalidSession(filePath, "first line is not a valid session header", toError(error));
}
if (typeof parsed !== "object" || parsed === null) {
throw invalidSession(filePath, "first line is not a valid session header");
}
const header = parsed as Partial<SessionHeader>;
if (header.type !== "session") throw invalidSession(filePath, "first line is not a valid session header");
if (header.version !== 3) throw invalidSession(filePath, "unsupported session version");
if (typeof header.id !== "string" || !header.id) throw invalidSession(filePath, "session header is missing id");
if (typeof header.timestamp !== "string" || !header.timestamp) {
if (!isRecord(parsed)) throw invalidSession(filePath, "first line is not a valid session header");
if (parsed.type !== "session") throw invalidSession(filePath, "first line is not a valid session header");
if (parsed.version !== 3) throw invalidSession(filePath, "unsupported session version");
if (typeof parsed.id !== "string" || !parsed.id) throw invalidSession(filePath, "session header is missing id");
if (typeof parsed.timestamp !== "string" || !parsed.timestamp) {
throw invalidSession(filePath, "session header is missing timestamp");
}
if (typeof header.cwd !== "string" || !header.cwd) throw invalidSession(filePath, "session header is missing cwd");
if (header.parentSession !== undefined && typeof header.parentSession !== "string") {
if (typeof parsed.cwd !== "string" || !parsed.cwd) throw invalidSession(filePath, "session header is missing cwd");
if (parsed.parentSession !== undefined && typeof parsed.parentSession !== "string") {
throw invalidSession(filePath, "session header parentSession must be a string");
}
if (
header.metadata !== undefined &&
(typeof header.metadata !== "object" || header.metadata === null || Array.isArray(header.metadata))
) {
throw invalidSession(filePath, "session header metadata must be an object");
}
return {
type: "session",
version: 3,
id: header.id,
timestamp: header.timestamp,
cwd: header.cwd,
parentSession: header.parentSession,
metadata: header.metadata,
id: parsed.id,
timestamp: parsed.timestamp,
cwd: parsed.cwd,
parentSession: parsed.parentSession,
};
}
@ -100,28 +91,19 @@ function parseEntryLine(line: string, filePath: string, lineNumber: number): Ses
} catch (error) {
throw invalidEntry(filePath, lineNumber, "is not valid JSON", toError(error));
}
if (typeof parsed !== "object" || parsed === null) {
throw invalidEntry(filePath, lineNumber, "is not a valid session entry");
}
const entry = parsed as {
type?: unknown;
id?: unknown;
parentId?: unknown;
timestamp?: unknown;
targetId?: unknown;
};
if (typeof entry.type !== "string") throw invalidEntry(filePath, lineNumber, "is missing entry type");
if (typeof entry.id !== "string" || !entry.id) throw invalidEntry(filePath, lineNumber, "is missing entry id");
if (entry.parentId !== null && typeof entry.parentId !== "string") {
if (!isRecord(parsed)) throw invalidEntry(filePath, lineNumber, "is not a valid session entry");
if (typeof parsed.type !== "string") throw invalidEntry(filePath, lineNumber, "is missing entry type");
if (typeof parsed.id !== "string" || !parsed.id) throw invalidEntry(filePath, lineNumber, "is missing entry id");
if (parsed.parentId !== null && typeof parsed.parentId !== "string") {
throw invalidEntry(filePath, lineNumber, "has invalid parentId");
}
if (typeof entry.timestamp !== "string" || !entry.timestamp) {
if (typeof parsed.timestamp !== "string" || !parsed.timestamp) {
throw invalidEntry(filePath, lineNumber, "is missing timestamp");
}
if (entry.type === "leaf" && entry.targetId !== null && typeof entry.targetId !== "string") {
if (parsed.type === "leaf" && parsed.targetId !== null && typeof parsed.targetId !== "string") {
throw invalidEntry(filePath, lineNumber, "has invalid targetId");
}
return entry as SessionTreeEntry;
return parsed as unknown as SessionTreeEntry;
}
function leafIdAfterEntry(entry: SessionTreeEntry): string | null {
@ -135,7 +117,6 @@ function headerToSessionMetadata(header: SessionHeader, path: string): JsonlSess
cwd: header.cwd,
path,
parentSessionPath: header.parentSession,
metadata: header.metadata,
};
}
@ -214,7 +195,6 @@ export class JsonlSessionStorage implements SessionStorage<JsonlSessionMetadata>
cwd: string;
sessionId: string;
parentSessionPath?: string;
metadata?: Record<string, unknown>;
},
): Promise<JsonlSessionStorage> {
const header: SessionHeader = {
@ -224,7 +204,6 @@ export class JsonlSessionStorage implements SessionStorage<JsonlSessionMetadata>
timestamp: new Date().toISOString(),
cwd: options.cwd,
parentSession: options.parentSessionPath,
metadata: options.metadata,
};
getFileSystemResultOrThrow(
await fs.writeFile(filePath, `${JSON.stringify(header)}\n`),

View file

@ -27,9 +27,7 @@ function buildLabelsById(entries: SessionTreeEntry[]): Map<string, string> {
function generateEntryId(byId: { has(id: string): boolean }): string {
for (let i = 0; i < 100; i++) {
// The uuidv7 prefix is timestamp-derived and nearly constant between calls,
// so short ids must come from the random tail.
const id = uuidv7().slice(-8);
const id = uuidv7().slice(0, 8);
if (!byId.has(id)) return id;
}
return uuidv7();

View file

@ -1,4 +1,4 @@
import type { ImageContent, TextContent } from "@earendil-works/pi-ai";
import type { ImageContent, TextContent } from "@earendil-works/pi-ai/base";
import type { AgentMessage } from "../../types.ts";
import { createBranchSummaryMessage, createCompactionSummaryMessage, createCustomMessage } from "../messages.ts";
import type {
@ -19,25 +19,11 @@ import type {
} from "../types.ts";
import { SessionError } from "../types.ts";
export type ContextEntryTransform = (entries: readonly SessionTreeEntry[]) => readonly SessionTreeEntry[];
export type CustomEntryContextMessageProjector = (
entry: CustomEntry,
index: number,
entries: readonly SessionTreeEntry[],
) => readonly AgentMessage[] | undefined;
export interface SessionContextBuildOptions {
/** Additional entry transforms applied after the default compaction transform. */
entryTransforms?: readonly ContextEntryTransform[];
/** Optional custom-entry projectors. Custom entries are omitted from model context by default. */
entryProjectors?: Readonly<Record<string, CustomEntryContextMessageProjector>>;
}
function deriveSessionContextState(pathEntries: readonly SessionTreeEntry[]): Omit<SessionContext, "messages"> {
export function buildSessionContext(pathEntries: SessionTreeEntry[]): SessionContext {
let thinkingLevel = "off";
let model: { provider: string; modelId: string } | null = null;
let activeToolNames: string[] | null = null;
let compaction: CompactionEntry | null = null;
for (const entry of pathEntries) {
if (entry.type === "thinking_level_change") {
@ -48,99 +34,56 @@ function deriveSessionContextState(pathEntries: readonly SessionTreeEntry[]): Om
model = { provider: entry.message.provider, modelId: entry.message.model };
} else if (entry.type === "active_tools_change") {
activeToolNames = [...entry.activeToolNames];
}
}
return { thinkingLevel, model, activeToolNames };
}
export function defaultContextEntryTransform(pathEntries: readonly SessionTreeEntry[]): SessionTreeEntry[] {
let compaction: CompactionEntry | null = null;
for (const entry of pathEntries) {
if (entry.type === "compaction") {
} else if (entry.type === "compaction") {
compaction = entry;
}
}
if (!compaction) {
return [...pathEntries];
const messages: AgentMessage[] = [];
const appendMessage = (entry: SessionTreeEntry) => {
if (entry.type === "message") {
messages.push(entry.message as AgentMessage);
} else if (entry.type === "custom_message") {
messages.push(
createCustomMessage(
entry.customType,
entry.content as string | (TextContent | ImageContent)[],
entry.display,
entry.details,
entry.timestamp,
),
);
} else if (entry.type === "branch_summary" && entry.summary) {
messages.push(createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp));
}
};
if (compaction) {
messages.push(createCompactionSummaryMessage(compaction.summary, compaction.tokensBefore, compaction.timestamp));
const compactionIdx = pathEntries.findIndex((e) => e.type === "compaction" && e.id === compaction.id);
let foundFirstKept = false;
for (let i = 0; i < compactionIdx; i++) {
const entry = pathEntries[i]!;
if (entry.id === compaction.firstKeptEntryId) foundFirstKept = true;
if (foundFirstKept) appendMessage(entry);
}
for (let i = compactionIdx + 1; i < pathEntries.length; i++) {
appendMessage(pathEntries[i]!);
}
} else {
for (const entry of pathEntries) {
appendMessage(entry);
}
}
const entries: SessionTreeEntry[] = [compaction];
const compactionIdx = pathEntries.findIndex((entry) => entry.type === "compaction" && entry.id === compaction.id);
let foundFirstKept = false;
for (let i = 0; i < compactionIdx; i++) {
const entry = pathEntries[i]!;
if (entry.id === compaction.firstKeptEntryId) foundFirstKept = true;
if (foundFirstKept) entries.push(entry);
}
for (let i = compactionIdx + 1; i < pathEntries.length; i++) {
entries.push(pathEntries[i]!);
}
return entries;
}
export function buildContextEntries(
pathEntries: readonly SessionTreeEntry[],
options: SessionContextBuildOptions = {},
): SessionTreeEntry[] {
let entries = defaultContextEntryTransform(pathEntries);
for (const transform of options.entryTransforms ?? []) {
entries = [...transform(entries)];
}
return entries;
}
export function sessionEntryToContextMessages(
entry: SessionTreeEntry,
index: number,
entries: readonly SessionTreeEntry[],
options: SessionContextBuildOptions = {},
): AgentMessage[] {
if (entry.type === "message") {
return [entry.message as AgentMessage];
}
if (entry.type === "custom_message") {
return [
createCustomMessage(
entry.customType,
entry.content as string | (TextContent | ImageContent)[],
entry.display,
entry.details,
entry.timestamp,
),
];
}
if (entry.type === "compaction") {
return [createCompactionSummaryMessage(entry.summary, entry.tokensBefore, entry.timestamp)];
}
if (entry.type === "branch_summary" && entry.summary) {
return [createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp)];
}
if (entry.type === "custom") {
return [...(options.entryProjectors?.[entry.customType]?.(entry, index, entries) ?? [])];
}
return [];
}
export function buildSessionContext(
pathEntries: readonly SessionTreeEntry[],
options: SessionContextBuildOptions = {},
): SessionContext {
const state = deriveSessionContextState(pathEntries);
const contextEntries = buildContextEntries(pathEntries, options);
const messages = contextEntries.flatMap((entry, index) =>
sessionEntryToContextMessages(entry, index, contextEntries, options),
);
return { ...state, messages };
return { messages, thinkingLevel, model, activeToolNames };
}
export class Session<TMetadata extends SessionMetadata = SessionMetadata> {
private storage: SessionStorage<TMetadata>;
private contextBuildOptions: SessionContextBuildOptions;
constructor(storage: SessionStorage<TMetadata>, contextBuildOptions: SessionContextBuildOptions = {}) {
constructor(storage: SessionStorage<TMetadata>) {
this.storage = storage;
this.contextBuildOptions = contextBuildOptions;
}
getMetadata(): Promise<TMetadata> {
@ -168,22 +111,8 @@ export class Session<TMetadata extends SessionMetadata = SessionMetadata> {
return this.storage.getPathToRoot(leafId);
}
async buildContextEntries(options: SessionContextBuildOptions = {}): Promise<SessionTreeEntry[]> {
return buildContextEntries(await this.getBranch(), this.mergeContextBuildOptions(options));
}
async buildContext(options: SessionContextBuildOptions = {}): Promise<SessionContext> {
return buildSessionContext(await this.getBranch(), this.mergeContextBuildOptions(options));
}
private mergeContextBuildOptions(options: SessionContextBuildOptions): SessionContextBuildOptions {
return {
entryTransforms: [...(this.contextBuildOptions.entryTransforms ?? []), ...(options.entryTransforms ?? [])],
entryProjectors: {
...(this.contextBuildOptions.entryProjectors ?? {}),
...(options.entryProjectors ?? {}),
},
};
async buildContext(): Promise<SessionContext> {
return buildSessionContext(await this.getBranch());
}
getLabel(id: string): Promise<string | undefined> {
@ -305,13 +234,12 @@ export class Session<TMetadata extends SessionMetadata = SessionMetadata> {
}
async appendSessionName(name: string): Promise<string> {
const sanitizedName = name.replace(/[\r\n]+/g, " ").trim();
return this.appendTypedEntry({
type: "session_info",
id: await this.storage.createEntryId(),
parentId: await this.storage.getLeafId(),
timestamp: new Date().toISOString(),
name: sanitizedName,
name: name.trim(),
} satisfies SessionInfoEntry);
}

View file

@ -1,5 +1,5 @@
import type { ImageContent, Model, Models, SimpleStreamOptions, TextContent, Transport } from "@earendil-works/pi-ai";
import type { AgentEvent, AgentMessage, AgentTool, QueueMode, ThinkingLevel } from "../index.ts";
import type { ImageContent, Model, SimpleStreamOptions, TextContent, Transport } from "@earendil-works/pi-ai/base";
import type { AgentEvent, AgentMessage, AgentTool, QueueMode, ThinkingLevel } from "../types.ts";
import type { Session } from "./session/session.ts";
/** Result of a fallible operation. Expected failures are returned as `ok: false` instead of thrown. */
@ -240,6 +240,22 @@ export interface FileInfo {
mtimeMs: number;
}
/** Options for {@link Shell.exec}. */
export interface ExecutionEnvExecOptions {
/** Working directory for the command. Relative paths are resolved against {@link ExecutionEnv.cwd}. Defaults to {@link ExecutionEnv.cwd}. */
cwd?: string;
/** Additional environment variables for the command. Values override the environment defaults. Defaults to no overrides. */
env?: Record<string, string>;
/** Timeout in seconds. Implementations should return a timeout error when the command exceeds this duration. Defaults to no timeout. */
timeout?: number;
/** Abort signal used to terminate the command. Defaults to no abort signal. */
abortSignal?: AbortSignal;
/** Called with stdout chunks as they are produced. */
onStdout?: (chunk: string) => void;
/** Called with stderr chunks as they are produced. */
onStderr?: (chunk: string) => void;
}
/**
* Filesystem capability used by the harness.
*
@ -301,28 +317,12 @@ export interface FileSystem {
cleanup(): Promise<void>;
}
/** Options for {@link Shell.exec}. */
export interface ShellExecOptions {
/** Working directory for the command. Relative paths are resolved against {@link ExecutionEnv.cwd}. Defaults to {@link ExecutionEnv.cwd}. */
cwd?: string;
/** Additional environment variables for the command. Values override the environment defaults. Defaults to no overrides. */
env?: Record<string, string>;
/** Timeout in seconds. Implementations should return a timeout error when the command exceeds this duration. Defaults to no timeout. */
timeout?: number;
/** Abort signal used to terminate the command. Defaults to no abort signal. */
abortSignal?: AbortSignal;
/** Called with stdout chunks as they are produced. */
onStdout?: (chunk: string) => void;
/** Called with stderr chunks as they are produced. */
onStderr?: (chunk: string) => void;
}
/** Shell execution capability used by the harness. */
export interface Shell {
/** Execute a shell command in {@link FileSystem.cwd} unless `options.cwd` is provided. */
exec(
command: string,
options?: ShellExecOptions,
options?: ExecutionEnvExecOptions,
): Promise<Result<{ stdout: string; stderr: string; exitCode: number }, ExecutionError>>;
/** Release shell resources. Must be best-effort and must not throw or reject. */
cleanup(): Promise<void>;
@ -435,7 +435,6 @@ export interface JsonlSessionMetadata extends SessionMetadata {
cwd: string;
path: string;
parentSessionPath?: string;
metadata?: Record<string, unknown>;
}
export interface SessionStorage<TMetadata extends SessionMetadata = SessionMetadata> {
@ -481,7 +480,6 @@ export interface SessionRepo<
export interface JsonlSessionCreateOptions extends SessionCreateOptions {
cwd: string;
parentSessionPath?: string;
metadata?: Record<string, unknown>;
}
export interface JsonlSessionListOptions {
@ -804,12 +802,6 @@ export interface AgentHarnessOptions<
> {
env: ExecutionEnv;
session: Session;
/**
* Provider collection used for all model requests (turn streaming,
* compaction, branch summarization). Auth resolves through the providers'
* auth.
*/
models: Models;
tools?: TTool[];
/**
* Concrete resources available to explicit invocation methods and system-prompt callbacks.
@ -826,6 +818,9 @@ export interface AgentHarnessOptions<
activeTools: TTool[];
resources: AgentHarnessResources<TSkill, TPromptTemplate>;
}) => string | Promise<string>);
getApiKeyAndHeaders?: (
model: Model<any>,
) => Promise<{ apiKey: string; headers?: Record<string, string> } | undefined>;
/** Curated stream/provider request options. Snapshotted at turn start. */
streamOptions?: AgentHarnessStreamOptions;
model: Model<any>;

View file

@ -1,7 +1,15 @@
import { type ExecutionEnv, ExecutionError, err, ok, type Result, type ShellExecOptions, toError } from "../types.ts";
import {
type ExecutionEnv,
type ExecutionEnvExecOptions,
ExecutionError,
err,
ok,
type Result,
toError,
} from "../types.ts";
import { DEFAULT_MAX_BYTES, truncateTail } from "./truncate.ts";
export interface ShellCaptureOptions extends Omit<ShellExecOptions, "onStdout" | "onStderr"> {
export interface ShellCaptureOptions extends Omit<ExecutionEnvExecOptions, "onStdout" | "onStderr"> {
onChunk?: (chunk: string) => void;
}

View file

@ -1,46 +1,5 @@
// Core Agent
export * from "./agent.ts";
// Loop functions
export * from "./agent-loop.ts";
export * from "./harness/agent-harness.ts";
export {
type BranchPreparation,
type BranchSummaryDetails,
type CollectEntriesResult,
collectEntriesForBranchSummary,
generateBranchSummary,
prepareBranchEntries,
} from "./harness/compaction/branch-summarization.ts";
export {
calculateContextTokens,
compact,
DEFAULT_COMPACTION_SETTINGS,
estimateContextTokens,
estimateTokens,
findCutPoint,
findTurnStartIndex,
generateSummary,
getLastAssistantUsage,
prepareCompaction,
serializeConversation,
shouldCompact,
} from "./harness/compaction/compaction.ts";
export * from "./harness/messages.ts";
export * from "./harness/prompt-templates.ts";
export * from "./harness/session/jsonl-repo.ts";
export * from "./harness/session/jsonl-storage.ts";
export * from "./harness/session/memory-repo.ts";
export * from "./harness/session/memory-storage.ts";
export * from "./harness/session/repo-utils.ts";
export * from "./harness/session/session.ts";
export { uuidv7 } from "./harness/session/uuid.ts";
export * from "./harness/skills.ts";
export * from "./harness/system-prompt.ts";
// Harness
export * from "./harness/types.ts";
export * from "./harness/utils/shell-output.ts";
export * from "./harness/utils/truncate.ts";
// Proxy utilities
export * from "./proxy.ts";
// Types
export * from "./types.ts";
// Import the default pi-ai entrypoint so that all built-in providers register
// automatically. Unlike "@earendil-works/pi-ai/base" which does not.
import "@earendil-works/pi-ai";
export * from "./base.ts";

View file

@ -14,7 +14,7 @@ import {
type SimpleStreamOptions,
type StopReason,
type ToolCall,
} from "@earendil-works/pi-ai";
} from "@earendil-works/pi-ai/base";
// Create stream class matching ProxyMessageEventStream
class ProxyMessageEventStream extends EventStream<AssistantMessageEvent, AssistantMessage> {

View file

@ -1,22 +1,19 @@
import type {
Api,
AssistantMessage,
AssistantMessageEvent,
AssistantMessageEventStream,
Context,
ImageContent,
Message,
Model,
SimpleStreamOptions,
streamSimple,
TextContent,
Tool,
ToolResultMessage,
} from "@earendil-works/pi-ai";
} from "@earendil-works/pi-ai/base";
import type { Static, TSchema } from "typebox";
/**
* Stream function used by the agent loop. `Models.streamSimple` satisfies
* this shape.
* Stream function used by the agent loop.
*
* Contract:
* - Must not throw or return a rejected promise for request/model/runtime failures.
@ -25,10 +22,8 @@ import type { Static, TSchema } from "typebox";
* final AssistantMessage with stopReason "error" or "aborted" and errorMessage.
*/
export type StreamFn = (
model: Model<Api>,
context: Context,
options?: SimpleStreamOptions,
) => AssistantMessageEventStream | Promise<AssistantMessageEventStream>;
...args: Parameters<typeof streamSimple>
) => ReturnType<typeof streamSimple> | Promise<ReturnType<typeof streamSimple>>;
/**
* Configuration for how tool calls from a single assistant message are executed.
@ -283,10 +278,10 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
/**
* Thinking/reasoning level for models that support it.
* Note: "xhigh" and "max" are only supported by selected model families. Use model
* thinking-level metadata from @earendil-works/pi-ai to detect support for a concrete model.
* Note: "xhigh" is only supported by selected model families. Use model thinking-level metadata
* from @earendil-works/pi-ai to detect support for a concrete model.
*/
export type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
export type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
/**
* Extensible interface for custom app messages.
@ -352,8 +347,6 @@ export interface AgentToolResult<T> {
content: (TextContent | ImageContent)[];
/** Arbitrary structured details for logs or UI rendering. */
details: T;
/** Names of tools introduced by this result and available from this transcript point onward. */
addedToolNames?: string[];
/**
* Hint that the agent should stop after the current tool batch.
* Early termination only happens when every finalized tool result in the batch sets this to true.

View file

@ -307,79 +307,6 @@ describe("agentLoop with AgentMessage", () => {
}
});
it("should not execute tool calls from a length-truncated assistant message", async () => {
const toolSchema = Type.Object({ value: Type.String() });
const executed: string[] = [];
const tool: AgentTool<typeof toolSchema, { value: string }> = {
name: "echo",
label: "Echo",
description: "Echo tool",
parameters: toolSchema,
async execute(_toolCallId, params) {
executed.push(params.value);
return {
content: [{ type: "text", text: `echoed: ${params.value}` }],
details: { value: params.value },
};
},
};
const context: AgentContext = {
systemPrompt: "",
messages: [],
tools: [tool],
};
const config: AgentLoopConfig = {
model: createModel(),
convertToLlm: identityConverter,
};
let callIndex = 0;
const streamFn = () => {
const stream = new MockAssistantStream();
queueMicrotask(() => {
if (callIndex === 0) {
// Output hit the token limit mid tool call. The salvage parser can
// produce arguments that validate but are silently truncated, so
// nothing in this message may execute.
const message = createAssistantMessage(
[{ type: "toolCall", id: "tool-1", name: "echo", arguments: { value: "hel" } }],
"length",
);
stream.push({ type: "done", reason: "length", message });
} else {
const message = createAssistantMessage([{ type: "text", text: "done" }]);
stream.push({ type: "done", reason: "stop", message });
}
callIndex++;
});
return stream;
};
const events: AgentEvent[] = [];
const stream = agentLoop([createUserMessage("echo something")], context, config, undefined, streamFn);
for await (const event of stream) {
events.push(event);
}
// The tool must never execute with potentially truncated arguments.
expect(executed).toEqual([]);
const toolEnd = events.find((e) => e.type === "tool_execution_end");
expect(toolEnd).toBeDefined();
if (toolEnd?.type === "tool_execution_end") {
expect(toolEnd.isError).toBe(true);
const text = toolEnd.result.content.find((c: { type: string }) => c.type === "text");
expect(text && "text" in text ? text.text : "").toContain("output token limit");
}
// The loop continues so the model can re-issue the tool call.
expect(callIndex).toBe(2);
const messages = await stream.result();
expect(messages[messages.length - 1].role).toBe("assistant");
});
it("should execute mutated beforeToolCall args without revalidation", async () => {
const toolSchema = Type.Object({ value: Type.String() });
const executed: Array<string | number> = [];

View file

@ -1,4 +1,4 @@
import { type AssistantMessage, type AssistantMessageEvent, EventStream, getModel } from "@earendil-works/pi-ai/compat";
import { type AssistantMessage, type AssistantMessageEvent, EventStream, getModel } from "@earendil-works/pi-ai";
import { Type } from "typebox";
import { describe, expect, it } from "vitest";
import { Agent, type AgentEvent, type AgentTool, type AgentToolUpdateCallback } from "../src/index.ts";
@ -630,47 +630,6 @@ describe("Agent", () => {
expect(responseCount).toBe(2);
});
it("keeps legacy prepareNextTurn signal callback behavior", async () => {
const schema = Type.Object({});
const tool: AgentTool<typeof schema> = {
name: "noop",
label: "Noop",
description: "Noop tool",
parameters: schema,
execute: async () => ({ content: [{ type: "text", text: "ok" }], details: {} }),
};
let requestCount = 0;
let sawAbortSignal = false;
const agent = new Agent({
initialState: { tools: [tool] },
prepareNextTurn: async (signal) => {
sawAbortSignal = signal instanceof AbortSignal;
return undefined;
},
streamFn: () => {
requestCount++;
const stream = new MockAssistantStream();
queueMicrotask(() => {
if (requestCount === 1) {
const message = createAssistantToolUseMessage([
{ type: "toolCall", id: "tool-1", name: "noop", arguments: {} },
]);
stream.push({ type: "done", reason: "toolUse", message });
return;
}
const message = createAssistantMessage("done");
stream.push({ type: "done", reason: "stop", message });
});
return stream;
},
});
await agent.prompt("start");
expect(requestCount).toBe(2);
expect(sawAbortSignal).toBe(true);
});
it("forwards sessionId to streamFn options", async () => {
let receivedSessionId: string | undefined;
const agent = new Agent({

View file

@ -9,7 +9,7 @@ import {
registerFauxProvider,
type ToolResultMessage,
type UserMessage,
} from "@earendil-works/pi-ai/compat";
} from "@earendil-works/pi-ai";
import { afterEach, describe, expect, it } from "vitest";
import { Agent, type AgentEvent } from "../src/index.ts";
import { calculateTool } from "./utils/calculate.ts";

View file

@ -1,27 +1,18 @@
import {
createModels,
type FauxProviderHandle,
fauxAssistantMessage,
fauxProvider,
fauxToolCall,
type StreamOptions,
} from "@earendil-works/pi-ai";
import { describe, expect, it } from "vitest";
import { fauxAssistantMessage, fauxToolCall, registerFauxProvider, type StreamOptions } from "@earendil-works/pi-ai";
import { afterEach, describe, expect, it } from "vitest";
import { AgentHarness } from "../../src/harness/agent-harness.ts";
import { NodeExecutionEnv } from "../../src/harness/env/nodejs.ts";
import { InMemorySessionStorage } from "../../src/harness/session/memory-storage.ts";
import { Session } from "../../src/harness/session/session.ts";
import { calculateTool } from "../utils/calculate.ts";
/** Shared collection; each faux provider gets a unique id so coexisting fakes route correctly. */
const models = createModels();
let fauxCount = 0;
const registrations: Array<{ unregister(): void }> = [];
function newFaux(): FauxProviderHandle {
const faux = fauxProvider({ provider: `faux-${++fauxCount}` });
models.setProvider(faux.provider);
return faux;
}
afterEach(() => {
for (const registration of registrations.splice(0)) {
registration.unregister();
}
});
function createHarness(options: ConstructorParameters<typeof AgentHarness>[0]): AgentHarness {
return new AgentHarness(options);
@ -36,9 +27,10 @@ function captureOptions(options: StreamOptions | undefined): StreamOptions {
}
describe("AgentHarness stream configuration", () => {
it("snapshots stream options before provider request hooks", async () => {
it("snapshots stream options and merges auth headers before provider request hooks", async () => {
let capturedOptions: StreamOptions | undefined;
const registration = newFaux();
const registration = registerFauxProvider();
registrations.push(registration);
registration.setResponses([
(_context, options) => {
capturedOptions = options;
@ -48,7 +40,6 @@ describe("AgentHarness stream configuration", () => {
const session = new Session(new InMemorySessionStorage({ metadata: { id: "session-1", createdAt: "now" } }));
const harness = createHarness({
models,
env: new NodeExecutionEnv({ cwd: process.cwd() }),
session,
model: registration.getModel(),
@ -60,11 +51,12 @@ describe("AgentHarness stream configuration", () => {
metadata: { base: true },
cacheRetention: "none",
},
getApiKeyAndHeaders: async () => ({ apiKey: "secret", headers: { "x-auth": "auth" } }),
});
harness.on("before_provider_request", (event) => {
expect(event.sessionId).toBe("session-1");
expect(event.streamOptions.headers).toEqual({ "x-base": "base" });
expect(event.streamOptions.headers).toEqual({ "x-base": "base", "x-auth": "auth" });
return {
streamOptions: {
headers: { "x-hook": "hook" },
@ -76,19 +68,21 @@ describe("AgentHarness stream configuration", () => {
await harness.prompt("hello");
expect(capturedOptions).toMatchObject({
apiKey: "secret",
timeoutMs: 1000,
maxRetries: 2,
maxRetryDelayMs: 3000,
sessionId: "session-1",
cacheRetention: "none",
});
expect(capturedOptions?.headers).toEqual({ "x-base": "base", "x-hook": "hook" });
expect(capturedOptions?.headers).toEqual({ "x-base": "base", "x-auth": "auth", "x-hook": "hook" });
expect(capturedOptions?.metadata).toEqual({ base: true, hook: true });
});
it("chains provider request patches and supports deletion semantics", async () => {
let capturedOptions: StreamOptions | undefined;
const registration = newFaux();
const registration = registerFauxProvider();
registrations.push(registration);
registration.setResponses([
(_context, options) => {
capturedOptions = options;
@ -97,7 +91,6 @@ describe("AgentHarness stream configuration", () => {
]);
const harness = createHarness({
models,
env: new NodeExecutionEnv({ cwd: process.cwd() }),
session: new Session(new InMemorySessionStorage()),
model: registration.getModel(),
@ -140,7 +133,8 @@ describe("AgentHarness stream configuration", () => {
it("uses updated stream options for save-point snapshots without mutating the active request", async () => {
const capturedOptions: StreamOptions[] = [];
const registration = newFaux();
const registration = registerFauxProvider();
registrations.push(registration);
registration.setResponses([
(_context, options) => {
capturedOptions.push(captureOptions(options));
@ -155,7 +149,6 @@ describe("AgentHarness stream configuration", () => {
]);
const harness = createHarness({
models,
env: new NodeExecutionEnv({ cwd: process.cwd() }),
session: new Session(new InMemorySessionStorage()),
model: registration.getModel(),
@ -181,7 +174,8 @@ describe("AgentHarness stream configuration", () => {
it("chains provider payload hooks", async () => {
const seenPayloads: unknown[] = [];
let finalPayload: unknown;
const registration = newFaux();
const registration = registerFauxProvider();
registrations.push(registration);
registration.setResponses([
async (_context, options, _state, model) => {
finalPayload = await options?.onPayload?.({ steps: ["provider"] }, model);
@ -190,7 +184,6 @@ describe("AgentHarness stream configuration", () => {
]);
const harness = createHarness({
models,
env: new NodeExecutionEnv({ cwd: process.cwd() }),
session: new Session(new InMemorySessionStorage()),
model: registration.getModel(),

View file

@ -1,13 +1,5 @@
import {
createModels,
type FauxProviderHandle,
fauxAssistantMessage,
fauxProvider,
fauxToolCall,
type RegisterFauxProviderOptions,
} from "@earendil-works/pi-ai";
import { getModel } from "@earendil-works/pi-ai/compat";
import { describe, expect, it } from "vitest";
import { fauxAssistantMessage, fauxToolCall, getModel, registerFauxProvider } from "@earendil-works/pi-ai";
import { afterEach, describe, expect, it } from "vitest";
import { AgentHarness } from "../../src/harness/agent-harness.ts";
import { NodeExecutionEnv } from "../../src/harness/env/nodejs.ts";
import { InMemorySessionStorage } from "../../src/harness/session/memory-storage.ts";
@ -25,15 +17,7 @@ interface AppPromptTemplate extends PromptTemplate {
source: "project" | "user";
}
/** Shared collection; each faux provider gets a unique id so coexisting fakes route correctly. */
const models = createModels();
let fauxCount = 0;
function newFaux(options: RegisterFauxProviderOptions = {}): FauxProviderHandle {
const faux = fauxProvider({ provider: `faux-${++fauxCount}`, ...options });
models.setProvider(faux.provider);
return faux;
}
const registrations: Array<{ unregister(): void }> = [];
function textFromUserMessages(messages: Array<{ role: string; content: unknown }>): string[] {
return messages.flatMap((message) => {
@ -60,13 +44,18 @@ function getReasoning(options: unknown): unknown {
return options.reasoning;
}
afterEach(() => {
for (const registration of registrations.splice(0)) {
registration.unregister();
}
});
describe("AgentHarness", () => {
it("constructs directly and exposes queue modes", () => {
const session = new Session(new InMemorySessionStorage());
const env = new NodeExecutionEnv({ cwd: process.cwd() });
const initialModel = getModel("anthropic", "claude-sonnet-4-5");
const harness = new AgentHarness({
models,
env,
session,
model: initialModel,
@ -87,7 +76,8 @@ describe("AgentHarness", () => {
});
it("drains one queued steering message at a time and emits queue updates", async () => {
const registration = newFaux();
const registration = registerFauxProvider();
registrations.push(registration);
const userCounts: number[] = [];
registration.setResponses([
(context) => {
@ -104,7 +94,6 @@ describe("AgentHarness", () => {
},
]);
const harness = new AgentHarness({
models,
env: new NodeExecutionEnv({ cwd: process.cwd() }),
session: new Session(new InMemorySessionStorage()),
model: registration.getModel(),
@ -130,7 +119,8 @@ describe("AgentHarness", () => {
});
it("appends before_agent_start messages and persists them", async () => {
const registration = newFaux();
const registration = registerFauxProvider();
registrations.push(registration);
let requestText: string[] = [];
registration.setResponses([
(context) => {
@ -140,7 +130,6 @@ describe("AgentHarness", () => {
]);
const session = new Session(new InMemorySessionStorage());
const harness = new AgentHarness({
models,
env: new NodeExecutionEnv({ cwd: process.cwd() }),
session,
model: registration.getModel(),
@ -162,7 +151,8 @@ describe("AgentHarness", () => {
});
it("abort clears steer and follow-up queues but preserves next-turn messages", async () => {
const registration = newFaux();
const registration = registerFauxProvider();
registrations.push(registration);
let releaseFirstResponse: (() => void) | undefined;
let abortedSignal: AbortSignal | undefined;
const firstResponseReleased = new Promise<void>((resolve) => {
@ -181,7 +171,6 @@ describe("AgentHarness", () => {
},
]);
const harness = new AgentHarness({
models,
env: new NodeExecutionEnv({ cwd: process.cwd() }),
session: new Session(new InMemorySessionStorage()),
model: registration.getModel(),
@ -217,7 +206,8 @@ describe("AgentHarness", () => {
});
it("drains follow-up messages one at a time after the agent would otherwise stop", async () => {
const registration = newFaux();
const registration = registerFauxProvider();
registrations.push(registration);
const userCounts: number[] = [];
registration.setResponses([
(context) => {
@ -234,7 +224,6 @@ describe("AgentHarness", () => {
},
]);
const harness = new AgentHarness({
models,
env: new NodeExecutionEnv({ cwd: process.cwd() }),
session: new Session(new InMemorySessionStorage()),
model: registration.getModel(),
@ -260,11 +249,11 @@ describe("AgentHarness", () => {
});
it("settles thrown hook failures with persisted assistant error messages", async () => {
const registration = newFaux();
const registration = registerFauxProvider();
registrations.push(registration);
registration.setResponses([() => fauxAssistantMessage("should not be used")]);
const session = new Session(new InMemorySessionStorage());
const harness = new AgentHarness({
models,
env: new NodeExecutionEnv({ cwd: process.cwd() }),
session,
model: registration.getModel(),
@ -291,12 +280,13 @@ describe("AgentHarness", () => {
});
it("refreshes model, thinking level, resources, system prompt, and active tools at save points", async () => {
const registration = newFaux({
const registration = registerFauxProvider({
models: [
{ id: "first", reasoning: true },
{ id: "second", reasoning: true },
],
});
registrations.push(registration);
const secondModel = registration.getModel("second");
if (!secondModel) throw new Error("missing second faux model");
const captured: Array<{ modelId: string; reasoning: unknown; systemPrompt: string; tools: string[] }> = [];
@ -323,7 +313,6 @@ describe("AgentHarness", () => {
},
]);
const harness = new AgentHarness<Skill, PromptTemplate, AgentTool>({
models,
env: new NodeExecutionEnv({ cwd: process.cwd() }),
session: new Session(new InMemorySessionStorage()),
model: registration.getModel(),
@ -356,11 +345,11 @@ describe("AgentHarness", () => {
});
it("orders pending listener session writes after agent-emitted messages", async () => {
const registration = newFaux();
const registration = registerFauxProvider();
registrations.push(registration);
registration.setResponses([() => fauxAssistantMessage("ok")]);
const session = new Session(new InMemorySessionStorage());
const harness = new AgentHarness({
models,
env: new NodeExecutionEnv({ cwd: process.cwd() }),
session,
model: registration.getModel(),
@ -387,11 +376,11 @@ describe("AgentHarness", () => {
});
it("waitForIdle waits for external run settlement and awaited listeners", async () => {
const registration = newFaux();
const registration = registerFauxProvider();
registrations.push(registration);
registration.setResponses([() => fauxAssistantMessage("ok")]);
const barrier = deferred();
const harness = new AgentHarness({
models,
env: new NodeExecutionEnv({ cwd: process.cwd() }),
session: new Session(new InMemorySessionStorage()),
model: registration.getModel(),
@ -419,7 +408,8 @@ describe("AgentHarness", () => {
});
it("runs tool_call and tool_result hooks through the direct loop", async () => {
const registration = newFaux();
const registration = registerFauxProvider();
registrations.push(registration);
registration.setResponses([
() =>
fauxAssistantMessage(fauxToolCall("calculate", { expression: "2 + 2" }, { id: "call-1" }), {
@ -428,7 +418,6 @@ describe("AgentHarness", () => {
]);
const session = new Session(new InMemorySessionStorage());
const harness = new AgentHarness({
models,
env: new NodeExecutionEnv({ cwd: process.cwd() }),
session,
model: registration.getModel(),
@ -473,7 +462,6 @@ describe("AgentHarness", () => {
const inspectTool: AppTool = { ...calculateTool, name: "inspect", source: "builtin" };
const searchTool: AppTool = { ...calculateTool, name: "search", source: "extension" };
const harness = new AgentHarness<AppSkill, AppPromptTemplate, AppTool>({
models,
env,
session,
model,
@ -542,12 +530,11 @@ describe("AgentHarness", () => {
const env = new NodeExecutionEnv({ cwd: process.cwd() });
const model = getModel("anthropic", "claude-sonnet-4-5");
expect(
() => new AgentHarness({ env, session, models, model, tools: [calculateTool], activeToolNames: ["missing"] }),
() => new AgentHarness({ env, session, model, tools: [calculateTool], activeToolNames: ["missing"] }),
).toThrow(/Unknown tool/);
expect(
() =>
new AgentHarness({
models,
env,
session,
model,
@ -558,7 +545,6 @@ describe("AgentHarness", () => {
expect(
() =>
new AgentHarness({
models,
env,
session,
model,
@ -572,7 +558,7 @@ describe("AgentHarness", () => {
const session = new Session(new InMemorySessionStorage());
const env = new NodeExecutionEnv({ cwd: process.cwd() });
const model = getModel("anthropic", "claude-sonnet-4-5");
const harness = new AgentHarness<AppSkill, AppPromptTemplate, AgentTool>({ env, session, models, model });
const harness = new AgentHarness<AppSkill, AppPromptTemplate, AgentTool>({ env, session, model });
const skill: AppSkill = {
name: "inspect",
description: "Inspect things",

View file

@ -1,14 +1,13 @@
import {
type AssistantMessage,
createModels,
type FauxProviderHandle,
type FauxProviderRegistration,
fauxAssistantMessage,
fauxProvider,
type Message,
type Model,
registerFauxProvider,
type Usage,
} from "@earendil-works/pi-ai";
import { beforeEach, describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
type CompactionPreparation,
calculateContextTokens,
@ -122,13 +121,11 @@ function createModelChangeEntry(provider: string, modelId: string, parentId: str
};
}
/** Shared collection; each faux provider gets a unique id so coexisting fakes route correctly. */
const models = createModels();
let fauxCount = 0;
function createFauxModel(reasoning: boolean, maxTokens = 8192): { faux: FauxProviderHandle; model: Model<string> } {
const faux = fauxProvider({
provider: `faux-${++fauxCount}`,
function createFauxModel(
reasoning: boolean,
maxTokens = 8192,
): { faux: FauxProviderRegistration; model: Model<string> } {
const faux = registerFauxProvider({
models: [
{
id: reasoning ? "reasoning-model" : "non-reasoning-model",
@ -138,10 +135,18 @@ function createFauxModel(reasoning: boolean, maxTokens = 8192): { faux: FauxProv
},
],
});
models.setProvider(faux.provider);
fauxRegistrations.push(faux);
return { faux, model: faux.getModel() };
}
const fauxRegistrations: FauxProviderRegistration[] = [];
afterEach(() => {
while (fauxRegistrations.length > 0) {
fauxRegistrations.pop()?.unregister();
}
});
describe("harness compaction", () => {
beforeEach(() => {
nextId = 0;
@ -301,28 +306,11 @@ describe("harness compaction", () => {
createMessageEntry({ ...assistant, stopReason: "error" }),
]),
).toBeUndefined();
expect(
getLastAssistantUsage([
createMessageEntry(createUserMessage("user")),
createMessageEntry(assistant),
createMessageEntry(createAssistantMessage("partial", createMockUsage(0, 0))),
]),
).toBe(usage);
expect(estimateContextTokens([createUserMessage("no usage")]).lastUsageIndex).toBeNull();
expect(estimateContextTokens([assistant, createUserMessage("tail")])).toMatchObject({
usageTokens: 20,
lastUsageIndex: 0,
});
const estimate = estimateContextTokens([
createUserMessage("Hello"),
assistant,
createUserMessage("continue"),
createAssistantMessage("Partial thinking", createMockUsage(0, 0)),
]);
expect(estimate.usageTokens).toBe(20);
expect(estimate.lastUsageIndex).toBe(1);
expect(estimate.trailingTokens).toBeGreaterThan(0);
expect(estimate.tokens).toBe(20 + estimate.trailingTokens);
});
it("builds session context with a compaction entry", () => {
@ -457,9 +445,19 @@ describe("harness compaction", () => {
},
]);
getOrThrow(
await generateSummary(messages, models, reasoningModel, 2000, undefined, undefined, undefined, "medium"),
await generateSummary(
messages,
reasoningModel,
2000,
"test-key",
undefined,
undefined,
undefined,
undefined,
"medium",
),
);
expect(seenOptions[0]).toMatchObject({ reasoning: "medium" });
expect(seenOptions[0]).toMatchObject({ reasoning: "medium", apiKey: "test-key" });
const { faux: fauxOff, model: offModel } = createFauxModel(true);
fauxOff.setResponses([
@ -468,7 +466,9 @@ describe("harness compaction", () => {
return fauxAssistantMessage("## Goal\nTest summary");
},
]);
getOrThrow(await generateSummary(messages, models, offModel, 2000, undefined, undefined, undefined, "off"));
getOrThrow(
await generateSummary(messages, offModel, 2000, "test-key", undefined, undefined, undefined, undefined, "off"),
);
expect(seenOptions[1]).not.toHaveProperty("reasoning");
const { faux: fauxNonReasoning, model: nonReasoningModel } = createFauxModel(false);
@ -479,7 +479,17 @@ describe("harness compaction", () => {
},
]);
getOrThrow(
await generateSummary(messages, models, nonReasoningModel, 2000, undefined, undefined, undefined, "medium"),
await generateSummary(
messages,
nonReasoningModel,
2000,
"test-key",
undefined,
undefined,
undefined,
undefined,
"medium",
),
);
expect(seenOptions[2]).not.toHaveProperty("reasoning");
});
@ -498,7 +508,16 @@ describe("harness compaction", () => {
]);
const summary = getOrThrow(
await generateSummary(messages, models, model, 2000, undefined, "focus", "old summary"),
await generateSummary(
messages,
model,
2000,
"test-key",
{ "x-test": "yes" },
undefined,
"focus",
"old summary",
),
);
expect(summary).toContain("Test summary");
@ -510,7 +529,7 @@ describe("harness compaction", () => {
const messages: AgentMessage[] = [createUserMessage("Summarize this.")];
const { faux: errorFaux, model: errorModel } = createFauxModel(false);
errorFaux.setResponses([fauxAssistantMessage("", { stopReason: "error", errorMessage: "boom" })]);
const errorResult = await generateSummary(messages, models, errorModel, 2000);
const errorResult = await generateSummary(messages, errorModel, 2000, "test-key");
expect(errorResult).toMatchObject({
ok: false,
error: { code: "summarization_failed", message: "Summarization failed: boom" },
@ -518,7 +537,7 @@ describe("harness compaction", () => {
const { faux: abortedFaux, model: abortedModel } = createFauxModel(false);
abortedFaux.setResponses([fauxAssistantMessage("", { stopReason: "aborted", errorMessage: "stopped" })]);
const abortedResult = await generateSummary(messages, models, abortedModel, 2000);
const abortedResult = await generateSummary(messages, abortedModel, 2000, "test-key");
expect(abortedResult).toMatchObject({ ok: false, error: { code: "aborted", message: "stopped" } });
});
@ -546,7 +565,7 @@ describe("harness compaction", () => {
settings: { enabled: true, reserveTokens: 500000, keepRecentTokens: 20000 },
};
getOrThrow(await compact(preparation, models, model));
getOrThrow(await compact(preparation, model, "test-key"));
expect(seenOptions.map((options) => options?.maxTokens)).toEqual([128000, 128000]);
});
@ -564,7 +583,7 @@ describe("harness compaction", () => {
};
const { faux: historyFaux, model: historyModel } = createFauxModel(false);
historyFaux.setResponses([fauxAssistantMessage("", { stopReason: "error", errorMessage: "history failed" })]);
expect(await compact(preparation, models, historyModel)).toMatchObject({
expect(await compact(preparation, historyModel, "test-key")).toMatchObject({
ok: false,
error: { code: "summarization_failed", message: "Summarization failed: history failed" },
});
@ -572,8 +591,8 @@ describe("harness compaction", () => {
const { model: invalidModel } = createFauxModel(false);
const invalidResult = await compact(
{ ...preparation, messagesToSummarize: [], firstKeptEntryId: "" },
models,
invalidModel,
"test-key",
);
expect(invalidResult).toMatchObject({ ok: false, error: { code: "invalid_session" } });
});
@ -598,7 +617,7 @@ describe("harness compaction", () => {
settings: { enabled: true, reserveTokens: 2000, keepRecentTokens: 20 },
};
getOrThrow(await compact(preparation, models, model, undefined, undefined, "high"));
getOrThrow(await compact(preparation, model, "test-key", undefined, undefined, undefined, "high"));
expect(seenOptions[0]).toMatchObject({ reasoning: "high" });
});
@ -617,14 +636,14 @@ describe("harness compaction", () => {
const { faux, model } = createFauxModel(false);
faux.setResponses([fauxAssistantMessage("", { stopReason: "error", errorMessage: "prefix failed" })]);
expect(await compact(preparation, models, model)).toMatchObject({
expect(await compact(preparation, model, "test-key")).toMatchObject({
ok: false,
error: { code: "summarization_failed", message: "Turn prefix summarization failed: prefix failed" },
});
const { faux: abortedFaux, model: abortedModel } = createFauxModel(false);
abortedFaux.setResponses([fauxAssistantMessage("", { stopReason: "aborted", errorMessage: "prefix stopped" })]);
expect(await compact(preparation, models, abortedModel)).toMatchObject({
expect(await compact(preparation, abortedModel, "test-key")).toMatchObject({
ok: false,
error: { code: "aborted", message: "prefix stopped" },
});
@ -643,7 +662,7 @@ describe("harness compaction", () => {
expect(preparation).toBeDefined();
const { faux, model } = createFauxModel(false);
faux.setResponses([fauxAssistantMessage("## Goal\nTest summary")]);
const result = getOrThrow(await compact(preparation!, models, model));
const result = getOrThrow(await compact(preparation!, model, "test-key"));
expect(result.summary.length).toBeGreaterThan(0);
expect(result.firstKeptEntryId).toBeTruthy();
expect(result.details).toBeDefined();

View file

@ -65,28 +65,4 @@ describe("JsonlSessionRepo", () => {
expect(existsSync(sourceMetadata.path)).toBe(false);
await expect(repo.open(sourceMetadata)).rejects.toThrow("Session not found");
});
it("persists header metadata through create, list, and fork", async () => {
const root = createTempDir();
const env = new NodeExecutionEnv({ cwd: root });
const repo = new JsonlSessionRepo({ fs: env, sessionsRoot: root });
const source = await repo.create({
cwd: "/tmp/source",
id: "source-session",
metadata: { profile: "reviewer" },
});
const sourceMetadata = await source.getMetadata();
expect(sourceMetadata.metadata).toEqual({ profile: "reviewer" });
expect((await repo.list({ cwd: "/tmp/source" })).map((listed) => listed.metadata)).toEqual([
{ profile: "reviewer" },
]);
const fork = await repo.fork(sourceMetadata, { cwd: "/tmp/target", id: "fork-session" });
expect((await fork.getMetadata()).metadata).toEqual({ profile: "reviewer" });
const overridden = await repo.fork(sourceMetadata, {
cwd: "/tmp/target",
id: "overridden-session",
metadata: { profile: "writer" },
});
expect((await overridden.getMetadata()).metadata).toEqual({ profile: "writer" });
});
});

View file

@ -4,18 +4,10 @@ import { describe, expect, it } from "vitest";
import { NodeExecutionEnv } from "../../src/harness/env/nodejs.ts";
import { JsonlSessionStorage } from "../../src/harness/session/jsonl-storage.ts";
import { InMemorySessionStorage } from "../../src/harness/session/memory-storage.ts";
import { type ContextEntryTransform, Session } from "../../src/harness/session/session.ts";
import { Session } from "../../src/harness/session/session.ts";
import type { SessionStorage } from "../../src/harness/types.ts";
import { createAssistantMessage, createTempDir, createUserMessage, getLatestTempDir } from "./session-test-utils.ts";
function getTextData(data: unknown): string {
if (typeof data !== "object" || data === null || !("text" in data)) {
return "";
}
const value = (data as { text?: unknown }).text;
return typeof value === "string" ? value : "";
}
async function runSessionSuite(
name: string,
createStorage: () => SessionStorage | Promise<SessionStorage>,
@ -94,51 +86,6 @@ async function runSessionSuite(
expect(context.messages[1]?.role).toBe("custom");
});
it("keeps custom entries in context entries but omits them from messages by default", async () => {
const session = new Session(await createStorage());
await session.appendMessage(createUserMessage("one"));
await session.appendCustomEntry("chat_message", { text: "hello" });
const contextEntries = await session.buildContextEntries();
const context = await session.buildContext();
expect(contextEntries.map((entry) => entry.type)).toEqual(["message", "custom"]);
expect(context.messages).toHaveLength(1);
});
it("projects custom entries with configured custom-entry projectors", async () => {
const session = new Session(await createStorage(), {
entryProjectors: {
chat_message: (entry) => [createUserMessage(`chat: ${getTextData(entry.data)}`)],
},
});
await session.appendMessage(createUserMessage("one"));
await session.appendCustomEntry("chat_message", { text: "hello" });
const context = await session.buildContext();
expect(context.messages.map((message) => message.role)).toEqual(["user", "user"]);
expect(context.messages[1]).toMatchObject({ content: [{ type: "text", text: "chat: hello" }] });
});
it("applies context entry transforms after default compaction selection", async () => {
let observedFirstEntryType: string | undefined;
const dropCompaction: ContextEntryTransform = (entries) => {
observedFirstEntryType = entries[0]?.type;
return entries.filter((entry) => entry.type !== "compaction");
};
const session = new Session(await createStorage(), { entryTransforms: [dropCompaction] });
await session.appendMessage(createUserMessage("one"));
const kept = await session.appendMessage(createUserMessage("two"));
await session.appendCompaction("summary", kept, 1234);
await session.appendMessage(createUserMessage("three"));
const context = await session.buildContext();
expect(observedFirstEntryType).toBe("compaction");
expect(context.messages.map((message) => message.role)).toEqual(["user", "user"]);
});
it("normalizes session names", async () => {
const session = new Session(await createStorage());
await session.appendSessionName(" hello\nworld\r\nagain ");
expect(await session.getSessionName()).toBe("hello world again");
});
it("supports labels and session info entries without affecting context", async () => {
const session = new Session(await createStorage());
const user1 = await session.appendMessage(createUserMessage("one"));

View file

@ -186,48 +186,6 @@ describe("JsonlSessionStorage", () => {
expect(await loadJsonlSessionMetadata(env, filePath)).toEqual(metadata);
});
it("round-trips custom header metadata", async () => {
const dir = createTempDir();
const env = new NodeExecutionEnv({ cwd: dir });
const filePath = join(dir, "session.jsonl");
const storage = await JsonlSessionStorage.create(env, filePath, {
cwd: dir,
sessionId: "session-1",
metadata: { profile: "reviewer" },
});
expect((await storage.getMetadata()).metadata).toEqual({ profile: "reviewer" });
const loaded = await JsonlSessionStorage.open(env, filePath);
expect((await loaded.getMetadata()).metadata).toEqual({ profile: "reviewer" });
expect((await loadJsonlSessionMetadata(env, filePath)).metadata).toEqual({ profile: "reviewer" });
});
it("omits header metadata when not provided", async () => {
const dir = createTempDir();
const env = new NodeExecutionEnv({ cwd: dir });
const filePath = join(dir, "session.jsonl");
await JsonlSessionStorage.create(env, filePath, { cwd: dir, sessionId: "session-1" });
expect(JSON.parse(readFileSync(filePath, "utf8").trim())).not.toHaveProperty("metadata");
expect((await loadJsonlSessionMetadata(env, filePath)).metadata).toBeUndefined();
});
it("throws for non-object header metadata", async () => {
const dir = createTempDir();
const env = new NodeExecutionEnv({ cwd: dir });
const filePath = join(dir, "session.jsonl");
const header = {
type: "session",
version: 3,
id: "session-1",
timestamp: "2026-01-01T00:00:00.000Z",
cwd: dir,
metadata: "profile",
};
writeFileSync(filePath, `${JSON.stringify(header)}\n`);
await expect(JsonlSessionStorage.open(env, filePath)).rejects.toThrow(
"session header metadata must be an object",
);
});
it("loads existing entries and reconstructs leaf", async () => {
const dir = createTempDir();
const env = new NodeExecutionEnv({ cwd: dir });

View file

@ -1,8 +1,6 @@
import { homedir } from "node:os";
import { join } from "node:path";
import { createModels } from "@earendil-works/pi-ai";
import { cloudflareAIGatewayProvider } from "@earendil-works/pi-ai/providers/cloudflare-ai-gateway";
import { openaiProvider } from "@earendil-works/pi-ai/providers/openai";
import { getModel } from "@earendil-works/pi-ai";
import { NodeExecutionEnv } from "../../src/harness/env/nodejs.ts";
import { InMemorySessionStorage } from "../../src/harness/session/memory-storage.ts";
import {
@ -37,22 +35,11 @@ const { promptTemplates: sourcedPromptTemplates } = await loadSourcedPromptTempl
(promptTemplate, source) => ({ ...promptTemplate, source }),
);
const models = createModels();
models.setProvider(openaiProvider());
models.setProvider(cloudflareAIGatewayProvider());
const model = models.getModel("openai", "gpt-5.5");
// const model = models.getModel("cloudflare-ai-gateway", "claude-haiku-4-5");
if (!model) {
console.log("Model not found");
process.exit(-1);
}
const session = new Session(new InMemorySessionStorage());
const agent = new AgentHarness({
env,
session,
models,
model,
model: getModel("openai", "gpt-5.5"),
thinkingLevel: "low",
systemPrompt: ({ env, resources }) =>
[

View file

@ -1,21 +1,15 @@
import { fileURLToPath } from "node:url";
import { defineConfig } from "vitest/config";
const aiSrcIndex = fileURLToPath(new URL("../ai/src/index.ts", import.meta.url));
const aiSrcCompat = fileURLToPath(new URL("../ai/src/compat.ts", import.meta.url));
export default defineConfig({
resolve: {
alias: {
"@earendil-works/pi-ai/base": new URL("../ai/src/base.ts", import.meta.url).pathname,
"@earendil-works/pi-ai": new URL("../ai/src/index.ts", import.meta.url).pathname,
},
},
test: {
globals: true,
environment: "node",
testTimeout: 30000, // 30 seconds for API calls
reporters: process.env.GITHUB_ACTIONS ? ["dot", "github-actions"] : ["dot"],
silent: "passed-only",
},
resolve: {
alias: [
{ find: /^@earendil-works\/pi-ai$/, replacement: aiSrcIndex },
{ find: /^@earendil-works\/pi-ai\/compat$/, replacement: aiSrcCompat },
],
},
});

View file

@ -1,10 +1,12 @@
import { fileURLToPath } from "node:url";
import { defineConfig } from "vitest/config";
const aiSrcIndex = fileURLToPath(new URL("../ai/src/index.ts", import.meta.url));
const aiSrcCompat = fileURLToPath(new URL("../ai/src/compat.ts", import.meta.url));
export default defineConfig({
resolve: {
alias: {
"@earendil-works/pi-ai/base": new URL("../ai/src/base.ts", import.meta.url).pathname,
"@earendil-works/pi-ai": new URL("../ai/src/index.ts", import.meta.url).pathname,
},
},
test: {
globals: true,
environment: "node",
@ -19,10 +21,4 @@ export default defineConfig({
reportsDirectory: "coverage/harness",
},
},
resolve: {
alias: [
{ find: /^@earendil-works\/pi-ai$/, replacement: aiSrcIndex },
{ find: /^@earendil-works\/pi-ai\/compat$/, replacement: aiSrcCompat },
],
},
});

View file

@ -1,257 +1,5 @@
# Changelog
## [Unreleased]
### Fixed
- Fixed GitHub Copilot long-context pricing tiers in generated model metadata ([#6668](https://github.com/earendil-works/pi/issues/6668)).
- Fixed Kimi Coding subscription models to report API-equivalent implied costs when models.dev reports zero pricing.
- Fixed OpenAI Responses early stream endings to be classified as retryable provider errors ([#6727](https://github.com/earendil-works/pi/issues/6727)).
## [0.80.10] - 2026-07-16
### Fixed
- Fixed Kimi Coding requests to use Anthropic adaptive thinking effort without token budgets, and enabled empty thinking signatures for K3 and `kimi-for-coding`.
- Fixed Kimi K3 pricing metadata for Moonshot AI and Moonshot AI China.
- Fixed Kimi Coding K3 thinking-level metadata to expose only the supported `max` level ([#6737](https://github.com/earendil-works/pi/issues/6737)).
- Fixed catalog generation restoring xAI models removed in 0.80.9 ([#6736](https://github.com/earendil-works/pi/issues/6736)).
## [0.80.9] - 2026-07-16
### Added
- Added Kimi K3 support for Kimi Coding, Moonshot AI, Moonshot AI China, OpenRouter, and Vercel AI Gateway.
- Added Kimi deferred tool loading to OpenAI-compatible Chat Completions through `compat.deferredToolsMode`.
### Changed
- Changed xAI device OAuth to open a prefilled authorization link and added provider-specific OAuth login labels ([#6734](https://github.com/earendil-works/pi-mono/pull/6734) by [@Jaaneek](https://github.com/Jaaneek)).
### Fixed
- Fixed Kimi K3 output limits for Vercel AI Gateway and OpenRouter models.
### Removed
- Removed Grok 3, Grok 3 Fast, Grok 4.20 variants, and Grok Code Fast 1 from the built-in xAI model catalog ([#6734](https://github.com/earendil-works/pi-mono/pull/6734) by [@Jaaneek](https://github.com/Jaaneek)).
## [0.80.8] - 2026-07-16
### Breaking Changes
- Changed runtime authentication to provider-scoped `Models.checkAuth()`, `getAuth()`, `login()`, and `logout()` APIs. `checkAuth()` now returns `AuthCheck | undefined`, and API-key auth resolvers no longer receive a model.
- Removed the legacy built-in OAuth provider objects, global OAuth registry APIs, and public low-level built-in login/refresh functions. Use canonical `Provider.auth.oauth` methods instead; the `oauth` subpath now retains only extension compatibility types.
- Renamed the canonical login interaction interface from `AuthLoginCallbacks` to `AuthInteraction`; it exposes the provider-neutral `prompt()`/`notify()` protocol used by API-key and OAuth flows.
- Changed the `Models` request contract: `getAuth(model)` now includes model headers, while `getAuth(providerId)` remains provider-scoped, and Models stream options may include `transformHeaders`. Custom `Models` implementations must execute the transform after merging auth/model and explicit headers, then remove it before provider dispatch.
- Changed dynamic model refresh to `Models.refresh(options)`, which refreshes every configured dynamic provider and returns per-provider errors/cancellation state. `Provider.refreshModels(context)` now receives the effective credential, scoped model storage, network policy, and abort signal.
### Added
- Added provider-owned authentication and availability resolution to `Models`, including stored OAuth refresh and interactive login support through `CredentialStore`.
- Added async non-secret credential enumeration through `CredentialStore.list()` and credential-aware `Provider.filterModels()` availability policy.
- Added neutral auth-flow information/link events and provider-owned Amazon Bedrock and Google Vertex AI credential selection flows.
- Added `ModelsStore` with an in-memory default for restoring and persisting dynamic provider catalogs.
- Added the dynamic Radius `pi-messages` gateway provider with OAuth and credential-specific catalog refresh.
- Added `Models.refresh({ force: true })` to let providers bypass freshness checks for explicit refreshes.
- Added xAI device-code OAuth login and routed Grok 4.5 through OpenAI Responses, with low, medium, and high thinking support ([#6651](https://github.com/earendil-works/pi-mono/pull/6651) by [@Jaaneek](https://github.com/Jaaneek)).
### Changed
- Changed `Models.getAuth(model)` to include model headers and added a Models-only `transformHeaders` stream option that runs after auth and explicit header assembly but is not forwarded to providers.
### Fixed
- Fixed Cloudflare Workers AI and AI Gateway streams to materialize account and gateway endpoint placeholders after auth resolution, including compat streaming with custom model objects.
- Fixed lazy provider streams to preserve their final assistant message when forwarding an inner stream.
- Fixed OpenAI Codex session IDs longer than 64 characters to meet the API limit ([#6630](https://github.com/earendil-works/pi-mono/issues/6630)).
## [0.80.7] - 2026-07-14
### Breaking Changes
- Removed the `OpenAIResponsesCompat.sendSessionIdHeader` flag. Session-affinity behavior is now controlled by `compat.sessionAffinityFormat` (`"openai"`, `"openai-nosession"`, or `"openrouter"`). Replace `sendSessionIdHeader: false` with `sessionAffinityFormat: "openai-nosession"` ([#6496](https://github.com/earendil-works/pi-mono/pull/6496) by [@petrroll](https://github.com/petrroll)).
### Added
- Added cache-friendly dynamic tool loading. `ToolResultMessage.addedToolNames` marks where tools from `Context.tools` became available; Anthropic and OpenAI Responses use native deferred loading so late tools stay out of the cached prefix, while other providers continue using `Context.tools` normally ([#6474](https://github.com/earendil-works/pi-mono/pull/6474)).
- Added native `xhigh` and `max` thinking levels for Claude Fable 5 across all generated provider catalogs ([#6490](https://github.com/earendil-works/pi-mono/pull/6490) by [@davidbrai](https://github.com/davidbrai)).
- Added `toolChoice` support to OpenAI and Codex Responses, including required and named tool selection ([#6588](https://github.com/earendil-works/pi-mono/pull/6588) by [@xl0](https://github.com/xl0)).
### Fixed
- Fixed OpenRouter model context windows to use the top provider's actual context length ([#6481](https://github.com/earendil-works/pi-mono/pull/6481) by [@davidbrai](https://github.com/davidbrai)).
- Fixed the GitHub Copilot `mai-code-1-flash-picker` model to route through the `/responses` endpoint ([#6544](https://github.com/earendil-works/pi-mono/pull/6544) by [@petrroll](https://github.com/petrroll)).
- Fixed Amazon Bedrock requests to use the generic `apiKey` stream option as a Bedrock bearer token.
- Fixed OpenRouter OpenAI-compatible session IDs to use the `x-session-id` header instead of OpenAI-specific session-affinity fields ([#6496](https://github.com/earendil-works/pi-mono/pull/6496) by [@petrroll](https://github.com/petrroll)).
- Fixed Amazon Bedrock ambient AWS credentials to keep using SigV4 authentication, including for custom model IDs ([#6532](https://github.com/earendil-works/pi-mono/pull/6532) by [@ribelo](https://github.com/ribelo)).
- Fixed Cloudflare Workers AI and AI Gateway authentication to use ambient account and gateway IDs when stored credentials contain only an API key ([#6292](https://github.com/earendil-works/pi-mono/pull/6292) by [@markphelps](https://github.com/markphelps)).
- Fixed Amazon Bedrock errors to report unhandled provider stop reasons instead of only `An unknown error occurred` ([#6598](https://github.com/earendil-works/pi-mono/pull/6598) by [@davidbrai](https://github.com/davidbrai)).
- Fixed Azure OpenAI Responses reasoning replay when `encrypted_content` appears only in the terminal response event ([#6608](https://github.com/earendil-works/pi-mono/pull/6608) by [@davidbrai](https://github.com/davidbrai)).
- Fixed Anthropic-compatible proxies that omit `usage` from `message_delta` events ([#6611](https://github.com/earendil-works/pi-mono/pull/6611) by [@davidbrai](https://github.com/davidbrai)).
- Fixed OpenCode OpenAI Responses models to omit the unsupported `session-id` header while preserving other cache-affinity data ([#6645](https://github.com/earendil-works/pi-mono/pull/6645) by [@davidbrai](https://github.com/davidbrai)).
## [0.80.6] - 2026-07-09
### Added
- Added a separate opt-in `max` thinking level, including native `xhigh` and `max` support for GPT-5.6 and Anthropic adaptive-thinking effort metadata matching Anthropic's documentation: `max` on all adaptive Claude models, native `xhigh` on Opus 4.7/4.8, Sonnet 5, and Fable 5 only.
- Added request-wide input-token pricing tiers to model cost metadata and usage cost calculation.
### Fixed
- Fixed post-compaction output-token budgeting to ignore stale assistant usage from before the compaction boundary ([#6464](https://github.com/earendil-works/pi/issues/6464)).
- Fixed GPT-5.4 and GPT-5.5 long-context cost accounting while retaining the intentional 272K default context limit for models that require an explicit override.
- Fixed GPT-5.6 metadata to keep direct OpenAI requests in the 272K short-context tier while exposing the Codex backend's 372K context window with long-context pricing, and removed the nonexistent bare `gpt-5.6` alias from the OpenAI and Azure OpenAI Responses catalogs.
- Fixed Anthropic message conversion to preserve thinking blocks with empty thinking text but a valid signature instead of dropping them, avoiding thinking-block errors on newer Claude models ([#6457](https://github.com/earendil-works/pi/pull/6457) by [@davidbrai](https://github.com/davidbrai)).
## [0.80.5] - 2026-07-09
## [0.80.4] - 2026-07-09
### Fixed
- Fixed retry classification for gRPC `ResourceExhausted` provider errors such as NVIDIA NIM transient exhaustion responses ([#6449](https://github.com/earendil-works/pi/pull/6449) by [@davidbrai](https://github.com/davidbrai)).
- Fixed low-level message transformation to normalize `null` message content before provider conversion, avoiding crashes on lax imported transcripts ([#6343](https://github.com/earendil-works/pi/pull/6343)).
- Fixed Xiaomi Token Plan model metadata to follow the upstream models.dev token-plan catalogs, removing unsupported `mimo-v2-omni` variants ([#6204](https://github.com/earendil-works/pi/issues/6204)).
- Fixed GitHub Copilot device-code login polling to wait before the first token poll, avoiding incorrect device-code failures for some users after browser authorization ([#6187](https://github.com/earendil-works/pi/issues/6187)).
- Fixed OAuth device-code polling to honor the server-provided `slow_down` interval instead of only applying the RFC 8628 5-second increment, so GitHub Copilot login recovers instead of appearing to hang when polls arrive early (e.g. WSL/VM clock drift) ([#6187](https://github.com/earendil-works/pi/issues/6187)).
- Fixed OpenAI Codex user-agent construction to synchronously load Node OS metadata, avoiding a startup race that could report `pi (browser)` in Node/Bun.
- Fixed Fireworks GLM 5.2 Fast to use the OpenAI-compatible endpoint and `thinkingLevelMap`, aligning it with GLM 5.2 ([#6195](https://github.com/earendil-works/pi/issues/6195)).
- Fixed Amazon Bedrock prompt-cache points for Claude Fable 5 and Claude Sonnet 5 ([#6235](https://github.com/earendil-works/pi/issues/6235)).
- Fixed Amazon Bedrock Claude 5 prompt-cache pricing metadata by removing stale fallback overrides.
- Fixed DS4 server context overflow detection for `Prompt has ... tokens, but the configured context size is ... tokens` errors ([#6262](https://github.com/earendil-works/pi/issues/6262)).
- Fixed OpenAI Codex WebSocket sessions to rotate cached connections before the backend's 60-minute limit, avoiding connection-limit failures on long sessions ([#6268](https://github.com/earendil-works/pi/issues/6268)).
- Fixed Cloudflare Workers AI / AI Gateway auth to fall back to the ambient `CLOUDFLARE_ACCOUNT_ID` (and `CLOUDFLARE_GATEWAY_ID`) when the stored credential carries only the API key, so `/login`-style key-only credentials no longer leave the `{CLOUDFLARE_ACCOUNT_ID}` placeholder unresolved and return 404 ([#6021](https://github.com/earendil-works/pi/issues/6021)).
- Fixed OpenAI Completions and Responses providers to send `(no tool output)` instead of `(see attached image)` when a tool result has empty text and no image content, preventing the model from hallucinating image attachments.
- Fixed OpenAI Responses and Azure OpenAI Responses requests to avoid sending `max_output_tokens` values below the provider minimum ([#6265](https://github.com/earendil-works/pi/issues/6265)).
- Fixed retry classification for Cloudflare 524 timeout responses ([#6239](https://github.com/earendil-works/pi/issues/6239)).
- Fixed retry classification for Bun fetch socket-drop errors such as `socket connection was closed`, so transient stream disconnects retry automatically ([#6431](https://github.com/earendil-works/pi/issues/6431)).
- Fixed GitHub Copilot extended context window models (Claude Opus 4.7/4.8, Claude Opus 4.6, Claude Sonnet 4.6/5, Claude Fable 5, GPT-5.3 Codex, GPT-5.4, GPT-5.5) to use `contextWindow: 1000000`, preventing premature compaction and under-budgeting ([#6439](https://github.com/earendil-works/pi/issues/6439)).
### 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`.
- 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 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.
## [0.80.3] - 2026-06-30
### Added
- Added Anthropic Claude Sonnet 5 model metadata for Anthropic-compatible, Bedrock, OpenRouter, and Vercel AI Gateway providers.
- Added Azure OpenAI Responses support for modern Microsoft Foundry endpoint URLs ([#6004](https://github.com/earendil-works/pi/pull/6004) by [@gukoff](https://github.com/gukoff)).
- Added an optional `reasoning` field to `Usage` reporting reasoning/thinking token counts as a subset of `output`. Populated for Anthropic (`output_tokens_details.thinking_tokens`), OpenAI Responses/Codex/Azure (`output_tokens_details.reasoning_tokens`), OpenAI Completions (`completion_tokens_details.reasoning_tokens`), and Google Generative AI / Vertex (`thoughtsTokenCount`). Bedrock Converse and Mistral are not populated because those APIs do not return a reasoning token breakdown ([#6057](https://github.com/earendil-works/pi/issues/6057)).
### Changed
- Changed OpenAI Codex Responses SSE response-header waits to use the configured HTTP timeout instead of the previous fixed 20 second timeout, reducing false timeouts on slow connections ([#4945](https://github.com/earendil-works/pi/issues/4945)).
### Fixed
- Fixed Claude Sonnet 5 metadata to use adaptive thinking payloads for Anthropic-compatible and Bedrock requests.
- Fixed generated Xiaomi MiMo model pricing to match current pay-as-you-go pricing from models.dev ([#6138](https://github.com/earendil-works/pi/issues/6138)).
- Fixed provider HTTP errors to include response bodies instead of opaque SDK messages ([#5832](https://github.com/earendil-works/pi/pull/5832) by [@stephanmck](https://github.com/stephanmck)).
- Fixed `streamSimple()` to send a context-aware max-token cap so providers that count input and output against one context window do not reject long requests ([#5595](https://github.com/earendil-works/pi/issues/5595)).
- Fixed OpenAI Responses streams to preserve reasoning replay state when output items finish out of order ([#6009](https://github.com/earendil-works/pi/issues/6009)).
- Fixed retry classification for provider errors that explicitly tell callers to retry the request ([#6019](https://github.com/earendil-works/pi/issues/6019)).
- Fixed Z.AI preserved thinking requests to send `thinking.clear_thinking: false` when thinking is enabled, allowing replayed `reasoning_content` to participate in provider caching ([#6083](https://github.com/earendil-works/pi/issues/6083)).
## [0.80.2] - 2026-06-23
### Changed
- Changed `ApiKeyCredential` to use the `auth.json`-compatible discriminator `type: "api_key"` and provider-scoped `env` values instead of `type: "api-key"` and metadata.
### Fixed
- Fixed Anthropic-compatible custom models to use explicit compatibility metadata instead of provider-name heuristics for session-affinity headers and unsupported tool-field omissions.
- Fixed request-scoped `apiKey` and `env` values to participate in provider auth resolution, so providers such as Cloudflare can derive request-specific base URLs from explicit call options ([#6021](https://github.com/earendil-works/pi/issues/6021)).
- Restored temporary legacy per-API stream aliases such as `streamSimpleOpenAICompletions` on the compat entrypoint ([#6016](https://github.com/earendil-works/pi/issues/6016), [#6017](https://github.com/earendil-works/pi/issues/6017)).
- Restored runtime `detectCompat` fallback in `openai-completions` for models without explicit compat metadata ([#6020](https://github.com/earendil-works/pi/issues/6020)).
## [0.80.1] - 2026-06-23
### Fixed
- Fixed a regression in Amazon Bedrock scoped `AWS_PROFILE` endpoint resolution for built-in inference profile endpoints.
- Fixed Fireworks Anthropic-compatible requests to apply session-affinity and unsupported tool-field defaults for custom Fireworks models.
- Fixed Together MiniMax M2.7 metadata to avoid unsupported Together reasoning toggles.
## [0.80.0] - 2026-06-23
### Breaking Changes
- The root entrypoint (`@earendil-works/pi-ai`) is now core-only and side-effect free. The old global API moved to the temporary `@earendil-works/pi-ai/compat` entrypoint, a strict superset of the root: switching a file's import path is the only migration step. Moved symbols include `stream`/`complete`/`streamSimple`/`completeSimple`, `getModel`/`getModels`/`getProviders` (now deprecated aliases of `getBuiltinModel`/`getBuiltinModels`/`getBuiltinProviders` from `@earendil-works/pi-ai/providers/all`), `registerApiProvider`/`unregisterApiProviders`/`resetApiProviders`/`getApiProvider`, `getEnvApiKey`/`findEnvKeys`, `setBedrockProviderModule`, the per-API lazy stream wrappers (`anthropicMessagesApi`, ...), and the image-generation API.
- Renamed the `Provider` type to `ProviderId`. `Provider` now names the runtime provider interface (id, name, auth, model listing, stream behavior).
- API implementation modules moved from `src/providers/` to `@earendil-works/pi-ai/api/*`, renamed by API id (`anthropic` -> `api/anthropic-messages`, `google` -> `api/google-generative-ai`, `mistral` -> `api/mistral-conversations`, `amazon-bedrock` -> `api/bedrock-converse-stream`), each exporting exactly `stream` and `streamSimple`. The old per-impl export names (`streamAnthropic`, `streamSimpleAnthropic`, ...) and legacy raw API subpaths (`./anthropic`, `./google`, `./openai-completions`, ...) are gone; import raw API implementations through `@earendil-works/pi-ai/api/*`.
- Removed the `@earendil-works/pi-ai/base` selective-provider entrypoint; use the root/core APIs with explicit `createModels()` collections and provider factories for isolated bundles.
Migration guide:
- Read `packages/ai/README.md` in full for the new `Models` API, provider factories, auth configuration, image generation, and custom provider examples.
- To keep the old global API temporarily, change imports from `@earendil-works/pi-ai` to `@earendil-works/pi-ai/compat`. The compat entrypoint preserves `stream`/`complete`, generated catalog reads, API registry helpers, env API-key helpers, lazy API wrappers, and image globals, but it will be removed in a future release.
- To migrate to the new runtime, create a `Models` collection and call methods on it:
```ts
import { builtinModels } from "@earendil-works/pi-ai/providers/all";
const models = builtinModels();
const model = models.getModel("anthropic", "claude-haiku-4-5");
if (!model) throw new Error("model not found");
const message = await models.complete(model, {
messages: [{ role: "user", content: "Hello", timestamp: Date.now() }],
});
```
- For an isolated provider set, register provider factories explicitly:
```ts
import { createModels } from "@earendil-works/pi-ai";
import { anthropicProvider } from "@earendil-works/pi-ai/providers/anthropic";
const models = createModels();
models.setProvider(anthropicProvider());
```
- To call a raw API implementation directly, import from `@earendil-works/pi-ai/api/*` and pass a compatible model plus auth/options yourself. Raw API modules export `stream` and `streamSimple`; use `.result()` on the returned stream for `complete`/`completeSimple` behavior:
```ts
import { streamSimple } from "@earendil-works/pi-ai/api/anthropic-messages";
import { getBuiltinModel } from "@earendil-works/pi-ai/providers/all";
const model = getBuiltinModel("anthropic", "claude-haiku-4-5");
const stream = streamSimple(
model,
{ messages: [{ role: "user", content: "Hello", timestamp: Date.now() }] },
{ apiKey: process.env.ANTHROPIC_API_KEY },
);
const message = await stream.result();
```
Custom raw models must set the matching `api` value (for example `"anthropic-messages"` for `api/anthropic-messages`) and any required provider compatibility metadata in `model.compat`.
### Added
- New `Models` runtime: `createModels()` builds an isolated provider collection with sync model reads (`getModels`/`getModel` return the last-known lists), an explicit async `refresh(provider?)` for dynamic providers, auth resolution (`getAuth`), and `stream`/`complete`/`streamSimple`/`completeSimple` that resolve auth through the owning provider. `createProvider()` builds providers from parts (single API implementation or a map dispatched on `model.api`; static `models` array plus an optional `refreshModels` fetcher with in-flight dedupe); `hasApi()` narrows dynamically listed models.
- Provider auth substrate: `ProviderAuth` (`{ apiKey?, oauth? }`), one type-tagged credential per provider, `CredentialStore` (`read`/`modify`/`delete` with serialized writes; in-memory default), `envApiKeyAuth()`, `lazyOAuth()`, and injectable `AuthContext`. OAuth refresh runs under the store lock with double-checked expiry; a stored credential owns its provider (no silent env fallback).
- One provider factory per built-in provider under `@earendil-works/pi-ai/providers/*` (e.g. `anthropicProvider()`, `openrouterProvider()`), plus `@earendil-works/pi-ai/providers/all` with `builtinProviders()`/`builtinModels()` and typed `getBuiltin*` catalog reads. Generated catalogs are split per provider, so importing one provider pulls one catalog; `sideEffects` metadata makes the package tree-shakeable.
- OAuth flows (Anthropic, OpenAI Codex, GitHub Copilot) gained `OAuthAuth` adapters (`login`/`refresh`/`toAuth`) on unified `prompt()`/`notify()` login callbacks; Copilot's per-credential base URL is derived in `toAuth()`.
- `fauxProvider()` returns a faux `Provider` for tests built on explicit `Models` collections.
- Image generation mirrors the chat-side design: `createImagesModels()`/`ImagesProvider`/`createImagesProvider()` with sync model reads, explicit `refresh()`, provider-resolved auth, and never-rejecting `generateImages()`; `openrouterImagesProvider()` factory plus `builtinImagesProviders()`/`builtinImagesModels()` in `providers/all`. The `ImagesProvider` id type alias is renamed to `ImagesProviderId`; the old global image API stays on `/compat`.
- Provider auth results can carry provider-scoped environment values that `Models` and `ImagesModels` merge into API implementation options.
### Fixed
- Fixed OpenAI Responses streams to fail when they end before a terminal response event and to treat `response.incomplete` as a length stop ([#5526](https://github.com/earendil-works/pi/pull/5526) by [@dmmulroy](https://github.com/dmmulroy)).
- Fixed Amazon Bedrock endpoint resolution to honor scoped `AWS_PROFILE` values.
- Fixed Cloudflare providers to require account/gateway configuration and route built-in `/compat` requests through provider auth.
- Fixed `/compat` API-key injection to honor request-scoped `env` values.
- Fixed OpenAI Codex Responses WebSocket sessions to reconnect once when OpenAI's connection limit is reached before output starts ([#5973](https://github.com/earendil-works/pi/issues/5973)).
- Fixed OpenCode Go GLM-5.2 metadata to expose `xhigh` reasoning and send `reasoning_effort: "max"` ([#5967](https://github.com/earendil-works/pi/issues/5967)).
## [0.79.10] - 2026-06-22
### Fixed

File diff suppressed because it is too large Load diff

View file

@ -1,31 +1,58 @@
{
"name": "@earendil-works/pi-ai",
"version": "0.80.10",
"version": "0.79.10",
"description": "Unified LLM API with automatic model discovery and provider configuration",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"sideEffects": [
"./dist/compat.js",
"./dist/images.js",
"./dist/providers/images/register-builtins.js"
],
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./compat": {
"types": "./dist/compat.d.ts",
"import": "./dist/compat.js"
"./base": {
"types": "./dist/base.d.ts",
"import": "./dist/base.js"
},
"./providers/*": {
"types": "./dist/providers/*.d.ts",
"import": "./dist/providers/*.js"
"./amazon-bedrock": {
"types": "./dist/providers/amazon-bedrock.d.ts",
"import": "./dist/providers/amazon-bedrock.js"
},
"./api/*": {
"types": "./dist/api/*.d.ts",
"import": "./dist/api/*.js"
"./anthropic": {
"types": "./dist/providers/anthropic.d.ts",
"import": "./dist/providers/anthropic.js"
},
"./azure-openai-responses": {
"types": "./dist/providers/azure-openai-responses.d.ts",
"import": "./dist/providers/azure-openai-responses.js"
},
"./google": {
"types": "./dist/providers/google.d.ts",
"import": "./dist/providers/google.js"
},
"./google-vertex": {
"types": "./dist/providers/google-vertex.d.ts",
"import": "./dist/providers/google-vertex.js"
},
"./mistral": {
"types": "./dist/providers/mistral.d.ts",
"import": "./dist/providers/mistral.js"
},
"./openai-codex-responses": {
"types": "./dist/providers/openai-codex-responses.d.ts",
"import": "./dist/providers/openai-codex-responses.js"
},
"./openai-completions": {
"types": "./dist/providers/openai-completions.d.ts",
"import": "./dist/providers/openai-completions.js"
},
"./openai-responses": {
"types": "./dist/providers/openai-responses.d.ts",
"import": "./dist/providers/openai-responses.js"
},
"./openrouter-images": {
"types": "./dist/providers/images/openrouter.d.ts",
"import": "./dist/providers/images/openrouter.js"
},
"./oauth": {
"types": "./dist/oauth.d.ts",
@ -34,10 +61,6 @@
"./bedrock-provider": {
"types": "./dist/bedrock-provider.d.ts",
"import": "./dist/bedrock-provider.js"
},
"./bun-oauth": {
"types": "./dist/bun-oauth.d.ts",
"import": "./dist/bun-oauth.js"
}
},
"bin": {
@ -50,9 +73,8 @@
"scripts": {
"clean": "shx rm -rf dist",
"generate-models": "node scripts/generate-models.ts",
"generate-model-catalog": "node scripts/generate-models.ts --strict --json-only --json-output ../../.artifacts/model-catalog",
"generate-image-models": "node scripts/generate-image-models.ts",
"build": "npm run generate-models && tsgo -p tsconfig.build.json && shx rm -rf dist/providers/data && shx cp -r src/providers/data dist/providers/data",
"build": "npm run generate-models && npm run generate-image-models && tsgo -p tsconfig.build.json",
"test": "vitest --run",
"prepublishOnly": "npm run clean && npm run build"
},

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,98 @@
import type {
Api,
AssistantMessageEventStream,
Context,
Model,
SimpleStreamOptions,
StreamFunction,
StreamOptions,
} from "./types.ts";
export type ApiStreamFunction = (
model: Model<Api>,
context: Context,
options?: StreamOptions,
) => AssistantMessageEventStream;
export type ApiStreamSimpleFunction = (
model: Model<Api>,
context: Context,
options?: SimpleStreamOptions,
) => AssistantMessageEventStream;
export interface ApiProvider<TApi extends Api = Api, TOptions extends StreamOptions = StreamOptions> {
api: TApi;
stream: StreamFunction<TApi, TOptions>;
streamSimple: StreamFunction<TApi, SimpleStreamOptions>;
}
interface ApiProviderInternal {
api: Api;
stream: ApiStreamFunction;
streamSimple: ApiStreamSimpleFunction;
}
type RegisteredApiProvider = {
provider: ApiProviderInternal;
sourceId?: string;
};
const apiProviderRegistry = new Map<string, RegisteredApiProvider>();
function wrapStream<TApi extends Api, TOptions extends StreamOptions>(
api: TApi,
stream: StreamFunction<TApi, TOptions>,
): ApiStreamFunction {
return (model, context, options) => {
if (model.api !== api) {
throw new Error(`Mismatched api: ${model.api} expected ${api}`);
}
return stream(model as Model<TApi>, context, options as TOptions);
};
}
function wrapStreamSimple<TApi extends Api>(
api: TApi,
streamSimple: StreamFunction<TApi, SimpleStreamOptions>,
): ApiStreamSimpleFunction {
return (model, context, options) => {
if (model.api !== api) {
throw new Error(`Mismatched api: ${model.api} expected ${api}`);
}
return streamSimple(model as Model<TApi>, context, options);
};
}
export function registerApiProvider<TApi extends Api, TOptions extends StreamOptions>(
provider: ApiProvider<TApi, TOptions>,
sourceId?: string,
): void {
apiProviderRegistry.set(provider.api, {
provider: {
api: provider.api,
stream: wrapStream(provider.api, provider.stream),
streamSimple: wrapStreamSimple(provider.api, provider.streamSimple),
},
sourceId,
});
}
export function getApiProvider(api: Api): ApiProviderInternal | undefined {
return apiProviderRegistry.get(api)?.provider;
}
export function getApiProviders(): ApiProviderInternal[] {
return Array.from(apiProviderRegistry.values(), (entry) => entry.provider);
}
export function unregisterApiProviders(sourceId: string): void {
for (const [api, entry] of apiProviderRegistry.entries()) {
if (entry.sourceId === sourceId) {
apiProviderRegistry.delete(api);
}
}
}
export function clearApiProviders(): void {
apiProviderRegistry.clear();
}

View file

@ -1,4 +0,0 @@
import type { ProviderStreams } from "../types.ts";
import { lazyApi } from "./lazy.ts";
export const anthropicMessagesApi = (): ProviderStreams => lazyApi(() => import("./anthropic-messages.ts"));

File diff suppressed because it is too large Load diff

View file

@ -1,4 +0,0 @@
import type { ProviderStreams } from "../types.ts";
import { lazyApi } from "./lazy.ts";
export const azureOpenAIResponsesApi = (): ProviderStreams => lazyApi(() => import("./azure-openai-responses.ts"));

View file

@ -1,298 +0,0 @@
import { AzureOpenAI } from "openai";
import type { ResponseCreateParamsStreaming } from "openai/resources/responses/responses.js";
import { clampThinkingLevel } from "../models.ts";
import type {
Api,
AssistantMessage,
Context,
Model,
SimpleStreamOptions,
StreamFunction,
StreamOptions,
} from "../types.ts";
import { formatProviderError, normalizeProviderError } from "../utils/error-body.ts";
import { AssistantMessageEventStream } from "../utils/event-stream.ts";
import { headersToRecord } from "../utils/headers.ts";
import { getProviderEnvValue } from "../utils/provider-env.ts";
import { clampOpenAIPromptCacheKey } from "./openai-prompt-cache.ts";
import { convertResponsesMessages, convertResponsesTools, processResponsesStream } from "./openai-responses-shared.ts";
import { buildBaseOptions } from "./simple-options.ts";
const DEFAULT_AZURE_API_VERSION = "v1";
const AZURE_TOOL_CALL_PROVIDERS = new Set(["openai", "openai-codex", "opencode", "azure-openai-responses"]);
// OpenAI Responses rejects max_output_tokens below 16: https://github.com/earendil-works/pi/issues/6265
const OPENAI_RESPONSES_MIN_OUTPUT_TOKENS = 16;
function parseDeploymentNameMap(value: string | undefined): Map<string, string> {
const map = new Map<string, string>();
if (!value) return map;
for (const entry of value.split(",")) {
const trimmed = entry.trim();
if (!trimmed) continue;
const [modelId, deploymentName] = trimmed.split("=", 2);
if (!modelId || !deploymentName) continue;
map.set(modelId.trim(), deploymentName.trim());
}
return map;
}
function resolveDeploymentName(model: Model<"azure-openai-responses">, options?: AzureOpenAIResponsesOptions): string {
if (options?.azureDeploymentName) {
return options.azureDeploymentName;
}
const mappedDeployment = parseDeploymentNameMap(
getProviderEnvValue("AZURE_OPENAI_DEPLOYMENT_NAME_MAP", options?.env),
).get(model.id);
return mappedDeployment || model.id;
}
function formatAzureOpenAIError(error: unknown): string {
return formatProviderError(normalizeProviderError(error), "Azure OpenAI API error");
}
// Azure OpenAI Responses-specific options
export interface AzureOpenAIResponsesOptions extends StreamOptions {
reasoningEffort?: "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
reasoningSummary?: "auto" | "detailed" | "concise" | null;
azureApiVersion?: string;
azureResourceName?: string;
azureBaseUrl?: string;
azureDeploymentName?: string;
}
/**
* Generate function for Azure OpenAI Responses API
*/
export const stream: StreamFunction<"azure-openai-responses", AzureOpenAIResponsesOptions> = (
model: Model<"azure-openai-responses">,
context: Context,
options?: AzureOpenAIResponsesOptions,
): AssistantMessageEventStream => {
const stream = new AssistantMessageEventStream();
// Start async processing
(async () => {
const deploymentName = resolveDeploymentName(model, options);
const output: AssistantMessage = {
role: "assistant",
content: [],
api: "azure-openai-responses" as Api,
provider: model.provider,
model: model.id,
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop",
timestamp: Date.now(),
};
try {
// Create Azure OpenAI client
const apiKey = options?.apiKey;
if (!apiKey) {
throw new Error(`No API key for provider: ${model.provider}`);
}
const client = createClient(model, apiKey, options);
let params = buildParams(model, context, options, deploymentName);
const nextParams = await options?.onPayload?.(params, model);
if (nextParams !== undefined) {
params = nextParams as ResponseCreateParamsStreaming;
}
const requestOptions = {
...(options?.signal ? { signal: options.signal } : {}),
...(options?.timeoutMs !== undefined ? { timeout: options.timeoutMs } : {}),
maxRetries: options?.maxRetries ?? 0,
};
const { data: openaiStream, response } = await client.responses.create(params, requestOptions).withResponse();
await options?.onResponse?.({ status: response.status, headers: headersToRecord(response.headers) }, model);
stream.push({ type: "start", partial: output });
await processResponsesStream(openaiStream, output, stream, model);
if (options?.signal?.aborted) {
throw new Error("Request was aborted");
}
if (output.stopReason === "aborted" || output.stopReason === "error") {
throw new Error("An unknown error occurred");
}
stream.push({ type: "done", reason: output.stopReason, message: output });
stream.end();
} catch (error) {
for (const block of output.content) {
delete (block as { index?: number }).index;
// partialJson is only a streaming scratch buffer; never persist it.
delete (block as { partialJson?: string }).partialJson;
}
output.stopReason = options?.signal?.aborted ? "aborted" : "error";
output.errorMessage = formatAzureOpenAIError(error);
stream.push({ type: "error", reason: output.stopReason, error: output });
stream.end();
}
})();
return stream;
};
export const streamSimple: StreamFunction<"azure-openai-responses", SimpleStreamOptions> = (
model: Model<"azure-openai-responses">,
context: Context,
options?: SimpleStreamOptions,
): AssistantMessageEventStream => {
const apiKey = options?.apiKey;
if (!apiKey) {
throw new Error(`No API key for provider: ${model.provider}`);
}
const base = buildBaseOptions(model, context, options, apiKey);
const clampedReasoning = options?.reasoning ? clampThinkingLevel(model, options.reasoning) : undefined;
const reasoningEffort = clampedReasoning === "off" ? undefined : clampedReasoning;
return stream(model, context, {
...base,
reasoningEffort,
} satisfies AzureOpenAIResponsesOptions);
};
function normalizeAzureBaseUrl(baseUrl: string): string {
const trimmed = baseUrl.trim().replace(/\/+$/, "");
let url: URL;
try {
url = new URL(trimmed);
} catch {
throw new Error(`Invalid Azure OpenAI base URL: ${baseUrl}`);
}
const isAzureHost =
url.hostname.endsWith(".openai.azure.com") ||
url.hostname.endsWith(".cognitiveservices.azure.com") ||
url.hostname.endsWith(".ai.azure.com");
const normalizedPath = url.pathname.replace(/\/+$/, "");
// Ensure Azure hosts have /openai/v1 as base path so the AzureOpenAI SDK
// can append /deployments/<model>/... and ?api-version=v1 correctly.
if (
isAzureHost &&
(normalizedPath === "" ||
normalizedPath === "/" ||
normalizedPath === "/openai" ||
normalizedPath === "/openai/v1/responses")
) {
url.pathname = "/openai/v1";
url.search = "";
}
return url.toString().replace(/\/+$/, "");
}
function buildDefaultBaseUrl(resourceName: string): string {
return `https://${resourceName}.openai.azure.com/openai/v1`;
}
function resolveAzureConfig(
model: Model<"azure-openai-responses">,
options?: AzureOpenAIResponsesOptions,
): { baseUrl: string; apiVersion: string } {
const apiVersion =
options?.azureApiVersion ||
getProviderEnvValue("AZURE_OPENAI_API_VERSION", options?.env) ||
DEFAULT_AZURE_API_VERSION;
const baseUrl =
options?.azureBaseUrl?.trim() || getProviderEnvValue("AZURE_OPENAI_BASE_URL", options?.env)?.trim() || undefined;
const resourceName = options?.azureResourceName || getProviderEnvValue("AZURE_OPENAI_RESOURCE_NAME", options?.env);
let resolvedBaseUrl = baseUrl;
if (!resolvedBaseUrl && resourceName) {
resolvedBaseUrl = buildDefaultBaseUrl(resourceName);
}
if (!resolvedBaseUrl && model.baseUrl) {
resolvedBaseUrl = model.baseUrl;
}
if (!resolvedBaseUrl) {
throw new Error(
"Azure OpenAI base URL is required. Set AZURE_OPENAI_BASE_URL or AZURE_OPENAI_RESOURCE_NAME, or pass azureBaseUrl, azureResourceName, or model.baseUrl.",
);
}
return {
baseUrl: normalizeAzureBaseUrl(resolvedBaseUrl),
apiVersion,
};
}
function createClient(model: Model<"azure-openai-responses">, apiKey: string, options?: AzureOpenAIResponsesOptions) {
const headers = { ...model.headers };
if (options?.headers) {
Object.assign(headers, options.headers);
}
const { baseUrl, apiVersion } = resolveAzureConfig(model, options);
return new AzureOpenAI({
apiKey,
apiVersion,
dangerouslyAllowBrowser: true,
defaultHeaders: headers,
baseURL: baseUrl,
});
}
function buildParams(
model: Model<"azure-openai-responses">,
context: Context,
options: AzureOpenAIResponsesOptions | undefined,
deploymentName: string,
) {
const messages = convertResponsesMessages(model, context, AZURE_TOOL_CALL_PROVIDERS);
const params: ResponseCreateParamsStreaming = {
model: deploymentName,
input: messages,
stream: true,
prompt_cache_key: clampOpenAIPromptCacheKey(options?.sessionId),
store: false,
};
if (options?.maxTokens) {
params.max_output_tokens = Math.max(options.maxTokens, OPENAI_RESPONSES_MIN_OUTPUT_TOKENS);
}
if (options?.temperature !== undefined) {
params.temperature = options?.temperature;
}
if (context.tools && context.tools.length > 0) {
params.tools = convertResponsesTools(context.tools);
}
if (model.reasoning) {
if (options?.reasoningEffort || options?.reasoningSummary) {
const effort = options?.reasoningEffort
? (model.thinkingLevelMap?.[options.reasoningEffort] ?? options.reasoningEffort)
: "medium";
params.reasoning = {
effort: effort as NonNullable<typeof params.reasoning>["effort"],
summary: options?.reasoningSummary || "auto",
};
params.include = ["reasoning.encrypted_content"];
} else if (model.thinkingLevelMap?.off !== null) {
params.reasoning = {
effort: (model.thinkingLevelMap?.off ?? "none") as NonNullable<typeof params.reasoning>["effort"],
};
}
}
return params;
}

View file

@ -1,30 +0,0 @@
import type { ProviderStreams } from "../types.ts";
import { lazyApi } from "./lazy.ts";
/**
* Loads the bedrock implementation through a variable specifier so bundlers
* (browser smoke, Bun compile) cannot follow the import into the Node-only
* AWS SDK. The `.ts`/`.js` rewrite keeps the trick working from both source
* and built output.
*/
const importNodeOnlyApi = (specifier: string): Promise<unknown> => {
const runtimeSpecifier = import.meta.url.endsWith(".js") ? specifier.replace(/\.ts$/, ".js") : specifier;
return import(runtimeSpecifier);
};
let bedrockModuleOverride: ProviderStreams | undefined;
/**
* Overrides the dynamically imported bedrock implementation. Used by the Bun
* binary build, where the variable-specifier import cannot be bundled; the
* build registers a statically imported module instead.
*/
export function setBedrockProviderModule(module: ProviderStreams): void {
bedrockModuleOverride = module;
}
export const bedrockConverseStreamApi = (): ProviderStreams =>
lazyApi(
async () =>
bedrockModuleOverride ?? ((await importNodeOnlyApi("./bedrock-converse-stream.ts")) as ProviderStreams),
);

File diff suppressed because it is too large Load diff

View file

@ -1,4 +0,0 @@
import type { ProviderStreams } from "../types.ts";
import { lazyApi } from "./lazy.ts";
export const googleGenerativeAIApi = (): ProviderStreams => lazyApi(() => import("./google-generative-ai.ts"));

View file

@ -1,509 +0,0 @@
import {
type GenerateContentConfig,
type GenerateContentParameters,
GoogleGenAI,
type ThinkingConfig,
} from "@google/genai";
import { calculateCost, clampThinkingLevel } from "../models.ts";
import type {
Api,
AssistantMessage,
Context,
Model,
ProviderHeaders,
SimpleStreamOptions,
StreamFunction,
StreamOptions,
TextContent,
ThinkingBudgets,
ThinkingContent,
ThinkingLevel,
ToolCall,
} from "../types.ts";
import { formatProviderError, normalizeProviderError } from "../utils/error-body.ts";
import { AssistantMessageEventStream } from "../utils/event-stream.ts";
import { providerHeadersToRecord } from "../utils/headers.ts";
import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts";
import type { GoogleThinkingLevel } from "./google-shared.ts";
import {
convertMessages,
convertTools,
isThinkingPart,
mapStopReason,
mapToolChoice,
retainThoughtSignature,
} from "./google-shared.ts";
import { buildBaseOptions } from "./simple-options.ts";
export interface GoogleOptions extends StreamOptions {
toolChoice?: "auto" | "none" | "any";
thinking?: {
enabled: boolean;
budgetTokens?: number; // -1 for dynamic, 0 to disable
level?: GoogleThinkingLevel;
};
}
// Counter for generating unique tool call IDs
let toolCallCounter = 0;
export const stream: StreamFunction<"google-generative-ai", GoogleOptions> = (
model: Model<"google-generative-ai">,
context: Context,
options?: GoogleOptions,
): AssistantMessageEventStream => {
const stream = new AssistantMessageEventStream();
(async () => {
const output: AssistantMessage = {
role: "assistant",
content: [],
api: "google-generative-ai" as Api,
provider: model.provider,
model: model.id,
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop",
timestamp: Date.now(),
};
try {
const apiKey = options?.apiKey;
if (!apiKey) {
throw new Error(`No API key for provider: ${model.provider}`);
}
const client = createClient(model, apiKey, options?.headers);
let params = buildParams(model, context, options);
const nextParams = await options?.onPayload?.(params, model);
if (nextParams !== undefined) {
params = nextParams as GenerateContentParameters;
}
const googleStream = await client.models.generateContentStream(params);
stream.push({ type: "start", partial: output });
let currentBlock: TextContent | ThinkingContent | null = null;
const blocks = output.content;
const blockIndex = () => blocks.length - 1;
for await (const chunk of googleStream) {
// @google/genai documents GenerateContentResponse.responseId as an output-only field
// used to identify each response. Keep the first non-empty one from the stream.
output.responseId ||= chunk.responseId;
const candidate = chunk.candidates?.[0];
if (candidate?.content?.parts) {
for (const part of candidate.content.parts) {
if (part.text !== undefined) {
const isThinking = isThinkingPart(part);
if (
!currentBlock ||
(isThinking && currentBlock.type !== "thinking") ||
(!isThinking && currentBlock.type !== "text")
) {
if (currentBlock) {
if (currentBlock.type === "text") {
stream.push({
type: "text_end",
contentIndex: blocks.length - 1,
content: currentBlock.text,
partial: output,
});
} else {
stream.push({
type: "thinking_end",
contentIndex: blockIndex(),
content: currentBlock.thinking,
partial: output,
});
}
}
if (isThinking) {
currentBlock = { type: "thinking", thinking: "", thinkingSignature: undefined };
output.content.push(currentBlock);
stream.push({ type: "thinking_start", contentIndex: blockIndex(), partial: output });
} else {
currentBlock = { type: "text", text: "" };
output.content.push(currentBlock);
stream.push({ type: "text_start", contentIndex: blockIndex(), partial: output });
}
}
if (currentBlock.type === "thinking") {
currentBlock.thinking += part.text;
currentBlock.thinkingSignature = retainThoughtSignature(
currentBlock.thinkingSignature,
part.thoughtSignature,
);
stream.push({
type: "thinking_delta",
contentIndex: blockIndex(),
delta: part.text,
partial: output,
});
} else {
currentBlock.text += part.text;
currentBlock.textSignature = retainThoughtSignature(
currentBlock.textSignature,
part.thoughtSignature,
);
stream.push({
type: "text_delta",
contentIndex: blockIndex(),
delta: part.text,
partial: output,
});
}
}
if (part.functionCall) {
if (currentBlock) {
if (currentBlock.type === "text") {
stream.push({
type: "text_end",
contentIndex: blockIndex(),
content: currentBlock.text,
partial: output,
});
} else {
stream.push({
type: "thinking_end",
contentIndex: blockIndex(),
content: currentBlock.thinking,
partial: output,
});
}
currentBlock = null;
}
// Generate unique ID if not provided or if it's a duplicate
const providedId = part.functionCall.id;
const needsNewId =
!providedId || output.content.some((b) => b.type === "toolCall" && b.id === providedId);
const toolCallId = needsNewId
? `${part.functionCall.name}_${Date.now()}_${++toolCallCounter}`
: providedId;
const toolCall: ToolCall = {
type: "toolCall",
id: toolCallId,
name: part.functionCall.name || "",
arguments: (part.functionCall.args as Record<string, any>) ?? {},
...(part.thoughtSignature && { thoughtSignature: part.thoughtSignature }),
};
output.content.push(toolCall);
stream.push({ type: "toolcall_start", contentIndex: blockIndex(), partial: output });
stream.push({
type: "toolcall_delta",
contentIndex: blockIndex(),
delta: JSON.stringify(toolCall.arguments),
partial: output,
});
stream.push({ type: "toolcall_end", contentIndex: blockIndex(), toolCall, partial: output });
}
}
}
if (candidate?.finishReason) {
output.stopReason = mapStopReason(candidate.finishReason);
if (output.content.some((b) => b.type === "toolCall")) {
output.stopReason = "toolUse";
}
}
if (chunk.usageMetadata) {
output.usage = {
input:
(chunk.usageMetadata.promptTokenCount || 0) - (chunk.usageMetadata.cachedContentTokenCount || 0),
output:
(chunk.usageMetadata.candidatesTokenCount || 0) + (chunk.usageMetadata.thoughtsTokenCount || 0),
cacheRead: chunk.usageMetadata.cachedContentTokenCount || 0,
cacheWrite: 0,
reasoning: chunk.usageMetadata.thoughtsTokenCount || 0,
totalTokens: chunk.usageMetadata.totalTokenCount || 0,
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
total: 0,
},
};
calculateCost(model, output.usage);
}
}
if (currentBlock) {
if (currentBlock.type === "text") {
stream.push({
type: "text_end",
contentIndex: blockIndex(),
content: currentBlock.text,
partial: output,
});
} else {
stream.push({
type: "thinking_end",
contentIndex: blockIndex(),
content: currentBlock.thinking,
partial: output,
});
}
}
if (options?.signal?.aborted) {
throw new Error("Request was aborted");
}
if (output.stopReason === "aborted" || output.stopReason === "error") {
throw new Error("An unknown error occurred");
}
stream.push({ type: "done", reason: output.stopReason, message: output });
stream.end();
} catch (error) {
// Remove internal index property used during streaming
for (const block of output.content) {
if ("index" in block) {
delete (block as { index?: number }).index;
}
}
output.stopReason = options?.signal?.aborted ? "aborted" : "error";
output.errorMessage = formatProviderError(normalizeProviderError(error));
stream.push({ type: "error", reason: output.stopReason, error: output });
stream.end();
}
})();
return stream;
};
export const streamSimple: StreamFunction<"google-generative-ai", SimpleStreamOptions> = (
model: Model<"google-generative-ai">,
context: Context,
options?: SimpleStreamOptions,
): AssistantMessageEventStream => {
const apiKey = options?.apiKey;
if (!apiKey) {
throw new Error(`No API key for provider: ${model.provider}`);
}
const base = buildBaseOptions(model, context, options, apiKey);
if (!options?.reasoning) {
return stream(model, context, { ...base, thinking: { enabled: false } } satisfies GoogleOptions);
}
const clampedReasoning = clampThinkingLevel(model, options.reasoning);
const effort = (clampedReasoning === "off" ? "high" : clampedReasoning) as ClampedThinkingLevel;
const googleModel = model as Model<"google-generative-ai">;
if (isGemini3ProModel(googleModel) || isGemini3FlashModel(googleModel) || isGemma4Model(googleModel)) {
return stream(model, context, {
...base,
thinking: {
enabled: true,
level: getThinkingLevel(effort, googleModel),
},
} satisfies GoogleOptions);
}
return stream(model, context, {
...base,
thinking: {
enabled: true,
budgetTokens: getGoogleBudget(googleModel, effort, options.thinkingBudgets),
},
} satisfies GoogleOptions);
};
function createClient(
model: Model<"google-generative-ai">,
apiKey?: string,
optionsHeaders?: ProviderHeaders,
): GoogleGenAI {
const httpOptions: { baseUrl?: string; apiVersion?: string; headers?: Record<string, string> } = {};
if (model.baseUrl) {
httpOptions.baseUrl = model.baseUrl;
httpOptions.apiVersion = ""; // baseUrl already includes version path, don't append
}
const headers = providerHeadersToRecord({ ...model.headers, ...optionsHeaders });
if (headers) {
httpOptions.headers = headers;
}
return new GoogleGenAI({
apiKey,
httpOptions: Object.keys(httpOptions).length > 0 ? httpOptions : undefined,
});
}
function buildParams(
model: Model<"google-generative-ai">,
context: Context,
options: GoogleOptions = {},
): GenerateContentParameters {
const contents = convertMessages(model, context);
const generationConfig: GenerateContentConfig = {};
if (options.temperature !== undefined) {
generationConfig.temperature = options.temperature;
}
if (options.maxTokens !== undefined) {
generationConfig.maxOutputTokens = options.maxTokens;
}
const config: GenerateContentConfig = {
...(Object.keys(generationConfig).length > 0 && generationConfig),
...(context.systemPrompt && { systemInstruction: sanitizeSurrogates(context.systemPrompt) }),
...(context.tools && context.tools.length > 0 && { tools: convertTools(context.tools) }),
};
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) {
const thinkingConfig: ThinkingConfig = { includeThoughts: true };
if (options.thinking.level !== undefined) {
// Cast to any since our GoogleThinkingLevel mirrors Google's ThinkingLevel enum values
thinkingConfig.thinkingLevel = options.thinking.level as any;
} else if (options.thinking.budgetTokens !== undefined) {
thinkingConfig.thinkingBudget = options.thinking.budgetTokens;
}
config.thinkingConfig = thinkingConfig;
} else if (model.reasoning && options.thinking && !options.thinking.enabled) {
config.thinkingConfig = getDisabledThinkingConfig(model);
}
if (options.signal) {
if (options.signal.aborted) {
throw new Error("Request aborted");
}
config.abortSignal = options.signal;
}
const params: GenerateContentParameters = {
model: model.id,
contents,
config,
};
return params;
}
type ClampedThinkingLevel = Exclude<ThinkingLevel, "xhigh" | "max">;
function isGemma4Model(model: Model<"google-generative-ai">): boolean {
return /gemma-?4/.test(model.id.toLowerCase());
}
function isGemini3ProModel(model: Model<"google-generative-ai">): boolean {
return /gemini-3(?:\.\d+)?-pro/.test(model.id.toLowerCase());
}
function isGemini3FlashModel(model: Model<"google-generative-ai">): boolean {
const id = model.id.toLowerCase();
return /gemini-3(?:\.\d+)?-flash/.test(id) || id === "gemini-flash-latest" || id === "gemini-flash-lite-latest";
}
function getDisabledThinkingConfig(model: Model<"google-generative-ai">): ThinkingConfig {
// Google docs: Gemini 3.1 Pro cannot disable thinking, and Gemini 3 Flash / Flash-Lite
// do not support full thinking-off either. For Gemini 3 models, use the lowest supported
// thinkingLevel without includeThoughts so hidden thinking remains invisible to pi.
if (isGemini3ProModel(model)) {
return { thinkingLevel: "LOW" as any };
}
if (isGemini3FlashModel(model)) {
return { thinkingLevel: "MINIMAL" as any };
}
if (isGemma4Model(model)) {
return { thinkingLevel: "MINIMAL" as any };
}
// Gemini 2.x supports disabling via thinkingBudget = 0.
return { thinkingBudget: 0 };
}
function getThinkingLevel(effort: ClampedThinkingLevel, model: Model<"google-generative-ai">): GoogleThinkingLevel {
if (isGemini3ProModel(model)) {
switch (effort) {
case "minimal":
case "low":
return "LOW";
case "medium":
case "high":
return "HIGH";
}
}
if (isGemma4Model(model)) {
switch (effort) {
case "minimal":
case "low":
return "MINIMAL";
case "medium":
case "high":
return "HIGH";
}
}
switch (effort) {
case "minimal":
return "MINIMAL";
case "low":
return "LOW";
case "medium":
return "MEDIUM";
case "high":
return "HIGH";
}
}
function getGoogleBudget(
model: Model<"google-generative-ai">,
effort: ClampedThinkingLevel,
customBudgets?: ThinkingBudgets,
): number {
if (customBudgets?.[effort] !== undefined) {
return customBudgets[effort]!;
}
if (model.id.includes("2.5-pro")) {
const budgets: Record<ClampedThinkingLevel, number> = {
minimal: 128,
low: 2048,
medium: 8192,
high: 32768,
};
return budgets[effort];
}
if (model.id.includes("2.5-flash-lite")) {
const budgets: Record<ClampedThinkingLevel, number> = {
minimal: 512,
low: 2048,
medium: 8192,
high: 24576,
};
return budgets[effort];
}
if (model.id.includes("2.5-flash")) {
const budgets: Record<ClampedThinkingLevel, number> = {
minimal: 128,
low: 2048,
medium: 8192,
high: 24576,
};
return budgets[effort];
}
return -1;
}

View file

@ -1,4 +0,0 @@
import type { ProviderStreams } from "../types.ts";
import { lazyApi } from "./lazy.ts";
export const googleVertexApi = (): ProviderStreams => lazyApi(() => import("./google-vertex.ts"));

View file

@ -1,584 +0,0 @@
import {
type GenerateContentConfig,
type GenerateContentParameters,
GoogleGenAI,
type HttpOptions,
ResourceScope,
type ThinkingConfig,
ThinkingLevel,
} from "@google/genai";
import { calculateCost, clampThinkingLevel } from "../models.ts";
import type {
Api,
AssistantMessage,
Context,
Model,
ThinkingLevel as PiThinkingLevel,
ProviderEnv,
ProviderHeaders,
SimpleStreamOptions,
StreamFunction,
StreamOptions,
TextContent,
ThinkingBudgets,
ThinkingContent,
ToolCall,
} from "../types.ts";
import { formatProviderError, normalizeProviderError } from "../utils/error-body.ts";
import { AssistantMessageEventStream } from "../utils/event-stream.ts";
import { providerHeadersToRecord } from "../utils/headers.ts";
import { getProviderEnvValue } from "../utils/provider-env.ts";
import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts";
import type { GoogleThinkingLevel } from "./google-shared.ts";
import {
convertMessages,
convertTools,
isThinkingPart,
mapStopReason,
mapToolChoice,
retainThoughtSignature,
} from "./google-shared.ts";
import { buildBaseOptions } from "./simple-options.ts";
export interface GoogleVertexOptions extends StreamOptions {
toolChoice?: "auto" | "none" | "any";
thinking?: {
enabled: boolean;
budgetTokens?: number; // -1 for dynamic, 0 to disable
level?: GoogleThinkingLevel;
};
project?: string;
location?: string;
}
const API_VERSION = "v1";
const GCP_VERTEX_CREDENTIALS_MARKER = "gcp-vertex-credentials";
const THINKING_LEVEL_MAP: Record<GoogleThinkingLevel, ThinkingLevel> = {
THINKING_LEVEL_UNSPECIFIED: ThinkingLevel.THINKING_LEVEL_UNSPECIFIED,
MINIMAL: ThinkingLevel.MINIMAL,
LOW: ThinkingLevel.LOW,
MEDIUM: ThinkingLevel.MEDIUM,
HIGH: ThinkingLevel.HIGH,
};
// Counter for generating unique tool call IDs
let toolCallCounter = 0;
export const stream: StreamFunction<"google-vertex", GoogleVertexOptions> = (
model: Model<"google-vertex">,
context: Context,
options?: GoogleVertexOptions,
): AssistantMessageEventStream => {
const stream = new AssistantMessageEventStream();
(async () => {
const output: AssistantMessage = {
role: "assistant",
content: [],
api: "google-vertex" as Api,
provider: model.provider,
model: model.id,
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop",
timestamp: Date.now(),
};
try {
const apiKey = resolveApiKey(options);
// Create the client using either a Vertex API key, if provided, or ADC with project and location
const client = apiKey
? createClientWithApiKey(model, apiKey, options?.headers)
: createClient(model, resolveProject(options), resolveLocation(options), options?.headers, options?.env);
let params = buildParams(model, context, options);
const nextParams = await options?.onPayload?.(params, model);
if (nextParams !== undefined) {
params = nextParams as GenerateContentParameters;
}
const googleStream = await client.models.generateContentStream(params);
stream.push({ type: "start", partial: output });
let currentBlock: TextContent | ThinkingContent | null = null;
const blocks = output.content;
const blockIndex = () => blocks.length - 1;
for await (const chunk of googleStream) {
// Vertex uses the same @google/genai GenerateContentResponse type as Gemini.
// responseId is documented there as an output-only identifier for each response.
output.responseId ||= chunk.responseId;
const candidate = chunk.candidates?.[0];
if (candidate?.content?.parts) {
for (const part of candidate.content.parts) {
if (part.text !== undefined) {
const isThinking = isThinkingPart(part);
if (
!currentBlock ||
(isThinking && currentBlock.type !== "thinking") ||
(!isThinking && currentBlock.type !== "text")
) {
if (currentBlock) {
if (currentBlock.type === "text") {
stream.push({
type: "text_end",
contentIndex: blocks.length - 1,
content: currentBlock.text,
partial: output,
});
} else {
stream.push({
type: "thinking_end",
contentIndex: blockIndex(),
content: currentBlock.thinking,
partial: output,
});
}
}
if (isThinking) {
currentBlock = { type: "thinking", thinking: "", thinkingSignature: undefined };
output.content.push(currentBlock);
stream.push({ type: "thinking_start", contentIndex: blockIndex(), partial: output });
} else {
currentBlock = { type: "text", text: "" };
output.content.push(currentBlock);
stream.push({ type: "text_start", contentIndex: blockIndex(), partial: output });
}
}
if (currentBlock.type === "thinking") {
currentBlock.thinking += part.text;
currentBlock.thinkingSignature = retainThoughtSignature(
currentBlock.thinkingSignature,
part.thoughtSignature,
);
stream.push({
type: "thinking_delta",
contentIndex: blockIndex(),
delta: part.text,
partial: output,
});
} else {
currentBlock.text += part.text;
currentBlock.textSignature = retainThoughtSignature(
currentBlock.textSignature,
part.thoughtSignature,
);
stream.push({
type: "text_delta",
contentIndex: blockIndex(),
delta: part.text,
partial: output,
});
}
}
if (part.functionCall) {
if (currentBlock) {
if (currentBlock.type === "text") {
stream.push({
type: "text_end",
contentIndex: blockIndex(),
content: currentBlock.text,
partial: output,
});
} else {
stream.push({
type: "thinking_end",
contentIndex: blockIndex(),
content: currentBlock.thinking,
partial: output,
});
}
currentBlock = null;
}
const providedId = part.functionCall.id;
const needsNewId =
!providedId || output.content.some((b) => b.type === "toolCall" && b.id === providedId);
const toolCallId = needsNewId
? `${part.functionCall.name}_${Date.now()}_${++toolCallCounter}`
: providedId;
const toolCall: ToolCall = {
type: "toolCall",
id: toolCallId,
name: part.functionCall.name || "",
arguments: (part.functionCall.args as Record<string, any>) ?? {},
...(part.thoughtSignature && { thoughtSignature: part.thoughtSignature }),
};
output.content.push(toolCall);
stream.push({ type: "toolcall_start", contentIndex: blockIndex(), partial: output });
stream.push({
type: "toolcall_delta",
contentIndex: blockIndex(),
delta: JSON.stringify(toolCall.arguments),
partial: output,
});
stream.push({ type: "toolcall_end", contentIndex: blockIndex(), toolCall, partial: output });
}
}
}
if (candidate?.finishReason) {
output.stopReason = mapStopReason(candidate.finishReason);
if (output.content.some((b) => b.type === "toolCall")) {
output.stopReason = "toolUse";
}
}
if (chunk.usageMetadata) {
output.usage = {
input:
(chunk.usageMetadata.promptTokenCount || 0) - (chunk.usageMetadata.cachedContentTokenCount || 0),
output:
(chunk.usageMetadata.candidatesTokenCount || 0) + (chunk.usageMetadata.thoughtsTokenCount || 0),
cacheRead: chunk.usageMetadata.cachedContentTokenCount || 0,
cacheWrite: 0,
reasoning: chunk.usageMetadata.thoughtsTokenCount || 0,
totalTokens: chunk.usageMetadata.totalTokenCount || 0,
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
total: 0,
},
};
calculateCost(model, output.usage);
}
}
if (currentBlock) {
if (currentBlock.type === "text") {
stream.push({
type: "text_end",
contentIndex: blockIndex(),
content: currentBlock.text,
partial: output,
});
} else {
stream.push({
type: "thinking_end",
contentIndex: blockIndex(),
content: currentBlock.thinking,
partial: output,
});
}
}
if (options?.signal?.aborted) {
throw new Error("Request was aborted");
}
if (output.stopReason === "aborted" || output.stopReason === "error") {
throw new Error("An unknown error occurred");
}
stream.push({ type: "done", reason: output.stopReason, message: output });
stream.end();
} catch (error) {
// Remove internal index property used during streaming
for (const block of output.content) {
if ("index" in block) {
delete (block as { index?: number }).index;
}
}
output.stopReason = options?.signal?.aborted ? "aborted" : "error";
output.errorMessage = formatProviderError(normalizeProviderError(error));
stream.push({ type: "error", reason: output.stopReason, error: output });
stream.end();
}
})();
return stream;
};
export const streamSimple: StreamFunction<"google-vertex", SimpleStreamOptions> = (
model: Model<"google-vertex">,
context: Context,
options?: SimpleStreamOptions,
): AssistantMessageEventStream => {
const base = buildBaseOptions(model, context, options, undefined);
if (!options?.reasoning) {
return stream(model, context, {
...base,
thinking: { enabled: false },
} satisfies GoogleVertexOptions);
}
const clampedReasoning = clampThinkingLevel(model, options.reasoning);
const effort = (clampedReasoning === "off" ? "high" : clampedReasoning) as ClampedThinkingLevel;
const geminiModel = model as unknown as Model<"google-generative-ai">;
if (isGemini3ProModel(geminiModel) || isGemini3FlashModel(geminiModel)) {
return stream(model, context, {
...base,
thinking: {
enabled: true,
level: getGemini3ThinkingLevel(effort, geminiModel),
},
} satisfies GoogleVertexOptions);
}
return stream(model, context, {
...base,
thinking: {
enabled: true,
budgetTokens: getGoogleBudget(geminiModel, effort, options.thinkingBudgets),
},
} satisfies GoogleVertexOptions);
};
function createClient(
model: Model<"google-vertex">,
project: string,
location: string,
optionsHeaders?: ProviderHeaders,
env?: ProviderEnv,
): GoogleGenAI {
const googleAuthOptions = buildGoogleAuthOptions(env);
return new GoogleGenAI({
vertexai: true,
project,
location,
apiVersion: API_VERSION,
...(googleAuthOptions ? { googleAuthOptions } : {}),
httpOptions: buildHttpOptions(model, optionsHeaders),
});
}
function createClientWithApiKey(
model: Model<"google-vertex">,
apiKey: string,
optionsHeaders?: ProviderHeaders,
): GoogleGenAI {
return new GoogleGenAI({
vertexai: true,
apiKey,
apiVersion: API_VERSION,
httpOptions: buildHttpOptions(model, optionsHeaders),
});
}
function buildHttpOptions(model: Model<"google-vertex">, optionsHeaders?: ProviderHeaders): HttpOptions | undefined {
const httpOptions: HttpOptions = {};
const baseUrl = resolveCustomBaseUrl(model.baseUrl);
if (baseUrl) {
httpOptions.baseUrl = baseUrl;
httpOptions.baseUrlResourceScope = ResourceScope.COLLECTION;
if (baseUrlIncludesApiVersion(baseUrl)) {
httpOptions.apiVersion = "";
}
}
const headers = providerHeadersToRecord({ ...model.headers, ...optionsHeaders });
if (headers) {
httpOptions.headers = headers;
}
return Object.keys(httpOptions).length > 0 ? httpOptions : undefined;
}
function resolveCustomBaseUrl(baseUrl: string): string | undefined {
const trimmed = baseUrl.trim();
if (!trimmed || trimmed.includes("{location}")) {
return undefined;
}
return trimmed;
}
function baseUrlIncludesApiVersion(baseUrl: string): boolean {
try {
const url = new URL(baseUrl);
return url.pathname.split("/").some((part) => /^v\d+(?:beta\d*)?$/.test(part));
} catch {
return /(?:^|\/)v\d+(?:beta\d*)?(?:\/|$)/.test(baseUrl);
}
}
function buildGoogleAuthOptions(env?: ProviderEnv): { keyFilename: string } | undefined {
const keyFilename = getProviderEnvValue("GOOGLE_APPLICATION_CREDENTIALS", env);
return keyFilename ? { keyFilename } : undefined;
}
function resolveApiKey(options?: GoogleVertexOptions): string | undefined {
const apiKey = options?.apiKey?.trim();
if (!apiKey || apiKey === GCP_VERTEX_CREDENTIALS_MARKER || isPlaceholderApiKey(apiKey)) {
return undefined;
}
return apiKey;
}
function isPlaceholderApiKey(apiKey: string): boolean {
return /^<[^>]+>$/.test(apiKey);
}
function resolveProject(options?: GoogleVertexOptions): string {
const project =
options?.project ||
getProviderEnvValue("GOOGLE_CLOUD_PROJECT", options?.env) ||
getProviderEnvValue("GCLOUD_PROJECT", options?.env);
if (!project) {
throw new Error(
"Vertex AI requires a project ID. Set GOOGLE_CLOUD_PROJECT/GCLOUD_PROJECT or pass project in options.",
);
}
return project;
}
function resolveLocation(options?: GoogleVertexOptions): string {
const location = options?.location || getProviderEnvValue("GOOGLE_CLOUD_LOCATION", options?.env);
if (!location) {
throw new Error("Vertex AI requires a location. Set GOOGLE_CLOUD_LOCATION or pass location in options.");
}
return location;
}
function buildParams(
model: Model<"google-vertex">,
context: Context,
options: GoogleVertexOptions = {},
): GenerateContentParameters {
const contents = convertMessages(model, context);
const generationConfig: GenerateContentConfig = {};
if (options.temperature !== undefined) {
generationConfig.temperature = options.temperature;
}
if (options.maxTokens !== undefined) {
generationConfig.maxOutputTokens = options.maxTokens;
}
const config: GenerateContentConfig = {
...(Object.keys(generationConfig).length > 0 && generationConfig),
...(context.systemPrompt && { systemInstruction: sanitizeSurrogates(context.systemPrompt) }),
...(context.tools && context.tools.length > 0 && { tools: convertTools(context.tools) }),
};
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) {
const thinkingConfig: ThinkingConfig = { includeThoughts: true };
if (options.thinking.level !== undefined) {
thinkingConfig.thinkingLevel = THINKING_LEVEL_MAP[options.thinking.level];
} else if (options.thinking.budgetTokens !== undefined) {
thinkingConfig.thinkingBudget = options.thinking.budgetTokens;
}
config.thinkingConfig = thinkingConfig;
} else if (model.reasoning && options.thinking && !options.thinking.enabled) {
config.thinkingConfig = getDisabledThinkingConfig(model);
}
if (options.signal) {
if (options.signal.aborted) {
throw new Error("Request aborted");
}
config.abortSignal = options.signal;
}
const params: GenerateContentParameters = {
model: model.id,
contents,
config,
};
return params;
}
type ClampedThinkingLevel = Exclude<PiThinkingLevel, "xhigh" | "max">;
function isGemini3ProModel(model: Model<"google-generative-ai">): boolean {
return /gemini-3(?:\.\d+)?-pro/.test(model.id.toLowerCase());
}
function isGemini3FlashModel(model: Model<"google-generative-ai">): boolean {
const id = model.id.toLowerCase();
return /gemini-3(?:\.\d+)?-flash/.test(id) || id === "gemini-flash-latest" || id === "gemini-flash-lite-latest";
}
function getDisabledThinkingConfig(model: Model<"google-vertex">): ThinkingConfig {
// Google docs: Gemini 3.1 Pro cannot disable thinking, and Gemini 3 Flash / Flash-Lite
// do not support full thinking-off either. For Gemini 3 models, use the lowest supported
// thinkingLevel without includeThoughts so hidden thinking remains invisible to pi.
const geminiModel = model as unknown as Model<"google-generative-ai">;
if (isGemini3ProModel(geminiModel)) {
return { thinkingLevel: ThinkingLevel.LOW };
}
if (isGemini3FlashModel(geminiModel)) {
return { thinkingLevel: ThinkingLevel.MINIMAL };
}
// Gemini 2.x supports disabling via thinkingBudget = 0.
return { thinkingBudget: 0 };
}
function getGemini3ThinkingLevel(
effort: ClampedThinkingLevel,
model: Model<"google-generative-ai">,
): GoogleThinkingLevel {
if (isGemini3ProModel(model)) {
switch (effort) {
case "minimal":
case "low":
return "LOW";
case "medium":
case "high":
return "HIGH";
}
}
switch (effort) {
case "minimal":
return "MINIMAL";
case "low":
return "LOW";
case "medium":
return "MEDIUM";
case "high":
return "HIGH";
}
}
function getGoogleBudget(
model: Model<"google-generative-ai">,
effort: ClampedThinkingLevel,
customBudgets?: ThinkingBudgets,
): number {
if (customBudgets?.[effort] !== undefined) {
return customBudgets[effort]!;
}
if (model.id.includes("2.5-pro")) {
const budgets: Record<ClampedThinkingLevel, number> = {
minimal: 128,
low: 2048,
medium: 8192,
high: 32768,
};
return budgets[effort];
}
if (model.id.includes("2.5-flash")) {
const budgets: Record<ClampedThinkingLevel, number> = {
minimal: 128,
low: 2048,
medium: 8192,
high: 24576,
};
return budgets[effort];
}
return -1;
}

View file

@ -1,75 +0,0 @@
import type { Api, AssistantMessage, AssistantMessageEvent, Model, ProviderStreams } from "../types.ts";
import { AssistantMessageEventStream } from "../utils/event-stream.ts";
function createSetupErrorMessage(model: Model<Api>, error: unknown): AssistantMessage {
return {
role: "assistant",
content: [],
api: model.api,
provider: model.provider,
model: model.id,
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "error",
errorMessage: error instanceof Error ? error.message : String(error),
timestamp: Date.now(),
};
}
function hasResult(
source: AsyncIterable<AssistantMessageEvent>,
): source is AsyncIterable<AssistantMessageEvent> & { result(): Promise<AssistantMessage> } {
return typeof (source as { result?: unknown }).result === "function";
}
async function forwardStream(
target: AssistantMessageEventStream,
source: AsyncIterable<AssistantMessageEvent>,
): Promise<void> {
for await (const event of source) {
target.push(event);
}
target.end(hasResult(source) ? await source.result() : undefined);
}
/**
* Returns a stream synchronously while running async setup (auth resolution,
* lazy module loading) behind it. Setup failures terminate the stream with an
* error event.
*/
export function lazyStream(
model: Model<Api>,
setup: () => Promise<AsyncIterable<AssistantMessageEvent>>,
): AssistantMessageEventStream {
const outer = new AssistantMessageEventStream();
setup()
.then((inner) => forwardStream(outer, inner))
.catch((error) => {
const message = createSetupErrorMessage(model, error);
outer.push({ type: "error", reason: "error", error: message });
outer.end(message);
});
return outer;
}
/**
* Wraps a dynamically imported API implementation module as `ProviderStreams`.
* The module loads on first stream call; the host's import cache deduplicates
* loads. Load failures terminate the returned stream with an error event.
*/
export function lazyApi(load: () => Promise<ProviderStreams>): ProviderStreams {
return {
stream: (model, context, options) =>
lazyStream(model, async () => (await load()).stream(model, context, options)),
streamSimple: (model, context, options) =>
lazyStream(model, async () => (await load()).streamSimple(model, context, options)),
};
}

View file

@ -1,4 +0,0 @@
import type { ProviderStreams } from "../types.ts";
import { lazyApi } from "./lazy.ts";
export const mistralConversationsApi = (): ProviderStreams => lazyApi(() => import("./mistral-conversations.ts"));

View file

@ -1,664 +0,0 @@
import { Mistral } from "@mistralai/mistralai";
import type {
ChatCompletionStreamRequest,
ChatCompletionStreamRequestMessage,
CompletionEvent,
ContentChunk,
FunctionTool,
} from "@mistralai/mistralai/models/components";
import { calculateCost, clampThinkingLevel } from "../models.ts";
import type {
AssistantMessage,
Context,
Message,
Model,
SimpleStreamOptions,
StopReason,
StreamFunction,
StreamOptions,
TextContent,
ThinkingContent,
Tool,
ToolCall,
} from "../types.ts";
import { AssistantMessageEventStream } from "../utils/event-stream.ts";
import { shortHash } from "../utils/hash.ts";
import { parseStreamingJson } from "../utils/json-parse.ts";
import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts";
import { buildBaseOptions } from "./simple-options.ts";
import { transformMessages } from "./transform-messages.ts";
const MISTRAL_TOOL_CALL_ID_LENGTH = 9;
const MAX_MISTRAL_ERROR_BODY_CHARS = 4000;
/**
* Provider-specific options for the Mistral API.
*/
type MistralReasoningEffort = "none" | "high";
export interface MistralOptions extends StreamOptions {
toolChoice?: "auto" | "none" | "any" | "required" | { type: "function"; function: { name: string } };
promptMode?: "reasoning";
reasoningEffort?: MistralReasoningEffort;
}
/**
* Stream responses from Mistral using `chat.stream`.
*/
export const stream: StreamFunction<"mistral-conversations", MistralOptions> = (
model: Model<"mistral-conversations">,
context: Context,
options?: MistralOptions,
): AssistantMessageEventStream => {
const stream = new AssistantMessageEventStream();
(async () => {
const output = createOutput(model);
try {
const apiKey = options?.apiKey;
if (!apiKey) {
throw new Error(`No API key for provider: ${model.provider}`);
}
// Intentionally per-request: avoids shared SDK mutable state across concurrent consumers.
const mistral = new Mistral({
apiKey,
serverURL: model.baseUrl,
});
const normalizeMistralToolCallId = createMistralToolCallIdNormalizer();
const transformedMessages = transformMessages(context.messages, model, (id) => normalizeMistralToolCallId(id));
let payload = buildChatPayload(model, context, transformedMessages, options);
const nextPayload = await options?.onPayload?.(payload, model);
if (nextPayload !== undefined) {
payload = nextPayload as ChatCompletionStreamRequest;
}
const mistralStream = await mistral.chat.stream(payload, buildRequestOptions(model, options));
stream.push({ type: "start", partial: output });
await consumeChatStream(model, output, stream, mistralStream);
if (options?.signal?.aborted) {
throw new Error("Request was aborted");
}
if (output.stopReason === "aborted" || output.stopReason === "error") {
throw new Error("An unknown error occurred");
}
stream.push({ type: "done", reason: output.stopReason, message: output });
stream.end();
} catch (error) {
for (const block of output.content) {
// partialArgs is only a streaming scratch buffer; never persist it.
delete (block as { partialArgs?: string }).partialArgs;
}
output.stopReason = options?.signal?.aborted ? "aborted" : "error";
output.errorMessage = formatMistralError(error);
stream.push({ type: "error", reason: output.stopReason, error: output });
stream.end();
}
})();
return stream;
};
/**
* Maps provider-agnostic `SimpleStreamOptions` to Mistral options.
*/
export const streamSimple: StreamFunction<"mistral-conversations", SimpleStreamOptions> = (
model: Model<"mistral-conversations">,
context: Context,
options?: SimpleStreamOptions,
): AssistantMessageEventStream => {
const apiKey = options?.apiKey;
if (!apiKey) {
throw new Error(`No API key for provider: ${model.provider}`);
}
const base = buildBaseOptions(model, context, options, apiKey);
const clampedReasoning = options?.reasoning ? clampThinkingLevel(model, options.reasoning) : undefined;
const reasoning = clampedReasoning === "off" ? undefined : clampedReasoning;
const shouldUseReasoning = model.reasoning && reasoning !== undefined;
return stream(model, context, {
...base,
promptMode: shouldUseReasoning && usesPromptModeReasoning(model) ? "reasoning" : undefined,
reasoningEffort:
shouldUseReasoning && usesReasoningEffort(model) ? mapReasoningEffort(model, reasoning) : undefined,
} satisfies MistralOptions);
};
function createOutput(model: Model<"mistral-conversations">): AssistantMessage {
return {
role: "assistant",
content: [],
api: model.api,
provider: model.provider,
model: model.id,
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop",
timestamp: Date.now(),
};
}
function createMistralToolCallIdNormalizer(): (id: string) => string {
const idMap = new Map<string, string>();
const reverseMap = new Map<string, string>();
return (id: string): string => {
const existing = idMap.get(id);
if (existing) return existing;
let attempt = 0;
while (true) {
const candidate = deriveMistralToolCallId(id, attempt);
const owner = reverseMap.get(candidate);
if (!owner || owner === id) {
idMap.set(id, candidate);
reverseMap.set(candidate, id);
return candidate;
}
attempt++;
}
};
}
function deriveMistralToolCallId(id: string, attempt: number): string {
const normalized = id.replace(/[^a-zA-Z0-9]/g, "");
if (attempt === 0 && normalized.length === MISTRAL_TOOL_CALL_ID_LENGTH) return normalized;
const seedBase = normalized || id;
const seed = attempt === 0 ? seedBase : `${seedBase}:${attempt}`;
return shortHash(seed)
.replace(/[^a-zA-Z0-9]/g, "")
.slice(0, MISTRAL_TOOL_CALL_ID_LENGTH);
}
function formatMistralError(error: unknown): string {
if (error instanceof Error) {
const sdkError = error as Error & { statusCode?: unknown; body?: unknown };
const statusCode = typeof sdkError.statusCode === "number" ? sdkError.statusCode : undefined;
const bodyText = typeof sdkError.body === "string" ? sdkError.body.trim() : undefined;
if (statusCode !== undefined && bodyText) {
return `Mistral API error (${statusCode}): ${truncateErrorText(bodyText, MAX_MISTRAL_ERROR_BODY_CHARS)}`;
}
if (statusCode !== undefined) return `Mistral API error (${statusCode}): ${error.message}`;
return error.message;
}
return safeJsonStringify(error);
}
function truncateErrorText(text: string, maxChars: number): string {
if (text.length <= maxChars) return text;
return `${text.slice(0, maxChars)}... [truncated ${text.length - maxChars} chars]`;
}
function safeJsonStringify(value: unknown): string {
try {
const serialized = JSON.stringify(value);
return serialized === undefined ? String(value) : serialized;
} catch {
return String(value);
}
}
function buildRequestOptions(model: Model<"mistral-conversations">, options?: MistralOptions) {
const requestOptions: {
signal?: AbortSignal;
retries: { strategy: "none" };
headers?: Record<string, string>;
} = {
retries: { strategy: "none" },
};
if (options?.signal) requestOptions.signal = options.signal;
const headers: Record<string, string> = {};
if (model.headers) Object.assign(headers, model.headers);
if (options?.headers) Object.assign(headers, options.headers);
// Mistral infrastructure uses `x-affinity` for KV-cache reuse (prefix caching).
// Respect explicit caller-provided header values.
if (shouldUsePromptCaching(options) && !headers["x-affinity"]) {
headers["x-affinity"] = options.sessionId;
}
if (Object.keys(headers).length > 0) {
requestOptions.headers = headers;
}
return requestOptions;
}
function buildChatPayload(
model: Model<"mistral-conversations">,
context: Context,
messages: Message[],
options?: MistralOptions,
): ChatCompletionStreamRequest {
const payload: ChatCompletionStreamRequest = {
model: model.id,
stream: true,
messages: toChatMessages(messages, model.input.includes("image")),
};
if (context.tools?.length) payload.tools = toFunctionTools(context.tools);
if (options?.temperature !== undefined) payload.temperature = options.temperature;
if (options?.maxTokens !== undefined) payload.maxTokens = options.maxTokens;
if (options?.toolChoice) payload.toolChoice = mapToolChoice(options.toolChoice);
if (options?.promptMode) payload.promptMode = options.promptMode;
if (options?.reasoningEffort) payload.reasoningEffort = options.reasoningEffort;
if (shouldUsePromptCaching(options)) payload.promptCacheKey = options.sessionId;
if (context.systemPrompt) {
payload.messages.unshift({
role: "system",
content: sanitizeSurrogates(context.systemPrompt),
});
}
return payload;
}
function shouldUsePromptCaching(options?: MistralOptions): options is MistralOptions & { sessionId: string } {
return options?.cacheRetention !== "none" && !!options?.sessionId;
}
function getMistralCachedPromptTokens(usage: unknown, promptTokens: number): number {
const rawUsage = usage as {
promptTokensDetails?: { cachedTokens?: unknown } | null;
prompt_tokens_details?: { cached_tokens?: unknown } | null;
promptTokenDetails?: { cachedTokens?: unknown } | null;
prompt_token_details?: { cached_tokens?: unknown } | null;
numCachedTokens?: unknown;
num_cached_tokens?: unknown;
};
const rawCachedTokens =
rawUsage.promptTokensDetails?.cachedTokens ??
rawUsage.prompt_tokens_details?.cached_tokens ??
rawUsage.promptTokenDetails?.cachedTokens ??
rawUsage.prompt_token_details?.cached_tokens ??
rawUsage.numCachedTokens ??
rawUsage.num_cached_tokens ??
0;
const cachedTokens = typeof rawCachedTokens === "number" && Number.isFinite(rawCachedTokens) ? rawCachedTokens : 0;
return Math.min(promptTokens, Math.max(0, cachedTokens));
}
async function consumeChatStream(
model: Model<"mistral-conversations">,
output: AssistantMessage,
stream: AssistantMessageEventStream,
mistralStream: AsyncIterable<CompletionEvent>,
): Promise<void> {
let currentBlock: TextContent | ThinkingContent | null = null;
const blocks = output.content;
const blockIndex = () => blocks.length - 1;
const toolBlocksByKey = new Map<string, number>();
const finishCurrentBlock = (block?: typeof currentBlock) => {
if (!block) return;
if (block.type === "text") {
stream.push({
type: "text_end",
contentIndex: blockIndex(),
content: block.text,
partial: output,
});
return;
}
if (block.type === "thinking") {
stream.push({
type: "thinking_end",
contentIndex: blockIndex(),
content: block.thinking,
partial: output,
});
}
};
for await (const event of mistralStream) {
const chunk = event.data;
// Mistral's streamed CompletionChunk carries an id field. Keep the first non-empty one,
// mirroring how OpenAI-style streaming exposes a stable response identifier per stream.
output.responseId ||= chunk.id;
if (chunk.usage) {
const promptTokens = chunk.usage.promptTokens || 0;
const cachedPromptTokens = getMistralCachedPromptTokens(chunk.usage, promptTokens);
output.usage.input = Math.max(0, promptTokens - cachedPromptTokens);
output.usage.output = chunk.usage.completionTokens || 0;
output.usage.cacheRead = cachedPromptTokens;
output.usage.cacheWrite = 0;
output.usage.totalTokens =
chunk.usage.totalTokens ||
output.usage.input + output.usage.output + output.usage.cacheRead + output.usage.cacheWrite;
calculateCost(model, output.usage);
}
const choice = chunk.choices[0];
if (!choice) continue;
if (choice.finishReason) {
output.stopReason = mapChatStopReason(choice.finishReason);
}
const delta = choice.delta;
if (delta.content !== null && delta.content !== undefined) {
const contentItems = typeof delta.content === "string" ? [delta.content] : delta.content;
for (const item of contentItems) {
if (typeof item === "string") {
const textDelta = sanitizeSurrogates(item);
if (!currentBlock || currentBlock.type !== "text") {
finishCurrentBlock(currentBlock);
currentBlock = { type: "text", text: "" };
output.content.push(currentBlock);
stream.push({ type: "text_start", contentIndex: blockIndex(), partial: output });
}
currentBlock.text += textDelta;
stream.push({
type: "text_delta",
contentIndex: blockIndex(),
delta: textDelta,
partial: output,
});
continue;
}
if (item.type === "thinking") {
const deltaText = item.thinking
.map((part) => ("text" in part ? part.text : ""))
.filter((text) => text.length > 0)
.join("");
const thinkingDelta = sanitizeSurrogates(deltaText);
if (!thinkingDelta) continue;
if (!currentBlock || currentBlock.type !== "thinking") {
finishCurrentBlock(currentBlock);
currentBlock = { type: "thinking", thinking: "" };
output.content.push(currentBlock);
stream.push({ type: "thinking_start", contentIndex: blockIndex(), partial: output });
}
currentBlock.thinking += thinkingDelta;
stream.push({
type: "thinking_delta",
contentIndex: blockIndex(),
delta: thinkingDelta,
partial: output,
});
continue;
}
if (item.type === "text") {
const textDelta = sanitizeSurrogates(item.text);
if (!currentBlock || currentBlock.type !== "text") {
finishCurrentBlock(currentBlock);
currentBlock = { type: "text", text: "" };
output.content.push(currentBlock);
stream.push({ type: "text_start", contentIndex: blockIndex(), partial: output });
}
currentBlock.text += textDelta;
stream.push({
type: "text_delta",
contentIndex: blockIndex(),
delta: textDelta,
partial: output,
});
}
}
}
const toolCalls = delta.toolCalls || [];
for (const toolCall of toolCalls) {
if (currentBlock) {
finishCurrentBlock(currentBlock);
currentBlock = null;
}
const callId =
toolCall.id && toolCall.id !== "null"
? toolCall.id
: deriveMistralToolCallId(`toolcall:${toolCall.index ?? 0}`, 0);
const key = `${callId}:${toolCall.index || 0}`;
const existingIndex = toolBlocksByKey.get(key);
let block: (ToolCall & { partialArgs?: string }) | undefined;
if (existingIndex !== undefined) {
const existing = output.content[existingIndex];
if (existing?.type === "toolCall") {
block = existing as ToolCall & { partialArgs?: string };
}
}
if (!block) {
block = {
type: "toolCall",
id: callId,
name: toolCall.function.name,
arguments: {},
partialArgs: "",
};
output.content.push(block);
toolBlocksByKey.set(key, output.content.length - 1);
stream.push({ type: "toolcall_start", contentIndex: output.content.length - 1, partial: output });
}
const argsDelta =
typeof toolCall.function.arguments === "string"
? toolCall.function.arguments
: JSON.stringify(toolCall.function.arguments || {});
block.partialArgs = (block.partialArgs || "") + argsDelta;
block.arguments = parseStreamingJson<Record<string, unknown>>(block.partialArgs);
stream.push({
type: "toolcall_delta",
contentIndex: toolBlocksByKey.get(key)!,
delta: argsDelta,
partial: output,
});
}
}
finishCurrentBlock(currentBlock);
for (const index of toolBlocksByKey.values()) {
const block = output.content[index];
if (block.type !== "toolCall") continue;
const toolBlock = block as ToolCall & { partialArgs?: string };
toolBlock.arguments = parseStreamingJson<Record<string, unknown>>(toolBlock.partialArgs);
// Finalize in-place and strip the scratch buffer so replay only
// carries parsed arguments.
delete toolBlock.partialArgs;
stream.push({
type: "toolcall_end",
contentIndex: index,
toolCall: toolBlock,
partial: output,
});
}
}
function toFunctionTools(tools: Tool[]): Array<FunctionTool & { type: "function" }> {
return tools.map((tool) => ({
type: "function",
function: {
name: tool.name,
description: tool.description,
parameters: stripSymbolKeys(tool.parameters) as Record<string, unknown>,
strict: false,
},
}));
}
function stripSymbolKeys(value: unknown): unknown {
if (Array.isArray(value)) {
return value.map((item) => stripSymbolKeys(item));
}
if (value && typeof value === "object") {
const result: Record<string, unknown> = {};
for (const [key, entry] of Object.entries(value)) {
result[key] = stripSymbolKeys(entry);
}
return result;
}
return value;
}
function toChatMessages(messages: Message[], supportsImages: boolean): ChatCompletionStreamRequestMessage[] {
const result: ChatCompletionStreamRequestMessage[] = [];
for (const msg of messages) {
if (msg.role === "user") {
if (typeof msg.content === "string") {
result.push({ role: "user", content: sanitizeSurrogates(msg.content) });
continue;
}
const hadImages = msg.content.some((item) => item.type === "image");
const content: ContentChunk[] = msg.content
.filter((item) => item.type === "text" || supportsImages)
.map((item) => {
if (item.type === "text") return { type: "text", text: sanitizeSurrogates(item.text) };
return { type: "image_url", imageUrl: `data:${item.mimeType};base64,${item.data}` };
});
if (content.length > 0) {
result.push({ role: "user", content });
continue;
}
if (hadImages && !supportsImages) {
result.push({ role: "user", content: "(image omitted: model does not support images)" });
}
continue;
}
if (msg.role === "assistant") {
const contentParts: ContentChunk[] = [];
const toolCalls: Array<{ id: string; type: "function"; function: { name: string; arguments: string } }> = [];
for (const block of msg.content) {
if (block.type === "text") {
if (block.text.trim().length > 0) {
contentParts.push({ type: "text", text: sanitizeSurrogates(block.text) });
}
continue;
}
if (block.type === "thinking") {
if (block.thinking.trim().length > 0) {
contentParts.push({
type: "thinking",
thinking: [{ type: "text", text: sanitizeSurrogates(block.thinking) }],
});
}
continue;
}
toolCalls.push({
id: block.id,
type: "function",
function: { name: block.name, arguments: JSON.stringify(block.arguments || {}) },
});
}
const assistantMessage: ChatCompletionStreamRequestMessage = { role: "assistant" };
if (contentParts.length > 0) assistantMessage.content = contentParts;
if (toolCalls.length > 0) assistantMessage.toolCalls = toolCalls;
if (contentParts.length > 0 || toolCalls.length > 0) result.push(assistantMessage);
continue;
}
const toolContent: ContentChunk[] = [];
const textResult = msg.content
.filter((part) => part.type === "text")
.map((part) => (part.type === "text" ? sanitizeSurrogates(part.text) : ""))
.join("\n");
const hasImages = msg.content.some((part) => part.type === "image");
const toolText = buildToolResultText(textResult, hasImages, supportsImages, msg.isError);
toolContent.push({ type: "text", text: toolText });
for (const part of msg.content) {
if (!supportsImages) continue;
if (part.type !== "image") continue;
toolContent.push({
type: "image_url",
imageUrl: `data:${part.mimeType};base64,${part.data}`,
});
}
result.push({
role: "tool",
toolCallId: msg.toolCallId,
name: msg.toolName,
content: toolContent,
});
}
return result;
}
function buildToolResultText(text: string, hasImages: boolean, supportsImages: boolean, isError: boolean): string {
const trimmed = text.trim();
const errorPrefix = isError ? "[tool error] " : "";
if (trimmed.length > 0) {
const imageSuffix = hasImages && !supportsImages ? "\n[tool image omitted: model does not support images]" : "";
return `${errorPrefix}${trimmed}${imageSuffix}`;
}
if (hasImages) {
if (supportsImages) {
return isError ? "[tool error] (see attached image)" : "(see attached image)";
}
return isError
? "[tool error] (image omitted: model does not support images)"
: "(image omitted: model does not support images)";
}
return isError ? "[tool error] (no tool output)" : "(no tool output)";
}
function usesReasoningEffort(model: Model<"mistral-conversations">): boolean {
return model.id === "mistral-small-2603" || model.id === "mistral-small-latest" || model.id === "mistral-medium-3.5";
}
function usesPromptModeReasoning(model: Model<"mistral-conversations">): boolean {
return model.reasoning && !usesReasoningEffort(model);
}
function mapReasoningEffort(
model: Model<"mistral-conversations">,
level: Exclude<SimpleStreamOptions["reasoning"], undefined>,
): MistralReasoningEffort {
return (model.thinkingLevelMap?.[level] ?? "high") as MistralReasoningEffort;
}
function mapToolChoice(
choice: MistralOptions["toolChoice"],
): "auto" | "none" | "any" | "required" | { type: "function"; function: { name: string } } | undefined {
if (!choice) return undefined;
if (choice === "auto" || choice === "none" || choice === "any" || choice === "required") {
return choice as any;
}
return {
type: "function",
function: { name: choice.function.name },
};
}
function mapChatStopReason(reason: string | null): StopReason {
if (reason === null) return "stop";
switch (reason) {
case "stop":
return "stop";
case "length":
case "model_length":
return "length";
case "tool_calls":
return "toolUse";
case "error":
return "error";
default:
return "stop";
}
}

View file

@ -1,4 +0,0 @@
import type { ProviderStreams } from "../types.ts";
import { lazyApi } from "./lazy.ts";
export const openAICodexResponsesApi = (): ProviderStreams => lazyApi(() => import("./openai-codex-responses.ts"));

View file

@ -1,4 +0,0 @@
import type { ProviderStreams } from "../types.ts";
import { lazyApi } from "./lazy.ts";
export const openAICompletionsApi = (): ProviderStreams => lazyApi(() => import("./openai-completions.ts"));

View file

@ -1,4 +0,0 @@
import type { ProviderStreams } from "../types.ts";
import { lazyApi } from "./lazy.ts";
export const openAIResponsesApi = (): ProviderStreams => lazyApi(() => import("./openai-responses.ts"));

View file

@ -1,10 +0,0 @@
import type { ImagesModel, ProviderImages } from "../types.ts";
export const openrouterImagesApi = (): ProviderImages => ({
generateImages: async (model, context, options) =>
(await import("./openrouter-images.ts")).generateImages(
model as ImagesModel<"openrouter-images">,
context,
options,
),
});

View file

@ -1,4 +0,0 @@
import type { ProviderStreams } from "../types.ts";
import { lazyApi } from "./lazy.ts";
export const piMessagesApi = (): ProviderStreams => lazyApi(() => import("./pi-messages.ts"));

View file

@ -1,436 +0,0 @@
/**
* pi-messages API implementation.
*
* Streams pi's own message protocol directly to a backend: the request is a
* single POST of `{ model, context, options }` to `<baseUrl>/messages`, the
* response is an SSE stream of serialized assistant-message events plus a
* terminal `done`/`error` event. This is the wire protocol spoken by the
* Radius gateway, but any backend implementing it can be used, e.g. via a
* models.json custom provider with `"api": "pi-messages"`.
*/
import type {
AssistantMessage,
AssistantMessageEvent,
CacheRetention,
Context,
Model,
ProviderEnv,
SimpleStreamOptions,
StreamFunction,
StreamOptions,
ThinkingLevel,
ToolCall,
} from "../types.ts";
import { appendAssistantMessageDiagnostic, createAssistantMessageDiagnostic } from "../utils/diagnostics.ts";
import { AssistantMessageEventStream } from "../utils/event-stream.ts";
import { headersToRecord, providerHeadersToRecord } from "../utils/headers.ts";
import { parseStreamingJson } from "../utils/json-parse.ts";
import { getProviderEnvValue } from "../utils/provider-env.ts";
export interface PiMessagesOptions extends StreamOptions {
reasoning?: ThinkingLevel;
toolChoice?: "auto" | "none" | "required" | { type: "function"; function: { name: string } };
/** Ask the backend for debug metadata (e.g. routing response headers). */
debug?: boolean;
}
type PiMessagesUsage = AssistantMessage["usage"];
type PiMessagesStopReason = AssistantMessage["stopReason"];
/** Impact summary of a server-side message rewrite (e.g. a gateway policy). */
export type PiMessagesRewriteImpact = {
policyId: string;
policyVersion: number;
changed: boolean;
tokenCountChange: number;
messageCountChange: number;
systemPromptChanged: boolean;
};
/** Serialized assistant-message event as sent by a pi-messages backend. */
export type PiMessagesEvent =
| { type: "start" }
| { type: "text_start"; contentIndex: number }
| { type: "text_delta"; contentIndex: number; delta: string }
| { type: "text_end"; contentIndex: number; content: string; contentSignature?: string }
| { type: "thinking_start"; contentIndex: number }
| { type: "thinking_delta"; contentIndex: number; delta: string }
| {
type: "thinking_end";
contentIndex: number;
content: string;
contentSignature?: string;
redacted?: boolean;
}
| { type: "toolcall_start"; contentIndex: number; id: string; toolName: string }
| { type: "toolcall_delta"; contentIndex: number; delta: string }
| { type: "toolcall_end"; contentIndex: number; toolCall: ToolCall }
| {
type: "done";
reason: Extract<PiMessagesStopReason, "stop" | "length" | "toolUse">;
usage: PiMessagesUsage;
responseId?: string;
rewrite?: PiMessagesRewriteImpact;
}
| {
type: "error";
reason: Extract<PiMessagesStopReason, "aborted" | "error">;
usage: PiMessagesUsage;
errorMessage?: string;
responseId?: string;
rewrite?: PiMessagesRewriteImpact;
};
type PiMessagesErrorBody = {
error?: {
message?: unknown;
code?: unknown;
details?: unknown;
[key: string]: unknown;
};
};
export class PiMessagesResponseError extends Error {
code?: string;
readonly diagnosticDetails: Record<string, unknown>;
constructor(message: string, code: string | undefined, diagnosticDetails: Record<string, unknown>) {
super(message);
this.name = "PiMessagesResponseError";
this.code = code;
this.diagnosticDetails = diagnosticDetails;
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function parsePiMessagesErrorBody(body: string): PiMessagesErrorBody | undefined {
try {
const parsed = JSON.parse(body) as unknown;
return isRecord(parsed) && isRecord(parsed.error) ? (parsed as PiMessagesErrorBody) : undefined;
} catch {
return undefined;
}
}
function truncateDiagnosticString(value: string): string {
const maxLength = 8192;
return value.length > maxLength ? `${value.slice(0, maxLength)}` : value;
}
function formatPiMessagesResponseError(
response: Response,
body: string,
errorBody: PiMessagesErrorBody | undefined,
): string {
const message = typeof errorBody?.error?.message === "string" ? errorBody.error.message : undefined;
const code = typeof errorBody?.error?.code === "string" ? errorBody.error.code : undefined;
const suffix = message ?? body;
const codeSuffix = code ? ` (${code})` : "";
return `${response.status} ${response.statusText}: ${suffix}${codeSuffix}`;
}
function createPiMessagesResponseError(
model: Model<"pi-messages">,
url: URL,
response: Response,
body: string,
): PiMessagesResponseError {
const errorBody = parsePiMessagesErrorBody(body);
const code = typeof errorBody?.error?.code === "string" ? errorBody.error.code : undefined;
return new PiMessagesResponseError(formatPiMessagesResponseError(response, body, errorBody), code, {
version: 1,
provider: model.provider,
model: model.id,
url: url.toString(),
status: response.status,
statusText: response.statusText,
error: errorBody?.error,
body: errorBody ? undefined : truncateDiagnosticString(body),
timestampMs: Date.now(),
});
}
function createEmptyUsage(): PiMessagesUsage {
return {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
};
}
function appendRewriteDiagnostic(message: AssistantMessage, rewrite: PiMessagesRewriteImpact | undefined): void {
if (!rewrite) {
return;
}
appendAssistantMessageDiagnostic(message, {
type: "pi_messages_rewrite",
timestamp: Date.now(),
details: { ...rewrite },
});
}
function createEventConverter(model: Model<"pi-messages">) {
const partial: AssistantMessage = {
role: "assistant",
content: [],
api: model.api,
provider: model.provider,
model: model.id,
usage: createEmptyUsage(),
stopReason: "stop",
timestamp: Date.now(),
};
const toolJson = new Map<number, string>();
return (event: PiMessagesEvent): AssistantMessageEvent => {
switch (event.type) {
case "done":
Object.assign(partial, {
stopReason: event.reason,
usage: event.usage,
responseId: event.responseId,
});
appendRewriteDiagnostic(partial, event.rewrite);
return { type: "done", reason: event.reason, message: partial };
case "error":
Object.assign(partial, {
stopReason: event.reason,
usage: event.usage,
errorMessage: event.errorMessage,
responseId: event.responseId,
});
appendRewriteDiagnostic(partial, event.rewrite);
return { type: "error", reason: event.reason, error: partial };
case "start":
break;
case "text_start":
partial.content[event.contentIndex] = { type: "text", text: "" };
break;
case "text_delta":
(partial.content[event.contentIndex] as { text: string }).text += event.delta;
break;
case "text_end":
Object.assign(partial.content[event.contentIndex]!, {
text: event.content,
textSignature: event.contentSignature,
});
break;
case "thinking_start":
partial.content[event.contentIndex] = { type: "thinking", thinking: "" };
break;
case "thinking_delta":
(partial.content[event.contentIndex] as { thinking: string }).thinking += event.delta;
break;
case "thinking_end":
Object.assign(partial.content[event.contentIndex]!, {
thinking: event.content,
thinkingSignature: event.contentSignature,
redacted: event.redacted,
});
break;
case "toolcall_start":
partial.content[event.contentIndex] = {
type: "toolCall",
id: event.id,
name: event.toolName,
arguments: {},
};
toolJson.set(event.contentIndex, "");
break;
case "toolcall_delta": {
const json = `${toolJson.get(event.contentIndex) ?? ""}${event.delta}`;
toolJson.set(event.contentIndex, json);
(partial.content[event.contentIndex] as ToolCall).arguments =
parseStreamingJson<ToolCall["arguments"]>(json);
break;
}
case "toolcall_end":
Object.assign(partial.content[event.contentIndex]!, event.toolCall);
toolJson.delete(event.contentIndex);
return {
type: "toolcall_end",
contentIndex: event.contentIndex,
toolCall: partial.content[event.contentIndex] as ToolCall,
partial,
};
}
return { ...event, partial } as AssistantMessageEvent;
};
}
async function* readPiMessagesEvents(stream: ReadableStream<Uint8Array>): AsyncGenerator<PiMessagesEvent> {
const decoder = new TextDecoder();
const reader = stream.getReader();
let buffer = "";
try {
while (true) {
const { done, value } = await reader.read();
buffer += done ? decoder.decode() : decoder.decode(value, { stream: true });
buffer = buffer.replace(/\r\n/g, "\n");
let split = buffer.indexOf("\n\n");
while (split !== -1) {
const event = parsePiMessagesEvent(buffer.slice(0, split));
if (event) {
yield event;
}
buffer = buffer.slice(split + 2);
split = buffer.indexOf("\n\n");
}
if (done) {
break;
}
}
if (buffer.trim()) {
const event = parsePiMessagesEvent(buffer);
if (event) {
yield event;
}
}
} finally {
reader.releaseLock();
}
}
function parsePiMessagesEvent(raw: string): PiMessagesEvent | undefined {
const data = raw
.split("\n")
.find((line) => line.startsWith("data:"))
?.slice(5)
.trim();
return data && data !== "[DONE]" ? (JSON.parse(data) as PiMessagesEvent) : undefined;
}
function createErrorEvent(model: Model<"pi-messages">, error: unknown, aborted: boolean): AssistantMessageEvent {
const reason = aborted ? "aborted" : "error";
const assistantMessage: AssistantMessage = {
role: "assistant",
content: [],
api: model.api,
provider: model.provider,
model: model.id,
usage: createEmptyUsage(),
stopReason: reason,
errorMessage: error instanceof Error ? error.message : String(error),
timestamp: Date.now(),
};
if (!aborted && error instanceof PiMessagesResponseError) {
appendAssistantMessageDiagnostic(
assistantMessage,
createAssistantMessageDiagnostic("pi_messages_response_failure", error, error.diagnosticDetails),
);
}
return { type: "error", reason, error: assistantMessage };
}
function resolveCacheRetention(cacheRetention?: CacheRetention, env?: ProviderEnv): CacheRetention | undefined {
if (cacheRetention) {
return cacheRetention;
}
// Backend defaults apply when unset; only the legacy env opt-in is mapped.
return getProviderEnvValue("PI_CACHE_RETENTION", env) === "long" ? "long" : undefined;
}
export const stream: StreamFunction<"pi-messages", PiMessagesOptions> = (
model: Model<"pi-messages">,
context: Context,
options?: PiMessagesOptions,
): AssistantMessageEventStream => {
const eventStream = new AssistantMessageEventStream();
const convertEvent = createEventConverter(model);
void (async () => {
try {
const apiKey = options?.apiKey;
if (!apiKey) {
throw new Error(`No API key provided for provider "${model.provider}"`);
}
const url = new URL(`${model.baseUrl.replace(/\/+$/u, "")}/messages`);
if (options?.debug) {
url.searchParams.set("debug", "1");
}
let payload: unknown = {
model: model.id,
context,
options: {
temperature: options?.temperature,
maxTokens: options?.maxTokens,
reasoning: options?.reasoning,
cacheRetention: resolveCacheRetention(options?.cacheRetention, options?.env),
sessionId: options?.sessionId,
toolChoice: options?.toolChoice,
},
};
const nextPayload = await options?.onPayload?.(payload, model);
if (nextPayload !== undefined) {
payload = nextPayload;
}
const response = await fetch(url, {
method: "POST",
headers: {
authorization: `Bearer ${apiKey}`,
accept: "text/event-stream",
"content-type": "application/json",
...providerHeadersToRecord(options?.headers),
},
body: JSON.stringify(payload),
signal: options?.signal,
});
await options?.onResponse?.({ status: response.status, headers: headersToRecord(response.headers) }, model);
if (!response.ok) {
const body = await response.text();
throw createPiMessagesResponseError(model, url, response, body);
}
if (!response.body) {
throw new Error(`${model.provider} response has no body`);
}
for await (const piEvent of readPiMessagesEvents(response.body)) {
const event = convertEvent(piEvent);
eventStream.push(event);
if (event.type === "done" || event.type === "error") {
return;
}
}
throw new Error(`${model.provider} stream ended without a terminal event`);
} catch (error) {
eventStream.push(createErrorEvent(model, error, options?.signal?.aborted ?? false));
}
})();
return eventStream;
};
export const streamSimple: StreamFunction<"pi-messages", SimpleStreamOptions> = (
model: Model<"pi-messages">,
context: Context,
options?: SimpleStreamOptions,
): AssistantMessageEventStream => {
const extra = options as PiMessagesOptions | undefined;
return stream(model, context, {
...options,
reasoning: options?.reasoning,
toolChoice: extra?.toolChoice,
debug: extra?.debug,
});
};

View file

@ -1,45 +0,0 @@
import type { AuthContext } from "./types.ts";
interface NodeFsModule {
access(path: string): Promise<void>;
}
interface NodeOsModule {
homedir(): string;
}
// Variable specifier so browser bundlers do not try to resolve node builtins.
const importNodeModule = (specifier: string): Promise<unknown> => import(specifier);
function getProcessEnv(): Record<string, string | undefined> | undefined {
const proc = (globalThis as { process?: { env?: Record<string, string | undefined> } }).process;
return proc?.env;
}
/**
* Default auth context: env vars from `process.env` (undefined in browsers),
* file existence via node:fs (always false in browsers).
*/
export function defaultProviderAuthContext(): AuthContext {
return {
async env(name: string): Promise<string | undefined> {
const value = getProcessEnv()?.[name];
return typeof value === "string" && value.trim().length > 0 ? value : undefined;
},
async fileExists(path: string): Promise<boolean> {
try {
const fs = (await importNodeModule("node:fs/promises")) as NodeFsModule;
let resolved = path;
if (resolved.startsWith("~")) {
const os = (await importNodeModule("node:os")) as NodeOsModule;
resolved = os.homedir() + resolved.slice(1);
}
await fs.access(resolved);
return true;
} catch {
return false;
}
},
};
}

View file

@ -1,51 +0,0 @@
import type { Credential, CredentialInfo, CredentialStore } from "./types.ts";
/**
* Default in-memory credential store. Apps inject persistent stores.
* Keyed by `Provider.id`, one credential per provider; see `CredentialStore`.
* Writes are serialized per provider through a promise chain.
*/
export class InMemoryCredentialStore implements CredentialStore {
private credentials = new Map<string, Credential>();
private chains = new Map<string, Promise<unknown>>();
/** Serialize tasks per provider id. */
private enqueue<T>(providerId: string, task: () => Promise<T>): Promise<T> {
const previous = this.chains.get(providerId) ?? Promise.resolve();
const next = (async () => {
await previous.catch(() => {});
return task();
})();
this.chains.set(
providerId,
next.catch(() => {}),
);
return next;
}
async read(providerId: string): Promise<Credential | undefined> {
return this.credentials.get(providerId);
}
async list(): Promise<readonly CredentialInfo[]> {
return [...this.credentials].map(([providerId, credential]) => ({ providerId, type: credential.type }));
}
modify(
providerId: string,
fn: (current: Credential | undefined) => Promise<Credential | undefined>,
): Promise<Credential | undefined> {
return this.enqueue(providerId, async () => {
const current = this.credentials.get(providerId);
const next = await fn(current);
if (next !== undefined) this.credentials.set(providerId, next);
return next ?? current;
});
}
delete(providerId: string): Promise<void> {
return this.enqueue(providerId, async () => {
this.credentials.delete(providerId);
});
}
}

View file

@ -1,47 +0,0 @@
import type { ApiKeyAuth, OAuthAuth } from "./types.ts";
/**
* Standard api-key auth: a stored credential key wins, otherwise the first
* set env var resolves. Includes a `login` that prompts for the key.
* Providers with non-standard resolution (provider env, ambient files, IAM)
* write their own `ApiKeyAuth`.
*/
export function envApiKeyAuth(name: string, envVars: readonly string[]): ApiKeyAuth {
return {
name,
login: async (interaction) => {
const key = await interaction.prompt({ type: "secret", message: `Enter ${name}` });
return { type: "api_key", key };
},
resolve: async ({ ctx, credential }) => {
if (credential?.key) return { auth: { apiKey: credential.key }, source: "stored credential" };
for (const envVar of envVars) {
const value = await ctx.env(envVar);
if (value) return { auth: { apiKey: value }, source: envVar };
}
return undefined;
},
};
}
/**
* Wraps a dynamically imported `OAuthAuth` so provider definitions can
* advertise OAuth without importing the implementation. The flow loads on
* first `login`/`refresh`/`toAuth` call; callers keep Node-only flow code out
* of bundles by loading through a bundler-opaque dynamic import (variable
* specifier, see the bedrock lazy wrapper).
*/
export function lazyOAuth(input: { name: string; loginLabel?: string; load: () => Promise<OAuthAuth> }): OAuthAuth {
let promise: Promise<OAuthAuth> | undefined;
const loaded = () => {
promise ??= input.load();
return promise;
};
return {
name: input.name,
loginLabel: input.loginLabel,
login: async (interaction) => (await loaded()).login(interaction),
refresh: async (credential) => (await loaded()).refresh(credential),
toAuth: async (credential) => (await loaded()).toAuth(credential),
};
}

View file

@ -1,56 +0,0 @@
import type { OAuthAuth } from "../types.ts";
/**
* Loads an OAuth flow module through a variable specifier so bundlers cannot
* follow the import into Node-only flow code (`node:http` callback servers,
* `node:crypto` PKCE). The `.ts`/`.js` rewrite keeps the trick working from
* both source and built output.
*/
const importOAuthModule = (specifier: string): Promise<unknown> => {
const runtimeSpecifier = import.meta.url.endsWith(".js") ? specifier.replace(/\.ts$/, ".js") : specifier;
return import(runtimeSpecifier);
};
type OAuthFlowLoaders = {
anthropic: () => OAuthAuth | Promise<OAuthAuth>;
openaiCodex: () => OAuthAuth | Promise<OAuthAuth>;
githubCopilot: () => OAuthAuth | Promise<OAuthAuth>;
xai: () => OAuthAuth | Promise<OAuthAuth>;
radius: (options: { name: string; gateway: string }) => OAuthAuth | Promise<OAuthAuth>;
};
let bundledLoaders: OAuthFlowLoaders | undefined;
/** Registers statically bundled OAuth flows for standalone Bun binaries. */
export function registerBundledOAuthFlowLoaders(loaders: OAuthFlowLoaders): void {
bundledLoaders = loaders;
}
export const loadAnthropicOAuth = async (): Promise<OAuthAuth> => {
if (bundledLoaders) return bundledLoaders.anthropic();
return ((await importOAuthModule("./anthropic.ts")) as { anthropicOAuth: OAuthAuth }).anthropicOAuth;
};
export const loadOpenAICodexOAuth = async (): Promise<OAuthAuth> => {
if (bundledLoaders) return bundledLoaders.openaiCodex();
return ((await importOAuthModule("./openai-codex.ts")) as { openaiCodexOAuth: OAuthAuth }).openaiCodexOAuth;
};
export const loadGitHubCopilotOAuth = async (): Promise<OAuthAuth> => {
if (bundledLoaders) return bundledLoaders.githubCopilot();
return ((await importOAuthModule("./github-copilot.ts")) as { githubCopilotOAuth: OAuthAuth }).githubCopilotOAuth;
};
export const loadXaiOAuth = async (): Promise<OAuthAuth> => {
if (bundledLoaders) return bundledLoaders.xai();
return ((await importOAuthModule("./xai.ts")) as { xaiOAuth: OAuthAuth }).xaiOAuth;
};
export const loadRadiusOAuth = async (options: { name: string; gateway: string }): Promise<OAuthAuth> => {
if (bundledLoaders) return bundledLoaders.radius(options);
return (
(await importOAuthModule("./radius.ts")) as {
createRadiusOAuth: (input: { name: string; gateway: string }) => OAuthAuth;
}
).createRadiusOAuth(options);
};

View file

@ -1,410 +0,0 @@
/**
* Radius gateway OAuth flow.
*
* Radius is a pi-messages gateway. OAuth endpoints are discovered from the
* gateway (`/v1/oauth`); model catalog loading is owned by the Radius provider.
*
* NOTE: This module uses node:http for the OAuth callback server.
* It is only intended for CLI use, not browser environments.
*/
// NEVER convert to top-level imports - breaks browser/Vite builds
let _http: typeof import("node:http") | null = null;
if (typeof process !== "undefined" && (process.versions?.node || process.versions?.bun)) {
import("node:http").then((m) => {
_http = m;
});
}
import { normalizeRadiusGatewayUrl } from "../../providers/radius-config.ts";
import type { AuthInteraction, OAuthAuth, OAuthCredential } from "../types.ts";
import { pollOAuthDeviceCodeFlow } from "./device-code.ts";
import { oauthErrorHtml, oauthSuccessHtml } from "./oauth-page.ts";
import { generatePKCE } from "./pkce.ts";
const CALLBACK_HOST = "127.0.0.1";
const CALLBACK_PORT = 1456;
const CALLBACK_PATH = "/oauth/callback";
const REDIRECT_URI = `http://${CALLBACK_HOST}:${CALLBACK_PORT}${CALLBACK_PATH}`;
const TOKEN_EXPIRY_SKEW_MS = 60_000;
const LOGIN_METHOD_BROWSER = "browser";
const LOGIN_METHOD_DEVICE_CODE = "device-code";
type RadiusOAuthConfig = {
issuer: string;
authorizationEndpoint: string;
tokenEndpoint: string;
deviceAuthorizationEndpoint: string;
deviceAuthorizationEventsEndpoint: string;
verificationEndpoint: string;
clientId: string;
scope: string;
deviceCodeGrantType: string;
};
type DeviceAuthorizationResponse = {
device_code: string;
user_code: string;
verification_uri?: string;
verification_uri_complete?: string;
expires_in: number;
interval?: number;
};
async function loadRadiusOAuthConfig(gateway: string): Promise<RadiusOAuthConfig> {
const response = await fetch(new URL("/v1/oauth", gateway), {
headers: { accept: "application/json" },
});
if (!response.ok) {
throw new Error(
`Could not load Radius OAuth config from ${gateway}: ${response.status} ${await response.text()}`,
);
}
return (await response.json()) as RadiusOAuthConfig;
}
class OAuthResponseError extends Error {
readonly status: number;
readonly oauthError?: string;
constructor(status: number, oauthError: string | undefined, description: string | undefined, message: string) {
const detail = oauthError
? description
? `${oauthError}: ${description}`
: oauthError
: description || String(status);
super(`${message}: ${detail}`);
this.status = status;
this.oauthError = oauthError;
}
}
async function readOAuthResponseError(response: Response, message: string): Promise<OAuthResponseError> {
const text = await response.text().catch(() => "");
let oauthError: string | undefined;
let description: string | undefined;
if (text) {
try {
const data = JSON.parse(text) as { error?: unknown; error_description?: unknown };
oauthError = typeof data.error === "string" ? data.error : undefined;
description = typeof data.error_description === "string" ? data.error_description : undefined;
} catch {
description = text;
}
}
return new OAuthResponseError(response.status, oauthError, description, message);
}
async function requestOAuthToken(
oauth: RadiusOAuthConfig,
body: URLSearchParams,
signal?: AbortSignal,
): Promise<OAuthCredential> {
let response: Response;
try {
response = await fetch(oauth.tokenEndpoint, {
method: "POST",
headers: { accept: "application/json", "content-type": "application/x-www-form-urlencoded" },
body,
signal,
});
} catch (error) {
if (signal?.aborted) {
throw new Error("Login cancelled");
}
throw error;
}
if (!response.ok) {
throw await readOAuthResponseError(response, "Radius OAuth token request failed");
}
const data = (await response.json()) as {
access_token: string;
refresh_token: string;
expires_in: number;
scope?: string;
};
return {
type: "oauth",
access: data.access_token,
refresh: data.refresh_token,
expires: Date.now() + data.expires_in * 1000 - TOKEN_EXPIRY_SKEW_MS,
scope: data.scope,
};
}
type OAuthCallbackServer = {
waitForCode(): Promise<string | null>;
close(): void;
};
function startOAuthCallbackServer(
expectedState: string,
signal: AbortSignal | undefined,
): Promise<OAuthCallbackServer> {
if (!_http) {
throw new Error("Radius OAuth is only available in Node.js environments");
}
let settle: (code: string | null) => void = () => {};
let settled = false;
const wait = new Promise<string | null>((resolve) => {
settle = resolve;
});
const finish = (code: string | null) => {
if (settled) {
return;
}
settled = true;
signal?.removeEventListener("abort", onAbort);
settle(code);
};
const onAbort = () => finish(null);
signal?.addEventListener("abort", onAbort, { once: true });
const sendPage = (response: import("node:http").ServerResponse, status: number, html: string) => {
response.statusCode = status;
response.setHeader("content-type", "text/html; charset=utf-8");
response.end(html);
};
const server = _http.createServer((request, response) => {
const url = new URL(request.url ?? "/", REDIRECT_URI);
if (url.pathname !== CALLBACK_PATH) {
sendPage(response, 404, oauthErrorHtml("Callback route not found."));
return;
}
if (url.searchParams.get("state") !== expectedState) {
sendPage(response, 400, oauthErrorHtml("OAuth state mismatch."));
return;
}
const error = url.searchParams.get("error");
if (error) {
sendPage(response, 400, oauthErrorHtml(url.searchParams.get("error_description") ?? error));
finish(null);
return;
}
const code = url.searchParams.get("code");
if (!code) {
sendPage(response, 400, oauthErrorHtml("Missing authorization code."));
return;
}
sendPage(response, 200, oauthSuccessHtml("Signed in to Radius. You may now close this page."));
finish(code);
});
return new Promise((resolve) => {
server
.listen(CALLBACK_PORT, CALLBACK_HOST, () => {
resolve({
waitForCode: () => wait,
close: () => {
finish(null);
server.close();
},
});
})
.once("error", () => {
finish(null);
resolve({ waitForCode: async () => null, close: () => {} });
});
});
}
async function loginWithBrowser(oauth: RadiusOAuthConfig, interaction: AuthInteraction): Promise<OAuthCredential> {
const { verifier, challenge } = await generatePKCE();
const state = crypto.randomUUID();
const authorizeUrl = new URL(oauth.authorizationEndpoint);
authorizeUrl.search = new URLSearchParams({
response_type: "code",
client_id: oauth.clientId,
redirect_uri: REDIRECT_URI,
scope: oauth.scope,
code_challenge: challenge,
code_challenge_method: "S256",
handoff: "url",
state,
}).toString();
const callbackServer = await startOAuthCallbackServer(state, interaction.signal);
interaction.notify({ type: "progress", message: `Listening for OAuth callback on ${REDIRECT_URI}` });
interaction.notify({
type: "auth_url",
url: authorizeUrl.toString(),
instructions: "Continue in your browser.",
});
try {
const code = await callbackServer.waitForCode();
if (!code) {
if (interaction.signal?.aborted) {
throw new Error("Login cancelled");
}
throw new Error("OAuth callback did not complete.");
}
return await requestOAuthToken(
oauth,
new URLSearchParams({
grant_type: "authorization_code",
client_id: oauth.clientId,
redirect_uri: REDIRECT_URI,
code,
code_verifier: verifier,
}),
interaction.signal,
);
} finally {
callbackServer.close();
}
}
async function requestDeviceAuthorization(
oauth: RadiusOAuthConfig,
signal: AbortSignal | undefined,
): Promise<DeviceAuthorizationResponse> {
let response: Response;
try {
response = await fetch(oauth.deviceAuthorizationEndpoint, {
method: "POST",
headers: { accept: "application/json", "content-type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({ client_id: oauth.clientId, scope: oauth.scope }),
signal,
});
} catch (error) {
if (signal?.aborted) {
throw new Error("Login cancelled");
}
throw error;
}
if (!response.ok) {
throw await readOAuthResponseError(response, "Radius OAuth device authorization failed");
}
const data = (await response.json()) as Partial<DeviceAuthorizationResponse>;
if (!data.device_code || !data.user_code || !data.expires_in) {
throw new Error("Radius OAuth device authorization response is missing required fields");
}
return {
device_code: data.device_code,
user_code: data.user_code,
verification_uri: data.verification_uri,
verification_uri_complete: data.verification_uri_complete,
expires_in: data.expires_in,
interval: data.interval,
};
}
async function loginWithDeviceCode(oauth: RadiusOAuthConfig, interaction: AuthInteraction): Promise<OAuthCredential> {
const device = await requestDeviceAuthorization(oauth, interaction.signal);
interaction.notify({
type: "device_code",
userCode: device.user_code,
verificationUri: device.verification_uri || oauth.verificationEndpoint,
intervalSeconds: device.interval,
expiresInSeconds: device.expires_in,
});
return pollOAuthDeviceCodeFlow<OAuthCredential>({
intervalSeconds: device.interval,
expiresInSeconds: device.expires_in,
signal: interaction.signal,
poll: async () => {
try {
const credentials = await requestOAuthToken(
oauth,
new URLSearchParams({
grant_type: oauth.deviceCodeGrantType,
client_id: oauth.clientId,
device_code: device.device_code,
}),
interaction.signal,
);
return { status: "complete", value: credentials };
} catch (error) {
if (!(error instanceof OAuthResponseError)) {
throw error;
}
switch (error.oauthError) {
case "authorization_pending":
return { status: "pending" };
case "slow_down":
return { status: "slow_down" };
case "expired_token":
return { status: "failed", message: "Device authorization expired." };
case "access_denied":
return { status: "failed", message: "Device authorization was denied." };
default:
throw error;
}
}
},
});
}
export interface RadiusOAuthOptions {
name: string;
gateway: string;
}
export function createRadiusOAuth(options: RadiusOAuthOptions): OAuthAuth {
const gateway = normalizeRadiusGatewayUrl(options.gateway);
return {
name: options.name,
async login(interaction): Promise<OAuthCredential> {
const oauth = await loadRadiusOAuthConfig(gateway);
const loginMethod = await interaction.prompt({
type: "select",
message: `Sign in to ${options.name}:`,
options: [
{ id: LOGIN_METHOD_BROWSER, label: "Sign in with browser (recommended)" },
{
id: LOGIN_METHOD_DEVICE_CODE,
label: "Sign in with device code (when signing in from another device)",
},
],
});
let credential: OAuthCredential;
if (loginMethod === LOGIN_METHOD_DEVICE_CODE) {
credential = await loginWithDeviceCode(oauth, interaction);
} else if (loginMethod === LOGIN_METHOD_BROWSER) {
credential = await loginWithBrowser(oauth, interaction);
} else {
throw new Error(`Unknown ${options.name} sign-in method: ${loginMethod}`);
}
return credential;
},
async refresh(credential, signal): Promise<OAuthCredential> {
const oauth = await loadRadiusOAuthConfig(gateway);
const refreshed = await requestOAuthToken(
oauth,
new URLSearchParams({
grant_type: "refresh_token",
client_id: oauth.clientId,
refresh_token: credential.refresh,
}),
signal,
);
return refreshed;
},
async toAuth(credential) {
return { apiKey: credential.access };
},
};
}

View file

@ -1,238 +0,0 @@
/**
* xAI OAuth device-code flow.
*/
import type { AuthInteraction, OAuthAuth, OAuthCredential } from "../types.ts";
import { pollOAuthDeviceCodeFlow } from "./device-code.ts";
const XAI_CLIENT_ID = "b1a00492-073a-47ea-816f-4c329264a828";
const XAI_SCOPE = "openid profile email offline_access grok-cli:access api:access";
const XAI_DEVICE_CODE_URL = "https://auth.x.ai/oauth2/device/code";
const XAI_TOKEN_URL = "https://auth.x.ai/oauth2/token";
// Refresh slightly before the reported expiry to avoid using a token that dies mid-request.
const REFRESH_SKEW_MS = 5 * 60 * 1000;
const DEFAULT_TOKEN_LIFETIME_SECONDS = 3600;
type JsonObject = Record<string, unknown>;
type OAuthHttpResponse = {
ok: boolean;
status: number;
body: JsonObject;
};
type XaiDeviceCode = {
deviceCode: string;
userCode: string;
verificationUri: string;
verificationUriComplete?: string;
intervalSeconds?: number;
expiresInSeconds: number;
};
function requiredString(body: JsonObject, field: string): string {
const value = body[field];
if (typeof value !== "string" || value.length === 0) {
throw new Error(`Invalid xAI OAuth response field: ${field}`);
}
return value;
}
function positiveNumber(body: JsonObject, field: string): number {
const value = body[field];
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
throw new Error(`Invalid xAI OAuth response field: ${field}`);
}
return value;
}
// The verification URI is opened in the user's browser; force it to be an https URL
// so a malicious response cannot make `open` launch something else.
function validateVerificationUri(raw: string): string {
let url: URL;
try {
url = new URL(raw);
} catch {
throw new Error("Untrusted verification URI in xAI OAuth response");
}
if (url.protocol !== "https:") {
throw new Error("Untrusted verification URI in xAI OAuth response");
}
return url.href;
}
async function postForm(url: string, fields: Record<string, string>, signal?: AbortSignal): Promise<OAuthHttpResponse> {
let response: Response;
try {
response = await fetch(url, {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams(fields),
signal,
});
} catch (error) {
if (signal?.aborted) {
throw new Error("Login cancelled");
}
throw error;
}
let body: JsonObject;
try {
const parsed = (await response.json()) as unknown;
body = parsed && typeof parsed === "object" && !Array.isArray(parsed) ? (parsed as JsonObject) : {};
} catch {
if (signal?.aborted) {
throw new Error("Login cancelled");
}
throw new Error(`xAI OAuth returned invalid JSON (HTTP ${response.status})`);
}
return {
ok: response.ok,
status: response.status,
body,
};
}
function requestFailure(action: string, response: OAuthHttpResponse): Error {
const error = typeof response.body.error === "string" ? response.body.error : undefined;
const description =
typeof response.body.error_description === "string" ? response.body.error_description : undefined;
const detail = [error, description].filter(Boolean).join(": ");
return new Error(`xAI OAuth ${action} failed (HTTP ${response.status})${detail ? `: ${detail}` : ""}`);
}
function parseDeviceCode(body: JsonObject): XaiDeviceCode {
// RFC 8628 allows interval 0 (no minimum wait); fall back to the poller's
// default instead of failing on non-positive or malformed values.
const interval = body.interval;
const intervalSeconds =
typeof interval === "number" && Number.isFinite(interval) && interval > 0 ? interval : undefined;
const verificationUriComplete =
typeof body.verification_uri_complete === "string" && body.verification_uri_complete.length > 0
? validateVerificationUri(body.verification_uri_complete)
: undefined;
return {
deviceCode: requiredString(body, "device_code"),
userCode: requiredString(body, "user_code"),
verificationUri: validateVerificationUri(requiredString(body, "verification_uri")),
verificationUriComplete,
intervalSeconds,
expiresInSeconds: positiveNumber(body, "expires_in"),
};
}
function credentialsFromTokenResponse(body: JsonObject, previousRefreshToken?: string): OAuthCredential {
const access = requiredString(body, "access_token");
// xAI may omit refresh_token on refresh when the token is not rotated.
const refresh =
body.refresh_token === undefined && previousRefreshToken
? previousRefreshToken
: requiredString(body, "refresh_token");
const expiresInSeconds =
body.expires_in === undefined ? DEFAULT_TOKEN_LIFETIME_SECONDS : positiveNumber(body, "expires_in");
return {
type: "oauth",
access,
refresh,
expires: Date.now() + expiresInSeconds * 1000 - REFRESH_SKEW_MS,
};
}
async function requestDeviceCode(signal?: AbortSignal): Promise<XaiDeviceCode> {
const response = await postForm(
XAI_DEVICE_CODE_URL,
{
client_id: XAI_CLIENT_ID,
scope: XAI_SCOPE,
referrer: "pi",
},
signal,
);
if (!response.ok) {
throw requestFailure("device authorization", response);
}
return parseDeviceCode(response.body);
}
async function pollForTokens(device: XaiDeviceCode, signal?: AbortSignal): Promise<OAuthCredential> {
return pollOAuthDeviceCodeFlow<OAuthCredential>({
intervalSeconds: device.intervalSeconds,
expiresInSeconds: device.expiresInSeconds,
waitBeforeFirstPoll: true,
signal,
poll: async () => {
const response = await postForm(
XAI_TOKEN_URL,
{
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
client_id: XAI_CLIENT_ID,
device_code: device.deviceCode,
},
signal,
);
if (response.ok) {
return { status: "complete", value: credentialsFromTokenResponse(response.body) };
}
const error = response.body.error;
if (error === "authorization_pending") {
return { status: "pending" };
}
if (error === "slow_down") {
const interval = response.body.interval;
return { status: "slow_down", intervalSeconds: typeof interval === "number" ? interval : undefined };
}
if (error === "access_denied" || error === "authorization_denied") {
return { status: "failed", message: "xAI device authorization was denied" };
}
if (error === "expired_token") {
return { status: "failed", message: "xAI device code expired" };
}
return { status: "failed", message: requestFailure("device token polling", response).message };
},
});
}
async function loginXai(interaction: AuthInteraction): Promise<OAuthCredential> {
const device = await requestDeviceCode(interaction.signal);
interaction.notify({
type: "device_code",
userCode: device.userCode,
verificationUri: device.verificationUriComplete ?? device.verificationUri,
intervalSeconds: device.intervalSeconds,
expiresInSeconds: device.expiresInSeconds,
});
return pollForTokens(device, interaction.signal);
}
async function refreshXaiToken(refreshToken: string, signal?: AbortSignal): Promise<OAuthCredential> {
const response = await postForm(
XAI_TOKEN_URL,
{
grant_type: "refresh_token",
client_id: XAI_CLIENT_ID,
refresh_token: refreshToken,
},
signal,
);
if (!response.ok) {
throw requestFailure("token refresh", response);
}
return credentialsFromTokenResponse(response.body, refreshToken);
}
export const xaiOAuth: OAuthAuth = {
name: "xAI (Grok/X subscription)",
loginLabel: "Sign in with SuperGrok or X Premium",
login: loginXai,
refresh: (credential, signal) => refreshXaiToken(credential.refresh, signal),
async toAuth(credential) {
return { apiKey: credential.access };
},
};

View file

@ -1,139 +0,0 @@
import type { ProviderEnv } from "../types.ts";
import type {
ApiKeyAuth,
ApiKeyCredential,
AuthContext,
AuthResult,
Credential,
CredentialStore,
OAuthAuth,
OAuthCredential,
ProviderAuth,
} from "./types.ts";
export type ModelsErrorCode = "model_source" | "model_validation" | "provider" | "stream" | "auth" | "oauth";
export interface AuthResolutionOverrides {
apiKey?: string;
env?: ProviderEnv;
}
export class ModelsError extends Error {
readonly code: ModelsErrorCode;
constructor(code: ModelsErrorCode, message: string, options?: { cause?: unknown }) {
super(message, options);
this.name = "ModelsError";
this.code = code;
}
}
/**
* Auth resolution shared by the `Models` and `ImagesModels` collections.
* A stored credential owns the provider: ambient/env is consulted only when
* nothing is stored. No silent env fallback after a failed refresh or for a
* credential type without a matching handler.
*/
export async function resolveProviderAuth(
provider: { id: string; auth: ProviderAuth },
credentials: CredentialStore,
authContext: AuthContext,
overrides?: AuthResolutionOverrides,
): Promise<AuthResult | undefined> {
const requestAuthContext = overrides?.env ? overlayEnvAuthContext(authContext, overrides.env) : authContext;
if (overrides?.apiKey !== undefined && provider.auth.apiKey) {
return resolveApiKey(requestAuthContext, provider.auth.apiKey, provider.id, {
type: "api_key",
key: overrides.apiKey,
env: overrides.env,
});
}
const stored = await readCredential(credentials, provider.id);
if (stored) {
if (stored.type === "oauth" && provider.auth.oauth) {
return resolveStoredOAuth(credentials, provider.id, provider.auth.oauth, stored);
}
if (stored.type === "api_key" && provider.auth.apiKey) {
const credential = overrides?.env ? { ...stored, env: { ...stored.env, ...overrides.env } } : stored;
return resolveApiKey(requestAuthContext, provider.auth.apiKey, provider.id, credential);
}
return undefined;
}
// Ambient (env vars, AWS profiles, ADC files).
return provider.auth.apiKey
? resolveApiKey(requestAuthContext, provider.auth.apiKey, provider.id, undefined)
: undefined;
}
function overlayEnvAuthContext(base: AuthContext, env: ProviderEnv): AuthContext {
return {
env: async (name) => env[name] || (await base.env(name)),
fileExists: (path) => base.fileExists(path),
};
}
/**
* OAuth resolution with double-checked locking (same pattern as today's
* AuthStorage): valid tokens cost zero locks; expired tokens lock, re-check
* expiry under the lock, refresh once globally, and persist the rotated
* credential before release.
*/
async function resolveStoredOAuth(
credentials: CredentialStore,
providerId: string,
oauth: OAuthAuth,
stored: OAuthCredential,
): Promise<AuthResult | undefined> {
let credential = stored;
if (Date.now() >= credential.expires) {
// Optimistic check said expired; the authoritative check runs under the lock.
let post: Credential | undefined;
try {
post = await credentials.modify(providerId, async (current) => {
if (current?.type !== "oauth") return undefined; // logged out meanwhile
if (Date.now() < current.expires) return undefined; // another process/request refreshed
try {
return await oauth.refresh(current);
} catch (error) {
throw new ModelsError("oauth", `OAuth refresh failed for ${providerId}`, { cause: error });
}
});
} catch (error) {
if (error instanceof ModelsError) throw error;
throw new ModelsError("auth", `Credential store modify failed for ${providerId}`, { cause: error });
}
if (post?.type !== "oauth") return undefined; // logged out meanwhile
credential = post;
}
try {
return { auth: await oauth.toAuth(credential), source: "OAuth" };
} catch (error) {
throw new ModelsError("oauth", `OAuth auth derivation failed for ${providerId}`, { cause: error });
}
}
async function resolveApiKey(
authContext: AuthContext,
apiKey: ApiKeyAuth,
providerId: string,
credential: ApiKeyCredential | undefined,
): Promise<AuthResult | undefined> {
try {
return await apiKey.resolve({ ctx: authContext, credential });
} catch (error) {
throw new ModelsError("auth", `API key auth failed for provider ${providerId}`, { cause: error });
}
}
async function readCredential(credentials: CredentialStore, providerId: string): Promise<Credential | undefined> {
try {
return await credentials.read(providerId);
} catch (error) {
throw new ModelsError("auth", `Credential store read failed for ${providerId}`, { cause: error });
}
}

View file

@ -1,220 +0,0 @@
import type { ProviderEnv, ProviderHeaders } from "../types.ts";
/**
* Request auth for a single model request. If a value cannot be expressed as
* `apiKey`, `headers`, or `baseUrl`, it is provider config, not auth.
*/
export interface ModelAuth {
apiKey?: string;
headers?: ProviderHeaders;
baseUrl?: string;
}
/**
* Stored api-key credential. `env` holds provider-scoped environment/config
* values such as Cloudflare account/gateway ids.
*/
export interface ApiKeyCredential {
type: "api_key";
key?: string;
env?: ProviderEnv;
}
/** OAuth token data returned by extension compatibility flows. */
export interface OAuthCredentials {
refresh: string;
access: string;
expires: number;
[key: string]: unknown;
}
/** Stored canonical OAuth credential. */
export interface OAuthCredential extends OAuthCredentials {
type: "oauth";
}
/** One type-tagged credential per provider — the shape of today's auth.json. */
export type Credential = ApiKeyCredential | OAuthCredential;
/** Non-secret credential metadata for account/status enumeration. */
export interface CredentialInfo {
providerId: string;
type: Credential["type"];
}
/**
* App-owned credential storage, keyed by `Provider.id`, one credential per
* provider. `modify` is the only write path, so every mutation is a
* serialized read-modify-write; `Models.getAuth()` runs OAuth refresh inside
* `modify` so concurrent requests cannot double-refresh a rotated token. The
* app persists a credential after login via
* `modify(provider.id, async () => credential)`. Login/logout orchestration
* is app-owned.
*
* Error semantics: `read` resolves `undefined` for missing entries. Methods
* reject only on storage failure; `Models` wraps such rejections in
* `ModelsError` with code "auth". Best-effort stores that serve an in-memory
* view and record persistence errors internally (like coding-agent's
* AuthStorage) are valid implementations.
*/
export interface CredentialStore {
/**
* Read the stored credential, possibly expired. Display/status use;
* resolved request auth comes from `Models.getAuth()`.
*/
read(providerId: string): Promise<Credential | undefined>;
/**
* List stored credential metadata without resolving or exposing secrets.
* Implementations must not execute configured API-key commands while listing.
*/
list(): Promise<readonly CredentialInfo[]>;
/**
* Serialized write the only write path. `fn` sees the current credential
* because correct writes (refresh, login-during-refresh) depend on it;
* return the new credential, or undefined to leave the entry unchanged.
* Mutual exclusion per provider id, cross-process too where the backing
* store supports it (e.g. a file lock). Resolves with the post-write
* credential. Rejections from `fn` propagate.
*/
modify(
providerId: string,
fn: (current: Credential | undefined) => Promise<Credential | undefined>,
): Promise<Credential | undefined>;
/** Remove a credential (logout). Implementations serialize this against `modify`. */
delete(providerId: string): Promise<void>;
}
/** Environment access for auth resolution. Injectable for tests and browsers. */
export interface AuthContext {
env(name: string): Promise<string | undefined>;
/** Check whether a file exists. Supports a leading `~`. Always false in browsers. */
fileExists(path: string): Promise<boolean>;
}
/** Result of resolving auth for a model. */
export interface AuthResult {
auth: ModelAuth;
/** Provider-scoped environment/config values resolved from credentials and ambient context. */
env?: ProviderEnv;
/** Human-readable label for status UI: "ANTHROPIC_API_KEY", "OAuth", "~/.aws/credentials". */
source?: string;
}
export interface AuthCheck {
source?: string;
type: "api_key" | "oauth";
}
export type AuthType = "api_key" | "oauth";
/**
* Prompt shown to the user during login. `signal` lets the flow cancel a
* pending prompt when an out-of-band event resolves the step, e.g. a
* `manual_code` prompt raced against a callback server, aborted when the
* callback wins.
*/
export type AuthPrompt = { signal?: AbortSignal } & (
| { type: "text"; message: string; placeholder?: string }
| { type: "secret"; message: string; placeholder?: string }
| { type: "select"; message: string; options: readonly { id: string; label: string; description?: string }[] }
| { type: "manual_code"; message: string; placeholder?: string }
);
export interface AuthInfoLink {
url: string;
label?: string;
}
export type AuthEvent =
| { type: "info"; message: string; links?: readonly AuthInfoLink[] }
| { type: "auth_url"; url: string; instructions?: string }
| {
type: "device_code";
userCode: string;
verificationUri: string;
intervalSeconds?: number;
expiresInSeconds?: number;
}
| { type: "progress"; message: string };
/**
* Login interaction callbacks serving both api-key and OAuth flows.
*
* `prompt()` returns the entered/selected string (`select` returns the option
* id). Rejects on cancel/abort. `signal` aborts the whole login flow;
* per-prompt cancellation uses `AuthPrompt.signal`.
*/
export interface AuthInteraction {
signal?: AbortSignal;
prompt(prompt: AuthPrompt): Promise<string>;
notify(event: AuthEvent): void;
}
/**
* Api-key auth: stored key/provider env plus ambient sources (env vars, AWS
* profiles, ADC files). Ambient-only providers omit `login`.
*/
export interface ApiKeyAuth {
/** Display name, e.g. "Anthropic API key". */
name: string;
/** Interactive setup (prompt for key/provider env). Absent = ambient-only. */
login?(interaction: AuthInteraction): Promise<ApiKeyCredential>;
/**
* Optional side-effect-free availability check. Use this when `resolve()` may
* execute commands or perform other request-time work. Missing means Models
* checks availability by resolving auth.
*/
check?(input: { ctx: AuthContext; credential?: ApiKeyCredential }): Promise<AuthCheck | undefined>;
/**
* Resolve auth from the stored credential and/or ambient sources, merging
* per field (`credential.key ?? env("...")`, `credential.env?.NAME ?? env("...")`).
* undefined = not configured. Resolution is provider-scoped; model-specific
* endpoint preparation happens after auth has been resolved.
*/
resolve(input: { ctx: AuthContext; credential?: ApiKeyCredential }): Promise<AuthResult | undefined>;
}
/**
* OAuth auth. The `refresh`/`toAuth` split lets `Models` own the locked
* refresh pattern: `refresh` produces a credential, `toAuth` derives request
* auth from whatever credential ends up stored.
*/
export interface OAuthAuth {
/** Display name, e.g. "Anthropic (Claude Pro/Max)". */
name: string;
/** Selector label for the subscription login option, e.g. "Sign in with SuperGrok or X Premium". */
loginLabel?: string;
login(interaction: AuthInteraction): Promise<OAuthCredential>;
/**
* Exchange the refresh token. Network call; throws on failure
* (invalid_grant etc.). `Models` runs this under the store lock.
*/
refresh(credential: OAuthCredential, signal?: AbortSignal): Promise<OAuthCredential>;
/**
* Side-effect-free derivation of request auth from a valid credential.
* Covers per-credential baseUrl (GitHub Copilot). Async so lazy wrappers
* can load the implementation on first use.
*/
toAuth(credential: OAuthCredential): Promise<ModelAuth>;
}
/**
* Provider auth. At least one of `apiKey`/`oauth` must be present: even
* ambient-credential providers and keyless local servers provide `apiKey`
* auth whose `resolve()` reports whether the provider is configured.
*/
export interface ProviderAuth {
apiKey?: ApiKeyAuth;
oauth?: OAuthAuth;
}

45
packages/ai/src/base.ts Normal file
View file

@ -0,0 +1,45 @@
export type { Static, TSchema } from "typebox";
export { Type } from "typebox";
export * from "./api-registry.ts";
export * from "./env-api-keys.ts";
export * from "./image-models.ts";
export * from "./images.ts";
export * from "./images-api-registry.ts";
export * from "./models.ts";
export type { BedrockOptions, BedrockThinkingDisplay } from "./providers/amazon-bedrock.ts";
export type { AnthropicEffort, AnthropicOptions, AnthropicThinkingDisplay } from "./providers/anthropic.ts";
export type { AzureOpenAIResponsesOptions } from "./providers/azure-openai-responses.ts";
export * from "./providers/faux.ts";
export type { GoogleOptions } from "./providers/google.ts";
export type { GoogleThinkingLevel } from "./providers/google-shared.ts";
export type { GoogleVertexOptions } from "./providers/google-vertex.ts";
export type { MistralOptions } from "./providers/mistral.ts";
export type {
OpenAICodexResponsesOptions,
OpenAICodexWebSocketDebugStats,
} from "./providers/openai-codex-responses.ts";
export type { OpenAICompletionsOptions } from "./providers/openai-completions.ts";
export type { OpenAIResponsesOptions } from "./providers/openai-responses.ts";
export * from "./session-resources.ts";
export * from "./stream.ts";
export * from "./types.ts";
export * from "./utils/diagnostics.ts";
export * from "./utils/event-stream.ts";
export * from "./utils/json-parse.ts";
export type {
OAuthAuthInfo,
OAuthCredentials,
OAuthDeviceCodeInfo,
OAuthLoginCallbacks,
OAuthPrompt,
OAuthProvider,
OAuthProviderId,
OAuthProviderInfo,
OAuthProviderInterface,
OAuthSelectOption,
OAuthSelectPrompt,
} from "./utils/oauth/types.ts";
export * from "./utils/overflow.ts";
export * from "./utils/typebox-helpers.ts";
export * from "./utils/validation.ts";

View file

@ -1,6 +1,8 @@
import { stream, streamSimple } from "./api/bedrock-converse-stream.ts";
import { register, streamBedrock, streamSimpleBedrock } from "./providers/amazon-bedrock.ts";
export { register };
export const bedrockProviderModule = {
stream,
streamSimple,
streamBedrock,
streamSimpleBedrock,
};

View file

@ -1,17 +0,0 @@
import { anthropicOAuth } from "./auth/oauth/anthropic.ts";
import { githubCopilotOAuth } from "./auth/oauth/github-copilot.ts";
import { registerBundledOAuthFlowLoaders } from "./auth/oauth/load.ts";
import { openaiCodexOAuth } from "./auth/oauth/openai-codex.ts";
import { createRadiusOAuth } from "./auth/oauth/radius.ts";
import { xaiOAuth } from "./auth/oauth/xai.ts";
/** Register OAuth flows statically embedded in the standalone Bun binary. */
export function registerBunOAuthFlows(): void {
registerBundledOAuthFlowLoaders({
anthropic: () => anthropicOAuth,
openaiCodex: () => openaiCodexOAuth,
githubCopilot: () => githubCopilotOAuth,
xai: () => xaiOAuth,
radius: createRadiusOAuth,
});
}

View file

@ -1,74 +1,71 @@
#!/usr/bin/env node
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { createInterface } from "node:readline";
import type { AuthPrompt, OAuthCredential, Provider } from "./index.ts";
import { builtinProviders } from "./providers/all.ts";
import { existsSync, readFileSync, writeFileSync } from "fs";
import { getOAuthProvider, getOAuthProviders } from "./utils/oauth/index.ts";
import type { OAuthCredentials, OAuthProviderId } from "./utils/oauth/types.ts";
const AUTH_FILE = "auth.json";
const PROVIDERS = builtinProviders().filter(
(provider): provider is Provider & { auth: { oauth: NonNullable<Provider["auth"]["oauth"]> } } =>
provider.auth.oauth !== undefined,
);
const PROVIDERS = getOAuthProviders();
function prompt(rl: ReturnType<typeof createInterface>, question: string): Promise<string> {
return new Promise((resolve) => rl.question(question, resolve));
}
function loadAuth(): Record<string, OAuthCredential> {
function loadAuth(): Record<string, { type: "oauth" } & OAuthCredentials> {
if (!existsSync(AUTH_FILE)) return {};
try {
return JSON.parse(readFileSync(AUTH_FILE, "utf-8")) as Record<string, OAuthCredential>;
return JSON.parse(readFileSync(AUTH_FILE, "utf-8"));
} catch {
return {};
}
}
function saveAuth(auth: Record<string, OAuthCredential>): void {
function saveAuth(auth: Record<string, { type: "oauth" } & OAuthCredentials>): void {
writeFileSync(AUTH_FILE, JSON.stringify(auth, null, 2), "utf-8");
}
async function answerPrompt(rl: ReturnType<typeof createInterface>, authPrompt: AuthPrompt): Promise<string> {
if (authPrompt.type === "select") {
console.log(`\n${authPrompt.message}`);
for (let index = 0; index < authPrompt.options.length; index++) {
console.log(` ${index + 1}. ${authPrompt.options[index].label}`);
}
const choice = Number.parseInt(await prompt(rl, `Enter number (1-${authPrompt.options.length}): `), 10) - 1;
const selected = authPrompt.options[choice];
if (!selected) throw new Error("Invalid selection");
return selected.id;
async function login(providerId: OAuthProviderId): Promise<void> {
const provider = getOAuthProvider(providerId);
if (!provider) {
console.error(`Unknown provider: ${providerId}`);
process.exit(1);
}
return prompt(rl, `${authPrompt.message}${authPrompt.placeholder ? ` (${authPrompt.placeholder})` : ""}: `);
}
async function login(providerId: string): Promise<void> {
const provider = PROVIDERS.find((entry) => entry.id === providerId);
if (!provider) throw new Error(`Unknown provider: ${providerId}`);
const rl = createInterface({ input: process.stdin, output: process.stdout });
const promptFn = (msg: string) => prompt(rl, `${msg} `);
try {
const credential = await provider.auth.oauth.login({
prompt: (authPrompt) => answerPrompt(rl, authPrompt),
notify: (event) => {
switch (event.type) {
case "auth_url":
console.log(`\nOpen this URL in your browser:\n${event.url}`);
if (event.instructions) console.log(event.instructions);
break;
case "device_code":
console.log(`\nOpen this URL in your browser:\n${event.verificationUri}`);
console.log(`Enter code: ${event.userCode}`);
break;
case "info":
case "progress":
console.log(event.message);
break;
}
const credentials = await provider.login({
onAuth: (info) => {
console.log(`\nOpen this URL in your browser:\n${info.url}`);
if (info.instructions) console.log(info.instructions);
console.log();
},
onDeviceCode: (info) => {
console.log(`\nOpen this URL in your browser:\n${info.verificationUri}`);
console.log(`Enter code: ${info.userCode}`);
console.log();
},
onPrompt: async (p) => {
return await promptFn(`${p.message}${p.placeholder ? ` (${p.placeholder})` : ""}:`);
},
onSelect: async (p) => {
console.log(`\n${p.message}`);
for (let i = 0; i < p.options.length; i++) {
console.log(` ${i + 1}. ${p.options[i].label}`);
}
const choice = await promptFn(`Enter number (1-${p.options.length}):`);
const index = parseInt(choice, 10) - 1;
return p.options[index]?.id;
},
onProgress: (msg) => console.log(msg),
});
const auth = loadAuth();
auth[providerId] = credential;
auth[providerId] = { type: "oauth", ...credentials };
saveAuth(auth);
console.log(`\nCredentials saved to ${AUTH_FILE}`);
} finally {
rl.close();
@ -78,41 +75,73 @@ async function login(providerId: string): Promise<void> {
async function main(): Promise<void> {
const args = process.argv.slice(2);
const command = args[0];
if (!command || command === "help" || command === "--help" || command === "-h") {
const providerList = PROVIDERS.map((provider) => ` ${provider.id.padEnd(20)} ${provider.name}`).join("\n");
console.log(
`Usage: npx @earendil-works/pi-ai <command> [provider]\n\nCommands:\n login [provider] Login to an OAuth provider\n list List available providers\n\nProviders:\n${providerList}`,
);
const providerList = PROVIDERS.map((p) => ` ${p.id.padEnd(20)} ${p.name}`).join("\n");
console.log(`Usage: npx @earendil-works/pi-ai <command> [provider]
Commands:
login [provider] Login to an OAuth provider
list List available providers
Providers:
${providerList}
Examples:
npx @earendil-works/pi-ai login # interactive provider selection
npx @earendil-works/pi-ai login anthropic # login to specific provider
npx @earendil-works/pi-ai list # list providers
`);
return;
}
if (command === "list") {
for (const provider of PROVIDERS) console.log(`${provider.id.padEnd(20)} ${provider.name}`);
console.log("Available OAuth providers:\n");
for (const p of PROVIDERS) {
console.log(` ${p.id.padEnd(20)} ${p.name}`);
}
return;
}
if (command === "login") {
let providerId = args[1];
if (!providerId) {
let provider = args[1] as OAuthProviderId | undefined;
if (!provider) {
const rl = createInterface({ input: process.stdin, output: process.stdout });
try {
for (let index = 0; index < PROVIDERS.length; index++) {
console.log(` ${index + 1}. ${PROVIDERS[index].name}`);
}
const index = Number.parseInt(await prompt(rl, `Enter number (1-${PROVIDERS.length}): `), 10) - 1;
providerId = PROVIDERS[index]?.id;
} finally {
rl.close();
console.log("Select a provider:\n");
for (let i = 0; i < PROVIDERS.length; i++) {
console.log(` ${i + 1}. ${PROVIDERS[i].name}`);
}
console.log();
const choice = await prompt(rl, `Enter number (1-${PROVIDERS.length}): `);
rl.close();
const index = parseInt(choice, 10) - 1;
if (index < 0 || index >= PROVIDERS.length) {
console.error("Invalid selection");
process.exit(1);
}
provider = PROVIDERS[index].id;
}
if (!providerId || !PROVIDERS.some((provider) => provider.id === providerId)) {
throw new Error(`Unknown provider: ${providerId ?? ""}`);
if (!PROVIDERS.some((p) => p.id === provider)) {
console.error(`Unknown provider: ${provider}`);
console.error(`Use 'npx @earendil-works/pi-ai list' to see available providers`);
process.exit(1);
}
await login(providerId);
console.log(`Logging in to ${provider}...`);
await login(provider);
return;
}
throw new Error(`Unknown command: ${command}`);
console.error(`Unknown command: ${command}`);
console.error(`Use 'npx @earendil-works/pi-ai --help' for usage`);
process.exit(1);
}
main().catch((error: unknown) => {
console.error("Error:", error instanceof Error ? error.message : String(error));
main().catch((err) => {
console.error("Error:", err.message);
process.exit(1);
});

View file

@ -1,298 +0,0 @@
/**
* Temporary compatibility entrypoint preserving the old global pi-ai API
* surface: api-dispatch `stream()`/`complete()` with env API key injection,
* the api-registry, generated catalog reads (`getModel`/`getModels`/
* `getProviders`), per-API lazy stream wrappers, and image generation.
*
* Existing apps switch imports from "@earendil-works/pi-ai" to
* "@earendil-works/pi-ai/compat" unchanged; new code uses `createModels()`
* and the provider factories. This module is deleted with the coding-agent
* ModelManager migration.
*/
export * from "./api/anthropic-messages.lazy.ts";
export * from "./api/azure-openai-responses.lazy.ts";
export * from "./api/bedrock-converse-stream.lazy.ts";
export * from "./api/google-generative-ai.lazy.ts";
export * from "./api/google-vertex.lazy.ts";
export * from "./api/mistral-conversations.lazy.ts";
export * from "./api/openai-codex-responses.lazy.ts";
export * from "./api/openai-completions.lazy.ts";
export * from "./api/openai-responses.lazy.ts";
export * from "./api/pi-messages.lazy.ts";
export * from "./env-api-keys.ts";
export * from "./image-models.ts";
export * from "./images.ts";
export * from "./images-api-registry.ts";
export * from "./index.ts";
export * from "./legacy-api-aliases.ts";
export * from "./providers/images/register-builtins.ts";
import { anthropicMessagesApi } from "./api/anthropic-messages.lazy.ts";
import { azureOpenAIResponsesApi } from "./api/azure-openai-responses.lazy.ts";
import { bedrockConverseStreamApi } from "./api/bedrock-converse-stream.lazy.ts";
import { googleGenerativeAIApi } from "./api/google-generative-ai.lazy.ts";
import { googleVertexApi } from "./api/google-vertex.lazy.ts";
import { mistralConversationsApi } from "./api/mistral-conversations.lazy.ts";
import { openAICodexResponsesApi } from "./api/openai-codex-responses.lazy.ts";
import { openAICompletionsApi } from "./api/openai-completions.lazy.ts";
import { openAIResponsesApi } from "./api/openai-responses.lazy.ts";
import { piMessagesApi } from "./api/pi-messages.lazy.ts";
import { getEnvApiKey } from "./env-api-keys.ts";
import type { ModelsApiStreamOptions } from "./models.ts";
import { builtinModels, getBuiltinModel, getBuiltinModels, getBuiltinProviders } from "./providers/all.ts";
export type { BuiltinProvider } from "./providers/all.ts";
import { createFauxCore, type FauxProviderRegistration, type RegisterFauxProviderOptions } from "./providers/faux.ts";
import type {
Api,
ApiStreamOptions,
AssistantMessage,
AssistantMessageEventStream,
Context,
Model,
ProviderStreamOptions,
ProviderStreams,
SimpleStreamOptions,
StreamFunction,
StreamOptions,
} from "./types.ts";
/** @deprecated Static catalog read. Use `getBuiltinModel` from "@earendil-works/pi-ai/providers/all" or `Models.getModel()`. */
export const getModel = getBuiltinModel;
/** @deprecated Static catalog read. Use `getBuiltinModels` from "@earendil-works/pi-ai/providers/all" or `Models.getModels()`. */
export const getModels = getBuiltinModels;
/** @deprecated Static catalog read. Use `getBuiltinProviders` from "@earendil-works/pi-ai/providers/all" or `Models.getProviders()`. */
export const getProviders = getBuiltinProviders;
export type ApiStreamFunction = (
model: Model<Api>,
context: Context,
options?: StreamOptions,
) => AssistantMessageEventStream;
export type ApiStreamSimpleFunction = (
model: Model<Api>,
context: Context,
options?: SimpleStreamOptions,
) => AssistantMessageEventStream;
export interface ApiProvider<TApi extends Api = Api, TOptions extends StreamOptions = StreamOptions> {
api: TApi;
stream: StreamFunction<TApi, TOptions>;
streamSimple: StreamFunction<TApi, SimpleStreamOptions>;
}
interface ApiProviderInternal {
api: Api;
stream: ApiStreamFunction;
streamSimple: ApiStreamSimpleFunction;
}
type RegisteredApiProvider = {
provider: ApiProviderInternal;
sourceId?: string;
};
const apiProviderRegistry = new Map<string, RegisteredApiProvider>();
function wrapStream<TApi extends Api, TOptions extends StreamOptions>(
api: TApi,
stream: StreamFunction<TApi, TOptions>,
): ApiStreamFunction {
return (model, context, options) => {
if (model.api !== api) {
throw new Error(`Mismatched api: ${model.api} expected ${api}`);
}
return stream(model as Model<TApi>, context, options as TOptions);
};
}
function wrapStreamSimple<TApi extends Api>(
api: TApi,
streamSimple: StreamFunction<TApi, SimpleStreamOptions>,
): ApiStreamSimpleFunction {
return (model, context, options) => {
if (model.api !== api) {
throw new Error(`Mismatched api: ${model.api} expected ${api}`);
}
return streamSimple(model as Model<TApi>, context, options);
};
}
export function registerApiProvider<TApi extends Api, TOptions extends StreamOptions>(
provider: ApiProvider<TApi, TOptions>,
sourceId?: string,
): void {
apiProviderRegistry.set(provider.api, {
provider: {
api: provider.api,
stream: wrapStream(provider.api, provider.stream),
streamSimple: wrapStreamSimple(provider.api, provider.streamSimple),
},
sourceId,
});
}
export function getApiProvider(api: Api): ApiProviderInternal | undefined {
return apiProviderRegistry.get(api)?.provider;
}
export function getApiProviders(): ApiProviderInternal[] {
return Array.from(apiProviderRegistry.values(), (entry) => entry.provider);
}
export function unregisterApiProviders(sourceId: string): void {
for (const [api, entry] of apiProviderRegistry.entries()) {
if (entry.sourceId === sourceId) {
apiProviderRegistry.delete(api);
}
}
}
function clearApiProviders(): void {
apiProviderRegistry.clear();
}
export function registerFauxProvider(options: RegisterFauxProviderOptions = {}): FauxProviderRegistration {
const core = createFauxCore(options);
const sourceId = `faux-provider-${Math.random().toString(36).slice(2, 10)}`;
registerApiProvider({ api: core.api, stream: core.stream, streamSimple: core.streamSimple }, sourceId);
return {
api: core.api,
models: core.models,
getModel: core.getModel,
state: core.state,
setResponses: core.setResponses,
appendResponses: core.appendResponses,
getPendingResponseCount: core.getPendingResponseCount,
unregister() {
unregisterApiProviders(sourceId);
},
};
}
const BUILTIN_APIS: [Api, ProviderStreams][] = [
["anthropic-messages", anthropicMessagesApi()],
["openai-completions", openAICompletionsApi()],
["openai-responses", openAIResponsesApi()],
["openai-codex-responses", openAICodexResponsesApi()],
["azure-openai-responses", azureOpenAIResponsesApi()],
["google-generative-ai", googleGenerativeAIApi()],
["google-vertex", googleVertexApi()],
["mistral-conversations", mistralConversationsApi()],
["bedrock-converse-stream", bedrockConverseStreamApi()],
["pi-messages", piMessagesApi()],
];
const builtinApiProviderInstances = new Map<Api, ReturnType<typeof getApiProvider>>();
/**
* Registers the builtin API implementations into the api-registry without
* clobbering existing entries: compat may load after a test or extension has
* already registered an override for a builtin api id.
*/
export function registerBuiltInApiProviders(): void {
for (const [api, streams] of BUILTIN_APIS) {
if (!getApiProvider(api)) {
registerApiProvider({ api, stream: streams.stream, streamSimple: streams.streamSimple });
}
builtinApiProviderInstances.set(api, getApiProvider(api));
}
}
export function resetApiProviders(): void {
clearApiProviders();
builtinApiProviderInstances.clear();
registerBuiltInApiProviders();
}
registerBuiltInApiProviders();
const compatModels = builtinModels();
const AMBIENT_AUTH_MARKER = "<authenticated>";
function hasExplicitApiKey(apiKey: string | undefined): apiKey is string {
return typeof apiKey === "string" && apiKey.trim().length > 0;
}
function withEnvApiKey<TOptions extends StreamOptions>(
model: Model<Api>,
options: TOptions | undefined,
): TOptions | undefined {
if (hasExplicitApiKey(options?.apiKey)) return options;
const apiKey = getEnvApiKey(model.provider, options?.env);
if (!apiKey || apiKey === AMBIENT_AUTH_MARKER) return options;
return { ...options, apiKey } as TOptions;
}
function hasResolvedCloudflareAuth(options: StreamOptions | undefined): boolean {
return hasExplicitApiKey(options?.apiKey) || typeof options?.headers?.["cf-aig-authorization"] === "string";
}
function getBuiltinProviderForModel(model: Model<Api>) {
if (getApiProvider(model.api) !== builtinApiProviderInstances.get(model.api)) return undefined;
const provider = compatModels.getProvider(model.provider);
return provider?.getModels().some((candidate) => candidate.api === model.api) ? provider : undefined;
}
function resolveApiProvider(api: Api) {
const provider = getApiProvider(api);
if (!provider) {
throw new Error(`No API provider registered for api: ${api}`);
}
return provider;
}
export function stream<TApi extends Api>(
model: Model<TApi>,
context: Context,
options?: ProviderStreamOptions,
): AssistantMessageEventStream {
const builtinProvider = getBuiltinProviderForModel(model);
if (builtinProvider) {
if (model.provider.startsWith("cloudflare-") && !hasResolvedCloudflareAuth(options)) {
return compatModels.stream(model, context, options as ModelsApiStreamOptions<TApi> | undefined);
}
return builtinProvider.stream(model, context, withEnvApiKey(model, options) as ApiStreamOptions<TApi>);
}
const provider = resolveApiProvider(model.api);
return provider.stream(model, context, withEnvApiKey(model, options) as StreamOptions);
}
export async function complete<TApi extends Api>(
model: Model<TApi>,
context: Context,
options?: ProviderStreamOptions,
): Promise<AssistantMessage> {
const s = stream(model, context, options);
return s.result();
}
export function streamSimple<TApi extends Api>(
model: Model<TApi>,
context: Context,
options?: SimpleStreamOptions,
): AssistantMessageEventStream {
const builtinProvider = getBuiltinProviderForModel(model);
if (builtinProvider) {
if (model.provider.startsWith("cloudflare-") && !hasResolvedCloudflareAuth(options)) {
return compatModels.streamSimple(model, context, options);
}
return builtinProvider.streamSimple(model, context, withEnvApiKey(model, options));
}
const provider = resolveApiProvider(model.api);
return provider.streamSimple(model, context, withEnvApiKey(model, options));
}
export async function completeSimple<TApi extends Api>(
model: Model<TApi>,
context: Context,
options?: SimpleStreamOptions,
): Promise<AssistantMessage> {
const s = streamSimple(model, context, options);
return s.result();
}

View file

@ -1,45 +0,0 @@
import type { OAuthCredentials } from "../auth/types.ts";
/** Legacy extension OAuth prompt. */
export interface OAuthPrompt {
message: string;
placeholder?: string;
allowEmpty?: boolean;
}
/** Legacy extension OAuth authorization link. */
export interface OAuthAuthInfo {
url: string;
instructions?: string;
}
/** Legacy extension OAuth device-code notification. */
export interface OAuthDeviceCodeInfo {
userCode: string;
verificationUri: string;
intervalSeconds?: number;
expiresInSeconds?: number;
}
export interface OAuthSelectOption {
id: string;
label: string;
}
export interface OAuthSelectPrompt {
message: string;
options: OAuthSelectOption[];
}
/** Callback surface retained only for coding-agent extension compatibility. */
export interface OAuthLoginCallbacks {
onAuth(info: OAuthAuthInfo): void;
onDeviceCode(info: OAuthDeviceCodeInfo): void;
onPrompt(prompt: OAuthPrompt): Promise<string>;
onProgress?(message: string): void;
onManualCodeInput?(): Promise<string>;
onSelect(prompt: OAuthSelectPrompt): Promise<string | undefined>;
signal?: AbortSignal;
}
export type { OAuthCredentials };

View file

@ -82,7 +82,6 @@ function getApiKeyEnvVars(provider: string): readonly string[] | undefined {
groq: "GROQ_API_KEY",
cerebras: "CEREBRAS_API_KEY",
xai: "XAI_API_KEY",
radius: "RADIUS_API_KEY",
openrouter: "OPENROUTER_API_KEY",
"vercel-ai-gateway": "AI_GATEWAY_API_KEY",
zai: "ZAI_API_KEY",

View file

@ -155,21 +155,6 @@ export const IMAGE_MODELS = {
cacheWrite: 0,
},
} satisfies ImagesModel<"openrouter-images">,
"google/gemini-3.1-flash-lite-image": {
id: "google/gemini-3.1-flash-lite-image",
name: "Google: Nano Banana 2 Lite (Gemini 3.1 Flash Lite Image)",
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["image", "text"],
output: ["image", "text"],
cost: {
input: 0.25,
output: 1.5,
cacheRead: 0,
cacheWrite: 0,
},
} satisfies ImagesModel<"openrouter-images">,
"microsoft/mai-image-2.5": {
id: "microsoft/mai-image-2.5",
name: "Microsoft: MAI-Image-2.5",
@ -230,51 +215,6 @@ export const IMAGE_MODELS = {
cacheWrite: 0,
},
} satisfies ImagesModel<"openrouter-images">,
"openai/gpt-image-1": {
id: "openai/gpt-image-1",
name: "OpenAI: GPT Image 1",
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["text", "image"],
output: ["image"],
cost: {
input: 10,
output: 10,
cacheRead: 1.25,
cacheWrite: 0,
},
} satisfies ImagesModel<"openrouter-images">,
"openai/gpt-image-1-mini": {
id: "openai/gpt-image-1-mini",
name: "OpenAI: GPT Image 1 Mini",
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["text", "image"],
output: ["image"],
cost: {
input: 2.5,
output: 2.5,
cacheRead: 0.25,
cacheWrite: 0,
},
} satisfies ImagesModel<"openrouter-images">,
"openai/gpt-image-2": {
id: "openai/gpt-image-2",
name: "OpenAI: GPT Image 2",
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["text", "image"],
output: ["image"],
cost: {
input: 8,
output: 8,
cacheRead: 2,
cacheWrite: 0,
},
} satisfies ImagesModel<"openrouter-images">,
"openrouter/auto": {
id: "openrouter/auto",
name: "Auto Router",
@ -470,6 +410,36 @@ export const IMAGE_MODELS = {
cacheWrite: 0,
},
} satisfies ImagesModel<"openrouter-images">,
"sourceful/riverflow-v2-fast-preview": {
id: "sourceful/riverflow-v2-fast-preview",
name: "Sourceful: Riverflow V2 Fast Preview",
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["text", "image"],
output: ["image"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
} satisfies ImagesModel<"openrouter-images">,
"sourceful/riverflow-v2-max-preview": {
id: "sourceful/riverflow-v2-max-preview",
name: "Sourceful: Riverflow V2 Max Preview",
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["text", "image"],
output: ["image"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
} satisfies ImagesModel<"openrouter-images">,
"sourceful/riverflow-v2-pro": {
id: "sourceful/riverflow-v2-pro",
name: "Sourceful: Riverflow V2 Pro",
@ -485,6 +455,21 @@ export const IMAGE_MODELS = {
cacheWrite: 0,
},
} satisfies ImagesModel<"openrouter-images">,
"sourceful/riverflow-v2-standard-preview": {
id: "sourceful/riverflow-v2-standard-preview",
name: "Sourceful: Riverflow V2 Standard Preview",
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["text", "image"],
output: ["image"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
} satisfies ImagesModel<"openrouter-images">,
"sourceful/riverflow-v2.5-fast": {
id: "sourceful/riverflow-v2.5-fast",
name: "Sourceful: Riverflow V2.5 Fast",

View file

@ -51,3 +51,7 @@ export function registerImagesApiProvider<TApi extends ImagesApi, TOptions exten
export function getImagesApiProvider(api: ImagesApi): ImagesApiProviderInternal | undefined {
return imagesApiProviderRegistry.get(api)?.provider;
}
export function clearImagesApiProviders(): void {
imagesApiProviderRegistry.clear();
}

View file

@ -1,274 +0,0 @@
import { defaultProviderAuthContext as defaultAuthContext } from "./auth/context.ts";
import { InMemoryCredentialStore } from "./auth/credential-store.ts";
import { type AuthResolutionOverrides, ModelsError, resolveProviderAuth } from "./auth/resolve.ts";
import type { AuthContext, AuthResult, CredentialStore, ProviderAuth } from "./auth/types.ts";
import type { CreateModelsOptions } from "./models.ts";
import type { AssistantImages, ImagesApi, ImagesContext, ImagesModel, ImagesOptions, ProviderImages } from "./types.ts";
/**
* An image-generation provider: the image-side counterpart of `Provider`.
* Owns id/name metadata, auth, model listing, and generation behavior.
*/
export interface ImagesProvider {
readonly id: string;
readonly name: string;
/**
* Required: at least one of `apiKey`/`oauth`. Same semantics as chat
* providers; `ImagesModels.getAuth()` returns undefined when the provider
* is unconfigured.
*/
readonly auth: ProviderAuth;
/**
* Current known models, sync. Static providers return their catalog;
* dynamic providers return the list as of the last `refreshModels()`
* (empty before the first). Must not throw; `ImagesModels` treats a
* throwing implementation as having no models.
*/
getModels(): readonly ImagesModel<ImagesApi>[];
/**
* Dynamic providers only: fetch and update the model list. May reject
* (network); on rejection the model list stays at its last-known state
* and a later call retries.
*/
refreshModels?(): Promise<void>;
generateImages(
model: ImagesModel<ImagesApi>,
context: ImagesContext,
options?: ImagesOptions,
): Promise<AssistantImages>;
}
/**
* Runtime collection of image-generation providers plus auth application and
* generation convenience: the image-side counterpart of `Models`.
*/
export interface ImagesModels {
getProviders(): readonly ImagesProvider[];
getProvider(id: string): ImagesProvider | undefined;
/**
* Sync read of last-known models from one provider or all providers.
* Best-effort: a provider whose `getModels()` throws yields no models.
*/
getModels(provider?: string): readonly ImagesModel<ImagesApi>[];
/** Sync runtime model lookup against last-known lists. */
getModel(provider: string, id: string): ImagesModel<ImagesApi> | undefined;
/**
* Ask dynamic providers to re-fetch their model lists. With a provider id,
* rejects with `ModelsError` ("model_source") on that provider's fetch
* failure; without one, refreshes all providers concurrently best-effort.
* Static providers (no `refreshModels`) are no-ops.
*/
refresh(provider?: string): Promise<void>;
/**
* Resolve request auth by provider id or image model. Same contract as
* `Models.getAuth()`: undefined when unknown/unconfigured, rejects with
* `ModelsError` ("oauth"/"auth") on real failures.
*/
getAuth(providerId: string, overrides?: AuthResolutionOverrides): Promise<AuthResult | undefined>;
getAuth(model: ImagesModel<ImagesApi>, overrides?: AuthResolutionOverrides): Promise<AuthResult | undefined>;
/**
* Generate images through the owning provider with auth resolved and
* merged (explicit options win per field). Never rejects; failures are
* returned as an `AssistantImages` with `stopReason: "error"`.
*/
generateImages(
model: ImagesModel<ImagesApi>,
context: ImagesContext,
options?: ImagesOptions,
): Promise<AssistantImages>;
}
export interface MutableImagesModels extends ImagesModels {
/** Upsert/replace by provider.id. Provider ids are unique. */
setProvider(provider: ImagesProvider): void;
deleteProvider(id: string): void;
clearProviders(): void;
}
class ImagesModelsImpl implements MutableImagesModels {
private providers = new Map<string, ImagesProvider>();
private credentials: CredentialStore;
private authContext: AuthContext;
constructor(options?: CreateModelsOptions) {
this.credentials = options?.credentials ?? new InMemoryCredentialStore();
this.authContext = options?.authContext ?? defaultAuthContext();
}
setProvider(provider: ImagesProvider): void {
this.providers.set(provider.id, provider);
}
deleteProvider(id: string): void {
this.providers.delete(id);
}
clearProviders(): void {
this.providers.clear();
}
getProviders(): readonly ImagesProvider[] {
return Array.from(this.providers.values());
}
getProvider(id: string): ImagesProvider | undefined {
return this.providers.get(id);
}
getModels(provider?: string): readonly ImagesModel<ImagesApi>[] {
if (provider !== undefined) {
const entry = this.providers.get(provider);
if (!entry) return [];
try {
return entry.getModels();
} catch {
return [];
}
}
const models: ImagesModel<ImagesApi>[] = [];
for (const entry of this.providers.values()) {
try {
models.push(...entry.getModels());
} catch {
// Best-effort: ill-behaved providers yield no models.
}
}
return models;
}
getModel(provider: string, id: string): ImagesModel<ImagesApi> | undefined {
return this.getModels(provider).find((model) => model.id === id);
}
async refresh(provider?: string): Promise<void> {
if (provider !== undefined) {
const entry = this.providers.get(provider);
if (!entry?.refreshModels) return;
try {
await entry.refreshModels();
} catch (error) {
if (error instanceof ModelsError) throw error;
throw new ModelsError("model_source", `Model refresh failed for ${provider}`, { cause: error });
}
return;
}
// Cannot reject: the async mapper turns even sync throws from ill-behaved
// providers into rejections, and allSettled captures all of them.
await Promise.allSettled(Array.from(this.providers.values(), async (entry) => entry.refreshModels?.()));
}
getAuth(providerId: string, overrides?: AuthResolutionOverrides): Promise<AuthResult | undefined>;
getAuth(model: ImagesModel<ImagesApi>, overrides?: AuthResolutionOverrides): Promise<AuthResult | undefined>;
async getAuth(
providerOrModel: string | ImagesModel<ImagesApi>,
overrides?: AuthResolutionOverrides,
): Promise<AuthResult | undefined> {
const providerId = typeof providerOrModel === "string" ? providerOrModel : providerOrModel.provider;
const provider = this.providers.get(providerId);
if (!provider) return undefined;
return resolveProviderAuth(provider, this.credentials, this.authContext, overrides);
}
async generateImages(
model: ImagesModel<ImagesApi>,
context: ImagesContext,
options?: ImagesOptions,
): Promise<AssistantImages> {
try {
const provider = this.providers.get(model.provider);
if (!provider) {
throw new ModelsError("provider", `Unknown provider: ${model.provider}`);
}
const resolution = await this.getAuth(model, {
apiKey: options?.apiKey,
env: options?.env,
});
const auth = resolution?.auth;
if (!auth) {
return provider.generateImages(model, context, options);
}
const requestModel = auth.baseUrl ? { ...model, baseUrl: auth.baseUrl } : model;
// Explicit request options win per-field; headers/env merge per key.
const apiKey = options?.apiKey ?? auth.apiKey;
const headers = auth.headers || options?.headers ? { ...auth.headers, ...options?.headers } : undefined;
const env =
resolution.env || options?.env ? { ...(resolution.env ?? {}), ...(options?.env ?? {}) } : undefined;
return await provider.generateImages(requestModel, context, { ...options, apiKey, headers, env });
} catch (error) {
return {
api: model.api,
provider: model.provider,
model: model.id,
output: [],
stopReason: "error",
errorMessage: error instanceof Error ? error.message : String(error),
timestamp: Date.now(),
};
}
}
}
export function createImagesModels(options?: CreateModelsOptions): MutableImagesModels {
return new ImagesModelsImpl(options);
}
export interface CreateImagesProviderOptions {
id: string;
/** Display name. Default: `id`. */
name?: string;
/** Required — every provider has auth semantics, even ambient/keyless ones. */
auth: ProviderAuth;
/** Initial model list (empty for purely dynamic providers). */
models: readonly ImagesModel<ImagesApi>[];
/**
* Dynamic providers: fetch the current list. Stored on success; concurrent
* calls share one in-flight fetch. May reject: the stored list then stays
* at its last-known state, the rejection propagates to the caller of
* `refreshModels()` (wrapped as ModelsError "model_source" by
* `ImagesModels.refresh(provider)`), and a later call retries.
*/
refreshModels?: () => Promise<readonly ImagesModel<ImagesApi>[]>;
api: ProviderImages;
}
/** Builds an image-generation provider from parts. */
export function createImagesProvider(input: CreateImagesProviderOptions): ImagesProvider {
let models = input.models;
let inflightRefresh: Promise<void> | undefined;
const refreshModels = input.refreshModels;
return {
id: input.id,
name: input.name ?? input.id,
auth: input.auth,
getModels: () => models,
refreshModels: refreshModels
? () => {
inflightRefresh ??= (async () => {
try {
models = await refreshModels();
} finally {
inflightRefresh = undefined;
}
})();
return inflightRefresh;
}
: undefined,
generateImages: (model, context, options) => input.api.generateImages(model, context, options),
};
}

View file

@ -1,5 +1,3 @@
import "./providers/images/register-builtins.ts";
import { getImagesApiProvider } from "./images-api-registry.ts";
import type { AssistantImages, ImagesApi, ImagesContext, ImagesModel, ProviderImagesOptions } from "./types.ts";

View file

@ -1,45 +1,6 @@
export type { Static, TSchema } from "typebox";
export { Type } from "typebox";
import { registerBuiltInImagesApiProviders } from "./providers/register-builtins.ts";
// Core only, side-effect free: no generated catalogs, no provider factories,
// no api-registry, no OAuth implementations, no compat. Provider factories
// live under "@earendil-works/pi-ai/providers/*", API implementations under
// "@earendil-works/pi-ai/api/*", the old global API under
// "@earendil-works/pi-ai/compat".
export type { AnthropicEffort, AnthropicOptions, AnthropicThinkingDisplay } from "./api/anthropic-messages.ts";
export type { AzureOpenAIResponsesOptions } from "./api/azure-openai-responses.ts";
export type { BedrockOptions, BedrockThinkingDisplay } from "./api/bedrock-converse-stream.ts";
export type { GoogleOptions } from "./api/google-generative-ai.ts";
export type { GoogleThinkingLevel } from "./api/google-shared.ts";
export type { GoogleVertexOptions } from "./api/google-vertex.ts";
export * from "./api/lazy.ts";
export type { MistralOptions } from "./api/mistral-conversations.ts";
export type { OpenAICodexResponsesOptions, OpenAICodexWebSocketDebugStats } from "./api/openai-codex-responses.ts";
export type { OpenAICompletionsOptions } from "./api/openai-completions.ts";
export type { OpenAIResponsesOptions } from "./api/openai-responses.ts";
export type { PiMessagesEvent, PiMessagesOptions, PiMessagesRewriteImpact } from "./api/pi-messages.ts";
export * from "./auth/context.ts";
export * from "./auth/credential-store.ts";
export * from "./auth/helpers.ts";
export * from "./auth/types.ts";
export type {
OAuthAuthInfo,
OAuthDeviceCodeInfo,
OAuthLoginCallbacks,
OAuthPrompt,
OAuthSelectOption,
OAuthSelectPrompt,
} from "./compat/extension-oauth-types.ts";
export * from "./images-models.ts";
export * from "./models.ts";
export * from "./models-store.ts";
export * from "./providers/faux.ts";
export * from "./session-resources.ts";
export * from "./types.ts";
export * from "./utils/diagnostics.ts";
export * from "./utils/event-stream.ts";
export * from "./utils/json-parse.ts";
export * from "./utils/overflow.ts";
export * from "./utils/retry.ts";
export * from "./utils/typebox-helpers.ts";
export * from "./utils/validation.ts";
export * from "./base.ts";
export * from "./providers/register-builtins.ts";
registerBuiltInImagesApiProviders();

View file

@ -1,108 +0,0 @@
import { anthropicMessagesApi } from "./api/anthropic-messages.lazy.ts";
import type { AnthropicOptions } from "./api/anthropic-messages.ts";
import { azureOpenAIResponsesApi } from "./api/azure-openai-responses.lazy.ts";
import type { AzureOpenAIResponsesOptions } from "./api/azure-openai-responses.ts";
import { googleGenerativeAIApi } from "./api/google-generative-ai.lazy.ts";
import type { GoogleOptions } from "./api/google-generative-ai.ts";
import { googleVertexApi } from "./api/google-vertex.lazy.ts";
import type { GoogleVertexOptions } from "./api/google-vertex.ts";
import { mistralConversationsApi } from "./api/mistral-conversations.lazy.ts";
import type { MistralOptions } from "./api/mistral-conversations.ts";
import { openAICodexResponsesApi } from "./api/openai-codex-responses.lazy.ts";
import type { OpenAICodexResponsesOptions } from "./api/openai-codex-responses.ts";
import { openAICompletionsApi } from "./api/openai-completions.lazy.ts";
import type { OpenAICompletionsOptions } from "./api/openai-completions.ts";
import { openAIResponsesApi } from "./api/openai-responses.lazy.ts";
import type { OpenAIResponsesOptions } from "./api/openai-responses.ts";
import type { SimpleStreamOptions, StreamFunction } from "./types.ts";
const anthropicMessagesStreams = anthropicMessagesApi();
const azureOpenAIResponsesStreams = azureOpenAIResponsesApi();
const googleGenerativeAIStreams = googleGenerativeAIApi();
const googleVertexStreams = googleVertexApi();
const mistralConversationsStreams = mistralConversationsApi();
const openAICodexResponsesStreams = openAICodexResponsesApi();
const openAICompletionsStreams = openAICompletionsApi();
const openAIResponsesStreams = openAIResponsesApi();
/** @deprecated Use `stream` from `@earendil-works/pi-ai/api/anthropic-messages` or `anthropicMessagesApi().stream`. */
export const streamAnthropic = anthropicMessagesStreams.stream as StreamFunction<
"anthropic-messages",
AnthropicOptions
>;
/** @deprecated Use `streamSimple` from `@earendil-works/pi-ai/api/anthropic-messages` or `anthropicMessagesApi().streamSimple`. */
export const streamSimpleAnthropic = anthropicMessagesStreams.streamSimple as StreamFunction<
"anthropic-messages",
SimpleStreamOptions
>;
/** @deprecated Use `stream` from `@earendil-works/pi-ai/api/azure-openai-responses` or `azureOpenAIResponsesApi().stream`. */
export const streamAzureOpenAIResponses = azureOpenAIResponsesStreams.stream as StreamFunction<
"azure-openai-responses",
AzureOpenAIResponsesOptions
>;
/** @deprecated Use `streamSimple` from `@earendil-works/pi-ai/api/azure-openai-responses` or `azureOpenAIResponsesApi().streamSimple`. */
export const streamSimpleAzureOpenAIResponses = azureOpenAIResponsesStreams.streamSimple as StreamFunction<
"azure-openai-responses",
SimpleStreamOptions
>;
/** @deprecated Use `stream` from `@earendil-works/pi-ai/api/google-generative-ai` or `googleGenerativeAIApi().stream`. */
export const streamGoogle = googleGenerativeAIStreams.stream as StreamFunction<"google-generative-ai", GoogleOptions>;
/** @deprecated Use `streamSimple` from `@earendil-works/pi-ai/api/google-generative-ai` or `googleGenerativeAIApi().streamSimple`. */
export const streamSimpleGoogle = googleGenerativeAIStreams.streamSimple as StreamFunction<
"google-generative-ai",
SimpleStreamOptions
>;
/** @deprecated Use `stream` from `@earendil-works/pi-ai/api/google-vertex` or `googleVertexApi().stream`. */
export const streamGoogleVertex = googleVertexStreams.stream as StreamFunction<"google-vertex", GoogleVertexOptions>;
/** @deprecated Use `streamSimple` from `@earendil-works/pi-ai/api/google-vertex` or `googleVertexApi().streamSimple`. */
export const streamSimpleGoogleVertex = googleVertexStreams.streamSimple as StreamFunction<
"google-vertex",
SimpleStreamOptions
>;
/** @deprecated Use `stream` from `@earendil-works/pi-ai/api/mistral-conversations` or `mistralConversationsApi().stream`. */
export const streamMistral = mistralConversationsStreams.stream as StreamFunction<
"mistral-conversations",
MistralOptions
>;
/** @deprecated Use `streamSimple` from `@earendil-works/pi-ai/api/mistral-conversations` or `mistralConversationsApi().streamSimple`. */
export const streamSimpleMistral = mistralConversationsStreams.streamSimple as StreamFunction<
"mistral-conversations",
SimpleStreamOptions
>;
/** @deprecated Use `stream` from `@earendil-works/pi-ai/api/openai-codex-responses` or `openAICodexResponsesApi().stream`. */
export const streamOpenAICodexResponses = openAICodexResponsesStreams.stream as StreamFunction<
"openai-codex-responses",
OpenAICodexResponsesOptions
>;
/** @deprecated Use `streamSimple` from `@earendil-works/pi-ai/api/openai-codex-responses` or `openAICodexResponsesApi().streamSimple`. */
export const streamSimpleOpenAICodexResponses = openAICodexResponsesStreams.streamSimple as StreamFunction<
"openai-codex-responses",
SimpleStreamOptions
>;
/** @deprecated Use `stream` from `@earendil-works/pi-ai/api/openai-completions` or `openAICompletionsApi().stream`. */
export const streamOpenAICompletions = openAICompletionsStreams.stream as StreamFunction<
"openai-completions",
OpenAICompletionsOptions
>;
/** @deprecated Use `streamSimple` from `@earendil-works/pi-ai/api/openai-completions` or `openAICompletionsApi().streamSimple`. */
export const streamSimpleOpenAICompletions = openAICompletionsStreams.streamSimple as StreamFunction<
"openai-completions",
SimpleStreamOptions
>;
/** @deprecated Use `stream` from `@earendil-works/pi-ai/api/openai-responses` or `openAIResponsesApi().stream`. */
export const streamOpenAIResponses = openAIResponsesStreams.stream as StreamFunction<
"openai-responses",
OpenAIResponsesOptions
>;
/** @deprecated Use `streamSimple` from `@earendil-works/pi-ai/api/openai-responses` or `openAIResponsesApi().streamSimple`. */
export const streamSimpleOpenAIResponses = openAIResponsesStreams.streamSimple as StreamFunction<
"openai-responses",
SimpleStreamOptions
>;

View file

@ -1,38 +0,0 @@
import type { Api, Model } from "./types.ts";
export interface ModelsStoreEntry {
models: readonly Model<Api>[];
/** Unix timestamp of the last completed remote check. */
checkedAt?: number;
}
/** Persistent model catalogs keyed by provider ID. */
export interface ModelsStore {
read(providerId: string): Promise<ModelsStoreEntry | undefined>;
write(providerId: string, entry: ModelsStoreEntry): Promise<void>;
delete(providerId: string): Promise<void>;
}
/** ModelsStore scoped to one provider. Providers cannot access other providers' catalogs. */
export interface ProviderModelsStore {
read(): Promise<ModelsStoreEntry | undefined>;
write(entry: ModelsStoreEntry): Promise<void>;
delete(): Promise<void>;
}
export class InMemoryModelsStore implements ModelsStore {
private readonly entries = new Map<string, ModelsStoreEntry>();
async read(providerId: string): Promise<ModelsStoreEntry | undefined> {
const entry = this.entries.get(providerId);
return entry ? structuredClone(entry) : undefined;
}
async write(providerId: string, entry: ModelsStoreEntry): Promise<void> {
this.entries.set(providerId, structuredClone(entry));
}
async delete(providerId: string): Promise<void> {
this.entries.delete(providerId);
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,664 +1,54 @@
import { lazyStream } from "./api/lazy.ts";
import { defaultProviderAuthContext as defaultAuthContext } from "./auth/context.ts";
import { InMemoryCredentialStore } from "./auth/credential-store.ts";
import { type AuthResolutionOverrides, ModelsError, resolveProviderAuth } from "./auth/resolve.ts";
import type {
AuthCheck,
AuthContext,
AuthInteraction,
AuthResult,
AuthType,
Credential,
CredentialStore,
ProviderAuth,
} from "./auth/types.ts";
import { InMemoryModelsStore, type ModelsStore, type ProviderModelsStore } from "./models-store.ts";
import type {
Api,
ApiStreamOptions,
AssistantMessage,
AssistantMessageEventStream,
Context,
Model,
ModelCostRates,
ModelThinkingLevel,
ProviderHeaders,
ProviderStreams,
SimpleStreamOptions,
StreamOptions,
Usage,
} from "./types.ts";
import { MODELS } from "./models.generated.ts";
import type { Api, KnownProvider, Model, ModelThinkingLevel, Usage } from "./types.ts";
export { ModelsError, type ModelsErrorCode } from "./auth/resolve.ts";
const modelRegistry: Map<string, Map<string, Model<Api>>> = new Map();
export interface RefreshModelsContext {
/** Effective configured credential. OAuth credentials are refreshed before network access. */
credential?: Credential;
/** Persistent model storage scoped to this provider ID. */
store: ProviderModelsStore;
/** False during offline/cache-only initialization. */
allowNetwork: boolean;
/** Bypass provider freshness checks and fetch immediately when network access is allowed. */
force?: boolean;
signal?: AbortSignal;
// Initialize registry from MODELS on module load
for (const [provider, models] of Object.entries(MODELS)) {
const providerModels = new Map<string, Model<Api>>();
for (const [id, model] of Object.entries(models)) {
providerModels.set(id, model as Model<Api>);
}
modelRegistry.set(provider, providerModels);
}
export interface ModelsRefreshOptions {
allowNetwork?: boolean;
/** Bypass provider freshness checks and fetch immediately when network access is allowed. */
force?: boolean;
signal?: AbortSignal;
type ModelApi<
TProvider extends KnownProvider,
TModelId extends keyof (typeof MODELS)[TProvider],
> = (typeof MODELS)[TProvider][TModelId] extends { api: infer TApi } ? (TApi extends Api ? TApi : never) : never;
export function getModel<TProvider extends KnownProvider, TModelId extends keyof (typeof MODELS)[TProvider]>(
provider: TProvider,
modelId: TModelId,
): Model<ModelApi<TProvider, TModelId>> {
const providerModels = modelRegistry.get(provider);
return providerModels?.get(modelId as string) as Model<ModelApi<TProvider, TModelId>>;
}
export interface ModelsRefreshResult {
aborted: boolean;
errors: ReadonlyMap<string, Error>;
export function getProviders(): KnownProvider[] {
return Array.from(modelRegistry.keys()) as KnownProvider[];
}
export interface ModelsStreamTransforms {
/** Transform fully assembled model/auth/request headers before provider dispatch. */
transformHeaders?: (headers: ProviderHeaders) => ProviderHeaders | Promise<ProviderHeaders>;
}
export type ModelsApiStreamOptions<TApi extends Api> = ApiStreamOptions<TApi> & ModelsStreamTransforms;
export type ModelsSimpleStreamOptions = SimpleStreamOptions & ModelsStreamTransforms;
/**
* A provider is the concrete runtime unit. It owns id/name/base metadata,
* auth methods, model listing, and stream behavior.
*
* `TApi` lets concrete provider factories declare which APIs their models
* use (e.g. `openaiProvider(): Provider<"openai-responses" | "openai-completions">`),
* giving typed model lists to direct factory users. Inside a `Models`
* collection providers are held as `Provider<Api>`.
*/
export interface Provider<TApi extends Api = Api> {
readonly id: string;
readonly name: string;
readonly baseUrl?: string;
readonly headers?: ProviderHeaders;
/**
* Required: at least one of `apiKey`/`oauth`. Every provider has auth
* semantics even providers with only ambient credentials (env vars, AWS
* profiles, ADC files) and keyless local servers provide `apiKey` auth
* whose `resolve()` reports whether the provider is configured.
* `Models.getAuth()` returns undefined when the provider is unconfigured.
*/
readonly auth: ProviderAuth;
/**
* Current known models, sync. Static providers return their catalog;
* dynamic providers return the list as of the last `refreshModels()`
* (empty before the first). Must not throw; `Models` treats a throwing
* implementation as having no models.
*/
getModels(): readonly Model<TApi>[];
/**
* Dynamic providers only: restore the provider-scoped stored catalog and optionally fetch
* a newer list using the effective credential. Implementations must retain their previous
* list on failure and honor the shared abort signal for network requests.
*/
refreshModels?(context: RefreshModelsContext): Promise<void>;
/**
* Optional provider policy for credential-specific model availability.
* `getModels()` remains the complete synchronous catalog; `Models.getAvailable()`
* applies this filter after confirming that provider auth is configured.
*/
filterModels?(models: readonly Model<TApi>[], credential: Credential | undefined): readonly Model<TApi>[];
stream<T extends TApi>(
model: Model<T>,
context: Context,
options?: ApiStreamOptions<T>,
): AssistantMessageEventStream;
streamSimple(model: Model<TApi>, context: Context, options?: SimpleStreamOptions): AssistantMessageEventStream;
}
/**
* Runtime collection of providers plus auth application and stream
* convenience. Providers own stream behavior; `Models` resolves auth and
* delegates each request to the provider that owns the model.
*/
export interface Models {
getProviders(): readonly Provider[];
getProvider(id: string): Provider | undefined;
/**
* Sync read of last-known models from one provider or all providers.
* Best-effort: a provider whose `getModels()` throws yields no models.
*/
getModels(provider?: string): readonly Model<Api>[];
/**
* Sync runtime model lookup against last-known lists. Dynamic model lists
* are typed as `Model<Api>`; narrow with the `hasApi()` type guard.
*/
getModel(provider: string, id: string): Model<Api> | undefined;
/**
* Refresh every configured dynamic provider concurrently. Provider errors and cancellation
* are returned without rejecting; static and unconfigured providers are skipped.
*/
refresh(options?: ModelsRefreshOptions): Promise<ModelsRefreshResult>;
/** Check whether a provider has complete auth configuration without refreshing OAuth. */
checkAuth(providerId: string): Promise<AuthCheck | undefined>;
/** Return models whose providers have complete auth configuration. */
getAvailable(providerId?: string): Promise<readonly Model<Api>[]>;
/**
* Resolve provider-scoped auth by provider id, or provider auth plus static
* model headers when passed a model. Includes a source label for status UI.
* Resolves `undefined` when the provider is unknown or unconfigured.
* Rejects with `ModelsError`: code "oauth" when a token refresh fails (the
* stored credential is preserved for retry; re-login fixes it), code "auth"
* when api-key resolution or the credential store fails. Request paths
* surface rejections as stream errors.
*/
getAuth(providerId: string, overrides?: AuthResolutionOverrides): Promise<AuthResult | undefined>;
getAuth(model: Model<Api>, overrides?: AuthResolutionOverrides): Promise<AuthResult | undefined>;
/** Run a provider-owned login flow and persist its returned credential. */
login(providerId: string, type: AuthType, interaction: AuthInteraction): Promise<Credential>;
/** Remove the stored credential for a provider. */
logout(providerId: string): Promise<void>;
stream<TApi extends Api>(
model: Model<TApi>,
context: Context,
options?: ModelsApiStreamOptions<TApi>,
): AssistantMessageEventStream;
complete<TApi extends Api>(
model: Model<TApi>,
context: Context,
options?: ModelsApiStreamOptions<TApi>,
): Promise<AssistantMessage>;
streamSimple(model: Model<Api>, context: Context, options?: ModelsSimpleStreamOptions): AssistantMessageEventStream;
completeSimple(model: Model<Api>, context: Context, options?: ModelsSimpleStreamOptions): Promise<AssistantMessage>;
}
export interface MutableModels extends Models {
/** Upsert/replace by provider.id. Provider ids are unique. */
setProvider(provider: Provider): void;
deleteProvider(id: string): void;
clearProviders(): void;
}
export interface CreateModelsOptions {
credentials?: CredentialStore;
modelsStore?: ModelsStore;
authContext?: AuthContext;
}
function mergeHeaders(
base: ProviderHeaders | undefined,
override: ProviderHeaders | undefined,
): ProviderHeaders | undefined {
if (!base && !override) return undefined;
const merged = { ...base };
for (const [name, value] of Object.entries(override ?? {})) {
const lowerName = name.toLowerCase();
for (const existingName of Object.keys(merged)) {
if (existingName.toLowerCase() === lowerName) delete merged[existingName];
}
merged[name] = value;
}
return merged;
}
class ModelsImpl implements MutableModels {
private providers = new Map<string, Provider>();
private credentials: CredentialStore;
private modelsStore: ModelsStore;
private authContext: AuthContext;
constructor(options?: CreateModelsOptions) {
this.credentials = options?.credentials ?? new InMemoryCredentialStore();
this.modelsStore = options?.modelsStore ?? new InMemoryModelsStore();
this.authContext = options?.authContext ?? defaultAuthContext();
}
setProvider(provider: Provider): void {
this.providers.set(provider.id, provider);
}
deleteProvider(id: string): void {
this.providers.delete(id);
}
clearProviders(): void {
this.providers.clear();
}
getProviders(): readonly Provider[] {
return Array.from(this.providers.values());
}
getProvider(id: string): Provider | undefined {
return this.providers.get(id);
}
getModels(provider?: string): readonly Model<Api>[] {
if (provider !== undefined) {
const entry = this.providers.get(provider);
if (!entry) return [];
try {
return entry.getModels();
} catch {
return [];
}
}
const models: Model<Api>[] = [];
for (const entry of this.providers.values()) {
try {
models.push(...entry.getModels());
} catch {
// Best-effort: ill-behaved providers yield no models.
}
}
return models;
}
getModel(provider: string, id: string): Model<Api> | undefined {
return this.getModels(provider).find((model) => model.id === id);
}
async refresh(options: ModelsRefreshOptions = {}): Promise<ModelsRefreshResult> {
const allowNetwork = options.allowNetwork ?? true;
const errors = new Map<string, Error>();
const refreshable = Array.from(this.providers.values()).filter(
(provider): provider is Provider & Required<Pick<Provider, "refreshModels">> =>
provider.refreshModels !== undefined,
);
await Promise.all(
refreshable.map(async (provider) => {
if (options.signal?.aborted) return;
const store: ProviderModelsStore = {
read: () => this.modelsStore.read(provider.id),
write: (entry) => this.modelsStore.write(provider.id, entry),
delete: () => this.modelsStore.delete(provider.id),
};
let stored: Credential | undefined;
try {
stored = await this.readCredential(provider.id);
const credential = await this.resolveRefreshCredential(provider, stored, allowNetwork, options.signal);
if (!credential) return;
await provider.refreshModels({
credential,
store,
allowNetwork,
force: options.force,
signal: options.signal,
});
} catch (error) {
if (!options.signal?.aborted) {
errors.set(
provider.id,
error instanceof Error
? error
: new ModelsError("model_source", `Model refresh failed for ${provider.id}`, { cause: error }),
);
}
try {
await provider.refreshModels({
credential: stored,
store,
allowNetwork: false,
signal: options.signal,
});
} catch {
// Preserve the original auth/network error; cache restoration is best-effort here.
}
}
}),
);
return { aborted: options.signal?.aborted ?? false, errors };
}
private async resolveRefreshCredential(
provider: Provider,
stored: Credential | undefined,
allowNetwork: boolean,
signal?: AbortSignal,
): Promise<Credential | undefined> {
if (stored?.type === "oauth") {
const oauth = provider.auth.oauth;
if (!oauth) return undefined;
if (!allowNetwork || Date.now() < stored.expires) return stored;
if (signal?.aborted) return undefined;
const post = await this.credentials.modify(provider.id, async (current) => {
if (current?.type !== "oauth" || Date.now() < current.expires) return undefined;
return oauth.refresh(current, signal);
});
return post?.type === "oauth" ? post : undefined;
}
const apiKey = provider.auth.apiKey;
if (!apiKey) return undefined;
const credential = stored?.type === "api_key" ? stored : undefined;
const result = await apiKey.resolve({ ctx: this.authContext, credential });
if (!result) return undefined;
return { type: "api_key", key: result.auth.apiKey, env: result.env };
}
private async readCredential(providerId: string): Promise<Credential | undefined> {
try {
return await this.credentials.read(providerId);
} catch (error) {
throw new ModelsError("auth", `Credential store read failed for ${providerId}`, { cause: error });
}
}
private async checkProviderAuth(
provider: Provider,
credential: Credential | undefined,
): Promise<AuthCheck | undefined> {
if (credential?.type === "oauth") {
return provider.auth.oauth ? { source: "OAuth", type: "oauth" } : undefined;
}
const apiKey = provider.auth.apiKey;
if (!apiKey) return undefined;
if (apiKey.check) {
try {
return await apiKey.check({
ctx: this.authContext,
credential: credential?.type === "api_key" ? credential : undefined,
});
} catch (error) {
throw new ModelsError("auth", `API key auth check failed for provider ${provider.id}`, { cause: error });
}
}
const resolution = await resolveProviderAuth(provider, this.credentials, this.authContext);
return resolution ? { source: resolution.source, type: "api_key" } : undefined;
}
async checkAuth(providerId: string): Promise<AuthCheck | undefined> {
const provider = this.providers.get(providerId);
if (!provider) return undefined;
return this.checkProviderAuth(provider, await this.readCredential(providerId));
}
async getAvailable(providerId?: string): Promise<readonly Model<Api>[]> {
const providers = providerId
? [this.providers.get(providerId)].filter((entry) => entry !== undefined)
: this.getProviders();
const checks = await Promise.all(
providers.map(async (provider) => {
const credential = await this.readCredential(provider.id);
return { provider, credential, auth: await this.checkProviderAuth(provider, credential) };
}),
);
return checks.flatMap(({ provider, credential, auth }) => {
if (!auth) return [];
const models = provider.getModels();
return provider.filterModels?.(models, credential) ?? models;
});
}
getAuth(providerId: string, overrides?: AuthResolutionOverrides): Promise<AuthResult | undefined>;
getAuth(model: Model<Api>, overrides?: AuthResolutionOverrides): Promise<AuthResult | undefined>;
async getAuth(
providerOrModel: string | Model<Api>,
overrides?: AuthResolutionOverrides,
): Promise<AuthResult | undefined> {
const providerId = typeof providerOrModel === "string" ? providerOrModel : providerOrModel.provider;
const provider = this.providers.get(providerId);
if (!provider) return undefined;
const result = await resolveProviderAuth(provider, this.credentials, this.authContext, overrides);
if (!result || typeof providerOrModel === "string" || !providerOrModel.headers) return result;
return {
...result,
auth: {
...result.auth,
headers: mergeHeaders(result.auth.headers, providerOrModel.headers),
},
};
}
async login(providerId: string, type: AuthType, interaction: AuthInteraction): Promise<Credential> {
const provider = this.providers.get(providerId);
if (!provider) throw new ModelsError("provider", `Unknown provider: ${providerId}`);
const method = type === "oauth" ? provider.auth.oauth : provider.auth.apiKey;
if (!method?.login) {
throw new ModelsError("auth", `${provider.name} does not support ${type} login`);
}
const credential = await method.login(interaction);
try {
await this.credentials.modify(providerId, async () => credential);
} catch (error) {
throw new ModelsError("auth", `Credential store modify failed for ${providerId}`, { cause: error });
}
return credential;
}
async logout(providerId: string): Promise<void> {
try {
await this.credentials.delete(providerId);
} catch (error) {
throw new ModelsError("auth", `Credential store delete failed for ${providerId}`, { cause: error });
}
}
private requireProvider(model: Model<Api>): Provider {
const provider = this.providers.get(model.provider);
if (!provider) {
throw new ModelsError("provider", `Unknown provider: ${model.provider}`);
}
return provider;
}
private async applyAuth<TOptions extends StreamOptions & ModelsStreamTransforms>(
model: Model<Api>,
options: TOptions | undefined,
): Promise<{ requestModel: Model<Api>; requestOptions: StreamOptions | undefined }> {
this.requireProvider(model);
const resolution = await this.getAuth(model, {
apiKey: options?.apiKey,
env: options?.env,
});
if (!resolution) {
throw new ModelsError("auth", `Provider is not configured: ${model.provider}`);
}
const auth = resolution.auth;
// Explicit request options win per-field; the Models-only transform runs last.
const apiKey = options?.apiKey ?? auth.apiKey;
let headers = mergeHeaders(auth.headers, options?.headers);
if (options?.transformHeaders) headers = await options.transformHeaders(headers ?? {});
const env = resolution.env || options?.env ? { ...(resolution.env ?? {}), ...(options?.env ?? {}) } : undefined;
const requestModel = auth.baseUrl ? { ...model, baseUrl: auth.baseUrl } : model;
const { transformHeaders: _transformHeaders, ...providerOptions } = options ?? {};
const requestOptions = { ...providerOptions, apiKey, headers, env } as StreamOptions;
return { requestModel, requestOptions };
}
stream<TApi extends Api>(
model: Model<TApi>,
context: Context,
options?: ModelsApiStreamOptions<TApi>,
): AssistantMessageEventStream {
return lazyStream(model, async () => {
const provider = this.requireProvider(model);
const { requestModel, requestOptions } = await this.applyAuth(
model,
options as ModelsApiStreamOptions<Api> | undefined,
);
return provider.stream(requestModel as Model<TApi>, context, requestOptions as ApiStreamOptions<TApi>);
});
}
async complete<TApi extends Api>(
model: Model<TApi>,
context: Context,
options?: ModelsApiStreamOptions<TApi>,
): Promise<AssistantMessage> {
return this.stream(model, context, options).result();
}
streamSimple(model: Model<Api>, context: Context, options?: ModelsSimpleStreamOptions): AssistantMessageEventStream {
return lazyStream(model, async () => {
const provider = this.requireProvider(model);
const { requestModel, requestOptions } = await this.applyAuth(model, options);
return provider.streamSimple(requestModel, context, requestOptions as SimpleStreamOptions);
});
}
async completeSimple(
model: Model<Api>,
context: Context,
options?: ModelsSimpleStreamOptions,
): Promise<AssistantMessage> {
return this.streamSimple(model, context, options).result();
}
}
export function createModels(options?: CreateModelsOptions): MutableModels {
return new ModelsImpl(options);
}
export interface CreateProviderOptions<TApi extends Api = Api> {
id: string;
/** Display name. Default: `id`. */
name?: string;
baseUrl?: string;
headers?: ProviderHeaders;
/** Required — every provider has auth semantics, even ambient/keyless ones. */
auth: ProviderAuth;
/** Static baseline model list (empty for purely dynamic providers). */
models: readonly Model<TApi>[];
/** Fetch a dynamic model overlay. createProvider restores/persists it through ModelsStore. */
fetchModels?: (context: RefreshModelsContext) => Promise<readonly Model<TApi>[]>;
filterModels?: (models: readonly Model<TApi>[], credential: Credential | undefined) => readonly Model<TApi>[];
/** Single implementation, or map keyed by `model.api` for mixed-API providers. */
api: ProviderStreams | Partial<Record<TApi, ProviderStreams>>;
}
/**
* Builds a provider from parts. Built-in provider factories and models.json
* custom providers both go through this. A single `api` streams all models;
* an `api` map dispatches on `model.api`, and a model whose api has no entry
* produces a stream error.
*/
export function createProvider<TApi extends Api = Api>(input: CreateProviderOptions<TApi>): Provider<TApi> {
const baselineModels = input.models;
let dynamicModels: readonly Model<TApi>[] = [];
let inflightRefresh: Promise<void> | undefined;
const fetchModels = input.fetchModels;
const currentModels = (): readonly Model<TApi>[] => {
const merged = [...baselineModels];
for (const model of dynamicModels) {
const index = merged.findIndex((entry) => entry.id === model.id);
if (index >= 0) merged[index] = model;
else merged.push(model);
}
return merged;
};
const single =
typeof (input.api as ProviderStreams).stream === "function" ? (input.api as ProviderStreams) : undefined;
const byApi = single ? undefined : (input.api as Partial<Record<string, ProviderStreams>>);
const apiFor = (model: Model<Api>): ProviderStreams | undefined => single ?? byApi?.[model.api];
const dispatch = (
model: Model<Api>,
run: (streams: ProviderStreams) => AssistantMessageEventStream,
): AssistantMessageEventStream => {
const streams = apiFor(model);
if (!streams) {
return lazyStream(model, async () => {
throw new ModelsError("stream", `Provider ${input.id} has no API implementation for "${model.api}"`);
});
}
return run(streams);
};
return {
id: input.id,
name: input.name ?? input.id,
baseUrl: input.baseUrl,
headers: input.headers,
auth: input.auth,
getModels: currentModels,
refreshModels: fetchModels
? (context) => {
inflightRefresh ??= (async () => {
try {
const stored = await context.store.read();
if (stored) {
dynamicModels = stored.models
.filter((model) => model.provider === input.id)
.map((model) => model as Model<TApi>);
}
if (!context.allowNetwork || context.signal?.aborted) return;
const refreshed = await fetchModels(context);
if (context.signal?.aborted) return;
dynamicModels = refreshed;
await context.store.write({ models: refreshed, checkedAt: Date.now() });
} finally {
inflightRefresh = undefined;
}
})();
return inflightRefresh;
}
: undefined,
filterModels: input.filterModels,
stream: (model, context, options) => dispatch(model, (streams) => streams.stream(model, context, options)),
streamSimple: (model, context, options) =>
dispatch(model, (streams) => streams.streamSimple(model, context, options)),
};
}
/**
* Runtime-checked narrowing for dynamically looked-up models:
*
* ```ts
* const model = models.getModel("anthropic", "claude-opus-4-7");
* if (model && hasApi(model, "anthropic-messages")) {
* // model: Model<"anthropic-messages">, stream options fully typed
* }
* ```
*/
export function hasApi<TApi extends Api>(model: Model<Api>, api: TApi): model is Model<TApi> {
return model.api === api;
export function getModels<TProvider extends KnownProvider>(
provider: TProvider,
): Model<ModelApi<TProvider, keyof (typeof MODELS)[TProvider]>>[] {
const models = modelRegistry.get(provider);
return models ? (Array.from(models.values()) as Model<ModelApi<TProvider, keyof (typeof MODELS)[TProvider]>>[]) : [];
}
export function calculateCost<TApi extends Api>(model: Model<TApi>, usage: Usage): Usage["cost"] {
const inputTokens = usage.input + usage.cacheRead + usage.cacheWrite;
let rates: ModelCostRates = model.cost;
let matchedThreshold = -1;
for (const tier of model.cost.tiers ?? []) {
if (inputTokens > tier.inputTokensAbove && tier.inputTokensAbove > matchedThreshold) {
rates = tier;
matchedThreshold = tier.inputTokensAbove;
}
}
// Anthropic charges 2x base input for 1h cache writes.
const longWrite = usage.cacheWrite1h ?? 0;
const shortWrite = usage.cacheWrite - longWrite;
usage.cost.input = (rates.input / 1000000) * usage.input;
usage.cost.output = (rates.output / 1000000) * usage.output;
usage.cost.cacheRead = (rates.cacheRead / 1000000) * usage.cacheRead;
usage.cost.cacheWrite = (rates.cacheWrite * shortWrite + rates.input * 2 * longWrite) / 1000000;
usage.cost.input = (model.cost.input / 1000000) * usage.input;
usage.cost.output = (model.cost.output / 1000000) * usage.output;
usage.cost.cacheRead = (model.cost.cacheRead / 1000000) * usage.cacheRead;
usage.cost.cacheWrite = (model.cost.cacheWrite * shortWrite + model.cost.input * 2 * longWrite) / 1000000;
usage.cost.total = usage.cost.input + usage.cost.output + usage.cost.cacheRead + usage.cost.cacheWrite;
return usage.cost;
}
const EXTENDED_THINKING_LEVELS: ModelThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
const EXTENDED_THINKING_LEVELS: ModelThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh"];
export function getSupportedThinkingLevels<TApi extends Api>(model: Model<TApi>): ModelThinkingLevel[] {
if (!model.reasoning) return ["off"];
@ -666,7 +56,7 @@ export function getSupportedThinkingLevels<TApi extends Api>(model: Model<TApi>)
return EXTENDED_THINKING_LEVELS.filter((level) => {
const mapped = model.thinkingLevelMap?.[level];
if (mapped === null) return false;
if (level === "xhigh" || level === "max") return mapped !== undefined;
if (level === "xhigh") return mapped !== undefined;
return true;
});
}

View file

@ -1,10 +1 @@
/** Type-only compatibility entry point for coding-agent extension OAuth declarations. */
export type {
OAuthAuthInfo,
OAuthCredentials,
OAuthDeviceCodeInfo,
OAuthLoginCallbacks,
OAuthPrompt,
OAuthSelectOption,
OAuthSelectPrompt,
} from "./compat/extension-oauth-types.ts";
export * from "./utils/oauth/index.ts";

View file

@ -1,140 +0,0 @@
import { createImagesModels, type ImagesProvider, type MutableImagesModels } from "../images-models.ts";
import { MODELS } from "../models.generated.ts";
import { type CreateModelsOptions, createModels, type MutableModels, type Provider } from "../models.ts";
import type { Api, Model } from "../types.ts";
import { amazonBedrockProvider } from "./amazon-bedrock.ts";
import { antLingProvider } from "./ant-ling.ts";
import { anthropicProvider } from "./anthropic.ts";
import { azureOpenAIResponsesProvider } from "./azure-openai-responses.ts";
import { cerebrasProvider } from "./cerebras.ts";
import { cloudflareAIGatewayProvider } from "./cloudflare-ai-gateway.ts";
import { cloudflareWorkersAIProvider } from "./cloudflare-workers-ai.ts";
import { deepseekProvider } from "./deepseek.ts";
import { fireworksProvider } from "./fireworks.ts";
import { githubCopilotProvider } from "./github-copilot.ts";
import { googleProvider } from "./google.ts";
import { googleVertexProvider } from "./google-vertex.ts";
import { groqProvider } from "./groq.ts";
import { huggingfaceProvider } from "./huggingface.ts";
import { kimiCodingProvider } from "./kimi-coding.ts";
import { minimaxProvider } from "./minimax.ts";
import { minimaxCnProvider } from "./minimax-cn.ts";
import { mistralProvider } from "./mistral.ts";
import { moonshotaiProvider } from "./moonshotai.ts";
import { moonshotaiCnProvider } from "./moonshotai-cn.ts";
import { nvidiaProvider } from "./nvidia.ts";
import { openaiProvider } from "./openai.ts";
import { openaiCodexProvider } from "./openai-codex.ts";
import { opencodeProvider } from "./opencode.ts";
import { opencodeGoProvider } from "./opencode-go.ts";
import { openrouterProvider } from "./openrouter.ts";
import { openrouterImagesProvider } from "./openrouter-images.ts";
import { radiusProvider } from "./radius.ts";
import { togetherProvider } from "./together.ts";
import { vercelAIGatewayProvider } from "./vercel-ai-gateway.ts";
import { xaiProvider } from "./xai.ts";
import { xiaomiProvider } from "./xiaomi.ts";
import { xiaomiTokenPlanAmsProvider } from "./xiaomi-token-plan-ams.ts";
import { xiaomiTokenPlanCnProvider } from "./xiaomi-token-plan-cn.ts";
import { xiaomiTokenPlanSgpProvider } from "./xiaomi-token-plan-sgp.ts";
import { zaiProvider } from "./zai.ts";
import { zaiCodingCnProvider } from "./zai-coding-cn.ts";
export { radiusProvider };
/** Providers present in the generated catalog. `KnownProvider` additionally
* includes purely dynamic providers (e.g. "radius") that have no static
* catalog entry. */
export type BuiltinProvider = keyof typeof MODELS;
type BuiltinModelApi<
TProvider extends BuiltinProvider,
TModelId extends keyof (typeof MODELS)[TProvider],
> = (typeof MODELS)[TProvider][TModelId] extends { api: infer TApi } ? (TApi extends Api ? TApi : never) : never;
/** Typed read of the generated built-in catalog. */
export function getBuiltinModel<TProvider extends BuiltinProvider, TModelId extends keyof (typeof MODELS)[TProvider]>(
provider: TProvider,
modelId: TModelId,
): Model<BuiltinModelApi<TProvider, TModelId>> {
const models = MODELS[provider] as Record<string, Model<Api>> | undefined;
return models?.[modelId as string] as Model<BuiltinModelApi<TProvider, TModelId>>;
}
export function getBuiltinProviders(): BuiltinProvider[] {
return Object.keys(MODELS) as BuiltinProvider[];
}
export function getBuiltinModels<TProvider extends BuiltinProvider>(
provider: TProvider,
): Model<BuiltinModelApi<TProvider, keyof (typeof MODELS)[TProvider]>>[] {
const models = MODELS[provider] as Record<string, Model<Api>> | undefined;
return models
? (Object.values(models) as Model<BuiltinModelApi<TProvider, keyof (typeof MODELS)[TProvider]>>[])
: [];
}
/** All built-in providers, freshly constructed. */
export function builtinProviders(): Provider[] {
return [
amazonBedrockProvider(),
antLingProvider(),
anthropicProvider(),
azureOpenAIResponsesProvider(),
cerebrasProvider(),
cloudflareAIGatewayProvider(),
cloudflareWorkersAIProvider(),
deepseekProvider(),
fireworksProvider(),
githubCopilotProvider(),
googleProvider(),
googleVertexProvider(),
groqProvider(),
huggingfaceProvider(),
kimiCodingProvider(),
minimaxProvider(),
minimaxCnProvider(),
mistralProvider(),
moonshotaiProvider(),
moonshotaiCnProvider(),
nvidiaProvider(),
openaiProvider(),
openaiCodexProvider(),
opencodeProvider(),
opencodeGoProvider(),
openrouterProvider(),
radiusProvider(),
togetherProvider(),
vercelAIGatewayProvider(),
xaiProvider(),
xiaomiProvider(),
xiaomiTokenPlanAmsProvider(),
xiaomiTokenPlanCnProvider(),
xiaomiTokenPlanSgpProvider(),
zaiProvider(),
zaiCodingCnProvider(),
];
}
/** A `Models` collection with every built-in provider registered. */
export function builtinModels(options?: CreateModelsOptions): MutableModels {
const models = createModels(options);
for (const provider of builtinProviders()) {
models.setProvider(provider);
}
return models;
}
/** All built-in image-generation providers, freshly constructed. */
export function builtinImagesProviders(): ImagesProvider[] {
return [openrouterImagesProvider()];
}
/** An `ImagesModels` collection with every built-in image-generation provider registered. */
export function builtinImagesModels(options?: CreateModelsOptions): MutableImagesModels {
const models = createImagesModels(options);
for (const provider of builtinImagesProviders()) {
models.setProvider(provider);
}
return models;
}

View file

@ -1,444 +0,0 @@
// This file is auto-generated by scripts/generate-models.ts
// Do not edit manually - run 'npm run generate-models' to update
import values from "./data/amazon-bedrock.json" with { type: "json" };
import type { Model } from "../types.ts";
export const AMAZON_BEDROCK_MODELS = values as {
"amazon.nova-2-lite-v1:0": Model<"bedrock-converse-stream"> & {
id: "amazon.nova-2-lite-v1:0";
provider: "amazon-bedrock";
};
"amazon.nova-lite-v1:0": Model<"bedrock-converse-stream"> & {
id: "amazon.nova-lite-v1:0";
provider: "amazon-bedrock";
};
"amazon.nova-micro-v1:0": Model<"bedrock-converse-stream"> & {
id: "amazon.nova-micro-v1:0";
provider: "amazon-bedrock";
};
"amazon.nova-pro-v1:0": Model<"bedrock-converse-stream"> & {
id: "amazon.nova-pro-v1:0";
provider: "amazon-bedrock";
};
"anthropic.claude-fable-5": Model<"bedrock-converse-stream"> & {
id: "anthropic.claude-fable-5";
provider: "amazon-bedrock";
};
"anthropic.claude-haiku-4-5-20251001-v1:0": Model<"bedrock-converse-stream"> & {
id: "anthropic.claude-haiku-4-5-20251001-v1:0";
provider: "amazon-bedrock";
};
"anthropic.claude-opus-4-1-20250805-v1:0": Model<"bedrock-converse-stream"> & {
id: "anthropic.claude-opus-4-1-20250805-v1:0";
provider: "amazon-bedrock";
};
"anthropic.claude-opus-4-5-20251101-v1:0": Model<"bedrock-converse-stream"> & {
id: "anthropic.claude-opus-4-5-20251101-v1:0";
provider: "amazon-bedrock";
};
"anthropic.claude-opus-4-6-v1": Model<"bedrock-converse-stream"> & {
id: "anthropic.claude-opus-4-6-v1";
provider: "amazon-bedrock";
};
"anthropic.claude-opus-4-7": Model<"bedrock-converse-stream"> & {
id: "anthropic.claude-opus-4-7";
provider: "amazon-bedrock";
};
"anthropic.claude-opus-4-8": Model<"bedrock-converse-stream"> & {
id: "anthropic.claude-opus-4-8";
provider: "amazon-bedrock";
};
"anthropic.claude-sonnet-4-5-20250929-v1:0": Model<"bedrock-converse-stream"> & {
id: "anthropic.claude-sonnet-4-5-20250929-v1:0";
provider: "amazon-bedrock";
};
"anthropic.claude-sonnet-4-6": Model<"bedrock-converse-stream"> & {
id: "anthropic.claude-sonnet-4-6";
provider: "amazon-bedrock";
};
"anthropic.claude-sonnet-5": Model<"bedrock-converse-stream"> & {
id: "anthropic.claude-sonnet-5";
provider: "amazon-bedrock";
};
"au.anthropic.claude-haiku-4-5-20251001-v1:0": Model<"bedrock-converse-stream"> & {
id: "au.anthropic.claude-haiku-4-5-20251001-v1:0";
provider: "amazon-bedrock";
};
"au.anthropic.claude-opus-4-6-v1": Model<"bedrock-converse-stream"> & {
id: "au.anthropic.claude-opus-4-6-v1";
provider: "amazon-bedrock";
};
"au.anthropic.claude-opus-4-8": Model<"bedrock-converse-stream"> & {
id: "au.anthropic.claude-opus-4-8";
provider: "amazon-bedrock";
};
"au.anthropic.claude-sonnet-4-5-20250929-v1:0": Model<"bedrock-converse-stream"> & {
id: "au.anthropic.claude-sonnet-4-5-20250929-v1:0";
provider: "amazon-bedrock";
};
"au.anthropic.claude-sonnet-4-6": Model<"bedrock-converse-stream"> & {
id: "au.anthropic.claude-sonnet-4-6";
provider: "amazon-bedrock";
};
"au.anthropic.claude-sonnet-5": Model<"bedrock-converse-stream"> & {
id: "au.anthropic.claude-sonnet-5";
provider: "amazon-bedrock";
};
"deepseek.r1-v1:0": Model<"bedrock-converse-stream"> & {
id: "deepseek.r1-v1:0";
provider: "amazon-bedrock";
};
"deepseek.v3-v1:0": Model<"bedrock-converse-stream"> & {
id: "deepseek.v3-v1:0";
provider: "amazon-bedrock";
};
"deepseek.v3.2": Model<"bedrock-converse-stream"> & {
id: "deepseek.v3.2";
provider: "amazon-bedrock";
};
"eu.anthropic.claude-fable-5": Model<"bedrock-converse-stream"> & {
id: "eu.anthropic.claude-fable-5";
provider: "amazon-bedrock";
};
"eu.anthropic.claude-haiku-4-5-20251001-v1:0": Model<"bedrock-converse-stream"> & {
id: "eu.anthropic.claude-haiku-4-5-20251001-v1:0";
provider: "amazon-bedrock";
};
"eu.anthropic.claude-opus-4-5-20251101-v1:0": Model<"bedrock-converse-stream"> & {
id: "eu.anthropic.claude-opus-4-5-20251101-v1:0";
provider: "amazon-bedrock";
};
"eu.anthropic.claude-opus-4-6-v1": Model<"bedrock-converse-stream"> & {
id: "eu.anthropic.claude-opus-4-6-v1";
provider: "amazon-bedrock";
};
"eu.anthropic.claude-opus-4-7": Model<"bedrock-converse-stream"> & {
id: "eu.anthropic.claude-opus-4-7";
provider: "amazon-bedrock";
};
"eu.anthropic.claude-opus-4-8": Model<"bedrock-converse-stream"> & {
id: "eu.anthropic.claude-opus-4-8";
provider: "amazon-bedrock";
};
"eu.anthropic.claude-sonnet-4-5-20250929-v1:0": Model<"bedrock-converse-stream"> & {
id: "eu.anthropic.claude-sonnet-4-5-20250929-v1:0";
provider: "amazon-bedrock";
};
"eu.anthropic.claude-sonnet-4-6": Model<"bedrock-converse-stream"> & {
id: "eu.anthropic.claude-sonnet-4-6";
provider: "amazon-bedrock";
};
"eu.anthropic.claude-sonnet-5": Model<"bedrock-converse-stream"> & {
id: "eu.anthropic.claude-sonnet-5";
provider: "amazon-bedrock";
};
"global.anthropic.claude-fable-5": Model<"bedrock-converse-stream"> & {
id: "global.anthropic.claude-fable-5";
provider: "amazon-bedrock";
};
"global.anthropic.claude-haiku-4-5-20251001-v1:0": Model<"bedrock-converse-stream"> & {
id: "global.anthropic.claude-haiku-4-5-20251001-v1:0";
provider: "amazon-bedrock";
};
"global.anthropic.claude-opus-4-5-20251101-v1:0": Model<"bedrock-converse-stream"> & {
id: "global.anthropic.claude-opus-4-5-20251101-v1:0";
provider: "amazon-bedrock";
};
"global.anthropic.claude-opus-4-6-v1": Model<"bedrock-converse-stream"> & {
id: "global.anthropic.claude-opus-4-6-v1";
provider: "amazon-bedrock";
};
"global.anthropic.claude-opus-4-7": Model<"bedrock-converse-stream"> & {
id: "global.anthropic.claude-opus-4-7";
provider: "amazon-bedrock";
};
"global.anthropic.claude-opus-4-8": Model<"bedrock-converse-stream"> & {
id: "global.anthropic.claude-opus-4-8";
provider: "amazon-bedrock";
};
"global.anthropic.claude-sonnet-4-5-20250929-v1:0": Model<"bedrock-converse-stream"> & {
id: "global.anthropic.claude-sonnet-4-5-20250929-v1:0";
provider: "amazon-bedrock";
};
"global.anthropic.claude-sonnet-4-6": Model<"bedrock-converse-stream"> & {
id: "global.anthropic.claude-sonnet-4-6";
provider: "amazon-bedrock";
};
"global.anthropic.claude-sonnet-5": Model<"bedrock-converse-stream"> & {
id: "global.anthropic.claude-sonnet-5";
provider: "amazon-bedrock";
};
"google.gemma-3-27b-it": Model<"bedrock-converse-stream"> & {
id: "google.gemma-3-27b-it";
provider: "amazon-bedrock";
};
"google.gemma-3-4b-it": Model<"bedrock-converse-stream"> & {
id: "google.gemma-3-4b-it";
provider: "amazon-bedrock";
};
"jp.anthropic.claude-haiku-4-5-20251001-v1:0": Model<"bedrock-converse-stream"> & {
id: "jp.anthropic.claude-haiku-4-5-20251001-v1:0";
provider: "amazon-bedrock";
};
"jp.anthropic.claude-opus-4-7": Model<"bedrock-converse-stream"> & {
id: "jp.anthropic.claude-opus-4-7";
provider: "amazon-bedrock";
};
"jp.anthropic.claude-opus-4-8": Model<"bedrock-converse-stream"> & {
id: "jp.anthropic.claude-opus-4-8";
provider: "amazon-bedrock";
};
"jp.anthropic.claude-sonnet-4-5-20250929-v1:0": Model<"bedrock-converse-stream"> & {
id: "jp.anthropic.claude-sonnet-4-5-20250929-v1:0";
provider: "amazon-bedrock";
};
"jp.anthropic.claude-sonnet-4-6": Model<"bedrock-converse-stream"> & {
id: "jp.anthropic.claude-sonnet-4-6";
provider: "amazon-bedrock";
};
"jp.anthropic.claude-sonnet-5": Model<"bedrock-converse-stream"> & {
id: "jp.anthropic.claude-sonnet-5";
provider: "amazon-bedrock";
};
"meta.llama3-1-70b-instruct-v1:0": Model<"bedrock-converse-stream"> & {
id: "meta.llama3-1-70b-instruct-v1:0";
provider: "amazon-bedrock";
};
"meta.llama3-1-8b-instruct-v1:0": Model<"bedrock-converse-stream"> & {
id: "meta.llama3-1-8b-instruct-v1:0";
provider: "amazon-bedrock";
};
"meta.llama3-3-70b-instruct-v1:0": Model<"bedrock-converse-stream"> & {
id: "meta.llama3-3-70b-instruct-v1:0";
provider: "amazon-bedrock";
};
"meta.llama4-maverick-17b-instruct-v1:0": Model<"bedrock-converse-stream"> & {
id: "meta.llama4-maverick-17b-instruct-v1:0";
provider: "amazon-bedrock";
};
"meta.llama4-scout-17b-instruct-v1:0": Model<"bedrock-converse-stream"> & {
id: "meta.llama4-scout-17b-instruct-v1:0";
provider: "amazon-bedrock";
};
"minimax.minimax-m2": Model<"bedrock-converse-stream"> & {
id: "minimax.minimax-m2";
provider: "amazon-bedrock";
};
"minimax.minimax-m2.1": Model<"bedrock-converse-stream"> & {
id: "minimax.minimax-m2.1";
provider: "amazon-bedrock";
};
"minimax.minimax-m2.5": Model<"bedrock-converse-stream"> & {
id: "minimax.minimax-m2.5";
provider: "amazon-bedrock";
};
"mistral.devstral-2-123b": Model<"bedrock-converse-stream"> & {
id: "mistral.devstral-2-123b";
provider: "amazon-bedrock";
};
"mistral.magistral-small-2509": Model<"bedrock-converse-stream"> & {
id: "mistral.magistral-small-2509";
provider: "amazon-bedrock";
};
"mistral.ministral-3-14b-instruct": Model<"bedrock-converse-stream"> & {
id: "mistral.ministral-3-14b-instruct";
provider: "amazon-bedrock";
};
"mistral.ministral-3-3b-instruct": Model<"bedrock-converse-stream"> & {
id: "mistral.ministral-3-3b-instruct";
provider: "amazon-bedrock";
};
"mistral.ministral-3-8b-instruct": Model<"bedrock-converse-stream"> & {
id: "mistral.ministral-3-8b-instruct";
provider: "amazon-bedrock";
};
"mistral.mistral-large-3-675b-instruct": Model<"bedrock-converse-stream"> & {
id: "mistral.mistral-large-3-675b-instruct";
provider: "amazon-bedrock";
};
"mistral.pixtral-large-2502-v1:0": Model<"bedrock-converse-stream"> & {
id: "mistral.pixtral-large-2502-v1:0";
provider: "amazon-bedrock";
};
"mistral.voxtral-mini-3b-2507": Model<"bedrock-converse-stream"> & {
id: "mistral.voxtral-mini-3b-2507";
provider: "amazon-bedrock";
};
"mistral.voxtral-small-24b-2507": Model<"bedrock-converse-stream"> & {
id: "mistral.voxtral-small-24b-2507";
provider: "amazon-bedrock";
};
"moonshot.kimi-k2-thinking": Model<"bedrock-converse-stream"> & {
id: "moonshot.kimi-k2-thinking";
provider: "amazon-bedrock";
};
"moonshotai.kimi-k2.5": Model<"bedrock-converse-stream"> & {
id: "moonshotai.kimi-k2.5";
provider: "amazon-bedrock";
};
"nvidia.nemotron-nano-12b-v2": Model<"bedrock-converse-stream"> & {
id: "nvidia.nemotron-nano-12b-v2";
provider: "amazon-bedrock";
};
"nvidia.nemotron-nano-3-30b": Model<"bedrock-converse-stream"> & {
id: "nvidia.nemotron-nano-3-30b";
provider: "amazon-bedrock";
};
"nvidia.nemotron-nano-9b-v2": Model<"bedrock-converse-stream"> & {
id: "nvidia.nemotron-nano-9b-v2";
provider: "amazon-bedrock";
};
"nvidia.nemotron-super-3-120b": Model<"bedrock-converse-stream"> & {
id: "nvidia.nemotron-super-3-120b";
provider: "amazon-bedrock";
};
"openai.gpt-5.4": Model<"bedrock-converse-stream"> & {
id: "openai.gpt-5.4";
provider: "amazon-bedrock";
};
"openai.gpt-5.5": Model<"bedrock-converse-stream"> & {
id: "openai.gpt-5.5";
provider: "amazon-bedrock";
};
"openai.gpt-5.6-luna": Model<"bedrock-converse-stream"> & {
id: "openai.gpt-5.6-luna";
provider: "amazon-bedrock";
};
"openai.gpt-5.6-sol": Model<"bedrock-converse-stream"> & {
id: "openai.gpt-5.6-sol";
provider: "amazon-bedrock";
};
"openai.gpt-5.6-terra": Model<"bedrock-converse-stream"> & {
id: "openai.gpt-5.6-terra";
provider: "amazon-bedrock";
};
"openai.gpt-oss-120b": Model<"bedrock-converse-stream"> & {
id: "openai.gpt-oss-120b";
provider: "amazon-bedrock";
};
"openai.gpt-oss-120b-1:0": Model<"bedrock-converse-stream"> & {
id: "openai.gpt-oss-120b-1:0";
provider: "amazon-bedrock";
};
"openai.gpt-oss-20b": Model<"bedrock-converse-stream"> & {
id: "openai.gpt-oss-20b";
provider: "amazon-bedrock";
};
"openai.gpt-oss-20b-1:0": Model<"bedrock-converse-stream"> & {
id: "openai.gpt-oss-20b-1:0";
provider: "amazon-bedrock";
};
"openai.gpt-oss-safeguard-120b": Model<"bedrock-converse-stream"> & {
id: "openai.gpt-oss-safeguard-120b";
provider: "amazon-bedrock";
};
"openai.gpt-oss-safeguard-20b": Model<"bedrock-converse-stream"> & {
id: "openai.gpt-oss-safeguard-20b";
provider: "amazon-bedrock";
};
"qwen.qwen3-235b-a22b-2507-v1:0": Model<"bedrock-converse-stream"> & {
id: "qwen.qwen3-235b-a22b-2507-v1:0";
provider: "amazon-bedrock";
};
"qwen.qwen3-32b-v1:0": Model<"bedrock-converse-stream"> & {
id: "qwen.qwen3-32b-v1:0";
provider: "amazon-bedrock";
};
"qwen.qwen3-coder-30b-a3b-v1:0": Model<"bedrock-converse-stream"> & {
id: "qwen.qwen3-coder-30b-a3b-v1:0";
provider: "amazon-bedrock";
};
"qwen.qwen3-coder-480b-a35b-v1:0": Model<"bedrock-converse-stream"> & {
id: "qwen.qwen3-coder-480b-a35b-v1:0";
provider: "amazon-bedrock";
};
"qwen.qwen3-coder-next": Model<"bedrock-converse-stream"> & {
id: "qwen.qwen3-coder-next";
provider: "amazon-bedrock";
};
"qwen.qwen3-next-80b-a3b": Model<"bedrock-converse-stream"> & {
id: "qwen.qwen3-next-80b-a3b";
provider: "amazon-bedrock";
};
"qwen.qwen3-vl-235b-a22b": Model<"bedrock-converse-stream"> & {
id: "qwen.qwen3-vl-235b-a22b";
provider: "amazon-bedrock";
};
"us.anthropic.claude-fable-5": Model<"bedrock-converse-stream"> & {
id: "us.anthropic.claude-fable-5";
provider: "amazon-bedrock";
};
"us.anthropic.claude-haiku-4-5-20251001-v1:0": Model<"bedrock-converse-stream"> & {
id: "us.anthropic.claude-haiku-4-5-20251001-v1:0";
provider: "amazon-bedrock";
};
"us.anthropic.claude-opus-4-1-20250805-v1:0": Model<"bedrock-converse-stream"> & {
id: "us.anthropic.claude-opus-4-1-20250805-v1:0";
provider: "amazon-bedrock";
};
"us.anthropic.claude-opus-4-5-20251101-v1:0": Model<"bedrock-converse-stream"> & {
id: "us.anthropic.claude-opus-4-5-20251101-v1:0";
provider: "amazon-bedrock";
};
"us.anthropic.claude-opus-4-6-v1": Model<"bedrock-converse-stream"> & {
id: "us.anthropic.claude-opus-4-6-v1";
provider: "amazon-bedrock";
};
"us.anthropic.claude-opus-4-7": Model<"bedrock-converse-stream"> & {
id: "us.anthropic.claude-opus-4-7";
provider: "amazon-bedrock";
};
"us.anthropic.claude-opus-4-8": Model<"bedrock-converse-stream"> & {
id: "us.anthropic.claude-opus-4-8";
provider: "amazon-bedrock";
};
"us.anthropic.claude-sonnet-4-5-20250929-v1:0": Model<"bedrock-converse-stream"> & {
id: "us.anthropic.claude-sonnet-4-5-20250929-v1:0";
provider: "amazon-bedrock";
};
"us.anthropic.claude-sonnet-4-6": Model<"bedrock-converse-stream"> & {
id: "us.anthropic.claude-sonnet-4-6";
provider: "amazon-bedrock";
};
"us.anthropic.claude-sonnet-5": Model<"bedrock-converse-stream"> & {
id: "us.anthropic.claude-sonnet-5";
provider: "amazon-bedrock";
};
"us.deepseek.r1-v1:0": Model<"bedrock-converse-stream"> & {
id: "us.deepseek.r1-v1:0";
provider: "amazon-bedrock";
};
"us.meta.llama4-maverick-17b-instruct-v1:0": Model<"bedrock-converse-stream"> & {
id: "us.meta.llama4-maverick-17b-instruct-v1:0";
provider: "amazon-bedrock";
};
"us.meta.llama4-scout-17b-instruct-v1:0": Model<"bedrock-converse-stream"> & {
id: "us.meta.llama4-scout-17b-instruct-v1:0";
provider: "amazon-bedrock";
};
"writer.palmyra-x4-v1:0": Model<"bedrock-converse-stream"> & {
id: "writer.palmyra-x4-v1:0";
provider: "amazon-bedrock";
};
"writer.palmyra-x5-v1:0": Model<"bedrock-converse-stream"> & {
id: "writer.palmyra-x5-v1:0";
provider: "amazon-bedrock";
};
"xai.grok-4.3": Model<"bedrock-converse-stream"> & {
id: "xai.grok-4.3";
provider: "amazon-bedrock";
};
"zai.glm-4.7": Model<"bedrock-converse-stream"> & {
id: "zai.glm-4.7";
provider: "amazon-bedrock";
};
"zai.glm-4.7-flash": Model<"bedrock-converse-stream"> & {
id: "zai.glm-4.7-flash";
provider: "amazon-bedrock";
};
"zai.glm-5": Model<"bedrock-converse-stream"> & {
id: "zai.glm-5";
provider: "amazon-bedrock";
};
};

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