cartethyia 2.4.6 → 2.5.1
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/README.md +45 -13
- package/dist/dashboard/assets/BannedPage-ChqnTRpZ.js +1 -0
- package/dist/dashboard/assets/BannedPage-ChqnTRpZ.js.gz +0 -0
- package/dist/dashboard/assets/{CliToolDetailPage-Bcy150Jo.js → CliToolDetailPage-Bb9CNuoh.js} +1 -1
- package/dist/dashboard/assets/CliToolDetailPage-Bb9CNuoh.js.gz +0 -0
- package/dist/dashboard/assets/{CliToolsPage-DyNXefph.js → CliToolsPage-BXUaRCQQ.js} +1 -1
- package/dist/dashboard/assets/CliToolsPage-BXUaRCQQ.js.gz +0 -0
- package/dist/dashboard/assets/CombosPage-CisNZOUt.js +1 -0
- package/dist/dashboard/assets/CombosPage-CisNZOUt.js.gz +0 -0
- package/dist/dashboard/assets/{ConsoleLogPage-CATpAEOz.js → ConsoleLogPage-BOpI_LOH.js} +1 -1
- package/dist/dashboard/assets/ConsoleLogPage-BOpI_LOH.js.gz +0 -0
- package/dist/dashboard/assets/{CustomizationPage-D6jEU7nL.js → CustomizationPage-DAkdvJ4k.js} +1 -1
- package/dist/dashboard/assets/CustomizationPage-DAkdvJ4k.js.gz +0 -0
- package/dist/dashboard/assets/{HealthEventsModal-CZkpn0a7.js → HealthEventsModal-DeT-VXHd.js} +1 -1
- package/dist/dashboard/assets/HealthEventsModal-DeT-VXHd.js.gz +0 -0
- package/dist/dashboard/assets/{LoginPage-BJlMVN4U.js → LoginPage-CytxSeGP.js} +1 -1
- package/dist/dashboard/assets/LoginPage-CytxSeGP.js.gz +0 -0
- package/dist/dashboard/assets/{ModelPicker-Br3CXEau.js → ModelPicker-oQ-SDXp8.js} +1 -1
- package/dist/dashboard/assets/ModelPicker-oQ-SDXp8.js.gz +0 -0
- package/dist/dashboard/assets/OverviewPage-CU7S1wJs.js +1 -0
- package/dist/dashboard/assets/OverviewPage-CU7S1wJs.js.gz +0 -0
- package/dist/dashboard/assets/ProviderDetailPage-DLm2baot.js +3 -0
- package/dist/dashboard/assets/ProviderDetailPage-DLm2baot.js.gz +0 -0
- package/dist/dashboard/assets/ProviderIcon-CmfzxpVM.js +1 -0
- package/dist/dashboard/assets/ProviderIcon-CmfzxpVM.js.gz +0 -0
- package/dist/dashboard/assets/ProvidersPage-DOXfuzfL.js +1 -0
- package/dist/dashboard/assets/ProvidersPage-DOXfuzfL.js.gz +0 -0
- package/dist/dashboard/assets/ProxyPage-wvk0ciQN.js +7 -0
- package/dist/dashboard/assets/ProxyPage-wvk0ciQN.js.gz +0 -0
- package/dist/dashboard/assets/QuotaPage-Cn_fcUHE.js +1 -0
- package/dist/dashboard/assets/QuotaPage-Cn_fcUHE.js.gz +0 -0
- package/dist/dashboard/assets/{SettingsPage-BHW4Zt06.js → SettingsPage-8-eVadhc.js} +1 -1
- package/dist/dashboard/assets/SettingsPage-8-eVadhc.js.gz +0 -0
- package/dist/dashboard/assets/{SetupPage-VOYC_lIa.js → SetupPage-uEIXPyGO.js} +1 -1
- package/dist/dashboard/assets/SetupPage-uEIXPyGO.js.gz +0 -0
- package/dist/dashboard/assets/SortableList-DCY0TPTZ.js +1 -0
- package/dist/dashboard/assets/SortableList-DCY0TPTZ.js.gz +0 -0
- package/dist/dashboard/assets/{StudioPage-BNIDLiU6.js → StudioPage-DqK5E26W.js} +4 -4
- package/dist/dashboard/assets/StudioPage-DqK5E26W.js.gz +0 -0
- package/dist/dashboard/assets/UsagePage-38JfiZ0H.js +56 -0
- package/dist/dashboard/assets/UsagePage-38JfiZ0H.js.gz +0 -0
- package/dist/dashboard/assets/{api-keys-BETfaMYZ.js → api-keys-BmDbsior.js} +1 -1
- package/dist/dashboard/assets/api-keys-BmDbsior.js.gz +0 -0
- package/dist/dashboard/assets/app-BbpSarVV.css +2 -0
- package/dist/dashboard/assets/app-BbpSarVV.css.gz +0 -0
- package/dist/dashboard/assets/assert-BHfaei-X.js +1 -0
- package/dist/dashboard/assets/assert-BHfaei-X.js.gz +0 -0
- package/dist/dashboard/assets/{badge-BTyGk_DL.js → badge-DpXT4Ixt.js} +1 -1
- package/dist/dashboard/assets/badge-DpXT4Ixt.js.gz +0 -0
- package/dist/dashboard/assets/{banner-bujTWYM4.js → banner-CSAAWLJv.js} +1 -1
- package/dist/dashboard/assets/banner-CSAAWLJv.js.gz +0 -0
- package/dist/dashboard/assets/{card-B0Bnx5CA.js → card-DEhRXXw4.js} +1 -1
- package/dist/dashboard/assets/card-DEhRXXw4.js.gz +0 -0
- package/dist/dashboard/assets/{cli-tools-DE2ug812.js → cli-tools-B0wBq-RL.js} +1 -1
- package/dist/dashboard/assets/cli-tools-B0wBq-RL.js.gz +0 -0
- package/dist/dashboard/assets/{clipboard-button-R_rArH8S.js → clipboard-button-DOo6EU8O.js} +1 -1
- package/dist/dashboard/assets/clipboard-button-DOo6EU8O.js.gz +0 -0
- package/dist/dashboard/assets/{dialog-BWIP_EvW.js → dialog-BvQeBMfP.js} +1 -1
- package/dist/dashboard/assets/dialog-BvQeBMfP.js.gz +0 -0
- package/dist/dashboard/assets/{drawer-BKcgVugF.js → drawer-CMwPXwQB.js} +1 -1
- package/dist/dashboard/assets/drawer-CMwPXwQB.js.gz +0 -0
- package/dist/dashboard/assets/entry--yj84zk3.js +4 -0
- package/dist/dashboard/assets/entry--yj84zk3.js.gz +0 -0
- package/dist/dashboard/assets/{entry-CcWkGR4a.js → entry-D4O2229X.js} +1 -1
- package/dist/dashboard/assets/{entry-CcWkGR4a.js.gz → entry-D4O2229X.js.gz} +0 -0
- package/dist/dashboard/assets/{entry-kTiTFrvh.js → entry-DKvr5Q9K.js} +1 -1
- package/dist/dashboard/assets/entry-DKvr5Q9K.js.gz +0 -0
- package/dist/dashboard/assets/{github-badge-Booc5mNe.js → github-badge-zliLvBwr.js} +1 -1
- package/dist/dashboard/assets/github-badge-zliLvBwr.js.gz +0 -0
- package/dist/dashboard/assets/{index-D70pChfn.js → index-CcD1eSJ6.js} +2 -2
- package/dist/dashboard/assets/index-CcD1eSJ6.js.gz +0 -0
- package/dist/dashboard/assets/{inline-D1TCqM2D.js → inline-D1IlZZ8H.js} +1 -1
- package/dist/dashboard/assets/inline-D1IlZZ8H.js.gz +0 -0
- package/dist/dashboard/assets/{input-B9oyU93y.js → input-BpVZdrbc.js} +1 -1
- package/dist/dashboard/assets/input-BpVZdrbc.js.gz +0 -0
- package/dist/dashboard/assets/{layout-BArzXirQ.js → layout-BioLW7gN.js} +1 -1
- package/dist/dashboard/assets/layout-BioLW7gN.js.gz +0 -0
- package/dist/dashboard/assets/{list-row-BVZzaoHF.js → list-row-CwVcGAIc.js} +1 -1
- package/dist/dashboard/assets/list-row-CwVcGAIc.js.gz +0 -0
- package/dist/dashboard/assets/{live-EmuIayqp.js → live-yfEJFF9T.js} +1 -1
- package/dist/dashboard/assets/live-yfEJFF9T.js.gz +0 -0
- package/dist/dashboard/assets/{model-bans-CSPQ9xB0.js → model-bans-CT-TxSNX.js} +1 -1
- package/dist/dashboard/assets/model-bans-CT-TxSNX.js.gz +0 -0
- package/dist/dashboard/assets/providers/cursor.webp +0 -0
- package/dist/dashboard/assets/providers/trae.webp +0 -0
- package/dist/dashboard/assets/{routing-BExgxxBU.js → routing-Cs6jl49L.js} +1 -1
- package/dist/dashboard/assets/routing-Cs6jl49L.js.gz +0 -0
- package/dist/dashboard/assets/select-XPJ-dIw3.js +41 -0
- package/dist/dashboard/assets/select-XPJ-dIw3.js.gz +0 -0
- package/dist/dashboard/assets/settings-CfcTcOyU.js +1 -0
- package/dist/dashboard/assets/settings-CfcTcOyU.js.gz +0 -0
- package/dist/dashboard/assets/{stack-Ca5HxI5i.js → stack-C9_uUjaA.js} +1 -1
- package/dist/dashboard/assets/stack-C9_uUjaA.js.gz +0 -0
- package/dist/dashboard/assets/{state-CzUPfLXo.js → state-DA0B95UQ.js} +1 -1
- package/dist/dashboard/assets/state-DA0B95UQ.js.gz +0 -0
- package/dist/dashboard/assets/{switch-DOUIky_i.js → switch-Cittxy-x.js} +1 -1
- package/dist/dashboard/assets/switch-Cittxy-x.js.gz +0 -0
- package/dist/dashboard/assets/{toast-C76WrMhc.js → toast-CNt_jzO4.js} +1 -1
- package/dist/dashboard/assets/toast-CNt_jzO4.js.gz +0 -0
- package/dist/dashboard/assets/{useMutation-D87FZwH0.js → useMutation-BQKMcJ1H.js} +1 -1
- package/dist/dashboard/assets/useMutation-BQKMcJ1H.js.gz +0 -0
- package/dist/dashboard/index.html +1 -1
- package/dist/dashboard/index.html.gz +0 -0
- package/dist/dashboard/precompressed-assets.json +51 -52
- package/migrations/full-postgres/0015_add_payload_stages.sql +18 -0
- package/migrations/full-postgres/0016_add_share_link_slug.sql +20 -0
- package/migrations/full-postgres/0017_add_models_pro_only.sql +18 -0
- package/migrations/full-postgres/0018_add_accounts_quota_plan.sql +18 -0
- package/migrations/full-postgres/0019_add_models_free_tier.sql +15 -0
- package/migrations/lite-sqlite3/0015_add_payload_stages.sql +18 -0
- package/migrations/lite-sqlite3/0016_add_share_link_slug.sql +20 -0
- package/migrations/lite-sqlite3/0017_add_models_pro_only.sql +18 -0
- package/migrations/lite-sqlite3/0018_add_accounts_quota_plan.sql +18 -0
- package/migrations/lite-sqlite3/0019_add_models_free_tier.sql +15 -0
- package/package.json +3 -2
- package/scripts/cli/command/console-menu.ts +30 -11
- package/scripts/cli/command/start.ts +77 -13
- package/scripts/cli/command/tray.ts +47 -8
- package/scripts/cli/command/update.ts +1 -1
- package/scripts/cli/gateway/instance-lock.ts +105 -0
- package/scripts/cli/gateway/spawn.ts +173 -101
- package/scripts/cli/ui/background.ts +110 -49
- package/src/boot/app.ts +20 -6
- package/src/boot/dependencies.ts +19 -0
- package/src/boot/elysia-typebox-bridge.ts +37 -0
- package/src/boot/lifecycle.ts +2 -0
- package/src/config/cooldowns.ts +138 -138
- package/src/config/providers.ts +5 -5
- package/src/config/runtime.ts +19 -3
- package/src/config/spec.ts +9 -0
- package/src/console/api-keys/contracts.ts +414 -391
- package/src/console/api-keys/routes.ts +880 -870
- package/src/console/auth/account-reset-store.ts +14 -0
- package/src/console/auth/account-reset.ts +3 -2
- package/src/console/auth/setup-service.ts +6 -3
- package/src/console/domain-registration.ts +9 -2
- package/src/console/observability/contracts.ts +475 -455
- package/src/console/observability/store.ts +1015 -952
- package/src/console/providers/catalog/contracts.ts +31 -0
- package/src/console/providers/catalog/provider-operations.ts +8 -0
- package/src/console/providers/catalog/store.ts +1532 -1525
- package/src/console/providers/oauth/callback-listener.ts +18 -3
- package/src/console/providers/oauth/routes.ts +38 -15
- package/src/console/quota/refresh.ts +19 -0
- package/src/console/updates/routes.ts +162 -4
- package/src/main.ts +3 -0
- package/src/network/http2-bidi.ts +85 -0
- package/src/network/outbound-fetch.ts +1 -26
- package/src/network/pool/agent.ts +478 -460
- package/src/network/pool/resolver.ts +1104 -544
- package/src/network/pool-health-machine.ts +12 -5
- package/src/observability/payload-capture.ts +344 -324
- package/src/persistence/db-pg.ts +9 -3
- package/src/persistence/db-sqlite.ts +497 -495
- package/src/persistence/health-event-flush.ts +137 -0
- package/src/persistence/migrate.ts +181 -129
- package/src/persistence/schema.ts +1360 -1295
- package/src/persistence/settings-revision.ts +25 -0
- package/src/persistence/shared-cache.ts +217 -0
- package/src/persistence/stores/share-store.ts +556 -528
- package/src/persistence/usage-flush.ts +61 -1
- package/src/protocol/canonical-model.ts +23 -1
- package/src/protocol/response/chat.ts +143 -10
- package/src/protocol/response/omp-tool-call-markup.ts +221 -0
- package/src/providers/default-registry.ts +173 -1
- package/src/providers/discovery/openai-model-discovery.ts +39 -3
- package/src/providers/discovery/openrouter-free-roster.ts +98 -0
- package/src/providers/discovery/probing-service.ts +1022 -1001
- package/src/providers/integrations/buddy/buddy-catalog-shared.ts +39 -0
- package/src/providers/integrations/buddy/buddy-chat-shared.ts +913 -914
- package/src/providers/integrations/buddy/buddy-conversation-headers.ts +18 -5
- package/src/providers/integrations/buddy/codebuddy/cn-provider.ts +4 -0
- package/src/providers/integrations/buddy/codebuddy/provider.ts +180 -175
- package/src/providers/integrations/buddy/codebuddy/shared.ts +15 -6
- package/src/providers/integrations/buddy/workbuddy/provider.ts +206 -204
- package/src/providers/integrations/buddy/workbuddy/shared.ts +3 -3
- package/src/providers/integrations/claude/claude.ts +606 -582
- package/src/providers/integrations/cline/cline-quota.ts +18 -16
- package/src/providers/integrations/cline/cline.ts +539 -468
- package/src/providers/integrations/commandcode/commandcode.ts +675 -541
- package/src/providers/integrations/commandcode/discovery.ts +116 -0
- package/src/providers/integrations/commandcode/quota.ts +160 -0
- package/src/providers/integrations/cursor/cursor-auth.ts +418 -0
- package/src/providers/integrations/cursor/cursor-catalog.ts +368 -0
- package/src/providers/integrations/cursor/cursor-http2.ts +300 -0
- package/src/providers/integrations/cursor/cursor-identity.ts +92 -0
- package/src/providers/integrations/cursor/cursor-request.ts +471 -0
- package/src/providers/integrations/cursor/cursor-session-manager.ts +281 -0
- package/src/providers/integrations/cursor/cursor-shared.ts +108 -0
- package/src/providers/integrations/cursor/cursor.ts +960 -0
- package/src/providers/integrations/cursor/protocol/framing.ts +129 -0
- package/src/providers/integrations/cursor/protocol/messages.ts +2355 -0
- package/src/providers/integrations/cursor/protocol/struct.ts +305 -0
- package/src/providers/integrations/github/github.ts +4 -4
- package/src/providers/integrations/hermes.ts +79 -8
- package/src/providers/integrations/kilo/kilo-discovery.ts +10 -1
- package/src/providers/integrations/kilo/kilo.ts +153 -146
- package/src/providers/integrations/opencode/provider.ts +532 -538
- package/src/providers/integrations/openrouter/provider.ts +139 -281
- package/src/providers/integrations/qoder/qoder-catalog.ts +450 -0
- package/src/providers/integrations/qoder/qoder-oauth.ts +310 -257
- package/src/providers/integrations/qoder/qoder-realm.ts +106 -0
- package/src/providers/integrations/qoder/qoder-virtual.ts +77 -0
- package/src/providers/integrations/qoder/qoder.ts +865 -730
- package/src/providers/integrations/trae/trae-catalog.ts +190 -0
- package/src/providers/integrations/trae/trae-headers.ts +149 -0
- package/src/providers/integrations/trae/trae-oauth.ts +773 -0
- package/src/providers/integrations/trae/trae-protocol.ts +400 -0
- package/src/providers/integrations/trae/trae-realm.ts +220 -0
- package/src/providers/integrations/trae/trae.ts +458 -0
- package/src/providers/operations/account-health-service.ts +107 -10
- package/src/providers/operations/client-versions.ts +133 -6
- package/src/providers/operations/provider-catalog-seeder.ts +95 -86
- package/src/providers/operations/provider-credential-service.ts +46 -7
- package/src/providers/provider-contracts.ts +76 -0
- package/src/providers/provider-metadata.ts +78 -6
- package/src/providers/provider-model-definition.ts +373 -349
- package/src/security/api-key-auth.ts +126 -8
- package/src/transport/contracts.ts +3 -0
- package/src/transport/dispatch/attempt-finalize.ts +32 -1
- package/src/transport/dispatch/attempt-loop.ts +530 -515
- package/src/transport/dispatch/generation.ts +88 -21
- package/src/transport/dispatch/leases.ts +99 -2
- package/src/transport/dispatch/proxy-request.ts +60 -3
- package/src/transport/dispatch/streaming/finalize.ts +16 -1
- package/src/transport/dispatch/streaming/stream.ts +22 -2
- package/src/transport/dispatch/upstream.ts +19 -1
- package/src/transport/guards/auth-guard.ts +26 -1
- package/src/transport/guards/body-policy.ts +5 -4
- package/src/transport/request/preparer.ts +103 -0
- package/src/transport/request/state.ts +14 -0
- package/src/transport/routing/plan-gate.ts +45 -0
- package/src/transport/routing/route-catalog.ts +799 -777
- package/src/transport/routing/route-model.ts +52 -1
- package/src/transport/routing/router.ts +51 -0
- package/dist/dashboard/assets/BannedPage-QhvbJB9o.js +0 -1
- package/dist/dashboard/assets/BannedPage-QhvbJB9o.js.gz +0 -0
- package/dist/dashboard/assets/CliToolDetailPage-Bcy150Jo.js.gz +0 -0
- package/dist/dashboard/assets/CliToolsPage-DyNXefph.js.gz +0 -0
- package/dist/dashboard/assets/CombosPage-Cak0ljnO.js +0 -1
- package/dist/dashboard/assets/CombosPage-Cak0ljnO.js.gz +0 -0
- package/dist/dashboard/assets/ConsoleLogPage-CATpAEOz.js.gz +0 -0
- package/dist/dashboard/assets/CustomizationPage-D6jEU7nL.js.gz +0 -0
- package/dist/dashboard/assets/HealthEventsModal-CZkpn0a7.js.gz +0 -0
- package/dist/dashboard/assets/LoginPage-BJlMVN4U.js.gz +0 -0
- package/dist/dashboard/assets/ModelPicker-Br3CXEau.js.gz +0 -0
- package/dist/dashboard/assets/OverviewPage-CQxXbo2g.js +0 -1
- package/dist/dashboard/assets/OverviewPage-CQxXbo2g.js.gz +0 -0
- package/dist/dashboard/assets/ProviderDetailPage--0RBNh99.js +0 -3
- package/dist/dashboard/assets/ProviderDetailPage--0RBNh99.js.gz +0 -0
- package/dist/dashboard/assets/ProviderIcon-paOCq8I0.js +0 -1
- package/dist/dashboard/assets/ProviderIcon-paOCq8I0.js.gz +0 -0
- package/dist/dashboard/assets/ProvidersPage-1M-C3mbu.js +0 -1
- package/dist/dashboard/assets/ProvidersPage-1M-C3mbu.js.gz +0 -0
- package/dist/dashboard/assets/ProxyPage-CXiumA53.js +0 -7
- package/dist/dashboard/assets/ProxyPage-CXiumA53.js.gz +0 -0
- package/dist/dashboard/assets/QuotaPage-BGDIfIYP.js +0 -1
- package/dist/dashboard/assets/QuotaPage-BGDIfIYP.js.gz +0 -0
- package/dist/dashboard/assets/SettingsPage-BHW4Zt06.js.gz +0 -0
- package/dist/dashboard/assets/SetupPage-VOYC_lIa.js.gz +0 -0
- package/dist/dashboard/assets/SortableList-DpOTnGuH.js +0 -1
- package/dist/dashboard/assets/SortableList-DpOTnGuH.js.gz +0 -0
- package/dist/dashboard/assets/StudioPage-BNIDLiU6.js.gz +0 -0
- package/dist/dashboard/assets/UsagePage-BAFaSDQV.js +0 -56
- package/dist/dashboard/assets/UsagePage-BAFaSDQV.js.gz +0 -0
- package/dist/dashboard/assets/api-keys-BETfaMYZ.js.gz +0 -0
- package/dist/dashboard/assets/app-BLCvGdzb.css +0 -2
- package/dist/dashboard/assets/app-BLCvGdzb.css.gz +0 -0
- package/dist/dashboard/assets/assert-7J6_LVD3.js +0 -1
- package/dist/dashboard/assets/assert-7J6_LVD3.js.gz +0 -0
- package/dist/dashboard/assets/badge-BTyGk_DL.js.gz +0 -0
- package/dist/dashboard/assets/banner-bujTWYM4.js.gz +0 -0
- package/dist/dashboard/assets/card-B0Bnx5CA.js.gz +0 -0
- package/dist/dashboard/assets/cli-tools-DE2ug812.js.gz +0 -0
- package/dist/dashboard/assets/clipboard-button-R_rArH8S.js.gz +0 -0
- package/dist/dashboard/assets/dialog-BWIP_EvW.js.gz +0 -0
- package/dist/dashboard/assets/drawer-BKcgVugF.js.gz +0 -0
- package/dist/dashboard/assets/entry-7dpgvpJ3.js +0 -4
- package/dist/dashboard/assets/entry-7dpgvpJ3.js.gz +0 -0
- package/dist/dashboard/assets/entry-kTiTFrvh.js.gz +0 -0
- package/dist/dashboard/assets/github-badge-Booc5mNe.js.gz +0 -0
- package/dist/dashboard/assets/index-D70pChfn.js.gz +0 -0
- package/dist/dashboard/assets/inline-D1TCqM2D.js.gz +0 -0
- package/dist/dashboard/assets/input-B9oyU93y.js.gz +0 -0
- package/dist/dashboard/assets/layout-BArzXirQ.js.gz +0 -0
- package/dist/dashboard/assets/list-row-BVZzaoHF.js.gz +0 -0
- package/dist/dashboard/assets/live-EmuIayqp.js.gz +0 -0
- package/dist/dashboard/assets/model-bans-CSPQ9xB0.js.gz +0 -0
- package/dist/dashboard/assets/routing-BExgxxBU.js.gz +0 -0
- package/dist/dashboard/assets/select-C9mMoEpJ.js +0 -41
- package/dist/dashboard/assets/select-C9mMoEpJ.js.gz +0 -0
- package/dist/dashboard/assets/settings-C4_Gn_wq.js +0 -1
- package/dist/dashboard/assets/settings-C4_Gn_wq.js.gz +0 -0
- package/dist/dashboard/assets/sliders-horizontal-ClG7dBeJ.js +0 -1
- package/dist/dashboard/assets/sliders-horizontal-ClG7dBeJ.js.gz +0 -0
- package/dist/dashboard/assets/stack-Ca5HxI5i.js.gz +0 -0
- package/dist/dashboard/assets/state-CzUPfLXo.js.gz +0 -0
- package/dist/dashboard/assets/switch-DOUIky_i.js.gz +0 -0
- package/dist/dashboard/assets/toast-C76WrMhc.js.gz +0 -0
- package/dist/dashboard/assets/useMutation-D87FZwH0.js.gz +0 -0
- package/src/network/tls/bun-client-hello.ts +0 -112
- /package/dist/dashboard/assets/{app-Cjq7nrwu.js → app-C-Y4oZT0.js} +0 -0
- /package/dist/dashboard/assets/{app-Cjq7nrwu.js.gz → app-C-Y4oZT0.js.gz} +0 -0
|
@@ -1,914 +1,913 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Chat-wire payload and message normalization shared by the Tencent "buddy"
|
|
3
|
-
* family providers — CodeBuddy International (`cb`), CodeBuddy CN (`cbcn`),
|
|
4
|
-
* and WorkBuddy. All three speak the same upstream contract for this part of
|
|
5
|
-
* the payload, so the rules live here once instead of being copied per
|
|
6
|
-
* variant:
|
|
7
|
-
*
|
|
8
|
-
* - streaming is mandatory (`stream=true`);
|
|
9
|
-
* - `reasoning_summary` is opted in only when a `reasoning_effort` is
|
|
10
|
-
* requested;
|
|
11
|
-
* - the agent-namespace fields are never forwarded;
|
|
12
|
-
* - caller `system`/`developer` turns are replaced by the variant's fixed
|
|
13
|
-
* leading system prompt, and bare string user content becomes a typed text
|
|
14
|
-
* block; and
|
|
15
|
-
* - consecutive same-role `user` turns must be merged before dispatch.
|
|
16
|
-
*
|
|
17
|
-
* Provider-specific identity headers and catalog tables stay in each variant's
|
|
18
|
-
* own `*-shared.ts`.
|
|
19
|
-
*/
|
|
20
|
-
import { createHash } from "node:crypto";
|
|
21
|
-
import { completeRequiredSchema } from "../../../protocol/codecs";
|
|
22
|
-
import type { CanonicalRequest } from "../../../protocol/canonical-model";
|
|
23
|
-
import { isRecord } from "../../../protocol/codecs";
|
|
24
|
-
import { applyDeepSeekReasoning, applyOpenAIReasoning, backfillDeepSeekReasoningContent, clampedEffortOutputFloor } from "../../provider-reasoning";
|
|
25
|
-
import { BUDDY_CN_EFFORTS, BUDDY_INTL_EFFORTS, BUDDY_SHARED_RAW } from "./buddy-catalog-shared";
|
|
26
|
-
// Imported for the CN roster only: a CN-only id has no row in the shared
|
|
27
|
-
// table, and without one `raiseOutputForRaisedEffort` silently no-ops, so a
|
|
28
|
-
// raised effort still answers inside a budget sized for the lower one. It is
|
|
29
|
-
// read inside the function below — never at module init — so the cycle with
|
|
30
|
-
// cn-provider (which imports this module) resolves before any call.
|
|
31
|
-
import { CODEBUDDY_CN_RAW } from "./codebuddy/cn-provider";
|
|
32
|
-
import { buddyAccountUid } from "./buddy-oauth-shared";
|
|
33
|
-
import type { ProviderDispatchContext } from "../../provider-contracts";
|
|
34
|
-
|
|
35
|
-
const BUDDY_TOOL_NAME_PATTERN = /^[a-zA-Z0-9_-]+$/;
|
|
36
|
-
const INVALID_BUDDY_TOOL_NAME_CHARS = /[^a-zA-Z0-9_-]/g;
|
|
37
|
-
const MAX_BUDDY_TOOL_NAME_LENGTH = 64;
|
|
38
|
-
|
|
39
|
-
function uniqueToolName(base: string, used: Set<string>): string {
|
|
40
|
-
const clipped = (base || "tool").slice(0, MAX_BUDDY_TOOL_NAME_LENGTH);
|
|
41
|
-
if (!used.has(clipped)) return clipped;
|
|
42
|
-
for (let suffix = 2; ; suffix += 1) {
|
|
43
|
-
const suffixText = `_${suffix}`;
|
|
44
|
-
const candidate = `${clipped.slice(0, MAX_BUDDY_TOOL_NAME_LENGTH - suffixText.length)}${suffixText}`;
|
|
45
|
-
if (!used.has(candidate)) return candidate;
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
function normalizedToolName(value: string, index: number, used: Set<string>): string {
|
|
50
|
-
const base =
|
|
51
|
-
BUDDY_TOOL_NAME_PATTERN.test(value) && value.length <= MAX_BUDDY_TOOL_NAME_LENGTH
|
|
52
|
-
? value
|
|
53
|
-
: value.replace(INVALID_BUDDY_TOOL_NAME_CHARS, "");
|
|
54
|
-
return uniqueToolName(base || `tool_${index + 1}`, used);
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* Buddy's Chat Completions wire has no `web_search_preview` hosted tool; its
|
|
59
|
-
* search is an ordinary function named `web_search` with a `{query}` schema.
|
|
60
|
-
* Rewriting the hosted declaration here lets the caller's own tool loop
|
|
61
|
-
* (Model Lab, or any client that executes its functions) run the query at
|
|
62
|
-
* `POST /v1/search`. The gateway never executes it: an unexecuted call is
|
|
63
|
-
* returned to the caller like any other function call.
|
|
64
|
-
*/
|
|
65
|
-
function buddyHostedSearchFunction(rawTool: Record<string, unknown>): Record<string, unknown> | undefined {
|
|
66
|
-
const type = rawTool["type"];
|
|
67
|
-
if (type !== "web_search" && type !== "web_search_preview") return undefined;
|
|
68
|
-
return {
|
|
69
|
-
type: "function",
|
|
70
|
-
function: {
|
|
71
|
-
name: "web_search",
|
|
72
|
-
description: "Search the web for current information.",
|
|
73
|
-
parameters: {
|
|
74
|
-
type: "object",
|
|
75
|
-
properties: {
|
|
76
|
-
query: { type: "string", description: "The search query." },
|
|
77
|
-
},
|
|
78
|
-
required: ["query"],
|
|
79
|
-
additionalProperties: false,
|
|
80
|
-
},
|
|
81
|
-
},
|
|
82
|
-
};
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
export function normalizeBuddyToolNames(payload: Record<string, unknown>): void {
|
|
87
|
-
if (!Array.isArray(payload["tools"])) return;
|
|
88
|
-
const used = new Set<string>();
|
|
89
|
-
const replacements = new Map<string, string>();
|
|
90
|
-
payload["tools"] = payload["tools"].map((rawTool, index) => {
|
|
91
|
-
if (!isRecord(rawTool)) return rawTool;
|
|
92
|
-
const hostedSearch = buddyHostedSearchFunction(rawTool);
|
|
93
|
-
if (hostedSearch !== undefined) {
|
|
94
|
-
used.add("web_search");
|
|
95
|
-
return hostedSearch;
|
|
96
|
-
}
|
|
97
|
-
const fn = isRecord(rawTool["function"]) ? rawTool["function"] : undefined;
|
|
98
|
-
const custom = isRecord(rawTool["custom"]) ? rawTool["custom"] : undefined;
|
|
99
|
-
const sourceName = fn?.["name"] ?? custom?.["name"] ?? rawTool["name"];
|
|
100
|
-
if (typeof sourceName !== "string") return rawTool;
|
|
101
|
-
const name = normalizedToolName(sourceName, index, used);
|
|
102
|
-
used.add(name);
|
|
103
|
-
if (!replacements.has(sourceName)) replacements.set(sourceName, name);
|
|
104
|
-
if (fn !== undefined)
|
|
105
|
-
return {
|
|
106
|
-
...rawTool,
|
|
107
|
-
function: {
|
|
108
|
-
...fn,
|
|
109
|
-
name,
|
|
110
|
-
...(fn["parameters"] === undefined ? {} : { parameters: completeRequiredSchema(fn["parameters"]) }),
|
|
111
|
-
},
|
|
112
|
-
};
|
|
113
|
-
if (custom !== undefined)
|
|
114
|
-
return {
|
|
115
|
-
...rawTool,
|
|
116
|
-
custom: {
|
|
117
|
-
...custom,
|
|
118
|
-
name,
|
|
119
|
-
...(custom["format"] === undefined ? {} : { format: completeRequiredSchema(custom["format"]) }),
|
|
120
|
-
},
|
|
121
|
-
};
|
|
122
|
-
return {
|
|
123
|
-
...rawTool,
|
|
124
|
-
name,
|
|
125
|
-
...(rawTool["parameters"] === undefined ? {} : { parameters: completeRequiredSchema(rawTool["parameters"]) }),
|
|
126
|
-
};
|
|
127
|
-
});
|
|
128
|
-
const messages = payload["messages"];
|
|
129
|
-
if (Array.isArray(messages)) {
|
|
130
|
-
for (const message of messages) {
|
|
131
|
-
if (!isRecord(message) || !Array.isArray(message["tool_calls"])) continue;
|
|
132
|
-
for (const call of message["tool_calls"]) {
|
|
133
|
-
if (!isRecord(call) || !isRecord(call["function"])) continue;
|
|
134
|
-
const name = call["function"]["name"];
|
|
135
|
-
const replacement = typeof name === "string" ? replacements.get(name) : undefined;
|
|
136
|
-
if (replacement !== undefined) call["function"]["name"] = replacement;
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
const choice = payload["tool_choice"];
|
|
141
|
-
if (!isRecord(choice)) return;
|
|
142
|
-
if (choice["type"] === "allowed_tools" && Array.isArray(choice["tools"])) {
|
|
143
|
-
for (const rawAllowed of choice["tools"]) {
|
|
144
|
-
if (!isRecord(rawAllowed)) continue;
|
|
145
|
-
const allowedFn = isRecord(rawAllowed["function"]) ? rawAllowed["function"] : rawAllowed;
|
|
146
|
-
const name = allowedFn["name"];
|
|
147
|
-
const replacement = typeof name === "string" ? replacements.get(name) : undefined;
|
|
148
|
-
if (replacement !== undefined) allowedFn["name"] = replacement;
|
|
149
|
-
}
|
|
150
|
-
return;
|
|
151
|
-
}
|
|
152
|
-
const choiceFn = isRecord(choice["function"]) ? choice["function"] : undefined;
|
|
153
|
-
const choiceCustom = isRecord(choice["custom"]) ? choice["custom"] : undefined;
|
|
154
|
-
const choiceName = choiceFn?.["name"] ?? choiceCustom?.["name"] ?? choice["name"];
|
|
155
|
-
if (typeof choiceName !== "string") return;
|
|
156
|
-
const replacement = replacements.get(choiceName);
|
|
157
|
-
if (replacement === undefined) return;
|
|
158
|
-
if (choiceFn !== undefined) choice["function"] = { ...choiceFn, name: replacement };
|
|
159
|
-
else if (choiceCustom !== undefined) choice["custom"] = { ...choiceCustom, name: replacement };
|
|
160
|
-
else choice["name"] = replacement;
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
/**
|
|
165
|
-
* The upstream id `auto` resolves to, per realm.
|
|
166
|
-
*
|
|
167
|
-
* Both realms have a "let the backend choose" id, and the reference rewrites
|
|
168
|
-
* `auto` onto it before anything downstream keys on the name — so eligibility,
|
|
169
|
-
* cooldowns and the catalog all see one id. CN's is `default`; intl's is
|
|
170
|
-
* `default-model`.
|
|
171
|
-
*/
|
|
172
|
-
const BUDDY_AUTO_TARGET: Readonly<Record<"intl" | "cn", string>> = {
|
|
173
|
-
intl: "auto",
|
|
174
|
-
cn: "default",
|
|
175
|
-
};
|
|
176
|
-
|
|
177
|
-
/**
|
|
178
|
-
* Rewrites `auto` onto the realm's backend-selected id.
|
|
179
|
-
*
|
|
180
|
-
* Only a bare, exact `auto` is rewritten (after trimming): a provider-qualified
|
|
181
|
-
* name or anything carrying a prefix is a different request and is left alone.
|
|
182
|
-
*/
|
|
183
|
-
function resolveBuddyAutoModel(payload: Record<string, unknown>, realm: "intl" | "cn"): void {
|
|
184
|
-
const value = payload["model"];
|
|
185
|
-
if (typeof value !== "string") return;
|
|
186
|
-
if (value.trim().toLowerCase() !== "auto") return;
|
|
187
|
-
payload["model"] = BUDDY_AUTO_TARGET[realm];
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
/**
|
|
191
|
-
* Account-isolated prompt-cache key, `wb2a-<uid8>-<convHex>`.
|
|
192
|
-
*
|
|
193
|
-
* A client-supplied key is kept: the caller already knows which prefix it wants
|
|
194
|
-
* reused. Otherwise the key is derived here, and the uid segment is a hard
|
|
195
|
-
* isolation factor. The same conversation id on two pooled accounts must not
|
|
196
|
-
* produce the same key — upstream prefix-cache reuse across accounts both
|
|
197
|
-
* leaks one account's conversation into another's cache and bills the wrong
|
|
198
|
-
* account for the hit. An empty conversation still hashes the uid alone, so
|
|
199
|
-
* two accounts with no session still never collide.
|
|
200
|
-
*
|
|
201
|
-
* The generic router only forwards a client key. This stays in the buddy hook
|
|
202
|
-
* because the `wb2a-` shape and the uid isolation are a buddy-family fact.
|
|
203
|
-
*/
|
|
204
|
-
function injectBuddyPromptCacheKey(
|
|
205
|
-
payload: Record<string, unknown>,
|
|
206
|
-
request: CanonicalRequest | undefined,
|
|
207
|
-
context: ProviderDispatchContext | undefined,
|
|
208
|
-
): void {
|
|
209
|
-
const existing = payload["prompt_cache_key"];
|
|
210
|
-
if (typeof existing === "string" && existing.trim().length > 0) return;
|
|
211
|
-
const uid = buddyCacheUid(context);
|
|
212
|
-
const conversation = buddyCacheConversation(payload, request, context);
|
|
213
|
-
payload["prompt_cache_key"] = buildBuddyPromptCacheKey(uid, conversation);
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
/**
|
|
217
|
-
* The account uid from the access token, or `""` when the credential carries
|
|
218
|
-
* none. Empty still injects a key — the readable segment becomes `"-"` — which
|
|
219
|
-
* is the reference's own fallback for a credential with no identity.
|
|
220
|
-
*/
|
|
221
|
-
function buddyCacheUid(context: ProviderDispatchContext | undefined): string {
|
|
222
|
-
const secret = context?.credential?.secret;
|
|
223
|
-
if (secret === undefined || secret.byteLength === 0) return "";
|
|
224
|
-
const token = new TextDecoder().decode(secret).trim();
|
|
225
|
-
if (token.length === 0) return "";
|
|
226
|
-
return buddyAccountUid(token) ?? "";
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
/**
|
|
230
|
-
* Conversation hash source: the body's own id first, then the inbound session
|
|
231
|
-
* the gateway already resolved. A fabricated id is never invented — an empty
|
|
232
|
-
* source hashes the uid alone, so a new session does not reuse another
|
|
233
|
-
* session's prefix while accounts still never collide.
|
|
234
|
-
*/
|
|
235
|
-
function buddyCacheConversation(
|
|
236
|
-
payload: Record<string, unknown>,
|
|
237
|
-
request: CanonicalRequest | undefined,
|
|
238
|
-
context: ProviderDispatchContext | undefined,
|
|
239
|
-
): string {
|
|
240
|
-
for (const field of ["conversation_id", "conversationId"] as const) {
|
|
241
|
-
const value = payload[field];
|
|
242
|
-
if (typeof value === "string" && value.trim().length > 0) return value.trim();
|
|
243
|
-
}
|
|
244
|
-
const fromRequest = request?.conversation?.conversation_id;
|
|
245
|
-
if (typeof fromRequest === "string" && fromRequest.trim().length > 0) return fromRequest.trim();
|
|
246
|
-
const header = context?.request_headers?.["x-conversation-id"]?.trim();
|
|
247
|
-
return header && header.length > 0 ? header : "";
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
/**
|
|
251
|
-
* `wb2a-<uid8>-<sha256(uid|conversation) hex[:32]>`.
|
|
252
|
-
*
|
|
253
|
-
* `uid8` is the isolation segment a human can read; the hash covers the full
|
|
254
|
-
* uid, so two accounts sharing a prefix still never collide. Stable for the
|
|
255
|
-
* same account and conversation, different for any other pair.
|
|
256
|
-
*/
|
|
257
|
-
export function buildBuddyPromptCacheKey(uid: string, conversation: string): string {
|
|
258
|
-
const uid8 = uid.length > 8 ? uid.slice(0, 8) : uid.length > 0 ? uid : "-";
|
|
259
|
-
const digest = createHash("sha256").update(`${uid}|${conversation}`).digest("hex").slice(0, 32);
|
|
260
|
-
return `wb2a-${uid8}-${digest}`;
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
/**
|
|
264
|
-
* Rewrites the
|
|
265
|
-
*
|
|
266
|
-
* The upstream blocks any request whose body contains that exact digit run —
|
|
267
|
-
*
|
|
268
|
-
*
|
|
269
|
-
*
|
|
270
|
-
*
|
|
271
|
-
*
|
|
272
|
-
*
|
|
273
|
-
*
|
|
274
|
-
*
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
rewriteBlockedCodeField(message, "
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
*
|
|
314
|
-
*
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
//
|
|
325
|
-
//
|
|
326
|
-
//
|
|
327
|
-
// sent `
|
|
328
|
-
//
|
|
329
|
-
//
|
|
330
|
-
//
|
|
331
|
-
//
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
payload["
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
delete payload["
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
//
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
//
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
delete payload["
|
|
379
|
-
delete payload["
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
const
|
|
385
|
-
|
|
386
|
-
if (
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
*
|
|
395
|
-
*
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
let
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
const
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
const
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
out.push(...
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
messages.
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
*
|
|
448
|
-
*
|
|
449
|
-
*
|
|
450
|
-
*
|
|
451
|
-
*
|
|
452
|
-
*
|
|
453
|
-
*
|
|
454
|
-
*
|
|
455
|
-
*
|
|
456
|
-
*
|
|
457
|
-
*
|
|
458
|
-
*
|
|
459
|
-
*
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
const
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
const
|
|
489
|
-
const
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
*
|
|
497
|
-
*
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
const
|
|
502
|
-
const
|
|
503
|
-
|
|
504
|
-
if (m["role"] === "
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
messages.
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
*
|
|
564
|
-
*
|
|
565
|
-
*
|
|
566
|
-
*
|
|
567
|
-
*
|
|
568
|
-
*
|
|
569
|
-
*
|
|
570
|
-
*
|
|
571
|
-
*
|
|
572
|
-
*
|
|
573
|
-
*
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
const
|
|
578
|
-
const
|
|
579
|
-
const
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
const
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
.
|
|
597
|
-
|
|
598
|
-
const
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
payload
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
const
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
*
|
|
622
|
-
*
|
|
623
|
-
*
|
|
624
|
-
*
|
|
625
|
-
*
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
):
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
*
|
|
644
|
-
*
|
|
645
|
-
*
|
|
646
|
-
*
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
//
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
const
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
if (
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
const
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
kept
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
*
|
|
740
|
-
*
|
|
741
|
-
*
|
|
742
|
-
*
|
|
743
|
-
*
|
|
744
|
-
*
|
|
745
|
-
*
|
|
746
|
-
*
|
|
747
|
-
*
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
const
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
...previous,
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
messages.
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
*
|
|
769
|
-
*
|
|
770
|
-
*
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
const
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
if (
|
|
787
|
-
else
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
const
|
|
792
|
-
const
|
|
793
|
-
const
|
|
794
|
-
const
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
...(Array.isArray(
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
if (
|
|
803
|
-
else
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
*
|
|
815
|
-
*
|
|
816
|
-
*
|
|
817
|
-
*
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
if (
|
|
823
|
-
if (
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
if (
|
|
829
|
-
|
|
830
|
-
//
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
messages.
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
*
|
|
841
|
-
*
|
|
842
|
-
*
|
|
843
|
-
*
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
)
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
*
|
|
861
|
-
*
|
|
862
|
-
*
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
)
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
*
|
|
876
|
-
*
|
|
877
|
-
*
|
|
878
|
-
*
|
|
879
|
-
*
|
|
880
|
-
*
|
|
881
|
-
*
|
|
882
|
-
*
|
|
883
|
-
*
|
|
884
|
-
*
|
|
885
|
-
*
|
|
886
|
-
*
|
|
887
|
-
*
|
|
888
|
-
*
|
|
889
|
-
*
|
|
890
|
-
*
|
|
891
|
-
*
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
messages.
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Chat-wire payload and message normalization shared by the Tencent "buddy"
|
|
3
|
+
* family providers — CodeBuddy International (`cb`), CodeBuddy CN (`cbcn`),
|
|
4
|
+
* and WorkBuddy. All three speak the same upstream contract for this part of
|
|
5
|
+
* the payload, so the rules live here once instead of being copied per
|
|
6
|
+
* variant:
|
|
7
|
+
*
|
|
8
|
+
* - streaming is mandatory (`stream=true`);
|
|
9
|
+
* - `reasoning_summary` is opted in only when a `reasoning_effort` is
|
|
10
|
+
* requested;
|
|
11
|
+
* - the agent-namespace fields are never forwarded;
|
|
12
|
+
* - caller `system`/`developer` turns are replaced by the variant's fixed
|
|
13
|
+
* leading system prompt, and bare string user content becomes a typed text
|
|
14
|
+
* block; and
|
|
15
|
+
* - consecutive same-role `user` turns must be merged before dispatch.
|
|
16
|
+
*
|
|
17
|
+
* Provider-specific identity headers and catalog tables stay in each variant's
|
|
18
|
+
* own `*-shared.ts`.
|
|
19
|
+
*/
|
|
20
|
+
import { createHash } from "node:crypto";
|
|
21
|
+
import { completeRequiredSchema } from "../../../protocol/codecs";
|
|
22
|
+
import type { CanonicalRequest } from "../../../protocol/canonical-model";
|
|
23
|
+
import { isRecord } from "../../../protocol/codecs";
|
|
24
|
+
import { applyDeepSeekReasoning, applyOpenAIReasoning, backfillDeepSeekReasoningContent, clampedEffortOutputFloor } from "../../provider-reasoning";
|
|
25
|
+
import { BUDDY_CN_EFFORTS, BUDDY_INTL_EFFORTS, BUDDY_SHARED_RAW } from "./buddy-catalog-shared";
|
|
26
|
+
// Imported for the CN roster only: a CN-only id has no row in the shared
|
|
27
|
+
// table, and without one `raiseOutputForRaisedEffort` silently no-ops, so a
|
|
28
|
+
// raised effort still answers inside a budget sized for the lower one. It is
|
|
29
|
+
// read inside the function below — never at module init — so the cycle with
|
|
30
|
+
// cn-provider (which imports this module) resolves before any call.
|
|
31
|
+
import { CODEBUDDY_CN_RAW } from "./codebuddy/cn-provider";
|
|
32
|
+
import { buddyAccountUid } from "./buddy-oauth-shared";
|
|
33
|
+
import type { ProviderDispatchContext } from "../../provider-contracts";
|
|
34
|
+
|
|
35
|
+
const BUDDY_TOOL_NAME_PATTERN = /^[a-zA-Z0-9_-]+$/;
|
|
36
|
+
const INVALID_BUDDY_TOOL_NAME_CHARS = /[^a-zA-Z0-9_-]/g;
|
|
37
|
+
const MAX_BUDDY_TOOL_NAME_LENGTH = 64;
|
|
38
|
+
|
|
39
|
+
function uniqueToolName(base: string, used: Set<string>): string {
|
|
40
|
+
const clipped = (base || "tool").slice(0, MAX_BUDDY_TOOL_NAME_LENGTH);
|
|
41
|
+
if (!used.has(clipped)) return clipped;
|
|
42
|
+
for (let suffix = 2; ; suffix += 1) {
|
|
43
|
+
const suffixText = `_${suffix}`;
|
|
44
|
+
const candidate = `${clipped.slice(0, MAX_BUDDY_TOOL_NAME_LENGTH - suffixText.length)}${suffixText}`;
|
|
45
|
+
if (!used.has(candidate)) return candidate;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function normalizedToolName(value: string, index: number, used: Set<string>): string {
|
|
50
|
+
const base =
|
|
51
|
+
BUDDY_TOOL_NAME_PATTERN.test(value) && value.length <= MAX_BUDDY_TOOL_NAME_LENGTH
|
|
52
|
+
? value
|
|
53
|
+
: value.replace(INVALID_BUDDY_TOOL_NAME_CHARS, "");
|
|
54
|
+
return uniqueToolName(base || `tool_${index + 1}`, used);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Buddy's Chat Completions wire has no `web_search_preview` hosted tool; its
|
|
59
|
+
* search is an ordinary function named `web_search` with a `{query}` schema.
|
|
60
|
+
* Rewriting the hosted declaration here lets the caller's own tool loop
|
|
61
|
+
* (Model Lab, or any client that executes its functions) run the query at
|
|
62
|
+
* `POST /v1/search`. The gateway never executes it: an unexecuted call is
|
|
63
|
+
* returned to the caller like any other function call.
|
|
64
|
+
*/
|
|
65
|
+
function buddyHostedSearchFunction(rawTool: Record<string, unknown>): Record<string, unknown> | undefined {
|
|
66
|
+
const type = rawTool["type"];
|
|
67
|
+
if (type !== "web_search" && type !== "web_search_preview") return undefined;
|
|
68
|
+
return {
|
|
69
|
+
type: "function",
|
|
70
|
+
function: {
|
|
71
|
+
name: "web_search",
|
|
72
|
+
description: "Search the web for current information.",
|
|
73
|
+
parameters: {
|
|
74
|
+
type: "object",
|
|
75
|
+
properties: {
|
|
76
|
+
query: { type: "string", description: "The search query." },
|
|
77
|
+
},
|
|
78
|
+
required: ["query"],
|
|
79
|
+
additionalProperties: false,
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
export function normalizeBuddyToolNames(payload: Record<string, unknown>): void {
|
|
87
|
+
if (!Array.isArray(payload["tools"])) return;
|
|
88
|
+
const used = new Set<string>();
|
|
89
|
+
const replacements = new Map<string, string>();
|
|
90
|
+
payload["tools"] = payload["tools"].map((rawTool, index) => {
|
|
91
|
+
if (!isRecord(rawTool)) return rawTool;
|
|
92
|
+
const hostedSearch = buddyHostedSearchFunction(rawTool);
|
|
93
|
+
if (hostedSearch !== undefined) {
|
|
94
|
+
used.add("web_search");
|
|
95
|
+
return hostedSearch;
|
|
96
|
+
}
|
|
97
|
+
const fn = isRecord(rawTool["function"]) ? rawTool["function"] : undefined;
|
|
98
|
+
const custom = isRecord(rawTool["custom"]) ? rawTool["custom"] : undefined;
|
|
99
|
+
const sourceName = fn?.["name"] ?? custom?.["name"] ?? rawTool["name"];
|
|
100
|
+
if (typeof sourceName !== "string") return rawTool;
|
|
101
|
+
const name = normalizedToolName(sourceName, index, used);
|
|
102
|
+
used.add(name);
|
|
103
|
+
if (!replacements.has(sourceName)) replacements.set(sourceName, name);
|
|
104
|
+
if (fn !== undefined)
|
|
105
|
+
return {
|
|
106
|
+
...rawTool,
|
|
107
|
+
function: {
|
|
108
|
+
...fn,
|
|
109
|
+
name,
|
|
110
|
+
...(fn["parameters"] === undefined ? {} : { parameters: completeRequiredSchema(fn["parameters"]) }),
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
if (custom !== undefined)
|
|
114
|
+
return {
|
|
115
|
+
...rawTool,
|
|
116
|
+
custom: {
|
|
117
|
+
...custom,
|
|
118
|
+
name,
|
|
119
|
+
...(custom["format"] === undefined ? {} : { format: completeRequiredSchema(custom["format"]) }),
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
return {
|
|
123
|
+
...rawTool,
|
|
124
|
+
name,
|
|
125
|
+
...(rawTool["parameters"] === undefined ? {} : { parameters: completeRequiredSchema(rawTool["parameters"]) }),
|
|
126
|
+
};
|
|
127
|
+
});
|
|
128
|
+
const messages = payload["messages"];
|
|
129
|
+
if (Array.isArray(messages)) {
|
|
130
|
+
for (const message of messages) {
|
|
131
|
+
if (!isRecord(message) || !Array.isArray(message["tool_calls"])) continue;
|
|
132
|
+
for (const call of message["tool_calls"]) {
|
|
133
|
+
if (!isRecord(call) || !isRecord(call["function"])) continue;
|
|
134
|
+
const name = call["function"]["name"];
|
|
135
|
+
const replacement = typeof name === "string" ? replacements.get(name) : undefined;
|
|
136
|
+
if (replacement !== undefined) call["function"]["name"] = replacement;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
const choice = payload["tool_choice"];
|
|
141
|
+
if (!isRecord(choice)) return;
|
|
142
|
+
if (choice["type"] === "allowed_tools" && Array.isArray(choice["tools"])) {
|
|
143
|
+
for (const rawAllowed of choice["tools"]) {
|
|
144
|
+
if (!isRecord(rawAllowed)) continue;
|
|
145
|
+
const allowedFn = isRecord(rawAllowed["function"]) ? rawAllowed["function"] : rawAllowed;
|
|
146
|
+
const name = allowedFn["name"];
|
|
147
|
+
const replacement = typeof name === "string" ? replacements.get(name) : undefined;
|
|
148
|
+
if (replacement !== undefined) allowedFn["name"] = replacement;
|
|
149
|
+
}
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
const choiceFn = isRecord(choice["function"]) ? choice["function"] : undefined;
|
|
153
|
+
const choiceCustom = isRecord(choice["custom"]) ? choice["custom"] : undefined;
|
|
154
|
+
const choiceName = choiceFn?.["name"] ?? choiceCustom?.["name"] ?? choice["name"];
|
|
155
|
+
if (typeof choiceName !== "string") return;
|
|
156
|
+
const replacement = replacements.get(choiceName);
|
|
157
|
+
if (replacement === undefined) return;
|
|
158
|
+
if (choiceFn !== undefined) choice["function"] = { ...choiceFn, name: replacement };
|
|
159
|
+
else if (choiceCustom !== undefined) choice["custom"] = { ...choiceCustom, name: replacement };
|
|
160
|
+
else choice["name"] = replacement;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* The upstream id `auto` resolves to, per realm.
|
|
166
|
+
*
|
|
167
|
+
* Both realms have a "let the backend choose" id, and the reference rewrites
|
|
168
|
+
* `auto` onto it before anything downstream keys on the name — so eligibility,
|
|
169
|
+
* cooldowns and the catalog all see one id. CN's is `default`; intl's is
|
|
170
|
+
* `default-model`.
|
|
171
|
+
*/
|
|
172
|
+
const BUDDY_AUTO_TARGET: Readonly<Record<"intl" | "cn", string>> = {
|
|
173
|
+
intl: "auto",
|
|
174
|
+
cn: "default",
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Rewrites `auto` onto the realm's backend-selected id.
|
|
179
|
+
*
|
|
180
|
+
* Only a bare, exact `auto` is rewritten (after trimming): a provider-qualified
|
|
181
|
+
* name or anything carrying a prefix is a different request and is left alone.
|
|
182
|
+
*/
|
|
183
|
+
function resolveBuddyAutoModel(payload: Record<string, unknown>, realm: "intl" | "cn"): void {
|
|
184
|
+
const value = payload["model"];
|
|
185
|
+
if (typeof value !== "string") return;
|
|
186
|
+
if (value.trim().toLowerCase() !== "auto") return;
|
|
187
|
+
payload["model"] = BUDDY_AUTO_TARGET[realm];
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Account-isolated prompt-cache key, `wb2a-<uid8>-<convHex>`.
|
|
192
|
+
*
|
|
193
|
+
* A client-supplied key is kept: the caller already knows which prefix it wants
|
|
194
|
+
* reused. Otherwise the key is derived here, and the uid segment is a hard
|
|
195
|
+
* isolation factor. The same conversation id on two pooled accounts must not
|
|
196
|
+
* produce the same key — upstream prefix-cache reuse across accounts both
|
|
197
|
+
* leaks one account's conversation into another's cache and bills the wrong
|
|
198
|
+
* account for the hit. An empty conversation still hashes the uid alone, so
|
|
199
|
+
* two accounts with no session still never collide.
|
|
200
|
+
*
|
|
201
|
+
* The generic router only forwards a client key. This stays in the buddy hook
|
|
202
|
+
* because the `wb2a-` shape and the uid isolation are a buddy-family fact.
|
|
203
|
+
*/
|
|
204
|
+
function injectBuddyPromptCacheKey(
|
|
205
|
+
payload: Record<string, unknown>,
|
|
206
|
+
request: CanonicalRequest | undefined,
|
|
207
|
+
context: ProviderDispatchContext | undefined,
|
|
208
|
+
): void {
|
|
209
|
+
const existing = payload["prompt_cache_key"];
|
|
210
|
+
if (typeof existing === "string" && existing.trim().length > 0) return;
|
|
211
|
+
const uid = buddyCacheUid(context);
|
|
212
|
+
const conversation = buddyCacheConversation(payload, request, context);
|
|
213
|
+
payload["prompt_cache_key"] = buildBuddyPromptCacheKey(uid, conversation);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* The account uid from the access token, or `""` when the credential carries
|
|
218
|
+
* none. Empty still injects a key — the readable segment becomes `"-"` — which
|
|
219
|
+
* is the reference's own fallback for a credential with no identity.
|
|
220
|
+
*/
|
|
221
|
+
function buddyCacheUid(context: ProviderDispatchContext | undefined): string {
|
|
222
|
+
const secret = context?.credential?.secret;
|
|
223
|
+
if (secret === undefined || secret.byteLength === 0) return "";
|
|
224
|
+
const token = new TextDecoder().decode(secret).trim();
|
|
225
|
+
if (token.length === 0) return "";
|
|
226
|
+
return buddyAccountUid(token) ?? "";
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Conversation hash source: the body's own id first, then the inbound session
|
|
231
|
+
* the gateway already resolved. A fabricated id is never invented — an empty
|
|
232
|
+
* source hashes the uid alone, so a new session does not reuse another
|
|
233
|
+
* session's prefix while accounts still never collide.
|
|
234
|
+
*/
|
|
235
|
+
function buddyCacheConversation(
|
|
236
|
+
payload: Record<string, unknown>,
|
|
237
|
+
request: CanonicalRequest | undefined,
|
|
238
|
+
context: ProviderDispatchContext | undefined,
|
|
239
|
+
): string {
|
|
240
|
+
for (const field of ["conversation_id", "conversationId"] as const) {
|
|
241
|
+
const value = payload[field];
|
|
242
|
+
if (typeof value === "string" && value.trim().length > 0) return value.trim();
|
|
243
|
+
}
|
|
244
|
+
const fromRequest = request?.conversation?.conversation_id;
|
|
245
|
+
if (typeof fromRequest === "string" && fromRequest.trim().length > 0) return fromRequest.trim();
|
|
246
|
+
const header = context?.request_headers?.["x-conversation-id"]?.trim();
|
|
247
|
+
return header && header.length > 0 ? header : "";
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* `wb2a-<uid8>-<sha256(uid|conversation) hex[:32]>`.
|
|
252
|
+
*
|
|
253
|
+
* `uid8` is the isolation segment a human can read; the hash covers the full
|
|
254
|
+
* uid, so two accounts sharing a prefix still never collide. Stable for the
|
|
255
|
+
* same account and conversation, different for any other pair.
|
|
256
|
+
*/
|
|
257
|
+
export function buildBuddyPromptCacheKey(uid: string, conversation: string): string {
|
|
258
|
+
const uid8 = uid.length > 8 ? uid.slice(0, 8) : uid.length > 0 ? uid : "-";
|
|
259
|
+
const digest = createHash("sha256").update(`${uid}|${conversation}`).digest("hex").slice(0, 32);
|
|
260
|
+
return `wb2a-${uid8}-${digest}`;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Rewrites the buddy channel-block code in outbound message text.
|
|
265
|
+
*
|
|
266
|
+
* The upstream blocks any request whose body contains that exact digit run —
|
|
267
|
+
* the check is a verbatim match on the code itself, not a semantic one. A client
|
|
268
|
+
* that quotes the error (a retry, a pasted log, a tool result) is refused on
|
|
269
|
+
* the next turn merely for mentioning it. Inserting a hyphen keeps the number
|
|
270
|
+
* readable and breaks the match.
|
|
271
|
+
*
|
|
272
|
+
* Only text is touched. Image parts and tool-call argument structure are not
|
|
273
|
+
* rewritten, because a digit run inside a base64 blob or a JSON key is not
|
|
274
|
+
* what the check reads and rewriting it would corrupt the payload.
|
|
275
|
+
*/
|
|
276
|
+
function rewriteBuddyBlockedCode(payload: Record<string, unknown>): void {
|
|
277
|
+
const messages = payload["messages"];
|
|
278
|
+
if (!Array.isArray(messages)) return;
|
|
279
|
+
for (const message of messages) {
|
|
280
|
+
if (!isRecord(message)) continue;
|
|
281
|
+
rewriteBlockedCodeField(message, "content");
|
|
282
|
+
rewriteBlockedCodeField(message, "reasoning_content");
|
|
283
|
+
const calls = message["tool_calls"];
|
|
284
|
+
if (!Array.isArray(calls)) continue;
|
|
285
|
+
for (const call of calls) {
|
|
286
|
+
if (!isRecord(call)) continue;
|
|
287
|
+
const fn = call["function"];
|
|
288
|
+
if (isRecord(fn) && typeof fn["arguments"] === "string") {
|
|
289
|
+
fn["arguments"] = fn["arguments"].split("11128").join("11-128");
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function rewriteBlockedCodeField(message: Record<string, unknown>, field: string): void {
|
|
296
|
+
const value = message[field];
|
|
297
|
+
if (typeof value === "string") {
|
|
298
|
+
if (value.includes("11128")) message[field] = value.split("11128").join("11-128");
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
if (!Array.isArray(value)) return;
|
|
302
|
+
for (const part of value) {
|
|
303
|
+
if (!isRecord(part)) continue;
|
|
304
|
+
const text = part["text"];
|
|
305
|
+
if (typeof text === "string" && text.includes("11128")) {
|
|
306
|
+
part["text"] = text.split("11128").join("11-128");
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Payload prefix shared by the buddy pre-payload hooks: force `stream=true`,
|
|
313
|
+
* apply the provider's DeepSeek thinking contract, opt `reasoning_summary` in
|
|
314
|
+
* only when an effort is requested, and drop agent-namespace fields.
|
|
315
|
+
*/
|
|
316
|
+
export function buddyPrePayloadCommon(
|
|
317
|
+
payload: Record<string, unknown>,
|
|
318
|
+
request?: CanonicalRequest,
|
|
319
|
+
realm: "intl" | "cn" = "intl",
|
|
320
|
+
context?: ProviderDispatchContext,
|
|
321
|
+
): void {
|
|
322
|
+
injectBuddyPromptCacheKey(payload, request, context);
|
|
323
|
+
// `auto` and `default-model` are the same upstream model under two names, and
|
|
324
|
+
// the reference resolves one to the other everywhere — catalog, eligibility,
|
|
325
|
+
// cooldown keys. Treating them as two independent rows meant a client that
|
|
326
|
+
// sent `auto` got a different (account, model) cooldown key from a client that
|
|
327
|
+
// sent `default-model` for the same backend model, so one could be cooling
|
|
328
|
+
// while the other dialed it.
|
|
329
|
+
//
|
|
330
|
+
// Resolved here rather than in the generic router because it is a buddy-family
|
|
331
|
+
// fact: `auto` is a real model name on other providers.
|
|
332
|
+
resolveBuddyAutoModel(payload, realm);
|
|
333
|
+
// Canonical upstream renames: max_completion_tokens is OpenAI compat alias, buddy only reads max_tokens
|
|
334
|
+
if (payload["max_tokens"] === undefined && payload["max_completion_tokens"] !== undefined) {
|
|
335
|
+
const v = payload["max_completion_tokens"];
|
|
336
|
+
if (typeof v === "number" && Number.isFinite(v) && v > 0) payload["max_tokens"] = v;
|
|
337
|
+
delete payload["max_completion_tokens"];
|
|
338
|
+
} else if (payload["max_completion_tokens"] !== undefined) {
|
|
339
|
+
delete payload["max_completion_tokens"];
|
|
340
|
+
}
|
|
341
|
+
// tool_choice: buddy Go struct is string — object form 400s (code 11101). Normalize to wire string.
|
|
342
|
+
normalizeBuddyToolChoice(payload);
|
|
343
|
+
// image_url string → object (OpenAI allows string, buddy requires {url})
|
|
344
|
+
normalizeBuddyImageUrl(payload);
|
|
345
|
+
// developer role → system (buddy whitelist rejects developer, 11128)
|
|
346
|
+
normalizeBuddyRoles(payload);
|
|
347
|
+
applyOpenAIReasoning(payload, request);
|
|
348
|
+
applyDeepSeekReasoning(payload);
|
|
349
|
+
// sol family defaults to high reasoning like reference thinking.go defaultDeepSeekEffort — sol without effort gets 500/invoke error otherwise
|
|
350
|
+
injectSolReasoningDefault(payload);
|
|
351
|
+
payload["stream"] = true;
|
|
352
|
+
const effort = payload["reasoning_effort"];
|
|
353
|
+
if (effort === "none" || effort === "off") {
|
|
354
|
+
delete payload["reasoning_effort"];
|
|
355
|
+
delete payload["reasoning_summary"];
|
|
356
|
+
} else if (typeof effort === "string" && effort.length > 0) {
|
|
357
|
+
payload["reasoning_summary"] = "auto";
|
|
358
|
+
} else {
|
|
359
|
+
delete payload["reasoning_summary"];
|
|
360
|
+
}
|
|
361
|
+
backfillDeepSeekReasoningContent(payload);
|
|
362
|
+
clampBuddyReasoningEffort(payload, realm);
|
|
363
|
+
const messages = payload["messages"];
|
|
364
|
+
if (Array.isArray(messages)) {
|
|
365
|
+
const arr = messages as Array<Record<string, unknown>>;
|
|
366
|
+
// Nameless calls 400 before anything else can be judged, and their results
|
|
367
|
+
// must be orphans before the repack below tries to reorder them.
|
|
368
|
+
dropBuddyEmptyNameToolCalls(arr);
|
|
369
|
+
// Buddy wire: repack tool result blocks before cleanup (image_resize_notice splits tool results → 11148)
|
|
370
|
+
repackBuddyToolResultBlocks(arr);
|
|
371
|
+
cleanupBuddyOrphanToolPairs(arr);
|
|
372
|
+
coalesceConsecutiveAssistantMessages(arr);
|
|
373
|
+
// After every merge: a quoted `11128` in any surviving text blocks the
|
|
374
|
+
// whole request, and the merges above are what produce the final text.
|
|
375
|
+
rewriteBuddyBlockedCode(payload);
|
|
376
|
+
}
|
|
377
|
+
delete payload["agent"];
|
|
378
|
+
delete payload["agent_mode"];
|
|
379
|
+
delete payload["agent_prompt"];
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function injectSolReasoningDefault(payload: Record<string, unknown>): void {
|
|
383
|
+
const model = typeof payload["model"] === "string" ? (payload["model"] as string) : "";
|
|
384
|
+
const isSol = model.toLowerCase().includes("sol");
|
|
385
|
+
if (!isSol) return;
|
|
386
|
+
if (payload["reasoning_effort"] !== undefined) return;
|
|
387
|
+
const thinking = payload["thinking"] as Record<string, unknown> | undefined;
|
|
388
|
+
if (thinking && typeof thinking["type"] === "string" && (thinking["type"] as string) !== "") return;
|
|
389
|
+
payload["reasoning_effort"] = "high";
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Repack tool result blocks: move non-tool messages interleaved between
|
|
394
|
+
* assistant tool_calls and its tool results to after the block.
|
|
395
|
+
* Port of workbuddy2api repackToolResultBlocks — fixes 11148 tool_call_sequence_broken.
|
|
396
|
+
*/
|
|
397
|
+
function repackBuddyToolResultBlocks(messages: Array<Record<string, unknown>>): void {
|
|
398
|
+
if (messages.length < 3) return;
|
|
399
|
+
const out: Array<Record<string, unknown>> = [];
|
|
400
|
+
let changed = false;
|
|
401
|
+
let i = 0;
|
|
402
|
+
while (i < messages.length) {
|
|
403
|
+
const m = messages[i] as Record<string, unknown>;
|
|
404
|
+
if (!m || m["role"] !== "assistant" || !Array.isArray(m["tool_calls"]) || (m["tool_calls"] as unknown[]).length === 0) {
|
|
405
|
+
out.push(m);
|
|
406
|
+
i += 1;
|
|
407
|
+
continue;
|
|
408
|
+
}
|
|
409
|
+
const want = new Set<string>();
|
|
410
|
+
for (const tc of m["tool_calls"] as unknown[]) {
|
|
411
|
+
const id = (tc as Record<string, unknown>)?.["id"];
|
|
412
|
+
if (typeof id === "string" && id.length > 0) want.add(id);
|
|
413
|
+
}
|
|
414
|
+
out.push(m);
|
|
415
|
+
i += 1;
|
|
416
|
+
const results: Array<Record<string, unknown>> = [];
|
|
417
|
+
const between: Array<Record<string, unknown>> = [];
|
|
418
|
+
let sawNonTool = false;
|
|
419
|
+
while (i < messages.length) {
|
|
420
|
+
const mm = messages[i] as Record<string, unknown>;
|
|
421
|
+
if (!mm || typeof mm !== "object") break;
|
|
422
|
+
const role = mm["role"];
|
|
423
|
+
if (role === "tool") {
|
|
424
|
+
const id = mm["tool_call_id"];
|
|
425
|
+
if (typeof id !== "string" || !want.has(id)) break;
|
|
426
|
+
results.push(mm);
|
|
427
|
+
if (sawNonTool) changed = true;
|
|
428
|
+
i += 1;
|
|
429
|
+
continue;
|
|
430
|
+
}
|
|
431
|
+
if (results.length === 0) break;
|
|
432
|
+
between.push(mm);
|
|
433
|
+
sawNonTool = true;
|
|
434
|
+
i += 1;
|
|
435
|
+
}
|
|
436
|
+
out.push(...results);
|
|
437
|
+
out.push(...between);
|
|
438
|
+
if (between.length > 0) changed = true;
|
|
439
|
+
}
|
|
440
|
+
if (!changed) return;
|
|
441
|
+
messages.length = 0;
|
|
442
|
+
messages.push(...out);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Drop assistant tool_calls whose function name is empty.
|
|
447
|
+
*
|
|
448
|
+
* A streamed call can lose its name and land in the client transcript as
|
|
449
|
+
* `function.name: ""` (see `tool-identity.ts`: the client then replays it and
|
|
450
|
+
* every later request is rejected with "`name` must be non-empty"). Buddy
|
|
451
|
+
* reports this as `11133` with `param: ""`, naming no field, so the transcript
|
|
452
|
+
* is the only place it can be caught.
|
|
453
|
+
*
|
|
454
|
+
* The name is only ever absent, never wrong: replaying the same body with the
|
|
455
|
+
* name set to an identifier that is not even in `tools` returns 200, so the
|
|
456
|
+
* upstream rejects the missing value rather than an unknown one. Dropping the
|
|
457
|
+
* call is therefore not lossy — a nameless call cannot be dispatched anyway.
|
|
458
|
+
* The matching tool result becomes an orphan and
|
|
459
|
+
* `cleanupBuddyOrphanToolPairs` removes it in the same pass.
|
|
460
|
+
*/
|
|
461
|
+
function dropBuddyEmptyNameToolCalls(messages: Array<Record<string, unknown>>): void {
|
|
462
|
+
let changed = false;
|
|
463
|
+
for (const message of messages) {
|
|
464
|
+
if (message["role"] !== "assistant" || !Array.isArray(message["tool_calls"])) continue;
|
|
465
|
+
const calls = message["tool_calls"] as unknown[];
|
|
466
|
+
const kept = calls.filter((call) => {
|
|
467
|
+
if (call === null || typeof call !== "object") return true;
|
|
468
|
+
const fn = (call as Record<string, unknown>)["function"];
|
|
469
|
+
if (fn === null || typeof fn !== "object") return true;
|
|
470
|
+
const name = (fn as Record<string, unknown>)["name"];
|
|
471
|
+
return typeof name === "string" && name.trim().length > 0;
|
|
472
|
+
});
|
|
473
|
+
if (kept.length === calls.length) continue;
|
|
474
|
+
const { tool_calls: _dropped, ...rest } = message as Record<string, unknown> & { tool_calls: unknown };
|
|
475
|
+
if (kept.length === 0) {
|
|
476
|
+
messages[messages.indexOf(message)] = rest;
|
|
477
|
+
} else {
|
|
478
|
+
(message as Record<string, unknown>)["tool_calls"] = kept;
|
|
479
|
+
}
|
|
480
|
+
changed = true;
|
|
481
|
+
}
|
|
482
|
+
if (!changed) return;
|
|
483
|
+
// A message left with neither content nor calls carries nothing upstream.
|
|
484
|
+
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
|
485
|
+
const message = messages[i];
|
|
486
|
+
if (message === undefined || message["role"] !== "assistant") continue;
|
|
487
|
+
const hasCalls = Array.isArray(message["tool_calls"]) && (message["tool_calls"] as unknown[]).length > 0;
|
|
488
|
+
const content = message["content"];
|
|
489
|
+
const hasContent = (typeof content === "string" && content.trim().length > 0) || Array.isArray(content);
|
|
490
|
+
if (!hasCalls && !hasContent) messages.splice(i, 1);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* Remove orphan tool_calls / tool results that lack a pair — port of
|
|
496
|
+
* workbuddy2api cleanupOrphanToolCalls. Lets a broken history self-heal
|
|
497
|
+
* instead of 400ing every subsequent request.
|
|
498
|
+
*/
|
|
499
|
+
function cleanupBuddyOrphanToolPairs(messages: Array<Record<string, unknown>>): void {
|
|
500
|
+
const toolResultIds = new Set<string>();
|
|
501
|
+
const toolCallIds = new Set<string>();
|
|
502
|
+
for (const m of messages) {
|
|
503
|
+
if (m["role"] === "tool" && typeof m["tool_call_id"] === "string") toolResultIds.add(m["tool_call_id"] as string);
|
|
504
|
+
if (m["role"] === "assistant" && Array.isArray(m["tool_calls"])) {
|
|
505
|
+
for (const tc of m["tool_calls"] as unknown[]) {
|
|
506
|
+
const id = (tc as Record<string, unknown>)?.["id"];
|
|
507
|
+
if (typeof id === "string") toolCallIds.add(id);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
if (toolResultIds.size === 0 && toolCallIds.size === 0) return;
|
|
512
|
+
const out: Array<Record<string, unknown>> = [];
|
|
513
|
+
let changed = false;
|
|
514
|
+
for (const m of messages) {
|
|
515
|
+
if (m["role"] === "assistant" && Array.isArray(m["tool_calls"])) {
|
|
516
|
+
const kept = (m["tool_calls"] as unknown[]).filter((tc) => {
|
|
517
|
+
const id = (tc as Record<string, unknown>)?.["id"];
|
|
518
|
+
return typeof id === "string" && toolResultIds.has(id);
|
|
519
|
+
});
|
|
520
|
+
if (kept.length === 0 && toolCallIds.size > 0 && (m["tool_calls"] as unknown[]).length > 0) {
|
|
521
|
+
// Drop empty tool_calls key — keep message without it
|
|
522
|
+
const { tool_calls: _drop, ...rest } = m as Record<string, unknown> & { tool_calls: unknown };
|
|
523
|
+
if (Object.keys(kept).length === 0) changed = true;
|
|
524
|
+
out.push(rest as Record<string, unknown>);
|
|
525
|
+
if (kept.length !== (m["tool_calls"] as unknown[]).length) changed = true;
|
|
526
|
+
continue;
|
|
527
|
+
}
|
|
528
|
+
if (kept.length !== (m["tool_calls"] as unknown[]).length) {
|
|
529
|
+
changed = true;
|
|
530
|
+
out.push({ ...m, tool_calls: kept });
|
|
531
|
+
continue;
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
if (m["role"] === "tool") {
|
|
535
|
+
const id = m["tool_call_id"];
|
|
536
|
+
if (typeof id === "string" && !toolCallIds.has(id as string)) {
|
|
537
|
+
changed = true;
|
|
538
|
+
continue;
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
out.push(m);
|
|
542
|
+
}
|
|
543
|
+
if (!changed) return;
|
|
544
|
+
messages.length = 0;
|
|
545
|
+
messages.push(...out);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
const BUDDY_EFFORT_RANK: Readonly<Record<string, number>> = {
|
|
551
|
+
off: 0,
|
|
552
|
+
none: 0,
|
|
553
|
+
minimal: 1,
|
|
554
|
+
low: 2,
|
|
555
|
+
medium: 3,
|
|
556
|
+
high: 4,
|
|
557
|
+
xhigh: 5,
|
|
558
|
+
max: 6,
|
|
559
|
+
};
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* Clamps an effort the model's ladder does not serve to the nearest served
|
|
563
|
+
* level, and clamps a `max_tokens` above the catalog output limit down to it.
|
|
564
|
+
*
|
|
565
|
+
* Both are repaired here rather than refused. A client that asked for `max`
|
|
566
|
+
* on a model that serves only `high` wants an answer, and the gateway is the
|
|
567
|
+
* place that knows the ladder — refusing with 400 makes the client retry a
|
|
568
|
+
* request the gateway could have served. The output budget is lifted when the
|
|
569
|
+
* effort was raised, because a higher level spends part of the same budget on
|
|
570
|
+
* reasoning and a limit sized for the lower one truncates the answer.
|
|
571
|
+
*
|
|
572
|
+
* A model with no ladder is left alone. Absence means the levels have not
|
|
573
|
+
* been measured, and clamping it would invent a constraint.
|
|
574
|
+
*/
|
|
575
|
+
function clampBuddyReasoningEffort(payload: Record<string, unknown>, realm: "intl" | "cn"): void {
|
|
576
|
+
const model = typeof payload["model"] === "string" ? payload["model"].trim().toLowerCase() : "";
|
|
577
|
+
const table = realm === "cn" ? BUDDY_CN_EFFORTS : BUDDY_INTL_EFFORTS;
|
|
578
|
+
const supported = model.length > 0 ? table[model] : undefined;
|
|
579
|
+
const key =
|
|
580
|
+
"reasoning_effort" in payload
|
|
581
|
+
? "reasoning_effort"
|
|
582
|
+
: "reasoningEffort" in payload
|
|
583
|
+
? "reasoningEffort"
|
|
584
|
+
: undefined;
|
|
585
|
+
if (key !== undefined && supported !== undefined) {
|
|
586
|
+
const requested = payload[key];
|
|
587
|
+
if (typeof requested === "string") {
|
|
588
|
+
const normalized = requested.trim().toLowerCase();
|
|
589
|
+
const requestedRank = BUDDY_EFFORT_RANK[normalized];
|
|
590
|
+
if (
|
|
591
|
+
requestedRank !== undefined &&
|
|
592
|
+
!supported.some((effort) => effort.toLowerCase() === normalized)
|
|
593
|
+
) {
|
|
594
|
+
const ranked = supported
|
|
595
|
+
.map((effort) => ({ effort, rank: BUDDY_EFFORT_RANK[effort.toLowerCase()] }))
|
|
596
|
+
.filter((entry): entry is { effort: string; rank: number } => entry.rank !== undefined);
|
|
597
|
+
const below = ranked.filter((entry) => entry.rank <= requestedRank);
|
|
598
|
+
const chosen =
|
|
599
|
+
below.length > 0
|
|
600
|
+
? below.reduce((best, entry) => (entry.rank > best.rank ? entry : best))
|
|
601
|
+
: ranked.reduce((best, entry) => (entry.rank < best.rank ? entry : best));
|
|
602
|
+
payload[key] = chosen.effort;
|
|
603
|
+
raiseOutputForRaisedEffort(payload, normalized, chosen.effort.trim().toLowerCase());
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
const limit = model.length > 0 ? buddyModelOutputLimit(model) : undefined;
|
|
608
|
+
const maxTokens = payload["max_tokens"];
|
|
609
|
+
if (
|
|
610
|
+
limit !== undefined &&
|
|
611
|
+
typeof maxTokens === "number" &&
|
|
612
|
+
Number.isFinite(maxTokens) &&
|
|
613
|
+
maxTokens > limit
|
|
614
|
+
) {
|
|
615
|
+
payload["max_tokens"] = limit;
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* Lifts `max_tokens` when the ladder raised the caller's effort.
|
|
621
|
+
*
|
|
622
|
+
* A higher level spends part of the same output budget on reasoning, so a
|
|
623
|
+
* limit sized for the lower one can no longer hold the answer. The floor is
|
|
624
|
+
* half the model's catalog output limit, and a budget already above it is
|
|
625
|
+
* left alone.
|
|
626
|
+
*/
|
|
627
|
+
function raiseOutputForRaisedEffort(
|
|
628
|
+
payload: Record<string, unknown>,
|
|
629
|
+
requested: string,
|
|
630
|
+
applied: string,
|
|
631
|
+
): void {
|
|
632
|
+
const model = typeof payload["model"] === "string" ? payload["model"].trim().toLowerCase() : "";
|
|
633
|
+
if (model.length === 0) return;
|
|
634
|
+
const floor = clampedEffortOutputFloor(requested, applied, buddyModelOutputLimit(model));
|
|
635
|
+
if (floor === undefined) return;
|
|
636
|
+
const current = payload["max_tokens"];
|
|
637
|
+
if (typeof current === "number" && Number.isFinite(current) && current >= floor) return;
|
|
638
|
+
payload["max_tokens"] = floor;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/**
|
|
642
|
+
* The catalog output limit for one buddy model id, when a catalog names it.
|
|
643
|
+
*
|
|
644
|
+
* Both rosters are searched: CN-only ids (glm-5.1, hy4-preview, the CN
|
|
645
|
+
* DeepSeek rows) exist only in the CN table, and searching the shared one
|
|
646
|
+
* alone left every CN model without a limit.
|
|
647
|
+
*/
|
|
648
|
+
function buddyModelOutputLimit(model: string): number | undefined {
|
|
649
|
+
const row =
|
|
650
|
+
BUDDY_SHARED_RAW.find((entry) => entry[0]?.toLowerCase() === model) ??
|
|
651
|
+
CODEBUDDY_CN_RAW.find((entry) => entry[0]?.toLowerCase() === model);
|
|
652
|
+
const out = row?.[5];
|
|
653
|
+
return typeof out === "number" && Number.isFinite(out) && out > 0 ? out : undefined;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
function normalizeBuddyToolChoice(payload: Record<string, unknown>): void {
|
|
657
|
+
if (!("tool_choice" in payload)) return;
|
|
658
|
+
const tc = payload["tool_choice"];
|
|
659
|
+
if (typeof tc === "string") {
|
|
660
|
+
// `none` means "do not call this turn", not "forget the tool schemas".
|
|
661
|
+
// Deleting tools makes DeepSeek emit unparseable DSML text instead.
|
|
662
|
+
if (tc.trim().toLowerCase() === "none") payload["tool_choice"] = "none";
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
665
|
+
if (tc && typeof tc === "object" && !Array.isArray(tc)) {
|
|
666
|
+
const obj = tc as Record<string, unknown>;
|
|
667
|
+
const typ = typeof obj["type"] === "string" ? (obj["type"] as string).trim().toLowerCase() : "";
|
|
668
|
+
if (typ === "none") {
|
|
669
|
+
payload["tool_choice"] = "none";
|
|
670
|
+
} else if (typ === "auto" || typ === "required") {
|
|
671
|
+
payload["tool_choice"] = typ;
|
|
672
|
+
} else if (typ === "function") {
|
|
673
|
+
let name = "";
|
|
674
|
+
const fn = obj["function"];
|
|
675
|
+
if (fn && typeof fn === "object" && !Array.isArray(fn)) name = typeof (fn as Record<string, unknown>)["name"] === "string" ? ((fn as Record<string, unknown>)["name"] as string) : "";
|
|
676
|
+
if (!name && typeof obj["name"] === "string") name = obj["name"] as string;
|
|
677
|
+
name = name.trim();
|
|
678
|
+
payload["tool_choice"] = name.length > 0 ? name : "auto";
|
|
679
|
+
} else {
|
|
680
|
+
delete payload["tool_choice"];
|
|
681
|
+
}
|
|
682
|
+
return;
|
|
683
|
+
}
|
|
684
|
+
delete payload["tool_choice"];
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
/** Images kept in one outbound body. The upstream rejects a larger set. */
|
|
688
|
+
const BUDDY_MAX_IMAGES = 16;
|
|
689
|
+
const BUDDY_IMAGE_OMITTED = "[Image omitted by request image limit.]";
|
|
690
|
+
|
|
691
|
+
function normalizeBuddyImageUrl(payload: Record<string, unknown>): void {
|
|
692
|
+
const messages = payload["messages"];
|
|
693
|
+
if (!Array.isArray(messages)) return;
|
|
694
|
+
let kept = 0;
|
|
695
|
+
for (const m of messages) {
|
|
696
|
+
if (!m || typeof m !== "object") continue;
|
|
697
|
+
const content = (m as Record<string, unknown>)["content"];
|
|
698
|
+
if (!Array.isArray(content)) continue;
|
|
699
|
+
for (let index = 0; index < content.length; index += 1) {
|
|
700
|
+
const part = content[index];
|
|
701
|
+
if (!part || typeof part !== "object") continue;
|
|
702
|
+
const p = part as Record<string, unknown>;
|
|
703
|
+
if (p["type"] !== "image_url") continue;
|
|
704
|
+
const v = p["image_url"];
|
|
705
|
+
if (typeof v === "string") {
|
|
706
|
+
if (v.length === 0) continue;
|
|
707
|
+
p["image_url"] = { url: v };
|
|
708
|
+
}
|
|
709
|
+
kept += 1;
|
|
710
|
+
if (kept > BUDDY_MAX_IMAGES) {
|
|
711
|
+
content[index] = { type: "text", text: BUDDY_IMAGE_OMITTED };
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
function normalizeBuddyRoles(payload: Record<string, unknown>): void {
|
|
718
|
+
const messages = payload["messages"];
|
|
719
|
+
if (!Array.isArray(messages)) return;
|
|
720
|
+
for (const m of messages) {
|
|
721
|
+
if (!m || typeof m !== "object") continue;
|
|
722
|
+
const rec = m as Record<string, unknown>;
|
|
723
|
+
if (rec["role"] === "developer") rec["role"] = "system";
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
/** Normalizes one message's content into a Chat-wire content-part array. */
|
|
728
|
+
function contentParts(content: unknown): Array<Record<string, unknown>> {
|
|
729
|
+
if (typeof content === "string")
|
|
730
|
+
return content.length === 0 ? [] : [{ type: "text", text: content }];
|
|
731
|
+
if (!Array.isArray(content)) return [];
|
|
732
|
+
return content.filter(
|
|
733
|
+
(part): part is Record<string, unknown> => part !== null && typeof part === "object",
|
|
734
|
+
);
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
/**
|
|
738
|
+
* Coalesces consecutive `user` messages into one, preserving content-part
|
|
739
|
+
* order. The buddy upstream merges consecutive same-role turns itself and
|
|
740
|
+
* drops `image_url` parts while doing so, so an attachment delivered as its
|
|
741
|
+
* own user turn (the shape VS Code Copilot emits for a pasted screenshot)
|
|
742
|
+
* reaches the model as text only — the image is silently gone even though the
|
|
743
|
+
* gateway forwarded it. Merging here keeps every image in the same message as
|
|
744
|
+
* the text the caller sent alongside it.
|
|
745
|
+
*
|
|
746
|
+
* Lossless: only adjacent same-role `user` turns merge, and every part is
|
|
747
|
+
* retained in order. Assistant/tool/system turns are untouched.
|
|
748
|
+
*/
|
|
749
|
+
export function coalesceConsecutiveUserMessages(messages: Array<Record<string, unknown>>): void {
|
|
750
|
+
const out: Array<Record<string, unknown>> = [];
|
|
751
|
+
for (const message of messages) {
|
|
752
|
+
const previous = out[out.length - 1];
|
|
753
|
+
if (message["role"] !== "user" || previous?.["role"] !== "user") {
|
|
754
|
+
out.push(message);
|
|
755
|
+
continue;
|
|
756
|
+
}
|
|
757
|
+
out[out.length - 1] = {
|
|
758
|
+
...previous,
|
|
759
|
+
content: [...contentParts(previous["content"]), ...contentParts(message["content"])],
|
|
760
|
+
};
|
|
761
|
+
}
|
|
762
|
+
messages.length = 0;
|
|
763
|
+
messages.push(...out);
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
/**
|
|
767
|
+
* Responses input can split one assistant turn into several adjacent items
|
|
768
|
+
* (text, reasoning, and parallel function calls). Chat wire needs a single
|
|
769
|
+
* assistant message so the reasoning trace accompanies every call in the
|
|
770
|
+
* batch; tool results or user turns always end the batch.
|
|
771
|
+
*/
|
|
772
|
+
function coalesceConsecutiveAssistantMessages(messages: Array<Record<string, unknown>>): void {
|
|
773
|
+
let write = 0;
|
|
774
|
+
for (const message of messages) {
|
|
775
|
+
const previous = messages[write - 1];
|
|
776
|
+
if (message["role"] !== "assistant" || previous?.["role"] !== "assistant") {
|
|
777
|
+
messages[write++] = message;
|
|
778
|
+
continue;
|
|
779
|
+
}
|
|
780
|
+
const firstContent = previous["content"];
|
|
781
|
+
const nextContent = message["content"];
|
|
782
|
+
let content: unknown = firstContent ?? nextContent;
|
|
783
|
+
if (firstContent != null && nextContent != null) {
|
|
784
|
+
if (typeof firstContent === "string" && typeof nextContent === "string") {
|
|
785
|
+
if (firstContent.length === 0) content = nextContent;
|
|
786
|
+
else if (nextContent.length === 0) content = firstContent;
|
|
787
|
+
else content = `${firstContent}\n${nextContent}`;
|
|
788
|
+
} else content = [...contentParts(firstContent), ...contentParts(nextContent)];
|
|
789
|
+
}
|
|
790
|
+
const firstCalls = previous["tool_calls"];
|
|
791
|
+
const nextCalls = message["tool_calls"];
|
|
792
|
+
const firstReasoning = previous["reasoning_content"];
|
|
793
|
+
const nextReasoning = message["reasoning_content"];
|
|
794
|
+
const merged: Record<string, unknown> = { ...previous, ...message, content };
|
|
795
|
+
if (Array.isArray(firstCalls) || Array.isArray(nextCalls))
|
|
796
|
+
merged["tool_calls"] = [
|
|
797
|
+
...(Array.isArray(firstCalls) ? firstCalls : []),
|
|
798
|
+
...(Array.isArray(nextCalls) ? nextCalls : []),
|
|
799
|
+
];
|
|
800
|
+
if (typeof firstReasoning === "string" && typeof nextReasoning === "string") {
|
|
801
|
+
if (firstReasoning.length === 0) merged["reasoning_content"] = nextReasoning;
|
|
802
|
+
else if (nextReasoning.length === 0) merged["reasoning_content"] = firstReasoning;
|
|
803
|
+
else merged["reasoning_content"] = `${firstReasoning}\n${nextReasoning}`;
|
|
804
|
+
} else if (typeof firstReasoning === "string" || typeof nextReasoning === "string") {
|
|
805
|
+
merged["reasoning_content"] = firstReasoning ?? nextReasoning;
|
|
806
|
+
}
|
|
807
|
+
messages[write - 1] = merged;
|
|
808
|
+
}
|
|
809
|
+
messages.length = write;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
/**
|
|
813
|
+
* Drops messages whose content is empty, mirroring the buddy upstream's own
|
|
814
|
+
* validation (empty-content turns are rejected with code `11151`). Guarded:
|
|
815
|
+
* anything carrying tool calls, tool results, or reasoning is kept — an
|
|
816
|
+
* assistant `tool_calls` turn with null content and a ledger-folded result
|
|
817
|
+
* text are both load-bearing, not empty.
|
|
818
|
+
*/
|
|
819
|
+
export function dropEmptyBuddyMessages(messages: Array<Record<string, unknown>>): void {
|
|
820
|
+
const kept = messages.filter((message) => {
|
|
821
|
+
if (!isRecord(message)) return true;
|
|
822
|
+
if (Array.isArray(message["tool_calls"]) && message["tool_calls"].length > 0) return true;
|
|
823
|
+
if (typeof message["tool_call_id"] === "string" && message["tool_call_id"].trim().length > 0)
|
|
824
|
+
return true;
|
|
825
|
+
if (typeof message["reasoning_content"] === "string") return true;
|
|
826
|
+
const content = message["content"];
|
|
827
|
+
if (typeof content === "string") return content.trim().length > 0;
|
|
828
|
+
if (Array.isArray(content)) return content.length > 0;
|
|
829
|
+
// Null content with no tool/reasoning load is a bare turn the upstream
|
|
830
|
+
// rejects — drop it rather than dispatch a known-invalid payload.
|
|
831
|
+
return false;
|
|
832
|
+
});
|
|
833
|
+
if (kept.length === messages.length) return;
|
|
834
|
+
messages.length = 0;
|
|
835
|
+
messages.push(...kept);
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
/**
|
|
839
|
+
* Guarantees the wire opens with a `system` turn. The buddy upstream rejects a
|
|
840
|
+
* first message that is not a system prompt (code `11128`), and a CN-neutralized
|
|
841
|
+
* or caller-less history can otherwise arrive with a bare `user` turn first.
|
|
842
|
+
* Non-empty by construction: an empty system turn trips the sibling `11151`
|
|
843
|
+
* validation above, so the placeholder carries real content.
|
|
844
|
+
*/
|
|
845
|
+
export function ensureBuddyLeadingSystem(
|
|
846
|
+
messages: Array<Record<string, unknown>>,
|
|
847
|
+
systemPrompt: string,
|
|
848
|
+
): void {
|
|
849
|
+
if (messages.length === 0) {
|
|
850
|
+
messages.push({ role: "system", content: systemPrompt });
|
|
851
|
+
return;
|
|
852
|
+
}
|
|
853
|
+
const first = messages[0];
|
|
854
|
+
if (isRecord(first) && first["role"] === "system") return;
|
|
855
|
+
messages.unshift({ role: "system", content: systemPrompt });
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
/**
|
|
859
|
+
* Runs the full buddy message-envelope tail shared by every variant: merge
|
|
860
|
+
* consecutive user turns, drop empty-content turns the upstream rejects
|
|
861
|
+
* (`11151`), then guarantee a leading system turn (`11128`). The system text
|
|
862
|
+
* is the variant's own prompt, so each pre-payload hook passes its prompt in.
|
|
863
|
+
*/
|
|
864
|
+
function finalizeBuddyMessages(
|
|
865
|
+
messages: Array<Record<string, unknown>>,
|
|
866
|
+
systemPrompt: string,
|
|
867
|
+
): void {
|
|
868
|
+
coalesceConsecutiveUserMessages(messages);
|
|
869
|
+
dropEmptyBuddyMessages(messages);
|
|
870
|
+
ensureBuddyLeadingSystem(messages, systemPrompt);
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
/**
|
|
874
|
+
* Applies the buddy-family message envelope in place: drop caller
|
|
875
|
+
* `system`/`developer` turns, install the variant's fixed leading system
|
|
876
|
+
* prompt, rebuild bare string user content as a typed text block, then
|
|
877
|
+
* coalesce consecutive user turns.
|
|
878
|
+
*
|
|
879
|
+
* The upstream gateway expects that fixed leading prompt — and validates its
|
|
880
|
+
* text, not merely the presence of a `system` turn. Substituting caller text
|
|
881
|
+
* there answers `400 · 11128 "Illegal API invocation from an unapproved
|
|
882
|
+
* channel"` on CodeBuddy, which reads the prompt as the calling channel's
|
|
883
|
+
* identity; forwarding caller text as a `developer` turn is rejected too. So
|
|
884
|
+
* every variant that has one applies the same transformation — only the prompt
|
|
885
|
+
* text differs, which is why the caller passes it in. Variants without a fixed
|
|
886
|
+
* prompt (CodeBuddy CN replaces caller system text with a neutralizer instead)
|
|
887
|
+
* keep their own path and share only `coalesceConsecutiveUserMessages`.
|
|
888
|
+
*
|
|
889
|
+
* Tool call/output pairing is intentionally NOT handled here: the shared
|
|
890
|
+
* canonical repair (`repairRequestToolCalls`, request/preparer) owns it for
|
|
891
|
+
* every route, so this stays a system-prompt and content-shape hook.
|
|
892
|
+
*/
|
|
893
|
+
export function applyBuddySystemPrompt(
|
|
894
|
+
messages: Array<Record<string, unknown>>,
|
|
895
|
+
systemPrompt: string,
|
|
896
|
+
): void {
|
|
897
|
+
const source = messages.filter(
|
|
898
|
+
(message) => message["role"] !== "system" && message["role"] !== "developer",
|
|
899
|
+
);
|
|
900
|
+
messages.length = 0;
|
|
901
|
+
messages.push({ role: "system", content: systemPrompt });
|
|
902
|
+
for (const message of source) {
|
|
903
|
+
if (message["role"] === "user" && typeof message["content"] === "string") {
|
|
904
|
+
messages.push({
|
|
905
|
+
...message,
|
|
906
|
+
content: [{ type: "text", text: message["content"] }],
|
|
907
|
+
});
|
|
908
|
+
} else {
|
|
909
|
+
messages.push({ ...message });
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
finalizeBuddyMessages(messages, systemPrompt);
|
|
913
|
+
}
|