@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/llm.ts
ADDED
|
@@ -0,0 +1,2682 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The llm module of the 1.1.90 consolidation: every correlated variation of the model routing and prompt library logic interned in this one file, so the module family carries one surface without duplicate variations.
|
|
3
|
+
* The correlation is the llm family: llm holds the model call core of the 1.1.57 family (the shaped request building, the provider call through the transport seam, the local model path, the stream delta parsing, the command guard with intent classification and plan drafting, the replan on fail, the step reflection, the guardrail stripping, the parse output guard, the tool briefs and the usage accounting with its budget check); modelroute holds the routing table that resolves one request to its provider with fallback routes, provider marks and revision bumps; and promptlibrary holds the template library with its variables, rendering, saving, search and removal.
|
|
4
|
+
* No provider, key or template is ever hardcoded: every route and prompt stays the user's choice, and no model call ever bypasses the consent gates.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type {
|
|
8
|
+
apikeyref,
|
|
9
|
+
baseurlconfig,
|
|
10
|
+
commandentity,
|
|
11
|
+
commandparse,
|
|
12
|
+
costbudget,
|
|
13
|
+
costestimate,
|
|
14
|
+
draftstep,
|
|
15
|
+
gatewaychatstate,
|
|
16
|
+
gatewaychattoken,
|
|
17
|
+
gatewayerror,
|
|
18
|
+
gatewaykind,
|
|
19
|
+
gatewaymodelinfo,
|
|
20
|
+
intentkind,
|
|
21
|
+
localmodelconfig,
|
|
22
|
+
modelcacherecord,
|
|
23
|
+
modelmessage,
|
|
24
|
+
modeloutput,
|
|
25
|
+
modelroute,
|
|
26
|
+
parseguard,
|
|
27
|
+
plandraft,
|
|
28
|
+
policyevaluation,
|
|
29
|
+
prompttemplate,
|
|
30
|
+
providerconfig,
|
|
31
|
+
protocolstyle,
|
|
32
|
+
reflectnote,
|
|
33
|
+
replanrecord,
|
|
34
|
+
secretvaultentry,
|
|
35
|
+
tokenstream,
|
|
36
|
+
toolbrief,
|
|
37
|
+
tooldef,
|
|
38
|
+
usagerecord,
|
|
39
|
+
} from "./types.js";
|
|
40
|
+
import {
|
|
41
|
+
planlint,
|
|
42
|
+
egressconsentgate,
|
|
43
|
+
gatewaybaseurlgate,
|
|
44
|
+
gatewaycachewindowvalid,
|
|
45
|
+
gatewayconsentgate,
|
|
46
|
+
gatewaykeyconsentgate,
|
|
47
|
+
gatewayprefixgate,
|
|
48
|
+
gatewayretrycapvalid,
|
|
49
|
+
} from "./policy.js";
|
|
50
|
+
import { sendfetch, type fetchtransport, type transportresponse } from "./http.js";
|
|
51
|
+
import { randomid } from "./memory.js";
|
|
52
|
+
import { secretleakscan, vaultdelete, vaultstore, vaultvaluefor } from "./security.js";
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Llm integration logics of the 1.1.57 family.
|
|
56
|
+
* Every provider call concern lives in this file: the request shaping for the four protocol styles the user picks (the openai compatible chat completions shape, the openai responses shape, the anthropic messages shape and the google gemini shape — wire shapes for interoperability, never provider names), the response parsing per style, the completion call through the established fetch machinery with its reviewed retries and backoff, the local model calls that never leave the machine, the streaming token parse per style, the natural language command parsing with intent classification, the goal to plan drafting with the grammar lint, the replan of failed runs, the per step reflection with its running lessons, the openapi style tool briefs for model consumption, the parse guardrails that strip code fences and chatter, validate model text against the expected schema, retry malformed output and refuse after exhaustion, and the usage records with cost budget halts.
|
|
57
|
+
* Nothing is hardcoded: no endpoint, no model name, no key, no temperature and no token ceiling ever leaves the user configuration, page content never joins a call the user has not granted and every model drafted plan still passes the same human review.
|
|
58
|
+
*/
|
|
59
|
+
|
|
60
|
+
/** The refusal markers reviewed by default; user configured markers always win and an empty configured list disables the marker scan. */
|
|
61
|
+
export const defaultrefusalmarkers: string[] = ["i cannot", "i can't", "i'm unable", "refusal:", "cannot comply"];
|
|
62
|
+
|
|
63
|
+
/** One shaped model request: the url, the method, the headers and the body text per protocol style. */
|
|
64
|
+
export interface shapedrequest {
|
|
65
|
+
url: string;
|
|
66
|
+
method: string;
|
|
67
|
+
headers: Record<string, string>;
|
|
68
|
+
body: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The usage a completion answer reports: prompt, completion and total token counts. */
|
|
72
|
+
export interface modelusage {
|
|
73
|
+
prompttokens: number;
|
|
74
|
+
completiontokens: number;
|
|
75
|
+
totaltokens: number;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Builds the completion request of one protocol style: the endpoint url, the headers with the key placed where the style expects it and the body envelope the style speaks. The user configured headers merge over the shape headers so any gateway works. */
|
|
79
|
+
export function buildrequest(input: {
|
|
80
|
+
provider: { endpoint: string; style: protocolstyle; headers?: Record<string, string> };
|
|
81
|
+
model: string;
|
|
82
|
+
messages: modelmessage[];
|
|
83
|
+
apikey?: string;
|
|
84
|
+
temperature?: number;
|
|
85
|
+
maxtokens?: number;
|
|
86
|
+
stream?: boolean;
|
|
87
|
+
}): shapedrequest {
|
|
88
|
+
const headers: Record<string, string> = { "content-type": "application/json" };
|
|
89
|
+
let url = input.provider.endpoint;
|
|
90
|
+
const style = input.provider.style;
|
|
91
|
+
if (style === "chatcompletions") {
|
|
92
|
+
if (input.apikey !== undefined && input.apikey.trim() !== "") headers.authorization = `Bearer ${input.apikey}`;
|
|
93
|
+
const body: Record<string, unknown> = {
|
|
94
|
+
model: input.model,
|
|
95
|
+
messages: input.messages.map((message) => ({ role: message.role, content: message.content })),
|
|
96
|
+
...(input.temperature !== undefined ? { temperature: input.temperature } : {}),
|
|
97
|
+
...(input.maxtokens !== undefined ? { max_tokens: input.maxtokens } : {}),
|
|
98
|
+
...(input.stream === true ? { stream: true } : {}),
|
|
99
|
+
};
|
|
100
|
+
return {
|
|
101
|
+
url,
|
|
102
|
+
method: "POST",
|
|
103
|
+
headers: { ...headers, ...(input.provider.headers ?? {}) },
|
|
104
|
+
body: JSON.stringify(body),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
if (style === "responses") {
|
|
108
|
+
if (input.apikey !== undefined && input.apikey.trim() !== "") headers.authorization = `Bearer ${input.apikey}`;
|
|
109
|
+
const system = input.messages
|
|
110
|
+
.filter((message) => message.role === "system")
|
|
111
|
+
.map((message) => message.content)
|
|
112
|
+
.join("\n");
|
|
113
|
+
const turns = input.messages
|
|
114
|
+
.filter((message) => message.role !== "system")
|
|
115
|
+
.map((message) => ({ role: message.role === "assistant" ? "assistant" : "user", content: message.content }));
|
|
116
|
+
const body: Record<string, unknown> = {
|
|
117
|
+
model: input.model,
|
|
118
|
+
input: turns,
|
|
119
|
+
...(system.trim() !== "" ? { instructions: system } : {}),
|
|
120
|
+
...(input.temperature !== undefined ? { temperature: input.temperature } : {}),
|
|
121
|
+
...(input.maxtokens !== undefined ? { max_output_tokens: input.maxtokens } : {}),
|
|
122
|
+
...(input.stream === true ? { stream: true } : {}),
|
|
123
|
+
};
|
|
124
|
+
return {
|
|
125
|
+
url,
|
|
126
|
+
method: "POST",
|
|
127
|
+
headers: { ...headers, ...(input.provider.headers ?? {}) },
|
|
128
|
+
body: JSON.stringify(body),
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
if (style === "messages") {
|
|
132
|
+
if (input.apikey !== undefined && input.apikey.trim() !== "") headers["x-api-key"] = input.apikey;
|
|
133
|
+
const system = input.messages
|
|
134
|
+
.filter((message) => message.role === "system")
|
|
135
|
+
.map((message) => message.content)
|
|
136
|
+
.join("\n");
|
|
137
|
+
const turns = input.messages
|
|
138
|
+
.filter((message) => message.role !== "system")
|
|
139
|
+
.map((message) => ({ role: message.role, content: message.content }));
|
|
140
|
+
const body: Record<string, unknown> = {
|
|
141
|
+
model: input.model,
|
|
142
|
+
messages: turns,
|
|
143
|
+
...(system.trim() !== "" ? { system } : {}),
|
|
144
|
+
...(input.temperature !== undefined ? { temperature: input.temperature } : {}),
|
|
145
|
+
...(input.maxtokens !== undefined ? { max_tokens: input.maxtokens } : {}),
|
|
146
|
+
...(input.stream === true ? { stream: true } : {}),
|
|
147
|
+
};
|
|
148
|
+
return {
|
|
149
|
+
url,
|
|
150
|
+
method: "POST",
|
|
151
|
+
headers: { ...headers, ...(input.provider.headers ?? {}) },
|
|
152
|
+
body: JSON.stringify(body),
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
if (input.apikey !== undefined && input.apikey.trim() !== "")
|
|
156
|
+
url = `${url}${url.includes("?") ? "&" : "?"}key=${encodeURIComponent(input.apikey)}`;
|
|
157
|
+
const system = input.messages
|
|
158
|
+
.filter((message) => message.role === "system")
|
|
159
|
+
.map((message) => message.content)
|
|
160
|
+
.join("\n");
|
|
161
|
+
const turns = input.messages
|
|
162
|
+
.filter((message) => message.role !== "system")
|
|
163
|
+
.map((message) => ({ role: message.role === "assistant" ? "model" : "user", parts: [{ text: message.content }] }));
|
|
164
|
+
const body: Record<string, unknown> = {
|
|
165
|
+
contents: turns,
|
|
166
|
+
...(system.trim() !== "" ? { systemInstruction: { parts: [{ text: system }] } } : {}),
|
|
167
|
+
...(input.temperature !== undefined
|
|
168
|
+
? {
|
|
169
|
+
generationConfig: {
|
|
170
|
+
temperature: input.temperature,
|
|
171
|
+
...(input.maxtokens !== undefined ? { maxOutputTokens: input.maxtokens } : {}),
|
|
172
|
+
},
|
|
173
|
+
}
|
|
174
|
+
: input.maxtokens !== undefined
|
|
175
|
+
? { generationConfig: { maxOutputTokens: input.maxtokens } }
|
|
176
|
+
: {}),
|
|
177
|
+
};
|
|
178
|
+
return {
|
|
179
|
+
url,
|
|
180
|
+
method: "POST",
|
|
181
|
+
headers: { ...headers, ...(input.provider.headers ?? {}) },
|
|
182
|
+
body: JSON.stringify(body),
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Reads one number field of a parsed json object; a missing or non numeric field reports undefined. */
|
|
187
|
+
function numberof(value: unknown): number | undefined {
|
|
188
|
+
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Parses one completion answer of the protocol style: the answer text and the usage counts; a malformed answer reports why nothing parsed. */
|
|
192
|
+
export function parsecompletion(
|
|
193
|
+
style: protocolstyle,
|
|
194
|
+
body: string,
|
|
195
|
+
): { text?: string; usage?: modelusage; reason?: string } {
|
|
196
|
+
let parsed: unknown;
|
|
197
|
+
try {
|
|
198
|
+
parsed = JSON.parse(body);
|
|
199
|
+
} catch {
|
|
200
|
+
return { reason: "The provider answer is not json." };
|
|
201
|
+
}
|
|
202
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
203
|
+
return { reason: "The provider answer is not a json object." };
|
|
204
|
+
const record = parsed as Record<string, unknown>;
|
|
205
|
+
if (style === "chatcompletions") {
|
|
206
|
+
const choice = Array.isArray(record.choices)
|
|
207
|
+
? (record.choices[0] as Record<string, unknown> | undefined)
|
|
208
|
+
: undefined;
|
|
209
|
+
const message =
|
|
210
|
+
choice !== undefined && choice.message !== undefined && typeof choice.message === "object"
|
|
211
|
+
? (choice.message as Record<string, unknown>)
|
|
212
|
+
: undefined;
|
|
213
|
+
if (message === undefined || typeof message.content !== "string")
|
|
214
|
+
return { reason: "The chat completions answer carries no message content." };
|
|
215
|
+
const usage =
|
|
216
|
+
record.usage !== undefined && typeof record.usage === "object"
|
|
217
|
+
? (record.usage as Record<string, unknown>)
|
|
218
|
+
: undefined;
|
|
219
|
+
const prompttokens = usage !== undefined ? numberof(usage["prompt_tokens"]) : undefined;
|
|
220
|
+
const completiontokens = usage !== undefined ? numberof(usage["completion_tokens"]) : undefined;
|
|
221
|
+
const totaltokens = usage !== undefined ? numberof(usage["total_tokens"]) : undefined;
|
|
222
|
+
return {
|
|
223
|
+
text: message.content,
|
|
224
|
+
...(prompttokens !== undefined || completiontokens !== undefined || totaltokens !== undefined
|
|
225
|
+
? {
|
|
226
|
+
usage: {
|
|
227
|
+
prompttokens: prompttokens ?? 0,
|
|
228
|
+
completiontokens: completiontokens ?? 0,
|
|
229
|
+
totaltokens: totaltokens ?? (prompttokens ?? 0) + (completiontokens ?? 0),
|
|
230
|
+
},
|
|
231
|
+
}
|
|
232
|
+
: {}),
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
if (style === "responses") {
|
|
236
|
+
const direct = typeof record["output_text"] === "string" ? record["output_text"] : undefined;
|
|
237
|
+
let text = direct;
|
|
238
|
+
if (text === undefined && Array.isArray(record.output)) {
|
|
239
|
+
const parts: string[] = [];
|
|
240
|
+
for (const item of record.output) {
|
|
241
|
+
if (item && typeof item === "object" && Array.isArray((item as Record<string, unknown>).content)) {
|
|
242
|
+
for (const part of (item as Record<string, unknown>).content as unknown[]) {
|
|
243
|
+
if (
|
|
244
|
+
part &&
|
|
245
|
+
typeof part === "object" &&
|
|
246
|
+
(part as Record<string, unknown>).type === "output_text" &&
|
|
247
|
+
typeof (part as Record<string, unknown>).text === "string"
|
|
248
|
+
)
|
|
249
|
+
parts.push((part as Record<string, unknown>).text as string);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
if (parts.length > 0) text = parts.join("");
|
|
254
|
+
}
|
|
255
|
+
if (text === undefined) return { reason: "The responses answer carries no output text." };
|
|
256
|
+
const usage =
|
|
257
|
+
record.usage !== undefined && typeof record.usage === "object"
|
|
258
|
+
? (record.usage as Record<string, unknown>)
|
|
259
|
+
: undefined;
|
|
260
|
+
const prompttokens = usage !== undefined ? numberof(usage["input_tokens"]) : undefined;
|
|
261
|
+
const completiontokens = usage !== undefined ? numberof(usage["output_tokens"]) : undefined;
|
|
262
|
+
const totaltokens = usage !== undefined ? numberof(usage["total_tokens"]) : undefined;
|
|
263
|
+
return {
|
|
264
|
+
text,
|
|
265
|
+
...(prompttokens !== undefined || completiontokens !== undefined || totaltokens !== undefined
|
|
266
|
+
? {
|
|
267
|
+
usage: {
|
|
268
|
+
prompttokens: prompttokens ?? 0,
|
|
269
|
+
completiontokens: completiontokens ?? 0,
|
|
270
|
+
totaltokens: totaltokens ?? (prompttokens ?? 0) + (completiontokens ?? 0),
|
|
271
|
+
},
|
|
272
|
+
}
|
|
273
|
+
: {}),
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
if (style === "messages") {
|
|
277
|
+
const parts: string[] = [];
|
|
278
|
+
if (Array.isArray(record.content)) {
|
|
279
|
+
for (const part of record.content) {
|
|
280
|
+
if (
|
|
281
|
+
part &&
|
|
282
|
+
typeof part === "object" &&
|
|
283
|
+
(part as Record<string, unknown>).type === "text" &&
|
|
284
|
+
typeof (part as Record<string, unknown>).text === "string"
|
|
285
|
+
)
|
|
286
|
+
parts.push((part as Record<string, unknown>).text as string);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
if (parts.length === 0) return { reason: "The messages answer carries no text block." };
|
|
290
|
+
const usage =
|
|
291
|
+
record.usage !== undefined && typeof record.usage === "object"
|
|
292
|
+
? (record.usage as Record<string, unknown>)
|
|
293
|
+
: undefined;
|
|
294
|
+
const prompttokens = usage !== undefined ? numberof(usage["input_tokens"]) : undefined;
|
|
295
|
+
const completiontokens = usage !== undefined ? numberof(usage["output_tokens"]) : undefined;
|
|
296
|
+
return {
|
|
297
|
+
text: parts.join(""),
|
|
298
|
+
...(prompttokens !== undefined || completiontokens !== undefined
|
|
299
|
+
? {
|
|
300
|
+
usage: {
|
|
301
|
+
prompttokens: prompttokens ?? 0,
|
|
302
|
+
completiontokens: completiontokens ?? 0,
|
|
303
|
+
totaltokens: (prompttokens ?? 0) + (completiontokens ?? 0),
|
|
304
|
+
},
|
|
305
|
+
}
|
|
306
|
+
: {}),
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
const candidate = Array.isArray(record.candidates)
|
|
310
|
+
? (record.candidates[0] as Record<string, unknown> | undefined)
|
|
311
|
+
: undefined;
|
|
312
|
+
const content =
|
|
313
|
+
candidate !== undefined && candidate.content !== undefined && typeof candidate.content === "object"
|
|
314
|
+
? (candidate.content as Record<string, unknown>).parts
|
|
315
|
+
: undefined;
|
|
316
|
+
const parts: string[] = [];
|
|
317
|
+
if (Array.isArray(content)) {
|
|
318
|
+
for (const part of content) {
|
|
319
|
+
if (part && typeof part === "object" && typeof (part as Record<string, unknown>).text === "string")
|
|
320
|
+
parts.push((part as Record<string, unknown>).text as string);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
if (parts.length === 0) return { reason: "The gemini answer carries no candidate text." };
|
|
324
|
+
const usage =
|
|
325
|
+
record.usageMetadata !== undefined && typeof record.usageMetadata === "object"
|
|
326
|
+
? (record.usageMetadata as Record<string, unknown>)
|
|
327
|
+
: undefined;
|
|
328
|
+
const prompttokens = usage !== undefined ? numberof(usage.promptTokenCount) : undefined;
|
|
329
|
+
const completiontokens = usage !== undefined ? numberof(usage.candidatesTokenCount) : undefined;
|
|
330
|
+
const totaltokens = usage !== undefined ? numberof(usage.totalTokenCount) : undefined;
|
|
331
|
+
return {
|
|
332
|
+
text: parts.join(""),
|
|
333
|
+
...(prompttokens !== undefined || completiontokens !== undefined || totaltokens !== undefined
|
|
334
|
+
? {
|
|
335
|
+
usage: {
|
|
336
|
+
prompttokens: prompttokens ?? 0,
|
|
337
|
+
completiontokens: completiontokens ?? 0,
|
|
338
|
+
totaltokens: totaltokens ?? (prompttokens ?? 0) + (completiontokens ?? 0),
|
|
339
|
+
},
|
|
340
|
+
}
|
|
341
|
+
: {}),
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** Returns true when the url points at a local machine endpoint: a loopback host keeps the model call inside the machine. */
|
|
346
|
+
export function islocalorigin(url: string): boolean {
|
|
347
|
+
try {
|
|
348
|
+
const host = new URL(url).hostname.toLowerCase();
|
|
349
|
+
return (
|
|
350
|
+
host === "localhost" || host === "127.0.0.1" || host === "::1" || host === "[::1]" || host.endsWith(".localhost")
|
|
351
|
+
);
|
|
352
|
+
} catch {
|
|
353
|
+
return false;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** Sends one completion request to a user configured provider: the request shapes per protocol style, the page content stays stripped unless the user granted it, a configured auth reference without a resolved key refuses the call and the answer parses per style with its usage counts; retries and backoff ride the reviewed fetch options. */
|
|
358
|
+
export async function callmodel(input: {
|
|
359
|
+
provider: providerconfig;
|
|
360
|
+
model: string;
|
|
361
|
+
messages: modelmessage[];
|
|
362
|
+
apikey?: string;
|
|
363
|
+
temperature?: number;
|
|
364
|
+
maxtokens?: number;
|
|
365
|
+
pagecontent?: string;
|
|
366
|
+
pagegrant?: boolean;
|
|
367
|
+
stream?: boolean;
|
|
368
|
+
options?: { timeout?: number; retries?: number; backoff?: number; follow?: number };
|
|
369
|
+
transport: fetchtransport;
|
|
370
|
+
sleep?: (milliseconds: number) => Promise<void>;
|
|
371
|
+
now?: () => number;
|
|
372
|
+
}): Promise<{ text: string; usage?: modelusage; request: shapedrequest }> {
|
|
373
|
+
if (input.provider.endpoint.trim() === "")
|
|
374
|
+
throw new Error("The provider needs the user configured endpoint url before any call leaves.");
|
|
375
|
+
if (input.provider.authref !== undefined && (input.apikey === undefined || input.apikey.trim() === ""))
|
|
376
|
+
throw new Error(
|
|
377
|
+
`The provider ${input.provider.name} references the stored key ${input.provider.authref.name} and the call needs the resolved key material.`,
|
|
378
|
+
);
|
|
379
|
+
const consent = egressconsentgate({
|
|
380
|
+
...(input.pagecontent !== undefined ? { pagecontent: input.pagecontent } : {}),
|
|
381
|
+
granted: input.pagegrant === true,
|
|
382
|
+
});
|
|
383
|
+
if (!consent.allowed) throw new Error(consent.reason ?? "The page content stayed ungranted and the call refused.");
|
|
384
|
+
const shaped = buildrequest({
|
|
385
|
+
provider: input.provider,
|
|
386
|
+
model: input.model,
|
|
387
|
+
messages: input.messages,
|
|
388
|
+
...(input.apikey !== undefined ? { apikey: input.apikey } : {}),
|
|
389
|
+
...(input.temperature !== undefined ? { temperature: input.temperature } : {}),
|
|
390
|
+
...(input.maxtokens !== undefined ? { maxtokens: input.maxtokens } : {}),
|
|
391
|
+
...(input.stream === true ? { stream: true } : {}),
|
|
392
|
+
});
|
|
393
|
+
const transport = await sendfetch({
|
|
394
|
+
request: { url: shaped.url, method: shaped.method, headers: shaped.headers, body: shaped.body },
|
|
395
|
+
...(input.options !== undefined ? { options: input.options } : {}),
|
|
396
|
+
transport: input.transport,
|
|
397
|
+
...(input.sleep !== undefined ? { sleep: input.sleep } : {}),
|
|
398
|
+
...(input.now !== undefined ? { now: input.now } : {}),
|
|
399
|
+
});
|
|
400
|
+
const parsed = parsecompletion(input.provider.style, transport.body);
|
|
401
|
+
if (parsed.text === undefined) throw new Error(parsed.reason ?? "The provider answer did not parse.");
|
|
402
|
+
return { text: parsed.text, ...(parsed.usage !== undefined ? { usage: parsed.usage } : {}), request: shaped };
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** Sends one completion request to the local model endpoint: the endpoint must stay local so the call never leaves the machine, the key stays optional because local runtimes need none and the answer parses per the configured style. */
|
|
406
|
+
export async function calllocal(input: {
|
|
407
|
+
local: localmodelconfig;
|
|
408
|
+
messages: modelmessage[];
|
|
409
|
+
apikey?: string;
|
|
410
|
+
temperature?: number;
|
|
411
|
+
maxtokens?: number;
|
|
412
|
+
options?: { timeout?: number; retries?: number; backoff?: number; follow?: number };
|
|
413
|
+
transport: fetchtransport;
|
|
414
|
+
sleep?: (milliseconds: number) => Promise<void>;
|
|
415
|
+
now?: () => number;
|
|
416
|
+
}): Promise<{ text: string; usage?: modelusage; request: shapedrequest }> {
|
|
417
|
+
if (input.local.endpoint.trim() === "")
|
|
418
|
+
throw new Error("The local model needs the user configured endpoint url before any call runs.");
|
|
419
|
+
if (!islocalorigin(input.local.endpoint))
|
|
420
|
+
throw new Error("The local model endpoint must stay a local machine address; the call never leaves the machine.");
|
|
421
|
+
const provider: providerconfig = {
|
|
422
|
+
id: "local",
|
|
423
|
+
name: "The local model endpoint",
|
|
424
|
+
endpoint: input.local.endpoint,
|
|
425
|
+
style: input.local.style,
|
|
426
|
+
models: [input.local.model],
|
|
427
|
+
status: "available",
|
|
428
|
+
createdat: 0,
|
|
429
|
+
};
|
|
430
|
+
return callmodel({
|
|
431
|
+
provider,
|
|
432
|
+
model: input.local.model,
|
|
433
|
+
messages: input.messages,
|
|
434
|
+
...(input.apikey !== undefined ? { apikey: input.apikey } : {}),
|
|
435
|
+
...(input.temperature !== undefined ? { temperature: input.temperature } : {}),
|
|
436
|
+
...(input.maxtokens !== undefined ? { maxtokens: input.maxtokens } : {}),
|
|
437
|
+
...(input.options !== undefined ? { options: input.options } : {}),
|
|
438
|
+
transport: input.transport,
|
|
439
|
+
...(input.sleep !== undefined ? { sleep: input.sleep } : {}),
|
|
440
|
+
...(input.now !== undefined ? { now: input.now } : {}),
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/** Parses one streaming event payload of the protocol style into its token text: the chat completions delta content, the responses output text delta, the messages content block text delta and the gemini candidate part text. */
|
|
445
|
+
export function streamdelta(style: protocolstyle, event: string): string {
|
|
446
|
+
let parsed: unknown;
|
|
447
|
+
try {
|
|
448
|
+
parsed = JSON.parse(event);
|
|
449
|
+
} catch {
|
|
450
|
+
return "";
|
|
451
|
+
}
|
|
452
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return "";
|
|
453
|
+
const record = parsed as Record<string, unknown>;
|
|
454
|
+
if (style === "chatcompletions") {
|
|
455
|
+
const choice = Array.isArray(record.choices)
|
|
456
|
+
? (record.choices[0] as Record<string, unknown> | undefined)
|
|
457
|
+
: undefined;
|
|
458
|
+
const delta =
|
|
459
|
+
choice !== undefined && choice.delta !== undefined && typeof choice.delta === "object"
|
|
460
|
+
? (choice.delta as Record<string, unknown>).content
|
|
461
|
+
: undefined;
|
|
462
|
+
return typeof delta === "string" ? delta : "";
|
|
463
|
+
}
|
|
464
|
+
if (style === "responses") {
|
|
465
|
+
if (record.type === "response.output_text.delta" && typeof record.delta === "string") return record.delta;
|
|
466
|
+
return "";
|
|
467
|
+
}
|
|
468
|
+
if (style === "messages") {
|
|
469
|
+
if (
|
|
470
|
+
record.type === "content_block_delta" &&
|
|
471
|
+
record.delta !== undefined &&
|
|
472
|
+
typeof record.delta === "object" &&
|
|
473
|
+
typeof (record.delta as Record<string, unknown>).text === "string"
|
|
474
|
+
)
|
|
475
|
+
return (record.delta as Record<string, unknown>).text as string;
|
|
476
|
+
return "";
|
|
477
|
+
}
|
|
478
|
+
const candidate = Array.isArray(record.candidates)
|
|
479
|
+
? (record.candidates[0] as Record<string, unknown> | undefined)
|
|
480
|
+
: undefined;
|
|
481
|
+
const content =
|
|
482
|
+
candidate !== undefined && candidate.content !== undefined && typeof candidate.content === "object"
|
|
483
|
+
? (candidate.content as Record<string, unknown>).parts
|
|
484
|
+
: undefined;
|
|
485
|
+
if (!Array.isArray(content)) return "";
|
|
486
|
+
const parts: string[] = [];
|
|
487
|
+
for (const part of content) {
|
|
488
|
+
if (part && typeof part === "object" && typeof (part as Record<string, unknown>).text === "string")
|
|
489
|
+
parts.push((part as Record<string, unknown>).text as string);
|
|
490
|
+
}
|
|
491
|
+
return parts.join("");
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/** Parses one server sent event body of the protocol style into the ordered token stream it carries; the done marker rides the last token of a finished stream. */
|
|
495
|
+
export function parsestream(style: protocolstyle, body: string): tokenstream[] {
|
|
496
|
+
const tokens: tokenstream[] = [];
|
|
497
|
+
let seq = 0;
|
|
498
|
+
let done = false;
|
|
499
|
+
for (const line of body.split(/\r?\n/)) {
|
|
500
|
+
const trimmed = line.trim();
|
|
501
|
+
if (trimmed === "") continue;
|
|
502
|
+
if (!trimmed.startsWith("data:")) continue;
|
|
503
|
+
const payload = trimmed.slice(5).trim();
|
|
504
|
+
if (payload === "[DONE]") {
|
|
505
|
+
done = true;
|
|
506
|
+
continue;
|
|
507
|
+
}
|
|
508
|
+
const text = streamdelta(style, payload);
|
|
509
|
+
if (text === "") continue;
|
|
510
|
+
seq += 1;
|
|
511
|
+
tokens.push({ seq, text, done: false });
|
|
512
|
+
}
|
|
513
|
+
if (tokens.length > 0 && done)
|
|
514
|
+
tokens[tokens.length - 1] = { ...(tokens[tokens.length - 1] as tokenstream), done: true };
|
|
515
|
+
return tokens;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/** Streams one completion request when the provider supports it: the request carries the stream flag of its style, the transport seam returns the server sent event body and the ordered tokens assemble into the answer text with its token count. */
|
|
519
|
+
export async function streammodel(input: {
|
|
520
|
+
provider: providerconfig;
|
|
521
|
+
model: string;
|
|
522
|
+
messages: modelmessage[];
|
|
523
|
+
apikey?: string;
|
|
524
|
+
temperature?: number;
|
|
525
|
+
maxtokens?: number;
|
|
526
|
+
pagecontent?: string;
|
|
527
|
+
pagegrant?: boolean;
|
|
528
|
+
options?: { timeout?: number; retries?: number; backoff?: number; follow?: number };
|
|
529
|
+
transport: fetchtransport;
|
|
530
|
+
sleep?: (milliseconds: number) => Promise<void>;
|
|
531
|
+
now?: () => number;
|
|
532
|
+
}): Promise<{ text: string; tokens: number; request: shapedrequest }> {
|
|
533
|
+
if (input.provider.endpoint.trim() === "")
|
|
534
|
+
throw new Error("The provider needs the user configured endpoint url before any call leaves.");
|
|
535
|
+
if (input.provider.authref !== undefined && (input.apikey === undefined || input.apikey.trim() === ""))
|
|
536
|
+
throw new Error(
|
|
537
|
+
`The provider ${input.provider.name} references the stored key ${input.provider.authref.name} and the call needs the resolved key material.`,
|
|
538
|
+
);
|
|
539
|
+
const consent = egressconsentgate({
|
|
540
|
+
...(input.pagecontent !== undefined ? { pagecontent: input.pagecontent } : {}),
|
|
541
|
+
granted: input.pagegrant === true,
|
|
542
|
+
});
|
|
543
|
+
if (!consent.allowed) throw new Error(consent.reason ?? "The page content stayed ungranted and the call refused.");
|
|
544
|
+
const shaped = buildrequest({
|
|
545
|
+
provider: input.provider,
|
|
546
|
+
model: input.model,
|
|
547
|
+
messages: input.messages,
|
|
548
|
+
...(input.apikey !== undefined ? { apikey: input.apikey } : {}),
|
|
549
|
+
...(input.temperature !== undefined ? { temperature: input.temperature } : {}),
|
|
550
|
+
...(input.maxtokens !== undefined ? { maxtokens: input.maxtokens } : {}),
|
|
551
|
+
stream: true,
|
|
552
|
+
});
|
|
553
|
+
const transport = await sendfetch({
|
|
554
|
+
request: { url: shaped.url, method: shaped.method, headers: shaped.headers, body: shaped.body },
|
|
555
|
+
...(input.options !== undefined ? { options: input.options } : {}),
|
|
556
|
+
transport: input.transport,
|
|
557
|
+
...(input.sleep !== undefined ? { sleep: input.sleep } : {}),
|
|
558
|
+
...(input.now !== undefined ? { now: input.now } : {}),
|
|
559
|
+
});
|
|
560
|
+
const tokens = parsestream(input.provider.style, transport.body);
|
|
561
|
+
if (tokens.length === 0) return { text: transport.body, tokens: 0, request: shaped };
|
|
562
|
+
return { text: tokens.map((token) => token.text).join(""), tokens: tokens.length, request: shaped };
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/** The command parse guard schema: the intent kind, the entities array and the confidence number the parsed command answer carries. */
|
|
566
|
+
export const commandguard: parseguard = {
|
|
567
|
+
schema: {
|
|
568
|
+
intent: { type: "string", required: true },
|
|
569
|
+
entities: { type: "array", required: true },
|
|
570
|
+
confidence: { type: "number", required: true },
|
|
571
|
+
},
|
|
572
|
+
retries: 1,
|
|
573
|
+
};
|
|
574
|
+
|
|
575
|
+
/** Classifies one natural language request into its intent kind with a confidence score: the deterministic keyword scan works without any provider, the confidence rides the matched keyword density and an unmatched request classifies as ask with a low score. */
|
|
576
|
+
export function classifyintent(text: string): { intent: intentkind; confidence: number } {
|
|
577
|
+
const words = text
|
|
578
|
+
.toLowerCase()
|
|
579
|
+
.split(/[^a-z0-9]+/)
|
|
580
|
+
.filter(Boolean);
|
|
581
|
+
if (words.length === 0) return { intent: "ask", confidence: 0 };
|
|
582
|
+
const scores: Record<intentkind, number> = { navigate: 0, extract: 0, fill: 0, monitor: 0, automate: 0, ask: 0 };
|
|
583
|
+
const keywords: Array<[intentkind, string[]]> = [
|
|
584
|
+
["navigate", ["go", "open", "visit", "navigate", "browse", "url", "site", "page", "to"]],
|
|
585
|
+
["extract", ["extract", "scrape", "collect", "read", "gather", "copy", "table", "data", "text"]],
|
|
586
|
+
["fill", ["fill", "type", "enter", "form", "submit", "login", "sign", "checkout", "field"]],
|
|
587
|
+
["monitor", ["watch", "monitor", "observe", "track", "alert", "notify", "poll", "changes"]],
|
|
588
|
+
["automate", ["automate", "workflow", "repeat", "every", "schedule", "batch", "pipeline", "steps", "then"]],
|
|
589
|
+
["ask", ["what", "who", "when", "where", "why", "how", "explain", "summarize", "ask", "question", "tell"]],
|
|
590
|
+
];
|
|
591
|
+
for (const [intent, list] of keywords) for (const word of list) if (words.includes(word)) scores[intent] += 1;
|
|
592
|
+
let best: intentkind = "ask";
|
|
593
|
+
let bestscore = scores.ask;
|
|
594
|
+
for (const [intent] of keywords)
|
|
595
|
+
if (scores[intent] > bestscore) {
|
|
596
|
+
best = intent;
|
|
597
|
+
bestscore = scores[intent];
|
|
598
|
+
}
|
|
599
|
+
const total = Object.values(scores).reduce((sum, value) => sum + value, 0);
|
|
600
|
+
const confidence =
|
|
601
|
+
bestscore === 0
|
|
602
|
+
? 0.1
|
|
603
|
+
: Math.min(1, Math.round(((bestscore / total) * 0.6 + Math.min(bestscore / 3, 1) * 0.4) * 100) / 100);
|
|
604
|
+
return { intent: best, confidence };
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/** Parses one natural language command into its structured result: the model call rides the routed provider, the answer passes the command guard with its retries and the parsed intent, entities and confidence land in the commandparse record; a model refusal reports why nothing parsed. */
|
|
608
|
+
export async function parsecommand(input: {
|
|
609
|
+
provider: providerconfig;
|
|
610
|
+
model: string;
|
|
611
|
+
text: string;
|
|
612
|
+
apikey?: string;
|
|
613
|
+
transport: fetchtransport;
|
|
614
|
+
sleep?: (milliseconds: number) => Promise<void>;
|
|
615
|
+
now?: () => number;
|
|
616
|
+
guard?: parseguard;
|
|
617
|
+
}): Promise<{ parse?: commandparse; output?: modeloutput; reason?: string }> {
|
|
618
|
+
if (input.text.trim() === "") return { reason: "The command parse needs the natural language text." };
|
|
619
|
+
const guard = input.guard ?? commandguard;
|
|
620
|
+
const answer = await callmodel({
|
|
621
|
+
provider: input.provider,
|
|
622
|
+
model: input.model,
|
|
623
|
+
messages: [
|
|
624
|
+
{
|
|
625
|
+
role: "system",
|
|
626
|
+
content:
|
|
627
|
+
"Parse the user command into json with the fields intent (one of navigate, extract, fill, monitor, automate, ask), entities (an array of { name, value } objects) and confidence (a number between 0 and 1). Answer with the json object only.",
|
|
628
|
+
},
|
|
629
|
+
{ role: "user", content: input.text },
|
|
630
|
+
],
|
|
631
|
+
...(input.apikey !== undefined ? { apikey: input.apikey } : {}),
|
|
632
|
+
transport: input.transport,
|
|
633
|
+
...(input.sleep !== undefined ? { sleep: input.sleep } : {}),
|
|
634
|
+
...(input.now !== undefined ? { now: input.now } : {}),
|
|
635
|
+
});
|
|
636
|
+
const output = guardoutput({ guard, attempts: [answer.text] });
|
|
637
|
+
if (output.verdict !== "valid" || output.parsed === undefined)
|
|
638
|
+
return { output, reason: output.reason ?? "The command answer failed its guard." };
|
|
639
|
+
const parsed = output.parsed;
|
|
640
|
+
if (typeof parsed.intent !== "string") return { output, reason: "The command answer carries no intent." };
|
|
641
|
+
const intents: intentkind[] = ["navigate", "extract", "fill", "monitor", "automate", "ask"];
|
|
642
|
+
if (!intents.includes(parsed.intent as intentkind))
|
|
643
|
+
return { output, reason: `The intent ${parsed.intent} is not one of the intent kinds.` };
|
|
644
|
+
const entities: commandentity[] = Array.isArray(parsed.entities)
|
|
645
|
+
? parsed.entities.filter(
|
|
646
|
+
(entity): entity is commandentity =>
|
|
647
|
+
entity !== null &&
|
|
648
|
+
typeof entity === "object" &&
|
|
649
|
+
!Array.isArray(entity) &&
|
|
650
|
+
typeof (entity as commandentity).name === "string" &&
|
|
651
|
+
typeof (entity as commandentity).value === "string",
|
|
652
|
+
)
|
|
653
|
+
: [];
|
|
654
|
+
const confidence =
|
|
655
|
+
typeof parsed.confidence === "number" && Number.isFinite(parsed.confidence)
|
|
656
|
+
? Math.min(1, Math.max(0, parsed.confidence))
|
|
657
|
+
: 0;
|
|
658
|
+
return {
|
|
659
|
+
parse: {
|
|
660
|
+
text: input.text,
|
|
661
|
+
intent: parsed.intent as intentkind,
|
|
662
|
+
entities,
|
|
663
|
+
confidence,
|
|
664
|
+
model: input.model,
|
|
665
|
+
providerid: input.provider.id,
|
|
666
|
+
parsedat: (input.now ?? Date.now)(),
|
|
667
|
+
},
|
|
668
|
+
output,
|
|
669
|
+
};
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
/** Builds one model drafted plan from a goal: the model call drafts the steps and open questions, the grammar lint checks every drafted step against the action grammar before review and the draft records its provider and model provenance; the draft never executes until the human review approves it. */
|
|
673
|
+
export async function draftplan(input: {
|
|
674
|
+
provider: providerconfig;
|
|
675
|
+
model: string;
|
|
676
|
+
goal: string;
|
|
677
|
+
origin?: string;
|
|
678
|
+
lessons?: string[];
|
|
679
|
+
apikey?: string;
|
|
680
|
+
transport: fetchtransport;
|
|
681
|
+
sleep?: (milliseconds: number) => Promise<void>;
|
|
682
|
+
now?: () => number;
|
|
683
|
+
}): Promise<{ draft?: plandraft; output?: modeloutput; reason?: string }> {
|
|
684
|
+
if (input.goal.trim() === "") return { reason: "The plan draft needs the goal." };
|
|
685
|
+
const lessons = input.lessons ?? [];
|
|
686
|
+
const answer = await callmodel({
|
|
687
|
+
provider: input.provider,
|
|
688
|
+
model: input.model,
|
|
689
|
+
messages: [
|
|
690
|
+
{
|
|
691
|
+
role: "system",
|
|
692
|
+
content: `Draft a browser agent plan as json with the fields goal (string), steps (an array of { kind, target, value, summary } objects using browser action kinds) and openquestions (an array of strings for what stays unclear).${lessons.length > 0 ? ` The running lessons of the earlier steps: ${lessons.join(" | ")}.` : ""} Answer with the json object only.`,
|
|
693
|
+
},
|
|
694
|
+
{ role: "user", content: input.goal },
|
|
695
|
+
],
|
|
696
|
+
...(input.apikey !== undefined ? { apikey: input.apikey } : {}),
|
|
697
|
+
transport: input.transport,
|
|
698
|
+
...(input.sleep !== undefined ? { sleep: input.sleep } : {}),
|
|
699
|
+
...(input.now !== undefined ? { now: input.now } : {}),
|
|
700
|
+
});
|
|
701
|
+
const guard: parseguard = {
|
|
702
|
+
schema: {
|
|
703
|
+
goal: { type: "string", required: true },
|
|
704
|
+
steps: { type: "array", required: true },
|
|
705
|
+
openquestions: { type: "array" },
|
|
706
|
+
},
|
|
707
|
+
retries: 1,
|
|
708
|
+
};
|
|
709
|
+
const output = guardoutput({ guard, attempts: [answer.text] });
|
|
710
|
+
if (output.verdict !== "valid" || output.parsed === undefined)
|
|
711
|
+
return { output, reason: output.reason ?? "The plan draft answer failed its guard." };
|
|
712
|
+
const parsed = output.parsed;
|
|
713
|
+
const rawsteps = Array.isArray(parsed.steps) ? parsed.steps : [];
|
|
714
|
+
const steps: draftstep[] = rawsteps
|
|
715
|
+
.filter(
|
|
716
|
+
(step): step is Record<string, unknown> => step !== null && typeof step === "object" && !Array.isArray(step),
|
|
717
|
+
)
|
|
718
|
+
.map((step, index) => ({
|
|
719
|
+
id: `step${index + 1}`,
|
|
720
|
+
kind: typeof step.kind === "string" ? step.kind : "",
|
|
721
|
+
...(typeof step.target === "string" && step.target.trim() !== "" ? { target: step.target } : {}),
|
|
722
|
+
...(typeof step.value === "string" && step.value.trim() !== "" ? { value: step.value } : {}),
|
|
723
|
+
summary: typeof step.summary === "string" ? step.summary : "",
|
|
724
|
+
}));
|
|
725
|
+
const openquestions = Array.isArray(parsed.openquestions)
|
|
726
|
+
? parsed.openquestions.filter((question): question is string => typeof question === "string")
|
|
727
|
+
: [];
|
|
728
|
+
const draft: plandraft = {
|
|
729
|
+
id: randomid(),
|
|
730
|
+
goal: typeof parsed.goal === "string" && parsed.goal.trim() !== "" ? parsed.goal : input.goal,
|
|
731
|
+
steps,
|
|
732
|
+
openquestions,
|
|
733
|
+
providerid: input.provider.id,
|
|
734
|
+
model: input.model,
|
|
735
|
+
state: "draft",
|
|
736
|
+
lintfindings: [],
|
|
737
|
+
createdat: (input.now ?? Date.now)(),
|
|
738
|
+
};
|
|
739
|
+
draft.lintfindings = planlint(draft, input.origin ?? "");
|
|
740
|
+
return { draft, output };
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
/** Regenerates the tail of a failed plan: the completed steps stay untouched, the failed steps fall away and the model drafted tail steps carry the fresh review marker so the human review approves every revised step again; the replan record keeps the failure reason and the model provenance. */
|
|
744
|
+
export async function replannonfail(input: {
|
|
745
|
+
provider: providerconfig;
|
|
746
|
+
model: string;
|
|
747
|
+
draft: plandraft;
|
|
748
|
+
completedstepids: string[];
|
|
749
|
+
failedstepids: string[];
|
|
750
|
+
reason: string;
|
|
751
|
+
lessons?: string[];
|
|
752
|
+
apikey?: string;
|
|
753
|
+
transport: fetchtransport;
|
|
754
|
+
sleep?: (milliseconds: number) => Promise<void>;
|
|
755
|
+
now?: () => number;
|
|
756
|
+
}): Promise<{ replan?: replanrecord; output?: modeloutput; reason?: string }> {
|
|
757
|
+
if (input.reason.trim() === "") return { reason: "The replan needs the failure reason." };
|
|
758
|
+
const completed = input.draft.steps.filter((step) => input.completedstepids.includes(step.id));
|
|
759
|
+
const failed = input.draft.steps.filter((step) => input.failedstepids.includes(step.id));
|
|
760
|
+
const lessons = input.lessons ?? [];
|
|
761
|
+
const answer = await callmodel({
|
|
762
|
+
provider: input.provider,
|
|
763
|
+
model: input.model,
|
|
764
|
+
messages: [
|
|
765
|
+
{
|
|
766
|
+
role: "system",
|
|
767
|
+
content: `The plan ${input.draft.goal} failed at the steps ${failed.map((step) => step.summary).join("; ") || "unknown"} with the reason: ${input.reason}. The completed steps stay: ${completed.map((step) => step.summary).join("; ") || "none"}.${lessons.length > 0 ? ` The running lessons: ${lessons.join(" | ")}.` : ""} Draft the revised tail steps of the plan as json with the field steps (an array of { kind, target, value, summary } objects using browser action kinds). Answer with the json object only.`,
|
|
768
|
+
},
|
|
769
|
+
{ role: "user", content: input.draft.goal },
|
|
770
|
+
],
|
|
771
|
+
...(input.apikey !== undefined ? { apikey: input.apikey } : {}),
|
|
772
|
+
transport: input.transport,
|
|
773
|
+
...(input.sleep !== undefined ? { sleep: input.sleep } : {}),
|
|
774
|
+
...(input.now !== undefined ? { now: input.now } : {}),
|
|
775
|
+
});
|
|
776
|
+
const guard: parseguard = { schema: { steps: { type: "array", required: true } }, retries: 1 };
|
|
777
|
+
const output = guardoutput({ guard, attempts: [answer.text] });
|
|
778
|
+
if (output.verdict !== "valid" || output.parsed === undefined)
|
|
779
|
+
return { output, reason: output.reason ?? "The replan answer failed its guard." };
|
|
780
|
+
const rawsteps = Array.isArray(output.parsed.steps) ? output.parsed.steps : [];
|
|
781
|
+
const tail: draftstep[] = rawsteps
|
|
782
|
+
.filter(
|
|
783
|
+
(step): step is Record<string, unknown> => step !== null && typeof step === "object" && !Array.isArray(step),
|
|
784
|
+
)
|
|
785
|
+
.map((step, index) => ({
|
|
786
|
+
id: `tail${index + 1}`,
|
|
787
|
+
kind: typeof step.kind === "string" ? step.kind : "",
|
|
788
|
+
...(typeof step.target === "string" && step.target.trim() !== "" ? { target: step.target } : {}),
|
|
789
|
+
...(typeof step.value === "string" && step.value.trim() !== "" ? { value: step.value } : {}),
|
|
790
|
+
summary: typeof step.summary === "string" ? step.summary : "",
|
|
791
|
+
freshreview: true,
|
|
792
|
+
}));
|
|
793
|
+
const replan: replanrecord = {
|
|
794
|
+
id: randomid(),
|
|
795
|
+
draftid: input.draft.id,
|
|
796
|
+
completedstepids: [...input.completedstepids],
|
|
797
|
+
failedstepids: [...input.failedstepids],
|
|
798
|
+
tail,
|
|
799
|
+
reason: input.reason,
|
|
800
|
+
providerid: input.provider.id,
|
|
801
|
+
model: input.model,
|
|
802
|
+
state: "pending",
|
|
803
|
+
createdat: (input.now ?? Date.now)(),
|
|
804
|
+
};
|
|
805
|
+
return { replan, output };
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
/** Reflects one executed step: the model call reads the step outcome with the running lessons of the earlier steps and answers the lesson learned plus the advice for the next step; the note records its run, step and model provenance. */
|
|
809
|
+
export async function reflectstep(input: {
|
|
810
|
+
provider: providerconfig;
|
|
811
|
+
model: string;
|
|
812
|
+
runid: string;
|
|
813
|
+
stepid: string;
|
|
814
|
+
outcome: string;
|
|
815
|
+
lessons?: string[];
|
|
816
|
+
apikey?: string;
|
|
817
|
+
transport: fetchtransport;
|
|
818
|
+
sleep?: (milliseconds: number) => Promise<void>;
|
|
819
|
+
now?: () => number;
|
|
820
|
+
}): Promise<{ note?: reflectnote; output?: modeloutput; reason?: string }> {
|
|
821
|
+
if (input.outcome.trim() === "") return { reason: "The reflection needs the step outcome." };
|
|
822
|
+
const lessons = input.lessons ?? [];
|
|
823
|
+
const answer = await callmodel({
|
|
824
|
+
provider: input.provider,
|
|
825
|
+
model: input.model,
|
|
826
|
+
messages: [
|
|
827
|
+
{
|
|
828
|
+
role: "system",
|
|
829
|
+
content: `Reflect on the executed step ${input.stepid} of the run ${input.runid} with the outcome: ${input.outcome}.${lessons.length > 0 ? ` The running lessons of the earlier steps: ${lessons.join(" | ")}.` : ""} Answer as json with the fields outcome (string), lesson (string) and advice (string) for the next step. Answer with the json object only.`,
|
|
830
|
+
},
|
|
831
|
+
{ role: "user", content: input.outcome },
|
|
832
|
+
],
|
|
833
|
+
...(input.apikey !== undefined ? { apikey: input.apikey } : {}),
|
|
834
|
+
transport: input.transport,
|
|
835
|
+
...(input.sleep !== undefined ? { sleep: input.sleep } : {}),
|
|
836
|
+
...(input.now !== undefined ? { now: input.now } : {}),
|
|
837
|
+
});
|
|
838
|
+
const guard: parseguard = {
|
|
839
|
+
schema: {
|
|
840
|
+
outcome: { type: "string", required: true },
|
|
841
|
+
lesson: { type: "string", required: true },
|
|
842
|
+
advice: { type: "string", required: true },
|
|
843
|
+
},
|
|
844
|
+
retries: 1,
|
|
845
|
+
};
|
|
846
|
+
const output = guardoutput({ guard, attempts: [answer.text] });
|
|
847
|
+
if (output.verdict !== "valid" || output.parsed === undefined)
|
|
848
|
+
return { output, reason: output.reason ?? "The reflection answer failed its guard." };
|
|
849
|
+
const parsed = output.parsed;
|
|
850
|
+
if (typeof parsed.lesson !== "string" || typeof parsed.advice !== "string")
|
|
851
|
+
return { output, reason: "The reflection answer carries no lesson or advice." };
|
|
852
|
+
const note: reflectnote = {
|
|
853
|
+
id: randomid(),
|
|
854
|
+
runid: input.runid,
|
|
855
|
+
stepid: input.stepid,
|
|
856
|
+
outcome: typeof parsed.outcome === "string" ? parsed.outcome : input.outcome,
|
|
857
|
+
lesson: parsed.lesson,
|
|
858
|
+
advice: parsed.advice,
|
|
859
|
+
providerid: input.provider.id,
|
|
860
|
+
model: input.model,
|
|
861
|
+
createdat: (input.now ?? Date.now)(),
|
|
862
|
+
};
|
|
863
|
+
return { note, output };
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
/** Summarizes the running lessons of the reflection notes so the next prompt carries them; the newest lesson of every step rides the summary in step order. */
|
|
867
|
+
export function reflectionsummary(notes: reflectnote[]): string {
|
|
868
|
+
const latest = new Map<string, reflectnote>();
|
|
869
|
+
for (const note of notes) latest.set(note.stepid, note);
|
|
870
|
+
const lessons = [...latest.values()].sort((one, two) => one.createdat - two.createdat).map((note) => note.lesson);
|
|
871
|
+
return lessons.length === 0 ? "" : lessons.join(" | ");
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
/** Strips the guardrail noise of one model answer before parsing: code fences open the payload, chatter lines around a json object fall away and a fenced block without a language tag keeps its inner text. The fence runs through plain index scans, because a regex over the fence prefix would backtrack polynomially on adversarial answers built from repeated fence markers. */
|
|
875
|
+
export function stripguardrails(text: string): string {
|
|
876
|
+
let candidate = text;
|
|
877
|
+
const open = text.indexOf("```");
|
|
878
|
+
if (open !== -1) {
|
|
879
|
+
let cursor = open + 3;
|
|
880
|
+
while (cursor < text.length) {
|
|
881
|
+
const tag = text[cursor] ?? "";
|
|
882
|
+
if (!((tag >= "a" && tag <= "z") || (tag >= "A" && tag <= "Z"))) break;
|
|
883
|
+
cursor += 1;
|
|
884
|
+
} /* the language tag, either case */
|
|
885
|
+
while (cursor < text.length) {
|
|
886
|
+
const space = text[cursor] ?? "";
|
|
887
|
+
if (space !== " " && space !== "\t" && space !== "\r" && space !== "\n") break;
|
|
888
|
+
cursor += 1;
|
|
889
|
+
} /* the whitespace after the tag */
|
|
890
|
+
const close = text.indexOf("```", cursor);
|
|
891
|
+
if (close !== -1) candidate = text.slice(cursor, close);
|
|
892
|
+
}
|
|
893
|
+
const start = candidate.indexOf("{");
|
|
894
|
+
const end = candidate.lastIndexOf("}");
|
|
895
|
+
if (start >= 0 && end > start) return candidate.slice(start, end + 1);
|
|
896
|
+
const arraystart = candidate.indexOf("[");
|
|
897
|
+
const arrayend = candidate.lastIndexOf("]");
|
|
898
|
+
if (arraystart >= 0 && arrayend > arraystart) return candidate.slice(arraystart, arrayend + 1);
|
|
899
|
+
return candidate.trim();
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
/** Validates one stripped model answer against the guard schema: every required field must be present with its declared type while optional fields pass through; the verdict reports invalid with the reason otherwise. */
|
|
903
|
+
export function parseoutput(input: { guard: parseguard; text: string }): modeloutput {
|
|
904
|
+
const raw = input.text;
|
|
905
|
+
const stripped = stripguardrails(raw);
|
|
906
|
+
const markers = input.guard.refusalmarkers ?? defaultrefusalmarkers;
|
|
907
|
+
const lowered = stripped.toLowerCase();
|
|
908
|
+
for (const marker of markers)
|
|
909
|
+
if (marker.trim() !== "" && lowered.includes(marker.toLowerCase()))
|
|
910
|
+
return { raw, verdict: "refused", reason: `The model answer carries the refusal marker ${marker}.`, attempts: 1 };
|
|
911
|
+
let parsed: unknown;
|
|
912
|
+
try {
|
|
913
|
+
parsed = JSON.parse(stripped);
|
|
914
|
+
} catch {
|
|
915
|
+
return { raw, verdict: "invalid", reason: "The model answer is not json after the guardrail strip.", attempts: 1 };
|
|
916
|
+
}
|
|
917
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
918
|
+
return { raw, verdict: "invalid", reason: "The model answer is not a json object.", attempts: 1 };
|
|
919
|
+
const record = parsed as Record<string, unknown>;
|
|
920
|
+
for (const [name, field] of Object.entries(input.guard.schema)) {
|
|
921
|
+
const value = record[name];
|
|
922
|
+
if (value === undefined || value === null) {
|
|
923
|
+
if (field.required === true)
|
|
924
|
+
return {
|
|
925
|
+
raw,
|
|
926
|
+
verdict: "invalid",
|
|
927
|
+
reason: `The required field ${name} of the expected schema is missing.`,
|
|
928
|
+
attempts: 1,
|
|
929
|
+
};
|
|
930
|
+
continue;
|
|
931
|
+
}
|
|
932
|
+
const actual = Array.isArray(value) ? "array" : typeof value;
|
|
933
|
+
if (actual !== field.type)
|
|
934
|
+
return {
|
|
935
|
+
raw,
|
|
936
|
+
verdict: "invalid",
|
|
937
|
+
reason: `The field ${name} carries a ${actual} value where the schema asks a ${field.type}.`,
|
|
938
|
+
attempts: 1,
|
|
939
|
+
};
|
|
940
|
+
}
|
|
941
|
+
return { raw, parsed: record, verdict: "valid", attempts: 1 };
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
/** Runs the parse guard over the model answer attempts: a valid attempt wins, a refusal marker refuses without retries, malformed answers retry up to the configured count and the exhaustion of every attempt refuses the output so nothing invalid ever executes. */
|
|
945
|
+
export function guardoutput(input: { guard: parseguard; attempts: string[] }): modeloutput {
|
|
946
|
+
const limit = Math.max(1, Math.floor(input.guard.retries) + 1);
|
|
947
|
+
const attempts = input.attempts.slice(0, limit);
|
|
948
|
+
let last: modeloutput | undefined;
|
|
949
|
+
for (let index = 0; index < attempts.length; index += 1) {
|
|
950
|
+
const output = parseoutput({ guard: input.guard, text: attempts[index] ?? "" });
|
|
951
|
+
last = { ...output, attempts: index + 1 };
|
|
952
|
+
if (output.verdict === "valid") return last;
|
|
953
|
+
if (output.verdict === "refused") return { ...output, attempts: index + 1 };
|
|
954
|
+
}
|
|
955
|
+
const exhausted: modeloutput =
|
|
956
|
+
last === undefined
|
|
957
|
+
? { raw: "", verdict: "invalid", reason: "The model answer never arrived.", attempts: 0 }
|
|
958
|
+
: {
|
|
959
|
+
...last,
|
|
960
|
+
verdict: "invalid",
|
|
961
|
+
reason: `${last.reason ?? "The model answer failed its guard."} Every retry attempt failed, so the guard refuses the output and nothing executes.`,
|
|
962
|
+
};
|
|
963
|
+
return exhausted;
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
/** Builds the openapi style tool brief of one catalog tool: the name, the summary line, the description, the risk class and the typed parameter list with their required markers. */
|
|
967
|
+
export function toolbriefof(tool: tooldef): toolbrief {
|
|
968
|
+
return {
|
|
969
|
+
tool: tool.name,
|
|
970
|
+
summary: `${tool.name}: ${tool.description.split(".")[0] ?? tool.description}.`,
|
|
971
|
+
description: tool.description,
|
|
972
|
+
risk: tool.risk,
|
|
973
|
+
parameters: Object.entries(tool.inputschema.properties).map(([name, property]) => ({
|
|
974
|
+
name,
|
|
975
|
+
type: property.type,
|
|
976
|
+
description: property.description,
|
|
977
|
+
required: property.required === true,
|
|
978
|
+
})),
|
|
979
|
+
};
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
/** Renders the tool briefs in openapi style for model consumption: every tool lists its name, summary, risk class and typed parameters so the model knows the surface it may propose; the consent notice states that side effects need the named approved step. */
|
|
983
|
+
export function rendertoolbriefs(tools: tooldef[]): string {
|
|
984
|
+
const blocks = tools.map((tool) => {
|
|
985
|
+
const brief = toolbriefof(tool);
|
|
986
|
+
const parameters = brief.parameters
|
|
987
|
+
.map(
|
|
988
|
+
(parameter) =>
|
|
989
|
+
` - name: ${parameter.name}\n type: ${parameter.type}\n required: ${parameter.required ? "true" : "false"}\n description: ${parameter.description}`,
|
|
990
|
+
)
|
|
991
|
+
.join("\n");
|
|
992
|
+
return ` - tool: ${brief.tool}\n summary: ${brief.summary}\n risk: ${brief.risk}\n parameters:\n${parameters}`;
|
|
993
|
+
});
|
|
994
|
+
return `tools:\n${blocks.join("\n")}\nconsent: every tool with side effects executes only the approved plan step it names; a proposal without the approved step stays refused.`;
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
/** Records one usage entry of a model call: the run and step ids ride the record together with the provider, the endpoint, the model, the token counts and the cost; newer records prepend so the newest call reads first. */
|
|
998
|
+
export function addusage(records: usagerecord[], record: usagerecord): usagerecord[] {
|
|
999
|
+
return [record, ...records];
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
/** Aggregates the usage records per run, per step, per session and per period: the prompt, completion and total token counts, the cost total and the call count of every record the filter keeps; the session filter of the 1.1.83 gateway family reports the per session totals beside the per run totals. */
|
|
1003
|
+
export function usagetotals(
|
|
1004
|
+
records: usagerecord[],
|
|
1005
|
+
filter: { runid?: string; stepid?: string; sessionid?: string; since?: number; until?: number } = {},
|
|
1006
|
+
): { prompttokens: number; completiontokens: number; totaltokens: number; cost: number; calls: number } {
|
|
1007
|
+
const kept = records.filter(
|
|
1008
|
+
(record) =>
|
|
1009
|
+
(filter.runid === undefined || record.runid === filter.runid) &&
|
|
1010
|
+
(filter.stepid === undefined || record.stepid === filter.stepid) &&
|
|
1011
|
+
(filter.sessionid === undefined || record.sessionid === filter.sessionid) &&
|
|
1012
|
+
(filter.since === undefined || record.at >= filter.since) &&
|
|
1013
|
+
(filter.until === undefined || record.at <= filter.until),
|
|
1014
|
+
);
|
|
1015
|
+
return kept.reduce(
|
|
1016
|
+
(totals, record) => ({
|
|
1017
|
+
prompttokens: totals.prompttokens + record.prompttokens,
|
|
1018
|
+
completiontokens: totals.completiontokens + record.completiontokens,
|
|
1019
|
+
totaltokens: totals.totaltokens + record.totaltokens,
|
|
1020
|
+
cost: totals.cost + record.cost,
|
|
1021
|
+
calls: totals.calls + 1,
|
|
1022
|
+
}),
|
|
1023
|
+
{ prompttokens: 0, completiontokens: 0, totaltokens: 0, cost: 0, calls: 0 },
|
|
1024
|
+
);
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
/** Checks the cost budget of a run: a reached token or currency ceiling halts the run and asks the user before anything else runs, while an absent ceiling stays unbounded because every ceiling is a user choice. */
|
|
1028
|
+
export function budgetcheck(input: { budget: costbudget | undefined; totals: { totaltokens: number; cost: number } }): {
|
|
1029
|
+
allowed: boolean;
|
|
1030
|
+
halted: boolean;
|
|
1031
|
+
asksuser: boolean;
|
|
1032
|
+
reason?: string;
|
|
1033
|
+
} {
|
|
1034
|
+
if (input.budget === undefined) return { allowed: true, halted: false, asksuser: false };
|
|
1035
|
+
if (
|
|
1036
|
+
input.budget.maxtokens !== undefined &&
|
|
1037
|
+
Number.isFinite(input.budget.maxtokens) &&
|
|
1038
|
+
input.totals.totaltokens >= input.budget.maxtokens
|
|
1039
|
+
)
|
|
1040
|
+
return {
|
|
1041
|
+
allowed: false,
|
|
1042
|
+
halted: true,
|
|
1043
|
+
asksuser: true,
|
|
1044
|
+
reason: `The run reached the user configured token ceiling of ${input.budget.maxtokens} and halts until the user answers.`,
|
|
1045
|
+
};
|
|
1046
|
+
if (
|
|
1047
|
+
input.budget.maxcost !== undefined &&
|
|
1048
|
+
Number.isFinite(input.budget.maxcost) &&
|
|
1049
|
+
input.totals.cost >= input.budget.maxcost
|
|
1050
|
+
)
|
|
1051
|
+
return {
|
|
1052
|
+
allowed: false,
|
|
1053
|
+
halted: true,
|
|
1054
|
+
asksuser: true,
|
|
1055
|
+
reason: `The run reached the user configured cost ceiling of ${input.budget.maxcost} and halts until the user answers.`,
|
|
1056
|
+
};
|
|
1057
|
+
return { allowed: true, halted: false, asksuser: false };
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
/* ── Merged from modelroute.ts ── */
|
|
1061
|
+
|
|
1062
|
+
/**
|
|
1063
|
+
* Model routing of the 1.1.57 llm integration family.
|
|
1064
|
+
* Every routing concern lives in this file: the task kind resolution that maps a task kind onto its preferred provider and model, the availability marking of providers that failed their last call, the fallback resolution on refusal or outage with the user configured fallback pair and the route validation that keeps every entry a user configured choice.
|
|
1065
|
+
* No default route, no default model and no default provider ever applies: an unrouted task kind reports why nothing routed and the user picks the pair.
|
|
1066
|
+
*/
|
|
1067
|
+
|
|
1068
|
+
/** Validates one model route entry: the task kind, the provider and the model stay user configured values and the fallback pair needs both halves or none. */
|
|
1069
|
+
export function routevalid(route: modelroute): policyevaluation {
|
|
1070
|
+
if (route.kind.trim() === "") return { allowed: false, reason: "The model route needs its task kind." };
|
|
1071
|
+
if (route.providerid.trim() === "")
|
|
1072
|
+
return { allowed: false, reason: "The model route needs the provider it routes to." };
|
|
1073
|
+
if (route.model.trim() === "")
|
|
1074
|
+
return { allowed: false, reason: "The model route needs the model name it routes to." };
|
|
1075
|
+
const hasfallbackprovider = route.fallbackproviderid !== undefined && route.fallbackproviderid.trim() !== "";
|
|
1076
|
+
const hasfallbackmodel = route.fallbackmodel !== undefined && route.fallbackmodel.trim() !== "";
|
|
1077
|
+
if (hasfallbackprovider !== hasfallbackmodel)
|
|
1078
|
+
return { allowed: false, reason: "The fallback of a model route needs its provider and its model together." };
|
|
1079
|
+
return { allowed: true };
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
/** Returns every route of one task kind in revision order, newest revision first. */
|
|
1083
|
+
export function routesfor(routes: modelroute[], kind: string): modelroute[] {
|
|
1084
|
+
return routes.filter((route) => route.kind === kind).sort((one, two) => two.revision - one.revision);
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
/** Resolves the provider and model for one task kind: the newest route of the kind whose provider exists and stays available wins; an unrouted kind, a missing provider or an unavailable provider reports why nothing routed. */
|
|
1088
|
+
export function resolveroute(input: { routes: modelroute[]; providers: providerconfig[]; kind: string }): {
|
|
1089
|
+
route?: modelroute;
|
|
1090
|
+
provider?: providerconfig;
|
|
1091
|
+
model?: string;
|
|
1092
|
+
reason?: string;
|
|
1093
|
+
} {
|
|
1094
|
+
const candidates = routesfor(input.routes, input.kind);
|
|
1095
|
+
if (candidates.length === 0)
|
|
1096
|
+
return {
|
|
1097
|
+
reason: `No model route configures the task kind ${input.kind}; the user picks the provider and model pair.`,
|
|
1098
|
+
};
|
|
1099
|
+
for (const route of candidates) {
|
|
1100
|
+
if (!routevalid(route).allowed) continue;
|
|
1101
|
+
const provider = input.providers.find((candidate) => candidate.id === route.providerid);
|
|
1102
|
+
if (provider === undefined)
|
|
1103
|
+
return { reason: `The route of ${input.kind} names the missing provider ${route.providerid}.` };
|
|
1104
|
+
if (provider.status === "unavailable")
|
|
1105
|
+
return {
|
|
1106
|
+
reason: `The provider ${provider.name} of the route of ${input.kind} stays marked unavailable from its last failure.`,
|
|
1107
|
+
};
|
|
1108
|
+
if (!provider.models.includes(route.model))
|
|
1109
|
+
return {
|
|
1110
|
+
reason: `The route of ${input.kind} names the model ${route.model} outside the model list of ${provider.name}.`,
|
|
1111
|
+
};
|
|
1112
|
+
return { route, provider, model: route.model };
|
|
1113
|
+
}
|
|
1114
|
+
return { reason: `Every route of the task kind ${input.kind} failed its validation.` };
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
/** Marks one provider unavailable after a failed call or available after a successful test; every other provider stays untouched. */
|
|
1118
|
+
export function markprovider(input: {
|
|
1119
|
+
providers: providerconfig[];
|
|
1120
|
+
providerid: string;
|
|
1121
|
+
available: boolean;
|
|
1122
|
+
now: number;
|
|
1123
|
+
}): providerconfig[] {
|
|
1124
|
+
return input.providers.map((provider) =>
|
|
1125
|
+
provider.id === input.providerid
|
|
1126
|
+
? {
|
|
1127
|
+
...provider,
|
|
1128
|
+
status: input.available ? ("available" as const) : ("unavailable" as const),
|
|
1129
|
+
lastcheckedat: input.now,
|
|
1130
|
+
}
|
|
1131
|
+
: provider,
|
|
1132
|
+
);
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
/** Resolves the fallback pair of one task kind on refusal or outage: the user configured fallback provider and model win when they exist and stay available; a missing fallback reports why nothing fell back so the caller asks the user. */
|
|
1136
|
+
export function fallbackroute(input: { routes: modelroute[]; providers: providerconfig[]; kind: string }): {
|
|
1137
|
+
route?: modelroute;
|
|
1138
|
+
provider?: providerconfig;
|
|
1139
|
+
model?: string;
|
|
1140
|
+
reason?: string;
|
|
1141
|
+
} {
|
|
1142
|
+
const candidates = routesfor(input.routes, input.kind);
|
|
1143
|
+
const primary = candidates.find((route) => routevalid(route).allowed);
|
|
1144
|
+
if (primary === undefined)
|
|
1145
|
+
return { reason: `No valid route configures the task kind ${input.kind}, so no fallback applies.` };
|
|
1146
|
+
if (primary.fallbackproviderid === undefined || primary.fallbackmodel === undefined)
|
|
1147
|
+
return { reason: `The route of ${input.kind} carries no user configured fallback pair.` };
|
|
1148
|
+
const provider = input.providers.find((candidate) => candidate.id === primary.fallbackproviderid);
|
|
1149
|
+
if (provider === undefined)
|
|
1150
|
+
return { reason: `The fallback names the missing provider ${primary.fallbackproviderid}.` };
|
|
1151
|
+
if (provider.status === "unavailable")
|
|
1152
|
+
return { reason: `The fallback provider ${provider.name} stays marked unavailable from its last failure.` };
|
|
1153
|
+
if (!provider.models.includes(primary.fallbackmodel))
|
|
1154
|
+
return {
|
|
1155
|
+
reason: `The fallback names the model ${primary.fallbackmodel} outside the model list of ${provider.name}.`,
|
|
1156
|
+
};
|
|
1157
|
+
return { route: primary, provider, model: primary.fallbackmodel };
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
/** Bumps the revision of one route entry so the revision history of the routing table stays queryable; the newest revision of a kind wins the resolution. */
|
|
1161
|
+
export function bumprevision(route: modelroute, now: number): modelroute {
|
|
1162
|
+
return { ...route, revision: route.revision + 1, updatedat: now };
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
/* ── Merged from promptlibrary.ts ── */
|
|
1166
|
+
|
|
1167
|
+
/**
|
|
1168
|
+
* Prompt template library of the 1.1.57 llm integration family.
|
|
1169
|
+
* Every template concern lives in this file: the variable extraction from double braced placeholders, the template render that fills the variables, the consent notice requirement of sensitive flows, the versioning of every saved template with its change notes and the library search.
|
|
1170
|
+
* The library holds user templates only: no built-in template ever ships, so every prompt body stays a user reviewed choice.
|
|
1171
|
+
*/
|
|
1172
|
+
|
|
1173
|
+
/** Extracts the double braced variable names of one template body in order of appearance, deduplicated. */
|
|
1174
|
+
export function templatevariables(body: string): string[] {
|
|
1175
|
+
const names: string[] = [];
|
|
1176
|
+
for (const match of body.matchAll(/\{\{\s*([a-z0-9]+)\s*\}\}/g)) {
|
|
1177
|
+
const name = match[1] ?? "";
|
|
1178
|
+
if (name !== "" && !names.includes(name)) names.push(name);
|
|
1179
|
+
}
|
|
1180
|
+
return names;
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
/** Renders one template with its variables: every declared variable substitutes its value while a sensitive flow needs its consent notice before the render leaves; the notice rides the rendered text so the reviewer sees the consent line. */
|
|
1184
|
+
export function rendertemplate(input: {
|
|
1185
|
+
template: prompttemplate;
|
|
1186
|
+
variables?: Record<string, unknown>;
|
|
1187
|
+
sensitive?: boolean;
|
|
1188
|
+
consentnotice?: string;
|
|
1189
|
+
}): { text?: string; reason?: string } {
|
|
1190
|
+
if (input.sensitive === true && (input.consentnotice === undefined || input.consentnotice.trim() === ""))
|
|
1191
|
+
return { reason: "The sensitive flow needs its consent notice before the template renders." };
|
|
1192
|
+
const variables = input.variables ?? {};
|
|
1193
|
+
const missing = input.template.variables.filter(
|
|
1194
|
+
(name) =>
|
|
1195
|
+
variables[name] === undefined ||
|
|
1196
|
+
variables[name] === null ||
|
|
1197
|
+
(typeof variables[name] === "string" && (variables[name] as string).trim() === ""),
|
|
1198
|
+
);
|
|
1199
|
+
if (missing.length > 0) return { reason: `The template variables ${missing.join(", ")} stay empty.` };
|
|
1200
|
+
let text = input.template.body.replace(/\{\{\s*([a-z0-9]+)\s*\}\}/g, (whole, name: string) => {
|
|
1201
|
+
const value = variables[name];
|
|
1202
|
+
if (value === undefined || value === null) return whole;
|
|
1203
|
+
return typeof value === "string" ? value : JSON.stringify(value);
|
|
1204
|
+
});
|
|
1205
|
+
if (input.sensitive === true && input.consentnotice !== undefined)
|
|
1206
|
+
text = `${text}\nConsent notice: ${input.consentnotice}`;
|
|
1207
|
+
return { text };
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
/** Saves one template version: a new name creates the first version while a known name bumps the version with its change notes; every earlier version stays stored so the history never rewrites. */
|
|
1211
|
+
export function savetemplate(input: {
|
|
1212
|
+
templates: prompttemplate[];
|
|
1213
|
+
name: string;
|
|
1214
|
+
body: string;
|
|
1215
|
+
notes?: string;
|
|
1216
|
+
now: number;
|
|
1217
|
+
}): prompttemplate[] {
|
|
1218
|
+
const existing = input.templates.filter((template) => template.name === input.name);
|
|
1219
|
+
const version = existing.length === 0 ? 1 : Math.max(...existing.map((template) => template.version)) + 1;
|
|
1220
|
+
const record: prompttemplate = {
|
|
1221
|
+
id: randomid(),
|
|
1222
|
+
name: input.name,
|
|
1223
|
+
body: input.body,
|
|
1224
|
+
variables: templatevariables(input.body),
|
|
1225
|
+
version,
|
|
1226
|
+
...(input.notes !== undefined && input.notes.trim() !== "" ? { notes: input.notes } : {}),
|
|
1227
|
+
createdat: input.now,
|
|
1228
|
+
};
|
|
1229
|
+
return [record, ...input.templates];
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
/** Returns the newest stored version of one template name; an unknown name reports undefined. */
|
|
1233
|
+
export function latesttemplate(templates: prompttemplate[], name: string): prompttemplate | undefined {
|
|
1234
|
+
const versions = templates.filter((template) => template.name === name);
|
|
1235
|
+
return versions.length === 0
|
|
1236
|
+
? undefined
|
|
1237
|
+
: versions.reduce((newest, template) => (template.version > newest.version ? template : newest));
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
/** Searches the library: the query matches the template name, the body text, the change notes or one of the declared variables, newest version first. */
|
|
1241
|
+
export function searchtemplates(templates: prompttemplate[], query: string): prompttemplate[] {
|
|
1242
|
+
const term = query.trim().toLowerCase();
|
|
1243
|
+
const matches =
|
|
1244
|
+
term === ""
|
|
1245
|
+
? templates
|
|
1246
|
+
: templates.filter(
|
|
1247
|
+
(template) =>
|
|
1248
|
+
template.name.toLowerCase().includes(term) ||
|
|
1249
|
+
template.body.toLowerCase().includes(term) ||
|
|
1250
|
+
(template.notes ?? "").toLowerCase().includes(term) ||
|
|
1251
|
+
template.variables.some((variable) => variable.toLowerCase().includes(term)),
|
|
1252
|
+
);
|
|
1253
|
+
return [...matches].sort((one, two) => two.version - one.version || two.createdat - one.createdat);
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
/** Removes every version of one template name from the library; the change history leaves with the name. */
|
|
1257
|
+
export function removetemplate(templates: prompttemplate[], name: string): prompttemplate[] {
|
|
1258
|
+
return templates.filter((template) => template.name !== name);
|
|
1259
|
+
}
|
|
1260
|
+
/* ── Merged: the grand merge section ── the correlated provider gateway adapter logics of the merged repository interned here, one surface without duplicate variations. ── */
|
|
1261
|
+
|
|
1262
|
+
/**
|
|
1263
|
+
* Provider gateway logics of the 1.1.83 family.
|
|
1264
|
+
* Every provider adapter concern lives in this file: the adapter interface one request, stream and cancel contract shares, the four adapters (the openaicompat chat completions shape with its streamed deltas, the anthropicgateway messages shape with its streamed content blocks and its system prompt mapping, the geminigateway generate content shape with its streamed candidate parts and its tool schema mapping, and the ollamalocal localhost runtime with its local model listing), the baseurlconfig one endpoint url per provider with its scheme, host and path shape validation, the per provider key vault behind the secretvault seam, the modellist discovery with its configurable cache window, the capabilityad tool catalog advertisement, the routing of task kinds onto providers with the local fallback, the token budget accounting with its warning threshold, the guardrails over every parsed model answer with the structured retry prompt and the capped retries, and the prompt template library of the per task templates with their variable slots.
|
|
1265
|
+
* The module composes with the llm provider model instead of duplicating it: the three remote adapters speak through the same request shaping, answer parsing and stream parsing the llm module owns, while the ollamalocal adapter speaks its own localhost wire format. Nothing is hardcoded: no remote endpoint, no model name and no key ever leaves the user configuration, the ollamalocal adapter alone defaults to the localhost machine and never to a cloud url, every timeout, retry, backoff and cache window stays a user choice, all http rides the injected fetch transport seam and every key resolves through the vault seam at the last possible moment.
|
|
1266
|
+
*/
|
|
1267
|
+
|
|
1268
|
+
/** The local machine base url the ollamalocal adapter alone defaults to: a loopback address of the machine itself, never a cloud url — the explicit contract exception, while every remote provider ships an empty base url until the user configures one. */
|
|
1269
|
+
export const ollamalocaldefault = "http://localhost:11434";
|
|
1270
|
+
|
|
1271
|
+
/** The provider kinds of the gateway family in their stable order; every surface that lists the adapters reads this one list. */
|
|
1272
|
+
export const gatewaykinds: gatewaykind[] = ["openaicompat", "anthropicgateway", "geminigateway", "ollamalocal"];
|
|
1273
|
+
|
|
1274
|
+
/** Reads the default base url of one provider kind: the ollamalocal runtime alone carries the localhost machine default and every remote kind carries none, so no provider endpoint is contacted unless the user configured one. */
|
|
1275
|
+
export function defaultbaseurl(kind: gatewaykind): string {
|
|
1276
|
+
return kind === "ollamalocal" ? ollamalocaldefault : "";
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
/** Joins the user configured base url, the optional per provider path prefix and the wire path of the adapter into the request url; the base keeps its trailing shape and the prefix sits between the base and the wire path. The slash trims run through plain character walks, because a leading or trailing slash regex would itself read as polynomial on adversarial bases. */
|
|
1280
|
+
export function gatewayurl(config: Pick<baseurlconfig, "baseurl" | "pathprefix">, path: string): string {
|
|
1281
|
+
const base = trailingslashes(config.baseurl.trim());
|
|
1282
|
+
const trimmedprefix = (config.pathprefix ?? "").trim();
|
|
1283
|
+
const prefix = leadingslashes(trailingslashes(trimmedprefix));
|
|
1284
|
+
const wire = path.startsWith("/") ? path : `/${path}`;
|
|
1285
|
+
return `${base}${prefix !== "" ? `/${prefix}` : ""}${wire}`;
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
/** Trims the trailing forward slashes of one string through a plain character walk, because a trailing slash regex would itself read as polynomial on adversarial bases. */
|
|
1289
|
+
function trailingslashes(value: string): string {
|
|
1290
|
+
let end = value.length;
|
|
1291
|
+
while (end > 0 && value.charCodeAt(end - 1) === 0x2f) end -= 1;
|
|
1292
|
+
return value.slice(0, end);
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
/** Trims the leading forward slashes of one string through a plain character walk, because a leading slash regex would itself read as polynomial on adversarial prefixes. */
|
|
1296
|
+
function leadingslashes(value: string): string {
|
|
1297
|
+
let start = 0;
|
|
1298
|
+
while (start < value.length && value.charCodeAt(start) === 0x2f) start += 1;
|
|
1299
|
+
return value.slice(start);
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
/** The input every adapter shapes into one wire request: the provider config, the model, the conversation, the resolved key material, the sampling choices, the stream flag, the tool catalog the capabilityad advertises and the extra headers the user reviewed. */
|
|
1303
|
+
export interface gatewayrequestinput {
|
|
1304
|
+
config: baseurlconfig;
|
|
1305
|
+
model: string;
|
|
1306
|
+
messages: modelmessage[];
|
|
1307
|
+
/** Resolved key material; the value reaches the wire headers only and never a log, an audit entry or an export. */
|
|
1308
|
+
apikey?: string;
|
|
1309
|
+
temperature?: number;
|
|
1310
|
+
maxtokens?: number;
|
|
1311
|
+
stream?: boolean;
|
|
1312
|
+
/** The tool catalog the request advertises through the capabilityad serialization of the adapter. */
|
|
1313
|
+
tools?: tooldef[];
|
|
1314
|
+
headers?: Record<string, string>;
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
/** The provider adapter contract: every adapter builds its wire request, parses one answer body, parses one streamed body into ordered tokens, lists its models and serializes the tool catalog in its own wire format — one request, stream and cancel contract shared by all four. */
|
|
1318
|
+
export interface gatewayadapter {
|
|
1319
|
+
readonly kind: gatewaykind;
|
|
1320
|
+
/** The protocol style of the llm provider model this adapter extends, when it speaks one of them; the ollamalocal runtime speaks its own localhost wire format and carries none. */
|
|
1321
|
+
readonly style?: "chatcompletions" | "messages" | "gemini";
|
|
1322
|
+
/** The wire path of the chat endpoint relative to the base url; the gemini shape rides its model in the path. */
|
|
1323
|
+
chatpath(model: string): string;
|
|
1324
|
+
/** The wire path of the model list endpoint relative to the base url. */
|
|
1325
|
+
modelspath(): string;
|
|
1326
|
+
/** Builds one wire request: the url, the method, the headers with the key placed where the shape expects it and the body envelope the shape speaks, with the tool catalog embedded in the provider format when the request carries one. */
|
|
1327
|
+
build(input: gatewayrequestinput): ReturnType<typeof buildrequest>;
|
|
1328
|
+
/** Parses one answer body of the adapter wire format: the answer text and its usage counts, or the reason nothing parsed. */
|
|
1329
|
+
parseanswer(body: string): { text?: string; usage?: modelusage; reason?: string };
|
|
1330
|
+
/** Parses one streamed body of the adapter wire format into its ordered token stream: the chat completions deltas, the messages content blocks, the gemini candidate parts or the ollama message lines. */
|
|
1331
|
+
parsestream(body: string): tokenstream[];
|
|
1332
|
+
/** Parses one model list body of the adapter wire format into the discovered models with their context and modality hints when offered. */
|
|
1333
|
+
parsemodellist(body: string): gatewaymodelinfo[];
|
|
1334
|
+
/** Serializes the tool catalog into the tool schema format of the provider for the capabilityad advertisement. */
|
|
1335
|
+
toolcatalog(tools: tooldef[]): unknown[];
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
/** Reads one record field as a plain object when it is one. */
|
|
1339
|
+
function recordof(value: unknown): Record<string, unknown> | undefined {
|
|
1340
|
+
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
1341
|
+
? (value as Record<string, unknown>)
|
|
1342
|
+
: undefined;
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
/** Reads one record field as a finite number when it is one. */
|
|
1346
|
+
/** Reads the wire body of one shaped request as a record so the adapter merges its tool catalog, and answers the re-serialized body; a malformed body passes through untouched so the wire shape never silently changes. */
|
|
1347
|
+
function withbody(
|
|
1348
|
+
request: ReturnType<typeof buildrequest>,
|
|
1349
|
+
merge: (body: Record<string, unknown>) => void,
|
|
1350
|
+
): ReturnType<typeof buildrequest> {
|
|
1351
|
+
let parsed: unknown;
|
|
1352
|
+
try {
|
|
1353
|
+
parsed = JSON.parse(request.body);
|
|
1354
|
+
} catch {
|
|
1355
|
+
return request;
|
|
1356
|
+
}
|
|
1357
|
+
const record = recordof(parsed);
|
|
1358
|
+
if (record === undefined) return request;
|
|
1359
|
+
merge(record);
|
|
1360
|
+
return { ...request, body: JSON.stringify(record) };
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
/** The openai compatible chat completions adapter: any endpoint the user configures that speaks the chat completions shape works, the bearer header carries the resolved key, the streamed answer rides server sent event deltas and the tool catalog serializes into the function tool schema. */
|
|
1364
|
+
export const openaicompatadapter: gatewayadapter = {
|
|
1365
|
+
kind: "openaicompat",
|
|
1366
|
+
style: "chatcompletions",
|
|
1367
|
+
chatpath: () => "/chat/completions",
|
|
1368
|
+
modelspath: () => "/models",
|
|
1369
|
+
build: (input) => {
|
|
1370
|
+
const shaped = buildrequest({
|
|
1371
|
+
provider: {
|
|
1372
|
+
endpoint: gatewayurl(input.config, "/chat/completions"),
|
|
1373
|
+
style: "chatcompletions",
|
|
1374
|
+
...(input.headers !== undefined ? { headers: input.headers } : {}),
|
|
1375
|
+
},
|
|
1376
|
+
model: input.model,
|
|
1377
|
+
messages: input.messages,
|
|
1378
|
+
...(input.apikey !== undefined ? { apikey: input.apikey } : {}),
|
|
1379
|
+
...(input.temperature !== undefined ? { temperature: input.temperature } : {}),
|
|
1380
|
+
...(input.maxtokens !== undefined ? { maxtokens: input.maxtokens } : {}),
|
|
1381
|
+
...(input.stream === true ? { stream: true } : {}),
|
|
1382
|
+
});
|
|
1383
|
+
if (input.tools === undefined || input.tools.length === 0) return shaped;
|
|
1384
|
+
return withbody(shaped, (body) => {
|
|
1385
|
+
body.tools = input.tools?.map((tool) => ({
|
|
1386
|
+
type: "function",
|
|
1387
|
+
function: {
|
|
1388
|
+
name: tool.name,
|
|
1389
|
+
description: tool.description,
|
|
1390
|
+
parameters: {
|
|
1391
|
+
type: "object",
|
|
1392
|
+
properties: Object.fromEntries(
|
|
1393
|
+
Object.entries(tool.inputschema.properties).map(([name, property]) => [
|
|
1394
|
+
name,
|
|
1395
|
+
{
|
|
1396
|
+
type: property.type,
|
|
1397
|
+
description: property.description,
|
|
1398
|
+
...(property.required === true ? { required: true } : {}),
|
|
1399
|
+
...(property.default !== undefined ? { default: property.default } : {}),
|
|
1400
|
+
},
|
|
1401
|
+
]),
|
|
1402
|
+
),
|
|
1403
|
+
required: tool.inputschema.required,
|
|
1404
|
+
},
|
|
1405
|
+
},
|
|
1406
|
+
}));
|
|
1407
|
+
});
|
|
1408
|
+
},
|
|
1409
|
+
parseanswer: (body) => parsecompletion("chatcompletions", body),
|
|
1410
|
+
parsestream: (body) => parsestream("chatcompletions", body),
|
|
1411
|
+
parsemodellist: (body) => {
|
|
1412
|
+
let parsed: unknown;
|
|
1413
|
+
try {
|
|
1414
|
+
parsed = JSON.parse(body);
|
|
1415
|
+
} catch {
|
|
1416
|
+
return [];
|
|
1417
|
+
}
|
|
1418
|
+
const record = recordof(parsed);
|
|
1419
|
+
const list = record !== undefined && Array.isArray(record.data) ? record.data : undefined;
|
|
1420
|
+
if (list === undefined) return [];
|
|
1421
|
+
const models: gatewaymodelinfo[] = [];
|
|
1422
|
+
for (const item of list) {
|
|
1423
|
+
const entry = recordof(item);
|
|
1424
|
+
if (entry === undefined || typeof entry.id !== "string" || entry.id.trim() === "") continue;
|
|
1425
|
+
const context = numberof(entry["context_length"]) ?? numberof(entry["context_window"]);
|
|
1426
|
+
models.push({
|
|
1427
|
+
id: entry.id,
|
|
1428
|
+
...(typeof entry.label === "string" && entry.label.trim() !== "" ? { label: entry.label } : {}),
|
|
1429
|
+
...(context !== undefined ? { contextwindow: context } : {}),
|
|
1430
|
+
...(Array.isArray(entry.modalities)
|
|
1431
|
+
? { modalities: entry.modalities.filter((kind): kind is string => typeof kind === "string") }
|
|
1432
|
+
: {}),
|
|
1433
|
+
});
|
|
1434
|
+
}
|
|
1435
|
+
return models;
|
|
1436
|
+
},
|
|
1437
|
+
toolcatalog: (tools) =>
|
|
1438
|
+
tools.map((tool) => ({
|
|
1439
|
+
type: "function",
|
|
1440
|
+
function: {
|
|
1441
|
+
name: tool.name,
|
|
1442
|
+
description: tool.description,
|
|
1443
|
+
parameters: {
|
|
1444
|
+
type: "object",
|
|
1445
|
+
properties: Object.fromEntries(
|
|
1446
|
+
Object.entries(tool.inputschema.properties).map(([name, property]) => [
|
|
1447
|
+
name,
|
|
1448
|
+
{
|
|
1449
|
+
type: property.type,
|
|
1450
|
+
description: property.description,
|
|
1451
|
+
...(property.required === true ? { required: true } : {}),
|
|
1452
|
+
},
|
|
1453
|
+
]),
|
|
1454
|
+
),
|
|
1455
|
+
required: tool.inputschema.required,
|
|
1456
|
+
},
|
|
1457
|
+
},
|
|
1458
|
+
})),
|
|
1459
|
+
};
|
|
1460
|
+
|
|
1461
|
+
/** The anthropic messages adapter: the messages wire shape with its system prompt mapping to the top level system field, the streamed answer rides content block deltas and the tool catalog serializes into the input schema format. */
|
|
1462
|
+
export const anthropicgatewayadapter: gatewayadapter = {
|
|
1463
|
+
kind: "anthropicgateway",
|
|
1464
|
+
style: "messages",
|
|
1465
|
+
chatpath: () => "/v1/messages",
|
|
1466
|
+
modelspath: () => "/v1/models",
|
|
1467
|
+
build: (input) => {
|
|
1468
|
+
const shaped = buildrequest({
|
|
1469
|
+
provider: {
|
|
1470
|
+
endpoint: gatewayurl(input.config, "/v1/messages"),
|
|
1471
|
+
style: "messages",
|
|
1472
|
+
...(input.headers !== undefined ? { headers: input.headers } : {}),
|
|
1473
|
+
},
|
|
1474
|
+
model: input.model,
|
|
1475
|
+
messages: input.messages,
|
|
1476
|
+
...(input.apikey !== undefined ? { apikey: input.apikey } : {}),
|
|
1477
|
+
...(input.temperature !== undefined ? { temperature: input.temperature } : {}),
|
|
1478
|
+
...(input.maxtokens !== undefined ? { maxtokens: input.maxtokens } : {}),
|
|
1479
|
+
...(input.stream === true ? { stream: true } : {}),
|
|
1480
|
+
});
|
|
1481
|
+
if (input.tools === undefined || input.tools.length === 0) return shaped;
|
|
1482
|
+
return withbody(shaped, (body) => {
|
|
1483
|
+
body.tools = input.tools?.map((tool) => ({
|
|
1484
|
+
name: tool.name,
|
|
1485
|
+
description: tool.description,
|
|
1486
|
+
input_schema: {
|
|
1487
|
+
type: "object",
|
|
1488
|
+
properties: Object.fromEntries(
|
|
1489
|
+
Object.entries(tool.inputschema.properties).map(([name, property]) => [
|
|
1490
|
+
name,
|
|
1491
|
+
{
|
|
1492
|
+
type: property.type,
|
|
1493
|
+
description: property.description,
|
|
1494
|
+
...(property.required === true ? { required: true } : {}),
|
|
1495
|
+
},
|
|
1496
|
+
]),
|
|
1497
|
+
),
|
|
1498
|
+
required: tool.inputschema.required,
|
|
1499
|
+
},
|
|
1500
|
+
}));
|
|
1501
|
+
});
|
|
1502
|
+
},
|
|
1503
|
+
parseanswer: (body) => parsecompletion("messages", body),
|
|
1504
|
+
parsestream: (body) => parsestream("messages", body),
|
|
1505
|
+
parsemodellist: (body) => {
|
|
1506
|
+
let parsed: unknown;
|
|
1507
|
+
try {
|
|
1508
|
+
parsed = JSON.parse(body);
|
|
1509
|
+
} catch {
|
|
1510
|
+
return [];
|
|
1511
|
+
}
|
|
1512
|
+
const record = recordof(parsed);
|
|
1513
|
+
const list = record !== undefined && Array.isArray(record.data) ? record.data : undefined;
|
|
1514
|
+
if (list === undefined) return [];
|
|
1515
|
+
const models: gatewaymodelinfo[] = [];
|
|
1516
|
+
for (const item of list) {
|
|
1517
|
+
const entry = recordof(item);
|
|
1518
|
+
if (entry === undefined || typeof entry.id !== "string" || entry.id.trim() === "") continue;
|
|
1519
|
+
const context = numberof(entry["context_window"]);
|
|
1520
|
+
models.push({
|
|
1521
|
+
id: entry.id,
|
|
1522
|
+
...(typeof entry["display_name"] === "string" && (entry["display_name"] as string).trim() !== ""
|
|
1523
|
+
? { label: entry["display_name"] as string }
|
|
1524
|
+
: {}),
|
|
1525
|
+
...(context !== undefined ? { contextwindow: context } : {}),
|
|
1526
|
+
});
|
|
1527
|
+
}
|
|
1528
|
+
return models;
|
|
1529
|
+
},
|
|
1530
|
+
toolcatalog: (tools) =>
|
|
1531
|
+
tools.map((tool) => ({
|
|
1532
|
+
name: tool.name,
|
|
1533
|
+
description: tool.description,
|
|
1534
|
+
input_schema: {
|
|
1535
|
+
type: "object",
|
|
1536
|
+
properties: Object.fromEntries(
|
|
1537
|
+
Object.entries(tool.inputschema.properties).map(([name, property]) => [
|
|
1538
|
+
name,
|
|
1539
|
+
{
|
|
1540
|
+
type: property.type,
|
|
1541
|
+
description: property.description,
|
|
1542
|
+
...(property.required === true ? { required: true } : {}),
|
|
1543
|
+
},
|
|
1544
|
+
]),
|
|
1545
|
+
),
|
|
1546
|
+
required: tool.inputschema.required,
|
|
1547
|
+
},
|
|
1548
|
+
})),
|
|
1549
|
+
};
|
|
1550
|
+
|
|
1551
|
+
/** The gemini generate content adapter: the generate content wire shape with the model riding the path, the streamed answer rides candidate parts and the tool schema maps into the function declarations format. */
|
|
1552
|
+
export const geminigatewayadapter: gatewayadapter = {
|
|
1553
|
+
kind: "geminigateway",
|
|
1554
|
+
style: "gemini",
|
|
1555
|
+
chatpath: (model) => `/v1beta/models/${encodeURIComponent(model)}:generateContent`,
|
|
1556
|
+
modelspath: () => "/v1beta/models",
|
|
1557
|
+
build: (input) => {
|
|
1558
|
+
const shaped = buildrequest({
|
|
1559
|
+
provider: {
|
|
1560
|
+
endpoint: gatewayurl(input.config, geminigatewayadapter.chatpath(input.model)),
|
|
1561
|
+
style: "gemini",
|
|
1562
|
+
...(input.headers !== undefined ? { headers: input.headers } : {}),
|
|
1563
|
+
},
|
|
1564
|
+
model: input.model,
|
|
1565
|
+
messages: input.messages,
|
|
1566
|
+
...(input.apikey !== undefined ? { apikey: input.apikey } : {}),
|
|
1567
|
+
...(input.temperature !== undefined ? { temperature: input.temperature } : {}),
|
|
1568
|
+
...(input.maxtokens !== undefined ? { maxtokens: input.maxtokens } : {}),
|
|
1569
|
+
...(input.stream === true ? { stream: true } : {}),
|
|
1570
|
+
});
|
|
1571
|
+
if (input.tools === undefined || input.tools.length === 0) return shaped;
|
|
1572
|
+
return withbody(shaped, (body) => {
|
|
1573
|
+
body.tools = [
|
|
1574
|
+
{
|
|
1575
|
+
functionDeclarations: input.tools?.map((tool) => ({
|
|
1576
|
+
name: tool.name,
|
|
1577
|
+
description: tool.description,
|
|
1578
|
+
parameters: {
|
|
1579
|
+
type: "object",
|
|
1580
|
+
properties: Object.fromEntries(
|
|
1581
|
+
Object.entries(tool.inputschema.properties).map(([name, property]) => [
|
|
1582
|
+
name,
|
|
1583
|
+
{
|
|
1584
|
+
type: property.type,
|
|
1585
|
+
description: property.description,
|
|
1586
|
+
...(property.required === true ? { required: true } : {}),
|
|
1587
|
+
},
|
|
1588
|
+
]),
|
|
1589
|
+
),
|
|
1590
|
+
required: tool.inputschema.required,
|
|
1591
|
+
},
|
|
1592
|
+
})),
|
|
1593
|
+
},
|
|
1594
|
+
];
|
|
1595
|
+
});
|
|
1596
|
+
},
|
|
1597
|
+
parseanswer: (body) => parsecompletion("gemini", body),
|
|
1598
|
+
parsestream: (body) => parsestream("gemini", body),
|
|
1599
|
+
parsemodellist: (body) => {
|
|
1600
|
+
let parsed: unknown;
|
|
1601
|
+
try {
|
|
1602
|
+
parsed = JSON.parse(body);
|
|
1603
|
+
} catch {
|
|
1604
|
+
return [];
|
|
1605
|
+
}
|
|
1606
|
+
const record = recordof(parsed);
|
|
1607
|
+
const list = record !== undefined && Array.isArray(record.models) ? record.models : undefined;
|
|
1608
|
+
if (list === undefined) return [];
|
|
1609
|
+
const models: gatewaymodelinfo[] = [];
|
|
1610
|
+
for (const item of list) {
|
|
1611
|
+
const entry = recordof(item);
|
|
1612
|
+
if (entry === undefined || typeof entry.name !== "string" || entry.name.trim() === "") continue;
|
|
1613
|
+
const inputlimit = numberof(entry["inputTokenLimit"]);
|
|
1614
|
+
const methods = Array.isArray(entry["supportedGenerationMethods"])
|
|
1615
|
+
? (entry["supportedGenerationMethods"] as unknown[]).filter((kind): kind is string => typeof kind === "string")
|
|
1616
|
+
: undefined;
|
|
1617
|
+
models.push({
|
|
1618
|
+
id: entry.name.replace(/^models\//, ""),
|
|
1619
|
+
...(typeof entry.displayName === "string" && entry.displayName.trim() !== ""
|
|
1620
|
+
? { label: entry.displayName }
|
|
1621
|
+
: {}),
|
|
1622
|
+
...(inputlimit !== undefined ? { contextwindow: inputlimit } : {}),
|
|
1623
|
+
...(methods !== undefined ? { modalities: methods } : {}),
|
|
1624
|
+
});
|
|
1625
|
+
}
|
|
1626
|
+
return models;
|
|
1627
|
+
},
|
|
1628
|
+
toolcatalog: (tools) => [
|
|
1629
|
+
{
|
|
1630
|
+
functionDeclarations: tools.map((tool) => ({
|
|
1631
|
+
name: tool.name,
|
|
1632
|
+
description: tool.description,
|
|
1633
|
+
parameters: {
|
|
1634
|
+
type: "object",
|
|
1635
|
+
properties: Object.fromEntries(
|
|
1636
|
+
Object.entries(tool.inputschema.properties).map(([name, property]) => [
|
|
1637
|
+
name,
|
|
1638
|
+
{
|
|
1639
|
+
type: property.type,
|
|
1640
|
+
description: property.description,
|
|
1641
|
+
...(property.required === true ? { required: true } : {}),
|
|
1642
|
+
},
|
|
1643
|
+
]),
|
|
1644
|
+
),
|
|
1645
|
+
required: tool.inputschema.required,
|
|
1646
|
+
},
|
|
1647
|
+
})),
|
|
1648
|
+
},
|
|
1649
|
+
],
|
|
1650
|
+
};
|
|
1651
|
+
|
|
1652
|
+
/** The ollama local runtime adapter: it speaks its own localhost wire format (the chat endpoint with the message envelope, the tag list of the locally installed models and the newline delimited stream lines), the base url defaults to the local machine alone and never to a cloud url, and a call never leaves the machine. */
|
|
1653
|
+
export const ollamalocaladapter: gatewayadapter = {
|
|
1654
|
+
kind: "ollamalocal",
|
|
1655
|
+
chatpath: () => "/api/chat",
|
|
1656
|
+
modelspath: () => "/api/tags",
|
|
1657
|
+
build: (input) => {
|
|
1658
|
+
const headers: Record<string, string> = { "content-type": "application/json", ...(input.headers ?? {}) };
|
|
1659
|
+
if (input.apikey !== undefined && input.apikey.trim() !== "") headers.authorization = `Bearer ${input.apikey}`;
|
|
1660
|
+
const body: Record<string, unknown> = {
|
|
1661
|
+
model: input.model,
|
|
1662
|
+
messages: input.messages.map((message) => ({ role: message.role, content: message.content })),
|
|
1663
|
+
...(input.stream === true ? { stream: true } : {}),
|
|
1664
|
+
};
|
|
1665
|
+
if (input.tools !== undefined && input.tools.length > 0)
|
|
1666
|
+
body.tools = input.tools.map((tool) => ({
|
|
1667
|
+
type: "function",
|
|
1668
|
+
function: {
|
|
1669
|
+
name: tool.name,
|
|
1670
|
+
description: tool.description,
|
|
1671
|
+
parameters: {
|
|
1672
|
+
type: "object",
|
|
1673
|
+
properties: Object.fromEntries(
|
|
1674
|
+
Object.entries(tool.inputschema.properties).map(([name, property]) => [
|
|
1675
|
+
name,
|
|
1676
|
+
{
|
|
1677
|
+
type: property.type,
|
|
1678
|
+
description: property.description,
|
|
1679
|
+
...(property.required === true ? { required: true } : {}),
|
|
1680
|
+
},
|
|
1681
|
+
]),
|
|
1682
|
+
),
|
|
1683
|
+
required: tool.inputschema.required,
|
|
1684
|
+
},
|
|
1685
|
+
},
|
|
1686
|
+
}));
|
|
1687
|
+
return { url: gatewayurl(input.config, "/api/chat"), method: "POST", headers, body: JSON.stringify(body) };
|
|
1688
|
+
},
|
|
1689
|
+
parseanswer: (body) => {
|
|
1690
|
+
let parsed: unknown;
|
|
1691
|
+
try {
|
|
1692
|
+
parsed = JSON.parse(body);
|
|
1693
|
+
} catch {
|
|
1694
|
+
return { reason: "The ollama answer is not json." };
|
|
1695
|
+
}
|
|
1696
|
+
const record = recordof(parsed);
|
|
1697
|
+
if (record === undefined) return { reason: "The ollama answer is not a json object." };
|
|
1698
|
+
const message = recordof(record.message);
|
|
1699
|
+
if (message === undefined || typeof message.content !== "string")
|
|
1700
|
+
return { reason: "The ollama answer carries no message content." };
|
|
1701
|
+
const prompttokens = numberof(record["prompt_eval_count"]);
|
|
1702
|
+
const completiontokens = numberof(record["eval_count"]);
|
|
1703
|
+
return {
|
|
1704
|
+
text: message.content,
|
|
1705
|
+
...(prompttokens !== undefined || completiontokens !== undefined
|
|
1706
|
+
? {
|
|
1707
|
+
usage: {
|
|
1708
|
+
prompttokens: prompttokens ?? 0,
|
|
1709
|
+
completiontokens: completiontokens ?? 0,
|
|
1710
|
+
totaltokens: (prompttokens ?? 0) + (completiontokens ?? 0),
|
|
1711
|
+
},
|
|
1712
|
+
}
|
|
1713
|
+
: {}),
|
|
1714
|
+
};
|
|
1715
|
+
},
|
|
1716
|
+
parsestream: (body) => {
|
|
1717
|
+
const tokens: tokenstream[] = [];
|
|
1718
|
+
let seq = 0;
|
|
1719
|
+
let done = false;
|
|
1720
|
+
for (const line of body.split(/\r?\n/)) {
|
|
1721
|
+
const trimmed = line.trim();
|
|
1722
|
+
if (trimmed === "") continue;
|
|
1723
|
+
let parsed: unknown;
|
|
1724
|
+
try {
|
|
1725
|
+
parsed = JSON.parse(trimmed);
|
|
1726
|
+
} catch {
|
|
1727
|
+
continue;
|
|
1728
|
+
}
|
|
1729
|
+
const record = recordof(parsed);
|
|
1730
|
+
if (record === undefined) continue;
|
|
1731
|
+
const message = recordof(record.message);
|
|
1732
|
+
const text = message !== undefined && typeof message.content === "string" ? message.content : "";
|
|
1733
|
+
if (record.done === true) done = true;
|
|
1734
|
+
if (text === "") continue;
|
|
1735
|
+
seq += 1;
|
|
1736
|
+
tokens.push({ seq, text, done: false });
|
|
1737
|
+
}
|
|
1738
|
+
if (tokens.length > 0 && done)
|
|
1739
|
+
tokens[tokens.length - 1] = { ...(tokens[tokens.length - 1] as tokenstream), done: true };
|
|
1740
|
+
return tokens;
|
|
1741
|
+
},
|
|
1742
|
+
parsemodellist: (body) => {
|
|
1743
|
+
let parsed: unknown;
|
|
1744
|
+
try {
|
|
1745
|
+
parsed = JSON.parse(body);
|
|
1746
|
+
} catch {
|
|
1747
|
+
return [];
|
|
1748
|
+
}
|
|
1749
|
+
const record = recordof(parsed);
|
|
1750
|
+
const list = record !== undefined && Array.isArray(record.models) ? record.models : undefined;
|
|
1751
|
+
if (list === undefined) return [];
|
|
1752
|
+
const models: gatewaymodelinfo[] = [];
|
|
1753
|
+
for (const item of list) {
|
|
1754
|
+
const entry = recordof(item);
|
|
1755
|
+
if (entry === undefined || typeof entry.name !== "string" || entry.name.trim() === "") continue;
|
|
1756
|
+
const details = recordof(entry.details);
|
|
1757
|
+
const families =
|
|
1758
|
+
details !== undefined && Array.isArray(details.families)
|
|
1759
|
+
? (details.families as unknown[]).filter((kind): kind is string => typeof kind === "string")
|
|
1760
|
+
: undefined;
|
|
1761
|
+
models.push({
|
|
1762
|
+
id: entry.name,
|
|
1763
|
+
...(details !== undefined && typeof details["parameter_size"] === "string"
|
|
1764
|
+
? { label: `${entry.name} (${details["parameter_size"] as string})` }
|
|
1765
|
+
: {}),
|
|
1766
|
+
...(families !== undefined ? { modalities: families } : {}),
|
|
1767
|
+
});
|
|
1768
|
+
}
|
|
1769
|
+
return models;
|
|
1770
|
+
},
|
|
1771
|
+
toolcatalog: (tools) =>
|
|
1772
|
+
tools.map((tool) => ({
|
|
1773
|
+
type: "function",
|
|
1774
|
+
function: {
|
|
1775
|
+
name: tool.name,
|
|
1776
|
+
description: tool.description,
|
|
1777
|
+
parameters: {
|
|
1778
|
+
type: "object",
|
|
1779
|
+
properties: Object.fromEntries(
|
|
1780
|
+
Object.entries(tool.inputschema.properties).map(([name, property]) => [
|
|
1781
|
+
name,
|
|
1782
|
+
{
|
|
1783
|
+
type: property.type,
|
|
1784
|
+
description: property.description,
|
|
1785
|
+
...(property.required === true ? { required: true } : {}),
|
|
1786
|
+
},
|
|
1787
|
+
]),
|
|
1788
|
+
),
|
|
1789
|
+
required: tool.inputschema.required,
|
|
1790
|
+
},
|
|
1791
|
+
},
|
|
1792
|
+
})),
|
|
1793
|
+
};
|
|
1794
|
+
|
|
1795
|
+
/** Every adapter of the gateway family in its stable order; the surfaces and the tests read this one record. */
|
|
1796
|
+
export const gatewayadapters: Record<gatewaykind, gatewayadapter> = {
|
|
1797
|
+
openaicompat: openaicompatadapter,
|
|
1798
|
+
anthropicgateway: anthropicgatewayadapter,
|
|
1799
|
+
geminigateway: geminigatewayadapter,
|
|
1800
|
+
ollamalocal: ollamalocaladapter,
|
|
1801
|
+
};
|
|
1802
|
+
|
|
1803
|
+
/** Reads the adapter of one provider kind; an unknown kind refuses because the four adapters are the contract. */
|
|
1804
|
+
export function adapterof(kind: gatewaykind): gatewayadapter {
|
|
1805
|
+
const adapter = gatewayadapters[kind];
|
|
1806
|
+
if (adapter === undefined) throw new Error(`The gateway family carries no ${kind} adapter.`);
|
|
1807
|
+
return adapter;
|
|
1808
|
+
}
|
|
1809
|
+
|
|
1810
|
+
/** The cancel contract of the gateway family: one cancel state the caller creates, passes into the request and stream calls and cancels from any surface; the calls check the flag before the dispatch, between the attempts and after every streamed token. */
|
|
1811
|
+
export interface gatewaycancelstate {
|
|
1812
|
+
cancelled(): boolean;
|
|
1813
|
+
cancel(reason?: string): void;
|
|
1814
|
+
reason(): string;
|
|
1815
|
+
}
|
|
1816
|
+
|
|
1817
|
+
/** Creates one cancel state of the gateway stream contract; the state stays silent until the caller cancels it. */
|
|
1818
|
+
export function gatewaycancel(): gatewaycancelstate {
|
|
1819
|
+
let cancelled = false;
|
|
1820
|
+
let reason = "The user cancelled the provider call.";
|
|
1821
|
+
return {
|
|
1822
|
+
cancelled: () => cancelled,
|
|
1823
|
+
cancel: (why?: string) => {
|
|
1824
|
+
cancelled = true;
|
|
1825
|
+
if (why !== undefined && why.trim() !== "") reason = why.trim();
|
|
1826
|
+
},
|
|
1827
|
+
reason: () => reason,
|
|
1828
|
+
};
|
|
1829
|
+
}
|
|
1830
|
+
|
|
1831
|
+
/** Builds one structured gateway error with its code, message, retry hint and request id; every failure surface of the family answers this one shape. */
|
|
1832
|
+
export function gatewayerrorof(
|
|
1833
|
+
code: gatewayerror["code"],
|
|
1834
|
+
message: string,
|
|
1835
|
+
requestid: string,
|
|
1836
|
+
retryhint?: string,
|
|
1837
|
+
retryafter?: number,
|
|
1838
|
+
): gatewayerror {
|
|
1839
|
+
return {
|
|
1840
|
+
code,
|
|
1841
|
+
message,
|
|
1842
|
+
requestid,
|
|
1843
|
+
...(retryhint !== undefined && retryhint.trim() !== "" ? { retryhint } : {}),
|
|
1844
|
+
...(retryafter !== undefined && Number.isFinite(retryafter) ? { retryafter } : {}),
|
|
1845
|
+
};
|
|
1846
|
+
}
|
|
1847
|
+
|
|
1848
|
+
/** Normalizes one thrown error into the structured gateway error surface: a thrown gateway error passes through, a timeout keeps its code and every other failure grades as a transport error with its retry hint. */
|
|
1849
|
+
export function gatewayerrorfrom(error: unknown, requestid: string): gatewayerror {
|
|
1850
|
+
if (
|
|
1851
|
+
error !== null &&
|
|
1852
|
+
typeof error === "object" &&
|
|
1853
|
+
"code" in error &&
|
|
1854
|
+
"requestid" in error &&
|
|
1855
|
+
typeof (error as gatewayerror).code === "string"
|
|
1856
|
+
)
|
|
1857
|
+
return error as gatewayerror;
|
|
1858
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1859
|
+
const code = message.includes("timed out") ? "timeout" : "transport";
|
|
1860
|
+
return gatewayerrorof(
|
|
1861
|
+
code,
|
|
1862
|
+
message,
|
|
1863
|
+
requestid,
|
|
1864
|
+
"A transient network failure may succeed on a retry; the caller decides whether one runs.",
|
|
1865
|
+
);
|
|
1866
|
+
}
|
|
1867
|
+
|
|
1868
|
+
/** Reads the retry after milliseconds a rate limited answer announced: the retry-after header carries seconds or milliseconds, the request epoch dates pass through and an absent or malformed header answers undefined. */
|
|
1869
|
+
export function gatewayretryafterof(headers: Record<string, string>): number | undefined {
|
|
1870
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
1871
|
+
if (name.toLowerCase() !== "retry-after") continue;
|
|
1872
|
+
const trimmed = value.trim();
|
|
1873
|
+
if (/^\d+$/.test(trimmed)) {
|
|
1874
|
+
const parsed = Number.parseInt(trimmed, 10);
|
|
1875
|
+
return parsed > 10_000_000 ? parsed : parsed * 1000;
|
|
1876
|
+
}
|
|
1877
|
+
const date = Date.parse(trimmed);
|
|
1878
|
+
if (Number.isFinite(date)) return Math.max(0, date - Date.now());
|
|
1879
|
+
}
|
|
1880
|
+
return undefined;
|
|
1881
|
+
}
|
|
1882
|
+
|
|
1883
|
+
/** Masks one provider key for every log, audit entry and error message: the mask answers the key length alone, so no key material — no prefix, no suffix, no fragment — ever reaches a printed line. */
|
|
1884
|
+
export function maskkey(key: string): string {
|
|
1885
|
+
return `vault-key(${key.length} chars, material never prints)`;
|
|
1886
|
+
}
|
|
1887
|
+
|
|
1888
|
+
/** Builds the log safe view of one shaped request: the authorization, api key and proxy header values answer their masked form and a gemini url key parameter strips, so the audit trail and the surfaces never record key material. */
|
|
1889
|
+
export function maskrequest(request: { url: string; method: string; headers: Record<string, string>; body: string }): {
|
|
1890
|
+
url: string;
|
|
1891
|
+
method: string;
|
|
1892
|
+
headers: Record<string, string>;
|
|
1893
|
+
body: string;
|
|
1894
|
+
} {
|
|
1895
|
+
const headers: Record<string, string> = {};
|
|
1896
|
+
for (const [name, value] of Object.entries(request.headers)) {
|
|
1897
|
+
const lowered = name.toLowerCase();
|
|
1898
|
+
headers[name] =
|
|
1899
|
+
lowered === "authorization" || lowered === "x-api-key" || lowered.includes("token") || lowered.includes("secret")
|
|
1900
|
+
? maskkey(value)
|
|
1901
|
+
: value;
|
|
1902
|
+
}
|
|
1903
|
+
const url = request.url.replace(/([?&])key=[^&]*/g, "$1key=masked");
|
|
1904
|
+
return { url, method: request.method, headers, body: request.body };
|
|
1905
|
+
}
|
|
1906
|
+
|
|
1907
|
+
/** Stores one provider api key behind the vault seam under the explicit consent of the user: the key material enters the seam at the store moment and the metadata record alone answers, scoped to the origin of the provider base url so a key of one gateway never rides another. */
|
|
1908
|
+
export async function storeproviderkey(input: {
|
|
1909
|
+
seam: { put(vaultid: string, value: string): Promise<void> };
|
|
1910
|
+
config: Pick<baseurlconfig, "providerid" | "kind" | "baseurl">;
|
|
1911
|
+
profileid: string;
|
|
1912
|
+
value: string;
|
|
1913
|
+
consent: boolean;
|
|
1914
|
+
now: number;
|
|
1915
|
+
}): Promise<secretvaultentry> {
|
|
1916
|
+
const gate = gatewaykeyconsentgate({ consent: input.consent, providerid: input.config.providerid });
|
|
1917
|
+
if (!gate.allowed)
|
|
1918
|
+
throw new Error(gate.reason ?? "The provider key needs its explicit consent before the vault stores it.");
|
|
1919
|
+
if (input.value === "") throw new Error("The provider key needs its key material; an empty value stores nothing.");
|
|
1920
|
+
let scope = input.config.providerid;
|
|
1921
|
+
try {
|
|
1922
|
+
scope = new URL(input.config.baseurl).origin;
|
|
1923
|
+
} catch {
|
|
1924
|
+
/* the base url without a parsed origin keeps the provider id scope */
|
|
1925
|
+
}
|
|
1926
|
+
return vaultstore({
|
|
1927
|
+
seam: input.seam,
|
|
1928
|
+
label: input.config.providerid,
|
|
1929
|
+
scope,
|
|
1930
|
+
profileid: input.profileid,
|
|
1931
|
+
provenance: "user",
|
|
1932
|
+
value: input.value,
|
|
1933
|
+
now: input.now,
|
|
1934
|
+
});
|
|
1935
|
+
}
|
|
1936
|
+
|
|
1937
|
+
/** Resolves one provider key from the vault seam at the last possible moment before the wire header: the value answers to the dispatching caller only, never to a log writer, and the record stamps its use. */
|
|
1938
|
+
export async function resolveproviderkey(input: {
|
|
1939
|
+
seam: { fetch(vaultid: string): Promise<string | undefined> };
|
|
1940
|
+
entry: secretvaultentry;
|
|
1941
|
+
}): Promise<{ ok: boolean; value?: string; reason: string }> {
|
|
1942
|
+
const resolved = await vaultvaluefor({ seam: input.seam, entry: input.entry });
|
|
1943
|
+
return {
|
|
1944
|
+
ok: resolved.ok,
|
|
1945
|
+
...(resolved.value !== undefined ? { value: resolved.value } : {}),
|
|
1946
|
+
reason: resolved.reason,
|
|
1947
|
+
};
|
|
1948
|
+
}
|
|
1949
|
+
|
|
1950
|
+
/** Revokes one provider key with one click: the value drops from the seam and the metadata record leaves with it, because a deleted key leaves no trace a surface could read. */
|
|
1951
|
+
export async function revokeproviderkey(input: {
|
|
1952
|
+
seam: { drop(vaultid: string): Promise<void> };
|
|
1953
|
+
entry: secretvaultentry;
|
|
1954
|
+
}): Promise<{ dropped: boolean; label: string; reason: string }> {
|
|
1955
|
+
return vaultdelete({ seam: input.seam, entry: input.entry });
|
|
1956
|
+
}
|
|
1957
|
+
|
|
1958
|
+
/** Checks one export candidate list for leaked provider keys: a candidate whose sha-256 digest matches a stored vault record is a leak the export refuses, because key material never rides an export path. */
|
|
1959
|
+
export async function keyexportcheck(input: {
|
|
1960
|
+
candidates: string[];
|
|
1961
|
+
entries: secretvaultentry[];
|
|
1962
|
+
}): Promise<{ leaks: string[]; reason: string }> {
|
|
1963
|
+
return secretleakscan({ candidates: input.candidates, entries: input.entries });
|
|
1964
|
+
}
|
|
1965
|
+
|
|
1966
|
+
/** The call options of the one request contract: the timeout, the retry count, the backoff base, the jitter window and the extra headers the user reviewed — every bound a user choice with no code default. */
|
|
1967
|
+
export interface gatewayoptions {
|
|
1968
|
+
timeout?: number;
|
|
1969
|
+
retries?: number;
|
|
1970
|
+
backoff?: number;
|
|
1971
|
+
/** Jitter window in milliseconds the backoff waits add; a zero window keeps the deterministic backoff. */
|
|
1972
|
+
jitter?: number;
|
|
1973
|
+
headers?: Record<string, string>;
|
|
1974
|
+
}
|
|
1975
|
+
|
|
1976
|
+
/** The result of one gateway request: the answer text, the usage counts, the request id, the shaped request and the transport facts. */
|
|
1977
|
+
export interface gatewayresult {
|
|
1978
|
+
text: string;
|
|
1979
|
+
usage?: modelusage;
|
|
1980
|
+
requestid: string;
|
|
1981
|
+
request: ReturnType<typeof buildrequest>;
|
|
1982
|
+
status: number;
|
|
1983
|
+
duration: number;
|
|
1984
|
+
retries: number;
|
|
1985
|
+
}
|
|
1986
|
+
|
|
1987
|
+
/** Dispatches one wire request through the shared contract loop: the consent and base url gates pass first, the adapter builds its wire request, the transport seam carries it, a rate limited answer honors its retry-after header with the backoff, a transient failure retries under the jittered backoff, the timeout races every attempt, the cancel state stops before the dispatch and between the attempts, and the raw transport answer returns to the caller that parses it — every failure answers the structured gateway error. */
|
|
1988
|
+
async function dispatch(input: {
|
|
1989
|
+
config: baseurlconfig;
|
|
1990
|
+
model: string;
|
|
1991
|
+
messages: modelmessage[];
|
|
1992
|
+
apikey?: string;
|
|
1993
|
+
tools?: tooldef[];
|
|
1994
|
+
temperature?: number;
|
|
1995
|
+
maxtokens?: number;
|
|
1996
|
+
stream?: boolean;
|
|
1997
|
+
options?: gatewayoptions;
|
|
1998
|
+
requestid?: string;
|
|
1999
|
+
cancel?: gatewaycancelstate;
|
|
2000
|
+
transport: fetchtransport;
|
|
2001
|
+
sleep?: (milliseconds: number) => Promise<void>;
|
|
2002
|
+
jitter?: () => number;
|
|
2003
|
+
now?: () => number;
|
|
2004
|
+
}): Promise<{
|
|
2005
|
+
response: transportresponse;
|
|
2006
|
+
requestid: string;
|
|
2007
|
+
request: ReturnType<typeof buildrequest>;
|
|
2008
|
+
duration: number;
|
|
2009
|
+
retries: number;
|
|
2010
|
+
}> {
|
|
2011
|
+
const requestid = input.requestid ?? randomid();
|
|
2012
|
+
const adapter = adapterof(input.config.kind);
|
|
2013
|
+
const sleep =
|
|
2014
|
+
input.sleep ??
|
|
2015
|
+
((milliseconds: number) => new Promise<void>((resolve) => setTimeout(resolve, Math.max(0, milliseconds))));
|
|
2016
|
+
const jitter = input.jitter ?? (() => 0);
|
|
2017
|
+
const now = input.now ?? Date.now;
|
|
2018
|
+
const consent = gatewayconsentgate({ config: input.config });
|
|
2019
|
+
if (!consent.allowed)
|
|
2020
|
+
throw gatewayerrorof(
|
|
2021
|
+
"noconsent",
|
|
2022
|
+
consent.reason ?? "The provider call stayed behind the consent gate.",
|
|
2023
|
+
requestid,
|
|
2024
|
+
"Grant the provider consent from the options and the call runs.",
|
|
2025
|
+
);
|
|
2026
|
+
const baseurl = gatewaybaseurlgate({ kind: input.config.kind, baseurl: input.config.baseurl });
|
|
2027
|
+
if (!baseurl.allowed)
|
|
2028
|
+
throw gatewayerrorof("notconfigured", baseurl.reason ?? "The provider base url failed its validation.", requestid);
|
|
2029
|
+
const shaped = adapter.build({
|
|
2030
|
+
config: input.config,
|
|
2031
|
+
model: input.model,
|
|
2032
|
+
messages: input.messages,
|
|
2033
|
+
...(input.apikey !== undefined ? { apikey: input.apikey } : {}),
|
|
2034
|
+
...(input.tools !== undefined ? { tools: input.tools } : {}),
|
|
2035
|
+
...(input.temperature !== undefined ? { temperature: input.temperature } : {}),
|
|
2036
|
+
...(input.maxtokens !== undefined ? { maxtokens: input.maxtokens } : {}),
|
|
2037
|
+
...(input.stream === true ? { stream: true } : {}),
|
|
2038
|
+
...(input.options?.headers !== undefined ? { headers: input.options.headers } : {}),
|
|
2039
|
+
});
|
|
2040
|
+
const attempts = Math.max(1, Math.floor(input.options?.retries ?? 0) + 1);
|
|
2041
|
+
const backoff = input.options?.backoff ?? 0;
|
|
2042
|
+
const jitterwindow = input.options?.jitter ?? 0;
|
|
2043
|
+
let retries = 0;
|
|
2044
|
+
let lasterror: gatewayerror | undefined;
|
|
2045
|
+
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
2046
|
+
if (input.cancel?.cancelled() === true) throw gatewayerrorof("cancelled", input.cancel.reason(), requestid);
|
|
2047
|
+
const startedat = now();
|
|
2048
|
+
let response: transportresponse | undefined;
|
|
2049
|
+
try {
|
|
2050
|
+
const sent = input.transport(shaped.url, {
|
|
2051
|
+
method: shaped.method,
|
|
2052
|
+
headers: shaped.headers,
|
|
2053
|
+
body: shaped.body,
|
|
2054
|
+
redirect: "follow",
|
|
2055
|
+
});
|
|
2056
|
+
const timeout = input.options?.timeout;
|
|
2057
|
+
if (timeout !== undefined && Number.isFinite(timeout) && timeout >= 0) {
|
|
2058
|
+
let timedout = false;
|
|
2059
|
+
response = await Promise.race([
|
|
2060
|
+
sent,
|
|
2061
|
+
sleep(timeout).then(() => {
|
|
2062
|
+
timedout = true;
|
|
2063
|
+
return undefined;
|
|
2064
|
+
}),
|
|
2065
|
+
]).then(
|
|
2066
|
+
(value) =>
|
|
2067
|
+
value ??
|
|
2068
|
+
(timedout
|
|
2069
|
+
? (() => {
|
|
2070
|
+
throw new Error(`The request timed out after ${timeout} milliseconds.`);
|
|
2071
|
+
})()
|
|
2072
|
+
: value),
|
|
2073
|
+
);
|
|
2074
|
+
} else {
|
|
2075
|
+
response = await sent;
|
|
2076
|
+
}
|
|
2077
|
+
} catch (error) {
|
|
2078
|
+
lasterror = gatewayerrorfrom(error, requestid);
|
|
2079
|
+
}
|
|
2080
|
+
if (response !== undefined) {
|
|
2081
|
+
if (response.status === 429) {
|
|
2082
|
+
const wait = gatewayretryafterof(response.headers) ?? 0;
|
|
2083
|
+
if (attempt < attempts) {
|
|
2084
|
+
retries = attempt;
|
|
2085
|
+
await sleep(wait + (jitterwindow > 0 ? Math.floor(jitter() * jitterwindow) : 0));
|
|
2086
|
+
continue;
|
|
2087
|
+
}
|
|
2088
|
+
throw gatewayerrorof(
|
|
2089
|
+
"ratelimited",
|
|
2090
|
+
`The provider answered rate limited and the retry budget of ${attempts - 1} attempt${attempts - 1 === 1 ? "" : "s"} is exhausted.`,
|
|
2091
|
+
requestid,
|
|
2092
|
+
"Wait the announced window and send the request again.",
|
|
2093
|
+
wait,
|
|
2094
|
+
);
|
|
2095
|
+
}
|
|
2096
|
+
if (response.status === 401 || response.status === 403)
|
|
2097
|
+
throw gatewayerrorof(
|
|
2098
|
+
"unauthorized",
|
|
2099
|
+
`The provider answered ${response.status}: the key the vault resolved did not authorize the call.`,
|
|
2100
|
+
requestid,
|
|
2101
|
+
"Check the stored key of the provider and revoke it if it rotated.",
|
|
2102
|
+
);
|
|
2103
|
+
if (response.status >= 200 && response.status < 300) {
|
|
2104
|
+
if (input.cancel?.cancelled() === true) throw gatewayerrorof("cancelled", input.cancel.reason(), requestid);
|
|
2105
|
+
return { response, requestid, request: shaped, duration: now() - startedat, retries };
|
|
2106
|
+
}
|
|
2107
|
+
lasterror = gatewayerrorof(
|
|
2108
|
+
"transport",
|
|
2109
|
+
`The provider answered ${response.status}: ${response.body.slice(0, 200)}`,
|
|
2110
|
+
requestid,
|
|
2111
|
+
"A transient server failure may succeed on a retry; the caller decides whether one runs.",
|
|
2112
|
+
);
|
|
2113
|
+
}
|
|
2114
|
+
if (attempt < attempts) {
|
|
2115
|
+
const wait = backoff * attempt + (jitterwindow > 0 ? Math.floor(jitter() * jitterwindow) : 0);
|
|
2116
|
+
if (wait > 0) await sleep(wait);
|
|
2117
|
+
retries = attempt;
|
|
2118
|
+
}
|
|
2119
|
+
}
|
|
2120
|
+
throw lasterror ?? gatewayerrorof("transport", "The provider call failed without an answer.", requestid);
|
|
2121
|
+
}
|
|
2122
|
+
|
|
2123
|
+
/** Sends one gateway request through the shared contract: the dispatch loop runs the gates, the retries, the rate limit honoring, the timeout race and the cancellation, and the adapter parses the answer body with its usage counts — every failure answers the structured gateway error. */
|
|
2124
|
+
export async function gatewaycall(input: {
|
|
2125
|
+
config: baseurlconfig;
|
|
2126
|
+
model: string;
|
|
2127
|
+
messages: modelmessage[];
|
|
2128
|
+
apikey?: string;
|
|
2129
|
+
tools?: tooldef[];
|
|
2130
|
+
temperature?: number;
|
|
2131
|
+
maxtokens?: number;
|
|
2132
|
+
options?: gatewayoptions;
|
|
2133
|
+
requestid?: string;
|
|
2134
|
+
cancel?: gatewaycancelstate;
|
|
2135
|
+
transport: fetchtransport;
|
|
2136
|
+
sleep?: (milliseconds: number) => Promise<void>;
|
|
2137
|
+
jitter?: () => number;
|
|
2138
|
+
now?: () => number;
|
|
2139
|
+
}): Promise<gatewayresult> {
|
|
2140
|
+
const adapter = adapterof(input.config.kind);
|
|
2141
|
+
const outcome = await dispatch({
|
|
2142
|
+
config: input.config,
|
|
2143
|
+
model: input.model,
|
|
2144
|
+
messages: input.messages,
|
|
2145
|
+
...(input.apikey !== undefined ? { apikey: input.apikey } : {}),
|
|
2146
|
+
...(input.tools !== undefined ? { tools: input.tools } : {}),
|
|
2147
|
+
...(input.temperature !== undefined ? { temperature: input.temperature } : {}),
|
|
2148
|
+
...(input.maxtokens !== undefined ? { maxtokens: input.maxtokens } : {}),
|
|
2149
|
+
...(input.options !== undefined ? { options: input.options } : {}),
|
|
2150
|
+
...(input.requestid !== undefined ? { requestid: input.requestid } : {}),
|
|
2151
|
+
...(input.cancel !== undefined ? { cancel: input.cancel } : {}),
|
|
2152
|
+
transport: input.transport,
|
|
2153
|
+
...(input.sleep !== undefined ? { sleep: input.sleep } : {}),
|
|
2154
|
+
...(input.jitter !== undefined ? { jitter: input.jitter } : {}),
|
|
2155
|
+
...(input.now !== undefined ? { now: input.now } : {}),
|
|
2156
|
+
});
|
|
2157
|
+
const parsed = adapter.parseanswer(outcome.response.body);
|
|
2158
|
+
if (parsed.text === undefined)
|
|
2159
|
+
throw gatewayerrorof(
|
|
2160
|
+
"parse",
|
|
2161
|
+
parsed.reason ?? "The provider answer did not parse.",
|
|
2162
|
+
outcome.requestid,
|
|
2163
|
+
"A retried request may parse; the caller decides whether one runs.",
|
|
2164
|
+
);
|
|
2165
|
+
return {
|
|
2166
|
+
text: parsed.text,
|
|
2167
|
+
...(parsed.usage !== undefined ? { usage: parsed.usage } : {}),
|
|
2168
|
+
requestid: outcome.requestid,
|
|
2169
|
+
request: outcome.request,
|
|
2170
|
+
status: outcome.response.status,
|
|
2171
|
+
duration: outcome.duration,
|
|
2172
|
+
retries: outcome.retries,
|
|
2173
|
+
};
|
|
2174
|
+
}
|
|
2175
|
+
|
|
2176
|
+
/** Streams one gateway request through the shared contract: the request carries the stream flag of its wire format, the adapter parses the streamed body into its ordered tokens (the chat completions deltas, the messages content blocks, the gemini candidate parts or the ollama message lines), the onevent seam fires for every token as it parses so the surfaces append the answer incrementally, and the cancel state stops the parse loop at the token position it reached. */
|
|
2177
|
+
export async function gatewaystream(input: {
|
|
2178
|
+
config: baseurlconfig;
|
|
2179
|
+
model: string;
|
|
2180
|
+
messages: modelmessage[];
|
|
2181
|
+
apikey?: string;
|
|
2182
|
+
tools?: tooldef[];
|
|
2183
|
+
temperature?: number;
|
|
2184
|
+
maxtokens?: number;
|
|
2185
|
+
options?: gatewayoptions;
|
|
2186
|
+
requestid?: string;
|
|
2187
|
+
cancel?: gatewaycancelstate;
|
|
2188
|
+
transport: fetchtransport;
|
|
2189
|
+
sleep?: (milliseconds: number) => Promise<void>;
|
|
2190
|
+
jitter?: () => number;
|
|
2191
|
+
now?: () => number;
|
|
2192
|
+
onevent?: (token: tokenstream) => void;
|
|
2193
|
+
}): Promise<{
|
|
2194
|
+
tokens: tokenstream[];
|
|
2195
|
+
text: string;
|
|
2196
|
+
requestid: string;
|
|
2197
|
+
request: ReturnType<typeof buildrequest>;
|
|
2198
|
+
status: number;
|
|
2199
|
+
retries: number;
|
|
2200
|
+
}> {
|
|
2201
|
+
const adapter = adapterof(input.config.kind);
|
|
2202
|
+
const outcome = await dispatch({
|
|
2203
|
+
config: input.config,
|
|
2204
|
+
model: input.model,
|
|
2205
|
+
messages: input.messages,
|
|
2206
|
+
...(input.apikey !== undefined ? { apikey: input.apikey } : {}),
|
|
2207
|
+
...(input.tools !== undefined ? { tools: input.tools } : {}),
|
|
2208
|
+
...(input.temperature !== undefined ? { temperature: input.temperature } : {}),
|
|
2209
|
+
...(input.maxtokens !== undefined ? { maxtokens: input.maxtokens } : {}),
|
|
2210
|
+
stream: true,
|
|
2211
|
+
...(input.options !== undefined ? { options: input.options } : {}),
|
|
2212
|
+
...(input.requestid !== undefined ? { requestid: input.requestid } : {}),
|
|
2213
|
+
...(input.cancel !== undefined ? { cancel: input.cancel } : {}),
|
|
2214
|
+
transport: input.transport,
|
|
2215
|
+
...(input.sleep !== undefined ? { sleep: input.sleep } : {}),
|
|
2216
|
+
...(input.jitter !== undefined ? { jitter: input.jitter } : {}),
|
|
2217
|
+
...(input.now !== undefined ? { now: input.now } : {}),
|
|
2218
|
+
});
|
|
2219
|
+
const tokens: tokenstream[] = [];
|
|
2220
|
+
for (const token of adapter.parsestream(outcome.response.body)) {
|
|
2221
|
+
if (input.cancel?.cancelled() === true) break;
|
|
2222
|
+
tokens.push(token);
|
|
2223
|
+
input.onevent?.(token);
|
|
2224
|
+
}
|
|
2225
|
+
return {
|
|
2226
|
+
tokens,
|
|
2227
|
+
text: tokens.map((token) => token.text).join(""),
|
|
2228
|
+
requestid: outcome.requestid,
|
|
2229
|
+
request: outcome.request,
|
|
2230
|
+
status: outcome.response.status,
|
|
2231
|
+
retries: outcome.retries,
|
|
2232
|
+
};
|
|
2233
|
+
}
|
|
2234
|
+
|
|
2235
|
+
/** Fetches the model list of one provider through the adapter: the cache window the user configured decides whether the cached list still serves, a fresh fetch rides the model list endpoint of the wire format, the discovered models annotate with their context and modality hints when the provider offers them and a failed fetch keeps the cache untouched. */
|
|
2236
|
+
export async function fetchmodellist(input: {
|
|
2237
|
+
config: baseurlconfig;
|
|
2238
|
+
apikey?: string;
|
|
2239
|
+
cachewindow?: number;
|
|
2240
|
+
cache?: modelcacherecord;
|
|
2241
|
+
transport: fetchtransport;
|
|
2242
|
+
options?: gatewayoptions;
|
|
2243
|
+
now?: () => number;
|
|
2244
|
+
}): Promise<{ models: gatewaymodelinfo[]; cached: boolean; fetchedat: number; reason?: string }> {
|
|
2245
|
+
const now = input.now ?? Date.now;
|
|
2246
|
+
const adapter = adapterof(input.config.kind);
|
|
2247
|
+
if (input.cache !== undefined) {
|
|
2248
|
+
const window = input.cachewindow;
|
|
2249
|
+
if (window !== undefined && Number.isFinite(window) && window > 0 && now() - input.cache.fetchedat < window)
|
|
2250
|
+
return { models: input.cache.models, cached: true, fetchedat: input.cache.fetchedat };
|
|
2251
|
+
}
|
|
2252
|
+
const baseurl = gatewaybaseurlgate({ kind: input.config.kind, baseurl: input.config.baseurl });
|
|
2253
|
+
if (!baseurl.allowed)
|
|
2254
|
+
return {
|
|
2255
|
+
models: [],
|
|
2256
|
+
cached: false,
|
|
2257
|
+
fetchedat: now(),
|
|
2258
|
+
...(baseurl.reason !== undefined ? { reason: baseurl.reason } : {}),
|
|
2259
|
+
};
|
|
2260
|
+
const headers: Record<string, string> = { ...(input.options?.headers ?? {}) };
|
|
2261
|
+
let url = gatewayurl(input.config, adapter.modelspath());
|
|
2262
|
+
if (input.apikey !== undefined && input.apikey.trim() !== "") {
|
|
2263
|
+
if (input.config.kind === "geminigateway")
|
|
2264
|
+
url = `${url}${url.includes("?") ? "&" : "?"}key=${encodeURIComponent(input.apikey)}`;
|
|
2265
|
+
else headers.authorization = `Bearer ${input.apikey}`;
|
|
2266
|
+
}
|
|
2267
|
+
let response: transportresponse;
|
|
2268
|
+
try {
|
|
2269
|
+
response = await input.transport(url, { method: "GET", headers, redirect: "follow" });
|
|
2270
|
+
} catch (error) {
|
|
2271
|
+
return {
|
|
2272
|
+
models: [],
|
|
2273
|
+
cached: false,
|
|
2274
|
+
fetchedat: now(),
|
|
2275
|
+
reason: `The model list fetch failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
2276
|
+
};
|
|
2277
|
+
}
|
|
2278
|
+
if (response.status < 200 || response.status >= 300)
|
|
2279
|
+
return {
|
|
2280
|
+
models: [],
|
|
2281
|
+
cached: false,
|
|
2282
|
+
fetchedat: now(),
|
|
2283
|
+
reason: `The model list endpoint answered ${response.status}.`,
|
|
2284
|
+
};
|
|
2285
|
+
|
|
2286
|
+
const models = adapter.parsemodellist(response.body);
|
|
2287
|
+
return { models, cached: false, fetchedat: now() };
|
|
2288
|
+
}
|
|
2289
|
+
|
|
2290
|
+
/** Builds the capabilityad advertisement of the tool catalog: the tools serialize into the wire format of the provider (the function schema of the chat completions shape, the input schema of the messages shape, the function declarations of the generate content shape or the function schema of the local runtime), every tool declares its consent requirement from its metadata and the plan review gate rides the required capabilities because a model answer never executes a step the human review did not approve. */
|
|
2291
|
+
export function capabilityadvertisement(input: { kind: gatewaykind; tools: tooldef[] }): {
|
|
2292
|
+
kind: gatewaykind;
|
|
2293
|
+
tools: unknown[];
|
|
2294
|
+
consent: Array<{ tool: string; review: string; riskclass?: string; approvalrequired?: boolean }>;
|
|
2295
|
+
requiredcapabilities: string[];
|
|
2296
|
+
} {
|
|
2297
|
+
const adapter = adapterof(input.kind);
|
|
2298
|
+
return {
|
|
2299
|
+
kind: input.kind,
|
|
2300
|
+
tools: adapter.toolcatalog(input.tools),
|
|
2301
|
+
consent: input.tools.map((tool) => ({
|
|
2302
|
+
tool: tool.name,
|
|
2303
|
+
review:
|
|
2304
|
+
tool.consentmeta?.review ?? `${tool.name} is a ${tool.risk} tool; it runs only behind its reviewed plan step.`,
|
|
2305
|
+
...(tool.consentmeta?.riskclass !== undefined ? { riskclass: tool.consentmeta.riskclass } : {}),
|
|
2306
|
+
...(tool.consentmeta?.approvalrequired === true ? { approvalrequired: true } : {}),
|
|
2307
|
+
})),
|
|
2308
|
+
requiredcapabilities: ["planreview"],
|
|
2309
|
+
};
|
|
2310
|
+
}
|
|
2311
|
+
|
|
2312
|
+
/** Resolves the gateway route of one task kind: the user rules of the model routing table map the kind onto its provider and model, a remote provider the consent or enable gates refuse falls back to the user configured fallback pair, and when remote calls are not allowed at all the resolution falls back to the local provider so the task stays on the machine. */
|
|
2313
|
+
export function resolvegatewayroute(input: {
|
|
2314
|
+
kind: string;
|
|
2315
|
+
routes: modelroute[];
|
|
2316
|
+
providers: providerconfig[];
|
|
2317
|
+
configs: baseurlconfig[];
|
|
2318
|
+
local?: localmodelconfig;
|
|
2319
|
+
remoteallowed?: boolean;
|
|
2320
|
+
sessionorigin?: string;
|
|
2321
|
+
}): {
|
|
2322
|
+
source: "route" | "fallback" | "local";
|
|
2323
|
+
provider?: providerconfig;
|
|
2324
|
+
model?: string;
|
|
2325
|
+
config?: baseurlconfig;
|
|
2326
|
+
local?: localmodelconfig;
|
|
2327
|
+
reason?: string;
|
|
2328
|
+
} {
|
|
2329
|
+
const remoteallowed = input.remoteallowed !== false;
|
|
2330
|
+
const configof = (providerid: string): baseurlconfig | undefined =>
|
|
2331
|
+
input.configs.find((config) => config.providerid === providerid);
|
|
2332
|
+
const primary = resolveroute({ routes: input.routes, providers: input.providers, kind: input.kind });
|
|
2333
|
+
if (primary.provider !== undefined && primary.model !== undefined) {
|
|
2334
|
+
const config = configof(primary.provider.id);
|
|
2335
|
+
if (remoteallowed && (config === undefined || gatewayconsentgate({ config }).allowed))
|
|
2336
|
+
return {
|
|
2337
|
+
source: "route",
|
|
2338
|
+
provider: primary.provider,
|
|
2339
|
+
model: primary.model,
|
|
2340
|
+
...(config !== undefined ? { config } : {}),
|
|
2341
|
+
};
|
|
2342
|
+
}
|
|
2343
|
+
const fallback = fallbackroute({ routes: input.routes, providers: input.providers, kind: input.kind });
|
|
2344
|
+
if (fallback.provider !== undefined && fallback.model !== undefined && remoteallowed) {
|
|
2345
|
+
const config = configof(fallback.provider.id);
|
|
2346
|
+
if (config === undefined || gatewayconsentgate({ config }).allowed)
|
|
2347
|
+
return {
|
|
2348
|
+
source: "fallback",
|
|
2349
|
+
provider: fallback.provider,
|
|
2350
|
+
model: fallback.model,
|
|
2351
|
+
...(config !== undefined ? { config } : {}),
|
|
2352
|
+
};
|
|
2353
|
+
}
|
|
2354
|
+
if (input.local !== undefined && input.local.endpoint.trim() !== "")
|
|
2355
|
+
return {
|
|
2356
|
+
source: "local",
|
|
2357
|
+
local: input.local,
|
|
2358
|
+
model: input.local.model,
|
|
2359
|
+
reason: remoteallowed
|
|
2360
|
+
? `The remote route of ${input.kind} stayed behind its gates, so the resolution fell back to the local provider.`
|
|
2361
|
+
: `Remote calls are not allowed, so the ${input.kind} task fell back to the local provider.`,
|
|
2362
|
+
};
|
|
2363
|
+
return {
|
|
2364
|
+
source: "route",
|
|
2365
|
+
reason: primary.reason ?? `No model route configures the task kind ${input.kind} and no local provider stands in.`,
|
|
2366
|
+
};
|
|
2367
|
+
}
|
|
2368
|
+
|
|
2369
|
+
/** Builds one usage record of a gateway call: the request id rides the record for the correlation across the run, the session id reports the per session totals beside the per run totals, the cost answers the user configured price per million tokens and the local marker stamps the calls that never left the machine. */
|
|
2370
|
+
export function gatewayusagerecord(input: {
|
|
2371
|
+
providerid: string;
|
|
2372
|
+
endpoint: string;
|
|
2373
|
+
model: string;
|
|
2374
|
+
usage?: modelusage;
|
|
2375
|
+
costpermilliontokens?: number;
|
|
2376
|
+
sessionid?: string;
|
|
2377
|
+
runid?: string;
|
|
2378
|
+
stepid?: string;
|
|
2379
|
+
requestid: string;
|
|
2380
|
+
local?: boolean;
|
|
2381
|
+
now: number;
|
|
2382
|
+
}): usagerecord {
|
|
2383
|
+
const prompttokens = input.usage?.prompttokens ?? 0;
|
|
2384
|
+
const completiontokens = input.usage?.completiontokens ?? 0;
|
|
2385
|
+
const totaltokens = input.usage?.totaltokens ?? prompttokens + completiontokens;
|
|
2386
|
+
const cost =
|
|
2387
|
+
input.costpermilliontokens !== undefined && Number.isFinite(input.costpermilliontokens)
|
|
2388
|
+
? ((prompttokens + completiontokens) / 1_000_000) * input.costpermilliontokens
|
|
2389
|
+
: 0;
|
|
2390
|
+
return {
|
|
2391
|
+
id: randomid(),
|
|
2392
|
+
...(input.runid !== undefined ? { runid: input.runid } : {}),
|
|
2393
|
+
...(input.stepid !== undefined ? { stepid: input.stepid } : {}),
|
|
2394
|
+
...(input.sessionid !== undefined ? { sessionid: input.sessionid } : {}),
|
|
2395
|
+
requestid: input.requestid,
|
|
2396
|
+
providerid: input.providerid,
|
|
2397
|
+
endpoint: input.endpoint,
|
|
2398
|
+
model: input.model,
|
|
2399
|
+
prompttokens,
|
|
2400
|
+
completiontokens,
|
|
2401
|
+
totaltokens,
|
|
2402
|
+
cost,
|
|
2403
|
+
...(input.local === true ? { local: true } : {}),
|
|
2404
|
+
at: input.now,
|
|
2405
|
+
};
|
|
2406
|
+
}
|
|
2407
|
+
|
|
2408
|
+
/** Checks the warning threshold of the cost budget: the budget tracking warns once the recorded tokens or cost cross the user configured share of their ceiling, while the halt of the reviewed budget check stays separate — a warning never blocks a call, the halt does. */
|
|
2409
|
+
export function gatewaybudgetwarning(input: { budget?: costbudget; totals: { totaltokens: number; cost: number } }): {
|
|
2410
|
+
warned: boolean;
|
|
2411
|
+
tokenratio?: number;
|
|
2412
|
+
costratio?: number;
|
|
2413
|
+
reason?: string;
|
|
2414
|
+
} {
|
|
2415
|
+
const budget = input.budget;
|
|
2416
|
+
if (
|
|
2417
|
+
budget === undefined ||
|
|
2418
|
+
budget.warnratio === undefined ||
|
|
2419
|
+
!Number.isFinite(budget.warnratio) ||
|
|
2420
|
+
budget.warnratio <= 0
|
|
2421
|
+
)
|
|
2422
|
+
return { warned: false };
|
|
2423
|
+
const ratio = Math.min(1, Math.max(0, budget.warnratio));
|
|
2424
|
+
const tokenratio =
|
|
2425
|
+
budget.maxtokens !== undefined && Number.isFinite(budget.maxtokens) && budget.maxtokens > 0
|
|
2426
|
+
? input.totals.totaltokens / budget.maxtokens
|
|
2427
|
+
: undefined;
|
|
2428
|
+
const costratio =
|
|
2429
|
+
budget.maxcost !== undefined && Number.isFinite(budget.maxcost) && budget.maxcost > 0
|
|
2430
|
+
? input.totals.cost / budget.maxcost
|
|
2431
|
+
: undefined;
|
|
2432
|
+
const warned = (tokenratio !== undefined && tokenratio >= ratio) || (costratio !== undefined && costratio >= ratio);
|
|
2433
|
+
if (!warned)
|
|
2434
|
+
return {
|
|
2435
|
+
warned: false,
|
|
2436
|
+
...(tokenratio !== undefined ? { tokenratio } : {}),
|
|
2437
|
+
...(costratio !== undefined ? { costratio } : {}),
|
|
2438
|
+
};
|
|
2439
|
+
return {
|
|
2440
|
+
warned: true,
|
|
2441
|
+
...(tokenratio !== undefined ? { tokenratio } : {}),
|
|
2442
|
+
...(costratio !== undefined ? { costratio } : {}),
|
|
2443
|
+
reason: `The recorded usage crossed the user configured warning threshold of ${Math.round(ratio * 100)} percent${tokenratio !== undefined && tokenratio >= ratio ? ` at ${Math.round(tokenratio * 100)} percent of the token ceiling` : ""}${costratio !== undefined && costratio >= ratio ? ` at ${Math.round(costratio * 100)} percent of the cost ceiling` : ""}.`,
|
|
2444
|
+
};
|
|
2445
|
+
}
|
|
2446
|
+
|
|
2447
|
+
/** Builds the cost estimate view of the configured providers: the projected cost per million tokens answers the user configured price of every provider and model pair beside the recorded tokens and cost of the usage records, so the surfaces show what a call costs before it runs — a pair without configured pricing answers zero instead of an invented number. */
|
|
2448
|
+
export function costestimates(input: {
|
|
2449
|
+
configs: baseurlconfig[];
|
|
2450
|
+
providers: providerconfig[];
|
|
2451
|
+
records: usagerecord[];
|
|
2452
|
+
}): costestimate[] {
|
|
2453
|
+
const pairs = new Map<string, costestimate>();
|
|
2454
|
+
const pricingof = (providerid: string, model: string): { permillion: number; currency?: string } | undefined => {
|
|
2455
|
+
const config = input.configs.find((entry) => entry.providerid === providerid);
|
|
2456
|
+
if (
|
|
2457
|
+
config !== undefined &&
|
|
2458
|
+
config.costpermilliontokens !== undefined &&
|
|
2459
|
+
Number.isFinite(config.costpermilliontokens)
|
|
2460
|
+
)
|
|
2461
|
+
return {
|
|
2462
|
+
permillion: config.costpermilliontokens,
|
|
2463
|
+
...(config.currency !== undefined && config.currency.trim() !== "" ? { currency: config.currency.trim() } : {}),
|
|
2464
|
+
};
|
|
2465
|
+
const provider = input.providers.find((entry) => entry.id === providerid);
|
|
2466
|
+
if (
|
|
2467
|
+
provider !== undefined &&
|
|
2468
|
+
provider.costpermilliontokens !== undefined &&
|
|
2469
|
+
Number.isFinite(provider.costpermilliontokens) &&
|
|
2470
|
+
provider.models.includes(model)
|
|
2471
|
+
)
|
|
2472
|
+
return {
|
|
2473
|
+
permillion: provider.costpermilliontokens,
|
|
2474
|
+
...(provider.currency !== undefined && provider.currency.trim() !== ""
|
|
2475
|
+
? { currency: provider.currency.trim() }
|
|
2476
|
+
: {}),
|
|
2477
|
+
};
|
|
2478
|
+
return undefined;
|
|
2479
|
+
};
|
|
2480
|
+
for (const record of input.records) {
|
|
2481
|
+
const key = `${record.providerid}::${record.model}`;
|
|
2482
|
+
const entry = pairs.get(key) ?? {
|
|
2483
|
+
providerid: record.providerid,
|
|
2484
|
+
model: record.model,
|
|
2485
|
+
permillion: 0,
|
|
2486
|
+
recordedtokens: 0,
|
|
2487
|
+
recordedcost: 0,
|
|
2488
|
+
};
|
|
2489
|
+
entry.recordedtokens += record.totaltokens;
|
|
2490
|
+
entry.recordedcost += record.cost;
|
|
2491
|
+
const pricing = pricingof(record.providerid, record.model);
|
|
2492
|
+
if (pricing !== undefined) {
|
|
2493
|
+
entry.permillion = pricing.permillion;
|
|
2494
|
+
if (pricing.currency !== undefined) entry.currency = pricing.currency;
|
|
2495
|
+
}
|
|
2496
|
+
pairs.set(key, entry);
|
|
2497
|
+
}
|
|
2498
|
+
for (const provider of input.providers) {
|
|
2499
|
+
if (provider.costpermilliontokens === undefined || !Number.isFinite(provider.costpermilliontokens)) continue;
|
|
2500
|
+
for (const model of provider.models) {
|
|
2501
|
+
const key = `${provider.id}::${model}`;
|
|
2502
|
+
if (pairs.has(key)) continue;
|
|
2503
|
+
pairs.set(key, {
|
|
2504
|
+
providerid: provider.id,
|
|
2505
|
+
model,
|
|
2506
|
+
permillion: provider.costpermilliontokens,
|
|
2507
|
+
...(provider.currency !== undefined && provider.currency.trim() !== ""
|
|
2508
|
+
? { currency: provider.currency.trim() }
|
|
2509
|
+
: {}),
|
|
2510
|
+
recordedtokens: 0,
|
|
2511
|
+
recordedcost: 0,
|
|
2512
|
+
});
|
|
2513
|
+
}
|
|
2514
|
+
}
|
|
2515
|
+
return [...pairs.values()];
|
|
2516
|
+
}
|
|
2517
|
+
|
|
2518
|
+
/** The reviewed default guard of the gateway plan answers: the goal, the steps array and the open questions the plan schema asks, with one retry before the refusal. */
|
|
2519
|
+
export const gatewayplanguard: parseguard = {
|
|
2520
|
+
schema: {
|
|
2521
|
+
goal: { type: "string", required: true },
|
|
2522
|
+
steps: { type: "array", required: true },
|
|
2523
|
+
openquestions: { type: "array" },
|
|
2524
|
+
},
|
|
2525
|
+
retries: 1,
|
|
2526
|
+
};
|
|
2527
|
+
|
|
2528
|
+
/** Builds the structured retry prompt of one refused model answer: the prompt names the schema the answer failed, quotes the failure reason of the guard and asks the model to answer with the corrected json object alone, so a retry teaches the shape instead of repeating the failure. */
|
|
2529
|
+
export function guardretryprompt(output: modeloutput): string {
|
|
2530
|
+
return `Your previous answer failed its guard: ${output.reason ?? "the answer did not match the expected schema."} Answer again with a single json object that carries the required fields with their declared types — the goal as a string, the steps as an array of { kind, target, value, summary } objects using browser action kinds and the openquestions as an array of strings — and nothing else around it.`;
|
|
2531
|
+
}
|
|
2532
|
+
|
|
2533
|
+
/** Runs the gateway guardrails over the model answer attempts: every attempt validates against the plan schema through the shared parse guard, the attempts cap at the user configured retry count, a valid attempt wins, a refusal marker refuses without retries and the exhaustion of every capped attempt refuses the output so nothing invalid ever reaches a plan — the structured retry prompt answers whenever a retry is left. */
|
|
2534
|
+
export function gatewayguard(input: { guard?: parseguard; attempts: string[] }): {
|
|
2535
|
+
output: modeloutput;
|
|
2536
|
+
retryprompt?: string;
|
|
2537
|
+
cappedat: number;
|
|
2538
|
+
} {
|
|
2539
|
+
const guard = input.guard ?? gatewayplanguard;
|
|
2540
|
+
const capcheck = gatewayretrycapvalid(guard.retries);
|
|
2541
|
+
if (!capcheck.allowed) throw new Error(capcheck.reason ?? "The configured retry cap failed its validation.");
|
|
2542
|
+
const cap = Math.max(1, Math.floor(guard.retries) + 1);
|
|
2543
|
+
const attempts = input.attempts.slice(0, cap);
|
|
2544
|
+
const output = guardoutput({ guard, attempts });
|
|
2545
|
+
if (output.verdict === "invalid" && attempts.length < cap)
|
|
2546
|
+
return { output, retryprompt: guardretryprompt(output), cappedat: cap };
|
|
2547
|
+
return { output, cappedat: cap };
|
|
2548
|
+
}
|
|
2549
|
+
|
|
2550
|
+
/** The per task prompt templates of the gateway family: every template ships with its double braced variable slots and its version, the memory store keeps the versions and a template renders through the shared prompt library with its variables. */
|
|
2551
|
+
export function gatewaytemplates(now: number): prompttemplate[] {
|
|
2552
|
+
return [
|
|
2553
|
+
{
|
|
2554
|
+
id: randomid(),
|
|
2555
|
+
name: "gateway.parsecommand",
|
|
2556
|
+
body: "Parse the user command into json with the fields intent (one of navigate, extract, fill, monitor, automate, ask), entities (an array of { name, value } objects) and confidence (a number between 0 and 1). The command: {{command}}. Answer with the json object only.",
|
|
2557
|
+
variables: ["command"],
|
|
2558
|
+
version: 1,
|
|
2559
|
+
notes: "The gateway parsecommand template of the 1.1.83 family.",
|
|
2560
|
+
createdat: now,
|
|
2561
|
+
},
|
|
2562
|
+
{
|
|
2563
|
+
id: randomid(),
|
|
2564
|
+
name: "gateway.draftplan",
|
|
2565
|
+
body: "Draft a browser agent plan for the goal {{goal}} as json with the fields goal (string), steps (an array of { kind, target, value, summary } objects using browser action kinds) and openquestions (an array of strings for what stays unclear). The tool catalog: {{tools}}. Answer with the json object only.",
|
|
2566
|
+
variables: ["goal", "tools"],
|
|
2567
|
+
version: 1,
|
|
2568
|
+
notes: "The gateway draftplan template of the 1.1.83 family.",
|
|
2569
|
+
createdat: now,
|
|
2570
|
+
},
|
|
2571
|
+
{
|
|
2572
|
+
id: randomid(),
|
|
2573
|
+
name: "gateway.replan",
|
|
2574
|
+
body: "The plan for {{goal}} failed at the steps {{failed}} with the reason {{reason}}. The completed steps stay: {{completed}}. Draft the revised tail steps as json with the field steps (an array of { kind, target, value, summary } objects using browser action kinds). Answer with the json object only.",
|
|
2575
|
+
variables: ["goal", "failed", "reason", "completed"],
|
|
2576
|
+
version: 1,
|
|
2577
|
+
notes: "The gateway replan template of the 1.1.83 family.",
|
|
2578
|
+
createdat: now,
|
|
2579
|
+
},
|
|
2580
|
+
{
|
|
2581
|
+
id: randomid(),
|
|
2582
|
+
name: "gateway.reflect",
|
|
2583
|
+
body: "Reflect on the executed step {{stepid}} of the run {{runid}} with the outcome: {{outcome}}. Answer as json with the fields outcome (string), lesson (string) and advice (string) for the next step. Answer with the json object only.",
|
|
2584
|
+
variables: ["stepid", "runid", "outcome"],
|
|
2585
|
+
version: 1,
|
|
2586
|
+
notes: "The gateway reflect template of the 1.1.83 family.",
|
|
2587
|
+
createdat: now,
|
|
2588
|
+
},
|
|
2589
|
+
{
|
|
2590
|
+
id: randomid(),
|
|
2591
|
+
name: "gateway.chat",
|
|
2592
|
+
body: "{{prompt}}",
|
|
2593
|
+
variables: ["prompt"],
|
|
2594
|
+
version: 1,
|
|
2595
|
+
notes:
|
|
2596
|
+
"The gateway chat template of the 1.1.83 family; the streamed answers render token by token in the sidepanel chat.",
|
|
2597
|
+
createdat: now,
|
|
2598
|
+
},
|
|
2599
|
+
];
|
|
2600
|
+
}
|
|
2601
|
+
|
|
2602
|
+
/** Composes one gateway prompt from a stored template and its variable values through the shared prompt library; a missing template name answers the reason so the caller refuses instead of sending a hollow prompt. */
|
|
2603
|
+
export function composegatewayprompt(input: {
|
|
2604
|
+
templates: prompttemplate[];
|
|
2605
|
+
name: string;
|
|
2606
|
+
variables: Record<string, unknown>;
|
|
2607
|
+
}): { text?: string; reason?: string } {
|
|
2608
|
+
const template = input.templates.find((entry) => entry.name === input.name);
|
|
2609
|
+
if (template === undefined) return { reason: `No stored prompt template answers the name ${input.name}.` };
|
|
2610
|
+
return rendertemplate({ template, variables: input.variables });
|
|
2611
|
+
}
|
|
2612
|
+
|
|
2613
|
+
/** Assembles the streamed tokens of a gateway chat for incremental rendering: every poll reads the tokens past its cursor, the assembled text carries only the fresh batch and the done marker answers whether a finished stream delivered its last token — the sidepanel chat appends every batch as it arrives instead of waiting for the whole answer. */
|
|
2614
|
+
export function streamrender(
|
|
2615
|
+
tokens: gatewaychattoken[],
|
|
2616
|
+
cursor: number,
|
|
2617
|
+
finished: boolean,
|
|
2618
|
+
): { text: string; nextcursor: number; done: boolean } {
|
|
2619
|
+
const fresh = tokens.filter((token) => token.seq > cursor);
|
|
2620
|
+
const nextcursor = fresh.length > 0 ? (fresh[fresh.length - 1] as gatewaychattoken).seq : cursor;
|
|
2621
|
+
const last = tokens.length > 0 ? (tokens[tokens.length - 1] as gatewaychattoken).seq : 0;
|
|
2622
|
+
return { text: fresh.map((token) => token.text).join(""), nextcursor, done: finished && nextcursor >= last };
|
|
2623
|
+
}
|
|
2624
|
+
|
|
2625
|
+
/** Builds one gateway chat state of a streamed exchange: the request id, the provider, the model, the prompt and the token list the panel polls through its cursor; the state never carries key material. */
|
|
2626
|
+
export function gatewaychatstateof(input: {
|
|
2627
|
+
requestid: string;
|
|
2628
|
+
config: baseurlconfig;
|
|
2629
|
+
model: string;
|
|
2630
|
+
prompt: string;
|
|
2631
|
+
tokens?: gatewaychattoken[];
|
|
2632
|
+
done?: boolean;
|
|
2633
|
+
error?: gatewayerror;
|
|
2634
|
+
now: number;
|
|
2635
|
+
}): gatewaychatstate {
|
|
2636
|
+
return {
|
|
2637
|
+
requestid: input.requestid,
|
|
2638
|
+
providerid: input.config.providerid,
|
|
2639
|
+
kind: input.config.kind,
|
|
2640
|
+
model: input.model,
|
|
2641
|
+
prompt: input.prompt,
|
|
2642
|
+
tokens: input.tokens ?? [],
|
|
2643
|
+
done: input.done === true,
|
|
2644
|
+
...(input.error !== undefined ? { error: input.error } : {}),
|
|
2645
|
+
at: input.now,
|
|
2646
|
+
};
|
|
2647
|
+
}
|
|
2648
|
+
|
|
2649
|
+
/** Reads whether one base url points at the local machine, shared with the llm provider model so the local marker of the usage records answers one rule. */
|
|
2650
|
+
export function gatewayislocal(config: Pick<baseurlconfig, "baseurl">): boolean {
|
|
2651
|
+
return islocalorigin(config.baseurl);
|
|
2652
|
+
}
|
|
2653
|
+
|
|
2654
|
+
/** Runs the reviewed budget check over the usage totals: the token and currency ceilings halt the run and ask the user, exactly as every model call of the llm family already does — the gateway calls pass the same budget gate. */
|
|
2655
|
+
export function gatewaybudgetcheck(input: {
|
|
2656
|
+
budget: costbudget | undefined;
|
|
2657
|
+
totals: { totaltokens: number; cost: number };
|
|
2658
|
+
}): { allowed: boolean; halted: boolean; asksuser: boolean; reason?: string } {
|
|
2659
|
+
return budgetcheck({ budget: input.budget, totals: input.totals });
|
|
2660
|
+
}
|
|
2661
|
+
|
|
2662
|
+
/** Validates one gateway config before it saves: the base url passes its scheme, host and path shape gate, the path prefix passes its namespace gate, the ollamalocal base url defaults to the local machine when the user left it empty and every remote kind refuses an empty base url. */
|
|
2663
|
+
export function validategatewayconfig(config: baseurlconfig): { ok: boolean; config: baseurlconfig; reason?: string } {
|
|
2664
|
+
const baseurl = config.baseurl.trim() !== "" ? config.baseurl.trim() : defaultbaseurl(config.kind);
|
|
2665
|
+
const normalized: baseurlconfig = { ...config, baseurl };
|
|
2666
|
+
const prefix = gatewayprefixgate(normalized.pathprefix ?? "");
|
|
2667
|
+
if (!prefix.allowed) return { ok: false, config, ...(prefix.reason !== undefined ? { reason: prefix.reason } : {}) };
|
|
2668
|
+
const gate = gatewaybaseurlgate({ kind: normalized.kind, baseurl });
|
|
2669
|
+
if (!gate.allowed) return { ok: false, config, ...(gate.reason !== undefined ? { reason: gate.reason } : {}) };
|
|
2670
|
+
return { ok: true, config: normalized };
|
|
2671
|
+
}
|
|
2672
|
+
|
|
2673
|
+
/** The key reference the gateway config carries: the name the surfaces show, the origin scope of the provider base url and the storage id of the vault entry — never the key material. */
|
|
2674
|
+
export function gatewaykeyref(name: string, baseurl: string, storageid: string, configuredat: number): apikeyref {
|
|
2675
|
+
let origins = [baseurl];
|
|
2676
|
+
try {
|
|
2677
|
+
origins = [new URL(baseurl).origin];
|
|
2678
|
+
} catch {
|
|
2679
|
+
/* a base url without a parsed origin keeps the raw text the user typed */
|
|
2680
|
+
}
|
|
2681
|
+
return { name, origins, header: "authorization", storageid, configuredat };
|
|
2682
|
+
}
|