@wenathlan/devthink 2.0.12 → 2.0.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (444) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/accounts.ts +2087 -0
  3. package/agent.ts +2331 -0
  4. package/antigravity.ts +2139 -0
  5. package/apifreeze.ts +4146 -0
  6. package/auth.ts +642 -0
  7. package/background.ts +16837 -0
  8. package/bridge.ts +2080 -0
  9. package/build.ts +54 -0
  10. package/bun.ts +26 -0
  11. package/capture.ts +1614 -0
  12. package/checksecrets.ts +72 -0
  13. package/cjs.ts +9 -0
  14. package/cli.ts +3500 -0
  15. package/commands.ts +1101 -0
  16. package/companion.ts +450 -0
  17. package/compatibility.ts +37 -0
  18. package/config.ts +3137 -0
  19. package/constants.ts +943 -0
  20. package/core.ts +1473 -0
  21. package/crossbrowser.ts +1359 -0
  22. package/dashdone.ts +605 -0
  23. package/data.ts +943 -0
  24. package/database.ts +133 -0
  25. package/debug.ts +850 -0
  26. package/deno.ts +27 -0
  27. package/devthink.ts +707 -0
  28. package/dist/accounts.d.ts +409 -0
  29. package/dist/agent.d.ts +837 -0
  30. package/dist/antigravity.d.ts +380 -0
  31. package/dist/apifreeze.d.ts +113 -0
  32. package/dist/auth.d.ts +224 -0
  33. package/{bridge.d.ts → dist/bridge.d.ts} +1 -1
  34. package/dist/capture.d.ts +595 -0
  35. package/dist/cjs.d.ts +6 -0
  36. package/{cli.js → dist/cli.js} +2 -2
  37. package/dist/commands.d.ts +310 -0
  38. package/dist/compatibility.d.ts +17 -0
  39. package/dist/config.d.ts +1853 -0
  40. package/dist/constants.d.ts +637 -0
  41. package/dist/core.d.ts +361 -0
  42. package/dist/data.d.ts +330 -0
  43. package/dist/database.d.ts +24 -0
  44. package/dist/debug.d.ts +270 -0
  45. package/dist/engine.d.ts +74 -0
  46. package/dist/environments.d.ts +244 -0
  47. package/dist/evidence.d.ts +277 -0
  48. package/dist/export.d.ts +311 -0
  49. package/dist/fingerprint.d.ts +594 -0
  50. package/dist/flow.d.ts +300 -0
  51. package/dist/gates.d.ts +103 -0
  52. package/dist/identity.d.ts +51 -0
  53. package/dist/ids.d.ts +7 -0
  54. package/dist/llm.d.ts +652 -0
  55. package/dist/models.d.ts +1807 -0
  56. package/dist/net.d.ts +325 -0
  57. package/dist/neutral.d.ts +41 -0
  58. package/dist/oauth.d.ts +703 -0
  59. package/dist/page.d.ts +1166 -0
  60. package/dist/perf.d.ts +481 -0
  61. package/dist/plan.d.ts +134 -0
  62. package/dist/project.d.ts +395 -0
  63. package/dist/providers.d.ts +37 -0
  64. package/dist/quota.d.ts +453 -0
  65. package/dist/recovery.d.ts +621 -0
  66. package/dist/request.d.ts +1492 -0
  67. package/dist/run.d.ts +779 -0
  68. package/dist/runtime.d.ts +212 -0
  69. package/dist/search.d.ts +375 -0
  70. package/dist/security.d.ts +534 -0
  71. package/dist/serve.d.ts +136 -0
  72. package/dist/session.d.ts +296 -0
  73. package/dist/storage.d.ts +53 -0
  74. package/dist/streaming.d.ts +1119 -0
  75. package/dist/swarm.d.ts +328 -0
  76. package/dist/tools.d.ts +170 -0
  77. package/dist/types.d.ts +8357 -0
  78. package/dist/utils.d.ts +82 -0
  79. package/dist/version.d.ts +7 -0
  80. package/dist/versionregistry.d.ts +406 -0
  81. package/dist/views.d.ts +579 -0
  82. package/dist/workbenchsession.d.ts +69 -0
  83. package/dist/workflow.d.ts +649 -0
  84. package/docs/readme.md +92 -0
  85. package/docs/schemas/antigravity.json +192 -0
  86. package/engine.ts +1524 -0
  87. package/environments.ts +1005 -0
  88. package/evidence.ts +643 -0
  89. package/export.ts +889 -0
  90. package/fingerprint.ts +1458 -0
  91. package/flow.ts +1190 -0
  92. package/gates.ts +307 -0
  93. package/gateway.ts +34 -0
  94. package/hardening.ts +191 -0
  95. package/headless.ts +369 -0
  96. package/http.ts +2110 -0
  97. package/identity.ts +246 -0
  98. package/ids.ts +26 -0
  99. package/index.ts +369 -0
  100. package/llm.ts +2682 -0
  101. package/mcp.ts +1835 -0
  102. package/{memory.js → memory.ts} +3834 -2310
  103. package/models.ts +3805 -0
  104. package/modes.ts +43 -0
  105. package/net.ts +1214 -0
  106. package/neutral.ts +362 -0
  107. package/node.ts +26 -0
  108. package/oauth.ts +2216 -0
  109. package/offscreen.ts +170 -0
  110. package/pack.ts +956 -0
  111. package/package.json +175 -82
  112. package/page.ts +7824 -0
  113. package/pagebridge.ts +1116 -0
  114. package/perf.ts +957 -0
  115. package/plan.ts +672 -0
  116. package/plugin.ts +76 -0
  117. package/{policy.js → policy.ts} +5073 -4679
  118. package/prisma.config.ts +27 -0
  119. package/progress.ts +1308 -0
  120. package/project.ts +1291 -0
  121. package/protocol.ts +4187 -0
  122. package/providers.ts +246 -0
  123. package/quota.ts +1887 -0
  124. package/recovery.ts +2433 -0
  125. package/request.ts +4482 -0
  126. package/run.ts +2135 -0
  127. package/runtime.ts +526 -0
  128. package/search.ts +1760 -0
  129. package/security.ts +1673 -0
  130. package/serve.ts +419 -0
  131. package/server.ts +5538 -0
  132. package/session.ts +1162 -0
  133. package/storage.ts +182 -0
  134. package/streaming.ts +4101 -0
  135. package/swarm.ts +884 -0
  136. package/sync.ts +56 -0
  137. package/tests/agentcert.test.ts +128 -0
  138. package/tests/agentfleet.test.ts +214 -0
  139. package/tests/agentreview.test.ts +241 -0
  140. package/tests/agentstream.test.ts +335 -0
  141. package/tests/agentwork.test.ts +356 -0
  142. package/tests/apifreeze.test.ts +290 -0
  143. package/tests/apimap.test.ts +112 -0
  144. package/tests/approvalgate.test.ts +175 -0
  145. package/tests/artifactmanifest.test.ts +204 -0
  146. package/tests/attentionfeed.test.ts +163 -0
  147. package/tests/backgroundruns.test.ts +100 -0
  148. package/tests/batchscheduling.test.ts +131 -0
  149. package/tests/blackboard.test.ts +238 -0
  150. package/tests/bridge.test.ts +1088 -0
  151. package/tests/browsercoverage.test.ts +134 -0
  152. package/tests/browserpolyfills.test.ts +124 -0
  153. package/tests/build.mjs +1260 -0
  154. package/tests/clientauth.test.ts +223 -0
  155. package/tests/clitools.test.ts +578 -0
  156. package/tests/confirmgates.test.ts +190 -0
  157. package/tests/containerpack.test.ts +143 -0
  158. package/tests/controlflow.test.ts +2120 -0
  159. package/tests/coordination.test.ts +345 -0
  160. package/tests/copyscreen.test.ts +139 -0
  161. package/tests/core.test.ts +417 -0
  162. package/tests/costcert.test.ts +95 -0
  163. package/tests/crossbrowser.test.ts +115 -0
  164. package/tests/cspaudit.test.ts +79 -0
  165. package/tests/dashdone.test.ts +615 -0
  166. package/tests/datagrid.test.ts +137 -0
  167. package/tests/doccheck.test.ts +107 -0
  168. package/tests/dombatch.test.ts +109 -0
  169. package/tests/emulation.test.ts +282 -0
  170. package/tests/environments.test.ts +259 -0
  171. package/tests/evidenceviews.test.ts +88 -0
  172. package/tests/exporttools.test.ts +185 -0
  173. package/tests/firefoxprep.test.ts +134 -0
  174. package/tests/flowlibrary.test.ts +242 -0
  175. package/tests/flowrun.test.ts +303 -0
  176. package/tests/forensics.test.ts +439 -0
  177. package/tests/gateway.test.ts +939 -0
  178. package/tests/headlesslib.test.ts +171 -0
  179. package/tests/httpstream.test.ts +277 -0
  180. package/tests/immutablelog.test.ts +157 -0
  181. package/tests/inboundguard.test.ts +177 -0
  182. package/tests/lazyload.test.ts +72 -0
  183. package/tests/librarymodes.test.ts +623 -0
  184. package/tests/llm.test.ts +696 -0
  185. package/tests/manifest.test.ts +226 -0
  186. package/tests/maskinputs.test.ts +150 -0
  187. package/tests/matrixverify.test.ts +111 -0
  188. package/tests/mavenpack.test.ts +75 -0
  189. package/tests/mcpserver.test.ts +704 -0
  190. package/tests/memory.test.ts +4155 -0
  191. package/tests/memorycare.test.ts +230 -0
  192. package/tests/migrateplan.test.ts +349 -0
  193. package/tests/minimization.test.ts +414 -0
  194. package/tests/modelroute.test.ts +112 -0
  195. package/tests/multiagent.test.ts +440 -0
  196. package/tests/nativebridge.test.ts +807 -0
  197. package/tests/navigation.test.ts +284 -0
  198. package/tests/nugetpack.test.ts +83 -0
  199. package/tests/orchestration.test.ts +449 -0
  200. package/tests/originpolicy.test.ts +405 -0
  201. package/tests/outputcompare.test.ts +97 -0
  202. package/tests/package.test.ts +91 -0
  203. package/tests/pagebridge.test.ts +5909 -0
  204. package/tests/pentest.test.ts +75 -0
  205. package/tests/perfrecords.test.ts +257 -0
  206. package/tests/permdiff.test.ts +85 -0
  207. package/tests/phishguard.test.ts +128 -0
  208. package/tests/pickerviews.test.ts +155 -0
  209. package/tests/pipelines.test.ts +381 -0
  210. package/tests/planlint.test.ts +240 -0
  211. package/tests/planreview.test.ts +383 -0
  212. package/tests/platformtargets.test.ts +56 -0
  213. package/tests/policy.test.ts +8284 -0
  214. package/tests/portability.test.ts +96 -0
  215. package/tests/progress.test.ts +1036 -0
  216. package/tests/promptlibrary.test.ts +116 -0
  217. package/tests/protocol.test.ts +4793 -0
  218. package/tests/publishingpipeline.test.ts +314 -0
  219. package/tests/quickactions.test.ts +164 -0
  220. package/tests/readiness.test.ts +78 -0
  221. package/tests/recipes.test.ts +163 -0
  222. package/tests/redactshots.test.ts +216 -0
  223. package/tests/registernvidiakeys.ts +51 -0
  224. package/tests/relayserve.test.ts +304 -0
  225. package/tests/releasenotes.test.ts +276 -0
  226. package/tests/resilience.test.ts +467 -0
  227. package/tests/resourceaware.test.ts +187 -0
  228. package/tests/runbudget.test.ts +125 -0
  229. package/tests/runhistory.test.ts +299 -0
  230. package/tests/runreplay.test.ts +113 -0
  231. package/tests/runresume.test.ts +184 -0
  232. package/tests/runstate.test.ts +288 -0
  233. package/tests/runtimeadapters.test.ts +251 -0
  234. package/tests/safariskeleton.test.ts +115 -0
  235. package/tests/sandboxframe.test.ts +196 -0
  236. package/tests/sbom.test.ts +105 -0
  237. package/tests/secretvault.test.ts +188 -0
  238. package/tests/selcache.test.ts +111 -0
  239. package/tests/sessioninterface.test.ts +791 -0
  240. package/tests/sessions.test.ts +591 -0
  241. package/tests/siteprefs.test.ts +228 -0
  242. package/tests/snapshotdelta.test.ts +165 -0
  243. package/tests/statusviews.test.ts +237 -0
  244. package/tests/stepmeter.test.ts +200 -0
  245. package/tests/streamparse.test.ts +163 -0
  246. package/tests/stylepruning.test.ts +90 -0
  247. package/tests/surfaces.test.ts +282 -0
  248. package/tests/sweep.test.ts +78 -0
  249. package/tests/syncbridge.test.ts +164 -0
  250. package/tests/taskqueue.test.ts +206 -0
  251. package/tests/toolcalls.test.ts +336 -0
  252. package/tests/toolcatalog.test.ts +201 -0
  253. package/tests/torture-agent.test.ts +706 -0
  254. package/tests/torture-auth.test.ts +461 -0
  255. package/tests/torture-commands.test.ts +895 -0
  256. package/tests/torture-data.test.ts +923 -0
  257. package/tests/torture-environments.test.ts +285 -0
  258. package/tests/torture-export.test.ts +263 -0
  259. package/tests/torture-http.test.ts +1379 -0
  260. package/tests/torture-llm.test.ts +808 -0
  261. package/tests/torture-memory.test.ts +1084 -0
  262. package/tests/torture-plan.test.ts +829 -0
  263. package/tests/torture-run.test.ts +626 -0
  264. package/tests/torture-session.test.ts +928 -0
  265. package/tests/torture-swarm.test.ts +1245 -0
  266. package/tests/torture-tools.test.ts +964 -0
  267. package/tests/torture-workflow.test.ts +2354 -0
  268. package/tests/tourviews.test.ts +52 -0
  269. package/tests/transparency.test.ts +147 -0
  270. package/tests/trigger.test.ts +941 -0
  271. package/tests/v1sunset.test.ts +428 -0
  272. package/tests/virtlist.test.ts +94 -0
  273. package/tests/vision.test.ts +540 -0
  274. package/tests/vsixpack.test.ts +145 -0
  275. package/tests/webapi.test.ts +523 -0
  276. package/tests/workflow.test.ts +1785 -0
  277. package/tests/workfloweditor.test.ts +759 -0
  278. package/tests/xpipack.test.ts +128 -0
  279. package/tools.ts +734 -0
  280. package/types.ts +9908 -0
  281. package/umd.ts +9 -0
  282. package/utils.ts +249 -0
  283. package/version.ts +8 -0
  284. package/versionregistry.ts +1310 -0
  285. package/views.ts +2153 -0
  286. package/vitest.config.ts +12 -0
  287. package/web/App.tsx +78 -0
  288. package/web/README.md +47 -0
  289. package/web/capacitor.config.ts +46 -0
  290. package/web/const.ts +19 -0
  291. package/web/control.shell.tsx +66 -0
  292. package/web/dashboardpage.ts +554 -0
  293. package/web/db.ts +493 -0
  294. package/web/design.html +541 -0
  295. package/web/gatewayview/Gateway.tsx +345 -0
  296. package/web/gatewayview/config.ts +1042 -0
  297. package/web/gatewayview/definition.ts +174 -0
  298. package/web/icons.ts +15 -0
  299. package/{manifest.json → web/manifest.json} +1 -1
  300. package/web/optionspage.ts +691 -0
  301. package/web/popup.ts +1651 -0
  302. package/web/schema.prisma +176 -0
  303. package/web/sidepanel.ts +7941 -0
  304. package/web/sitemanifest.json +13 -0
  305. package/web/terminal-ui.tsx +622 -0
  306. package/web/transparencypage.ts +541 -0
  307. package/web/vite.config.ts +39 -0
  308. package/web/workspace.ts +23 -0
  309. package/workbenchmemory.ts +104 -0
  310. package/workbenchsession.ts +304 -0
  311. package/workflow.ts +4495 -0
  312. package/bridge.d.ts.map +0 -1
  313. package/bridge.js +0 -1877
  314. package/bun.d.ts.map +0 -1
  315. package/bun.js +0 -52329
  316. package/caps/background.json +0 -712
  317. package/caps/cli.json +0 -360
  318. package/caps/library.json +0 -3611
  319. package/caps/mcp.json +0 -126
  320. package/caps/pagebridge.json +0 -377
  321. package/caps/popup.json +0 -51
  322. package/caps/sidepanel.json +0 -522
  323. package/checksums.txt +0 -142
  324. package/cli.d.ts.map +0 -1
  325. package/companion.d.ts.map +0 -1
  326. package/companion.js +0 -389
  327. package/crossbrowser.d.ts.map +0 -1
  328. package/crossbrowser.js +0 -1191
  329. package/dashdone.d.ts.map +0 -1
  330. package/dashdone.js +0 -368
  331. package/deno.d.ts.map +0 -1
  332. package/deno.js +0 -52325
  333. package/devthink.umd.js +0 -52346
  334. package/fixtures/bun.ts +0 -7
  335. package/fixtures/cjs.js +0 -6
  336. package/fixtures/deno.ts +0 -6
  337. package/fixtures/example-org-pagestate.json +0 -22
  338. package/fixtures/importers/automa-workflow.json +0 -18
  339. package/fixtures/importers/selenium-side.json +0 -20
  340. package/fixtures/importers/tabular-plan.csv +0 -7
  341. package/fixtures/importers/uivision-macro.json +0 -10
  342. package/fixtures/importers/v1-plan.json +0 -13
  343. package/fixtures/mcp-client.mjs +0 -14
  344. package/fixtures/pages/checkout.html +0 -192
  345. package/fixtures/pages/dashboard.html +0 -110
  346. package/fixtures/pages/feed.html +0 -113
  347. package/fixtures/pages/product-grid.html +0 -150
  348. package/fixtures/plans/form-inventory-plan.json +0 -11
  349. package/fixtures/plans/release-notes-plan.json +0 -11
  350. package/fixtures/recipes/agentscompete.json +0 -11
  351. package/fixtures/recipes/agentsforms.json +0 -11
  352. package/fixtures/recipes/agentsmonitor.json +0 -11
  353. package/fixtures/recipes/agentsreview.json +0 -29
  354. package/fixtures/recipes/agentswarm.json +0 -11
  355. package/fixtures/recipes/fillcalendar.json +0 -12
  356. package/fixtures/recipes/fillcheckout.json +0 -13
  357. package/fixtures/recipes/filldependentdropdowns.json +0 -12
  358. package/fixtures/recipes/fillfromcsv.json +0 -13
  359. package/fixtures/recipes/fillloginform.json +0 -12
  360. package/fixtures/recipes/fillmultistepwizard.json +0 -13
  361. package/fixtures/recipes/fillregistration.json +0 -12
  362. package/fixtures/recipes/fillsearchfilters.json +0 -12
  363. package/fixtures/recipes/filltypeahead.json +0 -11
  364. package/fixtures/recipes/monitoralerts.json +0 -12
  365. package/fixtures/recipes/monitoravailability.json +0 -11
  366. package/fixtures/recipes/monitornetworkquiet.json +0 -12
  367. package/fixtures/recipes/monitorpagechanges.json +0 -11
  368. package/fixtures/recipes/monitorpricechange.json +0 -11
  369. package/fixtures/recipes/monitorschedule.json +0 -12
  370. package/fixtures/recipes/scrapedynamicprices.json +0 -13
  371. package/fixtures/recipes/scrapeimagegallery.json +0 -12
  372. package/fixtures/recipes/scrapeinfinitefeed.json +0 -13
  373. package/fixtures/recipes/scrapenestedlists.json +0 -12
  374. package/fixtures/recipes/scrapeproductgrid.json +0 -12
  375. package/fixtures/recipes/scrapereviews.json +0 -12
  376. package/fixtures/recipes/scrapesearchresults.json +0 -12
  377. package/fixtures/recipes/scrapetablepagination.json +0 -12
  378. package/fixtures/recipes/testaccessibility.json +0 -34
  379. package/fixtures/recipes/testconsoleerrors.json +0 -12
  380. package/fixtures/recipes/testdeeplinks.json +0 -12
  381. package/fixtures/recipes/testformvalidation.json +0 -13
  382. package/fixtures/recipes/testlayoutshift.json +0 -12
  383. package/fixtures/recipes/testlinkcheck.json +0 -12
  384. package/fixtures/recipes/testloadtiming.json +0 -12
  385. package/fixtures/recipes/testvisualdiff.json +0 -12
  386. package/fixtures/workflows/changelog-digest-workflow.json +0 -13
  387. package/fixtures/workflows/form-survey-workflow.json +0 -13
  388. package/gallery.json +0 -706
  389. package/hardening.d.ts.map +0 -1
  390. package/hardening.js +0 -188
  391. package/headless.d.ts.map +0 -1
  392. package/headless.js +0 -11522
  393. package/http.d.ts.map +0 -1
  394. package/http.js +0 -2165
  395. package/index.cjs +0 -54682
  396. package/index.d.ts.map +0 -1
  397. package/index.js +0 -52310
  398. package/index.neutral.js +0 -46343
  399. package/mcp.d.ts.map +0 -1
  400. package/mcp.js +0 -20515
  401. package/memory.d.ts.map +0 -1
  402. package/nativehost.template.json +0 -10
  403. package/node.cjs +0 -54686
  404. package/node.d.ts.map +0 -1
  405. package/pack.d.ts.map +0 -1
  406. package/pack.js +0 -805
  407. package/policy.d.ts.map +0 -1
  408. package/progress.d.ts.map +0 -1
  409. package/progress.js +0 -645
  410. package/protocol.d.ts.map +0 -1
  411. package/protocol.js +0 -8334
  412. package/schemas/audit.schema.json +0 -226
  413. package/schemas/capability.schema.json +0 -113
  414. package/schemas/envelope.schema.json +0 -162
  415. package/schemas/memory.schema.json +0 -51
  416. package/schemas/observation.schema.json +0 -113
  417. package/schemas/plan.schema.json +0 -208
  418. package/schemas/progress.schema.json +0 -126
  419. package/schemas/proposal.schema.json +0 -68
  420. package/schemas/session.schema.json +0 -82
  421. package/schemas/tool.schema.json +0 -94
  422. package/server.d.ts.map +0 -1
  423. package/server.js +0 -28877
  424. package/umd-example.html +0 -18
  425. package/umd.d.ts.map +0 -1
  426. /package/{bun.d.ts → dist/bun.d.ts} +0 -0
  427. /package/{cli.d.ts → dist/cli.d.ts} +0 -0
  428. /package/{companion.d.ts → dist/companion.d.ts} +0 -0
  429. /package/{crossbrowser.d.ts → dist/crossbrowser.d.ts} +0 -0
  430. /package/{dashdone.d.ts → dist/dashdone.d.ts} +0 -0
  431. /package/{deno.d.ts → dist/deno.d.ts} +0 -0
  432. /package/{hardening.d.ts → dist/hardening.d.ts} +0 -0
  433. /package/{headless.d.ts → dist/headless.d.ts} +0 -0
  434. /package/{http.d.ts → dist/http.d.ts} +0 -0
  435. /package/{index.d.ts → dist/index.d.ts} +0 -0
  436. /package/{mcp.d.ts → dist/mcp.d.ts} +0 -0
  437. /package/{memory.d.ts → dist/memory.d.ts} +0 -0
  438. /package/{node.d.ts → dist/node.d.ts} +0 -0
  439. /package/{pack.d.ts → dist/pack.d.ts} +0 -0
  440. /package/{policy.d.ts → dist/policy.d.ts} +0 -0
  441. /package/{progress.d.ts → dist/progress.d.ts} +0 -0
  442. /package/{protocol.d.ts → dist/protocol.d.ts} +0 -0
  443. /package/{server.d.ts → dist/server.d.ts} +0 -0
  444. /package/{umd.d.ts → dist/umd.d.ts} +0 -0
