cartethyia 2.7.6 → 2.7.8
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 +4 -2
- package/README.md +26 -2
- package/dist/dashboard/assets/{BannedPage-B1N603cM.js → BannedPage-DeIbIiBU.js} +1 -1
- package/dist/dashboard/assets/BannedPage-DeIbIiBU.js.gz +0 -0
- package/dist/dashboard/assets/{CliToolDetailPage-9q4_4NoK.js → CliToolDetailPage-BRWKPQPf.js} +2 -2
- package/dist/dashboard/assets/CliToolDetailPage-BRWKPQPf.js.gz +0 -0
- package/dist/dashboard/assets/{CliToolsPage-DwshGtK-.js → CliToolsPage-BP-Y5iHN.js} +1 -1
- package/dist/dashboard/assets/CliToolsPage-BP-Y5iHN.js.gz +0 -0
- package/dist/dashboard/assets/{CombosPage-D762MIkp.js → CombosPage-B7JHozVa.js} +1 -1
- package/dist/dashboard/assets/CombosPage-B7JHozVa.js.gz +0 -0
- package/dist/dashboard/assets/{ConsoleLogPage-KRYjHZsX.js → ConsoleLogPage-GX7iGGcu.js} +1 -1
- package/dist/dashboard/assets/ConsoleLogPage-GX7iGGcu.js.gz +0 -0
- package/dist/dashboard/assets/CustomizationPage-HVonbGEa.js +1 -0
- package/dist/dashboard/assets/CustomizationPage-HVonbGEa.js.gz +0 -0
- package/dist/dashboard/assets/HealthEventsModal-xaut4OJ9.js +1 -0
- package/dist/dashboard/assets/HealthEventsModal-xaut4OJ9.js.gz +0 -0
- package/dist/dashboard/assets/{InjectSystemPromptPanel-d5v3K_Yc.js → InjectSystemPromptPanel-Cv-2Ol_K.js} +2 -2
- package/dist/dashboard/assets/InjectSystemPromptPanel-Cv-2Ol_K.js.gz +0 -0
- package/dist/dashboard/assets/{LoginPage-Ccv4tjNS.js → LoginPage-bKfz_b1K.js} +1 -1
- package/dist/dashboard/assets/LoginPage-bKfz_b1K.js.gz +0 -0
- package/dist/dashboard/assets/{ModelPicker-C6WlpU5E.js → ModelPicker-B-WQxx_H.js} +1 -1
- package/dist/dashboard/assets/ModelPicker-B-WQxx_H.js.gz +0 -0
- package/dist/dashboard/assets/{OverviewPage-DN9fYNWA.js → OverviewPage-Z9pJZFo8.js} +1 -1
- package/dist/dashboard/assets/OverviewPage-Z9pJZFo8.js.gz +0 -0
- package/dist/dashboard/assets/ProviderDetailPage-CMeDBxR1.js +7 -0
- package/dist/dashboard/assets/ProviderDetailPage-CMeDBxR1.js.gz +0 -0
- package/dist/dashboard/assets/ProvidersPage-BXPdOpG4.js +1 -0
- package/dist/dashboard/assets/ProvidersPage-BXPdOpG4.js.gz +0 -0
- package/dist/dashboard/assets/{ProxyPage-DxjvUzSD.js → ProxyPage-emlTuxz6.js} +1 -1
- package/dist/dashboard/assets/ProxyPage-emlTuxz6.js.gz +0 -0
- package/dist/dashboard/assets/QuotaPage-CMJKNYWb.js +1 -0
- package/dist/dashboard/assets/QuotaPage-CMJKNYWb.js.gz +0 -0
- package/dist/dashboard/assets/{SettingsPage-BW4AKj2H.js → SettingsPage-BSy7EwGo.js} +1 -1
- package/dist/dashboard/assets/SettingsPage-BSy7EwGo.js.gz +0 -0
- package/dist/dashboard/assets/{SetupPage-44BZrxFC.js → SetupPage-CUZrKs9r.js} +1 -1
- package/dist/dashboard/assets/SetupPage-CUZrKs9r.js.gz +0 -0
- package/dist/dashboard/assets/{StudioPage-DRPavCdU.js → StudioPage-jWmUdDpy.js} +2 -2
- package/dist/dashboard/assets/StudioPage-jWmUdDpy.js.gz +0 -0
- package/dist/dashboard/assets/{UsagePage-B7M7LvMM.js → UsagePage-5F9tGVGG.js} +2 -2
- package/dist/dashboard/assets/UsagePage-5F9tGVGG.js.gz +0 -0
- package/dist/dashboard/assets/app-BEsU8hKs.css +2 -0
- package/dist/dashboard/assets/app-BEsU8hKs.css.gz +0 -0
- package/dist/dashboard/assets/{badge-BaX2GTrn.js → badge-DyI_mJfi.js} +1 -1
- package/dist/dashboard/assets/badge-DyI_mJfi.js.gz +0 -0
- package/dist/dashboard/assets/{banner-DFH4Bkte.js → banner-B-yXtCqb.js} +1 -1
- package/dist/dashboard/assets/banner-B-yXtCqb.js.gz +0 -0
- package/dist/dashboard/assets/{card-CxDw0lK6.js → card-_HwAEqFF.js} +1 -1
- package/dist/dashboard/assets/card-_HwAEqFF.js.gz +0 -0
- package/dist/dashboard/assets/{clipboard-button-DfS1ntiq.js → clipboard-button-ByjCk0o9.js} +1 -1
- package/dist/dashboard/assets/clipboard-button-ByjCk0o9.js.gz +0 -0
- package/dist/dashboard/assets/{dialog-BK-NRr5u.js → dialog-BZhOlfz-.js} +1 -1
- package/dist/dashboard/assets/dialog-BZhOlfz-.js.gz +0 -0
- package/dist/dashboard/assets/{drawer-QqPul6sC.js → drawer-BADqIRYP.js} +1 -1
- package/dist/dashboard/assets/drawer-BADqIRYP.js.gz +0 -0
- package/dist/dashboard/assets/{entry-BMlYJVFj.js → entry-COaC-irN.js} +1 -1
- package/dist/dashboard/assets/entry-COaC-irN.js.gz +0 -0
- package/dist/dashboard/assets/{entry-D6WvojeD.js → entry-Ca7LSCet.js} +3 -3
- package/dist/dashboard/assets/entry-Ca7LSCet.js.gz +0 -0
- package/dist/dashboard/assets/{entry-Dmx7gBiN.js → entry-PDd27uCd.js} +1 -1
- package/dist/dashboard/assets/entry-PDd27uCd.js.gz +0 -0
- package/dist/dashboard/assets/formatters-DcHJyRiu.js +1 -0
- package/dist/dashboard/assets/formatters-DcHJyRiu.js.gz +0 -0
- package/dist/dashboard/assets/{index-Ko4dLRnV.js → index-CgKmFngv.js} +2 -2
- package/dist/dashboard/assets/index-CgKmFngv.js.gz +0 -0
- package/dist/dashboard/assets/{inline-CHJQwxLa.js → inline-B2BirKry.js} +1 -1
- package/dist/dashboard/assets/inline-B2BirKry.js.gz +0 -0
- package/dist/dashboard/assets/{input-BlmlizVV.js → input-D-qV9dKY.js} +1 -1
- package/dist/dashboard/assets/input-D-qV9dKY.js.gz +0 -0
- package/dist/dashboard/assets/{layout-Bz93I-yE.js → layout-B7tVWhtV.js} +1 -1
- package/dist/dashboard/assets/layout-B7tVWhtV.js.gz +0 -0
- package/dist/dashboard/assets/{list-row-Ml16KCfu.js → list-row-CtkGE3bF.js} +1 -1
- package/dist/dashboard/assets/list-row-CtkGE3bF.js.gz +0 -0
- package/dist/dashboard/assets/{live-7_QpnYGu.js → live-DGizF06-.js} +1 -1
- package/dist/dashboard/assets/live-DGizF06-.js.gz +0 -0
- package/dist/dashboard/assets/{network-CuMDaTzT.js → network-C9UE-bOr.js} +1 -1
- package/dist/dashboard/assets/network-C9UE-bOr.js.gz +0 -0
- package/dist/dashboard/assets/{numbers-CoqpaDMx.js → numbers-BnAd22a_.js} +1 -1
- package/dist/dashboard/assets/numbers-BnAd22a_.js.gz +0 -0
- package/dist/dashboard/assets/{pagination-B5aOoweZ.js → pagination-BAywyMzS.js} +1 -1
- package/dist/dashboard/assets/pagination-BAywyMzS.js.gz +0 -0
- package/dist/dashboard/assets/{select-BHqpd-bg.js → select-BCOcv8WK.js} +1 -1
- package/dist/dashboard/assets/select-BCOcv8WK.js.gz +0 -0
- package/dist/dashboard/assets/{stack-j7cXjgYX.js → stack-ByKt3Q1g.js} +1 -1
- package/dist/dashboard/assets/stack-ByKt3Q1g.js.gz +0 -0
- package/dist/dashboard/assets/{state-DHmEnLa2.js → state-DXbGTx7N.js} +1 -1
- package/dist/dashboard/assets/state-DXbGTx7N.js.gz +0 -0
- package/dist/dashboard/assets/{switch-DmZcyTIC.js → switch-ZeTsW623.js} +1 -1
- package/dist/dashboard/assets/switch-ZeTsW623.js.gz +0 -0
- package/dist/dashboard/index.html +2 -2
- package/dist/dashboard/index.html.gz +0 -0
- package/dist/dashboard/precompressed-assets.json +44 -44
- package/migrations/full-postgres/0043_add_provider_custom_user_agent.sql +14 -0
- package/migrations/lite-sqlite3/0043_add_provider_custom_user_agent.sql +10 -0
- package/package.json +3 -1
- package/src/config/security.ts +1 -1
- package/src/config/spec.ts +13 -6
- package/src/console/cli-tools/cartethyia-websearch-plugin.ts +186 -0
- package/src/console/cli-tools/injectors/opencode.ts +42 -14
- package/src/console/providers/catalog/contracts.ts +4 -0
- package/src/console/providers/catalog/routes.ts +1 -0
- package/src/console/providers/detail/routes.ts +8 -0
- package/src/console/providers/detail/store.ts +4 -0
- package/src/console/quota/account-quota-tenant.ts +74 -0
- package/src/observability/telemetry-buffer.ts +2 -1
- package/src/persistence/boot.ts +13 -4
- package/src/persistence/schema.ts +7 -1
- package/src/protocol/canonical-model.ts +7 -0
- package/src/protocol/request/gemini.ts +20 -1
- package/src/protocol/tool-identity.ts +15 -1
- package/src/providers/default-registry.ts +8 -2
- package/src/providers/discovery/discovery-types.ts +18 -0
- package/src/providers/discovery/probe-phases.ts +45 -0
- package/src/providers/discovery/probing-service.ts +41 -6
- package/src/providers/integrations/antigravity/antigravity-protocol.ts +20 -2
- package/src/providers/integrations/antigravity/antigravity.ts +24 -0
- package/src/providers/integrations/codex/codex.ts +28 -1
- package/src/providers/integrations/qoder/chat-proto.ts +328 -0
- package/src/providers/integrations/qoder/claim-qoder-credits.ts +258 -0
- package/src/providers/integrations/qoder/qoder-adapter.ts +275 -0
- package/src/providers/integrations/qoder/qoder-auth.ts +201 -0
- package/src/providers/integrations/qoder/qoder-body.ts +78 -0
- package/src/providers/integrations/qoder/qoder-catalog.ts +5 -1
- package/src/providers/integrations/qoder/qoder-chat.ts +514 -0
- package/src/providers/integrations/qoder/qoder-cosy.ts +162 -0
- package/src/providers/integrations/qoder/qoder-grpc.ts +667 -0
- package/src/providers/integrations/qoder/qoder-ide.ts +584 -0
- package/src/providers/integrations/qoder/qoder-oauth.ts +4 -4
- package/src/providers/integrations/qoder/qoder-quota.ts +1 -1
- package/src/providers/integrations/qoder/qoder-realm.ts +40 -4
- package/src/providers/integrations/qoder/qoder-types.ts +51 -0
- package/src/providers/integrations/qoder/qoder-umid.ts +123 -8
- package/src/providers/integrations/qoder/qoder.ts +147 -1889
- package/src/providers/operations/byok-wire-profile.ts +9 -12
- package/src/providers/provider-contracts.ts +7 -0
- package/src/providers/provider-usage.ts +9 -6
- package/src/security/abuse.ts +5 -5
- package/src/transport/dispatch/upstream.ts +4 -2
- package/src/transport/routing/route-catalog.ts +8 -0
- package/src/transport/routing/route-model.ts +4 -0
- package/src/transport/routing/router.ts +8 -117
- package/dist/dashboard/assets/BannedPage-B1N603cM.js.gz +0 -0
- package/dist/dashboard/assets/CliToolDetailPage-9q4_4NoK.js.gz +0 -0
- package/dist/dashboard/assets/CliToolsPage-DwshGtK-.js.gz +0 -0
- package/dist/dashboard/assets/CombosPage-D762MIkp.js.gz +0 -0
- package/dist/dashboard/assets/ConsoleLogPage-KRYjHZsX.js.gz +0 -0
- package/dist/dashboard/assets/CustomizationPage-DXsxk5uM.js +0 -1
- package/dist/dashboard/assets/CustomizationPage-DXsxk5uM.js.gz +0 -0
- package/dist/dashboard/assets/HealthEventsModal-BEjAjD4F.js +0 -1
- package/dist/dashboard/assets/HealthEventsModal-BEjAjD4F.js.gz +0 -0
- package/dist/dashboard/assets/InjectSystemPromptPanel-d5v3K_Yc.js.gz +0 -0
- package/dist/dashboard/assets/LoginPage-Ccv4tjNS.js.gz +0 -0
- package/dist/dashboard/assets/ModelPicker-C6WlpU5E.js.gz +0 -0
- package/dist/dashboard/assets/OverviewPage-DN9fYNWA.js.gz +0 -0
- package/dist/dashboard/assets/ProviderDetailPage-BY0ieUgz.js +0 -5
- package/dist/dashboard/assets/ProviderDetailPage-BY0ieUgz.js.gz +0 -0
- package/dist/dashboard/assets/ProvidersPage-DNsbfea2.js +0 -1
- package/dist/dashboard/assets/ProvidersPage-DNsbfea2.js.gz +0 -0
- package/dist/dashboard/assets/ProxyPage-DxjvUzSD.js.gz +0 -0
- package/dist/dashboard/assets/QuotaPage-OaHScjpX.js +0 -1
- package/dist/dashboard/assets/QuotaPage-OaHScjpX.js.gz +0 -0
- package/dist/dashboard/assets/SettingsPage-BW4AKj2H.js.gz +0 -0
- package/dist/dashboard/assets/SetupPage-44BZrxFC.js.gz +0 -0
- package/dist/dashboard/assets/StudioPage-DRPavCdU.js.gz +0 -0
- package/dist/dashboard/assets/UsagePage-B7M7LvMM.js.gz +0 -0
- package/dist/dashboard/assets/app-mweRcsKp.css +0 -2
- package/dist/dashboard/assets/app-mweRcsKp.css.gz +0 -0
- package/dist/dashboard/assets/badge-BaX2GTrn.js.gz +0 -0
- package/dist/dashboard/assets/banner-DFH4Bkte.js.gz +0 -0
- package/dist/dashboard/assets/card-CxDw0lK6.js.gz +0 -0
- package/dist/dashboard/assets/clipboard-button-DfS1ntiq.js.gz +0 -0
- package/dist/dashboard/assets/dialog-BK-NRr5u.js.gz +0 -0
- package/dist/dashboard/assets/drawer-QqPul6sC.js.gz +0 -0
- package/dist/dashboard/assets/entry-BMlYJVFj.js.gz +0 -0
- package/dist/dashboard/assets/entry-D6WvojeD.js.gz +0 -0
- package/dist/dashboard/assets/entry-Dmx7gBiN.js.gz +0 -0
- package/dist/dashboard/assets/formatters-u7JPB5BX.js +0 -1
- package/dist/dashboard/assets/formatters-u7JPB5BX.js.gz +0 -0
- package/dist/dashboard/assets/index-Ko4dLRnV.js.gz +0 -0
- package/dist/dashboard/assets/inline-CHJQwxLa.js.gz +0 -0
- package/dist/dashboard/assets/input-BlmlizVV.js.gz +0 -0
- package/dist/dashboard/assets/layout-Bz93I-yE.js.gz +0 -0
- package/dist/dashboard/assets/list-row-Ml16KCfu.js.gz +0 -0
- package/dist/dashboard/assets/live-7_QpnYGu.js.gz +0 -0
- package/dist/dashboard/assets/network-CuMDaTzT.js.gz +0 -0
- package/dist/dashboard/assets/numbers-CoqpaDMx.js.gz +0 -0
- package/dist/dashboard/assets/pagination-B5aOoweZ.js.gz +0 -0
- package/dist/dashboard/assets/select-BHqpd-bg.js.gz +0 -0
- package/dist/dashboard/assets/stack-j7cXjgYX.js.gz +0 -0
- package/dist/dashboard/assets/state-DHmEnLa2.js.gz +0 -0
- package/dist/dashboard/assets/switch-DmZcyTIC.js.gz +0 -0
- package/src/providers/integrations/qoder/qoder-text-tools.ts +0 -277
- /package/dist/dashboard/assets/{app-DRnQLhsc.js → app-CTxoqyYB.js} +0 -0
- /package/dist/dashboard/assets/{app-DRnQLhsc.js.gz → app-CTxoqyYB.js.gz} +0 -0
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Manual Qoder daily-credits claim (operator-triggered).
|
|
3
|
+
*
|
|
4
|
+
* Mirrors the ZCode gift-claim flow: an explicit `POST /accounts/:id/claim-qoder-credits`
|
|
5
|
+
* resolves the account's credential, ensures a native UMID identity, lists the
|
|
6
|
+
* sash campaigns, and claims the CREDITS `CLAIM_BENEFIT` campaign by its
|
|
7
|
+
* `campaignId` UUID. The CREDITS campaign only surfaces when the real UMID
|
|
8
|
+
* headers are sent (generated identities keep it hidden — verified live
|
|
9
|
+
* 2026-11-04, see research doc Part F).
|
|
10
|
+
*
|
|
11
|
+
* Credential kinds:
|
|
12
|
+
* - `dt-…` (device token) — used directly as the Bearer.
|
|
13
|
+
* - `pt-…` (personal access token) — exchanged first to a job token via
|
|
14
|
+
* `POST /api/v1/jobToken/exchange` (the sash surface rejects raw PATs with
|
|
15
|
+
* 401; verified live 2026-11-04).
|
|
16
|
+
*/
|
|
17
|
+
import type { CartethyiaDatabase } from "../../../persistence/db-handle";
|
|
18
|
+
import type { ValidatedFetch } from "../../../network/outbound-fetch";
|
|
19
|
+
import { getQoderVersion } from "../../operations/client-versions";
|
|
20
|
+
import { qoderRealmForProvider, QODER_REALMS } from "./qoder-realm";
|
|
21
|
+
import {
|
|
22
|
+
buildCampaignHeaders,
|
|
23
|
+
} from "./qoder-checkin";
|
|
24
|
+
import { qoderMachineHeaders } from "./qoder-umid";
|
|
25
|
+
import {
|
|
26
|
+
DrizzleQoderNativeIdentityStore,
|
|
27
|
+
allocateQoderNativeIdentity,
|
|
28
|
+
} from "./qoder-native-identity-store";
|
|
29
|
+
|
|
30
|
+
/** Result shape mirrors the ZCode gift claim so the console can reuse one UI. */
|
|
31
|
+
export interface QoderCreditClaimResult {
|
|
32
|
+
readonly state: "claimed" | "already_claimed" | "nothing" | "error";
|
|
33
|
+
readonly amount: number;
|
|
34
|
+
readonly detail: string | null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const QODER_CREDIT_PROVIDER_IDS = ["qoder", "qodercn"] as const;
|
|
38
|
+
|
|
39
|
+
const PAT_PREFIX = "pt-";
|
|
40
|
+
|
|
41
|
+
function asRecord(value: unknown): Record<string, unknown> | null {
|
|
42
|
+
if (typeof value !== "object" || value === null) return null;
|
|
43
|
+
return value as Record<string, unknown>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function asNumber(value: unknown): number | null {
|
|
47
|
+
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
48
|
+
if (typeof value === "string" && value.trim() !== "") {
|
|
49
|
+
const parsed = Number(value);
|
|
50
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function normalizeCampaign(raw: Record<string, unknown>): Record<string, unknown> {
|
|
56
|
+
const benefit = asRecord(raw["benefit"]);
|
|
57
|
+
return {
|
|
58
|
+
campaign_id: String(raw["campaignId"] ?? raw["campaign_id"] ?? ""),
|
|
59
|
+
campaign_key: String(raw["campaignKey"] ?? raw["campaign_key"] ?? ""),
|
|
60
|
+
action_type: String(raw["actionType"] ?? raw["action_type"] ?? ""),
|
|
61
|
+
claim_status: String(raw["claimStatus"] ?? raw["claim_status"] ?? ""),
|
|
62
|
+
...(benefit !== null ? { benefit } : {}),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Exchanges a PAT (`pt-…`) for a job token (`jt-…`) on the realm's openapi
|
|
68
|
+
* host. Returns the job token, or `null` when the exchange fails.
|
|
69
|
+
*
|
|
70
|
+
* The real client binds the machine identity at exchange time
|
|
71
|
+
* (`getMachineIdentityRequestFields`), so `machine_id` + `machine_token` are
|
|
72
|
+
* sent when available — the job token is then bound to that machine. The
|
|
73
|
+
* User-Agent and Cosy version mirror the real client (`qoder/<version>`).
|
|
74
|
+
*/
|
|
75
|
+
async function exchangePatToJobToken(
|
|
76
|
+
base: string,
|
|
77
|
+
pat: string,
|
|
78
|
+
fetcher: ValidatedFetch,
|
|
79
|
+
machine?: { readonly id?: string; readonly token?: string },
|
|
80
|
+
): Promise<string | null> {
|
|
81
|
+
try {
|
|
82
|
+
const body: Record<string, unknown> = { personal_token: pat };
|
|
83
|
+
if (machine?.id) body.machine_id = machine.id;
|
|
84
|
+
if (machine?.token) body.machine_token = machine.token;
|
|
85
|
+
const version = getQoderVersion();
|
|
86
|
+
const res = await fetcher(`${base}/api/v1/jobToken/exchange`, {
|
|
87
|
+
method: "POST",
|
|
88
|
+
headers: {
|
|
89
|
+
"Content-Type": "application/json",
|
|
90
|
+
"User-Agent": `qoder/${version}`,
|
|
91
|
+
"Cosy-ClientType": "5",
|
|
92
|
+
"Cosy-Version": version,
|
|
93
|
+
},
|
|
94
|
+
body: JSON.stringify(body),
|
|
95
|
+
signal: AbortSignal.timeout(25_000),
|
|
96
|
+
});
|
|
97
|
+
if (!res.ok) return null;
|
|
98
|
+
const payload: unknown = await res.json().catch(() => null);
|
|
99
|
+
const rec = asRecord(payload);
|
|
100
|
+
const token = String(rec?.["token"] ?? rec?.["device_token"] ?? rec?.["access_token"] ?? "");
|
|
101
|
+
return token.length > 0 ? token : null;
|
|
102
|
+
} catch {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Reads the platform machine id from the real client's cache when present
|
|
109
|
+
* (`%APPDATA%/Qoder/cache/machine_token.json` → `id`). Returns `undefined`
|
|
110
|
+
* when unavailable; callers fall back to the machine token.
|
|
111
|
+
*/
|
|
112
|
+
function readPlatformMachineId(): string | undefined {
|
|
113
|
+
try {
|
|
114
|
+
const { existsSync, readFileSync } = require("node:fs") as typeof import("node:fs");
|
|
115
|
+
const root = process.env.APPDATA ?? "";
|
|
116
|
+
const path = root ? `${root}/Qoder/cache/machine_token.json` : "";
|
|
117
|
+
if (!path || !existsSync(path)) return undefined;
|
|
118
|
+
const rec = JSON.parse(readFileSync(path, "utf8")) as { id?: string };
|
|
119
|
+
return typeof rec.id === "string" && rec.id.length > 0 ? rec.id : undefined;
|
|
120
|
+
} catch {
|
|
121
|
+
return undefined;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export async function claimQoderCredits(args: {
|
|
126
|
+
readonly db: CartethyiaDatabase;
|
|
127
|
+
readonly providerId: string;
|
|
128
|
+
readonly accountId: string;
|
|
129
|
+
readonly credential: string;
|
|
130
|
+
readonly fetcher: ValidatedFetch;
|
|
131
|
+
}): Promise<QoderCreditClaimResult> {
|
|
132
|
+
const { db, providerId, accountId, credential, fetcher } = args;
|
|
133
|
+
if (!credential) return { state: "error", amount: 0, detail: "Account has no stored credential." };
|
|
134
|
+
|
|
135
|
+
const realm = qoderRealmForProvider(providerId);
|
|
136
|
+
const base = QODER_REALMS[realm].openapi;
|
|
137
|
+
|
|
138
|
+
// Ensure a native UMID identity exists and is bound to this account.
|
|
139
|
+
const store = new DrizzleQoderNativeIdentityStore(db);
|
|
140
|
+
let bound = await store.get(providerId, accountId);
|
|
141
|
+
if (!bound) {
|
|
142
|
+
const minted = await allocateQoderNativeIdentity(store, providerId, accountId);
|
|
143
|
+
if (!minted) return { state: "error", amount: 0, detail: "Could not mint a UMID identity for this account." };
|
|
144
|
+
bound = minted;
|
|
145
|
+
}
|
|
146
|
+
const identity = {
|
|
147
|
+
machineToken: bound.machineToken,
|
|
148
|
+
machineType: bound.machineType,
|
|
149
|
+
machineCode: bound.machineCode,
|
|
150
|
+
isVm: false,
|
|
151
|
+
source: bound.source ?? "runtime-info",
|
|
152
|
+
} as const;
|
|
153
|
+
// The upstream binds COSY signatures to the platform machine id (UUID on
|
|
154
|
+
// Windows, serial-hex elsewhere). Use the real client's cached id when
|
|
155
|
+
// present; fall back to the machine token.
|
|
156
|
+
const machineId = readPlatformMachineId();
|
|
157
|
+
const umidHeaders = qoderMachineHeaders(identity, machineId);
|
|
158
|
+
|
|
159
|
+
// PATs must be exchanged to a job token before they are accepted by the sash
|
|
160
|
+
// surface (raw `pt-…` → 401). Device tokens (`dt-…`) are used directly.
|
|
161
|
+
// Exchange binds the machine identity like the real client.
|
|
162
|
+
let bearer = credential;
|
|
163
|
+
if (credential.startsWith(PAT_PREFIX)) {
|
|
164
|
+
const jt = await exchangePatToJobToken(base, credential, fetcher, {
|
|
165
|
+
...(machineId !== undefined ? { id: machineId } : {}),
|
|
166
|
+
token: identity.machineToken,
|
|
167
|
+
});
|
|
168
|
+
if (!jt) return { state: "error", amount: 0, detail: "Could not exchange PAT for a job token." };
|
|
169
|
+
bearer = jt;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const headers = {
|
|
173
|
+
...buildCampaignHeaders(bearer),
|
|
174
|
+
...umidHeaders,
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
// 1. List campaigns.
|
|
178
|
+
let campaigns: Array<Record<string, unknown>> = [];
|
|
179
|
+
try {
|
|
180
|
+
const res = await fetcher(`${base}/sash/api/v1/me/campaigns`, {
|
|
181
|
+
method: "GET",
|
|
182
|
+
headers,
|
|
183
|
+
signal: AbortSignal.timeout(25_000),
|
|
184
|
+
});
|
|
185
|
+
if (!res.ok) {
|
|
186
|
+
return { state: "error", amount: 0, detail: `Campaign list failed (HTTP ${res.status}).` };
|
|
187
|
+
}
|
|
188
|
+
const payload: unknown = await res.json().catch(() => null);
|
|
189
|
+
const rec = asRecord(payload);
|
|
190
|
+
const list = Array.isArray(rec?.["campaigns"])
|
|
191
|
+
? rec!["campaigns"]
|
|
192
|
+
: Array.isArray(payload)
|
|
193
|
+
? payload
|
|
194
|
+
: null;
|
|
195
|
+
if (Array.isArray(list)) campaigns = (list as Array<Record<string, unknown>>).map(normalizeCampaign);
|
|
196
|
+
} catch {
|
|
197
|
+
return { state: "error", amount: 0, detail: "Campaign list request failed." };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// 2. Pick the CREDITS CLAIM_BENEFIT campaign that is not already claimed.
|
|
201
|
+
const target = campaigns.find((c) => {
|
|
202
|
+
if (String(c["claim_status"] ?? "").toUpperCase() === "CLAIMED") return false;
|
|
203
|
+
const action = String(c["action_type"] ?? "").toUpperCase();
|
|
204
|
+
const kind = String(asRecord(c["benefit"])?.["kind"] ?? "").toUpperCase();
|
|
205
|
+
return action === "CLAIM_BENEFIT" && kind === "CREDITS";
|
|
206
|
+
});
|
|
207
|
+
if (!target) {
|
|
208
|
+
const anyClaimed = campaigns.some((c) => {
|
|
209
|
+
const action = String(c["action_type"] ?? "").toUpperCase();
|
|
210
|
+
const kind = String(asRecord(c["benefit"])?.["kind"] ?? "").toUpperCase();
|
|
211
|
+
return action === "CLAIM_BENEFIT" && kind === "CREDITS" && String(c["claim_status"] ?? "").toUpperCase() === "CLAIMED";
|
|
212
|
+
});
|
|
213
|
+
return anyClaimed
|
|
214
|
+
? { state: "already_claimed", amount: 0, detail: null }
|
|
215
|
+
: { state: "nothing", amount: 0, detail: null };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const campaignId = String(target["campaign_id"] ?? "");
|
|
219
|
+
if (!campaignId) return { state: "error", amount: 0, detail: "Campaign has no id." };
|
|
220
|
+
|
|
221
|
+
// 3. Claim.
|
|
222
|
+
try {
|
|
223
|
+
const res = await fetcher(`${base}/sash/api/v1/me/campaigns/${encodeURIComponent(campaignId)}/claim`, {
|
|
224
|
+
method: "POST",
|
|
225
|
+
headers: { ...headers, "Content-Type": "application/json" },
|
|
226
|
+
body: "{}",
|
|
227
|
+
signal: AbortSignal.timeout(25_000),
|
|
228
|
+
});
|
|
229
|
+
const raw = await res.text().catch(() => "");
|
|
230
|
+
const payload: unknown = raw ? safeParse(raw) : null;
|
|
231
|
+
const rec = asRecord(payload);
|
|
232
|
+
const status = String(rec?.["status"] ?? "").toUpperCase();
|
|
233
|
+
const failure = String(rec?.["failureCode"] ?? "").toUpperCase();
|
|
234
|
+
const success = rec?.["success"] !== false;
|
|
235
|
+
const benefit = asRecord(rec?.["benefit"]);
|
|
236
|
+
const amount = asNumber(rec?.["amount"] ?? benefit?.["amount"]) ?? 100;
|
|
237
|
+
if (res.status === 409 || failure === "ALREADY_CLAIMED" || (status === "CLAIMED" && failure === "REPLAYED")) {
|
|
238
|
+
return { state: "already_claimed", amount: 0, detail: null };
|
|
239
|
+
}
|
|
240
|
+
if (failure === "SAME_PERSON_ALREADY_CLAIMED" || status === "BLOCKED") {
|
|
241
|
+
return { state: "already_claimed", amount: 0, detail: "Same-person claim already used for this campaign." };
|
|
242
|
+
}
|
|
243
|
+
if (res.ok && success && (status === "CLAIMED" || status === "GRANTED" || status === "SUCCESS")) {
|
|
244
|
+
return { state: "claimed", amount, detail: null };
|
|
245
|
+
}
|
|
246
|
+
return { state: "error", amount: 0, detail: `Claim failed (${res.status})` };
|
|
247
|
+
} catch {
|
|
248
|
+
return { state: "error", amount: 0, detail: "Claim request failed." };
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function safeParse(raw: string): unknown {
|
|
253
|
+
try {
|
|
254
|
+
return JSON.parse(raw);
|
|
255
|
+
} catch {
|
|
256
|
+
return null;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Qoder adapter — transport selection, credential resolution, dispatch.
|
|
3
|
+
*
|
|
4
|
+
* Surface contract (2026-11-05):
|
|
5
|
+
* - `grpc` (default, production): `model.chat.ChatService/ChatCompletionStream`
|
|
6
|
+
* — the only proven carrier of real 1M-token context (HTTP IDE caps at
|
|
7
|
+
* ~256 KiB). Serves billed rows; a free-tier row (`lite`) is refused there
|
|
8
|
+
* (`quota exceeded` 116) and must use legacy COSY.
|
|
9
|
+
* - `cosy` (free/lite + forced legacy): `/algo/.../agent_chat_generation` —
|
|
10
|
+
* the only surface that accepts free-tier rows. Signing is the COSY Bearer
|
|
11
|
+
* scheme (AES/RSA + alphabet-scrambled body).
|
|
12
|
+
* - `ide` (`/model/v1/chat/completions`): **probe/testing only — never
|
|
13
|
+
* production.** It is the only surface that echoes strategy metadata
|
|
14
|
+
* (`raw_usage` → provider_model/target) and it streams immediately, but its
|
|
15
|
+
* ~256 KiB body cap cannot carry the 1M window the roster declares. Dispatch
|
|
16
|
+
* reaches it only when `probe_surface: "ide"` is set (discovery probes) or
|
|
17
|
+
* the option `surface: "ide"` is forced explicitly for local testing.
|
|
18
|
+
*/
|
|
19
|
+
import { randomUUID } from "node:crypto";
|
|
20
|
+
import { GatewayError } from "../../../protocol/gateway-error";
|
|
21
|
+
import type { CanonicalEvent, CanonicalRequest } from "../../../protocol/canonical-model";
|
|
22
|
+
import {
|
|
23
|
+
assertWireFamily,
|
|
24
|
+
readCredentialSecret,
|
|
25
|
+
type ProviderAdapter,
|
|
26
|
+
type ProviderDispatchContext,
|
|
27
|
+
type ProviderDispatchTarget,
|
|
28
|
+
type ProviderId,
|
|
29
|
+
} from "../../provider-contracts";
|
|
30
|
+
import { abortGatewayError, createUpstreamDeadlineLifecycle } from "../../operations/upstream-deadline";
|
|
31
|
+
import { getQoderVersion } from "../../operations/client-versions";
|
|
32
|
+
import { qoderMachineHostname, qoderMachineOs } from "../../operations/cli-platform";
|
|
33
|
+
import { mapUpstreamHttpError } from "../../../transport/failure-policy";
|
|
34
|
+
import { QODER_CN_MODEL_CONFIGS, QODER_INTL_MODEL_CONFIGS, qoderDiscoveredMaxContexts } from "./qoder-catalog";
|
|
35
|
+
import { isQoderVirtualModel, qoderDispatchModelId } from "./qoder-virtual";
|
|
36
|
+
import { isQoderPat, qoderOAuthAuth, qoderSessionId, resolveQoderAuth } from "./qoder-auth";
|
|
37
|
+
import { buildQoderRequest, callQoder } from "./qoder-chat";
|
|
38
|
+
import { buildQoderGrpcRequest, qoderGrpcMetadata, qoderGrpcToCanonicalEvents } from "./qoder-grpc";
|
|
39
|
+
import { buildQoderIdeRequest, qoderBodyToCanonicalEvents } from "./qoder-ide";
|
|
40
|
+
import {
|
|
41
|
+
QODER_CHAT_PATH,
|
|
42
|
+
QODER_PROVIDER_ID,
|
|
43
|
+
qoderIdeChatUrl,
|
|
44
|
+
qoderInferenceCandidates,
|
|
45
|
+
type QoderRealm,
|
|
46
|
+
} from "./qoder-realm";
|
|
47
|
+
import type { QoderModelConfig } from "./qoder-types";
|
|
48
|
+
|
|
49
|
+
interface QoderAdapterOptions {
|
|
50
|
+
readonly fetch?: typeof fetch;
|
|
51
|
+
/** Which deployment this adapter dispatches to. Defaults to intl. */
|
|
52
|
+
readonly realm?: QoderRealm;
|
|
53
|
+
/** Bundled provider id, so the adapter's own id matches the route's. */
|
|
54
|
+
readonly providerId?: string;
|
|
55
|
+
/**
|
|
56
|
+
* Which chat transport to dispatch on.
|
|
57
|
+
*
|
|
58
|
+
* `grpc` (default) uses the gRPC model-server surface
|
|
59
|
+
* (`model.chat.ChatService/ChatCompletionStream`) — the only proven carrier
|
|
60
|
+
* of real 1M-token context (the HTTP IDE surface caps at ~256 KiB). gRPC
|
|
61
|
+
* serves billed models; a free-tier row (`lite`) is refused there
|
|
62
|
+
* (`quota exceeded` 116) and must use the legacy COSY surface.
|
|
63
|
+
*
|
|
64
|
+
* `cosy` forces only the legacy surface (`/algo/.../agent_chat_generation`)
|
|
65
|
+
* — the free-tier path and the pre-gRPC behavior.
|
|
66
|
+
*
|
|
67
|
+
* `ide` forces the modern `/model/v1` surface — **probe/testing only, never
|
|
68
|
+
* production** (see module doc).
|
|
69
|
+
*/
|
|
70
|
+
readonly surface?: "grpc" | "cosy" | "ide";
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Resolves the transport for one dispatch. Exported for tests. */
|
|
74
|
+
export function resolveQoderTransport(
|
|
75
|
+
surface: "grpc" | "cosy" | "ide" | undefined,
|
|
76
|
+
modelConfig: QoderModelConfig,
|
|
77
|
+
): "grpc" | "cosy" | "ide" {
|
|
78
|
+
const chosen = surface ?? "grpc";
|
|
79
|
+
// `ide` is an explicit testing override — never auto-selected, and it does
|
|
80
|
+
// not apply the free-tier rule (a forced IDE call is a test call).
|
|
81
|
+
if (chosen === "ide") return "ide";
|
|
82
|
+
// Free-tier rows (`lite`) are refused on gRPC with `quota exceeded` (116);
|
|
83
|
+
// they must use the legacy COSY surface.
|
|
84
|
+
if (chosen === "grpc" && modelConfig.is_free === true) return "cosy";
|
|
85
|
+
return chosen;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The widest selectable context window for one model key (from the live list's
|
|
90
|
+
* `context_config`; falls back to the static roster's stated window).
|
|
91
|
+
*/
|
|
92
|
+
function qoderMaxContextFor(modelId: string, modelConfig: QoderModelConfig, realm: QoderRealm): number {
|
|
93
|
+
const discovered = qoderDiscoveredMaxContexts(realm).get(modelId);
|
|
94
|
+
if (discovered !== undefined) return discovered;
|
|
95
|
+
return modelConfig.max_input_tokens;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
class QoderAdapter implements ProviderAdapter {
|
|
99
|
+
readonly provider_id: ProviderId;
|
|
100
|
+
private readonly realm: QoderRealm;
|
|
101
|
+
private readonly surface: "grpc" | "cosy" | "ide";
|
|
102
|
+
private readonly fetchFn: typeof fetch;
|
|
103
|
+
|
|
104
|
+
constructor(options: QoderAdapterOptions = {}) {
|
|
105
|
+
this.provider_id = (options.providerId ?? QODER_PROVIDER_ID) as ProviderId;
|
|
106
|
+
this.realm = options.realm ?? "intl";
|
|
107
|
+
this.surface = options.surface ?? "grpc";
|
|
108
|
+
this.fetchFn = options.fetch ?? globalThis.fetch;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The static roster for this adapter's realm.
|
|
113
|
+
*
|
|
114
|
+
* A single merged table advertised CN-only keys to global accounts and vice
|
|
115
|
+
* versa, so a request was routed to a deployment that refuses it.
|
|
116
|
+
*/
|
|
117
|
+
#modelConfigs(): Record<string, QoderModelConfig> {
|
|
118
|
+
return (this.realm === "cn" ? QODER_CN_MODEL_CONFIGS : QODER_INTL_MODEL_CONFIGS) as Record<
|
|
119
|
+
string,
|
|
120
|
+
QoderModelConfig
|
|
121
|
+
>;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async *dispatch(
|
|
125
|
+
request: CanonicalRequest,
|
|
126
|
+
candidate: ProviderDispatchTarget,
|
|
127
|
+
context: ProviderDispatchContext,
|
|
128
|
+
): AsyncIterable<CanonicalEvent> {
|
|
129
|
+
if (candidate.wire_family !== "chat") {
|
|
130
|
+
assertWireFamily(candidate, "chat", "qoder");
|
|
131
|
+
}
|
|
132
|
+
const secret = readCredentialSecret(context.credential, "Qoder credential is required");
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* A caller-facing virtual name is translated to its roster key here.
|
|
136
|
+
*
|
|
137
|
+
* The translation has to happen before the config lookup and before the
|
|
138
|
+
* request body: the body carries the roster key, and looking up the
|
|
139
|
+
* virtual id in the config table would fall through to the synthetic
|
|
140
|
+
* default and silently dispatch under-declared capabilities.
|
|
141
|
+
*/
|
|
142
|
+
const requestedModelId = candidate.model_id || request.model;
|
|
143
|
+
if (isQoderVirtualModel(requestedModelId) && !this.#modelConfigs()[qoderDispatchModelId(requestedModelId)]) {
|
|
144
|
+
throw new GatewayError(
|
|
145
|
+
"model_not_found",
|
|
146
|
+
404,
|
|
147
|
+
`Qoder virtual model ${requestedModelId} resolves to a roster key this realm does not serve (request {requestId}). Try another model — this one's off the menu 📚`,
|
|
148
|
+
{ virtual_model: requestedModelId, target: qoderDispatchModelId(requestedModelId) },
|
|
149
|
+
"upstream",
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
const modelId = qoderDispatchModelId(requestedModelId);
|
|
153
|
+
const modelConfig = this.#modelConfigs()[modelId] ?? {
|
|
154
|
+
id: modelId,
|
|
155
|
+
display_name: modelId,
|
|
156
|
+
max_input_tokens: 180_000,
|
|
157
|
+
is_vl: true,
|
|
158
|
+
is_reasoning: false,
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
const lifecycle = createUpstreamDeadlineLifecycle(context);
|
|
162
|
+
|
|
163
|
+
// Outbound fetch respects SSRF binding when provided
|
|
164
|
+
const outboundFetch: typeof fetch = (context.outbound_fetch as unknown as typeof fetch) ?? this.fetchFn;
|
|
165
|
+
|
|
166
|
+
const fetcher: (url: string, init: RequestInit) => Promise<Response> = (url, init) =>
|
|
167
|
+
outboundFetch(url, { ...init, signal: lifecycle.signal } as RequestInit) as Promise<Response>;
|
|
168
|
+
|
|
169
|
+
try {
|
|
170
|
+
const version = getQoderVersion();
|
|
171
|
+
// Dual mode like Cline: a pasted PAT is exchanged for a job token per
|
|
172
|
+
// request, while an OAuth device token is used directly with the
|
|
173
|
+
// identity the login persisted.
|
|
174
|
+
const auth = isQoderPat(secret)
|
|
175
|
+
? await resolveQoderAuth({ secret, authState: context.credential.auth_state, signal: lifecycle.signal, fetcher, realm: this.realm })
|
|
176
|
+
: qoderOAuthAuth(secret, context.credential.auth_state, context.credential.account_id);
|
|
177
|
+
|
|
178
|
+
// Transport selection — see module doc for the surface contract.
|
|
179
|
+
const forceIde = this.surface === "ide" || candidate.probe_surface === "ide";
|
|
180
|
+
const useGrpc = !forceIde && resolveQoderTransport(this.surface, modelConfig) === "grpc";
|
|
181
|
+
|
|
182
|
+
if (forceIde) {
|
|
183
|
+
const ideUrl = qoderIdeChatUrl(this.realm);
|
|
184
|
+
const ideBody = buildQoderIdeRequest(modelId, request, modelConfig, auth, this.realm, version);
|
|
185
|
+
// Header fingerprint mirrors the desktop client's IDE surface (`PFn` /
|
|
186
|
+
// `Hc`): X-Request-ID normalized to uppercase, Cosy-Version, real
|
|
187
|
+
// machine OS, and the sanitized hostname on infer SSE requests. The
|
|
188
|
+
// User-Agent is the real IDE client's (`Bun/1.4.4`, live capture) and
|
|
189
|
+
// `X-Session-ID` matches the body's session id (sha256 userId:model).
|
|
190
|
+
const ideHostname = qoderMachineHostname();
|
|
191
|
+
const res = await fetcher(ideUrl, {
|
|
192
|
+
method: "POST",
|
|
193
|
+
headers: {
|
|
194
|
+
Authorization: `Bearer ${auth.securityOauthToken}`,
|
|
195
|
+
"Content-Type": "application/json",
|
|
196
|
+
Accept: "text/event-stream",
|
|
197
|
+
"User-Agent": "Bun/1.4.4",
|
|
198
|
+
"X-Request-ID": randomUUID().toUpperCase(),
|
|
199
|
+
"X-Session-ID": qoderSessionId(auth.userId, modelId),
|
|
200
|
+
"Cosy-Version": version,
|
|
201
|
+
"Cosy-ClientType": "5",
|
|
202
|
+
"Cosy-MachineOS": qoderMachineOs(),
|
|
203
|
+
...(ideHostname.length > 0 ? { "Cosy-MachineHostname": ideHostname } : {}),
|
|
204
|
+
},
|
|
205
|
+
body: JSON.stringify(ideBody),
|
|
206
|
+
});
|
|
207
|
+
if (!res.ok) throw await mapUpstreamHttpError(res, "qoder");
|
|
208
|
+
if (!res.body) throw new GatewayError("platform_unavailable", 502, "Qoder returned empty body (request {requestId}). The upstream came back empty-handed — retry 🌙", {}, "upstream");
|
|
209
|
+
lifecycle.release();
|
|
210
|
+
yield* qoderBodyToCanonicalEvents(res.body as ReadableStream<Uint8Array>, lifecycle.signal, modelId);
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (useGrpc) {
|
|
215
|
+
// The gRPC request mirrors the HTTP surfaces' OpenAI-shaped body, so
|
|
216
|
+
// the shared builder supplies the messages, tool declarations, and
|
|
217
|
+
// tool-choice knobs both surfaces expect. Without the `tools` array
|
|
218
|
+
// the upstream sees no toolset and answers with a DSML text block (or
|
|
219
|
+
// claims no tool exists) instead of a structured `tool_calls`.
|
|
220
|
+
const legacy = buildQoderRequest(modelId, request, modelConfig, auth, this.realm);
|
|
221
|
+
const grpcRequest = buildQoderGrpcRequest(
|
|
222
|
+
modelId,
|
|
223
|
+
request,
|
|
224
|
+
auth.userId,
|
|
225
|
+
qoderMaxContextFor(modelId, modelConfig, this.realm),
|
|
226
|
+
{
|
|
227
|
+
messages: legacy.messages as Array<Record<string, unknown>>,
|
|
228
|
+
tools: legacy.tools as Array<Record<string, unknown>>,
|
|
229
|
+
...(legacy.parameters === undefined
|
|
230
|
+
? {}
|
|
231
|
+
: { parameters: legacy.parameters as Record<string, unknown> }),
|
|
232
|
+
},
|
|
233
|
+
);
|
|
234
|
+
const grpcMetadata = qoderGrpcMetadata(auth);
|
|
235
|
+
lifecycle.release();
|
|
236
|
+
yield* qoderGrpcToCanonicalEvents(grpcRequest, grpcMetadata, this.realm, lifecycle.signal);
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const cosyUrl = candidate.endpoint_path?.startsWith("http")
|
|
241
|
+
? candidate.endpoint_path
|
|
242
|
+
: `${qoderInferenceCandidates(this.realm, auth.securityOauthToken)[0] ?? ""}${QODER_CHAT_PATH}`;
|
|
243
|
+
const cosyRequest = () =>
|
|
244
|
+
callQoder(cosyUrl, buildQoderRequest(modelId, request, modelConfig, auth, this.realm), modelId, auth, lifecycle.signal, fetcher, version);
|
|
245
|
+
const response = await cosyRequest();
|
|
246
|
+
if (!response.ok) throw await mapUpstreamHttpError(response, "qoder");
|
|
247
|
+
if (!response.body) throw new GatewayError("platform_unavailable", 502, "Qoder returned empty body (request {requestId}). The upstream came back empty-handed — retry 🌙", {}, "upstream");
|
|
248
|
+
// Headers arrived: the pre-stream deadline has served its purpose.
|
|
249
|
+
// From here the gateway stall/first-chunk watchdog (propagated via
|
|
250
|
+
// context.abort_signal) governs the body. Keeping this timer armed
|
|
251
|
+
// would kill healthy long streams at the stale pre-stream deadline.
|
|
252
|
+
lifecycle.release();
|
|
253
|
+
yield* qoderBodyToCanonicalEvents(response.body as ReadableStream<Uint8Array>, lifecycle.signal, request.model);
|
|
254
|
+
} catch (err: unknown) {
|
|
255
|
+
const abortError = abortGatewayError(lifecycle, err, context.abort_signal);
|
|
256
|
+
if (abortError) throw abortError;
|
|
257
|
+
throw err;
|
|
258
|
+
} finally {
|
|
259
|
+
lifecycle.release();
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export function createQoderAdapter(options: QoderAdapterOptions = {}): ProviderAdapter {
|
|
265
|
+
return new QoderAdapter(options);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Adapter for the China deployment.
|
|
270
|
+
*
|
|
271
|
+
* Same wire, different gateway host and roster — see `qoder-realm.ts`.
|
|
272
|
+
*/
|
|
273
|
+
export function createQoderCnAdapter(options: QoderAdapterOptions = {}): ProviderAdapter {
|
|
274
|
+
return new QoderAdapter({ ...options, realm: "cn", providerId: "qodercn" });
|
|
275
|
+
}
|