Compare commits
407 Commits
a8daa1d8ff
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| a7fb0a4369 | |||
| 539599dbb8 | |||
| 294ce75892 | |||
| f1c5e7dc1d | |||
| 812bca79a3 | |||
| 07a918d4cd | |||
| 8ad811c977 | |||
| 131546b2fe | |||
| 9ed9ed91dd | |||
| 0d8ea01b43 | |||
|
|
d93b92baca | ||
|
|
b4f31408b6 | ||
|
|
615bf2f874 | ||
|
|
1aa79b9b29 | ||
|
|
350ac3f344 | ||
|
|
500b568b04 | ||
|
|
5505316ea2 | ||
|
|
a1da88aed4 | ||
|
|
8597ebafd9 | ||
|
|
416c673dfe | ||
|
|
3095977d13 | ||
|
|
8b97e75c6b | ||
|
|
128330e36f | ||
|
|
1287b69fe0 | ||
|
|
6e6ce70caf | ||
|
|
226a316824 | ||
|
|
373cd6ae52 | ||
|
|
47d1d90a8e | ||
|
|
783571a615 | ||
|
|
56b22768db | ||
|
|
8eb9704b3e | ||
|
|
74677bbfbf | ||
|
|
a2f70e5f28 | ||
|
|
ea65a51a15 | ||
|
|
0d89a33373 | ||
|
|
1a418ad223 | ||
|
|
9179734cc2 | ||
|
|
651d10d90b | ||
|
|
8025fdd01f | ||
|
|
fd1ba2c7fe | ||
|
|
cab89d1435 | ||
|
|
c60f6a8ab3 | ||
|
|
b09fbde00e | ||
|
|
7d08c81a09 | ||
|
|
6b9f3f492f | ||
|
|
788a044483 | ||
|
|
c4ab61dcbf | ||
|
|
717494223d | ||
|
|
aae62dfa87 | ||
|
|
2b46f38863 | ||
|
|
d0b46764b1 | ||
|
|
908be616f2 | ||
|
|
bc93655e1c | ||
|
|
20da9bc140 | ||
|
|
7a14325b24 | ||
|
|
51f7523587 | ||
|
|
008c76f955 | ||
|
|
58dd2f5996 | ||
|
|
6d5ede31c8 | ||
|
|
ae89286d07 | ||
|
|
068ab5d19b | ||
|
|
29c1504cc1 | ||
|
|
12bb8dd2c9 | ||
|
|
31bfb2f16f | ||
|
|
34b6aea1e6 | ||
|
|
7da475db66 | ||
|
|
bd9f8773ad | ||
|
|
a93f066607 | ||
|
|
a78cd7ccb1 | ||
|
|
0b0b9eae62 | ||
|
|
6561cb293b | ||
|
|
b6b5bed9ae | ||
|
|
2431491c92 | ||
|
|
2d597f0212 | ||
|
|
910508595a | ||
|
|
0680726ac4 | ||
|
|
8f0e9251b9 | ||
|
|
7f29e7a369 | ||
|
|
3039f3e17d | ||
|
|
06d8c54de2 | ||
|
|
75b0d723c0 | ||
|
|
f8a77f47f2 | ||
|
|
b0c8f65ffa | ||
|
|
7cfd1af395 | ||
|
|
bee8e9c805 | ||
|
|
431d88f4fc | ||
|
|
0369bdb8f4 | ||
|
|
a2a5539e78 | ||
|
|
072398bddc | ||
|
|
1aa3c02d56 | ||
|
|
bba6af2c71 | ||
|
|
b1ad469be4 | ||
|
|
8a7ad60f03 | ||
|
|
3fa4095629 | ||
|
|
408ac103ec | ||
|
|
28b3af5d0f | ||
|
|
0be5bb6c96 | ||
|
|
a851968170 | ||
| 731ba23515 | |||
|
|
675dd18182 | ||
|
|
bb959aae01 | ||
|
|
24053eab66 | ||
|
|
5b6058c3a7 | ||
|
|
ba0ec61563 | ||
|
|
b5e13bcd8a | ||
|
|
93b3b7c1fe | ||
|
|
d683a581b7 | ||
|
|
6b40c99a59 | ||
| ad62015fe3 | |||
|
|
11b5403fad | ||
|
|
f0989800cb | ||
|
|
3fcfb7abf7 | ||
|
|
c48f656f88 | ||
|
|
2fbdff9dab | ||
|
|
5be8c31fa7 | ||
|
|
21a904f4b7 | ||
|
|
9e9fc79478 | ||
|
|
f315d81491 | ||
|
|
62e2866228 | ||
|
|
6f29450e13 | ||
|
|
b15148fe37 | ||
|
|
f2585c4c82 | ||
|
|
57b6bdce71 | ||
|
|
aa3a5233ad | ||
|
|
032c01c1e1 | ||
|
|
f21f3c4bbd | ||
|
|
e320f09618 | ||
|
|
121f0edbf4 | ||
|
|
7a3cb6312e | ||
|
|
b4bff7f0d0 | ||
|
|
a7cdc679e7 | ||
|
|
17721d5e01 | ||
|
|
daab056ac1 | ||
|
|
adf567c1c6 | ||
|
|
1b2c32c653 | ||
|
|
0caca6cf3f | ||
|
|
6102dd2084 | ||
|
|
1fc80f4f6c | ||
|
|
be7d5cf585 | ||
|
|
a455f62f72 | ||
|
|
1c24336560 | ||
|
|
3f44d3e2eb | ||
|
|
1da903983a | ||
|
|
406a2214aa | ||
| d51a055d78 | |||
|
|
0ab2aa86af | ||
|
|
a3cd03e7ff | ||
|
|
2f5066d7a0 | ||
|
|
e537dba3de | ||
|
|
9fd75b8acb | ||
|
|
a7f9fe681d | ||
|
|
cf5edd6394 | ||
|
|
0b6c95dda0 | ||
|
|
eb01f59dc9 | ||
|
|
9ccfcd7cfc | ||
|
|
dacb367e9e | ||
|
|
82f2b1e908 | ||
|
|
28df940f0d | ||
|
|
4d9f9f455d | ||
|
|
66f432cae4 | ||
|
|
6b5923f107 | ||
|
|
5a9d72ea02 | ||
|
|
a0c2465d47 | ||
|
|
dfd4571cb0 | ||
|
|
aa03982169 | ||
|
|
c5582102f5 | ||
|
|
143b251e77 | ||
|
|
ae7a885da2 | ||
|
|
b7e721cb38 | ||
|
|
69ea1a6310 | ||
|
|
d81ac20920 | ||
|
|
d041b5cc35 | ||
|
|
c20ea06d4a | ||
|
|
3d02d1da11 | ||
|
|
9632bddd38 | ||
|
|
64b51efb6e | ||
|
|
359a0769f1 | ||
|
|
5cb4f597f7 | ||
|
|
66335d3a49 | ||
|
|
28c83e8385 | ||
|
|
1906074369 | ||
|
|
e4907b3b09 | ||
|
|
db3f9953ee | ||
|
|
081a0a2bef | ||
|
|
84cdd02400 | ||
|
|
8da077bcca | ||
|
|
def99d395e | ||
|
|
22e4549275 | ||
|
|
2326d5cb4a | ||
|
|
c6bdfa1971 | ||
|
|
4c4863654e | ||
|
|
44e33798fd | ||
|
|
8cef3c8d7f | ||
|
|
20b78eafb4 | ||
|
|
2edd6b432a | ||
|
|
c10fb95fd9 | ||
|
|
35120d7e48 | ||
|
|
ce3a72444e | ||
|
|
2527441b40 | ||
|
|
d8aef0feff | ||
|
|
085a08582f | ||
|
|
21917fed37 | ||
|
|
718215bd95 | ||
|
|
dce3e28516 | ||
|
|
f4f72d4ed5 | ||
|
|
9d007fe642 | ||
|
|
38f18be447 | ||
|
|
130ae577ac | ||
|
|
72fd911356 | ||
|
|
eb43bd4493 | ||
|
|
8c6c8a4ef3 | ||
|
|
b8f6f660e7 | ||
|
|
59d7025668 | ||
|
|
22ac2713a3 | ||
|
|
e34b3b3803 | ||
|
|
98697d2d7a | ||
|
|
ff3e9df5f5 | ||
|
|
89a92207f1 | ||
|
|
db594d3a59 | ||
|
|
3e73204242 | ||
|
|
b9bfa7ed46 | ||
|
|
2808fc03f4 | ||
|
|
c52c22b39e | ||
|
|
c45787411e | ||
|
|
ca66adfe60 | ||
|
|
592c34c056 | ||
|
|
1d33a8eb7a | ||
|
|
4f7d756df8 | ||
|
|
e0c2813a2a | ||
|
|
f9ce0bf0e7 | ||
|
|
e4d6f45efe | ||
|
|
e9a9322199 | ||
|
|
dc7b547f62 | ||
|
|
86314bf38d | ||
|
|
564ad70fb8 | ||
|
|
4ac20511be | ||
|
|
ea3465a8e3 | ||
|
|
135fb545f9 | ||
|
|
83afcdc24f | ||
|
|
ba6e5298df | ||
|
|
a98e087e5d | ||
|
|
51df39b9b9 | ||
|
|
0462d44f56 | ||
|
|
e30b1b18d0 | ||
|
|
25a4a8ed1e | ||
|
|
6cb23f9b5d | ||
|
|
7e72ca47c8 | ||
|
|
276cb1bb89 | ||
|
|
2125221bfc | ||
|
|
0d38e17b68 | ||
|
|
6e269edfd0 | ||
|
|
6014801229 | ||
|
|
13898f048f | ||
|
|
4c6c4482b4 | ||
|
|
7c08227019 | ||
|
|
7c531d0518 | ||
|
|
f58c1702cf | ||
|
|
afcf9ff0dc | ||
|
|
8216cca5a7 | ||
|
|
e56521e323 | ||
|
|
335e09ba0d | ||
|
|
34719d3f4f | ||
|
|
893ba99466 | ||
|
|
ce065efa7e | ||
|
|
91a2f86600 | ||
|
|
654e4529d5 | ||
|
|
f429ddbffd | ||
|
|
d4fa41ae3c | ||
|
|
b177333026 | ||
|
|
ad9081b7fa | ||
|
|
40832d1957 | ||
|
|
82f29ea442 | ||
|
|
735ccbd00f | ||
|
|
5d9b28ee43 | ||
|
|
3911d6f5cd | ||
|
|
74d81bd363 | ||
|
|
b55fe1bbd4 | ||
|
|
dbb9911a54 | ||
|
|
0897f175e4 | ||
|
|
36515a3793 | ||
|
|
886fa6cd11 | ||
|
|
c1633e609d | ||
|
|
cfefc30cf1 | ||
|
|
0ffa590a33 | ||
|
|
9c4a3f3518 | ||
|
|
31b961f2ac | ||
|
|
ed1952c5ca | ||
|
|
5992a62baa | ||
|
|
a213abb97e | ||
|
|
778f519b23 | ||
|
|
ba2d313858 | ||
|
|
a36a132c70 | ||
|
|
7921ae499a | ||
|
|
01a8c2d62c | ||
|
|
dbcfc16741 | ||
|
|
edd1212200 | ||
|
|
9d2bceba5d | ||
|
|
42ce989a62 | ||
|
|
4faac05419 | ||
|
|
ce554ad3de | ||
|
|
0c448e12e8 | ||
|
|
3d1d18fa96 | ||
|
|
c122bcde44 | ||
|
|
7619aaefa9 | ||
|
|
17e9e87576 | ||
|
|
7a5dc0d0db | ||
|
|
c8df99a795 | ||
|
|
7be8a10d23 | ||
|
|
b6b0f6923f | ||
|
|
20bcab26e0 | ||
|
|
93600d8980 | ||
|
|
f3b4e1285c | ||
|
|
abf07d0c4d | ||
|
|
8322745e28 | ||
|
|
f29472c387 | ||
|
|
0127caea7b | ||
|
|
f9fa077bbb | ||
|
|
9380d5f2e4 | ||
|
|
1ab2899800 | ||
|
|
4b4641c6b0 | ||
|
|
b64f3f5eae | ||
|
|
d1fb34bc8d | ||
|
|
3f1ce9b6e9 | ||
|
|
f9832ccb82 | ||
|
|
97ef317389 | ||
|
|
7e70886652 | ||
|
|
bfa3d1fa60 | ||
|
|
ae50dec121 | ||
|
|
53ca936adb | ||
|
|
b63d26332f | ||
|
|
458a7bc27c | ||
|
|
3e9f717445 | ||
|
|
5b31ffd744 | ||
|
|
9d5fb70b7e | ||
|
|
edd26444af | ||
|
|
17d39ccfd4 | ||
|
|
8e77f8797b | ||
|
|
e43f2c3d0a | ||
|
|
a29a7902e9 | ||
|
|
5f00f3f50f | ||
|
|
cbe8625528 | ||
|
|
1fc57a5d5b | ||
|
|
bcea4b2e27 | ||
|
|
b85bf65678 | ||
|
|
99aec8e02b | ||
|
|
1e168a89c5 | ||
|
|
6ab62a06d7 | ||
|
|
39a26c84f8 | ||
|
|
706f8720f8 | ||
|
|
16dc525e7b | ||
|
|
41d28a9232 | ||
|
|
52dc08c1f7 | ||
|
|
7c02a55632 | ||
|
|
91b46c2ba9 | ||
|
|
701801dec2 | ||
|
|
61babc24cf | ||
|
|
4bbe2959bd | ||
|
|
b62776e4a2 | ||
|
|
493efd422d | ||
|
|
26f1e00f71 | ||
|
|
2531fc130d | ||
|
|
59ec8003db | ||
|
|
4402100830 | ||
|
|
8fb1e877cd | ||
|
|
96f0edd02b | ||
|
|
7c2775f6f6 | ||
|
|
71446c6c2b | ||
|
|
fa1180b6b7 | ||
|
|
fc8a155901 | ||
|
|
3eb002766f | ||
|
|
e007fcd0d2 | ||
|
|
ce0e801d8e | ||
|
|
9600ded922 | ||
|
|
d0d1d8edca | ||
|
|
fc51a40d02 | ||
|
|
30b3ab3532 | ||
|
|
c5181a266e | ||
|
|
4a98f748bb | ||
|
|
15f1dea8df | ||
|
|
30f48feae7 | ||
|
|
89ba72c35d | ||
|
|
83a227aed3 | ||
|
|
ea2b70ddd9 | ||
|
|
98477f2ee7 | ||
|
|
b9566fc111 | ||
|
|
373bd1282e | ||
|
|
8100046cb8 | ||
|
|
c4f86e3992 | ||
|
|
3f89350cef | ||
|
|
ba09f1c9e0 | ||
|
|
2e1f07bac2 | ||
|
|
e9146a5ff7 | ||
|
|
c85dbb1620 | ||
|
|
d80bcc367e | ||
|
|
42379a37bd | ||
|
|
9b62f1f87c | ||
|
|
d801d88a11 | ||
|
|
7002c68f8b | ||
|
|
c841a6c78f | ||
|
|
b3ed545938 | ||
|
|
60e2e4332d | ||
|
|
11e868b790 | ||
|
|
11c3a3a399 | ||
|
|
092279126b | ||
|
|
0a91cc99d0 | ||
|
|
baf4028fb9 | ||
|
|
c554364c2a |
24
.github/APPROVED_CONTRIBUTORS
vendored
24
.github/APPROVED_CONTRIBUTORS
vendored
@@ -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
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/bug.yml
vendored
2
.github/ISSUE_TEMPLATE/bug.yml
vendored
@@ -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:
|
||||
|
||||
49
.github/ISSUE_TEMPLATE/package-report.yml
vendored
Normal file
49
.github/ISSUE_TEMPLATE/package-report.yml
vendored
Normal 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
|
||||
120
.github/workflows/build-binaries.yml
vendored
120
.github/workflows/build-binaries.yml
vendored
@@ -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
|
||||
|
||||
8
.github/workflows/issue-gate.yml
vendored
8
.github/workflows/issue-gate.yml
vendored
@@ -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',
|
||||
});
|
||||
|
||||
137
.github/workflows/issue-triage-labels.yml
vendored
Normal file
137
.github/workflows/issue-triage-labels.yml
vendored
Normal 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}`);
|
||||
}
|
||||
2
.github/workflows/npm-audit.yml
vendored
2
.github/workflows/npm-audit.yml
vendored
@@ -1,8 +1,6 @@
|
||||
name: npm audit
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '37 7 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
|
||||
31
.github/workflows/remove-inprogress-on-close.yml
vendored
Normal file
31
.github/workflows/remove-inprogress-on-close.yml
vendored
Normal 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,
|
||||
});
|
||||
46
.gitignore
vendored
46
.gitignore
vendored
@@ -1,5 +1,8 @@
|
||||
node_modules/
|
||||
dist/
|
||||
|
||||
# SproutClaw persistent config — never commit
|
||||
.sproutclaw/
|
||||
*.log
|
||||
.DS_Store
|
||||
*.tsbuildinfo
|
||||
@@ -25,7 +28,7 @@ packages/*/dist-firefox/
|
||||
.npm/
|
||||
coverage/
|
||||
.nyc_output/
|
||||
.pi_config/
|
||||
.sproutclaw_config/
|
||||
tui-debug.log
|
||||
compaction-results/
|
||||
.opencode/
|
||||
@@ -36,12 +39,41 @@ out.html
|
||||
packages/coding-agent/binaries/
|
||||
todo.md
|
||||
plans/
|
||||
.pi/hf-sessions/
|
||||
.pi/hf-sessions-backup/
|
||||
.pi/agent/
|
||||
.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
|
||||
|
||||
# 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
|
||||
|
||||
# Root-level binary/build artifacts (do not commit)
|
||||
/*.wasm
|
||||
/llvm-*.js
|
||||
/rustc-*.js
|
||||
/util_cmd-*.js
|
||||
/wasm32-*.tar.br
|
||||
|
||||
@@ -38,7 +38,7 @@ fi
|
||||
# Restage files that were previously staged and may have been modified by formatting
|
||||
for file in $STAGED_FILES; do
|
||||
if [ -f "$file" ]; then
|
||||
git add "$file"
|
||||
git add "$file" 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
---
|
||||
description: Analyze GitHub issues (bugs or feature requests)
|
||||
argument-hint: "<issue>"
|
||||
---
|
||||
Analyze GitHub issue(s): $ARGUMENTS
|
||||
|
||||
For each issue:
|
||||
|
||||
1. Add the `inprogress` label to the issue via GitHub CLI and assign the issue to the local `gh` user before analysis starts. If either action fails, report that explicitly and continue.
|
||||
2. Read the issue in full, including all comments and linked issues/PRs.
|
||||
3. Do not trust analysis written in the issue. Independently verify behavior and derive your own analysis from the code and execution path.
|
||||
|
||||
4. **For bugs**:
|
||||
- Ignore any root cause analysis in the issue (likely wrong)
|
||||
- Read all related code files in full (no truncation)
|
||||
- Trace the code path and identify the actual root cause
|
||||
- Propose a fix
|
||||
|
||||
5. **For feature requests**:
|
||||
- Do not trust implementation proposals in the issue without verification
|
||||
- Read all related code files in full (no truncation)
|
||||
- Propose the most concise implementation approach
|
||||
- List affected files and changes needed
|
||||
|
||||
Do NOT implement unless explicitly asked. Analyze and propose only.
|
||||
@@ -1,37 +0,0 @@
|
||||
---
|
||||
description: Review PRs from URLs with structured issue and code analysis
|
||||
argument-hint: "<PR-URL>"
|
||||
---
|
||||
You are given one or more GitHub PR URLs: $@
|
||||
|
||||
For each PR URL, do the following in order:
|
||||
1. Add the `inprogress` label to the PR via GitHub CLI before analysis starts. If adding the label fails, report that explicitly and continue.
|
||||
2. Read the PR page in full. Include description, all comments, all commits, and all changed files.
|
||||
3. Identify any linked issues referenced in the PR body, comments, commit messages, or cross links. Read each issue in full, including all comments.
|
||||
4. Analyze the PR diff. Read all relevant code files in full with no truncation and compare against the diff. Do not fetch PR file blobs unless a file is missing on main or the diff context is insufficient. Include related code paths that are not in the diff but are required to validate behavior.
|
||||
5. Do not check for a changelog entry. Per CONTRIBUTING.md, contributor PRs must not edit `CHANGELOG.md` — the maintainer adds the entry when merging.
|
||||
6. Check if packages/coding-agent/README.md, packages/coding-agent/docs/*.md, packages/coding-agent/examples/**/*.md require modification. This is usually the case when existing features have been changed, or new features have been added.
|
||||
7. Provide a structured review with these sections:
|
||||
- What it does: one short paragraph describing the change and its intent.
|
||||
- Good: solid choices or improvements.
|
||||
- Bad: concrete issues, regressions, missing tests, or risks.
|
||||
- Ugly: subtle or high impact problems.
|
||||
- Tests: what is covered, what is missing, and whether existing tests are adequate.
|
||||
- Open questions for you: only things blocking a merge decision that need the user's input. Omit the section entirely if there are none.
|
||||
|
||||
Output format per PR:
|
||||
PR: <url>
|
||||
What it does:
|
||||
- ...
|
||||
Good:
|
||||
- ...
|
||||
Bad:
|
||||
- ...
|
||||
Ugly:
|
||||
- ...
|
||||
Tests:
|
||||
- ...
|
||||
Open questions for you:
|
||||
- ...
|
||||
|
||||
If no issues are found, say so under Bad and Ugly.
|
||||
@@ -1,35 +0,0 @@
|
||||
---
|
||||
description: Finish the current task end-to-end with changelog, commit, and push
|
||||
argument-hint: "[instructions]"
|
||||
---
|
||||
Wrap it.
|
||||
|
||||
Additional instructions: $ARGUMENTS
|
||||
|
||||
Determine context from the conversation history first.
|
||||
|
||||
Rules for context detection:
|
||||
- If the conversation already mentions a GitHub issue or PR, use that existing context.
|
||||
- If the work came from `/is` or `/pr`, assume the issue or PR context is already known from the conversation and from the analysis work already done.
|
||||
- If there is no GitHub issue or PR in the conversation history, treat this as non-GitHub work.
|
||||
|
||||
Unless I explicitly override something in this request, do the following in order:
|
||||
|
||||
1. Add or update the relevant package changelog entry under `## [Unreleased]` using the repo changelog rules.
|
||||
2. If this task is tied to a GitHub issue or PR and a final issue or PR comment has not already been posted in this session, draft it in my tone, preview it, and post exactly one final comment. The comment must end with this exact standalone disclaimer line, with no variations:
|
||||
|
||||
```text
|
||||
This comment is AI-generated by `/wr`
|
||||
```
|
||||
3. Commit only files you changed in this session.
|
||||
4. If this task is tied to exactly one GitHub issue, include `closes #<issue>` in the commit message. If it is tied to multiple issues, stop and ask which one to use. If it is not tied to any issue, do not include `closes #` or `fixes #` in the commit message.
|
||||
5. Check the current git branch. If it is not `main`, stop and ask what to do. Do not push from another branch unless I explicitly say so.
|
||||
6. Push the current branch.
|
||||
|
||||
Constraints:
|
||||
- Never stage unrelated files.
|
||||
- Never use `git add .` or `git add -A`.
|
||||
- Run required checks before committing if code changed.
|
||||
- Do not open a PR unless I explicitly ask.
|
||||
- If this is not GitHub issue or PR work, do not post a GitHub comment.
|
||||
- If a final issue or PR comment was already posted in this session, do not post another one unless I explicitly ask.
|
||||
@@ -1,57 +0,0 @@
|
||||
---
|
||||
name: add-llm-provider
|
||||
description: Checklist for adding a new LLM provider to packages/ai. Covers core types, provider implementation, lazy registration, model generation, the full test matrix, coding-agent wiring, and docs.
|
||||
---
|
||||
|
||||
# Adding a New LLM Provider (packages/ai)
|
||||
|
||||
A new provider touches multiple files. Work through these steps in order.
|
||||
|
||||
## 1. Core Types (`packages/ai/src/types.ts`)
|
||||
|
||||
- Add API identifier to `Api` type union (e.g. `"bedrock-converse-stream"`).
|
||||
- Create options interface extending `StreamOptions`.
|
||||
- Add mapping to `ApiOptionsMap`.
|
||||
- Add provider name to `KnownProvider` type union.
|
||||
|
||||
## 2. Provider Implementation (`packages/ai/src/providers/`)
|
||||
|
||||
Create a provider file exporting:
|
||||
|
||||
- `stream<Provider>()` returning `AssistantMessageEventStream`.
|
||||
- `streamSimple<Provider>()` for `SimpleStreamOptions` mapping.
|
||||
- Provider-specific options interface.
|
||||
- Message/tool conversion functions.
|
||||
- Response parsing that emits standardized events (`text`, `tool_call`, `thinking`, `usage`, `stop`).
|
||||
|
||||
## 3. Provider Exports and Lazy Registration
|
||||
|
||||
- Add a package subpath export in `packages/ai/package.json` pointing at `./dist/providers/<provider>.js`.
|
||||
- Add `export type` re-exports in `packages/ai/src/index.ts` for provider option types that should remain available from the root entry.
|
||||
- Register the provider in `packages/ai/src/providers/register-builtins.ts` via lazy loader wrappers; do not statically import provider implementation modules there.
|
||||
- Add credential detection in `packages/ai/src/env-api-keys.ts`.
|
||||
|
||||
## 4. Model Generation (`packages/ai/scripts/generate-models.ts`)
|
||||
|
||||
- Add logic to fetch/parse models from the provider source.
|
||||
- Map to the standardized `Model` interface.
|
||||
|
||||
## 5. Tests (`packages/ai/test/`)
|
||||
|
||||
- Always add the provider to `stream.test.ts` with at least one representative model, even if it reuses an existing API impl such as `openai-completions`.
|
||||
- Add the provider to the broader matrix where applicable: `tokens.test.ts`, `abort.test.ts`, `empty.test.ts`, `context-overflow.test.ts`, `unicode-surrogate.test.ts`, `tool-call-without-result.test.ts`, `image-tool-result.test.ts`, `total-tokens.test.ts`, `cross-provider-handoff.test.ts`.
|
||||
- For `cross-provider-handoff.test.ts`, add at least one provider/model pair. If the provider exposes multiple model families (e.g. GPT and Claude), add at least one pair per family.
|
||||
- For non-standard auth, create a utility (e.g. `bedrock-utils.ts`) with credential detection.
|
||||
|
||||
## 6. Coding Agent (`packages/coding-agent/`)
|
||||
|
||||
- `src/core/model-resolver.ts`: add default model ID to `defaultModelPerProvider`.
|
||||
- `src/core/provider-display-names.ts`: add API-key login display name so `/login` and related UI show the provider for built-in API-key auth.
|
||||
- `src/cli/args.ts`: add env var documentation.
|
||||
- `README.md`: add provider setup instructions.
|
||||
- `docs/providers.md`: add setup instructions, env var, and `auth.json` key.
|
||||
|
||||
## 7. Documentation
|
||||
|
||||
- `packages/ai/README.md`: add to providers table, document options/auth, add env vars.
|
||||
- `packages/ai/CHANGELOG.md`: add entry under `## [Unreleased]`.
|
||||
55
AGENTS.md
55
AGENTS.md
@@ -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
|
||||
|
||||
|
||||
102
CONTRIBUTING.md
102
CONTRIBUTING.md
@@ -1,93 +1,97 @@
|
||||
# Contributing to pi
|
||||
# 贡献指南
|
||||
|
||||
This guide exists to save both sides time.
|
||||
本指南旨在为双方节省时间。
|
||||
|
||||
## The One Rule
|
||||
## 设计理念
|
||||
|
||||
**You must understand your code.** If you cannot explain what your changes do and how they interact with the rest of the system, your PR will be closed.
|
||||
首先明确一点:**SproutClaw 的核心保持精简**。
|
||||
|
||||
Using AI to write code is fine. Submitting AI-generated slop without understanding it is not.
|
||||
如果你的功能不属于核心,它应该以扩展的形式实现。会让核心臃肿的 PR 大概率会被拒绝。
|
||||
|
||||
If you use an agent, run it from the `pi-mono` root directory so it picks up `AGENTS.md` automatically. Your agent must follow the rules and guidelines in that file.
|
||||
SproutClaw 的核心存在的意义在于精简且可扩展,以便扩展能够影响和定制其行为。即便是扩展的 hook 点也需要经过充分考量和讨论,避免引入难以维护的冗余代码和复杂交互。
|
||||
|
||||
## Contribution Gate
|
||||
## 唯一准则
|
||||
|
||||
All issues and PRs from new contributors are auto-closed by default.
|
||||
**你必须理解自己提交的代码。** 如果你无法解释你的改动做了什么、以及它与系统其他部分如何交互,你的 PR 将被关闭。
|
||||
|
||||
Issues submitted Friday through Sunday are not reviewed. If something is urgent, ask on Discord: https://discord.com/invite/3cU7Bz4UPx
|
||||
使用 AI 辅助编写代码没有问题,但在不理解代码的情况下直接提交 AI 生成的内容是不被接受的。
|
||||
|
||||
Maintainers review auto-closed issues daily and reopen worthwhile ones. Issues that do not meet the quality bar below will not be reopened or receive a reply.
|
||||
如果你使用 Agent,请在 `sproutclaw` 根目录运行,这样它会自动读取 `AGENTS.md`。你的 Agent 必须遵循该文件中的规则和指南。
|
||||
|
||||
Approval happens through maintainer replies on issues:
|
||||
## 贡献门槛
|
||||
|
||||
- `lgtmi`: your future issues will not be auto-closed
|
||||
- `lgtm`: your future issues and PRs will not be auto-closed
|
||||
来自新贡献者的 issue 和 PR 默认会被自动关闭。
|
||||
|
||||
`lgtmi` does not grant rights to submit PRs. Only `lgtm` grants rights to submit PRs.
|
||||
周五至周日提交的 issue 不保证会被审查。如有紧急问题,请在内部联系维护者。
|
||||
|
||||
## Quality Bar For Issues
|
||||
维护者会每日审查被自动关闭的 issue,并重新开放值得处理的内容。不符合以下质量标准的 issue 将不会被重新开放或收到回复。
|
||||
|
||||
If you open an issue, you must use one of the two GitHub issue templates.
|
||||
审批通过由维护者在 issue 中回复:
|
||||
|
||||
If you open an issue, keep it short, concrete, and worth reading.
|
||||
- `lgtmi`:你未来提交的 issue 将不再被自动关闭
|
||||
- `lgtm`:你未来提交的 issue 和 PR 将不再被自动关闭
|
||||
|
||||
- Keep it concise. If it does not fit on one screen, it is too long.
|
||||
- Write in your own voice.
|
||||
- State the bug or request clearly.
|
||||
- Explain why it matters.
|
||||
- If you want to implement the change yourself, say so.
|
||||
`lgtmi` 不授予提交 PR 的权限,只有 `lgtm` 才授予该权限。
|
||||
|
||||
If the issue is real and written well, a maintainer may reopen it, reply `lgtmi`, or reply `lgtm`.
|
||||
## Issue 质量标准
|
||||
|
||||
## Blocking
|
||||
提交 issue 时,必须使用两个 GitHub issue 模板之一。
|
||||
|
||||
If you ignore this document twice, or if you spam the tracker with agent-generated issues, your GitHub account will be permanently blocked.
|
||||
提交的 issue 应简洁、具体、有阅读价值:
|
||||
|
||||
If you send a large volume of issues through automation, your GitHub account will be permanently blocked. No taksies backsies.
|
||||
- 保持简短。如果超过一屏,就太长了。
|
||||
- 用自己的语言表达(不要用 LLM 生成文本;如果必须,请在后续评论中明确标注为 AI 生成)。
|
||||
- 清晰说明 bug 或需求。
|
||||
- 解释其重要性。
|
||||
- 如果你希望自己来实现,请说明。
|
||||
|
||||
## Before Submitting a PR
|
||||
如果 issue 真实且表述良好,维护者可能会重新开放、回复 `lgtmi` 或回复 `lgtm`。
|
||||
|
||||
Do not open a PR unless you have already been approved with `lgtm`.
|
||||
## 封禁规则
|
||||
|
||||
Before submitting a PR:
|
||||
如果你两次无视本文档,或通过 Agent 批量刷 issue,你的账号将被永久封禁。
|
||||
|
||||
如果你通过自动化手段大量提交 issue,你的账号将被永久封禁,且不可申诉。
|
||||
|
||||
## 提交 PR 前
|
||||
|
||||
未获得 `lgtm` 批准前,请勿提交 PR。
|
||||
|
||||
提交 PR 前,请先执行:
|
||||
|
||||
```bash
|
||||
npm run check
|
||||
./test.sh
|
||||
```
|
||||
|
||||
Both must pass.
|
||||
两项均须通过。
|
||||
|
||||
Do not edit `CHANGELOG.md`. Changelog entries are added by maintainers.
|
||||
不要修改 `CHANGELOG.md`,更新日志条目由维护者添加。
|
||||
|
||||
If you are adding a new provider to `packages/ai`, see `AGENTS.md` for required tests.
|
||||
如果你要在 `packages/ai` 中添加新的 provider,请参阅 `AGENTS.md` 中关于必要测试的说明。
|
||||
|
||||
## Philosophy
|
||||
## 有疑问?
|
||||
|
||||
pi's core is minimal. If your feature does not belong in the core, it should be an extension. PRs that bloat the core will likely be rejected.
|
||||
请联系项目维护者。
|
||||
|
||||
## Questions?
|
||||
## 常见问题
|
||||
|
||||
Ask on [Discord](https://discord.com/invite/nKXTsAcmbT).
|
||||
### 为什么新 issue 和 PR 会被自动关闭?
|
||||
|
||||
## FAQ
|
||||
SproutClaw 收到的 issue 数量超过维护者能够实时处理的上限。许多报告不符合本指南的质量标准,或者是通过 Agent 随意提交而未经本人审查和整理的。自动关闭机制让维护者可以按自己的节奏审查,并重新开放符合质量要求的 issue。
|
||||
|
||||
### Why are new issues and PRs auto-closed?
|
||||
### 为什么周末的 issue 优先级较低?
|
||||
|
||||
pi receives more issues than the maintainers can responsibly review in real time. Many reports do not meet the quality bar in this guide or do not follow CONTRIBUTING.md. Some are slung at the repository mindlessly via an agent instead of being reviewed and shaped by the person submitting them. Auto-closing creates a buffer so maintainers can review the tracker on their own schedule and reopen the issues that meet the quality bar.
|
||||
维护者在工作时间处理 issue。周末可能积累更多未处理内容,周五至周日提交的 issue 可能在周一审查时被遗漏或优先级降低。如有紧急问题,请直接联系维护者并附上简要描述、复现步骤和相关日志。
|
||||
|
||||
### Why are weekend issues not reviewed?
|
||||
### 为什么有些 issue 没有收到回复?
|
||||
|
||||
Maintainers need uninterrupted time away from the issue tracker. Issues submitted Friday through Sunday are auto-closed and are not part of the Monday review queue. If a problem is urgent, ask on Discord and include the short version, a repro, and the relevant logs.
|
||||
回复本身也是一种维护成本。低质量 issue、表述不清的报告、重复 issue 以及不遵循本指南的 issue 可能被直接关闭而不作说明。这样可以将时间留给可复现的 bug、深思熟虑的需求,以及真正用心整理报告的贡献者。
|
||||
|
||||
### Why do some issues get no reply?
|
||||
### 为什么不用 AI 来做所有分类工作?
|
||||
|
||||
A reply is maintenance work too. Low-signal issues, unclear reports, duplicates, and issues that do not follow this guide may be closed without discussion. This keeps time available for reproducible bugs, thoughtful requests, and contributors who have done the work to make their report actionable.
|
||||
AI 可以帮助归纳重复内容、汇总报告、发现缺失信息,但不被信任来做最终的维护决策。经过润色的 AI 生成 issue 仍然可能是错误的、有误导性的,或需要大量时间排查。人工审查始终是最终把关环节。
|
||||
|
||||
### Why not let AI triage everything?
|
||||
### 这对贡献者是否过于苛刻?
|
||||
|
||||
AI can help group duplicates, summarize reports, and spot missing information. It is not trusted to make final maintainer decisions. Polished AI-generated issues can still be wrong, misleading, or expensive to investigate. Human review remains the final gate.
|
||||
|
||||
### Is this hostile to contributors?
|
||||
|
||||
No. It is a guardrail against burnout and tracker spam. Short, concrete, reproducible issues are welcome. Thoughtful contributions are welcome. Automated slop, entitlement, and large volumes of low-effort reports are not.
|
||||
不是。这是防止维护者精力耗尽和 issue 刷屏的保护机制。简洁、具体、可复现的 issue 是受欢迎的,有深度的贡献同样受欢迎。自动化生成的低质量内容、无理取闹以及大量低价值报告则不被接受。
|
||||
|
||||
242
README.md
242
README.md
@@ -1,6 +1,6 @@
|
||||
# sproutclaw
|
||||
|
||||
`sproutclaw` 是一个面向服务器运维与开发工作的 Agent 助手,基于 pi-mono 改造,重点服务于日常服务器管理、Docker 部署、项目开发、故障排查和自动化工作流。
|
||||
`sproutclaw` 是一个面向服务器运维与开发工作的 Agent 助手,基于 [pi-mono](https://github.com/badlogic/pi-mono) 改造,重点服务于日常服务器管理、Docker 部署、项目开发、故障排查和自动化工作流。
|
||||
|
||||
## 定位
|
||||
|
||||
@@ -8,110 +8,192 @@
|
||||
- 开发协作助手:理解本地项目结构,执行代码修改、测试验证和 Git 工作流。
|
||||
- Docker 部署助手:按项目目录组织 `docker compose` 服务,关注数据持久化、端口规划和资源限制。
|
||||
- 内网服务助手:适配 smallmengya、bigmengya、alycd 等内网服务器使用习惯。
|
||||
- WebUI/TUI 双入口:保留控制台 TUI,同时扩展网页前端,方便在浏览器里管理会话和模型。
|
||||
- WebUI/TUI 双入口:保留控制台 TUI,同时提供独立的网页前端(`sproutclaw-web`)。
|
||||
|
||||
## 项目结构
|
||||
|
||||
| 路径 | 说明 |
|
||||
| --- | --- |
|
||||
| `packages/coding-agent` | 交互式 Agent CLI 与 TUI 主体 |
|
||||
| `packages/coding-agent` | 交互式 Agent CLI 与 TUI 主体(含 sproutclaw 定制补丁) |
|
||||
| `packages/agent` | Agent 运行时、工具调用和状态管理 |
|
||||
| `packages/ai` | 多模型、多 provider 的 LLM 接入层 |
|
||||
| `packages/tui` | 终端 UI 渲染库 |
|
||||
| `.pi/agent/extensions/webui` | 本地扩展的网页对话入口 |
|
||||
| `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
|
||||
npm install --ignore-scripts
|
||||
npm run build
|
||||
npm run check
|
||||
./test.sh
|
||||
./pi-test.sh
|
||||
```
|
||||
|
||||
运行本地源码版 Agent:
|
||||
从源码运行(开发调试,无需 build):
|
||||
|
||||
```bash
|
||||
./pi-test.sh
|
||||
./test.sh
|
||||
```
|
||||
|
||||
启动 WebUI 时,在 TUI 中执行:
|
||||
从已构建版运行(日常更快):
|
||||
|
||||
```bash
|
||||
./build.sh
|
||||
# 或
|
||||
./sproutclaw
|
||||
```
|
||||
|
||||
检查代码:
|
||||
|
||||
```bash
|
||||
npm run check
|
||||
```
|
||||
|
||||
## WebUI(sproutclaw-web)
|
||||
|
||||
WebUI 是独立项目,位于 `../sproutclaw-web/`,通过 systemd 以服务形式运行。
|
||||
|
||||
构建后端:
|
||||
|
||||
```bash
|
||||
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 定制**。
|
||||
|
||||
| 分支 | 用途 |
|
||||
| --- | --- |
|
||||
| `main` | 稳定可跑版本:已合并的上游 + sproutclaw 定制,推送到 GitHub / Gitea |
|
||||
| `feature/*` | 日常开发:WebUI、扩展、补丁等,完成后 merge 进 `main` |
|
||||
|
||||
### Remote 配置
|
||||
|
||||
```text
|
||||
/webui on 19133
|
||||
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
|
||||
```
|
||||
|
||||
关闭 WebUI:
|
||||
首次配置 upstream:
|
||||
|
||||
```text
|
||||
/webui off
|
||||
```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
|
||||
```
|
||||
|
||||
### 日常开发
|
||||
|
||||
```bash
|
||||
git checkout -b feature/my-fix
|
||||
# ... 开发、测试 ...
|
||||
git add <改动的具体文件>
|
||||
git commit -m "fix: ..."
|
||||
git checkout main
|
||||
git merge feature/my-fix
|
||||
./scripts/push-sproutclaw.sh
|
||||
git branch -d feature/my-fix
|
||||
```
|
||||
|
||||
### 推送到 GitHub / Gitea
|
||||
|
||||
```bash
|
||||
./scripts/push-sproutclaw.sh
|
||||
# 或手动
|
||||
git push origin main && git push gitea main
|
||||
```
|
||||
|
||||
### 合并上游 pi-mono 更新
|
||||
|
||||
```bash
|
||||
git fetch upstream
|
||||
git merge upstream/main --no-commit --no-ff
|
||||
# 解决冲突(见下方惯例),然后:
|
||||
git add <resolved-files>
|
||||
PI_ALLOW_LOCKFILE_CHANGE=1 git commit
|
||||
./scripts/push-sproutclaw.sh
|
||||
```
|
||||
|
||||
**合并冲突处理惯例:**
|
||||
|
||||
| 文件类型 | 处理方式 |
|
||||
| --- | --- |
|
||||
| `README.md`、sproutclaw 定制代码 | 保留本仓库版本 |
|
||||
| `*.generated.ts` | 采用 upstream |
|
||||
| `package-lock.json` | 采用 upstream,再 `npm install --ignore-scripts` |
|
||||
| 测试/文档/新增上游功能 | 采用 upstream |
|
||||
|
||||
合并后必做:
|
||||
|
||||
```bash
|
||||
npm install --ignore-scripts
|
||||
npm run build
|
||||
./test.sh
|
||||
```
|
||||
|
||||
### sproutclaw 相对 upstream 的定制改动
|
||||
|
||||
合并上游时,以下内容**不要误删**:
|
||||
|
||||
- `packages/coding-agent/package.json` 中 `piConfig.name = sproutclaw`、`configDir = .sproutclaw`(命名空间隔离)
|
||||
- `showChangelogOnStartup` 设置项(关闭启动 What's New)
|
||||
- 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
|
||||
|
||||
## 本地配置
|
||||
|
||||
`.sproutclaw/agent/` 中以下内容不进 Git:
|
||||
|
||||
- `settings.json` → 参考 `settings.example.json` 本地复制
|
||||
- `models.json`、`auth.json`、`mcp.json`
|
||||
- `sessions/`、`skills/`、`extensions/`、`prompts/`
|
||||
|
||||
环境变量(可选):
|
||||
|
||||
- `SPROUTCLAW_CODING_AGENT_DIR` — 覆盖 agent 配置目录,默认 `<repo>/.sproutclaw/agent`
|
||||
|
||||
首次部署:
|
||||
|
||||
```bash
|
||||
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` |
|
||||
|
||||
## 说明
|
||||
|
||||
这个仓库是自用分支,默认会围绕树萌芽的服务器环境、部署规范和日常开发习惯进行调整。上游能力来自 pi-mono,后续会继续精简、增强并沉淀适合服务器运维开发场景的 Agent 工作流。
|
||||
|
||||
To learn more about pi:
|
||||
|
||||
* [Visit pi.dev](https://pi.dev), the project website with demos
|
||||
* [Read the documentation](https://pi.dev/docs/latest), but you can also ask the agent to explain itself
|
||||
|
||||
## Share your OSS coding agent sessions
|
||||
|
||||
If you use pi or other coding agents for open source work, please share your sessions.
|
||||
|
||||
Public OSS session data helps improve coding agents with real-world tasks, tool use, failures, and fixes instead of toy benchmarks.
|
||||
|
||||
For the full explanation, see [this post on X](https://x.com/badlogicgames/status/2037811643774652911).
|
||||
|
||||
To publish sessions, use [`badlogic/pi-share-hf`](https://github.com/badlogic/pi-share-hf). Read its README.md for setup instructions. All you need is a Hugging Face account, the Hugging Face CLI, and `pi-share-hf`.
|
||||
|
||||
You can also watch [this video](https://x.com/badlogicgames/status/2041151967695634619), where I show how I publish my `pi-mono` sessions.
|
||||
|
||||
I regularly publish my own `pi-mono` work sessions here:
|
||||
|
||||
- [badlogicgames/pi-mono on Hugging Face](https://huggingface.co/datasets/badlogicgames/pi-mono)
|
||||
|
||||
## All Packages
|
||||
|
||||
| Package | Description |
|
||||
|---------|-------------|
|
||||
| **[@earendil-works/pi-ai](packages/ai)** | Unified multi-provider LLM API (OpenAI, Anthropic, Google, etc.) |
|
||||
| **[@earendil-works/pi-agent-core](packages/agent)** | Agent runtime with tool calling and state management |
|
||||
| **[@earendil-works/pi-coding-agent](packages/coding-agent)** | Interactive coding agent CLI |
|
||||
| **[@earendil-works/pi-tui](packages/tui)** | Terminal UI library with differential rendering |
|
||||
|
||||
For Slack/chat automation and workflows see [earendil-works/pi-chat](https://github.com/earendil-works/pi-chat).
|
||||
|
||||
## Contributing
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for contribution guidelines and [AGENTS.md](AGENTS.md) for project-specific rules (for both humans and agents).
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
npm install --ignore-scripts # Install all dependencies without running lifecycle scripts
|
||||
npm run build # Build all packages
|
||||
npm run check # Lint, format, and type check
|
||||
./test.sh # Run tests (skips LLM-dependent tests without API keys)
|
||||
./pi-test.sh # Run pi from sources (can be run from any directory)
|
||||
```
|
||||
|
||||
## Supply-chain hardening
|
||||
|
||||
We treat npm dependency changes as reviewed code changes.
|
||||
|
||||
- Direct external dependencies are pinned to exact versions. Internal workspace packages remain version-ranged.
|
||||
- `.npmrc` sets `save-exact=true` and `min-release-age=2` to avoid same-day dependency releases during npm resolution.
|
||||
- `package-lock.json` is the dependency ground truth. Pre-commit blocks accidental lockfile commits unless `PI_ALLOW_LOCKFILE_CHANGE=1` is set.
|
||||
- `npm run check` verifies pinned direct deps, native TypeScript import compatibility, and the generated coding-agent shrinkwrap.
|
||||
- The published CLI package includes `packages/coding-agent/npm-shrinkwrap.json`, generated from the root lockfile, to pin transitive deps for npm users.
|
||||
- Release smoke tests use `npm run release:local` to build, pack, and create isolated npm and Bun installs outside the repo before publishing.
|
||||
- Local release installs, documented npm installs, and `pi update --self` use `--ignore-scripts` where supported.
|
||||
- CI installs with `npm ci --ignore-scripts`, and a scheduled GitHub workflow runs `npm audit --omit=dev` plus `npm audit signatures --omit=dev`.
|
||||
- Shrinkwrap generation has an explicit allowlist for dependency lifecycle scripts; new lifecycle-script deps fail checks until reviewed.
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
这个仓库是自用 fork,默认围绕树萌芽的服务器环境、部署规范和日常开发习惯调整。上游能力来自 pi-mono,通过 `git fetch upstream && git merge upstream/main` 定期同步,sproutclaw 定制在 `feature/*` 开发后合入 `main`。
|
||||
|
||||
58
SECURITY.md
Normal file
58
SECURITY.md
Normal 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 触达。
|
||||
77
build.bat
Normal file
77
build.bat
Normal file
@@ -0,0 +1,77 @@
|
||||
|
||||
@echo off
|
||||
setlocal EnableDelayedExpansion
|
||||
|
||||
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 SPROUTCLAW_CODING_AGENT_DIR (
|
||||
set "SPROUTCLAW_CODING_AGENT_DIR=%SCRIPT_DIR%\.sproutclaw\agent"
|
||||
)
|
||||
|
||||
set "NO_ENV=false"
|
||||
set "ARGS="
|
||||
|
||||
:loop
|
||||
if "%~1"=="" goto endloop
|
||||
if "%~1"=="--no-env" (
|
||||
set "NO_ENV=true"
|
||||
) else (
|
||||
if "%ARGS%"=="" (
|
||||
set "ARGS=%1"
|
||||
) else (
|
||||
set "ARGS=%ARGS% %1"
|
||||
)
|
||||
)
|
||||
shift
|
||||
goto loop
|
||||
:endloop
|
||||
|
||||
if "%NO_ENV%"=="true" (
|
||||
set "ANTHROPIC_API_KEY="
|
||||
set "ANTHROPIC_OAUTH_TOKEN="
|
||||
set "OPENAI_API_KEY="
|
||||
set "GEMINI_API_KEY="
|
||||
set "GROQ_API_KEY="
|
||||
set "CEREBRAS_API_KEY="
|
||||
set "XAI_API_KEY="
|
||||
set "OPENROUTER_API_KEY="
|
||||
set "ZAI_API_KEY="
|
||||
set "MISTRAL_API_KEY="
|
||||
set "MINIMAX_API_KEY="
|
||||
set "MINIMAX_CN_API_KEY="
|
||||
set "AI_GATEWAY_API_KEY="
|
||||
set "OPENCODE_API_KEY="
|
||||
set "COPILOT_GITHUB_TOKEN="
|
||||
set "GH_TOKEN="
|
||||
set "GITHUB_TOKEN="
|
||||
set "HF_TOKEN="
|
||||
set "GOOGLE_APPLICATION_CREDENTIALS="
|
||||
set "GOOGLE_CLOUD_PROJECT="
|
||||
set "GCLOUD_PROJECT="
|
||||
set "GOOGLE_CLOUD_LOCATION="
|
||||
set "AWS_PROFILE="
|
||||
set "AWS_ACCESS_KEY_ID="
|
||||
set "AWS_SECRET_ACCESS_KEY="
|
||||
set "AWS_SESSION_TOKEN="
|
||||
set "AWS_REGION="
|
||||
set "AWS_DEFAULT_REGION="
|
||||
set "AWS_BEARER_TOKEN_BEDROCK="
|
||||
set "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI="
|
||||
set "AWS_CONTAINER_CREDENTIALS_FULL_URI="
|
||||
set "AWS_WEB_IDENTITY_TOKEN_FILE="
|
||||
set "AZURE_OPENAI_API_KEY="
|
||||
set "AZURE_OPENAI_BASE_URL="
|
||||
set "AZURE_OPENAI_RESOURCE_NAME="
|
||||
echo Running without API keys...
|
||||
)
|
||||
|
||||
if not exist "%PI_CLI_DIST%" (
|
||||
echo Build output not found: %PI_CLI_DIST% >&2
|
||||
echo Run from repo root: npm run build >&2
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
node "%PI_CLI_DIST%" %ARGS%
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PI_CLI_DIST="$SCRIPT_DIR/packages/coding-agent/dist/cli.js"
|
||||
|
||||
# Default to the repository-local pi config so this source checkout uses the
|
||||
# copied learning config instead of ~/.pi.
|
||||
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"
|
||||
|
||||
# Check for --no-env flag
|
||||
NO_ENV=false
|
||||
ARGS=()
|
||||
for arg in "$@"; do
|
||||
@@ -19,7 +20,6 @@ for arg in "$@"; do
|
||||
done
|
||||
|
||||
if [[ "$NO_ENV" == "true" ]]; then
|
||||
# Unset API keys (see packages/ai/src/env-api-keys.ts)
|
||||
unset ANTHROPIC_API_KEY
|
||||
unset ANTHROPIC_OAUTH_TOKEN
|
||||
unset OPENAI_API_KEY
|
||||
@@ -58,4 +58,10 @@ if [[ "$NO_ENV" == "true" ]]; then
|
||||
echo "Running without API keys..."
|
||||
fi
|
||||
|
||||
"$SCRIPT_DIR/node_modules/.bin/tsx" --tsconfig "$SCRIPT_DIR/tsconfig.json" "$SCRIPT_DIR/packages/coding-agent/src/cli.ts" ${ARGS[@]+"${ARGS[@]}"}
|
||||
if [[ ! -f "$PI_CLI_DIST" ]]; then
|
||||
echo "Build output not found: $PI_CLI_DIST" >&2
|
||||
echo "Run from repo root: npm run build" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exec node "$PI_CLI_DIST" ${ARGS[@]+"${ARGS[@]}"}
|
||||
142
context.md
142
context.md
@@ -1,142 +0,0 @@
|
||||
# Code Context
|
||||
|
||||
## Top-Level Structure
|
||||
|
||||
```
|
||||
.sproutclaw/
|
||||
├── .claude/ # Claude configuration
|
||||
├── .github/ # Issue templates, workflows (issue-gate, pr-gate, approve-contributor)
|
||||
├── .husky/ # Git hooks
|
||||
├── .pi/ # pi/pi-mono agent configuration (AGENTS.md)
|
||||
├── node_modules/
|
||||
├── packages/ # 5 monorepo workspaces
|
||||
│ ├── ai/ # @earendil-works/pi-ai v0.74.0
|
||||
│ ├── agent/ # @earendil-works/pi-agent-core v0.74.0
|
||||
│ ├── coding-agent/ # @earendil-works/pi-coding-agent v0.74.0
|
||||
│ ├── tui/ # @earendil-works/pi-tui v0.74.0
|
||||
│ └── web-ui/ # @earendil-works/pi-web-ui v0.74.0
|
||||
├── scripts/ # CI/release/utility scripts
|
||||
├── tmp/ # Temp files
|
||||
├── pi-test.sh # Interactive TUI test script
|
||||
├── README.md
|
||||
├── CONTRIBUTING.md
|
||||
├── AGENTS.md
|
||||
├── biome.json # Linting/formatting config
|
||||
├── tsconfig.base.json # Shared TS config
|
||||
├── tsconfig.json # Root TS config
|
||||
├── package.json # Workspace root (npm workspaces)
|
||||
└── bun.lock / package-lock.json
|
||||
```
|
||||
|
||||
## Packages
|
||||
|
||||
### 1. `packages/ai` — `@earendil-works/pi-ai`
|
||||
**Unified LLM API** — automatic model discovery, provider configuration, streaming.
|
||||
|
||||
- `src/index.ts` — package entry
|
||||
- `src/types.ts` — core types (`Api`, `StreamOptions`, `Model`, `KnownProvider`)
|
||||
- `src/stream.ts` — main streaming implementation
|
||||
- `src/models.ts` / `src/models.generated.ts` — model registry + auto-generated
|
||||
- `src/providers/` — individual provider implementations (Anthropic, Google, OpenAI, Mistral, etc.)
|
||||
- `src/cli.ts` — CLI for model listing etc.
|
||||
- `src/env-api-keys.ts` — credential detection from env vars
|
||||
- `src/oauth.ts` — OAuth support
|
||||
- `src/api-registry.ts` — API/stream provider registration
|
||||
- `scripts/generate-models.ts` — model list generation script
|
||||
- Subpath exports per provider (e.g. `./anthropic`, `./google`, etc.)
|
||||
|
||||
### 2. `packages/agent` — `@earendil-works/pi-agent-core`
|
||||
**General-purpose agent** — transport abstraction, state management, attachment support.
|
||||
|
||||
- `src/index.ts` — package entry
|
||||
- `src/types.ts` — core types (agent state, attachments, transports)
|
||||
- `src/agent.ts` — agent implementation
|
||||
- `src/agent-loop.ts` — agent main loop
|
||||
- `src/proxy.ts` — proxy support
|
||||
- `src/harness/` — test harness utilities
|
||||
|
||||
### 3. `packages/coding-agent` — `@earendil-works/pi-coding-agent`
|
||||
**Coding agent CLI** — the `pi` CLI binary with read, bash, edit, write tools and session management.
|
||||
|
||||
- `src/cli.ts` / `src/cli/` — CLI argument parsing / entry point
|
||||
- `src/main.ts` — main agent runner
|
||||
- `src/index.ts` — package entry
|
||||
- `src/config.ts` — configuration management
|
||||
- `src/core/` — core logic (model resolution, hooks, tool execution, session management)
|
||||
- `src/modes/` — operational modes
|
||||
- `src/bun/` — Bun-specific support
|
||||
- `src/migrations.ts` — data migration utilities
|
||||
- `src/package-manager-cli.ts` — package manager interaction
|
||||
- `docs/` — provider setup docs
|
||||
- `examples/` — extension examples (custom provider, sandbox)
|
||||
- Binary name: `pi`
|
||||
|
||||
### 4. `packages/tui` — `@earendil-works/pi-tui`
|
||||
**Terminal UI library** — differential rendering for text-based terminal applications.
|
||||
|
||||
- `src/index.ts` — package entry
|
||||
- `src/tui.ts` — core TUI rendering engine
|
||||
- `src/terminal.ts` — low-level terminal I/O
|
||||
- `src/keys.ts` / `src/keybindings.ts` — key input handling
|
||||
- `src/components/` — reusable UI components
|
||||
- `src/editor-component.ts` — text editor component
|
||||
- `src/autocomplete.ts` — autocomplete support
|
||||
- `src/fuzzy.ts` — fuzzy matching
|
||||
- `src/terminal-image.ts` — image rendering in terminal
|
||||
- `src/kill-ring.ts` / `src/undo-stack.ts` — editing helpers
|
||||
- `src/stdin-buffer.ts` / `src/utils.ts` — utilities
|
||||
|
||||
### 5. `packages/web-ui` — `@earendil-works/pi-web-ui`
|
||||
**Reusable web UI components** — AI chat interfaces powered by pi-ai.
|
||||
|
||||
- `src/index.ts` — package entry
|
||||
- `src/app.css` — Tailwind CSS stylesheet
|
||||
- `src/ChatPanel.ts` — main chat panel component
|
||||
- `src/components/` — reusable React components
|
||||
- `src/dialogs/` — dialog components
|
||||
- `src/prompts/` — prompt templates/management
|
||||
- `src/storage/` — client-side storage abstraction
|
||||
- `src/tools/` — tool integration
|
||||
- `src/utils/` — utilities
|
||||
- Build: TypeScript + Tailwind CSS
|
||||
|
||||
## Root Scripts (`scripts/`)
|
||||
|
||||
- `release.mjs` — automated release workflow (version + changelog + publish)
|
||||
- `sync-versions.js` — lockstep version sync across all packages
|
||||
- `cost.ts` / `stats.ts` — analytics/telemetry stats
|
||||
- `tool-stats.ts` / `edit-tool-stats.mjs` / `read-tool-stats.mjs` — tool usage statistics
|
||||
- `profile-coding-agent-node.mjs` — Node.js profiling for coding agent
|
||||
- `build-binaries.sh` — binary build script
|
||||
- `browser-smoke-entry.ts` / `check-browser-smoke.mjs` — browser smoke tests
|
||||
- `session-transcripts.ts` / `session-context-stats.mjs` — session logging
|
||||
|
||||
## Architecture Summary
|
||||
|
||||
```
|
||||
pi-tui (terminal UI) ─── pi-coding-agent (CLI + tools) ─── pi-agent-core (agent loop)
|
||||
│
|
||||
pi-web-ui (web UI) ────────────┤
|
||||
│
|
||||
pi-ai (LLM provider abstraction)
|
||||
```
|
||||
|
||||
- **pi-ai** is the foundation layer providing unified LLM API access (Anthropic, Google, OpenAI, Azure, Mistral, Bedrock, etc.)
|
||||
- **pi-agent-core** builds on pi-ai with agent loop, transport abstraction, and state management
|
||||
- **pi-coding-agent** is the main product: a coding agent CLI (`pi` command) with read/bash/edit/write tools and session management
|
||||
- **pi-tui** is a standalone TUI rendering library used by pi-coding-agent
|
||||
- **pi-web-ui** provides reusable chat UI components (React) powered by pi-ai
|
||||
|
||||
## Key Facts
|
||||
|
||||
- **Lockstep versioning**: all packages share the same version (currently 0.74.0)
|
||||
- **Release automation**: `npm run release:patch` / `release:minor` via `scripts/release.mjs`
|
||||
- **TypeScript with tsgo**: uses `tsgo` (TypeScript native preview) for fast builds; web-ui uses `tsc`
|
||||
- **Linting**: Biome (v2.3.5) for formatting and linting (`npm run check`)
|
||||
- **Testing**: vitest for ai/agent/coding-agent; node --test for tui
|
||||
- **No `any` types allowed**, standard top-level imports only (no inline `import()`)
|
||||
- **GitHub CI**: issue-gate, pr-gate, approve-contributor workflows for contributor management
|
||||
|
||||
## Start Here
|
||||
|
||||
Open `package.json` (root) for workspace structure and available scripts. For a specific package, start with its `package.json` then `src/index.ts` for the public API surface.
|
||||
3670
package-lock.json
generated
3670
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
19
package.json
19
package.json
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
39
packages/agent/src/base.ts
Normal file
39
packages/agent/src/base.ts
Normal 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";
|
||||
@@ -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];
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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.`;
|
||||
|
||||
|
||||
@@ -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. */
|
||||
|
||||
52
packages/agent/src/harness/env/nodejs.ts
vendored
52
packages/agent/src/harness/env/nodejs.ts
vendored
@@ -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)));
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -5,9 +5,14 @@ import { type ExecutionEnv, type FileInfo, type Result, type Skill, toError } fr
|
||||
const MAX_NAME_LENGTH = 64;
|
||||
const MAX_DESCRIPTION_LENGTH = 1024;
|
||||
const IGNORE_FILE_NAMES = [".gitignore", ".ignore", ".fdignore"];
|
||||
const IGNORED_ROOT_SKILL_MARKDOWN = new Set(["README.md"]);
|
||||
|
||||
type IgnoreMatcher = ReturnType<typeof ignore>;
|
||||
|
||||
function isIgnoredRootSkillMarkdown(fileName: string): boolean {
|
||||
return IGNORED_ROOT_SKILL_MARKDOWN.has(fileName) || fileName.toLowerCase() === "readme.md";
|
||||
}
|
||||
|
||||
export type SkillDiagnosticCode =
|
||||
| "file_info_failed"
|
||||
| "list_failed"
|
||||
@@ -165,7 +170,8 @@ async function loadSkillsFromDirInternal(
|
||||
continue;
|
||||
}
|
||||
|
||||
if (kind !== "file" || !includeRootFiles || !entry.name.endsWith(".md")) continue;
|
||||
if (kind !== "file" || !includeRootFiles || !entry.name.endsWith(".md") || isIgnoredRootSkillMarkdown(entry.name))
|
||||
continue;
|
||||
const result = await loadSkillFromFile(env, fullPath);
|
||||
if (result.skill) skills.push(result.skill);
|
||||
diagnostics.push(...result.diagnostics);
|
||||
@@ -234,6 +240,10 @@ async function loadSkillFromFile(
|
||||
env: ExecutionEnv,
|
||||
filePath: string,
|
||||
): Promise<{ skill: Skill | null; diagnostics: SkillDiagnostic[] }> {
|
||||
if (isIgnoredRootSkillMarkdown(basenameEnvPath(filePath))) {
|
||||
return { skill: null, diagnostics: [] };
|
||||
}
|
||||
|
||||
const diagnostics: SkillDiagnostic[] = [];
|
||||
const rawContent = await env.readTextFile(filePath);
|
||||
if (!rawContent.ok) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -113,4 +113,20 @@ Use this skill.
|
||||
expect(skills.map((skill) => skill.name)).toEqual(["skills"]);
|
||||
expect(skills[0]?.content).toBe("Root content");
|
||||
});
|
||||
|
||||
it("ignores README.md at the skills directory root", async () => {
|
||||
const root = createTempDir();
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
await env.createDir("skills/example", { recursive: true });
|
||||
await env.writeFile("skills/README.md", "# Skills\n\nDirectory documentation.");
|
||||
await env.writeFile(
|
||||
"skills/example/SKILL.md",
|
||||
"---\nname: example\ndescription: Example skill\n---\nUse this skill.",
|
||||
);
|
||||
|
||||
const { skills, diagnostics } = await loadSkills(env, "skills");
|
||||
|
||||
expect(diagnostics).toEqual([]);
|
||||
expect(skills.map((skill) => skill.name)).toEqual(["example"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)).
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
45
packages/ai/src/base.ts
Normal 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";
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
|
||||
@@ -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>";
|
||||
}
|
||||
|
||||
@@ -4,21 +4,21 @@
|
||||
import type { ImagesApi, ImagesModel } from "./types.ts";
|
||||
|
||||
export const IMAGE_MODELS = {
|
||||
"openrouter": {
|
||||
openrouter: {
|
||||
"black-forest-labs/flux.2-flex": {
|
||||
id: "black-forest-labs/flux.2-flex",
|
||||
name: "Black Forest Labs: FLUX.2 Flex",
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["text","image"],
|
||||
input: ["text", "image"],
|
||||
output: ["image"],
|
||||
cost: {
|
||||
"input": 0,
|
||||
"output": 0,
|
||||
"cacheRead": 0,
|
||||
"cacheWrite": 0
|
||||
}
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"black-forest-labs/flux.2-klein-4b": {
|
||||
id: "black-forest-labs/flux.2-klein-4b",
|
||||
@@ -26,14 +26,14 @@ export const IMAGE_MODELS = {
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["text","image"],
|
||||
input: ["text", "image"],
|
||||
output: ["image"],
|
||||
cost: {
|
||||
"input": 0,
|
||||
"output": 0,
|
||||
"cacheRead": 0,
|
||||
"cacheWrite": 0
|
||||
}
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"black-forest-labs/flux.2-max": {
|
||||
id: "black-forest-labs/flux.2-max",
|
||||
@@ -41,14 +41,14 @@ export const IMAGE_MODELS = {
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["text","image"],
|
||||
input: ["text", "image"],
|
||||
output: ["image"],
|
||||
cost: {
|
||||
"input": 0,
|
||||
"output": 0,
|
||||
"cacheRead": 0,
|
||||
"cacheWrite": 0
|
||||
}
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"black-forest-labs/flux.2-pro": {
|
||||
id: "black-forest-labs/flux.2-pro",
|
||||
@@ -56,14 +56,14 @@ export const IMAGE_MODELS = {
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["text","image"],
|
||||
input: ["text", "image"],
|
||||
output: ["image"],
|
||||
cost: {
|
||||
"input": 0,
|
||||
"output": 0,
|
||||
"cacheRead": 0,
|
||||
"cacheWrite": 0
|
||||
}
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"bytedance-seed/seedream-4.5": {
|
||||
id: "bytedance-seed/seedream-4.5",
|
||||
@@ -71,14 +71,14 @@ export const IMAGE_MODELS = {
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["image","text"],
|
||||
input: ["image", "text"],
|
||||
output: ["image"],
|
||||
cost: {
|
||||
"input": 0,
|
||||
"output": 0,
|
||||
"cacheRead": 0,
|
||||
"cacheWrite": 0
|
||||
}
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"google/gemini-2.5-flash-image": {
|
||||
id: "google/gemini-2.5-flash-image",
|
||||
@@ -86,14 +86,29 @@ export const IMAGE_MODELS = {
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["image","text"],
|
||||
output: ["image","text"],
|
||||
cost: {
|
||||
"input": 0.3,
|
||||
"output": 2.5,
|
||||
"cacheRead": 0.03,
|
||||
"cacheWrite": 0.08333333333333334
|
||||
}
|
||||
input: ["image", "text"],
|
||||
output: ["image", "text"],
|
||||
cost: {
|
||||
input: 0.3,
|
||||
output: 2.5,
|
||||
cacheRead: 0.03,
|
||||
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",
|
||||
@@ -101,14 +116,29 @@ export const IMAGE_MODELS = {
|
||||
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
|
||||
}
|
||||
input: ["image", "text"],
|
||||
output: ["image", "text"],
|
||||
cost: {
|
||||
input: 2,
|
||||
output: 12,
|
||||
cacheRead: 0.19999999999999998,
|
||||
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",
|
||||
@@ -116,14 +146,29 @@ export const IMAGE_MODELS = {
|
||||
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
|
||||
}
|
||||
input: ["image", "text"],
|
||||
output: ["image", "text"],
|
||||
cost: {
|
||||
input: 0.5,
|
||||
output: 3,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"microsoft/mai-image-2.5": {
|
||||
id: "microsoft/mai-image-2.5",
|
||||
name: "Microsoft: MAI-Image-2.5",
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["text", "image"],
|
||||
output: ["image"],
|
||||
cost: {
|
||||
input: 5,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"openai/gpt-5-image": {
|
||||
id: "openai/gpt-5-image",
|
||||
@@ -131,14 +176,14 @@ export const IMAGE_MODELS = {
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["image","text"],
|
||||
output: ["image","text"],
|
||||
cost: {
|
||||
"input": 10,
|
||||
"output": 10,
|
||||
"cacheRead": 1.25,
|
||||
"cacheWrite": 0
|
||||
}
|
||||
input: ["image", "text"],
|
||||
output: ["image", "text"],
|
||||
cost: {
|
||||
input: 10,
|
||||
output: 10,
|
||||
cacheRead: 1.25,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"openai/gpt-5-image-mini": {
|
||||
id: "openai/gpt-5-image-mini",
|
||||
@@ -146,14 +191,14 @@ export const IMAGE_MODELS = {
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["image","text"],
|
||||
output: ["image","text"],
|
||||
cost: {
|
||||
"input": 2.5,
|
||||
"output": 2,
|
||||
"cacheRead": 0.25,
|
||||
"cacheWrite": 0
|
||||
}
|
||||
input: ["image", "text"],
|
||||
output: ["image", "text"],
|
||||
cost: {
|
||||
input: 2.5,
|
||||
output: 2,
|
||||
cacheRead: 0.25,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"openai/gpt-5.4-image-2": {
|
||||
id: "openai/gpt-5.4-image-2",
|
||||
@@ -161,14 +206,14 @@ export const IMAGE_MODELS = {
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["image","text"],
|
||||
output: ["image","text"],
|
||||
cost: {
|
||||
"input": 8,
|
||||
"output": 15,
|
||||
"cacheRead": 2,
|
||||
"cacheWrite": 0
|
||||
}
|
||||
input: ["image", "text"],
|
||||
output: ["image", "text"],
|
||||
cost: {
|
||||
input: 8,
|
||||
output: 15,
|
||||
cacheRead: 2,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"openrouter/auto": {
|
||||
id: "openrouter/auto",
|
||||
@@ -176,59 +221,14 @@ export const IMAGE_MODELS = {
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["text","image"],
|
||||
output: ["text","image"],
|
||||
cost: {
|
||||
"input": -1000000,
|
||||
"output": -1000000,
|
||||
"cacheRead": 0,
|
||||
"cacheWrite": 0
|
||||
}
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"recraft/recraft-v3": {
|
||||
id: "recraft/recraft-v3",
|
||||
name: "Recraft: Recraft V3",
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["text","image"],
|
||||
output: ["image"],
|
||||
cost: {
|
||||
"input": 0,
|
||||
"output": 0,
|
||||
"cacheRead": 0,
|
||||
"cacheWrite": 0
|
||||
}
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"recraft/recraft-v4": {
|
||||
id: "recraft/recraft-v4",
|
||||
name: "Recraft: Recraft V4",
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["text","image"],
|
||||
output: ["image"],
|
||||
cost: {
|
||||
"input": 0,
|
||||
"output": 0,
|
||||
"cacheRead": 0,
|
||||
"cacheWrite": 0
|
||||
}
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"recraft/recraft-v4-pro": {
|
||||
id: "recraft/recraft-v4-pro",
|
||||
name: "Recraft: Recraft V4 Pro",
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["text","image"],
|
||||
output: ["image"],
|
||||
cost: {
|
||||
"input": 0,
|
||||
"output": 0,
|
||||
"cacheRead": 0,
|
||||
"cacheWrite": 0
|
||||
}
|
||||
input: ["text", "image"],
|
||||
output: ["text", "image"],
|
||||
cost: {
|
||||
input: -1000000,
|
||||
output: -1000000,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"recraft/recraft-v3": {
|
||||
id: "recraft/recraft-v3",
|
||||
@@ -401,14 +401,14 @@ export const IMAGE_MODELS = {
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["text","image"],
|
||||
input: ["text", "image"],
|
||||
output: ["image"],
|
||||
cost: {
|
||||
"input": 0,
|
||||
"output": 0,
|
||||
"cacheRead": 0,
|
||||
"cacheWrite": 0
|
||||
}
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"sourceful/riverflow-v2-fast-preview": {
|
||||
id: "sourceful/riverflow-v2-fast-preview",
|
||||
@@ -416,14 +416,14 @@ export const IMAGE_MODELS = {
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["text","image"],
|
||||
input: ["text", "image"],
|
||||
output: ["image"],
|
||||
cost: {
|
||||
"input": 0,
|
||||
"output": 0,
|
||||
"cacheRead": 0,
|
||||
"cacheWrite": 0
|
||||
}
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"sourceful/riverflow-v2-max-preview": {
|
||||
id: "sourceful/riverflow-v2-max-preview",
|
||||
@@ -431,14 +431,14 @@ export const IMAGE_MODELS = {
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["text","image"],
|
||||
input: ["text", "image"],
|
||||
output: ["image"],
|
||||
cost: {
|
||||
"input": 0,
|
||||
"output": 0,
|
||||
"cacheRead": 0,
|
||||
"cacheWrite": 0
|
||||
}
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"sourceful/riverflow-v2-pro": {
|
||||
id: "sourceful/riverflow-v2-pro",
|
||||
@@ -446,14 +446,14 @@ export const IMAGE_MODELS = {
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["text","image"],
|
||||
input: ["text", "image"],
|
||||
output: ["image"],
|
||||
cost: {
|
||||
"input": 0,
|
||||
"output": 0,
|
||||
"cacheRead": 0,
|
||||
"cacheWrite": 0
|
||||
}
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"sourceful/riverflow-v2-standard-preview": {
|
||||
id: "sourceful/riverflow-v2-standard-preview",
|
||||
@@ -461,14 +461,44 @@ export const IMAGE_MODELS = {
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["text","image"],
|
||||
input: ["text", "image"],
|
||||
output: ["image"],
|
||||
cost: {
|
||||
"input": 0,
|
||||
"output": 0,
|
||||
"cacheRead": 0,
|
||||
"cacheWrite": 0
|
||||
}
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"sourceful/riverflow-v2.5-fast": {
|
||||
id: "sourceful/riverflow-v2.5-fast",
|
||||
name: "Sourceful: Riverflow V2.5 Fast",
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["text", "image"],
|
||||
output: ["image"],
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"sourceful/riverflow-v2.5-pro": {
|
||||
id: "sourceful/riverflow-v2.5-pro",
|
||||
name: "Sourceful: Riverflow V2.5 Pro",
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["text", "image"],
|
||||
output: ["image"],
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"x-ai/grok-imagine-image-quality": {
|
||||
id: "x-ai/grok-imagine-image-quality",
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.`);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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>(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
41
packages/ai/src/utils/abort-signals.ts
Normal file
41
packages/ai/src/utils/abort-signals.ts
Normal 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);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
83
packages/ai/src/utils/oauth/device-code.ts
Normal file
83
packages/ai/src/utils/oauth/device-code.ts
Normal 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);
|
||||
}
|
||||
@@ -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 }];
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
52
packages/ai/src/utils/provider-env.ts
Normal file
52
packages/ai/src/utils/provider-env.ts
Normal 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
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
|
||||
30
packages/ai/test/anthropic-adaptive-thinking-models.test.ts
Normal file
30
packages/ai/test/anthropic-adaptive-thinking-models.test.ts
Normal 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)),
|
||||
);
|
||||
});
|
||||
});
|
||||
86
packages/ai/test/anthropic-cache-write-1h-cost.test.ts
Normal file
86
packages/ai/test/anthropic-cache-write-1h-cost.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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: "" }]);
|
||||
});
|
||||
});
|
||||
110
packages/ai/test/anthropic-force-adaptive-thinking.test.ts
Normal file
110
packages/ai/test/anthropic-force-adaptive-thinking.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user