Compare commits

...

393 Commits

Author SHA1 Message Date
a7fb0a4369 fix: 同步 PI_CODING_AGENT_DIR 并更新模型列表
Some checks failed
CI / build-check-test (push) Has been cancelled
- build.sh 导出 PI_CODING_AGENT_DIR 以对齐 pi-mcp-adapter 配置路径
- 更新 models.generated.ts
- 移除 bmi-calculator.html 示例文件
2026-06-26 13:21:14 +08:00
539599dbb8 feat(coding-agent): isolate SproutClaw namespace from upstream pi
Some checks failed
CI / build-check-test (push) Has been cancelled
Use piConfig name sproutclaw and configDir .sproutclaw so commands, env vars,
and project config no longer collide with the stock pi install.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 10:29:28 +08:00
294ce75892 docs: 更新 README,修正过时内容
Some checks failed
CI / build-check-test (push) Has been cancelled
- 移除 .pi/ 目录条目(已 gitignore)
- 更新 WebUI 说明为独立 sproutclaw-web 服务
- 修正 build.sh/test.sh 说明和脚本引用错误
- 更新合并上游流程(直接 merge,不再用临时分支)
- 补充 Gitea remote 配置说明

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 20:56:31 +08:00
f1c5e7dc1d chore: rename pi-built/pi-test scripts to build/test
Some checks failed
CI / build-check-test (push) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 20:54:00 +08:00
812bca79a3 docs: 将 CONTRIBUTING.md 和 SECURITY.md 翻译为中文
Some checks failed
CI / build-check-test (push) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 20:49:18 +08:00
07a918d4cd chore: merge upstream sync branch into main
Some checks failed
CI / build-check-test (push) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 20:18:34 +08:00
8ad811c977 feat: sync upstream pi-mono (386 commits)
从上游 badlogic/pi-mono 同步 386 个提交,手动解决所有冲突:

保留 SproutClaw 独有功能:
- RPC: reload 命令、bash 流式输出、get_extensions 命令
- settings-manager: showChangelogOnStartup 设置
- agent-session: turnIndex getter
- 移除 pi 版本检查通知(interactive-mode)
- skills 系统、启动脚本、自定义工具脚本

直接采用上游版本:
- packages/ai 模型列表及测试文件
- packages/coding-agent 文档
- 其余未冲突的全部上游代码

升级 @mistralai/mistralai 至 2.2.6(修复 promptCacheKey 类型错误)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 20:18:02 +08:00
Armin Ronacher
d93b92baca fix(coding-agent): show changelog URL in update notice 2026-06-20 22:23:25 +02:00
Armin Ronacher
b4f31408b6 Add [Unreleased] section for next cycle 2026-06-20 22:15:40 +02:00
Armin Ronacher
615bf2f874 Release v0.79.9 2026-06-20 22:15:37 +02:00
Armin Ronacher
1aa79b9b29 docs: update unreleased changelog audit 2026-06-20 22:00:00 +02:00
Armin Ronacher
350ac3f344 fix: remove inprogress from auto-closed issues 2026-06-20 21:48:37 +02:00
Armin Ronacher
500b568b04 fix(ai): use OpenAI endpoint for Fireworks GLM-5.2
closes #5923
2026-06-20 21:32:02 +02:00
Armin Ronacher
5505316ea2 fix(coding-agent): cache extension imports for session switches
Closes #5905.
2026-06-20 21:07:45 +02:00
Armin Ronacher
a1da88aed4 fix(coding-agent): make session path traversal linear
Avoid quadratic path construction when walking deep session branches. On a 600k-entry pathological session, path construction improved from about 20.3s with Array.unshift() to about 35ms with push() plus reverse().

Refs #5804.
2026-06-20 20:29:15 +02:00
Armin Ronacher
8597ebafd9 fix(ai): expose OpenRouter GLM-5.2 xhigh effort
closes #5770
2026-06-20 18:47:42 +02:00
Armin Ronacher
416c673dfe fix: skip no-action for to-discuss issues 2026-06-20 16:13:13 +02:00
Alexey Zaytsev
3095977d13 fix(tui): stabilize streaming code fence rendering (#5846) 2026-06-20 14:58:30 +02:00
Armin Ronacher
8b97e75c6b feat(ai): add chat-template thinking compat
closes #5673
2026-06-19 23:34:17 +02:00
Armin Ronacher
128330e36f fix(coding-agent): preserve untouched lines in fuzzy edit
closes #5899
2026-06-19 20:16:47 +02:00
Vegard Stikbakke
1287b69fe0 fix(coding-agent): run legacy WSL bash commands via stdin
closes #5893
2026-06-19 15:08:25 +02:00
Vegard Stikbakke
6e6ce70caf fix(ai): filter Copilot models by account availability
closes #5897
2026-06-19 13:17:13 +02:00
Armin Ronacher
226a316824 fix: mark auto-closed issues not planned 2026-06-19 12:46:56 +02:00
Vegard Stikbakke
373cd6ae52 fix(coding-agent): prioritize provider matches in model selector
closes #5892
2026-06-19 12:25:02 +02:00
Armin Ronacher
47d1d90a8e fix: close no-action issues as not planned 2026-06-19 11:08:16 +02:00
Armin Ronacher
783571a615 feat: track auto-closed issue triage 2026-06-19 11:03:11 +02:00
Armin Ronacher
56b22768db Add [Unreleased] section for next cycle 2026-06-19 09:53:45 +02:00
Armin Ronacher
8eb9704b3e Release v0.79.8 2026-06-19 09:53:43 +02:00
Armin Ronacher
74677bbfbf docs: audit unreleased changelogs 2026-06-19 09:45:03 +02:00
Armin Ronacher
a2f70e5f28 fix(coding-agent): reset tool test mocks 2026-06-19 09:22:13 +02:00
Armin Ronacher
ea65a51a15 fix: update vulnerable dependencies 2026-06-19 00:43:44 +02:00
Fred K. Schott
0d89a33373 feat(packages): Add selective pi-ai base entrypoints (#5348) 2026-06-19 00:41:49 +02:00
Armin Ronacher
1a418ad223 chore: remove inprogress label on close 2026-06-19 00:26:30 +02:00
Armin Ronacher
9179734cc2 docs(coding-agent): audit unreleased changelog 2026-06-18 23:58:34 +02:00
Armin Ronacher
651d10d90b feat(ai): enable Mistral prompt caching 2026-06-18 23:47:25 +02:00
Armin Ronacher
8025fdd01f meta: Update readmes slightly 2026-06-18 23:36:08 +02:00
Armin Ronacher
fd1ba2c7fe test(coding-agent): seed auto-compaction queue fixture 2026-06-18 23:15:18 +02:00
Armin Ronacher
cab89d1435 docs: audit unreleased changelogs 2026-06-18 23:09:41 +02:00
Armin Ronacher
c60f6a8ab3 feat(coding-agent): expose post-compaction token estimates
closes #5877
2026-06-18 23:01:10 +02:00
Danila Poyarkov
b09fbde00e feat(ai): add OpenRouter Fusion alias (#5866) 2026-06-18 22:39:02 +02:00
Armin Ronacher
7d08c81a09 fix(coding-agent): avoid empty compaction summaries
closes #4811
2026-06-18 22:17:57 +02:00
Armin Ronacher
6b9f3f492f fix(coding-agent): avoid retrying successful overflow compaction
closes #5720
2026-06-18 19:30:14 +02:00
Armin Ronacher
788a044483 Add [Unreleased] section for next cycle 2026-06-18 18:22:10 +02:00
Armin Ronacher
c4ab61dcbf Release v0.79.7 2026-06-18 18:22:07 +02:00
Armin Ronacher
717494223d docs: audit unreleased changelogs 2026-06-18 18:16:48 +02:00
Armin Ronacher
aae62dfa87 feat(coding-agent): make bare update self-only 2026-06-18 18:09:03 +02:00
Alexey Zaytsev
2b46f38863 feat(coding-agent): Expose edit-diff for extensions (#5756) 2026-06-18 17:14:39 +02:00
Armin Ronacher
d0b46764b1 feat(coding-agent): add automatic theme mode (#5874) 2026-06-18 17:13:47 +02:00
Armin Ronacher
908be616f2 ref: Remove some options from package reporting 2026-06-18 17:12:03 +02:00
Armin Ronacher
bc93655e1c meta: Added report template 2026-06-18 16:26:01 +02:00
Vegard Stikbakke
20da9bc140 fix attribution for 008c76f9 2026-06-18 10:59:25 +02:00
Diego de Oliveira
7a14325b24 feat(tui): detect Warp terminal and enable Kitty image protocol (#5841)
Warp supports the Kitty graphics protocol and OSC 8 hyperlinks,
but pi's terminal-image capability detection did not recognize it.
Add detection via TERM_PROGRAM=WarpTerminal, WARP_SESSION_ID,
and WARP_TERMINAL_SESSION_UUID so images render inline without
requiring the TERM_PROGRAM=kitty workaround.

Fixes #5827
2026-06-18 10:45:51 +02:00
Vegard Stikbakke
51f7523587 fix(coding-agent): include RPC request id on unknown commands
closes #5868
2026-06-18 10:44:59 +02:00
Vegard Stikbakke
008c76f955 feat(coding-agent): export project config dir name 2026-06-18 10:27:11 +02:00
Vegard Stikbakke
58dd2f5996 feat(ai): add GLM-5.2 to OpenCode Go model catalog
closes #5860
2026-06-18 10:06:23 +02:00
Armin Ronacher
6d5ede31c8 fix(coding-agent): match provider-first model searches 2026-06-17 17:16:39 +02:00
Vegard Stikbakke
ae89286d07 docs: update changelogs for tree panning
closes #5830
2026-06-17 14:48:35 +02:00
Vegard Stikbakke
068ab5d19b fix(coding-agent): horizontally pan tree selector
Fixes #5830
2026-06-17 14:41:39 +02:00
github-actions[bot]
29c1504cc1 chore: approve contributor dodiego 2026-06-17 07:24:29 +00:00
Armin Ronacher
12bb8dd2c9 Add [Unreleased] section for next cycle 2026-06-16 23:59:17 +02:00
Armin Ronacher
31bfb2f16f Release v0.79.6 2026-06-16 23:59:14 +02:00
Armin Ronacher
34b6aea1e6 docs(coding-agent): add changelog entries for fetch override and DeepSeek V4 thinking-off fixes 2026-06-16 23:59:00 +02:00
Armin Ronacher
7da475db66 fix(ai): regenerate model catalog 2026-06-16 23:58:43 +02:00
Armin Ronacher
bd9f8773ad fix(ai): restore OpenCode Go DeepSeek thinking controls 2026-06-16 23:14:56 +02:00
Armin Ronacher
a93f066607 fix(coding-agent): preserve fetch overrides 2026-06-16 20:04:53 +02:00
Armin Ronacher
a78cd7ccb1 fix(coding-agent): stabilize self-update tests 2026-06-16 20:02:20 +02:00
Armin Ronacher
0b0b9eae62 Add [Unreleased] section for next cycle 2026-06-16 19:54:26 +02:00
Armin Ronacher
6561cb293b Release v0.79.5 2026-06-16 19:54:23 +02:00
Armin Ronacher
b6b5bed9ae docs: update unreleased changelogs 2026-06-16 19:46:15 +02:00
Armin Ronacher
2431491c92 fix(ai): avoid duplicate OpenCode DeepSeek reasoning controls
closes #5818
2026-06-16 19:05:44 +02:00
Armin Ronacher
2d597f0212 fix(ai): tolerate null Responses message content
closes #5819
2026-06-16 19:00:12 +02:00
Armin Ronacher
910508595a feat(coding-agent): add settings http proxy
closes #5790
2026-06-16 18:53:19 +02:00
Armin Ronacher
0680726ac4 fix: upgrade marked to 18.0.5 2026-06-16 17:41:59 +02:00
Armin Ronacher
8f0e9251b9 fix(coding-agent): do not open browser for device code login 2026-06-16 17:35:50 +02:00
Armin Ronacher
7f29e7a369 feat: add provider-scoped environment overrides (#5807) 2026-06-16 17:19:08 +02:00
4h9fbZ
3039f3e17d fix(tui): restore cursorUp start-of-line jump when input is non-empty (#5789) 2026-06-16 15:14:17 +02:00
Armin Ronacher
06d8c54de2 fix(coding-agent): avoid Windows pi update exit assertion
closes #5805
2026-06-16 15:10:23 +02:00
Armin Ronacher
75b0d723c0 fix(ai): support Z.AI GLM-5.2 effort levels
closes #5770
2026-06-16 14:29:27 +02:00
Ramiz Wachtler
f8a77f47f2 feat(coding-agent): add Vercel AI Gateway attribution (#5798) 2026-06-16 09:37:37 +02:00
Armin Ronacher
b0c8f65ffa fix(ai): update Google Vertex Gemini models
closes #5761
2026-06-16 01:03:12 +02:00
Armin Ronacher
7cfd1af395 fix(coding-agent): keep empty session selector open
closes #5747
2026-06-16 00:39:44 +02:00
Armin Ronacher
bee8e9c805 feat(coding-agent): mark experimental sessions in footer 2026-06-15 23:22:23 +02:00
Armin Ronacher
431d88f4fc meta: Link to rfcs 2026-06-15 17:36:39 +02:00
Armin Ronacher
0369bdb8f4 fix(ai): add Moonshot CN Kimi K2.7 metadata
closes #5760
2026-06-15 17:08:59 +02:00
Armin Ronacher
1aa3c02d56 Add [Unreleased] section for next cycle 2026-06-15 10:28:24 +02:00
Armin Ronacher
bba6af2c71 Release v0.79.4 2026-06-15 10:28:21 +02:00
Armin Ronacher
b1ad469be4 docs: audit changelog entries 2026-06-15 10:23:19 +02:00
Armin Ronacher
8a7ad60f03 feat(coding-agent): add binary release checksums
closes #5739
2026-06-15 10:11:44 +02:00
Joseph Mearman
3fa4095629 fix: drain stdout before resolving when a child holds the pipe past exit (#5753) 2026-06-15 09:55:45 +02:00
Armin Ronacher
408ac103ec fix(ai): update Copilot Claude thinking metadata
closes #4637
2026-06-15 09:50:25 +02:00
github-actions[bot]
28b3af5d0f chore: approve contributor Mearman 2026-06-15 07:25:09 +00:00
Bucky
0be5bb6c96 fix(ai): price anthropic 1h cache writes at 2x input (#5738)
anthropic 1h cache writes were billed at the 5m rate; split the bucket and price the 1h portion correctly
2026-06-15 09:21:14 +02:00
Vegard Stikbakke
a851968170 docs(coding-agent): reorder containerization patterns 2026-06-15 09:16:13 +02:00
Armin Ronacher
bb959aae01 fix(coding-agent): wrap tree help on narrow terminals
closes #5055
2026-06-15 01:25:46 +02:00
Armin Ronacher
24053eab66 fix(tui): update tab overlay boundary expectation 2026-06-15 01:12:40 +02:00
Armin Ronacher
5b6058c3a7 fix(tui): align overlays over CJK wide cells
closes #5297
2026-06-15 01:06:15 +02:00
Armin Ronacher
ba0ec61563 fix(coding-agent): restore terminal on SIGTERM
closes #5724
2026-06-15 00:47:35 +02:00
Armin Ronacher
b5e13bcd8a docs(coding-agent): clarify active tools docs
closes #5729
2026-06-15 00:02:31 +02:00
Armin Ronacher
93b3b7c1fe fix(tui): preserve WezTerm Kitty images on full redraw
Reserve visible Kitty image rows before drawing placements in full redraws, while preserving the first-row path for taller-than-viewport images so #4415 stays fixed.

Closes #5618.
2026-06-14 23:16:59 +02:00
Armin Ronacher
d683a581b7 meta: Update CONTRIBUTING.md for clearer language 2026-06-14 22:31:42 +02:00
Alexey Zaytsev
6b40c99a59 feat(examples): Wrap question extension text instead of truncating (#5708) 2026-06-14 15:02:58 +02:00
Armin Ronacher
11b5403fad fix(coding-agent): exit after package commands
closes #5687
2026-06-14 10:53:00 +02:00
Vegard Stikbakke
f0989800cb feat: detect first-run terminal theme (#5385) 2026-06-14 07:14:42 +02:00
Armin Ronacher
3fcfb7abf7 docs(coding-agent): document extension resource lifecycle 2026-06-14 02:05:11 +02:00
Armin Ronacher
c48f656f88 fix(coding-agent): handle npm package semver ranges
closes #5695
2026-06-14 01:38:51 +02:00
Armin Ronacher
2fbdff9dab fix(coding-agent): fix pnpm self-update bin-dir
closes #5689
2026-06-14 01:31:50 +02:00
Armin Ronacher
5be8c31fa7 meta: add extension disclaimer to bug reporting 2026-06-14 00:57:56 +02:00
Armin Ronacher
21a904f4b7 fix(ai): disable OpenCode long cache retention for rejecting routes
closes #5702
2026-06-13 23:59:45 +02:00
Armin Ronacher
9e9fc79478 fix(coding-agent): treat uppercase config values as literals
closes #5661
2026-06-13 21:24:22 +02:00
Armin Ronacher
f315d81491 meta: Update weekend policy in contributing to be less confusing 2026-06-13 15:43:00 +02:00
Armin Ronacher
6f29450e13 fix(ai): update adaptive thinking model expectations 2026-06-13 11:17:38 +02:00
Armin Ronacher
b15148fe37 Add [Unreleased] section for next cycle 2026-06-13 11:11:14 +02:00
Armin Ronacher
f2585c4c82 Release v0.79.3 2026-06-13 11:11:11 +02:00
Armin Ronacher
57b6bdce71 docs(coding-agent): update Codex context limit changelog 2026-06-13 11:07:31 +02:00
Armin Ronacher
aa3a5233ad fix(ai): restore Codex context limits 2026-06-13 11:02:38 +02:00
Armin Ronacher
032c01c1e1 Add [Unreleased] section for next cycle 2026-06-13 00:25:18 +02:00
Armin Ronacher
f21f3c4bbd Release v0.79.2 2026-06-13 00:25:15 +02:00
Armin Ronacher
e320f09618 docs: update unreleased changelogs 2026-06-13 00:20:01 +02:00
Armin Ronacher
121f0edbf4 fix(ai): detect parenthesized context overflow errors
closes #5677
2026-06-13 00:01:37 +02:00
Michael Yu
7a3cb6312e fix(ai): normalize generated model costs (#5634) 2026-06-13 00:01:04 +02:00
Armin Ronacher
b4bff7f0d0 fix(coding-agent): avoid project trust prompt for update (#5674) 2026-06-12 23:37:16 +02:00
Armin Ronacher
a7cdc679e7 fix(ai): correct GPT-5 context window metadata
closes #5644
2026-06-12 23:03:10 +02:00
Armin Ronacher
17721d5e01 fix(tui): preserve unordered user list markers (closes #5657) 2026-06-12 20:22:53 +02:00
Armin Ronacher
daab056ac1 fix(agent): ignore late tool progress updates
closes #5573
2026-06-12 18:59:32 +02:00
Armin Ronacher
adf567c1c6 fix(coding-agent): rechain fork paths without labels
closes #5669
2026-06-12 18:50:31 +02:00
Armin Ronacher
1b2c32c653 fix(coding-agent): resolve authenticated slash model ids
closes #5643
2026-06-12 18:32:42 +02:00
Armin Ronacher
0caca6cf3f fix(tui): support slash-separated fuzzy filter tokens 2026-06-12 18:24:55 +02:00
Armin Ronacher
6102dd2084 fix(coding-agent): handle missing export themes
closes #5596
2026-06-12 17:56:43 +02:00
Armin Ronacher
1fc80f4f6c fix(coding-agent): preserve custom fallback thinking
closes #5552
2026-06-12 17:26:42 +02:00
Armin Ronacher
be7d5cf585 fix(ai): relax Codex SSE header timeout 2026-06-12 17:08:30 +02:00
Ramiz Wachtler
a455f62f72 fix(ai): preserve Anthropic refusal details (#5666)
Propagate Anthropic refusal stop_details explanation to errorMessage
2026-06-12 14:52:46 +02:00
Armin Ronacher
1c24336560 fix(tui): keep WezTerm Kitty images visible
Closes #5618.
2026-06-12 12:59:32 +02:00
Vegard Stikbakke
3f44d3e2eb fix(ai): remove stale OpenRouter Kimi free model assertion (#5650) 2026-06-12 09:00:15 +02:00
Vegard Stikbakke
1da903983a fix(coding-agent): skip first-time setup for forks (#5627) 2026-06-11 21:36:26 +02:00
Vegard Stikbakke
406a2214aa fix(coding-agent): refine setup copy 2026-06-10 19:55:19 +02:00
Vegard Stikbakke
0ab2aa86af feat(coding-agent): add experimental first-time setup flow (#5587)
Behind PI_EXPERIMENTAL=1, show a first-time setup dialog on interactive startup when the default agent directory is used and settings.json does not exist. The dialog preselects the detected terminal appearance with an explicit dark/light choice (live preview) and asks for opt-in analytics data sharing. Submitting writes settings.json, which serves as the completion marker; opting in stores a generated trackingId. Escape at any point skips out of setup.
2026-06-10 12:11:49 +02:00
Mario Zechner
a3cd03e7ff Merge pull request #5585 from haoqixu/fix-editor-cjk-wrap
fix(tui): wrap CJK text at character boundaries in editor
2026-06-10 11:43:42 +02:00
Mario Zechner
2f5066d7a0 Merge pull request #5562 from Perlence/fix-tui-render-loose-lists
fix(tui): separate list items with blank lines in loose lists
2026-06-10 11:42:12 +02:00
Mario Zechner
e537dba3de Merge pull request #5561 from unexge/push-lpxyxwstnswr
feat(ai): link AWS data retention docs in Bedrock validation errors
2026-06-10 11:41:29 +02:00
Mario Zechner
9fd75b8acb Merge pull request #5560 from haoqixu/fix-5552
fix(coding-agent): parse :thinking suffix from custom model IDs in fallback path
2026-06-10 11:39:52 +02:00
Mario Zechner
a7f9fe681d fix: bump shell-quote to 1.8.4 in lockfile (GHSA-w7jw-789q-3m8p) 2026-06-10 11:01:30 +02:00
Burak Varli
0b6c95dda0 feat(ai): link AWS data retention docs in Bedrock validation errors
When Bedrock rejects a request with "data retention mode '<mode>' is not
available for this model", append a pointer to the AWS data retention
documentation so users can configure a supported mode.
2026-06-10 07:45:50 +00:00
haoqixu
eb01f59dc9 fix(tui): wrap CJK text at character boundaries in editor 2026-06-10 15:39:44 +08:00
Armin Ronacher
9ccfcd7cfc fix(ai): omit disabled thinking for Claude Fable 5 2026-06-10 00:27:11 +02:00
Armin Ronacher
dacb367e9e fix(ai): expect Claude Fable 5 in adaptive thinking model test 2026-06-09 23:20:20 +02:00
Armin Ronacher
82f2b1e908 Add [Unreleased] section for next cycle 2026-06-09 23:14:55 +02:00
Armin Ronacher
28df940f0d Release v0.79.1 2026-06-09 23:14:52 +02:00
Armin Ronacher
4d9f9f455d fix(ai): regenerate image models for upstream Riverflow rename 2026-06-09 23:14:38 +02:00
Armin Ronacher
66f432cae4 fix(ai): regenerate models for Claude Fable 5 and Azure metadata overrides
Also add inherited coding-agent changelog entries for Fable 5 and the
Azure gpt-5.4/5.5 context window and gpt-5-pro maxTokens fixes.
2026-06-09 23:08:05 +02:00
Armin Ronacher
6b5923f107 fix(ai): correct Azure gpt-5.4/5.5 context window and gpt-5-pro maxTokens
Azure Foundry deploys gpt-5.4 and gpt-5.5 with a 1,050,000 context
window, but the Azure provider cloned OpenAI-direct's 272k API limit.
Override the context window in the Azure derivation.

Also fix gpt-5-pro maxTokens, which upstream metadata set to 272000 (a
duplicate of the input sub-limit) instead of the actual 128000 max
output; corrected at the source so OpenAI-direct and Azure both match.

closes #5559
2026-06-09 22:57:20 +02:00
Armin Ronacher
5a9d72ea02 feat(ai): add Claude Fable 5 metadata 2026-06-09 22:42:48 +02:00
Armin Ronacher
a0c2465d47 docs: audit unreleased changelogs 2026-06-09 22:33:02 +02:00
Sviatoslav Abakumov
dfd4571cb0 fix(tui): separate list items with blank lines in loose lists 2026-06-09 23:53:18 +04:00
haoqixu
aa03982169 fix(coding-agent): parse :thinking suffix from custom model IDs in fallback path
Fixes #5552
2026-06-10 02:01:35 +08:00
Mario Zechner
c5582102f5 Merge pull request #5553 from dannote/prompt-template-defaults
Add prompt template argument defaults
2026-06-09 14:23:08 +02:00
Mario Zechner
143b251e77 Merge branch 'main' into prompt-template-defaults 2026-06-09 14:23:00 +02:00
Mario Zechner
ae7a885da2 Closes #5045, /new should not persist if original session was ephemeral 2026-06-09 14:17:33 +02:00
Mario Zechner
b7e721cb38 feat(tui): support autocomplete trigger characters
closes #4703
2026-06-09 14:15:39 +02:00
Mario Zechner
69ea1a6310 docs(coding-agent): clarify model name display docs
closes #4841
2026-06-09 14:14:57 +02:00
Danila Poyarkov
d81ac20920 feat(coding-agent): add prompt template argument defaults 2026-06-09 15:12:07 +03:00
Mario Zechner
d041b5cc35 Merge pull request #5549 from earendil-works/approval-settings
feat(ui): Improved project approval settings
2026-06-09 13:47:29 +02:00
Armin Ronacher
c20ea06d4a fix: --help and --version redirect 2026-06-09 13:43:54 +02:00
Mario Zechner
3d02d1da11 fix(ai): map OpenCode max tokens
closes #5331
2026-06-09 13:41:08 +02:00
Mario Zechner
9632bddd38 fix(coding-agent): stabilize OAuth login prompt rows
closes #5433
2026-06-09 13:41:08 +02:00
Mario Zechner
64b51efb6e fix(ai): use z.ai thinking payload
closes #5330
2026-06-09 13:37:58 +02:00
Armin Ronacher
359a0769f1 fix: simplify help 2026-06-09 13:36:05 +02:00
Armin Ronacher
5cb4f597f7 feat(ui): Improved project approval settings 2026-06-09 13:25:54 +02:00
Vegard Stikbakke
66335d3a49 feat(coding-agent): add experimental feature guard (#5547) 2026-06-09 13:23:07 +02:00
Mario Zechner
28c83e8385 fix(coding-agent): sync queue modes on reload
closes #5377
2026-06-09 13:21:59 +02:00
Mario Zechner
1906074369 fix(coding-agent): handle invalid models json during migration
closes #5418
2026-06-09 13:14:24 +02:00
Mario Zechner
e4907b3b09 fix(tui): restore prompt draft after history browsing
closes #5494
2026-06-09 13:06:13 +02:00
Mario Zechner
db3f9953ee feat(coding-agent): expose project trust to extensions
closes #5523
2026-06-09 13:03:59 +02:00
github-actions[bot]
081a0a2bef chore: approve contributor dangooddd 2026-06-09 11:02:46 +00:00
Mario Zechner
84cdd02400 fix(ai): disable Azure OpenAI response storage
closes #5530
2026-06-09 12:53:17 +02:00
Mario Zechner
8da077bcca fix(tui): wrap CJK text at grapheme boundaries
closes #5495
2026-06-09 12:52:52 +02:00
github-actions[bot]
def99d395e chore: approve contributor vdxz 2026-06-09 10:51:30 +00:00
Mario Zechner
22e4549275 Merge pull request #5283 from smoosex/main
fix(tui): keep hardware cursor marker during slash-command autocomplete
2026-06-09 12:33:15 +02:00
Mario Zechner
2326d5cb4a fix(ai): disable Moonshot thinking when requested
closes #5531
2026-06-09 12:23:41 +02:00
github-actions[bot]
c6bdfa1971 chore: approve contributor davidlifschitz 2026-06-09 09:40:40 +00:00
Mario Zechner
4c4863654e Merge pull request #5505 from awakenalive/patch-1
Update models for together.ai
2026-06-09 10:52:03 +02:00
Mario Zechner
44e33798fd Merge pull request #5527 from AJM10565/fix/bedrock-arn-region-parsing
fix(amazon-bedrock): extract region from inference profile ARNs
2026-06-09 09:50:01 +02:00
ajm_ensighten
8cef3c8d7f fix(amazon-bedrock): extract region from inference profile ARNs
Application inference profile ARNs encode the region
(arn:aws:bedrock:<region>:<account>:...) but the provider ignored it,
falling through to AWS_REGION which may point to a different region.
Extract the region from the ARN when present, taking priority over
environment variables.

Fixes #4860
2026-06-08 17:01:51 -05:00
Armin Ronacher
20b78eafb4 fix(coding-agent): fix changelog links
Fixes #5516
2026-06-08 20:31:20 +02:00
Armin Ronacher
2edd6b432a Add [Unreleased] section for next cycle 2026-06-08 17:15:54 +02:00
Armin Ronacher
c10fb95fd9 Release v0.79.0 2026-06-08 17:15:51 +02:00
Armin Ronacher
35120d7e48 docs: audit unreleased changelogs 2026-06-08 16:01:13 +02:00
Armin Ronacher
ce3a72444e docs(coding-agent): document security model 2026-06-08 15:56:29 +02:00
Eric Henry
2527441b40 Update generate-models.ts
Removed together.ai MiniMaxAI/MiniMax-M2.5 due to it not being supported for serverless inference.

 Error: 400 Unable to access non-serverless model MiniMaxAI/MiniMax-M2.5. Please visit
 https://api.together.ai/models/MiniMaxAI/MiniMax-M2.5 to create and start a new dedicated endpoint for the model.
2026-06-08 08:32:16 -05:00
Mario Zechner
d8aef0feff feat(coding-agent): allow project trust extensions to defer 2026-06-08 13:38:29 +02:00
Mario Zechner
085a08582f fix(coding-agent): remove stale hooks export 2026-06-08 13:27:16 +02:00
Mario Zechner
21917fed37 Merge pull request #5499 from Roman-Galeev/fix/editor-cursor-move-refresh-autocomplete
fix(tui): re-query autocomplete picker on cursor movement
2026-06-08 13:13:05 +02:00
Mario Zechner
718215bd95 feat(coding-agent): add extension project trust decisions 2026-06-08 12:59:08 +02:00
Armin Ronacher
dce3e28516 fix: show security advisories in prompt widget 2026-06-08 12:35:18 +02:00
Armin Ronacher
f4f72d4ed5 docs(agent): add security advisory prompt 2026-06-08 12:35:18 +02:00
Roman Galeev
9d007fe642 fix(tui): re-query autocomplete picker on cursor movement
moveCursor() repositioned the cursor but never called updateAutocomplete()
or cancelAutocomplete(), unlike insertCharacter()/handleBackspace(). So an
open autocomplete picker went stale when the cursor moved: e.g. typing
`/cmd ` (argument menu open) then arrowing Left back into the command name
left the argument list showing against a `/cmd` prefix, and a Tab there
concatenated the stale suggestion onto the partial command name
(e.g. `/swarepo`).

Sync the picker at the end of moveCursor(): when a picker is open, re-query
via updateAutocomplete(), which refreshes the list for the new cursor
position or closes it when nothing matches.

Adds a regression test in test/editor.test.ts (verified to fail without the
fix). Fixes earendil-works/pi#5496.
2026-06-08 10:57:50 +02:00
Mario Zechner
38f18be447 fix(coding-agent): persist implicit project trust on reload 2026-06-08 10:08:01 +02:00
Mario Zechner
130ae577ac fix(tui,coding-agent): make keyboard protocol fallback response-driven
closes #5188
2026-06-07 17:04:19 +02:00
Mario Zechner
72fd911356 fix(coding-agent): neutralize compaction summarization prompt
closes #5401
2026-06-07 11:28:27 +02:00
Mario Zechner
eb43bd4493 fix(coding-agent): export package asset path helpers
closes #5415
2026-06-07 11:25:15 +02:00
Mario Zechner
8c6c8a4ef3 fix(agent): neutralize compaction summarization prompt
closes #5401
2026-06-07 11:19:10 +02:00
Mario Zechner
b8f6f660e7 fix(ai): honor OpenAI Responses developer role compat
closes #5456
2026-06-07 11:07:36 +02:00
Mario Zechner
59d7025668 fix(tui): position cursor for prompt history navigation
closes #5454
2026-06-07 10:56:09 +02:00
Mario Zechner
22ac2713a3 feat(coding-agent): export RPC extension UI types
closes #5455
2026-06-07 10:56:01 +02:00
Mario Zechner
e34b3b3803 docs(coding-agent): update tmux requirement docs
closes #5432
2026-06-07 10:34:57 +02:00
github-actions[bot]
98697d2d7a chore: approve contributor ItsumoSeito 2026-06-07 08:29:37 +00:00
Mario Zechner
ff3e9df5f5 fix(coding-agent): make trust input traversal portable 2026-06-06 22:32:35 +02:00
Mario Zechner
89a92207f1 feat(coding-agent): add project trust gating 2026-06-05 10:51:43 +02:00
Mario Zechner
db594d3a59 feat(coding-agent): show cache hit rate in footer 2026-06-05 10:51:43 +02:00
Mario Zechner
3e73204242 Merge pull request #5371 from Perlence/fix/skill-user-message-spacing
fix(coding-agent): add a space between the skill and user messages
2026-06-04 23:52:17 +02:00
Mario Zechner
b9bfa7ed46 Fix OpenRouter routing compat for custom providers
closes #5347
2026-06-04 23:50:01 +02:00
Mario Zechner
2808fc03f4 Merge pull request #5383 from xl0/instruct-pi-commit
docs: document commit message format
2026-06-04 23:48:19 +02:00
Mario Zechner
c52c22b39e Document issue view gh fields 2026-06-04 23:47:06 +02:00
Mario Zechner
c45787411e Fix tool expand hint punctuation styling
closes #5359
2026-06-04 23:44:25 +02:00
Mario Zechner
ca66adfe60 Add [Unreleased] section for next cycle 2026-06-04 18:31:35 +02:00
Mario Zechner
592c34c056 Release v0.78.1 2026-06-04 18:31:33 +02:00
Mario Zechner
1d33a8eb7a Update generated image model list 2026-06-04 18:31:11 +02:00
Mario Zechner
4f7d756df8 Exclude optional Gondolin example from root check 2026-06-04 18:27:18 +02:00
Mario Zechner
e0c2813a2a Audit unreleased changelog entries 2026-06-04 18:22:58 +02:00
Mario Zechner
f9ce0bf0e7 Update generated model lists 2026-06-04 18:22:58 +02:00
Alexey Zaytsev
e4d6f45efe docs: document commit message format 2026-06-03 23:15:15 -05:00
Sviatoslav Abakumov
e9a9322199 fix(coding-agent): add a space between the skill and user messages 2026-06-03 22:42:38 +04:00
Vegard Stikbakke
dc7b547f62 docs(coding-agent): remove containerization platform note 2026-06-03 15:55:33 +02:00
Vegard Stikbakke
86314bf38d docs: add containerization guide and Gondolin example (#5356) 2026-06-03 15:53:16 +02:00
Mario Zechner
564ad70fb8 Merge pull request #5333 from vastxie/zai-coding-cn-provider
feat(ai): add ZAI Coding Plan China provider
2026-06-03 01:07:01 +02:00
Mario Zechner
4ac20511be Merge pull request #5346 from mattiacerutti/fix/remove-codex-models
fix(ai): remove stale codex models
2026-06-03 00:54:27 +02:00
Armin Ronacher
ea3465a8e3 fix(coding-agent): move temporary extension cache (#5345) 2026-06-03 00:44:21 +02:00
Armin Ronacher
135fb545f9 fix(coding-agent): set auth file mode on creation 2026-06-03 00:32:54 +02:00
Mattia Cerutti
83afcdc24f fix(ai): remove stale codex models 2026-06-03 00:09:30 +02:00
Armin Ronacher
ba6e5298df fix(oauth): harden browser launch handling 2026-06-02 23:15:46 +02:00
Armin Ronacher
a98e087e5d fix(coding-agent): harden git package install paths 2026-06-02 18:20:35 +02:00
弎水
51df39b9b9 feat(ai): add ZAI Coding Plan China provider 2026-06-02 23:39:56 +08:00
Armin Ronacher
0462d44f56 doc: clarify wording in SECURITY.md 2026-06-02 17:24:41 +02:00
Armin Ronacher
e30b1b18d0 Added security file 2026-06-02 17:03:40 +02:00
Mario Zechner
25a4a8ed1e Add Ant Ling provider 2026-06-02 16:37:44 +02:00
Mario Zechner
6cb23f9b5d Fix HTML export URL sanitization 2026-06-02 16:32:18 +02:00
Mario Zechner
7e72ca47c8 Add MiniMax-M3 to direct minimax providers
closes #5313
2026-06-02 16:00:29 +02:00
Mario Zechner
276cb1bb89 Fix stale SDK stream options test after httpIdleTimeoutMs universalization 2026-06-02 15:53:37 +02:00
Mario Zechner
2125221bfc Fix OpenRouter Kimi reasoning compat
closes #5309
2026-06-02 15:36:52 +02:00
Mario Zechner
0d38e17b68 Fix empty self-rendered tool rows
closes #5299
2026-06-02 15:25:27 +02:00
Mario Zechner
6e269edfd0 Restore NVIDIA Qwen 3.5 122B model 2026-06-02 15:10:36 +02:00
Mario Zechner
6014801229 Add NVIDIA NIM provider 2026-06-02 15:09:36 +02:00
Mario Zechner
13898f048f Fix TUI tab width accounting
closes #5218
2026-06-02 13:54:47 +02:00
Mario Zechner
4c6c4482b4 Fix SDK embed ENOENT when package.json missing (closes #5226)
- Wrap package.json read in try/catch to handle missing file gracefully
- Use defaults (APP_NAME=pi, CONFIG_DIR_NAME=.pi, etc.) when package.json not found
- Preserves rebranding via piConfig when package.json exists
- Only ignores ENOENT; other errors still throw
2026-06-02 12:12:10 +02:00
Mario Zechner
7c08227019 Fix stale model references in AI tests after model regeneration
- Update GitHub Copilot tests to use available models (claude-haiku-4.5, gemini-2.5-pro)
- Update OpenRouter tests to use available models (google/gemini-2.5-flash, deepseek/deepseek-chat)
- Fix over-broad sed that incorrectly renamed OpenAI Responses gpt-4o test
- Remove nvidia.md from repo root
- Update packages/ai/CHANGELOG.md
2026-06-02 11:37:32 +02:00
Mario Zechner
7c531d0518 fix: apply httpIdleTimeoutMs to all providers, not just Codex Responses
The HTTP timeout setting (httpIdleTimeoutMs) was only used as a fallback for the
openai-codex-responses API. For other providers like openai-completions
(llama.cpp), the SDK default timeout (10 min) was used instead, ignoring the
user's disabled timeout setting.

Now httpIdleTimeoutMs applies universally as the default SDK request timeout
for all providers that support timeoutMs. Setting HTTP timeout = false (0)
correctly disables SDK timeouts across the board by sending max int32
(effectively infinite) instead of 0, since SDKs treat timeout=0 as immediate
timeout.

closes #5294
2026-06-02 11:26:36 +02:00
Mario Zechner
f58c1702cf fix: apply httpIdleTimeoutMs to all providers, not just Codex Responses
The HTTP timeout setting (httpIdleTimeoutMs) was only used as a fallback for the
openai-codex-responses API. For other providers like openai-completions
(llama.cpp), the SDK default timeout (10 min) was used instead, ignoring the
user's disabled timeout setting.

Now httpIdleTimeoutMs applies universally as the default SDK request timeout
for all providers that support timeoutMs. Setting HTTP timeout = false (0)
correctly disables SDK timeouts across the board.

closes #5294
2026-06-02 11:20:38 +02:00
Mario Zechner
afcf9ff0dc Merge pull request #5306 from xl0/system_prompt_options
Add system prompt options to extension commands
2026-06-02 11:09:30 +02:00
Alexey Zaytsev
8216cca5a7 Add system prompt options to extension commands 2026-06-01 15:42:56 -05:00
Mario Zechner
e56521e323 Add extension mode context 2026-06-01 18:32:03 +02:00
Mario Zechner
335e09ba0d Merge pull request #5264 from psoukie/fix/wsl-mntc-footer-branch-refresh
fix(coding-agent): refresh branch in footer on WSL /mnt repos
2026-06-01 18:07:52 +02:00
Mario Zechner
34719d3f4f Fix Bedrock blank text blocks
closes #4975
2026-06-01 18:04:04 +02:00
Mario Zechner
893ba99466 Merge pull request #5235 from nicobailon/feat/issue-5129-tui-overlay-focus
Feat/issue 5129 tui overlay focus
2026-06-01 17:58:01 +02:00
github-actions[bot]
ce065efa7e chore: approve contributor vastxie 2026-06-01 15:03:47 +00:00
Nico Bailon
91a2f86600 fix(tui): harden overlay focus restoration 2026-05-31 21:20:33 -07:00
Pavel Soukenik
654e4529d5 fix: refresh branch in footer on WSL /mnt repos 2026-05-31 17:12:11 -07:00
Mario Zechner
f429ddbffd Fix OpenAI GPT-5.5 thinking metadata
closes #5243
2026-06-01 00:39:48 +02:00
github-actions[bot]
d4fa41ae3c chore: approve contributor psoukie 2026-05-31 22:22:22 +00:00
Mario Zechner
b177333026 Merge pull request #5221 from PriNova/fix/openrouter-system-role
Fix OpenRouter reasoning instruction role
2026-06-01 00:09:58 +02:00
Mario Zechner
ad9081b7fa Merge pull request #5251 from yzhg1983/fix/anthropic-opus-temperature
fix(ai): suppress deprecated temperature param for Claude Opus 4.7+
2026-06-01 00:03:07 +02:00
Michael Yu
40832d1957 fix(ai): suppress deprecated temperature param for Claude Opus 4.7+ 2026-05-31 16:20:22 +08:00
Nico Bailon
82f29ea442 docs(coding-agent): expand overlay focus demo
Update the overlay QA focus demo to exercise three visible overlays with text input, focus cycling, per-panel dismissal, and visual focus ordering.
2026-05-30 13:29:36 -07:00
Nico Bailon
735ccbd00f fix(tui): release overlay focus to explicit targets
Add an explicit overlay unfocus target so callers can move input to the editor or another component while overlays remain visible. Align fallback overlay focus with visual focus order and cover blocked replacement release, null targets, and multi-overlay cycling.
2026-05-30 13:29:36 -07:00
Nico Bailon
5d9b28ee43 fix(tui): keep focused overlays interactive after UI
Preserve a focused visible overlay as the input owner across extension custom UI replacement, while letting the replacement receive and close its own input before focus is restored. Fixes #5129.
2026-05-30 13:29:36 -07:00
Mario Zechner
3911d6f5cd fix(coding-agent): stream large session files
closes #5231
2026-05-30 21:21:42 +02:00
PriNova
74d81bd363 Fix OpenAI completions developer role test 2026-05-30 12:24:19 +02:00
PriNova
b55fe1bbd4 Fix OpenRouter reasoning instruction role 2026-05-30 11:15:30 +02:00
Mario Zechner
dbb9911a54 Add [Unreleased] section for next cycle 2026-05-30 01:47:40 +02:00
Mario Zechner
0897f175e4 Release v0.78.0 2026-05-30 01:47:37 +02:00
Mario Zechner
36515a3793 Document release npm age gate override 2026-05-30 01:47:26 +02:00
Mario Zechner
886fa6cd11 Audit unreleased changelog entries 2026-05-30 01:27:28 +02:00
Mario Zechner
c1633e609d Clarify hardware cursor docs
closes #5200
2026-05-30 01:20:14 +02:00
Mario Zechner
cfefc30cf1 Merge pull request #5195 from yzhg1983/private/fix-startup-input-queue
fix(coding-agent): buffer early input before prompt loop
2026-05-30 01:15:45 +02:00
Mario Zechner
0ffa590a33 Fix GitLab Duo thinking metadata
closes #5201
2026-05-30 01:08:18 +02:00
Mario Zechner
9c4a3f3518 Fix ANSI wrapping stack overflow
closes #5185
2026-05-30 01:02:48 +02:00
Mario Zechner
31b961f2ac fix(coding-agent): sync clipboard binary archive deps
closes #5184
2026-05-30 00:52:03 +02:00
Mario Zechner
ed1952c5ca Merge pull request #5189 from mpazik/feat/tool-title-hyperlinks
OSC 8 hyperlinks file paths in tool titles
2026-05-30 00:05:22 +02:00
Mario Zechner
5992a62baa Merge pull request #5202 from xl0/export-args-parse
feat(coding-agent): Export CLI argument parser
2026-05-29 23:45:22 +02:00
Mario Zechner
a213abb97e Fix OpenRouter Kimi K2.6 developer role
closes #5159
2026-05-29 23:41:14 +02:00
Mario Zechner
778f519b23 Remove leading spaces from resume session hint 2026-05-29 23:30:02 +02:00
Mario Zechner
ba2d313858 fix(ai): handle OpenCode Kimi reasoning params 2026-05-29 23:27:48 +02:00
Mario Zechner
a36a132c70 fix(ai): abort Codex SSE body reads 2026-05-29 23:20:18 +02:00
Mario Zechner
7921ae499a Require explicit provider API keys 2026-05-29 22:36:05 +02:00
Mario Zechner
01a8c2d62c Merge pull request #5196 from earendil-works/fix/opencode-thinking-requests
fix(ai): handle OpenCode reasoning params
2026-05-29 22:32:13 +02:00
Alexey Zaytsev
dbcfc16741 feat(coding-agent): Export CLI argument parser 2026-05-29 13:22:46 -05:00
Michael Yu
edd1212200 fix(coding-agent): buffer early input before prompt loop 2026-05-29 23:03:03 +08:00
Marek Pazik
9d2bceba5d fix(tui): forward OSC 8 hyperlinks under tmux when the client supports them
detectCapabilities previously disabled hyperlinks under tmux unconditionally. tmux re-emits OSC 8 to the outer terminal only when the attached client advertises the 'hyperlinks' feature in client_termfeatures, and strips them otherwise.

Probe the running server with 'tmux display-message -p #{client_termfeatures}' and enable hyperlinks only when the feature is listed. The probe fails closed: any error (no tmux/server, timeout, old tmux) yields false. images stays null; only the hyperlink decision changed. The probe is injected into detectCapabilities so it stays testable without spawning a process.
2026-05-29 22:02:25 +10:00
Marek Pazik
42ce989a62 feat(coding-agent): hyperlink file paths in tool titles
Wrap the path shown in read/write/edit/ls tool titles in an OSC 8 file:// hyperlink when the terminal advertises hyperlink support.
The link target is always the absolute path, while the visible text stays relative/home-shortened and line annotations (e.g. :120-140) are kept out of the target.
2026-05-29 22:02:25 +10:00
Armin Ronacher
4faac05419 fix(ai): handle OpenCode reasoning params 2026-05-29 12:21:37 +02:00
Mario Zechner
ce554ad3de Add startup session name flag
closes #5153
2026-05-29 11:40:58 +02:00
Mario Zechner
0c448e12e8 Merge pull request #5176 from yzhg1983/myu003/resume-hint-on-exit
feat(coding-agent): print resume hint on interactive exit
2026-05-29 11:16:31 +02:00
Mario Zechner
3d1d18fa96 Merge pull request #5178 from stephanmck/bedrock-custom-headers-pr
ai: add custom-header support to Bedrock provider
2026-05-29 11:15:18 +02:00
Mario Zechner
c122bcde44 Merge pull request #5167 from xl0/expose-convert-png
feat(coding-agent): Export convertToPng for extensions
2026-05-29 11:13:42 +02:00
Stephan Schneider
7619aaefa9 ai: add custom-header support to Bedrock provider
Honour StreamOptions.headers in the Bedrock provider by attaching a
Smithy build-step middleware that merges caller headers into the
request before SigV4 signing. Reserved headers (x-amz-*, authorization,
host) are silently skipped to preserve signing and the bearer-token
auth path. No-op when no headers are supplied. Updates the
StreamOptions.headers JSDoc to drop the Bedrock caveat.
2026-05-29 10:21:44 +02:00
Michael Yu
17e9e87576 feat(coding-agent): print resume hint on interactive exit 2026-05-29 15:56:42 +08:00
Alexey Zaytsev
7a5dc0d0db feat(coding-agent): Export convertToPng for extensions 2026-05-29 00:44:51 -05:00
smoose
c8df99a795 fix(tui): keep hardware cursor marker during slash-command autocomplete
Remove the !autocompleteState guard so CURSOR_MARKER is still emitted
while the slash-command menu is visible. This lets the TUI position the
hardware cursor correctly, which fixes IME candidate-window placement
for CJK input methods.
2026-05-29 10:00:59 +08:00
github-actions[bot]
7be8a10d23 chore: approve contributor rolfvreijdenberger 2026-05-28 23:26:43 +00:00
Armin Ronacher
b6b0f6923f feat(ci): Actually bump setup-bun to 2.2.0 2026-05-29 01:03:12 +02:00
Armin Ronacher
20bcab26e0 feat(ci): Update setup-bun to 2.2.0 2026-05-29 00:51:11 +02:00
Armin Ronacher
93600d8980 fix(release): align package repository metadata 2026-05-29 00:44:58 +02:00
Armin Ronacher
f3b4e1285c fix(release): upgrade npm for trusted publishing 2026-05-29 00:36:43 +02:00
Armin Ronacher
abf07d0c4d Add [Unreleased] section for next cycle 2026-05-29 00:19:23 +02:00
Armin Ronacher
8322745e28 Release v0.77.0 2026-05-29 00:19:06 +02:00
Mario Zechner
f29472c387 Audit unreleased changelog entries 2026-05-29 00:06:06 +02:00
Mario Zechner
0127caea7b Fix OpenRouter DeepSeek V4 xhigh reasoning
closes #4801
2026-05-28 23:56:59 +02:00
Mario Zechner
f9fa077bbb Fix startup timing attribution
closes #4829
2026-05-28 23:53:20 +02:00
Mario Zechner
9380d5f2e4 feat(coding-agent): add exclude tools option closes #5109 2026-05-28 23:49:18 +02:00
Mario Zechner
1ab2899800 Remove unavailable tool preference guideline
Closes #5132
2026-05-28 23:37:13 +02:00
Armin Ronacher
4b4641c6b0 fix(coding-agent): scope custom session dir lookups 2026-05-28 23:25:33 +02:00
Mario Zechner
b64f3f5eae fix(coding-agent): run extension cleanup and restore terminal on signal exits
Signal-triggered shutdown (SIGTERM/SIGHUP) now emits session_shutdown
before any terminal writes, and SIGHUP no longer hard-exits, so extension
resources (e.g. sockets) are released and the terminal is restored even
when the terminal is gone. A genuinely dead terminal still exits without
hot-spinning via the EIO error path.

closes #5080
2026-05-28 23:19:27 +02:00
Mario Zechner
d1fb34bc8d fix(ai): use valid synthetic Responses message ids closes #5148 2026-05-28 23:13:15 +02:00
Mario Zechner
3f1ce9b6e9 fix(ai): avoid duplicate Codex replay message ids closes #5148 2026-05-28 23:11:05 +02:00
github-actions[bot]
f9832ccb82 chore: approve contributor stephanmck 2026-05-28 20:54:28 +00:00
Mario Zechner
97ef317389 Fix Kimi and Xiaomi model metadata
Closes #5075, closes #5078. Refs #5087.
2026-05-28 22:19:47 +02:00
github-actions[bot]
7e70886652 chore: approve contributor MichaelYochpaz 2026-05-28 20:05:12 +00:00
Mario Zechner
bfa3d1fa60 Update Claude Opus and GPT thinking metadata 2026-05-28 21:51:55 +02:00
Armin Ronacher
ae50dec121 chore(release): publish packages from CI 2026-05-28 17:43:05 +02:00
Mario Zechner
53ca936adb Update clipboard native addon
closes #5028
2026-05-28 16:59:44 +02:00
Mario Zechner
b63d26332f Finish harness tool registry semantics 2026-05-28 12:34:02 +02:00
Mario Zechner
458a7bc27c Fix Anthropic empty thinking signature replay
closes #4464
2026-05-28 12:04:33 +02:00
Armin Ronacher
3e9f717445 fix(coding-agent): make config env references explicit
closes #5095
2026-05-28 11:58:38 +02:00
Mario Zechner
5b31ffd744 Abort session work during dispose 2026-05-28 11:35:19 +02:00
Vegard Stikbakke
9d5fb70b7e feat(ai): add Codex device code login (#4911) 2026-05-28 11:09:33 +02:00
Mario Zechner
edd26444af Expose tool prompt guidelines to extensions
closes #4879
2026-05-28 11:03:25 +02:00
Mario Zechner
17d39ccfd4 Add changelog entry for PR 5115 2026-05-28 10:48:11 +02:00
Mario Zechner
8e77f8797b Merge pull request #5115 from DanielThomas/dannyt/sendUserMessage-agent-end
fix(coding-agent): drain follow-ups queued during agent_end
2026-05-28 10:47:31 +02:00
Mario Zechner
e43f2c3d0a Clarify PR review worktree rules 2026-05-28 10:31:03 +02:00
Danny Thomas
a29a7902e9 fix(coding-agent): drain follow-ups queued during agent_end
When an extension queues a follow-up during `agent_end` it
gets stuck on the follow-up queue until after the next user
message.

Add a hasQueuedMessages() check to _handlePostAgentRun
so the existing while/continue loop drains them.

- One-line fix in _handlePostAgentRun
- Integration test in agent-session-queue
- git-merge-and-resolve example extension with tests
2026-05-28 18:27:39 +10:00
Mario Zechner
5f00f3f50f Merge pull request #5091 from earendil-works/fix/kitty-da-negotiation
fix(tui): harden keyboard protocol negotiation
2026-05-28 10:23:41 +02:00
Mario Zechner
cbe8625528 Fix input event streaming behavior semantics 2026-05-28 10:13:06 +02:00
Mario Zechner
1fc57a5d5b Merge pull request #5107 from DanielThomas/dannyt/inputevent-streamingbehavior
feat(coding-agent): expose streamingBehavior on InputEvent
2026-05-28 10:11:30 +02:00
Danny Thomas
bcea4b2e27 feat(coding-agent): expose streamingBehavior on InputEvent
Add streamingBehavior to InputEvent so extensions can distinguish
idle prompts from mid-stream steers and queued follow-ups.

- Add streamingBehavior field to InputEvent type
- Thread it through ExtensionRunner.emitInput() and AgentSession.prompt()
- Add streaming-aware input gate example with tests
- Document in extensions.md
2026-05-28 15:52:38 +10:00
Armin Ronacher
b85bf65678 fix(coding-agent): restore diff code block highlighting
closes #5092
2026-05-28 00:49:27 +02:00
Armin Ronacher
99aec8e02b test(tui): reduce keyboard negotiation coverage 2026-05-27 23:03:13 +02:00
Armin Ronacher
1e168a89c5 docs(coding-agent): fix development AGENTS link
Closes #5041
2026-05-27 22:51:30 +02:00
Armin Ronacher
6ab62a06d7 fix(tui): harden keyboard protocol negotiation 2026-05-27 22:23:39 +02:00
Armin Ronacher
39a26c84f8 Add [Unreleased] section for next cycle 2026-05-27 22:04:01 +02:00
Armin Ronacher
706f8720f8 Release v0.76.0 2026-05-27 22:03:21 +02:00
Armin Ronacher
16dc525e7b fix(build): resolve internal packages from workspace dist 2026-05-27 21:56:55 +02:00
Armin Ronacher
41d28a9232 docs(changelog): audit unreleased entries 2026-05-27 21:40:07 +02:00
Armin Ronacher
52dc08c1f7 feat(session): Explicit session id naming (#5076) 2026-05-27 21:32:10 +02:00
Armin Ronacher
7c02a55632 fix(ai): timeout Codex SSE header stalls 2026-05-27 21:24:51 +02:00
Armin Ronacher
91b46c2ba9 fix(ai): avoid stale Cerebras test model 2026-05-27 20:57:00 +02:00
xu0o0
701801dec2 fix(tui): align input word segmentation with editor (#5068) 2026-05-27 20:50:04 +02:00
Armin Ronacher
61babc24cf feat(rpc): add excludeFromContext flag to bash command (closes #5039) 2026-05-27 18:34:42 +02:00
Sviatoslav Abakumov
4bbe2959bd fix(tui): provide the JetBrains terminal capabilities (#5037) 2026-05-27 12:20:44 +02:00
xu0o0
b62776e4a2 fix(tui): preserve ASCII punctuation word boundaries with Intl.Segmenter (#5067) 2026-05-27 12:12:10 +02:00
Armin Ronacher
493efd422d fix(codex): timeouts for websockets (#4979) 2026-05-27 12:11:29 +02:00
Armin Ronacher
26f1e00f71 fix(ai): use hyphenated Codex session header
closes #4967
2026-05-27 01:11:40 +02:00
Armin Ronacher
2531fc130d fix(ui): preserve user ordered-list markers (closes #5013) 2026-05-27 01:03:33 +02:00
Armin Ronacher
59ec8003db fix(coding-agent): bypass age gates for self-update
closes #4929
2026-05-27 00:59:09 +02:00
xu0o0
4402100830 fix(tui): leverage Intl.Segmenter for proper Unicode word boundaries (#5022) 2026-05-27 00:28:47 +02:00
Armin Ronacher
8fb1e877cd fix(ai): disable hidden provider 429 retries (#4991) 2026-05-26 23:41:58 +02:00
Mario Zechner
96f0edd02b Count user image tokens in context estimates
closes #4983
2026-05-26 18:46:54 +02:00
github-actions[bot]
7c2775f6f6 chore: approve contributor DanielThomas 2026-05-26 07:39:20 +00:00
Armin Ronacher
71446c6c2b fix(ai): correct Codex Spark context window
closes #4969
2026-05-26 09:08:40 +02:00
Armin Ronacher
fa1180b6b7 fix(ai): detect Poolside context overflow
Closes #4943
2026-05-26 08:58:21 +02:00
Armin Ronacher
fc8a155901 fix(ai): honor Codex Responses maxRetries 2026-05-25 12:30:04 +02:00
Armin Ronacher
3eb002766f fix(tui): enable OSC 8 for Windows Terminal (closes #4923) 2026-05-25 00:55:39 +02:00
Armin Ronacher
e007fcd0d2 fix(rpc): reject pending requests on child process exit
closes #4764
2026-05-25 00:40:07 +02:00
Armin Ronacher
ce0e801d8e fix(coding-agent): retry RPC stdout backpressure
closes #4897
2026-05-24 22:59:35 +02:00
Armin Ronacher
9600ded922 revert: fix rpc stdout backpressure
This reverts commit d0d1d8edca.
2026-05-24 11:50:53 +02:00
Armin Ronacher
d0d1d8edca fix(rpc): respect stdout backpressure
closes #4897
2026-05-24 11:28:02 +02:00
Mario Zechner
fc51a40d02 Merge pull request #4922 from earendil-works/horrifying-terminal-hack
fix(tui): detect Apple Terminal Shift+Enter
2026-05-23 20:19:57 +02:00
Armin Ronacher
30b3ab3532 fix(tui): remove native modifier escape hatch 2026-05-23 15:32:38 +02:00
Armin Ronacher
c5181a266e fix(tui): detect Apple Terminal Shift+Enter 2026-05-23 15:09:01 +02:00
Armin Ronacher
4a98f748bb chore(tui): remove unused xterm dependency 2026-05-23 14:18:49 +02:00
Armin Ronacher
15f1dea8df fix(coding-agent): disable managed extension peer resolution
closes #4907
2026-05-23 14:15:00 +02:00
Mario Zechner
30f48feae7 Add [Unreleased] section for next cycle 2026-05-23 12:08:14 +02:00
Mario Zechner
89ba72c35d Update release instructions and image models 2026-05-23 12:07:52 +02:00
Mario Zechner
83a227aed3 Update release instructions and generated models 2026-05-23 12:02:35 +02:00
Mario Zechner
ea2b70ddd9 Release v0.75.5 2026-05-23 11:58:31 +02:00
Mario Zechner
98477f2ee7 Clarify release smoke test instructions 2026-05-23 11:54:36 +02:00
Mario Zechner
b9566fc111 Audit unreleased changelog entries 2026-05-23 11:48:27 +02:00
Mario Zechner
373bd1282e Collapse read output by default
closes #4916
2026-05-23 11:40:54 +02:00
Mario Zechner
8100046cb8 Finish async tool cleanup 2026-05-23 11:36:41 +02:00
Mario Zechner
c4f86e3992 Merge remote-tracking branch 'origin/main' into pr-4756-squash-cleanup
# Conflicts:
#	scripts/build-binaries.sh
2026-05-23 10:52:28 +02:00
Mario Zechner
3f89350cef fix(coding-agent): ship clipboard sidecar in bun binaries
closes #4307
2026-05-23 10:47:35 +02:00
Mario Zechner
ba09f1c9e0 Refine async tool control flow 2026-05-23 10:30:00 +02:00
Mario Zechner
2e1f07bac2 fix(coding-agent): avoid invalid footer home abbreviation
closes #4878
2026-05-23 09:53:36 +02:00
Mario Zechner
e9146a5ff7 fix(coding-agent): use async operations in tools 2026-05-23 09:50:03 +02:00
Armin Ronacher
c85dbb1620 fix(coding-agent): reconcile pinned git update refs
closes #4869
2026-05-23 00:16:07 +02:00
Armin Ronacher
d80bcc367e test(ai): avoid hardcoded Fireworks router id 2026-05-22 22:59:10 +02:00
Armin Ronacher
42379a37bd fix(coding-agent): add OpenCode session headers
closes #4847
2026-05-22 22:22:55 +02:00
Mario Zechner
9b62f1f87c Fix Anthropic eager tool input compat test 2026-05-22 18:40:31 +02:00
Mario Zechner
d801d88a11 Support adaptive thinking for Anthropic-compatible aliases
closes #4790
2026-05-22 18:30:51 +02:00
Armin Ronacher
7002c68f8b fix(ai): declare Bedrock Smithy HTTP handler dependency
closes #4842
2026-05-22 16:50:19 +02:00
Mario Zechner
c841a6c78f Clean up OAuth device-code callbacks 2026-05-22 15:51:36 +02:00
Armin Ronacher
b3ed545938 fix(export-html): escape quotes in exported attributes
closes #4832
2026-05-22 15:48:21 +02:00
Mario Zechner
60e2e4332d Merge pull request #4898 from haoqixu/fix-4761
fix(coding-agent): use the right basedir for patterns
2026-05-22 15:22:45 +02:00
Mario Zechner
11e868b790 Merge pull request #4788 from earendil-works/refactor-device-code-login
feat(ai): refactor device code login for copilot
2026-05-22 15:21:42 +02:00
haoqixu
baf4028fb9 fix(coding-agent): use the right basedir for patterns 2026-05-22 18:33:26 +08:00
Vegard Stikbakke
c554364c2a feat(ai): refactor device code login for copilot 2026-05-22 08:54:18 +02:00
419 changed files with 28018 additions and 7233 deletions

View File

@@ -217,3 +217,27 @@ josephyoung pr
mbazso pr
AJM10565 pr
DanielThomas pr
MichaelYochpaz pr
stephanmck pr
rolfvreijdenberger pr
psoukie pr
vastxie pr
ItsumoSeito pr
davidlifschitz pr
vdxz pr
dangooddd pr
Mearman pr
dodiego pr

View File

@@ -11,6 +11,8 @@ body:
Keep this short. If it doesn't fit on one screen, it's too long. Write in your own voice.
**Important:** before reporting an issue in core, please validate first with `pi -ne` that this is not caused by an extension you loaded.
- type: textarea
id: description
attributes:

View File

@@ -0,0 +1,49 @@
name: Package Report
description: Report a problematic Pi package listed on pi.dev
labels: ["package-report"]
body:
- type: markdown
attributes:
value: |
Use this form to report a package listed on pi.dev. For Pi core bugs, use the bug report template instead.
New issues from new contributors are auto-closed by default. Maintainers review auto-closed issues daily. Issues that do not meet the quality bar in [CONTRIBUTING.md](https://github.com/earendil-works/pi-mono/blob/main/CONTRIBUTING.md) will not be reopened or receive a reply.
Keep this short. If it doesn't fit on one screen, it's too long. Write in your own voice.
- type: input
id: package-name
attributes:
label: Package name
description: The npm package name from pi.dev.
placeholder: "@scope/package"
validations:
required: true
- type: input
id: package-version
attributes:
label: Version
description: The package version shown on pi.dev.
placeholder: "0.1.0"
validations:
required: false
- type: dropdown
id: report-type
attributes:
label: What are you reporting?
options:
- Malicious or unsafe behavior
- Impersonation
- Trademark / TOS Violations
validations:
required: true
- type: textarea
id: details
attributes:
label: Details
description: Describe the concern and include links, logs, or screenshots if helpful.
validations:
required: true

View File

@@ -10,24 +10,30 @@ on:
description: 'Tag to build (e.g., v0.12.0)'
required: true
type: string
source_ref:
description: 'Source ref to build/publish (defaults to tag; use only for release recovery)'
required: false
type: string
permissions:
contents: write
permissions: {}
jobs:
build:
runs-on: ubuntu-latest
permissions:
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 }}
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ env.RELEASE_TAG }}
ref: ${{ env.SOURCE_REF }}
persist-credentials: false
- name: Setup Bun
uses: oven-sh/setup-bun@4bc047ad259df6fc24a6c9b0f9a0cb08cf17fbe5 # v2.0.1
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: 1.3.10
@@ -45,38 +51,86 @@ jobs:
run: |
VERSION="${RELEASE_TAG}"
VERSION="${VERSION#v}" # Remove 'v' prefix
# Extract changelog section for this version
cd packages/coding-agent
awk "/^## \[${VERSION}\]/{flag=1; next} /^## \[/{flag=0} flag" CHANGELOG.md > /tmp/release-notes.md
# If empty, use a default message
if [ ! -s /tmp/release-notes.md ]; then
echo "Release ${VERSION}" > /tmp/release-notes.md
fi
node scripts/release-notes.mjs extract --version "${VERSION}" --tag "${RELEASE_TAG}" --out /tmp/release-notes.md
- name: Create GitHub Release and upload binaries
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
cd packages/coding-agent/binaries
# Create release with changelog notes (or update if exists)
gh release create "${RELEASE_TAG}" \
--title "${RELEASE_TAG}" \
--notes-file /tmp/release-notes.md \
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 \
2>/dev/null || \
gh release upload "${RELEASE_TAG}" \
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 \
--clobber
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
)
sha256sum "${release_assets[@]}" > SHA256SUMS
release_assets+=(SHA256SUMS)
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: build
environment: npm-publish
permissions:
contents: read
id-token: write
env:
RELEASE_TAG: ${{ github.event.inputs.tag || github.ref_name }}
SOURCE_REF: ${{ github.event.inputs.source_ref || github.event.inputs.tag || github.ref_name }}
steps:
- name: Checkout
uses: actions/checkout@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'
registry-url: 'https://registry.npmjs.org'
cache: npm
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y libcairo2-dev libpango1.0-dev libjpeg-dev libgif-dev librsvg2-dev fd-find ripgrep
sudo ln -s $(which fdfind) /usr/local/bin/fd
- name: Install dependencies
run: npm ci --ignore-scripts
- name: Build
run: npm run build
- name: Check
run: npm run check
- name: Test
run: npm test
- name: 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
npm --version
- name: Publish npm packages
run: node scripts/publish.mjs

View File

@@ -111,9 +111,17 @@ jobs:
body: message,
});
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
labels: ['untriaged'],
});
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
state: 'closed',
state_reason: 'not_planned',
});

View File

@@ -0,0 +1,137 @@
name: Issue Triage Labels
on:
issues:
types: [reopened, labeled]
jobs:
update-labels:
runs-on: ubuntu-latest
permissions:
issues: write
steps:
- name: Update triage labels
uses: actions/github-script@v7
with:
script: |
const UNTRIAGED_LABEL = 'untriaged';
const NO_ACTION_LABEL = 'no-action';
const LAST_READ_LABEL = 'last-read';
const TO_DISCUSS_LABEL = 'to-discuss';
const INPROGRESS_LABEL = 'inprogress';
function issueHasLabel(issue, labelName) {
return (issue.labels ?? []).some((label) => label.name === labelName);
}
async function removeLabelIfPresent(issueNumber, issue, labelName) {
if (!issueHasLabel(issue, labelName)) {
console.log(`Issue #${issueNumber} does not have ${labelName}`);
return;
}
try {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
name: labelName,
});
console.log(`Removed ${labelName} from #${issueNumber}`);
} catch (error) {
if (error.status === 404) {
console.log(`Label ${labelName} was already absent from #${issueNumber}`);
return;
}
throw error;
}
}
if (context.payload.action === 'reopened') {
await removeLabelIfPresent(context.issue.number, context.payload.issue, UNTRIAGED_LABEL);
await removeLabelIfPresent(context.issue.number, context.payload.issue, NO_ACTION_LABEL);
return;
}
if (context.payload.action !== 'labeled' || context.payload.label?.name !== LAST_READ_LABEL) {
console.log('Not a last-read label event');
return;
}
const currentIssueNumber = context.issue.number;
const lastReadIssues = await github.paginate(github.rest.issues.listForRepo, {
owner: context.repo.owner,
repo: context.repo.repo,
state: 'all',
labels: LAST_READ_LABEL,
per_page: 100,
});
const previousIssueNumbers = lastReadIssues
.filter((issue) => !issue.pull_request)
.map((issue) => issue.number)
.filter((issueNumber) => issueNumber !== currentIssueNumber);
if (previousIssueNumbers.length === 0) {
console.log('No previous last-read issue found');
return;
}
const previousIssueNumber = Math.max(...previousIssueNumbers);
if (currentIssueNumber <= previousIssueNumber) {
console.log(
`Last-read was added to old issue #${currentIssueNumber}; latest last-read is #${previousIssueNumber}`,
);
return;
}
const untriagedIssues = await github.paginate(github.rest.issues.listForRepo, {
owner: context.repo.owner,
repo: context.repo.repo,
state: 'all',
labels: UNTRIAGED_LABEL,
per_page: 100,
});
const issuesToMark = untriagedIssues
.filter((issue) => !issue.pull_request)
.filter((issue) => issue.number >= previousIssueNumber && issue.number <= currentIssueNumber)
.sort((a, b) => a.number - b.number);
if (issuesToMark.length === 0) {
console.log(`No untriaged issues found from #${previousIssueNumber} to #${currentIssueNumber}`);
return;
}
for (const issue of issuesToMark) {
if (issueHasLabel(issue, TO_DISCUSS_LABEL)) {
console.log(`Skipped ${NO_ACTION_LABEL} for #${issue.number} because it has ${TO_DISCUSS_LABEL}`);
} else {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
labels: [NO_ACTION_LABEL],
});
console.log(`Added ${NO_ACTION_LABEL} to #${issue.number}`);
}
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
state: 'closed',
state_reason: 'not_planned',
});
console.log(`Closed #${issue.number} as not planned`);
await removeLabelIfPresent(issue.number, issue, INPROGRESS_LABEL);
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
name: UNTRIAGED_LABEL,
});
console.log(`Removed ${UNTRIAGED_LABEL} from #${issue.number}`);
}

View File

@@ -0,0 +1,31 @@
name: Remove In Progress Label On Close
on:
issues:
types: [closed]
jobs:
remove-label:
runs-on: ubuntu-latest
permissions:
issues: write
steps:
- name: Remove inprogress label
uses: actions/github-script@v7
with:
script: |
const labelName = 'inprogress';
const labels = context.payload.issue.labels ?? [];
const hasLabel = labels.some((label) => label.name === labelName);
if (!hasLabel) {
console.log(`Issue does not have ${labelName} label`);
return;
}
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
name: labelName,
});

54
.gitignore vendored
View File

@@ -1,8 +1,8 @@
node_modules/
dist/
# Pi persistent config — never commit
.pi/
# SproutClaw persistent config — never commit
.sproutclaw/
*.log
.DS_Store
*.tsbuildinfo
@@ -28,7 +28,7 @@ packages/*/dist-firefox/
.npm/
coverage/
.nyc_output/
.pi_config/
.sproutclaw_config/
tui-debug.log
compaction-results/
.opencode/
@@ -39,34 +39,34 @@ out.html
packages/coding-agent/binaries/
todo.md
plans/
.pi/hf-sessions/
.pi/hf-sessions-backup/
.pi/sessions/
.pi/extensions/webui/.webui.pid
.pi/extensions/webui/.webui.log
.sproutclaw/hf-sessions/
.sproutclaw/hf-sessions-backup/
.sproutclaw/sessions/
.sproutclaw/extensions/webui/.webui.pid
.sproutclaw/extensions/webui/.webui.log
# Local runtime / secrets (do not commit)
.local/
progress.md
# Pi agent: track extensions, ignore local runtime secrets and sessions
.pi/agent/sessions/
.pi/agent/git/
.pi/agent/npm/
.pi/agent/bin/
.pi/agent/data/
.pi/agent/auth.json
.pi/agent/models.json
.pi/agent/settings.json
.pi/agent/mcp.json
.pi/agent/mcp-cache.json
.pi/agent/mcp-npx-cache.json
.pi/agent/run-history.jsonl
.pi/agent/taskplane/
.pi/agent/skills/
.pi/agent/skills-disabled/
.pi/agent/extensions/webui/frontend/node_modules/
.pi/agent/extensions/webui/.webui.pid
.pi/agent/extensions/webui/data/
# SproutClaw agent: track extensions, ignore local runtime secrets and sessions
.sproutclaw/agent/sessions/
.sproutclaw/agent/git/
.sproutclaw/agent/npm/
.sproutclaw/agent/bin/
.sproutclaw/agent/data/
.sproutclaw/agent/auth.json
.sproutclaw/agent/models.json
.sproutclaw/agent/settings.json
.sproutclaw/agent/mcp.json
.sproutclaw/agent/mcp-cache.json
.sproutclaw/agent/mcp-npx-cache.json
.sproutclaw/agent/run-history.jsonl
.sproutclaw/agent/taskplane/
.sproutclaw/agent/skills/
.sproutclaw/agent/skills-disabled/
.sproutclaw/agent/extensions/webui/frontend/node_modules/
.sproutclaw/agent/extensions/webui/.webui.pid
.sproutclaw/agent/extensions/webui/data/
tmp/
bun.lock
collect.sh

View File

@@ -7,6 +7,7 @@
- No fluff or cheerful filler text (e.g., "Thanks @user" not "Thanks so much @user!")
- Technical prose only, be direct
- When the user asks a question, answer it first before making edits or running implementation commands.
- When responding to user feedback or an analysis, explicitly say whether you agree or disagree before saying what you changed.
## Code Quality
@@ -20,7 +21,7 @@
- Always ask before removing functionality or code that appears intentional.
- Do not preserve backward compatibility unless the user asks for it.
- Never hardcode key checks (e.g. `matchesKey(keyData, "ctrl+x")`). Add defaults to `DEFAULT_EDITOR_KEYBINDINGS` or `DEFAULT_APP_KEYBINDINGS` so they stay configurable.
- Never modify `packages/ai/src/models.generated.ts` directly; update `packages/ai/scripts/generate-models.ts` instead.
- Never modify `packages/ai/src/models.generated.ts` directly; update `packages/ai/scripts/generate-models.ts` instead, then regenerate. Including the resulting `models.generated.ts` diff is always OK, even if regeneration includes unrelated upstream model metadata changes.
## Commands
@@ -51,6 +52,7 @@ Committing:
- Stage explicit paths (`git add <path1> <path2>`); never `git add -A` / `git add .`.
- Before committing, run `git status` and verify you are only staging your files.
- `packages/ai/src/models.generated.ts` may always be included alongside your files.
- Message format: `{feat,fix,docs}[(ai,tui,agent,coding-agent)]: <commit message> (optionally multiple lines)`. Message is informative and concise.
Never run (destroys other agents' work or bypasses checks):
@@ -66,6 +68,12 @@ If rebase conflicts occur:
See `CONTRIBUTING.md` for the contributor gate (auto-close workflows, `lgtm`/`lgtmi`, quality bar).
When reviewing PRs:
- Do not run `gh pr checkout`, `git switch`, or otherwise move the worktree to the PR branch unless the user explicitly asks.
- Use `gh pr view`, `gh pr diff`, `gh api`, and local `git show`/`git diff` against fetched refs to inspect PR metadata, commits, and patches without changing branches.
- If you need PR file contents, fetch/read them into temporary files or use `git show <ref>:<path>` without switching branches.
When creating issues:
- Add `pkg:*` labels for affected packages (`pkg:agent`, `pkg:ai`, `pkg:coding-agent`, `pkg:tui`); use all that apply.
@@ -86,7 +94,7 @@ Run the TUI in a controlled terminal (from the repo root):
```bash
tmux new-session -d -s pi-test -x 80 -y 24
tmux send-keys -t pi-test "./pi-test.sh" Enter
tmux send-keys -t pi-test "./test.sh" Enter
sleep 3 && tmux capture-pane -t pi-test -p # capture after startup
tmux send-keys -t pi-test "your prompt here" Enter
tmux send-keys -t pi-test Escape # special keys (also C-o for ctrl+o, etc.)
@@ -119,40 +127,35 @@ Attribution:
```bash
npm run release:local -- --out /tmp/pi-local-release --force
cd /tmp
# Node package install smoke tests
/tmp/pi-local-release/node/pi --help
/tmp/pi-local-release/node/pi --version
/tmp/pi-local-release/node/pi --list-models
/tmp/pi-local-release/node/pi -p "Say exactly: ok"
/tmp/pi-local-release/node/pi
# Bun binary smoke tests
/tmp/pi-local-release/bun/pi --help
/tmp/pi-local-release/bun/pi --version
/tmp/pi-local-release/bun/pi --list-models
/tmp/pi-local-release/bun/pi -p "Say exactly: ok"
/tmp/pi-local-release/bun/pi
```
Verify startup, model/account listing, and at least one real prompt with the intended default provider. Failures are release blockers unless the user explicitly accepts the risk.
Verify both Node and Bun startup, model/account listing, interactive startup, and at least one real prompt with the intended default provider. The bare commands `/tmp/pi-local-release/node/pi` and `/tmp/pi-local-release/bun/pi` start interactive mode; run each in tmux, submit a prompt, and wait for the model reply before considering the interactive smoke test passed. Failures are release blockers unless the user explicitly accepts the risk.
3. **Brief the user on the WebAuthn flow before running anything**. Print exactly the following message and then stop and wait for the user to confirm in their next message:
```
Before I run the release script, read this carefully:
- `npm publish` uses WebAuthn 2FA.
- A login URL will appear in the live bash output in this TUI. I will NOT see it until the command exits.
- You must watch the bash output, cmd/ctrl-click the URL, log in in the browser, and select the "don't ask again for N minutes" option so publish can continue.
- This may happen more than once during the release.
Reply "ready" once you have read this and are watching the bash output. I will not run the release script until you do.
```
Do not proceed to step 4 until the user explicitly confirms.
4. **Run the release script**:
3. **Run the release script**:
```bash
npm run release:patch # fixes + additions
npm run release:minor # breaking changes
PI_ALLOW_LOCKFILE_CHANGE=1 npm_config_min_release_age=0 npm run release:patch # fixes + additions
PI_ALLOW_LOCKFILE_CHANGE=1 npm_config_min_release_age=0 npm run release:minor # breaking changes
```
Do not pass a `timeout` to the bash tool for this call. If publish fails partway, stop and report to the user what happened (which package failed, the error output) along with possible solutions. Never rerun the version bump on your own.
Use `npm_config_min_release_age=0` only for the release command. The repo's normal npm age gate can otherwise block the release lockfile refresh when the current workspace package version was published recently. Review any lockfile or shrinkwrap diffs the release creates before push.
5. **After publish succeeds**:
- Add fresh `## [Unreleased]` sections to package changelogs.
- Commit with `Add [Unreleased] section for next cycle`.
- Push `main` and the release tag.
The release script bumps all package versions, updates changelogs, regenerates release artifacts, runs `npm run check`, commits `Release vX.Y.Z`, tags `vX.Y.Z`, adds fresh `## [Unreleased]` changelog sections, commits `Add [Unreleased] section for next cycle`, then pushes `main` and the tag. Do not rerun the release script after a tag was pushed.
4. **CI publishes npm packages**: pushing the `vX.Y.Z` tag triggers `.github/workflows/build-binaries.yml`. The `publish-npm` job uses npm trusted publishing through GitHub Actions OIDC with environment `npm-publish`; no local `npm publish`, `npm whoami`, OTP, or WebAuthn flow is required.
5. **If CI publish fails**: inspect the failed `publish-npm` job. The publish helper is idempotent and skips package versions already present on npm, so rerun the tag workflow after fixing CI or transient npm issues. Do not rerun `npm run release:patch` or `npm run release:minor` for the same version.
## User Override

97
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,97 @@
# 贡献指南
本指南旨在为双方节省时间。
## 设计理念
首先明确一点:**SproutClaw 的核心保持精简**。
如果你的功能不属于核心,它应该以扩展的形式实现。会让核心臃肿的 PR 大概率会被拒绝。
SproutClaw 的核心存在的意义在于精简且可扩展,以便扩展能够影响和定制其行为。即便是扩展的 hook 点也需要经过充分考量和讨论,避免引入难以维护的冗余代码和复杂交互。
## 唯一准则
**你必须理解自己提交的代码。** 如果你无法解释你的改动做了什么、以及它与系统其他部分如何交互,你的 PR 将被关闭。
使用 AI 辅助编写代码没有问题,但在不理解代码的情况下直接提交 AI 生成的内容是不被接受的。
如果你使用 Agent请在 `sproutclaw` 根目录运行,这样它会自动读取 `AGENTS.md`。你的 Agent 必须遵循该文件中的规则和指南。
## 贡献门槛
来自新贡献者的 issue 和 PR 默认会被自动关闭。
周五至周日提交的 issue 不保证会被审查。如有紧急问题,请在内部联系维护者。
维护者会每日审查被自动关闭的 issue并重新开放值得处理的内容。不符合以下质量标准的 issue 将不会被重新开放或收到回复。
审批通过由维护者在 issue 中回复:
- `lgtmi`:你未来提交的 issue 将不再被自动关闭
- `lgtm`:你未来提交的 issue 和 PR 将不再被自动关闭
`lgtmi` 不授予提交 PR 的权限,只有 `lgtm` 才授予该权限。
## Issue 质量标准
提交 issue 时,必须使用两个 GitHub issue 模板之一。
提交的 issue 应简洁、具体、有阅读价值:
- 保持简短。如果超过一屏,就太长了。
- 用自己的语言表达(不要用 LLM 生成文本;如果必须,请在后续评论中明确标注为 AI 生成)。
- 清晰说明 bug 或需求。
- 解释其重要性。
- 如果你希望自己来实现,请说明。
如果 issue 真实且表述良好,维护者可能会重新开放、回复 `lgtmi` 或回复 `lgtm`
## 封禁规则
如果你两次无视本文档,或通过 Agent 批量刷 issue你的账号将被永久封禁。
如果你通过自动化手段大量提交 issue你的账号将被永久封禁且不可申诉。
## 提交 PR 前
未获得 `lgtm` 批准前,请勿提交 PR。
提交 PR 前,请先执行:
```bash
npm run check
./test.sh
```
两项均须通过。
不要修改 `CHANGELOG.md`,更新日志条目由维护者添加。
如果你要在 `packages/ai` 中添加新的 provider请参阅 `AGENTS.md` 中关于必要测试的说明。
## 有疑问?
请联系项目维护者。
## 常见问题
### 为什么新 issue 和 PR 会被自动关闭?
SproutClaw 收到的 issue 数量超过维护者能够实时处理的上限。许多报告不符合本指南的质量标准,或者是通过 Agent 随意提交而未经本人审查和整理的。自动关闭机制让维护者可以按自己的节奏审查,并重新开放符合质量要求的 issue。
### 为什么周末的 issue 优先级较低?
维护者在工作时间处理 issue。周末可能积累更多未处理内容周五至周日提交的 issue 可能在周一审查时被遗漏或优先级降低。如有紧急问题,请直接联系维护者并附上简要描述、复现步骤和相关日志。
### 为什么有些 issue 没有收到回复?
回复本身也是一种维护成本。低质量 issue、表述不清的报告、重复 issue 以及不遵循本指南的 issue 可能被直接关闭而不作说明。这样可以将时间留给可复现的 bug、深思熟虑的需求以及真正用心整理报告的贡献者。
### 为什么不用 AI 来做所有分类工作?
AI 可以帮助归纳重复内容、汇总报告、发现缺失信息,但不被信任来做最终的维护决策。经过润色的 AI 生成 issue 仍然可能是错误的、有误导性的,或需要大量时间排查。人工审查始终是最终把关环节。
### 这对贡献者是否过于苛刻?
不是。这是防止维护者精力耗尽和 issue 刷屏的保护机制。简洁、具体、可复现的 issue 是受欢迎的,有深度的贡献同样受欢迎。自动化生成的低质量内容、无理取闹以及大量低价值报告则不被接受。

154
README.md
View File

@@ -8,7 +8,7 @@
- 开发协作助手:理解本地项目结构,执行代码修改、测试验证和 Git 工作流。
- Docker 部署助手:按项目目录组织 `docker compose` 服务,关注数据持久化、端口规划和资源限制。
- 内网服务助手:适配 smallmengya、bigmengya、alycd 等内网服务器使用习惯。
- WebUI/TUI 双入口:保留控制台 TUI同时扩展网页前端,方便在浏览器里管理会话和模型
- WebUI/TUI 双入口:保留控制台 TUI同时提供独立的网页前端(`sproutclaw-web`
## 项目结构
@@ -18,158 +18,182 @@
| `packages/agent` | Agent 运行时、工具调用和状态管理 |
| `packages/ai` | 多模型、多 provider 的 LLM 接入层 |
| `packages/tui` | 终端 UI 渲染库 |
| `.pi/agent/extensions/webui` | 网页对话入口React 前端 + `backend/` HTTP 服务 + systemd 集成 |
| `.pi/agent/extensions/*/index.ts` | sproutclaw 本地扩展(中文启动页、状态行、命令安装等) |
| `.pi/agent/prompts/` | 自定义 slash prompt`cl`/`is`/`pr`/`wr` 等) |
| `.pi/agent/skills/` | 可复用 SkillsGitea、SSH、GitHub 等) |
| `pi-built.sh` | 使用已构建 `dist/cli.js` 启动 Agent |
| `sproutclaw` / `sproutclaw.bat` | 启动入口(从已构建 `dist/cli.js` 启动 |
| `build.sh` / `build.bat` | 同上,使用已构建版启动 |
| `test.sh` / `test.bat` | 从源码tsx启动用于开发调试 |
| `scripts/sync-upstream.sh` | 合并上游 pi-mono 更新 |
| `scripts/push-sproutclaw.sh` | 推送到 GitHub / Gitea |
> `.sproutclaw/agent/` 为本地持久化配置目录settings、auth、sessions、extensions、skills 等),已整体加入 `.gitignore`,不进入版本控制。与原版 pi 的 `~/.pi/agent/` 完全隔离。
## 常用命令
安装依赖并构建:
```bash
npm install --ignore-scripts
npm run build
npm run check
```
从源码运行(开发调试,无需 build
```bash
./test.sh
./pi-test.sh
```
运行本地源码版 Agent开发调试
从已构建版运行(日常更快
```bash
./pi-test.sh
./build.sh
# 或
./sproutclaw
```
运行已构建版 Agent日常更快
检查代码
```bash
npm run build
./pi-built.sh
npm run check
```
启动 WebUI 时,在 TUI 中执行:
## WebUIsproutclaw-web
```text
/webui on 19133
```
WebUI 是独立项目,位于 `../sproutclaw-web/`,通过 systemd 以服务形式运行。
关闭 WebUI
```text
/webui off
```
WebUI 前端单独构建:
构建后端
```bash
cd .pi/agent/extensions/webui/frontend && npm install && npm run build
cd ../sproutclaw-web
bash build.sh
```
构建前端:
```bash
cd ../sproutclaw-web/frontend
npm install && npm run build
```
管理服务:
```bash
systemctl start sproutclaw-web
systemctl stop sproutclaw-web
systemctl restart sproutclaw-web
systemctl status sproutclaw-web
```
## Git 分支策略
本仓库是 pi-mono 的 fork采用三层分支,同时维护**上游同步**和 **sproutclaw 定制**
本仓库是 pi-mono 的 fork同时维护**上游同步**和 **sproutclaw 定制**
| 分支 | 用途 |
| --- | --- |
| `main` | 稳定可跑版本:已合并的上游 + sproutclaw 定制,推送到 GitHub |
| `upstream-sync` | 临时同步分支:专门用来 `merge upstream/main`,解决冲突后再合回 `main` |
| `main` | 稳定可跑版本:已合并的上游 + sproutclaw 定制,推送到 GitHub / Gitea |
| `feature/*` | 日常开发WebUI、扩展、补丁等完成后 merge 进 `main` |
### Remote 配置
```text
origin → https://github.com/shumengya/sproutclaw.git (你的 GitHub
upstream → https://github.com/badlogic/pi-mono.git (上游 pi
gitea → ssh://git@git.shumengya.top:8022/shumengya/sproutclaw.git (内网备份,可选)
origin → https://github.com/shumengya/sproutclaw.git
upstream → https://github.com/badlogic/pi-mono.git
gitea → ssh://git@git.shumengya.top:8022/shumengya/sproutclaw.git
```
首次配置 upstream
```bash
git remote add upstream https://github.com/badlogic/pi-mono.git
git remote add gitea ssh://git@git.shumengya.top:8022/shumengya/sproutclaw.git
```
GitHub 网络不稳定时:
### 日常开发
```bash
export GIT_HTTP_VERSION=1.1
```
### 日常开发feature 分支)
```bash
./scripts/new-feature.sh webui-avatar-fix # 从 main 创建 feature/webui-avatar-fix
git checkout -b feature/my-fix
# ... 开发、测试 ...
git add <改动的具体文件> # 不要用 git add -A
git commit -m "fix(webui): agent avatar during tool calls"
git add <改动的具体文件>
git commit -m "fix: ..."
git checkout main
git merge feature/webui-avatar-fix
git merge feature/my-fix
./scripts/push-sproutclaw.sh
git branch -d feature/webui-avatar-fix # 合并后可删
git branch -d feature/my-fix
```
### 推送本地更新到 GitHub
### 推送到 GitHub / Gitea
```bash
git checkout main
./scripts/push-sproutclaw.sh
# 或手动
git push origin main && git push gitea main
```
### 合并上游 pi-mono 更新
```bash
./scripts/sync-upstream.sh
# 若有冲突,按下方规则解决后:
git fetch upstream
git merge upstream/main --no-commit --no-ff
# 解决冲突(见下方惯例),然后:
git add <resolved-files>
git commit
git checkout main && git merge upstream-sync
PI_ALLOW_LOCKFILE_CHANGE=1 git commit
./scripts/push-sproutclaw.sh
```
**合并冲突处理惯例:**
| 文件 | 处理方式 |
| 文件类型 | 处理方式 |
| --- | --- |
| `README.md`、sproutclaw 定制代码 | 保留本仓库版本 |
| `package-lock.json` | 采用 upstream再执行 `npm install --ignore-scripts` |
| `*.generated.ts` | 采用 upstream |
| `.pi/agent/extensions/` | 保留 sproutclaw 版本 |
| `package-lock.json` | 采用 upstream`npm install --ignore-scripts` |
| 测试/文档/新增上游功能 | 采用 upstream |
合并后必做:
```bash
npm install --ignore-scripts
npm run build
./pi-test.sh
./test.sh
```
### sproutclaw 相对 upstream 的 intentional 改动
### sproutclaw 相对 upstream 的定制改动
合并上游时,以下定制**不要误删**
合并上游时,以下内容**不要误删**
- `packages/coding-agent/package.json``piConfig.name = sproutclaw``configDir = .sproutclaw`(命名空间隔离)
- `showChangelogOnStartup` 设置项(关闭启动 What's New
- `.pi/agent/extensions/webui/` 网页 UI
- `.pi/agent/extensions/startup-chinese/` 等本地扩展目录
- `.pi/agent/extensions/webui/backend/` WebUI 后端模块化拆分
- 本 README(仅 sproutclaw 说明,不含上游文档)
- RPC 扩展:`reload` 命令、bash 流式输出(`bash_update`)、`get_extensions` 接口
- `sproutclaw` / `build.sh` / `test.sh` 等启动脚本
- `scripts/sync-upstream.sh``scripts/push-sproutclaw.sh` 等工具脚本
- 本 README 及中文版 CONTRIBUTING.md / SECURITY.md
## 本地配置
`.pi/agent/` 中以下内容**不进 Git**(含密钥/内网地址)
`.sproutclaw/agent/` 中以下内容不进 Git
- `settings.json` → 参考 `settings.example.json` 本地复制
- `models.json``auth.json`
- `sessions/`
- `models.json``auth.json``mcp.json`
- `sessions/``skills/``extensions/``prompts/`
环境变量(可选):
- `SPROUTCLAW_CODING_AGENT_DIR` — 覆盖 agent 配置目录,默认 `<repo>/.sproutclaw/agent`
首次部署:
```bash
cp .pi/agent/settings.example.json .pi/agent/settings.json
# 编辑 settings.json 填入 provider / model
cp .sproutclaw/agent/settings.example.json .sproutclaw/agent/settings.json
# 编辑 settings.json 填入 provider / model / auth 信息
```
### 与原版 pi 并存
| | 原版 pi | SproutClaw |
|---|---|---|
| 命令 | `pi` | `sproutclaw` / `mengya` |
| 全局配置 | `~/.pi/agent/` | `~/.sproutclaw/agent/` |
| 项目配置 | `.pi/` | `.sproutclaw/` |
| 环境变量 | `PI_CODING_AGENT_DIR` | `SPROUTCLAW_CODING_AGENT_DIR` |
## 说明
这个仓库是自用 fork默认围绕树萌芽的服务器环境、部署规范和日常开发习惯调整。上游能力来自 pi-mono通过 `upstream-sync` 分支定期同步sproutclaw 定制在 `feature/*` 开发后合入 `main`
这个仓库是自用 fork默认围绕树萌芽的服务器环境、部署规范和日常开发习惯调整。上游能力来自 pi-mono通过 `git fetch upstream && git merge upstream/main` 定期同步sproutclaw 定制在 `feature/*` 开发后合入 `main`

58
SECURITY.md Normal file
View File

@@ -0,0 +1,58 @@
# 安全政策
本文档旨在帮助你了解 SproutClaw 的安全设计理念及其边界范围。
## 安全模型概述
SproutClaw 是一个在本地运行的 Coding Agent运行于使用者账号的安全边界之内。监控其操作或将其限制在容器、虚拟机或其他沙箱环境中是使用者自身的责任。
SproutClaw 将本地用户账号及该账号可写的文件视为与 SproutClaw 进程处于同一信任边界内。如果攻击者能够修改用户主目录、工作区、Shell 启动文件、环境变量或 SproutClaw 配置文件,通常即可影响 SproutClaw 或其他本地开发工具的行为。依赖此类本地写入权限的报告不属于安全漏洞,除非其能证明 SproutClaw 本身授予了该写入权限,或越过了操作系统的权限边界。
SproutClaw 依赖使用者仅安装可信扩展、加载可信技能,并仅在可信仓库中使用。这是因为 `AGENTS.md` 等文件中的内容或代码注释可被用于对 Coding Agent 进行提示词注入,且此类攻击无法从根本上防御。
## 报告漏洞
如果你认为在 SproutClaw 中发现了安全漏洞,请通过以下方式私下报告:
- 直接联系项目维护者
报告时请包含:
- 问题描述及其影响
- 复现步骤、概念验证或相关日志
- 受影响的包、版本、提交哈希或配置信息
- 已知的缓解措施(如有)
请勿为安全敏感问题创建公开 issue。
## 范围内In Scope
以下属于安全问题的受理范围:
- 分发包、命令行工具、API 及仓库代码中的安全问题
## 范围外Out of Scope
以下内容不在安全问题受理范围之内:
- 本地代码执行或沙箱行为SproutClaw Coding Agent 有意不提供沙箱)
- 用户自行安装的扩展或技能的行为
- 在不可信仓库中工作所带来的风险
- 安装不可信扩展、技能、包或工具所带来的风险
- 由不可信中间人代理MITM Proxy导致的问题
- 将 SproutClaw 暴露在公网上的风险
- 提示词注入攻击
- 第三方或用户自行控制的凭据泄露
- 需要在目标机器上具备创建、修改、删除或替换文件、目录、符号链接、环境变量、Shell 配置或其他用户可控本地状态的能力才能复现的报告(包括 `~/.pi``~/.pi/agent/models.json`、工作区文件、`AGENTS.md`、技能、扩展、扩展配置、dotfiles 及通过 NFS/漫游配置/dotfile 管理器同步的文件),除非报告能证明 SproutClaw 本身授予了该访问权限
- 由用户有意弱化配置导致的问题
- 针对 SproutClaw Coding Agent 使用可信本地输入/配置的资源耗尽/DoS 声明
- 关于恶意模型输出的报告
- 用户批准或用户主动发起的本地操作被当作漏洞提交的报告
## 报告者须知
最有价值的报告应展示当前可复现的安全边界绕过,并附有已证明的实际影响。仅展示预期本地 Agent 行为、提示词注入或恶意可信扩展/技能的报告,在本安全模型下不构成安全漏洞。
例如,若报告显示向可信 SproutClaw 配置文件写入恶意内容会导致 SproutClaw 执行命令、加载攻击者控制的工具、向攻击者控制的端点发送凭据或改变其他行为,此类报告属于范围之外。
提交报告时,请尽量包含以下信息:受影响的确切路径、包版本或提交 SHA、配置信息以及针对最新版本或最新 `main` 分支的概念验证。对于依赖项报告,请提供证据说明已分发的依赖项确实受影响,且该问题可通过 SproutClaw 触达。

View File

@@ -1,404 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>BMI 指数计算器</title>
<style>
:root {
--bg: #f4f7fa;
--card: #ffffff;
--text: #1f2937;
--muted: #6b7280;
--primary: #3b82f6;
--primary-dark: #2563eb;
--radius: 16px;
--shadow: 0 10px 25px rgba(0, 0, 0, 0.08);
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "PingFang SC", "Microsoft YaHei", sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.container {
width: 100%;
max-width: 480px;
background: var(--card);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 32px;
}
h1 {
text-align: center;
font-size: 1.6rem;
margin-bottom: 8px;
}
.subtitle {
text-align: center;
color: var(--muted);
font-size: 0.9rem;
margin-bottom: 28px;
}
.form-group {
margin-bottom: 18px;
}
label {
display: block;
margin-bottom: 6px;
font-size: 0.9rem;
font-weight: 500;
}
.unit-toggle {
display: flex;
gap: 10px;
margin-bottom: 20px;
}
.unit-toggle button {
flex: 1;
padding: 10px;
border: 1px solid #d1d5db;
background: #f9fafb;
border-radius: 10px;
cursor: pointer;
font-size: 0.9rem;
transition: all 0.2s;
}
.unit-toggle button.active {
background: var(--primary);
color: #fff;
border-color: var(--primary);
}
input[type="number"] {
width: 100%;
padding: 12px 14px;
border: 1px solid #d1d5db;
border-radius: 10px;
font-size: 1rem;
transition: border-color 0.2s, box-shadow 0.2s;
}
input[type="number"]:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15);
}
.input-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
}
.btn {
width: 100%;
padding: 14px;
margin-top: 10px;
border: none;
border-radius: 10px;
background: var(--primary);
color: #fff;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
.btn:hover {
background: var(--primary-dark);
}
.result {
margin-top: 24px;
text-align: center;
display: none;
}
.result.show {
display: block;
animation: fadeIn 0.3s ease;
}
.bmi-value {
font-size: 3rem;
font-weight: 700;
line-height: 1;
}
.bmi-category {
font-size: 1.2rem;
margin-top: 8px;
font-weight: 600;
}
.bmi-tip {
margin-top: 10px;
color: var(--muted);
font-size: 0.9rem;
}
.scale {
margin-top: 22px;
display: flex;
height: 14px;
border-radius: 999px;
overflow: hidden;
position: relative;
}
.scale span {
flex: 1;
}
.scale .under { background: #60a5fa; }
.scale .normal { background: #22c55e; }
.scale .over { background: #f59e0b; }
.scale .obese { background: #ef4444; }
.scale-marker {
position: absolute;
top: -4px;
width: 4px;
height: 22px;
background: #1f2937;
border-radius: 2px;
transform: translateX(-50%);
transition: left 0.4s ease;
}
.scale-labels {
display: flex;
justify-content: space-between;
margin-top: 6px;
font-size: 0.75rem;
color: var(--muted);
}
.info-cards {
margin-top: 28px;
display: grid;
gap: 10px;
}
.info-card {
display: flex;
align-items: center;
padding: 12px 14px;
border-radius: 10px;
background: #f9fafb;
font-size: 0.9rem;
}
.dot {
width: 12px;
height: 12px;
border-radius: 50%;
margin-right: 10px;
flex-shrink: 0;
}
.dot.under { background: #60a5fa; }
.dot.normal { background: #22c55e; }
.dot.over { background: #f59e0b; }
.dot.obese { background: #ef4444; }
@keyframes fadeIn {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
@media (max-width: 480px) {
body {
padding: 0;
}
.container {
border-radius: 0;
min-height: 100vh;
max-width: 100%;
padding: 24px;
display: flex;
flex-direction: column;
justify-content: center;
}
h1 {
font-size: 1.4rem;
}
.bmi-value {
font-size: 2.6rem;
}
.input-row {
grid-template-columns: 1fr;
}
}
</style>
</head>
<body>
<div class="container">
<h1>BMI 指数计算器</h1>
<p class="subtitle">输入身高体重,快速了解你的身体质量指数</p>
<div class="unit-toggle">
<button id="unitMetric" class="active" onclick="setUnit('metric')">公制 (cm / kg)</button>
<button id="unitImperial" onclick="setUnit('imperial')">英制 (ft+in / lb)</button>
</div>
<form id="bmiForm" onsubmit="event.preventDefault(); calculateBMI();">
<div id="metricInputs">
<div class="form-group">
<label for="heightCm">身高(厘米)</label>
<input type="number" id="heightCm" placeholder="例如175" min="1" step="0.1" />
</div>
<div class="form-group">
<label for="weightKg">体重(公斤)</label>
<input type="number" id="weightKg" placeholder="例如65" min="1" step="0.1" />
</div>
</div>
<div id="imperialInputs" style="display: none;">
<div class="input-row">
<div class="form-group">
<label for="heightFt">身高(英尺)</label>
<input type="number" id="heightFt" placeholder="ft" min="1" step="1" />
</div>
<div class="form-group">
<label for="heightIn">身高(英寸)</label>
<input type="number" id="heightIn" placeholder="in" min="0" step="0.1" />
</div>
</div>
<div class="form-group">
<label for="weightLb">体重(磅)</label>
<input type="number" id="weightLb" placeholder="例如150" min="1" step="0.1" />
</div>
</div>
<button type="submit" class="btn">计算 BMI</button>
</form>
<div id="result" class="result">
<div class="bmi-value" id="bmiValue">--</div>
<div class="bmi-category" id="bmiCategory">--</div>
<div class="bmi-tip" id="bmiTip">--</div>
<div class="scale">
<span class="under"></span>
<span class="normal"></span>
<span class="over"></span>
<span class="obese"></span>
<div class="scale-marker" id="scaleMarker" style="left: 0%;"></div>
</div>
<div class="scale-labels">
<span>偏瘦</span>
<span>正常</span>
<span>偏胖</span>
<span>肥胖</span>
</div>
</div>
<div class="info-cards">
<div class="info-card"><span class="dot under"></span>偏瘦BMI &lt; 18.5</div>
<div class="info-card"><span class="dot normal"></span>正常18.5 ≤ BMI &lt; 24</div>
<div class="info-card"><span class="dot over"></span>偏胖24 ≤ BMI &lt; 28</div>
<div class="info-card"><span class="dot obese"></span>肥胖BMI ≥ 28</div>
</div>
</div>
<script>
let currentUnit = 'metric';
function setUnit(unit) {
currentUnit = unit;
document.getElementById('unitMetric').classList.toggle('active', unit === 'metric');
document.getElementById('unitImperial').classList.toggle('active', unit === 'imperial');
document.getElementById('metricInputs').style.display = unit === 'metric' ? 'block' : 'none';
document.getElementById('imperialInputs').style.display = unit === 'imperial' ? 'block' : 'none';
document.getElementById('result').classList.remove('show');
}
function calculateBMI() {
let bmi = 0;
if (currentUnit === 'metric') {
const heightCm = parseFloat(document.getElementById('heightCm').value);
const weightKg = parseFloat(document.getElementById('weightKg').value);
if (!heightCm || !weightKg || heightCm <= 0 || weightKg <= 0) {
alert('请输入有效的身高和体重');
return;
}
const heightM = heightCm / 100;
bmi = weightKg / (heightM * heightM);
} else {
const heightFt = parseFloat(document.getElementById('heightFt').value) || 0;
const heightIn = parseFloat(document.getElementById('heightIn').value) || 0;
const weightLb = parseFloat(document.getElementById('weightLb').value);
const totalInches = heightFt * 12 + heightIn;
if (!totalInches || !weightLb || totalInches <= 0 || weightLb <= 0) {
alert('请输入有效的身高和体重');
return;
}
bmi = 703 * weightLb / (totalInches * totalInches);
}
bmi = Math.round(bmi * 10) / 10;
let category = '';
let tip = '';
let color = '';
if (bmi < 18.5) {
category = '偏瘦';
tip = '建议适当增加营养摄入,配合力量训练增肌。';
color = '#60a5fa';
} else if (bmi < 24) {
category = '正常';
tip = 'BMI 在正常范围内,继续保持健康的生活方式!';
color = '#22c55e';
} else if (bmi < 28) {
category = '偏胖';
tip = '建议适当控制饮食,增加有氧运动。';
color = '#f59e0b';
} else {
category = '肥胖';
tip = '建议咨询医生或营养师,制定科学的减重计划。';
color = '#ef4444';
}
document.getElementById('bmiValue').textContent = bmi;
document.getElementById('bmiValue').style.color = color;
document.getElementById('bmiCategory').textContent = category;
document.getElementById('bmiCategory').style.color = color;
document.getElementById('bmiTip').textContent = tip;
// 刻度位置:把 BMI 映射到 15~35 的区间
let percent = ((bmi - 15) / (35 - 15)) * 100;
percent = Math.max(0, Math.min(100, percent));
document.getElementById('scaleMarker').style.left = percent + '%';
document.getElementById('result').classList.add('show');
}
</script>
</body>
</html>

View File

@@ -6,8 +6,8 @@ set "SCRIPT_DIR=%~dp0"
if "%SCRIPT_DIR:~-1%"=="\" set "SCRIPT_DIR=%SCRIPT_DIR:~0,-1%"
set "PI_CLI_DIST=%SCRIPT_DIR%\packages\coding-agent\dist\cli.js"
if not defined PI_CODING_AGENT_DIR (
set "PI_CODING_AGENT_DIR=%SCRIPT_DIR%\.pi\agent"
if not defined SPROUTCLAW_CODING_AGENT_DIR (
set "SPROUTCLAW_CODING_AGENT_DIR=%SCRIPT_DIR%\.sproutclaw\agent"
)
set "NO_ENV=false"

View File

@@ -4,7 +4,10 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PI_CLI_DIST="$SCRIPT_DIR/packages/coding-agent/dist/cli.js"
export PI_CODING_AGENT_DIR="${PI_CODING_AGENT_DIR:-$SCRIPT_DIR/.pi/agent}"
export SPROUTCLAW_CODING_AGENT_DIR="${SPROUTCLAW_CODING_AGENT_DIR:-$SCRIPT_DIR/.sproutclaw/agent}"
# Align pi-mcp-adapter (and other third-party packages that only read PI_CODING_AGENT_DIR)
# with SproutClaw's agent dir, so MCP configs in .sproutclaw/agent/mcp.json are loaded.
export PI_CODING_AGENT_DIR="$SPROUTCLAW_CODING_AGENT_DIR"
NO_ENV=false
ARGS=()

3670
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -7,7 +7,8 @@
"packages/coding-agent/examples/extensions/with-deps",
"packages/coding-agent/examples/extensions/custom-provider-anthropic",
"packages/coding-agent/examples/extensions/custom-provider-gitlab-duo",
"packages/coding-agent/examples/extensions/sandbox"
"packages/coding-agent/examples/extensions/sandbox",
"packages/coding-agent/examples/extensions/gondolin"
],
"scripts": {
"clean": "npm run clean --workspaces",
@@ -20,18 +21,19 @@
"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",
"version:patch": "npm version patch -ws --no-git-tag-version && node scripts/sync-versions.js && npm install --package-lock-only",
"version:minor": "npm version minor -ws --no-git-tag-version && node scripts/sync-versions.js && npm install --package-lock-only",
"version:major": "npm version major -ws --no-git-tag-version && node scripts/sync-versions.js && npm install --package-lock-only",
"version:patch": "npm version patch -ws --no-git-tag-version && node scripts/sync-versions.js && npm install --package-lock-only --ignore-scripts",
"version:minor": "npm version minor -ws --no-git-tag-version && node scripts/sync-versions.js && npm install --package-lock-only --ignore-scripts",
"version:major": "npm version major -ws --no-git-tag-version && node scripts/sync-versions.js && npm install --package-lock-only --ignore-scripts",
"version:set": "npm version -ws",
"prepublishOnly": "npm run clean && npm run build && npm run check",
"publish": "npm run prepublishOnly && npm publish -ws --access public",
"publish:dry": "npm run prepublishOnly && npm publish -ws --access public --dry-run",
"publish": "npm run prepublishOnly && node scripts/publish.mjs",
"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",
"release:patch": "node scripts/release.mjs patch",
"release:minor": "node scripts/release.mjs minor",
"release:major": "node scripts/release.mjs major",
"release:fix-links": "node scripts/release-notes.mjs fix-github-releases",
"prepare": "husky"
},
"devDependencies": {
@@ -39,7 +41,7 @@
"@biomejs/biome": "2.3.5",
"@types/node": "22.19.19",
"@typescript/native-preview": "7.0.0-dev.20260120.1",
"esbuild": "0.28.0",
"esbuild": "0.28.1",
"husky": "9.1.7",
"jiti": "2.7.0",
"shx": "0.4.0",
@@ -55,5 +57,8 @@
"gaxios": {
"rimraf": "6.1.2"
}
},
"dependencies": {
"@mistralai/mistralai": "2.2.6"
}
}

View File

@@ -2,6 +2,64 @@
## [Unreleased]
## [0.79.9] - 2026-06-20
### Fixed
- Fixed Node execution environment commands through legacy WSL `bash.exe` to pass scripts over stdin so shell variables expand in the target bash ([#5893](https://github.com/earendil-works/pi/issues/5893)).
## [0.79.8] - 2026-06-19
### Added
- Added `@earendil-works/pi-agent-core/base` for bundlers that want to pair the agent core with selective `@earendil-works/pi-ai/base` provider registration ([#5348](https://github.com/earendil-works/pi/pull/5348) by [@FredKSchott](https://github.com/FredKSchott)).
## [0.79.7] - 2026-06-18
## [0.79.6] - 2026-06-16
## [0.79.5] - 2026-06-16
## [0.79.4] - 2026-06-15
## [0.79.3] - 2026-06-13
## [0.79.2] - 2026-06-12
### Fixed
- Fixed late tool progress callbacks after tool settlement to be ignored instead of emitting stale `tool_execution_update` events ([#5573](https://github.com/earendil-works/pi/issues/5573)).
## [0.79.1] - 2026-06-09
## [0.79.0] - 2026-06-08
### Fixed
- Fixed the compaction summarization system prompt to use neutral AI assistant wording for non-coding agents ([#5401](https://github.com/earendil-works/pi/issues/5401)).
## [0.78.1] - 2026-06-04
## [0.78.0] - 2026-05-29
## [0.77.0] - 2026-05-28
### Breaking Changes
- Renamed agent harness `model_select` and `thinking_level_select` events to `model_update` and `thinking_level_update`.
### Added
- Added agent harness tool registry APIs, `tools_update` events, branch-scoped active-tool persistence, and duplicate tool validation.
## [0.76.0] - 2026-05-27
### Fixed
- Fixed context token estimates to count user image attachments consistently with tool result images ([#4983](https://github.com/earendil-works/pi/issues/4983)).
## [0.75.5] - 2026-05-23
## [0.75.4] - 2026-05-20
### Changed

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

View File

@@ -258,13 +258,14 @@ Done:
- Exported `QueueMode` from core types.
- Added `AgentHarnessOptions.steeringMode` and `followUpMode`.
- Added live `getSteeringMode()` / `setSteeringMode()` and `getFollowUpMode()` / `setFollowUpMode()`.
- Added `getTools()` and `getActiveTools()`.
- Added `tools_update` observability events, including active-tool-only updates.
- Active tool changes are persisted as branch-scoped `active_tools_change` entries.
- Duplicate tool names and duplicate active tool names reject.
Remaining:
- Add `getTools()` semantics.
- Add `getActiveTools()` semantics.
- Decide and implement tool update observability events.
- Include active-tool-only updates in the runtime config observability plan.
- None.
Notes:

View File

@@ -14,6 +14,8 @@ A fully durable `AgentHarness` is not realistic by itself because important depe
- resource loaders
- system-prompt callbacks/modifiers
Tool registries are runtime dependencies. The harness should persist serializable tool configuration, such as active tool names, but not concrete tool implementations.
The practical target is a semi-durable harness:
- session is the durable append-only state tree
@@ -29,6 +31,7 @@ Existing session state already includes harness state:
- model changes
- thinking-level changes
- active-tool changes
- leaf entries
- labels
- compactions and branch summaries
@@ -50,10 +53,38 @@ The app must recreate compatible runtime dependencies:
Harness can validate stable IDs/versions/hashes when available, but it cannot serialize these dependencies itself.
## Runtime configuration and restore
Constructor options remain explicit runtime configuration and do not read session state. Hidden async restore in a constructor would make failure handling ambiguous.
A future async builder/factory should own durable restore:
```ts
const harness = await AgentHarness.builder()
.env(env)
.session(session)
.model(defaultModel)
.tools(runtimeTools)
.defaultActiveTools(["read", "edit"])
.restore({ missingActiveTools: "fail" });
```
`restore()` should read the active branch, reduce durable harness configuration, apply defaults for missing entries, validate against app-supplied runtime dependencies, construct the harness, and optionally emit `source: "restore"` update events after construction.
For active tools:
- `active_tools_change` entries are branch-scoped durable config.
- If no `active_tools_change` exists on the branch, restore uses builder defaults, or all registered tools if no default active names were supplied.
- Active tool names must be unique.
- Tool registry names must be unique.
- Missing restored active tool names should fail restore by default; permissive drop/disable policies can be added explicitly later.
- Concrete tools are never restored from session; the host app must provide compatible tools.
## What harness should persist
Minimum useful durability entries:
- branch-scoped active tool names
- queued steer/followUp/nextTurn messages
- queue consumption tied to a turn
- pending session writes accepted during active operations
@@ -93,11 +124,11 @@ On startup:
3. Harness reduces session entries into:
- current leaf
- conversation branch
- harness config
- harness config, including active tool names
- queues
- pending writes
- active operation/turn/tool state
4. Harness validates required runtime dependencies.
4. Harness validates required runtime dependencies, including restored active tool names against the app-provided tool registry.
5. Harness reconciles unfinished operation state.
Provider streams are not resumable. Recovery can only retry from a durable boundary or mark the operation interrupted.
@@ -173,7 +204,7 @@ recovery: "mark_interrupted" | "retry_unfinished"
## Open questions
- Which harness config entries should move into session first: tools, active tools, resources, stream options, system prompt refs?
- Which remaining harness config entries should move into session first: resources, stream options, system prompt refs?
- Should resolved system prompt text be snapshotted per turn for audit/debug?
- Do we require strict dependency ID/version matching on resume?
- How much provider request data should be journaled?

View File

@@ -1,6 +1,6 @@
{
"name": "@earendil-works/pi-agent-core",
"version": "0.75.4",
"version": "0.79.9",
"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.75.4",
"@earendil-works/pi-ai": "^0.79.9",
"ignore": "7.0.5",
"typebox": "1.1.38",
"yaml": "2.9.0"
@@ -45,7 +49,7 @@
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/earendil-works/pi-mono.git",
"url": "git+https://github.com/earendil-works/pi.git",
"directory": "packages/agent"
},
"engines": {
@@ -53,8 +57,8 @@
},
"devDependencies": {
"@types/node": "24.12.4",
"@vitest/coverage-v8": "3.2.4",
"@vitest/coverage-v8": "4.1.9",
"typescript": "5.9.3",
"vitest": "3.2.4"
"vitest": "4.1.9"
}
}

View File

@@ -10,7 +10,7 @@ import {
streamSimple,
type ToolResultMessage,
validateToolArguments,
} from "@earendil-works/pi-ai";
} from "@earendil-works/pi-ai/base";
import type {
AgentContext,
AgentEvent,
@@ -631,6 +631,7 @@ async function executePreparedToolCall(
emit: AgentEventSink,
): Promise<ExecutedToolCallOutcome> {
const updateEvents: Promise<void>[] = [];
let acceptingUpdates = true;
try {
const result = await prepared.tool.execute(
@@ -638,6 +639,7 @@ async function executePreparedToolCall(
prepared.args as never,
signal,
(partialResult) => {
if (!acceptingUpdates) return;
updateEvents.push(
Promise.resolve(
emit({
@@ -651,14 +653,18 @@ async function executePreparedToolCall(
);
},
);
acceptingUpdates = false;
await Promise.all(updateEvents);
return { result, isError: false };
} catch (error) {
acceptingUpdates = false;
await Promise.all(updateEvents);
return {
result: createErrorToolResult(error instanceof Error ? error.message : String(error)),
isError: true,
};
} finally {
acceptingUpdates = false;
}
}

View File

@@ -7,7 +7,7 @@ import {
type TextContent,
type ThinkingBudgets,
type Transport,
} from "@earendil-works/pi-ai";
} from "@earendil-works/pi-ai/base";
import { runAgentLoop, runAgentLoopContinue } from "./agent-loop.ts";
import type {
AfterToolCallContext,

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

@@ -4,7 +4,7 @@ import {
type Model,
streamSimple,
type UserMessage,
} from "@earendil-works/pi-ai";
} from "@earendil-works/pi-ai/base";
import { runAgentLoop } from "../agent-loop.ts";
import type {
AgentContext,
@@ -86,6 +86,16 @@ function mergeHeaders(...headers: Array<Record<string, string> | undefined>): Re
return hasHeaders ? merged : undefined;
}
function findDuplicateNames(names: string[]): string[] {
const seen = new Set<string>();
const duplicates = new Set<string>();
for (const name of names) {
if (seen.has(name)) duplicates.add(name);
seen.add(name);
}
return [...duplicates];
}
function applyStreamOptionsPatch(
base: AgentHarnessStreamOptions,
patch?: AgentHarnessStreamOptionsPatch,
@@ -194,12 +204,20 @@ export class AgentHarness<
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)",
);
for (const tool of options.tools ?? []) {
this.tools.set(tool.name, tool);
}
this.model = options.model;
this.thinkingLevel = options.thinkingLevel ?? "off";
this.activeToolNames = options.activeToolNames ?? (options.tools ?? []).map((tool) => tool.name);
this.activeToolNames = options.activeToolNames
? [...options.activeToolNames]
: (options.tools ?? []).map((tool) => tool.name);
this.validateUniqueNames(this.activeToolNames, "Duplicate active tool name(s)");
this.validateToolNames(this.activeToolNames);
this.steeringQueueMode = options.steeringMode ?? "one-at-a-time";
this.followUpQueueMode = options.followUpMode ?? "one-at-a-time";
}
@@ -451,7 +469,14 @@ export class AgentHarness<
};
}
private validateUniqueNames(names: string[], message: string): void {
const duplicates = findDuplicateNames(names);
if (duplicates.length > 0)
throw new AgentHarnessError("invalid_argument", `${message}: ${duplicates.join(", ")}`);
}
private validateToolNames(toolNames: string[], tools: Map<string, TTool> = this.tools): void {
this.validateUniqueNames(toolNames, "Duplicate active tool name(s)");
const missing = toolNames.filter((name) => !tools.has(name));
if (missing.length > 0) throw new AgentHarnessError("invalid_argument", `Unknown tool(s): ${missing.join(", ")}`);
}
@@ -465,6 +490,8 @@ export class AgentHarness<
await this.session.appendModelChange(write.provider, write.modelId);
} else if (write.type === "thinking_level_change") {
await this.session.appendThinkingLevelChange(write.thinkingLevel);
} else if (write.type === "active_tools_change") {
await this.session.appendActiveToolsChange(write.activeToolNames);
} else if (write.type === "custom") {
await this.session.appendCustomEntry(write.customType, write.data);
} else if (write.type === "custom_message") {
@@ -838,10 +865,6 @@ export class AgentHarness<
return this.model;
}
getThinkingLevel(): ThinkingLevel {
return this.thinkingLevel;
}
async setModel(model: Model<any>): Promise<void> {
try {
const previousModel = this.model;
@@ -851,12 +874,16 @@ export class AgentHarness<
this.pendingSessionWrites.push({ type: "model_change", provider: model.provider, modelId: model.id });
}
this.model = model;
await this.emitOwn({ type: "model_select", model, previousModel, source: "set" });
await this.emitOwn({ type: "model_update", model, previousModel, source: "set" });
} catch (error) {
throw normalizeHarnessError(error, "session");
}
}
getThinkingLevel(): ThinkingLevel {
return this.thinkingLevel;
}
async setThinkingLevel(level: ThinkingLevel): Promise<void> {
try {
const previousLevel = this.thinkingLevel;
@@ -866,16 +893,70 @@ export class AgentHarness<
this.pendingSessionWrites.push({ type: "thinking_level_change", thinkingLevel: level });
}
this.thinkingLevel = level;
await this.emitOwn({ type: "thinking_level_select", level, previousLevel });
await this.emitOwn({ type: "thinking_level_update", level, previousLevel });
} catch (error) {
throw normalizeHarnessError(error, "session");
}
}
getTools(): TTool[] {
return [...this.tools.values()];
}
async setTools(tools: TTool[], activeToolNames?: string[]): Promise<void> {
try {
this.validateUniqueNames(
tools.map((tool) => tool.name),
"Duplicate tool name(s)",
);
const nextTools = new Map(tools.map((tool) => [tool.name, tool]));
const nextActiveToolNames = activeToolNames ? [...activeToolNames] : this.activeToolNames;
this.validateToolNames(nextActiveToolNames, nextTools);
const previousToolNames = [...this.tools.keys()];
const previousActiveToolNames = [...this.activeToolNames];
if (this.phase === "idle") {
await this.session.appendActiveToolsChange(nextActiveToolNames);
} else {
this.pendingSessionWrites.push({ type: "active_tools_change", activeToolNames: [...nextActiveToolNames] });
}
this.tools = nextTools;
this.activeToolNames = [...nextActiveToolNames];
await this.emitOwn({
type: "tools_update",
toolNames: [...this.tools.keys()],
previousToolNames,
activeToolNames: [...this.activeToolNames],
previousActiveToolNames,
source: "set",
});
} catch (error) {
throw normalizeHarnessError(error, "invalid_argument");
}
}
getActiveTools(): TTool[] {
return this.activeToolNames.map((name) => this.tools.get(name)!);
}
async setActiveTools(toolNames: string[]): Promise<void> {
try {
this.validateToolNames(toolNames);
const previousToolNames = [...this.tools.keys()];
const previousActiveToolNames = [...this.activeToolNames];
if (this.phase === "idle") {
await this.session.appendActiveToolsChange(toolNames);
} else {
this.pendingSessionWrites.push({ type: "active_tools_change", activeToolNames: [...toolNames] });
}
this.activeToolNames = [...toolNames];
await this.emitOwn({
type: "tools_update",
toolNames: [...this.tools.keys()],
previousToolNames,
activeToolNames: [...this.activeToolNames],
previousActiveToolNames,
source: "set",
});
} catch (error) {
throw normalizeHarnessError(error, "invalid_argument");
}
@@ -921,18 +1002,6 @@ export class AgentHarness<
this.streamOptions = cloneStreamOptions(streamOptions);
}
async setTools(tools: TTool[], activeToolNames?: string[]): Promise<void> {
try {
const nextTools = new Map(tools.map((tool) => [tool.name, tool]));
const nextActiveToolNames = activeToolNames ? [...activeToolNames] : this.activeToolNames;
this.validateToolNames(nextActiveToolNames, nextTools);
this.tools = nextTools;
this.activeToolNames = [...nextActiveToolNames];
} catch (error) {
throw normalizeHarnessError(error, "invalid_argument");
}
}
async abort(): Promise<AbortResult> {
const clearedSteer = [...this.steerQueue];
const clearedFollowUp = [...this.followUpQueue];

View File

@@ -1,5 +1,4 @@
import type { Model } from "@earendil-works/pi-ai";
import { completeSimple } from "@earendil-works/pi-ai";
import { completeSimple, type Model } from "@earendil-works/pi-ai/base";
import type { AgentMessage } from "../../types.ts";
import {
convertToLlm,
@@ -112,6 +111,7 @@ function getMessageFromEntry(entry: SessionTreeEntry): AgentMessage | undefined
return createCompactionSummaryMessage(entry.summary, entry.tokensBefore, entry.timestamp);
case "thinking_level_change":
case "model_change":
case "active_tools_change":
case "custom":
case "label":
case "session_info":

View File

@@ -1,5 +1,11 @@
import type { AssistantMessage, ImageContent, Model, TextContent, Usage } from "@earendil-works/pi-ai";
import { completeSimple } 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,
@@ -198,22 +204,33 @@ export function shouldCompact(contextTokens: number, contextWindow: number, sett
return contextTokens > contextWindow - settings.reserveTokens;
}
const ESTIMATED_IMAGE_CHARS = 4800;
function estimateTextAndImageContentChars(content: string | Array<{ type: string; text?: string }>): number {
if (typeof content === "string") {
return content.length;
}
let chars = 0;
for (const block of content) {
if (block.type === "text" && block.text) {
chars += block.text.length;
} else if (block.type === "image") {
chars += ESTIMATED_IMAGE_CHARS;
}
}
return chars;
}
/** Estimate token count for one message using a conservative character heuristic. */
export function estimateTokens(message: AgentMessage): number {
let chars = 0;
switch (message.role) {
case "user": {
const content = (message as { content: string | Array<{ type: string; text?: string }> }).content;
if (typeof content === "string") {
chars = content.length;
} else if (Array.isArray(content)) {
for (const block of content) {
if (block.type === "text" && block.text) {
chars += block.text.length;
}
}
}
chars = estimateTextAndImageContentChars(
(message as { content: string | Array<{ type: string; text?: string }> }).content,
);
return Math.ceil(chars / 4);
}
case "assistant": {
@@ -231,18 +248,7 @@ export function estimateTokens(message: AgentMessage): number {
}
case "custom":
case "toolResult": {
if (typeof message.content === "string") {
chars = message.content.length;
} else {
for (const block of message.content) {
if (block.type === "text" && block.text) {
chars += block.text.length;
}
if (block.type === "image") {
chars += 4800;
}
}
}
chars = estimateTextAndImageContentChars(message.content);
return Math.ceil(chars / 4);
}
case "bashExecution": {
@@ -281,6 +287,7 @@ function findValidCutPoints(entries: SessionTreeEntry[], startIndex: number, end
}
case "thinking_level_change":
case "model_change":
case "active_tools_change":
case "compaction":
case "branch_summary":
case "custom":
@@ -375,7 +382,7 @@ export function findCutPoint(
};
}
export const SUMMARIZATION_SYSTEM_PROMPT = `You are a context summarization assistant. Your task is to read a conversation between a user and an AI coding assistant, then produce a structured summary following the exact format specified.
export const SUMMARIZATION_SYSTEM_PROMPT = `You are a context summarization assistant. Your task is to read a conversation between a user and an AI assistant, then produce a structured summary following the exact format specified.
Do NOT continue the conversation. Do NOT respond to any questions in the conversation. ONLY output the structured summary.`;

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

@@ -144,12 +144,25 @@ async function findBashOnPath(): Promise<string | null> {
return firstMatch && (await pathExists(firstMatch)) ? firstMatch : null;
}
async function getShellConfig(
customShellPath?: string,
): Promise<Result<{ shell: string; args: string[] }, ExecutionError>> {
interface ShellConfig {
shell: string;
args: string[];
commandTransport?: "argv" | "stdin";
}
function isLegacyWslBashPath(path: string): boolean {
const normalized = path.replace(/\//g, "\\").toLowerCase();
return /^[a-z]:\\windows\\(?:system32|sysnative)\\bash\.exe$/.test(normalized);
}
function getBashShellConfig(shell: string): ShellConfig {
return isLegacyWslBashPath(shell) ? { shell, args: ["-s"], commandTransport: "stdin" } : { shell, args: ["-c"] };
}
async function getShellConfig(customShellPath?: string): Promise<Result<ShellConfig, ExecutionError>> {
if (customShellPath) {
if (await pathExists(customShellPath)) {
return ok({ shell: customShellPath, args: ["-c"] });
return ok(getBashShellConfig(customShellPath));
}
return err(new ExecutionError("shell_unavailable", `Custom shell path not found: ${customShellPath}`));
}
@@ -161,22 +174,22 @@ async function getShellConfig(
if (programFilesX86) candidates.push(`${programFilesX86}\\Git\\bin\\bash.exe`);
for (const candidate of candidates) {
if (await pathExists(candidate)) {
return ok({ shell: candidate, args: ["-c"] });
return ok(getBashShellConfig(candidate));
}
}
const bashOnPath = await findBashOnPath();
if (bashOnPath) {
return ok({ shell: bashOnPath, args: ["-c"] });
return ok(getBashShellConfig(bashOnPath));
}
return err(new ExecutionError("shell_unavailable", "No bash shell found"));
}
if (await pathExists("/bin/bash")) {
return ok({ shell: "/bin/bash", args: ["-c"] });
return ok(getBashShellConfig("/bin/bash"));
}
const bashOnPath = await findBashOnPath();
if (bashOnPath) {
return ok({ shell: bashOnPath, args: ["-c"] });
return ok(getBashShellConfig(bashOnPath));
}
return ok({ shell: "sh", args: ["-c"] });
}
@@ -274,13 +287,22 @@ export class NodeExecutionEnv implements ExecutionEnv {
};
try {
child = spawn(shellConfig.value.shell, [...shellConfig.value.args, command], {
cwd,
detached: process.platform !== "win32",
env: getShellEnv(this.shellEnv, options?.env),
stdio: ["ignore", "pipe", "pipe"],
windowsHide: true,
});
const commandFromStdin = shellConfig.value.commandTransport === "stdin";
child = spawn(
shellConfig.value.shell,
commandFromStdin ? shellConfig.value.args : [...shellConfig.value.args, command],
{
cwd,
detached: process.platform !== "win32",
env: getShellEnv(this.shellEnv, options?.env),
stdio: [commandFromStdin ? "pipe" : "ignore", "pipe", "pipe"],
windowsHide: true,
},
);
if (commandFromStdin) {
child.stdin?.on("error", () => {});
child.stdin?.end(command);
}
} catch (error) {
const cause = toError(error);
settle(err(new ExecutionError("spawn_error", cause.message, cause)));

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

@@ -1,7 +1,8 @@
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 {
ActiveToolsChangeEntry,
BranchSummaryEntry,
CompactionEntry,
CustomEntry,
@@ -21,6 +22,7 @@ import { SessionError } from "../types.ts";
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) {
@@ -30,6 +32,8 @@ export function buildSessionContext(pathEntries: SessionTreeEntry[]): SessionCon
model = { provider: entry.provider, modelId: entry.modelId };
} else if (entry.type === "message" && entry.message.role === "assistant") {
model = { provider: entry.message.provider, modelId: entry.message.model };
} else if (entry.type === "active_tools_change") {
activeToolNames = [...entry.activeToolNames];
} else if (entry.type === "compaction") {
compaction = entry;
}
@@ -72,7 +76,7 @@ export function buildSessionContext(pathEntries: SessionTreeEntry[]): SessionCon
}
}
return { messages, thinkingLevel, model };
return { messages, thinkingLevel, model, activeToolNames };
}
export class Session<TMetadata extends SessionMetadata = SessionMetadata> {
@@ -156,6 +160,16 @@ export class Session<TMetadata extends SessionMetadata = SessionMetadata> {
} satisfies ModelChangeEntry);
}
async appendActiveToolsChange(activeToolNames: string[]): Promise<string> {
return this.appendTypedEntry({
type: "active_tools_change",
id: await this.storage.createEntryId(),
parentId: await this.storage.getLeafId(),
timestamp: new Date().toISOString(),
activeToolNames: [...activeToolNames],
} satisfies ActiveToolsChangeEntry);
}
async appendCompaction<T = unknown>(
summary: string,
firstKeptEntryId: string,

View File

@@ -1,5 +1,5 @@
import type { ImageContent, Model, 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. */
@@ -354,6 +354,11 @@ export interface ModelChangeEntry extends SessionTreeEntryBase {
modelId: string;
}
export interface ActiveToolsChangeEntry extends SessionTreeEntryBase {
type: "active_tools_change";
activeToolNames: string[];
}
export interface CompactionEntry<T = unknown> extends SessionTreeEntryBase {
type: "compaction";
summary: string;
@@ -405,6 +410,7 @@ export type SessionTreeEntry =
| MessageEntry
| ThinkingLevelChangeEntry
| ModelChangeEntry
| ActiveToolsChangeEntry
| CompactionEntry
| BranchSummaryEntry
| CustomEntry
@@ -417,6 +423,7 @@ export interface SessionContext {
messages: AgentMessage[];
thinkingLevel: string;
model: { provider: string; modelId: string } | null;
activeToolNames: string[] | null;
}
export interface SessionMetadata {
@@ -593,19 +600,28 @@ export interface SessionTreeEvent {
fromHook?: boolean;
}
export interface ModelSelectEvent {
type: "model_select";
export interface ModelUpdateEvent {
type: "model_update";
model: Model<any>;
previousModel: Model<any> | undefined;
source: "set" | "restore";
}
export interface ThinkingLevelSelectEvent {
type: "thinking_level_select";
export interface ThinkingLevelUpdateEvent {
type: "thinking_level_update";
level: ThinkingLevel;
previousLevel: ThinkingLevel;
}
export interface ToolsUpdateEvent {
type: "tools_update";
toolNames: string[];
previousToolNames: string[];
activeToolNames: string[];
previousActiveToolNames: string[];
source: "set" | "restore";
}
export interface ResourcesUpdateEvent<
TSkill extends Skill = Skill,
TPromptTemplate extends PromptTemplate = PromptTemplate,
@@ -634,9 +650,10 @@ export type AgentHarnessOwnEvent<
| SessionCompactEvent
| SessionBeforeTreeEvent
| SessionTreeEvent
| ModelSelectEvent
| ThinkingLevelSelectEvent
| ResourcesUpdateEvent<TSkill, TPromptTemplate>;
| ModelUpdateEvent
| ThinkingLevelUpdateEvent
| ResourcesUpdateEvent<TSkill, TPromptTemplate>
| ToolsUpdateEvent;
export type AgentHarnessEvent<TSkill extends Skill = Skill, TPromptTemplate extends PromptTemplate = PromptTemplate> =
| AgentEvent
@@ -696,9 +713,10 @@ export type AgentHarnessEventResultMap = {
session_compact: undefined;
session_before_tree: SessionBeforeTreeResult | undefined;
session_tree: undefined;
model_select: undefined;
thinking_level_select: undefined;
model_update: undefined;
thinking_level_update: undefined;
resources_update: undefined;
tools_update: undefined;
queue_update: undefined;
save_point: undefined;
abort: undefined;

View File

@@ -1,44 +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/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";
// 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

@@ -9,7 +9,7 @@ import type {
TextContent,
Tool,
ToolResultMessage,
} from "@earendil-works/pi-ai";
} from "@earendil-works/pi-ai/base";
import type { Static, TSchema } from "typebox";
/**
@@ -354,7 +354,12 @@ export interface AgentToolResult<T> {
terminate?: boolean;
}
/** Callback used by tools to stream partial execution updates. */
/**
* Callback used by tools to stream partial execution updates.
*
* The callback is scoped to the current `execute()` invocation. Calls made after
* the tool promise settles are ignored.
*/
export type AgentToolUpdateCallback<T = any> = (partialResult: AgentToolResult<T>) => void;
/** Tool definition used by the agent runtime. */

View File

@@ -1,6 +1,7 @@
import { type AssistantMessage, type AssistantMessageEvent, EventStream, getModel } from "@earendil-works/pi-ai";
import { Type } from "typebox";
import { describe, expect, it } from "vitest";
import { Agent } from "../src/index.ts";
import { Agent, type AgentEvent, type AgentTool, type AgentToolUpdateCallback } from "../src/index.ts";
// Mock stream that mimics AssistantMessageEventStream
class MockAssistantStream extends EventStream<AssistantMessageEvent, AssistantMessage> {
@@ -36,6 +37,28 @@ function createAssistantMessage(text: string): AssistantMessage {
};
}
type ToolCallContent = Extract<AssistantMessage["content"][number], { type: "toolCall" }>;
function createAssistantToolUseMessage(content: ToolCallContent[]): AssistantMessage {
return {
role: "assistant",
content,
api: "openai-responses",
provider: "openai",
model: "mock",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "toolUse",
timestamp: Date.now(),
};
}
function createDeferred(): {
promise: Promise<void>;
resolve: () => void;
@@ -242,6 +265,147 @@ describe("Agent", () => {
expect(receivedSignal?.aborted).toBe(true);
});
it("should ignore tool updates after the tool execution settles", async () => {
const toolSchema = Type.Object({});
let delayedUpdate: AgentToolUpdateCallback<{ status: string }> | undefined;
const events: AgentEvent[] = [];
const unhandledRejections: unknown[] = [];
const onUnhandledRejection = (error: unknown) => {
unhandledRejections.push(error);
};
const tool: AgentTool<typeof toolSchema, { status: string }> = {
name: "delayed_tool",
label: "Delayed Tool",
description: "Captures progress callbacks",
parameters: toolSchema,
async execute(_toolCallId, _params, _signal, onUpdate) {
delayedUpdate = onUpdate;
onUpdate?.({
content: [{ type: "text", text: "running" }],
details: { status: "running" },
});
return {
content: [{ type: "text", text: "ok" }],
details: { status: "done" },
terminate: true,
};
},
};
const agent = new Agent({
initialState: { tools: [tool] },
streamFn: () => {
const stream = new MockAssistantStream();
queueMicrotask(() => {
stream.push({
type: "done",
reason: "toolUse",
message: createAssistantToolUseMessage([
{ type: "toolCall", id: "call-1", name: "delayed_tool", arguments: {} },
]),
});
});
return stream;
},
});
agent.subscribe((event) => {
events.push(event);
});
process.on("unhandledRejection", onUnhandledRejection);
try {
await agent.prompt("run tool");
const eventCountAfterPrompt = events.length;
delayedUpdate?.({
content: [{ type: "text", text: "late" }],
details: { status: "late" },
});
await new Promise((resolve) => setTimeout(resolve, 0));
expect(events.filter((event) => event.type === "tool_execution_update")).toHaveLength(1);
expect(events).toHaveLength(eventCountAfterPrompt);
expect(unhandledRejections).toEqual([]);
} finally {
process.off("unhandledRejection", onUnhandledRejection);
}
});
it("should ignore a settled parallel tool update while another tool is still running", async () => {
const toolSchema = Type.Object({});
const slowStarted = createDeferred();
const settledToolEnded = createDeferred();
const releaseSlow = createDeferred();
let settledToolUpdate: AgentToolUpdateCallback<{ status: string }> | undefined;
const events: AgentEvent[] = [];
const settledTool: AgentTool<typeof toolSchema, { status: string }> = {
name: "settled_tool",
label: "Settled Tool",
description: "Captures progress callbacks",
parameters: toolSchema,
async execute(_toolCallId, _params, _signal, onUpdate) {
settledToolUpdate = onUpdate;
return {
content: [{ type: "text", text: "done" }],
details: { status: "done" },
terminate: true,
};
},
};
const slowTool: AgentTool<typeof toolSchema, { status: string }> = {
name: "slow_tool",
label: "Slow Tool",
description: "Keeps the agent run active",
parameters: toolSchema,
async execute() {
slowStarted.resolve();
await releaseSlow.promise;
return {
content: [{ type: "text", text: "done" }],
details: { status: "done" },
terminate: true,
};
},
};
const agent = new Agent({
initialState: { tools: [settledTool, slowTool] },
streamFn: () => {
const stream = new MockAssistantStream();
queueMicrotask(() => {
stream.push({
type: "done",
reason: "toolUse",
message: createAssistantToolUseMessage([
{ type: "toolCall", id: "call-1", name: "settled_tool", arguments: {} },
{ type: "toolCall", id: "call-2", name: "slow_tool", arguments: {} },
]),
});
});
return stream;
},
});
agent.subscribe((event) => {
events.push(event);
if (event.type === "tool_execution_end" && event.toolCallId === "call-1") {
settledToolEnded.resolve();
}
});
const promptPromise = agent.prompt("run tools");
await Promise.all([slowStarted.promise, settledToolEnded.promise]);
const eventCountBeforeLateUpdate = events.length;
settledToolUpdate?.({
content: [{ type: "text", text: "late" }],
details: { status: "late" },
});
await new Promise((resolve) => setTimeout(resolve, 0));
expect(events).toHaveLength(eventCountBeforeLateUpdate);
releaseSlow.resolve();
await promptPromise;
expect(events.filter((event) => event.type === "tool_execution_update")).toHaveLength(0);
});
it("should update state with mutators", () => {
const agent = new Agent();

View File

@@ -17,10 +17,6 @@ interface AppPromptTemplate extends PromptTemplate {
source: "project" | "user";
}
interface AppTool extends AgentTool {
source: "builtin" | "extension";
}
const registrations: Array<{ unregister(): void }> = [];
function textFromUserMessages(messages: Array<{ role: string; content: unknown }>): string[] {
@@ -458,11 +454,111 @@ describe("AgentHarness", () => {
});
});
it("preserves app tool types for getters and update events", async () => {
const session = new Session(new InMemorySessionStorage());
const env = new NodeExecutionEnv({ cwd: process.cwd() });
const model = getModel("anthropic", "claude-sonnet-4-5");
type AppTool = AgentTool<typeof calculateTool.parameters, undefined> & { source: "builtin" | "extension" };
const inspectTool: AppTool = { ...calculateTool, name: "inspect", source: "builtin" };
const searchTool: AppTool = { ...calculateTool, name: "search", source: "extension" };
const harness = new AgentHarness<AppSkill, AppPromptTemplate, AppTool>({
env,
session,
model,
tools: [inspectTool, searchTool],
activeToolNames: ["inspect"],
});
const updates: Array<{
toolNames: string[];
previousToolNames: string[];
activeToolNames: string[];
previousActiveToolNames: string[];
source: "set" | "restore";
}> = [];
harness.subscribe((event) => {
if (event.type === "tools_update") {
updates.push({
toolNames: event.toolNames,
previousToolNames: event.previousToolNames,
activeToolNames: event.activeToolNames,
previousActiveToolNames: event.previousActiveToolNames,
source: event.source,
});
expect(harness.getActiveTools().map((tool) => tool.name)).toEqual(event.activeToolNames);
}
});
const tools = harness.getTools();
const activeTools = harness.getActiveTools();
tools.pop();
activeTools.pop();
expect(harness.getTools().map((tool) => tool.name)).toEqual(["inspect", "search"]);
expect(harness.getActiveTools().map((tool) => tool.source)).toEqual(["builtin"]);
await harness.setActiveTools(["search"]);
await harness.setTools([searchTool], ["search"]);
await expect(harness.setActiveTools(["missing"])).rejects.toMatchObject({ code: "invalid_argument" });
await expect(harness.setActiveTools(["search", "search"])).rejects.toMatchObject({ code: "invalid_argument" });
await expect(harness.setTools([inspectTool])).rejects.toMatchObject({ code: "invalid_argument" });
await expect(harness.setTools([inspectTool, inspectTool], ["inspect"])).rejects.toMatchObject({
code: "invalid_argument",
});
expect(updates).toEqual([
{
toolNames: ["inspect", "search"],
previousToolNames: ["inspect", "search"],
activeToolNames: ["search"],
previousActiveToolNames: ["inspect"],
source: "set",
},
{
toolNames: ["search"],
previousToolNames: ["inspect", "search"],
activeToolNames: ["search"],
previousActiveToolNames: ["search"],
source: "set",
},
]);
expect(harness.getTools().map((tool) => tool.source)).toEqual(["extension"]);
expect(harness.getActiveTools().map((tool) => tool.name)).toEqual(["search"]);
expect((await session.buildContext()).activeToolNames).toEqual(["search"]);
});
it("validates constructor tool names", () => {
const session = new Session(new InMemorySessionStorage());
const env = new NodeExecutionEnv({ cwd: process.cwd() });
const model = getModel("anthropic", "claude-sonnet-4-5");
expect(
() => new AgentHarness({ env, session, model, tools: [calculateTool], activeToolNames: ["missing"] }),
).toThrow(/Unknown tool/);
expect(
() =>
new AgentHarness({
env,
session,
model,
tools: [calculateTool, calculateTool],
activeToolNames: [calculateTool.name],
}),
).toThrow(/Duplicate tool/);
expect(
() =>
new AgentHarness({
env,
session,
model,
tools: [calculateTool],
activeToolNames: [calculateTool.name, calculateTool.name],
}),
).toThrow(/Duplicate active tool/);
});
it("preserves app resource types for getters and update events", async () => {
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, AppTool>({ env, session, model });
const harness = new AgentHarness<AppSkill, AppPromptTemplate, AgentTool>({ env, session, model });
const skill: AppSkill = {
name: "inspect",
description: "Inspect things",

View File

@@ -1,5 +1,5 @@
import { access, chmod, realpath, symlink } from "node:fs/promises";
import { join } from "node:path";
import { delimiter, join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { NodeExecutionEnv } from "../../src/harness/env/nodejs.ts";
import { FileError, getOrThrow } from "../../src/harness/types.ts";
@@ -201,6 +201,39 @@ describe("NodeExecutionEnv", () => {
expect(result).toEqual({ stdout: `${await realpath(root)}:ok`, stderr: "", exitCode: 0 });
});
it("uses stdin command transport for legacy WSL bash paths", async () => {
if (process.platform === "win32") return;
const root = createTempDir();
const shellPath = "C:\\Windows\\System32\\bash.exe";
const env = new NodeExecutionEnv({ cwd: root });
getOrThrow(await env.writeFile(shellPath, '#!/bin/sh\nprintf \'args:%s\\n\' "$*" >&2\nexec /bin/bash "$@"\n'));
await chmod(join(root, shellPath), 0o755);
const originalCwd = process.cwd();
const originalPath = process.env.PATH;
const platformDescriptor = Object.getOwnPropertyDescriptor(process, "platform");
try {
process.chdir(root);
process.env.PATH = `${root}${delimiter}${originalPath ?? ""}`;
Object.defineProperty(process, "platform", {
configurable: true,
value: "win32",
});
const wslEnv = new NodeExecutionEnv({ cwd: root, shellPath });
const nameExpansion = "$" + "{name}";
const result = getOrThrow(await wslEnv.exec(`name='World'; echo "Hello, ${nameExpansion}!"`));
expect(result).toEqual({ stdout: "Hello, World!\n", stderr: "args:-s\n", exitCode: 0 });
} finally {
process.chdir(originalCwd);
process.env.PATH = originalPath;
if (platformDescriptor) {
Object.defineProperty(process, "platform", platformDescriptor);
}
}
});
it("streams stdout and stderr chunks", async () => {
const root = createTempDir();
const env = new NodeExecutionEnv({ cwd: root });

View File

@@ -2,6 +2,10 @@
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"paths": {
"@earendil-works/pi-ai": ["../ai/dist/index.d.ts"],
"@earendil-works/pi-ai/*": ["../ai/dist/*.d.ts", "../ai/dist/providers/*.d.ts"]
},
"rootDir": "./src"
},
"include": ["src/**/*.ts"],

View File

@@ -1,6 +1,12 @@
import { defineConfig } from "vitest/config";
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",

View File

@@ -1,6 +1,12 @@
import { defineConfig } from "vitest/config";
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",

View File

@@ -2,8 +2,174 @@
## [Unreleased]
## [0.79.9] - 2026-06-20
### Added
- Added configurable `chat-template` thinking support for OpenAI-compatible providers that use `chat_template_kwargs`, such as DeepSeek models behind vLLM ([#5673](https://github.com/earendil-works/pi/issues/5673)).
### Fixed
- Fixed Fireworks GLM-5.2 metadata to use the OpenAI-compatible Chat Completions endpoint with `reasoning_effort` support ([#5923](https://github.com/earendil-works/pi/issues/5923)).
- Fixed OpenRouter GLM-5.2 metadata to expose `xhigh` reasoning and send OpenRouter's native `xhigh` effort ([#5770](https://github.com/earendil-works/pi/issues/5770)).
- Fixed GitHub Copilot OAuth model availability to use the authenticated account's model picker catalog ([#5897](https://github.com/earendil-works/pi/issues/5897)).
## [0.79.8] - 2026-06-19
### Added
- Added `@earendil-works/pi-ai/base` and direct provider registration exports for bundlers that want selective provider transports without root built-in registration ([#5348](https://github.com/earendil-works/pi/pull/5348) by [@FredKSchott](https://github.com/FredKSchott)).
- Added prompt caching for Mistral requests using the pi session ID as `prompt_cache_key`, including cached-token usage and cost accounting ([#5854](https://github.com/earendil-works/pi/issues/5854)).
- Added the OpenRouter Fusion alias as `openrouter/fusion` ([#5866](https://github.com/earendil-works/pi/pull/5866) by [@dannote](https://github.com/dannote)).
## [0.79.7] - 2026-06-18
### Added
- Added GLM-5.2 model to the OpenCode Go subscription model catalog ([#5860](https://github.com/earendil-works/pi/issues/5860)).
## [0.79.6] - 2026-06-16
### Fixed
- Fixed OpenCode Go DeepSeek V4 thinking-off requests to send the provider's `thinking: { type: "disabled" }` compatibility parameter.
## [0.79.5] - 2026-06-16
### Added
- Added provider-scoped `StreamOptions.env` overrides for provider configuration, including Cloudflare endpoint placeholders, Azure OpenAI, Google Vertex, Amazon Bedrock, cache retention, and proxy environment lookups ([#5728](https://github.com/earendil-works/pi/issues/5728)).
### Fixed
- Fixed OpenAI Responses streaming to tolerate null message content from OpenAI-compatible servers before tool calls ([#5819](https://github.com/earendil-works/pi/issues/5819)).
- Fixed OpenCode DeepSeek V4 thinking requests to avoid sending both `thinking` and `reasoning_effort` ([#5818](https://github.com/earendil-works/pi/issues/5818)).
- Fixed Z.AI GLM-5.2 thinking requests to send `reasoning_effort` with the provider's `high`/`max` effort mapping ([#5770](https://github.com/earendil-works/pi/issues/5770)).
- Fixed Google and `google-vertex` Gemini model metadata to map `latest` aliases to the current models, add Gemini 3.5 Flash for Vertex, correct Gemini 2.5 Flash Vertex cache pricing, and remove shut-down Vertex preview models ([#5761](https://github.com/earendil-works/pi/issues/5761)).
- Fixed Moonshot AI China model metadata to include Kimi K2.7 Code, and omitted unsupported thinking-off payloads for Kimi K2.7 Code models ([#5760](https://github.com/earendil-works/pi/issues/5760)).
## [0.79.4] - 2026-06-15
### Fixed
- Fixed Anthropic 1-hour prompt-cache write cost accounting to price 1-hour cache writes at 2x input instead of the 5-minute cache-write rate ([#5738](https://github.com/earendil-works/pi/pull/5738) by [@theBucky](https://github.com/theBucky)).
- Fixed GitHub Copilot Claude adaptive-thinking effort metadata to match manually checked Copilot model capabilities ([#4637](https://github.com/earendil-works/pi/issues/4637)).
- Fixed OpenCode/OpenCode Go completion models that reject `prompt_cache_retention` to omit long-retention cache fields when `cacheRetention` is `long` ([#5702](https://github.com/earendil-works/pi/issues/5702)).
## [0.79.3] - 2026-06-13
### Fixed
- Restored OpenAI GPT-5.4/GPT-5.5 and OpenAI Codex GPT-5.4/GPT-5.4 mini/GPT-5.5 context window metadata to the observed 272k-token Codex backend limit, avoiding a billing hazard from sending prompts above Codex's accepted limit (reported by [@trethore](https://github.com/trethore)).
## [0.79.2] - 2026-06-12
### Added
- Added AWS data retention documentation links to Amazon Bedrock unsupported data retention mode validation errors ([#5561](https://github.com/earendil-works/pi/pull/5561) by [@unexge](https://github.com/unexge)).
### Fixed
- Fixed OpenAI-compatible context overflow detection for parenthesized `maximum context length (N)` errors ([#5677](https://github.com/earendil-works/pi/issues/5677)).
- Fixed OpenAI GPT-5.4/GPT-5.5 and OpenAI Codex GPT-5.4/GPT-5.4 mini/GPT-5.5 context window metadata to match current OpenAI limits ([#5644](https://github.com/earendil-works/pi/issues/5644)).
- Increased the OpenAI Codex Responses SSE response-header timeout to 20 seconds to reduce false-positive stalls while retaining the bounded wait introduced for zero-event hangs ([#4945](https://github.com/earendil-works/pi/issues/4945)).
- Fixed Anthropic refusal stops to preserve provider `stop_details` explanations in error messages ([#5666](https://github.com/earendil-works/pi/pull/5666) by [@rwachtler](https://github.com/rwachtler)).
- Fixed Claude Fable 5 thinking-off requests to omit Anthropic's unsupported `thinking.type: "disabled"` payload ([#5567](https://github.com/earendil-works/pi/pull/5567) by [@tmustier](https://github.com/tmustier)).
## [0.79.1] - 2026-06-09
### Added
- Added Claude Fable 5 to Anthropic and Amazon Bedrock model metadata, with adaptive thinking and `xhigh` effort support.
### Fixed
- Fixed Amazon Bedrock inference profile ARN region resolution to prefer the ARN's embedded region over `AWS_REGION` ([#5527](https://github.com/earendil-works/pi/pull/5527) by [@AJM10565](https://github.com/AJM10565)).
- Fixed z.ai thinking-off requests to send the provider's `thinking: { type: "disabled" }` compatibility parameter ([#5330](https://github.com/earendil-works/pi/issues/5330)).
- Fixed OpenCode completions model metadata to send explicit `maxTokens` as `max_tokens` ([#5331](https://github.com/earendil-works/pi/issues/5331)).
- Fixed Moonshot Kimi thinking-off requests to send the provider's `thinking: { type: "disabled" }` compatibility parameter ([#5531](https://github.com/earendil-works/pi/issues/5531)).
- Fixed Azure OpenAI Responses requests to disable server-side response storage ([#5530](https://github.com/earendil-works/pi/issues/5530)).
- Fixed Azure GPT-5.4 and GPT-5.5 context window metadata to 1,050,000 tokens, matching Azure Foundry deployments instead of OpenAI's 272k limit ([#5559](https://github.com/earendil-works/pi/issues/5559)).
- Fixed OpenAI and Azure GPT-5 Pro `maxTokens` metadata to 128,000, correcting an upstream value that duplicated the 272,000 input sub-limit as the output limit ([#5559](https://github.com/earendil-works/pi/issues/5559)).
## [0.79.0] - 2026-06-08
### Fixed
- Fixed OpenAI Responses custom providers to honor `compat.supportsDeveloperRole: false` for reasoning models ([#5456](https://github.com/earendil-works/pi/issues/5456)).
- Fixed OpenRouter routing preferences on OpenAI-compatible custom providers to send `compat.openRouterRouting` even when `baseUrl` does not point directly at OpenRouter ([#5347](https://github.com/earendil-works/pi/issues/5347)).
## [0.78.1] - 2026-06-04
### Added
- Added Ant Ling as a built-in OpenAI-compatible provider with Ling 2.6 and Ring 2.6 models.
- Added MiniMax-M3 model to the `minimax` and `minimax-cn` direct providers, and removed the hardcoded context-window override that was masking models.dev values ([#5313](https://github.com/earendil-works/pi/issues/5313)).
- Added NVIDIA NIM as a built-in OpenAI-compatible provider, exposing public NIM models that support tool use.
### Fixed
- Fixed Amazon Bedrock requests to replace blank required user/tool-result text with a placeholder and skip blank replay text blocks ([#4975](https://github.com/earendil-works/pi/issues/4975)).
- Fixed Anthropic Claude Opus 4.7+ requests to suppress deprecated temperature parameters ([#5251](https://github.com/earendil-works/pi/pull/5251) by [@yzhg1983](https://github.com/yzhg1983)).
- Fixed OpenAI GPT-5.5 generated metadata to omit unsupported minimal thinking ([#5243](https://github.com/earendil-works/pi/issues/5243)).
- Fixed OpenRouter Kimi K2.6 thinking replay and preserved developer-role instructions for OpenRouter OpenAI and Anthropic models ([#5309](https://github.com/earendil-works/pi/issues/5309)).
- Fixed OpenRouter reasoning instruction requests to preserve the system role when required ([#5221](https://github.com/earendil-works/pi/pull/5221) by [@PriNova](https://github.com/PriNova)).
- Restored the NVIDIA Qwen 3.5 122B NIM model.
## [0.78.0] - 2026-05-29
### Breaking Changes
- Changed direct provider stream functions to require explicit `options.apiKey`; top-level `stream*`/`complete*` helpers still resolve built-in environment auth.
### Added
- Added custom Amazon Bedrock request header support via `StreamOptions.headers`, excluding reserved AWS signing headers ([#5178](https://github.com/earendil-works/pi-mono/pull/5178) by [@stephanmck](https://github.com/stephanmck)).
### Fixed
- Fixed OpenRouter Moonshot Kimi K2.6 requests to use `system` instead of unsupported `developer` messages ([#5159](https://github.com/earendil-works/pi-mono/issues/5159)).
- Fixed OpenCode Go Kimi K2.6 thinking requests to send `thinking` objects instead of invalid string values, and fixed OpenCode Zen Grok Build thinking requests to omit unsupported `reasoning_effort` ([#5169](https://github.com/earendil-works/pi-mono/issues/5169)).
- Fixed OpenAI Codex Responses SSE streams to abort response body reads after terminal events.
- Fixed OpenCode Kimi K2.6 generated metadata to use Anthropic-style thinking metadata instead of invalid reasoning-effort parameters.
## [0.77.0] - 2026-05-28
### Added
- Added OpenAI Codex subscription device-code login as a selectable headless alternative while keeping browser login as the default ([#4911](https://github.com/earendil-works/pi/pull/4911) by [@vegarsti](https://github.com/vegarsti)).
- Added Claude Opus 4.8 model metadata for Anthropic and updated Opus adaptive-thinking coverage to use it.
### Fixed
- Fixed OpenRouter DeepSeek V4 `xhigh` reasoning metadata to preserve OpenRouter's native effort instead of sending DeepSeek's `max` effort ([#4801](https://github.com/earendil-works/pi/issues/4801)).
- Fixed OpenAI Codex Responses replay after switching from Anthropic extended-thinking sessions by generating unique fallback message item IDs for converted thinking/text blocks ([#5148](https://github.com/earendil-works/pi/issues/5148)).
- Fixed Anthropic-compatible replay for providers that return empty thinking signatures by adding an opt-in `allowEmptySignature` compatibility flag ([#4464](https://github.com/earendil-works/pi/issues/4464)).
- Fixed OpenAI and OpenRouter GPT-5.5 Pro thinking level metadata to expose only supported medium, high, and xhigh efforts.
- Fixed OpenCode Go Kimi K2.6 thinking-off requests to send `thinking: "none"` ([#5078](https://github.com/earendil-works/pi/issues/5078)).
- Fixed Xiaomi Token Plan model metadata to omit unsupported `mimo-v2-flash` variants ([#5075](https://github.com/earendil-works/pi/issues/5075)).
## [0.76.0] - 2026-05-27
### Fixed
- Fixed OpenAI Codex Responses cache-affinity headers to send `session-id` instead of proxy-incompatible `session_id` ([#4967](https://github.com/earendil-works/pi/issues/4967)).
- Fixed `openai-codex/gpt-5.3-codex-spark` generated metadata to use its 128k context window ([#4969](https://github.com/earendil-works/pi/issues/4969)).
- Fixed OpenRouter/Poolside context overflow detection for `maximum allowed input length` errors ([#4943](https://github.com/earendil-works/pi/issues/4943)).
- Fixed OpenAI Codex Responses WebSocket streams and SSE response-header waits to apply bounded timeouts instead of waiting indefinitely when no events arrive ([#4945](https://github.com/earendil-works/pi/issues/4945)).
- Fixed provider retry controls so OpenAI Codex Responses honors `maxRetries`, SDK retries default to `0`, and quota/billing 429s are not retried behind Pi's retry handling ([#4991](https://github.com/earendil-works/pi-mono/pull/4991) by [@mitsuhiko](https://github.com/mitsuhiko)).
## [0.75.5] - 2026-05-23
### Breaking Changes
- Changed `OAuthLoginCallbacks` to require `onDeviceCode` and `onSelect`, so OAuth providers can rely on pi supplying device-code and selection UI callbacks ([#4788](https://github.com/earendil-works/pi-mono/pull/4788) by [@vegarsti](https://github.com/vegarsti)).
### Fixed
- Fixed custom Anthropic-compatible model aliases for adaptive-thinking Claude models by adding `compat.forceAdaptiveThinking` model metadata and moving built-in adaptive-thinking selection out of provider id substring checks ([#4797](https://github.com/earendil-works/pi-mono/pull/4797) by [@mbazso](https://github.com/mbazso)).
- Fixed GitHub Copilot OAuth login to rely on the required device-code callback without a runtime callback availability guard ([#4788](https://github.com/earendil-works/pi-mono/pull/4788) by [@vegarsti](https://github.com/vegarsti)).
- Fixed Amazon Bedrock provider loading under strict package managers by declaring its direct `@smithy/node-http-handler` dependency ([#4842](https://github.com/earendil-works/pi/issues/4842)).
- Fixed Amazon Bedrock Claude requests to send the model output token cap by default, matching Anthropic requests and avoiding Bedrock's 4096-token default truncation ([#4848](https://github.com/earendil-works/pi/issues/4848)).
## [0.75.4] - 2026-05-20
@@ -13,6 +179,10 @@
- Changed source syntax to avoid TypeScript constructs that require JavaScript emit, keeping the package compatible with Node.js strip-only TypeScript checks.
- Removed the package-level development watch scripts now that the root TypeScript check validates strip-only-compatible sources.
### Added
- Added first-class OAuth device-code callback metadata, shared polling support, and GitHub Copilot OAuth integration.
### Fixed
- Fixed OpenAI-compatible `streamSimple()` requests to stop sending model-derived default output token caps, avoiding context-window reservation failures on servers such as vLLM while preserving explicit `maxTokens` and required Anthropic `max_tokens` handling ([#4675](https://github.com/earendil-works/pi/issues/4675)).

View File

@@ -8,6 +8,7 @@ Unified LLM API with automatic model discovery, provider configuration, token an
- [Supported Providers](#supported-providers)
- [Installation](#installation)
- [Base Entry Point](#base-entry-point)
- [Quick Start](#quick-start)
- [Tools](#tools)
- [Defining Tools](#defining-tools)
@@ -38,6 +39,7 @@ Unified LLM API with automatic model discovery, provider configuration, token an
- [Browser Usage](#browser-usage)
- [Browser Compatibility Notes](#browser-compatibility-notes)
- [Environment Variables](#environment-variables-nodejs-only)
- [Provider-Scoped Environment Overrides](#provider-scoped-environment-overrides)
- [Checking Environment Variables](#checking-environment-variables)
- [OAuth Providers](#oauth-providers)
- [Vertex AI](#vertex-ai)
@@ -51,9 +53,11 @@ Unified LLM API with automatic model discovery, provider configuration, token an
## Supported Providers
- **OpenAI**
- **Ant Ling**
- **Azure OpenAI (Responses)**
- **OpenAI Codex** (ChatGPT Plus/Pro subscription, requires OAuth, see below)
- **DeepSeek**
- **NVIDIA NIM**
- **Anthropic**
- **Google**
- **Vertex AI** (Gemini via Vertex AI)
@@ -65,6 +69,7 @@ Unified LLM API with automatic model discovery, provider configuration, token an
- **xAI**
- **OpenRouter**
- **Vercel AI Gateway**
- **ZAI** (with separate Coding Plan China provider)
- **MiniMax**
- **Together AI**
- **GitHub Copilot** (requires OAuth, see below)
@@ -84,6 +89,30 @@ npm install @earendil-works/pi-ai
TypeBox exports are re-exported from `@earendil-works/pi-ai`: `Type`, `Static`, and `TSchema`.
## Base Entry Point
Use `@earendil-works/pi-ai/base` when a bundler should include only explicitly selected transport implementations. The base entry point exposes model discovery, registries, generic dispatch, environment-key helpers, and provider-neutral utilities without registering built-in transports during module initialization. Generic dispatch still resolves configured environment keys when called.
Import each selected transport directly and call its `register()` function:
```typescript
import { getModel, streamSimple } from '@earendil-works/pi-ai/base';
import { register as registerAnthropic } from '@earendil-works/pi-ai/anthropic';
registerAnthropic();
const model = getModel('anthropic', 'claude-sonnet-4-6');
const response = await streamSimple(model, {
messages: [{ role: 'user', content: 'Hello!' }]
}, {
apiKey: process.env.ANTHROPIC_API_KEY
}).result();
```
Direct transport imports bundle their implementation and SDK code. Register only the transports used by the application. For example, OpenRouter, Groq, xAI, and DeepSeek chat models share the `@earendil-works/pi-ai/openai-completions` transport.
Use `@earendil-works/pi-ai` for the batteries-included behavior. The root entry point remains backward-compatible: it registers lazy wrappers for all built-in transports and resolves environment API keys automatically during dispatch.
## Quick Start
```typescript
@@ -434,7 +463,7 @@ Do not use `stream()` or `complete()` for image generation. Image generation is
### Basic Image Generation
```typescript
import { getImageModel, generateImages } from '@mariozechner/pi-ai';
import { getImageModel, generateImages } from '@earendil-works/pi-ai';
const model = getImageModel('openrouter', 'google/gemini-2.5-flash-image');
@@ -801,7 +830,7 @@ A **provider** offers models through a specific API. For example:
- **Google** models use the `google-generative-ai` API
- **OpenAI** models use the `openai-responses` API
- **Mistral** models use the `mistral-conversations` API
- **xAI, Cerebras, Groq, Together AI, etc.** models use the `openai-completions` API (OpenAI-compatible)
- **xAI, Cerebras, Groq, NVIDIA NIM, Together AI, etc.** models use the `openai-completions` API (OpenAI-compatible)
### Querying Providers and Models
@@ -923,7 +952,7 @@ const ollamaReasoningModel: Model<'openai-completions'> = {
### OpenAI Compatibility Settings
The `openai-completions` API is implemented by many providers with minor differences. By default, the library auto-detects compatibility settings based on `baseUrl` for a small set of known OpenAI-compatible providers (Cerebras, xAI, Chutes, DeepSeek, Together AI, zAi, OpenCode, Cloudflare Workers AI, etc.). For custom proxies or unknown endpoints, you can override these settings via the `compat` field. For `openai-responses` models, the compat field only supports Responses-specific flags.
The `openai-completions` API is implemented by many providers with minor differences. By default, the library auto-detects compatibility settings based on `baseUrl` for a small set of known OpenAI-compatible providers (Cerebras, xAI, Chutes, DeepSeek, NVIDIA NIM, Together AI, zAi, OpenCode, Cloudflare Workers AI, etc.). For custom proxies or unknown endpoints, you can override these settings via the `compat` field. For `openai-responses` models, the compat field supports Responses-specific flags.
```typescript
interface OpenAICompletionsCompat {
@@ -938,14 +967,17 @@ interface OpenAICompletionsCompat {
requiresAssistantAfterToolResult?: boolean; // Whether tool results must be followed by an assistant message (default: false)
requiresThinkingAsText?: boolean; // Whether thinking blocks must be converted to text (default: false)
requiresReasoningContentOnAssistantMessages?: boolean; // Whether all replayed assistant messages must include empty reasoning_content when reasoning is enabled (default: auto-detected for DeepSeek)
thinkingFormat?: 'openai' | 'openrouter' | 'deepseek' | 'together' | 'zai' | 'qwen' | 'qwen-chat-template'; // Format for reasoning param: 'openai' uses reasoning_effort, 'openrouter' uses reasoning: { effort }, 'deepseek' uses thinking: { type } plus reasoning_effort, 'together' uses reasoning: { enabled } plus reasoning_effort when supported, 'zai' uses enable_thinking, 'qwen' uses enable_thinking, 'qwen-chat-template' uses chat_template_kwargs.enable_thinking (default: openai)
thinkingFormat?: 'openai' | 'openrouter' | 'deepseek' | 'together' | 'zai' | 'qwen' | 'chat-template' | 'qwen-chat-template' | 'string-thinking' | 'ant-ling'; // Format for reasoning param: 'openai' uses reasoning_effort, 'openrouter' uses reasoning: { effort }, 'deepseek' uses thinking: { type } plus reasoning_effort when supported, 'together' uses reasoning: { enabled } plus reasoning_effort when supported, 'zai' uses thinking: { type }, 'qwen' uses enable_thinking, 'chat-template' uses configurable chat_template_kwargs, 'qwen-chat-template' uses chat_template_kwargs.enable_thinking and preserve_thinking, 'string-thinking' uses top-level thinking, 'ant-ling' uses reasoning: { effort } only for mapped efforts (default: openai)
chatTemplateKwargs?: Record<string, string | number | boolean | null | { '$var': 'thinking.enabled' | 'thinking.effort'; omitWhenOff?: boolean }>; // chat_template_kwargs values; use $var for pi-controlled thinking values
cacheControlFormat?: 'anthropic'; // Anthropic-style cache_control on system prompt, last tool, and last user/assistant text content
openRouterRouting?: OpenRouterRouting; // OpenRouter routing preferences (default: {})
vercelGatewayRouting?: VercelGatewayRouting; // Vercel AI Gateway routing preferences (default: {})
}
interface OpenAIResponsesCompat {
// Reserved for future use
supportsDeveloperRole?: boolean; // Whether provider supports `developer` role vs `system` (default: true)
sendSessionIdHeader?: boolean; // Whether to send `session_id` from `sessionId` when caching is enabled (default: true)
supportsLongCacheRetention?: boolean; // Whether provider supports `prompt_cache_retention: "24h"` (default: true)
}
```
@@ -1091,6 +1123,7 @@ const response = await complete(model, {
- OAuth login flows are not supported in browser environments. Use the `@earendil-works/pi-ai/oauth` entry point in Node.js.
- In browser builds, Bedrock can still appear in model lists. Calls to Bedrock models fail at runtime.
- Use a server-side proxy or backend service if you need Bedrock or OAuth-based auth from a web app.
- Use `@earendil-works/pi-ai/base` plus explicit direct transport registration when a browser bundle should exclude unused provider SDK implementations.
### Environment Variables (Node.js only)
@@ -1099,9 +1132,11 @@ In Node.js environments, you can set environment variables to avoid passing API
| Provider | Environment Variable(s) |
|----------|------------------------|
| OpenAI | `OPENAI_API_KEY` |
| Ant Ling | `ANT_LING_API_KEY` |
| Azure OpenAI | `AZURE_OPENAI_API_KEY` + `AZURE_OPENAI_BASE_URL` (e.g. `https://{resource}.openai.azure.com`) or `AZURE_OPENAI_RESOURCE_NAME`. Supports `*.openai.azure.com` and `*.cognitiveservices.azure.com`; root endpoints auto-normalize to `/openai/v1`. Optional: `AZURE_OPENAI_API_VERSION` (default `v1`), `AZURE_OPENAI_DEPLOYMENT_NAME_MAP`. |
| Anthropic | `ANTHROPIC_API_KEY` or `ANTHROPIC_OAUTH_TOKEN` |
| DeepSeek | `DEEPSEEK_API_KEY` |
| NVIDIA NIM | `NVIDIA_API_KEY` |
| Google | `GEMINI_API_KEY` |
| Vertex AI | `GOOGLE_CLOUD_API_KEY` or `GOOGLE_CLOUD_PROJECT` (or `GCLOUD_PROJECT`) + `GOOGLE_CLOUD_LOCATION` + ADC |
| Mistral | `MISTRAL_API_KEY` |
@@ -1115,6 +1150,7 @@ In Node.js environments, you can set environment variables to avoid passing API
| OpenRouter | `OPENROUTER_API_KEY` |
| Vercel AI Gateway | `AI_GATEWAY_API_KEY` |
| zAI | `ZAI_API_KEY` |
| ZAI Coding Plan (China) | `ZAI_CODING_CN_API_KEY` |
| MiniMax | `MINIMAX_API_KEY` |
| OpenCode Zen / OpenCode Go | `OPENCODE_API_KEY` |
| Kimi For Coding | `KIMI_API_KEY` |
@@ -1137,6 +1173,24 @@ const response = await complete(model, context, {
});
```
### Provider-Scoped Environment Overrides
Pass `env` in stream options to scope provider configuration to a request. Values in `env` are used before process environment variables for API key discovery and provider configuration such as Cloudflare account IDs, Azure OpenAI settings, Vertex project/location, Bedrock settings, `PI_CACHE_RETENTION`, and `HTTP_PROXY`/`HTTPS_PROXY`.
```typescript
const model = getModel('cloudflare-ai-gateway', 'workers-ai/@cf/moonshotai/kimi-k2.6');
const response = await complete(model, context, {
env: {
CLOUDFLARE_API_KEY: '...',
CLOUDFLARE_ACCOUNT_ID: 'account-id',
CLOUDFLARE_GATEWAY_ID: 'gateway-id'
}
});
```
Use this when one process needs different provider settings per request, or when ambient environment variables should not leak into a provider call.
### Checking Environment Variables
```typescript
@@ -1306,6 +1360,7 @@ Create a new provider file (for example `amazon-bedrock.ts`) that exports:
- `stream<Provider>()` function returning `AssistantMessageEventStream`
- `streamSimple<Provider>()` for `SimpleStreamOptions` mapping
- `register()` for explicit direct transport registration from `@earendil-works/pi-ai/base`
- Provider-specific options interface
- Message conversion functions to transform `Context` to provider format
- Tool conversion if the provider supports tools
@@ -1316,7 +1371,8 @@ Create a new provider file (for example `amazon-bedrock.ts`) that exports:
- Register the API with `registerApiProvider()`
- Add a package subpath export in `package.json` for the provider module (`./dist/providers/<provider>.js`)
- Add lazy loader wrappers in `src/providers/register-builtins.ts`, do not statically import provider implementation modules there
- Add any root-level `export type` re-exports in `src/index.ts` that should remain available from `@earendil-works/pi-ai`
- Add any root-level `export type` re-exports in `src/index.ts` and `src/base.ts` that should remain available from `@earendil-works/pi-ai` and `@earendil-works/pi-ai/base`
- Keep `src/base.ts` free of built-in registration imports
- Add credential detection in `env-api-keys.ts` for the new provider
- Ensure `streamSimple` handles auth lookup via `getEnvApiKey()` or provider-specific auth

View File

@@ -1,6 +1,6 @@
{
"name": "@earendil-works/pi-ai",
"version": "0.75.4",
"version": "0.79.9",
"description": "Unified LLM API with automatic model discovery and provider configuration",
"type": "module",
"main": "./dist/index.js",
@@ -10,6 +10,14 @@
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./base": {
"types": "./dist/base.d.ts",
"import": "./dist/base.js"
},
"./amazon-bedrock": {
"types": "./dist/providers/amazon-bedrock.d.ts",
"import": "./dist/providers/amazon-bedrock.js"
},
"./anthropic": {
"types": "./dist/providers/anthropic.d.ts",
"import": "./dist/providers/anthropic.js"
@@ -42,6 +50,10 @@
"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",
"import": "./dist/oauth.js"
@@ -70,7 +82,9 @@
"@anthropic-ai/sdk": "0.91.1",
"@aws-sdk/client-bedrock-runtime": "3.1048.0",
"@google/genai": "1.52.0",
"@mistralai/mistralai": "2.2.1",
"@mistralai/mistralai": "2.2.6",
"@opentelemetry/api": "1.9.0",
"@smithy/node-http-handler": "4.7.3",
"http-proxy-agent": "7.0.2",
"https-proxy-agent": "7.0.6",
"openai": "6.26.0",
@@ -91,7 +105,7 @@
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/earendil-works/pi-mono.git",
"url": "git+https://github.com/earendil-works/pi.git",
"directory": "packages/ai"
},
"engines": {
@@ -100,6 +114,6 @@
"devDependencies": {
"@types/node": "24.12.4",
"canvas": "3.2.3",
"vitest": "3.2.4"
"vitest": "4.1.9"
}
}

View File

@@ -32,12 +32,17 @@ interface ModelsDevModel {
};
modalities?: {
input?: string[];
output?: string[];
};
provider?: {
npm?: string;
};
}
interface NvidiaNimModelListItem {
id: string;
}
interface AiGatewayModel {
id: string;
name?: string;
@@ -63,6 +68,8 @@ const KIMI_STATIC_HEADERS = {
"User-Agent": "KimiCLI/1.5",
} as const;
const MOONSHOT_CN_MIRRORED_MODEL_IDS = new Set(["kimi-k2.7-code", "kimi-k2.7-code-highspeed"]);
const TOGETHER_BASE_URL = "https://api.together.ai/v1";
const TOGETHER_BASE_COMPAT: OpenAICompletionsCompat = {
supportsStore: false,
@@ -87,7 +94,6 @@ const TOGETHER_TOGGLE_REASONING_EFFORT_COMPAT: OpenAICompletionsCompat = {
};
const TOGETHER_REASONING_ONLY_MODELS = new Set([
"deepseek-ai/DeepSeek-R1",
"MiniMaxAI/MiniMax-M2.5",
"MiniMaxAI/MiniMax-M2.7",
]);
const TOGETHER_REASONING_EFFORT_MODELS = new Set(["openai/gpt-oss-20b", "openai/gpt-oss-120b"]);
@@ -117,7 +123,47 @@ const TOGETHER_TOGGLE_REASONING_LEVEL_MAP = {
const AI_GATEWAY_MODELS_URL = "https://ai-gateway.vercel.sh/v1";
const AI_GATEWAY_BASE_URL = "https://ai-gateway.vercel.sh";
const VERTEX_BASE_URL = "https://{location}-aiplatform.googleapis.com";
const NVIDIA_BASE_URL = "https://integrate.api.nvidia.com/v1";
const NVIDIA_HEADERS = {
"NVCF-POLL-SECONDS": "3600",
} as const;
const NVIDIA_OPENAI_COMPAT: OpenAICompletionsCompat = {
supportsStore: false,
supportsDeveloperRole: false,
supportsReasoningEffort: false,
maxTokensField: "max_tokens",
supportsStrictMode: false,
supportsLongCacheRetention: false,
};
const NVIDIA_NIM_UNSUPPORTED_MODELS = new Set([
"abacusai/dracarys-llama-3.1-70b-instruct",
"bytedance/seed-oss-36b-instruct",
"deepseek-ai/deepseek-v4-flash",
"deepseek-ai/deepseek-v4-pro",
"google/gemma-2-2b-it",
"google/gemma-3n-e2b-it",
"google/gemma-3n-e4b-it",
"google/gemma-4-31b-it",
"meta/llama-3.2-1b-instruct",
"meta/llama-4-maverick-17b-128e-instruct",
"microsoft/phi-4-mini-instruct",
"minimaxai/minimax-m2.7",
"mistralai/mistral-nemotron",
"nvidia/nemotron-mini-4b-instruct",
"qwen/qwen3-next-80b-a3b-instruct",
"qwen/qwen3.5-397b-a17b",
"sarvamai/sarvam-m",
"upstage/solar-10.7b-instruct",
]);
const ZAI_TOOL_STREAM_UNSUPPORTED_MODELS = new Set(["glm-4.5", "glm-4.5-air", "glm-4.5-flash", "glm-4.5v"]);
const ZAI_GLM52_THINKING_LEVEL_MAP = {
minimal: null,
low: "high",
medium: "high",
high: "high",
xhigh: "max",
} as const;
const EAGER_TOOL_INPUT_STREAMING_UNSUPPORTED_ANTHROPIC_MODELS = new Set([
"github-copilot:claude-haiku-4.5",
"github-copilot:claude-sonnet-4",
@@ -132,6 +178,15 @@ const DEEPSEEK_V4_THINKING_LEVEL_MAP = {
xhigh: "max",
} as const;
const ANT_LING_RING_THINKING_LEVEL_MAP = {
off: null,
minimal: null,
low: null,
medium: null,
high: "high",
xhigh: "xhigh",
} as const;
const OPENAI_RESPONSES_NONE_REASONING_MODELS = new Set([
"gpt-5.1",
"gpt-5.2",
@@ -142,6 +197,23 @@ const OPENAI_RESPONSES_NONE_REASONING_MODELS = new Set([
"gpt-5.5",
]);
const OPENCODE_OPENAI_COMPLETIONS_LONG_CACHE_RETENTION_UNSUPPORTED_MODELS = new Set([
"opencode:deepseek-v4-flash",
"opencode:deepseek-v4-pro",
"opencode:kimi-k2.5",
"opencode:kimi-k2.6",
"opencode:minimax-m2.7",
"opencode-go:kimi-k2.6",
]);
// Checked manually against the authenticated GitHub Copilot /models endpoint on 2026-06-15.
// Keep this to narrow corrections over models.dev metadata instead of snapshotting Copilot's catalog.
const GITHUB_COPILOT_THINKING_LEVEL_OVERRIDES = {
"claude-opus-4.7": { minimal: "low" },
"claude-opus-4.8": { minimal: "low" },
"claude-sonnet-4.6": { minimal: "low", xhigh: "max" },
} satisfies Record<string, NonNullable<Model<Api>["thinkingLevelMap"]>>;
function mergeThinkingLevelMap(model: Model<any>, map: NonNullable<Model<any>["thinkingLevelMap"]>): void {
model.thinkingLevelMap = { ...model.thinkingLevelMap, ...map };
}
@@ -178,12 +250,36 @@ function isGoogleThinkingApi(model: Model<any>): boolean {
return model.api === "google-generative-ai" || model.api === "google-vertex";
}
function isAnthropicAdaptiveThinkingModel(modelId: string): boolean {
return (
modelId.includes("opus-4-6") ||
modelId.includes("opus-4.6") ||
modelId.includes("opus-4-7") ||
modelId.includes("opus-4.7") ||
modelId.includes("opus-4-8") ||
modelId.includes("opus-4.8") ||
modelId.includes("sonnet-4-6") ||
modelId.includes("sonnet-4.6") ||
modelId.includes("fable-5")
);
}
function isAnthropicTemperatureUnsupportedModel(modelId: string): boolean {
const id = modelId.toLowerCase();
return id.includes("opus-4-7") || id.includes("opus-4.7") || id.includes("opus-4-8") || id.includes("opus-4.8");
}
function mergeAnthropicMessagesCompat(model: Model<Api>, compat: AnthropicMessagesCompat): void {
model.compat = { ...(model.compat as AnthropicMessagesCompat | undefined), ...compat };
}
function isGemini3ProModel(modelId: string): boolean {
return /gemini-3(?:\.\d+)?-pro/.test(modelId.toLowerCase());
}
function isGemini3FlashModel(modelId: string): boolean {
return /gemini-3(?:\.\d+)?-flash/.test(modelId.toLowerCase());
const id = modelId.toLowerCase();
return /gemini-3(?:\.\d+)?-flash/.test(id) || id === "gemini-flash-latest" || id === "gemini-flash-lite-latest";
}
function isGemma4Model(modelId: string): boolean {
@@ -210,14 +306,42 @@ function applyThinkingLevelMetadata(model: Model<any>): void {
if (supportsOpenAiXhigh(model.id)) {
mergeThinkingLevelMap(model, { xhigh: "xhigh" });
}
if (model.provider === "openai" && model.id === "gpt-5.5") {
mergeThinkingLevelMap(model, { minimal: null });
}
if (model.id.endsWith("gpt-5.5-pro")) {
mergeThinkingLevelMap(model, { off: null, minimal: null, low: null });
}
if (model.id.includes("opus-4-6") || model.id.includes("opus-4.6")) {
mergeThinkingLevelMap(model, { xhigh: "max" });
}
if (model.id.includes("opus-4-7") || model.id.includes("opus-4.7")) {
if (
model.id.includes("opus-4-7") ||
model.id.includes("opus-4.7") ||
model.id.includes("opus-4-8") ||
model.id.includes("opus-4.8")
) {
mergeThinkingLevelMap(model, { xhigh: "xhigh" });
}
if (
(model.api === "anthropic-messages" || model.api === "bedrock-converse-stream") &&
model.id.includes("fable-5")
) {
mergeThinkingLevelMap(model, { off: null, xhigh: "xhigh" });
}
if (model.api === "anthropic-messages" && isAnthropicAdaptiveThinkingModel(model.id)) {
mergeAnthropicMessagesCompat(model, { forceAdaptiveThinking: true });
}
if (model.api === "anthropic-messages" && isAnthropicTemperatureUnsupportedModel(model.id)) {
mergeAnthropicMessagesCompat(model, { supportsTemperature: false });
}
if (model.api === "openai-completions" && model.id.includes("deepseek-v4")) {
mergeThinkingLevelMap(model, DEEPSEEK_V4_THINKING_LEVEL_MAP);
mergeThinkingLevelMap(
model,
model.provider === "openrouter"
? { ...DEEPSEEK_V4_THINKING_LEVEL_MAP, xhigh: "xhigh" }
: DEEPSEEK_V4_THINKING_LEVEL_MAP,
);
}
if (isGoogleThinkingApi(model) && isGemini3ProModel(model.id)) {
mergeThinkingLevelMap(model, { off: null, minimal: null, low: "LOW", medium: null, high: "HIGH" });
@@ -234,6 +358,15 @@ function applyThinkingLevelMetadata(model: Model<any>): void {
if (model.provider === "openai-codex" && supportsOpenAiXhigh(model.id)) {
mergeThinkingLevelMap(model, { minimal: "low" });
}
if (
(model.provider === "moonshotai" || model.provider === "moonshotai-cn") &&
(model.id === "kimi-k2.7-code" || model.id === "kimi-k2.7-code-highspeed")
) {
// Kimi K2.7 Code is always-thinking. Official docs say
// `thinking: { type: "disabled" }` is rejected, and callers can omit
// the thinking parameter to use the enabled default.
mergeThinkingLevelMap(model, { off: null });
}
if (model.provider === "openrouter" && model.id.startsWith("inception/mercury-2")) {
// Mercury 2 in instant mode (reasoning_effort: "none") disables tool calling.
// Mark "off" unsupported so the openai-completions provider omits the reasoning param
@@ -241,12 +374,41 @@ function applyThinkingLevelMetadata(model: Model<any>): void {
// Pi's low/medium/high pass through verbatim; OpenRouter normalizes to Mercury's vocabulary.
mergeThinkingLevelMap(model, { off: null });
}
if (model.provider === "openrouter" && model.id === "z-ai/glm-5.2") {
mergeThinkingLevelMap(model, { xhigh: "xhigh" });
}
if (model.provider === "fireworks" && model.id === "accounts/fireworks/models/glm-5p2") {
mergeThinkingLevelMap(model, { off: "none", minimal: null, low: "high", medium: "high", xhigh: "max" });
}
if (model.provider === "opencode-go" && model.id === "kimi-k2.6") {
// OpenCode Go exposes Kimi K2.6 thinking as on/off, not distinct effort tiers.
mergeThinkingLevelMap(model, { minimal: null, low: null, medium: null });
}
if (model.provider === "opencode" && model.id === "grok-build-0.1") {
// OpenCode Zen Grok Build reasons by default but rejects explicit reasoningEffort.
mergeThinkingLevelMap(model, { off: null, minimal: null, low: null, medium: null });
}
if (model.provider === "ant-ling" && model.reasoning) {
// Ring reasons by default. Only high/xhigh have documented explicit effort controls.
mergeThinkingLevelMap(model, ANT_LING_RING_THINKING_LEVEL_MAP);
}
if (model.provider === "github-copilot") {
const override = GITHUB_COPILOT_THINKING_LEVEL_OVERRIDES[model.id];
if (override) {
mergeThinkingLevelMap(model, override);
}
}
}
function getAnthropicMessagesCompat(provider: string, modelId: string): AnthropicMessagesCompat | undefined {
return EAGER_TOOL_INPUT_STREAMING_UNSUPPORTED_ANTHROPIC_MODELS.has(`${provider}:${modelId}`)
? { supportsEagerToolInputStreaming: false }
: undefined;
const compat: AnthropicMessagesCompat = {};
if (EAGER_TOOL_INPUT_STREAMING_UNSUPPORTED_ANTHROPIC_MODELS.has(`${provider}:${modelId}`)) {
compat.supportsEagerToolInputStreaming = false;
}
if (provider === "xiaomi" || provider.startsWith("xiaomi-token-plan-")) {
compat.allowEmptySignature = true;
}
return Object.keys(compat).length > 0 ? compat : undefined;
}
function getBedrockBaseUrl(modelId: string): string {
@@ -255,6 +417,34 @@ function getBedrockBaseUrl(modelId: string): string {
: "https://bedrock-runtime.us-east-1.amazonaws.com";
}
function normalizeNvidiaModelId(modelId: string): string {
return modelId.toLowerCase().replaceAll("_", ".");
}
function roundCost(value: number): number {
return Number(value.toFixed(6));
}
async function fetchNvidiaNimModelIds(): Promise<Map<string, string>> {
try {
console.log("Fetching models from NVIDIA NIM API...");
const response = await fetch(`${NVIDIA_BASE_URL}/models`);
const data = (await response.json()) as { data?: NvidiaNimModelListItem[] };
const modelIds = new Map<string, string>();
for (const model of data.data ?? []) {
modelIds.set(model.id, model.id);
modelIds.set(normalizeNvidiaModelId(model.id), model.id);
}
console.log(`Fetched ${data.data?.length ?? 0} model IDs from NVIDIA NIM`);
return modelIds;
} catch (error) {
console.error("Failed to fetch NVIDIA NIM models:", error);
return new Map();
}
}
async function fetchOpenRouterModels(): Promise<Model<any>[]> {
try {
console.log("Fetching models from OpenRouter API...");
@@ -280,10 +470,10 @@ async function fetchOpenRouterModels(): Promise<Model<any>[]> {
}
// Convert pricing from $/token to $/million tokens
const inputCost = parseFloat(model.pricing?.prompt || "0") * 1_000_000;
const outputCost = parseFloat(model.pricing?.completion || "0") * 1_000_000;
const cacheReadCost = parseFloat(model.pricing?.input_cache_read || "0") * 1_000_000;
const cacheWriteCost = parseFloat(model.pricing?.input_cache_write || "0") * 1_000_000;
const inputCost = roundCost(parseFloat(model.pricing?.prompt || "0") * 1_000_000);
const outputCost = roundCost(parseFloat(model.pricing?.completion || "0") * 1_000_000);
const cacheReadCost = roundCost(parseFloat(model.pricing?.input_cache_read || "0") * 1_000_000);
const cacheWriteCost = roundCost(parseFloat(model.pricing?.input_cache_write || "0") * 1_000_000);
const normalizedModel: Model<any> = {
id: modelKey,
@@ -339,10 +529,10 @@ async function fetchAiGatewayModels(): Promise<Model<any>[]> {
input.push("image");
}
const inputCost = toNumber(model.pricing?.input) * 1_000_000;
const outputCost = toNumber(model.pricing?.output) * 1_000_000;
const cacheReadCost = toNumber(model.pricing?.input_cache_read) * 1_000_000;
const cacheWriteCost = toNumber(model.pricing?.input_cache_write) * 1_000_000;
const inputCost = roundCost(toNumber(model.pricing?.input) * 1_000_000);
const outputCost = roundCost(toNumber(model.pricing?.output) * 1_000_000);
const cacheReadCost = roundCost(toNumber(model.pricing?.input_cache_read) * 1_000_000);
const cacheWriteCost = roundCost(toNumber(model.pricing?.input_cache_write) * 1_000_000);
models.push({
id: model.id,
@@ -378,6 +568,7 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
const data = await response.json();
const models: Model<any>[] = [];
const nvidiaNimModelIds = data.nvidia?.models ? await fetchNvidiaNimModelIds() : new Map<string, string>();
// Process Amazon Bedrock models
if (data["amazon-bedrock"]?.models) {
@@ -448,6 +639,13 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
for (const [modelId, model] of Object.entries(data.google.models)) {
const m = model as ModelsDevModel;
if (m.tool_call !== true) continue;
let source = m;
if (modelId === "gemini-flash-latest") {
source = (data.google.models["gemini-3.5-flash"] as ModelsDevModel | undefined) ?? m;
}
if (modelId === "gemini-flash-lite-latest") {
source = (data.google.models["gemini-3.1-flash-lite"] as ModelsDevModel | undefined) ?? m;
}
models.push({
id: modelId,
@@ -455,16 +653,57 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
api: "google-generative-ai",
provider: "google",
baseUrl: "https://generativelanguage.googleapis.com/v1beta",
reasoning: m.reasoning === true,
input: m.modalities?.input?.includes("image") ? ["text", "image"] : ["text"],
reasoning: source.reasoning === true,
input: source.modalities?.input?.includes("image") ? ["text", "image"] : ["text"],
cost: {
input: m.cost?.input || 0,
output: m.cost?.output || 0,
cacheRead: m.cost?.cache_read || 0,
cacheWrite: m.cost?.cache_write || 0,
input: source.cost?.input || 0,
output: source.cost?.output || 0,
cacheRead: source.cost?.cache_read || 0,
cacheWrite: source.cost?.cache_write || 0,
},
contextWindow: m.limit?.context || 4096,
maxTokens: m.limit?.output || 4096,
contextWindow: source.limit?.context || 4096,
maxTokens: source.limit?.output || 4096,
});
}
}
// Process Google Vertex Gemini models. The google-vertex models.dev catalog also includes
// Claude, OpenAI, and other MaaS models that do not use the @google/genai Gemini streaming
// path implemented by our google-vertex provider.
if (data["google-vertex"]?.models) {
for (const [modelId, model] of Object.entries(data["google-vertex"].models)) {
const m = model as ModelsDevModel;
if (m.tool_call !== true) continue;
if (!modelId.startsWith("gemini-")) continue;
if (modelId === "gemini-3.1-flash-lite-preview") continue;
let source = m;
if (modelId === "gemini-flash-latest") {
source = (data["google-vertex"].models["gemini-3.5-flash"] as ModelsDevModel | undefined) ?? m;
}
if (modelId === "gemini-flash-lite-latest") {
source = (data["google-vertex"].models["gemini-3.1-flash-lite"] as ModelsDevModel | undefined) ?? m;
}
// models.dev reports Vertex cache_read/cache_write values for Gemini 2.5 Flash that
// do not match the official Gemini API standard pricing table. pi only accounts
// cachedContentTokenCount as cacheRead.
const cacheRead = modelId === "gemini-2.5-flash" ? 0.03 : source.cost?.cache_read || 0;
models.push({
id: modelId,
name: m.name || modelId,
api: "google-vertex",
provider: "google-vertex",
baseUrl: VERTEX_BASE_URL,
reasoning: source.reasoning === true,
input: source.modalities?.input?.includes("image") ? ["text", "image"] : ["text"],
cost: {
input: source.cost?.input || 0,
output: source.cost?.output || 0,
cacheRead,
cacheWrite: 0,
},
contextWindow: source.limit?.context || 4096,
maxTokens: source.limit?.output || 4096,
});
}
}
@@ -656,34 +895,45 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
}
// Process zAi models
if (data["zai-coding-plan"]?.models) {
for (const [modelId, model] of Object.entries(data["zai-coding-plan"].models)) {
const m = model as ModelsDevModel;
if (m.tool_call !== true) continue;
const supportsImage = m.modalities?.input?.includes("image");
const zaiCodingPlanVariants = [
{ provider: "zai", baseUrl: "https://api.z.ai/api/coding/paas/v4" },
{ provider: "zai-coding-cn", baseUrl: "https://open.bigmodel.cn/api/coding/paas/v4" },
] as const;
models.push({
id: modelId,
name: m.name || modelId,
api: "openai-completions",
provider: "zai",
baseUrl: "https://api.z.ai/api/coding/paas/v4",
reasoning: m.reasoning === true,
input: supportsImage ? ["text", "image"] : ["text"],
cost: {
input: m.cost?.input || 0,
output: m.cost?.output || 0,
cacheRead: m.cost?.cache_read || 0,
cacheWrite: m.cost?.cache_write || 0,
},
compat: {
supportsDeveloperRole: false,
thinkingFormat: "zai",
...(!ZAI_TOOL_STREAM_UNSUPPORTED_MODELS.has(modelId) ? { zaiToolStream: true } : {}),
},
contextWindow: m.limit?.context || 4096,
maxTokens: m.limit?.output || 4096,
});
if (data["zai-coding-plan"]?.models) {
for (const { provider, baseUrl } of zaiCodingPlanVariants) {
for (const [modelId, model] of Object.entries(data["zai-coding-plan"].models)) {
const m = model as ModelsDevModel;
if (m.tool_call !== true) continue;
const supportsImage = m.modalities?.input?.includes("image");
const isGlm52 = modelId === "glm-5.2";
models.push({
id: modelId,
name: m.name || modelId,
api: "openai-completions",
provider,
baseUrl,
reasoning: m.reasoning === true,
...(isGlm52 ? { thinkingLevelMap: ZAI_GLM52_THINKING_LEVEL_MAP } : {}),
input: supportsImage ? ["text", "image"] : ["text"],
cost: {
input: m.cost?.input || 0,
output: m.cost?.output || 0,
cacheRead: m.cost?.cache_read || 0,
cacheWrite: m.cost?.cache_write || 0,
},
compat: {
supportsDeveloperRole: false,
thinkingFormat: "zai",
...(isGlm52 ? { supportsReasoningEffort: true } : {}),
...(!ZAI_TOOL_STREAM_UNSUPPORTED_MODELS.has(modelId) ? { zaiToolStream: true } : {}),
},
contextWindow: m.limit?.context || 4096,
maxTokens: m.limit?.output || 4096,
});
}
}
}
@@ -704,7 +954,7 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
cost: {
input: m.cost?.input || 0,
output: m.cost?.output || 0,
cacheRead: m.cost?.cache_read || 0,
cacheRead: m.cost?.cache_read ?? (m.cost?.input ? roundCost(m.cost.input * 0.1) : 0),
cacheWrite: m.cost?.cache_write || 0,
},
contextWindow: m.limit?.context || 4096,
@@ -779,6 +1029,40 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
}
}
// Process NVIDIA NIM models
if (data.nvidia?.models) {
for (const [modelId, model] of Object.entries(data.nvidia.models)) {
const m = model as ModelsDevModel;
if (m.tool_call !== true) continue;
if (!m.modalities?.input?.includes("text")) continue;
if (!m.modalities?.output?.includes("text")) continue;
const liveModelId = nvidiaNimModelIds.get(modelId) ?? nvidiaNimModelIds.get(normalizeNvidiaModelId(modelId));
if (!liveModelId) continue;
if (NVIDIA_NIM_UNSUPPORTED_MODELS.has(liveModelId)) continue;
models.push({
id: liveModelId,
name: m.name || liveModelId,
api: "openai-completions",
provider: "nvidia",
baseUrl: NVIDIA_BASE_URL,
headers: { ...NVIDIA_HEADERS },
reasoning: m.reasoning === true,
input: m.modalities.input.includes("image") ? ["text", "image"] : ["text"],
cost: {
input: m.cost?.input || 0,
output: m.cost?.output || 0,
cacheRead: m.cost?.cache_read || 0,
cacheWrite: m.cost?.cache_write || 0,
},
compat: NVIDIA_OPENAI_COMPAT,
contextWindow: m.limit?.context || 4096,
maxTokens: m.limit?.output || 4096,
});
}
}
// Process Together AI models
const togetherProvider = data.together ?? data.togetherai ?? data["together-ai"];
if (togetherProvider?.models) {
@@ -855,6 +1139,16 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
baseUrl = `${variant.basePath}/v1`;
}
if (variant.provider === "opencode" && modelId === "grok-build-0.1") {
compat = { ...(compat ?? {}), supportsReasoningEffort: false };
}
if ((variant.provider === "opencode" || variant.provider === "opencode-go") && modelId === "kimi-k2.6") {
// OpenCode Kimi K2.6 accepts Anthropic-style thinking objects
// and rejects string thinking values or combined reasoning_effort.
compat = { ...(compat ?? {}), thinkingFormat: "deepseek", supportsReasoningEffort: false };
}
// Fix known mismatches between models.dev npm data and actual
// OpenCode Go endpoint behaviour. models.dev reports these models
// as @ai-sdk/anthropic, but the OpenCode Go endpoints either don't
@@ -875,6 +1169,17 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
}
}
if (api === "openai-completions") {
compat = { ...(compat ?? {}), maxTokensField: "max_tokens" };
if (
OPENCODE_OPENAI_COMPLETIONS_LONG_CACHE_RETENTION_UNSUPPORTED_MODELS.has(
`${variant.provider}:${modelId}`,
)
) {
compat = { ...compat, supportsLongCacheRetention: false };
}
}
models.push({
id: modelId,
name: m.name || modelId,
@@ -1033,13 +1338,29 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
supportsReasoningEffort: false,
maxTokensField: "max_tokens",
supportsStrictMode: false,
thinkingFormat: "deepseek",
};
const getMoonshotProviderModels = (key: "moonshotai" | "moonshotai-cn"): Record<string, ModelsDevModel> => {
const providerModels = data[key]?.models as Record<string, ModelsDevModel> | undefined;
return providerModels ? { ...providerModels } : {};
};
const moonshotModels = {
moonshotai: getMoonshotProviderModels("moonshotai"),
"moonshotai-cn": getMoonshotProviderModels("moonshotai-cn"),
};
for (const { key, provider, baseUrl } of moonshotVariants) {
if (!data[key]?.models) continue;
// models.dev can lag the CN catalog while the global Moonshot catalog already
// has the model. Mirror selected current model IDs into moonshotai-cn until
// upstream CN metadata catches up.
for (const modelId of MOONSHOT_CN_MIRRORED_MODEL_IDS) {
const model = moonshotModels.moonshotai[modelId];
if (model && !moonshotModels["moonshotai-cn"][modelId]) {
moonshotModels["moonshotai-cn"][modelId] = model;
}
}
for (const [modelId, model] of Object.entries(data[key].models)) {
const m = model as ModelsDevModel;
for (const { key, provider, baseUrl } of moonshotVariants) {
for (const [modelId, m] of Object.entries(moonshotModels[key])) {
if (m.tool_call !== true) continue;
models.push({
@@ -1083,6 +1404,7 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
for (const [modelId, model] of Object.entries(data.xiaomi.models)) {
const m = model as ModelsDevModel;
if (m.tool_call !== true) continue;
if (provider.startsWith("xiaomi-token-plan-") && modelId === "mimo-v2-flash") continue;
models.push({
id: modelId,
@@ -1171,6 +1493,11 @@ async function generateModels() {
candidate.contextWindow = 272000;
candidate.maxTokens = 128000;
}
// models.dev reports gpt-5-pro output as 272000 (a duplicate of the input sub-limit);
// the actual max output is 128000. Also propagates to the derived Azure clone.
if (candidate.provider === "openai" && candidate.id === "gpt-5-pro") {
candidate.maxTokens = 128000;
}
// Keep selected OpenRouter model metadata stable until upstream settles.
if (candidate.provider === "openrouter" && candidate.id === "moonshotai/kimi-k2.5") {
candidate.cost.input = 0.41;
@@ -1178,12 +1505,23 @@ async function generateModels() {
candidate.cost.cacheRead = 0.07;
candidate.maxTokens = 4096;
}
if (candidate.provider === "openrouter" && candidate.id.startsWith("moonshotai/kimi-k2.6")) {
candidate.compat = {
...candidate.compat,
supportsDeveloperRole: false,
requiresReasoningContentOnAssistantMessages: true,
};
}
if (candidate.provider === "openrouter" && candidate.id === "z-ai/glm-5") {
candidate.cost.input = 0.6;
candidate.cost.output = 1.9;
candidate.cost.cacheRead = 0.119;
}
if (candidate.provider === "fireworks" && candidate.id === "accounts/fireworks/models/glm-5p2") {
candidate.api = "openai-completions";
candidate.baseUrl = "https://api.fireworks.ai/inference/v1";
candidate.compat = { supportsStore: false, supportsDeveloperRole: false };
}
}
@@ -1250,6 +1588,27 @@ async function generateModels() {
});
}
// Add missing Claude Opus 4.8
if (!allModels.some(m => m.provider === "anthropic" && m.id === "claude-opus-4-8")) {
allModels.push({
id: "claude-opus-4-8",
name: "Claude Opus 4.8",
api: "anthropic-messages",
baseUrl: "https://api.anthropic.com",
provider: "anthropic",
reasoning: true,
input: ["text", "image"],
cost: {
input: 5,
output: 25,
cacheRead: 0.5,
cacheWrite: 6.25,
},
contextWindow: 1000000,
maxTokens: 128000,
});
}
// Add missing Claude Sonnet 4.6
if (!allModels.some(m => m.provider === "anthropic" && m.id === "claude-sonnet-4-6")) {
allModels.push({
@@ -1451,33 +1810,72 @@ async function generateModels() {
];
allModels.push(...deepseekV4Models);
const antLingCompat: OpenAICompletionsCompat = {
supportsStore: false,
supportsDeveloperRole: false,
supportsReasoningEffort: false,
maxTokensField: "max_tokens",
supportsLongCacheRetention: false,
};
const antLingModels: Model<"openai-completions">[] = [
{
id: "Ling-2.6-flash",
name: "Ling 2.6 Flash",
api: "openai-completions",
baseUrl: "https://api.ant-ling.com/v1",
provider: "ant-ling",
reasoning: false,
input: ["text"],
cost: { input: 0.01, output: 0.02, cacheRead: 0, cacheWrite: 0 },
contextWindow: 262144,
maxTokens: 65536,
compat: antLingCompat,
},
{
id: "Ling-2.6-1T",
name: "Ling 2.6 1T",
api: "openai-completions",
baseUrl: "https://api.ant-ling.com/v1",
provider: "ant-ling",
reasoning: false,
input: ["text"],
cost: { input: 0.06, output: 0.25, cacheRead: 0, cacheWrite: 0 },
contextWindow: 262144,
maxTokens: 65536,
compat: antLingCompat,
},
{
id: "Ring-2.6-1T",
name: "Ring 2.6 1T",
api: "openai-completions",
baseUrl: "https://api.ant-ling.com/v1",
provider: "ant-ling",
reasoning: true,
input: ["text"],
cost: { input: 0.06, output: 0.25, cacheRead: 0, cacheWrite: 0 },
contextWindow: 262144,
maxTokens: 65536,
compat: { ...antLingCompat, thinkingFormat: "ant-ling" },
},
];
allModels.push(...antLingModels);
for (const candidate of allModels) {
if (candidate.api === "openai-completions" && candidate.id.includes("deepseek-v4")) {
const preservesNativeReasoningEffort = candidate.provider === "openrouter" || candidate.provider === "opencode";
candidate.compat = {
...candidate.compat,
...(candidate.provider === "openrouter"
...(preservesNativeReasoningEffort
? {
requiresReasoningContentOnAssistantMessages:
deepseekCompat.requiresReasoningContentOnAssistantMessages,
thinkingFormat: deepseekCompat.thinkingFormat,
}
: deepseekCompat),
};
mergeThinkingLevelMap(candidate, DEEPSEEK_V4_THINKING_LEVEL_MAP);
}
}
const minimaxDirectSupportedIds = new Set(["MiniMax-M2.7", "MiniMax-M2.7-highspeed"]);
for (const candidate of allModels) {
if (
(candidate.provider === "minimax" || candidate.provider === "minimax-cn") &&
minimaxDirectSupportedIds.has(candidate.id)
) {
candidate.contextWindow = 204800;
candidate.maxTokens = 131072;
}
}
const minimaxDirectSupportedIds = new Set(["MiniMax-M2.7", "MiniMax-M2.7-highspeed", "MiniMax-M3"]);
for (let i = allModels.length - 1; i >= 0; i--) {
const candidate = allModels[i];
@@ -1494,32 +1892,9 @@ async function generateModels() {
// Context window is based on observed server limits (400s above ~272k), not marketing numbers.
const CODEX_BASE_URL = "https://chatgpt.com/backend-api";
const CODEX_CONTEXT = 272000;
const CODEX_SPARK_CONTEXT = 128000;
const CODEX_MAX_TOKENS = 128000;
const codexModels: Model<"openai-codex-responses">[] = [
{
id: "gpt-5.2",
name: "GPT-5.2",
api: "openai-codex-responses",
provider: "openai-codex",
baseUrl: CODEX_BASE_URL,
reasoning: true,
input: ["text", "image"],
cost: { input: 1.75, output: 14, cacheRead: 0.175, cacheWrite: 0 },
contextWindow: CODEX_CONTEXT,
maxTokens: CODEX_MAX_TOKENS,
},
{
id: "gpt-5.3-codex",
name: "GPT-5.3 Codex",
api: "openai-codex-responses",
provider: "openai-codex",
baseUrl: CODEX_BASE_URL,
reasoning: true,
input: ["text", "image"],
cost: { input: 1.75, output: 14, cacheRead: 0.175, cacheWrite: 0 },
contextWindow: CODEX_CONTEXT,
maxTokens: CODEX_MAX_TOKENS,
},
{
id: "gpt-5.3-codex-spark",
name: "GPT-5.3 Codex Spark",
@@ -1529,7 +1904,7 @@ async function generateModels() {
reasoning: true,
input: ["text"],
cost: { input: 1.75, output: 14, cacheRead: 0.175, cacheWrite: 0 },
contextWindow: CODEX_CONTEXT,
contextWindow: CODEX_SPARK_CONTEXT,
maxTokens: CODEX_MAX_TOKENS,
},
{
@@ -1665,167 +2040,38 @@ async function generateModels() {
});
}
const VERTEX_BASE_URL = "https://{location}-aiplatform.googleapis.com";
const vertexModels: Model<"google-vertex">[] = [
{
id: "gemini-3-pro-preview",
name: "Gemini 3 Pro Preview (Vertex)",
api: "google-vertex",
provider: "google-vertex",
baseUrl: VERTEX_BASE_URL,
// Add "fusion" alias for openrouter/fusion. OpenRouter exposes Fusion as a
// router alias/plugin entry point; its model metadata does not advertise
// tools, but the alias resolves to a concrete model that can invoke caller
// tools and has the openrouter:fusion server tool auto-injected.
if (!allModels.some(m => m.provider === "openrouter" && m.id === "openrouter/fusion")) {
allModels.push({
id: "openrouter/fusion",
name: "OpenRouter: Fusion",
api: "openai-completions",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
reasoning: true,
input: ["text", "image"],
cost: { input: 2, output: 12, cacheRead: 0.2, cacheWrite: 0 },
input: ["text"],
cost: {
// we dont know about the costs because Fusion routes to multiple models
// and then charges you for the underlying used models
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 1000000,
maxTokens: 64000,
},
{
id: "gemini-3.1-pro-preview",
name: "Gemini 3.1 Pro Preview (Vertex)",
api: "google-vertex",
provider: "google-vertex",
baseUrl: VERTEX_BASE_URL,
reasoning: true,
input: ["text", "image"],
cost: { input: 2, output: 12, cacheRead: 0.2, cacheWrite: 0 },
contextWindow: 1048576,
maxTokens: 65536,
},
{
id: "gemini-3.1-pro-preview-customtools",
name: "Gemini 3.1 Pro Preview Custom Tools (Vertex)",
api: "google-vertex",
provider: "google-vertex",
baseUrl: VERTEX_BASE_URL,
reasoning: true,
input: ["text", "image"],
cost: { input: 2, output: 12, cacheRead: 0.2, cacheWrite: 0 },
contextWindow: 1048576,
maxTokens: 65536,
},
{
id: "gemini-3-flash-preview",
name: "Gemini 3 Flash Preview (Vertex)",
api: "google-vertex",
provider: "google-vertex",
baseUrl: VERTEX_BASE_URL,
reasoning: true,
input: ["text", "image"],
cost: { input: 0.5, output: 3, cacheRead: 0.05, cacheWrite: 0 },
contextWindow: 1048576,
maxTokens: 65536,
},
{
id: "gemini-2.0-flash",
name: "Gemini 2.0 Flash (Vertex)",
api: "google-vertex",
provider: "google-vertex",
baseUrl: VERTEX_BASE_URL,
reasoning: false,
input: ["text", "image"],
cost: { input: 0.15, output: 0.6, cacheRead: 0.0375, cacheWrite: 0 },
contextWindow: 1048576,
maxTokens: 8192,
},
{
id: "gemini-2.0-flash-lite",
name: "Gemini 2.0 Flash Lite (Vertex)",
api: "google-vertex",
provider: "google-vertex",
baseUrl: VERTEX_BASE_URL,
reasoning: true,
input: ["text", "image"],
cost: { input: 0.075, output: 0.3, cacheRead: 0.01875, cacheWrite: 0 },
contextWindow: 1048576,
maxTokens: 65536,
},
{
id: "gemini-2.5-pro",
name: "Gemini 2.5 Pro (Vertex)",
api: "google-vertex",
provider: "google-vertex",
baseUrl: VERTEX_BASE_URL,
reasoning: true,
input: ["text", "image"],
cost: { input: 1.25, output: 10, cacheRead: 0.125, cacheWrite: 0 },
contextWindow: 1048576,
maxTokens: 65536,
},
{
id: "gemini-2.5-flash",
name: "Gemini 2.5 Flash (Vertex)",
api: "google-vertex",
provider: "google-vertex",
baseUrl: VERTEX_BASE_URL,
reasoning: true,
input: ["text", "image"],
cost: { input: 0.3, output: 2.5, cacheRead: 0.03, cacheWrite: 0 },
contextWindow: 1048576,
maxTokens: 65536,
},
{
id: "gemini-2.5-flash-lite-preview-09-2025",
name: "Gemini 2.5 Flash Lite Preview 09-25 (Vertex)",
api: "google-vertex",
provider: "google-vertex",
baseUrl: VERTEX_BASE_URL,
reasoning: true,
input: ["text", "image"],
cost: { input: 0.1, output: 0.4, cacheRead: 0.01, cacheWrite: 0 },
contextWindow: 1048576,
maxTokens: 65536,
},
{
id: "gemini-2.5-flash-lite",
name: "Gemini 2.5 Flash Lite (Vertex)",
api: "google-vertex",
provider: "google-vertex",
baseUrl: VERTEX_BASE_URL,
reasoning: true,
input: ["text", "image"],
cost: { input: 0.1, output: 0.4, cacheRead: 0.01, cacheWrite: 0 },
contextWindow: 1048576,
maxTokens: 65536,
},
{
id: "gemini-1.5-pro",
name: "Gemini 1.5 Pro (Vertex)",
api: "google-vertex",
provider: "google-vertex",
baseUrl: VERTEX_BASE_URL,
reasoning: false,
input: ["text", "image"],
cost: { input: 1.25, output: 5, cacheRead: 0.3125, cacheWrite: 0 },
contextWindow: 1000000,
maxTokens: 8192,
},
{
id: "gemini-1.5-flash",
name: "Gemini 1.5 Flash (Vertex)",
api: "google-vertex",
provider: "google-vertex",
baseUrl: VERTEX_BASE_URL,
reasoning: false,
input: ["text", "image"],
cost: { input: 0.075, output: 0.3, cacheRead: 0.01875, cacheWrite: 0 },
contextWindow: 1000000,
maxTokens: 8192,
},
{
id: "gemini-1.5-flash-8b",
name: "Gemini 1.5 Flash-8B (Vertex)",
api: "google-vertex",
provider: "google-vertex",
baseUrl: VERTEX_BASE_URL,
reasoning: false,
input: ["text", "image"],
cost: { input: 0.0375, output: 0.15, cacheRead: 0.01, cacheWrite: 0 },
contextWindow: 1000000,
maxTokens: 8192,
},
];
allModels.push(...vertexModels);
maxTokens: 30000,
});
}
// Azure Foundry deploys these with larger context windows than OpenAI's own API,
// which caps gpt-5.4/gpt-5.5 at 272k. See models-sold-directly-by-azure docs.
const AZURE_CONTEXT_WINDOW_OVERRIDES: Record<string, number> = {
"gpt-5.4": 1050000,
"gpt-5.5": 1050000,
};
const azureOpenAiModels: Model<Api>[] = allModels
.filter((model) => model.provider === "openai" && model.api === "openai-responses")
.map((model) => ({
@@ -1833,6 +2079,7 @@ async function generateModels() {
api: "azure-openai-responses",
provider: "azure-openai-responses",
baseUrl: "",
contextWindow: AZURE_CONTEXT_WINDOW_OVERRIDES[model.id] ?? model.contextWindow,
}));
allModels.push(...azureOpenAiModels);

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,4 +1,6 @@
import { streamBedrock, streamSimpleBedrock } from "./providers/amazon-bedrock.ts";
import { register, streamBedrock, streamSimpleBedrock } from "./providers/amazon-bedrock.ts";
export { register };
export const bedrockProviderModule = {
streamBedrock,

View File

@@ -42,9 +42,23 @@ async function login(providerId: OAuthProviderId): Promise<void> {
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),
});

View File

@@ -23,44 +23,17 @@ if (typeof process !== "undefined" && (process.versions?.node || process.version
});
}
import type { KnownProvider } from "./types.ts";
let _procEnvCache: Map<string, string> | null = null;
/**
* Fallback for https://github.com/oven-sh/bun/issues/27802
* Bun compiled binaries have an empty `process.env` inside sandbox
* environments on Linux. We can recover the env from `/proc/self/environ`.
*/
function getProcEnv(key: string): string | undefined {
if (!process.versions?.bun) return undefined;
if (typeof process === "undefined") return undefined;
// If process.env already has entries, the bug is not triggered.
if (Object.keys(process.env).length > 0) return undefined;
if (_procEnvCache === null) {
_procEnvCache = new Map();
try {
const { readFileSync } = require("node:fs") as typeof import("node:fs");
const data = readFileSync("/proc/self/environ", "utf-8");
for (const entry of data.split("\0")) {
const idx = entry.indexOf("=");
if (idx > 0) {
_procEnvCache.set(entry.slice(0, idx), entry.slice(idx + 1));
}
}
} catch {
// /proc/self/environ may not be readable.
}
}
return _procEnvCache.get(key);
}
import type { KnownProvider, ProviderEnv } from "./types.ts";
import { getProviderEnvValue } from "./utils/provider-env.ts";
let cachedVertexAdcCredentialsExists: boolean | null = null;
function hasVertexAdcCredentials(): boolean {
function hasVertexAdcCredentials(env?: ProviderEnv): boolean {
const explicitCredentialsPath = env?.GOOGLE_APPLICATION_CREDENTIALS;
if (explicitCredentialsPath) {
return _existsSync ? _existsSync(explicitCredentialsPath) : false;
}
if (cachedVertexAdcCredentialsExists === null) {
// If node modules haven't loaded yet (async import race at startup),
// return false WITHOUT caching so the next call retries once they're ready.
@@ -75,7 +48,7 @@ function hasVertexAdcCredentials(): boolean {
}
// Check GOOGLE_APPLICATION_CREDENTIALS env var first (standard way)
const gacPath = process.env.GOOGLE_APPLICATION_CREDENTIALS || getProcEnv("GOOGLE_APPLICATION_CREDENTIALS");
const gacPath = getProviderEnvValue("GOOGLE_APPLICATION_CREDENTIALS", env);
if (gacPath) {
cachedVertexAdcCredentialsExists = _existsSync(gacPath);
} else {
@@ -99,8 +72,10 @@ function getApiKeyEnvVars(provider: string): readonly string[] | undefined {
}
const envMap: Record<string, string> = {
"ant-ling": "ANT_LING_API_KEY",
openai: "OPENAI_API_KEY",
"azure-openai-responses": "AZURE_OPENAI_API_KEY",
nvidia: "NVIDIA_API_KEY",
deepseek: "DEEPSEEK_API_KEY",
google: "GEMINI_API_KEY",
"google-vertex": "GOOGLE_CLOUD_API_KEY",
@@ -110,6 +85,7 @@ function getApiKeyEnvVars(provider: string): readonly string[] | undefined {
openrouter: "OPENROUTER_API_KEY",
"vercel-ai-gateway": "AI_GATEWAY_API_KEY",
zai: "ZAI_API_KEY",
"zai-coding-cn": "ZAI_CODING_CN_API_KEY",
mistral: "MISTRAL_API_KEY",
minimax: "MINIMAX_API_KEY",
"minimax-cn": "MINIMAX_CN_API_KEY",
@@ -140,13 +116,13 @@ function getApiKeyEnvVars(provider: string): readonly string[] | undefined {
* credential sources such as AWS profiles, AWS IAM credentials, and Google
* Application Default Credentials.
*/
export function findEnvKeys(provider: KnownProvider): string[] | undefined;
export function findEnvKeys(provider: string): string[] | undefined;
export function findEnvKeys(provider: string): string[] | undefined {
export function findEnvKeys(provider: KnownProvider, env?: ProviderEnv): string[] | undefined;
export function findEnvKeys(provider: string, env?: ProviderEnv): string[] | undefined;
export function findEnvKeys(provider: string, env?: ProviderEnv): string[] | undefined {
const envVars = getApiKeyEnvVars(provider);
if (!envVars) return undefined;
const found = envVars.filter((envVar) => !!process.env[envVar] || !!getProcEnv(envVar));
const found = envVars.filter((envVar) => !!getProviderEnvValue(envVar, env));
return found.length > 0 ? found : undefined;
}
@@ -155,25 +131,22 @@ export function findEnvKeys(provider: string): string[] | undefined {
*
* Will not return API keys for providers that require OAuth tokens.
*/
export function getEnvApiKey(provider: KnownProvider): string | undefined;
export function getEnvApiKey(provider: string): string | undefined;
export function getEnvApiKey(provider: string): string | undefined {
const envKeys = findEnvKeys(provider);
export function getEnvApiKey(provider: KnownProvider, env?: ProviderEnv): string | undefined;
export function getEnvApiKey(provider: string, env?: ProviderEnv): string | undefined;
export function getEnvApiKey(provider: string, env?: ProviderEnv): string | undefined {
const envKeys = findEnvKeys(provider, env);
if (envKeys?.[0]) {
return process.env[envKeys[0]] || getProcEnv(envKeys[0]);
return getProviderEnvValue(envKeys[0], env);
}
// Vertex AI supports either an explicit API key or Application Default Credentials.
// Auth is configured via `gcloud auth application-default login`.
if (provider === "google-vertex") {
const hasCredentials = hasVertexAdcCredentials();
const hasCredentials = hasVertexAdcCredentials(env);
const hasProject = !!(
process.env.GOOGLE_CLOUD_PROJECT ||
process.env.GCLOUD_PROJECT ||
getProcEnv("GOOGLE_CLOUD_PROJECT") ||
getProcEnv("GCLOUD_PROJECT")
getProviderEnvValue("GOOGLE_CLOUD_PROJECT", env) || getProviderEnvValue("GCLOUD_PROJECT", env)
);
const hasLocation = !!(process.env.GOOGLE_CLOUD_LOCATION || getProcEnv("GOOGLE_CLOUD_LOCATION"));
const hasLocation = !!getProviderEnvValue("GOOGLE_CLOUD_LOCATION", env);
if (hasCredentials && hasProject && hasLocation) {
return "<authenticated>";
@@ -189,18 +162,12 @@ export function getEnvApiKey(provider: string): string | undefined {
// 5. AWS_CONTAINER_CREDENTIALS_FULL_URI - ECS task roles (full URI)
// 6. AWS_WEB_IDENTITY_TOKEN_FILE - IRSA (IAM Roles for Service Accounts)
if (
process.env.AWS_PROFILE ||
(process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SECRET_ACCESS_KEY) ||
process.env.AWS_BEARER_TOKEN_BEDROCK ||
process.env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI ||
process.env.AWS_CONTAINER_CREDENTIALS_FULL_URI ||
process.env.AWS_WEB_IDENTITY_TOKEN_FILE ||
getProcEnv("AWS_PROFILE") ||
(getProcEnv("AWS_ACCESS_KEY_ID") && getProcEnv("AWS_SECRET_ACCESS_KEY")) ||
getProcEnv("AWS_BEARER_TOKEN_BEDROCK") ||
getProcEnv("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI") ||
getProcEnv("AWS_CONTAINER_CREDENTIALS_FULL_URI") ||
getProcEnv("AWS_WEB_IDENTITY_TOKEN_FILE")
getProviderEnvValue("AWS_PROFILE", env) ||
(getProviderEnvValue("AWS_ACCESS_KEY_ID", env) && getProviderEnvValue("AWS_SECRET_ACCESS_KEY", env)) ||
getProviderEnvValue("AWS_BEARER_TOKEN_BEDROCK", env) ||
getProviderEnvValue("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI", env) ||
getProviderEnvValue("AWS_CONTAINER_CREDENTIALS_FULL_URI", env) ||
getProviderEnvValue("AWS_WEB_IDENTITY_TOKEN_FILE", env)
) {
return "<authenticated>";
}

View File

@@ -95,6 +95,21 @@ export const IMAGE_MODELS = {
cacheWrite: 0.08333333333333334,
},
} satisfies ImagesModel<"openrouter-images">,
"google/gemini-3-pro-image": {
id: "google/gemini-3-pro-image",
name: "Google: Nano Banana Pro (Gemini 3 Pro Image)",
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["image", "text"],
output: ["image", "text"],
cost: {
input: 2,
output: 12,
cacheRead: 0.19999999999999998,
cacheWrite: 0.375,
},
} satisfies ImagesModel<"openrouter-images">,
"google/gemini-3-pro-image-preview": {
id: "google/gemini-3-pro-image-preview",
name: "Google: Nano Banana Pro (Gemini 3 Pro Image Preview)",
@@ -110,6 +125,21 @@ export const IMAGE_MODELS = {
cacheWrite: 0.375,
},
} satisfies ImagesModel<"openrouter-images">,
"google/gemini-3.1-flash-image": {
id: "google/gemini-3.1-flash-image",
name: "Google: Nano Banana 2 (Gemini 3.1 Flash Image)",
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["image", "text"],
output: ["image", "text"],
cost: {
input: 0.5,
output: 3,
cacheRead: 0,
cacheWrite: 0,
},
} satisfies ImagesModel<"openrouter-images">,
"google/gemini-3.1-flash-image-preview": {
id: "google/gemini-3.1-flash-image-preview",
name: "Google: Nano Banana 2 (Gemini 3.1 Flash Image Preview)",

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,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,46 +1,6 @@
export type { Static, TSchema } from "typebox";
export { Type } from "typebox";
import { registerBuiltInImagesApiProviders } from "./providers/register-builtins.ts";
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 * from "./providers/images/register-builtins.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 "./base.ts";
export * from "./providers/register-builtins.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,
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";
registerBuiltInImagesApiProviders();

File diff suppressed because it is too large Load Diff

View File

@@ -37,10 +37,13 @@ export function getModels<TProvider extends KnownProvider>(
}
export function calculateCost<TApi extends Api>(model: Model<TApi>, usage: Usage): Usage["cost"] {
// Anthropic charges 2x base input for 1h cache writes.
const longWrite = usage.cacheWrite1h ?? 0;
const shortWrite = usage.cacheWrite - longWrite;
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 / 1000000) * usage.cacheWrite;
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;
}

View File

@@ -1,3 +1,4 @@
import type { Agent as HttpsAgent } from "node:https";
import {
BedrockRuntimeClient,
type BedrockRuntimeClientConfig,
@@ -18,17 +19,23 @@ import {
type SystemContentBlock,
type ToolChoice,
type ToolConfiguration,
type ToolResultContentBlock,
ToolResultStatus,
} from "@aws-sdk/client-bedrock-runtime";
import { NodeHttpHandler } from "@smithy/node-http-handler";
import type { DocumentType } from "@smithy/types";
import type { BuildMiddleware, DocumentType, MetadataBearer } from "@smithy/types";
import { HttpProxyAgent } from "http-proxy-agent";
import { HttpsProxyAgent } from "https-proxy-agent";
import { registerApiProvider } from "../api-registry.ts";
import { calculateCost } from "../models.ts";
import type {
Api,
AssistantMessage,
CacheRetention,
Context,
ImageContent,
Model,
ProviderEnv,
SimpleStreamOptions,
StopReason,
StreamFunction,
@@ -43,7 +50,8 @@ import type {
} from "../types.ts";
import { AssistantMessageEventStream } from "../utils/event-stream.ts";
import { parseStreamingJson } from "../utils/json-parse.ts";
import { createHttpProxyAgentsForTarget } from "../utils/node-http-proxy.ts";
import { resolveHttpProxyUrlForTarget } from "../utils/node-http-proxy.ts";
import { getProviderEnvValue } from "../utils/provider-env.ts";
import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts";
import { adjustMaxTokensForThinking, buildBaseOptions, clampReasoning } from "./simple-options.ts";
import { transformMessages } from "./transform-messages.ts";
@@ -66,7 +74,7 @@ export interface BedrockOptions extends StreamOptions {
* - "omitted": Thinking content is redacted but the signature still travels back
* for multi-turn continuity, reducing time-to-first-text-token.
*
* Note: Anthropic's API default for Claude Opus 4.7 and Mythos Preview is
* Note: Anthropic's API default for Claude Opus 4.8 and Mythos Preview is
* "omitted". We default to "summarized" here to keep behavior consistent with
* older Claude 4 models. Only applies to Claude models on Bedrock.
*/
@@ -86,6 +94,8 @@ export interface BedrockOptions extends StreamOptions {
type Block = (TextContent | ThinkingContent | ToolCall) & { index?: number; partialJson?: string };
const EMPTY_TEXT_PLACEHOLDER = "<empty>";
export const streamBedrock: StreamFunction<"bedrock-converse-stream", BedrockOptions> = (
model: Model<"bedrock-converse-stream">,
context: Context,
@@ -115,18 +125,18 @@ export const streamBedrock: StreamFunction<"bedrock-converse-stream", BedrockOpt
const blocks = output.content as Block[];
const config: BedrockRuntimeClientConfig = {
profile: options.profile,
profile: options.profile || getProviderEnvValue("AWS_PROFILE", options.env),
};
const configuredRegion = getConfiguredBedrockRegion(options);
const hasConfiguredProfile = hasConfiguredBedrockProfile();
const hasAmbientConfiguredProfile = Boolean(getProviderEnvValue("AWS_PROFILE"));
const endpointRegion = getStandardBedrockEndpointRegion(model.baseUrl);
const useExplicitEndpoint = shouldUseExplicitBedrockEndpoint(
model.baseUrl,
configuredRegion,
hasConfiguredProfile,
hasAmbientConfiguredProfile,
);
// Only pin standard AWS Bedrock runtime endpoints when no region/profile is configured.
// Only pin standard AWS Bedrock runtime endpoints when no region or ambient AWS_PROFILE is configured.
// This preserves custom endpoints (VPC/proxy) from #3402 without forcing built-in
// catalog defaults such as us-east-1 to override AWS_REGION/AWS_PROFILE.
if (useExplicitEndpoint) {
@@ -134,37 +144,50 @@ export const streamBedrock: StreamFunction<"bedrock-converse-stream", BedrockOpt
}
// Resolve bearer token for Bedrock API key auth.
const bearerToken = options.bearerToken || process.env.AWS_BEARER_TOKEN_BEDROCK || undefined;
const useBearerToken = bearerToken !== undefined && process.env.AWS_BEDROCK_SKIP_AUTH !== "1";
const skipAuth = getProviderEnvValue("AWS_BEDROCK_SKIP_AUTH", options.env) === "1";
const bearerToken =
options.bearerToken || getProviderEnvValue("AWS_BEARER_TOKEN_BEDROCK", options.env) || undefined;
const useBearerToken = bearerToken !== undefined && !skipAuth;
// in Node.js/Bun environment only
if (typeof process !== "undefined" && (process.versions?.node || process.versions?.bun)) {
// Region resolution: explicit option > env vars > SDK default chain.
// When AWS_PROFILE is set, we leave region undefined so the SDK can
// resovle it from aws profile configs. Otherwise fall back to us-east-1.
if (configuredRegion) {
// Region resolution: ARN-embedded > explicit option > env vars > SDK default chain.
// When the model ID is an inference profile ARN, extract the region from it.
// This avoids conflicts with AWS_REGION set for other services.
const arnRegionMatch = model.id.match(/^arn:aws(?:-[a-z0-9-]+)?:bedrock:([a-z0-9-]+):/);
if (arnRegionMatch) {
config.region = arnRegionMatch[1];
} else if (configuredRegion) {
config.region = configuredRegion;
} else if (endpointRegion && useExplicitEndpoint) {
config.region = endpointRegion;
} else if (!hasConfiguredProfile) {
} else if (!hasAmbientConfiguredProfile) {
config.region = "us-east-1";
}
// Support proxies that don't need authentication
if (process.env.AWS_BEDROCK_SKIP_AUTH === "1") {
if (skipAuth) {
config.credentials = {
accessKeyId: "dummy-access-key",
secretAccessKey: "dummy-secret-key",
};
}
const proxyAgents = createHttpProxyAgentsForTarget(model.baseUrl);
if (proxyAgents) {
const credentials = getConfiguredBedrockCredentials(options.env);
if (!skipAuth && credentials) {
config.credentials = credentials;
}
const proxyUrl = resolveHttpProxyUrlForTarget(model.baseUrl, options.env);
if (proxyUrl) {
// Bedrock runtime uses NodeHttp2Handler by default since v3.798.0, which is based
// on `http2` module and has no support for http agent.
// Use NodeHttpHandler to support HTTP(S) proxy agents.
config.requestHandler = new NodeHttpHandler(proxyAgents);
} else if (process.env.AWS_BEDROCK_FORCE_HTTP1 === "1") {
config.requestHandler = new NodeHttpHandler({
httpAgent: new HttpProxyAgent(proxyUrl),
httpsAgent: new HttpsProxyAgent(proxyUrl) as unknown as HttpsAgent,
});
} else if (getProviderEnvValue("AWS_BEDROCK_FORCE_HTTP1", options.env) === "1") {
// Some custom endpoints require HTTP/1.1 instead of HTTP/2
config.requestHandler = new NodeHttpHandler();
}
@@ -182,12 +205,15 @@ export const streamBedrock: StreamFunction<"bedrock-converse-stream", BedrockOpt
try {
const client = new BedrockRuntimeClient(config);
const cacheRetention = resolveCacheRetention(options.cacheRetention);
if (options.headers && Object.keys(options.headers).length > 0) {
addCustomHeadersMiddleware(client, options.headers);
}
const cacheRetention = resolveCacheRetention(options.cacheRetention, options.env);
const inferenceMaxTokens = options.maxTokens ?? (isAnthropicClaudeModel(model) ? model.maxTokens : undefined);
let commandInput = {
modelId: model.id,
messages: convertMessages(context, model, cacheRetention),
system: buildSystemPrompt(context.systemPrompt, model, cacheRetention),
messages: convertMessages(context, model, cacheRetention, options.env),
system: buildSystemPrompt(context.systemPrompt, model, cacheRetention, options.env),
inferenceConfig: {
...(inferenceMaxTokens !== undefined && { maxTokens: inferenceMaxTokens }),
...(options.temperature !== undefined && { temperature: options.temperature }),
@@ -280,6 +306,13 @@ const BEDROCK_ERROR_PREFIXES: Record<string, string> = {
ServiceUnavailableException: "Service unavailable",
};
/**
* Some models reject the account/profile's configured Bedrock data retention mode
* (e.g. "data retention mode 'default' is not available for this model"). Point
* users at the AWS docs explaining how to configure a supported mode.
*/
const BEDROCK_DATA_RETENTION_DOCS_URL = "https://docs.aws.amazon.com/bedrock/latest/userguide/data-retention.html";
/**
* Format a Bedrock error with a human-readable prefix.
* AWS SDK exceptions (both from `client.send()` and from stream event items)
@@ -289,11 +322,50 @@ const BEDROCK_ERROR_PREFIXES: Record<string, string> = {
*/
function formatBedrockError(error: unknown): string {
const message = error instanceof Error ? error.message : JSON.stringify(error);
const dataRetentionHint = /data retention mode/i.test(message)
? ` See ${BEDROCK_DATA_RETENTION_DOCS_URL} for supported data retention modes.`
: "";
if (error instanceof BedrockRuntimeServiceException) {
const prefix = BEDROCK_ERROR_PREFIXES[error.name] ?? error.name;
return `${prefix}: ${message}`;
return `${prefix}: ${message}${dataRetentionHint}`;
}
return message;
return `${message}${dataRetentionHint}`;
}
/**
* Header keys that must never be overwritten by caller-supplied headers.
* `host` and `x-amz-*` participate in the SigV4 canonical request; `authorization`
* is owned by SigV4 or the bearer-token path (config.token + authSchemePreference).
* Compared case-insensitively (caller key is lower-cased before lookup).
*/
const RESERVED_HEADER_EXACT = new Set(["authorization", "host"]);
function isReservedHeader(key: string): boolean {
const lower = key.toLowerCase();
return lower.startsWith("x-amz-") || RESERVED_HEADER_EXACT.has(lower);
}
/**
* Attach caller-supplied headers to the outgoing Bedrock request via a Smithy
* `build`-step middleware. The `build` step runs after request serialisation but
* before SigV4 signing, so injected headers are covered by the signature. Reserved
* SigV4 / auth headers (`x-amz-*`, `authorization`, `host`) are silently skipped;
* all other caller headers override any existing same-named header on the request.
*/
function addCustomHeadersMiddleware(client: BedrockRuntimeClient, headers: Record<string, string>): void {
const middleware: BuildMiddleware<object, MetadataBearer> = (next) => async (args) => {
const request = args.request;
if (request && typeof request === "object" && "headers" in request) {
const requestHeaders = (request as { headers: Record<string, string> }).headers;
for (const [key, value] of Object.entries(headers)) {
if (!isReservedHeader(key)) {
requestHeaders[key] = value;
}
}
}
return next(args);
};
client.middlewareStack.add(middleware, { step: "build", name: "pi-ai-custom-headers", priority: "low" });
}
export const streamSimpleBedrock: StreamFunction<"bedrock-converse-stream", SimpleStreamOptions> = (
@@ -342,6 +414,14 @@ export const streamSimpleBedrock: StreamFunction<"bedrock-converse-stream", Simp
} satisfies BedrockOptions);
};
export function register(): void {
registerApiProvider({
api: "bedrock-converse-stream",
stream: streamBedrock,
streamSimple: streamSimpleBedrock,
});
}
function handleContentBlockStart(
event: ContentBlockStartEvent,
blocks: Block[],
@@ -481,12 +561,19 @@ function getModelMatchCandidates(modelId: string, modelName?: string): string[]
function supportsAdaptiveThinking(modelId: string, modelName?: string): boolean {
const candidates = getModelMatchCandidates(modelId, modelName);
return candidates.some((s) => s.includes("opus-4-6") || s.includes("opus-4-7") || s.includes("sonnet-4-6"));
return candidates.some(
(s) =>
s.includes("opus-4-6") ||
s.includes("opus-4-7") ||
s.includes("opus-4-8") ||
s.includes("sonnet-4-6") ||
s.includes("fable-5"),
);
}
function supportsNativeXhighEffort(model: Model<"bedrock-converse-stream">): boolean {
const candidates = getModelMatchCandidates(model.id, model.name);
return candidates.some((s) => s.includes("opus-4-7"));
return candidates.some((s) => s.includes("opus-4-7") || s.includes("opus-4-8") || s.includes("fable-5"));
}
function mapThinkingLevelToEffort(
@@ -515,11 +602,11 @@ function mapThinkingLevelToEffort(
* Resolve cache retention preference.
* Defaults to "short" and uses PI_CACHE_RETENTION for backward compatibility.
*/
function resolveCacheRetention(cacheRetention?: CacheRetention): CacheRetention {
function resolveCacheRetention(cacheRetention?: CacheRetention, env?: ProviderEnv): CacheRetention {
if (cacheRetention) {
return cacheRetention;
}
if (typeof process !== "undefined" && process.env.PI_CACHE_RETENTION === "long") {
if (getProviderEnvValue("PI_CACHE_RETENTION", env) === "long") {
return "long";
}
return "short";
@@ -554,14 +641,14 @@ function isAnthropicClaudeModel(model: Model<"bedrock-converse-stream">): boolea
* As a last resort, set AWS_BEDROCK_FORCE_CACHE=1 to enable cache points.
* Amazon Nova models have automatic caching and don't need explicit cache points.
*/
function supportsPromptCaching(model: Model<"bedrock-converse-stream">): boolean {
function supportsPromptCaching(model: Model<"bedrock-converse-stream">, env?: ProviderEnv): boolean {
const candidates = getModelMatchCandidates(model.id, model.name);
const hasClaudeRef = candidates.some((s) => s.includes("claude"));
if (!hasClaudeRef) {
// Application inference profiles don't contain the model name in the ARN.
// Allow users to force cache points via environment variable.
if (typeof process !== "undefined" && process.env.AWS_BEDROCK_FORCE_CACHE === "1") return true;
if (getProviderEnvValue("AWS_BEDROCK_FORCE_CACHE", env) === "1") return true;
return false;
}
// Claude 4.x models (opus-4, sonnet-4, haiku-4)
@@ -589,13 +676,14 @@ function buildSystemPrompt(
systemPrompt: string | undefined,
model: Model<"bedrock-converse-stream">,
cacheRetention: CacheRetention,
env?: ProviderEnv,
): SystemContentBlock[] | undefined {
if (!systemPrompt) return undefined;
const blocks: SystemContentBlock[] = [{ text: sanitizeSurrogates(systemPrompt) }];
// Add cache point for supported Claude models when caching is enabled
if (cacheRetention !== "none" && supportsPromptCaching(model)) {
if (cacheRetention !== "none" && supportsPromptCaching(model, env)) {
blocks.push({
cachePoint: { type: CachePointType.DEFAULT, ...(cacheRetention === "long" ? { ttl: CacheTTL.ONE_HOUR } : {}) },
});
@@ -609,10 +697,34 @@ function normalizeToolCallId(id: string): string {
return sanitized.length > 64 ? sanitized.slice(0, 64) : sanitized;
}
function createNonBlankTextBlock(text: string): ContentBlock.TextMember | undefined {
const sanitized = sanitizeSurrogates(text);
return sanitized.trim().length === 0 ? undefined : { text: sanitized };
}
function createRequiredTextBlock(text: string): ContentBlock.TextMember {
return createNonBlankTextBlock(text) ?? { text: EMPTY_TEXT_PLACEHOLDER };
}
function convertToolResultContent(content: (TextContent | ImageContent)[]): ToolResultContentBlock[] {
const result: ToolResultContentBlock[] = [];
for (const c of content) {
if (c.type === "image") {
result.push({ image: createImageBlock(c.mimeType, c.data) });
} else {
const textBlock = createNonBlankTextBlock(c.text);
if (textBlock) result.push(textBlock);
}
}
if (result.length === 0) result.push({ text: EMPTY_TEXT_PLACEHOLDER });
return result;
}
function convertMessages(
context: Context,
model: Model<"bedrock-converse-stream">,
cacheRetention: CacheRetention,
env?: ProviderEnv,
): Message[] {
const result: Message[] = [];
const transformedMessages = transformMessages(context.messages, model, normalizeToolCallId);
@@ -624,13 +736,15 @@ function convertMessages(
case "user": {
const content: ContentBlock[] = [];
if (typeof m.content === "string") {
content.push({ text: sanitizeSurrogates(m.content) });
content.push(createRequiredTextBlock(m.content));
} else {
for (const c of m.content) {
switch (c.type) {
case "text":
content.push({ text: sanitizeSurrogates(c.text) });
case "text": {
const textBlock = createNonBlankTextBlock(c.text);
if (textBlock) content.push(textBlock);
break;
}
case "image":
content.push({ image: createImageBlock(c.mimeType, c.data) });
break;
@@ -638,8 +752,8 @@ function convertMessages(
continue;
}
}
if (content.length === 0) content.push({ text: EMPTY_TEXT_PLACEHOLDER });
}
if (content.length === 0) continue;
result.push({
role: ConversationRole.USER,
content,
@@ -655,19 +769,22 @@ function convertMessages(
const contentBlocks: ContentBlock[] = [];
for (const c of m.content) {
switch (c.type) {
case "text":
case "text": {
// Skip empty text blocks
if (c.text.trim().length === 0) continue;
contentBlocks.push({ text: sanitizeSurrogates(c.text) });
const textBlock = createNonBlankTextBlock(c.text);
if (!textBlock) continue;
contentBlocks.push(textBlock);
break;
}
case "toolCall":
contentBlocks.push({
toolUse: { toolUseId: c.id, name: c.name, input: c.arguments },
});
break;
case "thinking":
case "thinking": {
// Skip empty thinking blocks
if (c.thinking.trim().length === 0) continue;
const thinking = sanitizeSurrogates(c.thinking);
if (thinking.trim().length === 0) continue;
// Only Anthropic models support the signature field in reasoningText.
// For other models, we omit the signature to avoid errors like:
// "This model doesn't support the reasoningContent.reasoningText.signature field"
@@ -676,12 +793,12 @@ function convertMessages(
// persisted message lacks a signature, Bedrock rejects the replayed
// reasoning block. Fall back to plain text, matching Anthropic.
if (!c.thinkingSignature || c.thinkingSignature.trim().length === 0) {
contentBlocks.push({ text: sanitizeSurrogates(c.thinking) });
contentBlocks.push({ text: thinking });
} else {
contentBlocks.push({
reasoningContent: {
reasoningText: {
text: sanitizeSurrogates(c.thinking),
text: thinking,
signature: c.thinkingSignature,
},
},
@@ -690,11 +807,12 @@ function convertMessages(
} else {
contentBlocks.push({
reasoningContent: {
reasoningText: { text: sanitizeSurrogates(c.thinking) },
reasoningText: { text: thinking },
},
});
}
break;
}
default:
continue;
}
@@ -718,11 +836,7 @@ function convertMessages(
toolResults.push({
toolResult: {
toolUseId: m.toolCallId,
content: m.content.map((c) =>
c.type === "image"
? { image: createImageBlock(c.mimeType, c.data) }
: { text: sanitizeSurrogates(c.text) },
),
content: convertToolResultContent(m.content),
status: m.isError ? ToolResultStatus.ERROR : ToolResultStatus.SUCCESS,
},
});
@@ -734,11 +848,7 @@ function convertMessages(
toolResults.push({
toolResult: {
toolUseId: nextMsg.toolCallId,
content: nextMsg.content.map((c) =>
c.type === "image"
? { image: createImageBlock(c.mimeType, c.data) }
: { text: sanitizeSurrogates(c.text) },
),
content: convertToolResultContent(nextMsg.content),
status: nextMsg.isError ? ToolResultStatus.ERROR : ToolResultStatus.SUCCESS,
},
});
@@ -760,7 +870,7 @@ function convertMessages(
}
// Add cache point to the last user message for supported Claude models when caching is enabled
if (cacheRetention !== "none" && supportsPromptCaching(model) && result.length > 0) {
if (cacheRetention !== "none" && supportsPromptCaching(model, env) && result.length > 0) {
const lastMessage = result[result.length - 1];
if (lastMessage.role === ConversationRole.USER && lastMessage.content) {
(lastMessage.content as ContentBlock[]).push({
@@ -822,19 +932,26 @@ function mapStopReason(reason: string | undefined): StopReason {
}
function getConfiguredBedrockRegion(options: BedrockOptions): string | undefined {
if (typeof process === "undefined") {
return options.region;
}
return options.region || process.env.AWS_REGION || process.env.AWS_DEFAULT_REGION || undefined;
return (
options.region ||
getProviderEnvValue("AWS_REGION", options.env) ||
getProviderEnvValue("AWS_DEFAULT_REGION", options.env) ||
undefined
);
}
function hasConfiguredBedrockProfile(): boolean {
if (typeof process === "undefined") {
return false;
function getConfiguredBedrockCredentials(env?: ProviderEnv): BedrockRuntimeClientConfig["credentials"] | undefined {
const accessKeyId = getProviderEnvValue("AWS_ACCESS_KEY_ID", env);
const secretAccessKey = getProviderEnvValue("AWS_SECRET_ACCESS_KEY", env);
if (!accessKeyId || !secretAccessKey) {
return undefined;
}
return Boolean(process.env.AWS_PROFILE);
const sessionToken = getProviderEnvValue("AWS_SESSION_TOKEN", env);
return {
accessKeyId,
secretAccessKey,
...(sessionToken ? { sessionToken } : {}),
};
}
function getStandardBedrockEndpointRegion(baseUrl: string | undefined): string | undefined {
@@ -854,14 +971,14 @@ function getStandardBedrockEndpointRegion(baseUrl: string | undefined): string |
function shouldUseExplicitBedrockEndpoint(
baseUrl: string,
configuredRegion: string | undefined,
hasConfiguredProfile: boolean,
hasAmbientConfiguredProfile: boolean,
): boolean {
const endpointRegion = getStandardBedrockEndpointRegion(baseUrl);
if (!endpointRegion) {
return true;
}
return !configuredRegion && !hasConfiguredProfile;
return !configuredRegion && !hasAmbientConfiguredProfile;
}
function isGovCloudBedrockTarget(model: Model<"bedrock-converse-stream">, options: BedrockOptions): boolean {

View File

@@ -5,8 +5,9 @@ import type {
MessageCreateParamsStreaming,
MessageParam,
RawMessageStreamEvent,
RefusalStopDetails,
} from "@anthropic-ai/sdk/resources/messages.js";
import { getEnvApiKey } from "../env-api-keys.ts";
import { registerApiProvider } from "../api-registry.ts";
import { calculateCost } from "../models.ts";
import type {
AnthropicMessagesCompat,
@@ -17,6 +18,7 @@ import type {
ImageContent,
Message,
Model,
ProviderEnv,
SimpleStreamOptions,
StopReason,
StreamFunction,
@@ -30,6 +32,7 @@ import type {
import { AssistantMessageEventStream } from "../utils/event-stream.ts";
import { headersToRecord } from "../utils/headers.ts";
import { parseJsonWithRepair, parseStreamingJson } from "../utils/json-parse.ts";
import { getProviderEnvValue } from "../utils/provider-env.ts";
import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts";
import { resolveCloudflareBaseUrl } from "./cloudflare.ts";
@@ -41,11 +44,11 @@ import { transformMessages } from "./transform-messages.ts";
* Resolve cache retention preference.
* Defaults to "short" and uses PI_CACHE_RETENTION for backward compatibility.
*/
function resolveCacheRetention(cacheRetention?: CacheRetention): CacheRetention {
function resolveCacheRetention(cacheRetention?: CacheRetention, env?: ProviderEnv): CacheRetention {
if (cacheRetention) {
return cacheRetention;
}
if (typeof process !== "undefined" && process.env.PI_CACHE_RETENTION === "long") {
if (getProviderEnvValue("PI_CACHE_RETENTION", env) === "long") {
return "long";
}
return "short";
@@ -54,8 +57,9 @@ function resolveCacheRetention(cacheRetention?: CacheRetention): CacheRetention
function getCacheControl(
model: Model<"anthropic-messages">,
cacheRetention?: CacheRetention,
env?: ProviderEnv,
): { retention: CacheRetention; cacheControl?: CacheControlEphemeral } {
const retention = resolveCacheRetention(cacheRetention);
const retention = resolveCacheRetention(cacheRetention, env);
if (retention === "none") {
return { retention };
}
@@ -164,7 +168,9 @@ export type AnthropicThinkingDisplay = "summarized" | "omitted";
const FINE_GRAINED_TOOL_STREAMING_BETA = "fine-grained-tool-streaming-2025-05-14";
const INTERLEAVED_THINKING_BETA = "interleaved-thinking-2025-05-14";
function getAnthropicCompat(model: Model<"anthropic-messages">): Required<AnthropicMessagesCompat> {
function getAnthropicCompat(
model: Model<"anthropic-messages">,
): Required<Omit<AnthropicMessagesCompat, "forceAdaptiveThinking">> {
// Auto-detect session affinity and cache control support from provider
const isFireworks = model.provider === "fireworks";
const isCloudflareAiGatewayAnthropic =
@@ -175,35 +181,42 @@ function getAnthropicCompat(model: Model<"anthropic-messages">): Required<Anthro
sendSessionAffinityHeaders:
model.compat?.sendSessionAffinityHeaders ?? !!(isFireworks || isCloudflareAiGatewayAnthropic),
supportsCacheControlOnTools: model.compat?.supportsCacheControlOnTools ?? !isFireworks,
supportsTemperature: model.compat?.supportsTemperature ?? true,
allowEmptySignature: model.compat?.allowEmptySignature ?? false,
};
}
export interface AnthropicOptions extends StreamOptions {
/**
* Enable extended thinking.
* For Opus 4.6 and Sonnet 4.6: uses adaptive thinking (model decides when/how much to think).
* For adaptive thinking models: the model decides when/how much to think.
* For older models: uses budget-based thinking with thinkingBudgetTokens.
* Default: undefined (thinking is omitted unless `streamSimpleAnthropic()` maps
* a simple reasoning level to this option, or callers set it explicitly).
*/
thinkingEnabled?: boolean;
/**
* Token budget for extended thinking (older models only).
* Ignored for Opus 4.6 and Sonnet 4.6, which use adaptive thinking.
* Ignored for adaptive thinking models.
* Default: 1024 when `thinkingEnabled` is true and no budget is provided.
*/
thinkingBudgetTokens?: number;
/**
* Effort level for adaptive thinking (Opus 4.6+ and Sonnet 4.6).
* Effort level for adaptive thinking models.
* Controls how much thinking Claude allocates:
* - "max": Always thinks with no constraints (Opus 4.6 only)
* - "xhigh": Highest reasoning level (Opus 4.7)
* - "high": Always thinks, deep reasoning (default)
* - "xhigh": Highest reasoning level (Opus 4.7+, Fable 5)
* - "high": Always thinks, deep reasoning
* - "medium": Moderate thinking, may skip for simple queries
* - "low": Minimal thinking, skips for simple tasks
* Ignored for older models.
* Default: omitted unless `streamSimpleAnthropic()` maps a simple reasoning
* level to this option.
*/
effort?: AnthropicEffort;
/**
* Controls how thinking content is returned in API responses.
* - "summarized": Thinking blocks contain summarized thinking text (default here).
* - "summarized": Thinking blocks contain summarized thinking text.
* - "omitted": Thinking blocks return an empty thinking field; the encrypted
* signature still travels back for multi-turn continuity. Use for faster
* time-to-first-text-token when your UI does not surface thinking.
@@ -211,9 +224,21 @@ export interface AnthropicOptions extends StreamOptions {
* Note: Anthropic's API default for Claude Opus 4.7 and Claude Mythos Preview
* is "omitted". We default to "summarized" here to keep behavior consistent
* with older Claude 4 models. Set this explicitly to "omitted" to opt in.
* Default: "summarized" when thinking is enabled.
*/
thinkingDisplay?: AnthropicThinkingDisplay;
/**
* Whether to request the interleaved thinking beta header for non-adaptive
* thinking models. Adaptive thinking models have interleaved thinking built in,
* so the header is skipped for them regardless of this setting.
* Default: true.
*/
interleavedThinking?: boolean;
/**
* Anthropic tool choice behavior. String values map to Anthropic's built-in
* choices; `{ type: "tool", name }` forces a specific tool.
* Default: omitted (Anthropic default behavior, currently equivalent to auto).
*/
toolChoice?: "auto" | "any" | "none" | { type: "tool"; name: string };
/**
* Pre-built Anthropic client instance. When provided, skips internal client
@@ -459,7 +484,10 @@ export const streamAnthropic: StreamFunction<"anthropic-messages", AnthropicOpti
client = options.client;
isOAuth = false;
} else {
const apiKey = options?.apiKey ?? getEnvApiKey(model.provider) ?? "";
const apiKey = options?.apiKey;
if (!apiKey) {
throw new Error(`No API key for provider: ${model.provider}`);
}
let copilotDynamicHeaders: Record<string, string> | undefined;
if (model.provider === "github-copilot") {
@@ -470,7 +498,7 @@ export const streamAnthropic: StreamFunction<"anthropic-messages", AnthropicOpti
});
}
const cacheRetention = options?.cacheRetention ?? resolveCacheRetention();
const cacheRetention = resolveCacheRetention(options?.cacheRetention, options?.env);
const cacheSessionId = cacheRetention === "none" ? undefined : options?.sessionId;
const created = createClient(
@@ -481,6 +509,7 @@ export const streamAnthropic: StreamFunction<"anthropic-messages", AnthropicOpti
options?.headers,
copilotDynamicHeaders,
cacheSessionId,
options?.env,
);
client = created.client;
isOAuth = created.isOAuthToken;
@@ -493,7 +522,7 @@ export const streamAnthropic: StreamFunction<"anthropic-messages", AnthropicOpti
const requestOptions = {
...(options?.signal ? { signal: options.signal } : {}),
...(options?.timeoutMs !== undefined ? { timeout: options.timeoutMs } : {}),
...(options?.maxRetries !== undefined ? { maxRetries: options.maxRetries } : {}),
maxRetries: options?.maxRetries ?? 0,
};
const response = await client.messages.create({ ...params, stream: true }, requestOptions).asResponse();
await options?.onResponse?.({ status: response.status, headers: headersToRecord(response.headers) }, model);
@@ -511,6 +540,7 @@ export const streamAnthropic: StreamFunction<"anthropic-messages", AnthropicOpti
output.usage.output = event.message.usage.output_tokens || 0;
output.usage.cacheRead = event.message.usage.cache_read_input_tokens || 0;
output.usage.cacheWrite = event.message.usage.cache_creation_input_tokens || 0;
output.usage.cacheWrite1h = event.message.usage.cache_creation?.ephemeral_1h_input_tokens || 0;
// Anthropic doesn't provide total_tokens, compute from components
output.usage.totalTokens =
output.usage.input + output.usage.output + output.usage.cacheRead + output.usage.cacheWrite;
@@ -637,7 +667,11 @@ export const streamAnthropic: StreamFunction<"anthropic-messages", AnthropicOpti
}
} else if (event.type === "message_delta") {
if (event.delta.stop_reason) {
output.stopReason = mapStopReason(event.delta.stop_reason);
const stopReasonResult = mapStopReason(event.delta.stop_reason, event.delta.stop_details);
output.stopReason = stopReasonResult.stopReason;
if (stopReasonResult.errorMessage) {
output.errorMessage = stopReasonResult.errorMessage;
}
}
// Only update usage fields if present (not null).
// Preserves input_tokens from message_start when proxies omit it in message_delta.
@@ -665,7 +699,7 @@ export const streamAnthropic: StreamFunction<"anthropic-messages", AnthropicOpti
}
if (output.stopReason === "aborted" || output.stopReason === "error") {
throw new Error("An unknown error occurred");
throw new Error(output.errorMessage || "An unknown error occurred");
}
stream.push({ type: "done", reason: output.stopReason, message: output });
@@ -686,24 +720,9 @@ export const streamAnthropic: StreamFunction<"anthropic-messages", AnthropicOpti
return stream;
};
/**
* Check if a model supports adaptive thinking (Opus 4.6+, Sonnet 4.6)
*/
function supportsAdaptiveThinking(modelId: string): boolean {
// Adaptive-thinking model IDs (with or without date suffix)
return (
modelId.includes("opus-4-6") ||
modelId.includes("opus-4.6") ||
modelId.includes("opus-4-7") ||
modelId.includes("opus-4.7") ||
modelId.includes("sonnet-4-6") ||
modelId.includes("sonnet-4.6")
);
}
/**
* Map ThinkingLevel to Anthropic effort levels for adaptive thinking.
* Note: effort "max" is only valid on Opus 4.6, while Opus 4.7 supports "xhigh".
* Note: effort "max" is only valid on Opus 4.6, while Opus 4.7+ and Fable 5 support "xhigh".
*/
function mapThinkingLevelToEffort(
model: Model<"anthropic-messages">,
@@ -730,7 +749,7 @@ export const streamSimpleAnthropic: StreamFunction<"anthropic-messages", SimpleS
context: Context,
options?: SimpleStreamOptions,
): AssistantMessageEventStream => {
const apiKey = options?.apiKey || getEnvApiKey(model.provider);
const apiKey = options?.apiKey;
if (!apiKey) {
throw new Error(`No API key for provider: ${model.provider}`);
}
@@ -740,9 +759,9 @@ export const streamSimpleAnthropic: StreamFunction<"anthropic-messages", SimpleS
return streamAnthropic(model, context, { ...base, thinkingEnabled: false } satisfies AnthropicOptions);
}
// For Opus 4.6 and Sonnet 4.6: use adaptive thinking with effort level
// For older models: use budget-based thinking
if (supportsAdaptiveThinking(model.id)) {
// For models with adaptive thinking: use an effort level.
// For older models: use budget-based thinking.
if (model.compat?.forceAdaptiveThinking === true) {
const effort = mapThinkingLevelToEffort(model, options.reasoning);
return streamAnthropic(model, context, {
...base,
@@ -768,6 +787,14 @@ export const streamSimpleAnthropic: StreamFunction<"anthropic-messages", SimpleS
} satisfies AnthropicOptions);
};
export function register(): void {
registerApiProvider({
api: "anthropic-messages",
stream: streamAnthropic,
streamSimple: streamSimpleAnthropic,
});
}
function isOAuthToken(apiKey: string): boolean {
return apiKey.includes("sk-ant-oat");
}
@@ -780,10 +807,10 @@ function createClient(
optionsHeaders?: Record<string, string>,
dynamicHeaders?: Record<string, string>,
sessionId?: string,
env?: ProviderEnv,
): { client: Anthropic; isOAuthToken: boolean } {
// Adaptive thinking models (Opus 4.6, Sonnet 4.6) have interleaved thinking built-in.
// The beta header is deprecated on Opus 4.6 and redundant on Sonnet 4.6, so skip it.
const needsInterleavedBeta = interleavedThinking && !supportsAdaptiveThinking(model.id);
// Adaptive thinking models have interleaved thinking built in, so skip the beta header.
const needsInterleavedBeta = interleavedThinking && model.compat?.forceAdaptiveThinking !== true;
const betaFeatures: string[] = [];
if (useFineGrainedToolStreamingBeta) {
betaFeatures.push(FINE_GRAINED_TOOL_STREAMING_BETA);
@@ -796,7 +823,7 @@ function createClient(
const client = new Anthropic({
apiKey: null,
authToken: null,
baseURL: resolveCloudflareBaseUrl(model),
baseURL: resolveCloudflareBaseUrl(model, env),
dangerouslyAllowBrowser: true,
defaultHeaders: mergeHeaders(
{
@@ -889,10 +916,11 @@ function buildParams(
isOAuthToken: boolean,
options?: AnthropicOptions,
): MessageCreateParamsStreaming {
const { cacheControl } = getCacheControl(model, options?.cacheRetention);
const { cacheControl } = getCacheControl(model, options?.cacheRetention, options?.env);
const compat = getAnthropicCompat(model);
const params: MessageCreateParamsStreaming = {
model: model.id,
messages: convertMessages(context.messages, model, isOAuthToken, cacheControl),
messages: convertMessages(context.messages, model, isOAuthToken, cacheControl, compat.allowEmptySignature),
max_tokens: options?.maxTokens ?? model.maxTokens,
stream: true,
};
@@ -924,13 +952,12 @@ function buildParams(
];
}
// Temperature is incompatible with extended thinking (adaptive or budget-based).
if (options?.temperature !== undefined && !options?.thinkingEnabled) {
// Temperature is incompatible with extended thinking and unsupported on Claude Opus 4.7+.
if (options?.temperature !== undefined && !options?.thinkingEnabled && compat.supportsTemperature) {
params.temperature = options.temperature;
}
if (context.tools && context.tools.length > 0) {
const compat = getAnthropicCompat(model);
params.tools = convertTools(
context.tools,
isOAuthToken,
@@ -939,14 +966,13 @@ function buildParams(
);
}
// Configure thinking mode: adaptive (Opus 4.6+ and Sonnet 4.6),
// budget-based (older models), or explicitly disabled.
// Configure thinking mode: adaptive, budget-based, or explicitly disabled.
if (model.reasoning) {
if (options?.thinkingEnabled) {
// Default to "summarized" so Opus 4.7 and Mythos Preview behave like
// older Claude 4 models (whose API default is also "summarized").
const display: AnthropicThinkingDisplay = options.thinkingDisplay ?? "summarized";
if (supportsAdaptiveThinking(model.id)) {
if (model.compat?.forceAdaptiveThinking === true) {
// Adaptive thinking: Claude decides when and how much to think.
params.thinking = { type: "adaptive", display };
if (options.effort) {
@@ -966,7 +992,7 @@ function buildParams(
display,
};
}
} else if (options?.thinkingEnabled === false) {
} else if (options?.thinkingEnabled === false && model.thinkingLevelMap?.off !== null) {
params.thinking = { type: "disabled" };
}
}
@@ -999,6 +1025,7 @@ function convertMessages(
model: Model<"anthropic-messages">,
isOAuthToken: boolean,
cacheControl?: CacheControlEphemeral,
allowEmptySignature = false,
): MessageParam[] {
const params: MessageParam[] = [];
@@ -1067,13 +1094,21 @@ function convertMessages(
}
if (block.thinking.trim().length === 0) continue;
// If thinking signature is missing/empty (e.g., from aborted stream),
// convert to plain text block without <thinking> tags to avoid API rejection
// and prevent Claude from mimicking the tags in responses
// convert to plain text for Anthropic. Some compatible providers emit
// and accept empty signatures, so let marked models preserve the block.
if (!block.thinkingSignature || block.thinkingSignature.trim().length === 0) {
blocks.push({
type: "text",
text: sanitizeSurrogates(block.thinking),
});
blocks.push(
allowEmptySignature
? {
type: "thinking",
thinking: sanitizeSurrogates(block.thinking),
signature: "",
}
: {
type: "text",
text: sanitizeSurrogates(block.thinking),
},
);
} else {
blocks.push({
type: "thinking",
@@ -1187,22 +1222,28 @@ function convertTools(
});
}
function mapStopReason(reason: Anthropic.Messages.StopReason | string): StopReason {
function mapStopReason(
reason: Anthropic.Messages.StopReason | string,
stopDetails?: RefusalStopDetails | null,
): { stopReason: StopReason; errorMessage?: string } {
switch (reason) {
case "end_turn":
return "stop";
return { stopReason: "stop" };
case "max_tokens":
return "length";
return { stopReason: "length" };
case "tool_use":
return "toolUse";
return { stopReason: "toolUse" };
case "refusal":
return "error";
return {
stopReason: "error",
errorMessage: stopDetails?.explanation || `The model refused to complete the request`,
};
case "pause_turn": // Stop is good enough -> resubmit
return "stop";
return { stopReason: "stop" };
case "stop_sequence":
return "stop"; // We don't supply stop sequences, so this should never happen
return { stopReason: "stop" }; // We don't supply stop sequences, so this should never happen
case "sensitive": // Content flagged by safety filters (not yet in SDK types)
return "error";
return { stopReason: "error" };
default:
// Handle unknown stop reasons gracefully (API may add new values)
throw new Error(`Unhandled stop reason: ${reason}`);

View File

@@ -1,6 +1,6 @@
import { AzureOpenAI } from "openai";
import type { ResponseCreateParamsStreaming } from "openai/resources/responses/responses.js";
import { getEnvApiKey } from "../env-api-keys.ts";
import { registerApiProvider } from "../api-registry.ts";
import { clampThinkingLevel } from "../models.ts";
import type {
Api,
@@ -13,6 +13,7 @@ import type {
} from "../types.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";
@@ -37,7 +38,9 @@ function resolveDeploymentName(model: Model<"azure-openai-responses">, options?:
if (options?.azureDeploymentName) {
return options.azureDeploymentName;
}
const mappedDeployment = parseDeploymentNameMap(process.env.AZURE_OPENAI_DEPLOYMENT_NAME_MAP).get(model.id);
const mappedDeployment = parseDeploymentNameMap(
getProviderEnvValue("AZURE_OPENAI_DEPLOYMENT_NAME_MAP", options?.env),
).get(model.id);
return mappedDeployment || model.id;
}
@@ -101,7 +104,10 @@ export const streamAzureOpenAIResponses: StreamFunction<"azure-openai-responses"
try {
// Create Azure OpenAI client
const apiKey = options?.apiKey || getEnvApiKey(model.provider) || "";
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);
@@ -111,7 +117,7 @@ export const streamAzureOpenAIResponses: StreamFunction<"azure-openai-responses"
const requestOptions = {
...(options?.signal ? { signal: options.signal } : {}),
...(options?.timeoutMs !== undefined ? { timeout: options.timeoutMs } : {}),
...(options?.maxRetries !== undefined ? { maxRetries: options.maxRetries } : {}),
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);
@@ -150,7 +156,7 @@ export const streamSimpleAzureOpenAIResponses: StreamFunction<"azure-openai-resp
context: Context,
options?: SimpleStreamOptions,
): AssistantMessageEventStream => {
const apiKey = options?.apiKey || getEnvApiKey(model.provider);
const apiKey = options?.apiKey;
if (!apiKey) {
throw new Error(`No API key for provider: ${model.provider}`);
}
@@ -165,6 +171,14 @@ export const streamSimpleAzureOpenAIResponses: StreamFunction<"azure-openai-resp
} satisfies AzureOpenAIResponsesOptions);
};
export function register(): void {
registerApiProvider({
api: "azure-openai-responses",
stream: streamAzureOpenAIResponses,
streamSimple: streamSimpleAzureOpenAIResponses,
});
}
function normalizeAzureBaseUrl(baseUrl: string): string {
const trimmed = baseUrl.trim().replace(/\/+$/, "");
let url: URL;
@@ -196,10 +210,14 @@ function resolveAzureConfig(
model: Model<"azure-openai-responses">,
options?: AzureOpenAIResponsesOptions,
): { baseUrl: string; apiVersion: string } {
const apiVersion = options?.azureApiVersion || process.env.AZURE_OPENAI_API_VERSION || DEFAULT_AZURE_API_VERSION;
const apiVersion =
options?.azureApiVersion ||
getProviderEnvValue("AZURE_OPENAI_API_VERSION", options?.env) ||
DEFAULT_AZURE_API_VERSION;
const baseUrl = options?.azureBaseUrl?.trim() || process.env.AZURE_OPENAI_BASE_URL?.trim() || undefined;
const resourceName = options?.azureResourceName || process.env.AZURE_OPENAI_RESOURCE_NAME;
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;
@@ -224,15 +242,6 @@ function resolveAzureConfig(
}
function createClient(model: Model<"azure-openai-responses">, apiKey: string, options?: AzureOpenAIResponsesOptions) {
if (!apiKey) {
if (!process.env.AZURE_OPENAI_API_KEY) {
throw new Error(
"Azure OpenAI API key is required. Set AZURE_OPENAI_API_KEY environment variable or pass it as an argument.",
);
}
apiKey = process.env.AZURE_OPENAI_API_KEY;
}
const headers = { ...model.headers };
if (options?.headers) {
@@ -263,6 +272,7 @@ function buildParams(
input: messages,
stream: true,
prompt_cache_key: clampOpenAIPromptCacheKey(options?.sessionId),
store: false,
};
if (options?.maxTokens) {

View File

@@ -1,4 +1,5 @@
import type { Api, Model } from "../types.ts";
import type { Api, Model, ProviderEnv } from "../types.ts";
import { getProviderEnvValue } from "../utils/provider-env.ts";
/** Workers AI direct endpoint. */
export const CLOUDFLARE_WORKERS_AI_BASE_URL =
@@ -20,12 +21,12 @@ export function isCloudflareProvider(provider: string): boolean {
return provider === "cloudflare-workers-ai" || provider === "cloudflare-ai-gateway";
}
/** Substitute `{VAR}` placeholders in a Cloudflare baseUrl from process.env. */
export function resolveCloudflareBaseUrl(model: Model<Api>): string {
/** Substitute `{VAR}` placeholders in a Cloudflare baseUrl from provider env or process.env. */
export function resolveCloudflareBaseUrl(model: Model<Api>, env?: ProviderEnv): string {
const url = model.baseUrl;
if (!url.includes("{")) return url;
const baseUrl = url.replace(/\{([A-Z_][A-Z0-9_]*)\}/g, (_match, name: string) => {
const value = process.env[name];
const value = getProviderEnvValue(name, env);
if (!value) {
throw new Error(`${name} is required for provider ${model.provider} but is not set.`);
}

View File

@@ -7,6 +7,7 @@ import {
type ThinkingConfig,
ThinkingLevel,
} from "@google/genai";
import { registerApiProvider } from "../api-registry.ts";
import { calculateCost, clampThinkingLevel } from "../models.ts";
import type {
Api,
@@ -14,6 +15,7 @@ import type {
Context,
Model,
ThinkingLevel as PiThinkingLevel,
ProviderEnv,
SimpleStreamOptions,
StreamFunction,
StreamOptions,
@@ -23,6 +25,7 @@ import type {
ToolCall,
} from "../types.ts";
import { AssistantMessageEventStream } from "../utils/event-stream.ts";
import { getProviderEnvValue } from "../utils/provider-env.ts";
import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts";
import type { GoogleThinkingLevel } from "./google-shared.ts";
import {
@@ -91,7 +94,7 @@ export const streamGoogleVertex: StreamFunction<"google-vertex", GoogleVertexOpt
// 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);
: 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) {
@@ -328,17 +331,28 @@ export const streamSimpleGoogleVertex: StreamFunction<"google-vertex", SimpleStr
} satisfies GoogleVertexOptions);
};
export function register(): void {
registerApiProvider({
api: "google-vertex",
stream: streamGoogleVertex,
streamSimple: streamSimpleGoogleVertex,
});
}
function createClient(
model: Model<"google-vertex">,
project: string,
location: string,
optionsHeaders?: Record<string, string>,
env?: ProviderEnv,
): GoogleGenAI {
const googleAuthOptions = buildGoogleAuthOptions(env);
return new GoogleGenAI({
vertexai: true,
project,
location,
apiVersion: API_VERSION,
...(googleAuthOptions ? { googleAuthOptions } : {}),
httpOptions: buildHttpOptions(model, optionsHeaders),
});
}
@@ -394,8 +408,13 @@ function baseUrlIncludesApiVersion(baseUrl: string): boolean {
}
}
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() || process.env.GOOGLE_CLOUD_API_KEY?.trim();
const apiKey = options?.apiKey?.trim();
if (!apiKey || apiKey === GCP_VERTEX_CREDENTIALS_MARKER || isPlaceholderApiKey(apiKey)) {
return undefined;
}
@@ -407,7 +426,10 @@ function isPlaceholderApiKey(apiKey: string): boolean {
}
function resolveProject(options?: GoogleVertexOptions): string {
const project = options?.project || process.env.GOOGLE_CLOUD_PROJECT || process.env.GCLOUD_PROJECT;
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.",
@@ -417,7 +439,7 @@ function resolveProject(options?: GoogleVertexOptions): string {
}
function resolveLocation(options?: GoogleVertexOptions): string {
const location = options?.location || process.env.GOOGLE_CLOUD_LOCATION;
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.");
}
@@ -490,7 +512,8 @@ function isGemini3ProModel(model: Model<"google-generative-ai">): boolean {
}
function isGemini3FlashModel(model: Model<"google-generative-ai">): boolean {
return /gemini-3(?:\.\d+)?-flash/.test(model.id.toLowerCase());
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 {

View File

@@ -4,7 +4,7 @@ import {
GoogleGenAI,
type ThinkingConfig,
} from "@google/genai";
import { getEnvApiKey } from "../env-api-keys.ts";
import { registerApiProvider } from "../api-registry.ts";
import { calculateCost, clampThinkingLevel } from "../models.ts";
import type {
Api,
@@ -72,7 +72,10 @@ export const streamGoogle: StreamFunction<"google-generative-ai", GoogleOptions>
};
try {
const apiKey = options?.apiKey || getEnvApiKey(model.provider) || "";
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);
@@ -280,7 +283,7 @@ export const streamSimpleGoogle: StreamFunction<"google-generative-ai", SimpleSt
context: Context,
options?: SimpleStreamOptions,
): AssistantMessageEventStream => {
const apiKey = options?.apiKey || getEnvApiKey(model.provider);
const apiKey = options?.apiKey;
if (!apiKey) {
throw new Error(`No API key for provider: ${model.provider}`);
}
@@ -313,6 +316,14 @@ export const streamSimpleGoogle: StreamFunction<"google-generative-ai", SimpleSt
} satisfies GoogleOptions);
};
export function register(): void {
registerApiProvider({
api: "google-generative-ai",
stream: streamGoogle,
streamSimple: streamSimpleGoogle,
});
}
function createClient(
model: Model<"google-generative-ai">,
apiKey?: string,
@@ -404,7 +415,8 @@ function isGemini3ProModel(model: Model<"google-generative-ai">): boolean {
}
function isGemini3FlashModel(model: Model<"google-generative-ai">): boolean {
return /gemini-3(?:\.\d+)?-flash/.test(model.id.toLowerCase());
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 {

View File

@@ -6,7 +6,7 @@ import type {
ChatCompletionContentPartText,
ChatCompletionCreateParamsNonStreaming,
} from "openai/resources/chat/completions.js";
import { getEnvApiKey } from "../../env-api-keys.ts";
import { registerImagesApiProvider } from "../../images-api-registry.ts";
import type {
AssistantImages,
ImageContent,
@@ -50,9 +50,9 @@ export const generateImagesOpenRouter: ImagesFunction<"openrouter-images", Image
};
try {
const apiKey = options?.apiKey || getEnvApiKey(model.provider);
const apiKey = options?.apiKey;
if (!apiKey) {
throw new Error(`No API key available for provider: ${model.provider}`);
throw new Error(`No API key for provider: ${model.provider}`);
}
const client = createClient(model, apiKey, options?.headers);
let params = buildParams(model, context);
@@ -63,7 +63,7 @@ export const generateImagesOpenRouter: ImagesFunction<"openrouter-images", Image
const requestOptions = {
...(options?.signal ? { signal: options.signal } : {}),
...(options?.timeoutMs !== undefined ? { timeout: options.timeoutMs } : {}),
...(options?.maxRetries !== undefined ? { maxRetries: options.maxRetries } : {}),
maxRetries: options?.maxRetries ?? 0,
};
const { data: response, response: rawResponse } = await client.chat.completions
.create(params as unknown as ChatCompletionCreateParamsNonStreaming, requestOptions)
@@ -104,6 +104,13 @@ export const generateImagesOpenRouter: ImagesFunction<"openrouter-images", Image
}
};
export function register(): void {
registerImagesApiProvider({
api: "openrouter-images",
generateImages: generateImagesOpenRouter,
});
}
function createClient(
model: ImagesModel<"openrouter-images">,
apiKey: string,

View File

@@ -1,50 +0,0 @@
import { registerImagesApiProvider } from "../../images-api-registry.ts";
import type { AssistantImages, ImagesContext, ImagesFunction, ImagesModel, ImagesOptions } from "../../types.ts";
import type { generateImagesOpenRouter as generateImagesOpenRouterFunction } from "./openrouter.ts";
interface OpenRouterImagesProviderModule {
generateImagesOpenRouter: typeof generateImagesOpenRouterFunction;
}
let openRouterImagesProviderModulePromise: Promise<OpenRouterImagesProviderModule> | undefined;
function createLazyLoadErrorImages(model: ImagesModel<"openrouter-images">, error: unknown): AssistantImages {
return {
api: model.api,
provider: model.provider,
model: model.id,
output: [],
stopReason: "error",
errorMessage: error instanceof Error ? error.message : String(error),
timestamp: Date.now(),
};
}
function loadOpenRouterImagesProviderModule(): Promise<OpenRouterImagesProviderModule> {
openRouterImagesProviderModulePromise ||= import("./openrouter.ts").then(
(module) => module as OpenRouterImagesProviderModule,
);
return openRouterImagesProviderModulePromise;
}
export const generateImagesOpenRouter: ImagesFunction<"openrouter-images", ImagesOptions> = async (
model: ImagesModel<"openrouter-images">,
context: ImagesContext,
options?: ImagesOptions,
) => {
try {
const module = await loadOpenRouterImagesProviderModule();
return await module.generateImagesOpenRouter(model, context, options);
} catch (error) {
return createLazyLoadErrorImages(model, error);
}
};
export function registerBuiltInImagesApiProviders(): void {
registerImagesApiProvider({
api: "openrouter-images",
generateImages: generateImagesOpenRouter,
});
}
registerBuiltInImagesApiProviders();

View File

@@ -6,7 +6,7 @@ import type {
ContentChunk,
FunctionTool,
} from "@mistralai/mistralai/models/components";
import { getEnvApiKey } from "../env-api-keys.ts";
import { registerApiProvider } from "../api-registry.ts";
import { calculateCost, clampThinkingLevel } from "../models.ts";
import type {
AssistantMessage,
@@ -57,7 +57,7 @@ export const streamMistral: StreamFunction<"mistral-conversations", MistralOptio
const output = createOutput(model);
try {
const apiKey = options?.apiKey || getEnvApiKey(model.provider);
const apiKey = options?.apiKey;
if (!apiKey) {
throw new Error(`No API key for provider: ${model.provider}`);
}
@@ -113,7 +113,7 @@ export const streamSimpleMistral: StreamFunction<"mistral-conversations", Simple
context: Context,
options?: SimpleStreamOptions,
): AssistantMessageEventStream => {
const apiKey = options?.apiKey || getEnvApiKey(model.provider);
const apiKey = options?.apiKey;
if (!apiKey) {
throw new Error(`No API key for provider: ${model.provider}`);
}
@@ -131,6 +131,14 @@ export const streamSimpleMistral: StreamFunction<"mistral-conversations", Simple
} satisfies MistralOptions);
};
export function register(): void {
registerApiProvider({
api: "mistral-conversations",
stream: streamMistral,
streamSimple: streamSimpleMistral,
});
}
function createOutput(model: Model<"mistral-conversations">): AssistantMessage {
return {
role: "assistant",
@@ -227,7 +235,7 @@ function buildRequestOptions(model: Model<"mistral-conversations">, options?: Mi
// Mistral infrastructure uses `x-affinity` for KV-cache reuse (prefix caching).
// Respect explicit caller-provided header values.
if (options?.sessionId && !headers["x-affinity"]) {
if (shouldUsePromptCaching(options) && !headers["x-affinity"]) {
headers["x-affinity"] = options.sessionId;
}
@@ -256,6 +264,7 @@ function buildChatPayload(
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({
@@ -267,6 +276,31 @@ function buildChatPayload(
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,
@@ -306,11 +340,16 @@ async function consumeChatStream(
output.responseId ||= chunk.id;
if (chunk.usage) {
output.usage.input = chunk.usage.promptTokens || 0;
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 = 0;
output.usage.cacheRead = cachedPromptTokens;
output.usage.cacheWrite = 0;
output.usage.totalTokens = chunk.usage.totalTokens || output.usage.input + output.usage.output;
output.usage.totalTokens =
chunk.usage.totalTokens ||
output.usage.input + output.usage.output + output.usage.cacheRead + output.usage.cacheWrite;
calculateCost(model, output.usage);
}

View File

@@ -20,7 +20,7 @@ if (typeof process !== "undefined" && (process.versions?.node || process.version
});
}
import { getEnvApiKey } from "../env-api-keys.ts";
import { registerApiProvider } from "../api-registry.ts";
import { clampThinkingLevel } from "../models.ts";
import { registerSessionResourceCleanup } from "../session-resources.ts";
import type {
@@ -28,11 +28,13 @@ import type {
AssistantMessage,
Context,
Model,
ProviderEnv,
SimpleStreamOptions,
StreamFunction,
StreamOptions,
Usage,
} from "../types.ts";
import { combineAbortSignals } from "../utils/abort-signals.ts";
import {
appendAssistantMessageDiagnostic,
createAssistantMessageDiagnostic,
@@ -40,6 +42,7 @@ import {
} from "../utils/diagnostics.ts";
import { AssistantMessageEventStream } from "../utils/event-stream.ts";
import { headersToRecord } from "../utils/headers.ts";
import { resolveHttpProxyUrlForTarget } from "../utils/node-http-proxy.ts";
import { clampOpenAIPromptCacheKey } from "./openai-prompt-cache.ts";
import { convertResponsesMessages, convertResponsesTools, processResponsesStream } from "./openai-responses-shared.ts";
import { buildBaseOptions } from "./simple-options.ts";
@@ -50,8 +53,13 @@ import { buildBaseOptions } from "./simple-options.ts";
const DEFAULT_CODEX_BASE_URL = "https://chatgpt.com/backend-api";
const JWT_CLAIM_PATH = "https://api.openai.com/auth" as const;
const MAX_RETRIES = 3;
const DEFAULT_MAX_RETRIES = 0;
const BASE_DELAY_MS = 1000;
const DEFAULT_MAX_RETRY_DELAY_MS = 60_000;
// Keep a bounded pre-header timeout so zero-event Codex SSE stalls fail instead of
// leaving callers stuck on "Working..." indefinitely. See #4945.
const DEFAULT_SSE_HEADER_TIMEOUT_MS = 20_000;
const DEFAULT_WEBSOCKET_CONNECT_TIMEOUT_MS = 15_000;
const CODEX_TOOL_CALL_PROVIDERS = new Set(["openai", "openai-codex", "opencode"]);
const WEBSOCKET_MESSAGE_TOO_BIG_CLOSE_CODE = 1009;
@@ -100,13 +108,54 @@ interface RequestBody {
// Retry Helpers
// ============================================================================
function isTerminalRateLimitError(errorText: string): boolean {
return /GoUsageLimitError|FreeUsageLimitError|Monthly usage limit reached|available balance|insufficient_quota|out of budget|quota exceeded|billing/i.test(
errorText,
);
}
function isRetryableError(status: number, errorText: string): boolean {
if (status === 429 && isTerminalRateLimitError(errorText)) {
return false;
}
if (status === 429 || status === 500 || status === 502 || status === 503 || status === 504) {
return true;
}
return /rate.?limit|overloaded|service.?unavailable|upstream.?connect|connection.?refused/i.test(errorText);
}
function getRetryAfterDelayMs(headers: Headers): number | undefined {
const retryAfterMs = headers.get("retry-after-ms");
if (retryAfterMs !== null) {
const millis = Number(retryAfterMs);
if (Number.isFinite(millis)) {
return Math.max(0, millis);
}
}
const retryAfter = headers.get("retry-after");
if (!retryAfter) {
return undefined;
}
const seconds = Number(retryAfter);
if (Number.isFinite(seconds)) {
return Math.max(0, seconds * 1000);
}
const date = Date.parse(retryAfter);
if (!Number.isNaN(date)) {
return Math.max(0, date - Date.now());
}
return undefined;
}
function capRetryDelayMs(delayMs: number, options?: StreamOptions): number {
const maxRetryDelayMs = options?.maxRetryDelayMs ?? DEFAULT_MAX_RETRY_DELAY_MS;
return maxRetryDelayMs > 0 ? Math.min(delayMs, maxRetryDelayMs) : delayMs;
}
function sleep(ms: number, signal?: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
if (signal?.aborted) {
@@ -121,6 +170,28 @@ function sleep(ms: number, signal?: AbortSignal): Promise<void> {
});
}
function normalizeTimeoutMs(value: number | undefined): number | undefined {
if (value === undefined) return undefined;
if (!Number.isFinite(value) || value < 0) {
throw new Error(`Invalid timeoutMs: ${String(value)}`);
}
return Math.floor(value);
}
function createSSEHeaderTimeout(): { signal: AbortSignal; clear: () => void; error: () => Error | undefined } {
const controller = new AbortController();
let error: Error | undefined;
const timeout = setTimeout(() => {
error = new Error(`Codex SSE response headers timed out after ${DEFAULT_SSE_HEADER_TIMEOUT_MS}ms`);
controller.abort(error);
}, DEFAULT_SSE_HEADER_TIMEOUT_MS);
return {
signal: controller.signal,
clear: () => clearTimeout(timeout),
error: () => error,
};
}
// ============================================================================
// Main Stream Function
// ============================================================================
@@ -152,7 +223,7 @@ export const streamOpenAICodexResponses: StreamFunction<"openai-codex-responses"
};
try {
const apiKey = options?.apiKey || getEnvApiKey(model.provider) || "";
const apiKey = options?.apiKey;
if (!apiKey) {
throw new Error(`No API key for provider: ${model.provider}`);
}
@@ -173,6 +244,8 @@ export const streamOpenAICodexResponses: StreamFunction<"openai-codex-responses"
websocketRequestId,
);
const bodyJson = JSON.stringify(body);
const idleTimeoutMs = normalizeTimeoutMs(options?.timeoutMs);
const websocketConnectTimeoutMs = normalizeTimeoutMs(options?.websocketConnectTimeoutMs);
const transport = options?.transport || "auto";
const websocketDisabledForSession = transport !== "sse" && isWebSocketSseFallbackActive(options?.sessionId);
if (websocketDisabledForSession) {
@@ -192,6 +265,8 @@ export const streamOpenAICodexResponses: StreamFunction<"openai-codex-responses"
() => {
websocketStarted = true;
},
idleTimeoutMs,
websocketConnectTimeoutMs,
options,
);
@@ -231,19 +306,30 @@ export const streamOpenAICodexResponses: StreamFunction<"openai-codex-responses"
// Fetch with retry logic for rate limits and transient errors
let response: Response | undefined;
let lastError: Error | undefined;
const maxRetries = options?.maxRetries ?? DEFAULT_MAX_RETRIES;
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
for (let attempt = 0; attempt <= maxRetries; attempt++) {
if (options?.signal?.aborted) {
throw new Error("Request was aborted");
}
try {
response = await fetch(resolveCodexUrl(model.baseUrl), {
method: "POST",
headers: sseHeaders,
body: bodyJson,
signal: options?.signal,
});
const headerTimeout = createSSEHeaderTimeout();
const combinedSignal = combineAbortSignals([options?.signal, headerTimeout.signal]);
try {
response = await fetch(resolveCodexUrl(model.baseUrl), {
method: "POST",
headers: sseHeaders,
body: bodyJson,
signal: combinedSignal.signal,
});
} catch (error) {
const timeoutError = headerTimeout.error();
throw timeoutError && !options?.signal?.aborted ? timeoutError : error;
} finally {
combinedSignal.cleanup();
headerTimeout.clear();
}
await options?.onResponse?.(
{ status: response.status, headers: headersToRecord(response.headers) },
model,
@@ -254,29 +340,14 @@ export const streamOpenAICodexResponses: StreamFunction<"openai-codex-responses"
}
const errorText = await response.text();
if (attempt < MAX_RETRIES && isRetryableError(response.status, errorText)) {
let delayMs = BASE_DELAY_MS * 2 ** attempt;
const retryAfterMs = response.headers.get("retry-after-ms");
if (retryAfterMs !== null) {
const millis = Number(retryAfterMs);
if (Number.isFinite(millis)) {
delayMs = Math.max(0, millis);
}
} else {
const retryAfter = response.headers.get("retry-after");
if (retryAfter) {
const seconds = Number(retryAfter);
if (Number.isFinite(seconds)) {
delayMs = Math.max(0, seconds * 1000);
} else {
const date = Date.parse(retryAfter);
if (!Number.isNaN(date)) {
delayMs = Math.max(0, date - Date.now());
}
}
}
}
if (attempt < maxRetries && isRetryableError(response.status, errorText)) {
const retryAfterDelayMs = getRetryAfterDelayMs(response.headers);
const delayMs =
retryAfterDelayMs === undefined
? BASE_DELAY_MS * 2 ** attempt
: response.status === 429
? capRetryDelayMs(retryAfterDelayMs, options)
: retryAfterDelayMs;
await sleep(delayMs, options?.signal);
continue;
@@ -297,7 +368,7 @@ export const streamOpenAICodexResponses: StreamFunction<"openai-codex-responses"
}
lastError = error instanceof Error ? error : new Error(String(error));
// Network errors are retryable
if (attempt < MAX_RETRIES && !lastError.message.includes("usage limit")) {
if (attempt < maxRetries && !lastError.message.includes("usage limit")) {
const delayMs = BASE_DELAY_MS * 2 ** attempt;
await sleep(delayMs, options?.signal);
continue;
@@ -343,7 +414,7 @@ export const streamSimpleOpenAICodexResponses: StreamFunction<"openai-codex-resp
context: Context,
options?: SimpleStreamOptions,
): AssistantMessageEventStream => {
const apiKey = options?.apiKey || getEnvApiKey(model.provider);
const apiKey = options?.apiKey;
if (!apiKey) {
throw new Error(`No API key for provider: ${model.provider}`);
}
@@ -358,6 +429,14 @@ export const streamSimpleOpenAICodexResponses: StreamFunction<"openai-codex-resp
} satisfies OpenAICodexResponsesOptions);
};
export function register(): void {
registerApiProvider({
api: "openai-codex-responses",
stream: streamOpenAICodexResponses,
streamSimple: streamSimpleOpenAICodexResponses,
});
}
// ============================================================================
// Request Building
// ============================================================================
@@ -477,7 +556,7 @@ async function processStream(
model: Model<"openai-codex-responses">,
options?: OpenAICodexResponsesOptions,
): Promise<void> {
await processResponsesStream(mapCodexEvents(parseSSE(response)), output, stream, model, {
await processResponsesStream(mapCodexEvents(parseSSE(response, options?.signal)), output, stream, model, {
serviceTier: options?.serviceTier,
resolveServiceTier: resolveCodexServiceTier,
applyServiceTierPricing: (usage, serviceTier) => applyServiceTierPricing(usage, serviceTier, model),
@@ -555,16 +634,26 @@ function normalizeCodexStatus(status: unknown): CodexResponseStatus | undefined
// SSE Parsing
// ============================================================================
async function* parseSSE(response: Response): AsyncGenerator<Record<string, unknown>> {
async function* parseSSE(response: Response, signal?: AbortSignal): AsyncGenerator<Record<string, unknown>> {
if (!response.body) return;
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
const onAbort = () => {
void reader.cancel().catch(() => {});
};
signal?.addEventListener("abort", onAbort, { once: true });
try {
while (true) {
if (signal?.aborted) {
throw new Error("Request was aborted");
}
const { done, value } = await reader.read();
if (signal?.aborted) {
throw new Error("Request was aborted");
}
if (done) break;
buffer += decoder.decode(value, { stream: true });
@@ -594,6 +683,7 @@ async function* parseSSE(response: Response): AsyncGenerator<Record<string, unkn
}
}
} finally {
signal?.removeEventListener("abort", onAbort);
try {
await reader.cancel();
} catch {}
@@ -735,19 +825,13 @@ type WebSocketConstructor = new (
) => WebSocketLike;
let _cachedWebsocket: WebSocketConstructor | null = null;
async function getWebSocketConstructor(): Promise<WebSocketConstructor | null> {
if (_cachedWebsocket) return _cachedWebsocket;
async function getWebSocketConstructor(env?: ProviderEnv): Promise<WebSocketConstructor | null> {
if (!env && _cachedWebsocket) return _cachedWebsocket;
// bun doesn't respect http proxy envs, ref: https://github.com/oven-sh/bun/issues/15489
// TODO: remove this when bun supports proxy envs in websocket.
if (
process?.versions?.bun &&
(process.env.HTTP_PROXY || process.env.HTTPS_PROXY || process.env.http_proxy || process.env.https_proxy)
) {
const m = await dynamicImport("proxy-from-env");
const getProxyForUrl = (m as { getProxyForUrl: (url: string | object | URL) => string }).getProxyForUrl;
_cachedWebsocket = class extends WebSocket {
if (typeof process !== "undefined" && process.versions?.bun) {
const WebSocketWithProxy = class extends WebSocket {
constructor(url: string | URL, options?: string | string[] | Record<string, unknown>) {
let _opts: Record<string, unknown> = {};
if (Array.isArray(options) || typeof options === "string") {
@@ -756,11 +840,17 @@ async function getWebSocketConstructor(): Promise<WebSocketConstructor | null> {
_opts = { ...options };
}
const proxy = getProxyForUrl(url.toString().replace(/^wss:/, "https:").replace(/^ws:/, "http:"));
super(url, { ..._opts, ...(proxy ? { proxy } : {}) } as any);
const proxyUrl = resolveHttpProxyUrlForTarget(
url.toString().replace(/^wss:/, "https:").replace(/^ws:/, "http:"),
env,
);
super(url, { ..._opts, ...(proxyUrl ? { proxy: proxyUrl.toString() } : {}) } as any);
}
};
return _cachedWebsocket;
if (!env) {
_cachedWebsocket = WebSocketWithProxy;
}
return WebSocketWithProxy;
}
const ctor = (globalThis as { WebSocket?: unknown }).WebSocket;
@@ -810,8 +900,14 @@ function scheduleSessionWebSocketExpiry(sessionId: string, entry: CachedWebSocke
}, SESSION_WEBSOCKET_CACHE_TTL_MS);
}
async function connectWebSocket(url: string, headers: Headers, signal?: AbortSignal): Promise<WebSocketLike> {
const WebSocketCtor = await getWebSocketConstructor();
async function connectWebSocket(
url: string,
headers: Headers,
signal?: AbortSignal,
connectTimeoutMs = DEFAULT_WEBSOCKET_CONNECT_TIMEOUT_MS,
env?: ProviderEnv,
): Promise<WebSocketLike> {
const WebSocketCtor = await getWebSocketConstructor(env);
if (!WebSocketCtor) {
throw new Error("WebSocket transport is not available in this runtime");
}
@@ -821,6 +917,7 @@ async function connectWebSocket(url: string, headers: Headers, signal?: AbortSig
return new Promise<WebSocketLike>((resolve, reject) => {
let settled = false;
let timeout: ReturnType<typeof setTimeout> | undefined;
let socket: WebSocketLike;
try {
@@ -830,6 +927,25 @@ async function connectWebSocket(url: string, headers: Headers, signal?: AbortSig
return;
}
const cleanup = () => {
if (timeout) {
clearTimeout(timeout);
timeout = undefined;
}
socket.removeEventListener("open", onOpen);
socket.removeEventListener("error", onError);
socket.removeEventListener("close", onClose);
signal?.removeEventListener("abort", onAbort);
};
const fail = (error: Error, closeReason?: string) => {
if (settled) return;
settled = true;
cleanup();
if (closeReason) {
closeWebSocketSilently(socket, 1000, closeReason);
}
reject(error);
};
const onOpen: WebSocketListener = () => {
if (settled) return;
settled = true;
@@ -837,38 +953,28 @@ async function connectWebSocket(url: string, headers: Headers, signal?: AbortSig
resolve(socket);
};
const onError: WebSocketListener = (event) => {
const error = extractWebSocketError(event);
if (settled) return;
settled = true;
cleanup();
reject(error);
fail(extractWebSocketError(event));
};
const onClose: WebSocketListener = (event) => {
const error = extractWebSocketCloseError(event);
if (settled) return;
settled = true;
cleanup();
reject(error);
fail(extractWebSocketCloseError(event));
};
const onAbort = () => {
if (settled) return;
settled = true;
cleanup();
socket.close(1000, "aborted");
reject(new Error("Request was aborted"));
};
const cleanup = () => {
socket.removeEventListener("open", onOpen);
socket.removeEventListener("error", onError);
socket.removeEventListener("close", onClose);
signal?.removeEventListener("abort", onAbort);
fail(new Error("Request was aborted"), "aborted");
};
socket.addEventListener("open", onOpen);
socket.addEventListener("error", onError);
socket.addEventListener("close", onClose);
signal?.addEventListener("abort", onAbort);
if (connectTimeoutMs > 0) {
timeout = setTimeout(() => {
fail(new Error(`WebSocket connect timeout after ${connectTimeoutMs}ms`), "connect_timeout");
}, connectTimeoutMs);
}
if (signal?.aborted) {
onAbort();
}
});
}
@@ -877,6 +983,8 @@ async function acquireWebSocket(
headers: Headers,
sessionId: string | undefined,
signal?: AbortSignal,
connectTimeoutMs?: number,
env?: ProviderEnv,
): Promise<{
socket: WebSocketLike;
entry?: CachedWebSocketConnection;
@@ -884,17 +992,11 @@ async function acquireWebSocket(
release: (options?: { keep?: boolean }) => void;
}> {
if (!sessionId) {
const socket = await connectWebSocket(url, headers, signal);
const socket = await connectWebSocket(url, headers, signal, connectTimeoutMs, env);
return {
socket,
reused: false,
release: ({ keep } = {}) => {
if (keep === false) {
closeWebSocketSilently(socket);
return;
}
closeWebSocketSilently(socket);
},
release: () => closeWebSocketSilently(socket),
};
}
@@ -922,7 +1024,7 @@ async function acquireWebSocket(
};
}
if (cached.busy) {
const socket = await connectWebSocket(url, headers, signal);
const socket = await connectWebSocket(url, headers, signal, connectTimeoutMs, env);
return {
socket,
reused: false,
@@ -937,7 +1039,7 @@ async function acquireWebSocket(
}
}
const socket = await connectWebSocket(url, headers, signal);
const socket = await connectWebSocket(url, headers, signal, connectTimeoutMs, env);
const entry: CachedWebSocketConnection = { socket, busy: true };
websocketSessionCache.set(sessionId, entry);
return {
@@ -1016,7 +1118,11 @@ async function decodeWebSocketData(data: unknown): Promise<string | null> {
return null;
}
async function* parseWebSocket(socket: WebSocketLike, signal?: AbortSignal): AsyncGenerator<Record<string, unknown>> {
async function* parseWebSocket(
socket: WebSocketLike,
signal?: AbortSignal,
idleTimeoutMs?: number,
): AsyncGenerator<Record<string, unknown>> {
const queue: Record<string, unknown>[] = [];
let pending: (() => void) | null = null;
let done = false;
@@ -1096,8 +1202,23 @@ async function* parseWebSocket(socket: WebSocketLike, signal?: AbortSignal): Asy
continue;
}
if (done) break;
await new Promise<void>((resolve) => {
let timeout: ReturnType<typeof setTimeout> | undefined;
await new Promise<void>((resolve, reject) => {
pending = resolve;
if (idleTimeoutMs !== undefined && idleTimeoutMs > 0) {
timeout = setTimeout(() => {
const error = new Error(`WebSocket idle timeout after ${idleTimeoutMs}ms`);
failed = error;
done = true;
pending = null;
closeWebSocketSilently(socket, 1000, "idle_timeout");
reject(error);
}, idleTimeoutMs);
}
}).finally(() => {
if (timeout) {
clearTimeout(timeout);
}
});
}
@@ -1194,9 +1315,18 @@ async function processWebSocketStream(
stream: AssistantMessageEventStream,
model: Model<"openai-codex-responses">,
onStart: () => void,
idleTimeoutMs: number | undefined,
websocketConnectTimeoutMs: number | undefined,
options?: OpenAICodexResponsesOptions,
): Promise<void> {
const { socket, entry, reused, release } = await acquireWebSocket(url, headers, options?.sessionId, options?.signal);
const { socket, entry, reused, release } = await acquireWebSocket(
url,
headers,
options?.sessionId,
options?.signal,
websocketConnectTimeoutMs,
options?.env,
);
let keepConnection = true;
const useCachedContext = options?.transport === "websocket-cached" || options?.transport === "auto";
// ChatGPT Codex Responses rejects `store: true` ("Store must be set to false").
@@ -1225,7 +1355,7 @@ async function processWebSocketStream(
socket.send(JSON.stringify({ type: "response.create", ...requestBody }));
await processResponsesStream(
startWebSocketOutputOnFirstEvent(
mapCodexEvents(parseWebSocket(socket, options?.signal)),
mapCodexEvents(parseWebSocket(socket, options?.signal, idleTimeoutMs)),
output,
stream,
onStart,
@@ -1348,7 +1478,7 @@ function buildSSEHeaders(
headers.set("content-type", "application/json");
if (sessionId) {
headers.set("session_id", sessionId);
headers.set("session-id", sessionId);
headers.set("x-client-request-id", sessionId);
}
@@ -1369,6 +1499,6 @@ function buildWebSocketHeaders(
headers.delete("openai-beta");
headers.set("OpenAI-Beta", OPENAI_BETA_RESPONSES_WEBSOCKETS);
headers.set("x-client-request-id", requestId);
headers.set("session_id", requestId);
headers.set("session-id", requestId);
return headers;
}

View File

@@ -10,16 +10,18 @@ import type {
ChatCompletionSystemMessageParam,
ChatCompletionToolMessageParam,
} from "openai/resources/chat/completions.js";
import { getEnvApiKey } from "../env-api-keys.ts";
import { registerApiProvider } from "../api-registry.ts";
import { calculateCost, clampThinkingLevel } from "../models.ts";
import type {
AssistantMessage,
CacheRetention,
ChatTemplateKwargValue,
Context,
ImageContent,
Message,
Model,
OpenAICompletionsCompat,
ProviderEnv,
SimpleStreamOptions,
StopReason,
StreamFunction,
@@ -33,6 +35,7 @@ import type {
import { AssistantMessageEventStream } from "../utils/event-stream.ts";
import { headersToRecord } from "../utils/headers.ts";
import { parseStreamingJson } from "../utils/json-parse.ts";
import { getProviderEnvValue } from "../utils/provider-env.ts";
import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts";
import { isCloudflareProvider, resolveCloudflareBaseUrl } from "./cloudflare.ts";
import { buildCopilotDynamicHeaders, hasCopilotVisionInput } from "./github-copilot-headers.ts";
@@ -89,6 +92,8 @@ type ResolvedOpenAICompletionsCompat = Omit<Required<OpenAICompletionsCompat>, "
cacheControlFormat?: OpenAICompletionsCompat["cacheControlFormat"];
};
type ResolvedChatTemplateKwargValue = string | number | boolean | null;
type ChatCompletionInstructionMessageParam = ChatCompletionDeveloperMessageParam | ChatCompletionSystemMessageParam;
type ChatCompletionTextPartWithCacheControl = ChatCompletionContentPartText & {
@@ -99,11 +104,11 @@ type ChatCompletionToolWithCacheControl = OpenAI.Chat.Completions.ChatCompletion
cache_control?: OpenAICompatCacheControl;
};
function resolveCacheRetention(cacheRetention?: CacheRetention): CacheRetention {
function resolveCacheRetention(cacheRetention?: CacheRetention, env?: ProviderEnv): CacheRetention {
if (cacheRetention) {
return cacheRetention;
}
if (typeof process !== "undefined" && process.env.PI_CACHE_RETENTION === "long") {
if (getProviderEnvValue("PI_CACHE_RETENTION", env) === "long") {
return "long";
}
return "short";
@@ -136,11 +141,14 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions", OpenA
};
try {
const apiKey = options?.apiKey || getEnvApiKey(model.provider) || "";
const apiKey = options?.apiKey;
if (!apiKey) {
throw new Error(`No API key for provider: ${model.provider}`);
}
const compat = getCompat(model);
const cacheRetention = resolveCacheRetention(options?.cacheRetention);
const cacheRetention = resolveCacheRetention(options?.cacheRetention, options?.env);
const cacheSessionId = cacheRetention === "none" ? undefined : options?.sessionId;
const client = createClient(model, context, apiKey, options?.headers, cacheSessionId, compat);
const client = createClient(model, context, apiKey, options?.headers, cacheSessionId, compat, options?.env);
let params = buildParams(model, context, options, compat, cacheRetention);
const nextParams = await options?.onPayload?.(params, model);
if (nextParams !== undefined) {
@@ -149,7 +157,7 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions", OpenA
const requestOptions = {
...(options?.signal ? { signal: options.signal } : {}),
...(options?.timeoutMs !== undefined ? { timeout: options.timeoutMs } : {}),
...(options?.maxRetries !== undefined ? { maxRetries: options.maxRetries } : {}),
maxRetries: options?.maxRetries ?? 0,
};
const { data: openaiStream, response } = await client.chat.completions
.create(params, requestOptions)
@@ -428,7 +436,7 @@ export const streamSimpleOpenAICompletions: StreamFunction<"openai-completions",
context: Context,
options?: SimpleStreamOptions,
): AssistantMessageEventStream => {
const apiKey = options?.apiKey || getEnvApiKey(model.provider);
const apiKey = options?.apiKey;
if (!apiKey) {
throw new Error(`No API key for provider: ${model.provider}`);
}
@@ -445,23 +453,23 @@ export const streamSimpleOpenAICompletions: StreamFunction<"openai-completions",
} satisfies OpenAICompletionsOptions);
};
export function register(): void {
registerApiProvider({
api: "openai-completions",
stream: streamOpenAICompletions,
streamSimple: streamSimpleOpenAICompletions,
});
}
function createClient(
model: Model<"openai-completions">,
context: Context,
apiKey?: string,
apiKey: string,
optionsHeaders?: Record<string, string>,
sessionId?: string,
compat: ResolvedOpenAICompletionsCompat = getCompat(model),
env?: ProviderEnv,
) {
if (!apiKey) {
if (!process.env.OPENAI_API_KEY) {
throw new Error(
"OpenAI API key is required. Set OPENAI_API_KEY environment variable or pass it as an argument.",
);
}
apiKey = process.env.OPENAI_API_KEY;
}
const headers = { ...model.headers };
if (model.provider === "github-copilot") {
const hasImages = hasCopilotVisionInput(context.messages);
@@ -494,7 +502,7 @@ function createClient(
return new OpenAI({
apiKey,
baseURL: isCloudflareProvider(model.provider) ? resolveCloudflareBaseUrl(model) : model.baseUrl,
baseURL: isCloudflareProvider(model.provider) ? resolveCloudflareBaseUrl(model, env) : model.baseUrl,
dangerouslyAllowBrowser: true,
defaultHeaders,
});
@@ -505,7 +513,7 @@ function buildParams(
context: Context,
options?: OpenAICompletionsOptions,
compat: ResolvedOpenAICompletionsCompat = getCompat(model),
cacheRetention: CacheRetention = resolveCacheRetention(options?.cacheRetention),
cacheRetention: CacheRetention = resolveCacheRetention(options?.cacheRetention, options?.env),
) {
const messages = convertMessages(model, context, compat);
const cacheControl = getCompatCacheControl(compat, cacheRetention);
@@ -561,7 +569,18 @@ function buildParams(
}
if (compat.thinkingFormat === "zai" && model.reasoning) {
(params as any).enable_thinking = !!options?.reasoningEffort;
const zaiParams = params as Omit<typeof params, "reasoning_effort"> & {
thinking?: { type: "enabled" | "disabled" };
reasoning_effort?: string;
};
zaiParams.thinking = { type: options?.reasoningEffort ? "enabled" : "disabled" };
if (options?.reasoningEffort && compat.supportsReasoningEffort) {
const mappedEffort = model.thinkingLevelMap?.[options.reasoningEffort];
const effort = mappedEffort === undefined ? options.reasoningEffort : mappedEffort;
if (typeof effort === "string") {
zaiParams.reasoning_effort = effort;
}
}
} else if (compat.thinkingFormat === "qwen" && model.reasoning) {
(params as any).enable_thinking = !!options?.reasoningEffort;
} else if (compat.thinkingFormat === "qwen-chat-template" && model.reasoning) {
@@ -569,9 +588,18 @@ function buildParams(
enable_thinking: !!options?.reasoningEffort,
preserve_thinking: true,
};
} else if (compat.thinkingFormat === "chat-template" && model.reasoning) {
const chatTemplateKwargs = buildChatTemplateKwargs(model, options, compat);
if (chatTemplateKwargs) {
(params as any).chat_template_kwargs = chatTemplateKwargs;
}
} else if (compat.thinkingFormat === "deepseek" && model.reasoning) {
(params as any).thinking = { type: options?.reasoningEffort ? "enabled" : "disabled" };
if (options?.reasoningEffort) {
(params as any).thinking = { type: "enabled" };
} else if (model.thinkingLevelMap?.off !== null) {
(params as any).thinking = { type: "disabled" };
}
if (options?.reasoningEffort && compat.supportsReasoningEffort) {
(params as any).reasoning_effort =
model.thinkingLevelMap?.[options.reasoningEffort] ?? options.reasoningEffort;
}
@@ -585,6 +613,11 @@ function buildParams(
} else if (model.thinkingLevelMap?.off !== null) {
openRouterParams.reasoning = { effort: model.thinkingLevelMap?.off ?? "none" };
}
} else if (compat.thinkingFormat === "ant-ling" && model.reasoning && options?.reasoningEffort) {
const effort = model.thinkingLevelMap?.[options.reasoningEffort];
if (typeof effort === "string") {
(params as typeof params & { reasoning?: { effort: string } }).reasoning = { effort };
}
} else if (compat.thinkingFormat === "together" && model.reasoning) {
const togetherParams = params as Omit<typeof params, "reasoning_effort"> & {
reasoning?: { enabled: boolean };
@@ -594,6 +627,13 @@ function buildParams(
if (options?.reasoningEffort && compat.supportsReasoningEffort) {
togetherParams.reasoning_effort = model.thinkingLevelMap?.[options.reasoningEffort] ?? options.reasoningEffort;
}
} else if (compat.thinkingFormat === "string-thinking" && model.reasoning) {
const stringThinkingParams = params as typeof params & { thinking?: string };
if (options?.reasoningEffort) {
stringThinkingParams.thinking = model.thinkingLevelMap?.[options.reasoningEffort] ?? options.reasoningEffort;
} else if (model.thinkingLevelMap?.off !== null) {
stringThinkingParams.thinking = model.thinkingLevelMap?.off ?? "none";
}
} else if (options?.reasoningEffort && model.reasoning && compat.supportsReasoningEffort) {
// OpenAI-style reasoning_effort
(params as any).reasoning_effort = model.thinkingLevelMap?.[options.reasoningEffort] ?? options.reasoningEffort;
@@ -605,7 +645,7 @@ function buildParams(
}
// OpenRouter provider routing preferences
if (model.baseUrl.includes("openrouter.ai") && model.compat?.openRouterRouting) {
if (model.compat?.openRouterRouting) {
(params as any).provider = model.compat.openRouterRouting;
}
@@ -623,6 +663,44 @@ function buildParams(
return params;
}
function buildChatTemplateKwargs(
model: Model<"openai-completions">,
options: OpenAICompletionsOptions | undefined,
compat: ResolvedOpenAICompletionsCompat,
): Record<string, ResolvedChatTemplateKwargValue> | undefined {
const kwargs: Record<string, ResolvedChatTemplateKwargValue> = {};
for (const [key, value] of Object.entries(compat.chatTemplateKwargs)) {
const resolved = resolveChatTemplateKwargValue(model, options, value);
if (resolved !== undefined) {
kwargs[key] = resolved;
}
}
return Object.keys(kwargs).length > 0 ? kwargs : undefined;
}
function resolveChatTemplateKwargValue(
model: Model<"openai-completions">,
options: OpenAICompletionsOptions | undefined,
value: ChatTemplateKwargValue,
): ResolvedChatTemplateKwargValue | undefined {
if (typeof value !== "object" || value === null) {
return value;
}
const reasoningEffort = options?.reasoningEffort;
if (!reasoningEffort && value.omitWhenOff) {
return undefined;
}
if (value.$var === "thinking.enabled") {
return !!reasoningEffort;
}
const mappedValue = reasoningEffort ? model.thinkingLevelMap?.[reasoningEffort] : model.thinkingLevelMap?.off;
return mappedValue === undefined ? reasoningEffort : typeof mappedValue === "string" ? mappedValue : undefined;
}
function getCompatCacheControl(
compat: ResolvedOpenAICompletionsCompat,
cacheRetention: CacheRetention,
@@ -1071,14 +1149,22 @@ function detectCompat(model: Model<"openai-completions">): ResolvedOpenAIComplet
const provider = model.provider;
const baseUrl = model.baseUrl;
const isZai = provider === "zai" || baseUrl.includes("api.z.ai");
const isZai =
provider === "zai" ||
provider === "zai-coding-cn" ||
baseUrl.includes("api.z.ai") ||
baseUrl.includes("open.bigmodel.cn");
const isTogether =
provider === "together" || baseUrl.includes("api.together.ai") || baseUrl.includes("api.together.xyz");
const isMoonshot = provider === "moonshotai" || provider === "moonshotai-cn" || baseUrl.includes("api.moonshot.");
const isOpenRouter = provider === "openrouter" || baseUrl.includes("openrouter.ai");
const isCloudflareWorkersAI = provider === "cloudflare-workers-ai" || baseUrl.includes("api.cloudflare.com");
const isCloudflareAiGateway = provider === "cloudflare-ai-gateway" || baseUrl.includes("gateway.ai.cloudflare.com");
const isNvidia = provider === "nvidia" || baseUrl.includes("integrate.api.nvidia.com");
const isAntLing = provider === "ant-ling" || baseUrl.includes("api.ant-ling.com");
const isNonStandard =
isNvidia ||
provider === "cerebras" ||
baseUrl.includes("cerebras.ai") ||
provider === "xai" ||
@@ -1091,18 +1177,23 @@ function detectCompat(model: Model<"openai-completions">): ResolvedOpenAIComplet
provider === "opencode" ||
baseUrl.includes("opencode.ai") ||
isCloudflareWorkersAI ||
isCloudflareAiGateway;
isCloudflareAiGateway ||
isAntLing;
const useMaxTokens = baseUrl.includes("chutes.ai") || isMoonshot || isCloudflareAiGateway || isTogether;
const useMaxTokens =
baseUrl.includes("chutes.ai") || isMoonshot || isCloudflareAiGateway || isTogether || isNvidia || isAntLing;
const isGrok = provider === "xai" || baseUrl.includes("api.x.ai");
const isDeepSeek = provider === "deepseek" || baseUrl.includes("deepseek.com");
const isOpenRouterDeveloperRoleModel =
isOpenRouter && (model.id.startsWith("anthropic/") || model.id.startsWith("openai/"));
const cacheControlFormat = provider === "openrouter" && model.id.startsWith("anthropic/") ? "anthropic" : undefined;
return {
supportsStore: !isNonStandard,
supportsDeveloperRole: !isNonStandard,
supportsReasoningEffort: !isGrok && !isZai && !isMoonshot && !isTogether && !isCloudflareAiGateway,
supportsDeveloperRole: isOpenRouterDeveloperRoleModel || (!isNonStandard && !isOpenRouter),
supportsReasoningEffort:
!isGrok && !isZai && !isMoonshot && !isTogether && !isCloudflareAiGateway && !isNvidia && !isAntLing,
supportsUsageInStreaming: true,
maxTokensField: useMaxTokens ? "max_tokens" : "max_completion_tokens",
requiresToolResultName: false,
@@ -1115,16 +1206,25 @@ function detectCompat(model: Model<"openai-completions">): ResolvedOpenAIComplet
? "zai"
: isTogether
? "together"
: provider === "openrouter" || baseUrl.includes("openrouter.ai")
? "openrouter"
: "openai",
: isAntLing
? "ant-ling"
: isOpenRouter
? "openrouter"
: "openai",
openRouterRouting: {},
vercelGatewayRouting: {},
chatTemplateKwargs: {},
zaiToolStream: false,
supportsStrictMode: !isMoonshot && !isTogether && !isCloudflareAiGateway,
supportsStrictMode: !isMoonshot && !isTogether && !isCloudflareAiGateway && !isNvidia,
cacheControlFormat,
sendSessionAffinityHeaders: false,
supportsLongCacheRetention: !(isTogether || isCloudflareWorkersAI || isCloudflareAiGateway),
supportsLongCacheRetention: !(
isTogether ||
isCloudflareWorkersAI ||
isCloudflareAiGateway ||
isNvidia ||
isAntLing
),
};
}
@@ -1152,6 +1252,7 @@ function getCompat(model: Model<"openai-completions">): ResolvedOpenAICompletion
thinkingFormat: model.compat.thinkingFormat ?? detected.thinkingFormat,
openRouterRouting: model.compat.openRouterRouting ?? {},
vercelGatewayRouting: model.compat.vercelGatewayRouting ?? detected.vercelGatewayRouting,
chatTemplateKwargs: model.compat.chatTemplateKwargs ?? detected.chatTemplateKwargs,
zaiToolStream: model.compat.zaiToolStream ?? detected.zaiToolStream,
supportsStrictMode: model.compat.supportsStrictMode ?? detected.supportsStrictMode,
cacheControlFormat: model.compat.cacheControlFormat ?? detected.cacheControlFormat,

View File

@@ -124,7 +124,8 @@ export function convertResponsesMessages<TApi extends Api>(
const includeSystemPrompt = options?.includeSystemPrompt ?? true;
if (includeSystemPrompt && context.systemPrompt) {
const role = model.reasoning ? "developer" : "system";
const compat = model.compat as { supportsDeveloperRole?: boolean } | undefined;
const role = model.reasoning && compat?.supportsDeveloperRole !== false ? "developer" : "system";
messages.push({
role,
content: sanitizeSurrogates(context.systemPrompt),
@@ -166,6 +167,7 @@ export function convertResponsesMessages<TApi extends Api>(
assistantMsg.model !== model.id &&
assistantMsg.provider === model.provider &&
assistantMsg.api === model.api;
let textBlockIndex = 0;
for (const block of msg.content) {
if (block.type === "thinking") {
@@ -176,10 +178,13 @@ export function convertResponsesMessages<TApi extends Api>(
} else if (block.type === "text") {
const textBlock = block as TextContent;
const parsedSignature = parseTextSignature(textBlock.textSignature);
const fallbackMessageId =
textBlockIndex === 0 ? `msg_pi_${msgIndex}` : `msg_pi_${msgIndex}_${textBlockIndex}`;
textBlockIndex++;
// OpenAI requires id to be max 64 characters
let msgId = parsedSignature?.id;
if (!msgId) {
msgId = `msg_${msgIndex}`;
msgId = fallbackMessageId;
} else if (msgId.length > 64) {
msgId = `msg_${shortHash(msgId)}`;
}
@@ -451,7 +456,8 @@ export async function processResponsesStream<TApi extends Api>(
});
currentBlock = null;
} else if (item.type === "message" && currentBlock?.type === "text") {
currentBlock.text = item.content.map((c) => (c.type === "output_text" ? c.text : c.refusal)).join("");
currentBlock.text =
item.content?.map((c) => (c.type === "output_text" ? c.text : c.refusal)).join("") || "";
currentBlock.textSignature = encodeTextSignatureV1(item.id, item.phase ?? undefined);
stream.push({
type: "text_end",

View File

@@ -1,6 +1,6 @@
import OpenAI from "openai";
import type { ResponseCreateParamsStreaming } from "openai/resources/responses/responses.js";
import { getEnvApiKey } from "../env-api-keys.ts";
import { registerApiProvider } from "../api-registry.ts";
import { clampThinkingLevel } from "../models.ts";
import type {
Api,
@@ -9,6 +9,7 @@ import type {
Context,
Model,
OpenAIResponsesCompat,
ProviderEnv,
SimpleStreamOptions,
StreamFunction,
StreamOptions,
@@ -16,6 +17,7 @@ import type {
} from "../types.ts";
import { AssistantMessageEventStream } from "../utils/event-stream.ts";
import { headersToRecord } from "../utils/headers.ts";
import { getProviderEnvValue } from "../utils/provider-env.ts";
import { isCloudflareProvider, resolveCloudflareBaseUrl } from "./cloudflare.ts";
import { buildCopilotDynamicHeaders, hasCopilotVisionInput } from "./github-copilot-headers.ts";
import { clampOpenAIPromptCacheKey } from "./openai-prompt-cache.ts";
@@ -28,11 +30,11 @@ const OPENAI_TOOL_CALL_PROVIDERS = new Set(["openai", "openai-codex", "opencode"
* Resolve cache retention preference.
* Defaults to "short" and uses PI_CACHE_RETENTION for backward compatibility.
*/
function resolveCacheRetention(cacheRetention?: CacheRetention): CacheRetention {
function resolveCacheRetention(cacheRetention?: CacheRetention, env?: ProviderEnv): CacheRetention {
if (cacheRetention) {
return cacheRetention;
}
if (typeof process !== "undefined" && process.env.PI_CACHE_RETENTION === "long") {
if (getProviderEnvValue("PI_CACHE_RETENTION", env) === "long") {
return "long";
}
return "short";
@@ -40,6 +42,7 @@ function resolveCacheRetention(cacheRetention?: CacheRetention): CacheRetention
function getCompat(model: Model<"openai-responses">): Required<OpenAIResponsesCompat> {
return {
supportsDeveloperRole: model.compat?.supportsDeveloperRole ?? true,
sendSessionIdHeader: model.compat?.sendSessionIdHeader ?? true,
supportsLongCacheRetention: model.compat?.supportsLongCacheRetention ?? true,
};
@@ -107,10 +110,13 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses", OpenAIRes
try {
// Create OpenAI client
const apiKey = options?.apiKey || getEnvApiKey(model.provider) || "";
const cacheRetention = resolveCacheRetention(options?.cacheRetention);
const apiKey = options?.apiKey;
if (!apiKey) {
throw new Error(`No API key for provider: ${model.provider}`);
}
const cacheRetention = resolveCacheRetention(options?.cacheRetention, options?.env);
const cacheSessionId = cacheRetention === "none" ? undefined : options?.sessionId;
const client = createClient(model, context, apiKey, options?.headers, cacheSessionId);
const client = createClient(model, context, apiKey, options?.headers, cacheSessionId, options?.env);
let params = buildParams(model, context, options);
const nextParams = await options?.onPayload?.(params, model);
if (nextParams !== undefined) {
@@ -119,7 +125,7 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses", OpenAIRes
const requestOptions = {
...(options?.signal ? { signal: options.signal } : {}),
...(options?.timeoutMs !== undefined ? { timeout: options.timeoutMs } : {}),
...(options?.maxRetries !== undefined ? { maxRetries: options.maxRetries } : {}),
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);
@@ -161,7 +167,7 @@ export const streamSimpleOpenAIResponses: StreamFunction<"openai-responses", Sim
context: Context,
options?: SimpleStreamOptions,
): AssistantMessageEventStream => {
const apiKey = options?.apiKey || getEnvApiKey(model.provider);
const apiKey = options?.apiKey;
if (!apiKey) {
throw new Error(`No API key for provider: ${model.provider}`);
}
@@ -176,22 +182,22 @@ export const streamSimpleOpenAIResponses: StreamFunction<"openai-responses", Sim
} satisfies OpenAIResponsesOptions);
};
export function register(): void {
registerApiProvider({
api: "openai-responses",
stream: streamOpenAIResponses,
streamSimple: streamSimpleOpenAIResponses,
});
}
function createClient(
model: Model<"openai-responses">,
context: Context,
apiKey?: string,
apiKey: string,
optionsHeaders?: Record<string, string>,
sessionId?: string,
env?: ProviderEnv,
) {
if (!apiKey) {
if (!process.env.OPENAI_API_KEY) {
throw new Error(
"OpenAI API key is required. Set OPENAI_API_KEY environment variable or pass it as an argument.",
);
}
apiKey = process.env.OPENAI_API_KEY;
}
const compat = getCompat(model);
const headers = { ...model.headers };
if (model.provider === "github-copilot") {
@@ -226,7 +232,7 @@ function createClient(
return new OpenAI({
apiKey,
baseURL: isCloudflareProvider(model.provider) ? resolveCloudflareBaseUrl(model) : model.baseUrl,
baseURL: isCloudflareProvider(model.provider) ? resolveCloudflareBaseUrl(model, env) : model.baseUrl,
dangerouslyAllowBrowser: true,
defaultHeaders,
});
@@ -235,7 +241,7 @@ function createClient(
function buildParams(model: Model<"openai-responses">, context: Context, options?: OpenAIResponsesOptions) {
const messages = convertResponsesMessages(model, context, OPENAI_TOOL_CALL_PROVIDERS);
const cacheRetention = resolveCacheRetention(options?.cacheRetention);
const cacheRetention = resolveCacheRetention(options?.cacheRetention, options?.env);
const compat = getCompat(model);
const params: ResponseCreateParamsStreaming = {
model: model.id,

View File

@@ -1,9 +1,14 @@
import { clearApiProviders, registerApiProvider } from "../api-registry.ts";
import { type ApiProvider, clearApiProviders, getApiProvider, registerApiProvider } from "../api-registry.ts";
import { getImagesApiProvider, type ImagesApiProvider, registerImagesApiProvider } from "../images-api-registry.ts";
import type {
Api,
AssistantImages,
AssistantMessage,
AssistantMessageEvent,
Context,
ImagesApi,
ImagesContext,
ImagesModel,
ImagesOptions,
Model,
SimpleStreamOptions,
StreamFunction,
@@ -20,70 +25,47 @@ import type { OpenAICodexResponsesOptions } from "./openai-codex-responses.ts";
import type { OpenAICompletionsOptions } from "./openai-completions.ts";
import type { OpenAIResponsesOptions } from "./openai-responses.ts";
interface LazyProviderModule<
TApi extends Api,
TOptions extends StreamOptions,
TSimpleOptions extends SimpleStreamOptions,
> {
stream: (model: Model<TApi>, context: Context, options?: TOptions) => AsyncIterable<AssistantMessageEvent>;
streamSimple: (
model: Model<TApi>,
context: Context,
options?: TSimpleOptions,
) => AsyncIterable<AssistantMessageEvent>;
interface RegisteringProviderModule {
register(): void;
}
interface AnthropicProviderModule {
streamAnthropic: StreamFunction<"anthropic-messages", AnthropicOptions>;
streamSimpleAnthropic: StreamFunction<"anthropic-messages", SimpleStreamOptions>;
function createLazyLoadErrorImages(model: ImagesModel<"openrouter-images">, error: unknown): AssistantImages {
return {
api: model.api,
provider: model.provider,
model: model.id,
output: [],
stopReason: "error",
errorMessage: error instanceof Error ? error.message : String(error),
timestamp: Date.now(),
};
}
interface AzureOpenAIResponsesProviderModule {
streamAzureOpenAIResponses: StreamFunction<"azure-openai-responses", AzureOpenAIResponsesOptions>;
streamSimpleAzureOpenAIResponses: StreamFunction<"azure-openai-responses", SimpleStreamOptions>;
}
interface GoogleProviderModule {
streamGoogle: StreamFunction<"google-generative-ai", GoogleOptions>;
streamSimpleGoogle: StreamFunction<"google-generative-ai", SimpleStreamOptions>;
}
interface GoogleVertexProviderModule {
streamGoogleVertex: StreamFunction<"google-vertex", GoogleVertexOptions>;
streamSimpleGoogleVertex: StreamFunction<"google-vertex", SimpleStreamOptions>;
}
interface MistralProviderModule {
streamMistral: StreamFunction<"mistral-conversations", MistralOptions>;
streamSimpleMistral: StreamFunction<"mistral-conversations", SimpleStreamOptions>;
}
interface OpenAICodexResponsesProviderModule {
streamOpenAICodexResponses: StreamFunction<"openai-codex-responses", OpenAICodexResponsesOptions>;
streamSimpleOpenAICodexResponses: StreamFunction<"openai-codex-responses", SimpleStreamOptions>;
}
interface OpenAICompletionsProviderModule {
streamOpenAICompletions: StreamFunction<"openai-completions", OpenAICompletionsOptions>;
streamSimpleOpenAICompletions: StreamFunction<"openai-completions", SimpleStreamOptions>;
}
interface OpenAIResponsesProviderModule {
streamOpenAIResponses: StreamFunction<"openai-responses", OpenAIResponsesOptions>;
streamSimpleOpenAIResponses: StreamFunction<"openai-responses", SimpleStreamOptions>;
function createLazyImagesApiProvider<TApi extends ImagesApi, TOptions extends ImagesOptions>(
api: TApi,
loadModule: () => Promise<RegisteringProviderModule>,
): ImagesApiProvider<TApi, TOptions> {
return {
api,
generateImages: async (model: ImagesModel<TApi>, context: ImagesContext, options?: TOptions) => {
try {
const module = await loadModule();
module.register();
const provider = getImagesApiProvider(api);
if (!provider) {
throw new Error(`No API provider registered for api: ${api}`);
}
return await provider.generateImages(model, context, options);
} catch (error) {
return createLazyLoadErrorImages(model as ImagesModel<"openrouter-images">, error);
}
},
};
}
interface BedrockProviderModule {
streamBedrock: (
model: Model<"bedrock-converse-stream">,
context: Context,
options?: BedrockOptions,
) => AsyncIterable<AssistantMessageEvent>;
streamSimpleBedrock: (
model: Model<"bedrock-converse-stream">,
context: Context,
options?: SimpleStreamOptions,
) => AsyncIterable<AssistantMessageEvent>;
streamBedrock: StreamFunction<"bedrock-converse-stream", BedrockOptions>;
streamSimpleBedrock: StreamFunction<"bedrock-converse-stream", SimpleStreamOptions>;
}
const importNodeOnlyProvider = (specifier: string): Promise<unknown> => {
@@ -91,42 +73,10 @@ const importNodeOnlyProvider = (specifier: string): Promise<unknown> => {
return import(runtimeSpecifier);
};
let anthropicProviderModulePromise:
| Promise<LazyProviderModule<"anthropic-messages", AnthropicOptions, SimpleStreamOptions>>
| undefined;
let azureOpenAIResponsesProviderModulePromise:
| Promise<LazyProviderModule<"azure-openai-responses", AzureOpenAIResponsesOptions, SimpleStreamOptions>>
| undefined;
let googleProviderModulePromise:
| Promise<LazyProviderModule<"google-generative-ai", GoogleOptions, SimpleStreamOptions>>
| undefined;
let googleVertexProviderModulePromise:
| Promise<LazyProviderModule<"google-vertex", GoogleVertexOptions, SimpleStreamOptions>>
| undefined;
let mistralProviderModulePromise:
| Promise<LazyProviderModule<"mistral-conversations", MistralOptions, SimpleStreamOptions>>
| undefined;
let openAICodexResponsesProviderModulePromise:
| Promise<LazyProviderModule<"openai-codex-responses", OpenAICodexResponsesOptions, SimpleStreamOptions>>
| undefined;
let openAICompletionsProviderModulePromise:
| Promise<LazyProviderModule<"openai-completions", OpenAICompletionsOptions, SimpleStreamOptions>>
| undefined;
let openAIResponsesProviderModulePromise:
| Promise<LazyProviderModule<"openai-responses", OpenAIResponsesOptions, SimpleStreamOptions>>
| undefined;
let bedrockProviderModuleOverride:
| LazyProviderModule<"bedrock-converse-stream", BedrockOptions, SimpleStreamOptions>
| undefined;
let bedrockProviderModulePromise:
| Promise<LazyProviderModule<"bedrock-converse-stream", BedrockOptions, SimpleStreamOptions>>
| undefined;
let bedrockProviderModuleOverride: BedrockProviderModule | undefined;
export function setBedrockProviderModule(module: BedrockProviderModule): void {
bedrockProviderModuleOverride = {
stream: module.streamBedrock,
streamSimple: module.streamSimpleBedrock,
};
bedrockProviderModuleOverride = module;
}
function forwardStream(target: AssistantMessageEventStream, source: AsyncIterable<AssistantMessageEvent>): void {
@@ -159,15 +109,29 @@ function createLazyLoadErrorMessage<TApi extends Api>(model: Model<TApi>, error:
};
}
function createLazyStream<TApi extends Api, TOptions extends StreamOptions, TSimpleOptions extends SimpleStreamOptions>(
loadModule: () => Promise<LazyProviderModule<TApi, TOptions, TSimpleOptions>>,
async function loadAndRegisterProvider<TApi extends Api>(
api: TApi,
loadModule: () => Promise<RegisteringProviderModule>,
) {
const module = await loadModule();
module.register();
const provider = getApiProvider(api);
if (!provider) {
throw new Error(`No API provider registered for api: ${api}`);
}
return provider;
}
function createLazyStream<TApi extends Api, TOptions extends StreamOptions>(
api: TApi,
loadModule: () => Promise<RegisteringProviderModule>,
): StreamFunction<TApi, TOptions> {
return (model, context, options) => {
const outer = new AssistantMessageEventStream();
loadModule()
.then((module) => {
const inner = module.stream(model, context, options);
loadAndRegisterProvider(api, loadModule)
.then((provider) => {
const inner = provider.stream(model, context, options);
forwardStream(outer, inner);
})
.catch((error) => {
@@ -180,17 +144,16 @@ function createLazyStream<TApi extends Api, TOptions extends StreamOptions, TSim
};
}
function createLazySimpleStream<
TApi extends Api,
TOptions extends StreamOptions,
TSimpleOptions extends SimpleStreamOptions,
>(loadModule: () => Promise<LazyProviderModule<TApi, TOptions, TSimpleOptions>>): StreamFunction<TApi, TSimpleOptions> {
function createLazySimpleStream<TApi extends Api>(
api: TApi,
loadModule: () => Promise<RegisteringProviderModule>,
): StreamFunction<TApi, SimpleStreamOptions> {
return (model, context, options) => {
const outer = new AssistantMessageEventStream();
loadModule()
.then((module) => {
const inner = module.streamSimple(model, context, options);
loadAndRegisterProvider(api, loadModule)
.then((provider) => {
const inner = provider.streamSimple(model, context, options);
forwardStream(outer, inner);
})
.catch((error) => {
@@ -203,201 +166,114 @@ function createLazySimpleStream<
};
}
function loadAnthropicProviderModule(): Promise<
LazyProviderModule<"anthropic-messages", AnthropicOptions, SimpleStreamOptions>
> {
anthropicProviderModulePromise ||= import("./anthropic.ts").then((module) => {
const provider = module as AnthropicProviderModule;
return {
stream: provider.streamAnthropic,
streamSimple: provider.streamSimpleAnthropic,
};
});
return anthropicProviderModulePromise;
function createLazyApiProvider<TApi extends Api, TOptions extends StreamOptions>(
api: TApi,
loadModule: () => Promise<RegisteringProviderModule>,
): ApiProvider<TApi, TOptions> {
return {
api,
stream: createLazyStream<TApi, TOptions>(api, loadModule),
streamSimple: createLazySimpleStream(api, loadModule),
};
}
function loadAzureOpenAIResponsesProviderModule(): Promise<
LazyProviderModule<"azure-openai-responses", AzureOpenAIResponsesOptions, SimpleStreamOptions>
> {
azureOpenAIResponsesProviderModulePromise ||= import("./azure-openai-responses.ts").then((module) => {
const provider = module as AzureOpenAIResponsesProviderModule;
return {
stream: provider.streamAzureOpenAIResponses,
streamSimple: provider.streamSimpleAzureOpenAIResponses,
};
});
return azureOpenAIResponsesProviderModulePromise;
}
function loadGoogleProviderModule(): Promise<
LazyProviderModule<"google-generative-ai", GoogleOptions, SimpleStreamOptions>
> {
googleProviderModulePromise ||= import("./google.ts").then((module) => {
const provider = module as GoogleProviderModule;
return {
stream: provider.streamGoogle,
streamSimple: provider.streamSimpleGoogle,
};
});
return googleProviderModulePromise;
}
function loadGoogleVertexProviderModule(): Promise<
LazyProviderModule<"google-vertex", GoogleVertexOptions, SimpleStreamOptions>
> {
googleVertexProviderModulePromise ||= import("./google-vertex.ts").then((module) => {
const provider = module as GoogleVertexProviderModule;
return {
stream: provider.streamGoogleVertex,
streamSimple: provider.streamSimpleGoogleVertex,
};
});
return googleVertexProviderModulePromise;
}
function loadMistralProviderModule(): Promise<
LazyProviderModule<"mistral-conversations", MistralOptions, SimpleStreamOptions>
> {
mistralProviderModulePromise ||= import("./mistral.ts").then((module) => {
const provider = module as MistralProviderModule;
return {
stream: provider.streamMistral,
streamSimple: provider.streamSimpleMistral,
};
});
return mistralProviderModulePromise;
}
function loadOpenAICodexResponsesProviderModule(): Promise<
LazyProviderModule<"openai-codex-responses", OpenAICodexResponsesOptions, SimpleStreamOptions>
> {
openAICodexResponsesProviderModulePromise ||= import("./openai-codex-responses.ts").then((module) => {
const provider = module as OpenAICodexResponsesProviderModule;
return {
stream: provider.streamOpenAICodexResponses,
streamSimple: provider.streamSimpleOpenAICodexResponses,
};
});
return openAICodexResponsesProviderModulePromise;
}
function loadOpenAICompletionsProviderModule(): Promise<
LazyProviderModule<"openai-completions", OpenAICompletionsOptions, SimpleStreamOptions>
> {
openAICompletionsProviderModulePromise ||= import("./openai-completions.ts").then((module) => {
const provider = module as OpenAICompletionsProviderModule;
return {
stream: provider.streamOpenAICompletions,
streamSimple: provider.streamSimpleOpenAICompletions,
};
});
return openAICompletionsProviderModulePromise;
}
function loadOpenAIResponsesProviderModule(): Promise<
LazyProviderModule<"openai-responses", OpenAIResponsesOptions, SimpleStreamOptions>
> {
openAIResponsesProviderModulePromise ||= import("./openai-responses.ts").then((module) => {
const provider = module as OpenAIResponsesProviderModule;
return {
stream: provider.streamOpenAIResponses,
streamSimple: provider.streamSimpleOpenAIResponses,
};
});
return openAIResponsesProviderModulePromise;
}
function loadBedrockProviderModule(): Promise<
LazyProviderModule<"bedrock-converse-stream", BedrockOptions, SimpleStreamOptions>
> {
if (bedrockProviderModuleOverride) {
return Promise.resolve(bedrockProviderModuleOverride);
}
bedrockProviderModulePromise ||= importNodeOnlyProvider("./amazon-bedrock.ts").then((module) => {
const provider = module as BedrockProviderModule;
return {
stream: provider.streamBedrock,
streamSimple: provider.streamSimpleBedrock,
};
});
return bedrockProviderModulePromise;
}
export const streamAnthropic = createLazyStream(loadAnthropicProviderModule);
export const streamSimpleAnthropic = createLazySimpleStream(loadAnthropicProviderModule);
export const streamAzureOpenAIResponses = createLazyStream(loadAzureOpenAIResponsesProviderModule);
export const streamSimpleAzureOpenAIResponses = createLazySimpleStream(loadAzureOpenAIResponsesProviderModule);
export const streamGoogle = createLazyStream(loadGoogleProviderModule);
export const streamSimpleGoogle = createLazySimpleStream(loadGoogleProviderModule);
export const streamGoogleVertex = createLazyStream(loadGoogleVertexProviderModule);
export const streamSimpleGoogleVertex = createLazySimpleStream(loadGoogleVertexProviderModule);
export const streamMistral = createLazyStream(loadMistralProviderModule);
export const streamSimpleMistral = createLazySimpleStream(loadMistralProviderModule);
export const streamOpenAICodexResponses = createLazyStream(loadOpenAICodexResponsesProviderModule);
export const streamSimpleOpenAICodexResponses = createLazySimpleStream(loadOpenAICodexResponsesProviderModule);
export const streamOpenAICompletions = createLazyStream(loadOpenAICompletionsProviderModule);
export const streamSimpleOpenAICompletions = createLazySimpleStream(loadOpenAICompletionsProviderModule);
export const streamOpenAIResponses = createLazyStream(loadOpenAIResponsesProviderModule);
export const streamSimpleOpenAIResponses = createLazySimpleStream(loadOpenAIResponsesProviderModule);
const streamBedrockLazy = createLazyStream(loadBedrockProviderModule);
const streamSimpleBedrockLazy = createLazySimpleStream(loadBedrockProviderModule);
export function registerBuiltInApiProviders(): void {
registerApiProvider({
api: "anthropic-messages",
stream: streamAnthropic,
streamSimple: streamSimpleAnthropic,
});
registerApiProvider({
api: "openai-completions",
stream: streamOpenAICompletions,
streamSimple: streamSimpleOpenAICompletions,
});
registerApiProvider({
api: "mistral-conversations",
stream: streamMistral,
streamSimple: streamSimpleMistral,
});
registerApiProvider({
api: "openai-responses",
stream: streamOpenAIResponses,
streamSimple: streamSimpleOpenAIResponses,
});
registerApiProvider({
api: "azure-openai-responses",
stream: streamAzureOpenAIResponses,
streamSimple: streamSimpleAzureOpenAIResponses,
});
registerApiProvider({
api: "openai-codex-responses",
stream: streamOpenAICodexResponses,
streamSimple: streamSimpleOpenAICodexResponses,
});
registerApiProvider({
api: "google-generative-ai",
stream: streamGoogle,
streamSimple: streamSimpleGoogle,
});
registerApiProvider({
api: "google-vertex",
stream: streamGoogleVertex,
streamSimple: streamSimpleGoogleVertex,
});
function registerBedrockProviderModule(module: BedrockProviderModule): void {
registerApiProvider({
api: "bedrock-converse-stream",
stream: streamBedrockLazy,
streamSimple: streamSimpleBedrockLazy,
stream: module.streamBedrock,
streamSimple: module.streamSimpleBedrock,
});
}
function loadBedrockProviderModule(): Promise<RegisteringProviderModule> {
const module = bedrockProviderModuleOverride;
if (module) {
return Promise.resolve({ register: () => registerBedrockProviderModule(module) });
}
return importNodeOnlyProvider("./amazon-bedrock.ts").then((provider) => provider as RegisteringProviderModule);
}
const anthropicProvider = createLazyApiProvider<"anthropic-messages", AnthropicOptions>(
"anthropic-messages",
() => import("./anthropic.ts"),
);
const azureOpenAIResponsesProvider = createLazyApiProvider<"azure-openai-responses", AzureOpenAIResponsesOptions>(
"azure-openai-responses",
() => import("./azure-openai-responses.ts"),
);
const googleProvider = createLazyApiProvider<"google-generative-ai", GoogleOptions>(
"google-generative-ai",
() => import("./google.ts"),
);
const googleVertexProvider = createLazyApiProvider<"google-vertex", GoogleVertexOptions>(
"google-vertex",
() => import("./google-vertex.ts"),
);
const mistralProvider = createLazyApiProvider<"mistral-conversations", MistralOptions>(
"mistral-conversations",
() => import("./mistral.ts"),
);
const openAICodexResponsesProvider = createLazyApiProvider<"openai-codex-responses", OpenAICodexResponsesOptions>(
"openai-codex-responses",
() => import("./openai-codex-responses.ts"),
);
const openAICompletionsProvider = createLazyApiProvider<"openai-completions", OpenAICompletionsOptions>(
"openai-completions",
() => import("./openai-completions.ts"),
);
const openAIResponsesProvider = createLazyApiProvider<"openai-responses", OpenAIResponsesOptions>(
"openai-responses",
() => import("./openai-responses.ts"),
);
const bedrockProvider = createLazyApiProvider<"bedrock-converse-stream", BedrockOptions>(
"bedrock-converse-stream",
loadBedrockProviderModule,
);
const openRouterImagesProvider = createLazyImagesApiProvider(
"openrouter-images",
() => import("./images/openrouter.ts"),
);
export const generateImagesOpenRouter = openRouterImagesProvider.generateImages;
export const streamAnthropic = anthropicProvider.stream;
export const streamSimpleAnthropic = anthropicProvider.streamSimple;
export const streamAzureOpenAIResponses = azureOpenAIResponsesProvider.stream;
export const streamSimpleAzureOpenAIResponses = azureOpenAIResponsesProvider.streamSimple;
export const streamGoogle = googleProvider.stream;
export const streamSimpleGoogle = googleProvider.streamSimple;
export const streamGoogleVertex = googleVertexProvider.stream;
export const streamSimpleGoogleVertex = googleVertexProvider.streamSimple;
export const streamMistral = mistralProvider.stream;
export const streamSimpleMistral = mistralProvider.streamSimple;
export const streamOpenAICodexResponses = openAICodexResponsesProvider.stream;
export const streamSimpleOpenAICodexResponses = openAICodexResponsesProvider.streamSimple;
export const streamOpenAICompletions = openAICompletionsProvider.stream;
export const streamSimpleOpenAICompletions = openAICompletionsProvider.streamSimple;
export const streamOpenAIResponses = openAIResponsesProvider.stream;
export const streamSimpleOpenAIResponses = openAIResponsesProvider.streamSimple;
const registerBuiltInApiProviderFunctions = [
() => registerApiProvider(anthropicProvider),
() => registerApiProvider(openAICompletionsProvider),
() => registerApiProvider(mistralProvider),
() => registerApiProvider(openAIResponsesProvider),
() => registerApiProvider(azureOpenAIResponsesProvider),
() => registerApiProvider(openAICodexResponsesProvider),
() => registerApiProvider(googleProvider),
() => registerApiProvider(googleVertexProvider),
() => registerApiProvider(bedrockProvider),
];
export function registerBuiltInImagesApiProviders(): void {
registerImagesApiProvider(openRouterImagesProvider);
}
export function registerBuiltInApiProviders(): void {
for (const register of registerBuiltInApiProviderFunctions) {
register();
}
}
export function resetApiProviders(): void {
clearApiProviders();
registerBuiltInApiProviders();

View File

@@ -13,9 +13,11 @@ export function buildBaseOptions(_model: Model<Api>, options?: SimpleStreamOptio
onPayload: options?.onPayload,
onResponse: options?.onResponse,
timeoutMs: options?.timeoutMs,
websocketConnectTimeoutMs: options?.websocketConnectTimeoutMs,
maxRetries: options?.maxRetries,
maxRetryDelayMs: options?.maxRetryDelayMs,
metadata: options?.metadata,
env: options?.env,
};
}

View File

@@ -1,6 +1,5 @@
import "./providers/register-builtins.ts";
import { getApiProvider } from "./api-registry.ts";
import { getEnvApiKey } from "./env-api-keys.ts";
import type {
Api,
AssistantMessage,
@@ -12,7 +11,19 @@ import type {
StreamOptions,
} from "./types.ts";
export { getEnvApiKey } from "./env-api-keys.ts";
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) return options;
return { ...options, apiKey } as TOptions;
}
function resolveApiProvider(api: Api) {
const provider = getApiProvider(api);
@@ -28,7 +39,7 @@ export function stream<TApi extends Api>(
options?: ProviderStreamOptions,
): AssistantMessageEventStream {
const provider = resolveApiProvider(model.api);
return provider.stream(model, context, options as StreamOptions);
return provider.stream(model, context, withEnvApiKey(model, options) as StreamOptions);
}
export async function complete<TApi extends Api>(
@@ -46,7 +57,7 @@ export function streamSimple<TApi extends Api>(
options?: SimpleStreamOptions,
): AssistantMessageEventStream {
const provider = resolveApiProvider(model.api);
return provider.streamSimple(model, context, options);
return provider.streamSimple(model, context, withEnvApiKey(model, options));
}
export async function completeSimple<TApi extends Api>(

View File

@@ -22,12 +22,14 @@ export type ImagesApi = KnownImagesApi | (string & {});
export type KnownProvider =
| "amazon-bedrock"
| "ant-ling"
| "anthropic"
| "google"
| "google-vertex"
| "openai"
| "azure-openai-responses"
| "openai-codex"
| "nvidia"
| "deepseek"
| "github-copilot"
| "xai"
@@ -36,6 +38,7 @@ export type KnownProvider =
| "openrouter"
| "vercel-ai-gateway"
| "zai"
| "zai-coding-cn"
| "mistral"
| "minimax"
| "minimax-cn"
@@ -62,6 +65,15 @@ export type ImagesProvider = KnownImagesProvider | string;
export type ThinkingLevel = "minimal" | "low" | "medium" | "high" | "xhigh";
export type ModelThinkingLevel = "off" | ThinkingLevel;
export type ThinkingLevelMap = Partial<Record<ModelThinkingLevel, string | null>>;
export type ChatTemplateKwargValue =
| string
| number
| boolean
| null
| {
$var: "thinking.enabled" | "thinking.effort";
omitWhenOff?: boolean;
};
/** Token budgets for each thinking level (token-based providers only) */
export interface ThinkingBudgets {
@@ -76,6 +88,9 @@ export type CacheRetention = "none" | "short" | "long";
export type Transport = "sse" | "websocket" | "websocket-cached" | "auto";
/** Provider-scoped environment overrides. Values take precedence over process.env. */
export type ProviderEnv = Record<string, string>;
export interface ProviderResponse {
status: number;
headers: Record<string, string>;
@@ -114,8 +129,10 @@ export interface StreamOptions {
onResponse?: (response: ProviderResponse, model: Model<Api>) => void | Promise<void>;
/**
* Optional custom HTTP headers to include in API requests.
* Merged with provider defaults; can override default headers.
* Not supported by all providers (e.g., AWS Bedrock uses SDK auth).
* Merged with provider defaults; caller values override default headers.
* On AWS Bedrock these are injected via a Smithy `build`-step middleware so
* they are covered by SigV4 signing; reserved headers (`x-amz-*`,
* `authorization`, `host`) are silently ignored to preserve SigV4 / bearer auth.
*/
headers?: Record<string, string>;
/**
@@ -123,6 +140,12 @@ export interface StreamOptions {
* For example, OpenAI and Anthropic SDK clients default to 10 minutes.
*/
timeoutMs?: number;
/**
* WebSocket connect timeout in milliseconds for providers that support
* WebSocket transports. This covers the connection/open handshake only;
* stream idleness after connection uses timeoutMs.
*/
websocketConnectTimeoutMs?: number;
/**
* Maximum retry attempts for providers/SDKs that support client-side retries.
* For example, OpenAI and Anthropic SDK clients default to 2.
@@ -142,6 +165,12 @@ export interface StreamOptions {
* For example, Anthropic uses `user_id` for abuse tracking and rate limiting.
*/
metadata?: Record<string, unknown>;
/**
* Provider-scoped environment values. These take precedence over process.env for
* provider configuration such as regional settings, endpoint placeholders, and
* proxy variables.
*/
env?: ProviderEnv;
}
export type ProviderStreamOptions = StreamOptions & Record<string, unknown>;
@@ -256,6 +285,8 @@ export interface Usage {
output: number;
cacheRead: number;
cacheWrite: number;
/** Subset of `cacheWrite` written with 1h retention. Only Anthropic reports this split. */
cacheWrite1h?: number;
totalTokens: number;
cost: {
input: number;
@@ -381,9 +412,21 @@ export interface OpenAICompletionsCompat {
requiresThinkingAsText?: boolean;
/** Whether all replayed assistant messages must include an empty reasoning_content field when reasoning is enabled. Default: auto-detected from URL. */
requiresReasoningContentOnAssistantMessages?: boolean;
/** Format for reasoning/thinking parameter. "openai" uses reasoning_effort, "openrouter" uses reasoning: { effort }, "deepseek" uses thinking: { type } plus reasoning_effort, "together" uses reasoning: { enabled } plus reasoning_effort when supported, "zai" uses top-level enable_thinking: boolean, "qwen" uses top-level enable_thinking: boolean, and "qwen-chat-template" uses chat_template_kwargs.enable_thinking. Default: "openai". */
thinkingFormat?: "openai" | "openrouter" | "deepseek" | "together" | "zai" | "qwen" | "qwen-chat-template";
/** OpenRouter-specific routing preferences. Only used when baseUrl points to OpenRouter. */
/** Format for reasoning/thinking parameter. "openai" uses reasoning_effort, "openrouter" uses reasoning: { effort }, "deepseek" uses thinking: { type } plus reasoning_effort when supported, "together" uses reasoning: { enabled } plus reasoning_effort when supported, "zai" uses thinking: { type }, "qwen" uses top-level enable_thinking: boolean, "qwen-chat-template" uses chat_template_kwargs.enable_thinking and preserve_thinking, "chat-template" uses configurable chat_template_kwargs, "string-thinking" uses top-level thinking: string, and "ant-ling" uses reasoning: { effort } only when the mapped effort is non-null. Default: "openai". */
thinkingFormat?:
| "openai"
| "openrouter"
| "deepseek"
| "together"
| "zai"
| "qwen"
| "chat-template"
| "qwen-chat-template"
| "string-thinking"
| "ant-ling";
/** Kwargs to send as `chat_template_kwargs` when `thinkingFormat` is `chat-template`. Use `{ "$var": "thinking.enabled" }` or `{ "$var": "thinking.effort" }` for pi-controlled thinking values. */
chatTemplateKwargs?: Record<string, ChatTemplateKwargValue>;
/** OpenRouter-compatible routing preferences sent as the `provider` request field. */
openRouterRouting?: OpenRouterRouting;
/** Vercel AI Gateway routing preferences. Only used when baseUrl points to Vercel AI Gateway. */
vercelGatewayRouting?: VercelGatewayRouting;
@@ -401,6 +444,8 @@ export interface OpenAICompletionsCompat {
/** Compatibility settings for OpenAI Responses APIs. */
export interface OpenAIResponsesCompat {
/** Whether the provider supports the `developer` role (vs `system`). Default: true. */
supportsDeveloperRole?: boolean;
/** Whether to send the OpenAI `session_id` cache-affinity header from `options.sessionId` when caching is enabled. Default: true. */
sendSessionIdHeader?: boolean;
/** Whether the provider supports `prompt_cache_retention: "24h"`. Default: true. */
@@ -435,6 +480,24 @@ export interface AnthropicMessagesCompat {
* Default: true.
*/
supportsCacheControlOnTools?: boolean;
/**
* Whether the model accepts the Anthropic `temperature` request field.
* Claude Opus 4.7+ rejects non-default temperature values.
* Default: true.
*/
supportsTemperature?: boolean;
/**
* Whether to force adaptive thinking (`thinking.type: "adaptive"` plus
* `output_config.effort`) regardless of the model id. Built-in models that
* require adaptive thinking set this in generated metadata. Custom
* Anthropic-compatible providers can set this to `true` for any model whose
* upstream requires the adaptive format. Set to `false` to
* opt out on overridden built-in models.
* Default: false.
*/
forceAdaptiveThinking?: boolean;
/** Whether to replay empty thinking signatures as `signature: ""` instead of converting thinking to text. Default: false. */
allowEmptySignature?: boolean;
}
/**

View File

@@ -0,0 +1,41 @@
export interface CombinedAbortSignal {
signal?: AbortSignal;
cleanup: () => void;
}
export function combineAbortSignals(signals: readonly (AbortSignal | undefined)[]): CombinedAbortSignal {
const activeSignals = signals.filter((signal): signal is AbortSignal => signal !== undefined);
if (activeSignals.length === 0) {
return { cleanup: () => {} };
}
if (activeSignals.length === 1) {
return { signal: activeSignals[0], cleanup: () => {} };
}
const controller = new AbortController();
const listeners: Array<{ signal: AbortSignal; listener: () => void }> = [];
const abort = (signal: AbortSignal) => {
if (!controller.signal.aborted) {
controller.abort(signal.reason);
}
};
for (const signal of activeSignals) {
if (signal.aborted) {
abort(signal);
break;
}
const listener = () => abort(signal);
signal.addEventListener("abort", listener, { once: true });
listeners.push({ signal, listener });
}
return {
signal: controller.signal,
cleanup: () => {
for (const { signal, listener } of listeners) {
signal.removeEventListener("abort", listener);
}
},
};
}

View File

@@ -1,7 +1,5 @@
import type { Agent as HttpAgent } from "node:http";
import type { Agent as HttpsAgent } from "node:https";
import { HttpProxyAgent } from "http-proxy-agent";
import { HttpsProxyAgent } from "https-proxy-agent";
import type { ProviderEnv } from "../types.ts";
import { getProviderEnvValue } from "./provider-env.ts";
const DEFAULT_PROXY_PORTS: Record<string, number> = {
ftp: 21,
@@ -12,16 +10,16 @@ const DEFAULT_PROXY_PORTS: Record<string, number> = {
wss: 443,
};
export interface NodeHttpProxyAgents {
httpAgent: HttpAgent;
httpsAgent: HttpsAgent;
}
export const UNSUPPORTED_PROXY_PROTOCOL_MESSAGE =
"Unsupported proxy protocol. SOCKS and PAC proxy URLs are not supported; use an HTTP or HTTPS proxy URL.";
function getProxyEnv(key: string): string {
return process.env[key.toLowerCase()] || process.env[key.toUpperCase()] || "";
function getProxyEnv(key: string, env?: ProviderEnv): string {
const lowercaseKey = key.toLowerCase();
const uppercaseKey = key.toUpperCase();
return (
env?.[lowercaseKey] ||
env?.[uppercaseKey] ||
getProviderEnvValue(lowercaseKey) ||
getProviderEnvValue(uppercaseKey) ||
""
);
}
function parseProxyTargetUrl(targetUrl: string | URL): URL | undefined {
@@ -36,8 +34,8 @@ function parseProxyTargetUrl(targetUrl: string | URL): URL | undefined {
}
}
function shouldProxyHostname(hostname: string, port: number): boolean {
const noProxy = getProxyEnv("no_proxy").toLowerCase();
function shouldProxyHostname(hostname: string, port: number, env?: ProviderEnv): boolean {
const noProxy = getProxyEnv("no_proxy", env).toLowerCase();
if (!noProxy) {
return true;
}
@@ -68,7 +66,7 @@ function shouldProxyHostname(hostname: string, port: number): boolean {
});
}
function getProxyForUrl(targetUrl: string | URL): string {
function getProxyForUrl(targetUrl: string | URL, env?: ProviderEnv): string {
const parsedUrl = parseProxyTargetUrl(targetUrl);
if (!parsedUrl?.protocol || !parsedUrl.host) {
return "";
@@ -77,19 +75,22 @@ function getProxyForUrl(targetUrl: string | URL): string {
const protocol = parsedUrl.protocol.split(":", 1)[0]!;
const hostname = parsedUrl.host.replace(/:\d*$/, "");
const port = Number.parseInt(parsedUrl.port, 10) || DEFAULT_PROXY_PORTS[protocol] || 0;
if (!shouldProxyHostname(hostname, port)) {
if (!shouldProxyHostname(hostname, port, env)) {
return "";
}
let proxy = getProxyEnv(`${protocol}_proxy`) || getProxyEnv("all_proxy");
let proxy = getProxyEnv(`${protocol}_proxy`, env) || getProxyEnv("all_proxy", env);
if (proxy && !proxy.includes("://")) {
proxy = `${protocol}://${proxy}`;
}
return proxy;
}
export function resolveHttpProxyUrlForTarget(targetUrl: string | URL): URL | undefined {
const proxy = getProxyForUrl(targetUrl);
export const UNSUPPORTED_PROXY_PROTOCOL_MESSAGE =
"Unsupported proxy protocol. SOCKS and PAC proxy URLs are not supported; use an HTTP or HTTPS proxy URL.";
export function resolveHttpProxyUrlForTarget(targetUrl: string | URL, env?: ProviderEnv): URL | undefined {
const proxy = getProxyForUrl(targetUrl, env);
if (!proxy) {
return undefined;
}
@@ -109,15 +110,3 @@ export function resolveHttpProxyUrlForTarget(targetUrl: string | URL): URL | und
return proxyUrl;
}
export function createHttpProxyAgentsForTarget(targetUrl: string | URL): NodeHttpProxyAgents | undefined {
const proxyUrl = resolveHttpProxyUrlForTarget(targetUrl);
if (!proxyUrl) {
return undefined;
}
return {
httpAgent: new HttpProxyAgent(proxyUrl),
httpsAgent: new HttpsProxyAgent(proxyUrl) as unknown as HttpsAgent,
};
}

View File

@@ -6,6 +6,7 @@
*/
import type { Server } from "node:http";
import { getProviderEnvValue } from "../provider-env.ts";
import { oauthErrorHtml, oauthSuccessHtml } from "./oauth-page.ts";
import { generatePKCE } from "./pkce.ts";
import type { OAuthCredentials, OAuthLoginCallbacks, OAuthPrompt, OAuthProviderInterface } from "./types.ts";
@@ -28,7 +29,7 @@ const decode = (s: string) => atob(s);
const CLIENT_ID = decode("OWQxYzI1MGEtZTYxYi00NGQ5LTg4ZWQtNTk0NGQxOTYyZjVl");
const AUTHORIZE_URL = "https://claude.ai/oauth/authorize";
const TOKEN_URL = "https://platform.claude.com/v1/oauth/token";
const CALLBACK_HOST = process.env.PI_OAUTH_CALLBACK_HOST || "127.0.0.1";
const CALLBACK_HOST = getProviderEnvValue("PI_OAUTH_CALLBACK_HOST") || "127.0.0.1";
const CALLBACK_PORT = 53692;
const CALLBACK_PATH = "/callback";
const REDIRECT_URI = `http://localhost:${CALLBACK_PORT}${CALLBACK_PATH}`;

View File

@@ -0,0 +1,83 @@
const CANCEL_MESSAGE = "Login cancelled";
const TIMEOUT_MESSAGE = "Device flow timed out";
const SLOW_DOWN_TIMEOUT_MESSAGE =
"Device flow timed out after one or more slow_down responses. This is often caused by clock drift in WSL or VM environments. Please sync or restart the VM clock and try again.";
const MINIMUM_INTERVAL_MS = 1000;
// RFC 8628 section 3.2: if the authorization server omits `interval`, the client must use 5 seconds.
const DEFAULT_POLL_INTERVAL_SECONDS = 5;
// RFC 8628 section 3.5: `slow_down` means the polling interval must increase by 5 seconds.
const SLOW_DOWN_INTERVAL_INCREMENT_MS = 5000;
type OAuthDeviceCodeIncompletePollResult =
| { status: "pending" }
| { status: "slow_down" }
| { status: "failed"; message: string };
export type OAuthDeviceCodePollResult<T> = OAuthDeviceCodeIncompletePollResult | { status: "complete"; value: T };
export type OAuthDeviceCodePollOptions<T> = {
intervalSeconds?: number;
expiresInSeconds?: number;
poll: () => Promise<OAuthDeviceCodePollResult<T>>;
signal?: AbortSignal;
};
function abortableSleep(ms: number, signal: AbortSignal | undefined, cancelMessage: string): Promise<void> {
return new Promise((resolve, reject) => {
if (signal?.aborted) {
reject(new Error(cancelMessage));
return;
}
const onAbort = () => {
clearTimeout(timeout);
reject(new Error(cancelMessage));
};
const timeout = setTimeout(() => {
signal?.removeEventListener("abort", onAbort);
resolve();
}, ms);
signal?.addEventListener("abort", onAbort, { once: true });
});
}
export async function pollOAuthDeviceCodeFlow<T>(options: OAuthDeviceCodePollOptions<T>): Promise<T> {
const deadline =
typeof options.expiresInSeconds === "number"
? Date.now() + options.expiresInSeconds * 1000
: Number.POSITIVE_INFINITY;
let intervalMs = Math.max(
MINIMUM_INTERVAL_MS,
Math.floor((options.intervalSeconds ?? DEFAULT_POLL_INTERVAL_SECONDS) * 1000),
);
let slowDownResponses = 0;
while (Date.now() < deadline) {
if (options.signal?.aborted) {
throw new Error(CANCEL_MESSAGE);
}
const result = await options.poll();
if (result.status === "complete") {
return result.value;
}
if (result.status === "failed") {
throw new Error(result.message);
}
if (result.status === "slow_down") {
slowDownResponses += 1;
// RFC 8628 section 3.5: apply this increase to this and all subsequent requests.
intervalMs = Math.max(MINIMUM_INTERVAL_MS, intervalMs + SLOW_DOWN_INTERVAL_INCREMENT_MS);
}
const remainingMs = deadline - Date.now();
if (remainingMs <= 0) {
break;
}
await abortableSleep(Math.min(intervalMs, remainingMs), options.signal, CANCEL_MESSAGE);
}
throw new Error(slowDownResponses > 0 ? SLOW_DOWN_TIMEOUT_MESSAGE : TIMEOUT_MESSAGE);
}

View File

@@ -4,10 +4,12 @@
import { getModels } from "../../models.ts";
import type { Api, Model } from "../../types.ts";
import type { OAuthCredentials, OAuthLoginCallbacks, OAuthProviderInterface } from "./types.ts";
import { pollOAuthDeviceCodeFlow } from "./device-code.ts";
import type { OAuthCredentials, OAuthDeviceCodeInfo, OAuthLoginCallbacks, OAuthProviderInterface } from "./types.ts";
type CopilotCredentials = OAuthCredentials & {
enterpriseUrl?: string;
availableModelIds: string[];
};
const decode = (s: string) => atob(s);
@@ -19,15 +21,13 @@ const COPILOT_HEADERS = {
"Editor-Plugin-Version": "copilot-chat/0.35.0",
"Copilot-Integration-Id": "vscode-chat",
} as const;
const INITIAL_POLL_INTERVAL_MULTIPLIER = 1.2;
const SLOW_DOWN_POLL_INTERVAL_MULTIPLIER = 1.4;
const COPILOT_API_VERSION = "2026-06-01";
type DeviceCodeResponse = {
device_code: string;
user_code: string;
verification_uri: string;
interval: number;
interval?: number;
expires_in: number;
};
@@ -40,7 +40,6 @@ type DeviceTokenSuccessResponse = {
type DeviceTokenErrorResponse = {
error: string;
error_description?: string;
interval?: number;
};
export function normalizeDomain(input: string): string | null {
@@ -91,6 +90,48 @@ export function getGitHubCopilotBaseUrl(token?: string, enterpriseDomain?: strin
return "https://api.individual.githubcopilot.com";
}
function asRecord(value: unknown): Record<string, unknown> | undefined {
return value && typeof value === "object" ? (value as Record<string, unknown>) : undefined;
}
function isSelectableCopilotModel(item: Record<string, unknown>): boolean {
const policy = asRecord(item.policy);
const capabilities = asRecord(item.capabilities);
const supports = asRecord(capabilities?.supports);
return item.model_picker_enabled === true && policy?.state !== "disabled" && supports?.tool_calls !== false;
}
function parseAvailableCopilotModelIds(raw: unknown): string[] {
const data = asRecord(raw)?.data;
if (!Array.isArray(data)) {
throw new Error("Invalid Copilot models response");
}
const ids: string[] = [];
for (const rawItem of data) {
const item = asRecord(rawItem);
const id = item?.id;
if (typeof id === "string" && item && isSelectableCopilotModel(item)) {
ids.push(id);
}
}
return ids;
}
async function fetchAvailableGitHubCopilotModelIds(copilotToken: string, enterpriseDomain?: string): Promise<string[]> {
const baseUrl = getGitHubCopilotBaseUrl(copilotToken, enterpriseDomain);
const raw = await fetchJson(`${baseUrl}/models`, {
headers: {
Accept: "application/json",
Authorization: `Bearer ${copilotToken}`,
...COPILOT_HEADERS,
"X-GitHub-Api-Version": COPILOT_API_VERSION,
},
signal: AbortSignal.timeout(5000),
});
return parseAvailableCopilotModelIds(raw);
}
async function fetchJson(url: string, init: RequestInit): Promise<unknown> {
const response = await fetch(url, init);
if (!response.ok) {
@@ -129,116 +170,82 @@ async function startDeviceFlow(domain: string): Promise<DeviceCodeResponse> {
typeof deviceCode !== "string" ||
typeof userCode !== "string" ||
typeof verificationUri !== "string" ||
typeof interval !== "number" ||
(interval !== undefined && typeof interval !== "number") ||
typeof expiresIn !== "number"
) {
throw new Error("Invalid device code response fields");
}
// The verification URI is opened in the user's browser and to prevent `open` from
// opening an executable or similar, we force it to be a URL.
let parsedUri: URL;
try {
parsedUri = new URL(verificationUri);
} catch {
throw new Error("Untrusted verification_uri in device code response");
}
if (parsedUri.protocol !== "https:" && parsedUri.protocol !== "http:") {
throw new Error("Untrusted verification_uri in device code response");
}
return {
device_code: deviceCode,
user_code: userCode,
verification_uri: verificationUri,
verification_uri: parsedUri.href,
interval,
expires_in: expiresIn,
};
}
/**
* Sleep that can be interrupted by an AbortSignal
*/
function abortableSleep(ms: number, signal?: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
if (signal?.aborted) {
reject(new Error("Login cancelled"));
return;
}
async function pollForGitHubAccessToken(
domain: string,
device: DeviceCodeResponse,
signal?: AbortSignal,
): Promise<string> {
const urls = getUrls(domain);
return pollOAuthDeviceCodeFlow<string>({
intervalSeconds: device.interval,
expiresInSeconds: device.expires_in,
signal,
poll: async () => {
const raw = await fetchJson(urls.accessTokenUrl, {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/x-www-form-urlencoded",
"User-Agent": "GitHubCopilotChat/0.35.0",
},
body: new URLSearchParams({
client_id: CLIENT_ID,
device_code: device.device_code,
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
}),
});
const timeout = setTimeout(resolve, ms);
if (raw && typeof raw === "object" && typeof (raw as DeviceTokenSuccessResponse).access_token === "string") {
return { status: "complete", value: (raw as DeviceTokenSuccessResponse).access_token };
}
signal?.addEventListener(
"abort",
() => {
clearTimeout(timeout);
reject(new Error("Login cancelled"));
},
{ once: true },
);
if (raw && typeof raw === "object" && typeof (raw as DeviceTokenErrorResponse).error === "string") {
const { error, error_description: description } = raw as DeviceTokenErrorResponse;
if (error === "authorization_pending") {
return { status: "pending" };
}
if (error === "slow_down") {
return { status: "slow_down" };
}
const descriptionSuffix = description ? `: ${description}` : "";
return { status: "failed", message: `Device flow failed: ${error}${descriptionSuffix}` };
}
return { status: "failed", message: "Invalid device token response" };
},
});
}
async function pollForGitHubAccessToken(
domain: string,
deviceCode: string,
intervalSeconds: number,
expiresIn: number,
signal?: AbortSignal,
) {
const urls = getUrls(domain);
const deadline = Date.now() + expiresIn * 1000;
let intervalMs = Math.max(1000, Math.floor(intervalSeconds * 1000));
let intervalMultiplier = INITIAL_POLL_INTERVAL_MULTIPLIER;
let slowDownResponses = 0;
while (Date.now() < deadline) {
if (signal?.aborted) {
throw new Error("Login cancelled");
}
const remainingMs = deadline - Date.now();
const waitMs = Math.min(Math.ceil(intervalMs * intervalMultiplier), remainingMs);
await abortableSleep(waitMs, signal);
const raw = await fetchJson(urls.accessTokenUrl, {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/x-www-form-urlencoded",
"User-Agent": "GitHubCopilotChat/0.35.0",
},
body: new URLSearchParams({
client_id: CLIENT_ID,
device_code: deviceCode,
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
}),
});
if (raw && typeof raw === "object" && typeof (raw as DeviceTokenSuccessResponse).access_token === "string") {
return (raw as DeviceTokenSuccessResponse).access_token;
}
if (raw && typeof raw === "object" && typeof (raw as DeviceTokenErrorResponse).error === "string") {
const { error, error_description: description, interval } = raw as DeviceTokenErrorResponse;
if (error === "authorization_pending") {
continue;
}
if (error === "slow_down") {
slowDownResponses += 1;
intervalMs =
typeof interval === "number" && interval > 0 ? interval * 1000 : Math.max(1000, intervalMs + 5000);
intervalMultiplier = SLOW_DOWN_POLL_INTERVAL_MULTIPLIER;
continue;
}
const descriptionSuffix = description ? `: ${description}` : "";
throw new Error(`Device flow failed: ${error}${descriptionSuffix}`);
}
}
if (slowDownResponses > 0) {
throw new Error(
"Device flow timed out after one or more slow_down responses. This is often caused by clock drift in WSL or VM environments. Please sync or restart the VM clock and try again.",
);
}
throw new Error("Device flow timed out");
}
/**
* Refresh GitHub Copilot token
*/
export async function refreshGitHubCopilotToken(
async function refreshGitHubCopilotAccessToken(
refreshToken: string,
enterpriseDomain?: string,
): Promise<OAuthCredentials> {
@@ -272,6 +279,20 @@ export async function refreshGitHubCopilotToken(
};
}
/**
* Refresh GitHub Copilot token
*/
export async function refreshGitHubCopilotToken(
refreshToken: string,
enterpriseDomain?: string,
): Promise<OAuthCredentials> {
const credentials = await refreshGitHubCopilotAccessToken(refreshToken, enterpriseDomain);
return {
...credentials,
availableModelIds: await fetchAvailableGitHubCopilotModelIds(credentials.access, enterpriseDomain),
};
}
/**
* Enable a model for the user's GitHub Copilot account.
* This is required for some models (like Claude, Grok) before they can be used.
@@ -319,13 +340,13 @@ async function enableAllGitHubCopilotModels(
/**
* Login with GitHub Copilot OAuth (device code flow)
*
* @param options.onAuth - Callback with URL and optional instructions (user code)
* @param options.onDeviceCode - Callback with URL and user code
* @param options.onPrompt - Callback to prompt user for input
* @param options.onProgress - Optional progress callback
* @param options.signal - Optional AbortSignal for cancellation
*/
export async function loginGitHubCopilot(options: {
onAuth: (url: string, instructions?: string) => void;
onDeviceCode: (info: OAuthDeviceCodeInfo) => void;
onPrompt: (prompt: { message: string; placeholder?: string; allowEmpty?: boolean }) => Promise<string>;
onProgress?: (message: string) => void;
signal?: AbortSignal;
@@ -348,21 +369,26 @@ export async function loginGitHubCopilot(options: {
const domain = enterpriseDomain || "github.com";
const device = await startDeviceFlow(domain);
options.onAuth(device.verification_uri, `Enter code: ${device.user_code}`);
options.onDeviceCode({
userCode: device.user_code,
verificationUri: device.verification_uri,
intervalSeconds: device.interval,
expiresInSeconds: device.expires_in,
});
const githubAccessToken = await pollForGitHubAccessToken(
domain,
device.device_code,
device.interval,
device.expires_in,
options.signal,
);
const credentials = await refreshGitHubCopilotToken(githubAccessToken, enterpriseDomain ?? undefined);
const githubAccessToken = await pollForGitHubAccessToken(domain, device, options.signal);
const credentials = await refreshGitHubCopilotAccessToken(githubAccessToken, enterpriseDomain ?? undefined);
// Enable all models after successful login
options.onProgress?.("Enabling models...");
await enableAllGitHubCopilotModels(credentials.access, enterpriseDomain ?? undefined);
return credentials;
// Fetch availability after policy enable so newly enabled models are included,
// while unavailable models are still filtered out.
return {
...credentials,
availableModelIds: await fetchAvailableGitHubCopilotModelIds(credentials.access, enterpriseDomain ?? undefined),
};
}
export const githubCopilotOAuthProvider: OAuthProviderInterface = {
@@ -371,7 +397,7 @@ export const githubCopilotOAuthProvider: OAuthProviderInterface = {
async login(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials> {
return loginGitHubCopilot({
onAuth: (url, instructions) => callbacks.onAuth({ url, instructions }),
onDeviceCode: callbacks.onDeviceCode,
onPrompt: callbacks.onPrompt,
onProgress: callbacks.onProgress,
signal: callbacks.signal,
@@ -391,6 +417,14 @@ export const githubCopilotOAuthProvider: OAuthProviderInterface = {
const creds = credentials as CopilotCredentials;
const domain = creds.enterpriseUrl ? (normalizeDomain(creds.enterpriseUrl) ?? undefined) : undefined;
const baseUrl = getGitHubCopilotBaseUrl(creds.access, domain);
return models.map((m) => (m.provider === "github-copilot" ? { ...m, baseUrl } : m));
// Older stored Pi auth entries do not have account-specific model IDs yet;
// keep their existing generated-catalog behavior until the next refresh/login.
const availableModelIds = "availableModelIds" in creds ? new Set(creds.availableModelIds) : undefined;
return models.flatMap((m) => {
if (m.provider !== "github-copilot") return [m];
if (availableModelIds && !availableModelIds.has(m.id)) return [];
return [{ ...m, baseUrl }];
});
},
};

View File

@@ -9,6 +9,7 @@
// Anthropic
export { anthropicOAuthProvider, loginAnthropic, refreshAnthropicToken } from "./anthropic.ts";
export * from "./device-code.ts";
// GitHub Copilot
export {
getGitHubCopilotBaseUrl,
@@ -18,7 +19,14 @@ export {
refreshGitHubCopilotToken,
} from "./github-copilot.ts";
// OpenAI Codex (ChatGPT OAuth)
export { loginOpenAICodex, openaiCodexOAuthProvider, refreshOpenAICodexToken } from "./openai-codex.ts";
export {
loginOpenAICodex,
loginOpenAICodexDeviceCode,
OPENAI_CODEX_BROWSER_LOGIN_METHOD,
OPENAI_CODEX_DEVICE_CODE_LOGIN_METHOD,
openaiCodexOAuthProvider,
refreshOpenAICodexToken,
} from "./openai-codex.ts";
export * from "./types.ts";

View File

@@ -17,21 +17,50 @@ if (typeof process !== "undefined" && (process.versions?.node || process.version
});
}
import { getProviderEnvValue } from "../provider-env.ts";
import { pollOAuthDeviceCodeFlow } from "./device-code.ts";
import { oauthErrorHtml, oauthSuccessHtml } from "./oauth-page.ts";
import { generatePKCE } from "./pkce.ts";
import type { OAuthCredentials, OAuthLoginCallbacks, OAuthPrompt, OAuthProviderInterface } from "./types.ts";
import type {
OAuthCredentials,
OAuthDeviceCodeInfo,
OAuthLoginCallbacks,
OAuthPrompt,
OAuthProviderInterface,
} from "./types.ts";
const CALLBACK_HOST = process.env.PI_OAUTH_CALLBACK_HOST || "127.0.0.1";
const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
const AUTHORIZE_URL = "https://auth.openai.com/oauth/authorize";
const TOKEN_URL = "https://auth.openai.com/oauth/token";
const AUTH_BASE_URL = "https://auth.openai.com";
const AUTHORIZE_URL = `${AUTH_BASE_URL}/oauth/authorize`;
const TOKEN_URL = `${AUTH_BASE_URL}/oauth/token`;
const REDIRECT_URI = "http://localhost:1455/auth/callback";
const DEVICE_USER_CODE_URL = `${AUTH_BASE_URL}/api/accounts/deviceauth/usercode`;
const DEVICE_TOKEN_URL = `${AUTH_BASE_URL}/api/accounts/deviceauth/token`;
const DEVICE_VERIFICATION_URI = `${AUTH_BASE_URL}/codex/device`;
const DEVICE_REDIRECT_URI = `${AUTH_BASE_URL}/deviceauth/callback`;
const DEVICE_CODE_TIMEOUT_SECONDS = 15 * 60;
export const OPENAI_CODEX_BROWSER_LOGIN_METHOD = "browser";
export const OPENAI_CODEX_DEVICE_CODE_LOGIN_METHOD = "device_code";
const SCOPE = "openid profile email offline_access";
const JWT_CLAIM_PATH = "https://api.openai.com/auth";
type TokenSuccess = { type: "success"; access: string; refresh: string; expires: number };
type TokenFailure = { type: "failed"; message: string; status?: number };
type TokenResult = TokenSuccess | TokenFailure;
type OAuthToken = { access: string; refresh: string; expires: number };
type TokenOperation = "exchange" | "refresh";
function getCallbackHost(): string {
return getProviderEnvValue("PI_OAUTH_CALLBACK_HOST") || "127.0.0.1";
}
type DeviceAuthInfo = {
deviceAuthId: string;
userCode: string;
intervalSeconds: number;
};
type DeviceTokenSuccess = {
authorizationCode: string;
codeVerifier: string;
};
type JwtPayload = {
[JWT_CLAIM_PATH]?: {
@@ -89,12 +118,47 @@ function decodeJwt(token: string): JwtPayload | null {
}
}
async function fetchWithLoginCancellation(input: string, init: RequestInit): Promise<Response> {
try {
return await fetch(input, init);
} catch (error) {
if (init.signal?.aborted) {
throw new Error("Login cancelled");
}
throw error;
}
}
async function readTokenResponse(response: Response, operation: TokenOperation): Promise<OAuthToken> {
if (!response.ok) {
const text = await response.text().catch(() => "");
throw new Error(`OpenAI Codex token ${operation} failed (${response.status}): ${text || response.statusText}`);
}
const rawJson = await response.json();
const json = rawJson as {
access_token?: string;
refresh_token?: string;
expires_in?: number;
} | null;
if (!json?.access_token || !json.refresh_token || typeof json.expires_in !== "number") {
throw new Error(`OpenAI Codex token ${operation} response missing fields: ${JSON.stringify(json)}`);
}
return {
access: json.access_token,
refresh: json.refresh_token,
expires: Date.now() + json.expires_in * 1000,
};
}
async function exchangeAuthorizationCode(
code: string,
verifier: string,
redirectUri: string = REDIRECT_URI,
): Promise<TokenResult> {
const response = await fetch(TOKEN_URL, {
signal?: AbortSignal,
): Promise<OAuthToken> {
const response = await fetchWithLoginCancellation(TOKEN_URL, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
@@ -104,41 +168,16 @@ async function exchangeAuthorizationCode(
code_verifier: verifier,
redirect_uri: redirectUri,
}),
signal,
});
if (!response.ok) {
const text = await response.text().catch(() => "");
return {
type: "failed",
status: response.status,
message: `OpenAI Codex token exchange failed (${response.status}): ${text || response.statusText}`,
};
}
const json = (await response.json()) as {
access_token?: string;
refresh_token?: string;
expires_in?: number;
};
if (!json.access_token || !json.refresh_token || typeof json.expires_in !== "number") {
return {
type: "failed",
message: `OpenAI Codex token exchange response missing fields: ${JSON.stringify(json)}`,
};
}
return {
type: "success",
access: json.access_token,
refresh: json.refresh_token,
expires: Date.now() + json.expires_in * 1000,
};
return readTokenResponse(response, "exchange");
}
async function refreshAccessToken(refreshToken: string): Promise<TokenResult> {
async function refreshAccessToken(refreshToken: string): Promise<OAuthToken> {
let response: Response;
try {
const response = await fetch(TOKEN_URL, {
response = await fetch(TOKEN_URL, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
@@ -147,41 +186,113 @@ async function refreshAccessToken(refreshToken: string): Promise<TokenResult> {
client_id: CLIENT_ID,
}),
});
if (!response.ok) {
const text = await response.text().catch(() => "");
return {
type: "failed",
status: response.status,
message: `OpenAI Codex token refresh failed (${response.status}): ${text || response.statusText}`,
};
}
const json = (await response.json()) as {
access_token?: string;
refresh_token?: string;
expires_in?: number;
};
if (!json.access_token || !json.refresh_token || typeof json.expires_in !== "number") {
return {
type: "failed",
message: `OpenAI Codex token refresh response missing fields: ${JSON.stringify(json)}`,
};
}
return {
type: "success",
access: json.access_token,
refresh: json.refresh_token,
expires: Date.now() + json.expires_in * 1000,
};
} catch (error) {
return {
type: "failed",
message: `OpenAI Codex token refresh error: ${error instanceof Error ? error.message : String(error)}`,
};
throw new Error(`OpenAI Codex token refresh error: ${error instanceof Error ? error.message : String(error)}`);
}
return readTokenResponse(response, "refresh");
}
async function startOpenAICodexDeviceAuth(signal?: AbortSignal): Promise<DeviceAuthInfo> {
const response = await fetchWithLoginCancellation(DEVICE_USER_CODE_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ client_id: CLIENT_ID }),
signal,
});
if (!response.ok) {
if (response.status === 404) {
throw new Error(
"OpenAI Codex device code login is not enabled for this server. Use browser login or verify the server URL.",
);
}
const responseBody = await response.text().catch(() => "");
throw new Error(
`OpenAI Codex device code request failed with status ${response.status}${responseBody ? `: ${responseBody}` : ""}`,
);
}
const rawJson = await response.json();
const json = rawJson as {
device_auth_id?: string;
user_code?: string;
interval?: number | string;
} | null;
const intervalSeconds = typeof json?.interval === "string" ? Number(json.interval.trim()) : json?.interval;
if (
!json?.device_auth_id ||
!json.user_code ||
typeof intervalSeconds !== "number" ||
!Number.isFinite(intervalSeconds) ||
intervalSeconds < 0
) {
throw new Error(`Invalid OpenAI Codex device code response: ${JSON.stringify(json)}`);
}
return {
deviceAuthId: json.device_auth_id,
userCode: json.user_code,
intervalSeconds,
};
}
async function pollOpenAICodexDeviceAuth(device: DeviceAuthInfo, signal?: AbortSignal): Promise<DeviceTokenSuccess> {
return pollOAuthDeviceCodeFlow<DeviceTokenSuccess>({
intervalSeconds: device.intervalSeconds,
expiresInSeconds: DEVICE_CODE_TIMEOUT_SECONDS,
signal,
poll: async () => {
const response = await fetchWithLoginCancellation(DEVICE_TOKEN_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
device_auth_id: device.deviceAuthId,
user_code: device.userCode,
}),
signal,
});
if (response.ok) {
const rawJson = await response.json();
const json = rawJson as { authorization_code?: string; code_verifier?: string } | null;
if (!json?.authorization_code || !json.code_verifier) {
return {
status: "failed",
message: `Invalid OpenAI Codex device auth token response: ${JSON.stringify(json)}`,
};
}
return {
status: "complete",
value: { authorizationCode: json.authorization_code, codeVerifier: json.code_verifier },
};
}
if (response.status === 403 || response.status === 404) {
return { status: "pending" };
}
const responseBody = await response.text().catch(() => "");
let errorCode: unknown;
try {
const json = JSON.parse(responseBody) as { error?: string | { code?: string } } | null;
const error = json?.error;
errorCode = typeof error === "object" ? error?.code : error;
} catch {}
if (errorCode === "deviceauth_authorization_pending") {
return { status: "pending" };
}
if (errorCode === "slow_down") {
return { status: "slow_down" };
}
return {
status: "failed",
message: `OpenAI Codex device auth failed with status ${response.status}${responseBody ? `: ${responseBody}` : ""}`,
};
},
});
}
async function createAuthorizationFlow(
@@ -261,7 +372,7 @@ function startLocalOAuthServer(state: string): Promise<OAuthServerInfo> {
return new Promise((resolve) => {
server
.listen(1455, CALLBACK_HOST, () => {
.listen(1455, getCallbackHost(), () => {
resolve({
close: () => server.close(),
cancelWait: () => {
@@ -294,6 +405,52 @@ function getAccountId(accessToken: string): string | null {
return typeof accountId === "string" && accountId.length > 0 ? accountId : null;
}
function credentialsFromToken(token: OAuthToken): OAuthCredentials {
const accountId = getAccountId(token.access);
if (!accountId) {
throw new Error("Failed to extract accountId from token");
}
return {
access: token.access,
refresh: token.refresh,
expires: token.expires,
accountId,
};
}
async function exchangeAuthorizationCodeForCredentials(
code: string,
verifier: string,
redirectUri: string,
signal?: AbortSignal,
): Promise<OAuthCredentials> {
return credentialsFromToken(await exchangeAuthorizationCode(code, verifier, redirectUri, signal));
}
/**
* Login with OpenAI Codex OAuth using the Codex device-code flow.
*/
export async function loginOpenAICodexDeviceCode(options: {
onDeviceCode: (info: OAuthDeviceCodeInfo) => void;
signal?: AbortSignal;
}): Promise<OAuthCredentials> {
const device = await startOpenAICodexDeviceAuth(options.signal);
options.onDeviceCode({
userCode: device.userCode,
verificationUri: DEVICE_VERIFICATION_URI,
intervalSeconds: device.intervalSeconds,
expiresInSeconds: DEVICE_CODE_TIMEOUT_SECONDS,
});
const code = await pollOpenAICodexDeviceAuth(device, options.signal);
return exchangeAuthorizationCodeForCredentials(
code.authorizationCode,
code.codeVerifier,
DEVICE_REDIRECT_URI,
options.signal,
);
}
/**
* Login with OpenAI Codex OAuth
*
@@ -391,22 +548,7 @@ export async function loginOpenAICodex(options: {
throw new Error("Missing authorization code");
}
const tokenResult = await exchangeAuthorizationCode(code, verifier);
if (tokenResult.type !== "success") {
throw new Error(tokenResult.message);
}
const accountId = getAccountId(tokenResult.access);
if (!accountId) {
throw new Error("Failed to extract accountId from token");
}
return {
access: tokenResult.access,
refresh: tokenResult.refresh,
expires: tokenResult.expires,
accountId,
};
return exchangeAuthorizationCodeForCredentials(code, verifier, REDIRECT_URI);
} finally {
server.close();
}
@@ -416,22 +558,7 @@ export async function loginOpenAICodex(options: {
* Refresh OpenAI Codex OAuth token
*/
export async function refreshOpenAICodexToken(refreshToken: string): Promise<OAuthCredentials> {
const result = await refreshAccessToken(refreshToken);
if (result.type !== "success") {
throw new Error(result.message);
}
const accountId = getAccountId(result.access);
if (!accountId) {
throw new Error("Failed to extract accountId from token");
}
return {
access: result.access,
refresh: result.refresh,
expires: result.expires,
accountId,
};
return credentialsFromToken(await refreshAccessToken(refreshToken));
}
export const openaiCodexOAuthProvider: OAuthProviderInterface = {
@@ -440,6 +567,28 @@ export const openaiCodexOAuthProvider: OAuthProviderInterface = {
usesCallbackServer: true,
async login(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials> {
const loginMethod = await callbacks.onSelect({
message: "Select OpenAI Codex login method:",
options: [
{ id: OPENAI_CODEX_BROWSER_LOGIN_METHOD, label: "Browser login (default)" },
{ id: OPENAI_CODEX_DEVICE_CODE_LOGIN_METHOD, label: "Device code login (headless)" },
],
});
if (!loginMethod) {
throw new Error("Login cancelled");
}
if (loginMethod === OPENAI_CODEX_DEVICE_CODE_LOGIN_METHOD) {
return loginOpenAICodexDeviceCode({
onDeviceCode: callbacks.onDeviceCode,
signal: callbacks.signal,
});
}
if (loginMethod !== OPENAI_CODEX_BROWSER_LOGIN_METHOD) {
throw new Error(`Unknown OpenAI Codex login method: ${loginMethod}`);
}
return loginOpenAICodex({
onAuth: callbacks.onAuth,
onPrompt: callbacks.onPrompt,

View File

@@ -23,6 +23,13 @@ export type OAuthAuthInfo = {
instructions?: string;
};
export type OAuthDeviceCodeInfo = {
userCode: string;
verificationUri: string;
intervalSeconds?: number;
expiresInSeconds?: number;
};
export type OAuthSelectOption = {
id: string;
label: string;
@@ -35,11 +42,12 @@ export type OAuthSelectPrompt = {
export interface OAuthLoginCallbacks {
onAuth: (info: OAuthAuthInfo) => void;
onDeviceCode: (info: OAuthDeviceCodeInfo) => void;
onPrompt: (prompt: OAuthPrompt) => Promise<string>;
onProgress?: (message: string) => void;
onManualCodeInput?: () => Promise<string>;
/** Show an interactive selector and return the selected option id, or undefined on cancel. */
onSelect?: (prompt: OAuthSelectPrompt) => Promise<string | undefined>;
onSelect: (prompt: OAuthSelectPrompt) => Promise<string | undefined>;
signal?: AbortSignal;
}

View File

@@ -12,10 +12,12 @@ import type { AssistantMessage } from "../types.ts";
* - Anthropic: "413 {\"error\":{\"type\":\"request_too_large\",\"message\":\"Request exceeds the maximum size\"}}"
* - OpenAI: "Your input exceeds the context window of this model"
* - OpenAI/LiteLLM: "Requested token count exceeds the model's maximum context length of 131072 tokens"
* - OpenAI-compatible: "Input length (265330) exceeds model's maximum context length (262144)."
* - Google: "The input token count (1196265) exceeds the maximum number of tokens allowed (1048575)"
* - xAI: "This model's maximum prompt length is 131072 but the request contains 537812 tokens"
* - Groq: "Please reduce the length of the messages or completion"
* - OpenRouter: "This endpoint's maximum context length is X tokens. However, you requested about Y tokens"
* - OpenRouter/Poolside: "Input length X exceeds the maximum allowed input length of Y tokens."
* - Together AI: "The input (X tokens) is longer than the model's context length (Y tokens)."
* - llama.cpp: "the request exceeds the available context size, try increasing it"
* - LM Studio: "tokens to keep from the initial prompt is greater than the context length"
@@ -35,11 +37,12 @@ const OVERFLOW_PATTERNS = [
/request_too_large/i, // Anthropic request byte-size overflow (HTTP 413)
/input is too long for requested model/i, // Amazon Bedrock
/exceeds the context window/i, // OpenAI (Completions & Responses API)
/exceeds (?:the )?(?:model'?s )?maximum context length of [\d,]+ tokens?/i, // OpenAI-compatible proxies (LiteLLM)
/exceeds (?:the )?(?:model'?s )?maximum context length(?: of [\d,]+ tokens?|\s*\([\d,]+\))/i, // OpenAI-compatible proxies (LiteLLM)
/input token count.*exceeds the maximum/i, // Google (Gemini)
/maximum prompt length is \d+/i, // xAI (Grok)
/reduce the length of the messages/i, // Groq
/maximum context length is \d+ tokens/i, // OpenRouter (all backends)
/maximum context length is \d+ tokens/i, // OpenRouter (most backends)
/exceeds (?:the )?maximum allowed input length of [\d,]+ tokens?/i, // OpenRouter/Poolside
/input \(\d+ tokens\) is longer than the model'?s context length \(\d+ tokens\)/i, // Together AI
/exceeds the limit of \d+/i, // GitHub Copilot
/exceeds the available context size/i, // llama.cpp server
@@ -83,13 +86,14 @@ const NON_OVERFLOW_PATTERNS = [
*
* **Reliable detection (returns error with detectable message):**
* - Anthropic: "prompt is too long: X tokens > Y maximum" or "request_too_large"
* - OpenAI (Completions & Responses): "exceeds the context window" or "exceeds the model's maximum context length of X tokens"
* - OpenAI (Completions & Responses): "exceeds the context window", "exceeds the model's maximum context length of X tokens", or "exceeds model's maximum context length (X)"
* - Google Gemini: "input token count exceeds the maximum"
* - xAI (Grok): "maximum prompt length is X but request contains Y"
* - Groq: "reduce the length of the messages"
* - Cerebras: 400/413 status code (no body)
* - Mistral: "Prompt contains X tokens ... too large for model with Y maximum context length"
* - OpenRouter (all backends): "maximum context length is X tokens"
* - OpenRouter (most backends): "maximum context length is X tokens"
* - OpenRouter/Poolside: "Input length X exceeds the maximum allowed input length of Y tokens."
* - Together AI: "The input (X tokens) is longer than the model's context length (Y tokens)."
* - llama.cpp: "exceeds the available context size"
* - LM Studio: "greater than the context length"

View File

@@ -0,0 +1,52 @@
import type { ProviderEnv } from "../types.ts";
let procEnvCache: Map<string, string> | null = null;
/**
* Fallback for https://github.com/oven-sh/bun/issues/27802.
* Bun compiled binaries can expose an empty process.env inside Linux sandboxes
* even though /proc/self/environ contains the environment.
*
* This intentionally duplicates restoreSandboxEnv() in
* packages/coding-agent/src/bun/restore-sandbox-env.ts. The ai package can be
* used directly, without going through that entrypoint, so provider env lookup
* must not depend on process.env having been patched.
*/
function getBunSandboxEnvValue(name: string): string | undefined {
if (typeof process === "undefined" || !process.versions?.bun || Object.keys(process.env).length > 0) {
return undefined;
}
if (procEnvCache === null) {
procEnvCache = new Map();
try {
const { readFileSync } = require("node:fs") as {
readFileSync(path: string, encoding: BufferEncoding): string;
};
const data = readFileSync("/proc/self/environ", "utf-8");
for (const entry of data.split("\0")) {
const idx = entry.indexOf("=");
if (idx > 0) {
procEnvCache.set(entry.slice(0, idx), entry.slice(idx + 1));
}
}
} catch {
// /proc/self/environ may not exist or may not be readable.
}
}
return procEnvCache.get(name);
}
/**
* Resolve a provider env value from scoped overrides, normal process.env, then
* the duplicated Bun sandbox fallback for direct pi-ai consumers.
*/
export function getProviderEnvValue(name: string, env?: ProviderEnv): string | undefined {
return (
env?.[name] ||
(typeof process !== "undefined" ? process.env[name] : undefined) ||
getBunSandboxEnvValue(name) ||
undefined
);
}

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { complete, stream } from "../src/index.ts";
import { getModel } from "../src/models.ts";
import { complete, stream } from "../src/stream.ts";
import type { Api, Context, Model, StreamOptions } from "../src/types.ts";
type StreamOptionsWithExtras = StreamOptions & Record<string, unknown>;

View File

@@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest";
import { getModels, getProviders } from "../src/models.ts";
import type { Api, Model } from "../src/types.ts";
const EXPECTED_CURRENT_ADAPTIVE_THINKING_MODELS = [
"anthropic/claude-fable-5",
"anthropic/claude-opus-4-8",
"cloudflare-ai-gateway/claude-fable-5",
"opencode/claude-opus-4-8",
"vercel-ai-gateway/anthropic/claude-opus-4.8",
];
function getAllModels(): Model<Api>[] {
return getProviders().flatMap((provider) => getModels(provider) as Model<Api>[]);
}
describe("Anthropic adaptive thinking model metadata", () => {
it("marks built-in Anthropic Messages models that use adaptive thinking", () => {
const flaggedModels = getAllModels()
.filter((model): model is Model<"anthropic-messages"> => model.api === "anthropic-messages")
.filter((model) => model.compat?.forceAdaptiveThinking === true)
.map((model) => `${model.provider}/${model.id}`)
.sort();
expect(flaggedModels).toEqual(expect.arrayContaining([...EXPECTED_CURRENT_ADAPTIVE_THINKING_MODELS].sort()));
expect(flaggedModels).toEqual(
flaggedModels.filter((modelId) => /(opus[-.]4[-.][678]|sonnet[-.]4[-.]6|fable[-.]5)/.test(modelId)),
);
});
});

View File

@@ -0,0 +1,86 @@
import type Anthropic from "@anthropic-ai/sdk";
import { describe, expect, it } from "vitest";
import { getModel } from "../src/models.ts";
import { streamAnthropic } from "../src/providers/anthropic.ts";
import type { Context } from "../src/types.ts";
function createSseResponse(events: Array<{ event: string; data: string }>): Response {
const body = events.map(({ event, data }) => `event: ${event}\ndata: ${data}\n`).join("\n");
return new Response(body, { status: 200, headers: { "content-type": "text/event-stream" } });
}
function createFakeAnthropicClient(response: Response): Anthropic {
return {
messages: { create: () => ({ asResponse: async () => response }) },
} as unknown as Anthropic;
}
function eventsWithCacheCreation(
cacheCreation: Record<string, number> | undefined,
): Array<{ event: string; data: string }> {
const startUsage: Record<string, unknown> = {
input_tokens: 100,
output_tokens: 0,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 1_000_000,
};
if (cacheCreation) startUsage.cache_creation = cacheCreation;
return [
{
event: "message_start",
data: JSON.stringify({ type: "message_start", message: { id: "msg_test", usage: startUsage } }),
},
{
event: "content_block_start",
data: JSON.stringify({ type: "content_block_start", index: 0, content_block: { type: "text", text: "" } }),
},
{
event: "content_block_delta",
data: JSON.stringify({ type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "Hi" } }),
},
{ event: "content_block_stop", data: JSON.stringify({ type: "content_block_stop", index: 0 }) },
{
event: "message_delta",
data: JSON.stringify({
type: "message_delta",
delta: { stop_reason: "end_turn" },
usage: {
input_tokens: 100,
output_tokens: 5,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 1_000_000,
},
}),
},
{ event: "message_stop", data: JSON.stringify({ type: "message_stop" }) },
];
}
// claude-opus-4-8: input 5, cacheWrite (5m) 6.25 per Mtok. 1h write = 2x input = 10.
const context: Context = { messages: [{ role: "user", content: "hi", timestamp: Date.now() }] };
describe("Anthropic 1h cache write cost", () => {
it("prices the 1h portion at 2x input and the rest at the 5m rate", async () => {
const model = getModel("anthropic", "claude-opus-4-8");
const response = createSseResponse(
eventsWithCacheCreation({ ephemeral_5m_input_tokens: 600_000, ephemeral_1h_input_tokens: 400_000 }),
);
const result = await streamAnthropic(model, context, { client: createFakeAnthropicClient(response) }).result();
expect(result.usage.cacheWrite).toBe(1_000_000);
expect(result.usage.cacheWrite1h).toBe(400_000);
// 600k * 6.25/Mtok + 400k * 10/Mtok = 3.75 + 4.0 = 7.75
expect(result.usage.cost.cacheWrite).toBeCloseTo(7.75, 10);
});
it("falls back to the 5m rate when no breakdown is reported", async () => {
const model = getModel("anthropic", "claude-opus-4-8");
const response = createSseResponse(eventsWithCacheCreation(undefined));
const result = await streamAnthropic(model, context, { client: createFakeAnthropicClient(response) }).result();
expect(result.usage.cacheWrite).toBe(1_000_000);
expect(result.usage.cacheWrite1h ?? 0).toBe(0);
// 1M * 6.25/Mtok = 6.25
expect(result.usage.cost.cacheWrite).toBeCloseTo(6.25, 10);
});
});

View File

@@ -12,8 +12,8 @@ interface CapturedRequest {
function createModel(baseUrl: string, compat?: Model<"anthropic-messages">["compat"]): Model<"anthropic-messages"> {
return {
id: "claude-opus-4-7",
name: "Claude Opus 4.7",
id: "claude-opus-4-8",
name: "Claude Opus 4.8",
api: "anthropic-messages",
provider: "test-anthropic",
baseUrl,
@@ -22,7 +22,7 @@ function createModel(baseUrl: string, compat?: Model<"anthropic-messages">["comp
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 200000,
maxTokens: 32000,
compat,
compat: { forceAdaptiveThinking: true, ...compat },
};
}

View File

@@ -1,8 +1,8 @@
import { Type } from "typebox";
import { describe, expect, it } from "vitest";
import { getEnvApiKey } from "../src/env-api-keys.ts";
import { complete } from "../src/index.ts";
import { getModels, getProviders } from "../src/models.ts";
import { complete } from "../src/stream.ts";
import type { Api, KnownProvider, Model, ProviderStreamOptions, Tool } from "../src/types.ts";
import { resolveApiKey } from "./oauth.ts";

View File

@@ -0,0 +1,88 @@
import { describe, expect, it } from "vitest";
import { streamSimple } from "../src/index.ts";
import type { AssistantMessage, Context, Model } from "../src/types.ts";
interface AnthropicPayload {
messages?: Array<{
role: string;
content: Array<{ type: string; text?: string; thinking?: string; signature?: string }>;
}>;
}
class PayloadCaptured extends Error {
constructor() {
super("payload captured");
this.name = "PayloadCaptured";
}
}
function makeModel(allowEmptySignature?: boolean): Model<"anthropic-messages"> {
return {
id: "mimo-v2.5-pro",
name: "MiMo-V2.5-Pro",
api: "anthropic-messages",
provider: "xiaomi-token-plan-ams",
baseUrl: "http://127.0.0.1:9/anthropic",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 1048576,
maxTokens: 1024,
...(allowEmptySignature === undefined ? {} : { compat: { allowEmptySignature } }),
};
}
function makeContext(thinkingSignature: string): Context {
const assistant: AssistantMessage = {
role: "assistant",
content: [{ type: "thinking", thinking: "internal reasoning", thinkingSignature }],
provider: "xiaomi-token-plan-ams",
api: "anthropic-messages",
model: "mimo-v2.5-pro",
timestamp: Date.now(),
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop",
};
return {
messages: [
{ role: "user", content: "first", timestamp: Date.now() },
assistant,
{ role: "user", content: "second", timestamp: Date.now() },
],
};
}
async function capturePayload(model: Model<"anthropic-messages">, context: Context): Promise<AnthropicPayload> {
let capturedPayload: AnthropicPayload | undefined;
const stream = streamSimple(model, context, {
apiKey: "fake-key",
onPayload: (payload) => {
capturedPayload = payload as AnthropicPayload;
throw new PayloadCaptured();
},
});
await stream.result();
if (!capturedPayload) throw new Error("Expected payload capture before request");
return capturedPayload;
}
describe("Anthropic empty thinking signature compat", () => {
it("converts empty-signature thinking to text by default", async () => {
const payload = await capturePayload(makeModel(), makeContext(""));
const assistant = payload.messages?.find((message) => message.role === "assistant");
expect(assistant?.content).toEqual([{ type: "text", text: "internal reasoning" }]);
});
it("preserves empty-signature thinking when allowEmptySignature is enabled", async () => {
const payload = await capturePayload(makeModel(true), makeContext(" "));
const assistant = payload.messages?.find((message) => message.role === "assistant");
expect(assistant?.content).toEqual([{ type: "thinking", thinking: "internal reasoning", signature: "" }]);
});
});

View File

@@ -0,0 +1,110 @@
import { describe, expect, it } from "vitest";
import { streamSimple } from "../src/index.ts";
import { getModel } from "../src/models.ts";
import type { Context, Model, SimpleStreamOptions } from "../src/types.ts";
interface AnthropicThinkingPayload {
thinking?: { type: string; budget_tokens?: number; display?: string };
output_config?: { effort?: string };
}
class PayloadCaptured extends Error {
constructor() {
super("payload captured");
this.name = "PayloadCaptured";
}
}
function makeContext(): Context {
return {
messages: [{ role: "user", content: "Hello", timestamp: Date.now() }],
};
}
function makeCustomModel(compat?: Model<"anthropic-messages">["compat"]): Model<"anthropic-messages"> {
return {
// Id intentionally does not match any built-in adaptive substring. This
// mirrors corporate proxy schemes such as `anthropic--claude-opus-latest`.
id: "vendor--claude-opus-latest",
name: "Vendor Proxy Opus Latest",
api: "anthropic-messages",
provider: "vendor-proxy",
baseUrl: "http://127.0.0.1:9",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 200000,
maxTokens: 32000,
compat,
};
}
async function capturePayload(
model: Model<"anthropic-messages">,
options?: SimpleStreamOptions,
): Promise<AnthropicThinkingPayload> {
let capturedPayload: AnthropicThinkingPayload | undefined;
const payloadCaptureModel: Model<"anthropic-messages"> = {
...model,
baseUrl: "http://127.0.0.1:9",
};
const s = streamSimple(payloadCaptureModel, makeContext(), {
...options,
apiKey: "fake-key",
onPayload: (payload) => {
capturedPayload = payload as AnthropicThinkingPayload;
throw new PayloadCaptured();
},
});
await s.result();
if (!capturedPayload) {
throw new Error("Expected payload to be captured before request failure");
}
return capturedPayload;
}
describe("Anthropic forceAdaptiveThinking compat override", () => {
it("sends legacy thinking payload for custom model ids by default", async () => {
const payload = await capturePayload(makeCustomModel(), { reasoning: "medium" });
expect(payload.thinking?.type).toBe("enabled");
expect(payload.output_config).toBeUndefined();
});
it("sends adaptive thinking payload when compat.forceAdaptiveThinking is true", async () => {
const payload = await capturePayload(makeCustomModel({ forceAdaptiveThinking: true }), { reasoning: "medium" });
expect(payload.thinking).toEqual({ type: "adaptive", display: "summarized" });
expect(payload.output_config).toEqual({ effort: "medium" });
});
it("uses adaptive thinking with native xhigh effort for Claude Fable 5", async () => {
const payload = await capturePayload(getModel("anthropic", "claude-fable-5"), { reasoning: "xhigh" });
expect(payload.thinking).toEqual({ type: "adaptive", display: "summarized" });
expect(payload.output_config).toEqual({ effort: "xhigh" });
});
it("allows built-in adaptive models to opt out with compat.forceAdaptiveThinking false", async () => {
const model: Model<"anthropic-messages"> = {
...getModel("anthropic", "claude-opus-4-8"),
compat: { forceAdaptiveThinking: false },
};
const payload = await capturePayload(model, { reasoning: "medium" });
expect(payload.thinking?.type).toBe("enabled");
expect(payload.output_config).toBeUndefined();
});
it("preserves thinking.type=disabled when reasoning is off regardless of override", async () => {
const payload = await capturePayload(makeCustomModel({ forceAdaptiveThinking: true }));
expect(payload.thinking).toEqual({ type: "disabled" });
expect(payload.output_config).toBeUndefined();
});
});

View File

@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
import { getEnvApiKey } from "../src/env-api-keys.ts";
import { complete } from "../src/index.ts";
import { getModels, getProviders } from "../src/models.ts";
import { complete } from "../src/stream.ts";
import type { Api, KnownProvider, Model, ProviderStreamOptions } from "../src/types.ts";
import { resolveApiKey } from "./oauth.ts";

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { streamSimple } from "../src/index.ts";
import { getModel } from "../src/models.ts";
import { streamSimple } from "../src/stream.ts";
import type { Context } from "../src/types.ts";
interface AnthropicThinkingPayload {
@@ -22,9 +22,9 @@ function makeContext(): Context {
};
}
describe.skipIf(!process.env.ANTHROPIC_API_KEY)("Anthropic Opus 4.7 smoke", () => {
it("streams Claude Opus 4.7 with reasoning enabled", { retry: 2, timeout: 30000 }, async () => {
const model = getModel("anthropic", "claude-opus-4-7");
describe.skipIf(!process.env.ANTHROPIC_API_KEY)("Anthropic Opus 4.8 smoke", () => {
it("streams Claude Opus 4.8 with reasoning enabled", { retry: 2, timeout: 30000 }, async () => {
const model = getModel("anthropic", "claude-opus-4-8");
let capturedPayload: AnthropicThinkingPayload | undefined;
const s = streamSimple(model, makeContext(), {
reasoning: "high",
@@ -53,12 +53,12 @@ describe.skipIf(!process.env.ANTHROPIC_API_KEY)("Anthropic Opus 4.7 smoke", () =
const thinkingBlock = response.content.find((block) => block.type === "thinking");
expect(thinkingBlock?.type).toBe("thinking");
if (!thinkingBlock || thinkingBlock.type !== "thinking") {
throw new Error("Expected thinking block from Claude Opus 4.7");
throw new Error("Expected thinking block from Claude Opus 4.8");
}
expect(typeof thinkingBlock.thinkingSignature).toBe("string");
const thinkingSignature = thinkingBlock.thinkingSignature;
if (!thinkingSignature) {
throw new Error("Expected thinking signature from Claude Opus 4.7");
throw new Error("Expected thinking signature from Claude Opus 4.8");
}
expect(thinkingSignature.length).toBeGreaterThan(0);

View File

@@ -166,6 +166,64 @@ describe("Anthropic raw SSE parsing", () => {
});
});
it("preserves refusal stop details from message_delta", async () => {
const model = getModel("anthropic", "claude-fable-5");
const context: Context = {
messages: [{ role: "user", content: "blocked request", timestamp: Date.now() }],
};
const explanation =
"This request triggered restrictions on violative cyber content and was blocked under Anthropic's Usage Policy. To learn more, provide feedback, or request an exemption based on how you use Claude, visit our help center: https://support.claude.com/en/articles/14604842-real-time-cyber-safeguards-on-claude.";
const response = createSseResponse([
{
event: "message_start",
data: JSON.stringify({
type: "message_start",
message: {
id: "msg_01XFUDYJgAACzvnptvVoYEL",
usage: {
input_tokens: 412,
output_tokens: 0,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
},
},
}),
},
{
event: "message_delta",
data: JSON.stringify({
type: "message_delta",
delta: {
stop_reason: "refusal",
stop_details: {
type: "refusal",
category: "cyber",
explanation,
},
},
usage: {
input_tokens: 412,
output_tokens: 0,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
},
}),
},
{
event: "message_stop",
data: JSON.stringify({ type: "message_stop" }),
},
]);
const stream = streamAnthropic(model, context, {
client: createFakeAnthropicClient(response),
});
const result = await stream.result();
expect(result.stopReason).toBe("error");
expect(result.errorMessage).toBe(explanation);
});
it("ignores unknown SSE events after message_stop", async () => {
const model = getModel("anthropic", "claude-haiku-4-5");
const context: Context = {

View File

@@ -0,0 +1,104 @@
import { describe, expect, it } from "vitest";
import { streamSimple } from "../src/index.ts";
import { getModel } from "../src/models.ts";
import type { Context, Model, SimpleStreamOptions } from "../src/types.ts";
interface AnthropicTemperaturePayload {
temperature?: number;
}
class PayloadCaptured extends Error {
constructor() {
super("payload captured");
this.name = "PayloadCaptured";
}
}
function makeContext(): Context {
return {
messages: [{ role: "user", content: "Hello", timestamp: Date.now() }],
};
}
function makeCustomModel(compat?: Model<"anthropic-messages">["compat"]): Model<"anthropic-messages"> {
return {
id: "vendor--claude-opus-4-7",
name: "Vendor Proxy Opus 4.7",
api: "anthropic-messages",
provider: "vendor-proxy",
baseUrl: "http://127.0.0.1:9",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 200000,
maxTokens: 32000,
compat,
};
}
async function capturePayload(
model: Model<"anthropic-messages">,
options?: SimpleStreamOptions,
): Promise<AnthropicTemperaturePayload> {
let capturedPayload: AnthropicTemperaturePayload | undefined;
const payloadCaptureModel: Model<"anthropic-messages"> = {
...model,
baseUrl: "http://127.0.0.1:9",
};
const s = streamSimple(payloadCaptureModel, makeContext(), {
...options,
apiKey: "fake-key",
onPayload: (payload) => {
capturedPayload = payload as AnthropicTemperaturePayload;
throw new PayloadCaptured();
},
});
await s.result();
if (!capturedPayload) {
throw new Error("Expected payload to be captured before request failure");
}
return capturedPayload;
}
describe("Anthropic temperature compatibility", () => {
it("omits temperature for Claude Opus 4.7", async () => {
const payload = await capturePayload(getModel("anthropic", "claude-opus-4-7"), { temperature: 0 });
expect(payload.temperature).toBeUndefined();
});
it("omits temperature for Claude Opus 4.8", async () => {
const payload = await capturePayload(getModel("anthropic", "claude-opus-4-8"), { temperature: 0 });
expect(payload.temperature).toBeUndefined();
});
it("omits default temperature for Claude Opus 4.7", async () => {
const payload = await capturePayload(getModel("anthropic", "claude-opus-4-7"), { temperature: 1 });
expect(payload.temperature).toBeUndefined();
});
it("keeps temperature for Claude Opus 4.6", async () => {
const payload = await capturePayload(getModel("anthropic", "claude-opus-4-6"), { temperature: 0 });
expect(payload.temperature).toBe(0);
});
it("keeps temperature for Claude Sonnet 4.6", async () => {
const payload = await capturePayload(getModel("anthropic", "claude-sonnet-4-6"), { temperature: 0 });
expect(payload.temperature).toBe(0);
});
it("omits temperature for custom models with supportsTemperature disabled", async () => {
const payload = await capturePayload(makeCustomModel({ supportsTemperature: false }), { temperature: 0 });
expect(payload.temperature).toBeUndefined();
});
});

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { streamSimple } from "../src/index.ts";
import { getModel } from "../src/models.ts";
import { streamSimple } from "../src/stream.ts";
import type { Context, Model, SimpleStreamOptions } from "../src/types.ts";
interface AnthropicThinkingPayload {
@@ -125,22 +125,29 @@ describe("Anthropic thinking disable payload", () => {
expect(payload.output_config).toBeUndefined();
});
it("sends thinking.type=disabled for Claude Opus 4.7 when thinking is off", async () => {
const payload = await capturePayload(getModel("anthropic", "claude-opus-4-7"));
it("sends thinking.type=disabled for Claude Opus 4.8 when thinking is off", async () => {
const payload = await capturePayload(getModel("anthropic", "claude-opus-4-8"));
expect(payload.thinking).toEqual({ type: "disabled" });
expect(payload.output_config).toBeUndefined();
});
it("uses adaptive thinking for Claude Opus 4.7 when reasoning is enabled", async () => {
const payload = await capturePayload(getModel("anthropic", "claude-opus-4-7"), { reasoning: "high" });
it("omits thinking.type=disabled for Claude Fable 5 when thinking is off", async () => {
const payload = await capturePayload(getModel("anthropic", "claude-fable-5"));
expect(payload.thinking).toBeUndefined();
expect(payload.output_config).toBeUndefined();
});
it("uses adaptive thinking for Claude Opus 4.8 when reasoning is enabled", async () => {
const payload = await capturePayload(getModel("anthropic", "claude-opus-4-8"), { reasoning: "high" });
expect(payload.thinking).toEqual({ type: "adaptive", display: "summarized" });
expect(payload.output_config).toEqual({ effort: "high" });
});
it("maps xhigh reasoning to effort=xhigh for Claude Opus 4.7", async () => {
const payload = await capturePayload(getModel("anthropic", "claude-opus-4-7"), { reasoning: "xhigh" });
it("maps xhigh reasoning to effort=xhigh for Claude Opus 4.8", async () => {
const payload = await capturePayload(getModel("anthropic", "claude-opus-4-8"), { reasoning: "xhigh" });
expect(payload.thinking).toEqual({ type: "adaptive", display: "summarized" });
expect(payload.output_config).toEqual({ effort: "xhigh" });

View File

@@ -1,7 +1,7 @@
import { Type } from "typebox";
import { describe, expect, it } from "vitest";
import { stream } from "../src/index.ts";
import { getModel } from "../src/models.ts";
import { stream } from "../src/stream.ts";
import type { Context, Tool } from "../src/types.ts";
import { resolveApiKey } from "./oauth.ts";

View File

@@ -13,6 +13,7 @@ interface CapturedAzureClientOptions {
interface CapturedAzureResponsesPayload {
prompt_cache_key?: string;
store?: boolean;
}
const azureMock = vi.hoisted(() => ({
@@ -144,6 +145,16 @@ describe("azure-openai-responses base URL normalization", () => {
expect(azureMock.lastParams?.prompt_cache_key).toBe("x".repeat(64));
});
it("disables server-side response storage", async () => {
const model = getModel("azure-openai-responses", "gpt-4o-mini");
await streamAzureOpenAIResponses(model, context, {
apiKey: "test-api-key",
azureBaseUrl: "https://my-resource.openai.azure.com",
}).result();
expect(azureMock.lastParams?.store).toBe(false);
});
it("builds correct default URL from AZURE_OPENAI_RESOURCE_NAME", async () => {
process.env.AZURE_OPENAI_RESOURCE_NAME = "my-resource";
const model = getModel("azure-openai-responses", "gpt-4o-mini");

View File

@@ -0,0 +1,68 @@
import { afterEach, describe, expect, it } from "vitest";
import { clearApiProviders, complete, getApiProvider, getApiProviders } from "../src/base.ts";
import { register as registerAmazonBedrock } from "../src/providers/amazon-bedrock.ts";
import { register as registerAnthropic } from "../src/providers/anthropic.ts";
import { register as registerAzureOpenAIResponses } from "../src/providers/azure-openai-responses.ts";
import { fauxAssistantMessage, registerFauxProvider } from "../src/providers/faux.ts";
import { register as registerGoogle } from "../src/providers/google.ts";
import { register as registerGoogleVertex } from "../src/providers/google-vertex.ts";
import { register as registerMistral } from "../src/providers/mistral.ts";
import { register as registerOpenAICodexResponses } from "../src/providers/openai-codex-responses.ts";
import { register as registerOpenAICompletions } from "../src/providers/openai-completions.ts";
import { register as registerOpenAIResponses } from "../src/providers/openai-responses.ts";
const registrations = [
["bedrock-converse-stream", registerAmazonBedrock],
["anthropic-messages", registerAnthropic],
["azure-openai-responses", registerAzureOpenAIResponses],
["google-generative-ai", registerGoogle],
["google-vertex", registerGoogleVertex],
["mistral-conversations", registerMistral],
["openai-codex-responses", registerOpenAICodexResponses],
["openai-completions", registerOpenAICompletions],
["openai-responses", registerOpenAIResponses],
] as const;
afterEach(() => {
clearApiProviders();
});
describe("base entrypoint", () => {
it("starts without built-in provider registrations", () => {
expect(getApiProviders()).toEqual([]);
});
it.each(registrations)("registers the %s transport explicitly", (api, register) => {
register();
expect(getApiProvider(api)?.api).toBe(api);
});
it("dispatches custom providers", async () => {
const registration = registerFauxProvider();
registration.setResponses([fauxAssistantMessage("hello")]);
const response = await complete(registration.getModel(), {
messages: [{ role: "user", content: "hi", timestamp: Date.now() }],
});
expect(response.content).toEqual([{ type: "text", text: "hello" }]);
});
it("fails clearly when no transport is registered", async () => {
await expect(
complete(
{
id: "missing-model",
name: "Missing Model",
api: "missing-api",
provider: "missing-provider",
baseUrl: "https://example.com",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 1,
maxTokens: 1,
},
{ messages: [] },
),
).rejects.toThrow("No API provider registered for api: missing-api");
});
});

View File

@@ -117,7 +117,7 @@ describe("bedrock convertMessages skips unknown content types", () => {
expect(p.messages[0].content[0]).toEqual({ text: "hello" });
});
it("skips user messages with only unknown content blocks", async () => {
it("replaces user messages with only unknown content blocks with a placeholder", async () => {
const messages: Message[] = [
{
role: "user",
@@ -128,9 +128,95 @@ describe("bedrock convertMessages skips unknown content types", () => {
const payload = await capturePayload({ messages });
expect(payload).toBeDefined();
const p = payload as { messages: Array<{ role: string; content: unknown[] }> };
expect(p.messages).toHaveLength(1);
expect(p.messages[0].content).toEqual([{ text: "<empty>" }]);
});
it("replaces blank user string content with a placeholder", async () => {
const payload = await capturePayload({
messages: [{ role: "user", content: " ", timestamp: Date.now() }],
});
expect(payload).toBeDefined();
const p = payload as { messages: Array<{ role: string; content: unknown[] }> };
expect(p.messages).toHaveLength(1);
expect(p.messages[0].content).toEqual([{ text: "<empty>" }]);
});
it("filters blank user text blocks when other content remains", async () => {
const payload = await capturePayload({
messages: [
{
role: "user",
content: [
{ type: "text", text: "" },
{ type: "text", text: "hello" },
],
timestamp: Date.now(),
},
],
});
expect(payload).toBeDefined();
const p = payload as { messages: Array<{ role: string; content: unknown[] }> };
expect(p.messages).toHaveLength(1);
expect(p.messages[0].content).toEqual([{ text: "hello" }]);
});
it("replaces user content emptied by surrogate sanitization with a placeholder", async () => {
const payload = await capturePayload({
messages: [{ role: "user", content: String.fromCharCode(0xd83d), timestamp: Date.now() }],
});
expect(payload).toBeDefined();
const p = payload as { messages: Array<{ role: string; content: unknown[] }> };
expect(p.messages).toHaveLength(1);
expect(p.messages[0].content).toEqual([{ text: "<empty>" }]);
});
it("skips assistant text blocks emptied by surrogate sanitization", async () => {
const messages: Message[] = [
{
role: "assistant",
content: [{ type: "text", text: String.fromCharCode(0xd83d) }],
api: "bedrock-converse-stream",
provider: "amazon-bedrock",
model: baseModel.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(),
},
];
const payload = await capturePayload({ messages });
expect(payload).toBeDefined();
const p = payload as { messages: Array<{ role: string; content: unknown[] }> };
expect(p.messages).toHaveLength(0);
});
it("replaces blank tool result content with a placeholder", async () => {
const messages: Message[] = [
{
role: "toolResult",
toolCallId: "tool-1",
toolName: "tool",
content: [{ type: "text", text: "" }],
isError: false,
timestamp: Date.now(),
},
];
const payload = await capturePayload({ messages });
expect(payload).toBeDefined();
const p = payload as {
messages: Array<{ role: string; content: Array<{ toolResult: { content: unknown[] } }> }>;
};
expect(p.messages).toHaveLength(1);
expect(p.messages[0].content[0].toolResult.content).toEqual([{ text: "<empty>" }]);
});
it("skips assistant messages with only unknown content blocks", async () => {
const messages: Message[] = [
{

View File

@@ -0,0 +1,202 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
type MiddlewareHandler = (next: (args: unknown) => Promise<unknown>) => (args: unknown) => Promise<unknown>;
const bedrockMock = vi.hoisted(() => ({
middlewareRegistrations: [] as Array<{
handler: MiddlewareHandler;
opts: { step?: string; name?: string; priority?: string };
}>,
}));
vi.mock("@aws-sdk/client-bedrock-runtime", () => {
class BedrockRuntimeServiceException extends Error {}
class BedrockRuntimeClient {
middlewareStack = {
add: (handler: MiddlewareHandler, opts: { step?: string; name?: string; priority?: string }) => {
bedrockMock.middlewareRegistrations.push({ handler, opts });
},
};
send(): Promise<never> {
return Promise.reject(new Error("mock send"));
}
}
class ConverseStreamCommand {
readonly input: unknown;
constructor(input: unknown) {
this.input = input;
}
}
return {
BedrockRuntimeClient,
BedrockRuntimeServiceException,
ConverseStreamCommand,
StopReason: {
END_TURN: "end_turn",
STOP_SEQUENCE: "stop_sequence",
MAX_TOKENS: "max_tokens",
MODEL_CONTEXT_WINDOW_EXCEEDED: "model_context_window_exceeded",
TOOL_USE: "tool_use",
},
CachePointType: { DEFAULT: "default" },
CacheTTL: { ONE_HOUR: "ONE_HOUR" },
ConversationRole: { ASSISTANT: "assistant", USER: "user" },
ImageFormat: { JPEG: "jpeg", PNG: "png", GIF: "gif", WEBP: "webp" },
ToolResultStatus: { ERROR: "error", SUCCESS: "success" },
};
});
import { getModel } from "../src/models.ts";
import type { BedrockOptions } from "../src/providers/amazon-bedrock.ts";
import { streamBedrock, streamSimpleBedrock } from "../src/providers/amazon-bedrock.ts";
import type { Context, Model } from "../src/types.ts";
const context: Context = {
messages: [{ role: "user", content: "hello", timestamp: Date.now() }],
};
const MIDDLEWARE_NAME = "pi-ai-custom-headers";
function getModelFixture(): Model<"bedrock-converse-stream"> {
return getModel("amazon-bedrock", "us.anthropic.claude-opus-4-8");
}
/**
* Drive a stream to completion so the middleware (registered before `client.send`)
* is captured even though the mocked `send()` rejects. Errors are swallowed because
* the rejecting mock is expected — we only care about the recorded registrations.
*/
async function driveBedrock(options: BedrockOptions): Promise<void> {
await streamBedrock(getModelFixture(), context, options)
.result()
.catch(() => undefined);
}
function findCustomHeadersRegistration() {
const matches = bedrockMock.middlewareRegistrations.filter((r) => r.opts.name === MIDDLEWARE_NAME);
return matches;
}
beforeEach(() => {
bedrockMock.middlewareRegistrations.length = 0;
});
describe("bedrock custom headers middleware", () => {
it("VC1: registers a build-step middleware that injects the caller header (happy path)", async () => {
await driveBedrock({ cacheRetention: "none", headers: { "x-custom": "v" } });
const registrations = findCustomHeadersRegistration();
expect(registrations).toHaveLength(1);
const [reg] = registrations;
expect(reg.opts.step).toBe("build");
expect(reg.opts.priority).toBe("low");
expect(reg.opts.name).toBe(MIDDLEWARE_NAME);
const nextSpy = vi.fn(async (a: unknown) => a);
const fakeArgs = { request: { headers: {} as Record<string, string> } };
await reg.handler(nextSpy)(fakeArgs);
expect(fakeArgs.request.headers["x-custom"]).toBe("v");
expect(nextSpy).toHaveBeenCalledTimes(1);
expect(nextSpy).toHaveBeenCalledWith(fakeArgs);
});
it("VC2: skips reserved headers case-insensitively while applying allowed ones", async () => {
await driveBedrock({
cacheRetention: "none",
headers: {
authorization: "evil",
"x-amz-date": "evil",
"x-allowed": "ok",
Authorization: "evil2",
"X-Amz-Date": "evil2",
HOST: "evil3",
},
});
const [reg] = findCustomHeadersRegistration();
expect(reg).toBeDefined();
const nextSpy = vi.fn(async (a: unknown) => a);
const fakeArgs = {
request: {
headers: {
authorization: "real-auth",
"x-amz-date": "real-date",
host: "real-host",
} as Record<string, string>,
},
};
await reg.handler(nextSpy)(fakeArgs);
expect(fakeArgs.request.headers.authorization).toBe("real-auth");
expect(fakeArgs.request.headers["x-amz-date"]).toBe("real-date");
expect(fakeArgs.request.headers.host).toBe("real-host");
expect(fakeArgs.request.headers["x-allowed"]).toBe("ok");
// Mixed-case reserved keys must be skipped too: a case-sensitive guard would
// add them back as distinct capitalised keys. Assert no such leak occurred and
// that the only new key beyond the three pre-existing ones is `x-allowed`.
expect(fakeArgs.request.headers.Authorization).toBeUndefined();
expect(fakeArgs.request.headers["X-Amz-Date"]).toBeUndefined();
expect(fakeArgs.request.headers.HOST).toBeUndefined();
expect(Object.keys(fakeArgs.request.headers).sort()).toEqual(
["authorization", "host", "x-allowed", "x-amz-date"].sort(),
);
expect(nextSpy).toHaveBeenCalledTimes(1);
});
it("VC3: registers no middleware when headers is undefined", async () => {
await driveBedrock({ cacheRetention: "none" });
expect(findCustomHeadersRegistration()).toHaveLength(0);
});
it("VC3: registers no middleware when headers is empty", async () => {
await driveBedrock({ cacheRetention: "none", headers: {} });
expect(findCustomHeadersRegistration()).toHaveLength(0);
});
it("VC3 (structural guard): passes through unchanged when the request has no headers", async () => {
await driveBedrock({ cacheRetention: "none", headers: { "x-custom": "v" } });
const [reg] = findCustomHeadersRegistration();
expect(reg).toBeDefined();
const nextSpy = vi.fn(async (a: unknown) => a);
const argsNoHeaders = { request: {} };
await expect(reg.handler(nextSpy)(argsNoHeaders)).resolves.toBeDefined();
expect(nextSpy).toHaveBeenCalledWith(argsNoHeaders);
const argsUndefinedRequest = { request: undefined };
await expect(reg.handler(nextSpy)(argsUndefinedRequest)).resolves.toBeDefined();
expect(nextSpy).toHaveBeenCalledWith(argsUndefinedRequest);
expect(nextSpy).toHaveBeenCalledTimes(2);
});
it("VC4: streamSimpleBedrock forwards headers end-to-end (regression guard)", async () => {
await streamSimpleBedrock(getModelFixture(), context, { headers: { "x-custom": "v" } })
.result()
.catch(() => undefined);
const registrations = findCustomHeadersRegistration();
expect(registrations).toHaveLength(1);
const [reg] = registrations;
expect(reg.opts.step).toBe("build");
const nextSpy = vi.fn(async (a: unknown) => a);
const fakeArgs = { request: { headers: {} as Record<string, string> } };
await reg.handler(nextSpy)(fakeArgs);
expect(fakeArgs.request.headers["x-custom"]).toBe("v");
});
});

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