package/streaming.ts ADDED
@@ -0,0 +1,4101 @@
1
+ /**
2
+ * @file streaming.ts
3
+ * @module provider/streaming
4
+ * @description
5
+ * Third-person observer view: the library watches Antigravity / CloudCode
6
+ * traffic and reproduces its streaming behavior without running Antigravity
7
+ * itself.
8
+ *
9
+ * Responsibilities isolated here:
10
+ * - Incremental SSE parser tolerant to network splits (parseSSEChunk)
11
+ * - Heterogeneous payload normalization (unwrap response/candidates/parts)
12
+ * - LRU cache for thinking signatures — 100 entries — Map-based
13
+ * - Preservation of thinking blocks between chunks (delta tracking)
14
+ * - OpenAI / Anthropic transformers compatible with opencode plugin SDK
15
+ * - Auto-recovery exponential backoff + jitter 0-80ms
16
+ * - Synthetic finish injection when cloudcode-pa closes abruptly
17
+ *
18
+ * Architectural constraints:
19
+ * - Library-first, root-first (no src/ folder)
20
+ * - Direct cloudcode-pa.googleapis.com — no localhost v1 proxy
21
+ * - Only node:* builtins + global fetch (Node >=18)
22
+ * -> node:crypto for FNV-1a, signature hashing, jitter
23
+ * -> global TextDecoder / ReadableStream (web standards in Node)
24
+ * - Production-ready: never throws in parser, defensive JSON tolerant,
25
+ * JSDoc everywhere, explicit error handling, no placeholder
26
+ *
27
+ * Date governance: 25/08/2026 — Canto do Buriti, PI, BR
28
+ * Version: 2.0.0 — the merged provider lineage
29
+ *
30
+ * Related modules:
31
+ * - constants.ts — endpoints, LRU_THINKING_CACHE_SIZE, models 2026
32
+ * - fingerprint.ts — FNV-1a reused here for deterministic ids
33
+ * - request.ts — builds Antigravity request body
34
+ * - recovery.ts — higher level auto-recovery using this module
35
+ *
36
+ * @author devthink
37
+ * @license MIT
38
+ */
39
+
40
+ import { createHash, randomUUID } from "node:crypto";
41
+ import * as os from "node:os";
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // Frozen-owner imports — identical values owned by constants.ts / core.ts
45
+ // ---------------------------------------------------------------------------
46
+
47
+ import {
48
+ BACKOFF_BASE_MS,
49
+ BACKOFF_FACTOR,
50
+ BACKOFF_MAX_MS,
51
+ GEMINI_CLI_OAUTH_CLIENT_ID as GEMINI_CLI_CLIENT_ID,
52
+ GEMINI_CLI_OAUTH_CLIENT_SECRET as GEMINI_CLI_CLIENT_SECRET,
53
+ JITTER_MAX_MS,
54
+ JITTER_MIN_MS,
55
+ LRU_THINKING_CACHE_SIZE,
56
+ MAX_RETRIES,
57
+ PROJECT_FALLBACK,
58
+ GEMINI_CLI_USER_AGENT,
59
+ CLIENT_METADATA_RAW,
60
+ CLOUDCODE_PA_BASE,
61
+ RETRYABLE_STATUS_CODES,
62
+ } from "./constants.js";
63
+ import { fnv1a32, getJitter, thinkingCache } from "./core.js";
64
+ import { X_GOOG_API_CLIENT_GEMINI_CLI as X_GOOG_API_CLIENT } from "./fingerprint.js";
65
+
66
+ // ---------------------------------------------------------------------------
67
+ // Constants — frozen, deterministic
68
+ // ---------------------------------------------------------------------------
69
+
70
+ // Values below are re-exported from the frozen owners so each symbol keeps a
71
+ // single definition; the module surface is unchanged for consumers.
72
+ export {
73
+ BACKOFF_BASE_MS,
74
+ BACKOFF_FACTOR,
75
+ BACKOFF_MAX_MS,
76
+ GEMINI_CLI_CLIENT_ID,
77
+ GEMINI_CLI_CLIENT_SECRET,
78
+ JITTER_MAX_MS,
79
+ JITTER_MIN_MS,
80
+ LRU_THINKING_CACHE_SIZE,
81
+ MAX_RETRIES,
82
+ PROJECT_FALLBACK,
83
+ fnv1a32,
84
+ getJitter,
85
+ thinkingCache,
86
+ };
87
+
88
+ // local variant: diverges from constants (owner list is [403,404,408,429,500,502,503,504]; this streaming subset omits 403/404/408)
89
+ /** Retryable HTTP status codes for cloudcode-pa */
90
+
91
+ /** Sentinel that Gemini / Antigravity sends to close stream */
92
+ export const SSE_DONE_SENTINEL = "[DONE]" as const;
93
+
94
+ // ---------------------------------------------------------------------------
95
+ // Helpers — FNV-1a 32-bit (deterministic, zero external dep)
96
+ // ---------------------------------------------------------------------------
97
+
98
+ // fnv1a32 is imported from core.js (identical charCodeAt + Math.imul algorithm)
99
+ // and re-exported above.
100
+
101
+ /**
102
+ * SHA-256 hex truncated to 16 chars — stable key for thinking text
103
+ * when length > few hundred chars. Falls back to FNV-1a if crypto fails.
104
+ *
105
+ * @param text - thinking text to hash
106
+ */
107
+ export function hashThinkingText(text: string): string {
108
+ try {
109
+ return createHash("sha256").update(text, "utf8").digest("hex").slice(0, 32);
110
+ } catch {
111
+ return fnv1a32(text).toString(16).padStart(8, "0");
112
+ }
113
+ }
114
+
115
+ // getJitter is imported from constants.js (identical randomInt 0-80 body)
116
+ // and re-exported above.
117
+
118
+ /**
119
+ * Promise-based sleep with jitter included for backoff
120
+ * @param ms - base delay
121
+ */
122
+ export function sleepWithJitter(ms: number): Promise<void> {
123
+ const jitter = getJitter();
124
+ const total = Math.max(0, ms + jitter);
125
+ return new Promise((resolve) => setTimeout(resolve, total));
126
+ }
127
+
128
+ // ---------------------------------------------------------------------------
129
+ // LRU Cache — thinking signatures 100 entradas
130
+ // ---------------------------------------------------------------------------
131
+
132
+ /**
133
+ * @description
134
+ * The observer notes that Claude thinking blocks carry `thoughtSignature`
135
+ * that must be preserved across turns, otherwise API returns
136
+ * "invalid signature". Original plugin stored 100 entries in Map.
137
+ * LRU eviction keeps most recent.
138
+ *
139
+ * This implementation is library-first: no external LRU dep,
140
+ * uses Map insertion order (ES2015 guarantees order).
141
+ */
142
+ // local variant: diverges from recovery (recovery.ReplaySignatureCache is the entry-based cache with signature keys and provenance; this is the hash->signature string map)
143
+ export class ReplaySignatureCache {
144
+ private readonly maxSize: number;
145
+ private readonly map: Map<string, string>;
146
+
147
+ /**
148
+ * @param maxSize - max entries, default 100 per spec
149
+ */
150
+ constructor(maxSize: number = LRU_THINKING_CACHE_SIZE) {
151
+ if (!Number.isInteger(maxSize) || maxSize <= 0) {
152
+ throw new TypeError(`maxSize must be positive integer, got ${maxSize}`);
153
+ }
154
+ this.maxSize = maxSize;
155
+ this.map = new Map<string, string>();
156
+ }
157
+
158
+ /** Current size */
159
+ get size(): number {
160
+ return this.map.size;
161
+ }
162
+
163
+ /** Max configured */
164
+ get capacity(): number {
165
+ return this.maxSize;
166
+ }
167
+
168
+ /**
169
+ * Retrieves signature for given thinking text.
170
+ * The cache key is hash of normalized text (trimmed).
171
+ *
172
+ * @param thinkingText - full thinking block text
173
+ * @returns signature or undefined
174
+ */
175
+ get(thinkingText: string): string | undefined {
176
+ if (!thinkingText || typeof thinkingText !== "string") return undefined;
177
+ const key = hashThinkingText(thinkingText.trim());
178
+ const val = this.map.get(key);
179
+ if (val !== undefined) {
180
+ // LRU touch: re-insert to move to end (most recent)
181
+ this.map.delete(key);
182
+ this.map.set(key, val);
183
+ }
184
+ return val;
185
+ }
186
+
187
+ /**
188
+ * Retrieves signature by direct hash key (when caller already hashed)
189
+ */
190
+ getByHash(hashKey: string): string | undefined {
191
+ const val = this.map.get(hashKey);
192
+ if (val !== undefined) {
193
+ this.map.delete(hashKey);
194
+ this.map.set(hashKey, val);
195
+ }
196
+ return val;
197
+ }
198
+
199
+ /**
200
+ * Stores signature keyed by thinking text
201
+ * @param thinkingText - full text
202
+ * @param signature - opaque signature string from API
203
+ */
204
+ set(thinkingText: string, signature: string): void {
205
+ if (!thinkingText || typeof thinkingText !== "string") return;
206
+ if (!signature || typeof signature !== "string") return;
207
+ const key = hashThinkingText(thinkingText.trim());
208
+ if (this.map.has(key)) this.map.delete(key);
209
+ this.map.set(key, signature);
210
+ this.evictIfNeeded();
211
+ }
212
+
213
+ /**
214
+ * Direct hash->signature set (when text not available)
215
+ */
216
+ setByHash(hashKey: string, signature: string): void {
217
+ if (!hashKey || !signature) return;
218
+ if (this.map.has(hashKey)) this.map.delete(hashKey);
219
+ this.map.set(hashKey, signature);
220
+ this.evictIfNeeded();
221
+ }
222
+
223
+ /** Checks presence without touching LRU order */
224
+ has(thinkingText: string): boolean {
225
+ if (!thinkingText) return false;
226
+ const key = hashThinkingText(thinkingText.trim());
227
+ return this.map.has(key);
228
+ }
229
+
230
+ /** Deletes entry by thinking text */
231
+ delete(thinkingText: string): boolean {
232
+ if (!thinkingText) return false;
233
+ const key = hashThinkingText(thinkingText.trim());
234
+ return this.map.delete(key);
235
+ }
236
+
237
+ /** Clears all */
238
+ clear(): void {
239
+ this.map.clear();
240
+ }
241
+
242
+ /** Iterates entries LRU order (oldest first) */
243
+ entries(): IterableIterator<[string, string]> {
244
+ return this.map.entries();
245
+ }
246
+
247
+ /** Keys in LRU order */
248
+ keys(): IterableIterator<string> {
249
+ return this.map.keys();
250
+ }
251
+
252
+ /** Returns diagnostic snapshot oldest->newest */
253
+ snapshot(): Array<{ hash: string; signaturePreview: string }> {
254
+ return Array.from(this.map.entries()).map(([h, s]) => ({
255
+ hash: h,
256
+ signaturePreview: s.slice(0, 24) + (s.length > 24 ? "…" : ""),
257
+ }));
258
+ }
259
+
260
+ private evictIfNeeded(): void {
261
+ while (this.map.size > this.maxSize) {
262
+ const oldestKey = this.map.keys().next().value;
263
+ if (oldestKey === undefined) break;
264
+ this.map.delete(oldestKey);
265
+ }
266
+ }
267
+ }
268
+
269
+ /** Global singleton used by transformers — library-first convenience */
270
+ export const globalThinkingSignatureCache = new ReplaySignatureCache(LRU_THINKING_CACHE_SIZE);
271
+
272
+ // ---------------------------------------------------------------------------
273
+ // SSE Incremental Parser — tolerante splits
274
+ // ---------------------------------------------------------------------------
275
+
276
+ /**
277
+ * Raw SSE event parsed from wire.
278
+ * Observer keeps it minimal — only what cloudcode-pa uses.
279
+ */
280
+ export interface ParsedSSEEvent {
281
+ /** Event type if server sent `event: xxx` */
282
+ event?: string | undefined;
283
+ /** Optional id `id: xxx` */
284
+ id?: string | undefined;
285
+ /** Retry interval `retry: xxx` */
286
+ retry?: number | undefined;
287
+ /** Joined data payload (may be JSON string, may be [DONE]) */
288
+ data: string;
289
+ /** Raw block for debugging */
290
+ rawBlock: string;
291
+ }
292
+
293
+ /**
294
+ * Mutable buffer holding incomplete SSE tail between network chunks.
295
+ * Third-person: the observer accumulates until double newline terminator.
296
+ */
297
+ export interface SSEParserState {
298
+ /** Leftover incomplete tail */
299
+ buffer: string;
300
+ /** How many events parsed so far — useful for diagnostics */
301
+ eventsSeen: number;
302
+ }
303
+
304
+ /**
305
+ * Creates fresh parser state.
306
+ */
307
+ export function createSSEParserState(): SSEParserState {
308
+ return { buffer: "", eventsSeen: 0 };
309
+ }
310
+
311
+ /**
312
+ * Parses one text chunk incrementally, tolerant to splits.
313
+ *
314
+ * The observer learned from production: fetch streaming may split in middle
315
+ * of `data: {` JSON, or split the `\n\n` delimiter across chunks.
316
+ * Strategy:
317
+ * 1. Append chunk to state.buffer
318
+ * 2. Normalize \r\n -> \n, \r -> \n
319
+ * 3. While buffer contains `\n\n` extract block
320
+ * 4. For each block, extract data: lines (join with \n per SSE spec)
321
+ * 5. Leave incomplete tail in buffer
322
+ *
323
+ * @param chunk - new text arrived from decoder (may be partial)
324
+ * @param state - mutable buffer object — will be updated in place
325
+ * @returns parsed events from this chunk only
326
+ *
327
+ * @example
328
+ * const state = createSSEParserState();
329
+ * let ev = parseSSEChunk("data: {\"cand", state); // [] — buffered
330
+ * ev = parseSSEChunk("idates\":[]}\n\n", state); // 1 event
331
+ */
332
+ // local variant: diverges from core (stateful incremental parser with buffer/event/id/retry fields; core.parseSSEChunk is stateless and returns only joined data payloads)
333
+ export function parseSSEChunk(chunk: string, state: SSEParserState): ParsedSSEEvent[] {
334
+ if (typeof chunk !== "string") {
335
+ throw new TypeError(`parseSSEChunk chunk must be string, got ${typeof chunk}`);
336
+ }
337
+ if (!state || typeof state.buffer !== "string") {
338
+ throw new TypeError("parseSSEChunk state must have .buffer string");
339
+ }
340
+
341
+ // Defensive: if chunk empty, nothing to do
342
+ if (chunk.length === 0) return [];
343
+
344
+ // Append and normalize line endings eagerly — \r\n and \r both become \n
345
+ // This keeps split tolerance for \r\n across chunks (e.g. \r in previous, \n in current)
346
+ state.buffer += chunk;
347
+ // We cannot replace globally before we have full delimiter? We do incremental replace
348
+ // but keep logic simple: replace \r\n first then \r
349
+ // Important: don't destroy partial \r at end — if buffer ends with \r, keep it and wait for \n
350
+ // So we only replace \r\n that are complete and standalone \r not at end.
351
+ // Implementation: temporary replace \r\n -> \n, then replace remaining \r \n? Let's be safe.
352
+ let buf = state.buffer.replace(/\r\n/g, "\n");
353
+ // If last char is \r (could be split \r\n), keep it pending — but we already replaced \r\n, so lonely \r stays
354
+ // Replace lonely \r not followed by anything? We convert them to \n except when it's last char and previous chunk may have split?
355
+ // For simplicity we convert all remaining \r to \n — worst case we may prematurely split, but still valid SSE allows \r as line terminator.
356
+ buf = buf.replace(/\r/g, "\n");
357
+ state.buffer = buf;
358
+
359
+ const events: ParsedSSEEvent[] = [];
360
+ let searchStart = 0;
361
+
362
+ while (true) {
363
+ const delimiterIdx = state.buffer.indexOf("\n\n", searchStart);
364
+ if (delimiterIdx === -1) break; // incomplete block — wait for more chunks
365
+
366
+ const block = state.buffer.slice(searchStart, delimiterIdx);
367
+ // Move window past delimiter
368
+ const nextStart = delimiterIdx + 2;
369
+ // Parse block lines
370
+ const lines = block.split("\n");
371
+ let dataLines: string[] = [];
372
+ let eventType: string | undefined;
373
+ let id: string | undefined;
374
+ let retry: number | undefined;
375
+
376
+ for (const rawLine of lines) {
377
+ if (rawLine.length === 0) continue;
378
+ if (rawLine.startsWith(":")) {
379
+ // SSE comment / keepalive — ignore per spec
380
+ continue;
381
+ }
382
+ // According to SSE spec, field is up to first colon
383
+ const colonIdx = rawLine.indexOf(":");
384
+ let field: string;
385
+ let value: string;
386
+ if (colonIdx === -1) {
387
+ field = rawLine.trim();
388
+ value = "";
389
+ } else {
390
+ field = rawLine.slice(0, colonIdx).trim();
391
+ value = rawLine.slice(colonIdx + 1);
392
+ // If value starts with single space, strip it (spec)
393
+ if (value.startsWith(" ")) value = value.slice(1);
394
+ }
395
+
396
+ switch (field) {
397
+ case "data":
398
+ dataLines.push(value);
399
+ break;
400
+ case "event":
401
+ eventType = value;
402
+ break;
403
+ case "id":
404
+ id = value;
405
+ break;
406
+ case "retry":
407
+ {
408
+ const n = Number.parseInt(value, 10);
409
+ if (Number.isFinite(n)) retry = n;
410
+ }
411
+ break;
412
+ default:
413
+ // Unknown field — spec says ignore
414
+ break;
415
+ }
416
+ }
417
+
418
+ // Per spec, if dataLines empty, no event dispatch
419
+ if (dataLines.length > 0) {
420
+ const data = dataLines.join("\n"); // multiple data: lines concatenated with \n
421
+ events.push({
422
+ event: eventType,
423
+ id,
424
+ retry,
425
+ data,
426
+ rawBlock: block,
427
+ });
428
+ state.eventsSeen++;
429
+ }
430
+
431
+ // Slice buffer: remove processed portion including delimiter
432
+ // Since we search from searchStart, we need to handle correctly
433
+ // Simplify: we maintain state.buffer as remaining after nextStart
434
+ state.buffer = state.buffer.slice(nextStart);
435
+ searchStart = 0; // reset because buffer changed
436
+
437
+ // Safety: avoid infinite loop on huge buffers without delimiter
438
+ if (state.buffer.length > 10 * 1024 * 1024) {
439
+ // 10MB without delimiter is malformed — truncate to avoid OOM
440
+ // Observer logs warning but continues
441
+ console.warn("[streaming] SSE buffer exceeded 10MB without delimiter, truncating");
442
+ state.buffer = state.buffer.slice(-1024 * 1024); // keep last 1MB
443
+ break;
444
+ }
445
+ }
446
+
447
+ return events;
448
+ }
449
+
450
+ /**
451
+ * Parses a Uint8Array / Buffer chunk using TextDecoder streaming.
452
+ * Convenience wrapper around parseSSEChunk for binary fetch streams.
453
+ *
454
+ * @param binaryChunk - Uint8Array from reader.read()
455
+ * @param decoder - TextDecoder with {stream:true}
456
+ * @param state - parser state
457
+ */
458
+ export function parseSSEBinaryChunk(
459
+ binaryChunk: Uint8Array,
460
+ decoder: { decode(input: Uint8Array, options?: { stream?: boolean }): string },
461
+ state: SSEParserState,
462
+ ): ParsedSSEEvent[] {
463
+ if (!(binaryChunk instanceof Uint8Array)) {
464
+ throw new TypeError("binaryChunk must be Uint8Array");
465
+ }
466
+ if (!(decoder instanceof TextDecoder)) {
467
+ throw new TypeError("decoder must be TextDecoder");
468
+ }
469
+ const text = decoder.decode(binaryChunk, { stream: true });
470
+ return parseSSEChunk(text, state);
471
+ }
472
+
473
+ // ---------------------------------------------------------------------------
474
+ // Payload normalization — heterogêneos
475
+ // ---------------------------------------------------------------------------
476
+
477
+ /**
478
+ * Normalized tool call extracted from Antigravity part
479
+ */
480
+ export interface NormalizedToolCall {
481
+ /** Deterministic id if not provided by model */
482
+ id: string;
483
+ /** Function / tool name sanitized */
484
+ name: string;
485
+ /** Parsed args object if JSON parsable, otherwise raw string */
486
+ args: unknown;
487
+ /** Raw JSON string of args — for OpenAI delta streaming */
488
+ argsJson: string;
489
+ /** Whether this is delta (partial args) or final */
490
+ isDelta?: boolean;
491
+ }
492
+
493
+ /**
494
+ * Single normalized chunk produced from one or more raw payloads
495
+ */
496
+ export interface NormalizedChunk {
497
+ /** Text delta to emit (incremental already deduped) */
498
+ text?: string | undefined;
499
+ /** Full accumulated text for tracking */
500
+ fullText?: string | undefined;
501
+ /** Thinking delta */
502
+ thinking?: string | undefined;
503
+ /** Full accumulated thinking */
504
+ fullThinking?: string | undefined;
505
+ /** Signature for thinking block if present */
506
+ thinkingSignature?: string | undefined;
507
+ /** Tool calls extracted */
508
+ toolCalls?: NormalizedToolCall[] | undefined;
509
+ /** Finish reason from cloudcode-pa, normalized to lowercase (stop, max_tokens, safety) */
510
+ finishReason?: string | undefined;
511
+ /** Raw original payload for debugging / grounding */
512
+ raw: unknown;
513
+ /** Candidate index if payload contained multiple candidates */
514
+ candidateIndex?: number | undefined;
515
+ /** Whether this part was marked as thought */
516
+ isThought?: boolean | undefined;
517
+ /** Grounding metadata e.g. groundingMetadata, groundingChunks */
518
+ groundingMetadata?: unknown | undefined;
519
+ /** Safety ratings if present */
520
+ safetyRatings?: unknown | undefined;
521
+ /** Usage metadata if present */
522
+ usageMetadata?: unknown | undefined;
523
+ /** Indicates stream is done (from [DONE] or finishReason) */
524
+ isFinished?: boolean | undefined;
525
+ }
526
+
527
+ /**
528
+ * Safe JSON parse — never throws, returns null on failure
529
+ * @param text - raw data string
530
+ */
531
+ export function safeJsonParse(text: string): unknown | null {
532
+ if (!text || typeof text !== "string") return null;
533
+ const trimmed = text.trim();
534
+ if (trimmed.length === 0) return null;
535
+ if (trimmed === SSE_DONE_SENTINEL) return { __sentinelDone: true };
536
+ try {
537
+ return JSON.parse(trimmed);
538
+ } catch {
539
+ // Tolerant attempt: sometimes cloudcode-pa sends multiple JSON concatenated with newline
540
+ // Try first line
541
+ try {
542
+ const firstLine = trimmed.split("\n")[0];
543
+ if (firstLine && firstLine !== trimmed) return JSON.parse(firstLine);
544
+ } catch {
545
+ // ignore
546
+ }
547
+ return null;
548
+ }
549
+ }
550
+
551
+ /**
552
+ * Unwraps heterogeneous envelope into candidate array.
553
+ * Handles observed shapes:
554
+ * - {response:{candidates:[...]}}
555
+ * - {candidates:[...]}
556
+ * - {candidate:{...}}
557
+ * - [{...}, {...}] (already array)
558
+ * - {content:{parts:[...]}} single candidate
559
+ * - {parts:[...]} direct parts
560
+ *
561
+ * @param payload - parsed JSON object
562
+ * @returns array of candidate-like objects
563
+ */
564
+ export function unwrapCandidates(payload: unknown): unknown[] {
565
+ if (!payload || typeof payload !== "object") return [];
566
+
567
+ // Sentinel DONE already handled elsewhere
568
+ const obj = payload as any;
569
+
570
+ if (obj.__sentinelDone) return [];
571
+
572
+ // Array directly = list of candidates or payloads
573
+ if (Array.isArray(obj)) {
574
+ return obj;
575
+ }
576
+
577
+ // response.candidates
578
+ if (obj.response) {
579
+ if (Array.isArray(obj.response.candidates)) return obj.response.candidates;
580
+ if (obj.response.candidate) return [obj.response.candidate];
581
+ if (obj.response.content || obj.response.parts) return [obj.response];
582
+ }
583
+
584
+ // candidates
585
+ if (Array.isArray(obj.candidates)) return obj.candidates;
586
+
587
+ // single candidate field
588
+ if (obj.candidate && typeof obj.candidate === "object") return [obj.candidate];
589
+
590
+ // content or parts at top level considered single candidate
591
+ if (obj.content || obj.parts || obj.text || obj.thoughtSignature) return [obj];
592
+
593
+ // Fallback: treat whole object as candidate
594
+ return [obj];
595
+ }
596
+
597
+ /**
598
+ * Extracts parts array from candidate
599
+ */
600
+ export function extractParts(candidate: unknown): unknown[] {
601
+ if (!candidate || typeof candidate !== "object") return [];
602
+ const c = candidate as any;
603
+
604
+ // Direct parts
605
+ if (Array.isArray(c.parts)) return c.parts;
606
+ // content.parts
607
+ if (c.content && Array.isArray(c.content.parts)) return c.content.parts;
608
+ // content array?
609
+ if (Array.isArray(c.content)) return c.content;
610
+ // text at candidate level as single part
611
+ if (typeof c.text === "string") {
612
+ return [{ text: c.text, thought: c.thought, thoughtSignature: c.thoughtSignature }];
613
+ }
614
+
615
+ // Gemini sometimes: candidate.content.parts empty but candidate itself is part
616
+ if (c.functionCall || c.function_call) return [c];
617
+
618
+ return [];
619
+ }
620
+
621
+ /**
622
+ * Normalizes single part object into text/thinking/toolCall
623
+ * @param part - raw part
624
+ */
625
+ export function normalizePart(part: unknown): {
626
+ text?: string;
627
+ thinking?: string;
628
+ thinkingSignature?: string;
629
+ toolCall?: NormalizedToolCall;
630
+ isThought?: boolean;
631
+ } {
632
+ if (!part || typeof part !== "object") {
633
+ if (typeof part === "string") return { text: part };
634
+ return {};
635
+ }
636
+ const p = part as any;
637
+
638
+ // Text + thought marker
639
+ // Antigravity observed: {text:"xxx", thought:true, thoughtSignature:"..."}
640
+ if (typeof p.text === "string") {
641
+ if (p.thought === true || p.isThought === true || p.thoughtSignature || p.type === "thinking") {
642
+ return {
643
+ thinking: p.text,
644
+ thinkingSignature: p.thoughtSignature || p.signature,
645
+ isThought: true,
646
+ };
647
+ }
648
+ return { text: p.text, isThought: false };
649
+ }
650
+
651
+ // Thinking explicitly: {thinking:"...", signature:"..."}
652
+ if (typeof p.thinking === "string") {
653
+ return {
654
+ thinking: p.thinking,
655
+ thinkingSignature: p.thoughtSignature || p.signature,
656
+ isThought: true,
657
+ };
658
+ }
659
+
660
+ // Function call shapes
661
+ // Gemini: {functionCall:{name, args}}
662
+ // Anthropic style: {tool_use:{id,name,input}}
663
+ // OpenAI style inside Gemini: {function_call:{name,arguments}}
664
+ const fc = p.functionCall || p.function_call || p.tool_use || p.toolUse;
665
+ if (fc && typeof fc === "object") {
666
+ const nameRaw = fc.name || fc.functionName || p.name || "unknown_tool";
667
+ // Sanitize name: must start with letter or underscore for gemini, but we normalize
668
+ const name = String(nameRaw).replace(/[^a-zA-Z0-9_.-]/g, "_");
669
+ let argsRaw = fc.args || fc.arguments || fc.input || {};
670
+ let argsJson: string;
671
+ let argsParsed: unknown = argsRaw;
672
+
673
+ if (typeof argsRaw === "string") {
674
+ argsJson = argsRaw;
675
+ try {
676
+ argsParsed = JSON.parse(argsRaw);
677
+ } catch {
678
+ argsParsed = argsRaw;
679
+ }
680
+ } else {
681
+ try {
682
+ argsJson = JSON.stringify(argsRaw);
683
+ } catch {
684
+ argsJson = "{}";
685
+ }
686
+ }
687
+
688
+ const id = fc.id || p.id || `toolu_${fnv1a32(name + argsJson).toString(16)}`;
689
+
690
+ return {
691
+ toolCall: {
692
+ id: String(id),
693
+ name,
694
+ args: argsParsed,
695
+ argsJson,
696
+ isDelta: !!p.isDelta,
697
+ },
698
+ };
699
+ }
700
+
701
+ // Direct tool call at part level
702
+ if (p.name && (p.args !== undefined || p.arguments !== undefined || p.input !== undefined)) {
703
+ return {
704
+ toolCall: {
705
+ id: p.id || `toolu_${fnv1a32(p.name).toString(16)}`,
706
+ name: String(p.name),
707
+ args: p.args ?? p.arguments ?? p.input ?? {},
708
+ argsJson:
709
+ typeof (p.args ?? p.arguments ?? p.input) === "string"
710
+ ? (p.args ?? p.arguments ?? p.input)
711
+ : JSON.stringify(p.args ?? p.arguments ?? p.input ?? {}),
712
+ },
713
+ };
714
+ }
715
+
716
+ return {};
717
+ }
718
+
719
+ /**
720
+ * Maps Gemini finishReason to normalized lowercase reason
721
+ */
722
+ export function mapFinishReason(rawReason: unknown): string | undefined {
723
+ if (!rawReason || typeof rawReason !== "string") return undefined;
724
+ const r = rawReason.trim().toUpperCase();
725
+ switch (r) {
726
+ case "STOP":
727
+ case "END_TURN":
728
+ case "FINISH":
729
+ return "stop";
730
+ case "MAX_TOKENS":
731
+ case "LENGTH":
732
+ return "length";
733
+ case "SAFETY":
734
+ case "RECITATION":
735
+ return "content_filter";
736
+ case "TOOL_CALLS":
737
+ case "TOOL_USE":
738
+ return "tool_calls";
739
+ default:
740
+ return rawReason.toLowerCase();
741
+ }
742
+ }
743
+
744
+ /**
745
+ * Core normalizer — converts raw SSE data strings (JSON) into normalized chunks.
746
+ * Tolerant to heterogeneous shapes observed in prod, daily, sandbox.
747
+ *
748
+ * @param rawDataStrings - array of data: payloads (already stripped of `data:` prefix)
749
+ * @returns normalized chunks (may be more than input if multiple candidates)
750
+ */
751
+ // local variant: diverges from core (accepts an array of raw data strings and returns rich NormalizedChunk objects; core.normalizePayloads takes one string and returns any[])
752
+ export function normalizePayloads(rawDataStrings: (string | unknown)[]): NormalizedChunk[] {
753
+ const out: NormalizedChunk[] = [];
754
+
755
+ for (const rawData of rawDataStrings) {
756
+ let parsed: unknown;
757
+
758
+ if (typeof rawData === "string") {
759
+ const trimmed = rawData.trim();
760
+ if (trimmed === "" || trimmed === SSE_DONE_SENTINEL) {
761
+ out.push({
762
+ raw: trimmed,
763
+ isFinished: true,
764
+ finishReason: "stop",
765
+ });
766
+ continue;
767
+ }
768
+ parsed = safeJsonParse(trimmed);
769
+ if (parsed === null) {
770
+ // If not JSON, treat as plain text delta (tolerant)
771
+ out.push({
772
+ text: rawData,
773
+ fullText: rawData,
774
+ raw: rawData,
775
+ });
776
+ continue;
777
+ }
778
+ } else {
779
+ parsed = rawData;
780
+ }
781
+
782
+ if (parsed === null) continue;
783
+
784
+ // Handle sentinel object
785
+ if ((parsed as any).__sentinelDone) {
786
+ out.push({
787
+ raw: parsed,
788
+ isFinished: true,
789
+ finishReason: "stop",
790
+ });
791
+ continue;
792
+ }
793
+
794
+ const candidates = unwrapCandidates(parsed);
795
+
796
+ for (let ci = 0; ci < candidates.length; ci++) {
797
+ const cand = candidates[ci] as any;
798
+ if (!cand) continue;
799
+
800
+ const parts = extractParts(cand);
801
+ let textAcc = "";
802
+ let thinkingAcc = "";
803
+ let thinkingSig: string | undefined;
804
+ const toolCalls: NormalizedToolCall[] = [];
805
+ let finishReason: string | undefined;
806
+ let groundingMetadata: unknown;
807
+ let safetyRatings: unknown;
808
+ let usageMetadata: unknown;
809
+
810
+ // Extract top-level fields from candidate
811
+ if (cand.finishReason || cand.finish_reason || cand.stopReason) {
812
+ finishReason = mapFinishReason(cand.finishReason || cand.finish_reason || cand.stopReason);
813
+ }
814
+ if (cand.groundingMetadata) groundingMetadata = cand.groundingMetadata;
815
+ if (cand.grounding_metadata) groundingMetadata = cand.grounding_metadata;
816
+ if (cand.safetyRatings) safetyRatings = cand.safetyRatings;
817
+ if (cand.usageMetadata || cand.usage_metadata) usageMetadata = cand.usageMetadata || cand.usage_metadata;
818
+
819
+ // If no parts, but candidate itself might be direct text (fallback)
820
+ if (parts.length === 0) {
821
+ // candidate may have content as string
822
+ if (typeof cand.content === "string") {
823
+ textAcc += cand.content;
824
+ } else if (typeof cand.text === "string" && !cand.thought) {
825
+ textAcc += cand.text;
826
+ } else if (typeof cand.thinking === "string") {
827
+ thinkingAcc += cand.thinking;
828
+ thinkingSig = cand.thoughtSignature || cand.signature;
829
+ }
830
+ } else {
831
+ for (const part of parts) {
832
+ const norm = normalizePart(part);
833
+ if (norm.text) textAcc += norm.text;
834
+ if (norm.thinking) thinkingAcc += norm.thinking;
835
+ if (norm.thinkingSignature) thinkingSig = norm.thinkingSignature;
836
+ if (norm.toolCall) toolCalls.push(norm.toolCall);
837
+ }
838
+ }
839
+
840
+ // Also check candidate-level thoughtSignature if no part signature
841
+ if (!thinkingSig && cand.thoughtSignature) thinkingSig = cand.thoughtSignature;
842
+ if (!thinkingSig && cand.signature) thinkingSig = cand.signature;
843
+
844
+ // Skip empty candidate unless it has finishReason
845
+ if (!textAcc && !thinkingAcc && toolCalls.length === 0 && !finishReason && !groundingMetadata) {
846
+ // Might still be useful if it contains usage etc, but skip if truly empty
847
+ // Keep as raw-only if it has any interesting field
848
+ if (!cand.candidates && !cand.content && !cand.parts) continue;
849
+ }
850
+
851
+ out.push({
852
+ text: textAcc || undefined,
853
+ fullText: textAcc || undefined,
854
+ thinking: thinkingAcc || undefined,
855
+ fullThinking: thinkingAcc || undefined,
856
+ thinkingSignature: thinkingSig,
857
+ toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
858
+ finishReason,
859
+ groundingMetadata,
860
+ safetyRatings,
861
+ usageMetadata,
862
+ raw: cand,
863
+ candidateIndex: ci,
864
+ isThought: thinkingAcc ? true : undefined,
865
+ isFinished: finishReason ? true : undefined,
866
+ });
867
+ }
868
+ }
869
+
870
+ return out;
871
+ }
872
+
873
+ // ---------------------------------------------------------------------------
874
+ // Preservation of thinking blocks between chunks — delta tracking
875
+ // ---------------------------------------------------------------------------
876
+
877
+ /**
878
+ * Extracts delta from full text given previously sent buffer.
879
+ * If new text starts with old, returns suffix, else returns full new.
880
+ * This handles both cumulative and incremental server behavior.
881
+ *
882
+ * @param full - current accumulated text from server (may be cumulative)
883
+ * @param alreadySent - buffer already emitted to client
884
+ * @returns delta to emit now
885
+ */
886
+ export function extractDelta(full: string, alreadySent: string): string {
887
+ if (!full) return "";
888
+ if (!alreadySent) return full;
889
+ if (full === alreadySent) return "";
890
+ if (full.startsWith(alreadySent)) {
891
+ return full.slice(alreadySent.length);
892
+ }
893
+ // If server sends incremental delta that doesn't start with previous (e.g. new block),
894
+ // we check if alreadySent ends with prefix of full? simplest fallback: return full
895
+ // But try to find largest common prefix
896
+ // For production safety, just return full if not prefix — deduplication layer above handles
897
+ // However attempt small heuristic: if full length < alreadySent length, likely reset -> return full
898
+ return full;
899
+ }
900
+
901
+ /**
902
+ * Tracks streaming session to preserve thinking and text between chunks.
903
+ * The observer watches two buffers: sentThinkingBuffer and sentTextBuffer.
904
+ * Prevents duplicated thinking blocks in UI (bug #120 in original repo).
905
+ */
906
+ export class StreamingSessionTracker {
907
+ private fullThinking = "";
908
+ private sentThinking = "";
909
+ private fullText = "";
910
+ private sentText = "";
911
+ private thinkingSignature?: string | undefined;
912
+ private finished = false;
913
+ private lastFinishReason?: string | undefined;
914
+ private toolCallsSeen = new Map<string, NormalizedToolCall>();
915
+
916
+ /**
917
+ * @param signatureCache - optional LRU cache for thinking signatures
918
+ */
919
+ constructor(public readonly signatureCache: ReplaySignatureCache = globalThinkingSignatureCache) {}
920
+
921
+ /** Whether stream already finished */
922
+ get isFinished(): boolean {
923
+ return this.finished;
924
+ }
925
+
926
+ get currentThinking(): string {
927
+ return this.fullThinking;
928
+ }
929
+
930
+ get currentText(): string {
931
+ return this.fullText;
932
+ }
933
+
934
+ /**
935
+ * Pushes new normalized chunk, deduplicates thinking/text, updates signature cache,
936
+ * and returns delta-only chunk ready for transformer.
937
+ *
938
+ * @param chunk - normalized from normalizePayloads
939
+ * @returns delta chunk (with only new portions) + metadata
940
+ */
941
+ push(chunk: NormalizedChunk): NormalizedChunk {
942
+ if (!chunk) return chunk;
943
+
944
+ let textDelta: string | undefined;
945
+ let thinkingDelta: string | undefined;
946
+
947
+ // ---- thinking preservation ----
948
+ if (chunk.fullThinking || chunk.thinking) {
949
+ const incomingThinking = chunk.fullThinking ?? chunk.thinking ?? "";
950
+ // Accumulate if server sent cumulative? Heuristic: if incoming longer than current and starts with current, replace full with incoming
951
+ if (!this.fullThinking) {
952
+ this.fullThinking = incomingThinking;
953
+ } else {
954
+ // If incoming contains current as prefix, it is cumulative update
955
+ if (incomingThinking.startsWith(this.fullThinking)) {
956
+ this.fullThinking = incomingThinking;
957
+ } else if (this.fullThinking.startsWith(incomingThinking)) {
958
+ // incoming is subset already processed — keep existing full
959
+ } else {
960
+ // Different block or incremental — append if not duplicate
961
+ // Check if already contains incoming suffix
962
+ if (!this.fullThinking.includes(incomingThinking)) {
963
+ this.fullThinking += incomingThinking;
964
+ }
965
+ }
966
+ }
967
+
968
+ // Extract what still needs to be sent
969
+ thinkingDelta = extractDelta(this.fullThinking, this.sentThinking);
970
+ if (thinkingDelta) {
971
+ this.sentThinking += thinkingDelta;
972
+ }
973
+
974
+ // Signature handling — preserve latest
975
+ if (chunk.thinkingSignature) {
976
+ this.thinkingSignature = chunk.thinkingSignature;
977
+ // Cache it keyed by current full thinking (when block finalized)
978
+ // But we cache progressively as well to avoid losing signature on crash
979
+ try {
980
+ this.signatureCache.set(this.fullThinking, chunk.thinkingSignature);
981
+ } catch {
982
+ // ignore cache errors
983
+ }
984
+ } else {
985
+ // Try to recover signature from cache if this thinking text was seen before
986
+ // (multiturn continuation)
987
+ try {
988
+ const cachedSig = this.signatureCache.get(this.fullThinking);
989
+ if (cachedSig) {
990
+ this.thinkingSignature = cachedSig;
991
+ chunk.thinkingSignature = cachedSig;
992
+ }
993
+ } catch {
994
+ // ignore
995
+ }
996
+ }
997
+ }
998
+
999
+ // ---- text preservation ----
1000
+ if (chunk.fullText || chunk.text) {
1001
+ const incomingText = chunk.fullText ?? chunk.text ?? "";
1002
+ if (!this.fullText) {
1003
+ this.fullText = incomingText;
1004
+ } else {
1005
+ if (incomingText.startsWith(this.fullText)) {
1006
+ this.fullText = incomingText;
1007
+ } else if (!this.fullText.includes(incomingText)) {
1008
+ // Append only if not already included
1009
+ this.fullText += incomingText;
1010
+ }
1011
+ }
1012
+ textDelta = extractDelta(this.fullText, this.sentText);
1013
+ if (textDelta) this.sentText += textDelta;
1014
+ }
1015
+
1016
+ // ---- tool calls ----
1017
+ let mergedToolCalls: NormalizedToolCall[] | undefined;
1018
+ if (chunk.toolCalls && chunk.toolCalls.length > 0) {
1019
+ mergedToolCalls = [];
1020
+ for (const tc of chunk.toolCalls) {
1021
+ const prev = this.toolCallsSeen.get(tc.id);
1022
+ if (!prev) {
1023
+ this.toolCallsSeen.set(tc.id, tc);
1024
+ mergedToolCalls.push(tc);
1025
+ } else {
1026
+ // Merge args (delta)
1027
+ if (tc.argsJson && tc.argsJson !== prev.argsJson) {
1028
+ // append delta args if looks like incremental JSON
1029
+ const mergedArgsJson = prev.argsJson + tc.argsJson;
1030
+ // Keep merged but also provide delta
1031
+ const merged: NormalizedToolCall = {
1032
+ ...tc,
1033
+ argsJson: mergedArgsJson,
1034
+ isDelta: true,
1035
+ };
1036
+ this.toolCallsSeen.set(tc.id, { ...prev, argsJson: mergedArgsJson });
1037
+ mergedToolCalls.push(merged);
1038
+ }
1039
+ }
1040
+ }
1041
+ if (mergedToolCalls.length === 0) mergedToolCalls = undefined;
1042
+ }
1043
+
1044
+ // ---- finish tracking ----
1045
+ if (chunk.finishReason || chunk.isFinished) {
1046
+ this.finished = true;
1047
+ this.lastFinishReason = chunk.finishReason || "stop";
1048
+ }
1049
+
1050
+ return {
1051
+ ...chunk,
1052
+ text: textDelta ?? undefined,
1053
+ fullText: this.fullText || undefined,
1054
+ thinking: thinkingDelta ?? undefined,
1055
+ fullThinking: this.fullThinking || undefined,
1056
+ thinkingSignature: this.thinkingSignature || chunk.thinkingSignature,
1057
+ toolCalls: mergedToolCalls,
1058
+ };
1059
+ }
1060
+
1061
+ /**
1062
+ * Returns synthetic finish chunk if stream ended without explicit finish.
1063
+ * Preserves last known signature.
1064
+ */
1065
+ createSyntheticFinish(reason = "stop"): NormalizedChunk | null {
1066
+ if (this.finished) return null;
1067
+ this.finished = true;
1068
+ this.lastFinishReason = reason;
1069
+ return {
1070
+ raw: { synthetic: true, reason, fullThinking: this.fullThinking, fullText: this.fullText },
1071
+ finishReason: reason,
1072
+ isFinished: true,
1073
+ thinkingSignature: this.thinkingSignature,
1074
+ fullThinking: this.fullThinking || undefined,
1075
+ fullText: this.fullText || undefined,
1076
+ };
1077
+ }
1078
+
1079
+ /** Resets tracker for new stream */
1080
+ reset(): void {
1081
+ this.fullThinking = "";
1082
+ this.sentThinking = "";
1083
+ this.fullText = "";
1084
+ this.sentText = "";
1085
+ this.thinkingSignature = undefined;
1086
+ this.finished = false;
1087
+ this.lastFinishReason = undefined;
1088
+ this.toolCallsSeen.clear();
1089
+ }
1090
+
1091
+ /** Diagnostic snapshot */
1092
+ snapshot(): {
1093
+ fullThinkingLen: number;
1094
+ sentThinkingLen: number;
1095
+ fullTextLen: number;
1096
+ sentTextLen: number;
1097
+ finished: boolean;
1098
+ toolCalls: number;
1099
+ hasSignature: boolean;
1100
+ } {
1101
+ return {
1102
+ fullThinkingLen: this.fullThinking.length,
1103
+ sentThinkingLen: this.sentThinking.length,
1104
+ fullTextLen: this.fullText.length,
1105
+ sentTextLen: this.sentText.length,
1106
+ finished: this.finished,
1107
+ toolCalls: this.toolCallsSeen.size,
1108
+ hasSignature: !!this.thinkingSignature,
1109
+ };
1110
+ }
1111
+ }
1112
+
1113
+ // ---------------------------------------------------------------------------
1114
+ // OpenAI compatible transformer — opencode expects chat.completion.chunk
1115
+ // ---------------------------------------------------------------------------
1116
+
1117
+ export interface OpenAIChunkOptions {
1118
+ /** Model id to echo back — e.g. antigravity-gemini-3-flash */
1119
+ model: string;
1120
+ /** Optional fixed id for stream — if not provided random-ish */
1121
+ id?: string;
1122
+ /** Creation timestamp in seconds */
1123
+ created?: number;
1124
+ }
1125
+
1126
+ function generateChatId(): string {
1127
+ try {
1128
+ const bytes = createHash("sha256")
1129
+ .update(String(Date.now()) + Math.random())
1130
+ .digest("hex")
1131
+ .slice(0, 12);
1132
+ return `chatcmpl-${bytes}`;
1133
+ } catch {
1134
+ return `chatcmpl-${fnv1a32(String(Date.now())).toString(16)}`;
1135
+ }
1136
+ }
1137
+
1138
+ /**
1139
+ * Maps normalized finishReason to OpenAI finish_reason values
1140
+ */
1141
+ function mapToOpenAIFinishReason(reason?: string): string | null {
1142
+ if (!reason) return null;
1143
+ switch (reason) {
1144
+ case "stop":
1145
+ return "stop";
1146
+ case "length":
1147
+ return "length";
1148
+ case "content_filter":
1149
+ return "content_filter";
1150
+ case "tool_calls":
1151
+ return "tool_calls";
1152
+ case "tool_use":
1153
+ return "tool_calls";
1154
+ default:
1155
+ return reason;
1156
+ }
1157
+ }
1158
+
1159
+ /**
1160
+ * Transforms normalized chunks into OpenAI SSE lines.
1161
+ * Library-first: stateless transform + session tracker for delta.
1162
+ *
1163
+ * The observer emits reasoning_content for thinking (opencode supports both
1164
+ * reasoning and reasoning_content per 2026 spec).
1165
+ *
1166
+ * @param chunk - delta chunk from StreamingSessionTracker
1167
+ * @param opts - model/id/created
1168
+ * @returns array of SSE formatted strings `data: {...}\n\n`
1169
+ */
1170
+ // local variant: diverges from core (returns SSE-formatted `data: {...}` strings with OpenAIChunkOptions; core.transformToOpenAI returns a bare chunk object)
1171
+ export function transformToOpenAI(
1172
+ chunk: NormalizedChunk,
1173
+ opts: OpenAIChunkOptions,
1174
+ tracker?: StreamingSessionTracker,
1175
+ ): string[] {
1176
+ const id = opts.id ?? generateChatId();
1177
+ const created = opts.created ?? Math.floor(Date.now() / 1000);
1178
+ const choiceIndex = chunk.candidateIndex ?? 0;
1179
+
1180
+ const delta: any = {};
1181
+
1182
+ // Thinking — map to reasoning_content (opencode + OpenAI compat)
1183
+ if (chunk.thinking && chunk.thinking.length > 0) {
1184
+ delta.reasoning_content = chunk.thinking;
1185
+ delta.reasoning = chunk.thinking; // alt field for compatibility
1186
+ // Include signature if present — some clients use it for multi-turn
1187
+ if (chunk.thinkingSignature) {
1188
+ delta.reasoning_content_signature = chunk.thinkingSignature;
1189
+ delta.thinking_signature = chunk.thinkingSignature;
1190
+ }
1191
+ }
1192
+
1193
+ // Text
1194
+ if (chunk.text && chunk.text.length > 0) {
1195
+ delta.content = chunk.text;
1196
+ }
1197
+
1198
+ // Tool calls
1199
+ if (chunk.toolCalls && chunk.toolCalls.length > 0) {
1200
+ delta.tool_calls = chunk.toolCalls.map((tc, idx) => ({
1201
+ index: idx,
1202
+ id: tc.id,
1203
+ type: "function",
1204
+ function: {
1205
+ name: tc.name,
1206
+ // OpenAI streams incremental args as delta; we send full or delta depending on isDelta
1207
+ arguments: tc.argsJson,
1208
+ },
1209
+ }));
1210
+ }
1211
+
1212
+ // Grounding metadata passthrough as extra field for search models
1213
+ if (chunk.groundingMetadata) {
1214
+ delta.grounding_metadata = chunk.groundingMetadata;
1215
+ }
1216
+
1217
+ const hasDelta = Object.keys(delta).length > 0;
1218
+ const finish = mapToOpenAIFinishReason(chunk.finishReason);
1219
+
1220
+ // If nothing to emit and no finish, skip (empty chunk)
1221
+ if (!hasDelta && !finish && !chunk.isFinished) {
1222
+ return [];
1223
+ }
1224
+
1225
+ const payload: any = {
1226
+ id,
1227
+ object: "chat.completion.chunk",
1228
+ created,
1229
+ model: opts.model,
1230
+ choices: [
1231
+ {
1232
+ index: choiceIndex,
1233
+ delta: hasDelta ? delta : {},
1234
+ finish_reason: finish ?? null,
1235
+ // Include logprobs null per OpenAI spec
1236
+ logprobs: null,
1237
+ },
1238
+ ],
1239
+ };
1240
+
1241
+ // If usage present, include for final chunk (some clients expect)
1242
+ if (chunk.usageMetadata && finish) {
1243
+ payload.usage = chunk.usageMetadata;
1244
+ }
1245
+
1246
+ return [`data: ${JSON.stringify(payload)}\n\n`];
1247
+ }
1248
+
1249
+ /**
1250
+ * Factory that returns stateful OpenAI transformer with internal tracker
1251
+ * and LRU signature cache. Suitable for opencode plugin hook.
1252
+ *
1253
+ * @param opts - model options
1254
+ * @param cache - optional cache injection
1255
+ */
1256
+ export function createOpenAIStreamingTransformer(
1257
+ opts: OpenAIChunkOptions,
1258
+ cache: ReplaySignatureCache = globalThinkingSignatureCache,
1259
+ ): {
1260
+ tracker: StreamingSessionTracker;
1261
+ transform: (chunk: NormalizedChunk) => string[];
1262
+ final: () => string[];
1263
+ } {
1264
+ const tracker = new StreamingSessionTracker(cache);
1265
+ const fixedId = opts.id ?? generateChatId();
1266
+ const created = opts.created ?? Math.floor(Date.now() / 1000);
1267
+
1268
+ return {
1269
+ tracker,
1270
+ transform: (chunk: NormalizedChunk) => {
1271
+ const deduped = tracker.push(chunk);
1272
+ // If after dedup nothing new but finish, still emit finish
1273
+ if (!deduped.text && !deduped.thinking && !deduped.toolCalls && !deduped.finishReason) {
1274
+ if (deduped.isFinished || deduped.finishReason) {
1275
+ // force emit finish even if deduped empty
1276
+ return transformToOpenAI(deduped, { ...opts, id: fixedId, created }, tracker);
1277
+ }
1278
+ return [];
1279
+ }
1280
+ return transformToOpenAI(deduped, { ...opts, id: fixedId, created }, tracker);
1281
+ },
1282
+ final: () => {
1283
+ const synthetic = tracker.createSyntheticFinish("stop");
1284
+ if (!synthetic) return [];
1285
+ return transformToOpenAI(synthetic, { ...opts, id: fixedId, created }, tracker);
1286
+ },
1287
+ };
1288
+ }
1289
+
1290
+ // ---------------------------------------------------------------------------
1291
+ // Anthropic compatible transformer — opencode uses Anthropic messages SSE
1292
+ // ---------------------------------------------------------------------------
1293
+
1294
+ export interface AnthropicChunkOptions {
1295
+ /** Model to echo */
1296
+ model: string;
1297
+ /** Message id */
1298
+ messageId?: string;
1299
+ }
1300
+
1301
+ type AnthropicBlockType = "text" | "thinking" | "tool_use";
1302
+
1303
+ /**
1304
+ * Internal state for Anthropic streaming sequence.
1305
+ * Observer maintains index counters per content block.
1306
+ */
1307
+ class AnthropicStateMachine {
1308
+ public messageStarted = false;
1309
+ public nextBlockIndex = 0;
1310
+ public activeBlocks = new Map<number, AnthropicBlockType>();
1311
+ public blockThinkingSent = new Map<number, string>();
1312
+ public blockTextSent = new Map<number, string>();
1313
+ public toolJsonSent = new Map<string, string>();
1314
+ public finished = false;
1315
+
1316
+ reset(): void {
1317
+ this.messageStarted = false;
1318
+ this.nextBlockIndex = 0;
1319
+ this.activeBlocks.clear();
1320
+ this.blockThinkingSent.clear();
1321
+ this.blockTextSent.clear();
1322
+ this.toolJsonSent.clear();
1323
+ this.finished = false;
1324
+ }
1325
+ }
1326
+
1327
+ /**
1328
+ * Transforms normalized chunk into Anthropic SSE events.
1329
+ * Emits event: / data: pairs as per Anthropic Messages streaming spec:
1330
+ * message_start, content_block_start, content_block_delta, content_block_stop,
1331
+ * message_delta, message_stop
1332
+ *
1333
+ * Library-first: each call returns array of formatted SSE lines.
1334
+ *
1335
+ * @param chunk - delta chunk from tracker
1336
+ * @param opts - model/message options
1337
+ * @param state - anthropic state machine (mutable)
1338
+ * @returns array of SSE lines (`event: xxx\ndata: {...}\n\n`)
1339
+ */
1340
+ // local variant: diverges from core (full stateful Anthropic event machine with block tracking; core.transformToAnthropic maps one payload to a single delta event)
1341
+ export function transformToAnthropic(
1342
+ chunk: NormalizedChunk,
1343
+ opts: AnthropicChunkOptions,
1344
+ state: AnthropicStateMachine,
1345
+ ): string[] {
1346
+ const out: string[] = [];
1347
+ const messageId = opts.messageId ?? `msg_${fnv1a32(String(Date.now())).toString(16)}`;
1348
+
1349
+ // Helper to emit event+data pair
1350
+ const emit = (eventType: string, dataObj: unknown): void => {
1351
+ out.push(`event: ${eventType}\ndata: ${JSON.stringify(dataObj)}\n\n`);
1352
+ };
1353
+
1354
+ // message_start on first emission
1355
+ if (!state.messageStarted) {
1356
+ state.messageStarted = true;
1357
+ emit("message_start", {
1358
+ type: "message_start",
1359
+ message: {
1360
+ id: messageId,
1361
+ type: "message",
1362
+ role: "assistant",
1363
+ content: [],
1364
+ model: opts.model,
1365
+ stop_reason: null,
1366
+ stop_sequence: null,
1367
+ usage: { input_tokens: 0, output_tokens: 0 },
1368
+ },
1369
+ });
1370
+ }
1371
+
1372
+ // Thinking handling
1373
+ if (chunk.thinking && chunk.thinking.length > 0) {
1374
+ // Find active thinking block or create new
1375
+ let idx = Array.from(state.activeBlocks.entries()).find(([, t]) => t === "thinking")?.[0];
1376
+ if (idx === undefined) {
1377
+ idx = state.nextBlockIndex++;
1378
+ state.activeBlocks.set(idx, "thinking");
1379
+ state.blockThinkingSent.set(idx, "");
1380
+ emit("content_block_start", {
1381
+ type: "content_block_start",
1382
+ index: idx,
1383
+ content_block: {
1384
+ type: "thinking",
1385
+ thinking: "",
1386
+ signature: chunk.thinkingSignature ?? "",
1387
+ },
1388
+ });
1389
+ }
1390
+
1391
+ // Delta
1392
+ const prev = state.blockThinkingSent.get(idx) ?? "";
1393
+ // chunk.thinking already delta from tracker, but we still track
1394
+ // For anthropic, thinking delta event
1395
+ emit("content_block_delta", {
1396
+ type: "content_block_delta",
1397
+ index: idx,
1398
+ delta: {
1399
+ type: "thinking_delta",
1400
+ thinking: chunk.thinking,
1401
+ },
1402
+ });
1403
+ state.blockThinkingSent.set(idx, prev + chunk.thinking);
1404
+ }
1405
+
1406
+ // Text handling
1407
+ if (chunk.text && chunk.text.length > 0) {
1408
+ let idx = Array.from(state.activeBlocks.entries()).find(([, t]) => t === "text")?.[0];
1409
+ // If thinking block active, we should keep text separate — create new block if first text
1410
+ // But also text may come after thinking stopped — we need new block
1411
+ const hasActiveText = state.activeBlocks.has(idx as number) && state.activeBlocks.get(idx as number) === "text";
1412
+ if (!hasActiveText) {
1413
+ // If there is active thinking and we now have text, we should stop thinking block first (per Claude spec thinking must precede text)
1414
+ const thinkingIdx = Array.from(state.activeBlocks.entries()).find(([, t]) => t === "thinking")?.[0];
1415
+ if (thinkingIdx !== undefined) {
1416
+ emit("content_block_stop", {
1417
+ type: "content_block_stop",
1418
+ index: thinkingIdx,
1419
+ });
1420
+ state.activeBlocks.delete(thinkingIdx);
1421
+ }
1422
+ idx = state.nextBlockIndex++;
1423
+ state.activeBlocks.set(idx, "text");
1424
+ state.blockTextSent.set(idx, "");
1425
+ emit("content_block_start", {
1426
+ type: "content_block_start",
1427
+ index: idx,
1428
+ content_block: {
1429
+ type: "text",
1430
+ text: "",
1431
+ },
1432
+ });
1433
+ }
1434
+
1435
+ emit("content_block_delta", {
1436
+ type: "content_block_delta",
1437
+ index: idx,
1438
+ delta: {
1439
+ type: "text_delta",
1440
+ text: chunk.text,
1441
+ },
1442
+ });
1443
+ const prev = state.blockTextSent.get(idx!) ?? "";
1444
+ state.blockTextSent.set(idx!, prev + chunk.text);
1445
+ }
1446
+
1447
+ // Tool calls
1448
+ if (chunk.toolCalls && chunk.toolCalls.length > 0) {
1449
+ for (const tc of chunk.toolCalls) {
1450
+ // One content block per tool_use
1451
+ // Check if tool already has block
1452
+ let idx: number | undefined;
1453
+ // We use tool id as map key for block index lookup
1454
+ for (const entry of Array.from(state.activeBlocks.entries())) {
1455
+ const bIdx = entry[0];
1456
+ const bType = entry[1];
1457
+ if (bType === "tool_use" && state.toolJsonSent.has(`idx_${bIdx}_id_${tc.id}`)) {
1458
+ idx = bIdx;
1459
+ break;
1460
+ }
1461
+ }
1462
+ const isNew = idx === undefined;
1463
+ if (isNew) {
1464
+ idx = state.nextBlockIndex++;
1465
+ state.activeBlocks.set(idx, "tool_use");
1466
+ state.toolJsonSent.set(`idx_${idx}_id_${tc.id}`, "");
1467
+ state.toolJsonSent.set(`id_${tc.id}`, String(idx)); // reverse lookup
1468
+ emit("content_block_start", {
1469
+ type: "content_block_start",
1470
+ index: idx,
1471
+ content_block: {
1472
+ type: "tool_use",
1473
+ id: tc.id,
1474
+ name: tc.name,
1475
+ input: {},
1476
+ },
1477
+ });
1478
+ }
1479
+
1480
+ // input_json delta
1481
+ // For Anthropic, partial_json is incremental input json
1482
+ const prevJson = state.toolJsonSent.get(`idx_${idx}_id_${tc.id}`) ?? "";
1483
+ const deltaJson = tc.isDelta ? tc.argsJson : extractDelta(tc.argsJson, prevJson) || tc.argsJson;
1484
+ if (deltaJson) {
1485
+ emit("content_block_delta", {
1486
+ type: "content_block_delta",
1487
+ index: idx,
1488
+ delta: {
1489
+ type: "input_json_delta",
1490
+ partial_json: deltaJson,
1491
+ },
1492
+ });
1493
+ state.toolJsonSent.set(`idx_${idx}_id_${tc.id}`, prevJson + deltaJson);
1494
+ }
1495
+ }
1496
+ }
1497
+
1498
+ // Finish handling
1499
+ if (chunk.finishReason || chunk.isFinished) {
1500
+ // Stop all active blocks
1501
+ for (const bIdx of Array.from(state.activeBlocks.keys())) {
1502
+ emit("content_block_stop", {
1503
+ type: "content_block_stop",
1504
+ index: bIdx,
1505
+ });
1506
+ }
1507
+ state.activeBlocks.clear();
1508
+
1509
+ // Map finish reason to Anthropic stop_reason
1510
+ let stopReason: string | null = "end_turn";
1511
+ switch (chunk.finishReason) {
1512
+ case "tool_calls":
1513
+ stopReason = "tool_use";
1514
+ break;
1515
+ case "length":
1516
+ stopReason = "max_tokens";
1517
+ break;
1518
+ case "content_filter":
1519
+ stopReason = "stop_sequence";
1520
+ break;
1521
+ default:
1522
+ stopReason = chunk.finishReason ? "end_turn" : "end_turn";
1523
+ }
1524
+
1525
+ emit("message_delta", {
1526
+ type: "message_delta",
1527
+ delta: {
1528
+ stop_reason: stopReason,
1529
+ stop_sequence: null,
1530
+ },
1531
+ usage: chunk.usageMetadata ?? { output_tokens: 0 },
1532
+ });
1533
+
1534
+ emit("message_stop", {
1535
+ type: "message_stop",
1536
+ });
1537
+
1538
+ state.finished = true;
1539
+ }
1540
+
1541
+ return out;
1542
+ }
1543
+
1544
+ /**
1545
+ * Factory for Anthropic streaming transformer compatible with opencode
1546
+ *
1547
+ * @param opts - model options
1548
+ * @param cache - LRU signature cache
1549
+ */
1550
+ export function createAnthropicStreamingTransformer(
1551
+ opts: AnthropicChunkOptions,
1552
+ cache: ReplaySignatureCache = globalThinkingSignatureCache,
1553
+ ): {
1554
+ tracker: StreamingSessionTracker;
1555
+ anthropicState: AnthropicStateMachine;
1556
+ transform: (chunk: NormalizedChunk) => string[];
1557
+ final: () => string[];
1558
+ } {
1559
+ const tracker = new StreamingSessionTracker(cache);
1560
+ const anthropicState = new AnthropicStateMachine();
1561
+
1562
+ return {
1563
+ tracker,
1564
+ anthropicState,
1565
+ transform: (chunk: NormalizedChunk) => {
1566
+ const deduped = tracker.push(chunk);
1567
+ // If nothing new and not finish, skip
1568
+ if (!deduped.text && !deduped.thinking && !deduped.toolCalls && !deduped.finishReason && !deduped.isFinished) {
1569
+ return [];
1570
+ }
1571
+ return transformToAnthropic(deduped, opts, anthropicState);
1572
+ },
1573
+ final: () => {
1574
+ const synthetic = tracker.createSyntheticFinish("stop");
1575
+ if (!synthetic) return [];
1576
+ return transformToAnthropic(synthetic, opts, anthropicState);
1577
+ },
1578
+ };
1579
+ }
1580
+
1581
+ // ---------------------------------------------------------------------------
1582
+ // Auto-recovery — retry exponencial e injeção finish sintético
1583
+ // ---------------------------------------------------------------------------
1584
+
1585
+ export interface RetryOptions {
1586
+ /** Max attempts including first */
1587
+ maxRetries?: number;
1588
+ /** Base delay ms */
1589
+ baseMs?: number;
1590
+ /** Factor */
1591
+ factor?: number;
1592
+ /** Max delay ms */
1593
+ maxMs?: number;
1594
+ /** Which status codes trigger retry */
1595
+ retryableStatus?: number[];
1596
+ }
1597
+
1598
+ /**
1599
+ * Determines if error/status is retryable.
1600
+ * Observer pattern: checks 429, 5xx, and network failures.
1601
+ *
1602
+ * @param status - HTTP status or undefined for network error
1603
+ */
1604
+ // local variant: diverges from models (adds opts.retryableStatus and treats undefined as retryable network failure; models.isRetryableStatus takes a plain status and hardcodes 403/404/408/429/5xx)
1605
+ export function isRetryableNetworkStatus(status: number | undefined, opts?: RetryOptions): boolean {
1606
+ if (status === undefined) return true; // network failure retryable
1607
+ const list = opts?.retryableStatus ?? (RETRYABLE_STATUS_CODES as unknown as number[]);
1608
+ return list.includes(status);
1609
+ }
1610
+
1611
+ /**
1612
+ * Fetches with exponential backoff + jitter.
1613
+ * Uses only node builtins + global fetch.
1614
+ * Third-person observer: it watches fetch, retries on transient errors.
1615
+ *
1616
+ * @param input - RequestInfo
1617
+ * @param init - RequestInit
1618
+ * @param retryOpts - backoff options
1619
+ * @returns Response
1620
+ * @throws last error after retries exhausted
1621
+ */
1622
+ export async function fetchWithExponentialRetry(
1623
+ input: Parameters<typeof fetch>[0],
1624
+ init?: RequestInit,
1625
+ retryOpts: RetryOptions = {},
1626
+ ): Promise<Response> {
1627
+ const maxRetries = retryOpts.maxRetries ?? MAX_RETRIES;
1628
+ const baseMs = retryOpts.baseMs ?? BACKOFF_BASE_MS;
1629
+ const factor = retryOpts.factor ?? BACKOFF_FACTOR;
1630
+ const maxMs = retryOpts.maxMs ?? BACKOFF_MAX_MS;
1631
+
1632
+ let attempt = 0;
1633
+ let lastError: unknown;
1634
+
1635
+ while (attempt <= maxRetries) {
1636
+ try {
1637
+ const res = await fetch(input, init);
1638
+
1639
+ if (res.ok) return res;
1640
+
1641
+ // Non-ok: check if retryable
1642
+ if (!isRetryableNetworkStatus(res.status, retryOpts) || attempt >= maxRetries) {
1643
+ return res; // return non-retryable response for caller to handle
1644
+ }
1645
+
1646
+ // Retryable status — consume body to free connection before retry
1647
+ try {
1648
+ await res.text();
1649
+ } catch {
1650
+ // ignore
1651
+ }
1652
+
1653
+ lastError = new Error(`retryable status ${res.status}`);
1654
+ } catch (err) {
1655
+ lastError = err;
1656
+ if (attempt >= maxRetries) break;
1657
+ // network error — retryable unless explicitly non-retryable
1658
+ }
1659
+
1660
+ // Backoff before next attempt
1661
+ if (attempt < maxRetries) {
1662
+ const delay = Math.min(baseMs * Math.pow(factor, attempt), maxMs);
1663
+ await sleepWithJitter(delay);
1664
+ }
1665
+
1666
+ attempt++;
1667
+ }
1668
+
1669
+ throw lastError instanceof Error ? lastError : new Error(String(lastError ?? "fetch retry exhausted"));
1670
+ }
1671
+
1672
+ /**
1673
+ * Creates synthetic finish chunk when stream closes abruptly.
1674
+ * Observer injects `stop` finish to keep opencode SDK happy.
1675
+ *
1676
+ * @param tracker - session tracker to know current state
1677
+ * @param reason - finish reason, default stop
1678
+ * @returns normalized finish chunk or null if already finished
1679
+ */
1680
+ export function injectSyntheticFinish(
1681
+ tracker: StreamingSessionTracker,
1682
+ reason: string = "stop",
1683
+ ): NormalizedChunk | null {
1684
+ return tracker.createSyntheticFinish(reason);
1685
+ }
1686
+
1687
+ /**
1688
+ * Wraps streaming response reading with auto-recovery.
1689
+ * If reader throws before finish, retries whole fetch (exponential).
1690
+ * On final failure, injects synthetic finish so UI doesn't hang.
1691
+ *
1692
+ * @param fetchFn - function that returns Promise<Response> (fresh fetch each attempt)
1693
+ * @param onChunk - callback for each normalized chunk (deduped)
1694
+ * @param retryOpts - retry options for fetch phase
1695
+ * @param tracker - optional tracker (if not provided new one created)
1696
+ */
1697
+ export async function withAutoRecoveryStreaming(
1698
+ fetchFn: () => Promise<Response>,
1699
+ onChunk: (chunk: NormalizedChunk) => void | Promise<void>,
1700
+ retryOpts: RetryOptions = {},
1701
+ tracker: StreamingSessionTracker = new StreamingSessionTracker(globalThinkingSignatureCache),
1702
+ ): Promise<void> {
1703
+ const maxRetries = retryOpts.maxRetries ?? MAX_RETRIES;
1704
+ let attempt = 0;
1705
+
1706
+ while (attempt <= maxRetries) {
1707
+ let response: Response;
1708
+ try {
1709
+ response = await fetchWithExponentialRetry(
1710
+ // dummy — actual fetchFn does request construction
1711
+ // We call fetchFn directly but reuse retry logic for network: we wrap fetchFn with backoff here
1712
+ // So first we try fetchFn()
1713
+ // fetchWithExponentialRetry expects input, not fn — we implement manual retry loop for fetchFn
1714
+ // Instead we directly call fetchFn and apply retry inside catch below
1715
+ // For simplicity we call fetchFn here and if it throws we retry
1716
+ // This inner try will rely on outer loop for backoff
1717
+ "" as unknown as Parameters<typeof fetch>[0],
1718
+ undefined,
1719
+ retryOpts,
1720
+ ).catch(() => {
1721
+ // unreachable because we pass empty input — we will replace with fetchFn call
1722
+ return null as any;
1723
+ });
1724
+ // Actually we need to call fetchFn, not fetchWithExponentialRetry with dummy
1725
+ // Re-do: override response with fetchFn result
1726
+ response = await fetchFn();
1727
+ } catch (err) {
1728
+ if (attempt >= maxRetries) {
1729
+ // Final failure — inject synthetic finish and exit
1730
+ const synthetic = tracker.createSyntheticFinish("stop");
1731
+ if (synthetic) await onChunk(synthetic);
1732
+ throw err;
1733
+ }
1734
+ const delay = Math.min(
1735
+ (retryOpts.baseMs ?? BACKOFF_BASE_MS) * Math.pow(retryOpts.factor ?? BACKOFF_FACTOR, attempt),
1736
+ retryOpts.maxMs ?? BACKOFF_MAX_MS,
1737
+ );
1738
+ await sleepWithJitter(delay);
1739
+ attempt++;
1740
+ continue;
1741
+ }
1742
+
1743
+ if (!response.ok) {
1744
+ if (isRetryableNetworkStatus(response.status, retryOpts) && attempt < maxRetries) {
1745
+ try {
1746
+ await response.text();
1747
+ } catch {
1748
+ // ignore
1749
+ }
1750
+ const delay = Math.min(
1751
+ (retryOpts.baseMs ?? BACKOFF_BASE_MS) * Math.pow(retryOpts.factor ?? BACKOFF_FACTOR, attempt),
1752
+ retryOpts.maxMs ?? BACKOFF_MAX_MS,
1753
+ );
1754
+ await sleepWithJitter(delay);
1755
+ attempt++;
1756
+ continue;
1757
+ }
1758
+ // Non-retryable error — propagate response error as exception with body preview
1759
+ let bodyPreview = "";
1760
+ try {
1761
+ bodyPreview = (await response.text()).slice(0, 2000);
1762
+ } catch {
1763
+ bodyPreview = "<no body>";
1764
+ }
1765
+ throw new Error(`cloudcode-pa ${response.status} ${response.statusText}: ${bodyPreview}`);
1766
+ }
1767
+
1768
+ // Stream reading phase — try to read all
1769
+ try {
1770
+ for await (const normalized of readSSEStream(response, tracker)) {
1771
+ await onChunk(normalized);
1772
+ }
1773
+
1774
+ // Normal end — ensure finish injected if missing
1775
+ if (!tracker.isFinished) {
1776
+ const synthetic = tracker.createSyntheticFinish("stop");
1777
+ if (synthetic) await onChunk(synthetic);
1778
+ }
1779
+
1780
+ return; // success
1781
+ } catch (streamErr) {
1782
+ // Stream broke mid-way, retry if not finished and attempts left
1783
+ if (attempt >= maxRetries || tracker.isFinished) {
1784
+ if (!tracker.isFinished) {
1785
+ const synthetic = tracker.createSyntheticFinish("stop");
1786
+ if (synthetic) await onChunk(synthetic);
1787
+ }
1788
+ throw streamErr;
1789
+ }
1790
+ const delay = Math.min(
1791
+ (retryOpts.baseMs ?? BACKOFF_BASE_MS) * Math.pow(retryOpts.factor ?? BACKOFF_FACTOR, attempt),
1792
+ retryOpts.maxMs ?? BACKOFF_MAX_MS,
1793
+ );
1794
+ await sleepWithJitter(delay);
1795
+ attempt++;
1796
+ // Continue retry loop — will re-fetch and continue? Note: we lost progress but tracker preserves sent state for dedup
1797
+ continue;
1798
+ }
1799
+ }
1800
+ }
1801
+
1802
+ // ---------------------------------------------------------------------------
1803
+ // High-level SSE stream reader — direct cloudcode-pa
1804
+ // ---------------------------------------------------------------------------
1805
+
1806
+ /**
1807
+ * Async generator that reads a fetch Response body (cloudcode-pa alt=sse)
1808
+ * and yields normalized chunks with preserved thinking blocks.
1809
+ *
1810
+ * The observer directly consumes https://cloudcode-pa.googleapis.com
1811
+ * (or daily-.../sandbox) without localhost v1 proxy.
1812
+ *
1813
+ * @param response - fetch Response with body ReadableStream
1814
+ * @param tracker - optional tracker for delta preservation (new one if not provided)
1815
+ * @yields NormalizedChunk (deduped delta already applied via tracker)
1816
+ */
1817
+ export async function* readSSEStream(
1818
+ response: Response,
1819
+ tracker: StreamingSessionTracker = new StreamingSessionTracker(globalThinkingSignatureCache),
1820
+ ): AsyncGenerator<NormalizedChunk> {
1821
+ if (!response || !response.body) {
1822
+ throw new TypeError("readSSEStream requires Response with body");
1823
+ }
1824
+
1825
+ const parserState = createSSEParserState();
1826
+ const decoder = new TextDecoder("utf-8");
1827
+
1828
+ // Node fetch Response.body is a ReadableStream (web)
1829
+ const reader = (response.body as any).getReader() as ReadableStreamDefaultReader<Uint8Array>;
1830
+
1831
+ if (!reader || typeof reader.read !== "function") {
1832
+ throw new Error("Response body does not implement getReader() — unsupported environment");
1833
+ }
1834
+
1835
+ try {
1836
+ while (true) {
1837
+ let result: { done: boolean; value?: Uint8Array | undefined };
1838
+ try {
1839
+ result = await reader.read();
1840
+ } catch (readErr) {
1841
+ // Network mid-stream failure — let outer auto-recovery handle
1842
+ const wrapped = new Error(`stream read failed: ${(readErr as Error).message}`);
1843
+ (wrapped as any).cause = readErr;
1844
+ throw wrapped;
1845
+ }
1846
+
1847
+ if (result.done) break;
1848
+
1849
+ const value = result.value;
1850
+ if (!value || value.length === 0) continue;
1851
+
1852
+ let textChunk: string;
1853
+ try {
1854
+ textChunk = decoder.decode(value, { stream: true });
1855
+ } catch (decErr) {
1856
+ console.warn("[streaming] TextDecoder failed, skipping chunk", decErr);
1857
+ continue;
1858
+ }
1859
+
1860
+ let events: ParsedSSEEvent[];
1861
+ try {
1862
+ events = parseSSEChunk(textChunk, parserState);
1863
+ } catch (parseErr) {
1864
+ console.warn("[streaming] parseSSEChunk failed", parseErr);
1865
+ continue;
1866
+ }
1867
+
1868
+ if (events.length === 0) continue;
1869
+
1870
+ for (const ev of events) {
1871
+ // Skip [DONE] sentinel here — normalize will handle but we also short-circuit
1872
+ if (ev.data.trim() === SSE_DONE_SENTINEL) {
1873
+ const chunks = normalizePayloads([ev.data]);
1874
+ for (const c of chunks) {
1875
+ const deduped = tracker.push(c);
1876
+ yield deduped;
1877
+ }
1878
+ continue;
1879
+ }
1880
+
1881
+ const normalizedList = normalizePayloads([ev.data]);
1882
+
1883
+ for (const norm of normalizedList) {
1884
+ const deduped = tracker.push(norm);
1885
+ // Yield only if has something new or finish
1886
+ if (
1887
+ deduped.text ||
1888
+ deduped.thinking ||
1889
+ (deduped.toolCalls && deduped.toolCalls.length > 0) ||
1890
+ deduped.finishReason ||
1891
+ deduped.isFinished
1892
+ ) {
1893
+ yield deduped;
1894
+ } else if (deduped.isFinished) {
1895
+ yield deduped;
1896
+ }
1897
+ }
1898
+ }
1899
+ }
1900
+
1901
+ // Flush decoder remaining
1902
+ try {
1903
+ const remaining = decoder.decode();
1904
+ if (remaining) {
1905
+ const events = parseSSEChunk(remaining, parserState);
1906
+ for (const ev of events) {
1907
+ const normalizedList = normalizePayloads([ev.data]);
1908
+ for (const norm of normalizedList) {
1909
+ yield tracker.push(norm);
1910
+ }
1911
+ }
1912
+ }
1913
+ } catch {
1914
+ // ignore flush errors
1915
+ }
1916
+
1917
+ // If buffer still has data without trailing \n\n, try to parse it as final block (tolerant)
1918
+ if (parserState.buffer.trim().length > 0) {
1919
+ const finalBlock = parserState.buffer.trim();
1920
+ // Attempt to treat it as data line even without delimiter
1921
+ // Remove possible "data:" prefix if present
1922
+ let dataPayload = finalBlock;
1923
+ if (dataPayload.startsWith("data:")) {
1924
+ dataPayload = dataPayload.slice(5).trimStart();
1925
+ }
1926
+ if (dataPayload && dataPayload !== SSE_DONE_SENTINEL) {
1927
+ const normalizedList = normalizePayloads([dataPayload]);
1928
+ for (const norm of normalizedList) {
1929
+ yield tracker.push(norm);
1930
+ }
1931
+ }
1932
+ parserState.buffer = "";
1933
+ }
1934
+ } finally {
1935
+ try {
1936
+ reader.releaseLock();
1937
+ } catch {
1938
+ // ignore
1939
+ }
1940
+ }
1941
+ }
1942
+
1943
+ /**
1944
+ * Convenience: stream cloudcode-pa response and transform to OpenAI SSE lines.
1945
+ * Used by opencode plugin fetch interceptor.
1946
+ *
1947
+ * @param response - fetch Response from cloudcode-pa
1948
+ * @param openAIOpts - model info
1949
+ * @param cache - optional LRU cache
1950
+ * @returns AsyncGenerator<string> of `data: {...}\n\n` lines
1951
+ */
1952
+ export async function* streamToOpenAI(
1953
+ response: Response,
1954
+ openAIOpts: OpenAIChunkOptions,
1955
+ cache: ReplaySignatureCache = globalThinkingSignatureCache,
1956
+ ): AsyncGenerator<string> {
1957
+ const tracker = new StreamingSessionTracker(cache);
1958
+ const fixedId = openAIOpts.id ?? generateChatId();
1959
+ const created = openAIOpts.created ?? Math.floor(Date.now() / 1000);
1960
+
1961
+ for await (const norm of readSSEStream(response, tracker)) {
1962
+ const lines = transformToOpenAI(norm, { ...openAIOpts, id: fixedId, created }, tracker);
1963
+ for (const line of lines) yield line;
1964
+ }
1965
+
1966
+ if (!tracker.isFinished) {
1967
+ const synthetic = tracker.createSyntheticFinish("stop");
1968
+ if (synthetic) {
1969
+ const lines = transformToOpenAI(synthetic, { ...openAIOpts, id: fixedId, created }, tracker);
1970
+ for (const line of lines) yield line;
1971
+ }
1972
+ }
1973
+
1974
+ // Final [DONE] for OpenAI clients
1975
+ yield `data: ${SSE_DONE_SENTINEL}\n\n`;
1976
+ }
1977
+
1978
+ /**
1979
+ * Convenience: stream cloudcode-pa response and transform to Anthropic SSE lines.
1980
+ *
1981
+ * @param response - fetch Response
1982
+ * @param anthropicOpts - model info
1983
+ * @param cache - optional LRU cache
1984
+ * @returns AsyncGenerator<string> of `event: ...\ndata: ...\n\n`
1985
+ */
1986
+ export async function* streamToAnthropic(
1987
+ response: Response,
1988
+ anthropicOpts: AnthropicChunkOptions,
1989
+ cache: ReplaySignatureCache = globalThinkingSignatureCache,
1990
+ ): AsyncGenerator<string> {
1991
+ const tracker = new StreamingSessionTracker(cache);
1992
+ const anthropicState = new AnthropicStateMachine();
1993
+
1994
+ for await (const norm of readSSEStream(response, tracker)) {
1995
+ const lines = transformToAnthropic(norm, anthropicOpts, anthropicState);
1996
+ for (const line of lines) yield line;
1997
+ }
1998
+
1999
+ if (!tracker.isFinished) {
2000
+ const synthetic = tracker.createSyntheticFinish("stop");
2001
+ if (synthetic) {
2002
+ const lines = transformToAnthropic(synthetic, anthropicOpts, anthropicState);
2003
+ for (const line of lines) yield line;
2004
+ }
2005
+ }
2006
+ }
2007
+
2008
+ // ---------------------------------------------------------------------------
2009
+ // Merged: canonical bypass constants (from ONDA 4 streaming module)
2010
+ // ---------------------------------------------------------------------------
2011
+
2012
+ // GEMINI_CLI_CLIENT_ID / GEMINI_CLI_CLIENT_SECRET / PROJECT_FALLBACK are
2013
+ // imported from constants.js (identical values) and re-exported above.
2014
+
2015
+ /** Production Cloud Code Assist base endpoint. */
2016
+
2017
+ /** Exact User-Agent sent by Gemini CLI 0.57.0. */
2018
+
2019
+ // local variant: diverges from constants (constants.X_GOOG_API_CLIENT is the antigravity/{ver} gl-node gax/grpc chain; this is the bare gl-node value)
2020
+ /** X-Goog-Api-Client value observed on real Gemini CLI traffic. */
2021
+
2022
+ /** Client-Metadata header string used by Gemini CLI bypass requests. */
2023
+
2024
+ // v2.1.15 Phase A: MODELS_2026 (the 11-id legacy bypass list) and the
2025
+ // ModelId2026 union moved to models.ts (MODELS_2026_LEGACY / ModelId2026) —
2026
+ // imported under the historical name and re-exported so the public surface
2027
+ // is unchanged.
2028
+ import { MODELS_2026_LEGACY as MODELS_2026, UA_DEFAULT_MODEL } from "./models.js";
2029
+ import type { ModelId2026 } from "./models.js";
2030
+ export { MODELS_2026 };
2031
+ export type { ModelId2026 };
2032
+
2033
+ // ---------------------------------------------------------------------------
2034
+ // Merged: raw cloudcode-pa payload types
2035
+ // ---------------------------------------------------------------------------
2036
+
2037
+ /**
2038
+ * Raw part shape emitted by cloudcode-pa (Gemini core with Claude interop).
2039
+ */
2040
+ export interface RawPart {
2041
+ text?: string;
2042
+ thought?: boolean;
2043
+ thoughtSignature?: string;
2044
+ functionCall?: { name: string; args?: Record<string, unknown> | string; id?: string };
2045
+ inlineData?: { mimeType: string; data: string };
2046
+ [k: string]: unknown;
2047
+ }
2048
+
2049
+ /**
2050
+ * Raw content container — either wrapped parts or bare text.
2051
+ */
2052
+ export interface RawContent {
2053
+ role?: string;
2054
+ parts?: RawPart[];
2055
+ text?: string;
2056
+ }
2057
+
2058
+ /**
2059
+ * Raw candidate inside a cloudcode-pa chunk.
2060
+ */
2061
+ export interface RawCandidate {
2062
+ content?: RawContent;
2063
+ finishReason?: string;
2064
+ finishMessage?: string;
2065
+ index?: number;
2066
+ tokenCount?: number;
2067
+ safetyRatings?: unknown[];
2068
+ parts?: RawPart[];
2069
+ text?: string;
2070
+ thought?: boolean;
2071
+ thoughtSignature?: string;
2072
+ }
2073
+
2074
+ /**
2075
+ * Heterogeneous raw chunk from v1internal:streamGenerateContent.
2076
+ */
2077
+ export interface CloudCodeRawChunk {
2078
+ candidates?: RawCandidate[];
2079
+ content?: RawContent;
2080
+ parts?: RawPart[];
2081
+ response?: CloudCodeRawChunk;
2082
+ usageMetadata?: {
2083
+ promptTokenCount?: number;
2084
+ candidatesTokenCount?: number;
2085
+ totalTokenCount?: number;
2086
+ thoughtsTokenCount?: number;
2087
+ };
2088
+ model?: string;
2089
+ modelVersion?: string;
2090
+ thought?: string;
2091
+ thoughtSignature?: string;
2092
+ [k: string]: unknown;
2093
+ }
2094
+
2095
+ // ---------------------------------------------------------------------------
2096
+ // Merged: normalized payload types
2097
+ // ---------------------------------------------------------------------------
2098
+
2099
+ /** Kind discriminator for {@link NormalizedPart}. */
2100
+ export type NormalizedPartKind = "text" | "thinking" | "thought_signature" | "function_call" | "inline_data";
2101
+
2102
+ /**
2103
+ * One normalized part with provenance indexes and optional signature.
2104
+ */
2105
+ export interface NormalizedPart {
2106
+ kind: NormalizedPartKind;
2107
+ text: string;
2108
+ thought: boolean;
2109
+ thoughtSignature?: string | undefined;
2110
+ functionCall?: { name: string; args: string; id: string };
2111
+ rawIndex: number;
2112
+ candidateIndex: number;
2113
+ finishReason?: string | undefined;
2114
+ }
2115
+
2116
+ /**
2117
+ * Fully normalized payload: parts plus finish/usage metadata.
2118
+ */
2119
+ export interface NormalizedPayload {
2120
+ parts: NormalizedPart[];
2121
+ finishReason?: string | undefined;
2122
+ usage?: CloudCodeRawChunk["usageMetadata"] | undefined;
2123
+ model?: string | undefined;
2124
+ isThinkingChunk: boolean;
2125
+ hasFinish: boolean;
2126
+ raw: CloudCodeRawChunk;
2127
+ }
2128
+
2129
+ // ---------------------------------------------------------------------------
2130
+ // Merged: streaming state + OpenAI / Anthropic wire types
2131
+ // ---------------------------------------------------------------------------
2132
+
2133
+ /**
2134
+ * Mutable state shared by the stateful OpenAI / Anthropic transformers.
2135
+ */
2136
+ export interface StreamingState {
2137
+ completionId: string;
2138
+ model: string;
2139
+ created: number;
2140
+ sentRole: boolean;
2141
+ accumulatedText: string;
2142
+ accumulatedThinking: string;
2143
+ lastFinishReason?: string | undefined;
2144
+ insideThinking: boolean;
2145
+ anthropicBlockIndex: number;
2146
+ anthropicBlockKinds: Map<number, NormalizedPartKind>;
2147
+ toolCallIdCounter: number;
2148
+ pendingSignatures: string[];
2149
+ }
2150
+
2151
+ /**
2152
+ * OpenAI chat.completion.chunk wire type emitted by the transformer.
2153
+ */
2154
+ export interface OpenAIChatChunk {
2155
+ id: string;
2156
+ object: "chat.completion.chunk";
2157
+ created: number;
2158
+ model: string;
2159
+ choices: Array<{
2160
+ index: number;
2161
+ delta: {
2162
+ role?: "assistant";
2163
+ content?: string;
2164
+ reasoning_content?: string;
2165
+ reasoning?: string;
2166
+ tool_calls?: Array<{
2167
+ index: number;
2168
+ id: string;
2169
+ type: "function";
2170
+ function: { name: string; arguments: string };
2171
+ }>;
2172
+ };
2173
+ finish_reason: string | null;
2174
+ }>;
2175
+ usage?: {
2176
+ prompt_tokens?: number;
2177
+ completion_tokens?: number;
2178
+ total_tokens?: number;
2179
+ completion_tokens_details?: { reasoning_tokens?: number };
2180
+ };
2181
+ }
2182
+
2183
+ /**
2184
+ * Anthropic-style SSE event descriptor.
2185
+ */
2186
+ export interface AnthropicSSEEvent {
2187
+ type: string;
2188
+ data?: unknown;
2189
+ sse?: string;
2190
+ }
2191
+
2192
+ /**
2193
+ * Diagnostic result shape for remainder-based SSE parsing.
2194
+ */
2195
+ export interface ParseSSEChunkResult {
2196
+ payloads: CloudCodeRawChunk[];
2197
+ remainder: string;
2198
+ consumedBytes: number;
2199
+ errors: string[];
2200
+ }
2201
+
2202
+ // ---------------------------------------------------------------------------
2203
+ // Merged: multi-turn thinking signature LRU cache (entry-based)
2204
+ // ---------------------------------------------------------------------------
2205
+
2206
+ /**
2207
+ * Byte-wise FNV-1a 32-bit hash of a UTF-8 string, rendered as 8 hex chars.
2208
+ * Shared by {@link ThinkingSignatureLRUCache.hashThinking} and
2209
+ * {@link buildHostSessionId} (v2.1.16 internal-dedup: the two identical
2210
+ * inline loops were extracted into this single private helper). Distinct from
2211
+ * core.js fnv1a32, which hashes UTF-16 code units.
2212
+ *
2213
+ * @param value - input string
2214
+ * @returns 8-char lowercase hex digest
2215
+ */
2216
+ function fnv1a32HexBytes(value: string): string {
2217
+ let h = 0x811c9dc5;
2218
+ const buf = Buffer.from(value, "utf8");
2219
+ for (let i = 0; i < buf.length; i++) {
2220
+ h ^= buf[i]!;
2221
+ h = Math.imul(h, 0x01000193) >>> 0;
2222
+ }
2223
+ return h.toString(16).padStart(8, "0");
2224
+ }
2225
+
2226
+ /**
2227
+ * Cached signature entry with turn/model provenance.
2228
+ */
2229
+ export interface ReplaySignatureEntry {
2230
+ signature: string;
2231
+ thinkingText: string;
2232
+ model: string;
2233
+ timestamp: number;
2234
+ turnId: string;
2235
+ tokenCount?: number | undefined;
2236
+ }
2237
+
2238
+ /**
2239
+ * Entry-based LRU cache for thinking signatures across turns.
2240
+ * Preserves thoughtSignature values that must be replayed on multi-turn
2241
+ * requests for gemini-3-pro / claude thinking models.
2242
+ *
2243
+ * Kept alongside {@link ReplaySignatureCache}, which maps plain text hashes
2244
+ * to signature strings.
2245
+ */
2246
+ export class ThinkingSignatureLRUCache {
2247
+ private readonly maxEntries: number;
2248
+ private readonly map: Map<string, ReplaySignatureEntry>;
2249
+
2250
+ constructor(maxEntries = 100) {
2251
+ this.maxEntries = maxEntries;
2252
+ this.map = new Map();
2253
+ }
2254
+
2255
+ private touch(key: string): void {
2256
+ const entry = this.map.get(key);
2257
+ if (!entry) return;
2258
+ // LRU: re-insert at the end (most recently used)
2259
+ this.map.delete(key);
2260
+ this.map.set(key, entry);
2261
+ }
2262
+
2263
+ get(key: string): ReplaySignatureEntry | undefined {
2264
+ const v = this.map.get(key);
2265
+ if (v) this.touch(key);
2266
+ return v;
2267
+ }
2268
+
2269
+ set(key: string, entry: ReplaySignatureEntry): void {
2270
+ if (this.map.has(key)) this.map.delete(key);
2271
+ this.map.set(key, entry);
2272
+ while (this.map.size > this.maxEntries) {
2273
+ const oldest = this.map.keys().next().value as string;
2274
+ this.map.delete(oldest);
2275
+ }
2276
+ }
2277
+
2278
+ /**
2279
+ * Short FNV-1a hex hash of thinking text, used as key when no turnId exists.
2280
+ */
2281
+ static hashThinking(text: string): string {
2282
+ return fnv1a32HexBytes(text);
2283
+ }
2284
+
2285
+ /**
2286
+ * Adds an entry derived from a payload fragment and returns the cache key.
2287
+ */
2288
+ addFromPayload(payload: {
2289
+ signature: string;
2290
+ thinkingText?: string;
2291
+ model?: string;
2292
+ turnId?: string;
2293
+ tokenCount?: number;
2294
+ }): string {
2295
+ const keyBase = payload.turnId ?? ThinkingSignatureLRUCache.hashThinking(payload.thinkingText ?? payload.signature);
2296
+ const entry: ReplaySignatureEntry = {
2297
+ signature: payload.signature,
2298
+ thinkingText: payload.thinkingText ?? "",
2299
+ model: payload.model ?? UA_DEFAULT_MODEL,
2300
+ timestamp: Date.now(),
2301
+ turnId: keyBase,
2302
+ tokenCount: payload.tokenCount,
2303
+ };
2304
+ this.set(keyBase, entry);
2305
+ return keyBase;
2306
+ }
2307
+
2308
+ findByModel(model: string): ReplaySignatureEntry[] {
2309
+ const out: ReplaySignatureEntry[] = [];
2310
+ for (const e of Array.from(this.map.values())) {
2311
+ if (e.model === model || e.model.includes(model) || model.includes(e.model)) out.push(e);
2312
+ }
2313
+ return out;
2314
+ }
2315
+
2316
+ clear(): void {
2317
+ this.map.clear();
2318
+ }
2319
+
2320
+ get size(): number {
2321
+ return this.map.size;
2322
+ }
2323
+
2324
+ entries(): IterableIterator<[string, ReplaySignatureEntry]> {
2325
+ return this.map.entries();
2326
+ }
2327
+
2328
+ toJSON(): Record<string, ReplaySignatureEntry> {
2329
+ const obj: Record<string, ReplaySignatureEntry> = {};
2330
+ for (const kv of Array.from(this.map.entries())) obj[kv[0]] = kv[1];
2331
+ return obj;
2332
+ }
2333
+ }
2334
+
2335
+ /**
2336
+ * Global singleton of the entry-based LRU cache. Kept alongside the text-hash
2337
+ * singleton {@link globalThinkingSignatureCache}.
2338
+ */
2339
+ export const globalThinkingSignatureLRUCache = new ThinkingSignatureLRUCache(100);
2340
+
2341
+ // ---------------------------------------------------------------------------
2342
+ // Merged: lightweight SSE parser utilities (v4/v5)
2343
+ // v2.1.16 dead-code cleanup: the exported LRU class (byte-identical twin of
2344
+ // core.ts's LRU, zero instantiations here) and the barrel-only compat trio
2345
+ // signatureCache / saveSignature / getSignature (zero consumers repo-wide)
2346
+ // were deleted; core.js keeps the shared LRU instance (thinkingCache).
2347
+ // ---------------------------------------------------------------------------
2348
+
2349
+ /**
2350
+ * Minimal SSE event shape used by the lightweight parser utilities.
2351
+ */
2352
+ export type sseEvent = { data: string };
2353
+
2354
+ /**
2355
+ * Linear-time strip of SDK-injected `[cache_control...]` markers (regex-free;
2356
+ * the former /\[cache_control.*?\]/g replace was polynomial on uncontrolled input).
2357
+ */
2358
+ export const stripCacheControlMarkers = (text: string): string => {
2359
+ let out = text;
2360
+ let idx = out.indexOf("[cache_control");
2361
+ while (idx !== -1) {
2362
+ const end = out.indexOf("]", idx);
2363
+ if (end === -1) break;
2364
+ out = out.slice(0, idx) + out.slice(end + 1);
2365
+ idx = out.indexOf("[cache_control", idx);
2366
+ }
2367
+ return out;
2368
+ };
2369
+
2370
+ /**
2371
+ * Strips SDK-injected cache_control markers from a thinking block payload.
2372
+ *
2373
+ * @param p - payload possibly containing `thinking`
2374
+ * @param keepThinking - kept for API compatibility; both branches preserve thinking
2375
+ * @returns payload with cleaned thinking text
2376
+ */
2377
+ export const handleThinkingBlock = (p: any, keepThinking = false): any => {
2378
+ if (!p.thinking) return p;
2379
+ // Filter sdk-injected cache_control markers
2380
+ const cleaned = {
2381
+ ...p.thinking,
2382
+ text: typeof p.thinking.text === "string" ? stripCacheControlMarkers(p.thinking.text) : p.thinking.text,
2383
+ };
2384
+ if (keepThinking) {
2385
+ return { ...p, thinking: cleaned };
2386
+ }
2387
+ return { ...p, thinking: cleaned };
2388
+ };
2389
+
2390
+ // ---------------------------------------------------------------------------
2391
+ // Merged: tolerant SSE data parser (remainder-based)
2392
+ // ---------------------------------------------------------------------------
2393
+
2394
+ function safeJsonParseArray(text: string): { objects: CloudCodeRawChunk[]; remainder: string; errors: string[] } {
2395
+ const objects: CloudCodeRawChunk[] = [];
2396
+ const errors: string[] = [];
2397
+ let remainder = text;
2398
+
2399
+ // Trim BOM
2400
+ if (remainder.charCodeAt(0) === 0xfeff) remainder = remainder.slice(1);
2401
+
2402
+ let buffer = remainder;
2403
+ remainder = "";
2404
+
2405
+ const lines = buffer.split(/\r?\n/);
2406
+ let jsonAccumulator = "";
2407
+ let insideDataBlock = false;
2408
+ let dataPrefixFound = false;
2409
+
2410
+ const flushAccumulator = (): { parsed: CloudCodeRawChunk[]; leftover: string } => {
2411
+ const trimmed = jsonAccumulator.trim();
2412
+ if (!trimmed) return { parsed: [], leftover: "" };
2413
+ const parsed: CloudCodeRawChunk[] = [];
2414
+ let leftover = "";
2415
+
2416
+ // JSON array form [...]
2417
+ if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
2418
+ try {
2419
+ const arr = JSON.parse(trimmed) as unknown;
2420
+ if (Array.isArray(arr)) {
2421
+ for (const item of arr) {
2422
+ if (item && typeof item === "object") parsed.push(item as CloudCodeRawChunk);
2423
+ }
2424
+ return { parsed, leftover: "" };
2425
+ }
2426
+ } catch {
2427
+ // incomplete array -> treat as streamed object below
2428
+ }
2429
+ }
2430
+
2431
+ // Concatenated objects like }{ or }\n{ — balanced-brace incremental scan
2432
+ let depth = 0;
2433
+ let inString = false;
2434
+ let escape = false;
2435
+ let start = -1;
2436
+ for (let i = 0; i < trimmed.length; i++) {
2437
+ const ch = trimmed[i]!;
2438
+ if (escape) {
2439
+ escape = false;
2440
+ continue;
2441
+ }
2442
+ if (ch === "\\" && inString) {
2443
+ escape = true;
2444
+ continue;
2445
+ }
2446
+ if (ch === '"') {
2447
+ inString = !inString;
2448
+ continue;
2449
+ }
2450
+ if (inString) continue;
2451
+ if (ch === "{" || ch === "[") {
2452
+ if (depth === 0) start = i;
2453
+ depth++;
2454
+ } else if (ch === "}" || ch === "]") {
2455
+ depth--;
2456
+ if (depth === 0 && start !== -1) {
2457
+ const slice = trimmed.slice(start, i + 1);
2458
+ try {
2459
+ const obj = JSON.parse(slice);
2460
+ if (obj && typeof obj === "object") parsed.push(obj as CloudCodeRawChunk);
2461
+ } catch (e) {
2462
+ // Invalid JSON in this slice -> keep as leftover for retry
2463
+ errors.push(`json_parse_slice_failed: ${(e as Error).message} slice=${slice.slice(0, 120)}`);
2464
+ leftover = trimmed.slice(start);
2465
+ break;
2466
+ }
2467
+ start = -1;
2468
+ }
2469
+ }
2470
+ }
2471
+ if (depth !== 0 && start !== -1) {
2472
+ leftover = trimmed.slice(start);
2473
+ } else if (parsed.length === 0 && trimmed.length > 0 && leftover === "") {
2474
+ leftover = trimmed;
2475
+ }
2476
+ return { parsed, leftover };
2477
+ };
2478
+
2479
+ for (let li = 0; li < lines.length; li++) {
2480
+ const line = lines[li]!;
2481
+ const trimmedLine = line.trim();
2482
+
2483
+ if (trimmedLine.length === 0) {
2484
+ // Empty line marks end of SSE event -> flush
2485
+ if (jsonAccumulator.trim().length > 0) {
2486
+ const res = flushAccumulator();
2487
+ objects.push(...res.parsed);
2488
+ if (res.leftover) {
2489
+ jsonAccumulator = res.leftover;
2490
+ if (li === lines.length - 1 && res.parsed.length === 0) {
2491
+ break;
2492
+ }
2493
+ } else {
2494
+ jsonAccumulator = "";
2495
+ }
2496
+ }
2497
+ insideDataBlock = false;
2498
+ dataPrefixFound = false;
2499
+ continue;
2500
+ }
2501
+
2502
+ // [DONE] sentinel from intermediate OpenAI-compatible wrappers
2503
+ if (trimmedLine === "data: [DONE]" || trimmedLine === "[DONE]") {
2504
+ continue;
2505
+ }
2506
+
2507
+ let dataContent = "";
2508
+ if (trimmedLine.startsWith("data:")) {
2509
+ dataPrefixFound = true;
2510
+ insideDataBlock = true;
2511
+ dataContent = trimmedLine.slice(5).trimStart();
2512
+ if (dataContent.length === 0) {
2513
+ continue;
2514
+ }
2515
+ } else if (trimmedLine.startsWith("event:") || trimmedLine.startsWith("id:") || trimmedLine.startsWith(":")) {
2516
+ // SSE metadata lines — ignore but keep framing
2517
+ continue;
2518
+ } else if (insideDataBlock || !dataPrefixFound) {
2519
+ // Prefix-less line inside a data block (multi-line JSON) or plain ndjson
2520
+ dataContent = trimmedLine;
2521
+ }
2522
+
2523
+ if (dataContent.length > 0) {
2524
+ // Preserve embedded newlines inside escaped JSON strings
2525
+ if (jsonAccumulator.length > 0) jsonAccumulator += "\n";
2526
+ jsonAccumulator += dataContent;
2527
+ // Flush early when the accumulator looks complete and the next line starts
2528
+ // a new event or the buffer ends — tolerates missing blank separators.
2529
+ const looksComplete = (() => {
2530
+ let d = 0,
2531
+ ins = false,
2532
+ esc = false;
2533
+ for (let k = 0; k < jsonAccumulator.length; k++) {
2534
+ const c = jsonAccumulator[k]!;
2535
+ if (esc) {
2536
+ esc = false;
2537
+ continue;
2538
+ }
2539
+ if (c === "\\" && ins) {
2540
+ esc = true;
2541
+ continue;
2542
+ }
2543
+ if (c === '"') {
2544
+ ins = !ins;
2545
+ continue;
2546
+ }
2547
+ if (ins) continue;
2548
+ if (c === "{" || c === "[") d++;
2549
+ else if (c === "}" || c === "]") d--;
2550
+ }
2551
+ return d === 0 && !ins;
2552
+ })();
2553
+ const nextLine = lines[li + 1];
2554
+ const nextIsData = nextLine?.trim().startsWith("data:") ?? false;
2555
+ const nextEmpty = (nextLine?.trim().length ?? 1) === 0;
2556
+ if (looksComplete && (nextIsData || nextEmpty || li === lines.length - 1)) {
2557
+ const res = flushAccumulator();
2558
+ objects.push(...res.parsed);
2559
+ if (res.leftover) {
2560
+ jsonAccumulator = res.leftover;
2561
+ } else {
2562
+ jsonAccumulator = "";
2563
+ insideDataBlock = false;
2564
+ }
2565
+ }
2566
+ }
2567
+ }
2568
+
2569
+ if (jsonAccumulator.trim().length > 0) {
2570
+ const res = flushAccumulator();
2571
+ if (res.parsed.length > 0) {
2572
+ objects.push(...res.parsed);
2573
+ remainder = res.leftover;
2574
+ } else {
2575
+ remainder = jsonAccumulator;
2576
+ }
2577
+ } else {
2578
+ remainder = "";
2579
+ }
2580
+
2581
+ return { objects, remainder, errors };
2582
+ }
2583
+
2584
+ /**
2585
+ * Remainder-based SSE parser for cloudcode-pa streams. Pure function: feed each
2586
+ * network chunk together with the previous remainder; it returns fully parsed
2587
+ * payloads plus the new remainder. Tolerates UTF-8 splits, concatenated JSON,
2588
+ * ndjson without SSE framing and `[DONE]` sentinels.
2589
+ *
2590
+ * Named `parseSSEPayloads` because {@link parseSSEChunk} already owns the
2591
+ * event-block parser name in this module.
2592
+ *
2593
+ * @param chunk - new raw text or binary chunk
2594
+ * @param previousRemainder - leftover text from the previous call
2595
+ * @returns parsed payloads, new remainder, error notes and consumed length
2596
+ *
2597
+ * @example
2598
+ * let remainder = "";
2599
+ * for await (const raw of stream) {
2600
+ * const { payloads, remainder: next } = parseSSEPayloads(raw, remainder);
2601
+ * remainder = next;
2602
+ * for (const p of payloads) handle(p);
2603
+ * }
2604
+ */
2605
+ export function parseSSEPayloads(
2606
+ chunk: string | Uint8Array,
2607
+ previousRemainder = "",
2608
+ ): { payloads: CloudCodeRawChunk[]; remainder: string; errors: string[]; consumed: number } {
2609
+ let text = "";
2610
+ if (typeof chunk === "string") {
2611
+ text = chunk;
2612
+ } else {
2613
+ try {
2614
+ text = Buffer.from(chunk).toString("utf8");
2615
+ } catch {
2616
+ text = new TextDecoder("utf-8", { fatal: false }).decode(chunk);
2617
+ }
2618
+ }
2619
+
2620
+ const combined = previousRemainder + text;
2621
+ const { objects, remainder, errors } = safeJsonParseArray(combined);
2622
+
2623
+ // Unwrap an outer { response: {...} } layer when present
2624
+ const payloads = objects.map((obj) => {
2625
+ const maybe = (obj as any).response as CloudCodeRawChunk | undefined;
2626
+ if (
2627
+ maybe &&
2628
+ typeof maybe === "object" &&
2629
+ ((maybe as any).candidates || (maybe as any).content || (maybe as any).parts)
2630
+ ) {
2631
+ return maybe;
2632
+ }
2633
+ return obj;
2634
+ });
2635
+
2636
+ return {
2637
+ payloads,
2638
+ remainder,
2639
+ errors,
2640
+ consumed: text.length,
2641
+ };
2642
+ }
2643
+
2644
+ // ---------------------------------------------------------------------------
2645
+ // Merged: normalizePayloads over typed raw chunks
2646
+ // ---------------------------------------------------------------------------
2647
+
2648
+ function normalizeFinishReason(raw: string): string | undefined {
2649
+ if (!raw) return undefined;
2650
+ const upper = raw.toUpperCase();
2651
+ if (upper.includes("STOP") || upper === "FINISH_REASON_STOP" || upper === "END") return "stop";
2652
+ if (upper.includes("MAX_TOKENS") || upper.includes("LENGTH")) return "length";
2653
+ if (upper.includes("SAFETY") || upper.includes("BLOCK")) return "content_filter";
2654
+ if (upper.includes("RECITATION")) return "content_filter";
2655
+ if (upper.includes("TOOL")) return "tool_calls";
2656
+ if (upper.includes("FINISH_REASON_UNSPECIFIED")) return undefined;
2657
+ return upper.toLowerCase();
2658
+ }
2659
+
2660
+ function extractTextFromAny(obj: unknown): string | undefined {
2661
+ if (!obj) return undefined;
2662
+ if (typeof obj === "string") return obj;
2663
+ if (typeof obj !== "object") return undefined;
2664
+ const o = obj as Record<string, unknown>;
2665
+ if (typeof o.text === "string") return o.text;
2666
+ if (typeof o.content === "string") return o.content;
2667
+ if (typeof o.message === "string") return o.message;
2668
+ if (Array.isArray(o.parts)) {
2669
+ const txt = (o.parts as any[]).map((p) => (typeof p === "string" ? p : ((p as any)?.text ?? ""))).join("");
2670
+ if (txt) return txt;
2671
+ }
2672
+ return undefined;
2673
+ }
2674
+
2675
+ /**
2676
+ * Normalizes heterogeneous raw chunks into {@link NormalizedPayload} arrays:
2677
+ * unwraps `{response:{...}}`, builds virtual candidates for content/parts/text
2678
+ * shapes, classifies parts into text/thinking/function_call/inline_data/
2679
+ * thought_signature kinds and carries finish reasons and usage metadata.
2680
+ *
2681
+ * Named `normalizeRawPayloads` because the string-based {@link normalizePayloads}
2682
+ * keeps its historical name in this module.
2683
+ *
2684
+ * @param rawChunks - typed raw chunks (output of parseSSEPayloads)
2685
+ * @param opts - model fallback and candidate index fallback
2686
+ * @returns normalized payloads
2687
+ */
2688
+ export function normalizeRawPayloads(
2689
+ rawChunks: CloudCodeRawChunk[],
2690
+ opts?: {
2691
+ model?: string;
2692
+ candidateIndexFallback?: number;
2693
+ },
2694
+ ): NormalizedPayload[] {
2695
+ const modelFallback = opts?.model ?? UA_DEFAULT_MODEL;
2696
+ const out: NormalizedPayload[] = [];
2697
+
2698
+ let globalIndex = opts?.candidateIndexFallback ?? 0;
2699
+
2700
+ for (let rawIndex = 0; rawIndex < rawChunks.length; rawIndex++) {
2701
+ const raw = rawChunks[rawIndex]!;
2702
+ if (!raw || typeof raw !== "object") continue;
2703
+
2704
+ // Guarantee unwrapping even if the parser layer was bypassed
2705
+ const unwrapped: CloudCodeRawChunk =
2706
+ (raw as any).response && typeof (raw as any).response === "object"
2707
+ ? ((raw as any).response as CloudCodeRawChunk)
2708
+ : raw;
2709
+
2710
+ const candidatesList: RawCandidate[] = [];
2711
+
2712
+ if (Array.isArray(unwrapped.candidates) && unwrapped.candidates.length > 0) {
2713
+ candidatesList.push(...unwrapped.candidates);
2714
+ } else if (unwrapped.content) {
2715
+ candidatesList.push({ content: unwrapped.content, index: 0 });
2716
+ } else if (Array.isArray(unwrapped.parts) && unwrapped.parts.length > 0) {
2717
+ candidatesList.push({ content: { parts: unwrapped.parts }, index: 0 });
2718
+ } else if (typeof (unwrapped as any).text === "string") {
2719
+ candidatesList.push({ content: { parts: [{ text: (unwrapped as any).text as string }] }, index: 0 });
2720
+ } else if (typeof (unwrapped as any).thought === "string") {
2721
+ candidatesList.push({
2722
+ content: {
2723
+ parts: [
2724
+ {
2725
+ text: (unwrapped as any).thought as string,
2726
+ thought: true,
2727
+ thoughtSignature: (unwrapped as any).thoughtSignature as string | undefined,
2728
+ } as RawPart,
2729
+ ],
2730
+ },
2731
+ index: 0,
2732
+ });
2733
+ } else {
2734
+ const txt = extractTextFromAny(unwrapped);
2735
+ if (txt) {
2736
+ candidatesList.push({ content: { parts: [{ text: txt }] }, index: 0 });
2737
+ } else {
2738
+ continue;
2739
+ }
2740
+ }
2741
+
2742
+ for (let ci = 0; ci < candidatesList.length; ci++) {
2743
+ const cand = candidatesList[ci]!;
2744
+ const candIndex = typeof cand.index === "number" ? cand.index : ci;
2745
+ const finishReasonRaw = (cand.finishReason || (unwrapped as any).finishReason || "") as string;
2746
+ const finishReason = normalizeFinishReason(finishReasonRaw);
2747
+
2748
+ let rawParts: RawPart[] = [];
2749
+
2750
+ if (cand.content?.parts && Array.isArray(cand.content.parts)) {
2751
+ rawParts = cand.content.parts;
2752
+ } else if ((cand as any).parts && Array.isArray((cand as any).parts)) {
2753
+ rawParts = (cand as any).parts;
2754
+ } else if (typeof cand.text === "string" && cand.text.length > 0) {
2755
+ rawParts = [
2756
+ { text: cand.text, thought: !!(cand as any).thought, thoughtSignature: (cand as any).thoughtSignature },
2757
+ ];
2758
+ } else if (cand.content?.text && typeof cand.content.text === "string") {
2759
+ rawParts = [{ text: cand.content.text }];
2760
+ } else if (typeof (cand.content as any)?.text === "string") {
2761
+ rawParts = [{ text: (cand.content as any).text as string }];
2762
+ }
2763
+
2764
+ const normalizedParts: NormalizedPart[] = [];
2765
+ let isThinkingChunk = false;
2766
+
2767
+ for (let pi = 0; pi < rawParts.length; pi++) {
2768
+ const rp = rawParts[pi]!;
2769
+ if (!rp || typeof rp !== "object") continue;
2770
+
2771
+ const txt = typeof rp.text === "string" ? rp.text : extractTextFromAny(rp);
2772
+ const isThought = rp.thought === true || (rp as any).isThought === true;
2773
+ const sig =
2774
+ typeof rp.thoughtSignature === "string"
2775
+ ? rp.thoughtSignature
2776
+ : typeof (rp as any).thought_signature === "string"
2777
+ ? ((rp as any).thought_signature as string)
2778
+ : undefined;
2779
+
2780
+ if (isThought) isThinkingChunk = true;
2781
+
2782
+ if (rp.functionCall && typeof rp.functionCall === "object") {
2783
+ const fc = rp.functionCall as { name: string; args?: unknown; id?: string };
2784
+ const argsStr = typeof fc.args === "string" ? fc.args : JSON.stringify(fc.args ?? {});
2785
+ normalizedParts.push({
2786
+ kind: "function_call",
2787
+ text: "",
2788
+ thought: false,
2789
+ thoughtSignature: sig,
2790
+ functionCall: {
2791
+ name: fc.name || "unknown",
2792
+ args: argsStr,
2793
+ id: fc.id || `call_${globalIndex}_${ci}_${pi}_${Date.now().toString(36)}`,
2794
+ },
2795
+ rawIndex,
2796
+ candidateIndex: candIndex,
2797
+ finishReason,
2798
+ });
2799
+ continue;
2800
+ }
2801
+
2802
+ if (rp.inlineData) {
2803
+ normalizedParts.push({
2804
+ kind: "inline_data",
2805
+ text: "",
2806
+ thought: false,
2807
+ thoughtSignature: sig,
2808
+ rawIndex,
2809
+ candidateIndex: candIndex,
2810
+ finishReason,
2811
+ });
2812
+ continue;
2813
+ }
2814
+
2815
+ if (!txt && sig) {
2816
+ normalizedParts.push({
2817
+ kind: "thought_signature",
2818
+ text: "",
2819
+ thought: true,
2820
+ thoughtSignature: sig,
2821
+ rawIndex,
2822
+ candidateIndex: candIndex,
2823
+ finishReason,
2824
+ });
2825
+ continue;
2826
+ }
2827
+
2828
+ if (!txt && !sig) continue;
2829
+
2830
+ normalizedParts.push({
2831
+ kind: isThought ? "thinking" : "text",
2832
+ text: txt ?? "",
2833
+ thought: isThought,
2834
+ thoughtSignature: sig,
2835
+ rawIndex,
2836
+ candidateIndex: candIndex,
2837
+ finishReason,
2838
+ });
2839
+ }
2840
+
2841
+ const hasFinish = !!finishReason;
2842
+
2843
+ if (normalizedParts.length === 0 && !hasFinish) {
2844
+ // Usage-only chunk
2845
+ if (unwrapped.usageMetadata) {
2846
+ out.push({
2847
+ parts: [],
2848
+ finishReason: undefined,
2849
+ usage: unwrapped.usageMetadata,
2850
+ model: unwrapped.model ?? modelFallback,
2851
+ isThinkingChunk: false,
2852
+ hasFinish: false,
2853
+ raw: unwrapped,
2854
+ });
2855
+ }
2856
+ continue;
2857
+ }
2858
+
2859
+ out.push({
2860
+ parts: normalizedParts,
2861
+ finishReason: finishReason || undefined,
2862
+ usage: unwrapped.usageMetadata,
2863
+ model: unwrapped.model ?? modelFallback,
2864
+ isThinkingChunk,
2865
+ hasFinish,
2866
+ raw: unwrapped,
2867
+ });
2868
+
2869
+ globalIndex++;
2870
+ }
2871
+ }
2872
+
2873
+ return out;
2874
+ }
2875
+
2876
+ // ---------------------------------------------------------------------------
2877
+ // Merged: stateful OpenAI / Anthropic transforms over normalized payloads
2878
+ // ---------------------------------------------------------------------------
2879
+
2880
+ function newCompletionId(): string {
2881
+ try {
2882
+ const uuid = randomUUID();
2883
+ return `chatcmpl-${uuid.replace(/-/g, "").slice(0, 29)}`;
2884
+ } catch {
2885
+ return `chatcmpl-${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`;
2886
+ }
2887
+ }
2888
+
2889
+ /**
2890
+ * Creates the mutable state shared by the merged transformers.
2891
+ *
2892
+ * @param model - model id echoed back to clients
2893
+ * @param completionId - optional fixed completion id
2894
+ */
2895
+ export function createInitialStreamingState(model: string, completionId?: string): StreamingState {
2896
+ return {
2897
+ completionId: completionId ?? newCompletionId(),
2898
+ model,
2899
+ created: Math.floor(Date.now() / 1000),
2900
+ sentRole: false,
2901
+ accumulatedText: "",
2902
+ accumulatedThinking: "",
2903
+ lastFinishReason: undefined,
2904
+ insideThinking: false,
2905
+ anthropicBlockIndex: 0,
2906
+ anthropicBlockKinds: new Map(),
2907
+ toolCallIdCounter: 0,
2908
+ pendingSignatures: [],
2909
+ };
2910
+ }
2911
+
2912
+ /**
2913
+ * Transforms normalized payloads into OpenAI chat.completion.chunk objects.
2914
+ * Emits a leading role chunk, routes thinking into reasoning_content/reasoning,
2915
+ * caches thought signatures in the global entry-based LRU and streams tool calls.
2916
+ *
2917
+ * @param payloads - normalized payloads
2918
+ * @param state - mutable streaming state updated in place
2919
+ * @returns OpenAI chunk objects (serialize with serializeOpenAIChunksToSSE)
2920
+ */
2921
+ export function transformToOpenAIChunks(payloads: NormalizedPayload[], state: StreamingState): OpenAIChatChunk[] {
2922
+ const out: OpenAIChatChunk[] = [];
2923
+
2924
+ for (const p of payloads) {
2925
+ if (p.finishReason) state.lastFinishReason = p.finishReason;
2926
+
2927
+ if (p.parts.length === 0 && p.usage) {
2928
+ continue;
2929
+ }
2930
+
2931
+ for (const part of p.parts) {
2932
+ if (!state.sentRole) {
2933
+ out.push({
2934
+ id: state.completionId,
2935
+ object: "chat.completion.chunk",
2936
+ created: state.created,
2937
+ model: state.model,
2938
+ choices: [
2939
+ {
2940
+ index: part.candidateIndex,
2941
+ delta: { role: "assistant" },
2942
+ finish_reason: null,
2943
+ },
2944
+ ],
2945
+ });
2946
+ state.sentRole = true;
2947
+ }
2948
+
2949
+ if (part.thoughtSignature) {
2950
+ try {
2951
+ globalThinkingSignatureLRUCache.addFromPayload({
2952
+ signature: part.thoughtSignature,
2953
+ thinkingText: part.kind === "thinking" ? part.text : state.accumulatedThinking.slice(-2000),
2954
+ model: p.model ?? state.model,
2955
+ turnId: `${state.completionId}_${part.candidateIndex}`,
2956
+ });
2957
+ } catch {
2958
+ // Cache failures must never break streaming
2959
+ }
2960
+ if (!state.pendingSignatures.includes(part.thoughtSignature)) {
2961
+ state.pendingSignatures.push(part.thoughtSignature);
2962
+ if (state.pendingSignatures.length > 50) state.pendingSignatures.shift();
2963
+ }
2964
+ }
2965
+
2966
+ if (part.kind === "function_call" && part.functionCall) {
2967
+ out.push({
2968
+ id: state.completionId,
2969
+ object: "chat.completion.chunk",
2970
+ created: state.created,
2971
+ model: state.model,
2972
+ choices: [
2973
+ {
2974
+ index: part.candidateIndex,
2975
+ delta: {
2976
+ tool_calls: [
2977
+ {
2978
+ index: state.toolCallIdCounter++,
2979
+ id: part.functionCall.id,
2980
+ type: "function",
2981
+ function: {
2982
+ name: part.functionCall.name,
2983
+ arguments: part.functionCall.args,
2984
+ },
2985
+ },
2986
+ ],
2987
+ },
2988
+ finish_reason: null,
2989
+ },
2990
+ ],
2991
+ });
2992
+ continue;
2993
+ }
2994
+
2995
+ if (part.kind === "thinking" || part.thought) {
2996
+ if (!state.insideThinking) {
2997
+ state.insideThinking = true;
2998
+ }
2999
+ state.accumulatedThinking += part.text;
3000
+
3001
+ out.push({
3002
+ id: state.completionId,
3003
+ object: "chat.completion.chunk",
3004
+ created: state.created,
3005
+ model: state.model,
3006
+ choices: [
3007
+ {
3008
+ index: part.candidateIndex,
3009
+ delta: {
3010
+ reasoning_content: part.text,
3011
+ reasoning: part.text,
3012
+ },
3013
+ finish_reason: null,
3014
+ },
3015
+ ],
3016
+ });
3017
+ } else if (part.kind === "text") {
3018
+ if (state.insideThinking) {
3019
+ state.insideThinking = false;
3020
+ }
3021
+ state.accumulatedText += part.text;
3022
+
3023
+ out.push({
3024
+ id: state.completionId,
3025
+ object: "chat.completion.chunk",
3026
+ created: state.created,
3027
+ model: state.model,
3028
+ choices: [
3029
+ {
3030
+ index: part.candidateIndex,
3031
+ delta: {
3032
+ content: part.text,
3033
+ },
3034
+ finish_reason: null,
3035
+ },
3036
+ ],
3037
+ });
3038
+ } else if (part.kind === "thought_signature") {
3039
+ continue;
3040
+ }
3041
+ }
3042
+
3043
+ if (p.hasFinish && p.finishReason) {
3044
+ // Finish reason recorded; final emission handled by synthetic finish helpers
3045
+ }
3046
+ }
3047
+
3048
+ return out;
3049
+ }
3050
+
3051
+ /**
3052
+ * Builds the final OpenAI finish chunk (with optional usage mapping) once the
3053
+ * stream ends. The caller serializes it and appends the `[DONE]` marker.
3054
+ *
3055
+ * @param state - streaming state carrying the last observed finish reason
3056
+ * @param reason - fallback finish reason
3057
+ * @param usage - usage metadata captured from the stream
3058
+ */
3059
+ export function createSyntheticFinishChunks(
3060
+ state: StreamingState,
3061
+ reason: string = "stop",
3062
+ usage?: NormalizedPayload["usage"],
3063
+ ): OpenAIChatChunk[] {
3064
+ const finish = state.lastFinishReason ?? reason;
3065
+ const normalized = normalizeFinishReason(finish) ?? "stop";
3066
+
3067
+ const chunk: OpenAIChatChunk = {
3068
+ id: state.completionId,
3069
+ object: "chat.completion.chunk",
3070
+ created: state.created,
3071
+ model: state.model,
3072
+ choices: [
3073
+ {
3074
+ index: 0,
3075
+ delta: {},
3076
+ finish_reason: normalized,
3077
+ },
3078
+ ],
3079
+ };
3080
+
3081
+ if (usage) {
3082
+ (chunk as any).usage = {
3083
+ prompt_tokens: usage.promptTokenCount,
3084
+ completion_tokens: usage.candidatesTokenCount ?? usage.totalTokenCount,
3085
+ total_tokens: usage.totalTokenCount,
3086
+ completion_tokens_details: usage.thoughtsTokenCount ? { reasoning_tokens: usage.thoughtsTokenCount } : undefined,
3087
+ };
3088
+ }
3089
+
3090
+ return [chunk];
3091
+ }
3092
+
3093
+ /**
3094
+ * Serializes OpenAI chunks into SSE data lines ready for the wire.
3095
+ *
3096
+ * @param chunks - OpenAI chunk objects
3097
+ * @returns array of `data: {...}\n\n` strings
3098
+ */
3099
+ export function serializeOpenAIChunksToSSE(chunks: OpenAIChatChunk[]): string[] {
3100
+ const lines: string[] = [];
3101
+ for (const c of chunks) {
3102
+ lines.push(`data: ${JSON.stringify(c)}\n\n`);
3103
+ }
3104
+ return lines;
3105
+ }
3106
+
3107
+ /**
3108
+ * Transforms normalized payloads into Anthropic Messages SSE lines:
3109
+ * message_start, content_block_start/delta/stop and message_delta/stop.
3110
+ * Opens separate blocks when thinking interleaves with text so block ordering
3111
+ * satisfies Claude validation.
3112
+ *
3113
+ * @param payloads - normalized payloads
3114
+ * @param state - mutable streaming state updated in place
3115
+ * @returns formatted SSE strings (`event: ...\ndata: {...}\n\n`)
3116
+ */
3117
+ export function transformToAnthropicEvents(payloads: NormalizedPayload[], state: StreamingState): string[] {
3118
+ const sseLines: string[] = [];
3119
+
3120
+ const emit = (event: string, data: unknown): void => {
3121
+ sseLines.push(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
3122
+ };
3123
+
3124
+ if (state.anthropicBlockIndex === 0 && !state.sentRole) {
3125
+ emit("message_start", {
3126
+ type: "message_start",
3127
+ message: {
3128
+ id: state.completionId.replace("chatcmpl-", "msg_"),
3129
+ type: "message",
3130
+ role: "assistant",
3131
+ content: [],
3132
+ model: state.model,
3133
+ stop_reason: null,
3134
+ stop_sequence: null,
3135
+ usage: { input_tokens: 0, output_tokens: 0 },
3136
+ },
3137
+ });
3138
+ state.sentRole = true;
3139
+ }
3140
+
3141
+ for (const p of payloads) {
3142
+ if (p.finishReason) state.lastFinishReason = p.finishReason;
3143
+
3144
+ for (const part of p.parts) {
3145
+ if (part.thoughtSignature) {
3146
+ try {
3147
+ globalThinkingSignatureLRUCache.addFromPayload({
3148
+ signature: part.thoughtSignature,
3149
+ thinkingText: part.text || state.accumulatedThinking.slice(-2000),
3150
+ model: p.model ?? state.model,
3151
+ turnId: `${state.completionId}_${part.candidateIndex}`,
3152
+ });
3153
+ } catch {
3154
+ /* the guarded best-effort operation falls through: the outer flow owns the failure */
3155
+ }
3156
+ }
3157
+
3158
+ if (part.kind === "function_call" && part.functionCall) {
3159
+ const blockIndex = state.anthropicBlockIndex++;
3160
+ state.anthropicBlockKinds.set(blockIndex, "function_call");
3161
+
3162
+ emit("content_block_start", {
3163
+ type: "content_block_start",
3164
+ index: blockIndex,
3165
+ content_block: {
3166
+ type: "tool_use",
3167
+ id: part.functionCall.id,
3168
+ name: part.functionCall.name,
3169
+ input: {},
3170
+ },
3171
+ });
3172
+
3173
+ emit("content_block_delta", {
3174
+ type: "content_block_delta",
3175
+ index: blockIndex,
3176
+ delta: {
3177
+ type: "input_json_delta",
3178
+ partial_json: part.functionCall.args,
3179
+ },
3180
+ });
3181
+
3182
+ emit("content_block_stop", {
3183
+ type: "content_block_stop",
3184
+ index: blockIndex,
3185
+ });
3186
+ continue;
3187
+ }
3188
+
3189
+ const isThinking = part.kind === "thinking" || part.thought;
3190
+ const wantedKind: NormalizedPartKind = isThinking ? "thinking" : "text";
3191
+
3192
+ let currentBlockIndex = state.anthropicBlockIndex;
3193
+ const currentKind = state.anthropicBlockKinds.get(currentBlockIndex - 1);
3194
+
3195
+ const needNewBlock =
3196
+ currentBlockIndex === 0 ||
3197
+ (currentKind !== undefined && currentKind !== wantedKind && currentKind !== part.kind) ||
3198
+ state.insideThinking !== isThinking;
3199
+
3200
+ if (needNewBlock) {
3201
+ if (currentBlockIndex > 0 && currentKind !== undefined) {
3202
+ if (currentKind !== wantedKind) {
3203
+ emit("content_block_stop", {
3204
+ type: "content_block_stop",
3205
+ index: currentBlockIndex - 1,
3206
+ });
3207
+ }
3208
+ }
3209
+
3210
+ if (currentKind !== wantedKind || currentBlockIndex === 0) {
3211
+ const idx = state.anthropicBlockIndex;
3212
+ state.anthropicBlockKinds.set(idx, wantedKind);
3213
+ state.anthropicBlockIndex++;
3214
+
3215
+ if (isThinking) {
3216
+ emit("content_block_start", {
3217
+ type: "content_block_start",
3218
+ index: idx,
3219
+ content_block: { type: "thinking", thinking: "" },
3220
+ });
3221
+ state.insideThinking = true;
3222
+ } else {
3223
+ emit("content_block_start", {
3224
+ type: "content_block_start",
3225
+ index: idx,
3226
+ content_block: { type: "text", text: "" },
3227
+ });
3228
+ state.insideThinking = false;
3229
+ }
3230
+ currentBlockIndex = idx;
3231
+ }
3232
+ }
3233
+
3234
+ if (isThinking) {
3235
+ state.accumulatedThinking += part.text;
3236
+ const idx = state.anthropicBlockIndex - 1;
3237
+ emit("content_block_delta", {
3238
+ type: "content_block_delta",
3239
+ index: idx,
3240
+ delta: { type: "thinking_delta", thinking: part.text },
3241
+ });
3242
+ } else if (part.kind === "text") {
3243
+ state.accumulatedText += part.text;
3244
+ const idx = state.anthropicBlockIndex - 1;
3245
+ emit("content_block_delta", {
3246
+ type: "content_block_delta",
3247
+ index: idx,
3248
+ delta: { type: "text_delta", text: part.text },
3249
+ });
3250
+ }
3251
+ }
3252
+ }
3253
+
3254
+ return sseLines;
3255
+ }
3256
+
3257
+ /**
3258
+ * Emits the final Anthropic events (block stop, message_delta with stop_reason,
3259
+ * message_stop) once the stream ends.
3260
+ *
3261
+ * @param state - streaming state
3262
+ * @param reason - fallback stop reason
3263
+ * @param usage - usage metadata captured from the stream
3264
+ * @returns formatted SSE strings
3265
+ */
3266
+ export function createAnthropicSyntheticFinish(
3267
+ state: StreamingState,
3268
+ reason: string = "end_turn",
3269
+ usage?: NormalizedPayload["usage"],
3270
+ ): string[] {
3271
+ const lines: string[] = [];
3272
+
3273
+ const emit = (event: string, data: unknown) => {
3274
+ lines.push(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
3275
+ };
3276
+
3277
+ if (state.anthropicBlockIndex > 0) {
3278
+ const lastIdx = state.anthropicBlockIndex - 1;
3279
+ if (state.anthropicBlockKinds.has(lastIdx)) {
3280
+ emit("content_block_stop", { type: "content_block_stop", index: lastIdx });
3281
+ }
3282
+ }
3283
+
3284
+ emit("message_delta", {
3285
+ type: "message_delta",
3286
+ delta: {
3287
+ stop_reason: state.lastFinishReason ? mapFinishToAnthropic(state.lastFinishReason) : reason,
3288
+ stop_sequence: null,
3289
+ },
3290
+ usage: usage
3291
+ ? {
3292
+ output_tokens: usage.candidatesTokenCount ?? usage.totalTokenCount ?? 0,
3293
+ }
3294
+ : { output_tokens: 0 },
3295
+ });
3296
+
3297
+ emit("message_stop", { type: "message_stop" });
3298
+
3299
+ return lines;
3300
+ }
3301
+
3302
+ function mapFinishToAnthropic(finish: string): string {
3303
+ const n = finish.toLowerCase();
3304
+ if (n === "stop" || n.includes("stop")) return "end_turn";
3305
+ if (n === "length" || n.includes("max")) return "max_tokens";
3306
+ if (n === "tool_calls") return "tool_use";
3307
+ if (n === "content_filter") return "end_turn";
3308
+ return "end_turn";
3309
+ }
3310
+
3311
+ // ---------------------------------------------------------------------------
3312
+ // Merged: incremental SSE parser class + high-level transformer
3313
+ // ---------------------------------------------------------------------------
3314
+
3315
+ /**
3316
+ * Stateful incremental SSE parser wrapping parseSSEPayloads with streaming
3317
+ * UTF-8 decoding and byte accounting.
3318
+ */
3319
+ export class IncrementalSSEParser {
3320
+ private buffer: string;
3321
+ private readonly decoder: { decode(input?: Uint8Array, options?: { stream?: boolean }): string };
3322
+ private totalBytes: number;
3323
+
3324
+ constructor() {
3325
+ this.buffer = "";
3326
+ this.decoder = new TextDecoder("utf-8", { fatal: false });
3327
+ this.totalBytes = 0;
3328
+ }
3329
+
3330
+ /**
3331
+ * Pushes an incremental chunk (string, Uint8Array or Buffer), tolerating
3332
+ * splits anywhere, and returns payloads parsed so far.
3333
+ */
3334
+ push(chunk: string | Uint8Array | Buffer): CloudCodeRawChunk[] {
3335
+ let text: string;
3336
+ if (typeof chunk === "string") {
3337
+ text = chunk;
3338
+ } else if (chunk instanceof Uint8Array || Buffer.isBuffer(chunk)) {
3339
+ text = this.decoder.decode(chunk, { stream: true });
3340
+ } else {
3341
+ text = String(chunk);
3342
+ }
3343
+
3344
+ this.totalBytes += text.length;
3345
+ const result = parseSSEPayloads(text, this.buffer);
3346
+ this.buffer = result.remainder;
3347
+ return result.payloads;
3348
+ }
3349
+
3350
+ /**
3351
+ * Final flush: decodes any pending bytes and attempts to parse the remainder.
3352
+ */
3353
+ flush(): CloudCodeRawChunk[] {
3354
+ if (!this.buffer.trim()) {
3355
+ this.buffer = "";
3356
+ return [];
3357
+ }
3358
+ try {
3359
+ const finalText = this.decoder.decode();
3360
+ if (finalText) this.buffer += finalText;
3361
+ } catch {
3362
+ /* the guarded best-effort operation falls through: the outer flow owns the failure */
3363
+ }
3364
+ const result = parseSSEPayloads("", this.buffer);
3365
+ this.buffer = result.remainder;
3366
+ if (result.payloads.length > 0) {
3367
+ this.buffer = "";
3368
+ }
3369
+ return result.payloads;
3370
+ }
3371
+
3372
+ get remainder(): string {
3373
+ return this.buffer;
3374
+ }
3375
+
3376
+ get bytesReceived(): number {
3377
+ return this.totalBytes;
3378
+ }
3379
+
3380
+ reset(): void {
3381
+ this.buffer = "";
3382
+ this.totalBytes = 0;
3383
+ }
3384
+ }
3385
+
3386
+ /** Output format selector for {@link CloudCodeStreamingTransformer}. */
3387
+ export type OutputFormat = "openai" | "anthropic" | "raw";
3388
+
3389
+ /**
3390
+ * Options for the high-level streaming transformer.
3391
+ */
3392
+ export interface StreamingTransformerOptions {
3393
+ /** Target model id. */
3394
+ model: string;
3395
+ /** Wire format, openai by default. */
3396
+ format?: OutputFormat;
3397
+ /** Optional fixed completion id. */
3398
+ completionId?: string;
3399
+ /** External entry-based LRU cache (defaults to the global singleton). */
3400
+ thinkingCache?: ThinkingSignatureLRUCache;
3401
+ /** Inject a synthetic finish when the stream ends without one? Default true. */
3402
+ injectSyntheticFinish?: boolean;
3403
+ /** Append `[DONE]` after OpenAI SSE? Default true. */
3404
+ includeDoneMarker?: boolean;
3405
+ /** Callback invoked for every transformed SSE line. */
3406
+ onChunk?: (sseLine: string, state: StreamingState) => void;
3407
+ }
3408
+
3409
+ /**
3410
+ * High-level cloudcode-pa SSE transformer: push raw network chunks in, receive
3411
+ * serialized SSE lines out in the configured format, with synthetic finish
3412
+ * injection and `[DONE]` handling on flush.
3413
+ */
3414
+ export class CloudCodeStreamingTransformer {
3415
+ private readonly parser: IncrementalSSEParser;
3416
+ private readonly state: StreamingState;
3417
+ private readonly options: Required<StreamingTransformerOptions>;
3418
+ private sentFinished: boolean;
3419
+ private lastUsage?: CloudCodeRawChunk["usageMetadata"];
3420
+
3421
+ constructor(opts: StreamingTransformerOptions) {
3422
+ this.parser = new IncrementalSSEParser();
3423
+ this.state = createInitialStreamingState(opts.model, opts.completionId);
3424
+ this.options = {
3425
+ model: opts.model,
3426
+ format: opts.format ?? "openai",
3427
+ completionId: this.state.completionId,
3428
+ thinkingCache: opts.thinkingCache ?? globalThinkingSignatureLRUCache,
3429
+ injectSyntheticFinish: opts.injectSyntheticFinish ?? true,
3430
+ includeDoneMarker: opts.includeDoneMarker ?? true,
3431
+ onChunk: opts.onChunk ?? (() => {}),
3432
+ } as Required<StreamingTransformerOptions>;
3433
+ this.sentFinished = false;
3434
+ }
3435
+
3436
+ get streamingState(): StreamingState {
3437
+ return this.state;
3438
+ }
3439
+
3440
+ /**
3441
+ * Transforms one raw chunk into output SSE lines.
3442
+ */
3443
+ transformChunk(rawChunk: string | Uint8Array): string[] {
3444
+ const parsed = this.parser.push(rawChunk);
3445
+ if (parsed.length === 0) return [];
3446
+
3447
+ for (const r of parsed) {
3448
+ if ((r as any).usageMetadata) this.lastUsage = (r as any).usageMetadata;
3449
+ }
3450
+
3451
+ const normalized = normalizeRawPayloads(parsed, { model: this.options.model });
3452
+
3453
+ let sseLines: string[] = [];
3454
+
3455
+ if (this.options.format === "openai") {
3456
+ const openAIChunks = transformToOpenAIChunks(normalized, this.state);
3457
+ sseLines = serializeOpenAIChunksToSSE(openAIChunks);
3458
+ } else if (this.options.format === "anthropic") {
3459
+ sseLines = transformToAnthropicEvents(normalized, this.state);
3460
+ } else {
3461
+ for (const n of normalized) {
3462
+ sseLines.push(`data: ${JSON.stringify(n)}\n\n`);
3463
+ }
3464
+ }
3465
+
3466
+ for (const line of sseLines) {
3467
+ try {
3468
+ this.options.onChunk(line, this.state);
3469
+ } catch {
3470
+ /* the guarded best-effort operation falls through: the outer flow owns the failure */
3471
+ }
3472
+ }
3473
+
3474
+ return sseLines;
3475
+ }
3476
+
3477
+ /**
3478
+ * Flushes buffered input and injects the synthetic finish plus `[DONE]`.
3479
+ */
3480
+ flush(): string[] {
3481
+ const remainingParsed = this.parser.flush();
3482
+ const extraLines: string[] = [];
3483
+
3484
+ if (remainingParsed.length > 0) {
3485
+ for (const r of remainingParsed) {
3486
+ if ((r as any).usageMetadata) this.lastUsage = (r as any).usageMetadata;
3487
+ }
3488
+ const normalized = normalizeRawPayloads(remainingParsed, { model: this.options.model });
3489
+ if (this.options.format === "openai") {
3490
+ const chunks = transformToOpenAIChunks(normalized, this.state);
3491
+ extraLines.push(...serializeOpenAIChunksToSSE(chunks));
3492
+ } else if (this.options.format === "anthropic") {
3493
+ extraLines.push(...transformToAnthropicEvents(normalized, this.state));
3494
+ } else {
3495
+ for (const n of normalized) extraLines.push(`data: ${JSON.stringify(n)}\n\n`);
3496
+ }
3497
+ }
3498
+
3499
+ if (!this.sentFinished && this.options.injectSyntheticFinish) {
3500
+ const finishLines = this.createFinish();
3501
+ extraLines.push(...finishLines);
3502
+ this.sentFinished = true;
3503
+ }
3504
+
3505
+ if (this.options.format === "openai" && this.options.includeDoneMarker) {
3506
+ extraLines.push("data: [DONE]\n\n");
3507
+ }
3508
+
3509
+ for (const line of extraLines) {
3510
+ try {
3511
+ this.options.onChunk(line, this.state);
3512
+ } catch {
3513
+ /* the guarded best-effort operation falls through: the outer flow owns the failure */
3514
+ }
3515
+ }
3516
+
3517
+ return extraLines;
3518
+ }
3519
+
3520
+ private createFinish(): string[] {
3521
+ if (this.options.format === "openai") {
3522
+ const chunks = createSyntheticFinishChunks(this.state, "stop", this.lastUsage);
3523
+ return serializeOpenAIChunksToSSE(chunks);
3524
+ } else if (this.options.format === "anthropic") {
3525
+ return createAnthropicSyntheticFinish(this.state, "end_turn", this.lastUsage);
3526
+ } else {
3527
+ return [
3528
+ `data: ${JSON.stringify({ finishReason: this.state.lastFinishReason ?? "stop", usage: this.lastUsage })}\n\n`,
3529
+ ];
3530
+ }
3531
+ }
3532
+
3533
+ reset(model?: string): void {
3534
+ this.parser.reset();
3535
+ this.sentFinished = false;
3536
+ this.lastUsage = undefined;
3537
+ this.state.accumulatedText = "";
3538
+ this.state.accumulatedThinking = "";
3539
+ this.state.sentRole = false;
3540
+ this.state.insideThinking = false;
3541
+ this.state.lastFinishReason = undefined;
3542
+ this.state.anthropicBlockIndex = 0;
3543
+ this.state.anthropicBlockKinds.clear();
3544
+ this.state.toolCallIdCounter = 0;
3545
+ this.state.pendingSignatures = [];
3546
+ if (model) {
3547
+ this.state.model = model;
3548
+ (this.options as any).model = model;
3549
+ }
3550
+ this.state.completionId = newCompletionId();
3551
+ this.state.created = Math.floor(Date.now() / 1000);
3552
+ }
3553
+ }
3554
+
3555
+ // ---------------------------------------------------------------------------
3556
+ // Merged: resilient fetch + transformed stream
3557
+ // ---------------------------------------------------------------------------
3558
+
3559
+ /**
3560
+ * Options for {@link resilientFetch}.
3561
+ */
3562
+ export interface ResilientFetchOptions {
3563
+ /** Max attempts including the first. */
3564
+ maxRetries?: number;
3565
+ /** Base delay in ms. */
3566
+ baseDelayMs?: number;
3567
+ /** Max delay in ms. */
3568
+ maxDelayMs?: number;
3569
+ /** Apply jitter? Default true. */
3570
+ jitter?: boolean;
3571
+ /** Status codes that trigger a retry. */
3572
+ retryOn?: number[];
3573
+ /** External abort signal. */
3574
+ signal?: AbortSignal;
3575
+ /** Per-attempt timeout in ms. */
3576
+ timeoutMs?: number;
3577
+ }
3578
+
3579
+ const DEFAULT_RETRY_ON = [429, 500, 502, 503, 504];
3580
+
3581
+ /**
3582
+ * Computes exponential delay with optional multiplicative jitter (0.7x-1.3x).
3583
+ *
3584
+ * @param attempt - zero-based attempt index
3585
+ * @param base - base delay ms
3586
+ * @param max - maximum delay ms
3587
+ * @param jitter - apply jitter
3588
+ */
3589
+ export function computeExponentialDelay(attempt: number, base: number, max: number, jitter: boolean): number {
3590
+ let delay = base * Math.pow(2, attempt);
3591
+ if (delay > max) delay = max;
3592
+ if (jitter) {
3593
+ const jitterFactor = 0.7 + Math.random() * 0.6;
3594
+ delay = Math.floor(delay * jitterFactor);
3595
+ }
3596
+ return delay;
3597
+ }
3598
+
3599
+ function sleepMs(ms: number): Promise<void> {
3600
+ return new Promise((resolve) => setTimeout(resolve, ms));
3601
+ }
3602
+
3603
+ /**
3604
+ * Fetches streamGenerateContent with exponential backoff, per-attempt timeout
3605
+ * and external-signal support, keeping Gemini CLI bypass headers intact.
3606
+ *
3607
+ * Equivalent sibling of {@link fetchWithExponentialRetry}; both are kept.
3608
+ *
3609
+ * @param url - endpoint URL
3610
+ * @param init - RequestInit for fetch
3611
+ * @param opts - retry options
3612
+ * @returns Response with body stream
3613
+ */
3614
+ export async function resilientFetch(
3615
+ url: string,
3616
+ init: RequestInit,
3617
+ opts: ResilientFetchOptions = {},
3618
+ ): Promise<Response> {
3619
+ const maxRetries = opts.maxRetries ?? 3;
3620
+ const baseDelay = opts.baseDelayMs ?? 500;
3621
+ const maxDelay = opts.maxDelayMs ?? 5000;
3622
+ const jitter = opts.jitter ?? true;
3623
+ const retryOn = opts.retryOn ?? DEFAULT_RETRY_ON;
3624
+
3625
+ let lastError: Error | null = null;
3626
+
3627
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
3628
+ const timeoutMs = opts.timeoutMs ?? 120_000;
3629
+ const ac = new AbortController();
3630
+ const timer = setTimeout(() => ac.abort(), timeoutMs);
3631
+
3632
+ const onAbortExternal = () => ac.abort();
3633
+ if (opts.signal) {
3634
+ if (opts.signal.aborted) {
3635
+ clearTimeout(timer);
3636
+ throw new Error("resilientFetch aborted by external signal");
3637
+ }
3638
+ opts.signal.addEventListener("abort", onAbortExternal, { once: true });
3639
+ }
3640
+
3641
+ try {
3642
+ const mergedInit: RequestInit = {
3643
+ ...init,
3644
+ signal: ac.signal,
3645
+ };
3646
+
3647
+ const res = await fetch(url, mergedInit);
3648
+ clearTimeout(timer);
3649
+ if (opts.signal) opts.signal.removeEventListener("abort", onAbortExternal);
3650
+
3651
+ if (res.ok) return res;
3652
+
3653
+ if (isRetryableNetworkStatus(res.status, { retryableStatus: retryOn }) && attempt < maxRetries) {
3654
+ lastError = new Error(`retryable status ${res.status}`);
3655
+ try {
3656
+ await res.text();
3657
+ } catch {
3658
+ /* the guarded best-effort operation falls through: the outer flow owns the failure */
3659
+ }
3660
+ const delay = computeExponentialDelay(attempt, baseDelay, maxDelay, jitter);
3661
+ await sleepMs(delay);
3662
+ continue;
3663
+ }
3664
+
3665
+ return res;
3666
+ } catch (e) {
3667
+ clearTimeout(timer);
3668
+ if (opts.signal) opts.signal.removeEventListener("abort", onAbortExternal);
3669
+ const err = e as Error & { name?: string };
3670
+ lastError = err;
3671
+
3672
+ // AbortError retries only when it came from the internal timeout
3673
+ const isAbortTimeout = err.name === "AbortError";
3674
+ if (isAbortTimeout && attempt < maxRetries) {
3675
+ const delay = computeExponentialDelay(attempt, baseDelay, maxDelay, jitter);
3676
+ await sleepMs(delay);
3677
+ continue;
3678
+ }
3679
+
3680
+ if (attempt < maxRetries) {
3681
+ const delay = computeExponentialDelay(attempt, baseDelay, maxDelay, jitter);
3682
+ await sleepMs(delay);
3683
+ continue;
3684
+ }
3685
+
3686
+ throw lastError;
3687
+ }
3688
+ }
3689
+
3690
+ throw lastError ?? new Error("resilientFetch failed after retries");
3691
+ }
3692
+
3693
+ /**
3694
+ * High-level wrapper consuming streamGenerateContent with auto-recovery and
3695
+ * yielding SSE strings in the configured format. Tolerant to mid-stream breaks
3696
+ * (synthetic finish), missing finish reasons and UTF-8/JSON splits.
3697
+ *
3698
+ * @example
3699
+ * for await (const sseLine of createResilientTransformedStream(url, init, { model, format: "openai" })) {
3700
+ * res.write(sseLine);
3701
+ * }
3702
+ */
3703
+ export async function* createResilientTransformedStream(
3704
+ url: string,
3705
+ init: RequestInit,
3706
+ transformerOpts: StreamingTransformerOptions,
3707
+ fetchOpts: ResilientFetchOptions = {},
3708
+ ): AsyncGenerator<string, void, unknown> {
3709
+ const transformer = new CloudCodeStreamingTransformer(transformerOpts);
3710
+
3711
+ const response = await resilientFetch(url, init, fetchOpts);
3712
+
3713
+ if (!response.ok) {
3714
+ let errBody = "";
3715
+ try {
3716
+ errBody = await response.text();
3717
+ } catch {
3718
+ /* the guarded best-effort operation falls through: the outer flow owns the failure */
3719
+ }
3720
+ throw new Error(`cloudcode-pa stream failed ${response.status}: ${errBody.slice(0, 500)}`);
3721
+ }
3722
+
3723
+ if (!response.body) {
3724
+ yield* transformer.flush();
3725
+ return;
3726
+ }
3727
+
3728
+ const reader = response.body.getReader();
3729
+ const decoder = new TextDecoder("utf-8", { fatal: false });
3730
+
3731
+ try {
3732
+ while (true) {
3733
+ const { done, value } = await reader.read();
3734
+ if (done) break;
3735
+ const sseLines = transformer.transformChunk(value);
3736
+ for (const line of sseLines) yield line;
3737
+ }
3738
+
3739
+ const finalLines = transformer.flush();
3740
+ for (const line of finalLines) yield line;
3741
+ } catch (_e) {
3742
+ // Mid-stream failure: degrade gracefully by flushing what we have
3743
+ try {
3744
+ const finalLines = transformer.flush();
3745
+ for (const line of finalLines) yield line;
3746
+ } catch {
3747
+ // ignore
3748
+ }
3749
+ } finally {
3750
+ try {
3751
+ await reader.cancel();
3752
+ } catch {
3753
+ /* the guarded best-effort operation falls through: the outer flow owns the failure */
3754
+ }
3755
+ }
3756
+ }
3757
+
3758
+ // ---------------------------------------------------------------------------
3759
+ // Merged: bypass headers + deterministic session id
3760
+ // ---------------------------------------------------------------------------
3761
+
3762
+ /**
3763
+ * Options for {@link buildGeminiCliBypassHeaders}.
3764
+ */
3765
+ export interface BypassHeadersOptions {
3766
+ model?: string;
3767
+ accessToken?: string;
3768
+ projectId?: string;
3769
+ includeAuth?: boolean;
3770
+ }
3771
+
3772
+ /**
3773
+ * Builds headers fully compatible with official Gemini CLI traffic against
3774
+ * cloudcode-pa, including the X-Goog-Request-Params project binding.
3775
+ *
3776
+ * @param opts - auth/project options
3777
+ * @returns headers record ready for fetch
3778
+ */
3779
+ export function buildGeminiCliBypassHeaders(opts: BypassHeadersOptions = {}): Record<string, string> {
3780
+ const h: Record<string, string> = {
3781
+ "User-Agent": GEMINI_CLI_USER_AGENT,
3782
+ "X-Goog-Api-Client": X_GOOG_API_CLIENT,
3783
+ "Client-Metadata": CLIENT_METADATA_RAW,
3784
+ "Content-Type": "application/json",
3785
+ Accept: "text/event-stream, application/json",
3786
+ "X-Goog-Request-Params": `project=${opts.projectId ?? PROJECT_FALLBACK}`,
3787
+ };
3788
+
3789
+ if (opts.includeAuth && opts.accessToken) {
3790
+ h.Authorization = `Bearer ${opts.accessToken}`;
3791
+ }
3792
+
3793
+ return h;
3794
+ }
3795
+
3796
+ /**
3797
+ * Deterministic session id (FNV-1a hex) derived from directory, hostname, cwd
3798
+ * and pid. Streaming-module variant kept alongside the request-module version.
3799
+ *
3800
+ * @param directory - optional directory seed
3801
+ * @returns 8-char hex digest
3802
+ */
3803
+ export function buildHostSessionId(directory?: string): string {
3804
+ const base = directory ?? `${os.hostname()}|${process.cwd()}|${process.pid}`;
3805
+ return fnv1a32HexBytes(base);
3806
+ }
3807
+
3808
+ // ---------------------------------------------------------------------------
3809
+ // Merged: plugin utilities
3810
+ // ---------------------------------------------------------------------------
3811
+
3812
+ /**
3813
+ * Detects whether any payload carries thinking content.
3814
+ *
3815
+ * @param payloads - normalized payloads
3816
+ */
3817
+ export function hasThinkingContent(payloads: NormalizedPayload[]): boolean {
3818
+ return payloads.some((p) => p.isThinkingChunk || p.parts.some((pt) => pt.thought));
3819
+ }
3820
+
3821
+ /**
3822
+ * Collects all distinct thinking signatures for multi-turn replay.
3823
+ *
3824
+ * @param payloads - normalized payloads
3825
+ * @returns unique signature strings
3826
+ */
3827
+ export function extractThinkingSignatures(payloads: NormalizedPayload[]): string[] {
3828
+ const sigs: string[] = [];
3829
+ for (const p of payloads) {
3830
+ for (const part of p.parts) {
3831
+ if (part.thoughtSignature && !sigs.includes(part.thoughtSignature)) sigs.push(part.thoughtSignature);
3832
+ }
3833
+ }
3834
+ return sigs;
3835
+ }
3836
+
3837
+ /**
3838
+ * Exposes accumulated state metrics for observability in the opencode UI.
3839
+ *
3840
+ * @param state - streaming state
3841
+ */
3842
+ export function getStreamingSummary(state: StreamingState): {
3843
+ completionId: string;
3844
+ model: string;
3845
+ textChars: number;
3846
+ thinkingChars: number;
3847
+ finishReason?: string | undefined;
3848
+ toolCalls: number;
3849
+ pendingSignatures: number;
3850
+ } {
3851
+ return {
3852
+ completionId: state.completionId,
3853
+ model: state.model,
3854
+ textChars: state.accumulatedText.length,
3855
+ thinkingChars: state.accumulatedThinking.length,
3856
+ finishReason: state.lastFinishReason,
3857
+ toolCalls: state.toolCallIdCounter,
3858
+ pendingSignatures: state.pendingSignatures.length,
3859
+ };
3860
+ }
3861
+
3862
+ /**
3863
+ * Passes through the raw candidate payload when present, otherwise rebuilds a
3864
+ * minimal Gemini-shaped response from the normalized view.
3865
+ *
3866
+ * @param p - normalized payload-like object
3867
+ * @returns raw payload or reconstructed Gemini candidates envelope
3868
+ */
3869
+ export const transformToGemini = (p: any): any =>
3870
+ p.raw || { candidates: [{ content: { parts: [{ text: p.text || "" }] } }] };
3871
+
3872
+ // ---------------------------------------------------------------------------
3873
+ // Merged: Streaming named barrel (default export remains streamingModule)
3874
+ // ---------------------------------------------------------------------------
3875
+
3876
+ /**
3877
+ * Named bundle mirroring the legacy ONDA 4 module surface. Renamed members are
3878
+ * wired to their merged equivalents: `parseSSEPayloads`, `normalizeRawPayloads`
3879
+ * and the module-level `isRetryableNetworkStatus`.
3880
+ */
3881
+ export const Streaming = {
3882
+ IncrementalSSEParser,
3883
+ CloudCodeStreamingTransformer,
3884
+ ThinkingSignatureLRUCache,
3885
+ globalThinkingSignatureCache,
3886
+ globalThinkingSignatureLRUCache,
3887
+
3888
+ parseSSEPayloads,
3889
+ normalizeRawPayloads,
3890
+
3891
+ transformToOpenAIChunks,
3892
+ transformToAnthropicEvents,
3893
+ serializeOpenAIChunksToSSE,
3894
+ createSyntheticFinishChunks,
3895
+ createAnthropicSyntheticFinish,
3896
+ createInitialStreamingState,
3897
+
3898
+ resilientFetch,
3899
+ createResilientTransformedStream,
3900
+ computeExponentialDelay,
3901
+ isRetryableNetworkStatus,
3902
+
3903
+ buildGeminiCliBypassHeaders,
3904
+ buildHostSessionId,
3905
+
3906
+ hasThinkingContent,
3907
+ extractThinkingSignatures,
3908
+ getStreamingSummary,
3909
+ transformToGemini,
3910
+ handleThinkingBlock,
3911
+
3912
+ GEMINI_CLI_CLIENT_ID,
3913
+ GEMINI_CLI_CLIENT_SECRET,
3914
+ GEMINI_CLI_USER_AGENT,
3915
+ X_GOOG_API_CLIENT,
3916
+ CLIENT_METADATA_RAW,
3917
+ PROJECT_FALLBACK,
3918
+ MODELS_2026,
3919
+ } as const;
3920
+
3921
+ // ---------------------------------------------------------------------------
3922
+ // Exports — library-first barrel
3923
+ // ---------------------------------------------------------------------------
3924
+
3925
+ /**
3926
+ * Default export for barrel compatibility: contains all primary helpers
3927
+ */
3928
+ const streamingModule = {
3929
+ LRU_THINKING_CACHE_SIZE,
3930
+ ReplaySignatureCache,
3931
+ globalThinkingSignatureCache,
3932
+ createSSEParserState,
3933
+ parseSSEChunk,
3934
+ parseSSEBinaryChunk,
3935
+ safeJsonParse,
3936
+ unwrapCandidates,
3937
+ extractParts,
3938
+ normalizePart,
3939
+ normalizePayloads,
3940
+ mapFinishReason,
3941
+ extractDelta,
3942
+ StreamingSessionTracker,
3943
+ transformToOpenAI,
3944
+ createOpenAIStreamingTransformer,
3945
+ transformToAnthropic,
3946
+ createAnthropicStreamingTransformer,
3947
+ fetchWithExponentialRetry,
3948
+ injectSyntheticFinish,
3949
+ withAutoRecoveryStreaming,
3950
+ readSSEStream,
3951
+ streamToOpenAI,
3952
+ streamToAnthropic,
3953
+ fnv1a32,
3954
+ hashThinkingText,
3955
+ getJitter,
3956
+ sleepWithJitter,
3957
+ SSE_DONE_SENTINEL,
3958
+ };
3959
+
3960
+ export default streamingModule;
3961
+
3962
+ /* ════════════════════════════════════════════════════════════════════
3963
+ Section: the devthink workbench stream surface (the 1.1.16 ChatEvent
3964
+ family absorbed by the grand merge; the chat event vocabulary bridges
3965
+ onto the normalized streaming machinery above).
3966
+ ════════════════════════════════════════════════════════════════════ */
3967
+ export type ChatEvent =
3968
+ | { type: "start"; provider: string; model: string }
3969
+ | { type: "text"; text: string }
3970
+ | { type: "reasoning"; text: string }
3971
+ | { type: "tool"; name: string; input: unknown }
3972
+ | { type: "error"; message: string }
3973
+ | { type: "finish"; reason?: string };
3974
+
3975
+ export type StreamOptions = {
3976
+ provider: string;
3977
+ model: string;
3978
+ signal?: AbortSignal | undefined;
3979
+ };
3980
+
3981
+ function decodeJson(value: string): Record<string, unknown> | null {
3982
+ try {
3983
+ const parsed: unknown = JSON.parse(value);
3984
+ return parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : null;
3985
+ } catch {
3986
+ return null;
3987
+ }
3988
+ }
3989
+
3990
+ function extractSseFrames(buffer: { value: string }, incoming: string): string[] {
3991
+ buffer.value += incoming;
3992
+ const frames = buffer.value.split(/\r?\n\r?\n/);
3993
+ buffer.value = frames.pop() || "";
3994
+ return frames;
3995
+ }
3996
+
3997
+ function extractData(frame: string): string | null {
3998
+ const lines = frame.split(/\r?\n/).filter((line) => line.startsWith("data:"));
3999
+ if (lines.length === 0) return null;
4000
+ const value = lines
4001
+ .map((line) => line.slice(5).trimStart())
4002
+ .join("\n")
4003
+ .trim();
4004
+ return value && value !== "[DONE]" ? value : null;
4005
+ }
4006
+
4007
+ function textAt(value: unknown): string {
4008
+ return typeof value === "string" ? value : "";
4009
+ }
4010
+
4011
+ function normalizeOpenAi(frame: Record<string, unknown>): ChatEvent[] {
4012
+ const choices = Array.isArray(frame.choices) ? frame.choices : [];
4013
+ const choice = choices[0] as Record<string, unknown> | undefined;
4014
+ const delta = (choice?.delta || {}) as Record<string, unknown>;
4015
+ const events: ChatEvent[] = [];
4016
+ const reasoning = textAt(delta.reasoning_content || delta.reasoning);
4017
+ const text = textAt(delta.content);
4018
+ if (reasoning) events.push({ type: "reasoning", text: reasoning });
4019
+ if (text) events.push({ type: "text", text });
4020
+ const finish = choice?.finish_reason;
4021
+ if (finish) events.push({ type: "finish", reason: textAt(finish) });
4022
+ return events;
4023
+ }
4024
+
4025
+ function normalizeAnthropic(frame: Record<string, unknown>): ChatEvent[] {
4026
+ const type = textAt(frame.type);
4027
+ if (type === "content_block_delta") {
4028
+ const delta = (frame.delta || {}) as Record<string, unknown>;
4029
+ const text = textAt(delta.text);
4030
+ return text ? [{ type: "text", text }] : [];
4031
+ }
4032
+ if (type === "message_stop") return [{ type: "finish", reason: "stop" }];
4033
+ if (type === "error") {
4034
+ const error = (frame.error || {}) as Record<string, unknown>;
4035
+ return [{ type: "error", message: textAt(error.message) || "Anthropic returned an error." }];
4036
+ }
4037
+ return [];
4038
+ }
4039
+
4040
+ function normalizeGoogle(frame: Record<string, unknown>): ChatEvent[] {
4041
+ const candidates = Array.isArray(frame.candidates) ? frame.candidates : [];
4042
+ const content = ((candidates[0] as Record<string, unknown> | undefined)?.content || {}) as Record<string, unknown>;
4043
+ const parts = Array.isArray(content.parts) ? content.parts : [];
4044
+ const events: ChatEvent[] = [];
4045
+ for (const part of parts) {
4046
+ const item = part as Record<string, unknown>;
4047
+ const text = textAt(item.text);
4048
+ if (text) events.push({ type: "text", text });
4049
+ }
4050
+ const finish = textAt((candidates[0] as Record<string, unknown> | undefined)?.finishReason);
4051
+ if (finish) events.push({ type: "finish", reason: finish });
4052
+ return events;
4053
+ }
4054
+
4055
+ export function normalizeFrame(provider: string, frame: Record<string, unknown>): ChatEvent[] {
4056
+ const lower = provider.toLowerCase();
4057
+ if (lower === "anthropic") return normalizeAnthropic(frame);
4058
+ if (lower === "google") return normalizeGoogle(frame);
4059
+ const data = (frame.data || frame) as Record<string, unknown>;
4060
+ if (data !== frame && textAt(data.delta_content)) return [{ type: "text", text: textAt(data.delta_content) }];
4061
+ if (data !== frame && textAt(data.reasoning_content))
4062
+ return [{ type: "reasoning", text: textAt(data.reasoning_content) }];
4063
+ if (data.error || frame.error)
4064
+ return [{ type: "error", message: textAt(data.error || frame.error) || "The provider returned an error." }];
4065
+ return normalizeOpenAi(frame);
4066
+ }
4067
+
4068
+ export async function* parseEventStream(response: Response, options: StreamOptions): AsyncGenerator<ChatEvent> {
4069
+ if (!response.body) throw new Error("The provider returned an empty stream.");
4070
+ yield { type: "start", provider: options.provider, model: options.model };
4071
+ const reader = response.body.getReader();
4072
+ const decoder = new TextDecoder();
4073
+ const buffer = { value: "" };
4074
+ try {
4075
+ while (true) {
4076
+ const { done, value } = await reader.read();
4077
+ if (done) break;
4078
+ for (const frame of extractSseFrames(buffer, decoder.decode(value, { stream: true }))) {
4079
+ const payload = extractData(frame);
4080
+ if (!payload) continue;
4081
+ const parsed = decodeJson(payload);
4082
+ if (!parsed) continue;
4083
+ for (const event of normalizeFrame(options.provider, parsed)) yield event;
4084
+ }
4085
+ }
4086
+ } finally {
4087
+ reader.releaseLock();
4088
+ }
4089
+ yield { type: "finish", reason: "stop" };
4090
+ }
4091
+
4092
+ export async function collectEvents(events: AsyncIterable<ChatEvent>): Promise<{ text: string; reasoning: string }> {
4093
+ let text = "";
4094
+ let reasoning = "";
4095
+ for await (const event of events) {
4096
+ if (event.type === "text") text += event.text;
4097
+ if (event.type === "reasoning") reasoning += event.text;
4098
+ if (event.type === "error") throw new Error(event.message);
4099
+ }
4100
+ return { text, reasoning };
4101
+ }