@maudecode/talaria-web 1.15.0 → 1.17.2
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/_release.json +4 -4
- package/dist/api/automation-raw.js +6 -3
- package/dist/api/automation-raw.js.map +1 -1
- package/dist/api/automation-router.d.ts +158 -16
- package/dist/api/automation-router.js +12 -6
- package/dist/api/automation-router.js.map +1 -1
- package/dist/api/chat-router.d.ts +193 -6
- package/dist/api/chat-router.js +39 -16
- package/dist/api/chat-router.js.map +1 -1
- package/dist/api/git-router.d.ts +12 -1
- package/dist/api/git-router.js +60 -38
- package/dist/api/git-router.js.map +1 -1
- package/dist/api/raw-routes.d.ts +6 -0
- package/dist/api/raw-routes.js +35 -58
- package/dist/api/raw-routes.js.map +1 -1
- package/dist/api/router.d.ts +4 -2
- package/dist/api/router.js +8 -8
- package/dist/api/router.js.map +1 -1
- package/dist/api/sessions-router.d.ts +974 -42
- package/dist/api/sessions-router.js +87 -25
- package/dist/api/sessions-router.js.map +1 -1
- package/dist/api/settings-router.d.ts +254 -11
- package/dist/api/settings-router.js +68 -18
- package/dist/api/settings-router.js.map +1 -1
- package/dist/api/sse-routes.d.ts +11 -3
- package/dist/api/sse-routes.js +145 -65
- package/dist/api/sse-routes.js.map +1 -1
- package/dist/api/tools-raw.js +6 -2
- package/dist/api/tools-raw.js.map +1 -1
- package/dist/api/tools-router.d.ts +86 -9
- package/dist/api/tools-router.js +19 -15
- package/dist/api/tools-router.js.map +1 -1
- package/dist/app.d.ts +0 -8397
- package/dist/app.js +7 -2
- package/dist/app.js.map +1 -1
- package/dist/auth/gate.js +11 -4
- package/dist/auth/gate.js.map +1 -1
- package/dist/auth/oidc.d.ts +6 -2
- package/dist/auth/oidc.js +16 -11
- package/dist/auth/oidc.js.map +1 -1
- package/dist/auth/store.d.ts +14 -2
- package/dist/auth/store.js +26 -1
- package/dist/auth/store.js.map +1 -1
- package/dist/cli/dotenv.d.ts +1 -1
- package/dist/cli/dotenv.js +36 -3
- package/dist/cli/dotenv.js.map +1 -1
- package/dist/config/agent-config.d.ts +2 -0
- package/dist/config/agent-config.js +9 -0
- package/dist/config/agent-config.js.map +1 -1
- package/dist/http/addresses.d.ts +6 -0
- package/dist/http/addresses.js +26 -0
- package/dist/http/addresses.js.map +1 -1
- package/dist/http/context.d.ts +6 -3
- package/dist/http/context.js +10 -4
- package/dist/http/context.js.map +1 -1
- package/dist/links.d.ts +26 -0
- package/dist/links.js +60 -0
- package/dist/links.js.map +1 -0
- package/dist/onboarding.js +30 -7
- package/dist/onboarding.js.map +1 -1
- package/dist/profiles/profiles.js +1 -1
- package/dist/profiles/profiles.js.map +1 -1
- package/dist/providers/catalog.d.ts +167 -5
- package/dist/providers/catalog.js +662 -135
- package/dist/providers/catalog.js.map +1 -1
- package/dist/providers/tables.d.ts +3 -1
- package/dist/providers/tables.js +9 -4
- package/dist/providers/tables.js.map +1 -1
- package/dist/redact.d.ts +1 -0
- package/dist/redact.js +518 -15
- package/dist/redact.js.map +1 -1
- package/dist/runtime.js +79 -37
- package/dist/runtime.js.map +1 -1
- package/dist/sessions/anchor.d.ts +24 -3
- package/dist/sessions/anchor.js +94 -44
- package/dist/sessions/anchor.js.map +1 -1
- package/dist/sessions/background-tasks.d.ts +4 -1
- package/dist/sessions/background-tasks.js +7 -4
- package/dist/sessions/background-tasks.js.map +1 -1
- package/dist/sessions/claude-code.d.ts +19 -0
- package/dist/sessions/claude-code.js +250 -0
- package/dist/sessions/claude-code.js.map +1 -0
- package/dist/sessions/completions.d.ts +13 -1
- package/dist/sessions/completions.js +64 -12
- package/dist/sessions/completions.js.map +1 -1
- package/dist/sessions/compress.d.ts +14 -1
- package/dist/sessions/compress.js +52 -11
- package/dist/sessions/compress.js.map +1 -1
- package/dist/sessions/journal.d.ts +8 -0
- package/dist/sessions/journal.js +44 -10
- package/dist/sessions/journal.js.map +1 -1
- package/dist/sessions/list.d.ts +13 -5
- package/dist/sessions/list.js +56 -26
- package/dist/sessions/list.js.map +1 -1
- package/dist/sessions/merge.d.ts +122 -9
- package/dist/sessions/merge.js +544 -33
- package/dist/sessions/merge.js.map +1 -1
- package/dist/sessions/pending.d.ts +5 -0
- package/dist/sessions/pending.js +30 -10
- package/dist/sessions/pending.js.map +1 -1
- package/dist/sessions/service.d.ts +100 -4
- package/dist/sessions/service.js +567 -102
- package/dist/sessions/service.js.map +1 -1
- package/dist/sessions/session.d.ts +16 -1
- package/dist/sessions/session.js +29 -2
- package/dist/sessions/session.js.map +1 -1
- package/dist/sessions/shares.js +11 -2
- package/dist/sessions/shares.js.map +1 -1
- package/dist/sessions/state-db.d.ts +26 -4
- package/dist/sessions/state-db.js +126 -8
- package/dist/sessions/state-db.js.map +1 -1
- package/dist/sessions/store.js +6 -1
- package/dist/sessions/store.js.map +1 -1
- package/dist/sessions/streams.d.ts +10 -0
- package/dist/sessions/streams.js +14 -3
- package/dist/sessions/streams.js.map +1 -1
- package/dist/sessions/todo.d.ts +6 -1
- package/dist/sessions/todo.js +11 -4
- package/dist/sessions/todo.js.map +1 -1
- package/dist/sessions/tool-display.d.ts +12 -1
- package/dist/sessions/tool-display.js +76 -1
- package/dist/sessions/tool-display.js.map +1 -1
- package/dist/sessions/turn.d.ts +49 -10
- package/dist/sessions/turn.js +459 -185
- package/dist/sessions/turn.js.map +1 -1
- package/dist/settings.d.ts +20 -2
- package/dist/settings.js +49 -2
- package/dist/settings.js.map +1 -1
- package/dist/sidecar/fake.d.ts +2 -2
- package/dist/sidecar/fake.js +7 -2
- package/dist/sidecar/fake.js.map +1 -1
- package/dist/spa.js +2 -1
- package/dist/spa.js.map +1 -1
- package/dist/test/harness.js +2 -0
- package/dist/test/harness.js.map +1 -1
- package/dist/tools/commands.d.ts +15 -0
- package/dist/tools/commands.js +52 -0
- package/dist/tools/commands.js.map +1 -0
- package/dist/tools/crons.d.ts +4 -1
- package/dist/tools/crons.js +58 -11
- package/dist/tools/crons.js.map +1 -1
- package/dist/tools/insights.d.ts +8 -2
- package/dist/tools/insights.js +18 -2
- package/dist/tools/insights.js.map +1 -1
- package/dist/tools/kanban.d.ts +41 -1
- package/dist/tools/kanban.js +113 -2
- package/dist/tools/kanban.js.map +1 -1
- package/dist/tools/mcp.d.ts +2 -1
- package/dist/tools/mcp.js +16 -6
- package/dist/tools/mcp.js.map +1 -1
- package/dist/tools/terminal.d.ts +1 -0
- package/dist/tools/terminal.js +1 -1
- package/dist/tools/terminal.js.map +1 -1
- package/dist/tools/update-notifications.d.ts +1 -1
- package/dist/tools/update-notifications.js +1 -1
- package/dist/tools/update-notifications.js.map +1 -1
- package/dist/tools/updates.d.ts +15 -3
- package/dist/tools/updates.js +207 -41
- package/dist/tools/updates.js.map +1 -1
- package/dist/workspace/fs.d.ts +7 -3
- package/dist/workspace/fs.js +5 -1
- package/dist/workspace/fs.js.map +1 -1
- package/dist/workspace/git.d.ts +57 -10
- package/dist/workspace/git.js +302 -195
- package/dist/workspace/git.js.map +1 -1
- package/dist/workspace/media-refs.d.ts +13 -0
- package/dist/workspace/media-refs.js +165 -0
- package/dist/workspace/media-refs.js.map +1 -0
- package/dist/workspace/media.d.ts +19 -0
- package/dist/workspace/media.js +63 -2
- package/dist/workspace/media.js.map +1 -1
- package/dist/workspace/preview.d.ts +28 -0
- package/dist/workspace/preview.js +110 -0
- package/dist/workspace/preview.js.map +1 -0
- package/dist/workspace/rollback.d.ts +5 -4
- package/dist/workspace/rollback.js +12 -9
- package/dist/workspace/rollback.js.map +1 -1
- package/dist/workspace/upload.d.ts +2 -0
- package/dist/workspace/upload.js +4 -1
- package/dist/workspace/upload.js.map +1 -1
- package/dist/workspace/workspaces.d.ts +7 -1
- package/dist/workspace/workspaces.js +30 -21
- package/dist/workspace/workspaces.js.map +1 -1
- package/dist/workspace/worktrees.d.ts +2 -4
- package/dist/workspace/worktrees.js +28 -33
- package/dist/workspace/worktrees.js.map +1 -1
- package/package.json +4 -2
- package/sidecar/scripts/replay_sidecar.py +4 -2
- package/sidecar/talaria_sidecar/__init__.py +1 -1
- package/sidecar/talaria_sidecar/__main__.py +7 -1
- package/sidecar/talaria_sidecar/home.py +98 -2
- package/sidecar/talaria_sidecar/methods/__init__.py +2 -2
- package/sidecar/talaria_sidecar/methods/aux.py +19 -3
- package/sidecar/talaria_sidecar/methods/chat.py +551 -61
- package/sidecar/talaria_sidecar/methods/commands.py +61 -33
- package/sidecar/talaria_sidecar/methods/config.py +14 -10
- package/sidecar/talaria_sidecar/methods/cron.py +10 -6
- package/sidecar/talaria_sidecar/methods/kanban.py +115 -15
- package/sidecar/talaria_sidecar/methods/oauth.py +439 -0
- package/sidecar/talaria_sidecar/methods/process.py +60 -1
- package/sidecar/talaria_sidecar/methods/profiles.py +12 -5
- package/sidecar/talaria_sidecar/methods/providers.py +161 -1
- package/sidecar/talaria_sidecar/methods/runtime.py +5 -0
- package/sidecar/talaria_sidecar/methods/text.py +18 -21
- package/sidecar/talaria_sidecar/methods/usage.py +264 -5
- package/static/dist/FILES.txt +126 -121
- package/static/dist/assets/AppShell-fb2td9zi.js +1 -0
- package/static/dist/assets/ChatPage-cj96kiy9.js +25 -0
- package/static/dist/assets/Dialog-kxcg7ard.js +1 -0
- package/static/dist/assets/ExtensionRoute-jf7h0754.js +1 -0
- package/static/dist/assets/HubRoute-gmanpd5y.js +2 -0
- package/static/dist/assets/{Markdown-dy8t4dy7.js → Markdown-mdwlvj3w.js} +125 -125
- package/static/dist/assets/RightPanel-ftvrjlcp.js +1 -0
- package/static/dist/assets/Select-bilaypej.js +1 -0
- package/static/dist/assets/SessionListPanel-og8n36qs.js +1 -0
- package/static/dist/assets/SettingsLayout-jonjefmj.js +1 -0
- package/static/dist/assets/States-nmejrwrm.js +1 -0
- package/static/dist/assets/Toaster-nnl8zkt9.js +1 -0
- package/static/dist/assets/_app-gdidzd94.js +1 -0
- package/static/dist/assets/_app.ext._extensionId-opyh9y0v.js +1 -0
- package/static/dist/assets/_app.index-hbarzbjx.js +1 -0
- package/static/dist/assets/_app.insights-luywlarz.js +1 -0
- package/static/dist/assets/_app.kanban-kincnbr8.js +1 -0
- package/static/dist/assets/_app.logs-oi2gr3gs.js +1 -0
- package/static/dist/assets/_app.memory-b6jfivt6.js +1 -0
- package/static/dist/assets/_app.profiles-lpvixmqp.js +1 -0
- package/static/dist/assets/{_app.session._sessionId-gyoh5ylx.js → _app.session._sessionId-bfd4h3id.js} +1 -1
- package/static/dist/assets/_app.settings-vowx3iq0.js +1 -0
- package/static/dist/assets/_app.settings._section-cnntg1tu.js +3 -0
- package/static/dist/assets/_app.skills-fb57lpau.js +1 -0
- package/static/dist/assets/_app.tasks-eszpx1q7.js +2 -0
- package/static/dist/assets/_app.todos-nr05ac6w.js +1 -0
- package/static/dist/assets/_app.workspaces-d2luomu6.js +1 -0
- package/static/dist/assets/{abnfDiagram-VCTEODGH-cjqletra.js → abnfDiagram-VCTEODGH-b2x1lqot.js} +1 -1
- package/static/dist/assets/architecture-7GRP2DOG-hd0o7bde.js +1 -0
- package/static/dist/assets/architectureDiagram-5GKGNRK7-h38o5l7b.js +36 -0
- package/static/dist/assets/{back-e4m6nsnm.js → back-bemaer09.js} +1 -1
- package/static/dist/assets/{blockDiagram-I7D4REHJ-b195z021.js → blockDiagram-I7D4REHJ-bqquxv2u.js} +1 -1
- package/static/dist/assets/{c4Diagram-7LVT6UL2-olu212k1.js → c4Diagram-7LVT6UL2-f3fb8w6z.js} +1 -1
- package/static/dist/assets/cancel-drd0g6d9.js +1 -0
- package/static/dist/assets/channel-cpdgf867.js +1 -0
- package/static/dist/assets/{chunk-4HAMMTFA-b8o1nufs.js → chunk-4HAMMTFA-bzfyyzp3.js} +1 -1
- package/static/dist/assets/{chunk-75Z2AOVW-l1seddto.js → chunk-75Z2AOVW-ly99rfx5.js} +1 -1
- package/static/dist/assets/{chunk-DU6HZSFF-lo5zg15v.js → chunk-DU6HZSFF-bg6ecfqf.js} +3 -3
- package/static/dist/assets/{chunk-F27PBJKO-dsdzrp16.js → chunk-F27PBJKO-e8k7mmqw.js} +1 -1
- package/static/dist/assets/{chunk-GMAD6QVW-f3fxqq8b.js → chunk-GMAD6QVW-cmalnoaj.js} +1 -1
- package/static/dist/assets/{chunk-GVQU2GXP-h8egxtf6.js → chunk-GVQU2GXP-gmif5o01.js} +1 -1
- package/static/dist/assets/{chunk-IMKFNOWR-iolwd8s0.js → chunk-IMKFNOWR-f1nwwh6u.js} +1 -1
- package/static/dist/assets/{chunk-L3NEJ4N5-b88bbtwi.js → chunk-L3NEJ4N5-oktfh5v7.js} +1 -1
- package/static/dist/assets/{chunk-OSK3NFVY-e3e7nr6s.js → chunk-OSK3NFVY-b1sdx96z.js} +1 -1
- package/static/dist/assets/{chunk-P2QGCYS3-mdc46rt1.js → chunk-P2QGCYS3-fhu4nzxt.js} +1 -1
- package/static/dist/assets/{chunk-POPQ4Y6H-kqlllrrm.js → chunk-POPQ4Y6H-em31rnef.js} +1 -1
- package/static/dist/assets/{chunk-PWAF6VOD-he585k2x.js → chunk-PWAF6VOD-bxhhyftw.js} +1 -1
- package/static/dist/assets/{chunk-SHT3W25Y-gh94y9a4.js → chunk-SHT3W25Y-fmrvwcav.js} +3 -3
- package/static/dist/assets/{chunk-SVP7TREG-euxp9y7e.js → chunk-SVP7TREG-lymukl2j.js} +1 -1
- package/static/dist/assets/{chunk-TICWLB2K-c8cxes1q.js → chunk-TICWLB2K-lmza6k23.js} +1 -1
- package/static/dist/assets/classDiagram-ZZMXUADV-ko09sguk.js +1 -0
- package/static/dist/assets/classDiagram-v2-VYDZK3BY-ko09sguk.js +1 -0
- package/static/dist/assets/{copied-kq0u3ht7.js → copied-ima3pysb.js} +1 -1
- package/static/dist/assets/copy-fw5b1pc6.js +1 -0
- package/static/dist/assets/{cynefin-OW5HDTMX-lxd44ux4.js → cynefin-OW5HDTMX-czlhbyhs.js} +1 -1
- package/static/dist/assets/{cynefinDiagram-5FMLGOSQ-njpiwat8.js → cynefinDiagram-5FMLGOSQ-gq7mvswb.js} +1 -1
- package/static/dist/assets/{dagre-GXQ25YYZ-nvgy6yju.js → dagre-GXQ25YYZ-cqdbf7dr.js} +4 -4
- package/static/dist/assets/{diagram-S7CK7UJ4-g98nyv72.js → diagram-S7CK7UJ4-o6arxa58.js} +3 -3
- package/static/dist/assets/{diagram-UQ7AKVKN-ovgm6lvv.js → diagram-UQ7AKVKN-nhnd68tz.js} +1 -1
- package/static/dist/assets/{diagram-VSXAHHWV-pb2bn2e4.js → diagram-VSXAHHWV-dk2sclzn.js} +1 -1
- package/static/dist/assets/{diagram-VX7I27RA-6j4mvfqo.js → diagram-VX7I27RA-n3k5nyx3.js} +1 -1
- package/static/dist/assets/{diagram-Z3DM3KII-dqmb982r.js → diagram-Z3DM3KII-m6q9cwdh.js} +1 -1
- package/static/dist/assets/{ebnfDiagram-PWID7BFC-ozbpzb6d.js → ebnfDiagram-PWID7BFC-lsa5641p.js} +1 -1
- package/static/dist/assets/endpoints-d33siokx.js +64 -0
- package/static/dist/assets/{erDiagram-RLTQ6QDP-hl7m8vua.js → erDiagram-RLTQ6QDP-fo2ckbe6.js} +1 -1
- package/static/dist/assets/eventmodeling-NTZA5JFV-emctlo64.js +1 -0
- package/static/dist/assets/flowDiagram-HODETNUW-bfi3moqa.js +1 -0
- package/static/dist/assets/{ganttDiagram-EL5Y4UJY-ompixqmh.js → ganttDiagram-EL5Y4UJY-nzqz6x9g.js} +1 -1
- package/static/dist/assets/{gitGraph-4MIJSDKK-b6vxwp9q.js → gitGraph-4MIJSDKK-gukvcxe6.js} +1 -1
- package/static/dist/assets/{gitGraphDiagram-WWUBYQGX-k98f57on.js → gitGraphDiagram-WWUBYQGX-lqhdp8f0.js} +1 -1
- package/static/dist/assets/{highlighted-body-KPVGNVTW-cd3ff6ge.js → highlighted-body-KPVGNVTW-igw6cp39.js} +1 -1
- package/static/dist/assets/index-cxq58xb1.js +11 -0
- package/static/dist/assets/index-hgtx08n5.css +2 -0
- package/static/dist/assets/{info-A6RAGUB7-hjcwjami.js → info-A6RAGUB7-gvu7a93l.js} +1 -1
- package/static/dist/assets/infoDiagram-27XIBGKW-m8t8g6w1.js +2 -0
- package/static/dist/assets/{ishikawaDiagram-5VMMS53U-bxak4mwu.js → ishikawaDiagram-5VMMS53U-l07vkbgj.js} +1 -1
- package/static/dist/assets/{journeyDiagram-3NMN7TZE-khmihclr.js → journeyDiagram-3NMN7TZE-hybkgj8l.js} +1 -1
- package/static/dist/assets/{kanban-definition-UXKFOSKX-h1r32wdx.js → kanban-definition-UXKFOSKX-by13uvtn.js} +1 -1
- package/static/dist/assets/{lazyRouteComponent-b2eg0fws.js → lazyRouteComponent-af0lvgjx.js} +1 -1
- package/static/dist/assets/{line-friyjof3.js → line-osxq6fq7.js} +1 -1
- package/static/dist/assets/link-jyxicdhg.js +1 -0
- package/static/dist/assets/login-ec21t39x.js +1 -0
- package/static/dist/assets/mermaid-HWGCJPDP-gfl8z3ep.js +1 -0
- package/static/dist/assets/{mermaid-parser.core-f1gvpwrt.js → mermaid-parser.core-i91oxmou.js} +3 -3
- package/static/dist/assets/{mindmap-definition-YA3MSWOX-mt2oxwue.js → mindmap-definition-YA3MSWOX-m82mx8ww.js} +1 -1
- package/static/dist/assets/onboarding-btnltvyz.js +1 -0
- package/static/dist/assets/onboarding_model_label-dp7xffoh.js +1 -0
- package/static/dist/assets/{packet-AYTQ26CC-jik4ixlf.js → packet-AYTQ26CC-nbtgge2o.js} +1 -1
- package/static/dist/assets/{pegDiagram-XKGWAZYB-erxlv6cc.js → pegDiagram-XKGWAZYB-f1zxzga1.js} +1 -1
- package/static/dist/assets/{pie-WAS4IAKB-mnkoldfl.js → pie-WAS4IAKB-of8vuhxs.js} +1 -1
- package/static/dist/assets/{pieDiagram-E7YTZNPT-i7lwrada.js → pieDiagram-E7YTZNPT-o7y8ftio.js} +3 -3
- package/static/dist/assets/{quadrantDiagram-AXDQQJYC-fr8ejdoy.js → quadrantDiagram-AXDQQJYC-iqiwia1t.js} +1 -1
- package/static/dist/assets/{radar-RG4KPBEZ-otarjte2.js → radar-RG4KPBEZ-delnsy2v.js} +1 -1
- package/static/dist/assets/{railroad-74A4TZTK-lw362oto.js → railroad-74A4TZTK-nspnyq9u.js} +1 -1
- package/static/dist/assets/railroad-abnf-HS5TGJTU-nst1qxyn.js +1 -0
- package/static/dist/assets/railroad-ebnf-LZEXJU2U-ci37x8qy.js +1 -0
- package/static/dist/assets/railroad-peg-WCYAUIDC-aimkk07z.js +1 -0
- package/static/dist/assets/{railroadDiagram-O6MQD6OU-esyn75pw.js → railroadDiagram-O6MQD6OU-z41tqx5z.js} +1 -1
- package/static/dist/assets/refresh-b4uams1i.js +1 -0
- package/static/dist/assets/refresh-cw-nt1dtsx4.js +1 -0
- package/static/dist/assets/{requirementDiagram-BXWQKSXE-cyhi5u29.js → requirementDiagram-BXWQKSXE-bdvif194.js} +1 -1
- package/static/dist/assets/{sankeyDiagram-P5KCCOFB-jljpbo0h.js → sankeyDiagram-P5KCCOFB-h73e0rol.js} +1 -1
- package/static/dist/assets/{saved-do49si3v.js → saved-kg52csy7.js} +1 -1
- package/static/dist/assets/{sequenceDiagram-WJ2MYXX4-fz0dhlu7.js → sequenceDiagram-WJ2MYXX4-iv95mb1z.js} +1 -1
- package/static/dist/assets/{settings_section_extensions_title-b7lojn0o.js → settings_section_extensions_title-ej0iapxe.js} +1 -1
- package/static/dist/assets/share._token-f97e94r0.js +2 -0
- package/static/dist/assets/sliders-horizontal-fj18it47.js +1 -0
- package/static/dist/assets/sse-fwpqtzbg.js +1 -0
- package/static/dist/assets/{stateDiagram-D77RDMKH-kdnithdx.js → stateDiagram-D77RDMKH-k417ixmf.js} +1 -1
- package/static/dist/assets/stateDiagram-v2-MP3YSRHH-hwn4apct.js +1 -0
- package/static/dist/assets/{swimlanes-42K2YHIH-esd6gmz3.js → swimlanes-42K2YHIH-esola70w.js} +1 -1
- package/static/dist/assets/swimlanesDiagram-VR7AAH4N-d1h97oyg.js +8 -0
- package/static/dist/assets/{timeline-definition-24CTP7MA-g0qygccv.js → timeline-definition-24CTP7MA-getrz2y8.js} +1 -1
- package/static/dist/assets/todoStore-eginf9h4.js +1 -0
- package/static/dist/assets/{treeView-Q6P3EWNA-ndchzkuu.js → treeView-Q6P3EWNA-hc7ap1q7.js} +1 -1
- package/static/dist/assets/{treemap-WGGIJYW6-hwjxj0zz.js → treemap-WGGIJYW6-b7k1syy5.js} +1 -1
- package/static/dist/assets/tts-lxdxnmjd.js +1 -0
- package/static/dist/assets/{untitled-mbr6tuhu.js → untitled-ovpfthg4.js} +1 -1
- package/static/dist/assets/useForm-bcblv1rx.js +1 -0
- package/static/dist/assets/useLocale-fi5jb8ma.js +1 -0
- package/static/dist/assets/{useParams-lo9u33d1.js → useParams-lhg566ew.js} +1 -1
- package/static/dist/assets/{useSearch-lq1mr090.js → useSearch-lbrh6ejn.js} +1 -1
- package/static/dist/assets/{vennDiagram-4TSXK5OY-gubovd1r.js → vennDiagram-4TSXK5OY-n6pcy5qb.js} +1 -1
- package/static/dist/assets/{wardley-WFR3VGLG-ir3om65j.js → wardley-WFR3VGLG-lpa18vz1.js} +1 -1
- package/static/dist/assets/{wardleyDiagram-VM6X3IG4-ce0or1ie.js → wardleyDiagram-VM6X3IG4-kpclwwg7.js} +2 -2
- package/static/dist/assets/{xychartDiagram-S5SC5T6Z-dexylcjg.js → xychartDiagram-S5SC5T6Z-paa2n8y0.js} +1 -1
- package/static/dist/index.html +1 -1
- package/static/dist/sw.js +2 -2
- package/static/dist/assets/AppShell-orat1s43.js +0 -1
- package/static/dist/assets/ChatPage-ojhka3hy.js +0 -22
- package/static/dist/assets/HubRoute-gxgw1t33.js +0 -2
- package/static/dist/assets/RightPanel-c66ani1s.js +0 -1
- package/static/dist/assets/Select-fswf66up.js +0 -1
- package/static/dist/assets/SessionListPanel-goh5mtt8.js +0 -1
- package/static/dist/assets/SettingsLayout-n5d4wq4m.js +0 -1
- package/static/dist/assets/States-kxyu50at.js +0 -1
- package/static/dist/assets/Toaster-6zmbcqgg.js +0 -1
- package/static/dist/assets/_app-fowyruiu.js +0 -1
- package/static/dist/assets/_app.ext._extensionId-nvjyrwhy.js +0 -1
- package/static/dist/assets/_app.index-c9zv4y7w.js +0 -1
- package/static/dist/assets/_app.insights-jf12nofo.js +0 -1
- package/static/dist/assets/_app.kanban-lri5a4yu.js +0 -1
- package/static/dist/assets/_app.logs-md1p0zqo.js +0 -1
- package/static/dist/assets/_app.memory-bzvf2e28.js +0 -1
- package/static/dist/assets/_app.profiles-jpg7zmbn.js +0 -1
- package/static/dist/assets/_app.settings-mpuwlx4j.js +0 -1
- package/static/dist/assets/_app.settings._section-knz8rwoj.js +0 -1
- package/static/dist/assets/_app.skills-fwu1dkir.js +0 -1
- package/static/dist/assets/_app.tasks-h9gphtl3.js +0 -2
- package/static/dist/assets/_app.todos-hd1dnvrh.js +0 -1
- package/static/dist/assets/_app.workspaces-l8toyn5f.js +0 -1
- package/static/dist/assets/architecture-7GRP2DOG-cavnusaw.js +0 -1
- package/static/dist/assets/architectureDiagram-5GKGNRK7-k5a7v36n.js +0 -36
- package/static/dist/assets/channel-gozy3dp3.js +0 -1
- package/static/dist/assets/classDiagram-ZZMXUADV-n0vs2f2q.js +0 -1
- package/static/dist/assets/classDiagram-v2-VYDZK3BY-n0vs2f2q.js +0 -1
- package/static/dist/assets/endpoints-k8agnro8.js +0 -9
- package/static/dist/assets/eventmodeling-NTZA5JFV-j1y2ekec.js +0 -1
- package/static/dist/assets/flowDiagram-HODETNUW-g2cvcbm5.js +0 -1
- package/static/dist/assets/index-nfy0ezs6.css +0 -2
- package/static/dist/assets/index-nqed3oum.js +0 -11
- package/static/dist/assets/infoDiagram-27XIBGKW-fm06rj36.js +0 -2
- package/static/dist/assets/link-m90ahx3n.js +0 -1
- package/static/dist/assets/login-bw7m2fzo.js +0 -1
- package/static/dist/assets/matchContext-f8oy3lo7.js +0 -1
- package/static/dist/assets/mermaid-HWGCJPDP-njwcl62n.js +0 -1
- package/static/dist/assets/onboarding-ret37j1e.js +0 -1
- package/static/dist/assets/railroad-abnf-HS5TGJTU-m741owar.js +0 -1
- package/static/dist/assets/railroad-ebnf-LZEXJU2U-noa4fbia.js +0 -1
- package/static/dist/assets/railroad-peg-WCYAUIDC-o4bpw4nb.js +0 -1
- package/static/dist/assets/refresh-cw-k66keho6.js +0 -1
- package/static/dist/assets/search-j5ki6a9o.js +0 -1
- package/static/dist/assets/share._token-kg8w647b.js +0 -2
- package/static/dist/assets/sliders-horizontal-c4wya4dy.js +0 -1
- package/static/dist/assets/stateDiagram-v2-MP3YSRHH-k2wg2645.js +0 -1
- package/static/dist/assets/swimlanesDiagram-VR7AAH4N-n5kvjtgr.js +0 -8
- package/static/dist/assets/tabBuild-bjmyvmn5.js +0 -56
- package/static/dist/assets/todoStore-kjk5l0sy.js +0 -1
- package/static/dist/assets/useForm-ocxu460f.js +0 -1
- package/static/dist/assets/useLocale-fzy5jhg6.js +0 -1
|
@@ -8,17 +8,17 @@
|
|
|
8
8
|
* here. ponytail: one live-id cache with a 24h TTL replaces the Python
|
|
9
9
|
* publisher/provenance machinery; `refresh()` evicts it.
|
|
10
10
|
*/
|
|
11
|
-
import { readCapped } from '../http/capped.js';
|
|
12
11
|
import { homeDotenvKeys } from '../cli/dotenv.js';
|
|
13
|
-
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
12
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs';
|
|
14
13
|
import { createHash, randomUUID } from 'node:crypto';
|
|
15
14
|
import { join } from 'node:path';
|
|
16
15
|
import { atomicWriteText } from '../fs/atomic.js';
|
|
17
16
|
import { str } from '../util.js';
|
|
17
|
+
import { QUOTA_THRESHOLD_DEFAULTS } from '../settings.js';
|
|
18
18
|
import { loadEnvFile } from './env-file.js';
|
|
19
19
|
import { ACCOUNT_USAGE_PROVIDERS, FALLBACK_MODELS, OAUTH_PROVIDERS, PORTAL_PROVIDERS, PROVIDER_DISPLAY, PROVIDER_ENV_VAR, PROVIDER_ENV_VAR_ALIASES, PROVIDER_MODELS, SELF_HOSTED_PROVIDER_IDS, } from './tables.js';
|
|
20
20
|
import { activeProviderFromConfig, canonicaliseProviderId, configuredModelIds, configuredModelOptions, customProviderEntries, customProviderSlug, dict, effectiveDefaultModel, isDict, isOpenAiFamilyProvider, mainModelSupportsServiceTier, modelSection, parseProviderQualifiedModel, providerIdentity, resolveProviderAlias, } from '../config/agent-config.js';
|
|
21
|
-
/** Python `_model_matches_picker_selection`: same bare id, and the routing hints agree when
|
|
21
|
+
/** Python `_model_matches_picker_selection`: same bare id, and the routing hints agree when the selection names one. */
|
|
22
22
|
function modelMatchesPickerSelection(modelId, selected, providerId) {
|
|
23
23
|
const candidate = modelId.trim();
|
|
24
24
|
const sel = selected.trim();
|
|
@@ -26,12 +26,80 @@ function modelMatchesPickerSelection(modelId, selected, providerId) {
|
|
|
26
26
|
return false;
|
|
27
27
|
if (candidate === sel)
|
|
28
28
|
return true;
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
29
|
+
const [candBare, candProvider] = parseProviderQualifiedModel(candidate) ?? [candidate, providerId.trim()];
|
|
30
|
+
const [selBare, selProvider] = parseProviderQualifiedModel(sel) ?? [sel, ''];
|
|
31
|
+
return selBare === candBare && (!selProvider || selProvider.toLowerCase() === candProvider.toLowerCase());
|
|
32
|
+
}
|
|
33
|
+
/** TAL-301: every entry names its routing provider and bare id, split once by `parseProviderQualifiedModel`. */
|
|
34
|
+
function stampModelEntries(models, providerId) {
|
|
35
|
+
return models.map((m) => {
|
|
36
|
+
const [bare, provider] = parseProviderQualifiedModel(m.id) ?? [m.id, providerId];
|
|
37
|
+
return { ...m, bare_id: bare, provider_id: provider };
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
/** TAL-301: the catalog entry a stored `(model, provider)` pair selects, so clients render the pairing instead of computing it. */
|
|
41
|
+
export function catalogOptionId(catalog, model, provider) {
|
|
42
|
+
const bare = str(model).trim();
|
|
43
|
+
const pid = str(provider).trim().toLowerCase();
|
|
44
|
+
if (!bare || !pid)
|
|
45
|
+
return null;
|
|
46
|
+
for (const g of catalog.groups)
|
|
47
|
+
for (const e of [...g.models, ...(g.extra_models ?? [])])
|
|
48
|
+
if (e.bare_id === bare && str(e.provider_id).toLowerCase() === pid)
|
|
49
|
+
return e.id;
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
/** TAL-542: the vendor a first-party provider serves, or a model id names by prefix; '' when neither says. */
|
|
53
|
+
const PROVIDER_VENDOR = { openai: 'openai', 'openai-api': 'openai', 'openai-codex': 'openai', anthropic: 'anthropic', gemini: 'google', google: 'google' };
|
|
54
|
+
function modelVendor(model) {
|
|
55
|
+
const m = model.trim().toLowerCase();
|
|
56
|
+
if (m.includes('/'))
|
|
57
|
+
return { openai: 'openai', anthropic: 'anthropic', google: 'google', gemini: 'google' }[m.slice(0, m.indexOf('/'))] ?? '';
|
|
58
|
+
return m.startsWith('gpt') ? 'openai' : m.startsWith('claude') ? 'anthropic' : m.startsWith('gemini') ? 'google' : '';
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* TAL-542 (Python `_resolve_compatible_session_model_state` + `_repair_foreign_session_model_provider`): the pair a
|
|
62
|
+
* session's stale model starts on. A model its provider's catalog group does not list moves to the one other provider
|
|
63
|
+
* that lists it; else, when the model names another vendor than a first-party provider, to the profile default. No
|
|
64
|
+
* evidence (no group for the provider, a group built from a failed live lookup in `unlisted`, which never names an owner either) keeps the pair, and so does a stored
|
|
65
|
+
* provider that is the profile's own unless the model names another vendor. `null` keeps the pair.
|
|
66
|
+
*/
|
|
67
|
+
export function repairSessionModel(catalog, model, provider, unlisted = new Set()) {
|
|
68
|
+
const active = canonicaliseProviderId(catalog.active_provider);
|
|
69
|
+
const pid = canonicaliseProviderId(provider) || active;
|
|
70
|
+
if (!model.trim() || !pid || unlisted.has(pid) || !catalog.groups.some((g) => canonicaliseProviderId(g.provider_id) === pid))
|
|
71
|
+
return null;
|
|
72
|
+
const loose = (id) => id.trim().toLowerCase().replaceAll('-', '.');
|
|
73
|
+
const want = loose(model);
|
|
74
|
+
const entries = catalog.groups.flatMap((g) => [...g.models, ...(g.extra_models ?? [])]);
|
|
75
|
+
const tail = (id) => loose(id.slice(id.indexOf('/') + 1));
|
|
76
|
+
const own = entries.filter((e) => canonicaliseProviderId(e.provider_id) === pid).map((e) => str(e.bare_id));
|
|
77
|
+
if (own.some((bare) => loose(bare) === want))
|
|
78
|
+
return null;
|
|
79
|
+
// Equivalence across ids only drops a recognised vendor prefix; another namespace is no evidence. A match starts on the
|
|
80
|
+
// matching entry's own id, so the turn runs on the id that provider advertises.
|
|
81
|
+
const vendorless = (id) => loose(id.replace(/^(openai|anthropic|google|gemini)\//i, ''));
|
|
82
|
+
const same = (bare) => vendorless(bare) === vendorless(model);
|
|
83
|
+
const here = own.find(same);
|
|
84
|
+
if (here)
|
|
85
|
+
return [here, pid];
|
|
86
|
+
// Python `_catalog_model_id_matches`: a model the provider lists under another namespace is kept, never moved.
|
|
87
|
+
if (own.some((bare) => tail(bare) === want))
|
|
88
|
+
return null;
|
|
89
|
+
if (!provider || pid !== active) {
|
|
90
|
+
const owners = new Map(entries.filter((e) => same(str(e.bare_id)) && !unlisted.has(canonicaliseProviderId(e.provider_id))).map((e) => [canonicaliseProviderId(e.provider_id), str(e.bare_id)]));
|
|
91
|
+
const [only] = owners.size === 1 ? [...owners] : [];
|
|
92
|
+
if (only)
|
|
93
|
+
return [only[1], only[0]];
|
|
94
|
+
}
|
|
95
|
+
const vendor = PROVIDER_VENDOR[pid] ?? '';
|
|
96
|
+
const named = modelVendor(model);
|
|
97
|
+
// Python: Codex takes bare ids, so an `openai/` id on it is stale too.
|
|
98
|
+
if (!vendor || !named || (named === vendor && !(pid === 'openai-codex' && model.includes('/'))))
|
|
99
|
+
return null;
|
|
100
|
+
const defaultModel = str(catalog.default_bare_id).trim();
|
|
101
|
+
const defaultProvider = canonicaliseProviderId(catalog.default_provider_id) || active;
|
|
102
|
+
return defaultModel && defaultProvider && (defaultModel !== model || defaultProvider !== pid) ? [defaultModel, defaultProvider] : null;
|
|
35
103
|
}
|
|
36
104
|
/** Python `_split_picker_overflow_models`: past 25 rows the picker shows 15, keeping the selected model visible. */
|
|
37
105
|
export function splitPickerOverflow(models, selected, providerId) {
|
|
@@ -82,20 +150,196 @@ export function stampAuxiliarySelections(aux, catalog) {
|
|
|
82
150
|
}
|
|
83
151
|
/** Python `_BESPOKE_CATALOG_PROVIDERS`: cards whose catalog is resolved by their own rule, never the generic live probe. */
|
|
84
152
|
const BESPOKE_CATALOG_PROVIDERS = new Set(['openai-codex', 'nous', 'xai-oauth', 'lmstudio', 'opencode-go']);
|
|
153
|
+
/** A provider's configured endpoint: its `providers.<id>.base_url` (alias-aware), else the active model's `base_url`. */
|
|
154
|
+
const configuredBaseUrl = (pid, config) => {
|
|
155
|
+
const providers = dict(config.providers);
|
|
156
|
+
const cfg = dict(providers[pid] ?? providers[Object.keys(providers).find((k) => providerIdentity(k) === providerIdentity(pid)) ?? '']);
|
|
157
|
+
return str(cfg.base_url).trim() || (activeProviderFromConfig(config) === pid ? str(modelSection(config).base_url).trim() : '');
|
|
158
|
+
};
|
|
159
|
+
/** TAL-570: Ollama and LM Studio run keyless, so a configured endpoint makes them ready the way a key does. */
|
|
160
|
+
const selfHostedEndpoint = (pid, config) => SELF_HOSTED_PROVIDER_IDS.has(pid) && Boolean(configuredBaseUrl(pid, config));
|
|
85
161
|
/** Python `_unqualified_model_id`: strip a picker routing hint (`@provider:`). */
|
|
86
|
-
|
|
87
|
-
const raw = id.trim();
|
|
88
|
-
return raw.startsWith('@') && raw.includes(':') ? raw.slice(raw.indexOf(':') + 1) : raw;
|
|
89
|
-
}
|
|
162
|
+
const unqualifiedModelId = (id) => parseProviderQualifiedModel(id)?.[0] ?? id.trim();
|
|
90
163
|
/** Python `_MODEL_PICKER_OVERFLOW_THRESHOLD` / `_MODEL_PICKER_VISIBLE_TARGET`. */
|
|
91
164
|
const MODEL_PICKER_OVERFLOW_THRESHOLD = 25;
|
|
92
165
|
const MODEL_PICKER_VISIBLE_TARGET = 15;
|
|
93
166
|
const ACCOUNT_USAGE_CACHE_TTL_S = 45;
|
|
94
167
|
const LIVE_TTL_S = 86_400;
|
|
95
168
|
const PROVIDERS_TTL_S = 30;
|
|
96
|
-
|
|
97
|
-
const
|
|
169
|
+
/** The sidecar reads a balance endpoint with a 15 s timeout of its own. */
|
|
170
|
+
const BALANCE_TIMEOUT_MS = 20_000;
|
|
171
|
+
const PLUGIN_LIST_TIMEOUT_MS = 15_000;
|
|
172
|
+
/** TAL-288: the card text for a plugin provider the Agent does not report ready. */
|
|
173
|
+
const PLUGIN_SETUP_ERRORS = {
|
|
174
|
+
missing_cli: "This provider's CLI was not found on the server. Install it and sign in, then refresh.",
|
|
175
|
+
needs_setup: "This provider is not set up yet. Finish its setup in Hermes, then refresh.",
|
|
176
|
+
not_loaded: 'This provider is installed but could not be loaded. Check it with hermes plugins doctor, then restart Talaria Web.',
|
|
177
|
+
unavailable: "This provider's setup status is unavailable.",
|
|
178
|
+
};
|
|
179
|
+
const PLUGIN_NO_MODELS = 'This provider listed no models.';
|
|
98
180
|
const COST_SNAPSHOT_MAX_DAYS = 365;
|
|
181
|
+
/**
|
|
182
|
+
* The last `days` daily snapshots with their spend deltas, bar heights, monthly pace, and budget standing (the legacy
|
|
183
|
+
* Web chart's formulas). A drop in `used` is a credit reset, so that day's delta is its `used`.
|
|
184
|
+
*/
|
|
185
|
+
function costHistoryView(rows, days, budget) {
|
|
186
|
+
const window = rows.slice(-days);
|
|
187
|
+
const deltas = window.map((e, i) => {
|
|
188
|
+
const prev = window[i - 1];
|
|
189
|
+
if (i === 0 || e.used === null || prev?.used === null || prev?.used === undefined)
|
|
190
|
+
return null;
|
|
191
|
+
const delta = e.used - prev.used < 0 ? e.used : e.used - prev.used;
|
|
192
|
+
return Math.abs(delta) < 1e-9 ? 0 : Math.round(delta * 1e6) / 1e6;
|
|
193
|
+
});
|
|
194
|
+
const known = deltas.filter((d) => d !== null);
|
|
195
|
+
const maxDelta = Math.max(...deltas.map((d) => d ?? 0), 1e-9);
|
|
196
|
+
const pace = known.length ? (known.reduce((a, b) => a + b, 0) / known.length) * 30 : null;
|
|
197
|
+
const budgetPercent = pace !== null && pace > 0 && budget !== null && budget > 0 ? Math.round((pace / budget) * 100) : null;
|
|
198
|
+
return {
|
|
199
|
+
snapshots: window.map((e, i) => {
|
|
200
|
+
const delta = deltas[i] ?? null;
|
|
201
|
+
return { date: e.date, used: e.used, delta, bar_percent: delta === null ? 0 : Number(Math.max((delta / maxDelta) * 100, delta > 0 ? 2 : 0).toFixed(1)) };
|
|
202
|
+
}),
|
|
203
|
+
monthly_pace: pace === null ? null : Math.round(pace * 100) / 100,
|
|
204
|
+
has_enough_data: known.length > 0,
|
|
205
|
+
budget_percent: budgetPercent,
|
|
206
|
+
budget_level: budgetPercent === null ? null : budgetPercent >= 100 ? 'over' : budgetPercent >= 80 ? 'warn' : 'ok',
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
const FIVE_HOUR_WINDOW_S = 18_000;
|
|
210
|
+
const WEEK_WINDOW_S = 604_800;
|
|
211
|
+
/** Swift `rounded()`: half away from zero. */
|
|
212
|
+
function roundTo(value, digits) {
|
|
213
|
+
const scale = 10 ** digits;
|
|
214
|
+
return (Math.sign(value) * Math.round(Math.abs(value) * scale)) / scale;
|
|
215
|
+
}
|
|
216
|
+
const finite = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
|
|
217
|
+
/** ISO-8601 UTC for epoch seconds, without a `.000` fraction. */
|
|
218
|
+
export const isoAt = (seconds) => new Date(seconds * 1000).toISOString().replace('.000Z', 'Z');
|
|
219
|
+
/** ISO-8601 UTC (`…Z`) or null. Accepts the pre-TAL-409 sidecar's `str(datetime)` space form; a zone-less stamp is UTC. */
|
|
220
|
+
export function isoUtc(value) {
|
|
221
|
+
if (typeof value !== 'string' || !value.trim())
|
|
222
|
+
return null;
|
|
223
|
+
let text = value.trim().replace(' ', 'T');
|
|
224
|
+
if (text.includes('T') && !/(Z|[+-]\d{2}:?\d{2})$/i.test(text))
|
|
225
|
+
text += 'Z';
|
|
226
|
+
const ms = Date.parse(text);
|
|
227
|
+
return Number.isFinite(ms) ? isoAt(ms / 1000) : null;
|
|
228
|
+
}
|
|
229
|
+
/** The forecast a pace implies: per-hour or per-day budget until reset, and whether the projection empties the window first. */
|
|
230
|
+
export function quotaForecast(pace, remaining) {
|
|
231
|
+
const daily = pace.minutes_to_reset >= 24 * 60;
|
|
232
|
+
const margin = pace.projected_minutes_to_empty === null ? null : pace.projected_minutes_to_empty - pace.minutes_to_reset;
|
|
233
|
+
return {
|
|
234
|
+
outcome: margin === null || margin >= 0 ? 'safe' : 'warning',
|
|
235
|
+
budget_unit: daily ? 'day' : 'hour',
|
|
236
|
+
budget_percent: remaining !== null && pace.minutes_to_reset > 0 ? remaining / (pace.minutes_to_reset / (daily ? 24 * 60 : 60)) : null,
|
|
237
|
+
depletion_margin_minutes: margin,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* TAL-409: the one normaliser for account-usage windows (Python `_snapshot_windows_payload` plus the pace math the
|
|
242
|
+
* App used to run). Blank labels drop; `reset_at` becomes ISO `Z`; each window gets its duration, pace and forecast
|
|
243
|
+
* as of `nowSeconds`; the indexes name the windows the App shows for pace, session and weekly selections.
|
|
244
|
+
*/
|
|
245
|
+
export function normalizeQuotaWindows(raw, nowSeconds) {
|
|
246
|
+
const nowMs = nowSeconds * 1000;
|
|
247
|
+
const windows = [];
|
|
248
|
+
for (const entry of Array.isArray(raw) ? raw : []) {
|
|
249
|
+
if (!isDict(entry))
|
|
250
|
+
continue;
|
|
251
|
+
const label = str(entry.label).trim();
|
|
252
|
+
if (!label)
|
|
253
|
+
continue;
|
|
254
|
+
const usedRaw = finite(entry.used_percent);
|
|
255
|
+
const remainingRaw = finite(entry.remaining_percent);
|
|
256
|
+
const clamp = (v) => Math.min(Math.max(v, 0), 100);
|
|
257
|
+
const used = usedRaw !== null ? clamp(usedRaw) : remainingRaw !== null ? clamp(100 - remainingRaw) : null;
|
|
258
|
+
const remaining = used === null ? null : 100 - used;
|
|
259
|
+
const resetAt = isoUtc(entry.reset_at);
|
|
260
|
+
const resetMs = resetAt === null ? null : Date.parse(resetAt);
|
|
261
|
+
const minutesToReset = resetMs === null ? null : Math.max(0, roundTo((resetMs - nowMs) / 60_000, 0));
|
|
262
|
+
const lower = label.toLowerCase();
|
|
263
|
+
const providedSeconds = finite(entry.window_seconds);
|
|
264
|
+
const windowSeconds = providedSeconds
|
|
265
|
+
?? (lower.includes('week') ? WEEK_WINDOW_S
|
|
266
|
+
: lower.includes('5h') ? FIVE_HOUR_WINDOW_S
|
|
267
|
+
// A "Session" window is 5h, or the weekly window a provider collapsed into one session bucket.
|
|
268
|
+
: lower.includes('session') && minutesToReset !== null ? (minutesToReset > 5 * 60 ? WEEK_WINDOW_S : FIVE_HOUR_WINDOW_S)
|
|
269
|
+
: null);
|
|
270
|
+
let pace = null;
|
|
271
|
+
if (resetAt !== null && resetMs !== null && minutesToReset !== null && resetMs > nowMs && used !== null && remaining !== null
|
|
272
|
+
&& (windowSeconds === FIVE_HOUR_WINDOW_S || windowSeconds === WEEK_WINDOW_S)) {
|
|
273
|
+
const windowMinutes = windowSeconds / 60;
|
|
274
|
+
const elapsed = Math.max(0, windowMinutes - minutesToReset);
|
|
275
|
+
const expectedRemaining = roundTo(Math.min(Math.max((minutesToReset / windowMinutes) * 100, 0), 100), 1);
|
|
276
|
+
const expectedUsed = roundTo(100 - expectedRemaining, 1);
|
|
277
|
+
const usagePerMinute = elapsed > 0 ? used / elapsed : 0;
|
|
278
|
+
pace = {
|
|
279
|
+
expected_remaining_percent: expectedRemaining,
|
|
280
|
+
pace_delta_percent: roundTo(remaining - expectedRemaining, 1),
|
|
281
|
+
burn_rate: expectedUsed > 0 ? roundTo(used / expectedUsed, 2) : 0,
|
|
282
|
+
minutes_to_reset: minutesToReset,
|
|
283
|
+
projected_minutes_to_empty: usagePerMinute > 0 ? roundTo(remaining / usagePerMinute, 0) : null,
|
|
284
|
+
elapsed_minutes: elapsed,
|
|
285
|
+
valid_until: resetAt,
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
windows.push({
|
|
289
|
+
label, used_percent: usedRaw, remaining_percent: remaining, reset_at: resetAt, detail: typeof entry.detail === 'string' ? entry.detail : null,
|
|
290
|
+
window_seconds: windowSeconds, pace, forecast: pace ? quotaForecast(pace, remaining) : null,
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
const index = (match) => { const i = windows.findIndex(match); return i < 0 ? null : i; };
|
|
294
|
+
const labelHas = (needle) => (w) => w.label.toLowerCase().includes(needle);
|
|
295
|
+
const knownPace = (w) => w.window_seconds === FIVE_HOUR_WINDOW_S || w.window_seconds === WEEK_WINDOW_S || ['week', 'session', '5h'].some((n) => labelHas(n)(w));
|
|
296
|
+
return {
|
|
297
|
+
windows,
|
|
298
|
+
pace_window_index: index(labelHas('week')) ?? index(knownPace),
|
|
299
|
+
session_window_index: index(labelHas('session')) ?? index(labelHas('5h')),
|
|
300
|
+
weekly_window_index: index(labelHas('week')),
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
/** The burn-rate breakpoints apply once the window is old and used enough and the projection empties it before reset. */
|
|
304
|
+
function projectionEligible(pace, usedPercent, minimumElapsedHours) {
|
|
305
|
+
return pace.elapsed_minutes >= Math.max(0, minimumElapsedHours) * 60 && usedPercent >= 5 && pace.minutes_to_reset > 20
|
|
306
|
+
&& (pace.projected_minutes_to_empty ?? Infinity) < pace.minutes_to_reset;
|
|
307
|
+
}
|
|
308
|
+
/** The App's former `ProviderQuotaUrgencyCalculator.urgency` for one colour basis; a pace basis without a pace falls back to remaining. */
|
|
309
|
+
function quotaLevel(window, status, basis, t) {
|
|
310
|
+
if (status === 'stale')
|
|
311
|
+
return 'stale';
|
|
312
|
+
const remaining = window?.remaining_percent ?? null;
|
|
313
|
+
if (status !== 'available' || remaining === null)
|
|
314
|
+
return status === 'available' ? 'healthy' : 'unavailable';
|
|
315
|
+
const pace = window?.pace ?? null;
|
|
316
|
+
if (basis === 'pace' && pace) {
|
|
317
|
+
const eligible = projectionEligible(pace, 100 - remaining, t.pace_minimum_elapsed_hours);
|
|
318
|
+
if (eligible && pace.burn_rate >= Math.max(0, t.pace_critical_burn_rate_percent) / 100)
|
|
319
|
+
return 'critical';
|
|
320
|
+
if (eligible && pace.burn_rate >= Math.max(0, t.pace_warning_burn_rate_percent) / 100)
|
|
321
|
+
return 'warning';
|
|
322
|
+
return pace.pace_delta_percent <= -Math.max(0, t.pace_tolerance_percent) ? 'warning' : 'healthy';
|
|
323
|
+
}
|
|
324
|
+
if (remaining <= Math.max(0, t.critical_remaining_percent))
|
|
325
|
+
return 'critical';
|
|
326
|
+
if (remaining <= Math.max(0, t.warning_remaining_percent))
|
|
327
|
+
return 'warning';
|
|
328
|
+
return 'healthy';
|
|
329
|
+
}
|
|
330
|
+
export function classifyQuotaSource(source, t) {
|
|
331
|
+
const urgency = (w) => ({ remaining: quotaLevel(w, source.status, 'remaining', t), pace: quotaLevel(w, source.status, 'pace', t) });
|
|
332
|
+
return {
|
|
333
|
+
...source,
|
|
334
|
+
windows: source.windows.map((w) => ({
|
|
335
|
+
...w,
|
|
336
|
+
pace: w.pace && { ...w.pace, status: w.pace.pace_delta_percent <= -Math.max(0, t.pace_tolerance_percent) ? 'over' : w.pace.pace_delta_percent > 1 ? 'under' : 'on' },
|
|
337
|
+
projection_eligible: w.pace !== null && w.remaining_percent !== null && projectionEligible(w.pace, 100 - w.remaining_percent, t.pace_minimum_elapsed_hours),
|
|
338
|
+
urgency: urgency(w),
|
|
339
|
+
})),
|
|
340
|
+
urgency: { remaining: urgency(source.windows[0]).remaining, pace: urgency(source.windows[source.pace_window_index ?? 0]).pace },
|
|
341
|
+
};
|
|
342
|
+
}
|
|
99
343
|
export function displayName(pid) {
|
|
100
344
|
return PROVIDER_DISPLAY[pid] ?? pid.split('-').map((w) => (w ? w[0].toUpperCase() + w.slice(1) : w)).join(' ');
|
|
101
345
|
}
|
|
@@ -115,9 +359,7 @@ export function formatOllamaLabel(mid) {
|
|
|
115
359
|
}
|
|
116
360
|
/** Python `_get_label_for_model`: reuse a known label, else prettify the bare id. */
|
|
117
361
|
export function labelForModel(modelId, groups) {
|
|
118
|
-
|
|
119
|
-
if (lookup.startsWith('@') && lookup.includes(':'))
|
|
120
|
-
lookup = lookup.slice(lookup.indexOf(':') + 1);
|
|
362
|
+
const lookup = unqualifiedModelId(modelId);
|
|
121
363
|
const hasScheme = (s) => s.includes('://');
|
|
122
364
|
const norm = (s) => (s.includes('/') && !hasScheme(s) ? s.slice(s.indexOf('/') + 1) : s).replaceAll('-', '.').toLowerCase();
|
|
123
365
|
const target = norm(lookup);
|
|
@@ -186,12 +428,44 @@ export function deduplicateModelIds(groups) {
|
|
|
186
428
|
m.id = `@${groups[gi].provider_id}:${m.id}`;
|
|
187
429
|
}
|
|
188
430
|
}
|
|
431
|
+
/** OpenCode Go's usage windows: their label and length (Python `_sanitize_opencode_go_account_limits`). */
|
|
432
|
+
const OPENCODE_GO_WINDOWS = { rolling: ['5-hour', FIVE_HOUR_WINDOW_S], weekly: ['Weekly', WEEK_WINDOW_S], monthly: ['Monthly', null] };
|
|
433
|
+
/** One pool account's local state as account limits; `status` is the account's, which its quota source shows. */
|
|
434
|
+
function poolEntryLimits(entry, at) {
|
|
435
|
+
return {
|
|
436
|
+
source: 'local_pool', title: 'Credential pool', status: entry.status, available: entry.status === 'available', plan: null, details: [],
|
|
437
|
+
unavailable_reason: entry.unavailable_reason, retry_after: entry.retry_after, fetched_at: null, ...normalizeQuotaWindows([], at),
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
/** Python `_local_pool_snapshot`: a provider's pool accounts summarised as account limits. */
|
|
441
|
+
function poolLimits(entries, at) {
|
|
442
|
+
const count = (status) => entries.filter((e) => e.status === status).length;
|
|
443
|
+
const [available, exhausted, dead] = [count('available'), count('exhausted'), count('dead')];
|
|
444
|
+
const details = [`${String(available)}/${String(entries.length)} credentials available`, ...(exhausted ? [`${String(exhausted)} exhausted`] : []), ...(dead ? [`${String(dead)} dead`] : [])];
|
|
445
|
+
return {
|
|
446
|
+
source: 'local_pool', title: 'Credential pool', available: available > 0, plan: null, details, unavailable_reason: available ? null : 'All pool credentials are unavailable.', fetched_at: null,
|
|
447
|
+
...normalizeQuotaWindows([], at),
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
/** Quota sources keep the first row per source id, ordered by provider id, account label, then source id (TAL-272). */
|
|
451
|
+
export function uniqueQuotaSources(sources) {
|
|
452
|
+
const cmp = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
|
453
|
+
return sources.filter((q, i) => sources.findIndex((o) => o.source_id === q.source_id) === i)
|
|
454
|
+
.sort((a, b) => cmp(a.provider_id, b.provider_id) || cmp(a.account_label, b.account_label) || cmp(a.source_id, b.source_id));
|
|
455
|
+
}
|
|
189
456
|
export class ProviderCatalog {
|
|
190
457
|
deps;
|
|
458
|
+
/** Failed live lookups by home, provider, and the `sourceFingerprint` they ran under (TAL-542). */
|
|
191
459
|
liveFailed = new Set();
|
|
460
|
+
/** `source`: the `sourceFingerprint` the ids were fetched under; another one makes them stale (TAL-542). */
|
|
192
461
|
liveIds = new Map();
|
|
193
462
|
liveInflight = new Map();
|
|
463
|
+
/** `pooled`: the fill's `poolProviders` answer, null when unknown (TAL-638). */
|
|
194
464
|
providersCache = new Map();
|
|
465
|
+
/** TAL-301: the last catalog served per profile home, for the sync session payloads' `model_option_id`. */
|
|
466
|
+
lastModels = new Map();
|
|
467
|
+
/** TAL-542: per `lastModels` catalog, the `sourceFingerprint` it was built from, when, and the providers whose group came from a failed live lookup. */
|
|
468
|
+
lastModelsMeta = new Map();
|
|
195
469
|
constructor(deps) {
|
|
196
470
|
this.deps = deps;
|
|
197
471
|
}
|
|
@@ -214,6 +488,86 @@ export class ProviderCatalog {
|
|
|
214
488
|
this.liveIds.delete(key);
|
|
215
489
|
}
|
|
216
490
|
this.providersCache.clear();
|
|
491
|
+
// Credentials changed: the last pool answers no longer stand in for a failed lookup.
|
|
492
|
+
for (const key of [...this.lastPoolAnswers.keys()])
|
|
493
|
+
if (!profileHome || key.startsWith(`${profileHome}\0`))
|
|
494
|
+
this.lastPoolAnswers.delete(key);
|
|
495
|
+
// The picker catalog changed: option ids rebuild on the next read rather than pairing against the old one.
|
|
496
|
+
if (profileHome) {
|
|
497
|
+
this.lastModels.delete(profileHome);
|
|
498
|
+
this.lastModelsMeta.delete(profileHome);
|
|
499
|
+
}
|
|
500
|
+
else {
|
|
501
|
+
this.lastModels.clear();
|
|
502
|
+
this.lastModelsMeta.clear();
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
/** TAL-301: the entry id a stored `(model, provider)` pair selects in the last catalog built for this home; a cold home starts building one. */
|
|
506
|
+
modelOptionFor(profileHome, model, provider) {
|
|
507
|
+
const catalog = this.lastModels.get(profileHome);
|
|
508
|
+
if (!catalog) {
|
|
509
|
+
void this.warmModelOptions(profileHome);
|
|
510
|
+
return null;
|
|
511
|
+
}
|
|
512
|
+
return catalogOptionId(catalog, model, provider);
|
|
513
|
+
}
|
|
514
|
+
/**
|
|
515
|
+
* TAL-542: the identity of the files a catalog and its live ids derive from: config.yaml, `.env`, the Agent's sign-in
|
|
516
|
+
* store (`auth.json`, rewritten by an account switch), and the installed plugins directory, so a change outside Web
|
|
517
|
+
* makes both stale.
|
|
518
|
+
*/
|
|
519
|
+
sourceFingerprint(profileHome) {
|
|
520
|
+
const stamp = (name) => {
|
|
521
|
+
try {
|
|
522
|
+
const st = statSync(join(profileHome, name), { bigint: true });
|
|
523
|
+
return `${String(st.mtimeNs)}:${String(st.size)}:${String(st.ino)}`;
|
|
524
|
+
}
|
|
525
|
+
catch {
|
|
526
|
+
return 'missing';
|
|
527
|
+
}
|
|
528
|
+
};
|
|
529
|
+
return [this.deps.config.fingerprint(profileHome), ...['.env', 'auth.json', 'plugins'].map(stamp)].join('|');
|
|
530
|
+
}
|
|
531
|
+
/** The last catalog built for this home, younger than `maxAgeS`, while its source files are unchanged since (TAL-542). */
|
|
532
|
+
currentModels(profileHome, maxAgeS = Infinity) {
|
|
533
|
+
const meta = this.lastModelsMeta.get(profileHome);
|
|
534
|
+
return meta?.source === this.sourceFingerprint(profileHome) && this.deps.now() - meta.at < maxAgeS ? this.lastModels.get(profileHome) : undefined;
|
|
535
|
+
}
|
|
536
|
+
/**
|
|
537
|
+
* TAL-542: `repairSessionModel` against this home's catalog. Agent-owned sign-in and plugin state has no file to
|
|
538
|
+
* fingerprint, so the repair also needs a catalog no older than the providers listing it read (`PROVIDERS_TTL_S`);
|
|
539
|
+
* a cold or stale one keeps the pair and starts a rebuild.
|
|
540
|
+
*/
|
|
541
|
+
sessionModelRepair(profileHome, model, provider) {
|
|
542
|
+
const catalog = this.currentModels(profileHome, PROVIDERS_TTL_S);
|
|
543
|
+
if (!catalog) {
|
|
544
|
+
void this.warmSessionModelRepair(profileHome);
|
|
545
|
+
return null;
|
|
546
|
+
}
|
|
547
|
+
return repairSessionModel(catalog, model, provider, this.lastModelsMeta.get(profileHome)?.unconfirmed);
|
|
548
|
+
}
|
|
549
|
+
/**
|
|
550
|
+
* Builds the catalog `sessionModelRepair` reads unless a fresh enough one exists; call it after the request's last
|
|
551
|
+
* await. A source that changed while the build awaited makes that build stale, so it is rebuilt once.
|
|
552
|
+
*/
|
|
553
|
+
async warmSessionModelRepair(profileHome) {
|
|
554
|
+
for (let attempt = 0; attempt < 2 && !this.currentModels(profileHome, PROVIDERS_TTL_S); attempt += 1) {
|
|
555
|
+
try {
|
|
556
|
+
await this.models(profileHome);
|
|
557
|
+
}
|
|
558
|
+
catch {
|
|
559
|
+
return; /* fail closed: the pair is kept */
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
/** Builds the catalog `modelOptionFor` reads once per home, again after config.yaml or `.env` changes; an unavailable catalog leaves every option id null. */
|
|
564
|
+
async warmModelOptions(profileHome) {
|
|
565
|
+
if (this.currentModels(profileHome))
|
|
566
|
+
return;
|
|
567
|
+
try {
|
|
568
|
+
await this.models(profileHome);
|
|
569
|
+
}
|
|
570
|
+
catch { /* fail closed: no option ids */ }
|
|
217
571
|
}
|
|
218
572
|
/**
|
|
219
573
|
* The process-environment value of `name` as a named profile may see it: values `loadStartupEnv` copied from the
|
|
@@ -224,7 +578,7 @@ export class ProviderCatalog {
|
|
|
224
578
|
return undefined;
|
|
225
579
|
return this.deps.env[name];
|
|
226
580
|
}
|
|
227
|
-
/** Python `_provider_has_key` minus the credential pool
|
|
581
|
+
/** Python `_provider_has_key` minus the credential pool, which `probeKey` reads from the sidecar. */
|
|
228
582
|
providerHasKey(pid, config, envValues, profileHome) {
|
|
229
583
|
const envVar = providerEnvVar(pid);
|
|
230
584
|
if (envVar) {
|
|
@@ -262,7 +616,8 @@ export class ProviderCatalog {
|
|
|
262
616
|
return null;
|
|
263
617
|
}
|
|
264
618
|
}
|
|
265
|
-
|
|
619
|
+
/** `pooled`: the providers whose persisted credential pool holds an added account (TAL-638). */
|
|
620
|
+
async probeKey(profileHome, pid, config, envValues, pooled) {
|
|
266
621
|
let hasKey = this.providerHasKey(pid, config, envValues, profileHome);
|
|
267
622
|
let isOauth = OAUTH_PROVIDERS.has(pid);
|
|
268
623
|
let keySource = 'none';
|
|
@@ -307,6 +662,11 @@ export class ProviderCatalog {
|
|
|
307
662
|
else
|
|
308
663
|
keySource = 'config_yaml';
|
|
309
664
|
}
|
|
665
|
+
else if (pooled.has(pid)) {
|
|
666
|
+
// Python `_has_explicit_pool_credentials`: keys added with `hermes auth add` live only in the Agent's pool.
|
|
667
|
+
hasKey = true;
|
|
668
|
+
keySource = 'credential_pool';
|
|
669
|
+
}
|
|
310
670
|
else if (!providerEnvVar(pid) && /^[a-z][a-z0-9_-]{0,63}$/.test(pid)) {
|
|
311
671
|
const status = await this.authStatus(profileHome, pid);
|
|
312
672
|
if (status?.logged_in) {
|
|
@@ -321,47 +681,83 @@ export class ProviderCatalog {
|
|
|
321
681
|
/** Live model ids from the Agent for one provider, cached per profile home. */
|
|
322
682
|
async liveModelIds(profileHome, pid, opts = {}) {
|
|
323
683
|
const key = `${profileHome}\0${pid}`;
|
|
684
|
+
const source = this.sourceFingerprint(profileHome);
|
|
324
685
|
const hit = this.liveIds.get(key);
|
|
325
|
-
if (hit && !opts.force && this.deps.now() - hit.at < LIVE_TTL_S)
|
|
686
|
+
if (hit?.source === source && !opts.force && this.deps.now() - hit.at < LIVE_TTL_S)
|
|
326
687
|
return hit.ids;
|
|
327
|
-
|
|
688
|
+
// A lookup started under another source answers for the old endpoint or credential; this one starts its own.
|
|
689
|
+
const flightKey = `${key}\0${source}`;
|
|
690
|
+
const inflight = this.liveInflight.get(flightKey);
|
|
328
691
|
if (inflight && !opts.force)
|
|
329
692
|
return inflight;
|
|
330
693
|
const sidecar = this.deps.sidecar();
|
|
331
|
-
|
|
694
|
+
// No sidecar is a failed lookup for this source: the ids it keeps showing never confirm a stale-model repair (TAL-542).
|
|
695
|
+
if (!sidecar) {
|
|
696
|
+
this.liveFailed.add(flightKey);
|
|
332
697
|
return hit?.ids ?? [];
|
|
698
|
+
}
|
|
333
699
|
const run = sidecar.call('providers.model_ids', { profile_home: profileHome, provider: pid, ...(opts.force ? { force_refresh: true } : {}) }, { timeoutMs: 30_000 })
|
|
334
|
-
.then((r) => { this.liveIds.set(key, { at: this.deps.now(), ids: r.model_ids }); this.liveFailed.delete(
|
|
335
|
-
.catch((error) => { this.deps.log(`[catalog] live model ids for ${pid} failed: ${str(error.message)}`); this.liveFailed.add(
|
|
336
|
-
.finally(() => { this.liveInflight.delete(
|
|
337
|
-
this.liveInflight.set(
|
|
700
|
+
.then((r) => { this.liveIds.set(key, { at: this.deps.now(), ids: r.model_ids, source }); this.liveFailed.delete(flightKey); return r.model_ids; })
|
|
701
|
+
.catch((error) => { this.deps.log(`[catalog] live model ids for ${pid} failed: ${str(error.message)}`); this.liveFailed.add(flightKey); return hit?.ids ?? []; })
|
|
702
|
+
.finally(() => { this.liveInflight.delete(flightKey); });
|
|
703
|
+
this.liveInflight.set(flightKey, run);
|
|
338
704
|
return run;
|
|
339
705
|
}
|
|
706
|
+
/**
|
|
707
|
+
* TAL-288: the model-provider plugins installed and enabled in this profile, as the sidecar reads them from the Agent.
|
|
708
|
+
* A failed lookup lists none, so every other provider stays usable; an id that cannot be a provider routing hint is dropped.
|
|
709
|
+
*/
|
|
710
|
+
async pluginProviders(profileHome) {
|
|
711
|
+
const sidecar = this.deps.sidecar();
|
|
712
|
+
if (!sidecar)
|
|
713
|
+
return [];
|
|
714
|
+
try {
|
|
715
|
+
const { providers } = await sidecar.call('plugins.providers', { profile_home: profileHome }, { timeoutMs: PLUGIN_LIST_TIMEOUT_MS });
|
|
716
|
+
return providers.filter((p, i, all) => /^[a-z0-9][a-z0-9._-]{0,63}$/.test(p.name) && all.findIndex((q) => q.name === p.name) === i);
|
|
717
|
+
}
|
|
718
|
+
catch (error) {
|
|
719
|
+
this.deps.log(`[catalog] plugin providers failed: ${str(error.message)}`);
|
|
720
|
+
return [];
|
|
721
|
+
}
|
|
722
|
+
}
|
|
340
723
|
/** Python `get_providers`. */
|
|
341
724
|
async providers(profileHome) {
|
|
725
|
+
return structuredClone((await this.providerRows(profileHome)).payload);
|
|
726
|
+
}
|
|
727
|
+
/** The cached `providers` answer, shared with `quotas`; callers never mutate it. */
|
|
728
|
+
async providerRows(profileHome) {
|
|
342
729
|
const config = await this.deps.config.read(profileHome);
|
|
343
730
|
const envValues = loadEnvFile(join(profileHome, '.env'));
|
|
344
731
|
const cacheKey = JSON.stringify([config, envValues]);
|
|
345
732
|
const hit = this.providersCache.get(profileHome);
|
|
346
733
|
if (hit?.key === cacheKey && this.deps.now() - hit.at < PROVIDERS_TTL_S)
|
|
347
|
-
return
|
|
734
|
+
return hit;
|
|
735
|
+
const pooledIds = await this.poolProviders(profileHome);
|
|
736
|
+
const pooled = new Set(pooledIds ?? []);
|
|
348
737
|
const active = activeProviderFromConfig(config);
|
|
349
738
|
const known = new Set([...Object.keys(PROVIDER_DISPLAY), ...Object.keys(PROVIDER_MODELS), ...OAUTH_PROVIDERS]);
|
|
739
|
+
// A plugin never stands in for a built-in provider, by id or alias (that would route to the built-in's billing); it
|
|
740
|
+
// does own a `providers.<id>` config entry.
|
|
741
|
+
const plugins = (await this.pluginProviders(profileHome)).filter((p) => !known.has(p.name) && !known.has(providerIdentity(p.name)));
|
|
742
|
+
const pluginIds = new Set(plugins.map((p) => p.name));
|
|
350
743
|
const providersCfg = dict(config.providers);
|
|
351
744
|
for (const key of Object.keys(providersCfg)) {
|
|
352
745
|
const identity = canonicaliseProviderId(key);
|
|
353
|
-
known.
|
|
746
|
+
const id = known.has(identity) ? identity : key;
|
|
747
|
+
if (!pluginIds.has(id))
|
|
748
|
+
known.add(id);
|
|
354
749
|
}
|
|
355
750
|
const rows = [];
|
|
356
751
|
for (const pid of [...known].sort()) {
|
|
357
|
-
const probe = await this.probeKey(profileHome, pid, config, envValues);
|
|
752
|
+
const probe = await this.probeKey(profileHome, pid, config, envValues, pooled);
|
|
358
753
|
if (pid === 'openai' && !probe.hasKey && looksLikeCodexOauthToken(str(envValues[providerEnvVar('openai') ?? ''] ?? this.deps.env.OPENAI_API_KEY)))
|
|
359
754
|
continue;
|
|
755
|
+
const configured = probe.hasKey || selfHostedEndpoint(pid, config);
|
|
360
756
|
let models = pid === 'openrouter' ? FALLBACK_MODELS.map((m) => ({ id: m.id, label: m.label })) : [...(PROVIDER_MODELS[pid] ?? [])];
|
|
361
757
|
let modelsTotal = models.length;
|
|
362
758
|
// Python: the card prefers the live catalog for every keyed provider (exactly like the picker), keeping the
|
|
363
759
|
// static list as the cold/failed-probe fallback; Nous renders a featured subset with the full count.
|
|
364
|
-
if (
|
|
760
|
+
if (configured && (probe.isOauth || pid === 'lmstudio' || pid === 'nous' || !BESPOKE_CATALOG_PROVIDERS.has(pid))) {
|
|
365
761
|
const live = await this.liveModelIds(profileHome, pid);
|
|
366
762
|
if (live.length) {
|
|
367
763
|
models = pid === 'nous' ? live.slice(0, 25).map((id) => ({ id: `@nous:${id}`, label: `${formatOllamaLabel(id.includes('/') ? id.slice(id.indexOf('/') + 1) : id)} (via Nous)` })) : live.map((id) => ({ id, label: labelForModel(id, []) }));
|
|
@@ -377,11 +773,12 @@ export class ProviderCatalog {
|
|
|
377
773
|
if (pid !== 'nous')
|
|
378
774
|
modelsTotal += added.length;
|
|
379
775
|
}
|
|
380
|
-
const baseUrl =
|
|
776
|
+
const baseUrl = configuredBaseUrl(pid, config) || null;
|
|
381
777
|
rows.push({
|
|
382
778
|
id: pid,
|
|
383
779
|
display_name: displayName(pid),
|
|
384
780
|
has_key: probe.hasKey,
|
|
781
|
+
configured,
|
|
385
782
|
configurable: !probe.isOauth && Boolean(providerEnvVar(pid)),
|
|
386
783
|
is_oauth: probe.isOauth,
|
|
387
784
|
is_plugin_provider: false,
|
|
@@ -395,6 +792,17 @@ export class ProviderCatalog {
|
|
|
395
792
|
models_total: modelsTotal,
|
|
396
793
|
});
|
|
397
794
|
}
|
|
795
|
+
// TAL-288: a plugin is selectable only once the Agent reports its setup ready; it never borrows a built-in's key.
|
|
796
|
+
for (const plugin of plugins) {
|
|
797
|
+
const ready = plugin.setup === 'ready';
|
|
798
|
+
const live = ready ? await this.liveModelIds(profileHome, plugin.name) : [];
|
|
799
|
+
rows.push({
|
|
800
|
+
id: plugin.name, display_name: plugin.display_name || plugin.name, has_key: ready, configured: ready,
|
|
801
|
+
configurable: false, is_oauth: false, is_plugin_provider: true, is_self_hosted: false, is_custom: false, key_source: ready ? 'plugin' : 'none',
|
|
802
|
+
base_url: null, auth_error: plugin.setup === 'ready' ? (live.length ? null : PLUGIN_NO_MODELS) : PLUGIN_SETUP_ERRORS[plugin.setup], env_var: null,
|
|
803
|
+
models: live.map((id) => ({ id, label: labelForModel(id, []) })), models_total: live.length,
|
|
804
|
+
});
|
|
805
|
+
}
|
|
398
806
|
for (const cp of customProviderEntries(config)) {
|
|
399
807
|
const name = str(cp.name).trim();
|
|
400
808
|
if (!name)
|
|
@@ -406,7 +814,7 @@ export class ProviderCatalog {
|
|
|
406
814
|
const envRef = /^\$\{([^}]+)\}$/.exec(cpKey)?.[1] ?? '';
|
|
407
815
|
const hasKey = envRef ? Boolean((this.processEnv(envRef, profileHome) ?? '').trim()) : valueCountsAsApiKey(slug, cp.api_key) || Boolean(str(cp.key_env).trim() && this.processEnv(str(cp.key_env).trim(), profileHome));
|
|
408
816
|
rows.push({
|
|
409
|
-
id: slug, display_name: name, has_key: hasKey,
|
|
817
|
+
id: slug, display_name: name, has_key: hasKey, configured: hasKey,
|
|
410
818
|
configurable: false, is_oauth: false, is_plugin_provider: false, is_self_hosted: false, is_custom: true, key_source: str(cp.api_key).trim() ? 'config_yaml' : 'none',
|
|
411
819
|
base_url: str(cp.base_url).trim() || null, auth_error: null, env_var: null, models: ids.map((id) => ({ id, label: labelForModel(id, []) })), models_total: ids.length,
|
|
412
820
|
});
|
|
@@ -414,12 +822,16 @@ export class ProviderCatalog {
|
|
|
414
822
|
// Python `_provider_sort_key`: active first, then `custom:*`, then keyed providers, then the rest (alphabetical within).
|
|
415
823
|
const rank = (p) => (str(p.id) === active ? 0 : str(p.id).startsWith('custom:') ? 1 : p.has_key ? 2 : 3);
|
|
416
824
|
rows.sort((a, b) => rank(a) - rank(b) || (str(a.id) < str(b.id) ? -1 : str(a.id) > str(b.id) ? 1 : 0));
|
|
417
|
-
const
|
|
418
|
-
|
|
419
|
-
|
|
825
|
+
for (const row of rows)
|
|
826
|
+
row.models = stampModelEntries(row.models, str(row.id));
|
|
827
|
+
const entry = { at: this.deps.now(), key: cacheKey, payload: { providers: rows, active_provider: active }, pooled: pooledIds };
|
|
828
|
+
this.providersCache.set(profileHome, entry);
|
|
829
|
+
return entry;
|
|
420
830
|
}
|
|
421
831
|
/** Python `get_available_models` (static catalog + live ids for keyed providers). */
|
|
422
832
|
async models(profileHome) {
|
|
833
|
+
const source = this.sourceFingerprint(profileHome);
|
|
834
|
+
const unconfirmed = new Set();
|
|
423
835
|
const config = await this.deps.config.read(profileHome);
|
|
424
836
|
const envValues = loadEnvFile(join(profileHome, '.env'));
|
|
425
837
|
const active = activeProviderFromConfig(config);
|
|
@@ -462,12 +874,18 @@ export class ProviderCatalog {
|
|
|
462
874
|
if (canonical && this.providerHasKey(canonical, config, envValues, profileHome))
|
|
463
875
|
detected.add(canonical);
|
|
464
876
|
}
|
|
465
|
-
// Python: OAuth providers the Agent reports as logged in join the picker with their live catalog (#1567, #2545)
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
877
|
+
// Python: OAuth providers the Agent reports as logged in join the picker with their live catalog (#1567, #2545); so do
|
|
878
|
+
// ready plugin providers (TAL-288), under their own name, and providers keyed only through the credential pool (TAL-638).
|
|
879
|
+
const signedIn = new Set();
|
|
880
|
+
const pluginNames = new Map();
|
|
881
|
+
for (const row of (await this.providerRows(profileHome)).payload.providers) {
|
|
882
|
+
if (row.has_key !== true || (row.is_oauth !== true && row.is_plugin_provider !== true && row.key_source !== 'credential_pool'))
|
|
883
|
+
continue;
|
|
884
|
+
signedIn.add(str(row.id));
|
|
885
|
+
if (row.is_plugin_provider === true)
|
|
886
|
+
pluginNames.set(str(row.id), str(row.display_name) || str(row.id));
|
|
887
|
+
}
|
|
888
|
+
for (const pid of signedIn)
|
|
471
889
|
detected.add(pid);
|
|
472
890
|
const fallbackCfg = Array.isArray(config.fallback_providers) ? config.fallback_providers.filter(isDict) : [];
|
|
473
891
|
for (const entry of fallbackCfg) {
|
|
@@ -500,7 +918,7 @@ export class ProviderCatalog {
|
|
|
500
918
|
detected.add(named?.[0] ?? active ?? 'custom');
|
|
501
919
|
}
|
|
502
920
|
const groups = [];
|
|
503
|
-
for (const pid of [...detected].map((p) => (p.startsWith('custom') ? p : canonicaliseProviderId(p) || p)).filter((v, i, a) => v && a.indexOf(v) === i).sort()) {
|
|
921
|
+
for (const pid of [...detected].map((p) => (p.startsWith('custom') || pluginNames.has(p) ? p : canonicaliseProviderId(p) || p)).filter((v, i, a) => v && a.indexOf(v) === i).sort()) {
|
|
504
922
|
if (pid.startsWith('custom:')) {
|
|
505
923
|
const g = namedCustom.get(pid);
|
|
506
924
|
const models = [...(g?.models ?? [])];
|
|
@@ -521,12 +939,15 @@ export class ProviderCatalog {
|
|
|
521
939
|
let raw = [];
|
|
522
940
|
if ('models' in providerCfg && providerCfg.models_discovered !== true)
|
|
523
941
|
raw = configuredModelOptions(providerCfg.models);
|
|
524
|
-
if (!raw.length && (this.providerHasKey(pid, config, envValues, profileHome) ||
|
|
942
|
+
if (!raw.length && (this.providerHasKey(pid, config, envValues, profileHome) || selfHostedEndpoint(pid, config) || signedIn.has(pid))) {
|
|
525
943
|
const live = await this.liveModelIds(profileHome, pid);
|
|
944
|
+
// TAL-542: a failed lookup leaves this group's list unconfirmed, so the stale-model repair never trusts it.
|
|
945
|
+
if (this.liveFailed.has(`${profileHome}\0${pid}\0${source}`))
|
|
946
|
+
unconfirmed.add(pid);
|
|
526
947
|
if (live.length)
|
|
527
948
|
raw = live.map((id) => ({ id, label: pid === 'nous' ? `${formatOllamaLabel(id.includes('/') ? id.slice(id.indexOf('/') + 1) : id)} (via Nous)` : labelForModel(id, []) }));
|
|
528
949
|
// Python (#1567): an authenticated Nous account with an empty live catalog shows no group; only a failed lookup falls back to the curated list.
|
|
529
|
-
else if (pid === 'nous' && !this.liveFailed.has(`${profileHome}\0${pid}`))
|
|
950
|
+
else if (pid === 'nous' && !this.liveFailed.has(`${profileHome}\0${pid}\0${source}`))
|
|
530
951
|
continue;
|
|
531
952
|
}
|
|
532
953
|
if (!raw.length)
|
|
@@ -535,7 +956,7 @@ export class ProviderCatalog {
|
|
|
535
956
|
if (!raw.some((m) => m.id === id))
|
|
536
957
|
raw.push({ id, label: labelForModel(id, groups) });
|
|
537
958
|
if (raw.length)
|
|
538
|
-
groups.push({ provider: displayName(pid), provider_id: pid, models: applyProviderPrefix(raw, pid, active) });
|
|
959
|
+
groups.push({ provider: pluginNames.get(pid) ?? displayName(pid), provider_id: pid, models: applyProviderPrefix(raw, pid, active) });
|
|
539
960
|
}
|
|
540
961
|
if (defaultModel) {
|
|
541
962
|
const all = new Set(groups.flatMap((g) => g.models.map((m) => m.id)));
|
|
@@ -581,10 +1002,21 @@ export class ProviderCatalog {
|
|
|
581
1002
|
for (const [k, v] of Object.entries(rawAliases))
|
|
582
1003
|
if (k && v)
|
|
583
1004
|
aliases[k.trim()] = str(v).trim();
|
|
1005
|
+
const [defaultBare, defaultProvider] = parseProviderQualifiedModel(defaultModel) ?? [defaultModel, active];
|
|
1006
|
+
const defaults = { active_provider: active, default_model: defaultModel, default_provider_id: defaultProvider, default_bare_id: defaultBare };
|
|
584
1007
|
if (!kept.length && defaultModel) {
|
|
585
|
-
|
|
1008
|
+
const providerId = active ?? 'default';
|
|
1009
|
+
return this.remember(profileHome, { source, unconfirmed }, { ...defaults, groups: [{ provider: 'Default', provider_id: providerId, models: stampModelEntries([{ id: defaultModel, label: labelForModel(defaultModel, []) }], providerId) }], aliases: {}, configured_model_badges: {} });
|
|
586
1010
|
}
|
|
587
|
-
|
|
1011
|
+
const stamped = kept.map((g) => ({ ...g, models: stampModelEntries(g.models, g.provider_id), ...(g.extra_models ? { extra_models: stampModelEntries(g.extra_models, g.provider_id) } : {}) }));
|
|
1012
|
+
return this.remember(profileHome, { source, unconfirmed }, { ...defaults, groups: stamped, aliases, configured_model_badges: this.badges(kept, active, defaultModel, fallbackCfg) });
|
|
1013
|
+
}
|
|
1014
|
+
/** Stamps the default's entry id and keeps the catalog for `modelOptionFor`. */
|
|
1015
|
+
remember(profileHome, meta, catalog) {
|
|
1016
|
+
catalog.default_option_id = catalogOptionId(catalog, catalog.default_bare_id ?? null, catalog.default_provider_id ?? null);
|
|
1017
|
+
this.lastModels.set(profileHome, catalog);
|
|
1018
|
+
this.lastModelsMeta.set(profileHome, { ...meta, at: this.deps.now() });
|
|
1019
|
+
return structuredClone(catalog);
|
|
588
1020
|
}
|
|
589
1021
|
/** Python `_configured_model_badges_from_static_catalog`: which picker rows are the main/fallback selections. */
|
|
590
1022
|
badges(groups, active, defaultModel, fallback) {
|
|
@@ -640,7 +1072,7 @@ export class ProviderCatalog {
|
|
|
640
1072
|
if (provider !== 'nous' && ids.length > MODEL_PICKER_OVERFLOW_THRESHOLD)
|
|
641
1073
|
ids = ids.slice(0, MODEL_PICKER_VISIBLE_TARGET);
|
|
642
1074
|
const annotateFastTier = isOpenAiFamilyProvider(provider);
|
|
643
|
-
const models = ids.filter(Boolean).map((id) => ({ id, label: labelForModel(id, []), ...(annotateFastTier ? { supports_fast_tier: mainModelSupportsServiceTier(id, provider) } : {}) }));
|
|
1075
|
+
const models = stampModelEntries(ids.filter(Boolean).map((id) => ({ id, label: labelForModel(id, []), ...(annotateFastTier ? { supports_fast_tier: mainModelSupportsServiceTier(id, provider) } : {}) })), provider);
|
|
644
1076
|
return { provider, source, models, count: models.length };
|
|
645
1077
|
}
|
|
646
1078
|
/**
|
|
@@ -652,7 +1084,7 @@ export class ProviderCatalog {
|
|
|
652
1084
|
const config = await this.deps.config.read(profileHome);
|
|
653
1085
|
const section = modelSection(config);
|
|
654
1086
|
const pid = canonicaliseProviderId(provider ?? section.provider) || null;
|
|
655
|
-
const bare = model
|
|
1087
|
+
const bare = unqualifiedModelId(model);
|
|
656
1088
|
const positive = (v) => { const n = Math.trunc(Number(v)); return Number.isFinite(n) && n > 0 ? n : null; };
|
|
657
1089
|
const fromModels = (models) => {
|
|
658
1090
|
if (isDict(models)) {
|
|
@@ -675,7 +1107,7 @@ export class ProviderCatalog {
|
|
|
675
1107
|
let baseUrl = '';
|
|
676
1108
|
let configContextLength = null;
|
|
677
1109
|
const sectionModel = str(section.default || (typeof config.model === 'string' ? config.model : '')).trim();
|
|
678
|
-
if (sectionModel && [model, bare].includes(sectionModel
|
|
1110
|
+
if (sectionModel && [model, bare].includes(unqualifiedModelId(sectionModel)))
|
|
679
1111
|
configContextLength = positive(section.context_length);
|
|
680
1112
|
if (!pid || canonicaliseProviderId(section.provider) === pid)
|
|
681
1113
|
baseUrl = str(section.base_url).trim();
|
|
@@ -708,28 +1140,47 @@ export class ProviderCatalog {
|
|
|
708
1140
|
return v;
|
|
709
1141
|
}
|
|
710
1142
|
}
|
|
711
|
-
|
|
712
|
-
|
|
1143
|
+
// The same config sources `providerHasKey` counts, so a provider shown as keyed resolves its key.
|
|
1144
|
+
const model = modelSection(config);
|
|
1145
|
+
if (canonicaliseProviderId(model.provider) === canonicaliseProviderId(pid) && valueCountsAsApiKey(pid, model.api_key))
|
|
1146
|
+
return str(model.api_key).trim();
|
|
1147
|
+
for (const [key, value] of Object.entries(dict(config.providers))) {
|
|
1148
|
+
if (providerIdentity(key) === providerIdentity(pid) && isDict(value) && valueCountsAsApiKey(pid, value.api_key))
|
|
1149
|
+
return str(value.api_key).trim();
|
|
1150
|
+
}
|
|
1151
|
+
return null;
|
|
713
1152
|
}
|
|
714
|
-
/**
|
|
1153
|
+
/** Account-usage snapshots by profile home, provider, and pool account. */
|
|
715
1154
|
accountUsageCache = new Map();
|
|
1155
|
+
/**
|
|
1156
|
+
* Python `get_provider_quota`. `credential` scopes the read to one credential-pool account (TAL-548): a benched account
|
|
1157
|
+
* answers with its local pool state, and every other read uses that account's key.
|
|
1158
|
+
*/
|
|
716
1159
|
async quota(profileHome, providerRaw, opts = {}) {
|
|
1160
|
+
const at = opts.at ?? this.deps.now();
|
|
1161
|
+
const computed_at = isoAt(at);
|
|
717
1162
|
const config = await this.deps.config.read(profileHome);
|
|
718
1163
|
const provider = (providerRaw ?? activeProviderFromConfig(config) ?? '').trim().toLowerCase();
|
|
719
1164
|
if (!provider)
|
|
720
|
-
return { ok: false, provider: null, display_name: null, supported: false, status: 'unavailable', quota: null, message: 'No active provider is configured.' };
|
|
1165
|
+
return { computed_at, ok: false, provider: null, display_name: null, supported: false, status: 'unavailable', quota: null, message: 'No active provider is configured.' };
|
|
721
1166
|
const name = displayName(provider);
|
|
1167
|
+
const credential = opts.credential ?? null;
|
|
1168
|
+
const answer = (ok, status, message, extra = {}) => ({ computed_at, ok, provider, display_name: name, supported: true, status, quota: null, ...extra, message });
|
|
1169
|
+
if (credential && credential.status !== 'available') {
|
|
1170
|
+
return answer(false, 'unavailable', `${name} credential is unavailable. ${str(credential.unavailable_reason)}`.trim(), { account_limits: poolEntryLimits(credential, at) });
|
|
1171
|
+
}
|
|
722
1172
|
if (ACCOUNT_USAGE_PROVIDERS.has(provider)) {
|
|
723
1173
|
const sidecar = this.deps.sidecar();
|
|
724
1174
|
let limits = null;
|
|
725
1175
|
// Python `_ACCOUNT_USAGE_CACHE_TTL_SECONDS`: a snapshot answers repeat polls for 45 s unless `refresh` is set.
|
|
726
|
-
const cacheKey = `${profileHome}\0${provider}`;
|
|
1176
|
+
const cacheKey = `${profileHome}\0${provider}\0${credential?.credential_id ?? ''}`;
|
|
727
1177
|
const cached = this.accountUsageCache.get(cacheKey);
|
|
728
1178
|
if (cached && !opts.refresh && this.deps.now() - cached.at <= ACCOUNT_USAGE_CACHE_TTL_S)
|
|
729
1179
|
limits = cached.limits;
|
|
730
1180
|
else if (sidecar) {
|
|
731
1181
|
try {
|
|
732
|
-
const
|
|
1182
|
+
const params = { profile_home: profileHome, provider, ...(opts.refresh ? { refresh: true } : {}), ...(credential ? { credential_id: credential.credential_id } : {}) };
|
|
1183
|
+
const snapshot = (await sidecar.call('usage.account', params, { timeoutMs: 35_000 })).snapshot;
|
|
733
1184
|
if (snapshot)
|
|
734
1185
|
limits = { ...snapshot, title: str(snapshot.title) || 'Account limits', available: snapshot.available && !str(snapshot.unavailable_reason) };
|
|
735
1186
|
}
|
|
@@ -738,41 +1189,102 @@ export class ProviderCatalog {
|
|
|
738
1189
|
}
|
|
739
1190
|
this.accountUsageCache.set(cacheKey, { at: this.deps.now(), limits });
|
|
740
1191
|
}
|
|
1192
|
+
// The cache keeps the Agent's raw windows; pace is recomputed as of every response.
|
|
1193
|
+
if (limits)
|
|
1194
|
+
limits = { ...limits, ...normalizeQuotaWindows(limits.windows, at), fetched_at: isoUtc(limits.fetched_at) };
|
|
741
1195
|
if (limits?.available)
|
|
742
|
-
return
|
|
1196
|
+
return answer(true, limits.stale ? 'stale' : 'available', limits.stale ? `${name} refresh failed; showing last-known account limits.` : `${name} account limits loaded.`, { label: limits.title, account_limits: limits });
|
|
743
1197
|
const reason = str(limits?.unavailable_reason).trim();
|
|
744
|
-
return
|
|
745
|
-
}
|
|
746
|
-
if (provider === 'openrouter') {
|
|
747
|
-
const apiKey = this.apiKeyFor(
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
const
|
|
751
|
-
if (
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
1198
|
+
return answer(false, 'unavailable', reason ? `${name} account limits are unavailable. ${reason}` : `${name} account limits are unavailable. Confirm provider authentication and try again.`, { account_limits: limits });
|
|
1199
|
+
}
|
|
1200
|
+
if (provider === 'openrouter' || provider === 'deepseek' || provider === 'opencode-go') {
|
|
1201
|
+
const apiKey = this.apiKeyFor(provider, profileHome, config);
|
|
1202
|
+
const key = credential ? { credential_id: credential.credential_id } : apiKey ? { api_key: apiKey } : null;
|
|
1203
|
+
const read = key ? await this.balance(profileHome, provider, key) : null;
|
|
1204
|
+
const status = !key || read?.status === 'no_key' ? 'no_key' : read?.status === 'ok' ? 'ok' : read?.status === 'http_error' ? read.http_status : null;
|
|
1205
|
+
if (provider === 'openrouter') {
|
|
1206
|
+
if (status === 'no_key')
|
|
1207
|
+
return answer(false, 'no_key', 'OpenRouter quota status needs an OPENROUTER_API_KEY configured on the server.');
|
|
1208
|
+
if (status === 'ok' && read?.quota) {
|
|
1209
|
+
// The cost chart follows the configured key, so another pool account's spend never lands in it.
|
|
1210
|
+
if (!credential || credential.matches_api_key)
|
|
1211
|
+
this.recordCostSnapshot(profileHome, read.quota, at);
|
|
1212
|
+
return answer(true, 'available', 'OpenRouter quota status loaded.', { label: 'OpenRouter credits', quota: read.quota });
|
|
1213
|
+
}
|
|
1214
|
+
return status === 401 || status === 403 ? answer(false, 'invalid_key', 'OpenRouter rejected the configured API key.') : answer(false, 'unavailable', 'OpenRouter quota status is temporarily unavailable.');
|
|
1215
|
+
}
|
|
1216
|
+
if (provider === 'deepseek') {
|
|
1217
|
+
if (status === 'no_key')
|
|
1218
|
+
return answer(false, 'no_key', 'DeepSeek balance needs a DEEPSEEK_API_KEY configured on the server.', { balances: [] });
|
|
1219
|
+
if (status === 'ok' && read) {
|
|
1220
|
+
const available = read.is_available === true;
|
|
1221
|
+
return answer(available, available ? 'available' : 'exhausted', available ? 'DeepSeek balance loaded.' : 'DeepSeek balance is exhausted.', { label: 'DeepSeek balance', balances: read.balances });
|
|
1222
|
+
}
|
|
1223
|
+
return status === 401 || status === 403 ? answer(false, 'invalid_key', 'DeepSeek rejected the configured API key.', { balances: [] }) : answer(false, 'unavailable', 'DeepSeek balance is temporarily unavailable.', { balances: [] });
|
|
1224
|
+
}
|
|
1225
|
+
if (status === 'no_key')
|
|
1226
|
+
return answer(false, 'no_key', 'OpenCode Go account limits need an API key configured on the server.', { account_limits: null });
|
|
1227
|
+
if (status === 'ok' && read) {
|
|
1228
|
+
// Python `_sanitize_opencode_go_account_limits`: the rolling, weekly and monthly windows of the Go plan.
|
|
1229
|
+
const windows = read.windows.map((w) => ({ label: OPENCODE_GO_WINDOWS[w.key][0], window_seconds: OPENCODE_GO_WINDOWS[w.key][1], used_percent: w.used_percent, reset_at: w.reset_at, detail: w.rate_limited ? 'Rate limited' : null }));
|
|
1230
|
+
const limits = { provider, source: 'usage_api', title: 'Account limits', plan: 'Go', details: [], available: true, unavailable_reason: null, fetched_at: isoAt(at), ...normalizeQuotaWindows(windows, at) };
|
|
1231
|
+
return answer(true, 'available', 'OpenCode Go account limits loaded.', { label: 'OpenCode Go usage', account_limits: limits });
|
|
1232
|
+
}
|
|
1233
|
+
if (status === 401)
|
|
1234
|
+
return answer(false, 'invalid_key', 'OpenCode Go rejected the configured API key.', { account_limits: null });
|
|
1235
|
+
return answer(false, 'unavailable', status === 403 ? 'OpenCode Go account limits require an active subscription.' : 'OpenCode Go account limits are temporarily unavailable.', { account_limits: null });
|
|
1236
|
+
}
|
|
1237
|
+
// Python `_local_pool_snapshot`: any other provider with a credential pool shows its accounts' local state.
|
|
1238
|
+
const entries = credential ? [credential] : (await this.poolEntries(profileHome, provider, null)) ?? [];
|
|
1239
|
+
if (entries.length) {
|
|
1240
|
+
const limits = credential ? poolEntryLimits(credential, at) : poolLimits(entries, at);
|
|
1241
|
+
return limits.available ? answer(true, 'available', `${name} credential pool status loaded.`, { label: 'Credential pool', account_limits: limits }) : answer(false, 'unavailable', `${name} credential pool: all credentials are unavailable.`, { account_limits: limits });
|
|
1242
|
+
}
|
|
1243
|
+
return { computed_at, ok: false, provider, display_name: name, supported: false, status: 'unsupported', quota: null, message: `No verified server-side quota or balance endpoint is available for ${name}.` };
|
|
1244
|
+
}
|
|
1245
|
+
/**
|
|
1246
|
+
* TAL-548: the provider's credential-pool accounts as the sidecar reads them locally. Each says whether it holds
|
|
1247
|
+
* `apiKey`, the provider's configured key. Null when the pool is unknown (see `poolLookup`).
|
|
1248
|
+
*/
|
|
1249
|
+
poolEntries(profileHome, pid, apiKey) {
|
|
1250
|
+
return this.poolLookup(profileHome, `pool\0${pid}`, async (sidecar) => (await sidecar.call('usage.pool', { profile_home: profileHome, provider: pid, ...(apiKey ? { api_key: apiKey } : {}) })).entries);
|
|
1251
|
+
}
|
|
1252
|
+
/** The last answer of each credential-pool lookup, by profile home and lookup. */
|
|
1253
|
+
lastPoolAnswers = new Map();
|
|
1254
|
+
/**
|
|
1255
|
+
* TAL-548: one credential-pool lookup through the sidecar. A failed lookup answers with the last answer to the same
|
|
1256
|
+
* lookup, else null: an unknown pool, never an empty one, so no pool account is ever reported as removed.
|
|
1257
|
+
*/
|
|
1258
|
+
async poolLookup(profileHome, lookup, call) {
|
|
1259
|
+
const key = `${profileHome}\0${lookup}`;
|
|
760
1260
|
try {
|
|
761
|
-
const
|
|
762
|
-
if (!
|
|
763
|
-
|
|
764
|
-
const
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
let payload = JSON.parse(raw.toString('utf8'));
|
|
768
|
-
if (isDict(payload) && isDict(payload.data))
|
|
769
|
-
payload = payload.data;
|
|
770
|
-
const d = dict(payload);
|
|
771
|
-
const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
|
|
772
|
-
return { kind: 'ok', quota: { limit_remaining: num(d.limit_remaining), usage: num(d.usage), limit: num(d.limit) }, label: str(d.label).trim() || null };
|
|
1261
|
+
const sidecar = this.deps.sidecar();
|
|
1262
|
+
if (!sidecar)
|
|
1263
|
+
throw new Error('the sidecar is not running');
|
|
1264
|
+
const answer = await call(sidecar);
|
|
1265
|
+
this.lastPoolAnswers.set(key, answer);
|
|
1266
|
+
return answer;
|
|
773
1267
|
}
|
|
774
|
-
catch {
|
|
775
|
-
|
|
1268
|
+
catch (error) {
|
|
1269
|
+
this.deps.log(`[catalog] credential pool lookup ${lookup.replace('\0', ' ')} failed: ${str(error.message)}`);
|
|
1270
|
+
return this.lastPoolAnswers.get(key) ?? null;
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
/** TAL-548: the providers whose persisted credential pool holds an added account; none when the sidecar cannot say. */
|
|
1274
|
+
poolProviders(profileHome) {
|
|
1275
|
+
return this.poolLookup(profileHome, 'providers', async (sidecar) => (await sidecar.call('usage.pool_providers', { profile_home: profileHome })).providers);
|
|
1276
|
+
}
|
|
1277
|
+
/** TAL-548: one key-based balance read through the sidecar, which resolves a pool account's key itself; null when it fails. */
|
|
1278
|
+
async balance(profileHome, provider, key) {
|
|
1279
|
+
const sidecar = this.deps.sidecar();
|
|
1280
|
+
if (!sidecar)
|
|
1281
|
+
return null;
|
|
1282
|
+
try {
|
|
1283
|
+
return await sidecar.call('usage.balance', { profile_home: profileHome, provider, ...key }, { timeoutMs: BALANCE_TIMEOUT_MS });
|
|
1284
|
+
}
|
|
1285
|
+
catch (error) {
|
|
1286
|
+
this.deps.log(`[catalog] ${provider} balance failed: ${str(error.message)}`);
|
|
1287
|
+
return null;
|
|
776
1288
|
}
|
|
777
1289
|
}
|
|
778
1290
|
quotaScopeCache = null;
|
|
@@ -807,28 +1319,53 @@ export class ProviderCatalog {
|
|
|
807
1319
|
quotaProfileScopeId(profile) {
|
|
808
1320
|
return `qscope_${createHash('sha256').update(`${this.quotaServerScopeId()}\0${profile}`).digest('hex').slice(0, 32)}`;
|
|
809
1321
|
}
|
|
810
|
-
/**
|
|
1322
|
+
/**
|
|
1323
|
+
* Python `get_provider_quotas`: the stable-identity envelope the iOS widget persists. A keyed provider is one source per
|
|
1324
|
+
* credential-pool account, else one for its own key (Python `_quota_source_descriptors`, TAL-548). Each source id appears
|
|
1325
|
+
* once (two custom providers can share a slug), ordered by provider, account label, then source id.
|
|
1326
|
+
*/
|
|
811
1327
|
async quotas(profileHome, profile, opts = {}) {
|
|
812
|
-
const
|
|
1328
|
+
const at = this.deps.now();
|
|
1329
|
+
const { payload: status, pooled: pooledIds } = await this.providerRows(profileHome);
|
|
813
1330
|
const active = status.active_provider;
|
|
814
1331
|
const scopeId = this.quotaProfileScopeId(profile);
|
|
815
|
-
// Python `_quota_source_id(profile, provider, "provider"
|
|
816
|
-
const sourceId = (pid) => `qsrc_${createHash('sha256').update(`${scopeId}\0${pid}\
|
|
817
|
-
|
|
1332
|
+
// Python `_quota_source_id(profile, provider, credential_id)`: a pool account's id, or "provider" for the provider's own key.
|
|
1333
|
+
const sourceId = (pid, credential) => `qsrc_${createHash('sha256').update(`${scopeId}\0${pid}\0${credential}`).digest('hex').slice(0, 32)}`;
|
|
1334
|
+
// A provider configured only through `hermes auth add` has no key in .env or config.yaml, yet its pool accounts are
|
|
1335
|
+
// sources, including a signed-out OAuth provider's. An unknown pool (a failed lookup with no earlier answer) never
|
|
1336
|
+
// reports a requested source as missing.
|
|
1337
|
+
let poolUnknown = pooledIds === null;
|
|
1338
|
+
const pooled = new Set(pooledIds ?? []);
|
|
1339
|
+
const config = await this.deps.config.read(profileHome);
|
|
1340
|
+
const perProvider = await Promise.all(status.providers.filter((p) => p.has_key || p.is_custom || pooled.has(str(p.id))).map(async (p) => {
|
|
1341
|
+
const pid = str(p.id);
|
|
1342
|
+
const label = str(p.display_name) || pid;
|
|
1343
|
+
const apiKey = this.apiKeyFor(pid, profileHome, config);
|
|
1344
|
+
const found = await this.poolEntries(profileHome, pid, apiKey);
|
|
1345
|
+
if (!found)
|
|
1346
|
+
poolUnknown = true;
|
|
1347
|
+
const entries = found ?? [];
|
|
1348
|
+
const accounts = entries.map((e) => ({ source_id: sourceId(pid, e.credential_id), provider_id: pid, provider_label: label, account_label: e.label, credential: e }));
|
|
1349
|
+
// The provider's own key is a source unless a pool account already holds it.
|
|
1350
|
+
const ownKey = !entries.length || (apiKey !== null && !entries.some((e) => e.matches_api_key));
|
|
1351
|
+
return ownKey ? [{ source_id: sourceId(pid, 'provider'), provider_id: pid, provider_label: label, account_label: label, credential: null }, ...accounts] : accounts;
|
|
1352
|
+
}));
|
|
1353
|
+
let descriptors = uniqueQuotaSources(perProvider.flat());
|
|
818
1354
|
const requested = str(opts.sourceId).trim() || null;
|
|
819
1355
|
if (requested)
|
|
820
1356
|
descriptors = descriptors.filter((d) => d.source_id === requested);
|
|
1357
|
+
const thresholds = this.deps.quotaThresholds?.(profile) ?? QUOTA_THRESHOLD_DEFAULTS;
|
|
821
1358
|
const sources = await Promise.all(descriptors.map(async (d) => {
|
|
822
|
-
const q = await this.quota(profileHome, d.provider_id, { refresh: opts.refresh ?? false });
|
|
1359
|
+
const q = await this.quota(profileHome, d.provider_id, { refresh: opts.refresh ?? false, at, credential: d.credential });
|
|
823
1360
|
const limits = dict(q.account_limits);
|
|
824
|
-
return {
|
|
825
|
-
source_id: d.source_id, provider_id: d.provider_id, provider_label: d.provider_label, account_label: d.
|
|
1361
|
+
return classifyQuotaSource({
|
|
1362
|
+
source_id: d.source_id, provider_id: d.provider_id, provider_label: d.provider_label, account_label: d.account_label,
|
|
826
1363
|
is_active_provider: d.provider_id === active, supported: q.supported === true, status: str(limits.status) || str(q.status) || 'unavailable',
|
|
827
|
-
plan: limits.plan ?? null, windows: limits.windows ?? [], quota: q.quota ?? null, balances: q.balances ?? [], details: limits.details ?? [],
|
|
1364
|
+
plan: limits.plan ?? null, windows: (limits.windows ?? []), pace_window_index: (limits.pace_window_index ?? null), session_window_index: limits.session_window_index ?? null, weekly_window_index: limits.weekly_window_index ?? null, quota: q.quota ?? null, balances: q.balances ?? [], details: limits.details ?? [],
|
|
828
1365
|
unavailable_reason: limits.unavailable_reason ?? null, retry_after: limits.retry_after ?? null, fetched_at: limits.fetched_at ?? null, message: q.message ?? null,
|
|
829
|
-
};
|
|
1366
|
+
}, thresholds);
|
|
830
1367
|
}));
|
|
831
|
-
return { version: 1, scope_id: scopeId, profile_id: profile, active_provider: active, requested_source_id: requested, missing_source: Boolean(requested && !descriptors.length), sources };
|
|
1368
|
+
return { version: 1, computed_at: isoAt(at), scope_id: scopeId, profile_id: profile, active_provider: active, requested_source_id: requested, missing_source: Boolean(requested && !descriptors.length && !poolUnknown), sources };
|
|
832
1369
|
}
|
|
833
1370
|
/** Python `get_provider_cost_history` (OpenRouter only; daily snapshots under `<home>/cost-snapshots`). */
|
|
834
1371
|
async costHistory(profileHome, providerRaw, days) {
|
|
@@ -843,56 +1380,46 @@ export class ProviderCatalog {
|
|
|
843
1380
|
const apiKey = this.apiKeyFor('openrouter', profileHome, config);
|
|
844
1381
|
if (!apiKey)
|
|
845
1382
|
return { ok: false, provider, display_name: name, supported: true, status: 'no_key', monthly_budget: budget, message: 'OpenRouter cost history needs an OPENROUTER_API_KEY configured on the server.' };
|
|
846
|
-
const
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
const prev = window[i - 1];
|
|
861
|
-
if (i > 0 && e.used !== null && prev?.used !== null && prev?.used !== undefined) {
|
|
862
|
-
delta = e.used - prev.used;
|
|
863
|
-
if (delta < 0)
|
|
864
|
-
delta = e.used;
|
|
865
|
-
delta = Math.abs(delta) < 1e-9 ? 0 : Math.round(delta * 1e6) / 1e6;
|
|
866
|
-
}
|
|
867
|
-
return { date: e.date, used: e.used, delta };
|
|
868
|
-
});
|
|
869
|
-
};
|
|
870
|
-
const info = await this.fetchOpenRouterKey(apiKey);
|
|
871
|
-
if (info.kind !== 'ok') {
|
|
872
|
-
return { ok: false, provider, display_name: name, supported: true, status: 'unavailable', window_days: days, snapshots: deltas(read()), limit: null, label: null, monthly_budget: budget, message: 'OpenRouter cost history is temporarily unavailable. Showing last known data.' };
|
|
1383
|
+
const info = await this.balance(profileHome, 'openrouter', { api_key: apiKey });
|
|
1384
|
+
if (info?.status !== 'ok' || !info.quota) {
|
|
1385
|
+
return { ok: false, provider, display_name: name, supported: true, status: 'unavailable', window_days: days, ...costHistoryView(this.readCostSnapshots(profileHome), days, budget), limit: null, label: null, monthly_budget: budget, message: 'OpenRouter cost history is temporarily unavailable. Showing last known data.' };
|
|
1386
|
+
}
|
|
1387
|
+
const snapshots = this.recordCostSnapshot(profileHome, info.quota, this.deps.now());
|
|
1388
|
+
return { ok: true, provider, display_name: name, supported: true, status: 'available', window_days: days, ...costHistoryView(snapshots, days, budget), limit: info.quota.limit, label: info.label ?? 'OpenRouter credits', monthly_budget: budget, message: 'OpenRouter cost history loaded.' };
|
|
1389
|
+
}
|
|
1390
|
+
readCostSnapshots(profileHome) {
|
|
1391
|
+
try {
|
|
1392
|
+
const data = JSON.parse(readFileSync(join(profileHome, 'cost-snapshots', 'openrouter.json'), 'utf8'));
|
|
1393
|
+
return (Array.isArray(data.snapshots) ? data.snapshots : []).filter(isDict).map((e) => ({ date: str(e.date).trim(), used: typeof e.used === 'number' ? e.used : null, limit: typeof e.limit === 'number' ? e.limit : null })).filter((e) => e.date).sort((a, b) => (a.date < b.date ? -1 : 1));
|
|
1394
|
+
}
|
|
1395
|
+
catch {
|
|
1396
|
+
return [];
|
|
873
1397
|
}
|
|
874
|
-
|
|
1398
|
+
}
|
|
1399
|
+
/** Writes or updates the UTC day's OpenRouter usage snapshot; every successful key read (quota or cost history) records one. */
|
|
1400
|
+
recordCostSnapshot(profileHome, quota, at) {
|
|
1401
|
+
let snapshots = this.readCostSnapshots(profileHome);
|
|
875
1402
|
try {
|
|
876
|
-
const today =
|
|
877
|
-
const
|
|
878
|
-
const limit =
|
|
1403
|
+
const today = isoAt(at).slice(0, 10);
|
|
1404
|
+
const used = finite(quota.usage);
|
|
1405
|
+
const limit = finite(quota.limit);
|
|
879
1406
|
const existing = snapshots.find((e) => e.date === today);
|
|
880
1407
|
if (existing) {
|
|
881
|
-
existing.used =
|
|
1408
|
+
existing.used = used;
|
|
882
1409
|
existing.limit = limit;
|
|
883
1410
|
}
|
|
884
1411
|
else
|
|
885
|
-
snapshots.push({ date: today, used
|
|
1412
|
+
snapshots.push({ date: today, used, limit });
|
|
886
1413
|
snapshots.sort((a, b) => (a.date < b.date ? -1 : 1));
|
|
887
1414
|
if (snapshots.length > COST_SNAPSHOT_MAX_DAYS)
|
|
888
1415
|
snapshots = snapshots.slice(-COST_SNAPSHOT_MAX_DAYS);
|
|
889
1416
|
mkdirSync(join(profileHome, 'cost-snapshots'), { recursive: true });
|
|
890
|
-
atomicWriteText(
|
|
1417
|
+
atomicWriteText(join(profileHome, 'cost-snapshots', 'openrouter.json'), JSON.stringify({ provider: 'openrouter', snapshots }, null, 2));
|
|
1418
|
+
return snapshots;
|
|
891
1419
|
}
|
|
892
1420
|
catch {
|
|
893
|
-
|
|
1421
|
+
return this.readCostSnapshots(profileHome);
|
|
894
1422
|
}
|
|
895
|
-
return { ok: true, provider, display_name: name, supported: true, status: 'available', window_days: days, snapshots: deltas(snapshots), limit: info.quota.limit, label: info.label ?? 'OpenRouter credits', monthly_budget: budget, message: 'OpenRouter cost history loaded.' };
|
|
896
1423
|
}
|
|
897
1424
|
}
|
|
898
1425
|
export const envFileExists = (profileHome) => existsSync(join(profileHome, '.env'));
|