@wenathlan/devthink 2.0.13 → 2.0.15
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.
- package/CHANGELOG.md +27 -0
- package/accounts.ts +2087 -0
- package/agent.ts +2331 -0
- package/antigravity.ts +2139 -0
- package/apifreeze.ts +4146 -0
- package/auth.ts +642 -0
- package/background.ts +16837 -0
- package/bridge.ts +2080 -0
- package/build.ts +54 -0
- package/bun.ts +26 -0
- package/capture.ts +1614 -0
- package/checksecrets.ts +72 -0
- package/cjs.ts +9 -0
- package/cli.ts +3500 -0
- package/commands.ts +1101 -0
- package/companion.ts +450 -0
- package/compatibility.ts +37 -0
- package/config.ts +3137 -0
- package/constants.ts +943 -0
- package/core.ts +1473 -0
- package/crossbrowser.ts +1359 -0
- package/dashdone.ts +605 -0
- package/data.ts +943 -0
- package/database.ts +133 -0
- package/debug.ts +850 -0
- package/deno.ts +27 -0
- package/devthink.ts +707 -0
- package/dist/accounts.d.ts +409 -0
- package/dist/agent.d.ts +837 -0
- package/dist/antigravity.d.ts +380 -0
- package/dist/apifreeze.d.ts +113 -0
- package/dist/auth.d.ts +224 -0
- package/{bridge.d.ts → dist/bridge.d.ts} +1 -1
- package/dist/capture.d.ts +595 -0
- package/dist/cjs.d.ts +6 -0
- package/{cli.js → dist/cli.js} +2 -2
- package/dist/commands.d.ts +310 -0
- package/dist/compatibility.d.ts +17 -0
- package/dist/config.d.ts +1853 -0
- package/dist/constants.d.ts +637 -0
- package/dist/core.d.ts +361 -0
- package/dist/data.d.ts +330 -0
- package/dist/database.d.ts +24 -0
- package/dist/debug.d.ts +270 -0
- package/dist/engine.d.ts +74 -0
- package/dist/environments.d.ts +244 -0
- package/dist/evidence.d.ts +277 -0
- package/dist/export.d.ts +311 -0
- package/dist/fingerprint.d.ts +594 -0
- package/dist/flow.d.ts +300 -0
- package/dist/gates.d.ts +103 -0
- package/dist/identity.d.ts +51 -0
- package/dist/ids.d.ts +7 -0
- package/dist/llm.d.ts +652 -0
- package/dist/models.d.ts +1807 -0
- package/dist/net.d.ts +325 -0
- package/dist/neutral.d.ts +41 -0
- package/dist/oauth.d.ts +703 -0
- package/dist/page.d.ts +1166 -0
- package/dist/perf.d.ts +481 -0
- package/dist/plan.d.ts +134 -0
- package/dist/project.d.ts +395 -0
- package/dist/providers.d.ts +37 -0
- package/dist/quota.d.ts +453 -0
- package/dist/recovery.d.ts +621 -0
- package/dist/request.d.ts +1492 -0
- package/dist/run.d.ts +779 -0
- package/dist/runtime.d.ts +212 -0
- package/dist/search.d.ts +375 -0
- package/dist/security.d.ts +534 -0
- package/dist/serve.d.ts +136 -0
- package/dist/session.d.ts +296 -0
- package/dist/storage.d.ts +53 -0
- package/dist/streaming.d.ts +1119 -0
- package/dist/swarm.d.ts +328 -0
- package/dist/tools.d.ts +170 -0
- package/dist/types.d.ts +8357 -0
- package/dist/utils.d.ts +82 -0
- package/dist/version.d.ts +7 -0
- package/dist/versionregistry.d.ts +406 -0
- package/dist/views.d.ts +579 -0
- package/dist/workbenchsession.d.ts +69 -0
- package/dist/workflow.d.ts +649 -0
- package/docs/readme.md +92 -0
- package/docs/schemas/antigravity.json +192 -0
- package/engine.ts +1524 -0
- package/environments.ts +1005 -0
- package/evidence.ts +643 -0
- package/export.ts +889 -0
- package/fingerprint.ts +1458 -0
- package/flow.ts +1190 -0
- package/gates.ts +307 -0
- package/gateway.ts +34 -0
- package/hardening.ts +191 -0
- package/headless.ts +369 -0
- package/http.ts +2110 -0
- package/identity.ts +246 -0
- package/ids.ts +26 -0
- package/index.ts +369 -0
- package/llm.ts +2682 -0
- package/mcp.ts +1835 -0
- package/{memory.js → memory.ts} +3834 -2310
- package/models.ts +3805 -0
- package/modes.ts +43 -0
- package/net.ts +1214 -0
- package/neutral.ts +362 -0
- package/node.ts +26 -0
- package/oauth.ts +2216 -0
- package/offscreen.ts +170 -0
- package/pack.ts +956 -0
- package/package.json +175 -82
- package/page.ts +7824 -0
- package/pagebridge.ts +1116 -0
- package/perf.ts +957 -0
- package/plan.ts +672 -0
- package/plugin.ts +76 -0
- package/{policy.js → policy.ts} +5073 -4679
- package/prisma.config.ts +27 -0
- package/progress.ts +1308 -0
- package/project.ts +1291 -0
- package/protocol.ts +4187 -0
- package/providers.ts +246 -0
- package/quota.ts +1887 -0
- package/recovery.ts +2433 -0
- package/request.ts +4482 -0
- package/run.ts +2135 -0
- package/runtime.ts +526 -0
- package/search.ts +1760 -0
- package/security.ts +1673 -0
- package/serve.ts +419 -0
- package/server.ts +5538 -0
- package/session.ts +1162 -0
- package/storage.ts +182 -0
- package/streaming.ts +4101 -0
- package/swarm.ts +884 -0
- package/sync.ts +56 -0
- package/tests/agentcert.test.ts +128 -0
- package/tests/agentfleet.test.ts +214 -0
- package/tests/agentreview.test.ts +241 -0
- package/tests/agentstream.test.ts +335 -0
- package/tests/agentwork.test.ts +356 -0
- package/tests/apifreeze.test.ts +290 -0
- package/tests/apimap.test.ts +112 -0
- package/tests/approvalgate.test.ts +175 -0
- package/tests/artifactmanifest.test.ts +204 -0
- package/tests/attentionfeed.test.ts +163 -0
- package/tests/backgroundruns.test.ts +100 -0
- package/tests/batchscheduling.test.ts +131 -0
- package/tests/blackboard.test.ts +238 -0
- package/tests/bridge.test.ts +1088 -0
- package/tests/browsercoverage.test.ts +134 -0
- package/tests/browserpolyfills.test.ts +124 -0
- package/tests/build.mjs +1260 -0
- package/tests/clientauth.test.ts +223 -0
- package/tests/clitools.test.ts +578 -0
- package/tests/confirmgates.test.ts +190 -0
- package/tests/containerpack.test.ts +143 -0
- package/tests/controlflow.test.ts +2120 -0
- package/tests/coordination.test.ts +345 -0
- package/tests/copyscreen.test.ts +139 -0
- package/tests/core.test.ts +417 -0
- package/tests/costcert.test.ts +95 -0
- package/tests/crossbrowser.test.ts +115 -0
- package/tests/cspaudit.test.ts +79 -0
- package/tests/dashdone.test.ts +615 -0
- package/tests/datagrid.test.ts +137 -0
- package/tests/doccheck.test.ts +107 -0
- package/tests/dombatch.test.ts +109 -0
- package/tests/emulation.test.ts +282 -0
- package/tests/environments.test.ts +259 -0
- package/tests/evidenceviews.test.ts +88 -0
- package/tests/exporttools.test.ts +185 -0
- package/tests/firefoxprep.test.ts +134 -0
- package/tests/flowlibrary.test.ts +242 -0
- package/tests/flowrun.test.ts +303 -0
- package/tests/forensics.test.ts +439 -0
- package/tests/gateway.test.ts +939 -0
- package/tests/headlesslib.test.ts +171 -0
- package/tests/httpstream.test.ts +277 -0
- package/tests/immutablelog.test.ts +157 -0
- package/tests/inboundguard.test.ts +177 -0
- package/tests/lazyload.test.ts +72 -0
- package/tests/librarymodes.test.ts +623 -0
- package/tests/llm.test.ts +696 -0
- package/tests/manifest.test.ts +226 -0
- package/tests/maskinputs.test.ts +150 -0
- package/tests/matrixverify.test.ts +111 -0
- package/tests/mavenpack.test.ts +75 -0
- package/tests/mcpserver.test.ts +704 -0
- package/tests/memory.test.ts +4155 -0
- package/tests/memorycare.test.ts +230 -0
- package/tests/migrateplan.test.ts +349 -0
- package/tests/minimization.test.ts +414 -0
- package/tests/modelroute.test.ts +112 -0
- package/tests/multiagent.test.ts +440 -0
- package/tests/nativebridge.test.ts +807 -0
- package/tests/navigation.test.ts +284 -0
- package/tests/nugetpack.test.ts +83 -0
- package/tests/orchestration.test.ts +449 -0
- package/tests/originpolicy.test.ts +405 -0
- package/tests/outputcompare.test.ts +97 -0
- package/tests/package.test.ts +91 -0
- package/tests/pagebridge.test.ts +5909 -0
- package/tests/pentest.test.ts +75 -0
- package/tests/perfrecords.test.ts +257 -0
- package/tests/permdiff.test.ts +85 -0
- package/tests/phishguard.test.ts +128 -0
- package/tests/pickerviews.test.ts +155 -0
- package/tests/pipelines.test.ts +381 -0
- package/tests/planlint.test.ts +240 -0
- package/tests/planreview.test.ts +383 -0
- package/tests/platformtargets.test.ts +56 -0
- package/tests/policy.test.ts +8284 -0
- package/tests/portability.test.ts +96 -0
- package/tests/progress.test.ts +1036 -0
- package/tests/promptlibrary.test.ts +116 -0
- package/tests/protocol.test.ts +4793 -0
- package/tests/publishingpipeline.test.ts +314 -0
- package/tests/quickactions.test.ts +164 -0
- package/tests/readiness.test.ts +78 -0
- package/tests/recipes.test.ts +163 -0
- package/tests/redactshots.test.ts +216 -0
- package/tests/registernvidiakeys.ts +51 -0
- package/tests/relayserve.test.ts +304 -0
- package/tests/releasenotes.test.ts +276 -0
- package/tests/resilience.test.ts +467 -0
- package/tests/resourceaware.test.ts +187 -0
- package/tests/runbudget.test.ts +125 -0
- package/tests/runhistory.test.ts +299 -0
- package/tests/runreplay.test.ts +113 -0
- package/tests/runresume.test.ts +184 -0
- package/tests/runstate.test.ts +288 -0
- package/tests/runtimeadapters.test.ts +251 -0
- package/tests/safariskeleton.test.ts +115 -0
- package/tests/sandboxframe.test.ts +196 -0
- package/tests/sbom.test.ts +105 -0
- package/tests/secretvault.test.ts +188 -0
- package/tests/selcache.test.ts +111 -0
- package/tests/sessioninterface.test.ts +791 -0
- package/tests/sessions.test.ts +591 -0
- package/tests/siteprefs.test.ts +228 -0
- package/tests/snapshotdelta.test.ts +165 -0
- package/tests/statusviews.test.ts +237 -0
- package/tests/stepmeter.test.ts +200 -0
- package/tests/streamparse.test.ts +163 -0
- package/tests/stylepruning.test.ts +90 -0
- package/tests/surfaces.test.ts +282 -0
- package/tests/sweep.test.ts +78 -0
- package/tests/syncbridge.test.ts +164 -0
- package/tests/taskqueue.test.ts +206 -0
- package/tests/toolcalls.test.ts +336 -0
- package/tests/toolcatalog.test.ts +201 -0
- package/tests/torture-agent.test.ts +706 -0
- package/tests/torture-auth.test.ts +461 -0
- package/tests/torture-commands.test.ts +895 -0
- package/tests/torture-data.test.ts +923 -0
- package/tests/torture-environments.test.ts +285 -0
- package/tests/torture-export.test.ts +263 -0
- package/tests/torture-http.test.ts +1379 -0
- package/tests/torture-llm.test.ts +808 -0
- package/tests/torture-memory.test.ts +1084 -0
- package/tests/torture-plan.test.ts +829 -0
- package/tests/torture-run.test.ts +626 -0
- package/tests/torture-session.test.ts +928 -0
- package/tests/torture-swarm.test.ts +1245 -0
- package/tests/torture-tools.test.ts +964 -0
- package/tests/torture-workflow.test.ts +2354 -0
- package/tests/tourviews.test.ts +52 -0
- package/tests/transparency.test.ts +147 -0
- package/tests/trigger.test.ts +941 -0
- package/tests/v1sunset.test.ts +428 -0
- package/tests/virtlist.test.ts +94 -0
- package/tests/vision.test.ts +540 -0
- package/tests/vsixpack.test.ts +145 -0
- package/tests/webapi.test.ts +523 -0
- package/tests/workflow.test.ts +1785 -0
- package/tests/workfloweditor.test.ts +759 -0
- package/tests/xpipack.test.ts +128 -0
- package/tools.ts +734 -0
- package/types.ts +9908 -0
- package/umd.ts +9 -0
- package/utils.ts +249 -0
- package/version.ts +8 -0
- package/versionregistry.ts +1310 -0
- package/views.ts +2153 -0
- package/vitest.config.ts +12 -0
- package/web/App.tsx +78 -0
- package/web/README.md +47 -0
- package/web/capacitor.config.ts +46 -0
- package/web/const.ts +19 -0
- package/web/control.shell.tsx +66 -0
- package/web/dashboardpage.ts +554 -0
- package/web/db.ts +493 -0
- package/web/design.html +541 -0
- package/web/gatewayview/Gateway.tsx +345 -0
- package/web/gatewayview/config.ts +1042 -0
- package/web/gatewayview/definition.ts +174 -0
- package/web/icons.ts +15 -0
- package/{manifest.json → web/manifest.json} +1 -1
- package/web/optionspage.ts +691 -0
- package/web/popup.ts +1651 -0
- package/web/schema.prisma +176 -0
- package/web/sidepanel.ts +7941 -0
- package/web/sitemanifest.json +13 -0
- package/web/terminal-ui.tsx +622 -0
- package/web/transparencypage.ts +541 -0
- package/web/vite.config.ts +39 -0
- package/web/workspace.ts +23 -0
- package/workbenchmemory.ts +104 -0
- package/workbenchsession.ts +304 -0
- package/workflow.ts +4495 -0
- package/bridge.d.ts.map +0 -1
- package/bridge.js +0 -1877
- package/bun.d.ts.map +0 -1
- package/bun.js +0 -52329
- package/caps/background.json +0 -712
- package/caps/cli.json +0 -360
- package/caps/library.json +0 -3611
- package/caps/mcp.json +0 -126
- package/caps/pagebridge.json +0 -377
- package/caps/popup.json +0 -51
- package/caps/sidepanel.json +0 -522
- package/checksums.txt +0 -142
- package/cli.d.ts.map +0 -1
- package/companion.d.ts.map +0 -1
- package/companion.js +0 -389
- package/crossbrowser.d.ts.map +0 -1
- package/crossbrowser.js +0 -1191
- package/dashdone.d.ts.map +0 -1
- package/dashdone.js +0 -368
- package/deno.d.ts.map +0 -1
- package/deno.js +0 -52325
- package/devthink.umd.js +0 -52346
- package/fixtures/bun.ts +0 -7
- package/fixtures/cjs.js +0 -6
- package/fixtures/deno.ts +0 -6
- package/fixtures/example-org-pagestate.json +0 -22
- package/fixtures/importers/automa-workflow.json +0 -18
- package/fixtures/importers/selenium-side.json +0 -20
- package/fixtures/importers/tabular-plan.csv +0 -7
- package/fixtures/importers/uivision-macro.json +0 -10
- package/fixtures/importers/v1-plan.json +0 -13
- package/fixtures/mcp-client.mjs +0 -14
- package/fixtures/pages/checkout.html +0 -192
- package/fixtures/pages/dashboard.html +0 -110
- package/fixtures/pages/feed.html +0 -113
- package/fixtures/pages/product-grid.html +0 -150
- package/fixtures/plans/form-inventory-plan.json +0 -11
- package/fixtures/plans/release-notes-plan.json +0 -11
- package/fixtures/recipes/agentscompete.json +0 -11
- package/fixtures/recipes/agentsforms.json +0 -11
- package/fixtures/recipes/agentsmonitor.json +0 -11
- package/fixtures/recipes/agentsreview.json +0 -29
- package/fixtures/recipes/agentswarm.json +0 -11
- package/fixtures/recipes/fillcalendar.json +0 -12
- package/fixtures/recipes/fillcheckout.json +0 -13
- package/fixtures/recipes/filldependentdropdowns.json +0 -12
- package/fixtures/recipes/fillfromcsv.json +0 -13
- package/fixtures/recipes/fillloginform.json +0 -12
- package/fixtures/recipes/fillmultistepwizard.json +0 -13
- package/fixtures/recipes/fillregistration.json +0 -12
- package/fixtures/recipes/fillsearchfilters.json +0 -12
- package/fixtures/recipes/filltypeahead.json +0 -11
- package/fixtures/recipes/monitoralerts.json +0 -12
- package/fixtures/recipes/monitoravailability.json +0 -11
- package/fixtures/recipes/monitornetworkquiet.json +0 -12
- package/fixtures/recipes/monitorpagechanges.json +0 -11
- package/fixtures/recipes/monitorpricechange.json +0 -11
- package/fixtures/recipes/monitorschedule.json +0 -12
- package/fixtures/recipes/scrapedynamicprices.json +0 -13
- package/fixtures/recipes/scrapeimagegallery.json +0 -12
- package/fixtures/recipes/scrapeinfinitefeed.json +0 -13
- package/fixtures/recipes/scrapenestedlists.json +0 -12
- package/fixtures/recipes/scrapeproductgrid.json +0 -12
- package/fixtures/recipes/scrapereviews.json +0 -12
- package/fixtures/recipes/scrapesearchresults.json +0 -12
- package/fixtures/recipes/scrapetablepagination.json +0 -12
- package/fixtures/recipes/testaccessibility.json +0 -34
- package/fixtures/recipes/testconsoleerrors.json +0 -12
- package/fixtures/recipes/testdeeplinks.json +0 -12
- package/fixtures/recipes/testformvalidation.json +0 -13
- package/fixtures/recipes/testlayoutshift.json +0 -12
- package/fixtures/recipes/testlinkcheck.json +0 -12
- package/fixtures/recipes/testloadtiming.json +0 -12
- package/fixtures/recipes/testvisualdiff.json +0 -12
- package/fixtures/workflows/changelog-digest-workflow.json +0 -13
- package/fixtures/workflows/form-survey-workflow.json +0 -13
- package/gallery.json +0 -706
- package/hardening.d.ts.map +0 -1
- package/hardening.js +0 -188
- package/headless.d.ts.map +0 -1
- package/headless.js +0 -11522
- package/http.d.ts.map +0 -1
- package/http.js +0 -2165
- package/index.cjs +0 -54682
- package/index.d.ts.map +0 -1
- package/index.js +0 -52310
- package/index.neutral.js +0 -46343
- package/mcp.d.ts.map +0 -1
- package/mcp.js +0 -20515
- package/memory.d.ts.map +0 -1
- package/nativehost.template.json +0 -10
- package/node.cjs +0 -54686
- package/node.d.ts.map +0 -1
- package/pack.d.ts.map +0 -1
- package/pack.js +0 -805
- package/policy.d.ts.map +0 -1
- package/progress.d.ts.map +0 -1
- package/progress.js +0 -645
- package/protocol.d.ts.map +0 -1
- package/protocol.js +0 -8334
- package/schemas/audit.schema.json +0 -226
- package/schemas/capability.schema.json +0 -113
- package/schemas/envelope.schema.json +0 -162
- package/schemas/memory.schema.json +0 -51
- package/schemas/observation.schema.json +0 -113
- package/schemas/plan.schema.json +0 -208
- package/schemas/progress.schema.json +0 -126
- package/schemas/proposal.schema.json +0 -68
- package/schemas/session.schema.json +0 -82
- package/schemas/tool.schema.json +0 -94
- package/server.d.ts.map +0 -1
- package/server.js +0 -28877
- package/umd-example.html +0 -18
- package/umd.d.ts.map +0 -1
- /package/{bun.d.ts → dist/bun.d.ts} +0 -0
- /package/{cli.d.ts → dist/cli.d.ts} +0 -0
- /package/{companion.d.ts → dist/companion.d.ts} +0 -0
- /package/{crossbrowser.d.ts → dist/crossbrowser.d.ts} +0 -0
- /package/{dashdone.d.ts → dist/dashdone.d.ts} +0 -0
- /package/{deno.d.ts → dist/deno.d.ts} +0 -0
- /package/{hardening.d.ts → dist/hardening.d.ts} +0 -0
- /package/{headless.d.ts → dist/headless.d.ts} +0 -0
- /package/{http.d.ts → dist/http.d.ts} +0 -0
- /package/{index.d.ts → dist/index.d.ts} +0 -0
- /package/{mcp.d.ts → dist/mcp.d.ts} +0 -0
- /package/{memory.d.ts → dist/memory.d.ts} +0 -0
- /package/{node.d.ts → dist/node.d.ts} +0 -0
- /package/{pack.d.ts → dist/pack.d.ts} +0 -0
- /package/{policy.d.ts → dist/policy.d.ts} +0 -0
- /package/{progress.d.ts → dist/progress.d.ts} +0 -0
- /package/{protocol.d.ts → dist/protocol.d.ts} +0 -0
- /package/{server.d.ts → dist/server.d.ts} +0 -0
- /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
|
+
}
|