cartethyia 2.7.1 → 2.7.3
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/.env.example +2 -0
- package/dist/dashboard/assets/{BannedPage-D-J75hBM.js → BannedPage-2TJD37Hb.js} +1 -1
- package/dist/dashboard/assets/BannedPage-2TJD37Hb.js.gz +0 -0
- package/dist/dashboard/assets/CliToolDetailPage-xefJNRmJ.js +1 -0
- package/dist/dashboard/assets/CliToolDetailPage-xefJNRmJ.js.gz +0 -0
- package/dist/dashboard/assets/CliToolsPage-Rj9iHQ3F.js +1 -0
- package/dist/dashboard/assets/CliToolsPage-Rj9iHQ3F.js.gz +0 -0
- package/dist/dashboard/assets/{CombosPage-QoJ6foxo.js → CombosPage-JEbYJFxw.js} +1 -1
- package/dist/dashboard/assets/CombosPage-JEbYJFxw.js.gz +0 -0
- package/dist/dashboard/assets/{ConsoleLogPage-DpNRay4C.js → ConsoleLogPage-C3xx_inM.js} +1 -1
- package/dist/dashboard/assets/ConsoleLogPage-C3xx_inM.js.gz +0 -0
- package/dist/dashboard/assets/CustomizationPage-Dw306gJU.js +1 -0
- package/dist/dashboard/assets/CustomizationPage-Dw306gJU.js.gz +0 -0
- package/dist/dashboard/assets/HealthEventsModal-jaBo1TWV.js +1 -0
- package/dist/dashboard/assets/HealthEventsModal-jaBo1TWV.js.gz +0 -0
- package/dist/dashboard/assets/{LoginPage-DubdQcvX.js → LoginPage-C4w7vea-.js} +1 -1
- package/dist/dashboard/assets/LoginPage-C4w7vea-.js.gz +0 -0
- package/dist/dashboard/assets/{ModelPicker-BH8Tehdi.js → ModelPicker-BtPcAfot.js} +1 -1
- package/dist/dashboard/assets/ModelPicker-BtPcAfot.js.gz +0 -0
- package/dist/dashboard/assets/OverviewPage-CGC5gNs1.js +1 -0
- package/dist/dashboard/assets/OverviewPage-CGC5gNs1.js.gz +0 -0
- package/dist/dashboard/assets/ProviderDetailPage-DQcthNGt.js +4 -0
- package/dist/dashboard/assets/ProviderDetailPage-DQcthNGt.js.gz +0 -0
- package/dist/dashboard/assets/ProvidersPage-0OsXRGXe.js +1 -0
- package/dist/dashboard/assets/ProvidersPage-0OsXRGXe.js.gz +0 -0
- package/dist/dashboard/assets/ProxyPage-DdPc8slb.js +9 -0
- package/dist/dashboard/assets/ProxyPage-DdPc8slb.js.gz +0 -0
- package/dist/dashboard/assets/{QuotaPage-ATpaxrbl.js → QuotaPage-Ci5O4BnH.js} +1 -1
- package/dist/dashboard/assets/QuotaPage-Ci5O4BnH.js.gz +0 -0
- package/dist/dashboard/assets/{SettingsPage-CETophpx.js → SettingsPage-CDZjTt87.js} +1 -1
- package/dist/dashboard/assets/SettingsPage-CDZjTt87.js.gz +0 -0
- package/dist/dashboard/assets/SetupPage-BQS0Rx_u.js +1 -0
- package/dist/dashboard/assets/SetupPage-BQS0Rx_u.js.gz +0 -0
- package/dist/dashboard/assets/{SortableList-CAK-EN9z.js → SortableList-BAL5npBC.js} +1 -1
- package/dist/dashboard/assets/SortableList-BAL5npBC.js.gz +0 -0
- package/dist/dashboard/assets/{StudioPage-B7W2_RhT.js → StudioPage-TP7voAQ0.js} +3 -3
- package/dist/dashboard/assets/StudioPage-TP7voAQ0.js.gz +0 -0
- package/dist/dashboard/assets/{UsagePage-6T4EFDjW.js → UsagePage-Cj_fEzhg.js} +13 -13
- package/dist/dashboard/assets/UsagePage-Cj_fEzhg.js.gz +0 -0
- package/dist/dashboard/assets/{api-keys-CUi6UXsR.js → api-keys-MbI7e7rt.js} +1 -1
- package/dist/dashboard/assets/api-keys-MbI7e7rt.js.gz +0 -0
- package/dist/dashboard/assets/app-B2vyPoII.css +2 -0
- package/dist/dashboard/assets/app-B2vyPoII.css.gz +0 -0
- package/dist/dashboard/assets/arrow-up-59ms1iiT.js +1 -0
- package/dist/dashboard/assets/arrow-up-59ms1iiT.js.gz +0 -0
- package/dist/dashboard/assets/{assert-67xXH5rf.js → assert-C0YYuZJ4.js} +1 -1
- package/dist/dashboard/assets/assert-C0YYuZJ4.js.gz +0 -0
- package/dist/dashboard/assets/{badge-DC2L43Mj.js → badge-B_DMpMJN.js} +1 -1
- package/dist/dashboard/assets/badge-B_DMpMJN.js.gz +0 -0
- package/dist/dashboard/assets/{banner-BBVcb0J3.js → banner-DMrMPmth.js} +1 -1
- package/dist/dashboard/assets/banner-DMrMPmth.js.gz +0 -0
- package/dist/dashboard/assets/{card-Cca1XC6G.js → card-Be5Uzamx.js} +1 -1
- package/dist/dashboard/assets/card-Be5Uzamx.js.gz +0 -0
- package/dist/dashboard/assets/{cli-tools-KvsWa1Jl.js → cli-tools-muFRphDu.js} +1 -1
- package/dist/dashboard/assets/cli-tools-muFRphDu.js.gz +0 -0
- package/dist/dashboard/assets/{clipboard-button-DUzEbynq.js → clipboard-button-DOfrVmsi.js} +1 -1
- package/dist/dashboard/assets/clipboard-button-DOfrVmsi.js.gz +0 -0
- package/dist/dashboard/assets/{dialog-DFvWhwTa.js → dialog-C9omdjQt.js} +1 -1
- package/dist/dashboard/assets/dialog-C9omdjQt.js.gz +0 -0
- package/dist/dashboard/assets/drawer-Bm8WKaGY.js +1 -0
- package/dist/dashboard/assets/drawer-Bm8WKaGY.js.gz +0 -0
- package/dist/dashboard/assets/{entry-_xHo5x6r.js → entry-DS90gUks.js} +1 -1
- package/dist/dashboard/assets/entry-DS90gUks.js.gz +0 -0
- package/dist/dashboard/assets/{entry-BLBLxX0h.js → entry-j40LJotO.js} +3 -3
- package/dist/dashboard/assets/entry-j40LJotO.js.gz +0 -0
- package/dist/dashboard/assets/{entry-Bqy8RFvM.js → entry-q5HwZ8nN.js} +1 -1
- package/dist/dashboard/assets/entry-q5HwZ8nN.js.gz +0 -0
- package/dist/dashboard/assets/{concurrency-BDF0ok7y.js → fingerprint-pattern-BWuHtbW4.js} +1 -1
- package/dist/dashboard/assets/fingerprint-pattern-BWuHtbW4.js.gz +0 -0
- package/dist/dashboard/assets/{formatters-mpBPqAQ8.js → formatters-BTScrQpe.js} +1 -1
- package/dist/dashboard/assets/formatters-BTScrQpe.js.gz +0 -0
- package/dist/dashboard/assets/{github-badge-C9e8Ts7e.js → github-badge-Orjuzemr.js} +1 -1
- package/dist/dashboard/assets/github-badge-Orjuzemr.js.gz +0 -0
- package/dist/dashboard/assets/{index-D6QzEC_n.js → index-cPrX5vn6.js} +2 -2
- package/dist/dashboard/assets/index-cPrX5vn6.js.gz +0 -0
- package/dist/dashboard/assets/{inline-BFUcvllj.js → inline-Dvux8w-0.js} +1 -1
- package/dist/dashboard/assets/inline-Dvux8w-0.js.gz +0 -0
- package/dist/dashboard/assets/{input-C_ILAvBb.js → input-CU1Rgf3p.js} +1 -1
- package/dist/dashboard/assets/input-CU1Rgf3p.js.gz +0 -0
- package/dist/dashboard/assets/{layout-CBIyT6Rt.js → layout-C_7A6H55.js} +1 -1
- package/dist/dashboard/assets/layout-C_7A6H55.js.gz +0 -0
- package/dist/dashboard/assets/{list-row-ebJ_-Nsb.js → list-row-CzFvogbj.js} +1 -1
- package/dist/dashboard/assets/list-row-CzFvogbj.js.gz +0 -0
- package/dist/dashboard/assets/{live-D0mm--ME.js → live-KaG15HLQ.js} +1 -1
- package/dist/dashboard/assets/live-KaG15HLQ.js.gz +0 -0
- package/dist/dashboard/assets/{mutation-sMD965oO.js → mutation-JJGQm8TD.js} +1 -1
- package/dist/dashboard/assets/mutation-JJGQm8TD.js.gz +0 -0
- package/dist/dashboard/assets/{numbers-BO9dKaFr.js → numbers-BNDZnsUE.js} +1 -1
- package/dist/dashboard/assets/numbers-BNDZnsUE.js.gz +0 -0
- package/dist/dashboard/assets/{query-keys-CfL_Zdoh.js → query-keys-CLaNz8Xz.js} +1 -1
- package/dist/dashboard/assets/query-keys-CLaNz8Xz.js.gz +0 -0
- package/dist/dashboard/assets/{routing-BnIpi1IJ.js → routing-BEH32QOC.js} +1 -1
- package/dist/dashboard/assets/routing-BEH32QOC.js.gz +0 -0
- package/dist/dashboard/assets/{select-Cj5Nw43g.js → select-B3iTetHN.js} +1 -1
- package/dist/dashboard/assets/select-B3iTetHN.js.gz +0 -0
- package/dist/dashboard/assets/settings-2-Ds9DQp0t.js +1 -0
- package/dist/dashboard/assets/settings-2-Ds9DQp0t.js.gz +0 -0
- package/dist/dashboard/assets/{settings-BAFhDgX8.js → settings-Dt3Eqbnq.js} +1 -1
- package/dist/dashboard/assets/settings-Dt3Eqbnq.js.gz +0 -0
- package/dist/dashboard/assets/{stack-EMrOMt4I.js → stack-KrOrm7JZ.js} +1 -1
- package/dist/dashboard/assets/stack-KrOrm7JZ.js.gz +0 -0
- package/dist/dashboard/assets/{state-CWoo7ty5.js → state-DLS7IDMy.js} +1 -1
- package/dist/dashboard/assets/state-DLS7IDMy.js.gz +0 -0
- package/dist/dashboard/assets/{switch-CCjT6IkF.js → switch-42ItvbAs.js} +1 -1
- package/dist/dashboard/assets/switch-42ItvbAs.js.gz +0 -0
- package/dist/dashboard/assets/{useMutation-DjkLHUcE.js → useMutation-Dmnfh1Ue.js} +1 -1
- package/dist/dashboard/assets/useMutation-Dmnfh1Ue.js.gz +0 -0
- package/dist/dashboard/index.html +3 -2
- package/dist/dashboard/index.html.gz +0 -0
- package/dist/dashboard/precompressed-assets.json +117 -115
- package/migrations/full-postgres/0026_add_qoder_native_identity.sql +20 -0
- package/migrations/full-postgres/0027_qoder_identity_genuine_seed.sql +22 -0
- package/migrations/full-postgres/0028_add_family_template_key_telemetry.sql +40 -0
- package/migrations/full-postgres/0029_null_share_child_policy_columns.sql +38 -0
- package/migrations/full-postgres/0030_add_api_key_max_children.sql +10 -0
- package/migrations/full-postgres/0031_tenant_system_prompts.sql +16 -0
- package/migrations/lite-sqlite3/0026_add_qoder_native_identity.sql +15 -0
- package/migrations/lite-sqlite3/0027_qoder_identity_genuine_seed.sql +22 -0
- package/migrations/lite-sqlite3/0028_add_family_template_key_telemetry.sql +40 -0
- package/migrations/lite-sqlite3/0029_null_share_child_policy_columns.sql +38 -0
- package/migrations/lite-sqlite3/0030_add_api_key_max_children.sql +10 -0
- package/migrations/lite-sqlite3/0031_tenant_system_prompts.sql +10 -0
- package/package.json +1 -1
- package/src/config/network.ts +1 -1
- package/src/console/api-keys/bucket-spend.ts +7 -7
- package/src/console/api-keys/contracts.ts +423 -410
- package/src/console/api-keys/routes.ts +118 -19
- package/src/console/backup/export-endpoint.ts +8 -11
- package/src/console/backup/routes.ts +40 -8
- package/src/console/backup/service.ts +11 -6
- package/src/console/backup/store.ts +384 -88
- package/src/console/cli-tools/contracts.ts +10 -4
- package/src/console/dashboard-assets.ts +16 -1
- package/src/console/domain-registration.ts +2 -0
- package/src/console/quota/account-quota-tenant.ts +8 -1
- package/src/console/quota/account-quota.ts +4 -1
- package/src/console/quota/native-identity.ts +189 -0
- package/src/console/remote-support/contracts.ts +12 -6
- package/src/console/remote-support/service.ts +2 -2
- package/src/console/share/share-router.ts +39 -6
- package/src/console/share/share-stats.ts +64 -25
- package/src/console/share/share-usage.ts +24 -7
- package/src/console/system-prompt/contracts.ts +102 -0
- package/src/console/system-prompt/routes.ts +165 -0
- package/src/main.ts +11 -0
- package/src/network/outbound-fetch.ts +281 -226
- package/src/network/pool/agent.ts +7 -0
- package/src/observability/event-query.ts +118 -7
- package/src/persistence/db-pg.ts +42 -0
- package/src/persistence/schema.ts +102 -0
- package/src/persistence/stores/api-key-store.ts +753 -581
- package/src/persistence/stores/share-store.ts +65 -13
- package/src/persistence/stores/telemetry-store.ts +57 -1
- package/src/persistence/system-prompt-store.ts +104 -0
- package/src/protocol/reasoning.ts +65 -1
- package/src/protocol/request/chat.ts +1 -0
- package/src/providers/default-registry.ts +1 -0
- package/src/providers/integrations/antigravity/antigravity.ts +20 -23
- package/src/providers/integrations/buddy/buddy-chat-shared.ts +30 -2
- package/src/providers/integrations/buddy/buddy-media.ts +5 -1
- package/src/providers/integrations/buddy/codebuddy/provider.ts +5 -1
- package/src/providers/integrations/buddy/workbuddy/provider.ts +5 -1
- package/src/providers/integrations/cline/cline.ts +7 -0
- package/src/providers/integrations/github/github.ts +20 -1
- package/src/providers/integrations/hermes.ts +5 -1
- package/src/providers/integrations/kilo/kilo.ts +4 -0
- package/src/providers/integrations/ollamacloud-models.ts +50 -0
- package/src/providers/integrations/openrouter/provider.ts +4 -0
- package/src/providers/integrations/qoder/qoder-checkin.ts +224 -51
- package/src/providers/integrations/qoder/qoder-native-identity-store.ts +156 -0
- package/src/providers/integrations/qoder/qoder-oauth.ts +24 -4
- package/src/providers/integrations/qoder/qoder-realm.ts +20 -0
- package/src/providers/integrations/qoder/qoder-text-tools.ts +277 -0
- package/src/providers/integrations/qoder/qoder-umid.ts +288 -0
- package/src/providers/integrations/qoder/qoder.ts +1375 -1641
- package/src/providers/operations/account-error-classification.ts +1682 -1660
- package/src/providers/operations/account-health-service.ts +619 -621
- package/src/providers/operations/client-versions.ts +37 -22
- package/src/providers/operations/provider-catalog-service.ts +11 -1
- package/src/providers/provider-contracts.ts +15 -2
- package/src/security/access-control.ts +19 -0
- package/src/security/api-key-auth.ts +14 -1
- package/src/security/cli-client-fingerprint.ts +9 -2
- package/src/transport/dispatch/empty-stream.ts +224 -224
- package/src/transport/dispatch/proxy-request.ts +31 -2
- package/src/transport/dispatch/streaming/stream.ts +16 -1
- package/src/transport/dispatch/upstream.ts +10 -0
- package/src/transport/failure-policy.ts +33 -0
- package/src/transport/request/preparer.ts +17 -1
- package/src/transport/request/system-prompt-injection.ts +89 -0
- package/src/transport/request/tenant-preferences.ts +43 -0
- package/src/transport/routing/admission.ts +13 -3
- package/src/transport/routing/route-catalog.ts +41 -12
- package/src/transport/routing/route-model.ts +53 -0
- package/src/transport/routing/router.ts +306 -228
- package/src/transport/surface/responses/encode.ts +2 -3
- package/src/transport/surface/responses/parse.ts +49 -7
- package/src/types/undici.d.ts +33 -0
- package/src/workers/daily-checkin.ts +2 -0
- package/dist/dashboard/assets/BannedPage-D-J75hBM.js.gz +0 -0
- package/dist/dashboard/assets/CliToolDetailPage-CVe6RaBH.js +0 -1
- package/dist/dashboard/assets/CliToolDetailPage-CVe6RaBH.js.gz +0 -0
- package/dist/dashboard/assets/CliToolsPage-Dd8ZSamp.js +0 -1
- package/dist/dashboard/assets/CliToolsPage-Dd8ZSamp.js.gz +0 -0
- package/dist/dashboard/assets/CombosPage-QoJ6foxo.js.gz +0 -0
- package/dist/dashboard/assets/ConsoleLogPage-DpNRay4C.js.gz +0 -0
- package/dist/dashboard/assets/CustomizationPage-CKUFTcW4.js +0 -1
- package/dist/dashboard/assets/CustomizationPage-CKUFTcW4.js.gz +0 -0
- package/dist/dashboard/assets/HealthEventsModal-CzSLiDzr.js +0 -1
- package/dist/dashboard/assets/HealthEventsModal-CzSLiDzr.js.gz +0 -0
- package/dist/dashboard/assets/LoginPage-DubdQcvX.js.gz +0 -0
- package/dist/dashboard/assets/ModelPicker-BH8Tehdi.js.gz +0 -0
- package/dist/dashboard/assets/OverviewPage-DuWzAQFL.js +0 -1
- package/dist/dashboard/assets/OverviewPage-DuWzAQFL.js.gz +0 -0
- package/dist/dashboard/assets/ProviderDetailPage-BKEKe2Fm.js +0 -4
- package/dist/dashboard/assets/ProviderDetailPage-BKEKe2Fm.js.gz +0 -0
- package/dist/dashboard/assets/ProvidersPage-Bwotu8UJ.js +0 -1
- package/dist/dashboard/assets/ProvidersPage-Bwotu8UJ.js.gz +0 -0
- package/dist/dashboard/assets/ProxyPage-D7hpWbg9.js +0 -9
- package/dist/dashboard/assets/ProxyPage-D7hpWbg9.js.gz +0 -0
- package/dist/dashboard/assets/QuotaPage-ATpaxrbl.js.gz +0 -0
- package/dist/dashboard/assets/SettingsPage-CETophpx.js.gz +0 -0
- package/dist/dashboard/assets/SetupPage-BdgECjqq.js +0 -1
- package/dist/dashboard/assets/SetupPage-BdgECjqq.js.gz +0 -0
- package/dist/dashboard/assets/SortableList-CAK-EN9z.js.gz +0 -0
- package/dist/dashboard/assets/StudioPage-B7W2_RhT.js.gz +0 -0
- package/dist/dashboard/assets/UsagePage-6T4EFDjW.js.gz +0 -0
- package/dist/dashboard/assets/api-keys-CUi6UXsR.js.gz +0 -0
- package/dist/dashboard/assets/app-KvsUJUMF.css +0 -2
- package/dist/dashboard/assets/app-KvsUJUMF.css.gz +0 -0
- package/dist/dashboard/assets/assert-67xXH5rf.js.gz +0 -0
- package/dist/dashboard/assets/badge-DC2L43Mj.js.gz +0 -0
- package/dist/dashboard/assets/banner-BBVcb0J3.js.gz +0 -0
- package/dist/dashboard/assets/card-Cca1XC6G.js.gz +0 -0
- package/dist/dashboard/assets/cli-tools-KvsWa1Jl.js.gz +0 -0
- package/dist/dashboard/assets/clipboard-button-DUzEbynq.js.gz +0 -0
- package/dist/dashboard/assets/concurrency-BDF0ok7y.js.gz +0 -0
- package/dist/dashboard/assets/dialog-DFvWhwTa.js.gz +0 -0
- package/dist/dashboard/assets/drawer-CFM2sMX1.js +0 -1
- package/dist/dashboard/assets/drawer-CFM2sMX1.js.gz +0 -0
- package/dist/dashboard/assets/entry-BLBLxX0h.js.gz +0 -0
- package/dist/dashboard/assets/entry-Bqy8RFvM.js.gz +0 -0
- package/dist/dashboard/assets/entry-_xHo5x6r.js.gz +0 -0
- package/dist/dashboard/assets/formatters-mpBPqAQ8.js.gz +0 -0
- package/dist/dashboard/assets/github-badge-C9e8Ts7e.js.gz +0 -0
- package/dist/dashboard/assets/index-D6QzEC_n.js.gz +0 -0
- package/dist/dashboard/assets/inline-BFUcvllj.js.gz +0 -0
- package/dist/dashboard/assets/input-C_ILAvBb.js.gz +0 -0
- package/dist/dashboard/assets/layout-CBIyT6Rt.js.gz +0 -0
- package/dist/dashboard/assets/list-row-ebJ_-Nsb.js.gz +0 -0
- package/dist/dashboard/assets/live-D0mm--ME.js.gz +0 -0
- package/dist/dashboard/assets/mutation-sMD965oO.js.gz +0 -0
- package/dist/dashboard/assets/numbers-BO9dKaFr.js.gz +0 -0
- package/dist/dashboard/assets/query-keys-CfL_Zdoh.js.gz +0 -0
- package/dist/dashboard/assets/routing-BnIpi1IJ.js.gz +0 -0
- package/dist/dashboard/assets/select-Cj5Nw43g.js.gz +0 -0
- package/dist/dashboard/assets/settings-BAFhDgX8.js.gz +0 -0
- package/dist/dashboard/assets/stack-EMrOMt4I.js.gz +0 -0
- package/dist/dashboard/assets/state-CWoo7ty5.js.gz +0 -0
- package/dist/dashboard/assets/switch-CCjT6IkF.js.gz +0 -0
- package/dist/dashboard/assets/useMutation-DjkLHUcE.js.gz +0 -0
- /package/dist/dashboard/assets/{app-BaWd0puM.js → app-wvwCi4Eh.js} +0 -0
- /package/dist/dashboard/assets/{app-BaWd0puM.js.gz → app-wvwCi4Eh.js.gz} +0 -0
|
@@ -24,6 +24,7 @@ import type { CanonicalRequest } from "../../../protocol/canonical-model";
|
|
|
24
24
|
import { OpenAICompatibleAdapter, withBearerAuthentication } from "../../adapters/compatible-adapter";
|
|
25
25
|
import { providerBaseUrl } from "../../provider-metadata";
|
|
26
26
|
import { decodeKiloCredential } from "../../../security/credential-codec";
|
|
27
|
+
import { clineReasoningReplayPrepares } from "../buddy/buddy-chat-shared";
|
|
27
28
|
|
|
28
29
|
export const KILO_PROVIDER_ID = "kilo" as const;
|
|
29
30
|
export const KILO_BASE_URL = providerBaseUrl("kilo");
|
|
@@ -117,6 +118,9 @@ class KiloAdapter extends OpenAICompatibleAdapter {
|
|
|
117
118
|
provider_id: KILO_PROVIDER_ID,
|
|
118
119
|
base_url: KILO_BASE_URL,
|
|
119
120
|
endpoint_paths_by_wire_family: { chat: KILO_ENDPOINT_PATH },
|
|
121
|
+
prePayload: (payload) => {
|
|
122
|
+
for (const prepare of clineReasoningReplayPrepares) prepare(payload);
|
|
123
|
+
},
|
|
120
124
|
...(fetchImpl ? { fetchImpl } : {}),
|
|
121
125
|
}),
|
|
122
126
|
);
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ollama Cloud (`ollamacloud`) model catalog.
|
|
3
|
+
*
|
|
4
|
+
* The provider is discovery-backed: live `/v1/models` on `https://ollama.com/v1`
|
|
5
|
+
* is the primary source and publishes every model's real context window. This
|
|
6
|
+
* static floor keeps the models an operator most likely wants routable before
|
|
7
|
+
* any account sync — and pins the verified live limits so a cold process never
|
|
8
|
+
* falls back to the invented 200k/64k defaults.
|
|
9
|
+
*
|
|
10
|
+
* Limits below are taken from the live `/v1/models` roster (verified 2026-10):
|
|
11
|
+
* - `gemma4:31b` -> 262144 ctx, text+image, thinking none/medium
|
|
12
|
+
* - `gpt-oss:20b` / `gpt-oss:120b` -> 131072 ctx, text, reasoning low/med/high
|
|
13
|
+
* - `nemotron-3-nano:30b` / `nemotron-3-super` / `nemotron-3-ultra`
|
|
14
|
+
* -> 262144 ctx, text, thinking none/medium
|
|
15
|
+
*
|
|
16
|
+
* All six serve the chat wire (none is a Responses-native id), and all report
|
|
17
|
+
* `supported_in_api: true` on the live roster.
|
|
18
|
+
*/
|
|
19
|
+
import { defineModel } from "../provider-model-definition";
|
|
20
|
+
import type { ModelDefinition } from "../provider-model-definition";
|
|
21
|
+
|
|
22
|
+
export const OLLAMACLOUD_PROVIDER_ID = "ollamacloud" as const;
|
|
23
|
+
|
|
24
|
+
/** Chat path below the `/v1`-rooted base. */
|
|
25
|
+
const CHAT_ENDPOINT = "/chat/completions";
|
|
26
|
+
|
|
27
|
+
type CatalogRow = readonly [id: string, ctx: number, vision: boolean, reasoning: boolean];
|
|
28
|
+
|
|
29
|
+
function ollamaCloudModel([id, ctx, vision, reasoning]: CatalogRow): ModelDefinition {
|
|
30
|
+
return defineModel({
|
|
31
|
+
id,
|
|
32
|
+
providerId: OLLAMACLOUD_PROVIDER_ID,
|
|
33
|
+
wireFamily: "chat",
|
|
34
|
+
endpoint: CHAT_ENDPOINT,
|
|
35
|
+
ctx,
|
|
36
|
+
vision,
|
|
37
|
+
reasoning,
|
|
38
|
+
toolCall: true,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Static Ollama Cloud floor; live discovery adds to it per account. */
|
|
43
|
+
export const OLLAMACLOUD_MODELS: readonly ModelDefinition[] = [
|
|
44
|
+
["gemma4:31b", 262_144, true, true] as const,
|
|
45
|
+
["gpt-oss:20b", 131_072, false, true] as const,
|
|
46
|
+
["gpt-oss:120b", 131_072, false, true] as const,
|
|
47
|
+
["nemotron-3-nano:30b", 262_144, false, true] as const,
|
|
48
|
+
["nemotron-3-super", 262_144, false, true] as const,
|
|
49
|
+
["nemotron-3-ultra", 262_144, false, true] as const,
|
|
50
|
+
].map(ollamaCloudModel);
|
|
@@ -5,6 +5,7 @@ import { providerBaseUrl } from "../../provider-metadata";
|
|
|
5
5
|
import type { DiscoveryInput } from "../../discovery/discovery-types";
|
|
6
6
|
import { defineModel } from "../../provider-model-definition";
|
|
7
7
|
import { openrouterFreeRoster } from "../../discovery/openrouter-free-roster";
|
|
8
|
+
import { clineReasoningReplayPrepares } from "../buddy/buddy-chat-shared";
|
|
8
9
|
|
|
9
10
|
const OPENROUTER_PROVIDER_ID = "openrouter" as const;
|
|
10
11
|
|
|
@@ -112,6 +113,9 @@ export const OPENROUTER_SPEC: ApiKeyProviderSpec = {
|
|
|
112
113
|
provider_id: OPENROUTER_PROVIDER_ID,
|
|
113
114
|
endpoint_paths_by_wire_family: {},
|
|
114
115
|
extra_headers: OPENROUTER_REQUIRED_HEADERS,
|
|
116
|
+
prePayload: (payload) => {
|
|
117
|
+
for (const prepare of clineReasoningReplayPrepares) prepare(payload);
|
|
118
|
+
},
|
|
115
119
|
};
|
|
116
120
|
|
|
117
121
|
export async function discoverOpenrouterModels(
|
|
@@ -17,6 +17,18 @@
|
|
|
17
17
|
* the console surface treat Qoder like any other daily-grant provider.
|
|
18
18
|
*/
|
|
19
19
|
import { QODER_REALMS, qoderRealmForProvider } from "./qoder-realm";
|
|
20
|
+
import { getQoderVersion } from "../../operations/client-versions";
|
|
21
|
+
import {
|
|
22
|
+
qoderMachineHeaders,
|
|
23
|
+
type QoderNativeIdentity,
|
|
24
|
+
} from "./qoder-umid";
|
|
25
|
+
import {
|
|
26
|
+
allocateQoderNativeIdentity,
|
|
27
|
+
ensureQoderNativeIdentity,
|
|
28
|
+
type QoderNativeIdentityStore,
|
|
29
|
+
} from "./qoder-native-identity-store";
|
|
30
|
+
import type { NativeIdentityRecord } from "../../../persistence/schema";
|
|
31
|
+
import { log } from "../../../observability/logger";
|
|
20
32
|
|
|
21
33
|
/** Structural fetch bound; Bun's own fetch type carries extra host props. */
|
|
22
34
|
type Fetcher = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
|
@@ -37,6 +49,36 @@ const CHECKIN_CLAIM_PATH = "/sash/api/v1/me/daily-check-in/claim";
|
|
|
37
49
|
const CAMPAIGNS_PATH = "/sash/api/v1/me/campaigns";
|
|
38
50
|
const CAMPAIGN_CLAIM_PATH = "/sash/api/v1/me/campaigns/%s/claim";
|
|
39
51
|
|
|
52
|
+
/**
|
|
53
|
+
* Headers the official desktop client sends to the `/sash` campaign surface.
|
|
54
|
+
* Without these — notably `cosy-clienttype`, `cosy-version`, and the `Qoder`
|
|
55
|
+
* user agent — the upstream hides the campaign list entirely
|
|
56
|
+
* (`showCampaign:false`), so the daily grant never appears. Verified live:
|
|
57
|
+
* the bare Authorization+Accept request returns an empty list, the desktop
|
|
58
|
+
* header set returns `showCampaign:true` with the campaigns populated.
|
|
59
|
+
*
|
|
60
|
+
* `native` optionally carries the UMID machine headers (see `qoder-umid`);
|
|
61
|
+
* when present they unlock the daily 100-credit campaign, which the server
|
|
62
|
+
* otherwise silently filters out.
|
|
63
|
+
*/
|
|
64
|
+
export function buildCampaignHeaders(
|
|
65
|
+
credential: string,
|
|
66
|
+
native?: Record<string, string>,
|
|
67
|
+
): Record<string, string> {
|
|
68
|
+
return {
|
|
69
|
+
Authorization: `Bearer ${credential}`,
|
|
70
|
+
Accept: "application/json",
|
|
71
|
+
"User-Agent": "Qoder",
|
|
72
|
+
"Cosy-ClientType": "10",
|
|
73
|
+
"Cosy-Version": getQoderVersion(),
|
|
74
|
+
...(native ?? {}),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function campaignHeaders(credential: string): Record<string, string> {
|
|
79
|
+
return buildCampaignHeaders(credential);
|
|
80
|
+
}
|
|
81
|
+
|
|
40
82
|
function asRecord(value: unknown): Record<string, unknown> | null {
|
|
41
83
|
if (typeof value !== "object" || value === null) return null;
|
|
42
84
|
return value as Record<string, unknown>;
|
|
@@ -80,19 +122,32 @@ function asState(state: string, today: string, lastClaimedAt: unknown): { active
|
|
|
80
122
|
return { active, todayCheckedIn: claimedToday };
|
|
81
123
|
}
|
|
82
124
|
|
|
83
|
-
/**
|
|
84
|
-
|
|
125
|
+
/**
|
|
126
|
+
* Normalizes one upstream campaign row to the snake_case shape the claim loop
|
|
127
|
+
* consumes. The API returns camelCase keys (`campaignId`, `actionType`,
|
|
128
|
+
* `claimStatus`); the fixtures and the donor hub use snake_case — accept both.
|
|
129
|
+
*/
|
|
130
|
+
function normalizeCampaign(raw: Record<string, unknown>): Record<string, unknown> {
|
|
131
|
+
const benefit = asRecord(raw["benefit"]);
|
|
132
|
+
return {
|
|
133
|
+
campaign_id: String(raw["campaignId"] ?? raw["campaign_id"] ?? ""),
|
|
134
|
+
campaign_key: String(raw["campaignKey"] ?? raw["campaign_key"] ?? ""),
|
|
135
|
+
action_type: String(raw["actionType"] ?? raw["action_type"] ?? ""),
|
|
136
|
+
claim_status: String(raw["claimStatus"] ?? raw["claim_status"] ?? ""),
|
|
137
|
+
...(benefit !== null ? { benefit } : {}),
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** GET campaign list once with given headers; `null` when unreachable/unauthorized. */
|
|
142
|
+
async function getCampaigns(
|
|
85
143
|
base: string,
|
|
86
|
-
|
|
144
|
+
headers: Record<string, string>,
|
|
87
145
|
fetcher: Fetcher,
|
|
88
146
|
): Promise<Record<string, unknown>[] | null> {
|
|
89
147
|
try {
|
|
90
148
|
const response = await fetcher(`${base}${CAMPAIGNS_PATH}`, {
|
|
91
149
|
method: "GET",
|
|
92
|
-
headers
|
|
93
|
-
Authorization: `Bearer ${credential}`,
|
|
94
|
-
Accept: "application/json",
|
|
95
|
-
},
|
|
150
|
+
headers,
|
|
96
151
|
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
97
152
|
});
|
|
98
153
|
if (response.status === 401 || response.status === 403) return null;
|
|
@@ -100,12 +155,94 @@ async function fetchCampaigns(
|
|
|
100
155
|
const payload: unknown = await response.json().catch(() => null);
|
|
101
156
|
const rec = asRecord(payload);
|
|
102
157
|
const list = Array.isArray(rec?.["campaigns"]) ? rec!["campaigns"] : Array.isArray(payload) ? payload : null;
|
|
103
|
-
|
|
158
|
+
if (!Array.isArray(list)) return null;
|
|
159
|
+
return (list as Record<string, unknown>[]).map(normalizeCampaign);
|
|
104
160
|
} catch {
|
|
105
161
|
return null;
|
|
106
162
|
}
|
|
107
163
|
}
|
|
108
164
|
|
|
165
|
+
/**
|
|
166
|
+
* One attempted claim across a header set, returning the same result shape as
|
|
167
|
+
* `claimCampaign` (already-claimed grants resolve to `already_claimed`).
|
|
168
|
+
*/
|
|
169
|
+
async function tryClaimDaily(
|
|
170
|
+
base: string,
|
|
171
|
+
headers: Record<string, string>,
|
|
172
|
+
fetcher: Fetcher,
|
|
173
|
+
): Promise<{ state: DailyCheckinState; amount: number; error: string | null }> {
|
|
174
|
+
const campaigns = await getCampaigns(base, headers, fetcher);
|
|
175
|
+
if (campaigns === null) {
|
|
176
|
+
return { state: "error", amount: 0, error: "Campaign list unavailable." };
|
|
177
|
+
}
|
|
178
|
+
for (const campaign of campaigns) {
|
|
179
|
+
const benefit = asRecord(campaign["benefit"]);
|
|
180
|
+
const kind = String(benefit?.["kind"] ?? "").toUpperCase();
|
|
181
|
+
if (kind !== "CREDITS" && kind !== "") continue;
|
|
182
|
+
const actionType = String(campaign["action_type"] ?? "");
|
|
183
|
+
if (actionType !== "" && actionType !== "CLAIM_BENEFIT") continue;
|
|
184
|
+
const claimStatus = String(campaign["claim_status"] ?? "").toUpperCase();
|
|
185
|
+
if (claimStatus === "CLAIMED") return { state: "already_claimed", amount: 0, error: null };
|
|
186
|
+
if (claimStatus !== "CLAIMABLE") continue;
|
|
187
|
+
const campaignId = String(campaign["campaign_id"] ?? "");
|
|
188
|
+
if (!campaignId) continue;
|
|
189
|
+
const claim = await claimWithHeaders(base, campaignId, headers, fetcher);
|
|
190
|
+
if (claim.state === "claimed") {
|
|
191
|
+
const amount = asNumber(benefit?.["amount"]) ?? claim.amount;
|
|
192
|
+
return { state: "claimed", amount, error: null };
|
|
193
|
+
}
|
|
194
|
+
if (claim.state === "already_claimed") return { state: "already_claimed", amount: 0, error: null };
|
|
195
|
+
if (claim.state === "not_eligible") return { state: "not_eligible", amount: 0, error: null };
|
|
196
|
+
return { state: "error", amount: 0, error: "Campaign claim failed." };
|
|
197
|
+
}
|
|
198
|
+
return { state: "unavailable", amount: 0, error: "No daily CREDITS campaign claimable." };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Probes the daily CREDITS grant under a native device identity.
|
|
203
|
+
*
|
|
204
|
+
* The server binds the grant to the device identity and hides it once a
|
|
205
|
+
* device claimed today, so an account claims under its own persisted device
|
|
206
|
+
* (minted seed). A bound identity that resolves to `claimed`/`already_claimed`
|
|
207
|
+
* is terminal; one that resolves to `unavailable`/`not_eligible` is honest
|
|
208
|
+
* (the device is spent for the day or the person is blocked) and is also
|
|
209
|
+
* terminal — the account must not move to a different device, because that is
|
|
210
|
+
* how devices are re-spun. Only an *error* (stored identity stopped working)
|
|
211
|
+
* falls back to a freshly minted identity.
|
|
212
|
+
*
|
|
213
|
+
* Returns `{ result, identity }` where `identity` is the device that produced
|
|
214
|
+
* a terminal `claimed`/`already_claimed` outcome (or `null`).
|
|
215
|
+
*/
|
|
216
|
+
async function probeDailyClaim(
|
|
217
|
+
base: string,
|
|
218
|
+
credential: string,
|
|
219
|
+
fetcher: Fetcher,
|
|
220
|
+
options: {
|
|
221
|
+
readonly bound?: QoderNativeIdentity | null;
|
|
222
|
+
readonly alt?: QoderNativeIdentity | null;
|
|
223
|
+
} = {},
|
|
224
|
+
): Promise<{ result: { state: DailyCheckinState; amount: number; error: string | null }; identity: QoderNativeIdentity | null }> {
|
|
225
|
+
const basic = await tryClaimDaily(base, campaignHeaders(credential), fetcher);
|
|
226
|
+
if (basic.state === "claimed" || basic.state === "already_claimed") {
|
|
227
|
+
return { result: basic, identity: null };
|
|
228
|
+
}
|
|
229
|
+
if (basic.state === "unavailable") {
|
|
230
|
+
const identities = [options.bound, options.alt].filter((i): i is QoderNativeIdentity => i !== null && i !== undefined);
|
|
231
|
+
for (const identity of identities) {
|
|
232
|
+
const headers = { ...campaignHeaders(credential), ...qoderMachineHeaders(identity) };
|
|
233
|
+
const result = await tryClaimDaily(base, headers, fetcher);
|
|
234
|
+
if (result.state === "claimed" || result.state === "already_claimed") {
|
|
235
|
+
return { result, identity };
|
|
236
|
+
}
|
|
237
|
+
// Only an error falls through to the next identity (e.g. the bound one
|
|
238
|
+
// stopped working). Any honest outcome (unavailable/not_eligible) is
|
|
239
|
+
// terminal.
|
|
240
|
+
if (result.state !== "error") return { result, identity };
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
return { result: basic, identity: null };
|
|
244
|
+
}
|
|
245
|
+
|
|
109
246
|
/**
|
|
110
247
|
* Claims one campaign and returns the resulting state. Mirrors the donor's
|
|
111
248
|
* `claim_campaign` idempotence handling: a 409 or `ALREADY_CLAIMED` body is
|
|
@@ -113,10 +250,10 @@ async function fetchCampaigns(
|
|
|
113
250
|
* same-person block (not a failure of this account's day), and anything else
|
|
114
251
|
* is an error.
|
|
115
252
|
*/
|
|
116
|
-
async function
|
|
253
|
+
async function claimWithHeaders(
|
|
117
254
|
base: string,
|
|
118
255
|
campaignId: string,
|
|
119
|
-
|
|
256
|
+
headers: Record<string, string>,
|
|
120
257
|
fetcher: Fetcher,
|
|
121
258
|
): Promise<{ state: DailyCheckinState; amount: number }> {
|
|
122
259
|
const url = base + CAMPAIGN_CLAIM_PATH.replace("%s", encodeURIComponent(campaignId));
|
|
@@ -124,11 +261,7 @@ async function claimCampaign(
|
|
|
124
261
|
try {
|
|
125
262
|
response = await fetcher(url, {
|
|
126
263
|
method: "POST",
|
|
127
|
-
headers: {
|
|
128
|
-
Authorization: `Bearer ${credential}`,
|
|
129
|
-
"Content-Type": "application/json",
|
|
130
|
-
Accept: "application/json",
|
|
131
|
-
},
|
|
264
|
+
headers: { ...headers, "Content-Type": "application/json" },
|
|
132
265
|
body: "{}",
|
|
133
266
|
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
134
267
|
});
|
|
@@ -233,6 +366,16 @@ export async function fetchQoderDailyCheckin(args: {
|
|
|
233
366
|
readonly credential: string;
|
|
234
367
|
readonly fetcher: Fetcher;
|
|
235
368
|
readonly now?: () => Date;
|
|
369
|
+
/**
|
|
370
|
+
* The account's persisted native device identity. When absent and
|
|
371
|
+
* `nativeIdentityStore` is provided, the first check-in mints one and
|
|
372
|
+
* persists it (see `allocateQoderNativeIdentity`). When the store is absent
|
|
373
|
+
* too (tests), `identities` supplies the identities to probe.
|
|
374
|
+
*/
|
|
375
|
+
readonly nativeIdentity?: NativeIdentityRecord | null;
|
|
376
|
+
readonly nativeIdentityStore?: QoderNativeIdentityStore;
|
|
377
|
+
/** Test-only: override the identity list (defaults to bridge-minted). */
|
|
378
|
+
readonly identities?: readonly QoderNativeIdentity[];
|
|
236
379
|
}): Promise<DailyCheckinResult> {
|
|
237
380
|
const { providerId, accountId, credential, fetcher } = args;
|
|
238
381
|
const baseResult = baseOutcome(providerId, accountId);
|
|
@@ -258,46 +401,76 @@ export async function fetchQoderDailyCheckin(args: {
|
|
|
258
401
|
};
|
|
259
402
|
}
|
|
260
403
|
|
|
261
|
-
// Legacy surface absent: campaign fallback.
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
404
|
+
// Legacy surface absent: campaign fallback. Prefer the account's persisted
|
|
405
|
+
// device (loaded from the store); on first check-in with no stored device,
|
|
406
|
+
// mint one before probing so the account claims under the same identity
|
|
407
|
+
// every day.
|
|
408
|
+
const store = args.nativeIdentityStore;
|
|
409
|
+
let bound: QoderNativeIdentity | null = null;
|
|
410
|
+
let boundRecord: NativeIdentityRecord | null = args.nativeIdentity ?? null;
|
|
411
|
+
if (store) {
|
|
412
|
+
boundRecord = (await store.get(providerId, accountId)) ?? boundRecord;
|
|
265
413
|
}
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
const
|
|
269
|
-
if (
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
alreadySeen = true;
|
|
278
|
-
continue;
|
|
414
|
+
if (boundRecord) {
|
|
415
|
+
// Ensure the stored device is active on this host (replay its seed).
|
|
416
|
+
const active = store ? await ensureQoderNativeIdentity(store, providerId, accountId) : true;
|
|
417
|
+
if (active) {
|
|
418
|
+
bound = {
|
|
419
|
+
machineToken: boundRecord.machineToken,
|
|
420
|
+
machineType: boundRecord.machineType,
|
|
421
|
+
machineCode: boundRecord.machineCode,
|
|
422
|
+
isVm: false,
|
|
423
|
+
source: "runtime-info",
|
|
424
|
+
};
|
|
279
425
|
}
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
426
|
+
}
|
|
427
|
+
if (!bound && store) {
|
|
428
|
+
const minted = await allocateQoderNativeIdentity(store, providerId, accountId);
|
|
429
|
+
if (minted) {
|
|
430
|
+
boundRecord = minted;
|
|
431
|
+
bound = {
|
|
432
|
+
machineToken: minted.machineToken,
|
|
433
|
+
machineType: minted.machineType,
|
|
434
|
+
machineCode: minted.machineCode,
|
|
435
|
+
isVm: false,
|
|
436
|
+
source: "runtime-info",
|
|
437
|
+
};
|
|
291
438
|
}
|
|
292
|
-
|
|
293
|
-
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const alt = args.identities?.[0] ?? null;
|
|
442
|
+
const probe = await probeDailyClaim(base, credential, fetcher, {
|
|
443
|
+
bound,
|
|
444
|
+
alt,
|
|
445
|
+
});
|
|
446
|
+
const probed = probe.result;
|
|
447
|
+
|
|
448
|
+
// If a non-bound identity won (the bound one errored), persist it as the
|
|
449
|
+
// account's device — but only when its token is actually free, so the
|
|
450
|
+
// one-device-per-account invariant holds.
|
|
451
|
+
if (store && probe.identity !== null && probe.identity.machineToken !== bound?.machineToken) {
|
|
452
|
+
const occupied = await store.occupied(accountId);
|
|
453
|
+
if (!occupied.has(probe.identity.machineToken)) {
|
|
454
|
+
try {
|
|
455
|
+
await store.bind(providerId, accountId, {
|
|
456
|
+
machineToken: probe.identity.machineToken,
|
|
457
|
+
machineType: probe.identity.machineType,
|
|
458
|
+
machineCode: probe.identity.machineCode,
|
|
459
|
+
});
|
|
460
|
+
} catch (error) {
|
|
461
|
+
log.warn(`[qoder] could not bind native identity for account=${accountId}`, error as Error);
|
|
462
|
+
}
|
|
294
463
|
}
|
|
295
|
-
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
if (probed.state === "claimed") {
|
|
467
|
+
return { ...baseResult, state: "claimed", credit: probed.amount, streakDays: null };
|
|
468
|
+
}
|
|
469
|
+
if (probed.state === "already_claimed") {
|
|
470
|
+
return { ...baseResult, state: "already_claimed", streakDays: null };
|
|
471
|
+
}
|
|
472
|
+
if (probed.state === "not_eligible") {
|
|
296
473
|
return { ...baseResult, state: "not_eligible", error: "Same-person claim already used today." };
|
|
297
474
|
}
|
|
298
|
-
return {
|
|
299
|
-
...baseResult,
|
|
300
|
-
state: alreadySeen ? "already_claimed" : "unavailable",
|
|
301
|
-
error: alreadySeen ? null : "No daily CREDITS campaign claimable.",
|
|
302
|
-
};
|
|
475
|
+
return { ...baseResult, state: probed.state, error: probed.error };
|
|
303
476
|
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persistence for the Qoder native machine identity (UMID) bound to an
|
|
3
|
+
* account.
|
|
4
|
+
*
|
|
5
|
+
* The daily 100-credit check-in grant is device-scoped upstream, and the
|
|
6
|
+
* server accepts only genuine, non-VM device identities. A device identity is
|
|
7
|
+
* a deterministic function of a seed file (`~/.config/.locale_cfg`), so each
|
|
8
|
+
* account gets its own minted seed — its own "device". The bound record lives
|
|
9
|
+
* in `provider_accounts.native_identity` (plain JSON, not a secret).
|
|
10
|
+
*
|
|
11
|
+
* Uniqueness of the device is enforced by the partial unique index on the
|
|
12
|
+
* extracted `machineToken`; this store keeps the JS side of that invariant
|
|
13
|
+
* and surfaces the DB constraint as the backstop.
|
|
14
|
+
*/
|
|
15
|
+
import { and, eq, isNotNull, ne } from "drizzle-orm";
|
|
16
|
+
import type { CartethyiaDatabase } from "../../../persistence/db-handle";
|
|
17
|
+
import { providerAccounts, type NativeIdentityRecord } from "../../../persistence/schema";
|
|
18
|
+
import {
|
|
19
|
+
mintQoderNativeIdentity,
|
|
20
|
+
replayQoderNativeIdentity,
|
|
21
|
+
type QoderNativeIdentity,
|
|
22
|
+
} from "./qoder-umid";
|
|
23
|
+
|
|
24
|
+
/** Stateful read/write boundary for bound native identities. */
|
|
25
|
+
export interface QoderNativeIdentityStore {
|
|
26
|
+
/**
|
|
27
|
+
* The identity bound to one account, or `undefined` when the account has
|
|
28
|
+
* no identity yet.
|
|
29
|
+
*/
|
|
30
|
+
get(providerId: string, accountId: string): Promise<NativeIdentityRecord | undefined>;
|
|
31
|
+
/**
|
|
32
|
+
* Machine tokens already bound to **other** accounts, so a caller can mint
|
|
33
|
+
* a fresh device without colliding. A bound token is a physical device, and
|
|
34
|
+
* the unique index that backs this spans all `provider_accounts` rows. The
|
|
35
|
+
* account's own bound token is not "occupied" — reusing one's own device is
|
|
36
|
+
* the point.
|
|
37
|
+
*/
|
|
38
|
+
occupied(accountId: string): Promise<ReadonlySet<string>>;
|
|
39
|
+
/** Binds an identity to an account (upsert). Throws on a device collision. */
|
|
40
|
+
bind(providerId: string, accountId: string, record: NativeIdentityRecord): Promise<void>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export class DrizzleQoderNativeIdentityStore implements QoderNativeIdentityStore {
|
|
44
|
+
constructor(private readonly db: CartethyiaDatabase) {}
|
|
45
|
+
|
|
46
|
+
async get(providerId: string, accountId: string): Promise<NativeIdentityRecord | undefined> {
|
|
47
|
+
const rows = await this.db
|
|
48
|
+
.select({ nativeIdentity: providerAccounts.nativeIdentity })
|
|
49
|
+
.from(providerAccounts)
|
|
50
|
+
.where(and(eq(providerAccounts.id, accountId), eq(providerAccounts.providerId, providerId)))
|
|
51
|
+
.limit(1);
|
|
52
|
+
const row = rows[0];
|
|
53
|
+
return row?.nativeIdentity ?? undefined;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async occupied(accountId: string): Promise<ReadonlySet<string>> {
|
|
57
|
+
const rows = await this.db
|
|
58
|
+
.select({ nativeIdentity: providerAccounts.nativeIdentity })
|
|
59
|
+
.from(providerAccounts)
|
|
60
|
+
.where(
|
|
61
|
+
accountId
|
|
62
|
+
? and(isNotNull(providerAccounts.nativeIdentity), ne(providerAccounts.id, accountId))
|
|
63
|
+
: isNotNull(providerAccounts.nativeIdentity),
|
|
64
|
+
);
|
|
65
|
+
const occupied = new Set<string>();
|
|
66
|
+
for (const row of rows) {
|
|
67
|
+
const token = row.nativeIdentity?.machineToken;
|
|
68
|
+
if (token !== undefined && token !== "") occupied.add(token);
|
|
69
|
+
}
|
|
70
|
+
return occupied;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async bind(providerId: string, accountId: string, record: NativeIdentityRecord): Promise<void> {
|
|
74
|
+
await this.db
|
|
75
|
+
.update(providerAccounts)
|
|
76
|
+
.set({ nativeIdentity: record })
|
|
77
|
+
.where(and(eq(providerAccounts.id, accountId), eq(providerAccounts.providerId, providerId)));
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Serializes identity minting across accounts in this process, so two
|
|
83
|
+
* concurrent allocations cannot interleave seed swaps.
|
|
84
|
+
*/
|
|
85
|
+
let allocationQueue: Promise<unknown> = Promise.resolve();
|
|
86
|
+
function withAllocationLock<T>(work: () => Promise<T>): Promise<T> {
|
|
87
|
+
const next = allocationQueue.then(work, work);
|
|
88
|
+
allocationQueue = next.catch(() => undefined);
|
|
89
|
+
return next;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Identity minting injected for tests; defaults to the real bridge. */
|
|
93
|
+
export type NativeIdentityMinter = (accountId: string) => { identity: QoderNativeIdentity; seed: string } | null;
|
|
94
|
+
export type NativeIdentityReplayer = (seed: string, accountId: string) => QoderNativeIdentity | null;
|
|
95
|
+
|
|
96
|
+
const defaultMinter: NativeIdentityMinter = (accountId) => mintQoderNativeIdentity(accountId);
|
|
97
|
+
const defaultReplayer: NativeIdentityReplayer = (seed, accountId) => replayQoderNativeIdentity(seed, accountId);
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Mints and binds a fresh device identity for an account: runs the bridge
|
|
101
|
+
* with a cleared seed, captures the new seed, and persists both. Returns the
|
|
102
|
+
* bound record, or `null` when the bridge fails.
|
|
103
|
+
*
|
|
104
|
+
* `avoidToken` optionally excludes the account's own current token so a
|
|
105
|
+
* regenerate moves to a *different* device.
|
|
106
|
+
*/
|
|
107
|
+
export function allocateQoderNativeIdentity(
|
|
108
|
+
store: QoderNativeIdentityStore,
|
|
109
|
+
providerId: string,
|
|
110
|
+
accountId: string,
|
|
111
|
+
options: {
|
|
112
|
+
readonly avoidToken?: string;
|
|
113
|
+
readonly mint?: NativeIdentityMinter;
|
|
114
|
+
readonly replayer?: NativeIdentityReplayer;
|
|
115
|
+
} = {},
|
|
116
|
+
): Promise<NativeIdentityRecord | null> {
|
|
117
|
+
const mint = options.mint ?? defaultMinter;
|
|
118
|
+
return withAllocationLock(async () => {
|
|
119
|
+
const occupied = await store.occupied(accountId);
|
|
120
|
+
for (let i = 0; i < 6; i += 1) {
|
|
121
|
+
const minted = mint(accountId);
|
|
122
|
+
if (!minted) return null;
|
|
123
|
+
const token = minted.identity.machineToken;
|
|
124
|
+
if (occupied.has(token)) continue; // collision (rare) → mint again
|
|
125
|
+
if (token === options.avoidToken) continue;
|
|
126
|
+
const record: NativeIdentityRecord = {
|
|
127
|
+
machineToken: token,
|
|
128
|
+
machineType: minted.identity.machineType,
|
|
129
|
+
machineCode: minted.identity.machineCode,
|
|
130
|
+
...(minted.seed ? { seed: minted.seed } : {}),
|
|
131
|
+
source: minted.identity.source,
|
|
132
|
+
};
|
|
133
|
+
await store.bind(providerId, accountId, record);
|
|
134
|
+
return record;
|
|
135
|
+
}
|
|
136
|
+
return null;
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Ensures the bound identity is currently active on this host: replays the
|
|
142
|
+
* stored seed so the bridge yields the same token. Returns `false` when the
|
|
143
|
+
* account has no bound identity or replay failed.
|
|
144
|
+
*/
|
|
145
|
+
export async function ensureQoderNativeIdentity(
|
|
146
|
+
store: QoderNativeIdentityStore,
|
|
147
|
+
providerId: string,
|
|
148
|
+
accountId: string,
|
|
149
|
+
replayer: NativeIdentityReplayer = defaultReplayer,
|
|
150
|
+
): Promise<boolean> {
|
|
151
|
+
const record = await store.get(providerId, accountId);
|
|
152
|
+
if (!record) return false;
|
|
153
|
+
if (!record.seed) return true; // no seed captured → assume already active
|
|
154
|
+
const identity = replayer(record.seed, accountId);
|
|
155
|
+
return identity !== null && identity.machineToken === record.machineToken;
|
|
156
|
+
}
|
|
@@ -150,7 +150,18 @@ export interface QoderOAuthState {
|
|
|
150
150
|
|
|
151
151
|
/** Reads the persisted Qoder identity; `undefined` when the account predates it. */
|
|
152
152
|
export function parseQoderOAuthState(value: unknown): QoderOAuthState | undefined {
|
|
153
|
-
|
|
153
|
+
let root = record(value);
|
|
154
|
+
if (root === undefined && typeof value === "string") {
|
|
155
|
+
// The credential loader stores `auth_state` as JSON text in SQLite, so
|
|
156
|
+
// the resolver hands the adapter a string when the cache was cold. Parse
|
|
157
|
+
// it before giving up — a login that works once must work again after the
|
|
158
|
+
// cache is evicted.
|
|
159
|
+
try {
|
|
160
|
+
root = record(JSON.parse(value));
|
|
161
|
+
} catch {
|
|
162
|
+
return undefined;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
154
165
|
if (root === undefined) return undefined;
|
|
155
166
|
const userId = nonEmptyTrimmedString(root.userId);
|
|
156
167
|
const machineId = nonEmptyTrimmedString(root.machineId);
|
|
@@ -324,10 +335,19 @@ export class QoderOAuthClient extends OAuthDeviceFlow {
|
|
|
324
335
|
const expireTime = parseQoderExpiry(body?.expires_at, body?.expires_in);
|
|
325
336
|
const info = await this.fetchUserInfo(accessToken);
|
|
326
337
|
const label = info.name || info.email || undefined;
|
|
327
|
-
// The
|
|
328
|
-
//
|
|
329
|
-
//
|
|
338
|
+
// The poll body carries user_id; the profile endpoint is only a fallback.
|
|
339
|
+
// Dispatch needs the id for COSY signing, and an empty one would store an
|
|
340
|
+
// account that 401s on every request ("missing its user identity") — so a
|
|
341
|
+
// login that cannot resolve the id fails loudly here instead, while the
|
|
342
|
+
// user is still in front of the approval screen and can just retry.
|
|
330
343
|
const resolvedUserId = nonEmptyTrimmedString(body?.user_id) ?? info.id;
|
|
344
|
+
if (resolvedUserId === "") {
|
|
345
|
+
return {
|
|
346
|
+
status: "failed",
|
|
347
|
+
reason:
|
|
348
|
+
"Qoder login could not resolve the user id (poll had no user_id and the profile lookup returned none); try again",
|
|
349
|
+
};
|
|
350
|
+
}
|
|
331
351
|
const result: OAuthExchangeResult = {
|
|
332
352
|
access: accessToken,
|
|
333
353
|
// No refresh grant exists, so the upstream refresh token (when present)
|
|
@@ -78,6 +78,26 @@ export function qoderGatewayCandidates(realm: QoderRealm): readonly string[] {
|
|
|
78
78
|
return [config.gateway, ...config.gatewayFallbacks];
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
+
/**
|
|
82
|
+
* Inference hosts for one realm, ordered by token kind — mirrors the donor's
|
|
83
|
+
* `qoderInferenceBase` (9router): job tokens (`jt-...`) are served by `api2`
|
|
84
|
+
* and get rejected by `api3` with "Login expired" (403), while device tokens
|
|
85
|
+
* (`dt-...`) and everything else use the ordinary gateway set. CN has a
|
|
86
|
+
* single gateway and serves every token kind.
|
|
87
|
+
*/
|
|
88
|
+
export function qoderInferenceCandidates(
|
|
89
|
+
realm: QoderRealm,
|
|
90
|
+
token: string | undefined,
|
|
91
|
+
): readonly string[] {
|
|
92
|
+
const config = QODER_REALMS[realm];
|
|
93
|
+
if (realm === "cn") return [config.gateway];
|
|
94
|
+
if (token !== undefined && token.startsWith("jt-")) {
|
|
95
|
+
const fallbacks = config.gatewayFallbacks.filter((h) => h !== "https://api2.qoder.sh");
|
|
96
|
+
return ["https://api2.qoder.sh", config.gateway, ...fallbacks];
|
|
97
|
+
}
|
|
98
|
+
return [config.gateway, ...config.gatewayFallbacks];
|
|
99
|
+
}
|
|
100
|
+
|
|
81
101
|
/**
|
|
82
102
|
* Model keys that exist in exactly one realm.
|
|
83
103
|
*
|