SaylorTwift HF Staff commited on
Commit
aa47613
·
verified ·
1 Parent(s): d4f7e1d

Add files using upload-large-folder tool

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .github/APPROVED_CONTRIBUTORS +395 -0
  2. .github/ISSUE_TEMPLATE/bug.yml +45 -0
  3. .github/ISSUE_TEMPLATE/config.yml +5 -0
  4. .github/ISSUE_TEMPLATE/contribution.yml +36 -0
  5. .github/ISSUE_TEMPLATE/package-report.yml +49 -0
  6. .github/workflows/approve-contributor.yml +223 -0
  7. .github/workflows/build-binaries.yml +444 -0
  8. .github/workflows/ci.yml +42 -0
  9. .github/workflows/issue-analysis.yml +688 -0
  10. .github/workflows/issue-gate.yml +129 -0
  11. .github/workflows/issue-triage-labels.yml +142 -0
  12. .github/workflows/npm-audit.yml +31 -0
  13. .github/workflows/pr-gate.yml +128 -0
  14. .github/workflows/publish-model-catalog.yml +146 -0
  15. .github/workflows/remove-inprogress-on-close.yml +31 -0
  16. .pi/extensions/import-repro.ts +351 -0
  17. .pi/extensions/prompt-url-widget.ts +270 -0
  18. .pi/extensions/redraws.ts +24 -0
  19. .pi/extensions/tps.ts +47 -0
  20. .pi/git/.gitignore +2 -0
  21. .pi/npm/.gitignore +2 -0
  22. .pi/prompts/cl.md +54 -0
  23. .pi/prompts/deslop.md +67 -0
  24. .pi/prompts/is.md +28 -0
  25. .pi/prompts/pr.md +37 -0
  26. .pi/prompts/sa.md +163 -0
  27. .pi/prompts/wr.md +41 -0
  28. .pi/skills/add-llm-provider.md +57 -0
  29. .pi/skills/interactive-testing.md +19 -0
  30. .pi/skills/release.md +48 -0
  31. packages/ai/test/anthropic-eager-tool-input-compat.test.ts +166 -0
  32. packages/ai/test/bedrock-convert-messages.test.ts +410 -0
  33. packages/ai/test/bedrock-error-metadata.test.ts +212 -0
  34. packages/ai/test/cloudflare-stream.test.ts +65 -0
  35. packages/ai/test/error-body.test.ts +226 -0
  36. packages/ai/test/fetch-option.test.ts +178 -0
  37. packages/ai/test/image-tool-result.test.ts +550 -0
  38. packages/ai/test/kimi-coding-oauth.test.ts +270 -0
  39. packages/ai/test/mistral-raw-stop-reason.test.ts +61 -0
  40. packages/ai/test/oauth.ts +85 -0
  41. packages/ai/test/openai-completions-prompt-cache.test.ts +271 -0
  42. packages/ai/test/openai-completions-raw-stop-reason.test.ts +79 -0
  43. packages/ai/test/openai-completions-vllm-priority.test.ts +93 -0
  44. packages/ai/test/openai-responses-compat.test.ts +599 -0
  45. packages/ai/test/openai-responses-partial-json-cleanup.test.ts +106 -0
  46. packages/ai/test/openai-responses-reasoning-replay-e2e.test.ts +291 -0
  47. packages/ai/test/openrouter-cache-write-repro.test.ts +77 -0
  48. packages/ai/test/openrouter-reasoning-options.test.ts +109 -0
  49. packages/ai/test/overflow.test.ts +177 -0
  50. 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
+ });