Add files using upload-large-folder tool
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .github/APPROVED_CONTRIBUTORS +395 -0
- .github/ISSUE_TEMPLATE/bug.yml +45 -0
- .github/ISSUE_TEMPLATE/config.yml +5 -0
- .github/ISSUE_TEMPLATE/contribution.yml +36 -0
- .github/ISSUE_TEMPLATE/package-report.yml +49 -0
- .github/workflows/approve-contributor.yml +223 -0
- .github/workflows/build-binaries.yml +444 -0
- .github/workflows/ci.yml +42 -0
- .github/workflows/issue-analysis.yml +688 -0
- .github/workflows/issue-gate.yml +129 -0
- .github/workflows/issue-triage-labels.yml +142 -0
- .github/workflows/npm-audit.yml +31 -0
- .github/workflows/pr-gate.yml +128 -0
- .github/workflows/publish-model-catalog.yml +146 -0
- .github/workflows/remove-inprogress-on-close.yml +31 -0
- .pi/extensions/import-repro.ts +351 -0
- .pi/extensions/prompt-url-widget.ts +270 -0
- .pi/extensions/redraws.ts +24 -0
- .pi/extensions/tps.ts +47 -0
- .pi/git/.gitignore +2 -0
- .pi/npm/.gitignore +2 -0
- .pi/prompts/cl.md +54 -0
- .pi/prompts/deslop.md +67 -0
- .pi/prompts/is.md +28 -0
- .pi/prompts/pr.md +37 -0
- .pi/prompts/sa.md +163 -0
- .pi/prompts/wr.md +41 -0
- .pi/skills/add-llm-provider.md +57 -0
- .pi/skills/interactive-testing.md +19 -0
- .pi/skills/release.md +48 -0
- packages/ai/test/anthropic-eager-tool-input-compat.test.ts +166 -0
- packages/ai/test/bedrock-convert-messages.test.ts +410 -0
- packages/ai/test/bedrock-error-metadata.test.ts +212 -0
- packages/ai/test/cloudflare-stream.test.ts +65 -0
- packages/ai/test/error-body.test.ts +226 -0
- packages/ai/test/fetch-option.test.ts +178 -0
- packages/ai/test/image-tool-result.test.ts +550 -0
- packages/ai/test/kimi-coding-oauth.test.ts +270 -0
- packages/ai/test/mistral-raw-stop-reason.test.ts +61 -0
- packages/ai/test/oauth.ts +85 -0
- packages/ai/test/openai-completions-prompt-cache.test.ts +271 -0
- packages/ai/test/openai-completions-raw-stop-reason.test.ts +79 -0
- packages/ai/test/openai-completions-vllm-priority.test.ts +93 -0
- packages/ai/test/openai-responses-compat.test.ts +599 -0
- packages/ai/test/openai-responses-partial-json-cleanup.test.ts +106 -0
- packages/ai/test/openai-responses-reasoning-replay-e2e.test.ts +291 -0
- packages/ai/test/openrouter-cache-write-repro.test.ts +77 -0
- packages/ai/test/openrouter-reasoning-options.test.ts +109 -0
- packages/ai/test/overflow.test.ts +177 -0
- packages/ai/test/together-models.test.ts +86 -0
.github/APPROVED_CONTRIBUTORS
ADDED
|
@@ -0,0 +1,395 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# GitHub handles approved to bypass contribution auto-close
|
| 2 |
+
# Format: <username> <capability>
|
| 3 |
+
# capability:
|
| 4 |
+
# issue future issues stay open
|
| 5 |
+
# pr future issues and PRs stay open
|
| 6 |
+
|
| 7 |
+
herrnel pr
|
| 8 |
+
julien-c pr
|
| 9 |
+
barapa pr
|
| 10 |
+
alasano pr
|
| 11 |
+
aadishv pr
|
| 12 |
+
airtonix pr
|
| 13 |
+
aliou pr
|
| 14 |
+
aos pr
|
| 15 |
+
austinm911 pr
|
| 16 |
+
banteg pr
|
| 17 |
+
ben-vargas pr
|
| 18 |
+
butelo pr
|
| 19 |
+
can1357 pr
|
| 20 |
+
CarlosGtrz pr
|
| 21 |
+
cau1k pr
|
| 22 |
+
cmf pr
|
| 23 |
+
crcatala pr
|
| 24 |
+
Cursivez pr
|
| 25 |
+
cv pr
|
| 26 |
+
dannote pr
|
| 27 |
+
default-anton pr
|
| 28 |
+
dnouri pr
|
| 29 |
+
DronNick pr
|
| 30 |
+
enisdenjo pr
|
| 31 |
+
ferologics pr
|
| 32 |
+
fightbulc pr
|
| 33 |
+
ghoulr pr
|
| 34 |
+
gnattu pr
|
| 35 |
+
HACKE-RC pr
|
| 36 |
+
hewliyang pr
|
| 37 |
+
hjanuschka pr
|
| 38 |
+
iamd3vil pr
|
| 39 |
+
jblwilliams pr
|
| 40 |
+
joshp123 pr
|
| 41 |
+
jsinge97 pr
|
| 42 |
+
justram pr
|
| 43 |
+
kaofelix pr
|
| 44 |
+
kiliman pr
|
| 45 |
+
kim0 pr
|
| 46 |
+
lockmeister pr
|
| 47 |
+
LukeFost pr
|
| 48 |
+
lukele pr
|
| 49 |
+
m-box-mr pr
|
| 50 |
+
marckrenn pr
|
| 51 |
+
markusylisiurunen pr
|
| 52 |
+
mcinteerj pr
|
| 53 |
+
melihmucuk pr
|
| 54 |
+
mitsuhiko pr
|
| 55 |
+
mrexodia pr
|
| 56 |
+
nathyong pr
|
| 57 |
+
nickseelert pr
|
| 58 |
+
nicobailon pr
|
| 59 |
+
ninlds pr
|
| 60 |
+
ogulcancelik pr
|
| 61 |
+
patrick-kidger pr
|
| 62 |
+
paulbettner pr
|
| 63 |
+
Perlence pr
|
| 64 |
+
pjtf93 pr
|
| 65 |
+
prateekmedia pr
|
| 66 |
+
prathamdby pr
|
| 67 |
+
ribelo pr
|
| 68 |
+
richardgill pr
|
| 69 |
+
robinwander pr
|
| 70 |
+
ronyrus pr
|
| 71 |
+
roshanasingh4 pr
|
| 72 |
+
scutifer pr
|
| 73 |
+
skuridin pr
|
| 74 |
+
steipete pr
|
| 75 |
+
svkozak pr
|
| 76 |
+
tallshort pr
|
| 77 |
+
theBucky pr
|
| 78 |
+
thomasmhr pr
|
| 79 |
+
tiagoefreitas pr
|
| 80 |
+
timolins pr
|
| 81 |
+
tmustier pr
|
| 82 |
+
tudoroancea pr
|
| 83 |
+
unexge pr
|
| 84 |
+
vaayne pr
|
| 85 |
+
VaclavSynacek pr
|
| 86 |
+
vsabavat pr
|
| 87 |
+
w-winter pr
|
| 88 |
+
Whamp pr
|
| 89 |
+
WismutHansen pr
|
| 90 |
+
XesGaDeus pr
|
| 91 |
+
yevhen pr
|
| 92 |
+
badlogictest pr
|
| 93 |
+
terrorobe pr
|
| 94 |
+
zedrdave pr
|
| 95 |
+
mrud pr
|
| 96 |
+
toorusr pr
|
| 97 |
+
andresaraujo pr
|
| 98 |
+
lightningRalf pr
|
| 99 |
+
williballenthin pr
|
| 100 |
+
masonc15 pr
|
| 101 |
+
4h9fbZ pr
|
| 102 |
+
haoqixu pr
|
| 103 |
+
Graffioh pr
|
| 104 |
+
charles-cooper pr
|
| 105 |
+
emanuelst pr
|
| 106 |
+
juanibiapina pr
|
| 107 |
+
liby pr
|
| 108 |
+
pasky pr
|
| 109 |
+
odysseus0 pr
|
| 110 |
+
giuseppeg pr
|
| 111 |
+
michaelpersonal pr
|
| 112 |
+
academo pr
|
| 113 |
+
PriNova pr
|
| 114 |
+
semtexzv pr
|
| 115 |
+
jasonish pr
|
| 116 |
+
markusn pr
|
| 117 |
+
SamFold pr
|
| 118 |
+
Soleone pr
|
| 119 |
+
virtuald pr
|
| 120 |
+
NateSmyth pr
|
| 121 |
+
7Sageer pr
|
| 122 |
+
MatthieuBizien pr
|
| 123 |
+
sumeet pr
|
| 124 |
+
marchellodev pr
|
| 125 |
+
vedang pr
|
| 126 |
+
lucemia pr
|
| 127 |
+
mcollina pr
|
| 128 |
+
lajarre pr
|
| 129 |
+
smithbm2316 pr
|
| 130 |
+
drewburr pr
|
| 131 |
+
gordonhwc pr
|
| 132 |
+
deybhayden pr
|
| 133 |
+
tintinweb pr
|
| 134 |
+
asoules pr
|
| 135 |
+
zhahaoyu pr
|
| 136 |
+
in0vik pr
|
| 137 |
+
jtac pr
|
| 138 |
+
yzhg1983 pr
|
| 139 |
+
smcllns pr
|
| 140 |
+
dmmulroy pr
|
| 141 |
+
zmberber pr
|
| 142 |
+
andresvi94 pr
|
| 143 |
+
sudosubin pr
|
| 144 |
+
Mic92 pr
|
| 145 |
+
pmateusz pr
|
| 146 |
+
wirjo pr
|
| 147 |
+
jay-aye-see-kay pr
|
| 148 |
+
lucasmeijer pr
|
| 149 |
+
Evizero pr
|
| 150 |
+
|
| 151 |
+
ofa1 pr
|
| 152 |
+
|
| 153 |
+
crisog issue
|
| 154 |
+
|
| 155 |
+
mpazik pr
|
| 156 |
+
|
| 157 |
+
vekexasia pr
|
| 158 |
+
|
| 159 |
+
Michaelliv pr
|
| 160 |
+
|
| 161 |
+
cmraible pr
|
| 162 |
+
|
| 163 |
+
dljsjr pr
|
| 164 |
+
|
| 165 |
+
drio pr
|
| 166 |
+
|
| 167 |
+
jlaneve pr
|
| 168 |
+
|
| 169 |
+
tantara pr
|
| 170 |
+
|
| 171 |
+
Nutlope pr
|
| 172 |
+
|
| 173 |
+
xl0 pr
|
| 174 |
+
|
| 175 |
+
mdsjip pr
|
| 176 |
+
|
| 177 |
+
Exrun94 pr
|
| 178 |
+
|
| 179 |
+
marcbloech pr
|
| 180 |
+
|
| 181 |
+
pidalf pr
|
| 182 |
+
|
| 183 |
+
injaneity pr
|
| 184 |
+
|
| 185 |
+
thirtythreeforty pr
|
| 186 |
+
|
| 187 |
+
justinpbarnett pr
|
| 188 |
+
|
| 189 |
+
cristinaponcela pr
|
| 190 |
+
|
| 191 |
+
LooSik pr
|
| 192 |
+
|
| 193 |
+
mchenco pr
|
| 194 |
+
|
| 195 |
+
Phoen1xCode pr
|
| 196 |
+
|
| 197 |
+
louis030195 pr
|
| 198 |
+
|
| 199 |
+
technocidal pr
|
| 200 |
+
|
| 201 |
+
pandada8 pr
|
| 202 |
+
|
| 203 |
+
npupko issue
|
| 204 |
+
|
| 205 |
+
chrisvariety pr
|
| 206 |
+
|
| 207 |
+
maximilianzuern pr
|
| 208 |
+
|
| 209 |
+
brianmichel pr
|
| 210 |
+
|
| 211 |
+
abhinavmathur-atlan pr
|
| 212 |
+
|
| 213 |
+
mattiacerutti pr
|
| 214 |
+
|
| 215 |
+
josephyoung pr
|
| 216 |
+
|
| 217 |
+
mbazso pr
|
| 218 |
+
|
| 219 |
+
AJM10565 pr
|
| 220 |
+
|
| 221 |
+
DanielThomas pr
|
| 222 |
+
|
| 223 |
+
MichaelYochpaz pr
|
| 224 |
+
|
| 225 |
+
stephanmck pr
|
| 226 |
+
|
| 227 |
+
rolfvreijdenberger pr
|
| 228 |
+
|
| 229 |
+
psoukie pr
|
| 230 |
+
|
| 231 |
+
vastxie pr
|
| 232 |
+
|
| 233 |
+
ItsumoSeito pr
|
| 234 |
+
|
| 235 |
+
davidlifschitz pr
|
| 236 |
+
|
| 237 |
+
vdxz pr
|
| 238 |
+
|
| 239 |
+
dangooddd pr
|
| 240 |
+
|
| 241 |
+
Mearman pr
|
| 242 |
+
|
| 243 |
+
dodiego pr
|
| 244 |
+
|
| 245 |
+
any-victor pr
|
| 246 |
+
|
| 247 |
+
geraschenko pr
|
| 248 |
+
|
| 249 |
+
skhoroshavin pr
|
| 250 |
+
|
| 251 |
+
cyzlmh pr
|
| 252 |
+
|
| 253 |
+
xz-dev pr
|
| 254 |
+
|
| 255 |
+
rajp152k pr
|
| 256 |
+
|
| 257 |
+
affanali2k3 pr
|
| 258 |
+
|
| 259 |
+
ArcadiaLin pr
|
| 260 |
+
|
| 261 |
+
anilgulecha pr
|
| 262 |
+
|
| 263 |
+
DeviosLang pr
|
| 264 |
+
|
| 265 |
+
HarrodRen pr
|
| 266 |
+
|
| 267 |
+
aaronkyriesenbach pr
|
| 268 |
+
|
| 269 |
+
farid-fari pr
|
| 270 |
+
|
| 271 |
+
petrroll pr
|
| 272 |
+
|
| 273 |
+
vibeinging pr
|
| 274 |
+
|
| 275 |
+
DivineDominion pr
|
| 276 |
+
|
| 277 |
+
ananthakumaran pr
|
| 278 |
+
|
| 279 |
+
andrebreijao pr
|
| 280 |
+
|
| 281 |
+
anh-chu pr
|
| 282 |
+
|
| 283 |
+
rsaryev pr
|
| 284 |
+
|
| 285 |
+
QuintinShaw pr
|
| 286 |
+
|
| 287 |
+
R-Taneja pr
|
| 288 |
+
|
| 289 |
+
zaycruz pr
|
| 290 |
+
|
| 291 |
+
mteam88 pr
|
| 292 |
+
|
| 293 |
+
christianbasch pr
|
| 294 |
+
|
| 295 |
+
cpacker pr
|
| 296 |
+
|
| 297 |
+
rgarcia pr
|
| 298 |
+
|
| 299 |
+
renaudhartert-db pr
|
| 300 |
+
|
| 301 |
+
HyeokjaeLee pr
|
| 302 |
+
|
| 303 |
+
arajkumar pr
|
| 304 |
+
|
| 305 |
+
brianstanley pr
|
| 306 |
+
|
| 307 |
+
scruffymongrel pr
|
| 308 |
+
|
| 309 |
+
SI-RUI-ZHANG pr
|
| 310 |
+
|
| 311 |
+
sunnyyoung pr
|
| 312 |
+
|
| 313 |
+
muyiyr pr
|
| 314 |
+
|
| 315 |
+
hi-neason pr
|
| 316 |
+
|
| 317 |
+
acmerfight pr
|
| 318 |
+
|
| 319 |
+
tizmagik pr
|
| 320 |
+
|
| 321 |
+
jingtao-wisdomgraph pr
|
| 322 |
+
|
| 323 |
+
XRX193 pr
|
| 324 |
+
|
| 325 |
+
autopeasant pr
|
| 326 |
+
|
| 327 |
+
Snail-Turbo pr
|
| 328 |
+
|
| 329 |
+
futile pr
|
| 330 |
+
|
| 331 |
+
arasovic pr
|
| 332 |
+
|
| 333 |
+
xXJSONDeruloXx pr
|
| 334 |
+
|
| 335 |
+
Marvae pr
|
| 336 |
+
|
| 337 |
+
skkdevcraft pr
|
| 338 |
+
|
| 339 |
+
PierrunoYT pr
|
| 340 |
+
|
| 341 |
+
zhichli pr
|
| 342 |
+
|
| 343 |
+
wesleyzhangwq pr
|
| 344 |
+
|
| 345 |
+
dgokeeffe pr
|
| 346 |
+
|
| 347 |
+
vipentti pr
|
| 348 |
+
|
| 349 |
+
midastruth pr
|
| 350 |
+
|
| 351 |
+
Maximo-Guk pr
|
| 352 |
+
|
| 353 |
+
bigoldcat123 pr
|
| 354 |
+
|
| 355 |
+
johnatbasicas pr
|
| 356 |
+
|
| 357 |
+
powerfooI pr
|
| 358 |
+
|
| 359 |
+
yearth pr
|
| 360 |
+
|
| 361 |
+
pablasso pr
|
| 362 |
+
|
| 363 |
+
bilby91 pr
|
| 364 |
+
|
| 365 |
+
giannisCKS pr
|
| 366 |
+
|
| 367 |
+
Panoplos pr
|
| 368 |
+
|
| 369 |
+
haoyongchun1125-maker pr
|
| 370 |
+
|
| 371 |
+
gwokhou pr
|
| 372 |
+
|
| 373 |
+
gaoyk19 pr
|
| 374 |
+
|
| 375 |
+
cad0p pr
|
| 376 |
+
|
| 377 |
+
Jaaneek pr
|
| 378 |
+
|
| 379 |
+
CaiJichang212 pr
|
| 380 |
+
|
| 381 |
+
Mallikarjun-0 pr
|
| 382 |
+
|
| 383 |
+
wutongyuonce pr
|
| 384 |
+
|
| 385 |
+
Terminator666666 pr
|
| 386 |
+
|
| 387 |
+
y-nk pr
|
| 388 |
+
|
| 389 |
+
edverma pr
|
| 390 |
+
|
| 391 |
+
Charlie0113-T pr
|
| 392 |
+
|
| 393 |
+
RedBeard0531 pr
|
| 394 |
+
|
| 395 |
+
metaist pr
|
.github/ISSUE_TEMPLATE/bug.yml
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: Bug Report
|
| 2 |
+
description: Report something that's broken
|
| 3 |
+
labels: ["bug"]
|
| 4 |
+
body:
|
| 5 |
+
- type: markdown
|
| 6 |
+
attributes:
|
| 7 |
+
value: |
|
| 8 |
+
**Before you start:** Read [CONTRIBUTING.md](https://github.com/earendil-works/pi/blob/main/CONTRIBUTING.md).
|
| 9 |
+
|
| 10 |
+
New issues from new contributors are auto-closed by default. Maintainers review auto-closed issues daily. Issues that do not meet the quality bar in [CONTRIBUTING.md](https://github.com/earendil-works/pi/blob/main/CONTRIBUTING.md) will not be reopened or receive a reply.
|
| 11 |
+
|
| 12 |
+
Keep this short. If it doesn't fit on one screen, it's too long. Write in your own voice.
|
| 13 |
+
|
| 14 |
+
**Important:** before reporting an issue in core, please validate first with `pi -ne` that this is not caused by an extension you loaded.
|
| 15 |
+
|
| 16 |
+
- type: textarea
|
| 17 |
+
id: description
|
| 18 |
+
attributes:
|
| 19 |
+
label: What happened?
|
| 20 |
+
description: Be specific. Include error messages if any.
|
| 21 |
+
validations:
|
| 22 |
+
required: true
|
| 23 |
+
|
| 24 |
+
- type: textarea
|
| 25 |
+
id: repro
|
| 26 |
+
attributes:
|
| 27 |
+
label: Steps to reproduce
|
| 28 |
+
description: Minimal steps to trigger the bug.
|
| 29 |
+
validations:
|
| 30 |
+
required: true
|
| 31 |
+
|
| 32 |
+
- type: textarea
|
| 33 |
+
id: expected
|
| 34 |
+
attributes:
|
| 35 |
+
label: Expected behavior
|
| 36 |
+
validations:
|
| 37 |
+
required: false
|
| 38 |
+
|
| 39 |
+
- type: input
|
| 40 |
+
id: version
|
| 41 |
+
attributes:
|
| 42 |
+
label: Version
|
| 43 |
+
description: e.g. 0.49.0
|
| 44 |
+
validations:
|
| 45 |
+
required: false
|
.github/ISSUE_TEMPLATE/config.yml
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
blank_issues_enabled: false
|
| 2 |
+
contact_links:
|
| 3 |
+
- name: Questions
|
| 4 |
+
url: https://discord.com/invite/3cU7Bz4UPx
|
| 5 |
+
about: Ask questions on Discord instead of opening an issue
|
.github/ISSUE_TEMPLATE/contribution.yml
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: Contribution Proposal
|
| 2 |
+
description: Propose a change or feature (required for new contributors before submitting a PR)
|
| 3 |
+
labels: []
|
| 4 |
+
body:
|
| 5 |
+
- type: markdown
|
| 6 |
+
attributes:
|
| 7 |
+
value: |
|
| 8 |
+
**Before you start:** Read [CONTRIBUTING.md](https://github.com/earendil-works/pi/blob/main/CONTRIBUTING.md).
|
| 9 |
+
|
| 10 |
+
New issues from new contributors are auto-closed by default. Maintainers review auto-closed issues daily. Issues that do not meet the quality bar in [CONTRIBUTING.md](https://github.com/earendil-works/pi/blob/main/CONTRIBUTING.md) will not be reopened or receive a reply.
|
| 11 |
+
|
| 12 |
+
Keep this short. If it doesn't fit on one screen, it's too long. Write in your own voice.
|
| 13 |
+
|
| 14 |
+
- type: textarea
|
| 15 |
+
id: what
|
| 16 |
+
attributes:
|
| 17 |
+
label: What do you want to change?
|
| 18 |
+
description: Be specific and concise.
|
| 19 |
+
validations:
|
| 20 |
+
required: true
|
| 21 |
+
|
| 22 |
+
- type: textarea
|
| 23 |
+
id: why
|
| 24 |
+
attributes:
|
| 25 |
+
label: Why?
|
| 26 |
+
description: What problem does this solve?
|
| 27 |
+
validations:
|
| 28 |
+
required: true
|
| 29 |
+
|
| 30 |
+
- type: textarea
|
| 31 |
+
id: how
|
| 32 |
+
attributes:
|
| 33 |
+
label: How? (optional)
|
| 34 |
+
description: Brief technical approach if you have one in mind.
|
| 35 |
+
validations:
|
| 36 |
+
required: false
|
.github/ISSUE_TEMPLATE/package-report.yml
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: Package Report
|
| 2 |
+
description: Report a problematic Pi package listed on pi.dev
|
| 3 |
+
labels: ["package-report"]
|
| 4 |
+
body:
|
| 5 |
+
- type: markdown
|
| 6 |
+
attributes:
|
| 7 |
+
value: |
|
| 8 |
+
Use this form to report a package listed on pi.dev. For Pi core bugs, use the bug report template instead.
|
| 9 |
+
|
| 10 |
+
New issues from new contributors are auto-closed by default. Maintainers review auto-closed issues daily. Issues that do not meet the quality bar in [CONTRIBUTING.md](https://github.com/earendil-works/pi/blob/main/CONTRIBUTING.md) will not be reopened or receive a reply.
|
| 11 |
+
|
| 12 |
+
Keep this short. If it doesn't fit on one screen, it's too long. Write in your own voice.
|
| 13 |
+
|
| 14 |
+
- type: input
|
| 15 |
+
id: package-name
|
| 16 |
+
attributes:
|
| 17 |
+
label: Package name
|
| 18 |
+
description: The npm package name from pi.dev.
|
| 19 |
+
placeholder: "@scope/package"
|
| 20 |
+
validations:
|
| 21 |
+
required: true
|
| 22 |
+
|
| 23 |
+
- type: input
|
| 24 |
+
id: package-version
|
| 25 |
+
attributes:
|
| 26 |
+
label: Version
|
| 27 |
+
description: The package version shown on pi.dev.
|
| 28 |
+
placeholder: "0.1.0"
|
| 29 |
+
validations:
|
| 30 |
+
required: false
|
| 31 |
+
|
| 32 |
+
- type: dropdown
|
| 33 |
+
id: report-type
|
| 34 |
+
attributes:
|
| 35 |
+
label: What are you reporting?
|
| 36 |
+
options:
|
| 37 |
+
- Malicious or unsafe behavior
|
| 38 |
+
- Impersonation
|
| 39 |
+
- Trademark / TOS Violations
|
| 40 |
+
validations:
|
| 41 |
+
required: true
|
| 42 |
+
|
| 43 |
+
- type: textarea
|
| 44 |
+
id: details
|
| 45 |
+
attributes:
|
| 46 |
+
label: Details
|
| 47 |
+
description: Describe the concern and include links, logs, or screenshots if helpful.
|
| 48 |
+
validations:
|
| 49 |
+
required: true
|
.github/workflows/approve-contributor.yml
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: Approve Contributor
|
| 2 |
+
|
| 3 |
+
on:
|
| 4 |
+
issue_comment:
|
| 5 |
+
types: [created]
|
| 6 |
+
|
| 7 |
+
jobs:
|
| 8 |
+
approve:
|
| 9 |
+
runs-on: ubuntu-latest
|
| 10 |
+
permissions:
|
| 11 |
+
contents: write
|
| 12 |
+
issues: write
|
| 13 |
+
pull-requests: write
|
| 14 |
+
steps:
|
| 15 |
+
- name: Checkout
|
| 16 |
+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
| 17 |
+
with:
|
| 18 |
+
ref: ${{ github.event.repository.default_branch }}
|
| 19 |
+
|
| 20 |
+
- name: Update contributor approval
|
| 21 |
+
id: update
|
| 22 |
+
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
| 23 |
+
with:
|
| 24 |
+
script: |
|
| 25 |
+
const fs = require('fs');
|
| 26 |
+
|
| 27 |
+
const APPROVED_FILE = '.github/APPROVED_CONTRIBUTORS';
|
| 28 |
+
const VALID_CAPABILITIES = new Set(['issue', 'pr']);
|
| 29 |
+
const issueAuthor = context.payload.issue.user.login;
|
| 30 |
+
const commenter = context.payload.comment.user.login;
|
| 31 |
+
const commentBody = (context.payload.comment.body || '').trim();
|
| 32 |
+
|
| 33 |
+
const approvalAtStartPattern = /^[\s.]*(?:@[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\s*,\s*|[.:]\s*|\s+))*(lgtmi|lgtm)(?=$|[\s]|[^\p{L}\p{N}_\s])/iu;
|
| 34 |
+
const approvalAtEndPattern = /(?:^|[\s.])(lgtmi|lgtm)\s*(?:[^\p{L}\p{N}_\s])?\s*$/iu;
|
| 35 |
+
const approvalMatch = commentBody.match(approvalAtStartPattern) ?? commentBody.match(approvalAtEndPattern);
|
| 36 |
+
|
| 37 |
+
if (!approvalMatch) {
|
| 38 |
+
console.log('Comment does not start or end with lgtm or lgtmi');
|
| 39 |
+
core.setOutput('status', 'skipped');
|
| 40 |
+
return;
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
const targetCapability = approvalMatch[1].toLowerCase() === 'lgtmi' ? 'issue' : 'pr';
|
| 44 |
+
|
| 45 |
+
try {
|
| 46 |
+
const { data: permissionLevel } = await github.rest.repos.getCollaboratorPermissionLevel({
|
| 47 |
+
owner: context.repo.owner,
|
| 48 |
+
repo: context.repo.repo,
|
| 49 |
+
username: commenter,
|
| 50 |
+
});
|
| 51 |
+
|
| 52 |
+
if (!['admin', 'maintain', 'write'].includes(permissionLevel.permission)) {
|
| 53 |
+
console.log(`${commenter} does not have write access`);
|
| 54 |
+
core.setOutput('status', 'skipped');
|
| 55 |
+
return;
|
| 56 |
+
}
|
| 57 |
+
} catch {
|
| 58 |
+
console.log(`${commenter} does not have collaborator access`);
|
| 59 |
+
core.setOutput('status', 'skipped');
|
| 60 |
+
return;
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
function parseMentionedUsers(body) {
|
| 64 |
+
const users = [];
|
| 65 |
+
const seenUsers = new Set();
|
| 66 |
+
const mentionPattern = /(^|[^A-Za-z0-9_])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?)(?![A-Za-z0-9-]|\/)/g;
|
| 67 |
+
|
| 68 |
+
for (const match of body.matchAll(mentionPattern)) {
|
| 69 |
+
const username = match[2];
|
| 70 |
+
const normalizedUser = username.toLowerCase();
|
| 71 |
+
if (seenUsers.has(normalizedUser)) {
|
| 72 |
+
continue;
|
| 73 |
+
}
|
| 74 |
+
seenUsers.add(normalizedUser);
|
| 75 |
+
users.push(username);
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
return users;
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
function parseApprovedUsers(content) {
|
| 82 |
+
const lines = content.split('\n');
|
| 83 |
+
const entries = [];
|
| 84 |
+
const users = new Map();
|
| 85 |
+
|
| 86 |
+
for (const line of lines) {
|
| 87 |
+
const trimmed = line.trim();
|
| 88 |
+
if (!trimmed || trimmed.startsWith('#')) {
|
| 89 |
+
entries.push({ type: 'other', line });
|
| 90 |
+
continue;
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
const parts = trimmed.split(/\s+/);
|
| 94 |
+
if (parts.length !== 2) {
|
| 95 |
+
console.log(`Skipping malformed line: ${line}`);
|
| 96 |
+
entries.push({ type: 'other', line });
|
| 97 |
+
continue;
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
const [username, capability] = parts;
|
| 101 |
+
const normalizedCapability = capability.toLowerCase();
|
| 102 |
+
if (!VALID_CAPABILITIES.has(normalizedCapability)) {
|
| 103 |
+
console.log(`Skipping line with invalid capability: ${line}`);
|
| 104 |
+
entries.push({ type: 'other', line });
|
| 105 |
+
continue;
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
const normalizedUser = username.toLowerCase();
|
| 109 |
+
const entry = { type: 'user', username, normalizedUser, capability: normalizedCapability };
|
| 110 |
+
entries.push(entry);
|
| 111 |
+
users.set(normalizedUser, entry);
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
return { entries, users };
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
function stringifyApprovedUsers(entries) {
|
| 118 |
+
const normalizedEntries = [...entries];
|
| 119 |
+
|
| 120 |
+
while (normalizedEntries.length > 0) {
|
| 121 |
+
const lastEntry = normalizedEntries[normalizedEntries.length - 1];
|
| 122 |
+
if (lastEntry.type !== 'other' || lastEntry.line.trim() !== '') {
|
| 123 |
+
break;
|
| 124 |
+
}
|
| 125 |
+
normalizedEntries.pop();
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
return `${normalizedEntries
|
| 129 |
+
.map((entry) => (entry.type === 'user' ? `${entry.username} ${entry.capability}` : entry.line))
|
| 130 |
+
.join('\n')}\n`;
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
const content = fs.readFileSync(APPROVED_FILE, 'utf8');
|
| 134 |
+
const { entries, users } = parseApprovedUsers(content);
|
| 135 |
+
const mentionedUsers = parseMentionedUsers(commentBody);
|
| 136 |
+
const approvalTargets = mentionedUsers.length > 0 ? mentionedUsers : [issueAuthor];
|
| 137 |
+
const changedTargets = [];
|
| 138 |
+
const alreadyTargets = [];
|
| 139 |
+
|
| 140 |
+
for (const username of approvalTargets) {
|
| 141 |
+
const normalizedUser = username.toLowerCase();
|
| 142 |
+
const existingEntry = users.get(normalizedUser);
|
| 143 |
+
const existingCapability = existingEntry?.capability ?? null;
|
| 144 |
+
|
| 145 |
+
if (existingCapability === 'pr' || existingCapability === targetCapability) {
|
| 146 |
+
alreadyTargets.push(existingEntry?.username ?? username);
|
| 147 |
+
console.log(`${username} is already approved for ${existingCapability}`);
|
| 148 |
+
continue;
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
if (existingEntry) {
|
| 152 |
+
existingEntry.capability = targetCapability;
|
| 153 |
+
changedTargets.push(existingEntry.username);
|
| 154 |
+
} else {
|
| 155 |
+
const entry = { type: 'user', username, normalizedUser, capability: targetCapability };
|
| 156 |
+
entries.push(entry);
|
| 157 |
+
users.set(normalizedUser, entry);
|
| 158 |
+
changedTargets.push(username);
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
console.log(`Set ${username} capability to ${targetCapability}`);
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
core.setOutput('capability', targetCapability);
|
| 165 |
+
core.setOutput('changed_targets', JSON.stringify(changedTargets));
|
| 166 |
+
core.setOutput('already_targets', JSON.stringify(alreadyTargets));
|
| 167 |
+
|
| 168 |
+
if (changedTargets.length === 0) {
|
| 169 |
+
core.setOutput('status', 'already');
|
| 170 |
+
return;
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
fs.writeFileSync(APPROVED_FILE, stringifyApprovedUsers(entries));
|
| 174 |
+
core.setOutput('status', 'changed');
|
| 175 |
+
|
| 176 |
+
- name: Commit and push
|
| 177 |
+
if: steps.update.outputs.status == 'changed'
|
| 178 |
+
run: |
|
| 179 |
+
git config user.name "github-actions[bot]"
|
| 180 |
+
git config user.email "github-actions[bot]@users.noreply.github.com"
|
| 181 |
+
git add .github/APPROVED_CONTRIBUTORS
|
| 182 |
+
git diff --staged --quiet || git commit -m "chore: approve contributors from issue #${{ github.event.issue.number }}"
|
| 183 |
+
git push
|
| 184 |
+
|
| 185 |
+
- name: Comment on issue
|
| 186 |
+
if: steps.update.outputs.status == 'changed' || steps.update.outputs.status == 'already'
|
| 187 |
+
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
| 188 |
+
env:
|
| 189 |
+
CAPABILITY: ${{ steps.update.outputs.capability }}
|
| 190 |
+
CHANGED_TARGETS: ${{ steps.update.outputs.changed_targets }}
|
| 191 |
+
ALREADY_TARGETS: ${{ steps.update.outputs.already_targets }}
|
| 192 |
+
with:
|
| 193 |
+
script: |
|
| 194 |
+
const capability = process.env.CAPABILITY;
|
| 195 |
+
const changedTargets = JSON.parse(process.env.CHANGED_TARGETS || '[]');
|
| 196 |
+
const alreadyTargets = JSON.parse(process.env.ALREADY_TARGETS || '[]');
|
| 197 |
+
const defaultBranch = context.payload.repository.default_branch;
|
| 198 |
+
const formatTargets = (targets) => targets.map((target) => `@${target}`).join(', ');
|
| 199 |
+
const bodyLines = [];
|
| 200 |
+
|
| 201 |
+
if (changedTargets.length > 0) {
|
| 202 |
+
if (capability === 'issue') {
|
| 203 |
+
bodyLines.push(`${formatTargets(changedTargets)} approved for issues. Future issues will not be auto-closed. PRs still require \`lgtm\` at the start of a maintainer reply (optionally after one or more \`@username\` mentions) or at the end.`);
|
| 204 |
+
} else {
|
| 205 |
+
bodyLines.push(`${formatTargets(changedTargets)} approved for issues and PRs. Future issues and PRs will not be auto-closed.`);
|
| 206 |
+
}
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
if (alreadyTargets.length > 0) {
|
| 210 |
+
const verb = alreadyTargets.length === 1 ? 'is' : 'are';
|
| 211 |
+
bodyLines.push(`${formatTargets(alreadyTargets)} ${verb} already approved.`);
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
bodyLines.push('', `See [CONTRIBUTING.md](https://github.com/${context.repo.owner}/${context.repo.repo}/blob/${defaultBranch}/CONTRIBUTING.md).`);
|
| 215 |
+
const body = bodyLines.join('\n');
|
| 216 |
+
|
| 217 |
+
await github.rest.issues.createComment({
|
| 218 |
+
owner: context.repo.owner,
|
| 219 |
+
repo: context.repo.repo,
|
| 220 |
+
issue_number: context.issue.number,
|
| 221 |
+
body,
|
| 222 |
+
});
|
| 223 |
+
|
.github/workflows/build-binaries.yml
ADDED
|
@@ -0,0 +1,444 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: Build Binaries
|
| 2 |
+
|
| 3 |
+
on:
|
| 4 |
+
push:
|
| 5 |
+
tags:
|
| 6 |
+
- 'v*'
|
| 7 |
+
workflow_dispatch:
|
| 8 |
+
inputs:
|
| 9 |
+
tag:
|
| 10 |
+
description: 'Tag to build (e.g., v0.12.0)'
|
| 11 |
+
required: true
|
| 12 |
+
type: string
|
| 13 |
+
source_ref:
|
| 14 |
+
description: 'Source ref to build/publish (defaults to tag; use only for release recovery)'
|
| 15 |
+
required: false
|
| 16 |
+
type: string
|
| 17 |
+
|
| 18 |
+
permissions: {}
|
| 19 |
+
|
| 20 |
+
concurrency:
|
| 21 |
+
group: build-binaries-${{ github.event.inputs.tag || github.ref_name }}
|
| 22 |
+
cancel-in-progress: false
|
| 23 |
+
|
| 24 |
+
jobs:
|
| 25 |
+
# Keep the public GitHub Release publication last. Binary assets are staged in
|
| 26 |
+
# a draft release first; cleanup removes the draft if later publishing fails.
|
| 27 |
+
build:
|
| 28 |
+
runs-on: ubuntu-latest
|
| 29 |
+
permissions:
|
| 30 |
+
contents: read
|
| 31 |
+
env:
|
| 32 |
+
RELEASE_TAG: ${{ github.event.inputs.tag || github.ref_name }}
|
| 33 |
+
SOURCE_REF: ${{ github.event.inputs.source_ref || github.event.inputs.tag || github.ref_name }}
|
| 34 |
+
steps:
|
| 35 |
+
- name: Checkout
|
| 36 |
+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
| 37 |
+
with:
|
| 38 |
+
ref: ${{ env.SOURCE_REF }}
|
| 39 |
+
persist-credentials: false
|
| 40 |
+
|
| 41 |
+
- name: Setup Bun
|
| 42 |
+
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
| 43 |
+
with:
|
| 44 |
+
bun-version: 1.3.14
|
| 45 |
+
|
| 46 |
+
- name: Setup Node.js
|
| 47 |
+
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
| 48 |
+
with:
|
| 49 |
+
node-version: '22'
|
| 50 |
+
registry-url: 'https://registry.npmjs.org'
|
| 51 |
+
|
| 52 |
+
- name: Hydrate release model data
|
| 53 |
+
run: npm run hydrate:model-data
|
| 54 |
+
|
| 55 |
+
- name: Create source archive
|
| 56 |
+
run: |
|
| 57 |
+
set -euo pipefail
|
| 58 |
+
|
| 59 |
+
VERSION="${RELEASE_TAG#v}"
|
| 60 |
+
mkdir -p release-assets
|
| 61 |
+
./scripts/create-source-archive.sh \
|
| 62 |
+
--version "${VERSION}" \
|
| 63 |
+
--ref HEAD \
|
| 64 |
+
--out "release-assets/pi-${VERSION}-source.tar.gz"
|
| 65 |
+
|
| 66 |
+
- name: Build binaries from source archive
|
| 67 |
+
run: |
|
| 68 |
+
set -euo pipefail
|
| 69 |
+
|
| 70 |
+
VERSION="${RELEASE_TAG#v}"
|
| 71 |
+
build_root="$(mktemp -d)"
|
| 72 |
+
trap 'rm -rf "${build_root}"' EXIT
|
| 73 |
+
tar -xzf "release-assets/pi-${VERSION}-source.tar.gz" -C "${build_root}"
|
| 74 |
+
"${build_root}/pi-${VERSION}/scripts/build-binaries.sh" \
|
| 75 |
+
--offline-model-data \
|
| 76 |
+
--out "${GITHUB_WORKSPACE}/packages/coding-agent/binaries"
|
| 77 |
+
|
| 78 |
+
- name: Prepare GitHub release payload
|
| 79 |
+
run: |
|
| 80 |
+
set -euo pipefail
|
| 81 |
+
|
| 82 |
+
mkdir -p release-assets
|
| 83 |
+
|
| 84 |
+
VERSION="${RELEASE_TAG}"
|
| 85 |
+
VERSION="${VERSION#v}" # Remove 'v' prefix
|
| 86 |
+
node scripts/release-notes.mjs extract --version "${VERSION}" --tag "${RELEASE_TAG}" --out release-assets/RELEASE_NOTES.md
|
| 87 |
+
node scripts/generate-coding-agent-install-lock.mjs --check
|
| 88 |
+
cp packages/coding-agent/install-lock/package.json release-assets/pi-coding-agent-install-package.json
|
| 89 |
+
cp packages/coding-agent/install-lock/package-lock.json release-assets/pi-coding-agent-install-package-lock.json
|
| 90 |
+
|
| 91 |
+
cd packages/coding-agent/binaries
|
| 92 |
+
|
| 93 |
+
binary_assets=(
|
| 94 |
+
pi-darwin-arm64.tar.gz
|
| 95 |
+
pi-darwin-x64.tar.gz
|
| 96 |
+
pi-linux-x64.tar.gz
|
| 97 |
+
pi-linux-arm64.tar.gz
|
| 98 |
+
pi-windows-x64.zip
|
| 99 |
+
pi-windows-arm64.zip
|
| 100 |
+
)
|
| 101 |
+
|
| 102 |
+
for asset in "${binary_assets[@]}"; do
|
| 103 |
+
test -f "${asset}"
|
| 104 |
+
done
|
| 105 |
+
|
| 106 |
+
cp "${binary_assets[@]}" "${GITHUB_WORKSPACE}/release-assets/"
|
| 107 |
+
|
| 108 |
+
cd "${GITHUB_WORKSPACE}/release-assets"
|
| 109 |
+
source_asset="pi-${VERSION}-source.tar.gz"
|
| 110 |
+
release_assets=(
|
| 111 |
+
"${source_asset}"
|
| 112 |
+
pi-darwin-arm64.tar.gz
|
| 113 |
+
pi-darwin-x64.tar.gz
|
| 114 |
+
pi-linux-x64.tar.gz
|
| 115 |
+
pi-linux-arm64.tar.gz
|
| 116 |
+
pi-windows-x64.zip
|
| 117 |
+
pi-windows-arm64.zip
|
| 118 |
+
pi-coding-agent-install-package.json
|
| 119 |
+
pi-coding-agent-install-package-lock.json
|
| 120 |
+
)
|
| 121 |
+
sha256sum "${release_assets[@]}" > SHA256SUMS
|
| 122 |
+
|
| 123 |
+
- name: Upload GitHub release payload
|
| 124 |
+
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
| 125 |
+
with:
|
| 126 |
+
name: release-assets-${{ env.RELEASE_TAG }}
|
| 127 |
+
path: release-assets/*
|
| 128 |
+
if-no-files-found: error
|
| 129 |
+
retention-days: 14
|
| 130 |
+
|
| 131 |
+
smoke-test-binaries:
|
| 132 |
+
runs-on: ${{ matrix.runner }}
|
| 133 |
+
needs: build
|
| 134 |
+
strategy:
|
| 135 |
+
fail-fast: false
|
| 136 |
+
matrix:
|
| 137 |
+
runner:
|
| 138 |
+
- ubuntu-latest
|
| 139 |
+
- macos-latest
|
| 140 |
+
- windows-latest
|
| 141 |
+
permissions:
|
| 142 |
+
actions: read
|
| 143 |
+
env:
|
| 144 |
+
RELEASE_TAG: ${{ github.event.inputs.tag || github.ref_name }}
|
| 145 |
+
steps:
|
| 146 |
+
- name: Download binary archives
|
| 147 |
+
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
| 148 |
+
with:
|
| 149 |
+
name: release-assets-${{ env.RELEASE_TAG }}
|
| 150 |
+
path: release-assets
|
| 151 |
+
|
| 152 |
+
- name: Extract current-platform binary
|
| 153 |
+
id: binary
|
| 154 |
+
shell: bash
|
| 155 |
+
run: |
|
| 156 |
+
set -euo pipefail
|
| 157 |
+
|
| 158 |
+
runtime_platform="$(node -p '`${process.platform}-${process.arch}`')"
|
| 159 |
+
case "${runtime_platform}" in
|
| 160 |
+
darwin-arm64|darwin-x64|linux-arm64|linux-x64)
|
| 161 |
+
platform="${runtime_platform}"
|
| 162 |
+
archive="release-assets/pi-${platform}.tar.gz"
|
| 163 |
+
root="extracted/pi"
|
| 164 |
+
binary="${root}/pi"
|
| 165 |
+
;;
|
| 166 |
+
win32-arm64)
|
| 167 |
+
platform="windows-arm64"
|
| 168 |
+
archive="release-assets/pi-${platform}.zip"
|
| 169 |
+
root="extracted"
|
| 170 |
+
binary="${root}/pi.exe"
|
| 171 |
+
;;
|
| 172 |
+
win32-x64)
|
| 173 |
+
platform="windows-x64"
|
| 174 |
+
archive="release-assets/pi-${platform}.zip"
|
| 175 |
+
root="extracted"
|
| 176 |
+
binary="${root}/pi.exe"
|
| 177 |
+
;;
|
| 178 |
+
*)
|
| 179 |
+
echo "::error::Unsupported runner platform ${runtime_platform}"
|
| 180 |
+
exit 1
|
| 181 |
+
;;
|
| 182 |
+
esac
|
| 183 |
+
|
| 184 |
+
mkdir -p extracted
|
| 185 |
+
if [[ "${archive}" == *.zip ]]; then
|
| 186 |
+
export ARCHIVE_PATH="$(cygpath -w "${archive}")"
|
| 187 |
+
export DESTINATION_PATH="$(cygpath -w extracted)"
|
| 188 |
+
powershell.exe -NoProfile -NonInteractive -Command \
|
| 189 |
+
'Expand-Archive -LiteralPath $env:ARCHIVE_PATH -DestinationPath $env:DESTINATION_PATH -Force'
|
| 190 |
+
else
|
| 191 |
+
tar -xf "${archive}" -C extracted
|
| 192 |
+
fi
|
| 193 |
+
echo "root=${root}" >> "${GITHUB_OUTPUT}"
|
| 194 |
+
echo "binary=${binary}" >> "${GITHUB_OUTPUT}"
|
| 195 |
+
|
| 196 |
+
- name: Smoke-test binary
|
| 197 |
+
shell: bash
|
| 198 |
+
run: |
|
| 199 |
+
"${{ steps.binary.outputs.binary }}" --help
|
| 200 |
+
"${{ steps.binary.outputs.binary }}" --version
|
| 201 |
+
|
| 202 |
+
stage-github-release:
|
| 203 |
+
runs-on: ubuntu-latest
|
| 204 |
+
needs:
|
| 205 |
+
- build
|
| 206 |
+
- smoke-test-binaries
|
| 207 |
+
permissions:
|
| 208 |
+
actions: read
|
| 209 |
+
contents: write
|
| 210 |
+
env:
|
| 211 |
+
GH_REPO: ${{ github.repository }}
|
| 212 |
+
RELEASE_TAG: ${{ github.event.inputs.tag || github.ref_name }}
|
| 213 |
+
steps:
|
| 214 |
+
- name: Download GitHub release payload
|
| 215 |
+
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
| 216 |
+
with:
|
| 217 |
+
name: release-assets-${{ env.RELEASE_TAG }}
|
| 218 |
+
path: release-assets
|
| 219 |
+
|
| 220 |
+
- name: Validate GitHub release payload
|
| 221 |
+
run: |
|
| 222 |
+
set -euo pipefail
|
| 223 |
+
|
| 224 |
+
cd release-assets
|
| 225 |
+
|
| 226 |
+
VERSION="${RELEASE_TAG#v}"
|
| 227 |
+
source_asset="pi-${VERSION}-source.tar.gz"
|
| 228 |
+
expected_assets=(
|
| 229 |
+
"${source_asset}"
|
| 230 |
+
pi-darwin-arm64.tar.gz
|
| 231 |
+
pi-darwin-x64.tar.gz
|
| 232 |
+
pi-linux-x64.tar.gz
|
| 233 |
+
pi-linux-arm64.tar.gz
|
| 234 |
+
pi-windows-x64.zip
|
| 235 |
+
pi-windows-arm64.zip
|
| 236 |
+
pi-coding-agent-install-package.json
|
| 237 |
+
pi-coding-agent-install-package-lock.json
|
| 238 |
+
SHA256SUMS
|
| 239 |
+
RELEASE_NOTES.md
|
| 240 |
+
)
|
| 241 |
+
|
| 242 |
+
for asset in "${expected_assets[@]}"; do
|
| 243 |
+
test -f "${asset}"
|
| 244 |
+
done
|
| 245 |
+
|
| 246 |
+
sha256sum -c SHA256SUMS
|
| 247 |
+
|
| 248 |
+
- name: Create draft GitHub Release and upload assets
|
| 249 |
+
env:
|
| 250 |
+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
| 251 |
+
run: |
|
| 252 |
+
set -euo pipefail
|
| 253 |
+
|
| 254 |
+
cd release-assets
|
| 255 |
+
|
| 256 |
+
VERSION="${RELEASE_TAG#v}"
|
| 257 |
+
source_asset="pi-${VERSION}-source.tar.gz"
|
| 258 |
+
release_assets=(
|
| 259 |
+
"${source_asset}"
|
| 260 |
+
pi-darwin-arm64.tar.gz
|
| 261 |
+
pi-darwin-x64.tar.gz
|
| 262 |
+
pi-linux-x64.tar.gz
|
| 263 |
+
pi-linux-arm64.tar.gz
|
| 264 |
+
pi-windows-x64.zip
|
| 265 |
+
pi-windows-arm64.zip
|
| 266 |
+
pi-coding-agent-install-package.json
|
| 267 |
+
pi-coding-agent-install-package-lock.json
|
| 268 |
+
SHA256SUMS
|
| 269 |
+
)
|
| 270 |
+
|
| 271 |
+
existing_release="$(gh release view "${RELEASE_TAG}" --json isDraft --jq .isDraft 2>/dev/null || true)"
|
| 272 |
+
if [[ "${existing_release}" == "false" ]]; then
|
| 273 |
+
echo "::error::GitHub Release ${RELEASE_TAG} is already published. Refusing to mutate a public release."
|
| 274 |
+
exit 1
|
| 275 |
+
fi
|
| 276 |
+
if [[ "${existing_release}" == "true" ]]; then
|
| 277 |
+
gh release delete "${RELEASE_TAG}" --yes
|
| 278 |
+
fi
|
| 279 |
+
|
| 280 |
+
gh release create "${RELEASE_TAG}" \
|
| 281 |
+
--verify-tag \
|
| 282 |
+
--draft \
|
| 283 |
+
--title "${RELEASE_TAG}" \
|
| 284 |
+
--notes-file RELEASE_NOTES.md \
|
| 285 |
+
"${release_assets[@]}"
|
| 286 |
+
|
| 287 |
+
expected_asset_names="$(printf '%s\n' "${release_assets[@]}" | sort)"
|
| 288 |
+
actual_asset_names="$(gh release view "${RELEASE_TAG}" --json assets --jq '.assets[].name' | sort)"
|
| 289 |
+
|
| 290 |
+
if [[ "${actual_asset_names}" != "${expected_asset_names}" ]]; then
|
| 291 |
+
echo "::error::Draft GitHub Release asset set does not match expected files."
|
| 292 |
+
diff -u <(printf '%s\n' "${expected_asset_names}") <(printf '%s\n' "${actual_asset_names}") || true
|
| 293 |
+
exit 1
|
| 294 |
+
fi
|
| 295 |
+
|
| 296 |
+
publish-npm:
|
| 297 |
+
runs-on: ubuntu-latest
|
| 298 |
+
needs: stage-github-release
|
| 299 |
+
environment: npm-publish
|
| 300 |
+
permissions:
|
| 301 |
+
contents: read
|
| 302 |
+
id-token: write
|
| 303 |
+
env:
|
| 304 |
+
RELEASE_TAG: ${{ github.event.inputs.tag || github.ref_name }}
|
| 305 |
+
SOURCE_REF: ${{ github.event.inputs.source_ref || github.event.inputs.tag || github.ref_name }}
|
| 306 |
+
steps:
|
| 307 |
+
- name: Checkout
|
| 308 |
+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
| 309 |
+
with:
|
| 310 |
+
ref: ${{ env.SOURCE_REF }}
|
| 311 |
+
persist-credentials: false
|
| 312 |
+
|
| 313 |
+
- name: Setup Node.js
|
| 314 |
+
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
| 315 |
+
with:
|
| 316 |
+
node-version: '22'
|
| 317 |
+
registry-url: 'https://registry.npmjs.org'
|
| 318 |
+
cache: npm
|
| 319 |
+
|
| 320 |
+
- name: Install system dependencies
|
| 321 |
+
run: |
|
| 322 |
+
sudo apt-get update
|
| 323 |
+
sudo apt-get install -y libcairo2-dev libpango1.0-dev libjpeg-dev libgif-dev librsvg2-dev fd-find ripgrep
|
| 324 |
+
sudo ln -s $(which fdfind) /usr/local/bin/fd
|
| 325 |
+
|
| 326 |
+
- name: Install dependencies
|
| 327 |
+
run: npm ci --ignore-scripts
|
| 328 |
+
|
| 329 |
+
- name: Build
|
| 330 |
+
run: npm run build
|
| 331 |
+
|
| 332 |
+
- name: Check
|
| 333 |
+
run: npm run check
|
| 334 |
+
|
| 335 |
+
- name: Test
|
| 336 |
+
run: npm test
|
| 337 |
+
|
| 338 |
+
- name: Smoke-test packed npm consumer
|
| 339 |
+
run: npm run check:package-install
|
| 340 |
+
|
| 341 |
+
- name: Upgrade npm for trusted publishing
|
| 342 |
+
run: |
|
| 343 |
+
npm install -g npm@11.16.0 --ignore-scripts
|
| 344 |
+
npm --version
|
| 345 |
+
|
| 346 |
+
- name: Publish npm packages
|
| 347 |
+
run: node scripts/publish.mjs
|
| 348 |
+
|
| 349 |
+
announce-pi-dev-release:
|
| 350 |
+
runs-on: ubuntu-latest
|
| 351 |
+
needs: publish-npm
|
| 352 |
+
environment: pi-model-upload
|
| 353 |
+
concurrency:
|
| 354 |
+
group: announce-pi-dev-release
|
| 355 |
+
cancel-in-progress: false
|
| 356 |
+
permissions:
|
| 357 |
+
contents: read
|
| 358 |
+
env:
|
| 359 |
+
RELEASE_TAG: ${{ github.event.inputs.tag || github.ref_name }}
|
| 360 |
+
SOURCE_REF: ${{ github.event.inputs.source_ref || github.event.inputs.tag || github.ref_name }}
|
| 361 |
+
AWS_ACCESS_KEY_ID: ${{ secrets.PI_ARTIFACTS_R2_ACCESS_KEY_ID }}
|
| 362 |
+
AWS_SECRET_ACCESS_KEY: ${{ secrets.PI_ARTIFACTS_R2_SECRET_ACCESS_KEY }}
|
| 363 |
+
AWS_DEFAULT_REGION: auto
|
| 364 |
+
AWS_EC2_METADATA_DISABLED: 'true'
|
| 365 |
+
R2_ENDPOINT: https://67c0d357268b0fca6e0b465bb9d01b84.r2.cloudflarestorage.com
|
| 366 |
+
steps:
|
| 367 |
+
- name: Checkout
|
| 368 |
+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
| 369 |
+
with:
|
| 370 |
+
ref: ${{ env.SOURCE_REF }}
|
| 371 |
+
persist-credentials: false
|
| 372 |
+
|
| 373 |
+
- name: Setup Node.js
|
| 374 |
+
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
| 375 |
+
with:
|
| 376 |
+
node-version: '22'
|
| 377 |
+
|
| 378 |
+
- name: Verify AWS CLI
|
| 379 |
+
run: aws --version
|
| 380 |
+
|
| 381 |
+
- name: Announce verified release on pi.dev
|
| 382 |
+
run: |
|
| 383 |
+
node scripts/publish-release-announcement.mjs \
|
| 384 |
+
--version "${RELEASE_TAG#v}" \
|
| 385 |
+
--bucket pi-artifacts \
|
| 386 |
+
--endpoint "$R2_ENDPOINT" \
|
| 387 |
+
--installer-package-json packages/coding-agent/install-lock/package.json \
|
| 388 |
+
--installer-package-lock packages/coding-agent/install-lock/package-lock.json
|
| 389 |
+
|
| 390 |
+
publish-github-release:
|
| 391 |
+
runs-on: ubuntu-latest
|
| 392 |
+
needs:
|
| 393 |
+
- stage-github-release
|
| 394 |
+
- publish-npm
|
| 395 |
+
- announce-pi-dev-release
|
| 396 |
+
permissions:
|
| 397 |
+
contents: write
|
| 398 |
+
env:
|
| 399 |
+
GH_REPO: ${{ github.repository }}
|
| 400 |
+
RELEASE_TAG: ${{ github.event.inputs.tag || github.ref_name }}
|
| 401 |
+
steps:
|
| 402 |
+
- name: Publish staged GitHub Release
|
| 403 |
+
env:
|
| 404 |
+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
| 405 |
+
run: |
|
| 406 |
+
set -euo pipefail
|
| 407 |
+
|
| 408 |
+
existing_release="$(gh release view "${RELEASE_TAG}" --json isDraft --jq .isDraft 2>/dev/null || true)"
|
| 409 |
+
if [[ "${existing_release}" == "" ]]; then
|
| 410 |
+
echo "::error::Draft GitHub Release ${RELEASE_TAG} does not exist."
|
| 411 |
+
exit 1
|
| 412 |
+
fi
|
| 413 |
+
if [[ "${existing_release}" == "false" ]]; then
|
| 414 |
+
echo "::error::GitHub Release ${RELEASE_TAG} is already published."
|
| 415 |
+
exit 1
|
| 416 |
+
fi
|
| 417 |
+
|
| 418 |
+
gh release edit "${RELEASE_TAG}" --draft=false
|
| 419 |
+
|
| 420 |
+
cleanup-draft-github-release:
|
| 421 |
+
runs-on: ubuntu-latest
|
| 422 |
+
needs:
|
| 423 |
+
- build
|
| 424 |
+
- stage-github-release
|
| 425 |
+
- publish-npm
|
| 426 |
+
- announce-pi-dev-release
|
| 427 |
+
- publish-github-release
|
| 428 |
+
if: ${{ always() && needs.stage-github-release.result != 'skipped' && (needs.stage-github-release.result != 'success' || needs.publish-npm.result != 'success' || needs.announce-pi-dev-release.result != 'success' || needs.publish-github-release.result != 'success') }}
|
| 429 |
+
permissions:
|
| 430 |
+
contents: write
|
| 431 |
+
env:
|
| 432 |
+
GH_REPO: ${{ github.repository }}
|
| 433 |
+
RELEASE_TAG: ${{ github.event.inputs.tag || github.ref_name }}
|
| 434 |
+
steps:
|
| 435 |
+
- name: Delete draft GitHub Release after failure
|
| 436 |
+
env:
|
| 437 |
+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
| 438 |
+
run: |
|
| 439 |
+
set -euo pipefail
|
| 440 |
+
|
| 441 |
+
existing_release="$(gh release view "${RELEASE_TAG}" --json isDraft --jq .isDraft 2>/dev/null || true)"
|
| 442 |
+
if [[ "${existing_release}" == "true" ]]; then
|
| 443 |
+
gh release delete "${RELEASE_TAG}" --yes
|
| 444 |
+
fi
|
.github/workflows/ci.yml
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: CI
|
| 2 |
+
|
| 3 |
+
on:
|
| 4 |
+
push:
|
| 5 |
+
branches: [main]
|
| 6 |
+
pull_request:
|
| 7 |
+
branches: [main]
|
| 8 |
+
|
| 9 |
+
concurrency:
|
| 10 |
+
group: ci-${{ github.ref }}
|
| 11 |
+
cancel-in-progress: true
|
| 12 |
+
|
| 13 |
+
jobs:
|
| 14 |
+
build-check-test:
|
| 15 |
+
runs-on: ubuntu-latest
|
| 16 |
+
steps:
|
| 17 |
+
- name: Checkout
|
| 18 |
+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
| 19 |
+
|
| 20 |
+
- name: Setup Node.js
|
| 21 |
+
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
| 22 |
+
with:
|
| 23 |
+
node-version: 22
|
| 24 |
+
cache: npm
|
| 25 |
+
|
| 26 |
+
- name: Install system dependencies
|
| 27 |
+
run: |
|
| 28 |
+
sudo apt-get update
|
| 29 |
+
sudo apt-get install -y libcairo2-dev libpango1.0-dev libjpeg-dev libgif-dev librsvg2-dev fd-find ripgrep
|
| 30 |
+
sudo ln -s $(which fdfind) /usr/local/bin/fd
|
| 31 |
+
|
| 32 |
+
- name: Install dependencies
|
| 33 |
+
run: npm ci --ignore-scripts
|
| 34 |
+
|
| 35 |
+
- name: Build
|
| 36 |
+
run: npm run build
|
| 37 |
+
|
| 38 |
+
- name: Check
|
| 39 |
+
run: npm run check
|
| 40 |
+
|
| 41 |
+
- name: Test
|
| 42 |
+
run: npm test
|
.github/workflows/issue-analysis.yml
ADDED
|
@@ -0,0 +1,688 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Runs the repo's /is prompt against an issue when the `pi-analyze` label is added
|
| 2 |
+
# or when a staff member comments `@issuron analyze` anywhere on an issue.
|
| 3 |
+
#
|
| 4 |
+
# Comment triggers can include one `#run-on-*` tag anywhere in the text:
|
| 5 |
+
# @issuron analyze #run-on-linux -> ubuntu-latest (default)
|
| 6 |
+
# @issuron analyze #run-on-windows -> windows-latest
|
| 7 |
+
# @issuron analyze #run-on-mac -> macos-latest
|
| 8 |
+
#
|
| 9 |
+
# Label triggers always run on the default Linux runner. Runner selection is
|
| 10 |
+
# intentionally restricted to hardcoded aliases in the authorization step.
|
| 11 |
+
#
|
| 12 |
+
# Setup required before this works:
|
| 13 |
+
# 1. Create a `pi-analyze` GitHub environment on the repo and add a
|
| 14 |
+
# `PI_AUTH_JSON` secret containing the contents of a pi auth.json
|
| 15 |
+
# (~/.pi/agent/auth.json).
|
| 16 |
+
# 2. Create the `pi-analyze` label.
|
| 17 |
+
# 3. Add a repository secret `EARENDIL_ORG_READ_TOKEN` with permission to
|
| 18 |
+
# read `earendil-works` org membership. The authorization job uses it to
|
| 19 |
+
# verify that the label actor is an active member of `earendil-works/staff`.
|
| 20 |
+
# 4. Add an environment secret `PI_GIST_TOKEN` on `pi-analyze` with gist
|
| 21 |
+
# creation permission. The analysis job uses it to upload the exported
|
| 22 |
+
# session gist.
|
| 23 |
+
# 5. Add an environment secret `PI_AUTH_UPDATE_TOKEN` on `pi-analyze` with
|
| 24 |
+
# permission to update this repo's environment secrets. The analysis job
|
| 25 |
+
# uses it to write back refreshed `PI_AUTH_JSON` contents. Fine-grained
|
| 26 |
+
# tokens need repository permission `Environments: read and write` on this
|
| 27 |
+
# repo; classic tokens need the `repo` scope. The `Secrets` permission is
|
| 28 |
+
# not enough: it only covers repository-level Actions secrets, while every
|
| 29 |
+
# `/repos/{owner}/{repo}/environments/{env}/secrets/*` endpoint is governed
|
| 30 |
+
# by `Environments`. Without it `gh secret set` fails with "Resource not
|
| 31 |
+
# accessible by personal access token" and the stored credential goes stale
|
| 32 |
+
# until its refresh token is rejected.
|
| 33 |
+
#
|
| 34 |
+
# `PI_AUTH_JSON` must come from a login dedicated to this workflow. OpenAI Codex
|
| 35 |
+
# rotates refresh tokens on every refresh, so sharing one auth.json with a
|
| 36 |
+
# developer machine invalidates whichever copy refreshes second, and analysis
|
| 37 |
+
# then fails with "OAuth refresh failed for openai-codex".
|
| 38 |
+
#
|
| 39 |
+
# The selected runner must have Node.js support plus gh, fd, and ripgrep. GitHub
|
| 40 |
+
# hosted runners are bootstrapped below; future self-hosted aliases should have
|
| 41 |
+
# those dependencies preinstalled or installable by the setup steps.
|
| 42 |
+
#
|
| 43 |
+
# The session runs in a high-entropy checkout directory so the recorded cwd is
|
| 44 |
+
# a unique string. Import the session into a local checkout with the
|
| 45 |
+
# /ir extension command (.pi/extensions/import-repro.ts):
|
| 46 |
+
# pi "/ir <gist-id | gist-url | pi.dev/session URL>"
|
| 47 |
+
|
| 48 |
+
name: Issue Analysis
|
| 49 |
+
|
| 50 |
+
on:
|
| 51 |
+
issues:
|
| 52 |
+
types: [labeled]
|
| 53 |
+
issue_comment:
|
| 54 |
+
types: [created]
|
| 55 |
+
|
| 56 |
+
permissions:
|
| 57 |
+
contents: read
|
| 58 |
+
issues: write
|
| 59 |
+
|
| 60 |
+
concurrency:
|
| 61 |
+
group: issue-analysis-${{ github.event.issue.number }}
|
| 62 |
+
cancel-in-progress: false
|
| 63 |
+
|
| 64 |
+
jobs:
|
| 65 |
+
authorize:
|
| 66 |
+
runs-on: ubuntu-latest
|
| 67 |
+
outputs:
|
| 68 |
+
should_run: ${{ steps.verify.outputs.should_run }}
|
| 69 |
+
extra_instructions: ${{ steps.verify.outputs.extra_instructions }}
|
| 70 |
+
runs_on: ${{ steps.verify.outputs.runs_on }}
|
| 71 |
+
runner_os: ${{ steps.verify.outputs.runner_os }}
|
| 72 |
+
runner_profile: ${{ steps.verify.outputs.runner_profile }}
|
| 73 |
+
steps:
|
| 74 |
+
- name: Verify sender permission
|
| 75 |
+
id: verify
|
| 76 |
+
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
| 77 |
+
env:
|
| 78 |
+
ORG_READ_TOKEN: ${{ secrets.EARENDIL_ORG_READ_TOKEN }}
|
| 79 |
+
with:
|
| 80 |
+
script: |
|
| 81 |
+
const ANALYZE_LABEL = 'pi-analyze';
|
| 82 |
+
const TRIGGER_RE = /@issuron\s+analyze\b/i;
|
| 83 |
+
const RUN_ON_TAG_RE = /#run-on-([a-z0-9][a-z0-9_-]*)\b/gi;
|
| 84 |
+
const RUNNER_PROFILES = {
|
| 85 |
+
linux: { runsOn: 'ubuntu-latest', os: 'linux' },
|
| 86 |
+
windows: { runsOn: 'windows-latest', os: 'windows' },
|
| 87 |
+
mac: { runsOn: 'macos-latest', os: 'macos' },
|
| 88 |
+
};
|
| 89 |
+
const RUN_ON_ALIASES = {
|
| 90 |
+
linux: 'linux',
|
| 91 |
+
ubuntu: 'linux',
|
| 92 |
+
'ubuntu-latest': 'linux',
|
| 93 |
+
windows: 'windows',
|
| 94 |
+
win: 'windows',
|
| 95 |
+
'windows-latest': 'windows',
|
| 96 |
+
mac: 'mac',
|
| 97 |
+
macos: 'mac',
|
| 98 |
+
darwin: 'mac',
|
| 99 |
+
'macos-latest': 'mac',
|
| 100 |
+
};
|
| 101 |
+
|
| 102 |
+
const username = context.payload.sender.login;
|
| 103 |
+
let extraInstructions = '';
|
| 104 |
+
let runnerProfile = 'linux';
|
| 105 |
+
|
| 106 |
+
core.setOutput('should_run', 'false');
|
| 107 |
+
core.setOutput('extra_instructions', '');
|
| 108 |
+
core.setOutput('runs_on', JSON.stringify(RUNNER_PROFILES.linux.runsOn));
|
| 109 |
+
core.setOutput('runner_os', RUNNER_PROFILES.linux.os);
|
| 110 |
+
core.setOutput('runner_profile', runnerProfile);
|
| 111 |
+
|
| 112 |
+
if (context.eventName === 'issues') {
|
| 113 |
+
if (context.payload.action !== 'labeled' || context.payload.label?.name !== ANALYZE_LABEL) {
|
| 114 |
+
console.log('Not a pi-analyze label event');
|
| 115 |
+
return;
|
| 116 |
+
}
|
| 117 |
+
} else if (context.eventName === 'issue_comment') {
|
| 118 |
+
if (context.payload.issue.pull_request) {
|
| 119 |
+
console.log('Ignoring pull request comment');
|
| 120 |
+
return;
|
| 121 |
+
}
|
| 122 |
+
const body = context.payload.comment.body || '';
|
| 123 |
+
if (!TRIGGER_RE.test(body)) {
|
| 124 |
+
console.log('Comment does not contain an @issuron analyze trigger');
|
| 125 |
+
return;
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
const resolvedProfiles = new Set();
|
| 129 |
+
const unknownTags = [];
|
| 130 |
+
for (const match of body.matchAll(RUN_ON_TAG_RE)) {
|
| 131 |
+
const tag = match[1].toLowerCase();
|
| 132 |
+
const resolved = RUN_ON_ALIASES[tag];
|
| 133 |
+
if (!resolved) {
|
| 134 |
+
unknownTags.push(tag);
|
| 135 |
+
} else {
|
| 136 |
+
resolvedProfiles.add(resolved);
|
| 137 |
+
}
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
if (unknownTags.length > 0) {
|
| 141 |
+
core.setFailed(`Unknown issue analysis runner tag(s): ${unknownTags.map((tag) => `#run-on-${tag}`).join(', ')}`);
|
| 142 |
+
return;
|
| 143 |
+
}
|
| 144 |
+
if (resolvedProfiles.size > 1) {
|
| 145 |
+
core.setFailed(
|
| 146 |
+
`Conflicting issue analysis runner tags: ${Array.from(resolvedProfiles)
|
| 147 |
+
.map((profile) => `#run-on-${profile}`)
|
| 148 |
+
.join(', ')}`,
|
| 149 |
+
);
|
| 150 |
+
return;
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
runnerProfile = Array.from(resolvedProfiles)[0] || 'linux';
|
| 154 |
+
extraInstructions = body.replace(TRIGGER_RE, ' ').replace(RUN_ON_TAG_RE, ' ').trim();
|
| 155 |
+
} else {
|
| 156 |
+
console.log(`Unsupported event: ${context.eventName}`);
|
| 157 |
+
return;
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
async function removeTriggerLabel() {
|
| 161 |
+
if (context.eventName !== 'issues') return;
|
| 162 |
+
try {
|
| 163 |
+
await github.rest.issues.removeLabel({
|
| 164 |
+
owner: context.repo.owner,
|
| 165 |
+
repo: context.repo.repo,
|
| 166 |
+
issue_number: context.issue.number,
|
| 167 |
+
name: ANALYZE_LABEL,
|
| 168 |
+
});
|
| 169 |
+
} catch (error) {
|
| 170 |
+
if (error.status !== 404) throw error;
|
| 171 |
+
}
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
if (!process.env.ORG_READ_TOKEN) {
|
| 175 |
+
await removeTriggerLabel();
|
| 176 |
+
core.setFailed('EARENDIL_ORG_READ_TOKEN is not configured; refusing to run issue analysis.');
|
| 177 |
+
return;
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
try {
|
| 181 |
+
const response = await fetch(
|
| 182 |
+
`https://api.github.com/orgs/earendil-works/teams/staff/memberships/${encodeURIComponent(username)}`,
|
| 183 |
+
{
|
| 184 |
+
headers: {
|
| 185 |
+
Accept: 'application/vnd.github+json',
|
| 186 |
+
Authorization: `Bearer ${process.env.ORG_READ_TOKEN}`,
|
| 187 |
+
'X-GitHub-Api-Version': '2022-11-28',
|
| 188 |
+
},
|
| 189 |
+
},
|
| 190 |
+
);
|
| 191 |
+
|
| 192 |
+
if (response.status === 404) {
|
| 193 |
+
await removeTriggerLabel();
|
| 194 |
+
core.setFailed(`@${username} is not an active earendil-works/staff member.`);
|
| 195 |
+
return;
|
| 196 |
+
}
|
| 197 |
+
|
| 198 |
+
if (!response.ok) {
|
| 199 |
+
const body = await response.text();
|
| 200 |
+
await removeTriggerLabel();
|
| 201 |
+
core.setFailed(
|
| 202 |
+
`Could not verify earendil-works/staff membership for @${username}: HTTP ${response.status} ${body}`,
|
| 203 |
+
);
|
| 204 |
+
return;
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
const membership = await response.json();
|
| 208 |
+
if (membership.state !== 'active') {
|
| 209 |
+
await removeTriggerLabel();
|
| 210 |
+
core.setFailed(`@${username} is not an active earendil-works/staff member.`);
|
| 211 |
+
return;
|
| 212 |
+
}
|
| 213 |
+
console.log(`earendil-works/staff membership for @${username}: ${membership.state}`);
|
| 214 |
+
} catch (error) {
|
| 215 |
+
await removeTriggerLabel();
|
| 216 |
+
core.setFailed(
|
| 217 |
+
`Could not verify earendil-works/staff membership for @${username}: ${
|
| 218 |
+
error instanceof Error ? error.message : String(error)
|
| 219 |
+
}`,
|
| 220 |
+
);
|
| 221 |
+
return;
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
const { data } = await github.rest.repos.getCollaboratorPermissionLevel({
|
| 225 |
+
owner: context.repo.owner,
|
| 226 |
+
repo: context.repo.repo,
|
| 227 |
+
username,
|
| 228 |
+
});
|
| 229 |
+
if (!['admin', 'write'].includes(data.permission)) {
|
| 230 |
+
await removeTriggerLabel();
|
| 231 |
+
core.setFailed(
|
| 232 |
+
`@${username} has '${data.permission}' permission; write or admin is required to trigger issue analysis.`,
|
| 233 |
+
);
|
| 234 |
+
return;
|
| 235 |
+
}
|
| 236 |
+
|
| 237 |
+
if (context.eventName === 'issue_comment') {
|
| 238 |
+
await github.rest.issues.addLabels({
|
| 239 |
+
owner: context.repo.owner,
|
| 240 |
+
repo: context.repo.repo,
|
| 241 |
+
issue_number: context.issue.number,
|
| 242 |
+
labels: [ANALYZE_LABEL],
|
| 243 |
+
});
|
| 244 |
+
}
|
| 245 |
+
|
| 246 |
+
const profile = RUNNER_PROFILES[runnerProfile];
|
| 247 |
+
console.log(`Selected issue analysis runner profile: ${runnerProfile} (${JSON.stringify(profile.runsOn)})`);
|
| 248 |
+
core.setOutput('should_run', 'true');
|
| 249 |
+
core.setOutput('extra_instructions', extraInstructions);
|
| 250 |
+
core.setOutput('runs_on', JSON.stringify(profile.runsOn));
|
| 251 |
+
core.setOutput('runner_os', profile.os);
|
| 252 |
+
core.setOutput('runner_profile', runnerProfile);
|
| 253 |
+
|
| 254 |
+
analyze:
|
| 255 |
+
needs: authorize
|
| 256 |
+
if: needs.authorize.outputs.should_run == 'true'
|
| 257 |
+
runs-on: ${{ fromJSON(needs.authorize.outputs.runs_on) }}
|
| 258 |
+
environment: pi-analyze
|
| 259 |
+
timeout-minutes: 45
|
| 260 |
+
concurrency:
|
| 261 |
+
group: issue-analysis-pi-auth
|
| 262 |
+
cancel-in-progress: false
|
| 263 |
+
env:
|
| 264 |
+
ISSUE_ANALYSIS_MODEL: openai-codex/gpt-5.5
|
| 265 |
+
ISSUE_ANALYSIS_THINKING: high
|
| 266 |
+
steps:
|
| 267 |
+
- name: Create high-entropy working directory name
|
| 268 |
+
id: workdir
|
| 269 |
+
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
| 270 |
+
with:
|
| 271 |
+
script: |
|
| 272 |
+
const crypto = require('crypto');
|
| 273 |
+
core.setOutput('name', `pi-ci-${crypto.randomBytes(16).toString('hex')}`);
|
| 274 |
+
|
| 275 |
+
- name: Checkout
|
| 276 |
+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
| 277 |
+
with:
|
| 278 |
+
path: ${{ steps.workdir.outputs.name }}
|
| 279 |
+
|
| 280 |
+
- name: Setup Node.js
|
| 281 |
+
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
| 282 |
+
with:
|
| 283 |
+
node-version: 22
|
| 284 |
+
cache: npm
|
| 285 |
+
cache-dependency-path: ${{ steps.workdir.outputs.name }}/package-lock.json
|
| 286 |
+
|
| 287 |
+
- name: Install system dependencies (Linux)
|
| 288 |
+
if: needs.authorize.outputs.runner_os == 'linux'
|
| 289 |
+
run: |
|
| 290 |
+
sudo apt-get update
|
| 291 |
+
sudo apt-get install -y fd-find ripgrep
|
| 292 |
+
sudo ln -sf "$(which fdfind)" /usr/local/bin/fd
|
| 293 |
+
|
| 294 |
+
- name: Install system dependencies (macOS)
|
| 295 |
+
if: needs.authorize.outputs.runner_os == 'macos'
|
| 296 |
+
run: |
|
| 297 |
+
if ! command -v fd >/dev/null 2>&1; then
|
| 298 |
+
brew install fd
|
| 299 |
+
fi
|
| 300 |
+
if ! command -v rg >/dev/null 2>&1; then
|
| 301 |
+
brew install ripgrep
|
| 302 |
+
fi
|
| 303 |
+
|
| 304 |
+
- name: Install system dependencies (Windows)
|
| 305 |
+
if: needs.authorize.outputs.runner_os == 'windows'
|
| 306 |
+
shell: pwsh
|
| 307 |
+
run: |
|
| 308 |
+
$packages = @()
|
| 309 |
+
if (-not (Get-Command fd -ErrorAction SilentlyContinue)) {
|
| 310 |
+
$packages += "fd"
|
| 311 |
+
}
|
| 312 |
+
if (-not (Get-Command rg -ErrorAction SilentlyContinue)) {
|
| 313 |
+
$packages += "ripgrep"
|
| 314 |
+
}
|
| 315 |
+
if ($packages.Count -gt 0) {
|
| 316 |
+
if (-not (Get-Command choco -ErrorAction SilentlyContinue)) {
|
| 317 |
+
throw "fd and ripgrep must be installed on Windows runners, or Chocolatey must be available to install them."
|
| 318 |
+
}
|
| 319 |
+
choco install $packages -y --no-progress
|
| 320 |
+
}
|
| 321 |
+
fd --version
|
| 322 |
+
rg --version
|
| 323 |
+
|
| 324 |
+
- name: Install dependencies
|
| 325 |
+
working-directory: ${{ steps.workdir.outputs.name }}
|
| 326 |
+
run: npm ci --ignore-scripts
|
| 327 |
+
|
| 328 |
+
- name: Build
|
| 329 |
+
working-directory: ${{ steps.workdir.outputs.name }}
|
| 330 |
+
run: npm run build
|
| 331 |
+
|
| 332 |
+
- name: Write auth.json
|
| 333 |
+
id: write_auth
|
| 334 |
+
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
| 335 |
+
env:
|
| 336 |
+
PI_AUTH_JSON: ${{ secrets.PI_AUTH_JSON }}
|
| 337 |
+
with:
|
| 338 |
+
script: |
|
| 339 |
+
const fs = require('fs');
|
| 340 |
+
const path = require('path');
|
| 341 |
+
|
| 342 |
+
const authJson = process.env.PI_AUTH_JSON;
|
| 343 |
+
if (!authJson) {
|
| 344 |
+
throw new Error('PI_AUTH_JSON secret is not configured for the pi-analyze environment');
|
| 345 |
+
}
|
| 346 |
+
|
| 347 |
+
const agentDir = path.join(process.env.RUNNER_TEMP, 'pi-agent');
|
| 348 |
+
fs.mkdirSync(agentDir, { recursive: true });
|
| 349 |
+
const authPath = path.join(agentDir, 'auth.json');
|
| 350 |
+
fs.writeFileSync(authPath, authJson, { mode: 0o600 });
|
| 351 |
+
if (process.platform !== 'win32') {
|
| 352 |
+
fs.chmodSync(authPath, 0o600);
|
| 353 |
+
}
|
| 354 |
+
|
| 355 |
+
- name: Run pi /is
|
| 356 |
+
id: run_pi
|
| 357 |
+
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
| 358 |
+
env:
|
| 359 |
+
PI_CODING_AGENT_DIR: ${{ runner.temp }}/pi-agent
|
| 360 |
+
GH_TOKEN: ${{ github.token }}
|
| 361 |
+
ISSUE_URL: ${{ github.event.issue.html_url }}
|
| 362 |
+
EXTRA_INSTRUCTIONS: ${{ needs.authorize.outputs.extra_instructions }}
|
| 363 |
+
WORKDIR: ${{ steps.workdir.outputs.name }}
|
| 364 |
+
with:
|
| 365 |
+
script: |
|
| 366 |
+
const fs = require('fs');
|
| 367 |
+
const path = require('path');
|
| 368 |
+
const { spawn } = require('child_process');
|
| 369 |
+
|
| 370 |
+
const workdir = path.join(process.env.GITHUB_WORKSPACE, process.env.WORKDIR);
|
| 371 |
+
const outDir = path.join(process.env.RUNNER_TEMP, 'pi-out');
|
| 372 |
+
const sessionDir = path.join(outDir, 'session');
|
| 373 |
+
fs.mkdirSync(sessionDir, { recursive: true });
|
| 374 |
+
|
| 375 |
+
let prompt = `/is ${process.env.ISSUE_URL}`;
|
| 376 |
+
if (process.env.EXTRA_INSTRUCTIONS) {
|
| 377 |
+
prompt += '\n\nAdditional instructions from @issuron analyze comment:\n';
|
| 378 |
+
prompt += process.env.EXTRA_INSTRUCTIONS;
|
| 379 |
+
}
|
| 380 |
+
|
| 381 |
+
const outputPath = path.join(outDir, 'output.md');
|
| 382 |
+
const output = fs.createWriteStream(outputPath);
|
| 383 |
+
const args = [
|
| 384 |
+
'packages/coding-agent/src/cli.ts',
|
| 385 |
+
'-p',
|
| 386 |
+
'--approve',
|
| 387 |
+
'--session-dir',
|
| 388 |
+
sessionDir,
|
| 389 |
+
'--model',
|
| 390 |
+
process.env.ISSUE_ANALYSIS_MODEL,
|
| 391 |
+
'--thinking',
|
| 392 |
+
process.env.ISSUE_ANALYSIS_THINKING,
|
| 393 |
+
prompt,
|
| 394 |
+
];
|
| 395 |
+
|
| 396 |
+
const exitCode = await new Promise((resolve, reject) => {
|
| 397 |
+
const child = spawn('node', args, {
|
| 398 |
+
cwd: workdir,
|
| 399 |
+
env: process.env,
|
| 400 |
+
stdio: ['ignore', 'pipe', 'pipe'],
|
| 401 |
+
});
|
| 402 |
+
child.stdout.on('data', (chunk) => {
|
| 403 |
+
process.stdout.write(chunk);
|
| 404 |
+
output.write(chunk);
|
| 405 |
+
});
|
| 406 |
+
child.stderr.on('data', (chunk) => {
|
| 407 |
+
process.stderr.write(chunk);
|
| 408 |
+
});
|
| 409 |
+
child.on('error', reject);
|
| 410 |
+
child.on('close', resolve);
|
| 411 |
+
});
|
| 412 |
+
await new Promise((resolve) => output.end(resolve));
|
| 413 |
+
|
| 414 |
+
if (exitCode !== 0) {
|
| 415 |
+
throw new Error(`pi /is failed with exit code ${exitCode}`);
|
| 416 |
+
}
|
| 417 |
+
|
| 418 |
+
- name: Persist refreshed auth.json
|
| 419 |
+
if: always() && steps.write_auth.outcome == 'success'
|
| 420 |
+
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
| 421 |
+
env:
|
| 422 |
+
GH_TOKEN: ${{ secrets.PI_AUTH_UPDATE_TOKEN }}
|
| 423 |
+
PI_AUTH_JSON: ${{ secrets.PI_AUTH_JSON }}
|
| 424 |
+
PI_CODING_AGENT_DIR: ${{ runner.temp }}/pi-agent
|
| 425 |
+
with:
|
| 426 |
+
script: |
|
| 427 |
+
const fs = require('fs');
|
| 428 |
+
const path = require('path');
|
| 429 |
+
const { spawn } = require('child_process');
|
| 430 |
+
|
| 431 |
+
if (!process.env.GH_TOKEN) {
|
| 432 |
+
throw new Error('PI_AUTH_UPDATE_TOKEN is not configured for the pi-analyze environment');
|
| 433 |
+
}
|
| 434 |
+
|
| 435 |
+
const authPath = path.join(process.env.PI_CODING_AGENT_DIR, 'auth.json');
|
| 436 |
+
if (!fs.existsSync(authPath)) {
|
| 437 |
+
core.warning('auth.json was not created; skipping auth persistence');
|
| 438 |
+
return;
|
| 439 |
+
}
|
| 440 |
+
|
| 441 |
+
const authJson = fs.readFileSync(authPath, 'utf8');
|
| 442 |
+
let parsed;
|
| 443 |
+
try {
|
| 444 |
+
parsed = JSON.parse(authJson);
|
| 445 |
+
} catch (error) {
|
| 446 |
+
throw new Error(`Refusing to persist malformed auth.json: ${error instanceof Error ? error.message : String(error)}`);
|
| 447 |
+
}
|
| 448 |
+
|
| 449 |
+
const codexAuth = parsed['openai-codex'];
|
| 450 |
+
if (codexAuth?.type !== 'oauth' || typeof codexAuth.refresh !== 'string' || codexAuth.refresh.length === 0) {
|
| 451 |
+
throw new Error('Refusing to persist auth.json without openai-codex OAuth refresh credentials');
|
| 452 |
+
}
|
| 453 |
+
|
| 454 |
+
if (authJson === process.env.PI_AUTH_JSON) {
|
| 455 |
+
console.log('auth.json is unchanged; skipping secret update');
|
| 456 |
+
return;
|
| 457 |
+
}
|
| 458 |
+
|
| 459 |
+
await new Promise((resolve, reject) => {
|
| 460 |
+
const child = spawn(
|
| 461 |
+
'gh',
|
| 462 |
+
['secret', 'set', 'PI_AUTH_JSON', '--env', 'pi-analyze', '--repo', process.env.GITHUB_REPOSITORY],
|
| 463 |
+
{ env: process.env, stdio: ['pipe', 'inherit', 'pipe'] },
|
| 464 |
+
);
|
| 465 |
+
const stderrChunks = [];
|
| 466 |
+
child.stderr.on('data', (chunk) => {
|
| 467 |
+
stderrChunks.push(chunk);
|
| 468 |
+
process.stderr.write(chunk);
|
| 469 |
+
});
|
| 470 |
+
child.on('error', reject);
|
| 471 |
+
child.on('close', (code) => {
|
| 472 |
+
if (code === 0) {
|
| 473 |
+
resolve();
|
| 474 |
+
return;
|
| 475 |
+
}
|
| 476 |
+
const details = Buffer.concat(stderrChunks).toString().trim();
|
| 477 |
+
reject(
|
| 478 |
+
new Error(
|
| 479 |
+
`gh secret set failed with exit code ${code}${details ? `: ${details}` : ''}. ` +
|
| 480 |
+
'PI_AUTH_UPDATE_TOKEN needs repository permission "Environments: read and write" on this repo.',
|
| 481 |
+
),
|
| 482 |
+
);
|
| 483 |
+
});
|
| 484 |
+
child.stdin.end(authJson);
|
| 485 |
+
});
|
| 486 |
+
|
| 487 |
+
- name: Export session files
|
| 488 |
+
id: export_session_files
|
| 489 |
+
if: always()
|
| 490 |
+
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
| 491 |
+
env:
|
| 492 |
+
PI_CODING_AGENT_DIR: ${{ runner.temp }}/pi-agent
|
| 493 |
+
WORKDIR: ${{ steps.workdir.outputs.name }}
|
| 494 |
+
with:
|
| 495 |
+
script: |
|
| 496 |
+
const fs = require('fs');
|
| 497 |
+
const path = require('path');
|
| 498 |
+
const { spawn } = require('child_process');
|
| 499 |
+
|
| 500 |
+
function findFirstJsonl(dir) {
|
| 501 |
+
if (!fs.existsSync(dir)) return undefined;
|
| 502 |
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
| 503 |
+
for (const entry of entries) {
|
| 504 |
+
const entryPath = path.join(dir, entry.name);
|
| 505 |
+
if (entry.isDirectory()) {
|
| 506 |
+
const nested = findFirstJsonl(entryPath);
|
| 507 |
+
if (nested) return nested;
|
| 508 |
+
} else if (entry.isFile() && entry.name.endsWith('.jsonl')) {
|
| 509 |
+
return entryPath;
|
| 510 |
+
}
|
| 511 |
+
}
|
| 512 |
+
return undefined;
|
| 513 |
+
}
|
| 514 |
+
|
| 515 |
+
const outDir = path.join(process.env.RUNNER_TEMP, 'pi-out');
|
| 516 |
+
const sessionFile = findFirstJsonl(path.join(outDir, 'session'));
|
| 517 |
+
if (!sessionFile) {
|
| 518 |
+
throw new Error('No session jsonl file found');
|
| 519 |
+
}
|
| 520 |
+
|
| 521 |
+
const sessionJsonl = path.join(outDir, 'session.jsonl');
|
| 522 |
+
const sessionHtml = path.join(outDir, 'session.html');
|
| 523 |
+
fs.copyFileSync(sessionFile, sessionJsonl);
|
| 524 |
+
|
| 525 |
+
const workdir = path.join(process.env.GITHUB_WORKSPACE, process.env.WORKDIR);
|
| 526 |
+
const exitCode = await new Promise((resolve, reject) => {
|
| 527 |
+
const child = spawn(
|
| 528 |
+
'node',
|
| 529 |
+
['packages/coding-agent/src/cli.ts', '--no-extensions', '--export', sessionJsonl, sessionHtml],
|
| 530 |
+
{ cwd: workdir, env: process.env, stdio: 'inherit' },
|
| 531 |
+
);
|
| 532 |
+
child.on('error', reject);
|
| 533 |
+
child.on('close', resolve);
|
| 534 |
+
});
|
| 535 |
+
if (exitCode !== 0) {
|
| 536 |
+
throw new Error(`session export failed with exit code ${exitCode}`);
|
| 537 |
+
}
|
| 538 |
+
|
| 539 |
+
- name: Upload session gist
|
| 540 |
+
id: gist
|
| 541 |
+
if: always() && steps.export_session_files.outcome == 'success'
|
| 542 |
+
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
| 543 |
+
env:
|
| 544 |
+
PI_GIST_TOKEN: ${{ secrets.PI_GIST_TOKEN }}
|
| 545 |
+
with:
|
| 546 |
+
github-token: ${{ secrets.PI_GIST_TOKEN }}
|
| 547 |
+
script: |
|
| 548 |
+
const fs = require('fs');
|
| 549 |
+
const path = require('path');
|
| 550 |
+
|
| 551 |
+
if (!process.env.PI_GIST_TOKEN) {
|
| 552 |
+
throw new Error('PI_GIST_TOKEN is not configured');
|
| 553 |
+
}
|
| 554 |
+
|
| 555 |
+
const outDir = path.join(process.env.RUNNER_TEMP, 'pi-out');
|
| 556 |
+
const files = {};
|
| 557 |
+
for (const filename of ['session.html', 'session.jsonl']) {
|
| 558 |
+
files[filename] = { content: fs.readFileSync(path.join(outDir, filename), 'utf8') };
|
| 559 |
+
}
|
| 560 |
+
|
| 561 |
+
const response = await github.rest.gists.create({
|
| 562 |
+
public: false,
|
| 563 |
+
files,
|
| 564 |
+
});
|
| 565 |
+
const gistUrl = response.data.html_url;
|
| 566 |
+
const gistId = response.data.id;
|
| 567 |
+
core.setOutput('url', gistUrl);
|
| 568 |
+
core.setOutput('id', gistId);
|
| 569 |
+
core.setOutput('share_url', `https://pi.dev/session/#${gistId}`);
|
| 570 |
+
|
| 571 |
+
- name: Comment with session import instructions
|
| 572 |
+
if: always() && steps.run_pi.outcome == 'success' && steps.gist.outcome == 'success'
|
| 573 |
+
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
| 574 |
+
env:
|
| 575 |
+
GIST_URL: ${{ steps.gist.outputs.url }}
|
| 576 |
+
GIST_ID: ${{ steps.gist.outputs.id }}
|
| 577 |
+
SHARE_URL: ${{ steps.gist.outputs.share_url }}
|
| 578 |
+
SESSION_JSONL: ${{ runner.temp }}/pi-out/session.jsonl
|
| 579 |
+
with:
|
| 580 |
+
script: |
|
| 581 |
+
const fs = require('fs');
|
| 582 |
+
|
| 583 |
+
function extractLastAgentMessage(sessionPath) {
|
| 584 |
+
const lines = fs.readFileSync(sessionPath, 'utf8').split(/\r?\n/).filter(Boolean);
|
| 585 |
+
let lastText = '';
|
| 586 |
+
|
| 587 |
+
for (const line of lines) {
|
| 588 |
+
let entry;
|
| 589 |
+
try {
|
| 590 |
+
entry = JSON.parse(line);
|
| 591 |
+
} catch {
|
| 592 |
+
continue;
|
| 593 |
+
}
|
| 594 |
+
|
| 595 |
+
if (entry.type !== 'message' || entry.message?.role !== 'assistant') continue;
|
| 596 |
+
|
| 597 |
+
const content = entry.message.content;
|
| 598 |
+
const parts = [];
|
| 599 |
+
if (typeof content === 'string') {
|
| 600 |
+
parts.push(content);
|
| 601 |
+
} else if (Array.isArray(content)) {
|
| 602 |
+
for (const block of content) {
|
| 603 |
+
if (block?.type === 'text' && typeof block.text === 'string') {
|
| 604 |
+
parts.push(block.text);
|
| 605 |
+
}
|
| 606 |
+
}
|
| 607 |
+
}
|
| 608 |
+
|
| 609 |
+
const text = parts.join('\n\n').trim();
|
| 610 |
+
if (text) lastText = text;
|
| 611 |
+
}
|
| 612 |
+
|
| 613 |
+
if (!lastText) return '_No assistant output found._';
|
| 614 |
+
const maxLength = 55000;
|
| 615 |
+
if (lastText.length <= maxLength) return lastText;
|
| 616 |
+
return `${lastText.slice(0, maxLength)}\n\n_[truncated]_`;
|
| 617 |
+
}
|
| 618 |
+
|
| 619 |
+
const gistUrl = process.env.GIST_URL;
|
| 620 |
+
const gistId = process.env.GIST_ID;
|
| 621 |
+
const shareUrl = process.env.SHARE_URL;
|
| 622 |
+
const lastAgentMessage = extractLastAgentMessage(process.env.SESSION_JSONL);
|
| 623 |
+
const body = [
|
| 624 |
+
'Pi issue analysis finished.',
|
| 625 |
+
'',
|
| 626 |
+
`Share URL: ${shareUrl}`,
|
| 627 |
+
`Gist: ${gistUrl}`,
|
| 628 |
+
'',
|
| 629 |
+
'Continue locally from a checkout with:',
|
| 630 |
+
'',
|
| 631 |
+
'```sh',
|
| 632 |
+
`pi "/ir ${gistId}"`,
|
| 633 |
+
'```',
|
| 634 |
+
'',
|
| 635 |
+
'<details>',
|
| 636 |
+
'<summary>Agent analysis summary</summary>',
|
| 637 |
+
'',
|
| 638 |
+
lastAgentMessage,
|
| 639 |
+
'',
|
| 640 |
+
'</details>',
|
| 641 |
+
].join('\n');
|
| 642 |
+
|
| 643 |
+
await github.rest.issues.createComment({
|
| 644 |
+
owner: context.repo.owner,
|
| 645 |
+
repo: context.repo.repo,
|
| 646 |
+
issue_number: context.issue.number,
|
| 647 |
+
body,
|
| 648 |
+
});
|
| 649 |
+
|
| 650 |
+
- name: Comment on analysis failure
|
| 651 |
+
if: always() && steps.run_pi.outcome == 'failure'
|
| 652 |
+
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
| 653 |
+
env:
|
| 654 |
+
GIST_URL: ${{ steps.gist.outputs.url }}
|
| 655 |
+
SHARE_URL: ${{ steps.gist.outputs.share_url }}
|
| 656 |
+
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
| 657 |
+
with:
|
| 658 |
+
script: |
|
| 659 |
+
const lines = ['Pi issue analysis failed; no analysis was produced.', '', `Run: ${process.env.RUN_URL}`];
|
| 660 |
+
if (process.env.SHARE_URL) {
|
| 661 |
+
lines.push(`Partial session: ${process.env.SHARE_URL}`);
|
| 662 |
+
}
|
| 663 |
+
if (process.env.GIST_URL) {
|
| 664 |
+
lines.push(`Gist: ${process.env.GIST_URL}`);
|
| 665 |
+
}
|
| 666 |
+
|
| 667 |
+
await github.rest.issues.createComment({
|
| 668 |
+
owner: context.repo.owner,
|
| 669 |
+
repo: context.repo.repo,
|
| 670 |
+
issue_number: context.issue.number,
|
| 671 |
+
body: lines.join('\n'),
|
| 672 |
+
});
|
| 673 |
+
|
| 674 |
+
- name: Remove trigger label
|
| 675 |
+
if: always()
|
| 676 |
+
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
| 677 |
+
with:
|
| 678 |
+
script: |
|
| 679 |
+
try {
|
| 680 |
+
await github.rest.issues.removeLabel({
|
| 681 |
+
owner: context.repo.owner,
|
| 682 |
+
repo: context.repo.repo,
|
| 683 |
+
issue_number: context.issue.number,
|
| 684 |
+
name: 'pi-analyze',
|
| 685 |
+
});
|
| 686 |
+
} catch (error) {
|
| 687 |
+
if (error.status !== 404) throw error;
|
| 688 |
+
}
|
.github/workflows/issue-gate.yml
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: Issue Gate
|
| 2 |
+
|
| 3 |
+
on:
|
| 4 |
+
issues:
|
| 5 |
+
types: [opened]
|
| 6 |
+
|
| 7 |
+
jobs:
|
| 8 |
+
check-contributor:
|
| 9 |
+
runs-on: ubuntu-latest
|
| 10 |
+
permissions:
|
| 11 |
+
contents: read
|
| 12 |
+
issues: write
|
| 13 |
+
steps:
|
| 14 |
+
- name: Check issue author
|
| 15 |
+
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
| 16 |
+
with:
|
| 17 |
+
script: |
|
| 18 |
+
const APPROVED_FILE = '.github/APPROVED_CONTRIBUTORS';
|
| 19 |
+
const VALID_CAPABILITIES = new Set(['issue', 'pr']);
|
| 20 |
+
const TRUSTED_BOT_AUTHORS = new Set(['dependabot[bot]', 'sentry[bot]', 'claude[bot]']);
|
| 21 |
+
const issueAuthor = context.payload.issue.user.login;
|
| 22 |
+
const defaultBranch = context.payload.repository.default_branch;
|
| 23 |
+
const isBotAuthor = issueAuthor.endsWith('[bot]');
|
| 24 |
+
|
| 25 |
+
if (TRUSTED_BOT_AUTHORS.has(issueAuthor)) {
|
| 26 |
+
console.log(`Skipping trusted bot: ${issueAuthor}`);
|
| 27 |
+
return;
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
async function getPermission(username) {
|
| 31 |
+
try {
|
| 32 |
+
const { data: permissionLevel } = await github.rest.repos.getCollaboratorPermissionLevel({
|
| 33 |
+
owner: context.repo.owner,
|
| 34 |
+
repo: context.repo.repo,
|
| 35 |
+
username,
|
| 36 |
+
});
|
| 37 |
+
return permissionLevel.permission;
|
| 38 |
+
} catch {
|
| 39 |
+
return null;
|
| 40 |
+
}
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
async function getTextFile(path) {
|
| 44 |
+
const { data: fileContent } = await github.rest.repos.getContent({
|
| 45 |
+
owner: context.repo.owner,
|
| 46 |
+
repo: context.repo.repo,
|
| 47 |
+
path,
|
| 48 |
+
ref: defaultBranch,
|
| 49 |
+
});
|
| 50 |
+
|
| 51 |
+
if (!('content' in fileContent) || typeof fileContent.content !== 'string') {
|
| 52 |
+
throw new Error(`Expected file content for ${path}`);
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
return Buffer.from(fileContent.content, 'base64').toString('utf8');
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
function parseApprovedUsers(content) {
|
| 59 |
+
const users = new Map();
|
| 60 |
+
|
| 61 |
+
for (const rawLine of content.split('\n')) {
|
| 62 |
+
const line = rawLine.trim();
|
| 63 |
+
if (!line || line.startsWith('#')) continue;
|
| 64 |
+
|
| 65 |
+
const parts = line.split(/\s+/);
|
| 66 |
+
if (parts.length !== 2) {
|
| 67 |
+
console.log(`Skipping malformed line: ${rawLine}`);
|
| 68 |
+
continue;
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
const [username, capability] = parts;
|
| 72 |
+
const normalizedCapability = capability.toLowerCase();
|
| 73 |
+
if (!VALID_CAPABILITIES.has(normalizedCapability)) {
|
| 74 |
+
console.log(`Skipping line with invalid capability: ${rawLine}`);
|
| 75 |
+
continue;
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
users.set(username.toLowerCase(), normalizedCapability);
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
return users;
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
const permission = await getPermission(issueAuthor);
|
| 85 |
+
if (!isBotAuthor && ['admin', 'maintain', 'write'].includes(permission)) {
|
| 86 |
+
console.log(`${issueAuthor} is a collaborator with ${permission} access`);
|
| 87 |
+
return;
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
const approvedContent = await getTextFile(APPROVED_FILE);
|
| 91 |
+
const approvedUsers = parseApprovedUsers(approvedContent);
|
| 92 |
+
const capability = approvedUsers.get(issueAuthor.toLowerCase());
|
| 93 |
+
|
| 94 |
+
if (!isBotAuthor && (capability === 'issue' || capability === 'pr')) {
|
| 95 |
+
console.log(`${issueAuthor} is approved for ${capability}`);
|
| 96 |
+
return;
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
const message = [
|
| 100 |
+
'This issue was auto-closed. All issues from new contributors are auto-closed by default.',
|
| 101 |
+
'',
|
| 102 |
+
`Maintainers review auto-closed issues daily and reopen worthwhile ones. Issues that do not meet the quality bar in [CONTRIBUTING.md](https://github.com/${context.repo.owner}/${context.repo.repo}/blob/${defaultBranch}/CONTRIBUTING.md) will not be reopened or receive a reply.`,
|
| 103 |
+
'',
|
| 104 |
+
'If a maintainer replies `lgtmi` on one of your issues, your future issues will stay open. If a maintainer replies `lgtm`, your future issues and PRs will stay open. The command must be at the start of the reply (optionally after one or more `@username` mentions) or at the end.',
|
| 105 |
+
'',
|
| 106 |
+
`See [CONTRIBUTING.md](https://github.com/${context.repo.owner}/${context.repo.repo}/blob/${defaultBranch}/CONTRIBUTING.md).`,
|
| 107 |
+
].join('\n');
|
| 108 |
+
|
| 109 |
+
await github.rest.issues.createComment({
|
| 110 |
+
owner: context.repo.owner,
|
| 111 |
+
repo: context.repo.repo,
|
| 112 |
+
issue_number: context.issue.number,
|
| 113 |
+
body: message,
|
| 114 |
+
});
|
| 115 |
+
|
| 116 |
+
await github.rest.issues.addLabels({
|
| 117 |
+
owner: context.repo.owner,
|
| 118 |
+
repo: context.repo.repo,
|
| 119 |
+
issue_number: context.issue.number,
|
| 120 |
+
labels: ['untriaged'],
|
| 121 |
+
});
|
| 122 |
+
|
| 123 |
+
await github.rest.issues.update({
|
| 124 |
+
owner: context.repo.owner,
|
| 125 |
+
repo: context.repo.repo,
|
| 126 |
+
issue_number: context.issue.number,
|
| 127 |
+
state: 'closed',
|
| 128 |
+
state_reason: 'not_planned',
|
| 129 |
+
});
|
.github/workflows/issue-triage-labels.yml
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: Issue Triage Labels
|
| 2 |
+
|
| 3 |
+
on:
|
| 4 |
+
issues:
|
| 5 |
+
types: [reopened, labeled]
|
| 6 |
+
|
| 7 |
+
jobs:
|
| 8 |
+
update-labels:
|
| 9 |
+
runs-on: ubuntu-latest
|
| 10 |
+
permissions:
|
| 11 |
+
issues: write
|
| 12 |
+
steps:
|
| 13 |
+
- name: Update triage labels
|
| 14 |
+
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
| 15 |
+
with:
|
| 16 |
+
script: |
|
| 17 |
+
const UNTRIAGED_LABEL = 'untriaged';
|
| 18 |
+
const NO_ACTION_LABEL = 'no-action';
|
| 19 |
+
const LAST_READ_LABEL = 'last-read';
|
| 20 |
+
const TO_DISCUSS_LABEL = 'to-discuss';
|
| 21 |
+
const INPROGRESS_LABEL = 'inprogress';
|
| 22 |
+
|
| 23 |
+
function issueHasLabel(issue, labelName) {
|
| 24 |
+
return (issue.labels ?? []).some((label) => label.name === labelName);
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
async function removeLabelIfPresent(issueNumber, issue, labelName) {
|
| 28 |
+
if (!issueHasLabel(issue, labelName)) {
|
| 29 |
+
console.log(`Issue #${issueNumber} does not have ${labelName}`);
|
| 30 |
+
return;
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
try {
|
| 34 |
+
await github.rest.issues.removeLabel({
|
| 35 |
+
owner: context.repo.owner,
|
| 36 |
+
repo: context.repo.repo,
|
| 37 |
+
issue_number: issueNumber,
|
| 38 |
+
name: labelName,
|
| 39 |
+
});
|
| 40 |
+
console.log(`Removed ${labelName} from #${issueNumber}`);
|
| 41 |
+
} catch (error) {
|
| 42 |
+
if (error.status === 404) {
|
| 43 |
+
console.log(`Label ${labelName} was already absent from #${issueNumber}`);
|
| 44 |
+
return;
|
| 45 |
+
}
|
| 46 |
+
throw error;
|
| 47 |
+
}
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
if (context.payload.action === 'reopened') {
|
| 51 |
+
await removeLabelIfPresent(context.issue.number, context.payload.issue, UNTRIAGED_LABEL);
|
| 52 |
+
await removeLabelIfPresent(context.issue.number, context.payload.issue, NO_ACTION_LABEL);
|
| 53 |
+
return;
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
if (context.payload.action === 'labeled' && context.payload.label?.name === NO_ACTION_LABEL) {
|
| 57 |
+
await removeLabelIfPresent(context.issue.number, context.payload.issue, UNTRIAGED_LABEL);
|
| 58 |
+
return;
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
if (context.payload.action !== 'labeled' || context.payload.label?.name !== LAST_READ_LABEL) {
|
| 62 |
+
console.log('Not a last-read label event');
|
| 63 |
+
return;
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
const currentIssueNumber = context.issue.number;
|
| 67 |
+
const lastReadIssues = await github.paginate(github.rest.issues.listForRepo, {
|
| 68 |
+
owner: context.repo.owner,
|
| 69 |
+
repo: context.repo.repo,
|
| 70 |
+
state: 'all',
|
| 71 |
+
labels: LAST_READ_LABEL,
|
| 72 |
+
per_page: 100,
|
| 73 |
+
});
|
| 74 |
+
|
| 75 |
+
const previousIssueNumbers = lastReadIssues
|
| 76 |
+
.filter((issue) => !issue.pull_request)
|
| 77 |
+
.map((issue) => issue.number)
|
| 78 |
+
.filter((issueNumber) => issueNumber !== currentIssueNumber);
|
| 79 |
+
|
| 80 |
+
if (previousIssueNumbers.length === 0) {
|
| 81 |
+
console.log('No previous last-read issue found');
|
| 82 |
+
return;
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
const previousIssueNumber = Math.max(...previousIssueNumbers);
|
| 86 |
+
if (currentIssueNumber <= previousIssueNumber) {
|
| 87 |
+
console.log(
|
| 88 |
+
`Last-read was added to old issue #${currentIssueNumber}; latest last-read is #${previousIssueNumber}`,
|
| 89 |
+
);
|
| 90 |
+
return;
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
const untriagedIssues = await github.paginate(github.rest.issues.listForRepo, {
|
| 94 |
+
owner: context.repo.owner,
|
| 95 |
+
repo: context.repo.repo,
|
| 96 |
+
state: 'all',
|
| 97 |
+
labels: UNTRIAGED_LABEL,
|
| 98 |
+
per_page: 100,
|
| 99 |
+
});
|
| 100 |
+
|
| 101 |
+
const issuesToMark = untriagedIssues
|
| 102 |
+
.filter((issue) => !issue.pull_request)
|
| 103 |
+
.filter((issue) => issue.number >= previousIssueNumber && issue.number <= currentIssueNumber)
|
| 104 |
+
.sort((a, b) => a.number - b.number);
|
| 105 |
+
|
| 106 |
+
if (issuesToMark.length === 0) {
|
| 107 |
+
console.log(`No untriaged issues found from #${previousIssueNumber} to #${currentIssueNumber}`);
|
| 108 |
+
return;
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
for (const issue of issuesToMark) {
|
| 112 |
+
if (issueHasLabel(issue, TO_DISCUSS_LABEL)) {
|
| 113 |
+
console.log(`Skipped ${NO_ACTION_LABEL} for #${issue.number} because it has ${TO_DISCUSS_LABEL}`);
|
| 114 |
+
} else {
|
| 115 |
+
await github.rest.issues.addLabels({
|
| 116 |
+
owner: context.repo.owner,
|
| 117 |
+
repo: context.repo.repo,
|
| 118 |
+
issue_number: issue.number,
|
| 119 |
+
labels: [NO_ACTION_LABEL],
|
| 120 |
+
});
|
| 121 |
+
console.log(`Added ${NO_ACTION_LABEL} to #${issue.number}`);
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
await github.rest.issues.update({
|
| 125 |
+
owner: context.repo.owner,
|
| 126 |
+
repo: context.repo.repo,
|
| 127 |
+
issue_number: issue.number,
|
| 128 |
+
state: 'closed',
|
| 129 |
+
state_reason: 'not_planned',
|
| 130 |
+
});
|
| 131 |
+
console.log(`Closed #${issue.number} as not planned`);
|
| 132 |
+
|
| 133 |
+
await removeLabelIfPresent(issue.number, issue, INPROGRESS_LABEL);
|
| 134 |
+
|
| 135 |
+
await github.rest.issues.removeLabel({
|
| 136 |
+
owner: context.repo.owner,
|
| 137 |
+
repo: context.repo.repo,
|
| 138 |
+
issue_number: issue.number,
|
| 139 |
+
name: UNTRIAGED_LABEL,
|
| 140 |
+
});
|
| 141 |
+
console.log(`Removed ${UNTRIAGED_LABEL} from #${issue.number}`);
|
| 142 |
+
}
|
.github/workflows/npm-audit.yml
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: npm audit
|
| 2 |
+
|
| 3 |
+
on:
|
| 4 |
+
schedule:
|
| 5 |
+
- cron: '37 7 * * *'
|
| 6 |
+
workflow_dispatch:
|
| 7 |
+
|
| 8 |
+
permissions:
|
| 9 |
+
contents: read
|
| 10 |
+
|
| 11 |
+
jobs:
|
| 12 |
+
audit:
|
| 13 |
+
runs-on: ubuntu-latest
|
| 14 |
+
steps:
|
| 15 |
+
- name: Checkout
|
| 16 |
+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
| 17 |
+
|
| 18 |
+
- name: Setup Node.js
|
| 19 |
+
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
| 20 |
+
with:
|
| 21 |
+
node-version: 22
|
| 22 |
+
cache: npm
|
| 23 |
+
|
| 24 |
+
- name: Install dependencies without lifecycle scripts
|
| 25 |
+
run: npm ci --ignore-scripts --no-audit --no-fund
|
| 26 |
+
|
| 27 |
+
- name: Audit production vulnerabilities
|
| 28 |
+
run: npm audit --omit=dev --audit-level=moderate
|
| 29 |
+
|
| 30 |
+
- name: Verify registry signatures
|
| 31 |
+
run: npm audit signatures --omit=dev
|
.github/workflows/pr-gate.yml
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: PR Gate
|
| 2 |
+
|
| 3 |
+
on:
|
| 4 |
+
pull_request_target:
|
| 5 |
+
types: [opened]
|
| 6 |
+
|
| 7 |
+
jobs:
|
| 8 |
+
check-contributor:
|
| 9 |
+
runs-on: ubuntu-latest
|
| 10 |
+
permissions:
|
| 11 |
+
contents: read
|
| 12 |
+
issues: write
|
| 13 |
+
pull-requests: write
|
| 14 |
+
steps:
|
| 15 |
+
- name: Check if contributor is approved
|
| 16 |
+
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
| 17 |
+
with:
|
| 18 |
+
script: |
|
| 19 |
+
const APPROVED_FILE = '.github/APPROVED_CONTRIBUTORS';
|
| 20 |
+
const VALID_CAPABILITIES = new Set(['issue', 'pr']);
|
| 21 |
+
const TRUSTED_BOT_AUTHORS = new Set(['dependabot[bot]', 'sentry[bot]', 'claude[bot]']);
|
| 22 |
+
const prAuthor = context.payload.pull_request.user.login;
|
| 23 |
+
const defaultBranch = context.payload.repository.default_branch;
|
| 24 |
+
const isBotAuthor = prAuthor.endsWith('[bot]');
|
| 25 |
+
|
| 26 |
+
if (TRUSTED_BOT_AUTHORS.has(prAuthor)) {
|
| 27 |
+
console.log(`Skipping trusted bot: ${prAuthor}`);
|
| 28 |
+
return;
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
async function getPermission(username) {
|
| 32 |
+
try {
|
| 33 |
+
const { data: permissionLevel } = await github.rest.repos.getCollaboratorPermissionLevel({
|
| 34 |
+
owner: context.repo.owner,
|
| 35 |
+
repo: context.repo.repo,
|
| 36 |
+
username,
|
| 37 |
+
});
|
| 38 |
+
return permissionLevel.permission;
|
| 39 |
+
} catch {
|
| 40 |
+
return null;
|
| 41 |
+
}
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
async function getTextFile(path) {
|
| 45 |
+
const { data: fileContent } = await github.rest.repos.getContent({
|
| 46 |
+
owner: context.repo.owner,
|
| 47 |
+
repo: context.repo.repo,
|
| 48 |
+
path,
|
| 49 |
+
ref: defaultBranch,
|
| 50 |
+
});
|
| 51 |
+
|
| 52 |
+
if (!('content' in fileContent) || typeof fileContent.content !== 'string') {
|
| 53 |
+
throw new Error(`Expected file content for ${path}`);
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
return Buffer.from(fileContent.content, 'base64').toString('utf8');
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
function parseApprovedUsers(content) {
|
| 60 |
+
const users = new Map();
|
| 61 |
+
|
| 62 |
+
for (const rawLine of content.split('\n')) {
|
| 63 |
+
const line = rawLine.trim();
|
| 64 |
+
if (!line || line.startsWith('#')) continue;
|
| 65 |
+
|
| 66 |
+
const parts = line.split(/\s+/);
|
| 67 |
+
if (parts.length !== 2) {
|
| 68 |
+
console.log(`Skipping malformed line: ${rawLine}`);
|
| 69 |
+
continue;
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
const [username, capability] = parts;
|
| 73 |
+
const normalizedCapability = capability.toLowerCase();
|
| 74 |
+
if (!VALID_CAPABILITIES.has(normalizedCapability)) {
|
| 75 |
+
console.log(`Skipping line with invalid capability: ${rawLine}`);
|
| 76 |
+
continue;
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
users.set(username.toLowerCase(), normalizedCapability);
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
return users;
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
async function closePullRequest(message) {
|
| 86 |
+
await github.rest.issues.createComment({
|
| 87 |
+
owner: context.repo.owner,
|
| 88 |
+
repo: context.repo.repo,
|
| 89 |
+
issue_number: context.payload.pull_request.number,
|
| 90 |
+
body: message,
|
| 91 |
+
});
|
| 92 |
+
|
| 93 |
+
await github.rest.pulls.update({
|
| 94 |
+
owner: context.repo.owner,
|
| 95 |
+
repo: context.repo.repo,
|
| 96 |
+
pull_number: context.payload.pull_request.number,
|
| 97 |
+
state: 'closed',
|
| 98 |
+
});
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
const permission = await getPermission(prAuthor);
|
| 102 |
+
if (!isBotAuthor && ['admin', 'maintain', 'write'].includes(permission)) {
|
| 103 |
+
console.log(`${prAuthor} is a collaborator with ${permission} access`);
|
| 104 |
+
return;
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
const approvedContent = await getTextFile(APPROVED_FILE);
|
| 108 |
+
const approvedUsers = parseApprovedUsers(approvedContent);
|
| 109 |
+
const capability = approvedUsers.get(prAuthor.toLowerCase());
|
| 110 |
+
|
| 111 |
+
if (!isBotAuthor && capability === 'pr') {
|
| 112 |
+
console.log(`${prAuthor} is approved for PRs`);
|
| 113 |
+
return;
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
console.log(`${prAuthor} is not approved, closing PR`);
|
| 117 |
+
|
| 118 |
+
const message = [
|
| 119 |
+
'This PR was auto-closed. Only contributors approved with `lgtm` can open PRs. Open an issue first and ask a maintainer for approval.',
|
| 120 |
+
'',
|
| 121 |
+
`Maintainers review auto-closed issues daily. Issues that do not meet the quality bar in [CONTRIBUTING.md](https://github.com/${context.repo.owner}/${context.repo.repo}/blob/${defaultBranch}/CONTRIBUTING.md) will not be reopened or receive a reply.`,
|
| 122 |
+
'',
|
| 123 |
+
'If a maintainer replies `lgtmi`, your future issues will stay open. If a maintainer replies `lgtm`, your future issues and PRs will stay open. The command must be at the start of the reply (optionally after one or more `@username` mentions) or at the end.',
|
| 124 |
+
'',
|
| 125 |
+
`See [CONTRIBUTING.md](https://github.com/${context.repo.owner}/${context.repo.repo}/blob/${defaultBranch}/CONTRIBUTING.md).`,
|
| 126 |
+
].join('\n');
|
| 127 |
+
|
| 128 |
+
await closePullRequest(message);
|
.github/workflows/publish-model-catalog.yml
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: Publish Model Catalog
|
| 2 |
+
|
| 3 |
+
on:
|
| 4 |
+
workflow_run:
|
| 5 |
+
workflows:
|
| 6 |
+
- CI
|
| 7 |
+
types:
|
| 8 |
+
- completed
|
| 9 |
+
pull_request:
|
| 10 |
+
paths:
|
| 11 |
+
- '.github/workflows/publish-model-catalog.yml'
|
| 12 |
+
- '.gitignore'
|
| 13 |
+
- 'package.json'
|
| 14 |
+
- 'packages/ai/**'
|
| 15 |
+
- 'scripts/publish-model-catalog.mjs'
|
| 16 |
+
# GitHub schedules use UTC. Run hourly candidates across the CET/CEST
|
| 17 |
+
# boundaries; the publish job only uploads at 10:17, 12:17, and 14:17
|
| 18 |
+
# Europe/Vienna time.
|
| 19 |
+
schedule:
|
| 20 |
+
- cron: '17 8-13 * * 1-5'
|
| 21 |
+
workflow_dispatch:
|
| 22 |
+
inputs:
|
| 23 |
+
source_ref:
|
| 24 |
+
description: 'Commit, branch, or tag to generate from'
|
| 25 |
+
required: false
|
| 26 |
+
default: 'main'
|
| 27 |
+
type: string
|
| 28 |
+
publish:
|
| 29 |
+
description: 'Upload the generated catalog to production R2'
|
| 30 |
+
required: true
|
| 31 |
+
default: false
|
| 32 |
+
type: boolean
|
| 33 |
+
|
| 34 |
+
permissions:
|
| 35 |
+
contents: read
|
| 36 |
+
|
| 37 |
+
jobs:
|
| 38 |
+
generate:
|
| 39 |
+
if: ${{ github.event_name != 'workflow_run' || (github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.head_branch == 'main') }}
|
| 40 |
+
runs-on: ubuntu-latest
|
| 41 |
+
env:
|
| 42 |
+
SOURCE_REF: ${{ github.event.workflow_run.head_sha || inputs.source_ref || github.sha }}
|
| 43 |
+
steps:
|
| 44 |
+
- name: Checkout
|
| 45 |
+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
| 46 |
+
with:
|
| 47 |
+
ref: ${{ env.SOURCE_REF }}
|
| 48 |
+
persist-credentials: false
|
| 49 |
+
|
| 50 |
+
- name: Setup Node.js
|
| 51 |
+
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
| 52 |
+
with:
|
| 53 |
+
node-version: '22'
|
| 54 |
+
cache: npm
|
| 55 |
+
|
| 56 |
+
- name: Install dependencies
|
| 57 |
+
run: npm ci --ignore-scripts
|
| 58 |
+
|
| 59 |
+
- name: Generate model catalog JSON
|
| 60 |
+
run: npm run generate:model-catalog
|
| 61 |
+
|
| 62 |
+
- name: Validate model catalog JSON
|
| 63 |
+
run: npm run check:model-catalog
|
| 64 |
+
|
| 65 |
+
- name: Upload model catalog JSON
|
| 66 |
+
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
| 67 |
+
with:
|
| 68 |
+
name: model-catalog-json
|
| 69 |
+
path: .artifacts/model-catalog
|
| 70 |
+
if-no-files-found: error
|
| 71 |
+
retention-days: 14
|
| 72 |
+
|
| 73 |
+
publish:
|
| 74 |
+
if: ${{ github.event_name == 'schedule' || github.event_name == 'workflow_run' || (github.event_name == 'workflow_dispatch' && inputs.publish) }}
|
| 75 |
+
needs: generate
|
| 76 |
+
runs-on: ubuntu-latest
|
| 77 |
+
environment: pi-model-upload
|
| 78 |
+
concurrency:
|
| 79 |
+
group: publish-model-catalog-r2
|
| 80 |
+
cancel-in-progress: true
|
| 81 |
+
env:
|
| 82 |
+
SOURCE_REF: ${{ github.event.workflow_run.head_sha || inputs.source_ref || github.sha }}
|
| 83 |
+
AWS_ACCESS_KEY_ID: ${{ secrets.PI_ARTIFACTS_R2_ACCESS_KEY_ID }}
|
| 84 |
+
AWS_SECRET_ACCESS_KEY: ${{ secrets.PI_ARTIFACTS_R2_SECRET_ACCESS_KEY }}
|
| 85 |
+
AWS_DEFAULT_REGION: auto
|
| 86 |
+
AWS_EC2_METADATA_DISABLED: 'true'
|
| 87 |
+
R2_ENDPOINT: https://67c0d357268b0fca6e0b465bb9d01b84.r2.cloudflarestorage.com
|
| 88 |
+
steps:
|
| 89 |
+
- name: Checkout
|
| 90 |
+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
| 91 |
+
with:
|
| 92 |
+
ref: ${{ env.SOURCE_REF }}
|
| 93 |
+
persist-credentials: false
|
| 94 |
+
|
| 95 |
+
- name: Setup Node.js
|
| 96 |
+
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
| 97 |
+
with:
|
| 98 |
+
node-version: '22'
|
| 99 |
+
|
| 100 |
+
- name: Download model catalog JSON
|
| 101 |
+
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
| 102 |
+
with:
|
| 103 |
+
name: model-catalog-json
|
| 104 |
+
path: .artifacts/model-catalog
|
| 105 |
+
|
| 106 |
+
- name: Verify AWS CLI
|
| 107 |
+
run: aws --version
|
| 108 |
+
|
| 109 |
+
- name: Check publication window
|
| 110 |
+
id: publication-window
|
| 111 |
+
env:
|
| 112 |
+
EVENT_NAME: ${{ github.event_name }}
|
| 113 |
+
MANUAL_PUBLISH: ${{ inputs.publish || false }}
|
| 114 |
+
run: |
|
| 115 |
+
set -euo pipefail
|
| 116 |
+
|
| 117 |
+
local_day="$(TZ=Europe/Vienna date +%u)"
|
| 118 |
+
local_hour_text="$(TZ=Europe/Vienna date +%H)"
|
| 119 |
+
local_hour="$((10#$local_hour_text))"
|
| 120 |
+
local_time="$(TZ=Europe/Vienna date '+%Y-%m-%d %H:%M:%S %Z')"
|
| 121 |
+
allowed=false
|
| 122 |
+
reason="outside the Monday-Friday 10:00-15:00 Europe/Vienna publication window"
|
| 123 |
+
|
| 124 |
+
if [[ "$EVENT_NAME" == "workflow_dispatch" && "$MANUAL_PUBLISH" == "true" ]]; then
|
| 125 |
+
allowed=true
|
| 126 |
+
reason="manual publication"
|
| 127 |
+
elif (( local_day <= 5 && local_hour >= 10 && local_hour < 15 )); then
|
| 128 |
+
if [[ "$EVENT_NAME" != "schedule" ]] || (( (local_hour - 10) % 2 == 0 )); then
|
| 129 |
+
allowed=true
|
| 130 |
+
reason="business-hours publication"
|
| 131 |
+
else
|
| 132 |
+
reason="not a scheduled 10:17, 12:17, or 14:17 Europe/Vienna publication"
|
| 133 |
+
fi
|
| 134 |
+
fi
|
| 135 |
+
|
| 136 |
+
echo "allowed=$allowed" >> "$GITHUB_OUTPUT"
|
| 137 |
+
echo "R2 publication allowed: $allowed ($reason; local time: $local_time)"
|
| 138 |
+
|
| 139 |
+
- name: Publish model catalog to R2
|
| 140 |
+
if: steps.publication-window.outputs.allowed == 'true'
|
| 141 |
+
run: |
|
| 142 |
+
node scripts/publish-model-catalog.mjs \
|
| 143 |
+
--input .artifacts/model-catalog \
|
| 144 |
+
--bucket pi-artifacts \
|
| 145 |
+
--endpoint "$R2_ENDPOINT" \
|
| 146 |
+
--source-commit "$(git rev-parse HEAD)"
|
.github/workflows/remove-inprogress-on-close.yml
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: Remove In Progress Label On Close
|
| 2 |
+
|
| 3 |
+
on:
|
| 4 |
+
issues:
|
| 5 |
+
types: [closed]
|
| 6 |
+
|
| 7 |
+
jobs:
|
| 8 |
+
remove-label:
|
| 9 |
+
runs-on: ubuntu-latest
|
| 10 |
+
permissions:
|
| 11 |
+
issues: write
|
| 12 |
+
steps:
|
| 13 |
+
- name: Remove inprogress label
|
| 14 |
+
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
| 15 |
+
with:
|
| 16 |
+
script: |
|
| 17 |
+
const labelName = 'inprogress';
|
| 18 |
+
const labels = context.payload.issue.labels ?? [];
|
| 19 |
+
const hasLabel = labels.some((label) => label.name === labelName);
|
| 20 |
+
|
| 21 |
+
if (!hasLabel) {
|
| 22 |
+
console.log(`Issue does not have ${labelName} label`);
|
| 23 |
+
return;
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
await github.rest.issues.removeLabel({
|
| 27 |
+
owner: context.repo.owner,
|
| 28 |
+
repo: context.repo.repo,
|
| 29 |
+
issue_number: context.issue.number,
|
| 30 |
+
name: labelName,
|
| 31 |
+
});
|
.pi/extensions/import-repro.ts
ADDED
|
@@ -0,0 +1,351 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Import a pi session shared as a gist by the issue-analysis CI workflow
|
| 3 |
+
* (.github/workflows/issue-analysis.yml) and switch to it.
|
| 4 |
+
*
|
| 5 |
+
* The CI job runs in a high-entropy checkout directory; this command rewrites
|
| 6 |
+
* the recorded cwd to the local checkout, installs the session file into the
|
| 7 |
+
* current session directory, and switches to it.
|
| 8 |
+
*
|
| 9 |
+
* Usage:
|
| 10 |
+
* /ir b4d100022aefb12f25dd2d8485e0a82a
|
| 11 |
+
* /ir https://gist.github.com/mitsuhiko/b4d100022aefb12f25dd2d8485e0a82a
|
| 12 |
+
* /ir https://pi.dev/session/#b4d100022aefb12f25dd2d8485e0a82a
|
| 13 |
+
* /ir https://github.com/earendil-works/pi/issues/123
|
| 14 |
+
*
|
| 15 |
+
* pi "/ir <gist-id>"
|
| 16 |
+
*/
|
| 17 |
+
|
| 18 |
+
import { Buffer } from "node:buffer";
|
| 19 |
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
| 20 |
+
import { basename, isAbsolute, join, resolve } from "node:path";
|
| 21 |
+
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
| 22 |
+
|
| 23 |
+
const GIST_ID_RE = /^[0-9a-fA-F]{20,}$/;
|
| 24 |
+
const GIST_URL_RE = /^https:\/\/gist\.github\.com\/(?:[^/]+\/)?([0-9a-fA-F]{20,})(?:[/#?].*)?$/;
|
| 25 |
+
const SHARE_URL_RE = /^https:\/\/pi\.dev\/session\/#([0-9a-fA-F]{20,})(?:[/#?].*)?$/;
|
| 26 |
+
const ISSUE_URL_RE = /^https:\/\/github\.com\/([^/]+)\/([^/]+)\/issues\/(\d+)(?:[/#?].*)?$/;
|
| 27 |
+
const GIST_URL_IN_TEXT_RE = /https:\/\/gist\.github\.com\/(?:[^/\s]+\/)?([0-9a-fA-F]{20,})\b/g;
|
| 28 |
+
const SESSION_DATA_RE = /<script id="session-data" type="application\/json">([^<]+)<\/script>/;
|
| 29 |
+
|
| 30 |
+
interface SessionHeader {
|
| 31 |
+
type: "session";
|
| 32 |
+
id: string;
|
| 33 |
+
cwd: string;
|
| 34 |
+
[key: string]: unknown;
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
interface ExportedSessionData {
|
| 38 |
+
header: SessionHeader | null;
|
| 39 |
+
entries: Array<Record<string, unknown>>;
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
interface GistFile {
|
| 43 |
+
filename?: string;
|
| 44 |
+
raw_url?: string;
|
| 45 |
+
content?: string;
|
| 46 |
+
truncated?: boolean;
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
interface GistResponse {
|
| 50 |
+
files?: Record<string, GistFile>;
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
interface IssueComment {
|
| 54 |
+
body?: string | null;
|
| 55 |
+
user?: { login?: string } | null;
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
function parseRef(
|
| 59 |
+
ref: string,
|
| 60 |
+
cwd: string,
|
| 61 |
+
): { type: "gist"; id: string } | { type: "file"; path: string } | { type: "issue"; owner: string; repo: string; issue: string } {
|
| 62 |
+
if (ref.endsWith(".html") || ref.endsWith(".jsonl")) {
|
| 63 |
+
return { type: "file", path: isAbsolute(ref) ? ref : resolve(cwd, ref) };
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
const shareMatch = ref.match(SHARE_URL_RE);
|
| 67 |
+
if (shareMatch) return { type: "gist", id: shareMatch[1] };
|
| 68 |
+
|
| 69 |
+
const gistMatch = ref.match(GIST_URL_RE);
|
| 70 |
+
if (gistMatch) return { type: "gist", id: gistMatch[1] };
|
| 71 |
+
|
| 72 |
+
const issueMatch = ref.match(ISSUE_URL_RE);
|
| 73 |
+
if (issueMatch) return { type: "issue", owner: issueMatch[1], repo: issueMatch[2], issue: issueMatch[3] };
|
| 74 |
+
|
| 75 |
+
if (GIST_ID_RE.test(ref)) return { type: "gist", id: ref };
|
| 76 |
+
|
| 77 |
+
throw new Error(`expected a gist ID, gist URL, pi.dev share URL, issue URL, .html file, or .jsonl file: ${ref}`);
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
function parseSessionJsonl(raw: string): { header: SessionHeader; jsonl: string } {
|
| 81 |
+
const newlineIndex = raw.indexOf("\n");
|
| 82 |
+
const firstLine = newlineIndex === -1 ? raw : raw.slice(0, newlineIndex);
|
| 83 |
+
let parsed: unknown;
|
| 84 |
+
try {
|
| 85 |
+
parsed = JSON.parse(firstLine);
|
| 86 |
+
} catch {
|
| 87 |
+
throw new Error("first line of session file is not valid JSON");
|
| 88 |
+
}
|
| 89 |
+
const header = parsed as Partial<SessionHeader>;
|
| 90 |
+
if (header.type !== "session" || typeof header.id !== "string" || typeof header.cwd !== "string" || header.cwd === "") {
|
| 91 |
+
throw new Error("session file has no valid session header with a cwd");
|
| 92 |
+
}
|
| 93 |
+
return { header: header as SessionHeader, jsonl: raw };
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
function decodeExportedHtml(html: string): { header: SessionHeader; jsonl: string } {
|
| 97 |
+
const match = html.match(SESSION_DATA_RE);
|
| 98 |
+
if (!match) throw new Error("HTML does not contain embedded pi session data");
|
| 99 |
+
|
| 100 |
+
let data: unknown;
|
| 101 |
+
try {
|
| 102 |
+
data = JSON.parse(Buffer.from(match[1], "base64").toString("utf8"));
|
| 103 |
+
} catch {
|
| 104 |
+
throw new Error("embedded pi session data is not valid JSON");
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
const sessionData = data as Partial<ExportedSessionData>;
|
| 108 |
+
const header = sessionData.header;
|
| 109 |
+
if (!header || header.type !== "session" || typeof header.id !== "string" || typeof header.cwd !== "string") {
|
| 110 |
+
throw new Error("embedded pi session data has no valid session header");
|
| 111 |
+
}
|
| 112 |
+
if (!Array.isArray(sessionData.entries)) {
|
| 113 |
+
throw new Error("embedded pi session data has no entries array");
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
const lines = [header, ...sessionData.entries].map((entry) => JSON.stringify(entry));
|
| 117 |
+
return { header, jsonl: `${lines.join("\n")}\n` };
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
type SessionPlatform = "windows" | "unix" | "unknown";
|
| 121 |
+
|
| 122 |
+
function escapeJsonString(value: string): string {
|
| 123 |
+
return JSON.stringify(value).slice(1, -1);
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
function escapeRegExp(value: string): string {
|
| 127 |
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
function trimTrailingPathSeparators(value: string): string {
|
| 131 |
+
return value.replace(/[\\/]+$/, "");
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
function getPathTailName(value: string): string {
|
| 135 |
+
const trimmed = trimTrailingPathSeparators(value);
|
| 136 |
+
return trimmed.split(/[\\/]/).filter(Boolean).at(-1) ?? "";
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
function getWindowsDrivePathParts(value: string): { drive: string; rest: string } | undefined {
|
| 140 |
+
const trimmed = trimTrailingPathSeparators(value);
|
| 141 |
+
const driveMatch = trimmed.match(/^([A-Za-z]):[\\/](.*)$/);
|
| 142 |
+
if (driveMatch) {
|
| 143 |
+
return { drive: driveMatch[1].toUpperCase(), rest: driveMatch[2].replace(/[\\/]+/g, "/") };
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
const msysMatch = trimmed.match(/^\/([A-Za-z])\/(.*)$/);
|
| 147 |
+
if (msysMatch) {
|
| 148 |
+
return { drive: msysMatch[1].toUpperCase(), rest: msysMatch[2].replace(/[\\/]+/g, "/") };
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
return undefined;
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
function getCwdRewriteVariants(sourceCwd: string): string[] {
|
| 155 |
+
const trimmed = trimTrailingPathSeparators(sourceCwd);
|
| 156 |
+
const variants = new Set<string>();
|
| 157 |
+
if (trimmed) variants.add(trimmed);
|
| 158 |
+
|
| 159 |
+
const driveParts = getWindowsDrivePathParts(trimmed);
|
| 160 |
+
if (driveParts) {
|
| 161 |
+
const rest = driveParts.rest.replace(/^\/+|\/+$/g, "");
|
| 162 |
+
const backslashRest = rest.replace(/\//g, "\\");
|
| 163 |
+
variants.add(`${driveParts.drive}:\\${backslashRest}`);
|
| 164 |
+
variants.add(`${driveParts.drive}:/${rest}`);
|
| 165 |
+
variants.add(`/${driveParts.drive.toLowerCase()}/${rest}`);
|
| 166 |
+
variants.add(`/${driveParts.drive}/${rest}`);
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
return Array.from(variants).filter(Boolean).sort((a, b) => b.length - a.length);
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
function getCiWorkdirName(sourceCwd: string): string | undefined {
|
| 173 |
+
const name = getPathTailName(sourceCwd);
|
| 174 |
+
return /^pi-ci-[0-9a-f]{32}$/i.test(name) ? name : undefined;
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
function detectSessionPlatform(cwd: string): SessionPlatform {
|
| 178 |
+
if (/^[A-Za-z]:[\\/]/.test(cwd) || /^\/[A-Za-z]\//.test(cwd)) return "windows";
|
| 179 |
+
if (cwd.startsWith("/")) return "unix";
|
| 180 |
+
return "unknown";
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
function getLocalPlatform(): Exclude<SessionPlatform, "unknown"> {
|
| 184 |
+
return process.platform === "win32" ? "windows" : "unix";
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
function getPlatformContinuationNotice(sourceCwd: string): string | undefined {
|
| 188 |
+
const sourcePlatform = detectSessionPlatform(sourceCwd);
|
| 189 |
+
const localPlatform = getLocalPlatform();
|
| 190 |
+
if (sourcePlatform === "unknown" || sourcePlatform === localPlatform) return undefined;
|
| 191 |
+
if (localPlatform === "unix") {
|
| 192 |
+
return "This session was continued on a non-Windows machine; paths are now Unix style.";
|
| 193 |
+
}
|
| 194 |
+
return "This session was continued on a Windows machine; paths are now Windows style.";
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
/** Rewrite occurrences of the recorded CI cwd (JSON-escaped) to the target cwd. */
|
| 198 |
+
function rewriteSessionCwd(raw: string, sourceCwd: string, targetCwd: string): string {
|
| 199 |
+
const target = escapeJsonString(targetCwd);
|
| 200 |
+
let rewritten = raw;
|
| 201 |
+
|
| 202 |
+
for (const sourceVariant of getCwdRewriteVariants(sourceCwd)) {
|
| 203 |
+
if (sourceVariant === targetCwd) continue;
|
| 204 |
+
rewritten = rewritten.split(escapeJsonString(sourceVariant)).join(target);
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
const ciWorkdirName = getCiWorkdirName(sourceCwd);
|
| 208 |
+
if (ciWorkdirName) {
|
| 209 |
+
const escapedName = escapeRegExp(ciWorkdirName);
|
| 210 |
+
const windowsPathPatterns = [
|
| 211 |
+
new RegExp(`[A-Za-z]:(?:[^"\\r\\n])*?${escapedName}`, "g"),
|
| 212 |
+
new RegExp(`/[A-Za-z]/(?:[^"\\r\\n])*?${escapedName}`, "g"),
|
| 213 |
+
];
|
| 214 |
+
for (const pattern of windowsPathPatterns) {
|
| 215 |
+
rewritten = rewritten.replace(pattern, target);
|
| 216 |
+
}
|
| 217 |
+
}
|
| 218 |
+
|
| 219 |
+
return rewritten;
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
async function fetchText(url: string): Promise<string> {
|
| 223 |
+
const response = await fetch(url, { headers: { Accept: "application/vnd.github+json" } });
|
| 224 |
+
if (!response.ok) {
|
| 225 |
+
throw new Error(`failed to fetch ${url}: HTTP ${response.status}`);
|
| 226 |
+
}
|
| 227 |
+
return await response.text();
|
| 228 |
+
}
|
| 229 |
+
|
| 230 |
+
async function readGistFile(file: GistFile): Promise<string> {
|
| 231 |
+
if (file.content && !file.truncated) return file.content;
|
| 232 |
+
if (!file.raw_url) throw new Error(`gist file ${file.filename ?? "<unknown>"} has no raw URL`);
|
| 233 |
+
return await fetchText(file.raw_url);
|
| 234 |
+
}
|
| 235 |
+
|
| 236 |
+
async function findIssueGistId(owner: string, repo: string, issue: string): Promise<string> {
|
| 237 |
+
const gistIds: string[] = [];
|
| 238 |
+
let page = 1;
|
| 239 |
+
while (true) {
|
| 240 |
+
const response = await fetch(
|
| 241 |
+
`https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues/${encodeURIComponent(issue)}/comments?per_page=100&page=${page}`,
|
| 242 |
+
{ headers: { Accept: "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28" } },
|
| 243 |
+
);
|
| 244 |
+
if (!response.ok) throw new Error(`failed to fetch issue comments: HTTP ${response.status}`);
|
| 245 |
+
|
| 246 |
+
const comments = (await response.json()) as IssueComment[];
|
| 247 |
+
for (const comment of comments) {
|
| 248 |
+
if (comment.user?.login !== "github-actions[bot]") continue;
|
| 249 |
+
for (const match of (comment.body ?? "").matchAll(GIST_URL_IN_TEXT_RE)) {
|
| 250 |
+
gistIds.push(match[1]);
|
| 251 |
+
}
|
| 252 |
+
}
|
| 253 |
+
|
| 254 |
+
if (comments.length < 100) break;
|
| 255 |
+
page++;
|
| 256 |
+
}
|
| 257 |
+
|
| 258 |
+
const gistId = gistIds.at(-1);
|
| 259 |
+
if (!gistId) throw new Error(`no github-actions gist link found in comments on ${owner}/${repo}#${issue}`);
|
| 260 |
+
return gistId;
|
| 261 |
+
}
|
| 262 |
+
|
| 263 |
+
async function fetchGistSession(gistId: string): Promise<{ header: SessionHeader; jsonl: string }> {
|
| 264 |
+
const response = await fetch(`https://api.github.com/gists/${gistId}`, {
|
| 265 |
+
headers: {
|
| 266 |
+
Accept: "application/vnd.github+json",
|
| 267 |
+
"X-GitHub-Api-Version": "2022-11-28",
|
| 268 |
+
},
|
| 269 |
+
});
|
| 270 |
+
if (!response.ok) throw new Error(`failed to fetch gist ${gistId}: HTTP ${response.status}`);
|
| 271 |
+
|
| 272 |
+
const gist = (await response.json()) as GistResponse;
|
| 273 |
+
const files = Object.values(gist.files ?? {});
|
| 274 |
+
const jsonlFile = files.find((file) => file.filename?.endsWith(".jsonl"));
|
| 275 |
+
if (jsonlFile) return parseSessionJsonl(await readGistFile(jsonlFile));
|
| 276 |
+
|
| 277 |
+
const htmlFile = files.find((file) => file.filename?.endsWith(".html"));
|
| 278 |
+
if (htmlFile) return decodeExportedHtml(await readGistFile(htmlFile));
|
| 279 |
+
|
| 280 |
+
throw new Error(`gist ${gistId} has no .jsonl or .html session file`);
|
| 281 |
+
}
|
| 282 |
+
|
| 283 |
+
export default function (pi: ExtensionAPI) {
|
| 284 |
+
pi.registerCommand("ir", {
|
| 285 |
+
description: "Import a CI issue-analysis session from a gist ID, share URL, or issue URL and switch to it",
|
| 286 |
+
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
| 287 |
+
const ref = args.trim();
|
| 288 |
+
if (!ref) {
|
| 289 |
+
ctx.ui.notify("Usage: /ir <gist-id | gist-url | pi.dev/session URL | issue URL>", "error");
|
| 290 |
+
return;
|
| 291 |
+
}
|
| 292 |
+
|
| 293 |
+
try {
|
| 294 |
+
const targetCwd = ctx.sessionManager.getCwd();
|
| 295 |
+
const sessionDir = ctx.sessionManager.getSessionDir();
|
| 296 |
+
const parsedRef = parseRef(ref, targetCwd);
|
| 297 |
+
|
| 298 |
+
ctx.ui.notify(`Importing repro session from ${ref}...`, "info");
|
| 299 |
+
|
| 300 |
+
let sourceName: string;
|
| 301 |
+
let decoded: { header: SessionHeader; jsonl: string };
|
| 302 |
+
if (parsedRef.type === "gist") {
|
| 303 |
+
decoded = await fetchGistSession(parsedRef.id);
|
| 304 |
+
sourceName = `${parsedRef.id}.jsonl`;
|
| 305 |
+
} else if (parsedRef.type === "issue") {
|
| 306 |
+
const gistId = await findIssueGistId(parsedRef.owner, parsedRef.repo, parsedRef.issue);
|
| 307 |
+
decoded = await fetchGistSession(gistId);
|
| 308 |
+
sourceName = `${gistId}.jsonl`;
|
| 309 |
+
} else {
|
| 310 |
+
if (!existsSync(parsedRef.path)) throw new Error(`session file not found: ${parsedRef.path}`);
|
| 311 |
+
const raw = readFileSync(parsedRef.path, "utf8");
|
| 312 |
+
decoded = parsedRef.path.endsWith(".html") ? decodeExportedHtml(raw) : parseSessionJsonl(raw);
|
| 313 |
+
sourceName = basename(parsedRef.path).replace(/\.html$/, ".jsonl");
|
| 314 |
+
}
|
| 315 |
+
|
| 316 |
+
const platformNotice = getPlatformContinuationNotice(decoded.header.cwd);
|
| 317 |
+
const rewritten = rewriteSessionCwd(decoded.jsonl, decoded.header.cwd, targetCwd);
|
| 318 |
+
const destination = join(sessionDir, sourceName);
|
| 319 |
+
if (existsSync(destination)) {
|
| 320 |
+
const overwrite = await ctx.ui.confirm(
|
| 321 |
+
"Session already imported",
|
| 322 |
+
`Overwrite ${destination}? Local changes to that session will be lost.`,
|
| 323 |
+
);
|
| 324 |
+
if (!overwrite) {
|
| 325 |
+
ctx.ui.notify("Import cancelled", "warning");
|
| 326 |
+
return;
|
| 327 |
+
}
|
| 328 |
+
}
|
| 329 |
+
writeFileSync(destination, rewritten);
|
| 330 |
+
|
| 331 |
+
ctx.ui.notify(`Imported session ${decoded.header.id} (cwd ${decoded.header.cwd} -> ${targetCwd})`, "info");
|
| 332 |
+
await ctx.switchSession(destination, {
|
| 333 |
+
withSession: async (nextCtx) => {
|
| 334 |
+
if (!platformNotice) return;
|
| 335 |
+
await nextCtx.sendMessage(
|
| 336 |
+
{
|
| 337 |
+
customType: "import-repro",
|
| 338 |
+
content: platformNotice,
|
| 339 |
+
display: true,
|
| 340 |
+
details: { sourceCwd: decoded.header.cwd, targetCwd },
|
| 341 |
+
},
|
| 342 |
+
{ triggerTurn: false },
|
| 343 |
+
);
|
| 344 |
+
},
|
| 345 |
+
});
|
| 346 |
+
} catch (error) {
|
| 347 |
+
ctx.ui.notify(`ir: ${error instanceof Error ? error.message : String(error)}`, "error");
|
| 348 |
+
}
|
| 349 |
+
},
|
| 350 |
+
});
|
| 351 |
+
}
|
.pi/extensions/prompt-url-widget.ts
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { readFile } from "node:fs/promises";
|
| 2 |
+
import { homedir } from "node:os";
|
| 3 |
+
import { resolve } from "node:path";
|
| 4 |
+
import { DynamicBorder, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
| 5 |
+
import { Container, hyperlink, Text } from "@earendil-works/pi-tui";
|
| 6 |
+
|
| 7 |
+
const PR_PROMPT_PATTERN = /^\s*You are given one or more GitHub PR URLs:\s*(\S+)/im;
|
| 8 |
+
const ISSUE_PROMPT_PATTERN = /^\s*Analyze GitHub issue\(s\):\s*(\S+)/im;
|
| 9 |
+
const ADVISORY_PROMPT_PATTERN = /^\s*Update a GitHub security advisory for publication:\s*(\S+)/im;
|
| 10 |
+
|
| 11 |
+
type PromptMatch = {
|
| 12 |
+
kind: "pr" | "issue" | "advisory";
|
| 13 |
+
target: string;
|
| 14 |
+
};
|
| 15 |
+
|
| 16 |
+
type GhMetadata = {
|
| 17 |
+
title?: string;
|
| 18 |
+
detail?: string;
|
| 19 |
+
displayUrl?: string;
|
| 20 |
+
author?: {
|
| 21 |
+
login?: string;
|
| 22 |
+
name?: string | null;
|
| 23 |
+
};
|
| 24 |
+
};
|
| 25 |
+
|
| 26 |
+
type GitHubAdvisoryMetadata = {
|
| 27 |
+
ghsa_id?: string;
|
| 28 |
+
summary?: string;
|
| 29 |
+
severity?: string;
|
| 30 |
+
state?: string;
|
| 31 |
+
html_url?: string;
|
| 32 |
+
cve_id?: string | null;
|
| 33 |
+
};
|
| 34 |
+
|
| 35 |
+
type AdvisoryRef = {
|
| 36 |
+
owner: string;
|
| 37 |
+
repo: string;
|
| 38 |
+
ghsaId: string;
|
| 39 |
+
url: string;
|
| 40 |
+
};
|
| 41 |
+
|
| 42 |
+
function extractPromptMatch(prompt: string): PromptMatch | undefined {
|
| 43 |
+
const prMatch = prompt.match(PR_PROMPT_PATTERN);
|
| 44 |
+
if (prMatch?.[1]) {
|
| 45 |
+
return { kind: "pr", target: prMatch[1].trim() };
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
const issueMatch = prompt.match(ISSUE_PROMPT_PATTERN);
|
| 49 |
+
if (issueMatch?.[1]) {
|
| 50 |
+
return { kind: "issue", target: issueMatch[1].trim() };
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
const advisoryMatch = prompt.match(ADVISORY_PROMPT_PATTERN);
|
| 54 |
+
if (advisoryMatch?.[1]) {
|
| 55 |
+
return { kind: "advisory", target: advisoryMatch[1].trim() };
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
return undefined;
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
function getPromptLabel(kind: PromptMatch["kind"]): string {
|
| 62 |
+
if (kind === "pr") return "PR";
|
| 63 |
+
if (kind === "issue") return "Issue";
|
| 64 |
+
return "Advisory";
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
function parseAdvisoryUrl(value: string): AdvisoryRef | undefined {
|
| 68 |
+
const match = value.match(
|
| 69 |
+
/^https:\/\/github\.com\/([^/]+)\/([^/]+)\/security\/advisories\/(GHSA-[A-Za-z0-9-]+)(?:[/?#].*)?$/i,
|
| 70 |
+
);
|
| 71 |
+
if (!match?.[1] || !match[2] || !match[3]) return undefined;
|
| 72 |
+
return {
|
| 73 |
+
owner: match[1],
|
| 74 |
+
repo: match[2],
|
| 75 |
+
ghsaId: match[3],
|
| 76 |
+
url: `https://github.com/${match[1]}/${match[2]}/security/advisories/${match[3]}`,
|
| 77 |
+
};
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
function unquoteYamlValue(value: string): string {
|
| 81 |
+
const trimmed = value.trim();
|
| 82 |
+
if (
|
| 83 |
+
(trimmed.startsWith('"') && trimmed.endsWith('"')) ||
|
| 84 |
+
(trimmed.startsWith("'") && trimmed.endsWith("'"))
|
| 85 |
+
) {
|
| 86 |
+
return trimmed.slice(1, -1);
|
| 87 |
+
}
|
| 88 |
+
return trimmed;
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
function resolveDraftPath(cwd: string, target: string): string {
|
| 92 |
+
if (target === "~") return homedir();
|
| 93 |
+
if (target.startsWith("~/")) return resolve(homedir(), target.slice(2));
|
| 94 |
+
return resolve(cwd, target);
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
async function readAdvisoryRefFromDraft(cwd: string, target: string): Promise<AdvisoryRef | undefined> {
|
| 98 |
+
try {
|
| 99 |
+
const content = await readFile(resolveDraftPath(cwd, target), "utf8");
|
| 100 |
+
const frontmatter = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
| 101 |
+
const body = frontmatter?.[1] ?? content;
|
| 102 |
+
const urlMatch = body.match(/^advisory_url:\s*(.+)$/m);
|
| 103 |
+
if (!urlMatch?.[1]) return undefined;
|
| 104 |
+
return parseAdvisoryUrl(unquoteYamlValue(urlMatch[1]));
|
| 105 |
+
} catch {
|
| 106 |
+
return undefined;
|
| 107 |
+
}
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
function formatAdvisoryDetail(advisory: GitHubAdvisoryMetadata): string | undefined {
|
| 111 |
+
const parts = [advisory.ghsa_id, advisory.cve_id ?? undefined, advisory.severity, advisory.state]
|
| 112 |
+
.map((part) => part?.trim())
|
| 113 |
+
.filter((part): part is string => part !== undefined && part.length > 0);
|
| 114 |
+
return parts.length > 0 ? parts.join(" · ") : undefined;
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
async function fetchAdvisoryMetadata(pi: ExtensionAPI, cwd: string, target: string): Promise<GhMetadata | undefined> {
|
| 118 |
+
const advisoryRef = parseAdvisoryUrl(target) ?? (await readAdvisoryRefFromDraft(cwd, target));
|
| 119 |
+
if (!advisoryRef) return undefined;
|
| 120 |
+
|
| 121 |
+
try {
|
| 122 |
+
const result = await pi.exec("gh", [
|
| 123 |
+
"api",
|
| 124 |
+
`repos/${advisoryRef.owner}/${advisoryRef.repo}/security-advisories/${advisoryRef.ghsaId}`,
|
| 125 |
+
]);
|
| 126 |
+
if (result.code !== 0 || !result.stdout) return { displayUrl: advisoryRef.url };
|
| 127 |
+
const advisory = JSON.parse(result.stdout) as GitHubAdvisoryMetadata;
|
| 128 |
+
return {
|
| 129 |
+
title: advisory.summary,
|
| 130 |
+
detail: formatAdvisoryDetail(advisory),
|
| 131 |
+
displayUrl: advisory.html_url ?? advisoryRef.url,
|
| 132 |
+
};
|
| 133 |
+
} catch {
|
| 134 |
+
return { displayUrl: advisoryRef.url };
|
| 135 |
+
}
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
async function fetchGhMetadata(
|
| 139 |
+
pi: ExtensionAPI,
|
| 140 |
+
kind: PromptMatch["kind"],
|
| 141 |
+
target: string,
|
| 142 |
+
cwd: string,
|
| 143 |
+
): Promise<GhMetadata | undefined> {
|
| 144 |
+
if (kind === "advisory") {
|
| 145 |
+
return fetchAdvisoryMetadata(pi, cwd, target);
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
const args =
|
| 149 |
+
kind === "pr"
|
| 150 |
+
? ["pr", "view", target, "--json", "title,author"]
|
| 151 |
+
: ["issue", "view", target, "--json", "title,author"];
|
| 152 |
+
|
| 153 |
+
try {
|
| 154 |
+
const result = await pi.exec("gh", args);
|
| 155 |
+
if (result.code !== 0 || !result.stdout) return undefined;
|
| 156 |
+
return JSON.parse(result.stdout) as GhMetadata;
|
| 157 |
+
} catch {
|
| 158 |
+
return undefined;
|
| 159 |
+
}
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
function formatAuthor(author?: GhMetadata["author"]): string | undefined {
|
| 163 |
+
if (!author) return undefined;
|
| 164 |
+
const name = author.name?.trim();
|
| 165 |
+
const login = author.login?.trim();
|
| 166 |
+
if (name && login) return `${name} (@${login})`;
|
| 167 |
+
if (login) return `@${login}`;
|
| 168 |
+
if (name) return name;
|
| 169 |
+
return undefined;
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
export default function promptUrlWidgetExtension(pi: ExtensionAPI) {
|
| 173 |
+
const setWidget = (ctx: ExtensionContext, match: PromptMatch, metadata?: GhMetadata) => {
|
| 174 |
+
ctx.ui.setWidget("prompt-url", (_tui, thm) => {
|
| 175 |
+
const displayTarget = metadata?.displayUrl ?? match.target;
|
| 176 |
+
const titleText = metadata?.title
|
| 177 |
+
? thm.fg("accent", metadata.title)
|
| 178 |
+
: hyperlink(thm.fg("accent", displayTarget), displayTarget);
|
| 179 |
+
const detailText = metadata?.detail ?? formatAuthor(metadata?.author);
|
| 180 |
+
const detailLine = detailText ? thm.fg("muted", detailText) : undefined;
|
| 181 |
+
const urlLine = hyperlink(thm.fg("dim", displayTarget), displayTarget);
|
| 182 |
+
|
| 183 |
+
const lines = [titleText];
|
| 184 |
+
if (detailLine) lines.push(detailLine);
|
| 185 |
+
lines.push(urlLine);
|
| 186 |
+
|
| 187 |
+
const container = new Container();
|
| 188 |
+
container.addChild(new DynamicBorder((s: string) => thm.fg("muted", s)));
|
| 189 |
+
container.addChild(new Text(lines.join("\n"), 1, 0));
|
| 190 |
+
return container;
|
| 191 |
+
});
|
| 192 |
+
};
|
| 193 |
+
|
| 194 |
+
const applySessionName = (ctx: ExtensionContext, match: PromptMatch, metadata?: GhMetadata) => {
|
| 195 |
+
const label = getPromptLabel(match.kind);
|
| 196 |
+
const displayTarget = metadata?.displayUrl ?? match.target;
|
| 197 |
+
const trimmedTitle = metadata?.title?.trim();
|
| 198 |
+
const fallbackName = `${label}: ${match.target}`;
|
| 199 |
+
const desiredFallbackName = `${label}: ${displayTarget}`;
|
| 200 |
+
const desiredName = trimmedTitle ? `${label}: ${trimmedTitle} (${displayTarget})` : desiredFallbackName;
|
| 201 |
+
const currentName = pi.getSessionName()?.trim();
|
| 202 |
+
if (!currentName) {
|
| 203 |
+
pi.setSessionName(desiredName);
|
| 204 |
+
return;
|
| 205 |
+
}
|
| 206 |
+
if (currentName === match.target || currentName === fallbackName || currentName === desiredFallbackName) {
|
| 207 |
+
pi.setSessionName(desiredName);
|
| 208 |
+
}
|
| 209 |
+
};
|
| 210 |
+
|
| 211 |
+
const updatePromptContext = (ctx: ExtensionContext, match: PromptMatch) => {
|
| 212 |
+
setWidget(ctx, match);
|
| 213 |
+
applySessionName(ctx, match);
|
| 214 |
+
void fetchGhMetadata(pi, match.kind, match.target, ctx.cwd).then((meta) => {
|
| 215 |
+
setWidget(ctx, match, meta);
|
| 216 |
+
applySessionName(ctx, match, meta);
|
| 217 |
+
});
|
| 218 |
+
};
|
| 219 |
+
|
| 220 |
+
pi.on("before_agent_start", async (event, ctx) => {
|
| 221 |
+
if (!ctx.hasUI) return;
|
| 222 |
+
const match = extractPromptMatch(event.prompt);
|
| 223 |
+
if (!match) {
|
| 224 |
+
return;
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
updatePromptContext(ctx, match);
|
| 228 |
+
});
|
| 229 |
+
|
| 230 |
+
pi.on("session_switch", async (_event, ctx) => {
|
| 231 |
+
rebuildFromSession(ctx);
|
| 232 |
+
});
|
| 233 |
+
|
| 234 |
+
const getUserText = (content: string | { type: string; text?: string }[] | undefined): string => {
|
| 235 |
+
if (!content) return "";
|
| 236 |
+
if (typeof content === "string") return content;
|
| 237 |
+
return (
|
| 238 |
+
content
|
| 239 |
+
.filter((block): block is { type: "text"; text: string } => block.type === "text")
|
| 240 |
+
.map((block) => block.text)
|
| 241 |
+
.join("\n") ?? ""
|
| 242 |
+
);
|
| 243 |
+
};
|
| 244 |
+
|
| 245 |
+
const rebuildFromSession = (ctx: ExtensionContext) => {
|
| 246 |
+
if (!ctx.hasUI) return;
|
| 247 |
+
|
| 248 |
+
const entries = ctx.sessionManager.getEntries();
|
| 249 |
+
const lastMatch = [...entries].reverse().find((entry) => {
|
| 250 |
+
if (entry.type !== "message" || entry.message.role !== "user") return false;
|
| 251 |
+
const text = getUserText(entry.message.content);
|
| 252 |
+
return !!extractPromptMatch(text);
|
| 253 |
+
});
|
| 254 |
+
|
| 255 |
+
const content =
|
| 256 |
+
lastMatch?.type === "message" && lastMatch.message.role === "user" ? lastMatch.message.content : undefined;
|
| 257 |
+
const text = getUserText(content);
|
| 258 |
+
const match = text ? extractPromptMatch(text) : undefined;
|
| 259 |
+
if (!match) {
|
| 260 |
+
ctx.ui.setWidget("prompt-url", undefined);
|
| 261 |
+
return;
|
| 262 |
+
}
|
| 263 |
+
|
| 264 |
+
updatePromptContext(ctx, match);
|
| 265 |
+
};
|
| 266 |
+
|
| 267 |
+
pi.on("session_start", async (_event, ctx) => {
|
| 268 |
+
rebuildFromSession(ctx);
|
| 269 |
+
});
|
| 270 |
+
}
|
.pi/extensions/redraws.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Redraws Extension
|
| 3 |
+
*
|
| 4 |
+
* Exposes /tui to show TUI redraw stats.
|
| 5 |
+
*/
|
| 6 |
+
|
| 7 |
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
| 8 |
+
import { Text } from "@earendil-works/pi-tui";
|
| 9 |
+
|
| 10 |
+
export default function (pi: ExtensionAPI) {
|
| 11 |
+
pi.registerCommand("tui", {
|
| 12 |
+
description: "Show TUI stats",
|
| 13 |
+
handler: async (_args, ctx) => {
|
| 14 |
+
if (!ctx.hasUI) return;
|
| 15 |
+
let redraws = 0;
|
| 16 |
+
await ctx.ui.custom<void>((tui, _theme, _keybindings, done) => {
|
| 17 |
+
redraws = tui.fullRedraws;
|
| 18 |
+
done(undefined);
|
| 19 |
+
return new Text("", 0, 0);
|
| 20 |
+
});
|
| 21 |
+
ctx.ui.notify(`TUI full redraws: ${redraws}`, "info");
|
| 22 |
+
},
|
| 23 |
+
});
|
| 24 |
+
}
|
.pi/extensions/tps.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { AssistantMessage } from "@earendil-works/pi-ai";
|
| 2 |
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
| 3 |
+
|
| 4 |
+
function isAssistantMessage(message: unknown): message is AssistantMessage {
|
| 5 |
+
if (!message || typeof message !== "object") return false;
|
| 6 |
+
const role = (message as { role?: unknown }).role;
|
| 7 |
+
return role === "assistant";
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
export default function (pi: ExtensionAPI) {
|
| 11 |
+
let agentStartMs: number | null = null;
|
| 12 |
+
|
| 13 |
+
pi.on("agent_start", () => {
|
| 14 |
+
agentStartMs = Date.now();
|
| 15 |
+
});
|
| 16 |
+
|
| 17 |
+
pi.on("agent_end", (event, ctx) => {
|
| 18 |
+
if (!ctx.hasUI) return;
|
| 19 |
+
if (agentStartMs === null) return;
|
| 20 |
+
|
| 21 |
+
const elapsedMs = Date.now() - agentStartMs;
|
| 22 |
+
agentStartMs = null;
|
| 23 |
+
if (elapsedMs <= 0) return;
|
| 24 |
+
|
| 25 |
+
let input = 0;
|
| 26 |
+
let output = 0;
|
| 27 |
+
let cacheRead = 0;
|
| 28 |
+
let cacheWrite = 0;
|
| 29 |
+
let totalTokens = 0;
|
| 30 |
+
|
| 31 |
+
for (const message of event.messages) {
|
| 32 |
+
if (!isAssistantMessage(message)) continue;
|
| 33 |
+
input += message.usage.input || 0;
|
| 34 |
+
output += message.usage.output || 0;
|
| 35 |
+
cacheRead += message.usage.cacheRead || 0;
|
| 36 |
+
cacheWrite += message.usage.cacheWrite || 0;
|
| 37 |
+
totalTokens += message.usage.totalTokens || 0;
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
if (output <= 0) return;
|
| 41 |
+
|
| 42 |
+
const elapsedSeconds = elapsedMs / 1000;
|
| 43 |
+
const tokensPerSecond = output / elapsedSeconds;
|
| 44 |
+
const message = `TPS ${tokensPerSecond.toFixed(1)} tok/s. out ${output.toLocaleString()}, in ${input.toLocaleString()}, cache r/w ${cacheRead.toLocaleString()}/${cacheWrite.toLocaleString()}, total ${totalTokens.toLocaleString()}, ${elapsedSeconds.toFixed(1)}s`;
|
| 45 |
+
ctx.ui.notify(message, "info");
|
| 46 |
+
});
|
| 47 |
+
}
|
.pi/git/.gitignore
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
*
|
| 2 |
+
!.gitignore
|
.pi/npm/.gitignore
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
*
|
| 2 |
+
!.gitignore
|
.pi/prompts/cl.md
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
description: Audit changelog entries before release
|
| 3 |
+
---
|
| 4 |
+
Audit changelog entries for all commits since the last release.
|
| 5 |
+
|
| 6 |
+
## Process
|
| 7 |
+
|
| 8 |
+
1. **Find the last release tag:**
|
| 9 |
+
```bash
|
| 10 |
+
git tag --sort=-version:refname | head -1
|
| 11 |
+
```
|
| 12 |
+
|
| 13 |
+
2. **List all commits since that tag:**
|
| 14 |
+
```bash
|
| 15 |
+
git log <tag>..HEAD --oneline
|
| 16 |
+
```
|
| 17 |
+
|
| 18 |
+
3. **Read each package's [Unreleased] section:**
|
| 19 |
+
- packages/ai/CHANGELOG.md
|
| 20 |
+
- packages/tui/CHANGELOG.md
|
| 21 |
+
- packages/coding-agent/CHANGELOG.md
|
| 22 |
+
|
| 23 |
+
4. **For each commit, check:**
|
| 24 |
+
- Skip: changelog updates, doc-only changes, release housekeeping
|
| 25 |
+
- Skip: changes to generated model catalogs (for example `packages/ai/src/models.generated.ts`) unless accompanied by an intentional product-facing change in non-generated source/docs.
|
| 26 |
+
- Determine which package(s) the commit affects (use `git show <hash> --stat`)
|
| 27 |
+
- Verify a changelog entry exists in the affected package(s)
|
| 28 |
+
- For external contributions (PRs), verify format: `Description ([#N](url) by [@user](url))`
|
| 29 |
+
|
| 30 |
+
5. **Cross-package duplication rule:**
|
| 31 |
+
Changes in `ai`, `agent` or `tui` that affect end users should be duplicated to `coding-agent` changelog, since coding-agent is the user-facing package that depends on them.
|
| 32 |
+
|
| 33 |
+
6. **Add New Features section after changelog fixes:**
|
| 34 |
+
- Insert a `### New Features` section at the start of `## [Unreleased]` in `packages/coding-agent/CHANGELOG.md`.
|
| 35 |
+
- Propose the top new features to the user for confirmation before writing them.
|
| 36 |
+
- Link to relevant docs and sections whenever possible.
|
| 37 |
+
|
| 38 |
+
7. **Report:**
|
| 39 |
+
- List commits with missing entries
|
| 40 |
+
- List entries that need cross-package duplication
|
| 41 |
+
- Add any missing entries directly
|
| 42 |
+
|
| 43 |
+
## Changelog Format Reference
|
| 44 |
+
|
| 45 |
+
Sections (in order):
|
| 46 |
+
- `### Breaking Changes` - API changes requiring migration
|
| 47 |
+
- `### Added` - New features
|
| 48 |
+
- `### Changed` - Changes to existing functionality
|
| 49 |
+
- `### Fixed` - Bug fixes
|
| 50 |
+
- `### Removed` - Removed features
|
| 51 |
+
|
| 52 |
+
Attribution:
|
| 53 |
+
- Internal: `Fixed foo ([#123](https://github.com/earendil-works/pi/issues/123))`
|
| 54 |
+
- External: `Added bar ([#456](https://github.com/earendil-works/pi/pull/456) by [@user](https://github.com/user))`
|
.pi/prompts/deslop.md
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
description: simplify a completed workpackage
|
| 3 |
+
---
|
| 4 |
+
|
| 5 |
+
Review the completed workpackage and simplify its implementation without changing its intended behavior.
|
| 6 |
+
|
| 7 |
+
## Scope
|
| 8 |
+
|
| 9 |
+
- Focus on code added or affected by the workpackage and the nearby code needed to simplify it coherently.
|
| 10 |
+
- Do not perform unrelated cleanup.
|
| 11 |
+
- Read the affected files in full and understand the relevant invariants, call sites, tests, and public API before editing.
|
| 12 |
+
- Optimize for a direct, coherent, maintainable design—not the smallest diff.
|
| 13 |
+
|
| 14 |
+
## What to remove or simplify
|
| 15 |
+
|
| 16 |
+
- Remove abstractions that have no clear responsibility or do not reduce real complexity.
|
| 17 |
+
- Inline trivial helpers, wrappers, adapters, and pass-through layers when they obscure rather than clarify behavior.
|
| 18 |
+
- Remove duplicated logic and put shared behavior at the appropriate existing layer.
|
| 19 |
+
- Remove speculative flexibility, configuration, extension points, and generic machinery that have no current requirement.
|
| 20 |
+
- Remove speculative defensive programming inside trusted code. Validate at real trust boundaries; rely on types and established invariants internally.
|
| 21 |
+
- Do not keep fallback behavior for states that should be impossible. Prefer fixing the invariant or type model.
|
| 22 |
+
- Simplify excessive state, boolean flags, branching, indirection, and special cases. Rework the underlying representation when that is clearer.
|
| 23 |
+
- Tighten TypeScript types so invalid states are unrepresentable. Avoid `any`, unnecessary assertions, broad types, and optional fields for impossible states.
|
| 24 |
+
- Prefer direct, readable code over cleverness, premature abstraction, and framework-like infrastructure.
|
| 25 |
+
- Delete comments that merely restate the code, but preserve comments that explain constraints, intent, or non-obvious decisions.
|
| 26 |
+
|
| 27 |
+
## Approval gate for significant removals
|
| 28 |
+
|
| 29 |
+
Before making any significant or potentially intentional removal, stop and ask the user for approval. First explain:
|
| 30 |
+
|
| 31 |
+
1. what you propose to remove or change;
|
| 32 |
+
2. why it appears unnecessary or overly defensive;
|
| 33 |
+
3. what behavior, compatibility, extensibility, validation, or failure handling could be affected;
|
| 34 |
+
4. the simpler replacement, if any; and
|
| 35 |
+
5. your recommendation.
|
| 36 |
+
|
| 37 |
+
Wait for explicit approval before applying that change. Do not bundle approval for multiple independent decisions; present them separately when the tradeoffs differ.
|
| 38 |
+
|
| 39 |
+
Treat a decision as significant when it removes or materially changes any of the following:
|
| 40 |
+
|
| 41 |
+
- user-visible behavior or supported workflows;
|
| 42 |
+
- public APIs, persisted formats, protocol behavior, or backward compatibility;
|
| 43 |
+
- validation, authorization, security checks, recovery, retries, fallbacks, or error handling;
|
| 44 |
+
- an abstraction or extension point that appears deliberate or has multiple consumers;
|
| 45 |
+
- functionality covered by tests or documentation;
|
| 46 |
+
- code whose purpose or invariant is uncertain.
|
| 47 |
+
|
| 48 |
+
Routine, behavior-preserving cleanup does not require approval, such as inlining a single-use trivial helper, removing an unreachable branch proven impossible by the type model, or deleting newly added duplication. If uncertain whether a removal is significant, ask.
|
| 49 |
+
|
| 50 |
+
## Constraints
|
| 51 |
+
|
| 52 |
+
- Preserve intended behavior unless the user explicitly approves a change.
|
| 53 |
+
- Do not remove code merely because it is unused until you have checked whether it is a public API, extension point, generated entry point, or intentionally retained compatibility surface.
|
| 54 |
+
- Do not replace clear code with a new abstraction solely to reduce line count.
|
| 55 |
+
- Do not weaken tests to permit simplification. Update tests only when an approved behavior change or a cleaner equivalent structure requires it.
|
| 56 |
+
- Follow the repository's validation and testing instructions after edits.
|
| 57 |
+
|
| 58 |
+
## Completion
|
| 59 |
+
|
| 60 |
+
After editing, review the final diff for avoidable complexity, duplication, indirection, defensive branches, and accidental behavior changes. Report:
|
| 61 |
+
|
| 62 |
+
- what was simplified;
|
| 63 |
+
- which invariants the implementation now relies on;
|
| 64 |
+
- any significant removals you did not make because approval was not granted; and
|
| 65 |
+
- the validation performed and its results.
|
| 66 |
+
|
| 67 |
+
Passing tests and type checks is necessary but not sufficient. Leave the affected code easier to understand and change.
|
.pi/prompts/is.md
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
description: Analyze GitHub issues (bugs or feature requests)
|
| 3 |
+
argument-hint: "<issue>"
|
| 4 |
+
---
|
| 5 |
+
Analyze GitHub issue(s): $ARGUMENTS
|
| 6 |
+
|
| 7 |
+
For each issue:
|
| 8 |
+
|
| 9 |
+
1. If running under CI (`CI=true`), do not add the `inprogress` label and do not assign the issue. Otherwise, add the `inprogress` label to the issue via GitHub CLI and assign the issue to the local `gh` user before analysis starts. If either action fails, report that explicitly and continue.
|
| 10 |
+
2. Read the issue in full, including all comments and linked issues/PRs. Use fields supported by GitHub CLI, for example:
|
| 11 |
+
```sh
|
| 12 |
+
gh issue view <issue> --json title,body,comments,labels,assignees,state,url,author,createdAt,updatedAt,closedByPullRequestsReferences
|
| 13 |
+
```
|
| 14 |
+
3. Do not trust analysis written in the issue. Independently verify behavior and derive your own analysis from the code and execution path.
|
| 15 |
+
|
| 16 |
+
4. **For bugs**:
|
| 17 |
+
- Ignore any root cause analysis in the issue (likely wrong)
|
| 18 |
+
- Read all related code files in full (no truncation)
|
| 19 |
+
- Trace the code path and identify the actual root cause
|
| 20 |
+
- Propose a fix
|
| 21 |
+
|
| 22 |
+
5. **For feature requests**:
|
| 23 |
+
- Do not trust implementation proposals in the issue without verification
|
| 24 |
+
- Read all related code files in full (no truncation)
|
| 25 |
+
- Propose the most concise implementation approach
|
| 26 |
+
- List affected files and changes needed
|
| 27 |
+
|
| 28 |
+
Do NOT implement unless explicitly asked. Analyze and propose only.
|
.pi/prompts/pr.md
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
description: Review PRs from URLs with structured issue and code analysis
|
| 3 |
+
argument-hint: "<PR-URL>"
|
| 4 |
+
---
|
| 5 |
+
You are given one or more GitHub PR URLs: $@
|
| 6 |
+
|
| 7 |
+
For each PR URL, do the following in order:
|
| 8 |
+
1. Add the `inprogress` label to the PR via GitHub CLI before analysis starts. If adding the label fails, report that explicitly and continue.
|
| 9 |
+
2. Read the PR page in full. Include description, all comments, all commits, and all changed files.
|
| 10 |
+
3. Identify any linked issues referenced in the PR body, comments, commit messages, or cross links. Read each issue in full, including all comments.
|
| 11 |
+
4. Analyze the PR diff without checking out or switching to the PR branch. Use `gh pr diff`, `gh pr view`, `gh api`, and local main-branch files; if PR file contents are needed, use fetched refs with `git show <ref>:<path>` or temporary files. Read all relevant code files in full with no truncation and compare against the diff. Do not fetch PR file blobs unless a file is missing on main or the diff context is insufficient. Include related code paths that are not in the diff but are required to validate behavior.
|
| 12 |
+
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.
|
| 13 |
+
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.
|
| 14 |
+
7. Provide a structured review with these sections:
|
| 15 |
+
- What it does: one short paragraph describing the change and its intent.
|
| 16 |
+
- Good: solid choices or improvements.
|
| 17 |
+
- Bad: concrete issues, regressions, missing tests, or risks.
|
| 18 |
+
- Ugly: subtle or high impact problems.
|
| 19 |
+
- Tests: what is covered, what is missing, and whether existing tests are adequate.
|
| 20 |
+
- Open questions for you: only things blocking a merge decision that need the user's input. Omit the section entirely if there are none.
|
| 21 |
+
|
| 22 |
+
Output format per PR:
|
| 23 |
+
PR: <url>
|
| 24 |
+
What it does:
|
| 25 |
+
- ...
|
| 26 |
+
Good:
|
| 27 |
+
- ...
|
| 28 |
+
Bad:
|
| 29 |
+
- ...
|
| 30 |
+
Ugly:
|
| 31 |
+
- ...
|
| 32 |
+
Tests:
|
| 33 |
+
- ...
|
| 34 |
+
Open questions for you:
|
| 35 |
+
- ...
|
| 36 |
+
|
| 37 |
+
If no issues are found, say so under Bad and Ugly.
|
.pi/prompts/sa.md
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
description: Update a GitHub security advisory for publication
|
| 3 |
+
argument-hint: "<advisory-url-or-draft-path>"
|
| 4 |
+
---
|
| 5 |
+
Update a GitHub security advisory for publication: $ARGUMENTS
|
| 6 |
+
|
| 7 |
+
Use `gh` for all GitHub operations. Do not publish the advisory, change its state, or request a CVE unless the user explicitly agrees or the draft markdown explicitly says `request_cve: true`.
|
| 8 |
+
|
| 9 |
+
GitHub does not expose repository security advisory comments/discussion through the documented REST OpenAPI schema or public GraphQL schema. A 404 from guessed API endpoints such as `api.github.com/repos/.../security-advisories/<GHSA>/comments`, `.../timeline`, or `.../events` is expected and is not, by itself, an auth failure. Do not use a browser session, browser cookies, or cookie extraction to fetch advisory comments. Instead, clearly tell the user that advisory comments were not included and that they can paste any relevant comments if they want them considered.
|
| 10 |
+
|
| 11 |
+
## Input handling
|
| 12 |
+
|
| 13 |
+
- If `$ARGUMENTS` is a GitHub security advisory URL, start the investigation and drafting workflow.
|
| 14 |
+
- If `$ARGUMENTS` is a path to an existing markdown draft, read it and apply that draft to the advisory.
|
| 15 |
+
- In a follow-up message after this prompt, if the user says "update", "apply", "looks good", or similar, treat it as approval to apply the previously written temp markdown draft. Re-read the file from disk before updating GitHub.
|
| 16 |
+
- If applying a draft and there is no known draft path, ask the user for the markdown file path.
|
| 17 |
+
|
| 18 |
+
## Initial advisory workflow
|
| 19 |
+
|
| 20 |
+
1. Parse the advisory URL into `owner`, `repo`, and `GHSA` id.
|
| 21 |
+
2. Fetch the advisory with:
|
| 22 |
+
```sh
|
| 23 |
+
gh api repos/<owner>/<repo>/security-advisories/<GHSA>
|
| 24 |
+
```
|
| 25 |
+
Record the advisory's original severity, CVSS vector, and CVSS score exactly as returned before proposing changes.
|
| 26 |
+
3. Do not fetch advisory comments/discussion unless the user pasted them into the conversation:
|
| 27 |
+
- Inspect the advisory JSON for references, credits, linked issues/PRs, and any discussion fields.
|
| 28 |
+
- Do not rely on invented API endpoints such as `/comments`, `/timeline`, or `/events`; they commonly return 404 because GitHub does not expose draft advisory comments through the public API.
|
| 29 |
+
- Do not use a browser session, browser cookies, or cookie extraction to fetch comments.
|
| 30 |
+
- Explicitly tell the user: `Advisory comments were not included because GitHub does not expose them through the public API. Paste any relevant comments if you want them considered.`
|
| 31 |
+
- If the user pasted comments, read and consider them.
|
| 32 |
+
- Never pretend comments were read.
|
| 33 |
+
4. Investigate independently:
|
| 34 |
+
- Read the advisory text, metadata, affected package(s), version ranges, CVSS, CWE, references, and linked issues/PRs/commits.
|
| 35 |
+
- Inspect relevant code history, releases, changelogs, package metadata, and tags.
|
| 36 |
+
- Determine whether the vulnerability is already fixed.
|
| 37 |
+
- If fixed, identify the patched version(s) and the correct affected version range.
|
| 38 |
+
- Do not trust the reporter's analysis without verification.
|
| 39 |
+
5. Discuss CVSS with the user before drafting the final update:
|
| 40 |
+
- Propose a CVSS vector, score, and severity.
|
| 41 |
+
- Explain the controversial metrics briefly.
|
| 42 |
+
- Ask the user to confirm or adjust it.
|
| 43 |
+
6. Ask whether a CVE should be requested from GitHub for this advisory.
|
| 44 |
+
7. Draft a publication-ready advisory markdown file under `/tmp`, for example `/tmp/sa-<GHSA>.md`. Include both the original CVSS from the advisory and the proposed/confirmed updated CVSS.
|
| 45 |
+
8. Tell the user:
|
| 46 |
+
- the path to the temp markdown file
|
| 47 |
+
- the original advisory URL
|
| 48 |
+
- that they can edit the file and then say "update" or provide the path
|
| 49 |
+
|
| 50 |
+
## Draft markdown format
|
| 51 |
+
|
| 52 |
+
The draft file must contain YAML frontmatter followed by the advisory body. Include all fields needed to update GitHub and to decide whether to request a CVE.
|
| 53 |
+
|
| 54 |
+
```markdown
|
| 55 |
+
---
|
| 56 |
+
advisory_url: https://github.com/<owner>/<repo>/security/advisories/<GHSA>
|
| 57 |
+
owner: <owner>
|
| 58 |
+
repo: <repo>
|
| 59 |
+
ghsa_id: <GHSA>
|
| 60 |
+
summary: <short advisory summary>
|
| 61 |
+
original_severity: <low|medium|high|critical|null>
|
| 62 |
+
original_cvss_vector: <original CVSS:3.1/... or null>
|
| 63 |
+
original_cvss_score: <original number or null>
|
| 64 |
+
severity: <proposed/confirmed low|medium|high|critical>
|
| 65 |
+
cvss_vector: <proposed/confirmed CVSS:3.1/...>
|
| 66 |
+
cvss_score: <proposed/confirmed number>
|
| 67 |
+
cwe_ids:
|
| 68 |
+
- CWE-...
|
| 69 |
+
vulnerabilities:
|
| 70 |
+
- package:
|
| 71 |
+
ecosystem: npm
|
| 72 |
+
name: <package-name>
|
| 73 |
+
vulnerable_version_range: <range>
|
| 74 |
+
patched_versions: <range-or-version>
|
| 75 |
+
request_cve: false
|
| 76 |
+
---
|
| 77 |
+
|
| 78 |
+
# <Advisory title>
|
| 79 |
+
|
| 80 |
+
<Concise description of the vulnerability and vulnerable behavior.>
|
| 81 |
+
|
| 82 |
+
## Info
|
| 83 |
+
|
| 84 |
+
<Technical explanation of the root cause and affected component. Focus on facts needed by defenders and maintainers. Do not include PoC steps, exploit payloads, or copy-pastable exploit strings.>
|
| 85 |
+
|
| 86 |
+
## Impact
|
| 87 |
+
|
| 88 |
+
<Who can exploit it, prerequisites, confidentiality/integrity/availability impact, and realistic deployment assumptions.>
|
| 89 |
+
|
| 90 |
+
## Affected versions
|
| 91 |
+
|
| 92 |
+
- Affected: `<range>`
|
| 93 |
+
- Patched: `<version or range>`
|
| 94 |
+
|
| 95 |
+
## The solution
|
| 96 |
+
|
| 97 |
+
<Describe the fix and the patched release.>
|
| 98 |
+
|
| 99 |
+
## Recommendations
|
| 100 |
+
|
| 101 |
+
<Upgrade guidance and operational mitigations.>
|
| 102 |
+
|
| 103 |
+
## Workarounds
|
| 104 |
+
|
| 105 |
+
<Workarounds if any; otherwise skip this section entirely>
|
| 106 |
+
|
| 107 |
+
## Timeline
|
| 108 |
+
|
| 109 |
+
- YYYY-MM-DD: Report received
|
| 110 |
+
- YYYY-MM-DD: Fix committed
|
| 111 |
+
- YYYY-MM-DD: Fixed version released
|
| 112 |
+
- YYYY-MM-DD: Advisory published
|
| 113 |
+
|
| 114 |
+
## Credits
|
| 115 |
+
|
| 116 |
+
<Reporter/researcher attribution if appropriate, otherwise skip section.>
|
| 117 |
+
|
| 118 |
+
## References
|
| 119 |
+
|
| 120 |
+
- <links to releases, commits, advisories, documentation>
|
| 121 |
+
```
|
| 122 |
+
|
| 123 |
+
Use the curl advisory style as inspiration: clear sections, direct language, affected/fixed version facts, recommendations, timeline, and credits. Do not include a PoC.
|
| 124 |
+
|
| 125 |
+
## Applying a draft to GitHub
|
| 126 |
+
|
| 127 |
+
When the user approves with "update"/similar or provides a markdown path:
|
| 128 |
+
|
| 129 |
+
1. Re-read the markdown file from disk. Never rely on the previously generated content in memory.
|
| 130 |
+
2. Parse the YAML frontmatter and body.
|
| 131 |
+
3. Build a JSON payload in a temporary file. Map fields as follows:
|
| 132 |
+
- `summary` from frontmatter
|
| 133 |
+
- `description` from the markdown body after frontmatter
|
| 134 |
+
- `severity` from frontmatter if present
|
| 135 |
+
- `cvss_vector_string` from `cvss_vector`
|
| 136 |
+
- `cwe_ids` from frontmatter
|
| 137 |
+
- `vulnerabilities` from frontmatter
|
| 138 |
+
- Do not send `original_severity`, `original_cvss_vector`, or `original_cvss_score`; those fields are retained only for audit context.
|
| 139 |
+
4. Update the advisory with:
|
| 140 |
+
```sh
|
| 141 |
+
gh api -X PATCH repos/<owner>/<repo>/security-advisories/<GHSA> --input /tmp/<payload>.json
|
| 142 |
+
```
|
| 143 |
+
5. If and only if the markdown frontmatter has `request_cve: true`, request a CVE with:
|
| 144 |
+
```sh
|
| 145 |
+
gh api -X POST repos/<owner>/<repo>/security-advisories/<GHSA>/cve
|
| 146 |
+
```
|
| 147 |
+
Treat "already requested" or "already assigned" as non-fatal and report it.
|
| 148 |
+
6. Report what was updated:
|
| 149 |
+
- advisory URL
|
| 150 |
+
- summary
|
| 151 |
+
- affected range
|
| 152 |
+
- patched versions
|
| 153 |
+
- original CVSS vector/score/severity
|
| 154 |
+
- updated CVSS vector/score/severity
|
| 155 |
+
- whether CVE was requested
|
| 156 |
+
|
| 157 |
+
## Safety rules
|
| 158 |
+
|
| 159 |
+
- Do not include PoC material in the final advisory body.
|
| 160 |
+
- Do not request a CVE unless `request_cve: true` is present in the markdown file.
|
| 161 |
+
- Do not publish the advisory or change its state unless the user explicitly asks.
|
| 162 |
+
- Do not fetch advisory comments through browser sessions or cookies. State that comments were not included and invite the user to paste relevant comments if they want them considered.
|
| 163 |
+
- If there is uncertainty in affected ranges, patched versions, CVSS, or CVE request status, ask the user before applying.
|
.pi/prompts/wr.md
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
description: Finish the current task end-to-end with changelog, commit, and push
|
| 3 |
+
argument-hint: "[instructions]"
|
| 4 |
+
---
|
| 5 |
+
Wrap it.
|
| 6 |
+
|
| 7 |
+
Additional instructions: $ARGUMENTS
|
| 8 |
+
|
| 9 |
+
Determine context from the conversation history first.
|
| 10 |
+
|
| 11 |
+
Rules for context detection:
|
| 12 |
+
- If the conversation already mentions a GitHub issue or PR, use that existing context.
|
| 13 |
+
- 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.
|
| 14 |
+
- If there is no GitHub issue or PR in the conversation history, treat this as non-GitHub work.
|
| 15 |
+
|
| 16 |
+
Unless I explicitly override something in this request, do the following in order:
|
| 17 |
+
|
| 18 |
+
1. Add or update the relevant package changelog entry under `## [Unreleased]` using the repo changelog rules.
|
| 19 |
+
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:
|
| 20 |
+
|
| 21 |
+
```text
|
| 22 |
+
This comment is AI-generated by `/wr`
|
| 23 |
+
```
|
| 24 |
+
3. Commit only files you changed in this session.
|
| 25 |
+
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.
|
| 26 |
+
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.
|
| 27 |
+
6. Push the current branch.
|
| 28 |
+
7. If this task is tied to exactly one GitHub issue, explicitly close that issue with reason `completed` after the push so the issue-close workflows in `.github/` run. This applies to issues only, not PRs.
|
| 29 |
+
- Inspect `gh issue view <issue> --json state,stateReason,labels`.
|
| 30 |
+
- If the issue is open, run `gh issue close <issue> --reason completed`.
|
| 31 |
+
- If the issue is already closed with any reason other than `COMPLETED`, reopen it first, then close it with `gh issue close <issue> --reason completed` so GitHub emits a fresh close event.
|
| 32 |
+
- If the issue is already closed as `COMPLETED`, leave it closed unless the `inprogress` label is still present; in that case reopen it and close it again with reason `completed`.
|
| 33 |
+
|
| 34 |
+
Constraints:
|
| 35 |
+
- Never stage unrelated files.
|
| 36 |
+
- Never use `git add .` or `git add -A`.
|
| 37 |
+
- Run required checks before committing if code changed.
|
| 38 |
+
- Do not open a PR unless I explicitly ask.
|
| 39 |
+
- If this is not GitHub issue or PR work, do not post a GitHub comment.
|
| 40 |
+
- If a final issue or PR comment was already posted in this session, do not post another one unless I explicitly ask.
|
| 41 |
+
- When working against a branch other than `main`, skip the changelog.
|
.pi/skills/add-llm-provider.md
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
name: add-llm-provider
|
| 3 |
+
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.
|
| 4 |
+
---
|
| 5 |
+
|
| 6 |
+
# Adding a New LLM Provider (packages/ai)
|
| 7 |
+
|
| 8 |
+
A new provider touches multiple files. Work through these steps in order.
|
| 9 |
+
|
| 10 |
+
## 1. Core Types (`packages/ai/src/types.ts`)
|
| 11 |
+
|
| 12 |
+
- Add API identifier to `Api` type union (e.g. `"bedrock-converse-stream"`).
|
| 13 |
+
- Create options interface extending `StreamOptions`.
|
| 14 |
+
- Add mapping to `ApiOptionsMap`.
|
| 15 |
+
- Add provider name to `KnownProvider` type union.
|
| 16 |
+
|
| 17 |
+
## 2. Provider Implementation (`packages/ai/src/providers/`)
|
| 18 |
+
|
| 19 |
+
Create a provider file exporting:
|
| 20 |
+
|
| 21 |
+
- `stream<Provider>()` returning `AssistantMessageEventStream`.
|
| 22 |
+
- `streamSimple<Provider>()` for `SimpleStreamOptions` mapping.
|
| 23 |
+
- Provider-specific options interface.
|
| 24 |
+
- Message/tool conversion functions.
|
| 25 |
+
- Response parsing that emits standardized events (`text`, `tool_call`, `thinking`, `usage`, `stop`).
|
| 26 |
+
|
| 27 |
+
## 3. Provider Exports and Lazy Registration
|
| 28 |
+
|
| 29 |
+
- Add a package subpath export in `packages/ai/package.json` pointing at `./dist/providers/<provider>.js`.
|
| 30 |
+
- Add `export type` re-exports in `packages/ai/src/index.ts` for provider option types that should remain available from the root entry.
|
| 31 |
+
- Register the provider in `packages/ai/src/providers/register-builtins.ts` via lazy loader wrappers; do not statically import provider implementation modules there.
|
| 32 |
+
- Add credential detection in `packages/ai/src/env-api-keys.ts`.
|
| 33 |
+
|
| 34 |
+
## 4. Model Generation (`packages/ai/scripts/generate-models.ts`)
|
| 35 |
+
|
| 36 |
+
- Add logic to fetch/parse models from the provider source.
|
| 37 |
+
- Map to the standardized `Model` interface.
|
| 38 |
+
|
| 39 |
+
## 5. Tests (`packages/ai/test/`)
|
| 40 |
+
|
| 41 |
+
- 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`.
|
| 42 |
+
- 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`.
|
| 43 |
+
- 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.
|
| 44 |
+
- For non-standard auth, create a utility (e.g. `bedrock-utils.ts`) with credential detection.
|
| 45 |
+
|
| 46 |
+
## 6. Coding Agent (`packages/coding-agent/`)
|
| 47 |
+
|
| 48 |
+
- `src/core/model-resolver.ts`: add default model ID to `defaultModelPerProvider`.
|
| 49 |
+
- `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.
|
| 50 |
+
- `src/cli/args.ts`: add env var documentation.
|
| 51 |
+
- `README.md`: add provider setup instructions.
|
| 52 |
+
- `docs/providers.md`: add setup instructions, env var, and `auth.json` key.
|
| 53 |
+
|
| 54 |
+
## 7. Documentation
|
| 55 |
+
|
| 56 |
+
- `packages/ai/README.md`: add to providers table, document options/auth, add env vars.
|
| 57 |
+
- `packages/ai/CHANGELOG.md`: add entry under `## [Unreleased]`.
|
.pi/skills/interactive-testing.md
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
name: interactive-testing
|
| 3 |
+
description: Test and debug pi's interactive mode in a controlled tmux terminal. Use for TUI behavior checks and interactive release smoke tests.
|
| 4 |
+
---
|
| 5 |
+
|
| 6 |
+
# Testing pi Interactive Mode with tmux
|
| 7 |
+
|
| 8 |
+
Run the TUI in a controlled terminal (from the repo root, two directories above this skill):
|
| 9 |
+
|
| 10 |
+
```bash
|
| 11 |
+
tmux new-session -d -s pi-test -x 80 -y 24
|
| 12 |
+
tmux send-keys -t pi-test "./pi-test.sh" Enter
|
| 13 |
+
sleep 3 && tmux capture-pane -t pi-test -p # capture after startup
|
| 14 |
+
tmux send-keys -t pi-test "your prompt here" Enter
|
| 15 |
+
tmux send-keys -t pi-test Escape # special keys (also C-o for ctrl+o, etc.)
|
| 16 |
+
tmux kill-session -t pi-test
|
| 17 |
+
```
|
| 18 |
+
|
| 19 |
+
For release smoke tests, start the tmux session with `-c /tmp` and replace `./pi-test.sh` with the absolute path to the release binary. Test both Node and Bun binaries separately, submit a prompt, and wait for the model reply; startup alone is not a passing smoke test.
|
.pi/skills/release.md
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
name: release
|
| 3 |
+
description: Prepare, publish, verify, and recover pi releases. Use for release preparation, local release smoke tests, publishing, and failed release CI or announcements.
|
| 4 |
+
---
|
| 5 |
+
|
| 6 |
+
# Releasing pi
|
| 7 |
+
|
| 8 |
+
Run repository commands from the repo root (two directories above this skill), unless instructed otherwise.
|
| 9 |
+
|
| 10 |
+
**Lockstep versioning**: all packages share one version; every release updates all together. `patch` = fixes + additions, `minor` = breaking changes. No major releases.
|
| 11 |
+
|
| 12 |
+
1. **Update CHANGELOGs**: ask the user whether they ran the `/cl` prompt on the latest commit on `main`. If not, they must run `/cl` first to audit and update each package's `[Unreleased]` section before releasing.
|
| 13 |
+
|
| 14 |
+
2. **Local smoke test**: build an unpublished release and smoke test from outside the repo (so it can't resolve workspace files):
|
| 15 |
+
```bash
|
| 16 |
+
npm run release:local -- --out /tmp/pi-local-release --force
|
| 17 |
+
cd /tmp
|
| 18 |
+
|
| 19 |
+
# Node package install smoke tests
|
| 20 |
+
/tmp/pi-local-release/node/pi --help
|
| 21 |
+
/tmp/pi-local-release/node/pi --version
|
| 22 |
+
/tmp/pi-local-release/node/pi --list-models
|
| 23 |
+
/tmp/pi-local-release/node/pi -p "Say exactly: ok"
|
| 24 |
+
/tmp/pi-local-release/node/pi
|
| 25 |
+
|
| 26 |
+
# Bun binary smoke tests
|
| 27 |
+
/tmp/pi-local-release/bun/pi --help
|
| 28 |
+
/tmp/pi-local-release/bun/pi --version
|
| 29 |
+
/tmp/pi-local-release/bun/pi --list-models
|
| 30 |
+
/tmp/pi-local-release/bun/pi -p "Say exactly: ok"
|
| 31 |
+
/tmp/pi-local-release/bun/pi
|
| 32 |
+
```
|
| 33 |
+
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.
|
| 34 |
+
|
| 35 |
+
Load and follow [interactive-testing.md](interactive-testing.md) for the tmux workflow. Start each release binary from `/tmp`, not the repo root.
|
| 36 |
+
|
| 37 |
+
3. **Run the release script**:
|
| 38 |
+
```bash
|
| 39 |
+
PI_ALLOW_LOCKFILE_CHANGE=1 npm_config_min_release_age=0 npm run release:patch # fixes + additions
|
| 40 |
+
PI_ALLOW_LOCKFILE_CHANGE=1 npm_config_min_release_age=0 npm run release:minor # breaking changes
|
| 41 |
+
```
|
| 42 |
+
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.
|
| 43 |
+
|
| 44 |
+
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.
|
| 45 |
+
|
| 46 |
+
4. **CI verifies and announces the npm release**: 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. After publishing, `announce-pi-dev-release` verifies every public workspace package resolves at the exact release version and that its npm tarball is available, then writes the verified release marker to R2. `pi.dev/api/latest-version` reads that marker; it must never announce a release from npm before this job succeeds.
|
| 47 |
+
|
| 48 |
+
5. **If CI publish or announcement fails**: inspect the failed job. The publish helper is idempotent and skips package versions already present on npm; the announcement job rechecks availability before updating the R2 marker. Rerun the failed job or workflow after fixing CI or transient npm issues. Do not rerun `npm run release:patch` or `npm run release:minor` for the same version.
|
packages/ai/test/anthropic-eager-tool-input-compat.test.ts
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
| 2 |
+
import type { AddressInfo } from "node:net";
|
| 3 |
+
import { Type } from "typebox";
|
| 4 |
+
import { describe, expect, it } from "vitest";
|
| 5 |
+
import { stream as streamAnthropic } from "../src/api/anthropic-messages.ts";
|
| 6 |
+
import type { Context, Model, Tool } from "../src/types.ts";
|
| 7 |
+
|
| 8 |
+
interface CapturedRequest {
|
| 9 |
+
headers: IncomingMessage["headers"];
|
| 10 |
+
body: Record<string, unknown>;
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
function createModel(baseUrl: string, compat?: Model<"anthropic-messages">["compat"]): Model<"anthropic-messages"> {
|
| 14 |
+
return {
|
| 15 |
+
id: "claude-opus-4-8",
|
| 16 |
+
name: "Claude Opus 4.8",
|
| 17 |
+
api: "anthropic-messages",
|
| 18 |
+
provider: "test-anthropic",
|
| 19 |
+
baseUrl,
|
| 20 |
+
reasoning: true,
|
| 21 |
+
input: ["text"],
|
| 22 |
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
| 23 |
+
contextWindow: 200000,
|
| 24 |
+
maxTokens: 32000,
|
| 25 |
+
compat: { forceAdaptiveThinking: true, ...compat },
|
| 26 |
+
};
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
const tool: Tool = {
|
| 30 |
+
name: "lookup",
|
| 31 |
+
description: "Look up a value",
|
| 32 |
+
parameters: Type.Object({ value: Type.String() }),
|
| 33 |
+
};
|
| 34 |
+
|
| 35 |
+
const schemaCompatibilityTool: Tool = {
|
| 36 |
+
...tool,
|
| 37 |
+
parameters: Type.Object({ value: Type.String() }, { additionalProperties: false, title: "LookupInput" }),
|
| 38 |
+
};
|
| 39 |
+
|
| 40 |
+
const strictTool: Tool = {
|
| 41 |
+
...tool,
|
| 42 |
+
parameters: Type.Object(
|
| 43 |
+
{ value: Type.String(), optional: Type.Optional(Type.Number()) },
|
| 44 |
+
{ title: "StrictLookupInput" },
|
| 45 |
+
),
|
| 46 |
+
constrainedSampling: { type: "json_schema", strict: "prefer" },
|
| 47 |
+
};
|
| 48 |
+
|
| 49 |
+
function createContext(tools: Tool[] = [tool]): Context {
|
| 50 |
+
return {
|
| 51 |
+
messages: [{ role: "user", content: "Use the tool", timestamp: Date.now() }],
|
| 52 |
+
...(tools.length > 0 ? { tools } : {}),
|
| 53 |
+
};
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
async function readRequestBody(request: IncomingMessage): Promise<Record<string, unknown>> {
|
| 57 |
+
const chunks: Buffer[] = [];
|
| 58 |
+
for await (const chunk of request) {
|
| 59 |
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
| 60 |
+
}
|
| 61 |
+
return JSON.parse(Buffer.concat(chunks).toString("utf8")) as Record<string, unknown>;
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
function writeEmptySseResponse(response: ServerResponse): void {
|
| 65 |
+
response.writeHead(200, { "content-type": "text/event-stream" });
|
| 66 |
+
response.end();
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
async function captureAnthropicRequest(
|
| 70 |
+
compat: Model<"anthropic-messages">["compat"],
|
| 71 |
+
context: Context,
|
| 72 |
+
): Promise<CapturedRequest> {
|
| 73 |
+
let capturedRequest: CapturedRequest | undefined;
|
| 74 |
+
|
| 75 |
+
const server = createServer(async (request, response) => {
|
| 76 |
+
capturedRequest = {
|
| 77 |
+
headers: request.headers,
|
| 78 |
+
body: await readRequestBody(request),
|
| 79 |
+
};
|
| 80 |
+
writeEmptySseResponse(response);
|
| 81 |
+
});
|
| 82 |
+
|
| 83 |
+
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
| 84 |
+
const address = server.address() as AddressInfo;
|
| 85 |
+
|
| 86 |
+
try {
|
| 87 |
+
const stream = streamAnthropic(createModel(`http://127.0.0.1:${address.port}`, compat), context, {
|
| 88 |
+
apiKey: "test-key",
|
| 89 |
+
cacheRetention: "none",
|
| 90 |
+
});
|
| 91 |
+
|
| 92 |
+
for await (const event of stream) {
|
| 93 |
+
if (event.type === "done" || event.type === "error") break;
|
| 94 |
+
}
|
| 95 |
+
} finally {
|
| 96 |
+
await new Promise<void>((resolve, reject) => {
|
| 97 |
+
server.close((error) => (error ? reject(error) : resolve()));
|
| 98 |
+
});
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
if (!capturedRequest) {
|
| 102 |
+
throw new Error("Anthropic request was not captured");
|
| 103 |
+
}
|
| 104 |
+
return capturedRequest;
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
function getFirstTool(body: Record<string, unknown>): Record<string, unknown> {
|
| 108 |
+
const tools = body.tools;
|
| 109 |
+
if (!Array.isArray(tools) || typeof tools[0] !== "object" || tools[0] === null) {
|
| 110 |
+
throw new Error("Expected first tool in request body");
|
| 111 |
+
}
|
| 112 |
+
return tools[0] as Record<string, unknown>;
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
function getFirstToolInputSchema(body: Record<string, unknown>): Record<string, unknown> {
|
| 116 |
+
const inputSchema = getFirstTool(body).input_schema;
|
| 117 |
+
if (typeof inputSchema !== "object" || inputSchema === null || Array.isArray(inputSchema)) {
|
| 118 |
+
throw new Error("Expected first tool input schema in request body");
|
| 119 |
+
}
|
| 120 |
+
return inputSchema as Record<string, unknown>;
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
describe("Anthropic eager tool input streaming compatibility", () => {
|
| 124 |
+
it("sends per-tool eager_input_streaming by default", async () => {
|
| 125 |
+
const request = await captureAnthropicRequest(undefined, createContext());
|
| 126 |
+
|
| 127 |
+
expect(getFirstTool(request.body).eager_input_streaming).toBe(true);
|
| 128 |
+
expect(request.headers["anthropic-beta"]).toBeUndefined();
|
| 129 |
+
});
|
| 130 |
+
|
| 131 |
+
it("uses the legacy fine-grained tool streaming beta when eager tool input streaming is disabled", async () => {
|
| 132 |
+
const request = await captureAnthropicRequest({ supportsEagerToolInputStreaming: false }, createContext());
|
| 133 |
+
|
| 134 |
+
expect(getFirstTool(request.body).eager_input_streaming).toBeUndefined();
|
| 135 |
+
expect(request.headers["anthropic-beta"]).toBe("fine-grained-tool-streaming-2025-05-14");
|
| 136 |
+
});
|
| 137 |
+
|
| 138 |
+
it("does not send the legacy fine-grained tool streaming beta when there are no tools", async () => {
|
| 139 |
+
const request = await captureAnthropicRequest({ supportsEagerToolInputStreaming: false }, createContext([]));
|
| 140 |
+
|
| 141 |
+
expect(request.body.tools).toBeUndefined();
|
| 142 |
+
expect(request.headers["anthropic-beta"]).toBeUndefined();
|
| 143 |
+
});
|
| 144 |
+
|
| 145 |
+
it("only sends the full input schema for strict JSON-schema tools", async () => {
|
| 146 |
+
const legacyRequest = await captureAnthropicRequest(
|
| 147 |
+
{ supportsStrictTools: true },
|
| 148 |
+
createContext([schemaCompatibilityTool]),
|
| 149 |
+
);
|
| 150 |
+
const parameters = schemaCompatibilityTool.parameters as { properties?: unknown; required?: unknown };
|
| 151 |
+
expect(getFirstToolInputSchema(legacyRequest.body)).toEqual({
|
| 152 |
+
type: "object",
|
| 153 |
+
properties: parameters.properties,
|
| 154 |
+
required: parameters.required,
|
| 155 |
+
});
|
| 156 |
+
|
| 157 |
+
const strictRequest = await captureAnthropicRequest({ supportsStrictTools: true }, createContext([strictTool]));
|
| 158 |
+
expect(getFirstTool(strictRequest.body).strict).toBe(true);
|
| 159 |
+
expect(getFirstToolInputSchema(strictRequest.body)).toMatchObject({
|
| 160 |
+
additionalProperties: false,
|
| 161 |
+
required: ["value", "optional"],
|
| 162 |
+
properties: { optional: { anyOf: [{ type: "number" }, { type: "null" }] } },
|
| 163 |
+
title: "StrictLookupInput",
|
| 164 |
+
});
|
| 165 |
+
});
|
| 166 |
+
});
|
packages/ai/test/bedrock-convert-messages.test.ts
ADDED
|
@@ -0,0 +1,410 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Type } from "typebox";
|
| 2 |
+
import { describe, expect, it, vi } from "vitest";
|
| 3 |
+
|
| 4 |
+
const bedrockMock = vi.hoisted(() => ({
|
| 5 |
+
constructorCalls: [] as Array<Record<string, unknown>>,
|
| 6 |
+
streamEvents: undefined as unknown[] | undefined,
|
| 7 |
+
}));
|
| 8 |
+
|
| 9 |
+
vi.mock("@aws-sdk/client-bedrock-runtime", () => {
|
| 10 |
+
class BedrockRuntimeServiceException extends Error {}
|
| 11 |
+
|
| 12 |
+
class BedrockRuntimeClient {
|
| 13 |
+
constructor(config: Record<string, unknown>) {
|
| 14 |
+
bedrockMock.constructorCalls.push(config);
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
send(): Promise<unknown> {
|
| 18 |
+
if (bedrockMock.streamEvents) {
|
| 19 |
+
const events = bedrockMock.streamEvents;
|
| 20 |
+
return Promise.resolve({
|
| 21 |
+
$metadata: { httpStatusCode: 200 },
|
| 22 |
+
stream: (async function* () {
|
| 23 |
+
yield* events;
|
| 24 |
+
})(),
|
| 25 |
+
});
|
| 26 |
+
}
|
| 27 |
+
return Promise.reject(new Error("mock send"));
|
| 28 |
+
}
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
class ConverseStreamCommand {
|
| 32 |
+
readonly input: unknown;
|
| 33 |
+
|
| 34 |
+
constructor(input: unknown) {
|
| 35 |
+
this.input = input;
|
| 36 |
+
}
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
return {
|
| 40 |
+
BedrockRuntimeClient,
|
| 41 |
+
BedrockRuntimeServiceException,
|
| 42 |
+
ConverseStreamCommand,
|
| 43 |
+
StopReason: {
|
| 44 |
+
END_TURN: "end_turn",
|
| 45 |
+
STOP_SEQUENCE: "stop_sequence",
|
| 46 |
+
MAX_TOKENS: "max_tokens",
|
| 47 |
+
MODEL_CONTEXT_WINDOW_EXCEEDED: "model_context_window_exceeded",
|
| 48 |
+
TOOL_USE: "tool_use",
|
| 49 |
+
},
|
| 50 |
+
CachePointType: { DEFAULT: "default" },
|
| 51 |
+
CacheTTL: { ONE_HOUR: "ONE_HOUR" },
|
| 52 |
+
ConversationRole: { ASSISTANT: "assistant", USER: "user" },
|
| 53 |
+
ImageFormat: { JPEG: "jpeg", PNG: "png", GIF: "gif", WEBP: "webp" },
|
| 54 |
+
ToolResultStatus: { ERROR: "error", SUCCESS: "success" },
|
| 55 |
+
};
|
| 56 |
+
});
|
| 57 |
+
|
| 58 |
+
import { stream as streamBedrock } from "../src/api/bedrock-converse-stream.ts";
|
| 59 |
+
import type { Context, Message, Model } from "../src/types.ts";
|
| 60 |
+
|
| 61 |
+
const baseModel: Model<"bedrock-converse-stream"> = {
|
| 62 |
+
id: "us.anthropic.claude-sonnet-4-5-20250929-v1:0",
|
| 63 |
+
name: "Claude Sonnet 4.5 (US)",
|
| 64 |
+
api: "bedrock-converse-stream",
|
| 65 |
+
provider: "amazon-bedrock",
|
| 66 |
+
baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com",
|
| 67 |
+
reasoning: true,
|
| 68 |
+
input: ["text", "image"],
|
| 69 |
+
cost: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
|
| 70 |
+
contextWindow: 200000,
|
| 71 |
+
maxTokens: 64000,
|
| 72 |
+
compat: { supportsStrictMode: true },
|
| 73 |
+
};
|
| 74 |
+
|
| 75 |
+
const novaModel: Model<"bedrock-converse-stream"> = {
|
| 76 |
+
...baseModel,
|
| 77 |
+
id: "amazon.nova-lite-v1:0",
|
| 78 |
+
name: "Nova Lite",
|
| 79 |
+
reasoning: false,
|
| 80 |
+
compat: undefined,
|
| 81 |
+
};
|
| 82 |
+
|
| 83 |
+
async function capturePayload(context: Context, model = baseModel): Promise<unknown> {
|
| 84 |
+
let capturedPayload: unknown;
|
| 85 |
+
const s = streamBedrock(model, context, {
|
| 86 |
+
cacheRetention: "none",
|
| 87 |
+
signal: AbortSignal.abort(),
|
| 88 |
+
onPayload: (payload) => {
|
| 89 |
+
capturedPayload = payload;
|
| 90 |
+
return payload;
|
| 91 |
+
},
|
| 92 |
+
});
|
| 93 |
+
for await (const event of s) {
|
| 94 |
+
if (event.type === "error") break;
|
| 95 |
+
}
|
| 96 |
+
return capturedPayload;
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
describe("Bedrock constrained sampling", () => {
|
| 100 |
+
it("gates native strict tool use by model capability", async () => {
|
| 101 |
+
const context: Context = {
|
| 102 |
+
messages: [{ role: "user", content: "Use the tool", timestamp: Date.now() }],
|
| 103 |
+
tools: [
|
| 104 |
+
{
|
| 105 |
+
name: "lookup",
|
| 106 |
+
description: "Look up a value",
|
| 107 |
+
parameters: Type.Object({ value: Type.String() }),
|
| 108 |
+
constrainedSampling: { type: "json_schema", strict: "require" },
|
| 109 |
+
},
|
| 110 |
+
],
|
| 111 |
+
};
|
| 112 |
+
const payload = await capturePayload(context);
|
| 113 |
+
const toolConfig = (payload as { toolConfig: { tools: Array<{ toolSpec: { strict?: boolean } }> } }).toolConfig;
|
| 114 |
+
expect(toolConfig.tools[0].toolSpec.strict).toBe(true);
|
| 115 |
+
|
| 116 |
+
context.tools![0].constrainedSampling = { type: "json_schema", strict: "prefer" };
|
| 117 |
+
const novaPayload = await capturePayload(context, novaModel);
|
| 118 |
+
const novaToolConfig = (
|
| 119 |
+
novaPayload as {
|
| 120 |
+
toolConfig: { tools: Array<{ toolSpec: { strict?: boolean } }> };
|
| 121 |
+
}
|
| 122 |
+
).toolConfig;
|
| 123 |
+
expect(novaToolConfig.tools[0].toolSpec.strict).toBeUndefined();
|
| 124 |
+
});
|
| 125 |
+
});
|
| 126 |
+
|
| 127 |
+
describe("Bedrock tool arguments", () => {
|
| 128 |
+
it("preserves empty property names in streamed tool arguments", async () => {
|
| 129 |
+
bedrockMock.streamEvents = [
|
| 130 |
+
{ messageStart: { role: "assistant" } },
|
| 131 |
+
{
|
| 132 |
+
contentBlockStart: {
|
| 133 |
+
contentBlockIndex: 0,
|
| 134 |
+
start: { toolUse: { toolUseId: "tool-1", name: "edit" } },
|
| 135 |
+
},
|
| 136 |
+
},
|
| 137 |
+
{
|
| 138 |
+
contentBlockDelta: {
|
| 139 |
+
contentBlockIndex: 0,
|
| 140 |
+
delta: {
|
| 141 |
+
toolUse: {
|
| 142 |
+
input: '{"path":"/workspace/foobar/file.js","edits":[{"oldText":"first","newText":"updated first"},{"oldText":"second","newText":"updated second","":""}]}',
|
| 143 |
+
},
|
| 144 |
+
},
|
| 145 |
+
},
|
| 146 |
+
},
|
| 147 |
+
{ contentBlockStop: { contentBlockIndex: 0 } },
|
| 148 |
+
{ messageStop: { stopReason: "tool_use" } },
|
| 149 |
+
];
|
| 150 |
+
|
| 151 |
+
try {
|
| 152 |
+
const message = await streamBedrock(
|
| 153 |
+
baseModel,
|
| 154 |
+
{ messages: [{ role: "user", content: "Use the tool", timestamp: Date.now() }] },
|
| 155 |
+
{ cacheRetention: "none" },
|
| 156 |
+
).result();
|
| 157 |
+
|
| 158 |
+
expect(message.content[0]).toEqual({
|
| 159 |
+
type: "toolCall",
|
| 160 |
+
id: "tool-1",
|
| 161 |
+
name: "edit",
|
| 162 |
+
arguments: {
|
| 163 |
+
path: "/workspace/foobar/file.js",
|
| 164 |
+
edits: [
|
| 165 |
+
{ oldText: "first", newText: "updated first" },
|
| 166 |
+
{ oldText: "second", newText: "updated second", "": "" },
|
| 167 |
+
],
|
| 168 |
+
},
|
| 169 |
+
});
|
| 170 |
+
} finally {
|
| 171 |
+
bedrockMock.streamEvents = undefined;
|
| 172 |
+
}
|
| 173 |
+
});
|
| 174 |
+
});
|
| 175 |
+
|
| 176 |
+
describe("bedrock convertMessages skips unknown content types", () => {
|
| 177 |
+
it("skips unknown user content blocks instead of throwing", async () => {
|
| 178 |
+
const messages: Message[] = [
|
| 179 |
+
{
|
| 180 |
+
role: "user",
|
| 181 |
+
content: [
|
| 182 |
+
{ type: "text", text: "hello" },
|
| 183 |
+
{ type: "unknown", data: "foo" },
|
| 184 |
+
] as any,
|
| 185 |
+
timestamp: Date.now(),
|
| 186 |
+
},
|
| 187 |
+
];
|
| 188 |
+
const payload = await capturePayload({ messages });
|
| 189 |
+
expect(payload).toBeDefined();
|
| 190 |
+
const p = payload as { messages: Array<{ role: string; content: unknown[] }> };
|
| 191 |
+
expect(p.messages).toHaveLength(1);
|
| 192 |
+
expect(p.messages[0].content).toHaveLength(1);
|
| 193 |
+
expect(p.messages[0].content[0]).toEqual({ text: "hello" });
|
| 194 |
+
});
|
| 195 |
+
|
| 196 |
+
it("skips unknown assistant content blocks instead of throwing", async () => {
|
| 197 |
+
const messages: Message[] = [
|
| 198 |
+
{
|
| 199 |
+
role: "assistant",
|
| 200 |
+
content: [
|
| 201 |
+
{ type: "text", text: "hello" },
|
| 202 |
+
{ type: "unknown", data: "foo" },
|
| 203 |
+
] as any,
|
| 204 |
+
api: "bedrock-converse-stream",
|
| 205 |
+
provider: "amazon-bedrock",
|
| 206 |
+
model: baseModel.id,
|
| 207 |
+
usage: {
|
| 208 |
+
input: 0,
|
| 209 |
+
output: 0,
|
| 210 |
+
cacheRead: 0,
|
| 211 |
+
cacheWrite: 0,
|
| 212 |
+
totalTokens: 0,
|
| 213 |
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
| 214 |
+
},
|
| 215 |
+
stopReason: "stop",
|
| 216 |
+
timestamp: Date.now(),
|
| 217 |
+
},
|
| 218 |
+
];
|
| 219 |
+
const payload = await capturePayload({ messages });
|
| 220 |
+
expect(payload).toBeDefined();
|
| 221 |
+
const p = payload as { messages: Array<{ role: string; content: unknown[] }> };
|
| 222 |
+
expect(p.messages).toHaveLength(1);
|
| 223 |
+
expect(p.messages[0].content).toHaveLength(1);
|
| 224 |
+
expect(p.messages[0].content[0]).toEqual({ text: "hello" });
|
| 225 |
+
});
|
| 226 |
+
|
| 227 |
+
it("replaces user messages with only unknown content blocks with a placeholder", async () => {
|
| 228 |
+
const messages: Message[] = [
|
| 229 |
+
{
|
| 230 |
+
role: "user",
|
| 231 |
+
content: [{ type: "unknown", data: "foo" }] as any,
|
| 232 |
+
timestamp: Date.now(),
|
| 233 |
+
},
|
| 234 |
+
];
|
| 235 |
+
const payload = await capturePayload({ messages });
|
| 236 |
+
expect(payload).toBeDefined();
|
| 237 |
+
const p = payload as { messages: Array<{ role: string; content: unknown[] }> };
|
| 238 |
+
expect(p.messages).toHaveLength(1);
|
| 239 |
+
expect(p.messages[0].content).toEqual([{ text: "<empty>" }]);
|
| 240 |
+
});
|
| 241 |
+
|
| 242 |
+
it("replaces blank user string content with a placeholder", async () => {
|
| 243 |
+
const payload = await capturePayload({
|
| 244 |
+
messages: [{ role: "user", content: " ", timestamp: Date.now() }],
|
| 245 |
+
});
|
| 246 |
+
expect(payload).toBeDefined();
|
| 247 |
+
const p = payload as { messages: Array<{ role: string; content: unknown[] }> };
|
| 248 |
+
expect(p.messages).toHaveLength(1);
|
| 249 |
+
expect(p.messages[0].content).toEqual([{ text: "<empty>" }]);
|
| 250 |
+
});
|
| 251 |
+
|
| 252 |
+
it("filters blank user text blocks when other content remains", async () => {
|
| 253 |
+
const payload = await capturePayload({
|
| 254 |
+
messages: [
|
| 255 |
+
{
|
| 256 |
+
role: "user",
|
| 257 |
+
content: [
|
| 258 |
+
{ type: "text", text: "" },
|
| 259 |
+
{ type: "text", text: "hello" },
|
| 260 |
+
],
|
| 261 |
+
timestamp: Date.now(),
|
| 262 |
+
},
|
| 263 |
+
],
|
| 264 |
+
});
|
| 265 |
+
expect(payload).toBeDefined();
|
| 266 |
+
const p = payload as { messages: Array<{ role: string; content: unknown[] }> };
|
| 267 |
+
expect(p.messages).toHaveLength(1);
|
| 268 |
+
expect(p.messages[0].content).toEqual([{ text: "hello" }]);
|
| 269 |
+
});
|
| 270 |
+
|
| 271 |
+
it("replaces user content emptied by surrogate sanitization with a placeholder", async () => {
|
| 272 |
+
const payload = await capturePayload({
|
| 273 |
+
messages: [{ role: "user", content: String.fromCharCode(0xd83d), timestamp: Date.now() }],
|
| 274 |
+
});
|
| 275 |
+
expect(payload).toBeDefined();
|
| 276 |
+
const p = payload as { messages: Array<{ role: string; content: unknown[] }> };
|
| 277 |
+
expect(p.messages).toHaveLength(1);
|
| 278 |
+
expect(p.messages[0].content).toEqual([{ text: "<empty>" }]);
|
| 279 |
+
});
|
| 280 |
+
|
| 281 |
+
it("skips assistant text blocks emptied by surrogate sanitization", async () => {
|
| 282 |
+
const messages: Message[] = [
|
| 283 |
+
{
|
| 284 |
+
role: "assistant",
|
| 285 |
+
content: [{ type: "text", text: String.fromCharCode(0xd83d) }],
|
| 286 |
+
api: "bedrock-converse-stream",
|
| 287 |
+
provider: "amazon-bedrock",
|
| 288 |
+
model: baseModel.id,
|
| 289 |
+
usage: {
|
| 290 |
+
input: 0,
|
| 291 |
+
output: 0,
|
| 292 |
+
cacheRead: 0,
|
| 293 |
+
cacheWrite: 0,
|
| 294 |
+
totalTokens: 0,
|
| 295 |
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
| 296 |
+
},
|
| 297 |
+
stopReason: "stop",
|
| 298 |
+
timestamp: Date.now(),
|
| 299 |
+
},
|
| 300 |
+
];
|
| 301 |
+
const payload = await capturePayload({ messages });
|
| 302 |
+
expect(payload).toBeDefined();
|
| 303 |
+
const p = payload as { messages: Array<{ role: string; content: unknown[] }> };
|
| 304 |
+
expect(p.messages).toHaveLength(0);
|
| 305 |
+
});
|
| 306 |
+
|
| 307 |
+
it("replaces blank tool result content with a placeholder", async () => {
|
| 308 |
+
const messages: Message[] = [
|
| 309 |
+
{
|
| 310 |
+
role: "toolResult",
|
| 311 |
+
toolCallId: "tool-1",
|
| 312 |
+
toolName: "tool",
|
| 313 |
+
content: [{ type: "text", text: "" }],
|
| 314 |
+
isError: false,
|
| 315 |
+
timestamp: Date.now(),
|
| 316 |
+
},
|
| 317 |
+
];
|
| 318 |
+
const payload = await capturePayload({ messages });
|
| 319 |
+
expect(payload).toBeDefined();
|
| 320 |
+
const p = payload as {
|
| 321 |
+
messages: Array<{ role: string; content: Array<{ toolResult: { content: unknown[] } }> }>;
|
| 322 |
+
};
|
| 323 |
+
expect(p.messages).toHaveLength(1);
|
| 324 |
+
expect(p.messages[0].content[0].toolResult.content).toEqual([{ text: "<empty>" }]);
|
| 325 |
+
});
|
| 326 |
+
|
| 327 |
+
it("skips assistant messages with only unknown content blocks", async () => {
|
| 328 |
+
const messages: Message[] = [
|
| 329 |
+
{
|
| 330 |
+
role: "assistant",
|
| 331 |
+
content: [{ type: "unknown", data: "foo" }] as any,
|
| 332 |
+
api: "bedrock-converse-stream",
|
| 333 |
+
provider: "amazon-bedrock",
|
| 334 |
+
model: baseModel.id,
|
| 335 |
+
usage: {
|
| 336 |
+
input: 0,
|
| 337 |
+
output: 0,
|
| 338 |
+
cacheRead: 0,
|
| 339 |
+
cacheWrite: 0,
|
| 340 |
+
totalTokens: 0,
|
| 341 |
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
| 342 |
+
},
|
| 343 |
+
stopReason: "stop",
|
| 344 |
+
timestamp: Date.now(),
|
| 345 |
+
},
|
| 346 |
+
];
|
| 347 |
+
const payload = await capturePayload({ messages });
|
| 348 |
+
expect(payload).toBeDefined();
|
| 349 |
+
const p = payload as { messages: Array<{ role: string; content: unknown[] }> };
|
| 350 |
+
expect(p.messages).toHaveLength(0);
|
| 351 |
+
});
|
| 352 |
+
|
| 353 |
+
it("removes empty property names only from replayed Bedrock input", async () => {
|
| 354 |
+
const toolArguments = {
|
| 355 |
+
path: "/workspace/foobar/file.js",
|
| 356 |
+
edits: [
|
| 357 |
+
{ oldText: "first", newText: "updated first" },
|
| 358 |
+
{ oldText: "second", newText: "updated second", "": "" },
|
| 359 |
+
],
|
| 360 |
+
};
|
| 361 |
+
const messages: Message[] = [
|
| 362 |
+
{
|
| 363 |
+
role: "assistant",
|
| 364 |
+
content: [
|
| 365 |
+
{
|
| 366 |
+
type: "toolCall",
|
| 367 |
+
id: "tool-1",
|
| 368 |
+
name: "edit",
|
| 369 |
+
arguments: toolArguments,
|
| 370 |
+
},
|
| 371 |
+
],
|
| 372 |
+
api: "bedrock-converse-stream",
|
| 373 |
+
provider: "amazon-bedrock",
|
| 374 |
+
model: baseModel.id,
|
| 375 |
+
usage: {
|
| 376 |
+
input: 0,
|
| 377 |
+
output: 0,
|
| 378 |
+
cacheRead: 0,
|
| 379 |
+
cacheWrite: 0,
|
| 380 |
+
totalTokens: 0,
|
| 381 |
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
| 382 |
+
},
|
| 383 |
+
stopReason: "toolUse",
|
| 384 |
+
timestamp: Date.now(),
|
| 385 |
+
},
|
| 386 |
+
{
|
| 387 |
+
role: "toolResult",
|
| 388 |
+
toolCallId: "tool-1",
|
| 389 |
+
toolName: "edit",
|
| 390 |
+
content: [{ type: "text", text: "done" }],
|
| 391 |
+
isError: false,
|
| 392 |
+
timestamp: Date.now(),
|
| 393 |
+
},
|
| 394 |
+
{ role: "user", content: "Continue", timestamp: Date.now() },
|
| 395 |
+
];
|
| 396 |
+
|
| 397 |
+
const payload = await capturePayload({ messages });
|
| 398 |
+
const p = payload as {
|
| 399 |
+
messages: Array<{ content: Array<{ toolUse?: { input: unknown } }> }>;
|
| 400 |
+
};
|
| 401 |
+
expect(p.messages[0].content[0].toolUse?.input).toEqual({
|
| 402 |
+
path: "/workspace/foobar/file.js",
|
| 403 |
+
edits: [
|
| 404 |
+
{ oldText: "first", newText: "updated first" },
|
| 405 |
+
{ oldText: "second", newText: "updated second" },
|
| 406 |
+
],
|
| 407 |
+
});
|
| 408 |
+
expect(toolArguments.edits[1]).toEqual({ oldText: "second", newText: "updated second", "": "" });
|
| 409 |
+
});
|
| 410 |
+
});
|
packages/ai/test/bedrock-error-metadata.test.ts
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
| 2 |
+
|
| 3 |
+
type SendResult = { kind: "reject"; error: unknown } | { kind: "resolve"; response: unknown };
|
| 4 |
+
|
| 5 |
+
const bedrockMock = vi.hoisted(() => ({
|
| 6 |
+
send: undefined as SendResult | undefined,
|
| 7 |
+
// Exposed so tests can build errors that are real instances of the mocked base class.
|
| 8 |
+
ServiceException: undefined as unknown as new (message?: string) => Error,
|
| 9 |
+
}));
|
| 10 |
+
|
| 11 |
+
vi.mock("@aws-sdk/client-bedrock-runtime", () => {
|
| 12 |
+
class BedrockRuntimeServiceException extends Error {}
|
| 13 |
+
bedrockMock.ServiceException = BedrockRuntimeServiceException;
|
| 14 |
+
|
| 15 |
+
class BedrockRuntimeClient {
|
| 16 |
+
middlewareStack = { add: () => {} };
|
| 17 |
+
|
| 18 |
+
send(): Promise<unknown> {
|
| 19 |
+
const outcome = bedrockMock.send;
|
| 20 |
+
if (!outcome) return Promise.reject(new Error("test did not configure a send outcome"));
|
| 21 |
+
return outcome.kind === "reject" ? Promise.reject(outcome.error) : Promise.resolve(outcome.response);
|
| 22 |
+
}
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
class ConverseStreamCommand {
|
| 26 |
+
readonly input: unknown;
|
| 27 |
+
constructor(input: unknown) {
|
| 28 |
+
this.input = input;
|
| 29 |
+
}
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
return {
|
| 33 |
+
BedrockRuntimeClient,
|
| 34 |
+
BedrockRuntimeServiceException,
|
| 35 |
+
ConverseStreamCommand,
|
| 36 |
+
StopReason: {
|
| 37 |
+
END_TURN: "end_turn",
|
| 38 |
+
STOP_SEQUENCE: "stop_sequence",
|
| 39 |
+
MAX_TOKENS: "max_tokens",
|
| 40 |
+
MODEL_CONTEXT_WINDOW_EXCEEDED: "model_context_window_exceeded",
|
| 41 |
+
TOOL_USE: "tool_use",
|
| 42 |
+
},
|
| 43 |
+
CachePointType: { DEFAULT: "default" },
|
| 44 |
+
CacheTTL: { ONE_HOUR: "ONE_HOUR" },
|
| 45 |
+
ConversationRole: { ASSISTANT: "assistant", USER: "user" },
|
| 46 |
+
ImageFormat: { JPEG: "jpeg", PNG: "png", GIF: "gif", WEBP: "webp" },
|
| 47 |
+
ToolResultStatus: { ERROR: "error", SUCCESS: "success" },
|
| 48 |
+
};
|
| 49 |
+
});
|
| 50 |
+
|
| 51 |
+
import { stream as streamBedrock } from "../src/api/bedrock-converse-stream.ts";
|
| 52 |
+
import { getModel } from "../src/compat.ts";
|
| 53 |
+
import type { AssistantMessage, Context, Model } from "../src/types.ts";
|
| 54 |
+
import type { AssistantMessageDiagnostic } from "../src/utils/diagnostics.ts";
|
| 55 |
+
|
| 56 |
+
const DIAGNOSTIC_TYPE = "bedrock_response_failure";
|
| 57 |
+
const VALIDATION_MESSAGE = "The provided model identifier is invalid.";
|
| 58 |
+
const REQUEST_ID = "11111111-2222-3333-4444-555555555555";
|
| 59 |
+
|
| 60 |
+
const context: Context = {
|
| 61 |
+
messages: [{ role: "user", content: "hello", timestamp: Date.now() }],
|
| 62 |
+
};
|
| 63 |
+
|
| 64 |
+
function getModelFixture(): Model<"bedrock-converse-stream"> {
|
| 65 |
+
return getModel("amazon-bedrock", "us.anthropic.claude-opus-4-8");
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
/** What the SDK's `handleError` path throws for a non-2xx response; `name` is the modeled AWS error code. */
|
| 69 |
+
function makeServiceException(name: string, extra: Record<string, unknown> = {}): Error {
|
| 70 |
+
const error = new bedrockMock.ServiceException(VALIDATION_MESSAGE) as Error & Record<string, unknown>;
|
| 71 |
+
error.name = name;
|
| 72 |
+
Object.assign(error, extra);
|
| 73 |
+
return error;
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
/** Fails after `messageStart`, throwing from inside the iterator like `getMessageUnmarshaller` does. */
|
| 77 |
+
function respondWithFailingStream(thrown: unknown): SendResult {
|
| 78 |
+
return {
|
| 79 |
+
kind: "resolve",
|
| 80 |
+
response: {
|
| 81 |
+
$metadata: { httpStatusCode: 200, requestId: REQUEST_ID },
|
| 82 |
+
stream: (async function* () {
|
| 83 |
+
yield { messageStart: { role: "assistant" } };
|
| 84 |
+
throw thrown;
|
| 85 |
+
})(),
|
| 86 |
+
},
|
| 87 |
+
};
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
async function runBedrock(signal?: AbortSignal): Promise<AssistantMessage> {
|
| 91 |
+
return streamBedrock(getModelFixture(), context, { cacheRetention: "none", signal }).result();
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
function findDiagnostic(message: AssistantMessage): AssistantMessageDiagnostic | undefined {
|
| 95 |
+
return message.diagnostics?.find((d) => d.type === DIAGNOSTIC_TYPE);
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
beforeEach(() => {
|
| 99 |
+
bedrockMock.send = undefined;
|
| 100 |
+
});
|
| 101 |
+
|
| 102 |
+
describe("bedrock failure diagnostics", () => {
|
| 103 |
+
it("records status, error code and request id for a non-2xx from client.send()", async () => {
|
| 104 |
+
bedrockMock.send = {
|
| 105 |
+
kind: "reject",
|
| 106 |
+
error: makeServiceException("ValidationException", {
|
| 107 |
+
$metadata: { httpStatusCode: 400, requestId: REQUEST_ID },
|
| 108 |
+
}),
|
| 109 |
+
};
|
| 110 |
+
|
| 111 |
+
const message = await runBedrock();
|
| 112 |
+
const diagnostic = findDiagnostic(message);
|
| 113 |
+
|
| 114 |
+
expect(message.stopReason).toBe("error");
|
| 115 |
+
expect(diagnostic?.details).toEqual({ status: 400, errorCode: "ValidationException", requestId: REQUEST_ID });
|
| 116 |
+
expect(diagnostic?.error).toBeUndefined();
|
| 117 |
+
expect(Object.keys(diagnostic ?? {}).sort()).toEqual(["details", "timestamp", "type"]);
|
| 118 |
+
});
|
| 119 |
+
|
| 120 |
+
it("leaves errorMessage untouched so retry classification is unaffected", async () => {
|
| 121 |
+
bedrockMock.send = {
|
| 122 |
+
kind: "reject",
|
| 123 |
+
error: makeServiceException("ValidationException", {
|
| 124 |
+
$metadata: { httpStatusCode: 400, requestId: REQUEST_ID },
|
| 125 |
+
$response: { body: { pipe: () => {} } },
|
| 126 |
+
}),
|
| 127 |
+
};
|
| 128 |
+
|
| 129 |
+
expect((await runBedrock()).errorMessage).toBe(`Validation error: ${VALIDATION_MESSAGE}`);
|
| 130 |
+
});
|
| 131 |
+
|
| 132 |
+
it("reports only the request id for a modeled mid-stream exception", async () => {
|
| 133 |
+
// The SDK throws a bare object literal here, so the code is genuinely unavailable.
|
| 134 |
+
bedrockMock.send = respondWithFailingStream({ message: "Too many requests, please wait." });
|
| 135 |
+
|
| 136 |
+
const message = await runBedrock();
|
| 137 |
+
|
| 138 |
+
expect(message.stopReason).toBe("error");
|
| 139 |
+
expect(findDiagnostic(message)?.details).toEqual({ requestId: REQUEST_ID });
|
| 140 |
+
});
|
| 141 |
+
|
| 142 |
+
it("captures the error code for an unmodeled mid-stream error", async () => {
|
| 143 |
+
// This branch throws a real `Error` named after the frame's `:error-code`.
|
| 144 |
+
const unmodeled = new Error("Model stream terminated unexpectedly.");
|
| 145 |
+
unmodeled.name = "ModelStreamErrorException";
|
| 146 |
+
bedrockMock.send = respondWithFailingStream(unmodeled);
|
| 147 |
+
|
| 148 |
+
expect(findDiagnostic(await runBedrock())?.details).toEqual({
|
| 149 |
+
errorCode: "ModelStreamErrorException",
|
| 150 |
+
requestId: REQUEST_ID,
|
| 151 |
+
});
|
| 152 |
+
});
|
| 153 |
+
|
| 154 |
+
it("does not report a transport failure name as a provider error code", async () => {
|
| 155 |
+
// Real `Error`s with informative names, but not AWS codes; modeled ones end in "Exception".
|
| 156 |
+
const timeout = new Error("Connection timed out after 1000 ms");
|
| 157 |
+
timeout.name = "TimeoutError";
|
| 158 |
+
bedrockMock.send = respondWithFailingStream(timeout);
|
| 159 |
+
|
| 160 |
+
expect(findDiagnostic(await runBedrock())?.details).toEqual({ requestId: REQUEST_ID });
|
| 161 |
+
});
|
| 162 |
+
|
| 163 |
+
it("emits no diagnostic when the failure carries no provider metadata", async () => {
|
| 164 |
+
bedrockMock.send = { kind: "reject", error: new Error("socket hang up") };
|
| 165 |
+
|
| 166 |
+
const message = await runBedrock();
|
| 167 |
+
|
| 168 |
+
expect(message.stopReason).toBe("error");
|
| 169 |
+
expect(message.errorMessage).toBe("socket hang up");
|
| 170 |
+
expect(findDiagnostic(message)).toBeUndefined();
|
| 171 |
+
});
|
| 172 |
+
|
| 173 |
+
it("emits no diagnostic for an aborted turn", async () => {
|
| 174 |
+
const controller = new AbortController();
|
| 175 |
+
controller.abort();
|
| 176 |
+
bedrockMock.send = {
|
| 177 |
+
kind: "reject",
|
| 178 |
+
error: makeServiceException("ValidationException", {
|
| 179 |
+
$metadata: { httpStatusCode: 400, requestId: REQUEST_ID },
|
| 180 |
+
}),
|
| 181 |
+
};
|
| 182 |
+
|
| 183 |
+
const message = await runBedrock(controller.signal);
|
| 184 |
+
|
| 185 |
+
expect(message.stopReason).toBe("aborted");
|
| 186 |
+
expect(findDiagnostic(message)).toBeUndefined();
|
| 187 |
+
});
|
| 188 |
+
|
| 189 |
+
it("drops header-derived values that exceed the length bound", async () => {
|
| 190 |
+
bedrockMock.send = {
|
| 191 |
+
kind: "reject",
|
| 192 |
+
error: makeServiceException(`${"E".repeat(5000)}Exception`, {
|
| 193 |
+
$metadata: { httpStatusCode: 400, requestId: "R".repeat(5000) },
|
| 194 |
+
}),
|
| 195 |
+
};
|
| 196 |
+
|
| 197 |
+
expect(findDiagnostic(await runBedrock())?.details).toEqual({ status: 400 });
|
| 198 |
+
});
|
| 199 |
+
|
| 200 |
+
it("omits the SDK's Unknown placeholder instead of reporting it as a code", async () => {
|
| 201 |
+
// The SDK's fallback when the response carried no `x-amzn-errortype`.
|
| 202 |
+
bedrockMock.send = {
|
| 203 |
+
kind: "reject",
|
| 204 |
+
error: makeServiceException("Unknown", { $metadata: { httpStatusCode: 403, requestId: REQUEST_ID } }),
|
| 205 |
+
};
|
| 206 |
+
|
| 207 |
+
expect(findDiagnostic(await runBedrock())?.details).toEqual({
|
| 208 |
+
status: 403,
|
| 209 |
+
requestId: REQUEST_ID,
|
| 210 |
+
});
|
| 211 |
+
});
|
| 212 |
+
});
|
packages/ai/test/cloudflare-stream.test.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, expect, it } from "vitest";
|
| 2 |
+
import { cloudflareStreams } from "../src/providers/cloudflare-stream.ts";
|
| 3 |
+
import type { Api, Context, Model } from "../src/types.ts";
|
| 4 |
+
import { AssistantMessageEventStream } from "../src/utils/event-stream.ts";
|
| 5 |
+
|
| 6 |
+
const model: Model<Api> = {
|
| 7 |
+
id: "model",
|
| 8 |
+
name: "model",
|
| 9 |
+
api: "openai-completions",
|
| 10 |
+
provider: "cloudflare-ai-gateway",
|
| 11 |
+
baseUrl: "https://gateway.ai.cloudflare.com/v1/{CLOUDFLARE_ACCOUNT_ID}/{CLOUDFLARE_GATEWAY_ID}/openai",
|
| 12 |
+
reasoning: false,
|
| 13 |
+
input: ["text"],
|
| 14 |
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
| 15 |
+
contextWindow: 1000,
|
| 16 |
+
maxTokens: 100,
|
| 17 |
+
};
|
| 18 |
+
|
| 19 |
+
const context: Context = { messages: [] };
|
| 20 |
+
|
| 21 |
+
describe("Cloudflare provider streams", () => {
|
| 22 |
+
it("materializes the model endpoint before dispatch", () => {
|
| 23 |
+
const captured: string[] = [];
|
| 24 |
+
const streams = cloudflareStreams({
|
| 25 |
+
stream: (requestModel) => {
|
| 26 |
+
captured.push(requestModel.baseUrl);
|
| 27 |
+
return new AssistantMessageEventStream();
|
| 28 |
+
},
|
| 29 |
+
streamSimple: (requestModel) => {
|
| 30 |
+
captured.push(requestModel.baseUrl);
|
| 31 |
+
return new AssistantMessageEventStream();
|
| 32 |
+
},
|
| 33 |
+
});
|
| 34 |
+
const env = {
|
| 35 |
+
CLOUDFLARE_ACCOUNT_ID: "account",
|
| 36 |
+
CLOUDFLARE_GATEWAY_ID: "gateway",
|
| 37 |
+
};
|
| 38 |
+
|
| 39 |
+
streams.stream(model, context, { env });
|
| 40 |
+
streams.streamSimple(model, context, { env });
|
| 41 |
+
|
| 42 |
+
expect(captured).toEqual([
|
| 43 |
+
"https://gateway.ai.cloudflare.com/v1/account/gateway/openai",
|
| 44 |
+
"https://gateway.ai.cloudflare.com/v1/account/gateway/openai",
|
| 45 |
+
]);
|
| 46 |
+
});
|
| 47 |
+
|
| 48 |
+
it("keeps placeholders when the provider env does not resolve them", () => {
|
| 49 |
+
let captured: string | undefined;
|
| 50 |
+
const streams = cloudflareStreams({
|
| 51 |
+
stream: (requestModel) => {
|
| 52 |
+
captured = requestModel.baseUrl;
|
| 53 |
+
return new AssistantMessageEventStream();
|
| 54 |
+
},
|
| 55 |
+
streamSimple: (requestModel) => {
|
| 56 |
+
captured = requestModel.baseUrl;
|
| 57 |
+
return new AssistantMessageEventStream();
|
| 58 |
+
},
|
| 59 |
+
});
|
| 60 |
+
|
| 61 |
+
streams.streamSimple(model, context, {});
|
| 62 |
+
|
| 63 |
+
expect(captured).toBe(model.baseUrl);
|
| 64 |
+
});
|
| 65 |
+
});
|
packages/ai/test/error-body.test.ts
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Unit tests for the shared provider error-body normalizer.
|
| 2 |
+
//
|
| 3 |
+
// See issues/provider-error-body-passthrough. These cover one synthesized error
|
| 4 |
+
// object per SDK shape (Mistral, openai APIError, @google/genai ApiError, AWS
|
| 5 |
+
// Bedrock ServiceException), plus the non-Error fallback, truncation, the empty
|
| 6 |
+
// parsed-body edge case, and the formatProviderError compose helper.
|
| 7 |
+
|
| 8 |
+
import { describe, expect, it } from "vitest";
|
| 9 |
+
import { formatProviderError, MAX_PROVIDER_ERROR_BODY_CHARS, normalizeProviderError } from "../src/utils/error-body.ts";
|
| 10 |
+
|
| 11 |
+
describe("normalizeProviderError", () => {
|
| 12 |
+
it("extracts status and body from a Mistral-shaped error", () => {
|
| 13 |
+
const error = Object.assign(new Error("Mistral request failed"), {
|
| 14 |
+
statusCode: 403,
|
| 15 |
+
body: '{"error":"blocked by gateway WAF"}',
|
| 16 |
+
});
|
| 17 |
+
|
| 18 |
+
const norm = normalizeProviderError(error);
|
| 19 |
+
|
| 20 |
+
expect(norm.status).toBe(403);
|
| 21 |
+
expect(norm.body).toBe('{"error":"blocked by gateway WAF"}');
|
| 22 |
+
expect(norm.messageCarriesBody).toBe(false);
|
| 23 |
+
});
|
| 24 |
+
|
| 25 |
+
it("reads the parsed body off an openai APIError when the message is opaque", () => {
|
| 26 |
+
// makeMessage(status, error, message) yields "<status> status code (no body)"
|
| 27 |
+
// when the parsed body is unparsed, while the body stays on error.error.
|
| 28 |
+
const error = Object.assign(new Error("403 status code (no body)"), {
|
| 29 |
+
status: 403,
|
| 30 |
+
error: { error: "blocked by gateway WAF" },
|
| 31 |
+
});
|
| 32 |
+
|
| 33 |
+
const norm = normalizeProviderError(error);
|
| 34 |
+
|
| 35 |
+
expect(norm.status).toBe(403);
|
| 36 |
+
expect(norm.body).toBe('{"error":"blocked by gateway WAF"}');
|
| 37 |
+
expect(norm.messageCarriesBody).toBe(false);
|
| 38 |
+
});
|
| 39 |
+
|
| 40 |
+
it("preserves the message when @google/genai already folds the body into it", () => {
|
| 41 |
+
const body = { error: { code: 403, message: "Permission denied" } };
|
| 42 |
+
const error = Object.assign(new Error(JSON.stringify(body)), {
|
| 43 |
+
status: 403,
|
| 44 |
+
});
|
| 45 |
+
|
| 46 |
+
const norm = normalizeProviderError(error);
|
| 47 |
+
|
| 48 |
+
expect(norm.status).toBe(403);
|
| 49 |
+
expect(norm.messageCarriesBody).toBe(true);
|
| 50 |
+
expect(norm.message).toBe(JSON.stringify(body));
|
| 51 |
+
});
|
| 52 |
+
|
| 53 |
+
it("extracts status and body from a Bedrock-shaped ServiceException", () => {
|
| 54 |
+
const error = Object.assign(new Error("UnknownError"), {
|
| 55 |
+
name: "UnknownError",
|
| 56 |
+
$metadata: { httpStatusCode: 403 },
|
| 57 |
+
$response: { statusCode: 403, body: '{"message":"blocked by gateway WAF"}' },
|
| 58 |
+
});
|
| 59 |
+
|
| 60 |
+
const norm = normalizeProviderError(error);
|
| 61 |
+
|
| 62 |
+
expect(norm.status).toBe(403);
|
| 63 |
+
expect(norm.body).toBe('{"message":"blocked by gateway WAF"}');
|
| 64 |
+
expect(norm.messageCarriesBody).toBe(false);
|
| 65 |
+
});
|
| 66 |
+
|
| 67 |
+
it("ignores a Bedrock response stream instead of serializing its internals", () => {
|
| 68 |
+
const error = Object.assign(
|
| 69 |
+
new Error("Invocation of model ID anthropic.claude-opus-5 with on-demand throughput isn't supported."),
|
| 70 |
+
{
|
| 71 |
+
name: "ValidationException",
|
| 72 |
+
$metadata: { httpStatusCode: 400 },
|
| 73 |
+
$response: {
|
| 74 |
+
statusCode: 400,
|
| 75 |
+
body: { pipe: () => undefined, _events: { close: [null, null] } },
|
| 76 |
+
},
|
| 77 |
+
},
|
| 78 |
+
);
|
| 79 |
+
|
| 80 |
+
const norm = normalizeProviderError(error);
|
| 81 |
+
|
| 82 |
+
expect(norm.status).toBe(400);
|
| 83 |
+
expect(norm.body).toBeUndefined();
|
| 84 |
+
expect(norm.message).toContain("on-demand throughput isn't supported");
|
| 85 |
+
expect(norm.messageCarriesBody).toBe(true);
|
| 86 |
+
});
|
| 87 |
+
|
| 88 |
+
it("ignores a class-instance response body without a pipe method instead of serializing it", () => {
|
| 89 |
+
// Not every SDK response wrapper is a node stream: web ReadableStreams
|
| 90 |
+
// and SDK-specific wrapper classes have no `pipe`, but serializing them
|
| 91 |
+
// still yields internals-noise that would replace the real message.
|
| 92 |
+
class SdkHttpResponseBody {
|
| 93 |
+
locked = false;
|
| 94 |
+
state = { storedError: undefined };
|
| 95 |
+
}
|
| 96 |
+
const error = Object.assign(new Error("Input is too long for requested model."), {
|
| 97 |
+
name: "ValidationException",
|
| 98 |
+
$metadata: { httpStatusCode: 400 },
|
| 99 |
+
$response: { statusCode: 400, body: new SdkHttpResponseBody() },
|
| 100 |
+
});
|
| 101 |
+
|
| 102 |
+
const norm = normalizeProviderError(error);
|
| 103 |
+
|
| 104 |
+
expect(norm.status).toBe(400);
|
| 105 |
+
expect(norm.body).toBeUndefined();
|
| 106 |
+
expect(norm.message).toContain("Input is too long");
|
| 107 |
+
expect(norm.messageCarriesBody).toBe(true);
|
| 108 |
+
});
|
| 109 |
+
|
| 110 |
+
it("ignores a class-instance `error` field instead of serializing it", () => {
|
| 111 |
+
class SdkInnerError {
|
| 112 |
+
code = "EPROTO";
|
| 113 |
+
internalState = {};
|
| 114 |
+
}
|
| 115 |
+
const error = Object.assign(new Error("TLS handshake failed"), {
|
| 116 |
+
status: 502,
|
| 117 |
+
error: new SdkInnerError(),
|
| 118 |
+
});
|
| 119 |
+
|
| 120 |
+
const norm = normalizeProviderError(error);
|
| 121 |
+
|
| 122 |
+
expect(norm.body).toBeUndefined();
|
| 123 |
+
expect(norm.message).toBe("TLS handshake failed");
|
| 124 |
+
expect(norm.messageCarriesBody).toBe(true);
|
| 125 |
+
});
|
| 126 |
+
|
| 127 |
+
it("still surfaces a plain parsed JSON body object", () => {
|
| 128 |
+
const error = Object.assign(new Error("400 status code (no body)"), {
|
| 129 |
+
status: 400,
|
| 130 |
+
error: { message: "schema validation failed", field: "tools[0]" },
|
| 131 |
+
});
|
| 132 |
+
|
| 133 |
+
const norm = normalizeProviderError(error);
|
| 134 |
+
|
| 135 |
+
expect(norm.body).toBe('{"message":"schema validation failed","field":"tools[0]"}');
|
| 136 |
+
expect(norm.messageCarriesBody).toBe(false);
|
| 137 |
+
});
|
| 138 |
+
|
| 139 |
+
it("JSON-stringifies a non-Error thrown value", () => {
|
| 140 |
+
const norm = normalizeProviderError({ reason: "boom" });
|
| 141 |
+
|
| 142 |
+
expect(norm.status).toBeUndefined();
|
| 143 |
+
expect(norm.body).toBeUndefined();
|
| 144 |
+
expect(norm.message).toBe('{"reason":"boom"}');
|
| 145 |
+
expect(norm.messageCarriesBody).toBe(false);
|
| 146 |
+
});
|
| 147 |
+
|
| 148 |
+
it("treats an empty parsed body object as no body", () => {
|
| 149 |
+
const error = Object.assign(new Error("403 status code (no body)"), {
|
| 150 |
+
status: 403,
|
| 151 |
+
error: {},
|
| 152 |
+
});
|
| 153 |
+
|
| 154 |
+
const norm = normalizeProviderError(error);
|
| 155 |
+
|
| 156 |
+
expect(norm.body).toBeUndefined();
|
| 157 |
+
expect(norm.messageCarriesBody).toBe(true);
|
| 158 |
+
});
|
| 159 |
+
|
| 160 |
+
it("truncates the body at the cap", () => {
|
| 161 |
+
const longBody = "x".repeat(MAX_PROVIDER_ERROR_BODY_CHARS + 50);
|
| 162 |
+
const error = Object.assign(new Error("failed"), {
|
| 163 |
+
statusCode: 500,
|
| 164 |
+
body: longBody,
|
| 165 |
+
});
|
| 166 |
+
|
| 167 |
+
const norm = normalizeProviderError(error);
|
| 168 |
+
|
| 169 |
+
expect(norm.body).toContain("... [truncated 50 chars]");
|
| 170 |
+
expect(norm.body?.length).toBeLessThan(longBody.length);
|
| 171 |
+
});
|
| 172 |
+
|
| 173 |
+
it("sets messageCarriesBody when the message already contains the extracted body", () => {
|
| 174 |
+
const error = Object.assign(new Error("500: upstream exploded"), {
|
| 175 |
+
statusCode: 500,
|
| 176 |
+
body: "upstream exploded",
|
| 177 |
+
});
|
| 178 |
+
|
| 179 |
+
const norm = normalizeProviderError(error);
|
| 180 |
+
|
| 181 |
+
expect(norm.messageCarriesBody).toBe(true);
|
| 182 |
+
});
|
| 183 |
+
});
|
| 184 |
+
|
| 185 |
+
describe("formatProviderError", () => {
|
| 186 |
+
it("surfaces status and body without a prefix", () => {
|
| 187 |
+
const norm = normalizeProviderError(
|
| 188 |
+
Object.assign(new Error("403 status code (no body)"), {
|
| 189 |
+
status: 403,
|
| 190 |
+
error: { error: "blocked by gateway WAF" },
|
| 191 |
+
}),
|
| 192 |
+
);
|
| 193 |
+
|
| 194 |
+
const formatted = formatProviderError(norm);
|
| 195 |
+
|
| 196 |
+
expect(formatted).toContain("403");
|
| 197 |
+
expect(formatted).toContain("blocked by gateway WAF");
|
| 198 |
+
expect(formatted).not.toBe("403 status code (no body)");
|
| 199 |
+
});
|
| 200 |
+
|
| 201 |
+
it("applies a provider prefix with status and body", () => {
|
| 202 |
+
const norm = normalizeProviderError(
|
| 203 |
+
Object.assign(new Error("403 status code (no body)"), {
|
| 204 |
+
status: 403,
|
| 205 |
+
error: { error: "blocked by gateway WAF" },
|
| 206 |
+
}),
|
| 207 |
+
);
|
| 208 |
+
|
| 209 |
+
expect(formatProviderError(norm, "OpenAI API error")).toBe(
|
| 210 |
+
'OpenAI API error (403): {"error":"blocked by gateway WAF"}',
|
| 211 |
+
);
|
| 212 |
+
});
|
| 213 |
+
|
| 214 |
+
it("preserves the message (with prefix + status) when it already carries the body", () => {
|
| 215 |
+
const body = JSON.stringify({ error: { message: "Permission denied" } });
|
| 216 |
+
const norm = normalizeProviderError(Object.assign(new Error(body), { status: 403 }));
|
| 217 |
+
|
| 218 |
+
expect(formatProviderError(norm, "OpenAI API error")).toBe(`OpenAI API error (403): ${body}`);
|
| 219 |
+
});
|
| 220 |
+
|
| 221 |
+
it("returns the bare message for a non-Error value", () => {
|
| 222 |
+
const norm = normalizeProviderError({ reason: "boom" });
|
| 223 |
+
|
| 224 |
+
expect(formatProviderError(norm)).toBe('{"reason":"boom"}');
|
| 225 |
+
});
|
| 226 |
+
});
|
packages/ai/test/fetch-option.test.ts
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
| 2 |
+
import { streamSimple as streamAnthropic } from "../src/api/anthropic-messages.ts";
|
| 3 |
+
import { streamSimple as streamAzureOpenAIResponses } from "../src/api/azure-openai-responses.ts";
|
| 4 |
+
import { streamSimple as streamGoogleGenerativeAI } from "../src/api/google-generative-ai.ts";
|
| 5 |
+
import { streamSimple as streamGoogleVertex } from "../src/api/google-vertex.ts";
|
| 6 |
+
import { streamSimple as streamMistral } from "../src/api/mistral-conversations.ts";
|
| 7 |
+
import { streamSimple as streamOpenAICodexResponses } from "../src/api/openai-codex-responses.ts";
|
| 8 |
+
import { streamSimple as streamOpenAICompletions } from "../src/api/openai-completions.ts";
|
| 9 |
+
import { streamSimple as streamOpenAIResponses } from "../src/api/openai-responses.ts";
|
| 10 |
+
import { generateImages } from "../src/api/openrouter-images.ts";
|
| 11 |
+
import { streamSimple as streamPiMessages } from "../src/api/pi-messages.ts";
|
| 12 |
+
import type { Api, Context, FetchFunction, ImagesModel, Model } from "../src/types.ts";
|
| 13 |
+
|
| 14 |
+
const context: Context = {
|
| 15 |
+
messages: [{ role: "user", content: "hello", timestamp: 1 }],
|
| 16 |
+
};
|
| 17 |
+
|
| 18 |
+
function createModel<TApi extends Api>(api: TApi): Model<TApi> {
|
| 19 |
+
return {
|
| 20 |
+
id: "test-model",
|
| 21 |
+
name: "Test Model",
|
| 22 |
+
api,
|
| 23 |
+
provider: "test-provider",
|
| 24 |
+
baseUrl: "https://upstream.test/v1",
|
| 25 |
+
reasoning: false,
|
| 26 |
+
input: ["text"],
|
| 27 |
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
| 28 |
+
contextWindow: 10_000,
|
| 29 |
+
maxTokens: 1_000,
|
| 30 |
+
};
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
function mockFetches() {
|
| 34 |
+
const fallback = vi.fn<FetchFunction>(async () => {
|
| 35 |
+
throw new Error("ambient fetch must not be called");
|
| 36 |
+
});
|
| 37 |
+
const custom = vi.fn<FetchFunction>(
|
| 38 |
+
async () =>
|
| 39 |
+
new Response(JSON.stringify({ error: { message: "upstream rejected request" } }), {
|
| 40 |
+
status: 401,
|
| 41 |
+
headers: { "content-type": "application/json" },
|
| 42 |
+
}),
|
| 43 |
+
);
|
| 44 |
+
vi.stubGlobal("fetch", fallback);
|
| 45 |
+
return { custom, fallback };
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
function expectOnlyCustomFetch(
|
| 49 |
+
custom: ReturnType<typeof vi.fn<FetchFunction>>,
|
| 50 |
+
fallback: ReturnType<typeof vi.fn<FetchFunction>>,
|
| 51 |
+
) {
|
| 52 |
+
expect(custom).toHaveBeenCalled();
|
| 53 |
+
expect(fallback).not.toHaveBeenCalled();
|
| 54 |
+
expect(globalThis.fetch).toBe(fallback);
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
afterEach(() => {
|
| 58 |
+
vi.unstubAllGlobals();
|
| 59 |
+
});
|
| 60 |
+
|
| 61 |
+
describe("fetch stream option", () => {
|
| 62 |
+
it("passes fetch through streamSimple to the Anthropic SDK", async () => {
|
| 63 |
+
const { custom, fallback } = mockFetches();
|
| 64 |
+
await streamAnthropic(createModel("anthropic-messages"), context, {
|
| 65 |
+
apiKey: "test-key",
|
| 66 |
+
fetch: custom,
|
| 67 |
+
maxRetries: 0,
|
| 68 |
+
}).result();
|
| 69 |
+
expectOnlyCustomFetch(custom, fallback);
|
| 70 |
+
});
|
| 71 |
+
|
| 72 |
+
it("passes fetch through streamSimple to OpenAI SDK adapters", async () => {
|
| 73 |
+
const adapters = [
|
| 74 |
+
() =>
|
| 75 |
+
streamOpenAICompletions(createModel("openai-completions"), context, {
|
| 76 |
+
apiKey: "test-key",
|
| 77 |
+
fetch: custom,
|
| 78 |
+
maxRetries: 0,
|
| 79 |
+
}).result(),
|
| 80 |
+
() =>
|
| 81 |
+
streamOpenAIResponses(createModel("openai-responses"), context, {
|
| 82 |
+
apiKey: "test-key",
|
| 83 |
+
fetch: custom,
|
| 84 |
+
maxRetries: 0,
|
| 85 |
+
}).result(),
|
| 86 |
+
() =>
|
| 87 |
+
streamAzureOpenAIResponses(createModel("azure-openai-responses"), context, {
|
| 88 |
+
apiKey: "test-key",
|
| 89 |
+
fetch: custom,
|
| 90 |
+
maxRetries: 0,
|
| 91 |
+
}).result(),
|
| 92 |
+
];
|
| 93 |
+
const { custom, fallback } = mockFetches();
|
| 94 |
+
for (const run of adapters) {
|
| 95 |
+
await run();
|
| 96 |
+
}
|
| 97 |
+
expect(custom).toHaveBeenCalledTimes(adapters.length);
|
| 98 |
+
expect(fallback).not.toHaveBeenCalled();
|
| 99 |
+
expect(globalThis.fetch).toBe(fallback);
|
| 100 |
+
});
|
| 101 |
+
|
| 102 |
+
it("uses fetch for Mistral, Codex SSE, and pi-messages HTTP requests", async () => {
|
| 103 |
+
const { custom, fallback } = mockFetches();
|
| 104 |
+
await streamMistral(createModel("mistral-conversations"), context, {
|
| 105 |
+
apiKey: "test-key",
|
| 106 |
+
fetch: custom,
|
| 107 |
+
}).result();
|
| 108 |
+
await streamOpenAICodexResponses(createModel("openai-codex-responses"), context, {
|
| 109 |
+
apiKey: `header.${btoa(JSON.stringify({ "https://api.openai.com/auth": { chatgpt_account_id: "account" } }))}.signature`,
|
| 110 |
+
fetch: custom,
|
| 111 |
+
transport: "sse",
|
| 112 |
+
maxRetries: 0,
|
| 113 |
+
}).result();
|
| 114 |
+
await streamPiMessages(createModel("pi-messages"), context, {
|
| 115 |
+
apiKey: "test-key",
|
| 116 |
+
fetch: custom,
|
| 117 |
+
}).result();
|
| 118 |
+
expect(custom).toHaveBeenCalledTimes(3);
|
| 119 |
+
expect(fallback).not.toHaveBeenCalled();
|
| 120 |
+
expect(globalThis.fetch).toBe(fallback);
|
| 121 |
+
});
|
| 122 |
+
|
| 123 |
+
it("rejects custom fetch for Google adapters instead of silently bypassing it", async () => {
|
| 124 |
+
const { custom, fallback } = mockFetches();
|
| 125 |
+
const google = await streamGoogleGenerativeAI(createModel("google-generative-ai"), context, {
|
| 126 |
+
apiKey: "test-key",
|
| 127 |
+
fetch: custom,
|
| 128 |
+
}).result();
|
| 129 |
+
const vertex = await streamGoogleVertex(createModel("google-vertex"), context, {
|
| 130 |
+
apiKey: "test-key",
|
| 131 |
+
fetch: custom,
|
| 132 |
+
}).result();
|
| 133 |
+
|
| 134 |
+
expect(google.errorMessage).toContain("Custom fetch is not supported by the Google Generative AI adapter");
|
| 135 |
+
expect(vertex.errorMessage).toContain("Custom fetch is not supported by the Google Vertex adapter");
|
| 136 |
+
expect(custom).not.toHaveBeenCalled();
|
| 137 |
+
expect(fallback).not.toHaveBeenCalled();
|
| 138 |
+
expect(globalThis.fetch).toBe(fallback);
|
| 139 |
+
});
|
| 140 |
+
|
| 141 |
+
it("allows Google adapters to receive globalThis.fetch explicitly", async () => {
|
| 142 |
+
const ambient = vi.fn<FetchFunction>(
|
| 143 |
+
async () =>
|
| 144 |
+
new Response(JSON.stringify({ error: { message: "upstream rejected request" } }), {
|
| 145 |
+
status: 401,
|
| 146 |
+
headers: { "content-type": "application/json" },
|
| 147 |
+
}),
|
| 148 |
+
);
|
| 149 |
+
vi.stubGlobal("fetch", ambient);
|
| 150 |
+
const result = await streamGoogleGenerativeAI(createModel("google-generative-ai"), context, {
|
| 151 |
+
apiKey: "test-key",
|
| 152 |
+
fetch: ambient,
|
| 153 |
+
}).result();
|
| 154 |
+
|
| 155 |
+
expect(ambient).toHaveBeenCalledOnce();
|
| 156 |
+
expect(result.errorMessage).not.toContain("Custom fetch is not supported");
|
| 157 |
+
expect(globalThis.fetch).toBe(ambient);
|
| 158 |
+
});
|
| 159 |
+
|
| 160 |
+
it("uses fetch for image generation", async () => {
|
| 161 |
+
const { custom, fallback } = mockFetches();
|
| 162 |
+
const model: ImagesModel<"openrouter-images"> = {
|
| 163 |
+
...createModel("openrouter-images"),
|
| 164 |
+
provider: "openrouter",
|
| 165 |
+
output: ["image"],
|
| 166 |
+
};
|
| 167 |
+
await generateImages(
|
| 168 |
+
model,
|
| 169 |
+
{ input: [{ type: "text", text: "draw" }] },
|
| 170 |
+
{
|
| 171 |
+
apiKey: "test-key",
|
| 172 |
+
fetch: custom,
|
| 173 |
+
maxRetries: 0,
|
| 174 |
+
},
|
| 175 |
+
);
|
| 176 |
+
expectOnlyCustomFetch(custom, fallback);
|
| 177 |
+
});
|
| 178 |
+
});
|
packages/ai/test/image-tool-result.test.ts
ADDED
|
@@ -0,0 +1,550 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { readFileSync } from "node:fs";
|
| 2 |
+
import { join } from "node:path";
|
| 3 |
+
import { Type } from "typebox";
|
| 4 |
+
import { describe, expect, it } from "vitest";
|
| 5 |
+
import type { Api, Context, Model, Tool, ToolResultMessage } from "../src/compat.ts";
|
| 6 |
+
import { complete, getModel } from "../src/compat.ts";
|
| 7 |
+
import type { StreamOptions } from "../src/types.ts";
|
| 8 |
+
|
| 9 |
+
type StreamOptionsWithExtras = StreamOptions & Record<string, unknown>;
|
| 10 |
+
|
| 11 |
+
import { hasAzureOpenAICredentials, resolveAzureDeploymentName } from "./azure-utils.ts";
|
| 12 |
+
import { hasBedrockCredentials } from "./bedrock-utils.ts";
|
| 13 |
+
import { resolveApiKey } from "./oauth.ts";
|
| 14 |
+
|
| 15 |
+
// Resolve OAuth tokens at module level (async, runs before tests)
|
| 16 |
+
const oauthTokens = await Promise.all([
|
| 17 |
+
resolveApiKey("anthropic"),
|
| 18 |
+
resolveApiKey("github-copilot"),
|
| 19 |
+
resolveApiKey("openai-codex"),
|
| 20 |
+
]);
|
| 21 |
+
const [anthropicOAuthToken, githubCopilotToken, openaiCodexToken] = oauthTokens;
|
| 22 |
+
|
| 23 |
+
/**
|
| 24 |
+
* Test that tool results containing only images work correctly across all providers.
|
| 25 |
+
* This verifies that:
|
| 26 |
+
* 1. Tool results can contain image content blocks
|
| 27 |
+
* 2. Providers correctly pass images from tool results to the LLM
|
| 28 |
+
* 3. The LLM can see and describe images returned by tools
|
| 29 |
+
*/
|
| 30 |
+
async function handleToolWithImageResult<TApi extends Api>(model: Model<TApi>, options?: StreamOptionsWithExtras) {
|
| 31 |
+
// Check if the model supports images
|
| 32 |
+
if (!model.input.includes("image")) {
|
| 33 |
+
console.log(`Skipping tool image result test - model ${model.id} doesn't support images`);
|
| 34 |
+
return;
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
// Read the test image
|
| 38 |
+
const imagePath = join(__dirname, "data", "red-circle.png");
|
| 39 |
+
const imageBuffer = readFileSync(imagePath);
|
| 40 |
+
const base64Image = imageBuffer.toString("base64");
|
| 41 |
+
|
| 42 |
+
// Define a tool that returns only an image (no text)
|
| 43 |
+
const getImageSchema = Type.Object({});
|
| 44 |
+
const getImageTool: Tool<typeof getImageSchema> = {
|
| 45 |
+
name: "get_circle",
|
| 46 |
+
description: "Returns a circle image for visualization",
|
| 47 |
+
parameters: getImageSchema,
|
| 48 |
+
};
|
| 49 |
+
|
| 50 |
+
const context: Context = {
|
| 51 |
+
systemPrompt: "You are a helpful assistant that uses tools when asked.",
|
| 52 |
+
messages: [
|
| 53 |
+
{
|
| 54 |
+
role: "user",
|
| 55 |
+
content: "Call the get_circle tool to get an image, and describe what you see, shapes, colors, etc.",
|
| 56 |
+
timestamp: Date.now(),
|
| 57 |
+
},
|
| 58 |
+
],
|
| 59 |
+
tools: [getImageTool],
|
| 60 |
+
};
|
| 61 |
+
|
| 62 |
+
// First request - LLM should call the tool
|
| 63 |
+
const firstResponse = await complete(model, context, options);
|
| 64 |
+
expect(firstResponse.stopReason).toBe("toolUse");
|
| 65 |
+
|
| 66 |
+
// Find the tool call
|
| 67 |
+
const toolCall = firstResponse.content.find((b) => b.type === "toolCall");
|
| 68 |
+
expect(toolCall).toBeTruthy();
|
| 69 |
+
if (!toolCall || toolCall.type !== "toolCall") {
|
| 70 |
+
throw new Error("Expected tool call");
|
| 71 |
+
}
|
| 72 |
+
expect(toolCall.name).toBe("get_circle");
|
| 73 |
+
|
| 74 |
+
// Add the tool call to context
|
| 75 |
+
context.messages.push(firstResponse);
|
| 76 |
+
|
| 77 |
+
// Create tool result with ONLY an image (no text)
|
| 78 |
+
const toolResult: ToolResultMessage = {
|
| 79 |
+
role: "toolResult",
|
| 80 |
+
toolCallId: toolCall.id,
|
| 81 |
+
toolName: toolCall.name,
|
| 82 |
+
content: [
|
| 83 |
+
{
|
| 84 |
+
type: "image",
|
| 85 |
+
data: base64Image,
|
| 86 |
+
mimeType: "image/png",
|
| 87 |
+
},
|
| 88 |
+
],
|
| 89 |
+
isError: false,
|
| 90 |
+
timestamp: Date.now(),
|
| 91 |
+
};
|
| 92 |
+
|
| 93 |
+
context.messages.push(toolResult);
|
| 94 |
+
|
| 95 |
+
// Second request - LLM should describe the image from the tool result
|
| 96 |
+
const secondResponse = await complete(model, context, options);
|
| 97 |
+
expect(secondResponse.stopReason).toBe("stop");
|
| 98 |
+
expect(secondResponse.errorMessage).toBeFalsy();
|
| 99 |
+
|
| 100 |
+
// Verify the LLM can see and describe the image
|
| 101 |
+
const textContent = secondResponse.content.find((b) => b.type === "text");
|
| 102 |
+
expect(textContent).toBeTruthy();
|
| 103 |
+
if (textContent && textContent.type === "text") {
|
| 104 |
+
const lowerContent = textContent.text.toLowerCase();
|
| 105 |
+
// Should mention red and circle since that's what the image shows
|
| 106 |
+
expect(lowerContent).toContain("red");
|
| 107 |
+
expect(lowerContent).toContain("circle");
|
| 108 |
+
}
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
/**
|
| 112 |
+
* Test that tool results containing both text and images work correctly across all providers.
|
| 113 |
+
* This verifies that:
|
| 114 |
+
* 1. Tool results can contain mixed content blocks (text + images)
|
| 115 |
+
* 2. Providers correctly pass both text and images from tool results to the LLM
|
| 116 |
+
* 3. The LLM can see both the text and images in tool results
|
| 117 |
+
*/
|
| 118 |
+
async function handleToolWithTextAndImageResult<TApi extends Api>(
|
| 119 |
+
model: Model<TApi>,
|
| 120 |
+
options?: StreamOptionsWithExtras,
|
| 121 |
+
) {
|
| 122 |
+
// Check if the model supports images
|
| 123 |
+
if (!model.input.includes("image")) {
|
| 124 |
+
console.log(`Skipping tool text+image result test - model ${model.id} doesn't support images`);
|
| 125 |
+
return;
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
// Read the test image
|
| 129 |
+
const imagePath = join(__dirname, "data", "red-circle.png");
|
| 130 |
+
const imageBuffer = readFileSync(imagePath);
|
| 131 |
+
const base64Image = imageBuffer.toString("base64");
|
| 132 |
+
|
| 133 |
+
// Define a tool that returns both text and an image
|
| 134 |
+
const getImageSchema = Type.Object({});
|
| 135 |
+
const getImageTool: Tool<typeof getImageSchema> = {
|
| 136 |
+
name: "get_circle_with_description",
|
| 137 |
+
description: "Returns a circle image with a text description",
|
| 138 |
+
parameters: getImageSchema,
|
| 139 |
+
};
|
| 140 |
+
|
| 141 |
+
const context: Context = {
|
| 142 |
+
systemPrompt: "You are a helpful assistant that uses tools when asked.",
|
| 143 |
+
messages: [
|
| 144 |
+
{
|
| 145 |
+
role: "user",
|
| 146 |
+
content:
|
| 147 |
+
"Use the get_circle_with_description tool and tell me what you learned. Also say what color the shape is.",
|
| 148 |
+
timestamp: Date.now(),
|
| 149 |
+
},
|
| 150 |
+
],
|
| 151 |
+
tools: [getImageTool],
|
| 152 |
+
};
|
| 153 |
+
|
| 154 |
+
// First request - LLM should call the tool
|
| 155 |
+
const firstResponse = await complete(model, context, options);
|
| 156 |
+
expect(firstResponse.stopReason).toBe("toolUse");
|
| 157 |
+
|
| 158 |
+
// Find the tool call
|
| 159 |
+
const toolCall = firstResponse.content.find((b) => b.type === "toolCall");
|
| 160 |
+
expect(toolCall).toBeTruthy();
|
| 161 |
+
if (!toolCall || toolCall.type !== "toolCall") {
|
| 162 |
+
throw new Error("Expected tool call");
|
| 163 |
+
}
|
| 164 |
+
expect(toolCall.name).toBe("get_circle_with_description");
|
| 165 |
+
|
| 166 |
+
// Add the tool call to context
|
| 167 |
+
context.messages.push(firstResponse);
|
| 168 |
+
|
| 169 |
+
// Create tool result with BOTH text and image
|
| 170 |
+
const toolResult: ToolResultMessage = {
|
| 171 |
+
role: "toolResult",
|
| 172 |
+
toolCallId: toolCall.id,
|
| 173 |
+
toolName: toolCall.name,
|
| 174 |
+
content: [
|
| 175 |
+
{
|
| 176 |
+
type: "text",
|
| 177 |
+
text: "This is a geometric shape with specific properties: it has a diameter of 100 pixels.",
|
| 178 |
+
},
|
| 179 |
+
{
|
| 180 |
+
type: "image",
|
| 181 |
+
data: base64Image,
|
| 182 |
+
mimeType: "image/png",
|
| 183 |
+
},
|
| 184 |
+
],
|
| 185 |
+
isError: false,
|
| 186 |
+
timestamp: Date.now(),
|
| 187 |
+
};
|
| 188 |
+
|
| 189 |
+
context.messages.push(toolResult);
|
| 190 |
+
|
| 191 |
+
// Second request - LLM should describe both the text and image from the tool result
|
| 192 |
+
const secondResponse = await complete(model, context, options);
|
| 193 |
+
expect(secondResponse.stopReason).toBe("stop");
|
| 194 |
+
expect(secondResponse.errorMessage).toBeFalsy();
|
| 195 |
+
|
| 196 |
+
// Verify the LLM can see both text and image
|
| 197 |
+
const textContent = secondResponse.content.find((b) => b.type === "text");
|
| 198 |
+
expect(textContent).toBeTruthy();
|
| 199 |
+
if (textContent && textContent.type === "text") {
|
| 200 |
+
const lowerContent = textContent.text.toLowerCase();
|
| 201 |
+
// Should mention details from the text (diameter/pixels)
|
| 202 |
+
expect(lowerContent.match(/diameter|100|pixel/)).toBeTruthy();
|
| 203 |
+
// Should also mention the visual properties (red and circle)
|
| 204 |
+
expect(lowerContent).toContain("red");
|
| 205 |
+
expect(lowerContent).toContain("circle");
|
| 206 |
+
}
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
describe("Tool Results with Images", () => {
|
| 210 |
+
describe.skipIf(!process.env.GEMINI_API_KEY)("Google Provider (gemini-2.5-flash)", () => {
|
| 211 |
+
const llm = getModel("google", "gemini-2.5-flash");
|
| 212 |
+
|
| 213 |
+
it("should handle tool result with only image", { retry: 3, timeout: 30000 }, async () => {
|
| 214 |
+
await handleToolWithImageResult(llm);
|
| 215 |
+
});
|
| 216 |
+
|
| 217 |
+
it("should handle tool result with text and image", { retry: 3, timeout: 30000 }, async () => {
|
| 218 |
+
await handleToolWithTextAndImageResult(llm);
|
| 219 |
+
});
|
| 220 |
+
});
|
| 221 |
+
|
| 222 |
+
describe.skipIf(!process.env.OPENAI_API_KEY)("OpenAI Completions Provider (gpt-4o-mini)", () => {
|
| 223 |
+
const { compat: _compat, ...baseModel } = getModel("openai", "gpt-4o-mini");
|
| 224 |
+
void _compat;
|
| 225 |
+
const llm: Model<"openai-completions"> = {
|
| 226 |
+
...baseModel,
|
| 227 |
+
api: "openai-completions",
|
| 228 |
+
};
|
| 229 |
+
|
| 230 |
+
it("should handle tool result with only image", { retry: 3, timeout: 30000 }, async () => {
|
| 231 |
+
await handleToolWithImageResult(llm);
|
| 232 |
+
});
|
| 233 |
+
|
| 234 |
+
it("should handle tool result with text and image", { retry: 3, timeout: 30000 }, async () => {
|
| 235 |
+
await handleToolWithTextAndImageResult(llm);
|
| 236 |
+
});
|
| 237 |
+
});
|
| 238 |
+
|
| 239 |
+
describe.skipIf(!process.env.OPENAI_API_KEY)("OpenAI Responses Provider (gpt-5-mini)", () => {
|
| 240 |
+
const llm = getModel("openai", "gpt-5-mini");
|
| 241 |
+
|
| 242 |
+
it("should handle tool result with only image", { retry: 3, timeout: 30000 }, async () => {
|
| 243 |
+
await handleToolWithImageResult(llm);
|
| 244 |
+
});
|
| 245 |
+
|
| 246 |
+
it("should handle tool result with text and image", { retry: 3, timeout: 30000 }, async () => {
|
| 247 |
+
await handleToolWithTextAndImageResult(llm);
|
| 248 |
+
});
|
| 249 |
+
});
|
| 250 |
+
|
| 251 |
+
describe.skipIf(!hasAzureOpenAICredentials())("Azure OpenAI Responses Provider (gpt-4o-mini)", () => {
|
| 252 |
+
const llm = getModel("azure-openai-responses", "gpt-4o-mini");
|
| 253 |
+
const azureDeploymentName = resolveAzureDeploymentName(llm.id);
|
| 254 |
+
const azureOptions = azureDeploymentName ? { azureDeploymentName } : {};
|
| 255 |
+
|
| 256 |
+
it("should handle tool result with only image", { retry: 3, timeout: 30000 }, async () => {
|
| 257 |
+
await handleToolWithImageResult(llm, azureOptions);
|
| 258 |
+
});
|
| 259 |
+
|
| 260 |
+
it("should handle tool result with text and image", { retry: 3, timeout: 30000 }, async () => {
|
| 261 |
+
await handleToolWithTextAndImageResult(llm, azureOptions);
|
| 262 |
+
});
|
| 263 |
+
});
|
| 264 |
+
|
| 265 |
+
describe.skipIf(!process.env.ANTHROPIC_API_KEY)("Anthropic Provider (claude-haiku-4-5)", () => {
|
| 266 |
+
const model = getModel("anthropic", "claude-haiku-4-5");
|
| 267 |
+
|
| 268 |
+
it("should handle tool result with only image", { retry: 3, timeout: 30000 }, async () => {
|
| 269 |
+
await handleToolWithImageResult(model);
|
| 270 |
+
});
|
| 271 |
+
|
| 272 |
+
it("should handle tool result with text and image", { retry: 3, timeout: 30000 }, async () => {
|
| 273 |
+
await handleToolWithTextAndImageResult(model);
|
| 274 |
+
});
|
| 275 |
+
});
|
| 276 |
+
|
| 277 |
+
describe.skipIf(!process.env.OPENROUTER_API_KEY)("OpenRouter Provider (glm-4.5v)", () => {
|
| 278 |
+
const llm = getModel("openrouter", "z-ai/glm-4.5v");
|
| 279 |
+
|
| 280 |
+
it("should handle tool result with only image", { retry: 3, timeout: 30000 }, async () => {
|
| 281 |
+
await handleToolWithImageResult(llm);
|
| 282 |
+
});
|
| 283 |
+
|
| 284 |
+
it("should handle tool result with text and image", { retry: 3, timeout: 30000 }, async () => {
|
| 285 |
+
await handleToolWithTextAndImageResult(llm);
|
| 286 |
+
});
|
| 287 |
+
});
|
| 288 |
+
|
| 289 |
+
describe.skipIf(!process.env.MISTRAL_API_KEY)("Mistral Provider (pixtral-12b)", () => {
|
| 290 |
+
const llm = getModel("mistral", "pixtral-12b");
|
| 291 |
+
|
| 292 |
+
it("should handle tool result with only image", { retry: 5, timeout: 30000 }, async () => {
|
| 293 |
+
await handleToolWithImageResult(llm);
|
| 294 |
+
});
|
| 295 |
+
|
| 296 |
+
it("should handle tool result with text and image", { retry: 5, timeout: 30000 }, async () => {
|
| 297 |
+
await handleToolWithTextAndImageResult(llm);
|
| 298 |
+
});
|
| 299 |
+
});
|
| 300 |
+
|
| 301 |
+
describe.skipIf(!process.env.TOGETHER_API_KEY)("Together AI Provider (Kimi-K2.6)", () => {
|
| 302 |
+
const llm = getModel("together", "moonshotai/Kimi-K2.6");
|
| 303 |
+
const options = { reasoningEffort: "high" } satisfies StreamOptionsWithExtras;
|
| 304 |
+
|
| 305 |
+
it("should handle tool result with only image", { retry: 3, timeout: 30000 }, async () => {
|
| 306 |
+
await handleToolWithImageResult(llm, options);
|
| 307 |
+
});
|
| 308 |
+
|
| 309 |
+
it("should handle tool result with text and image", { retry: 3, timeout: 30000 }, async () => {
|
| 310 |
+
await handleToolWithTextAndImageResult(llm, options);
|
| 311 |
+
});
|
| 312 |
+
});
|
| 313 |
+
|
| 314 |
+
describe.skipIf(!process.env.BASETEN_API_KEY)("Baseten Provider (Kimi-K2.6)", () => {
|
| 315 |
+
const llm = getModel("baseten", "moonshotai/Kimi-K2.6");
|
| 316 |
+
const options = { reasoningEffort: "high" } satisfies StreamOptionsWithExtras;
|
| 317 |
+
|
| 318 |
+
it("should handle tool result with only image", { retry: 3, timeout: 30000 }, async () => {
|
| 319 |
+
await handleToolWithImageResult(llm, options);
|
| 320 |
+
});
|
| 321 |
+
|
| 322 |
+
it("should handle tool result with text and image", { retry: 3, timeout: 30000 }, async () => {
|
| 323 |
+
await handleToolWithTextAndImageResult(llm, options);
|
| 324 |
+
});
|
| 325 |
+
});
|
| 326 |
+
|
| 327 |
+
describe.skipIf(!process.env.XIAOMI_API_KEY)("Xiaomi MiMo (API billing) Provider (mimo-v2.5-pro)", () => {
|
| 328 |
+
const llm = getModel("xiaomi", "mimo-v2.5-pro");
|
| 329 |
+
|
| 330 |
+
it("should handle tool result with only image", { retry: 3, timeout: 30000 }, async () => {
|
| 331 |
+
await handleToolWithImageResult(llm);
|
| 332 |
+
});
|
| 333 |
+
|
| 334 |
+
// FIXME(xiaomi): when a tool_result contains both a descriptive text block
|
| 335 |
+
// and an image block, MiMo locks onto the text and ignores the image (it
|
| 336 |
+
// reports the text-derived diameter but never mentions the image's color).
|
| 337 |
+
// The image-only case above proves the image reaches the model, and the
|
| 338 |
+
// text-only path obviously works, so this is a multimodal-fusion quality
|
| 339 |
+
// issue in the model, not a transport bug. Re-enable when upstream model
|
| 340 |
+
// quality improves.
|
| 341 |
+
it.skip("should handle tool result with text and image", { retry: 3, timeout: 30000 }, async () => {
|
| 342 |
+
await handleToolWithTextAndImageResult(llm);
|
| 343 |
+
});
|
| 344 |
+
});
|
| 345 |
+
|
| 346 |
+
describe.skipIf(!process.env.XIAOMI_TOKEN_PLAN_CN_API_KEY)(
|
| 347 |
+
"Xiaomi MiMo Token Plan (CN) Provider (mimo-v2.5-pro)",
|
| 348 |
+
() => {
|
| 349 |
+
const llm = getModel("xiaomi-token-plan-cn", "mimo-v2.5-pro");
|
| 350 |
+
|
| 351 |
+
it("should handle tool result with only image", { retry: 3, timeout: 30000 }, async () => {
|
| 352 |
+
await handleToolWithImageResult(llm);
|
| 353 |
+
});
|
| 354 |
+
|
| 355 |
+
// FIXME(xiaomi): see the API-billing block above — same multimodal-fusion
|
| 356 |
+
// limitation applies to Token Plan endpoints (same model behind both).
|
| 357 |
+
it.skip("should handle tool result with text and image", { retry: 3, timeout: 30000 }, async () => {
|
| 358 |
+
await handleToolWithTextAndImageResult(llm);
|
| 359 |
+
});
|
| 360 |
+
},
|
| 361 |
+
);
|
| 362 |
+
|
| 363 |
+
describe.skipIf(!process.env.XIAOMI_TOKEN_PLAN_AMS_API_KEY)(
|
| 364 |
+
"Xiaomi MiMo Token Plan (AMS) Provider (mimo-v2.5-pro)",
|
| 365 |
+
() => {
|
| 366 |
+
const llm = getModel("xiaomi-token-plan-ams", "mimo-v2.5-pro");
|
| 367 |
+
|
| 368 |
+
it("should handle tool result with only image", { retry: 3, timeout: 30000 }, async () => {
|
| 369 |
+
await handleToolWithImageResult(llm);
|
| 370 |
+
});
|
| 371 |
+
|
| 372 |
+
// FIXME(xiaomi): see the API-billing block above — same multimodal-fusion
|
| 373 |
+
// limitation applies to Token Plan endpoints (same model behind both).
|
| 374 |
+
it.skip("should handle tool result with text and image", { retry: 3, timeout: 30000 }, async () => {
|
| 375 |
+
await handleToolWithTextAndImageResult(llm);
|
| 376 |
+
});
|
| 377 |
+
},
|
| 378 |
+
);
|
| 379 |
+
|
| 380 |
+
describe.skipIf(!process.env.XIAOMI_TOKEN_PLAN_SGP_API_KEY)(
|
| 381 |
+
"Xiaomi MiMo Token Plan (SGP) Provider (mimo-v2.5-pro)",
|
| 382 |
+
() => {
|
| 383 |
+
const llm = getModel("xiaomi-token-plan-sgp", "mimo-v2.5-pro");
|
| 384 |
+
|
| 385 |
+
it("should handle tool result with only image", { retry: 3, timeout: 30000 }, async () => {
|
| 386 |
+
await handleToolWithImageResult(llm);
|
| 387 |
+
});
|
| 388 |
+
|
| 389 |
+
// FIXME(xiaomi): see the API-billing block above — same multimodal-fusion
|
| 390 |
+
// limitation applies to Token Plan endpoints (same model behind both).
|
| 391 |
+
it.skip("should handle tool result with text and image", { retry: 3, timeout: 30000 }, async () => {
|
| 392 |
+
await handleToolWithTextAndImageResult(llm);
|
| 393 |
+
});
|
| 394 |
+
},
|
| 395 |
+
);
|
| 396 |
+
|
| 397 |
+
describe.skipIf(!process.env.QWEN_TOKEN_PLAN_API_KEY)("Qwen Token Plan Provider (qwen3.7-max)", () => {
|
| 398 |
+
const llm = getModel("qwen-token-plan", "qwen3.7-max");
|
| 399 |
+
|
| 400 |
+
it("should handle tool result with only image", { retry: 3, timeout: 30000 }, async () => {
|
| 401 |
+
await handleToolWithImageResult(llm);
|
| 402 |
+
});
|
| 403 |
+
|
| 404 |
+
it("should handle tool result with text and image", { retry: 3, timeout: 30000 }, async () => {
|
| 405 |
+
await handleToolWithTextAndImageResult(llm);
|
| 406 |
+
});
|
| 407 |
+
});
|
| 408 |
+
|
| 409 |
+
describe.skipIf(!process.env.QWEN_TOKEN_PLAN_API_KEY)("Qwen Token Plan Individual Provider (qwen3.8-max)", () => {
|
| 410 |
+
const llm = getModel("qwen-token-plan-individual", "qwen3.8-max");
|
| 411 |
+
|
| 412 |
+
it("should handle tool result with only image", { retry: 3, timeout: 30000 }, async () => {
|
| 413 |
+
await handleToolWithImageResult(llm);
|
| 414 |
+
});
|
| 415 |
+
|
| 416 |
+
it("should handle tool result with text and image", { retry: 3, timeout: 30000 }, async () => {
|
| 417 |
+
await handleToolWithTextAndImageResult(llm);
|
| 418 |
+
});
|
| 419 |
+
});
|
| 420 |
+
|
| 421 |
+
describe.skipIf(!process.env.QWEN_TOKEN_PLAN_CN_API_KEY)("Qwen Token Plan (CN) Provider (qwen3.7-max)", () => {
|
| 422 |
+
const llm = getModel("qwen-token-plan-cn", "qwen3.7-max");
|
| 423 |
+
|
| 424 |
+
it("should handle tool result with only image", { retry: 3, timeout: 30000 }, async () => {
|
| 425 |
+
await handleToolWithImageResult(llm);
|
| 426 |
+
});
|
| 427 |
+
|
| 428 |
+
it("should handle tool result with text and image", { retry: 3, timeout: 30000 }, async () => {
|
| 429 |
+
await handleToolWithTextAndImageResult(llm);
|
| 430 |
+
});
|
| 431 |
+
});
|
| 432 |
+
|
| 433 |
+
describe.skipIf(!process.env.KIMI_API_KEY)("Kimi For Coding Provider (kimi-for-coding)", () => {
|
| 434 |
+
const llm = getModel("kimi-coding", "kimi-for-coding");
|
| 435 |
+
|
| 436 |
+
it("should handle tool result with only image", { retry: 3, timeout: 30000 }, async () => {
|
| 437 |
+
await handleToolWithImageResult(llm);
|
| 438 |
+
});
|
| 439 |
+
|
| 440 |
+
it("should handle tool result with text and image", { retry: 3, timeout: 30000 }, async () => {
|
| 441 |
+
await handleToolWithTextAndImageResult(llm);
|
| 442 |
+
});
|
| 443 |
+
});
|
| 444 |
+
|
| 445 |
+
describe.skipIf(!process.env.AI_GATEWAY_API_KEY)("Vercel AI Gateway Provider (google/gemini-2.5-flash)", () => {
|
| 446 |
+
const llm = getModel("vercel-ai-gateway", "google/gemini-2.5-flash");
|
| 447 |
+
|
| 448 |
+
it("should handle tool result with only image", { retry: 3, timeout: 30000 }, async () => {
|
| 449 |
+
await handleToolWithImageResult(llm);
|
| 450 |
+
});
|
| 451 |
+
|
| 452 |
+
it("should handle tool result with text and image", { retry: 3, timeout: 30000 }, async () => {
|
| 453 |
+
await handleToolWithTextAndImageResult(llm);
|
| 454 |
+
});
|
| 455 |
+
});
|
| 456 |
+
|
| 457 |
+
describe.skipIf(!hasBedrockCredentials())("Amazon Bedrock Provider (claude-sonnet-4-5)", () => {
|
| 458 |
+
const llm = getModel("amazon-bedrock", "global.anthropic.claude-sonnet-4-5-20250929-v1:0");
|
| 459 |
+
|
| 460 |
+
it("should handle tool result with only image", { retry: 3, timeout: 30000 }, async () => {
|
| 461 |
+
await handleToolWithImageResult(llm);
|
| 462 |
+
});
|
| 463 |
+
|
| 464 |
+
it("should handle tool result with text and image", { retry: 3, timeout: 30000 }, async () => {
|
| 465 |
+
await handleToolWithTextAndImageResult(llm);
|
| 466 |
+
});
|
| 467 |
+
});
|
| 468 |
+
|
| 469 |
+
// =========================================================================
|
| 470 |
+
// OAuth-based providers (credentials from ~/.pi/agent/oauth.json)
|
| 471 |
+
// =========================================================================
|
| 472 |
+
|
| 473 |
+
describe("Anthropic OAuth Provider (claude-sonnet-4-5)", () => {
|
| 474 |
+
const model = getModel("anthropic", "claude-sonnet-4-5");
|
| 475 |
+
|
| 476 |
+
it.skipIf(!anthropicOAuthToken)(
|
| 477 |
+
"should handle tool result with only image",
|
| 478 |
+
{ retry: 3, timeout: 30000 },
|
| 479 |
+
async () => {
|
| 480 |
+
await handleToolWithImageResult(model, { apiKey: anthropicOAuthToken });
|
| 481 |
+
},
|
| 482 |
+
);
|
| 483 |
+
|
| 484 |
+
it.skipIf(!anthropicOAuthToken)(
|
| 485 |
+
"should handle tool result with text and image",
|
| 486 |
+
{ retry: 3, timeout: 30000 },
|
| 487 |
+
async () => {
|
| 488 |
+
await handleToolWithTextAndImageResult(model, { apiKey: anthropicOAuthToken });
|
| 489 |
+
},
|
| 490 |
+
);
|
| 491 |
+
});
|
| 492 |
+
|
| 493 |
+
describe("GitHub Copilot Provider", () => {
|
| 494 |
+
it.skipIf(!githubCopilotToken)(
|
| 495 |
+
"claude-haiku-4.5 - should handle tool result with only image",
|
| 496 |
+
{ retry: 3, timeout: 30000 },
|
| 497 |
+
async () => {
|
| 498 |
+
const llm = getModel("github-copilot", "claude-haiku-4.5");
|
| 499 |
+
await handleToolWithImageResult(llm, { apiKey: githubCopilotToken });
|
| 500 |
+
},
|
| 501 |
+
);
|
| 502 |
+
|
| 503 |
+
it.skipIf(!githubCopilotToken)(
|
| 504 |
+
"claude-haiku-4.5 - should handle tool result with text and image",
|
| 505 |
+
{ retry: 3, timeout: 30000 },
|
| 506 |
+
async () => {
|
| 507 |
+
const llm = getModel("github-copilot", "claude-haiku-4.5");
|
| 508 |
+
await handleToolWithTextAndImageResult(llm, { apiKey: githubCopilotToken });
|
| 509 |
+
},
|
| 510 |
+
);
|
| 511 |
+
|
| 512 |
+
it.skipIf(!githubCopilotToken)(
|
| 513 |
+
"claude-sonnet-4 - should handle tool result with only image",
|
| 514 |
+
{ retry: 3, timeout: 30000 },
|
| 515 |
+
async () => {
|
| 516 |
+
const llm = getModel("github-copilot", "claude-sonnet-4.6");
|
| 517 |
+
await handleToolWithImageResult(llm, { apiKey: githubCopilotToken });
|
| 518 |
+
},
|
| 519 |
+
);
|
| 520 |
+
|
| 521 |
+
it.skipIf(!githubCopilotToken)(
|
| 522 |
+
"claude-sonnet-4 - should handle tool result with text and image",
|
| 523 |
+
{ retry: 3, timeout: 30000 },
|
| 524 |
+
async () => {
|
| 525 |
+
const llm = getModel("github-copilot", "claude-sonnet-4.6");
|
| 526 |
+
await handleToolWithTextAndImageResult(llm, { apiKey: githubCopilotToken });
|
| 527 |
+
},
|
| 528 |
+
);
|
| 529 |
+
});
|
| 530 |
+
|
| 531 |
+
describe("OpenAI Codex Provider", () => {
|
| 532 |
+
it.skipIf(!openaiCodexToken)(
|
| 533 |
+
"gpt-5.5 - should handle tool result with only image",
|
| 534 |
+
{ retry: 3, timeout: 30000 },
|
| 535 |
+
async () => {
|
| 536 |
+
const llm = getModel("openai-codex", "gpt-5.5");
|
| 537 |
+
await handleToolWithImageResult(llm, { apiKey: openaiCodexToken });
|
| 538 |
+
},
|
| 539 |
+
);
|
| 540 |
+
|
| 541 |
+
it.skipIf(!openaiCodexToken)(
|
| 542 |
+
"gpt-5.5 - should handle tool result with text and image",
|
| 543 |
+
{ retry: 3, timeout: 30000 },
|
| 544 |
+
async () => {
|
| 545 |
+
const llm = getModel("openai-codex", "gpt-5.5");
|
| 546 |
+
await handleToolWithTextAndImageResult(llm, { apiKey: openaiCodexToken });
|
| 547 |
+
},
|
| 548 |
+
);
|
| 549 |
+
});
|
| 550 |
+
});
|
packages/ai/test/kimi-coding-oauth.test.ts
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
| 2 |
+
import { kimiCodingOAuth } from "../src/auth/oauth/kimi-coding.ts";
|
| 3 |
+
import type { ProviderAuthInteraction } from "../src/auth/types.ts";
|
| 4 |
+
|
| 5 |
+
const CLIENT_ID = "17e5f671-d194-4dfb-9706-5516cb48c098";
|
| 6 |
+
const OAUTH_HOST = "https://auth.kimi.com";
|
| 7 |
+
|
| 8 |
+
function jsonResponse(body: unknown, status: number = 200): Response {
|
| 9 |
+
return new Response(JSON.stringify(body), {
|
| 10 |
+
status,
|
| 11 |
+
headers: { "Content-Type": "application/json" },
|
| 12 |
+
});
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
function getUrl(input: unknown): string {
|
| 16 |
+
if (typeof input === "string") return input;
|
| 17 |
+
if (input instanceof URL) return input.toString();
|
| 18 |
+
if (input instanceof Request) return input.url;
|
| 19 |
+
throw new Error(`Unsupported fetch input: ${String(input)}`);
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
function deviceAuthorizationResponse(overrides?: Record<string, unknown>): Response {
|
| 23 |
+
return jsonResponse({
|
| 24 |
+
user_code: "ABCD-1234",
|
| 25 |
+
device_code: "device-code-123",
|
| 26 |
+
verification_uri: "https://www.kimi.com/code",
|
| 27 |
+
verification_uri_complete: "https://www.kimi.com/code?user_code=ABCD-1234",
|
| 28 |
+
interval: 5,
|
| 29 |
+
expires_in: 600,
|
| 30 |
+
...overrides,
|
| 31 |
+
});
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
function createInteraction(events: Array<Record<string, unknown>>): ProviderAuthInteraction {
|
| 35 |
+
return {
|
| 36 |
+
signal: new AbortController().signal,
|
| 37 |
+
prompt: async () => {
|
| 38 |
+
throw new Error("Kimi Code login should not prompt");
|
| 39 |
+
},
|
| 40 |
+
notify: (event) => events.push(event),
|
| 41 |
+
};
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
describe("Kimi Code OAuth", () => {
|
| 45 |
+
afterEach(() => {
|
| 46 |
+
vi.restoreAllMocks();
|
| 47 |
+
vi.unstubAllGlobals();
|
| 48 |
+
vi.unstubAllEnvs();
|
| 49 |
+
vi.useRealTimers();
|
| 50 |
+
});
|
| 51 |
+
|
| 52 |
+
it("logs in with the device authorization flow", async () => {
|
| 53 |
+
vi.useFakeTimers();
|
| 54 |
+
const startTime = new Date("2026-07-20T00:00:00Z");
|
| 55 |
+
vi.setSystemTime(startTime);
|
| 56 |
+
|
| 57 |
+
const events: Array<Record<string, unknown>> = [];
|
| 58 |
+
const pollResponses = [
|
| 59 |
+
jsonResponse({ error: "authorization_pending" }, 400),
|
| 60 |
+
jsonResponse({ access_token: "access-token", refresh_token: "refresh-token", expires_in: 3600 }),
|
| 61 |
+
];
|
| 62 |
+
const pollTimes: number[] = [];
|
| 63 |
+
|
| 64 |
+
vi.stubGlobal(
|
| 65 |
+
"fetch",
|
| 66 |
+
vi.fn(async (input: unknown, init?: RequestInit): Promise<Response> => {
|
| 67 |
+
const url = getUrl(input);
|
| 68 |
+
if (url === `${OAUTH_HOST}/api/oauth/device_authorization`) {
|
| 69 |
+
expect(init?.method).toBe("POST");
|
| 70 |
+
expect(init?.headers).toMatchObject({
|
| 71 |
+
"Content-Type": "application/x-www-form-urlencoded",
|
| 72 |
+
Accept: "application/json",
|
| 73 |
+
});
|
| 74 |
+
expect(new URLSearchParams(String(init?.body)).get("client_id")).toBe(CLIENT_ID);
|
| 75 |
+
return deviceAuthorizationResponse();
|
| 76 |
+
}
|
| 77 |
+
if (url === `${OAUTH_HOST}/api/oauth/token`) {
|
| 78 |
+
pollTimes.push(Date.now());
|
| 79 |
+
const params = new URLSearchParams(String(init?.body));
|
| 80 |
+
expect(params.get("grant_type")).toBe("urn:ietf:params:oauth:grant-type:device_code");
|
| 81 |
+
expect(params.get("client_id")).toBe(CLIENT_ID);
|
| 82 |
+
expect(params.get("device_code")).toBe("device-code-123");
|
| 83 |
+
const response = pollResponses.shift();
|
| 84 |
+
if (!response) throw new Error("Unexpected extra token poll");
|
| 85 |
+
return response;
|
| 86 |
+
}
|
| 87 |
+
throw new Error(`Unexpected fetch URL: ${url}`);
|
| 88 |
+
}),
|
| 89 |
+
);
|
| 90 |
+
|
| 91 |
+
const credentialPromise = kimiCodingOAuth.login(createInteraction(events));
|
| 92 |
+
for (let i = 0; i < 5 && events.length === 0; i++) {
|
| 93 |
+
await vi.advanceTimersByTimeAsync(0);
|
| 94 |
+
}
|
| 95 |
+
expect(events).toEqual([
|
| 96 |
+
{
|
| 97 |
+
type: "device_code",
|
| 98 |
+
userCode: "ABCD-1234",
|
| 99 |
+
verificationUri: "https://www.kimi.com/code?user_code=ABCD-1234",
|
| 100 |
+
intervalSeconds: 5,
|
| 101 |
+
expiresInSeconds: 600,
|
| 102 |
+
},
|
| 103 |
+
]);
|
| 104 |
+
|
| 105 |
+
// waitBeforeFirstPoll: first poll happens after the 5s interval.
|
| 106 |
+
await vi.advanceTimersByTimeAsync(4999);
|
| 107 |
+
expect(pollTimes).toEqual([]);
|
| 108 |
+
await vi.advanceTimersByTimeAsync(1);
|
| 109 |
+
expect(pollTimes).toEqual([startTime.getTime() + 5000]);
|
| 110 |
+
|
| 111 |
+
await vi.advanceTimersByTimeAsync(5000);
|
| 112 |
+
await expect(credentialPromise).resolves.toEqual({
|
| 113 |
+
type: "oauth",
|
| 114 |
+
access: "access-token",
|
| 115 |
+
refresh: "refresh-token",
|
| 116 |
+
expires: startTime.getTime() + 10000 + 3600 * 1000,
|
| 117 |
+
});
|
| 118 |
+
expect(pollTimes).toEqual([startTime.getTime() + 5000, startTime.getTime() + 10000]);
|
| 119 |
+
});
|
| 120 |
+
|
| 121 |
+
it("fails when the device code expires", async () => {
|
| 122 |
+
vi.useFakeTimers();
|
| 123 |
+
vi.stubGlobal(
|
| 124 |
+
"fetch",
|
| 125 |
+
vi.fn(async (input: unknown): Promise<Response> => {
|
| 126 |
+
const url = getUrl(input);
|
| 127 |
+
if (url === `${OAUTH_HOST}/api/oauth/device_authorization`) {
|
| 128 |
+
return deviceAuthorizationResponse();
|
| 129 |
+
}
|
| 130 |
+
if (url === `${OAUTH_HOST}/api/oauth/token`) {
|
| 131 |
+
return jsonResponse({ error: "expired_token" }, 400);
|
| 132 |
+
}
|
| 133 |
+
throw new Error(`Unexpected fetch URL: ${url}`);
|
| 134 |
+
}),
|
| 135 |
+
);
|
| 136 |
+
|
| 137 |
+
const credentialPromise = kimiCodingOAuth.login(createInteraction([]));
|
| 138 |
+
const assertion = expect(credentialPromise).rejects.toThrow("expired");
|
| 139 |
+
await vi.advanceTimersByTimeAsync(5000);
|
| 140 |
+
await assertion;
|
| 141 |
+
});
|
| 142 |
+
|
| 143 |
+
it("fails when the user denies the login", async () => {
|
| 144 |
+
vi.useFakeTimers();
|
| 145 |
+
vi.stubGlobal(
|
| 146 |
+
"fetch",
|
| 147 |
+
vi.fn(async (input: unknown): Promise<Response> => {
|
| 148 |
+
const url = getUrl(input);
|
| 149 |
+
if (url === `${OAUTH_HOST}/api/oauth/device_authorization`) {
|
| 150 |
+
return deviceAuthorizationResponse();
|
| 151 |
+
}
|
| 152 |
+
if (url === `${OAUTH_HOST}/api/oauth/token`) {
|
| 153 |
+
return jsonResponse({ error: "access_denied" }, 400);
|
| 154 |
+
}
|
| 155 |
+
throw new Error(`Unexpected fetch URL: ${url}`);
|
| 156 |
+
}),
|
| 157 |
+
);
|
| 158 |
+
|
| 159 |
+
const credentialPromise = kimiCodingOAuth.login(createInteraction([]));
|
| 160 |
+
const assertion = expect(credentialPromise).rejects.toThrow("denied");
|
| 161 |
+
await vi.advanceTimersByTimeAsync(5000);
|
| 162 |
+
await assertion;
|
| 163 |
+
});
|
| 164 |
+
|
| 165 |
+
it("honors the KIMI_CODE_OAUTH_HOST override", async () => {
|
| 166 |
+
vi.useFakeTimers();
|
| 167 |
+
vi.stubEnv("KIMI_CODE_OAUTH_HOST", "https://auth.example.com/");
|
| 168 |
+
|
| 169 |
+
const urls: string[] = [];
|
| 170 |
+
vi.stubGlobal(
|
| 171 |
+
"fetch",
|
| 172 |
+
vi.fn(async (input: unknown): Promise<Response> => {
|
| 173 |
+
const url = getUrl(input);
|
| 174 |
+
urls.push(url);
|
| 175 |
+
if (url === "https://auth.example.com/api/oauth/device_authorization") {
|
| 176 |
+
return deviceAuthorizationResponse({ interval: 1 });
|
| 177 |
+
}
|
| 178 |
+
if (url === "https://auth.example.com/api/oauth/token") {
|
| 179 |
+
return jsonResponse({ access_token: "a", refresh_token: "r", expires_in: 60 });
|
| 180 |
+
}
|
| 181 |
+
throw new Error(`Unexpected fetch URL: ${url}`);
|
| 182 |
+
}),
|
| 183 |
+
);
|
| 184 |
+
|
| 185 |
+
const credentialPromise = kimiCodingOAuth.login(createInteraction([]));
|
| 186 |
+
await vi.advanceTimersByTimeAsync(1000);
|
| 187 |
+
await expect(credentialPromise).resolves.toMatchObject({ access: "a", refresh: "r" });
|
| 188 |
+
expect(urls).toEqual([
|
| 189 |
+
"https://auth.example.com/api/oauth/device_authorization",
|
| 190 |
+
"https://auth.example.com/api/oauth/token",
|
| 191 |
+
]);
|
| 192 |
+
});
|
| 193 |
+
|
| 194 |
+
it("refreshes tokens and returns a Bearer header for requests", async () => {
|
| 195 |
+
vi.stubGlobal(
|
| 196 |
+
"fetch",
|
| 197 |
+
vi.fn(async (input: unknown, init?: RequestInit): Promise<Response> => {
|
| 198 |
+
const url = getUrl(input);
|
| 199 |
+
expect(url).toBe(`${OAUTH_HOST}/api/oauth/token`);
|
| 200 |
+
const params = new URLSearchParams(String(init?.body));
|
| 201 |
+
expect(params.get("grant_type")).toBe("refresh_token");
|
| 202 |
+
expect(params.get("refresh_token")).toBe("old-refresh");
|
| 203 |
+
expect(params.get("client_id")).toBe(CLIENT_ID);
|
| 204 |
+
return jsonResponse({ access_token: "new-access", refresh_token: "new-refresh", expires_in: 3600 });
|
| 205 |
+
}),
|
| 206 |
+
);
|
| 207 |
+
|
| 208 |
+
const before = Date.now();
|
| 209 |
+
const credential = await kimiCodingOAuth.refresh(
|
| 210 |
+
{
|
| 211 |
+
type: "oauth",
|
| 212 |
+
access: "old-access",
|
| 213 |
+
refresh: "old-refresh",
|
| 214 |
+
expires: before,
|
| 215 |
+
},
|
| 216 |
+
new AbortController().signal,
|
| 217 |
+
);
|
| 218 |
+
expect(credential).toEqual({
|
| 219 |
+
type: "oauth",
|
| 220 |
+
access: "new-access",
|
| 221 |
+
refresh: "new-refresh",
|
| 222 |
+
expires: expect.any(Number),
|
| 223 |
+
});
|
| 224 |
+
expect(credential.expires).toBeGreaterThanOrEqual(before + 3600 * 1000);
|
| 225 |
+
|
| 226 |
+
await expect(kimiCodingOAuth.toAuth(credential)).resolves.toEqual({
|
| 227 |
+
headers: { Authorization: "Bearer new-access" },
|
| 228 |
+
});
|
| 229 |
+
});
|
| 230 |
+
|
| 231 |
+
it("retries refresh on 429 and fails unauthorized on invalid_grant", async () => {
|
| 232 |
+
vi.useFakeTimers();
|
| 233 |
+
|
| 234 |
+
// 429 once, then success.
|
| 235 |
+
let calls = 0;
|
| 236 |
+
vi.stubGlobal(
|
| 237 |
+
"fetch",
|
| 238 |
+
vi.fn(async (): Promise<Response> => {
|
| 239 |
+
calls += 1;
|
| 240 |
+
if (calls === 1) return jsonResponse({ error: "temporarily_unavailable" }, 429);
|
| 241 |
+
return jsonResponse({ access_token: "a", refresh_token: "r", expires_in: 60 });
|
| 242 |
+
}),
|
| 243 |
+
);
|
| 244 |
+
|
| 245 |
+
const refreshPromise = kimiCodingOAuth.refresh(
|
| 246 |
+
{
|
| 247 |
+
type: "oauth",
|
| 248 |
+
access: "old",
|
| 249 |
+
refresh: "old",
|
| 250 |
+
expires: 0,
|
| 251 |
+
},
|
| 252 |
+
new AbortController().signal,
|
| 253 |
+
);
|
| 254 |
+
await vi.advanceTimersByTimeAsync(1000);
|
| 255 |
+
await expect(refreshPromise).resolves.toMatchObject({ access: "a" });
|
| 256 |
+
expect(calls).toBe(2);
|
| 257 |
+
|
| 258 |
+
// invalid_grant is not retried.
|
| 259 |
+
vi.stubGlobal(
|
| 260 |
+
"fetch",
|
| 261 |
+
vi.fn(async (): Promise<Response> => jsonResponse({ error: "invalid_grant" }, 400)),
|
| 262 |
+
);
|
| 263 |
+
await expect(
|
| 264 |
+
kimiCodingOAuth.refresh(
|
| 265 |
+
{ type: "oauth", access: "old", refresh: "old", expires: 0 },
|
| 266 |
+
new AbortController().signal,
|
| 267 |
+
),
|
| 268 |
+
).rejects.toThrow("unauthorized");
|
| 269 |
+
});
|
| 270 |
+
});
|
packages/ai/test/mistral-raw-stop-reason.test.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, expect, it } from "vitest";
|
| 2 |
+
import { stream as streamMistral } from "../src/api/mistral-conversations.ts";
|
| 3 |
+
import { getModel } from "../src/compat.ts";
|
| 4 |
+
import type { Context, FetchFunction } from "../src/types.ts";
|
| 5 |
+
|
| 6 |
+
const model = getModel("mistral", "devstral-medium-latest");
|
| 7 |
+
const context: Context = {
|
| 8 |
+
messages: [{ role: "user", content: "hello", timestamp: Date.now() }],
|
| 9 |
+
};
|
| 10 |
+
|
| 11 |
+
function createFetch(finishReason: string): FetchFunction {
|
| 12 |
+
return async () =>
|
| 13 |
+
new Response(
|
| 14 |
+
`data: ${JSON.stringify({
|
| 15 |
+
id: "mistral-response-id",
|
| 16 |
+
model: model.id,
|
| 17 |
+
choices: [
|
| 18 |
+
{
|
| 19 |
+
index: 0,
|
| 20 |
+
finish_reason: finishReason,
|
| 21 |
+
delta: {},
|
| 22 |
+
},
|
| 23 |
+
],
|
| 24 |
+
usage: {
|
| 25 |
+
prompt_tokens: 1,
|
| 26 |
+
completion_tokens: 0,
|
| 27 |
+
total_tokens: 1,
|
| 28 |
+
},
|
| 29 |
+
})}\n\ndata: [DONE]\n\n`,
|
| 30 |
+
{ headers: { "content-type": "text/event-stream" } },
|
| 31 |
+
);
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
describe("Mistral raw stop reasons", () => {
|
| 35 |
+
it("preserves raw Mistral finish reasons for successful stops", async () => {
|
| 36 |
+
const message = await streamMistral(model, context, { apiKey: "test", fetch: createFetch("stop") }).result();
|
| 37 |
+
|
| 38 |
+
expect(message.stopReason).toBe("stop");
|
| 39 |
+
expect(message.rawStopReason).toBe("stop");
|
| 40 |
+
expect(message.errorMessage).toBeUndefined();
|
| 41 |
+
});
|
| 42 |
+
|
| 43 |
+
it("preserves raw Mistral finish reasons for provider error stops", async () => {
|
| 44 |
+
const message = await streamMistral(model, context, { apiKey: "test", fetch: createFetch("error") }).result();
|
| 45 |
+
|
| 46 |
+
expect(message.stopReason).toBe("error");
|
| 47 |
+
expect(message.rawStopReason).toBe("error");
|
| 48 |
+
expect(message.errorMessage).toBe("Provider stopped with: error");
|
| 49 |
+
});
|
| 50 |
+
|
| 51 |
+
it("treats unknown Mistral finish reasons as provider error stops", async () => {
|
| 52 |
+
const message = await streamMistral(model, context, {
|
| 53 |
+
apiKey: "test",
|
| 54 |
+
fetch: createFetch("unmapped_error"),
|
| 55 |
+
}).result();
|
| 56 |
+
|
| 57 |
+
expect(message.stopReason).toBe("error");
|
| 58 |
+
expect(message.rawStopReason).toBe("unmapped_error");
|
| 59 |
+
expect(message.errorMessage).toBe("Provider stopped with: unmapped_error");
|
| 60 |
+
});
|
| 61 |
+
});
|
packages/ai/test/oauth.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Test helper for resolving API keys from ~/.pi/agent/auth.json
|
| 3 |
+
*
|
| 4 |
+
* Supports both API key and OAuth credentials.
|
| 5 |
+
* OAuth tokens are automatically refreshed if expired and saved back to auth.json.
|
| 6 |
+
*/
|
| 7 |
+
|
| 8 |
+
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
| 9 |
+
import { homedir } from "os";
|
| 10 |
+
import { dirname, join } from "path";
|
| 11 |
+
import type { OAuthCredentials } from "../src/auth/types.ts";
|
| 12 |
+
import { builtinProviders } from "../src/providers/all.ts";
|
| 13 |
+
|
| 14 |
+
const AUTH_PATH = join(homedir(), ".pi", "agent", "auth.json");
|
| 15 |
+
|
| 16 |
+
type ApiKeyCredential = {
|
| 17 |
+
type: "api_key";
|
| 18 |
+
key: string;
|
| 19 |
+
};
|
| 20 |
+
|
| 21 |
+
type OAuthCredentialEntry = {
|
| 22 |
+
type: "oauth";
|
| 23 |
+
} & OAuthCredentials;
|
| 24 |
+
|
| 25 |
+
type AuthCredential = ApiKeyCredential | OAuthCredentialEntry;
|
| 26 |
+
|
| 27 |
+
type AuthStorage = Record<string, AuthCredential>;
|
| 28 |
+
|
| 29 |
+
function loadAuthStorage(): AuthStorage {
|
| 30 |
+
if (!existsSync(AUTH_PATH)) {
|
| 31 |
+
return {};
|
| 32 |
+
}
|
| 33 |
+
try {
|
| 34 |
+
const content = readFileSync(AUTH_PATH, "utf-8");
|
| 35 |
+
return JSON.parse(content);
|
| 36 |
+
} catch {
|
| 37 |
+
return {};
|
| 38 |
+
}
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
function saveAuthStorage(storage: AuthStorage): void {
|
| 42 |
+
const configDir = dirname(AUTH_PATH);
|
| 43 |
+
if (!existsSync(configDir)) {
|
| 44 |
+
mkdirSync(configDir, { recursive: true, mode: 0o700 });
|
| 45 |
+
}
|
| 46 |
+
writeFileSync(AUTH_PATH, JSON.stringify(storage, null, 2), "utf-8");
|
| 47 |
+
chmodSync(AUTH_PATH, 0o600);
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
/**
|
| 51 |
+
* Resolve API key for a provider from ~/.pi/agent/auth.json
|
| 52 |
+
*
|
| 53 |
+
* For API key credentials, returns the key directly.
|
| 54 |
+
* For OAuth credentials, returns the access token (refreshing if expired and saving back).
|
| 55 |
+
*
|
| 56 |
+
*/
|
| 57 |
+
export async function resolveApiKey(provider: string): Promise<string | undefined> {
|
| 58 |
+
const storage = loadAuthStorage();
|
| 59 |
+
const entry = storage[provider];
|
| 60 |
+
|
| 61 |
+
if (!entry) return undefined;
|
| 62 |
+
|
| 63 |
+
if (entry.type === "api_key") {
|
| 64 |
+
return entry.key;
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
if (entry.type === "oauth") {
|
| 68 |
+
const oauth = builtinProviders().find((candidate) => candidate.id === provider)?.auth.oauth;
|
| 69 |
+
if (!oauth) return undefined;
|
| 70 |
+
let credential = entry;
|
| 71 |
+
try {
|
| 72 |
+
if (Date.now() >= credential.expires) {
|
| 73 |
+
credential = await oauth.refresh(credential, new AbortController().signal);
|
| 74 |
+
}
|
| 75 |
+
} catch (error) {
|
| 76 |
+
console.log(JSON.stringify(error));
|
| 77 |
+
return undefined;
|
| 78 |
+
}
|
| 79 |
+
storage[provider] = credential;
|
| 80 |
+
saveAuthStorage(storage);
|
| 81 |
+
return (await oauth.toAuth(credential)).apiKey;
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
return undefined;
|
| 85 |
+
}
|
packages/ai/test/openai-completions-prompt-cache.test.ts
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
| 2 |
+
import { stream as streamOpenAICompletions } from "../src/api/openai-completions.ts";
|
| 3 |
+
import { getModel } from "../src/compat.ts";
|
| 4 |
+
import type { Model } from "../src/types.ts";
|
| 5 |
+
|
| 6 |
+
interface FakeOpenAIClientOptions {
|
| 7 |
+
apiKey: string;
|
| 8 |
+
baseURL: string;
|
| 9 |
+
dangerouslyAllowBrowser: boolean;
|
| 10 |
+
defaultHeaders?: Record<string, string>;
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
interface CapturedCompletionsPayload {
|
| 14 |
+
prompt_cache_key?: string;
|
| 15 |
+
prompt_cache_retention?: "24h" | "in-memory" | null;
|
| 16 |
+
session_id?: string;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
const mockState = vi.hoisted(() => ({
|
| 20 |
+
lastParams: undefined as CapturedCompletionsPayload | undefined,
|
| 21 |
+
lastClientOptions: undefined as FakeOpenAIClientOptions | undefined,
|
| 22 |
+
}));
|
| 23 |
+
|
| 24 |
+
vi.mock("openai", () => {
|
| 25 |
+
class FakeOpenAI {
|
| 26 |
+
chat = {
|
| 27 |
+
completions: {
|
| 28 |
+
create: (params: CapturedCompletionsPayload) => {
|
| 29 |
+
mockState.lastParams = params;
|
| 30 |
+
const stream = {
|
| 31 |
+
async *[Symbol.asyncIterator]() {
|
| 32 |
+
yield {
|
| 33 |
+
choices: [{ delta: {}, finish_reason: "stop" }],
|
| 34 |
+
usage: {
|
| 35 |
+
prompt_tokens: 1,
|
| 36 |
+
completion_tokens: 1,
|
| 37 |
+
prompt_tokens_details: { cached_tokens: 0 },
|
| 38 |
+
completion_tokens_details: { reasoning_tokens: 0 },
|
| 39 |
+
},
|
| 40 |
+
};
|
| 41 |
+
},
|
| 42 |
+
};
|
| 43 |
+
const promise = Promise.resolve(stream) as Promise<typeof stream> & {
|
| 44 |
+
withResponse: () => Promise<{
|
| 45 |
+
data: typeof stream;
|
| 46 |
+
response: { status: number; headers: Headers };
|
| 47 |
+
}>;
|
| 48 |
+
};
|
| 49 |
+
promise.withResponse = async () => ({
|
| 50 |
+
data: stream,
|
| 51 |
+
response: { status: 200, headers: new Headers() },
|
| 52 |
+
});
|
| 53 |
+
return promise;
|
| 54 |
+
},
|
| 55 |
+
},
|
| 56 |
+
};
|
| 57 |
+
|
| 58 |
+
constructor(options: FakeOpenAIClientOptions) {
|
| 59 |
+
mockState.lastClientOptions = options;
|
| 60 |
+
}
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
return { default: FakeOpenAI };
|
| 64 |
+
});
|
| 65 |
+
|
| 66 |
+
describe("openai-completions prompt caching", () => {
|
| 67 |
+
const originalEnv = process.env.PI_CACHE_RETENTION;
|
| 68 |
+
|
| 69 |
+
beforeEach(() => {
|
| 70 |
+
mockState.lastParams = undefined;
|
| 71 |
+
mockState.lastClientOptions = undefined;
|
| 72 |
+
delete process.env.PI_CACHE_RETENTION;
|
| 73 |
+
});
|
| 74 |
+
|
| 75 |
+
afterEach(() => {
|
| 76 |
+
if (originalEnv === undefined) {
|
| 77 |
+
delete process.env.PI_CACHE_RETENTION;
|
| 78 |
+
} else {
|
| 79 |
+
process.env.PI_CACHE_RETENTION = originalEnv;
|
| 80 |
+
}
|
| 81 |
+
});
|
| 82 |
+
|
| 83 |
+
function createModel(overrides: Partial<Model<"openai-completions">> = {}): Model<"openai-completions"> {
|
| 84 |
+
const { compat: _compat, ...baseModel } = getModel("openai", "gpt-4o-mini");
|
| 85 |
+
return {
|
| 86 |
+
...(baseModel as Omit<Model<"openai-completions">, "api">),
|
| 87 |
+
api: "openai-completions",
|
| 88 |
+
...overrides,
|
| 89 |
+
};
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
async function captureRequest(
|
| 93 |
+
options?: {
|
| 94 |
+
cacheRetention?: "none" | "short" | "long";
|
| 95 |
+
sessionId?: string;
|
| 96 |
+
headers?: Record<string, string>;
|
| 97 |
+
},
|
| 98 |
+
model: Model<"openai-completions"> = createModel(),
|
| 99 |
+
) {
|
| 100 |
+
await streamOpenAICompletions(
|
| 101 |
+
model,
|
| 102 |
+
{
|
| 103 |
+
systemPrompt: "sys",
|
| 104 |
+
messages: [{ role: "user", content: "hi", timestamp: Date.now() }],
|
| 105 |
+
},
|
| 106 |
+
{ apiKey: "test-key", ...options },
|
| 107 |
+
).result();
|
| 108 |
+
|
| 109 |
+
return {
|
| 110 |
+
payload: mockState.lastParams,
|
| 111 |
+
headers: mockState.lastClientOptions?.defaultHeaders ?? {},
|
| 112 |
+
};
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
it("sets prompt_cache_key for direct OpenAI requests when caching is enabled", async () => {
|
| 116 |
+
const { payload } = await captureRequest({ sessionId: "session-123" });
|
| 117 |
+
|
| 118 |
+
expect(payload?.prompt_cache_key).toBe("session-123");
|
| 119 |
+
expect(payload?.prompt_cache_retention).toBeUndefined();
|
| 120 |
+
});
|
| 121 |
+
|
| 122 |
+
it("sets prompt_cache_retention to 24h for direct OpenAI requests when cacheRetention is long", async () => {
|
| 123 |
+
const { payload } = await captureRequest({ cacheRetention: "long", sessionId: "session-456" });
|
| 124 |
+
|
| 125 |
+
expect(payload?.prompt_cache_key).toBe("session-456");
|
| 126 |
+
expect(payload?.prompt_cache_retention).toBe("24h");
|
| 127 |
+
});
|
| 128 |
+
|
| 129 |
+
it("clamps prompt_cache_key to OpenAI's 64-character limit", async () => {
|
| 130 |
+
const sessionId = "x".repeat(67);
|
| 131 |
+
const { payload } = await captureRequest({ sessionId });
|
| 132 |
+
|
| 133 |
+
expect(payload?.prompt_cache_key).toBe("x".repeat(64));
|
| 134 |
+
});
|
| 135 |
+
|
| 136 |
+
it("omits prompt cache fields when cacheRetention is none", async () => {
|
| 137 |
+
const { payload } = await captureRequest({ cacheRetention: "none", sessionId: "session-789" });
|
| 138 |
+
|
| 139 |
+
expect(payload?.prompt_cache_key).toBeUndefined();
|
| 140 |
+
expect(payload?.prompt_cache_retention).toBeUndefined();
|
| 141 |
+
});
|
| 142 |
+
|
| 143 |
+
it("omits prompt cache fields for non-OpenAI base URLs without compatible long retention", async () => {
|
| 144 |
+
const model = createModel({
|
| 145 |
+
baseUrl: "https://proxy.example.com/v1",
|
| 146 |
+
compat: { supportsLongCacheRetention: false },
|
| 147 |
+
});
|
| 148 |
+
const { payload } = await captureRequest({ cacheRetention: "long", sessionId: "session-proxy" }, model);
|
| 149 |
+
|
| 150 |
+
expect(payload?.prompt_cache_key).toBeUndefined();
|
| 151 |
+
expect(payload?.prompt_cache_retention).toBeUndefined();
|
| 152 |
+
});
|
| 153 |
+
|
| 154 |
+
it("uses PI_CACHE_RETENTION for direct OpenAI requests", async () => {
|
| 155 |
+
process.env.PI_CACHE_RETENTION = "long";
|
| 156 |
+
const { payload } = await captureRequest({ sessionId: "session-env" });
|
| 157 |
+
|
| 158 |
+
expect(payload?.prompt_cache_key).toBe("session-env");
|
| 159 |
+
expect(payload?.prompt_cache_retention).toBe("24h");
|
| 160 |
+
});
|
| 161 |
+
|
| 162 |
+
it("sends known session-affinity headers when compat.sendSessionAffinityHeaders is enabled", async () => {
|
| 163 |
+
const model = createModel({
|
| 164 |
+
baseUrl: "https://proxy.example.com/v1",
|
| 165 |
+
compat: { sendSessionAffinityHeaders: true },
|
| 166 |
+
});
|
| 167 |
+
const { headers } = await captureRequest({ sessionId: "session-affinity" }, model);
|
| 168 |
+
|
| 169 |
+
expect(headers.session_id).toBe("session-affinity");
|
| 170 |
+
expect(headers["x-client-request-id"]).toBe("session-affinity");
|
| 171 |
+
expect(headers["x-session-affinity"]).toBe("session-affinity");
|
| 172 |
+
});
|
| 173 |
+
|
| 174 |
+
it.each(["accounts/fireworks/models/glm-5p2", "accounts/fireworks/routers/glm-5p2-fast"] as const)(
|
| 175 |
+
"sends Fireworks session affinity for %s",
|
| 176 |
+
async (modelId) => {
|
| 177 |
+
const model = getModel("fireworks", modelId);
|
| 178 |
+
const { headers } = await captureRequest({ sessionId: "fireworks-session" }, model);
|
| 179 |
+
|
| 180 |
+
expect(headers["x-session-affinity"]).toBe("fireworks-session");
|
| 181 |
+
},
|
| 182 |
+
);
|
| 183 |
+
|
| 184 |
+
it("uses OpenAI no-session format when configured", async () => {
|
| 185 |
+
const model = createModel({
|
| 186 |
+
compat: { sendSessionAffinityHeaders: true, sessionAffinityFormat: "openai-nosession" },
|
| 187 |
+
});
|
| 188 |
+
const { payload, headers } = await captureRequest({ sessionId: "session-nosession" }, model);
|
| 189 |
+
|
| 190 |
+
expect(payload?.session_id).toBeUndefined();
|
| 191 |
+
expect(payload?.prompt_cache_key).toBe("session-nosession");
|
| 192 |
+
expect(headers.session_id).toBeUndefined();
|
| 193 |
+
expect(headers["x-client-request-id"]).toBe("session-nosession");
|
| 194 |
+
expect(headers["x-session-affinity"]).toBe("session-nosession");
|
| 195 |
+
expect(headers["x-session-id"]).toBeUndefined();
|
| 196 |
+
});
|
| 197 |
+
|
| 198 |
+
it("uses OpenRouter session-affinity header when configured", async () => {
|
| 199 |
+
const model = createModel({
|
| 200 |
+
baseUrl: "https://proxy.example.com/v1",
|
| 201 |
+
compat: { sendSessionAffinityHeaders: true, sessionAffinityFormat: "openrouter" },
|
| 202 |
+
});
|
| 203 |
+
const { payload, headers } = await captureRequest({ sessionId: "session-proxy" }, model);
|
| 204 |
+
|
| 205 |
+
expect(payload?.session_id).toBeUndefined();
|
| 206 |
+
expect(payload?.prompt_cache_key).toBeUndefined();
|
| 207 |
+
expect(headers["x-session-id"]).toBe("session-proxy");
|
| 208 |
+
expect(headers.session_id).toBeUndefined();
|
| 209 |
+
expect(headers["x-client-request-id"]).toBeUndefined();
|
| 210 |
+
expect(headers["x-session-affinity"]).toBeUndefined();
|
| 211 |
+
});
|
| 212 |
+
|
| 213 |
+
it("sends OpenRouter session-affinity header by default for built-in OpenRouter models", async () => {
|
| 214 |
+
const model = getModel("openrouter", "auto");
|
| 215 |
+
const { payload, headers } = await captureRequest({ sessionId: "session-openrouter" }, model);
|
| 216 |
+
|
| 217 |
+
expect(payload?.session_id).toBeUndefined();
|
| 218 |
+
expect(payload?.prompt_cache_key).toBeUndefined();
|
| 219 |
+
expect(headers["x-session-id"]).toBe("session-openrouter");
|
| 220 |
+
expect(headers.session_id).toBeUndefined();
|
| 221 |
+
expect(headers["x-client-request-id"]).toBeUndefined();
|
| 222 |
+
expect(headers["x-session-affinity"]).toBeUndefined();
|
| 223 |
+
});
|
| 224 |
+
|
| 225 |
+
it("omits OpenRouter session-affinity data when disabled", async () => {
|
| 226 |
+
const model = createModel({
|
| 227 |
+
provider: "openrouter",
|
| 228 |
+
baseUrl: "https://openrouter.ai/api/v1",
|
| 229 |
+
compat: { sendSessionAffinityHeaders: false },
|
| 230 |
+
});
|
| 231 |
+
const { payload, headers } = await captureRequest({ sessionId: "session-openrouter" }, model);
|
| 232 |
+
|
| 233 |
+
expect(payload?.session_id).toBeUndefined();
|
| 234 |
+
expect(payload?.prompt_cache_key).toBeUndefined();
|
| 235 |
+
expect(headers["x-session-id"]).toBeUndefined();
|
| 236 |
+
});
|
| 237 |
+
|
| 238 |
+
it("omits session-affinity headers when cacheRetention is none", async () => {
|
| 239 |
+
const model = createModel({
|
| 240 |
+
baseUrl: "https://proxy.example.com/v1",
|
| 241 |
+
compat: { sendSessionAffinityHeaders: true },
|
| 242 |
+
});
|
| 243 |
+
const { headers } = await captureRequest({ cacheRetention: "none", sessionId: "session-affinity" }, model);
|
| 244 |
+
|
| 245 |
+
expect(headers.session_id).toBeUndefined();
|
| 246 |
+
expect(headers["x-client-request-id"]).toBeUndefined();
|
| 247 |
+
expect(headers["x-session-affinity"]).toBeUndefined();
|
| 248 |
+
});
|
| 249 |
+
|
| 250 |
+
it("lets explicit headers override generated session-affinity headers", async () => {
|
| 251 |
+
const model = createModel({
|
| 252 |
+
baseUrl: "https://proxy.example.com/v1",
|
| 253 |
+
compat: { sendSessionAffinityHeaders: true },
|
| 254 |
+
});
|
| 255 |
+
const { headers } = await captureRequest(
|
| 256 |
+
{
|
| 257 |
+
sessionId: "session-affinity",
|
| 258 |
+
headers: {
|
| 259 |
+
session_id: "override-session",
|
| 260 |
+
"x-client-request-id": "override-request",
|
| 261 |
+
"x-session-affinity": "override-affinity",
|
| 262 |
+
},
|
| 263 |
+
},
|
| 264 |
+
model,
|
| 265 |
+
);
|
| 266 |
+
|
| 267 |
+
expect(headers.session_id).toBe("override-session");
|
| 268 |
+
expect(headers["x-client-request-id"]).toBe("override-request");
|
| 269 |
+
expect(headers["x-session-affinity"]).toBe("override-affinity");
|
| 270 |
+
});
|
| 271 |
+
});
|
packages/ai/test/openai-completions-raw-stop-reason.test.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
| 2 |
+
import { stream as streamOpenAICompletions } from "../src/api/openai-completions.ts";
|
| 3 |
+
import type { Context, Model } from "../src/types.ts";
|
| 4 |
+
|
| 5 |
+
const mockState = vi.hoisted(() => ({
|
| 6 |
+
chunks: [] as unknown[],
|
| 7 |
+
}));
|
| 8 |
+
|
| 9 |
+
vi.mock("openai", () => {
|
| 10 |
+
class FakeOpenAI {
|
| 11 |
+
chat = {
|
| 12 |
+
completions: {
|
| 13 |
+
create: () => {
|
| 14 |
+
const chunks = mockState.chunks;
|
| 15 |
+
const stream = {
|
| 16 |
+
async *[Symbol.asyncIterator]() {
|
| 17 |
+
for (const chunk of chunks) yield chunk;
|
| 18 |
+
},
|
| 19 |
+
};
|
| 20 |
+
const promise = Promise.resolve(stream) as Promise<typeof stream> & {
|
| 21 |
+
withResponse: () => Promise<{
|
| 22 |
+
data: typeof stream;
|
| 23 |
+
response: { status: number; headers: Headers };
|
| 24 |
+
}>;
|
| 25 |
+
};
|
| 26 |
+
promise.withResponse = async () => ({
|
| 27 |
+
data: stream,
|
| 28 |
+
response: { status: 200, headers: new Headers() },
|
| 29 |
+
});
|
| 30 |
+
return promise;
|
| 31 |
+
},
|
| 32 |
+
},
|
| 33 |
+
};
|
| 34 |
+
}
|
| 35 |
+
return { default: FakeOpenAI };
|
| 36 |
+
});
|
| 37 |
+
|
| 38 |
+
const model: Model<"openai-completions"> = {
|
| 39 |
+
id: "test-model",
|
| 40 |
+
name: "Test Model",
|
| 41 |
+
api: "openai-completions",
|
| 42 |
+
provider: "openai",
|
| 43 |
+
baseUrl: "https://api.openai.com/v1",
|
| 44 |
+
reasoning: false,
|
| 45 |
+
input: ["text"],
|
| 46 |
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
| 47 |
+
contextWindow: 128_000,
|
| 48 |
+
maxTokens: 4096,
|
| 49 |
+
};
|
| 50 |
+
|
| 51 |
+
const context: Context = {
|
| 52 |
+
messages: [{ role: "user", content: "hello", timestamp: Date.now() }],
|
| 53 |
+
};
|
| 54 |
+
|
| 55 |
+
describe("OpenAI completions raw stop reasons", () => {
|
| 56 |
+
beforeEach(() => {
|
| 57 |
+
mockState.chunks = [];
|
| 58 |
+
});
|
| 59 |
+
|
| 60 |
+
it("preserves raw finish reasons for successful stops", async () => {
|
| 61 |
+
mockState.chunks = [{ id: "chatcmpl-1", choices: [{ index: 0, delta: {}, finish_reason: "stop" }] }];
|
| 62 |
+
|
| 63 |
+
const message = await streamOpenAICompletions(model, context, { apiKey: "test" }).result();
|
| 64 |
+
|
| 65 |
+
expect(message.stopReason).toBe("stop");
|
| 66 |
+
expect(message.rawStopReason).toBe("stop");
|
| 67 |
+
expect(message.errorMessage).toBeUndefined();
|
| 68 |
+
});
|
| 69 |
+
|
| 70 |
+
it("preserves raw finish reasons for provider error stops", async () => {
|
| 71 |
+
mockState.chunks = [{ id: "chatcmpl-2", choices: [{ index: 0, delta: {}, finish_reason: "content_filter" }] }];
|
| 72 |
+
|
| 73 |
+
const message = await streamOpenAICompletions(model, context, { apiKey: "test" }).result();
|
| 74 |
+
|
| 75 |
+
expect(message.stopReason).toBe("error");
|
| 76 |
+
expect(message.rawStopReason).toBe("content_filter");
|
| 77 |
+
expect(message.errorMessage).toBe("Provider finish_reason: content_filter");
|
| 78 |
+
});
|
| 79 |
+
});
|
packages/ai/test/openai-completions-vllm-priority.test.ts
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
| 2 |
+
import { stream as streamOpenAICompletions } from "../src/api/openai-completions.ts";
|
| 3 |
+
import { getModel } from "../src/compat.ts";
|
| 4 |
+
import type { Model } from "../src/types.ts";
|
| 5 |
+
|
| 6 |
+
interface CapturedCompletionsPayload {
|
| 7 |
+
priority?: number;
|
| 8 |
+
[key: string]: unknown;
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
const mockState = vi.hoisted(() => ({
|
| 12 |
+
lastParams: undefined as CapturedCompletionsPayload | undefined,
|
| 13 |
+
}));
|
| 14 |
+
|
| 15 |
+
vi.mock("openai", () => {
|
| 16 |
+
class FakeOpenAI {
|
| 17 |
+
chat = {
|
| 18 |
+
completions: {
|
| 19 |
+
create: (params: CapturedCompletionsPayload) => {
|
| 20 |
+
mockState.lastParams = params;
|
| 21 |
+
const stream = {
|
| 22 |
+
async *[Symbol.asyncIterator]() {
|
| 23 |
+
yield {
|
| 24 |
+
choices: [{ delta: {}, finish_reason: "stop" }],
|
| 25 |
+
usage: {
|
| 26 |
+
prompt_tokens: 1,
|
| 27 |
+
completion_tokens: 1,
|
| 28 |
+
prompt_tokens_details: { cached_tokens: 0 },
|
| 29 |
+
completion_tokens_details: { reasoning_tokens: 0 },
|
| 30 |
+
},
|
| 31 |
+
};
|
| 32 |
+
},
|
| 33 |
+
};
|
| 34 |
+
const promise = Promise.resolve(stream) as Promise<typeof stream> & {
|
| 35 |
+
withResponse: () => Promise<{
|
| 36 |
+
data: typeof stream;
|
| 37 |
+
response: { status: number; headers: Headers };
|
| 38 |
+
}>;
|
| 39 |
+
};
|
| 40 |
+
promise.withResponse = async () => ({
|
| 41 |
+
data: stream,
|
| 42 |
+
response: { status: 200, headers: new Headers() },
|
| 43 |
+
});
|
| 44 |
+
return promise;
|
| 45 |
+
},
|
| 46 |
+
},
|
| 47 |
+
};
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
return { default: FakeOpenAI };
|
| 51 |
+
});
|
| 52 |
+
|
| 53 |
+
function createModel(overrides: Partial<Model<"openai-completions">> = {}): Model<"openai-completions"> {
|
| 54 |
+
const { compat: _compat, ...baseModel } = getModel("openai", "gpt-4o-mini");
|
| 55 |
+
return {
|
| 56 |
+
...(baseModel as Omit<Model<"openai-completions">, "api">),
|
| 57 |
+
api: "openai-completions",
|
| 58 |
+
...overrides,
|
| 59 |
+
};
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
async function captureRequest(model: Model<"openai-completions">) {
|
| 63 |
+
await streamOpenAICompletions(
|
| 64 |
+
model,
|
| 65 |
+
{
|
| 66 |
+
systemPrompt: "sys",
|
| 67 |
+
messages: [{ role: "user", content: "hi", timestamp: Date.now() }],
|
| 68 |
+
},
|
| 69 |
+
{ apiKey: "test-key" },
|
| 70 |
+
).result();
|
| 71 |
+
|
| 72 |
+
return mockState.lastParams;
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
describe("openai-completions vllm priority", () => {
|
| 76 |
+
beforeEach(() => {
|
| 77 |
+
mockState.lastParams = undefined;
|
| 78 |
+
});
|
| 79 |
+
|
| 80 |
+
it("sends compat.vllmPriority as the top-level priority request field", async () => {
|
| 81 |
+
const payload = await captureRequest(
|
| 82 |
+
createModel({ compat: { vllmPriority: 10 } as Model<"openai-completions">["compat"] }),
|
| 83 |
+
);
|
| 84 |
+
|
| 85 |
+
expect(payload?.priority).toBe(10);
|
| 86 |
+
});
|
| 87 |
+
|
| 88 |
+
it("omits priority when vllmPriority is not set", async () => {
|
| 89 |
+
const payload = await captureRequest(createModel());
|
| 90 |
+
|
| 91 |
+
expect(payload?.priority).toBeUndefined();
|
| 92 |
+
});
|
| 93 |
+
});
|
packages/ai/test/openai-responses-compat.test.ts
ADDED
|
@@ -0,0 +1,599 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Type } from "typebox";
|
| 2 |
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
| 3 |
+
import { stream as streamOpenAIResponses } from "../src/api/openai-responses.ts";
|
| 4 |
+
import { getModel } from "../src/compat.ts";
|
| 5 |
+
import type { Model } from "../src/types.ts";
|
| 6 |
+
|
| 7 |
+
type CapturedHeaders = Headers | string[][] | Record<string, string | readonly string[]> | undefined;
|
| 8 |
+
|
| 9 |
+
interface CapturedResponsesPayload {
|
| 10 |
+
prompt_cache_key?: string;
|
| 11 |
+
session_id?: string;
|
| 12 |
+
tools?: Array<{ name?: string; strict?: boolean }>;
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
function getHeader(headers: CapturedHeaders, name: string): string | null {
|
| 16 |
+
if (!headers) return null;
|
| 17 |
+
if (headers instanceof Headers) return headers.get(name);
|
| 18 |
+
|
| 19 |
+
const lowerName = name.toLowerCase();
|
| 20 |
+
if (Array.isArray(headers)) {
|
| 21 |
+
const match = headers.find(([key]) => key?.toLowerCase() === lowerName);
|
| 22 |
+
return match?.[1] ?? null;
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
for (const [key, value] of Object.entries(headers)) {
|
| 26 |
+
if (key.toLowerCase() === lowerName) return typeof value === "string" ? value : value.join(", ");
|
| 27 |
+
}
|
| 28 |
+
return null;
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
async function captureOpenAIResponseHeaders(
|
| 32 |
+
options: Parameters<typeof streamOpenAIResponses>[2],
|
| 33 |
+
model: Model<"openai-responses"> = getModel("openai", "gpt-5.4"),
|
| 34 |
+
): Promise<{
|
| 35 |
+
sessionId: string | null;
|
| 36 |
+
clientRequestId: string | null;
|
| 37 |
+
xSessionId: string | null;
|
| 38 |
+
}> {
|
| 39 |
+
const captured = {
|
| 40 |
+
sessionId: null as string | null,
|
| 41 |
+
clientRequestId: null as string | null,
|
| 42 |
+
xSessionId: null as string | null,
|
| 43 |
+
};
|
| 44 |
+
vi.spyOn(globalThis, "fetch").mockImplementation(async (_input, init) => {
|
| 45 |
+
captured.sessionId = getHeader(init?.headers, "session_id");
|
| 46 |
+
captured.clientRequestId = getHeader(init?.headers, "x-client-request-id");
|
| 47 |
+
captured.xSessionId = getHeader(init?.headers, "x-session-id");
|
| 48 |
+
return new Response("data: [DONE]\n\n", {
|
| 49 |
+
status: 200,
|
| 50 |
+
headers: { "content-type": "text/event-stream" },
|
| 51 |
+
});
|
| 52 |
+
});
|
| 53 |
+
|
| 54 |
+
const stream = streamOpenAIResponses(
|
| 55 |
+
model,
|
| 56 |
+
{
|
| 57 |
+
systemPrompt: "sys",
|
| 58 |
+
messages: [{ role: "user", content: "hi", timestamp: Date.now() }],
|
| 59 |
+
},
|
| 60 |
+
{ apiKey: "test-key", ...options },
|
| 61 |
+
);
|
| 62 |
+
|
| 63 |
+
for await (const event of stream) {
|
| 64 |
+
if (event.type === "done" || event.type === "error") break;
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
return captured;
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
describe("openai-responses provider defaults", () => {
|
| 71 |
+
afterEach(() => {
|
| 72 |
+
vi.restoreAllMocks();
|
| 73 |
+
});
|
| 74 |
+
|
| 75 |
+
it("omits reasoning when no reasoning is requested", async () => {
|
| 76 |
+
const model = getModel("github-copilot", "gpt-5-mini");
|
| 77 |
+
let capturedPayload: unknown;
|
| 78 |
+
|
| 79 |
+
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
| 80 |
+
new Response("data: [DONE]\n\n", {
|
| 81 |
+
status: 200,
|
| 82 |
+
headers: { "content-type": "text/event-stream" },
|
| 83 |
+
}),
|
| 84 |
+
);
|
| 85 |
+
|
| 86 |
+
const stream = streamOpenAIResponses(
|
| 87 |
+
model,
|
| 88 |
+
{
|
| 89 |
+
systemPrompt: "sys",
|
| 90 |
+
messages: [{ role: "user", content: "hi", timestamp: Date.now() }],
|
| 91 |
+
},
|
| 92 |
+
{
|
| 93 |
+
apiKey: "test-key",
|
| 94 |
+
onPayload: (payload) => {
|
| 95 |
+
capturedPayload = payload;
|
| 96 |
+
},
|
| 97 |
+
},
|
| 98 |
+
);
|
| 99 |
+
|
| 100 |
+
for await (const event of stream) {
|
| 101 |
+
if (event.type === "done" || event.type === "error") break;
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
expect(capturedPayload).not.toBeNull();
|
| 105 |
+
expect(capturedPayload).not.toMatchObject({
|
| 106 |
+
reasoning: expect.anything(),
|
| 107 |
+
});
|
| 108 |
+
});
|
| 109 |
+
|
| 110 |
+
it("forwards required tool choice", async () => {
|
| 111 |
+
let capturedPayload: unknown;
|
| 112 |
+
|
| 113 |
+
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
| 114 |
+
new Response("data: [DONE]\n\n", {
|
| 115 |
+
status: 200,
|
| 116 |
+
headers: { "content-type": "text/event-stream" },
|
| 117 |
+
}),
|
| 118 |
+
);
|
| 119 |
+
|
| 120 |
+
const stream = streamOpenAIResponses(
|
| 121 |
+
getModel("openai", "gpt-5.4"),
|
| 122 |
+
{
|
| 123 |
+
messages: [
|
| 124 |
+
{
|
| 125 |
+
role: "user",
|
| 126 |
+
content: "Do not call ping. Respond with text instead.",
|
| 127 |
+
timestamp: Date.now(),
|
| 128 |
+
},
|
| 129 |
+
],
|
| 130 |
+
tools: [
|
| 131 |
+
{
|
| 132 |
+
name: "ping",
|
| 133 |
+
description: "Ping",
|
| 134 |
+
parameters: Type.Object({ value: Type.String() }),
|
| 135 |
+
},
|
| 136 |
+
],
|
| 137 |
+
},
|
| 138 |
+
{
|
| 139 |
+
apiKey: "test-key",
|
| 140 |
+
toolChoice: "required",
|
| 141 |
+
onPayload: (payload) => {
|
| 142 |
+
capturedPayload = payload;
|
| 143 |
+
},
|
| 144 |
+
},
|
| 145 |
+
);
|
| 146 |
+
|
| 147 |
+
for await (const event of stream) {
|
| 148 |
+
if (event.type === "done" || event.type === "error") break;
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
expect(capturedPayload).toMatchObject({
|
| 152 |
+
tool_choice: "required",
|
| 153 |
+
tools: [expect.objectContaining({ name: "ping" })],
|
| 154 |
+
});
|
| 155 |
+
});
|
| 156 |
+
|
| 157 |
+
it("sets strict mode explicitly for Cloudflare OpenAI Responses tools", async () => {
|
| 158 |
+
const model = getModel("cloudflare-ai-gateway", "gpt-5.6-sol");
|
| 159 |
+
let capturedPayload: CapturedResponsesPayload | undefined;
|
| 160 |
+
|
| 161 |
+
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
| 162 |
+
new Response("data: [DONE]\n\n", {
|
| 163 |
+
status: 200,
|
| 164 |
+
headers: { "content-type": "text/event-stream" },
|
| 165 |
+
}),
|
| 166 |
+
);
|
| 167 |
+
|
| 168 |
+
const stream = streamOpenAIResponses(
|
| 169 |
+
model,
|
| 170 |
+
{
|
| 171 |
+
messages: [{ role: "user", content: "Use a tool.", timestamp: Date.now() }],
|
| 172 |
+
tools: [
|
| 173 |
+
{
|
| 174 |
+
name: "ordinary",
|
| 175 |
+
description: "An ordinary tool",
|
| 176 |
+
parameters: Type.Object({
|
| 177 |
+
path: Type.String(),
|
| 178 |
+
offset: Type.Optional(Type.Number()),
|
| 179 |
+
}),
|
| 180 |
+
},
|
| 181 |
+
{
|
| 182 |
+
name: "constrained",
|
| 183 |
+
description: "A constrained tool",
|
| 184 |
+
parameters: Type.Object({ value: Type.String() }),
|
| 185 |
+
constrainedSampling: { type: "json_schema", strict: "prefer" },
|
| 186 |
+
},
|
| 187 |
+
],
|
| 188 |
+
},
|
| 189 |
+
{
|
| 190 |
+
apiKey: "test-key",
|
| 191 |
+
onPayload: (payload) => {
|
| 192 |
+
capturedPayload = payload as CapturedResponsesPayload;
|
| 193 |
+
},
|
| 194 |
+
},
|
| 195 |
+
);
|
| 196 |
+
|
| 197 |
+
for await (const event of stream) {
|
| 198 |
+
if (event.type === "done" || event.type === "error") break;
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
expect(model.compat?.supportsStrictMode).toBe(true);
|
| 202 |
+
expect(capturedPayload?.tools).toEqual([
|
| 203 |
+
expect.objectContaining({ name: "ordinary", strict: false }),
|
| 204 |
+
expect.objectContaining({ name: "constrained", strict: true }),
|
| 205 |
+
]);
|
| 206 |
+
});
|
| 207 |
+
|
| 208 |
+
it.each([
|
| 209 |
+
"gpt-5.1",
|
| 210 |
+
"gpt-5.2",
|
| 211 |
+
"gpt-5.3-codex",
|
| 212 |
+
"gpt-5.4",
|
| 213 |
+
"gpt-5.4-mini",
|
| 214 |
+
"gpt-5.4-nano",
|
| 215 |
+
"gpt-5.5",
|
| 216 |
+
"gpt-5.6-sol",
|
| 217 |
+
"gpt-5.6-terra",
|
| 218 |
+
"gpt-5.6-luna",
|
| 219 |
+
] as const)("sends none reasoning effort for OpenAI %s when no reasoning is requested", async (modelId) => {
|
| 220 |
+
const model = getModel("openai", modelId);
|
| 221 |
+
let capturedPayload: unknown;
|
| 222 |
+
|
| 223 |
+
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
| 224 |
+
new Response("data: [DONE]\n\n", {
|
| 225 |
+
status: 200,
|
| 226 |
+
headers: { "content-type": "text/event-stream" },
|
| 227 |
+
}),
|
| 228 |
+
);
|
| 229 |
+
|
| 230 |
+
const stream = streamOpenAIResponses(
|
| 231 |
+
model,
|
| 232 |
+
{
|
| 233 |
+
systemPrompt: "sys",
|
| 234 |
+
messages: [{ role: "user", content: "hi", timestamp: Date.now() }],
|
| 235 |
+
},
|
| 236 |
+
{
|
| 237 |
+
apiKey: "test-key",
|
| 238 |
+
onPayload: (payload) => {
|
| 239 |
+
capturedPayload = payload;
|
| 240 |
+
},
|
| 241 |
+
},
|
| 242 |
+
);
|
| 243 |
+
|
| 244 |
+
for await (const event of stream) {
|
| 245 |
+
if (event.type === "done" || event.type === "error") break;
|
| 246 |
+
}
|
| 247 |
+
|
| 248 |
+
expect(capturedPayload).toMatchObject({
|
| 249 |
+
reasoning: { effort: "none" },
|
| 250 |
+
});
|
| 251 |
+
});
|
| 252 |
+
|
| 253 |
+
it.each(["gpt-5", "gpt-5-mini", "gpt-5-nano", "gpt-5-pro", "gpt-5.2-pro", "gpt-5.4-pro", "gpt-5.5-pro"] as const)(
|
| 254 |
+
"omits reasoning effort for OpenAI %s when off is unsupported",
|
| 255 |
+
async (modelId) => {
|
| 256 |
+
const model = getModel("openai", modelId);
|
| 257 |
+
let capturedPayload: unknown;
|
| 258 |
+
|
| 259 |
+
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
| 260 |
+
new Response("data: [DONE]\n\n", {
|
| 261 |
+
status: 200,
|
| 262 |
+
headers: { "content-type": "text/event-stream" },
|
| 263 |
+
}),
|
| 264 |
+
);
|
| 265 |
+
|
| 266 |
+
const stream = streamOpenAIResponses(
|
| 267 |
+
model,
|
| 268 |
+
{
|
| 269 |
+
systemPrompt: "sys",
|
| 270 |
+
messages: [{ role: "user", content: "hi", timestamp: Date.now() }],
|
| 271 |
+
},
|
| 272 |
+
{
|
| 273 |
+
apiKey: "test-key",
|
| 274 |
+
onPayload: (payload) => {
|
| 275 |
+
capturedPayload = payload;
|
| 276 |
+
},
|
| 277 |
+
},
|
| 278 |
+
);
|
| 279 |
+
|
| 280 |
+
for await (const event of stream) {
|
| 281 |
+
if (event.type === "done" || event.type === "error") break;
|
| 282 |
+
}
|
| 283 |
+
|
| 284 |
+
expect(capturedPayload).not.toMatchObject({
|
| 285 |
+
reasoning: expect.anything(),
|
| 286 |
+
});
|
| 287 |
+
},
|
| 288 |
+
);
|
| 289 |
+
|
| 290 |
+
it("sets cache-affinity headers for official OpenAI Responses requests with a sessionId", async () => {
|
| 291 |
+
const captured = await captureOpenAIResponseHeaders({ sessionId: "session-123" });
|
| 292 |
+
|
| 293 |
+
expect(captured.sessionId).toBe("session-123");
|
| 294 |
+
expect(captured.clientRequestId).toBe("session-123");
|
| 295 |
+
});
|
| 296 |
+
|
| 297 |
+
it("clamps prompt_cache_key to OpenAI's 64-character limit", async () => {
|
| 298 |
+
const sessionId = "x".repeat(67);
|
| 299 |
+
let capturedPayload: Pick<CapturedResponsesPayload, "prompt_cache_key"> | undefined;
|
| 300 |
+
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
| 301 |
+
new Response("data: [DONE]\n\n", {
|
| 302 |
+
status: 200,
|
| 303 |
+
headers: { "content-type": "text/event-stream" },
|
| 304 |
+
}),
|
| 305 |
+
);
|
| 306 |
+
|
| 307 |
+
const stream = streamOpenAIResponses(
|
| 308 |
+
getModel("openai", "gpt-5.4"),
|
| 309 |
+
{
|
| 310 |
+
systemPrompt: "sys",
|
| 311 |
+
messages: [{ role: "user", content: "hi", timestamp: Date.now() }],
|
| 312 |
+
},
|
| 313 |
+
{
|
| 314 |
+
apiKey: "test-key",
|
| 315 |
+
sessionId,
|
| 316 |
+
onPayload: (payload) => {
|
| 317 |
+
capturedPayload = payload as Pick<CapturedResponsesPayload, "prompt_cache_key">;
|
| 318 |
+
},
|
| 319 |
+
},
|
| 320 |
+
);
|
| 321 |
+
|
| 322 |
+
for await (const event of stream) {
|
| 323 |
+
if (event.type === "done" || event.type === "error") break;
|
| 324 |
+
}
|
| 325 |
+
|
| 326 |
+
expect(capturedPayload?.prompt_cache_key).toBe("x".repeat(64));
|
| 327 |
+
});
|
| 328 |
+
|
| 329 |
+
it("sets cache-affinity headers for proxy OpenAI Responses requests with a sessionId", async () => {
|
| 330 |
+
const proxyModel: Model<"openai-responses"> = {
|
| 331 |
+
...getModel("openai", "gpt-5.4"),
|
| 332 |
+
provider: "opencode",
|
| 333 |
+
baseUrl: "https://proxy.example.com/v1",
|
| 334 |
+
};
|
| 335 |
+
const captured = await captureOpenAIResponseHeaders({ sessionId: "session-123" }, proxyModel);
|
| 336 |
+
|
| 337 |
+
expect(captured.sessionId).toBe("session-123");
|
| 338 |
+
expect(captured.clientRequestId).toBe("session-123");
|
| 339 |
+
});
|
| 340 |
+
|
| 341 |
+
it("uses OpenRouter session-affinity header when configured", async () => {
|
| 342 |
+
const proxyModel: Model<"openai-responses"> = {
|
| 343 |
+
...getModel("openai", "gpt-5.4"),
|
| 344 |
+
provider: "proxy",
|
| 345 |
+
baseUrl: "https://proxy.example.com/v1",
|
| 346 |
+
compat: { sessionAffinityFormat: "openrouter" },
|
| 347 |
+
};
|
| 348 |
+
let capturedPayload: CapturedResponsesPayload | undefined;
|
| 349 |
+
const captured = await captureOpenAIResponseHeaders(
|
| 350 |
+
{
|
| 351 |
+
sessionId: "session-proxy",
|
| 352 |
+
onPayload: (payload) => {
|
| 353 |
+
capturedPayload = payload as CapturedResponsesPayload;
|
| 354 |
+
},
|
| 355 |
+
},
|
| 356 |
+
proxyModel,
|
| 357 |
+
);
|
| 358 |
+
|
| 359 |
+
expect(captured.sessionId).toBeNull();
|
| 360 |
+
expect(captured.clientRequestId).toBeNull();
|
| 361 |
+
expect(captured.xSessionId).toBe("session-proxy");
|
| 362 |
+
expect(capturedPayload?.session_id).toBeUndefined();
|
| 363 |
+
expect(capturedPayload?.prompt_cache_key).toBe("session-proxy");
|
| 364 |
+
});
|
| 365 |
+
|
| 366 |
+
it("auto-detects OpenRouter session-affinity header for OpenRouter Responses endpoints", async () => {
|
| 367 |
+
const openRouterModel: Model<"openai-responses"> = {
|
| 368 |
+
...getModel("openai", "gpt-5.4"),
|
| 369 |
+
provider: "openrouter",
|
| 370 |
+
baseUrl: "https://openrouter.ai/api/v1",
|
| 371 |
+
};
|
| 372 |
+
let capturedPayload: CapturedResponsesPayload | undefined;
|
| 373 |
+
const captured = await captureOpenAIResponseHeaders(
|
| 374 |
+
{
|
| 375 |
+
sessionId: "session-openrouter",
|
| 376 |
+
onPayload: (payload) => {
|
| 377 |
+
capturedPayload = payload as CapturedResponsesPayload;
|
| 378 |
+
},
|
| 379 |
+
},
|
| 380 |
+
openRouterModel,
|
| 381 |
+
);
|
| 382 |
+
|
| 383 |
+
expect(captured.sessionId).toBeNull();
|
| 384 |
+
expect(captured.clientRequestId).toBeNull();
|
| 385 |
+
expect(captured.xSessionId).toBe("session-openrouter");
|
| 386 |
+
expect(capturedPayload?.session_id).toBeUndefined();
|
| 387 |
+
expect(capturedPayload?.prompt_cache_key).toBe("session-openrouter");
|
| 388 |
+
});
|
| 389 |
+
|
| 390 |
+
it("uses OpenAI no-session format when configured", async () => {
|
| 391 |
+
const proxyModel: Model<"openai-responses"> = {
|
| 392 |
+
...getModel("openai", "gpt-5.4"),
|
| 393 |
+
provider: "proxy",
|
| 394 |
+
baseUrl: "https://proxy.example.com/v1",
|
| 395 |
+
compat: { sessionAffinityFormat: "openai-nosession" },
|
| 396 |
+
};
|
| 397 |
+
let capturedPayload: CapturedResponsesPayload | undefined;
|
| 398 |
+
const captured = await captureOpenAIResponseHeaders(
|
| 399 |
+
{
|
| 400 |
+
sessionId: "session-proxy",
|
| 401 |
+
onPayload: (payload) => {
|
| 402 |
+
capturedPayload = payload as CapturedResponsesPayload;
|
| 403 |
+
},
|
| 404 |
+
},
|
| 405 |
+
proxyModel,
|
| 406 |
+
);
|
| 407 |
+
|
| 408 |
+
expect(captured.sessionId).toBeNull();
|
| 409 |
+
expect(captured.clientRequestId).toBe("session-proxy");
|
| 410 |
+
expect(captured.xSessionId).toBeNull();
|
| 411 |
+
expect(capturedPayload?.session_id).toBeUndefined();
|
| 412 |
+
expect(capturedPayload?.prompt_cache_key).toBe("session-proxy");
|
| 413 |
+
});
|
| 414 |
+
|
| 415 |
+
it("uses OpenAI no-session format for OpenCode Responses models", async () => {
|
| 416 |
+
const model = getModel("opencode", "gpt-5.4");
|
| 417 |
+
let capturedPayload: CapturedResponsesPayload | undefined;
|
| 418 |
+
const captured = await captureOpenAIResponseHeaders(
|
| 419 |
+
{
|
| 420 |
+
sessionId: "session-opencode",
|
| 421 |
+
onPayload: (payload) => {
|
| 422 |
+
capturedPayload = payload as CapturedResponsesPayload;
|
| 423 |
+
},
|
| 424 |
+
},
|
| 425 |
+
model,
|
| 426 |
+
);
|
| 427 |
+
|
| 428 |
+
expect(model.compat?.sessionAffinityFormat).toBe("openai-nosession");
|
| 429 |
+
expect(captured.sessionId).toBeNull();
|
| 430 |
+
expect(captured.clientRequestId).toBe("session-opencode");
|
| 431 |
+
expect(captured.xSessionId).toBeNull();
|
| 432 |
+
expect(capturedPayload?.prompt_cache_key).toBe("session-opencode");
|
| 433 |
+
});
|
| 434 |
+
|
| 435 |
+
it("can omit OpenAI session_id header while preserving other affinity data", async () => {
|
| 436 |
+
const proxyModel: Model<"openai-responses"> = {
|
| 437 |
+
...getModel("openai", "gpt-5.4"),
|
| 438 |
+
provider: "opencode",
|
| 439 |
+
baseUrl: "https://proxy.example.com/v1",
|
| 440 |
+
compat: { sessionAffinityFormat: "openai-nosession" },
|
| 441 |
+
};
|
| 442 |
+
let capturedPayload: CapturedResponsesPayload | undefined;
|
| 443 |
+
const captured = await captureOpenAIResponseHeaders(
|
| 444 |
+
{
|
| 445 |
+
sessionId: "session-123",
|
| 446 |
+
onPayload: (payload) => {
|
| 447 |
+
capturedPayload = payload as CapturedResponsesPayload;
|
| 448 |
+
},
|
| 449 |
+
},
|
| 450 |
+
proxyModel,
|
| 451 |
+
);
|
| 452 |
+
|
| 453 |
+
expect(captured.sessionId).toBeNull();
|
| 454 |
+
expect(captured.clientRequestId).toBe("session-123");
|
| 455 |
+
expect(capturedPayload?.prompt_cache_key).toBe("session-123");
|
| 456 |
+
});
|
| 457 |
+
|
| 458 |
+
it("lets explicit headers override the default OpenAI cache-affinity headers", async () => {
|
| 459 |
+
const captured = await captureOpenAIResponseHeaders({
|
| 460 |
+
sessionId: "session-123",
|
| 461 |
+
headers: {
|
| 462 |
+
session_id: "override-session",
|
| 463 |
+
"x-client-request-id": "override-request",
|
| 464 |
+
},
|
| 465 |
+
});
|
| 466 |
+
|
| 467 |
+
expect(captured.sessionId).toBe("override-session");
|
| 468 |
+
expect(captured.clientRequestId).toBe("override-request");
|
| 469 |
+
});
|
| 470 |
+
|
| 471 |
+
it("omits OpenAI cache-affinity headers when cacheRetention is none", async () => {
|
| 472 |
+
const captured = await captureOpenAIResponseHeaders({ cacheRetention: "none", sessionId: "session-123" });
|
| 473 |
+
|
| 474 |
+
expect(captured.sessionId).toBeNull();
|
| 475 |
+
expect(captured.clientRequestId).toBeNull();
|
| 476 |
+
});
|
| 477 |
+
|
| 478 |
+
it.each([
|
| 479 |
+
["gpt-5.4", "priority", 2],
|
| 480 |
+
["gpt-5.5", "priority", 2.5],
|
| 481 |
+
["gpt-5.5", "flex", 0.5],
|
| 482 |
+
] as const)("applies %s %s service-tier cost multiplier", async (modelId, serviceTier, multiplier) => {
|
| 483 |
+
const model = getModel("openai", modelId);
|
| 484 |
+
const tokenCount = 100_000;
|
| 485 |
+
const tokenScale = tokenCount / 1_000_000;
|
| 486 |
+
const sse = `${[
|
| 487 |
+
`data: ${JSON.stringify({
|
| 488 |
+
type: "response.completed",
|
| 489 |
+
response: {
|
| 490 |
+
status: "completed",
|
| 491 |
+
service_tier: serviceTier,
|
| 492 |
+
usage: {
|
| 493 |
+
input_tokens: tokenCount,
|
| 494 |
+
output_tokens: tokenCount,
|
| 495 |
+
total_tokens: tokenCount * 2,
|
| 496 |
+
input_tokens_details: { cached_tokens: 0 },
|
| 497 |
+
},
|
| 498 |
+
},
|
| 499 |
+
})}`,
|
| 500 |
+
].join("\n\n")}\n\n`;
|
| 501 |
+
|
| 502 |
+
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
| 503 |
+
new Response(sse, {
|
| 504 |
+
status: 200,
|
| 505 |
+
headers: { "content-type": "text/event-stream" },
|
| 506 |
+
}),
|
| 507 |
+
);
|
| 508 |
+
|
| 509 |
+
const stream = streamOpenAIResponses(
|
| 510 |
+
model,
|
| 511 |
+
{
|
| 512 |
+
systemPrompt: "sys",
|
| 513 |
+
messages: [{ role: "user", content: "hi", timestamp: Date.now() }],
|
| 514 |
+
},
|
| 515 |
+
{ apiKey: "test-key", serviceTier },
|
| 516 |
+
);
|
| 517 |
+
|
| 518 |
+
const result = await stream.result();
|
| 519 |
+
|
| 520 |
+
expect(result.usage.cost.input).toBe(model.cost.input * multiplier * tokenScale);
|
| 521 |
+
expect(result.usage.cost.output).toBe(model.cost.output * multiplier * tokenScale);
|
| 522 |
+
expect(result.usage.cost.total).toBe((model.cost.input + model.cost.output) * multiplier * tokenScale);
|
| 523 |
+
});
|
| 524 |
+
});
|
| 525 |
+
|
| 526 |
+
describe("openai-responses max_output_tokens compat", () => {
|
| 527 |
+
afterEach(() => {
|
| 528 |
+
vi.restoreAllMocks();
|
| 529 |
+
});
|
| 530 |
+
|
| 531 |
+
it("sends max_output_tokens by default", async () => {
|
| 532 |
+
let capturedPayload: { max_output_tokens?: number } | undefined;
|
| 533 |
+
|
| 534 |
+
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
| 535 |
+
new Response("data: [DONE]\n\n", {
|
| 536 |
+
status: 200,
|
| 537 |
+
headers: { "content-type": "text/event-stream" },
|
| 538 |
+
}),
|
| 539 |
+
);
|
| 540 |
+
|
| 541 |
+
const stream = streamOpenAIResponses(
|
| 542 |
+
getModel("openai", "gpt-5.4"),
|
| 543 |
+
{
|
| 544 |
+
systemPrompt: "sys",
|
| 545 |
+
messages: [{ role: "user", content: "hi", timestamp: Date.now() }],
|
| 546 |
+
},
|
| 547 |
+
{
|
| 548 |
+
apiKey: "test-key",
|
| 549 |
+
maxTokens: 1024,
|
| 550 |
+
onPayload: (payload) => {
|
| 551 |
+
capturedPayload = payload as { max_output_tokens?: number };
|
| 552 |
+
},
|
| 553 |
+
},
|
| 554 |
+
);
|
| 555 |
+
|
| 556 |
+
for await (const event of stream) {
|
| 557 |
+
if (event.type === "done" || event.type === "error") break;
|
| 558 |
+
}
|
| 559 |
+
|
| 560 |
+
expect(capturedPayload?.max_output_tokens).toBe(1024);
|
| 561 |
+
});
|
| 562 |
+
|
| 563 |
+
it("omits max_output_tokens when supportsMaxOutputTokens is false", async () => {
|
| 564 |
+
const baseModel = getModel("openai", "gpt-5.4");
|
| 565 |
+
const model: Model<"openai-responses"> = {
|
| 566 |
+
...baseModel,
|
| 567 |
+
compat: { ...baseModel.compat, supportsMaxOutputTokens: false },
|
| 568 |
+
};
|
| 569 |
+
let capturedPayload: { max_output_tokens?: number } | undefined;
|
| 570 |
+
|
| 571 |
+
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
| 572 |
+
new Response("data: [DONE]\n\n", {
|
| 573 |
+
status: 200,
|
| 574 |
+
headers: { "content-type": "text/event-stream" },
|
| 575 |
+
}),
|
| 576 |
+
);
|
| 577 |
+
|
| 578 |
+
const stream = streamOpenAIResponses(
|
| 579 |
+
model,
|
| 580 |
+
{
|
| 581 |
+
systemPrompt: "sys",
|
| 582 |
+
messages: [{ role: "user", content: "hi", timestamp: Date.now() }],
|
| 583 |
+
},
|
| 584 |
+
{
|
| 585 |
+
apiKey: "test-key",
|
| 586 |
+
maxTokens: 1024,
|
| 587 |
+
onPayload: (payload) => {
|
| 588 |
+
capturedPayload = payload as { max_output_tokens?: number };
|
| 589 |
+
},
|
| 590 |
+
},
|
| 591 |
+
);
|
| 592 |
+
|
| 593 |
+
for await (const event of stream) {
|
| 594 |
+
if (event.type === "done" || event.type === "error") break;
|
| 595 |
+
}
|
| 596 |
+
|
| 597 |
+
expect(capturedPayload?.max_output_tokens).toBeUndefined();
|
| 598 |
+
});
|
| 599 |
+
});
|
packages/ai/test/openai-responses-partial-json-cleanup.test.ts
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { ResponseStreamEvent } from "openai/resources/responses/responses.js";
|
| 2 |
+
import { describe, expect, it, vi } from "vitest";
|
| 3 |
+
import { processResponsesStream } from "../src/api/openai-responses-shared.ts";
|
| 4 |
+
import type { AssistantMessage, AssistantMessageEvent, Model } from "../src/types.ts";
|
| 5 |
+
import { AssistantMessageEventStream } from "../src/utils/event-stream.ts";
|
| 6 |
+
|
| 7 |
+
function createOutput(model: Model<"openai-responses">): AssistantMessage {
|
| 8 |
+
return {
|
| 9 |
+
role: "assistant",
|
| 10 |
+
content: [],
|
| 11 |
+
api: model.api,
|
| 12 |
+
provider: model.provider,
|
| 13 |
+
model: model.id,
|
| 14 |
+
usage: {
|
| 15 |
+
input: 0,
|
| 16 |
+
output: 0,
|
| 17 |
+
cacheRead: 0,
|
| 18 |
+
cacheWrite: 0,
|
| 19 |
+
totalTokens: 0,
|
| 20 |
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
| 21 |
+
},
|
| 22 |
+
stopReason: "pending",
|
| 23 |
+
timestamp: Date.now(),
|
| 24 |
+
};
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
async function* createFunctionCallEvents(argumentsJson: string): AsyncIterable<ResponseStreamEvent> {
|
| 28 |
+
yield {
|
| 29 |
+
type: "response.output_item.added",
|
| 30 |
+
item: {
|
| 31 |
+
type: "function_call",
|
| 32 |
+
id: "fc_test",
|
| 33 |
+
call_id: "call_test",
|
| 34 |
+
name: "edit",
|
| 35 |
+
arguments: "",
|
| 36 |
+
},
|
| 37 |
+
} as ResponseStreamEvent;
|
| 38 |
+
yield {
|
| 39 |
+
type: "response.function_call_arguments.delta",
|
| 40 |
+
delta: '{"path":"README.md"',
|
| 41 |
+
} as ResponseStreamEvent;
|
| 42 |
+
yield {
|
| 43 |
+
type: "response.function_call_arguments.delta",
|
| 44 |
+
delta: ',"content":"updated"}',
|
| 45 |
+
} as ResponseStreamEvent;
|
| 46 |
+
yield {
|
| 47 |
+
type: "response.function_call_arguments.done",
|
| 48 |
+
arguments: argumentsJson,
|
| 49 |
+
} as ResponseStreamEvent;
|
| 50 |
+
yield {
|
| 51 |
+
type: "response.output_item.done",
|
| 52 |
+
item: {
|
| 53 |
+
type: "function_call",
|
| 54 |
+
id: "fc_test",
|
| 55 |
+
call_id: "call_test",
|
| 56 |
+
name: "edit",
|
| 57 |
+
arguments: argumentsJson,
|
| 58 |
+
},
|
| 59 |
+
} as ResponseStreamEvent;
|
| 60 |
+
yield {
|
| 61 |
+
type: "response.completed",
|
| 62 |
+
sequence_number: 5,
|
| 63 |
+
response: { id: "resp_test", status: "completed" },
|
| 64 |
+
} as ResponseStreamEvent;
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
describe("openai responses partialJson cleanup", () => {
|
| 68 |
+
it("removes partialJson from persisted tool-call blocks at output_item.done", async () => {
|
| 69 |
+
const model: Model<"openai-responses"> = {
|
| 70 |
+
id: "gpt-5-mini",
|
| 71 |
+
name: "GPT-5 Mini",
|
| 72 |
+
api: "openai-responses",
|
| 73 |
+
provider: "openai",
|
| 74 |
+
baseUrl: "https://api.openai.com/v1",
|
| 75 |
+
reasoning: true,
|
| 76 |
+
input: ["text"],
|
| 77 |
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
| 78 |
+
contextWindow: 400000,
|
| 79 |
+
maxTokens: 128000,
|
| 80 |
+
};
|
| 81 |
+
const output = createOutput(model);
|
| 82 |
+
const stream = new AssistantMessageEventStream();
|
| 83 |
+
const pushSpy = vi.spyOn(stream, "push");
|
| 84 |
+
const argumentsJson = '{"path":"README.md","content":"updated"}';
|
| 85 |
+
|
| 86 |
+
await processResponsesStream(createFunctionCallEvents(argumentsJson), output, stream, model);
|
| 87 |
+
|
| 88 |
+
expect(output.content).toHaveLength(1);
|
| 89 |
+
const persistedToolCall = output.content[0];
|
| 90 |
+
expect(persistedToolCall?.type).toBe("toolCall");
|
| 91 |
+
if (!persistedToolCall || persistedToolCall.type !== "toolCall") {
|
| 92 |
+
throw new Error("Expected toolCall block");
|
| 93 |
+
}
|
| 94 |
+
expect(persistedToolCall.arguments).toEqual({ path: "README.md", content: "updated" });
|
| 95 |
+
expect("partialJson" in persistedToolCall).toBe(false);
|
| 96 |
+
|
| 97 |
+
const emittedEvents = pushSpy.mock.calls.map(([event]) => event as AssistantMessageEvent);
|
| 98 |
+
const toolCallEnd = emittedEvents.find((event) => event.type === "toolcall_end");
|
| 99 |
+
expect(toolCallEnd).toBeDefined();
|
| 100 |
+
if (!toolCallEnd || toolCallEnd.type !== "toolcall_end") {
|
| 101 |
+
throw new Error("Expected toolcall_end event");
|
| 102 |
+
}
|
| 103 |
+
expect(toolCallEnd.toolCall).toBe(persistedToolCall);
|
| 104 |
+
expect("partialJson" in toolCallEnd.toolCall).toBe(false);
|
| 105 |
+
});
|
| 106 |
+
});
|
packages/ai/test/openai-responses-reasoning-replay-e2e.test.ts
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Type } from "typebox";
|
| 2 |
+
import { describe, expect, it } from "vitest";
|
| 3 |
+
import { complete, getEnvApiKey, getModel } from "../src/compat.ts";
|
| 4 |
+
import type { AssistantMessage, Context, Message, Tool, ToolCall } from "../src/types.ts";
|
| 5 |
+
|
| 6 |
+
const testToolSchema = Type.Object({
|
| 7 |
+
value: Type.Number({ description: "A number to double" }),
|
| 8 |
+
});
|
| 9 |
+
|
| 10 |
+
const testTool: Tool<typeof testToolSchema> = {
|
| 11 |
+
name: "double_number",
|
| 12 |
+
description: "Doubles a number and returns the result",
|
| 13 |
+
parameters: testToolSchema,
|
| 14 |
+
};
|
| 15 |
+
|
| 16 |
+
describe.skipIf(!process.env.OPENAI_API_KEY || !process.env.ANTHROPIC_API_KEY)(
|
| 17 |
+
"OpenAI Responses reasoning replay e2e",
|
| 18 |
+
() => {
|
| 19 |
+
it("skips reasoning-only history after an aborted turn", { retry: 2 }, async () => {
|
| 20 |
+
const model = getModel("openai", "gpt-5-mini");
|
| 21 |
+
|
| 22 |
+
const apiKey = getEnvApiKey("openai");
|
| 23 |
+
if (!apiKey) {
|
| 24 |
+
throw new Error("Missing OPENAI_API_KEY");
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
const userMessage: Message = {
|
| 28 |
+
role: "user",
|
| 29 |
+
content: "Use the double_number tool to double 21.",
|
| 30 |
+
timestamp: Date.now(),
|
| 31 |
+
};
|
| 32 |
+
|
| 33 |
+
const assistantResponse = await complete(
|
| 34 |
+
model,
|
| 35 |
+
{
|
| 36 |
+
systemPrompt: "You are a helpful assistant. Use the tool.",
|
| 37 |
+
messages: [userMessage],
|
| 38 |
+
tools: [testTool],
|
| 39 |
+
},
|
| 40 |
+
{
|
| 41 |
+
apiKey,
|
| 42 |
+
reasoningEffort: "high",
|
| 43 |
+
},
|
| 44 |
+
);
|
| 45 |
+
|
| 46 |
+
const thinkingBlock = assistantResponse.content.find(
|
| 47 |
+
(block) => block.type === "thinking" && block.thinkingSignature,
|
| 48 |
+
);
|
| 49 |
+
if (!thinkingBlock || thinkingBlock.type !== "thinking") {
|
| 50 |
+
throw new Error("Missing thinking signature from OpenAI Responses");
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
const corruptedAssistant: AssistantMessage = {
|
| 54 |
+
...assistantResponse,
|
| 55 |
+
content: [thinkingBlock],
|
| 56 |
+
stopReason: "aborted",
|
| 57 |
+
};
|
| 58 |
+
|
| 59 |
+
const followUp: Message = {
|
| 60 |
+
role: "user",
|
| 61 |
+
content: "Say hello to confirm you can continue.",
|
| 62 |
+
timestamp: Date.now(),
|
| 63 |
+
};
|
| 64 |
+
|
| 65 |
+
const context: Context = {
|
| 66 |
+
systemPrompt: "You are a helpful assistant.",
|
| 67 |
+
messages: [userMessage, corruptedAssistant, followUp],
|
| 68 |
+
tools: [testTool],
|
| 69 |
+
};
|
| 70 |
+
|
| 71 |
+
const response = await complete(model, context, {
|
| 72 |
+
apiKey,
|
| 73 |
+
reasoningEffort: "high",
|
| 74 |
+
});
|
| 75 |
+
|
| 76 |
+
// The key assertion: no 400 error from orphaned reasoning item
|
| 77 |
+
expect(response.stopReason, `Error: ${response.errorMessage}`).not.toBe("error");
|
| 78 |
+
expect(response.errorMessage).toBeFalsy();
|
| 79 |
+
// Model should respond (text or tool call)
|
| 80 |
+
expect(response.content.length).toBeGreaterThan(0);
|
| 81 |
+
});
|
| 82 |
+
|
| 83 |
+
it("handles same-provider different-model handoff with tool calls", { retry: 2 }, async () => {
|
| 84 |
+
// This tests the scenario where:
|
| 85 |
+
// 1. Model A (gpt-5-mini) generates reasoning + function_call
|
| 86 |
+
// 2. User switches to Model B (gpt-5.2-codex) - same provider, different model
|
| 87 |
+
// 3. transform-messages: isSameModel=false, thinking converted to text
|
| 88 |
+
// 4. But tool call ID still has OpenAI pairing history (fc_xxx paired with rs_xxx)
|
| 89 |
+
// 5. Without fix: OpenAI returns 400 "function_call without required reasoning item"
|
| 90 |
+
// 6. With fix: tool calls/results converted to text, conversation continues
|
| 91 |
+
|
| 92 |
+
const modelA = getModel("openai", "gpt-5-mini");
|
| 93 |
+
const modelB = getModel("openai", "gpt-5.5");
|
| 94 |
+
|
| 95 |
+
const apiKey = getEnvApiKey("openai");
|
| 96 |
+
if (!apiKey) {
|
| 97 |
+
throw new Error("Missing OPENAI_API_KEY");
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
const userMessage: Message = {
|
| 101 |
+
role: "user",
|
| 102 |
+
content: "Use the double_number tool to double 21.",
|
| 103 |
+
timestamp: Date.now(),
|
| 104 |
+
};
|
| 105 |
+
|
| 106 |
+
// Get a real response from Model A with reasoning + tool call
|
| 107 |
+
const assistantResponse = await complete(
|
| 108 |
+
modelA,
|
| 109 |
+
{
|
| 110 |
+
systemPrompt: "You are a helpful assistant. Always use the tool when asked.",
|
| 111 |
+
messages: [userMessage],
|
| 112 |
+
tools: [testTool],
|
| 113 |
+
},
|
| 114 |
+
{
|
| 115 |
+
apiKey,
|
| 116 |
+
reasoningEffort: "high",
|
| 117 |
+
},
|
| 118 |
+
);
|
| 119 |
+
|
| 120 |
+
const toolCallBlock = assistantResponse.content.find((block) => block.type === "toolCall") as
|
| 121 |
+
| ToolCall
|
| 122 |
+
| undefined;
|
| 123 |
+
|
| 124 |
+
if (!toolCallBlock) {
|
| 125 |
+
throw new Error("Missing tool call from OpenAI Responses - model did not use the tool");
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
// Provide a tool result
|
| 129 |
+
const toolResult: Message = {
|
| 130 |
+
role: "toolResult",
|
| 131 |
+
toolCallId: toolCallBlock.id,
|
| 132 |
+
toolName: toolCallBlock.name,
|
| 133 |
+
content: [{ type: "text", text: "42" }],
|
| 134 |
+
isError: false,
|
| 135 |
+
timestamp: Date.now(),
|
| 136 |
+
};
|
| 137 |
+
|
| 138 |
+
const followUp: Message = {
|
| 139 |
+
role: "user",
|
| 140 |
+
content: "What was the result? Answer with just the number.",
|
| 141 |
+
timestamp: Date.now(),
|
| 142 |
+
};
|
| 143 |
+
|
| 144 |
+
// Now continue with Model B (different model, same provider)
|
| 145 |
+
const context: Context = {
|
| 146 |
+
systemPrompt: "You are a helpful assistant. Answer concisely.",
|
| 147 |
+
messages: [userMessage, assistantResponse, toolResult, followUp],
|
| 148 |
+
tools: [testTool],
|
| 149 |
+
};
|
| 150 |
+
|
| 151 |
+
let capturedPayload: any = null;
|
| 152 |
+
const response = await complete(modelB, context, {
|
| 153 |
+
apiKey,
|
| 154 |
+
reasoningEffort: "high",
|
| 155 |
+
onPayload: (payload) => {
|
| 156 |
+
capturedPayload = payload;
|
| 157 |
+
},
|
| 158 |
+
});
|
| 159 |
+
|
| 160 |
+
// The key assertion: no 400 error from orphaned function_call
|
| 161 |
+
expect(response.stopReason, `Error: ${response.errorMessage}`).not.toBe("error");
|
| 162 |
+
expect(response.errorMessage).toBeFalsy();
|
| 163 |
+
expect(response.content.length).toBeGreaterThan(0);
|
| 164 |
+
|
| 165 |
+
// Log what was sent for debugging
|
| 166 |
+
const input = capturedPayload?.input as any[];
|
| 167 |
+
const functionCalls = input?.filter((item: any) => item.type === "function_call") || [];
|
| 168 |
+
const reasoningItems = input?.filter((item: any) => item.type === "reasoning") || [];
|
| 169 |
+
|
| 170 |
+
console.log("Payload sent to API:");
|
| 171 |
+
console.log("- function_calls:", functionCalls.length);
|
| 172 |
+
console.log("- reasoning items:", reasoningItems.length);
|
| 173 |
+
console.log("- full input:", JSON.stringify(input, null, 2));
|
| 174 |
+
|
| 175 |
+
// Verify the model understood the context
|
| 176 |
+
const responseText = response.content
|
| 177 |
+
.filter((b) => b.type === "text")
|
| 178 |
+
.map((b) => (b as any).text)
|
| 179 |
+
.join("");
|
| 180 |
+
expect(responseText).toContain("42");
|
| 181 |
+
});
|
| 182 |
+
|
| 183 |
+
it("handles cross-provider handoff from Anthropic to OpenAI Codex", { retry: 2 }, async () => {
|
| 184 |
+
// This tests cross-provider handoff:
|
| 185 |
+
// 1. Anthropic model generates thinking + function_call (toolu_xxx ID)
|
| 186 |
+
// 2. User switches to OpenAI Codex
|
| 187 |
+
// 3. transform-messages: isSameModel=false, thinking converted to text
|
| 188 |
+
// 4. Tool call ID is Anthropic format (toolu_xxx), no OpenAI pairing history
|
| 189 |
+
// 5. Should work because foreign IDs have no pairing expectation
|
| 190 |
+
|
| 191 |
+
const anthropicModel = getModel("anthropic", "claude-sonnet-4-5");
|
| 192 |
+
const codexModel = getModel("openai", "gpt-5.5");
|
| 193 |
+
|
| 194 |
+
const anthropicApiKey = getEnvApiKey("anthropic");
|
| 195 |
+
const openaiApiKey = getEnvApiKey("openai");
|
| 196 |
+
if (!anthropicApiKey || !openaiApiKey) {
|
| 197 |
+
throw new Error("Missing API keys");
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
const userMessage: Message = {
|
| 201 |
+
role: "user",
|
| 202 |
+
content: "Use the double_number tool to double 21.",
|
| 203 |
+
timestamp: Date.now(),
|
| 204 |
+
};
|
| 205 |
+
|
| 206 |
+
// Get a real response from Anthropic with thinking + tool call
|
| 207 |
+
const assistantResponse = await complete(
|
| 208 |
+
anthropicModel,
|
| 209 |
+
{
|
| 210 |
+
systemPrompt: "You are a helpful assistant. Always use the tool when asked.",
|
| 211 |
+
messages: [userMessage],
|
| 212 |
+
tools: [testTool],
|
| 213 |
+
},
|
| 214 |
+
{
|
| 215 |
+
apiKey: anthropicApiKey,
|
| 216 |
+
thinkingEnabled: true,
|
| 217 |
+
thinkingBudgetTokens: 5000,
|
| 218 |
+
},
|
| 219 |
+
);
|
| 220 |
+
|
| 221 |
+
const toolCallBlock = assistantResponse.content.find((block) => block.type === "toolCall") as
|
| 222 |
+
| ToolCall
|
| 223 |
+
| undefined;
|
| 224 |
+
|
| 225 |
+
if (!toolCallBlock) {
|
| 226 |
+
throw new Error("Missing tool call from Anthropic - model did not use the tool");
|
| 227 |
+
}
|
| 228 |
+
|
| 229 |
+
console.log("Anthropic tool call ID:", toolCallBlock.id);
|
| 230 |
+
|
| 231 |
+
// Provide a tool result
|
| 232 |
+
const toolResult: Message = {
|
| 233 |
+
role: "toolResult",
|
| 234 |
+
toolCallId: toolCallBlock.id,
|
| 235 |
+
toolName: toolCallBlock.name,
|
| 236 |
+
content: [{ type: "text", text: "42" }],
|
| 237 |
+
isError: false,
|
| 238 |
+
timestamp: Date.now(),
|
| 239 |
+
};
|
| 240 |
+
|
| 241 |
+
const followUp: Message = {
|
| 242 |
+
role: "user",
|
| 243 |
+
content: "What was the result? Answer with just the number.",
|
| 244 |
+
timestamp: Date.now(),
|
| 245 |
+
};
|
| 246 |
+
|
| 247 |
+
// Now continue with Codex (different provider)
|
| 248 |
+
const context: Context = {
|
| 249 |
+
systemPrompt: "You are a helpful assistant. Answer concisely.",
|
| 250 |
+
messages: [userMessage, assistantResponse, toolResult, followUp],
|
| 251 |
+
tools: [testTool],
|
| 252 |
+
};
|
| 253 |
+
|
| 254 |
+
let capturedPayload: any = null;
|
| 255 |
+
const response = await complete(codexModel, context, {
|
| 256 |
+
apiKey: openaiApiKey,
|
| 257 |
+
reasoningEffort: "high",
|
| 258 |
+
onPayload: (payload) => {
|
| 259 |
+
capturedPayload = payload;
|
| 260 |
+
},
|
| 261 |
+
});
|
| 262 |
+
|
| 263 |
+
// Log what was sent
|
| 264 |
+
const input = capturedPayload?.input as any[];
|
| 265 |
+
const functionCalls = input?.filter((item: any) => item.type === "function_call") || [];
|
| 266 |
+
const reasoningItems = input?.filter((item: any) => item.type === "reasoning") || [];
|
| 267 |
+
|
| 268 |
+
console.log("Payload sent to Codex:");
|
| 269 |
+
console.log("- function_calls:", functionCalls.length);
|
| 270 |
+
console.log("- reasoning items:", reasoningItems.length);
|
| 271 |
+
if (functionCalls.length > 0) {
|
| 272 |
+
console.log(
|
| 273 |
+
"- function_call IDs:",
|
| 274 |
+
functionCalls.map((fc: any) => fc.id),
|
| 275 |
+
);
|
| 276 |
+
}
|
| 277 |
+
|
| 278 |
+
// The key assertion: no 400 error
|
| 279 |
+
expect(response.stopReason, `Error: ${response.errorMessage}`).not.toBe("error");
|
| 280 |
+
expect(response.errorMessage).toBeFalsy();
|
| 281 |
+
expect(response.content.length).toBeGreaterThan(0);
|
| 282 |
+
|
| 283 |
+
// Verify the model understood the context
|
| 284 |
+
const responseText = response.content
|
| 285 |
+
.filter((b) => b.type === "text")
|
| 286 |
+
.map((b) => (b as any).text)
|
| 287 |
+
.join("");
|
| 288 |
+
expect(responseText).toContain("42");
|
| 289 |
+
});
|
| 290 |
+
},
|
| 291 |
+
);
|
packages/ai/test/openrouter-cache-write-repro.test.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, expect, it } from "vitest";
|
| 2 |
+
import { completeSimple, getModel } from "../src/compat.ts";
|
| 3 |
+
|
| 4 |
+
function createLongSystemPrompt(): string {
|
| 5 |
+
const nonce = `${Date.now()}-${Math.random()}`;
|
| 6 |
+
return `You are a concise assistant.\nCache nonce: ${nonce}\n\n${Array(80)
|
| 7 |
+
.fill(
|
| 8 |
+
"Prompt-caching probe content. Keep this exact text stable across requests so the provider can reuse prefix tokens and report cache read and cache write usage.",
|
| 9 |
+
)
|
| 10 |
+
.join("\n\n")}`;
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
describe.skipIf(!process.env.OPENROUTER_API_KEY)("OpenRouter cache_write repro E2E", () => {
|
| 14 |
+
it(
|
| 15 |
+
"regression: preserves cache_write_tokens on openai-completions stream path",
|
| 16 |
+
{ retry: 2, timeout: 90000 },
|
| 17 |
+
async () => {
|
| 18 |
+
const model = getModel("openrouter", "google/gemini-2.5-flash");
|
| 19 |
+
const context = {
|
| 20 |
+
systemPrompt: createLongSystemPrompt(),
|
| 21 |
+
messages: [
|
| 22 |
+
{
|
| 23 |
+
role: "user" as const,
|
| 24 |
+
content: "Reply with exactly: OK",
|
| 25 |
+
timestamp: Date.now(),
|
| 26 |
+
},
|
| 27 |
+
],
|
| 28 |
+
};
|
| 29 |
+
|
| 30 |
+
const options = {
|
| 31 |
+
apiKey: process.env.OPENROUTER_API_KEY!,
|
| 32 |
+
maxTokens: 32,
|
| 33 |
+
temperature: 0,
|
| 34 |
+
onPayload: (payload: unknown) => {
|
| 35 |
+
const params = payload as {
|
| 36 |
+
messages?: Array<{
|
| 37 |
+
role?: string;
|
| 38 |
+
content?: string | Array<{ type?: string; text?: string; cache_control?: { type: string } }>;
|
| 39 |
+
}>;
|
| 40 |
+
};
|
| 41 |
+
const messages = params.messages;
|
| 42 |
+
if (!Array.isArray(messages)) return payload;
|
| 43 |
+
|
| 44 |
+
for (let i = messages.length - 1; i >= 0; i--) {
|
| 45 |
+
const msg = messages[i];
|
| 46 |
+
if (msg.role !== "user") continue;
|
| 47 |
+
if (typeof msg.content === "string") {
|
| 48 |
+
msg.content = [{ type: "text", text: msg.content, cache_control: { type: "ephemeral" } }];
|
| 49 |
+
break;
|
| 50 |
+
}
|
| 51 |
+
if (!Array.isArray(msg.content)) continue;
|
| 52 |
+
for (let j = msg.content.length - 1; j >= 0; j--) {
|
| 53 |
+
const part = msg.content[j];
|
| 54 |
+
if (part.type === "text") {
|
| 55 |
+
part.cache_control = { type: "ephemeral" };
|
| 56 |
+
break;
|
| 57 |
+
}
|
| 58 |
+
}
|
| 59 |
+
break;
|
| 60 |
+
}
|
| 61 |
+
return payload;
|
| 62 |
+
},
|
| 63 |
+
};
|
| 64 |
+
|
| 65 |
+
const first = await completeSimple(model, context, options);
|
| 66 |
+
expect(first.stopReason, first.errorMessage).toBe("stop");
|
| 67 |
+
|
| 68 |
+
const second = await completeSimple(model, context, options);
|
| 69 |
+
expect(second.stopReason, second.errorMessage).toBe("stop");
|
| 70 |
+
|
| 71 |
+
// Regression expectation: cache_write_tokens from provider usage must be preserved.
|
| 72 |
+
// With the cache_control marker above, at least one of the two calls should create cache.
|
| 73 |
+
const hasCacheWrite = first.usage.cacheWrite > 0 || second.usage.cacheWrite > 0;
|
| 74 |
+
expect(hasCacheWrite).toBe(true);
|
| 75 |
+
},
|
| 76 |
+
);
|
| 77 |
+
});
|
packages/ai/test/openrouter-reasoning-options.test.ts
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, expect, it } from "vitest";
|
| 2 |
+
import { getOpenRouterThinkingLevelMap } from "../scripts/openrouter-reasoning-options.ts";
|
| 3 |
+
import { streamSimple } from "../src/api/openai-completions.ts";
|
| 4 |
+
import type { Context, Model, ThinkingLevelMap } from "../src/types.ts";
|
| 5 |
+
|
| 6 |
+
const context: Context = {
|
| 7 |
+
messages: [{ role: "user", content: "Hello", timestamp: 0 }],
|
| 8 |
+
};
|
| 9 |
+
|
| 10 |
+
function openRouterModel(thinkingLevelMap?: ThinkingLevelMap): Model<"openai-completions"> {
|
| 11 |
+
return {
|
| 12 |
+
id: "stealth/ox-alpha",
|
| 13 |
+
name: "Ox Alpha",
|
| 14 |
+
api: "openai-completions",
|
| 15 |
+
provider: "openrouter",
|
| 16 |
+
baseUrl: "https://example.invalid/v1",
|
| 17 |
+
reasoning: true,
|
| 18 |
+
thinkingLevelMap,
|
| 19 |
+
input: ["text"],
|
| 20 |
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
| 21 |
+
contextWindow: 128000,
|
| 22 |
+
maxTokens: 4096,
|
| 23 |
+
compat: { thinkingFormat: "openrouter" },
|
| 24 |
+
};
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
async function capturePayload(model: Model<"openai-completions">, reasoning?: "low") {
|
| 28 |
+
let payload: { reasoning?: { effort?: string } } | undefined;
|
| 29 |
+
await streamSimple(model, context, {
|
| 30 |
+
apiKey: "test",
|
| 31 |
+
reasoning,
|
| 32 |
+
onPayload: (request) => {
|
| 33 |
+
payload = request as { reasoning?: { effort?: string } };
|
| 34 |
+
throw new Error("payload captured");
|
| 35 |
+
},
|
| 36 |
+
}).result();
|
| 37 |
+
if (!payload) throw new Error("OpenRouter payload was not captured");
|
| 38 |
+
return payload;
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
describe("getOpenRouterThinkingLevelMap", () => {
|
| 42 |
+
it("marks mandatory reasoning and unsupported efforts unavailable", () => {
|
| 43 |
+
expect(
|
| 44 |
+
getOpenRouterThinkingLevelMap({
|
| 45 |
+
mandatory: true,
|
| 46 |
+
default_enabled: true,
|
| 47 |
+
supported_efforts: ["max", "high", "low"],
|
| 48 |
+
default_effort: "max",
|
| 49 |
+
}),
|
| 50 |
+
).toEqual({
|
| 51 |
+
off: null,
|
| 52 |
+
minimal: null,
|
| 53 |
+
low: "low",
|
| 54 |
+
medium: null,
|
| 55 |
+
high: "high",
|
| 56 |
+
xhigh: null,
|
| 57 |
+
max: "max",
|
| 58 |
+
});
|
| 59 |
+
});
|
| 60 |
+
|
| 61 |
+
it("still marks off unavailable when OpenRouter omits effort metadata", () => {
|
| 62 |
+
expect(getOpenRouterThinkingLevelMap({ mandatory: true })).toEqual({ off: null });
|
| 63 |
+
});
|
| 64 |
+
|
| 65 |
+
it("keeps off available while restricting optional models to supported efforts", () => {
|
| 66 |
+
expect(
|
| 67 |
+
getOpenRouterThinkingLevelMap({
|
| 68 |
+
mandatory: false,
|
| 69 |
+
default_enabled: true,
|
| 70 |
+
supported_efforts: ["high", "low"],
|
| 71 |
+
}),
|
| 72 |
+
).toEqual({
|
| 73 |
+
off: "none",
|
| 74 |
+
minimal: null,
|
| 75 |
+
low: "low",
|
| 76 |
+
medium: null,
|
| 77 |
+
high: "high",
|
| 78 |
+
xhigh: null,
|
| 79 |
+
max: null,
|
| 80 |
+
});
|
| 81 |
+
});
|
| 82 |
+
|
| 83 |
+
it("does not add metadata for optional models without effort controls", () => {
|
| 84 |
+
expect(getOpenRouterThinkingLevelMap({ mandatory: false })).toBeUndefined();
|
| 85 |
+
});
|
| 86 |
+
});
|
| 87 |
+
|
| 88 |
+
describe("OpenRouter mandatory reasoning payloads", () => {
|
| 89 |
+
const mandatoryMap = getOpenRouterThinkingLevelMap({
|
| 90 |
+
mandatory: true,
|
| 91 |
+
supported_efforts: ["max", "high", "low"],
|
| 92 |
+
});
|
| 93 |
+
|
| 94 |
+
it("omits reasoning when a background call does not request it", async () => {
|
| 95 |
+
expect(await capturePayload(openRouterModel(mandatoryMap))).not.toHaveProperty("reasoning");
|
| 96 |
+
});
|
| 97 |
+
|
| 98 |
+
it("still sends an explicitly selected supported effort", async () => {
|
| 99 |
+
expect(await capturePayload(openRouterModel(mandatoryMap), "low")).toMatchObject({
|
| 100 |
+
reasoning: { effort: "low" },
|
| 101 |
+
});
|
| 102 |
+
});
|
| 103 |
+
|
| 104 |
+
it("continues to explicitly disable reasoning for optional models", async () => {
|
| 105 |
+
expect(await capturePayload(openRouterModel())).toMatchObject({
|
| 106 |
+
reasoning: { effort: "none" },
|
| 107 |
+
});
|
| 108 |
+
});
|
| 109 |
+
});
|
packages/ai/test/overflow.test.ts
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, expect, it } from "vitest";
|
| 2 |
+
import type { AssistantMessage } from "../src/types.ts";
|
| 3 |
+
import { isContextOverflow, isRecoverableLength } from "../src/utils/overflow.ts";
|
| 4 |
+
|
| 5 |
+
function createErrorMessage(errorMessage: string): AssistantMessage {
|
| 6 |
+
return {
|
| 7 |
+
role: "assistant",
|
| 8 |
+
content: [],
|
| 9 |
+
api: "openai-completions",
|
| 10 |
+
provider: "ollama",
|
| 11 |
+
model: "qwen3.5:35b",
|
| 12 |
+
usage: {
|
| 13 |
+
input: 0,
|
| 14 |
+
output: 0,
|
| 15 |
+
cacheRead: 0,
|
| 16 |
+
cacheWrite: 0,
|
| 17 |
+
totalTokens: 0,
|
| 18 |
+
cost: {
|
| 19 |
+
input: 0,
|
| 20 |
+
output: 0,
|
| 21 |
+
cacheRead: 0,
|
| 22 |
+
cacheWrite: 0,
|
| 23 |
+
total: 0,
|
| 24 |
+
},
|
| 25 |
+
},
|
| 26 |
+
stopReason: "error",
|
| 27 |
+
errorMessage,
|
| 28 |
+
timestamp: Date.now(),
|
| 29 |
+
};
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
describe("isContextOverflow", () => {
|
| 33 |
+
it("detects explicit Ollama prompt-too-long errors", () => {
|
| 34 |
+
const message = createErrorMessage("400 `prompt too long; exceeded max context length by 100918 tokens`");
|
| 35 |
+
expect(isContextOverflow(message, 32768)).toBe(true);
|
| 36 |
+
});
|
| 37 |
+
|
| 38 |
+
it("detects Together AI context length errors", () => {
|
| 39 |
+
const message = createErrorMessage(
|
| 40 |
+
"400 The input (516368 tokens) is longer than the model's context length (262144 tokens).",
|
| 41 |
+
);
|
| 42 |
+
expect(isContextOverflow(message, 262144)).toBe(true);
|
| 43 |
+
});
|
| 44 |
+
|
| 45 |
+
it("detects LiteLLM-wrapped OpenAI maximum context length errors", () => {
|
| 46 |
+
const message = createErrorMessage(
|
| 47 |
+
"Error: 503 litellm.ServiceUnavailableError: litellm.MidStreamFallbackError: litellm.APIConnectionError: APIConnectionError: OpenAIException - Requested token count exceeds the model's maximum context length of 131072 tokens.",
|
| 48 |
+
);
|
| 49 |
+
expect(isContextOverflow(message, 131072)).toBe(true);
|
| 50 |
+
});
|
| 51 |
+
|
| 52 |
+
it("detects OpenAI-compatible parenthesized maximum context length errors", () => {
|
| 53 |
+
const message = createErrorMessage(
|
| 54 |
+
"Error: 400 Input length (265330) exceeds model's maximum context length (262144).",
|
| 55 |
+
);
|
| 56 |
+
expect(isContextOverflow(message, 262144)).toBe(true);
|
| 57 |
+
});
|
| 58 |
+
|
| 59 |
+
it("detects OpenRouter Poolside maximum allowed input length errors", () => {
|
| 60 |
+
const message = createErrorMessage(
|
| 61 |
+
"Provider returned error: Input length 131393 exceeds the maximum allowed input length of 131040 tokens.",
|
| 62 |
+
);
|
| 63 |
+
expect(isContextOverflow(message, 131072)).toBe(true);
|
| 64 |
+
});
|
| 65 |
+
|
| 66 |
+
it("detects DS4 configured context size errors", () => {
|
| 67 |
+
const message = createErrorMessage(
|
| 68 |
+
"400 Prompt has 256468 tokens, but the configured context size is 256000 tokens",
|
| 69 |
+
);
|
| 70 |
+
expect(isContextOverflow(message, 256000)).toBe(true);
|
| 71 |
+
|
| 72 |
+
const commaMessage = createErrorMessage(
|
| 73 |
+
"Prompt has 5,958,968 tokens, but the configured context size is 256,000 tokens",
|
| 74 |
+
);
|
| 75 |
+
expect(isContextOverflow(commaMessage, 256000)).toBe(true);
|
| 76 |
+
});
|
| 77 |
+
|
| 78 |
+
it("does not treat generic non-overflow Ollama errors as overflow", () => {
|
| 79 |
+
const message = createErrorMessage("500 `model runner crashed unexpectedly`");
|
| 80 |
+
expect(isContextOverflow(message, 32768)).toBe(false);
|
| 81 |
+
});
|
| 82 |
+
|
| 83 |
+
it("does not treat Bedrock throttling 'Too many tokens' as overflow", () => {
|
| 84 |
+
// Bedrock returns this for HTTP 429 rate limiting, NOT context overflow.
|
| 85 |
+
// formatBedrockError uses a human-readable prefix for ThrottlingException.
|
| 86 |
+
const message = createErrorMessage("Throttling error: Too many tokens, please wait before trying again.");
|
| 87 |
+
expect(isContextOverflow(message, 200000)).toBe(false);
|
| 88 |
+
});
|
| 89 |
+
|
| 90 |
+
it("does not treat Bedrock service unavailable as overflow", () => {
|
| 91 |
+
const message = createErrorMessage("Service unavailable: The service is temporarily unavailable.");
|
| 92 |
+
expect(isContextOverflow(message, 200000)).toBe(false);
|
| 93 |
+
});
|
| 94 |
+
|
| 95 |
+
it("does not treat generic rate limit errors as overflow", () => {
|
| 96 |
+
const message = createErrorMessage("Rate limit exceeded, please retry after 30 seconds.");
|
| 97 |
+
expect(isContextOverflow(message, 200000)).toBe(false);
|
| 98 |
+
});
|
| 99 |
+
|
| 100 |
+
it("does not treat HTTP 429 style errors as overflow", () => {
|
| 101 |
+
const message = createErrorMessage("Too many requests. Please slow down.");
|
| 102 |
+
expect(isContextOverflow(message, 200000)).toBe(false);
|
| 103 |
+
});
|
| 104 |
+
|
| 105 |
+
function createLengthStopMessage(options: {
|
| 106 |
+
input: number;
|
| 107 |
+
cacheRead: number;
|
| 108 |
+
output: number;
|
| 109 |
+
cacheWrite?: number;
|
| 110 |
+
api?: AssistantMessage["api"];
|
| 111 |
+
provider?: string;
|
| 112 |
+
model?: string;
|
| 113 |
+
}): AssistantMessage {
|
| 114 |
+
const cacheWrite = options.cacheWrite ?? 0;
|
| 115 |
+
return {
|
| 116 |
+
role: "assistant",
|
| 117 |
+
content: [],
|
| 118 |
+
api: options.api ?? "openai-completions",
|
| 119 |
+
provider: options.provider ?? "test-provider",
|
| 120 |
+
model: options.model ?? "test-model",
|
| 121 |
+
usage: {
|
| 122 |
+
input: options.input,
|
| 123 |
+
output: options.output,
|
| 124 |
+
cacheRead: options.cacheRead,
|
| 125 |
+
cacheWrite,
|
| 126 |
+
totalTokens: options.input + options.cacheRead + cacheWrite + options.output,
|
| 127 |
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
| 128 |
+
},
|
| 129 |
+
stopReason: "length",
|
| 130 |
+
timestamp: Date.now(),
|
| 131 |
+
};
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
it("detects Xiaomi-style overflow (length stop with zero output and filled context)", () => {
|
| 135 |
+
const message = createLengthStopMessage({
|
| 136 |
+
input: 58,
|
| 137 |
+
cacheRead: 1048512,
|
| 138 |
+
output: 0,
|
| 139 |
+
provider: "xiaomi",
|
| 140 |
+
model: "mimo-v2.5-pro",
|
| 141 |
+
});
|
| 142 |
+
expect(isContextOverflow(message, 1048576)).toBe(true);
|
| 143 |
+
});
|
| 144 |
+
|
| 145 |
+
it("treats a length stop below the desired output limit as recoverable", () => {
|
| 146 |
+
const message = createLengthStopMessage({
|
| 147 |
+
input: 3,
|
| 148 |
+
cacheRead: 253584,
|
| 149 |
+
cacheWrite: 25554,
|
| 150 |
+
output: 16,
|
| 151 |
+
api: "openai-responses",
|
| 152 |
+
provider: "openai",
|
| 153 |
+
model: "gpt-5.6-sol",
|
| 154 |
+
});
|
| 155 |
+
expect(isRecoverableLength(message, 128000)).toBe(true);
|
| 156 |
+
});
|
| 157 |
+
|
| 158 |
+
it("does not recover a length stop that reached the desired output limit", () => {
|
| 159 |
+
const message = createLengthStopMessage({ input: 4062, cacheRead: 0, output: 1024 });
|
| 160 |
+
expect(isRecoverableLength(message, 1024)).toBe(false);
|
| 161 |
+
});
|
| 162 |
+
|
| 163 |
+
it("treats zero-output length stops as recoverable without context metadata", () => {
|
| 164 |
+
const message = createLengthStopMessage({ input: 100, cacheRead: 0, output: 0 });
|
| 165 |
+
expect(isRecoverableLength(message, 128000)).toBe(true);
|
| 166 |
+
});
|
| 167 |
+
|
| 168 |
+
it("does not treat normal length stops with output as context overflow", () => {
|
| 169 |
+
const message = createLengthStopMessage({ input: 1000, cacheRead: 0, output: 4096 });
|
| 170 |
+
expect(isContextOverflow(message, 200000)).toBe(false);
|
| 171 |
+
});
|
| 172 |
+
|
| 173 |
+
it("does not treat zero-output length stops far below context as context overflow", () => {
|
| 174 |
+
const message = createLengthStopMessage({ input: 100, cacheRead: 0, output: 0 });
|
| 175 |
+
expect(isContextOverflow(message, 200000)).toBe(false);
|
| 176 |
+
});
|
| 177 |
+
});
|
packages/ai/test/together-models.test.ts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { afterEach, describe, expect, it } from "vitest";
|
| 2 |
+
import { getModel } from "../src/compat.ts";
|
| 3 |
+
import { findEnvKeys, getEnvApiKey } from "../src/env-api-keys.ts";
|
| 4 |
+
|
| 5 |
+
const originalTogetherApiKey = process.env.TOGETHER_API_KEY;
|
| 6 |
+
|
| 7 |
+
afterEach(() => {
|
| 8 |
+
if (originalTogetherApiKey === undefined) {
|
| 9 |
+
delete process.env.TOGETHER_API_KEY;
|
| 10 |
+
} else {
|
| 11 |
+
process.env.TOGETHER_API_KEY = originalTogetherApiKey;
|
| 12 |
+
}
|
| 13 |
+
});
|
| 14 |
+
|
| 15 |
+
describe("Together models", () => {
|
| 16 |
+
it("registers the default Kimi K2.6 model via OpenAI-compatible Chat Completions API", () => {
|
| 17 |
+
const model = getModel("together", "moonshotai/Kimi-K2.6");
|
| 18 |
+
|
| 19 |
+
expect(model).toBeDefined();
|
| 20 |
+
expect(model.api).toBe("openai-completions");
|
| 21 |
+
expect(model.provider).toBe("together");
|
| 22 |
+
expect(model.baseUrl).toBe("https://api.together.ai/v1");
|
| 23 |
+
expect(model.reasoning).toBe(true);
|
| 24 |
+
expect(model.thinkingLevelMap).toEqual({ minimal: null, low: null, medium: null });
|
| 25 |
+
expect(model.input).toEqual(["text", "image"]);
|
| 26 |
+
expect(model.contextWindow).toBe(262144);
|
| 27 |
+
expect(model.maxTokens).toBe(131000);
|
| 28 |
+
expect(model.cost).toEqual({
|
| 29 |
+
input: 1.2,
|
| 30 |
+
output: 4.5,
|
| 31 |
+
cacheRead: 0.2,
|
| 32 |
+
cacheWrite: 0,
|
| 33 |
+
});
|
| 34 |
+
expect(model.compat).toEqual({
|
| 35 |
+
supportsStore: false,
|
| 36 |
+
supportsDeveloperRole: false,
|
| 37 |
+
supportsReasoningEffort: false,
|
| 38 |
+
maxTokensField: "max_tokens",
|
| 39 |
+
thinkingFormat: "together",
|
| 40 |
+
supportsStrictMode: false,
|
| 41 |
+
supportsLongCacheRetention: false,
|
| 42 |
+
});
|
| 43 |
+
});
|
| 44 |
+
|
| 45 |
+
it("models Together reasoning controls from the Together API surface", () => {
|
| 46 |
+
const gptOss = getModel("together", "openai/gpt-oss-120b");
|
| 47 |
+
expect(gptOss.thinkingLevelMap).toEqual({
|
| 48 |
+
off: null,
|
| 49 |
+
minimal: null,
|
| 50 |
+
low: "low",
|
| 51 |
+
medium: "medium",
|
| 52 |
+
high: "high",
|
| 53 |
+
max: null,
|
| 54 |
+
xhigh: null,
|
| 55 |
+
});
|
| 56 |
+
expect(gptOss.compat).toMatchObject({
|
| 57 |
+
supportsReasoningEffort: true,
|
| 58 |
+
thinkingFormat: "openai",
|
| 59 |
+
});
|
| 60 |
+
|
| 61 |
+
const deepSeekV4 = getModel("together", "deepseek-ai/DeepSeek-V4-Pro");
|
| 62 |
+
expect(deepSeekV4.thinkingLevelMap).toEqual({
|
| 63 |
+
minimal: null,
|
| 64 |
+
low: null,
|
| 65 |
+
medium: null,
|
| 66 |
+
high: "high",
|
| 67 |
+
xhigh: null,
|
| 68 |
+
});
|
| 69 |
+
expect(deepSeekV4.compat).toMatchObject({
|
| 70 |
+
supportsReasoningEffort: true,
|
| 71 |
+
thinkingFormat: "together",
|
| 72 |
+
});
|
| 73 |
+
|
| 74 |
+
const minimax = getModel("together", "MiniMaxAI/MiniMax-M2.7");
|
| 75 |
+
expect(minimax.thinkingLevelMap).toEqual({ off: null, minimal: null, low: null, medium: null });
|
| 76 |
+
expect(minimax.compat?.thinkingFormat).toBeUndefined();
|
| 77 |
+
expect(minimax.compat?.supportsReasoningEffort).toBe(false);
|
| 78 |
+
});
|
| 79 |
+
|
| 80 |
+
it("resolves TOGETHER_API_KEY from the environment", () => {
|
| 81 |
+
process.env.TOGETHER_API_KEY = "test-together-key";
|
| 82 |
+
|
| 83 |
+
expect(findEnvKeys("together")).toEqual(["TOGETHER_API_KEY"]);
|
| 84 |
+
expect(getEnvApiKey("together")).toBe("test-together-key");
|
| 85 |
+
});
|
| 86 |
+
});
|