cartethyia 2.5.2 → 2.5.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.
Files changed (96) hide show
  1. package/dist/dashboard/assets/{BannedPage-MsQaeQTs.js → BannedPage-hfCzi-Ux.js} +1 -1
  2. package/dist/dashboard/assets/BannedPage-hfCzi-Ux.js.gz +0 -0
  3. package/dist/dashboard/assets/{CliToolDetailPage-BO9nKg9w.js → CliToolDetailPage-DLGqYEwI.js} +1 -1
  4. package/dist/dashboard/assets/CliToolDetailPage-DLGqYEwI.js.gz +0 -0
  5. package/dist/dashboard/assets/{CliToolsPage-DfWHTJ7v.js → CliToolsPage-DEDyKBlC.js} +1 -1
  6. package/dist/dashboard/assets/CliToolsPage-DEDyKBlC.js.gz +0 -0
  7. package/dist/dashboard/assets/{CombosPage-BrCBQDAc.js → CombosPage-CfuYiQ8Z.js} +1 -1
  8. package/dist/dashboard/assets/CombosPage-CfuYiQ8Z.js.gz +0 -0
  9. package/dist/dashboard/assets/{ConsoleLogPage-C6Uh33er.js → ConsoleLogPage-bsLuZmYH.js} +1 -1
  10. package/dist/dashboard/assets/ConsoleLogPage-bsLuZmYH.js.gz +0 -0
  11. package/dist/dashboard/assets/{CustomizationPage-8aS9hyJu.js → CustomizationPage-_UWhwziJ.js} +1 -1
  12. package/dist/dashboard/assets/CustomizationPage-_UWhwziJ.js.gz +0 -0
  13. package/dist/dashboard/assets/{HealthEventsModal-qomXThxX.js → HealthEventsModal-Cjfc5WFS.js} +1 -1
  14. package/dist/dashboard/assets/HealthEventsModal-Cjfc5WFS.js.gz +0 -0
  15. package/dist/dashboard/assets/{LoginPage-C8hKyXBe.js → LoginPage-DDVIfZGW.js} +1 -1
  16. package/dist/dashboard/assets/LoginPage-DDVIfZGW.js.gz +0 -0
  17. package/dist/dashboard/assets/{OverviewPage-C-ExCy1V.js → OverviewPage-B53YYMng.js} +1 -1
  18. package/dist/dashboard/assets/OverviewPage-B53YYMng.js.gz +0 -0
  19. package/dist/dashboard/assets/ProviderDetailPage-B0KaAQf0.js +3 -0
  20. package/dist/dashboard/assets/ProviderDetailPage-B0KaAQf0.js.gz +0 -0
  21. package/dist/dashboard/assets/{ProvidersPage-BS9IEpvv.js → ProvidersPage-Dx31DgIO.js} +1 -1
  22. package/dist/dashboard/assets/ProvidersPage-Dx31DgIO.js.gz +0 -0
  23. package/dist/dashboard/assets/{ProxyPage-Rn_qXScb.js → ProxyPage-BpnR4kXT.js} +1 -1
  24. package/dist/dashboard/assets/ProxyPage-BpnR4kXT.js.gz +0 -0
  25. package/dist/dashboard/assets/{QuotaPage-BihkgQSq.js → QuotaPage-BpOQCnun.js} +1 -1
  26. package/dist/dashboard/assets/QuotaPage-BpOQCnun.js.gz +0 -0
  27. package/dist/dashboard/assets/SettingsPage-DV1eo2J_.js +1 -0
  28. package/dist/dashboard/assets/SettingsPage-DV1eo2J_.js.gz +0 -0
  29. package/dist/dashboard/assets/{SetupPage-CXjUlfqr.js → SetupPage-D6pmQ86L.js} +1 -1
  30. package/dist/dashboard/assets/SetupPage-D6pmQ86L.js.gz +0 -0
  31. package/dist/dashboard/assets/{SortableList-C-_oD3F2.js → SortableList-DWvKiuSl.js} +1 -1
  32. package/dist/dashboard/assets/SortableList-DWvKiuSl.js.gz +0 -0
  33. package/dist/dashboard/assets/{StudioPage-u9IxmYOI.js → StudioPage-CmkUrgdj.js} +1 -1
  34. package/dist/dashboard/assets/StudioPage-CmkUrgdj.js.gz +0 -0
  35. package/dist/dashboard/assets/{UsagePage-BEVLVrwP.js → UsagePage-B47uOydr.js} +1 -1
  36. package/dist/dashboard/assets/UsagePage-B47uOydr.js.gz +0 -0
  37. package/dist/dashboard/assets/{entry-Dj9AR0gY.js → entry-CkryrIea.js} +3 -3
  38. package/dist/dashboard/assets/entry-CkryrIea.js.gz +0 -0
  39. package/dist/dashboard/assets/{index-DCBtROoK.js → index-BydyKaDz.js} +2 -2
  40. package/dist/dashboard/assets/index-BydyKaDz.js.gz +0 -0
  41. package/dist/dashboard/assets/{select-DsX5QT8K.js → select-BO6h8yNv.js} +1 -1
  42. package/dist/dashboard/assets/select-BO6h8yNv.js.gz +0 -0
  43. package/dist/dashboard/index.html +1 -1
  44. package/dist/dashboard/index.html.gz +0 -0
  45. package/dist/dashboard/precompressed-assets.json +21 -21
  46. package/package.json +1 -1
  47. package/scripts/cli/command/console-menu.ts +248 -246
  48. package/scripts/cli/command/tray.ts +208 -204
  49. package/src/config/spec.ts +26 -2
  50. package/src/console/backup/routes.ts +52 -1
  51. package/src/console/backup/service.ts +60 -4
  52. package/src/console/share/share-router.ts +6 -9
  53. package/src/observability/payload-capture.ts +366 -344
  54. package/src/observability/payload-store.ts +12 -2
  55. package/src/observability/telemetry-status.ts +28 -16
  56. package/src/persistence/stores/api-key-store.ts +6 -0
  57. package/src/persistence/stores/share-store.ts +583 -556
  58. package/src/persistence/stores/telemetry-store.ts +1 -1
  59. package/src/protocol/response/gemini.ts +18 -4
  60. package/src/providers/integrations/antigravity/antigravity-quota.ts +11 -3
  61. package/src/providers/integrations/buddy/buddy-catalog-shared.ts +4 -4
  62. package/src/providers/integrations/buddy/workbuddy/shared.ts +201 -200
  63. package/src/providers/integrations/cline/cline-quota.ts +8 -11
  64. package/src/providers/integrations/opencode/provider.ts +532 -532
  65. package/src/providers/integrations/tokenharbor.ts +2 -2
  66. package/src/providers/integrations/trae/trae-catalog.ts +5 -3
  67. package/src/providers/operations/account-health-service.ts +23 -0
  68. package/src/providers/operations/client-versions.ts +9 -2
  69. package/src/providers/provider-metadata.ts +7 -1
  70. package/src/transport/dispatch/attempt-finalize.ts +46 -5
  71. package/src/transport/dispatch/proxy-request.ts +9 -0
  72. package/src/transport/dispatch/streaming/stream.ts +16 -2
  73. package/src/transport/routing/route-catalog.ts +806 -799
  74. package/dist/dashboard/assets/BannedPage-MsQaeQTs.js.gz +0 -0
  75. package/dist/dashboard/assets/CliToolDetailPage-BO9nKg9w.js.gz +0 -0
  76. package/dist/dashboard/assets/CliToolsPage-DfWHTJ7v.js.gz +0 -0
  77. package/dist/dashboard/assets/CombosPage-BrCBQDAc.js.gz +0 -0
  78. package/dist/dashboard/assets/ConsoleLogPage-C6Uh33er.js.gz +0 -0
  79. package/dist/dashboard/assets/CustomizationPage-8aS9hyJu.js.gz +0 -0
  80. package/dist/dashboard/assets/HealthEventsModal-qomXThxX.js.gz +0 -0
  81. package/dist/dashboard/assets/LoginPage-C8hKyXBe.js.gz +0 -0
  82. package/dist/dashboard/assets/OverviewPage-C-ExCy1V.js.gz +0 -0
  83. package/dist/dashboard/assets/ProviderDetailPage-tr811uaF.js +0 -3
  84. package/dist/dashboard/assets/ProviderDetailPage-tr811uaF.js.gz +0 -0
  85. package/dist/dashboard/assets/ProvidersPage-BS9IEpvv.js.gz +0 -0
  86. package/dist/dashboard/assets/ProxyPage-Rn_qXScb.js.gz +0 -0
  87. package/dist/dashboard/assets/QuotaPage-BihkgQSq.js.gz +0 -0
  88. package/dist/dashboard/assets/SettingsPage-DhiEjm6T.js +0 -1
  89. package/dist/dashboard/assets/SettingsPage-DhiEjm6T.js.gz +0 -0
  90. package/dist/dashboard/assets/SetupPage-CXjUlfqr.js.gz +0 -0
  91. package/dist/dashboard/assets/SortableList-C-_oD3F2.js.gz +0 -0
  92. package/dist/dashboard/assets/StudioPage-u9IxmYOI.js.gz +0 -0
  93. package/dist/dashboard/assets/UsagePage-BEVLVrwP.js.gz +0 -0
  94. package/dist/dashboard/assets/entry-Dj9AR0gY.js.gz +0 -0
  95. package/dist/dashboard/assets/index-DCBtROoK.js.gz +0 -0
  96. package/dist/dashboard/assets/select-DsX5QT8K.js.gz +0 -0
@@ -44,7 +44,7 @@ function addUsageDelta(
44
44
  }
45
45
  delta.requests += 1;
46
46
  // Same definition as every read-side error count, including the HTTP-status
47
- // exclusions (404/499/503): this rollup is durable, so a request counted here
47
+ // exclusions (404/499/503/504): this rollup is durable, so a request counted here
48
48
  // stays counted after its raw row is pruned and cannot be corrected later.
49
49
  if (isGatewayError(row.status, row.httpStatus)) delta.errors += 1;
50
50
  delta.inputTokens += row.inputTokens ?? 0;
@@ -63,11 +63,25 @@ export function responseParts(parts: readonly Record<string, unknown>[]): {
63
63
 
64
64
  export function mapGeminiUsage(body: Record<string, unknown>): Record<string, unknown> {
65
65
  const usage = isRecord(body["usageMetadata"]) ? (body["usageMetadata"] as Record<string, unknown>) : {};
66
+ const prompt = nullableNumber(usage["promptTokenCount"]) ?? 0;
67
+ const candidates = nullableNumber(usage["candidatesTokenCount"]) ?? 0;
68
+ // Reasoning is billed output that Gemini reports in its own counter:
69
+ // `candidatesTokenCount` excludes `thoughtsTokenCount`, so dropping it here
70
+ // silently under-reported every thinking model's completion tokens. It is
71
+ // surfaced through `output_tokens_details` because that is the slot
72
+ // `normalizeUsage` reads for reasoning — keeping it separate from the
73
+ // candidate count it is added into, so downstream can still split them.
74
+ const thoughts = nullableNumber(usage["thoughtsTokenCount"]) ?? 0;
75
+ const output = candidates + thoughts;
76
+ const cached = nullableNumber(usage["cachedContentTokenCount"]) ?? 0;
66
77
  return {
67
- input_tokens: nullableNumber(usage["promptTokenCount"]) ?? 0,
68
- output_tokens: nullableNumber(usage["candidatesTokenCount"]) ?? 0,
69
- total_tokens: nullableNumber(usage["totalTokenCount"]) ?? 0,
70
- cached_tokens: nullableNumber(usage["cachedContentTokenCount"]) ?? 0,
78
+ input_tokens: prompt,
79
+ output_tokens: output,
80
+ total_tokens: nullableNumber(usage["totalTokenCount"]) ?? prompt + output,
81
+ cached_tokens: cached,
82
+ ...(thoughts > 0
83
+ ? { output_tokens_details: { reasoning_tokens: thoughts } }
84
+ : {}),
71
85
  };
72
86
  }
73
87
 
@@ -189,12 +189,20 @@ function parseModelQuotas(payload: Record<string, unknown>): ProviderQuotaWindow
189
189
  const family = modelFamily(collapseAntigravityVariant(modelId));
190
190
  const familyLabel = FAMILY_LABELS[family.key] ?? family.label;
191
191
  for (const entry of modelQuotaEntries(model)) {
192
- const fraction = number(entry.info.remainingFraction);
193
- if (fraction === null) continue;
192
+ // A bucket with no fraction but a reset time is exhausted, not unknown:
193
+ // the upstream omits the field when there is nothing left to report, so
194
+ // dropping it let a healthy sibling window mask a lane that is blocked
195
+ // until reset. Only a bucket with neither signal is genuinely unknown.
196
+ // This matches what the summary path already does for a disabled bucket.
197
+ const reported = number(entry.info.remainingFraction);
198
+ const resetAt = isoDate(entry.info.resetTime);
199
+ const exhausted = reported === null && resetAt !== null;
200
+ if (reported === null && !exhausted) continue;
201
+ const fraction = reported ?? 0;
194
202
  const kind = entry.kind ?? summaryWindowKind(entry.info);
195
203
  const label = kind === undefined ? familyLabel : `${familyLabel} (${SUMMARY_WINDOW_LABELS[kind]})`;
196
204
  const key = `${family.key}:${kind ?? "model"}`;
197
- const window = fractionWindow(key, label, fraction, isoDate(entry.info.resetTime));
205
+ const window = fractionWindow(key, label, fraction, resetAt);
198
206
  grouped.set(key, keepWorse(grouped.get(key), window));
199
207
  }
200
208
  }
@@ -257,8 +257,8 @@ export const BUDDY_SHARED_RAW: readonly BuddyRawEntry[] = [
257
257
  ["claude-opus-4.6", "Claude Opus 4.6", true, true, 200_000, 64_000],
258
258
  ["claude-opus-4.7-1m", "Claude Opus 4.7 1M", true, true, 1_000_000, 64_000],
259
259
  ["claude-sonnet-4.6", "Claude Sonnet 4.6", true, true, 200_000, 32_000],
260
- ["deepseek-v4.1-flash", "DeepSeek V4.1 Flash", true, false, 1_000_000, 384_000],
261
- ["deepseek-v4.1-flash-sg", "DeepSeek V4.1 Flash SG", true, false, 1_000_000, 128_000],
260
+ ["deepseek-v4.1-flash", "DeepSeek V4.1 Flash", true, true, 1_000_000, 384_000],
261
+ ["deepseek-v4.1-flash-sg", "DeepSeek V4.1 Flash SG", true, true, 1_000_000, 128_000],
262
262
  ["deepseek-v4.1-pro", "DeepSeek V4.1 Pro", true, false, 1_000_000, 384_000],
263
263
  ["gemini-2.5-flash-image", "Gemini 2.5 Flash Image", true, true, 1_000_000, 64_000],
264
264
  ["gemini-3.0-pro-image", "Gemini 3.0 Pro Image", true, true, 1_000_000, 64_000],
@@ -286,8 +286,8 @@ export const BUDDY_SHARED_RAW: readonly BuddyRawEntry[] = [
286
286
  ["grok-4.7", "Grok 4.7", true, true, 500_000, 500_000],
287
287
  ["hy3", "Hy3", true, false, 192_000, 64_000, true],
288
288
  ["hy3-preview", "Hy3 Preview", true, false, 262_144, 64_000],
289
- ["hy4-preview", "Hy4 Preview", true, false, 1_000_000, 64_000, true],
290
- ["hy4-preview-f", "Hy4 Preview F", true, false, 1_000_000, 64_000, true],
289
+ ["hy4-preview", "Hy4 Preview", true, true, 1_000_000, 64_000, true],
290
+ ["hy4-preview-f", "Hy4 Preview F", true, true, 1_000_000, 64_000, true],
291
291
  ["kimi-k2.5", "Kimi K2.5", true, false, 164_000, 262_144],
292
292
  ["kimi-k2.6", "Kimi K2.6", true, false, 256_000, 262_144],
293
293
  ["kimi-k2.7", "Kimi K2.7", true, false, 256_000, 65_536],
@@ -1,200 +1,201 @@
1
- import { createHash } from "node:crypto";
2
- import { readFileSync } from "node:fs";
3
-
4
- import type { OpenAICompatibleAdapterConfig } from "../../../adapters/compatible-adapter";
5
- import { withBearerAuthentication } from "../../../adapters/compatible-adapter";
6
- import type { CanonicalRequest } from "../../../../protocol/canonical-model";
7
- import type { ProviderDispatchContext, ProviderDispatchTarget, ProviderId } from "../../../provider-contracts";
8
- import { resolveInboundSessionId } from "../../../operations/session-resolution";
9
- import { buddyConversationHeaders } from "../buddy-conversation-headers";
10
- import { buddyCliHeaders, buddyCliTraceHeaders } from "../buddy-cli-headers";
11
- import { buddyUserId, resolveBuddyIdentity } from "../buddy-identity";
12
- import {
13
- buildWorkBuddyUserAgent,
14
- getWorkBuddyClientVersion,
15
- getWorkBuddyCliVersion,
16
- } from "../../../operations/client-versions";
17
-
18
- /**
19
- * WorkBuddy serves chat from a versioned path on a base URL that carries no
20
- * version segment (billing uses the unversioned `/billing/…` path), so both the
21
- * adapter config and the static catalog rows must state it. The generic
22
- * `/chat/completions` default would join into
23
- * `https://www.workbuddy.ai/chat/completions`, which that upstream answers with
24
- * a 405 HTML error page.
25
- */
26
- export const WORKBUDDY_CHAT_PATH = "/v2/chat/completions" as const;
27
-
28
- export {
29
- buildWorkBuddyUserAgent,
30
- getWorkBuddyClientVersion,
31
- getWorkBuddyCliVersion,
32
- };
33
-
34
- /** WorkBuddy international gateway domain (Origin/Referer/X-Domain). */
35
- export const WORKBUDDY_DOMAIN = "www.workbuddy.ai";
36
-
37
- /**
38
- * Stable per-account device identifiers.
39
- *
40
- * WorkBuddy's desktop client presents one fixed virtual device per account.
41
- * Deriving `X-Machine-ID` / `X-Session-ID` from the account id (sha256, 36 hex)
42
- * keeps them constant across restarts while staying distinct per account, so
43
- * multiple pooled accounts are not correlated by a missing or drifting device
44
- * fingerprint. Mirrors the reference gateway's `wb2a:<purpose>:<uid>` scheme.
45
- */
46
- function deriveAccountStableId(accountId: string, purpose: string): string {
47
- const sum = createHash("sha256").update(`wb2a:${purpose}:${accountId}`).digest("hex");
48
- return sum.slice(0, 36);
49
- }
50
-
51
- /**
52
- * Per-request WorkBuddy chat headers — the exact desktop-client contract.
53
- *
54
- * The international gateway gates chat on the desktop `User-Agent` (platform
55
- * brand `WorkBuddy AI`; the wrong brand trips 403 code 11140), plus the
56
- * `X-CodeBuddy-Request` risk gate and the account-scoped device headers.
57
- * The background client-version monitor keeps this value fresh; dispatch only
58
- * reads the cached value and never waits on a version endpoint.
59
- *
60
- * `cliIdentity` selects the reference CLI contract instead. The two are not
61
- * variations of one set: the CLI drops every browser header and the device
62
- * token, and adds the agent envelope. Left unset, nothing changes.
63
- */
64
- export async function workbuddyHeaders(
65
- context?: ProviderDispatchContext,
66
- request?: CanonicalRequest,
67
- domain = WORKBUDDY_DOMAIN,
68
- clientVersion = getWorkBuddyClientVersion(),
69
- cliIdentity?: boolean,
70
- ): Promise<Record<string, string>> {
71
- const conversation = buddyConversationHeaders({
72
- ...(resolveInboundSessionId(context, request) === undefined
73
- ? {}
74
- : { conversationId: resolveInboundSessionId(context, request) }),
75
- });
76
- const accountId = context?.credential.account_id;
77
- if (resolveBuddyIdentity(context, cliIdentity) === "cli") {
78
- // The reference CLI: issued by the OpenAI Node SDK, so no browser header
79
- // and no device token, and the machine/session device pair does not
80
- // exist — only `X-User-Id`, which is `anonymous` without a uid.
81
- return {
82
- accept: "application/json",
83
- "user-agent": `CLI/${getWorkBuddyCliVersion()} CodeBuddy/${getWorkBuddyCliVersion()}`,
84
- "x-domain": domain,
85
- "x-agent-purpose": "conversation",
86
- "x-ide-name": "CLI",
87
- "x-ide-type": "CLI",
88
- "x-ide-version": getWorkBuddyCliVersion(),
89
- "x-product": "SaaS",
90
- "x-no-enterprise-id": "1",
91
- ...conversation,
92
- ...buddyCliTraceHeaders(conversation),
93
- ...buddyCliHeaders({ userId: buddyUserId(context) }),
94
- };
95
- }
96
- const headers: Record<string, string> = {
97
- accept: "application/json, text/event-stream",
98
- "user-agent": buildWorkBuddyUserAgent(clientVersion, getWorkBuddyCliVersion()),
99
- origin: `https://${domain}`,
100
- referer: `https://${domain}/`,
101
- "accept-language": "en-US",
102
- "x-requested-with": "XMLHttpRequest",
103
- "x-codebuddy-request": "1",
104
- "x-domain": domain,
105
- "x-agent-purpose": "conversation",
106
- "x-ide-name": "WorkBuddy",
107
- "x-ide-type": "WorkBuddy",
108
- "x-ide-version": clientVersion,
109
- // Both references send the literal `SaaS` here. `WorkBuddy` was a value
110
- // this repo invented; the desktop product name belongs in `x-ide-name`,
111
- // and the CN test below compares the two realms against each other rather
112
- // than to a hard-coded string.
113
- "x-product": "SaaS",
114
- // Personal international accounts carry no enterprise id: declare it
115
- // explicitly so upstream risk control does not treat the omission as
116
- // suspicious.
117
- "x-no-enterprise-id": "1",
118
- };
119
- // The conversation/tracing family the upstream's channel check looks for. A
120
- // live request carrying only `x-session-id` was refused for a missing
121
- // conversation id; the aggregation key below is the one the reference marks
122
- // mandatory.
123
- Object.assign(headers, conversation);
124
- if (accountId) {
125
- headers["x-user-id"] = accountId;
126
- headers["x-machine-id"] = deriveAccountStableId(accountId, "machine");
127
- headers["x-session-id"] = deriveAccountStableId(accountId, "session");
128
- }
129
- const deviceToken = readBuddyDeviceToken(context);
130
- if (deviceToken !== undefined) headers["x-device-token"] = deviceToken;
131
- return headers;
132
- }
133
-
134
- /**
135
- * Desktop risk token, sent only when the account actually has one.
136
- *
137
- * The account's own `auth_state` wins. When it carries none, a file the
138
- * desktop client writes (`CARTETHYIA_DEVICE_TOKEN_FILE`, default
139
- * `/app/data/device_token`) is read instead — cached for five minutes, and
140
- * ignored when it is larger than 1 KB, because a token file that size is not
141
- * a token. A missing file is the normal case and reads as "no token".
142
- */
143
- const DEVICE_TOKEN_FILE_MAX_BYTES = 1024;
144
- const DEVICE_TOKEN_FILE_TTL_MS = 5 * 60_000;
145
- let deviceTokenFileCache: { readonly value: string | undefined; readonly readAt: number } | undefined;
146
-
147
- export function readBuddyDeviceToken(context?: ProviderDispatchContext): string | undefined {
148
- const state = context?.credential.auth_state;
149
- const value = state?.["deviceToken"] ?? state?.["device_token"];
150
- if (typeof value === "string" && value.trim().length > 0) return value.trim();
151
- return readDeviceTokenFile();
152
- }
153
-
154
- function readDeviceTokenFile(): string | undefined {
155
- const now = Date.now();
156
- if (deviceTokenFileCache !== undefined && now - deviceTokenFileCache.readAt < DEVICE_TOKEN_FILE_TTL_MS) {
157
- return deviceTokenFileCache.value;
158
- }
159
- const path = process.env["CARTETHYIA_DEVICE_TOKEN_FILE"] ?? "/app/data/device_token";
160
- let value: string | undefined;
161
- try {
162
- const raw = readFileSync(path);
163
- if (raw.byteLength > 0 && raw.byteLength <= DEVICE_TOKEN_FILE_MAX_BYTES) {
164
- const text = raw.toString("utf8").trim();
165
- if (text.length > 0) value = text;
166
- }
167
- } catch {
168
- value = undefined;
169
- }
170
- deviceTokenFileCache = { value, readAt: now };
171
- return value;
172
- }
173
-
174
- /**
175
- * Shared OpenAI-compatible chat-adapter config for WorkBuddy: bearer auth
176
- * (api_key and oauth both use `Authorization: Bearer`), `/v2/chat/completions`
177
- * endpoint, per-request desktop-client headers, and the payload hook.
178
- */
179
- export function workbuddyAdapterConfig(args: {
180
- providerId: ProviderId;
181
- baseUrl: string;
182
- domain?: string;
183
- prePayload: (
184
- payload: Record<string, unknown>,
185
- request: CanonicalRequest,
186
- candidate: ProviderDispatchTarget,
187
- ) => void;
188
- fetchImpl?: typeof fetch;
189
- cliIdentity?: boolean;
190
- }): OpenAICompatibleAdapterConfig {
191
- return withBearerAuthentication({
192
- provider_id: args.providerId,
193
- base_url: args.baseUrl,
194
- endpoint_paths_by_wire_family: { chat: WORKBUDDY_CHAT_PATH },
195
- buildExtraHeaders: (context, request) =>
196
- workbuddyHeaders(context, request, args.domain ?? WORKBUDDY_DOMAIN, undefined, args.cliIdentity),
197
- prePayload: args.prePayload,
198
- ...(args.fetchImpl ? { fetchImpl: args.fetchImpl } : {}),
199
- });
200
- }
1
+ import { createHash } from "node:crypto";
2
+ import { readFileSync } from "node:fs";
3
+
4
+ import type { OpenAICompatibleAdapterConfig } from "../../../adapters/compatible-adapter";
5
+ import { withBearerAuthentication } from "../../../adapters/compatible-adapter";
6
+ import type { CanonicalRequest } from "../../../../protocol/canonical-model";
7
+ import type { ProviderDispatchContext, ProviderDispatchTarget, ProviderId } from "../../../provider-contracts";
8
+ import { resolveInboundSessionId } from "../../../operations/session-resolution";
9
+ import { buddyConversationHeaders } from "../buddy-conversation-headers";
10
+ import { buddyCliHeaders, buddyCliTraceHeaders } from "../buddy-cli-headers";
11
+ import { buddyUserId, resolveBuddyIdentity } from "../buddy-identity";
12
+ import {
13
+ buildWorkBuddyUserAgent,
14
+ getWorkBuddyClientVersion,
15
+ getWorkBuddyCliVersion,
16
+ } from "../../../operations/client-versions";
17
+
18
+ /**
19
+ * WorkBuddy serves chat from a versioned path on a base URL that carries no
20
+ * version segment (billing uses the unversioned `/billing/…` path), so both the
21
+ * adapter config and the static catalog rows must state it. The generic
22
+ * `/chat/completions` default would join into
23
+ * `https://www.workbuddy.ai/chat/completions`, which that upstream answers with
24
+ * a 405 HTML error page.
25
+ */
26
+ export const WORKBUDDY_CHAT_PATH = "/v2/chat/completions" as const;
27
+
28
+ export {
29
+ buildWorkBuddyUserAgent,
30
+ getWorkBuddyClientVersion,
31
+ getWorkBuddyCliVersion,
32
+ };
33
+
34
+ /** WorkBuddy international gateway domain (Origin/Referer/X-Domain). */
35
+ export const WORKBUDDY_DOMAIN = "www.workbuddy.ai";
36
+
37
+ /**
38
+ * Stable per-account device identifiers.
39
+ *
40
+ * WorkBuddy's desktop client presents one fixed virtual device per account.
41
+ * Deriving `X-Machine-ID` / `X-Session-ID` from the account id (sha256, 36 hex)
42
+ * keeps them constant across restarts while staying distinct per account, so
43
+ * multiple pooled accounts are not correlated by a missing or drifting device
44
+ * fingerprint. Mirrors the reference gateway's `wb2a:<purpose>:<uid>` scheme.
45
+ */
46
+ function deriveAccountStableId(accountId: string, purpose: string): string {
47
+ const sum = createHash("sha256").update(`wb2a:${purpose}:${accountId}`).digest("hex");
48
+ return sum.slice(0, 36);
49
+ }
50
+
51
+ /**
52
+ * Per-request WorkBuddy chat headers — the exact desktop-client contract.
53
+ *
54
+ * The international gateway gates chat on the desktop `User-Agent` (platform
55
+ * brand `WorkBuddy AI`, carried on the wire as the token `workbuddy-ai`;
56
+ * the wrong token trips 403 code 11140), plus the `X-CodeBuddy-Request` risk
57
+ * gate and the account-scoped device headers.
58
+ * The background client-version monitor keeps this value fresh; dispatch only
59
+ * reads the cached value and never waits on a version endpoint.
60
+ *
61
+ * `cliIdentity` selects the reference CLI contract instead. The two are not
62
+ * variations of one set: the CLI drops every browser header and the device
63
+ * token, and adds the agent envelope. Left unset, nothing changes.
64
+ */
65
+ export async function workbuddyHeaders(
66
+ context?: ProviderDispatchContext,
67
+ request?: CanonicalRequest,
68
+ domain = WORKBUDDY_DOMAIN,
69
+ clientVersion = getWorkBuddyClientVersion(),
70
+ cliIdentity?: boolean,
71
+ ): Promise<Record<string, string>> {
72
+ const conversation = buddyConversationHeaders({
73
+ ...(resolveInboundSessionId(context, request) === undefined
74
+ ? {}
75
+ : { conversationId: resolveInboundSessionId(context, request) }),
76
+ });
77
+ const accountId = context?.credential.account_id;
78
+ if (resolveBuddyIdentity(context, cliIdentity) === "cli") {
79
+ // The reference CLI: issued by the OpenAI Node SDK, so no browser header
80
+ // and no device token, and the machine/session device pair does not
81
+ // exist — only `X-User-Id`, which is `anonymous` without a uid.
82
+ return {
83
+ accept: "application/json",
84
+ "user-agent": `CLI/${getWorkBuddyCliVersion()} CodeBuddy/${getWorkBuddyCliVersion()}`,
85
+ "x-domain": domain,
86
+ "x-agent-purpose": "conversation",
87
+ "x-ide-name": "CLI",
88
+ "x-ide-type": "CLI",
89
+ "x-ide-version": getWorkBuddyCliVersion(),
90
+ "x-product": "SaaS",
91
+ "x-no-enterprise-id": "1",
92
+ ...conversation,
93
+ ...buddyCliTraceHeaders(conversation),
94
+ ...buddyCliHeaders({ userId: buddyUserId(context) }),
95
+ };
96
+ }
97
+ const headers: Record<string, string> = {
98
+ accept: "application/json, text/event-stream",
99
+ "user-agent": buildWorkBuddyUserAgent(clientVersion, getWorkBuddyCliVersion()),
100
+ origin: `https://${domain}`,
101
+ referer: `https://${domain}/`,
102
+ "accept-language": "en-US",
103
+ "x-requested-with": "XMLHttpRequest",
104
+ "x-codebuddy-request": "1",
105
+ "x-domain": domain,
106
+ "x-agent-purpose": "conversation",
107
+ "x-ide-name": "WorkBuddy",
108
+ "x-ide-type": "WorkBuddy",
109
+ "x-ide-version": clientVersion,
110
+ // Both references send the literal `SaaS` here. `WorkBuddy` was a value
111
+ // this repo invented; the desktop product name belongs in `x-ide-name`,
112
+ // and the CN test below compares the two realms against each other rather
113
+ // than to a hard-coded string.
114
+ "x-product": "SaaS",
115
+ // Personal international accounts carry no enterprise id: declare it
116
+ // explicitly so upstream risk control does not treat the omission as
117
+ // suspicious.
118
+ "x-no-enterprise-id": "1",
119
+ };
120
+ // The conversation/tracing family the upstream's channel check looks for. A
121
+ // live request carrying only `x-session-id` was refused for a missing
122
+ // conversation id; the aggregation key below is the one the reference marks
123
+ // mandatory.
124
+ Object.assign(headers, conversation);
125
+ if (accountId) {
126
+ headers["x-user-id"] = accountId;
127
+ headers["x-machine-id"] = deriveAccountStableId(accountId, "machine");
128
+ headers["x-session-id"] = deriveAccountStableId(accountId, "session");
129
+ }
130
+ const deviceToken = readBuddyDeviceToken(context);
131
+ if (deviceToken !== undefined) headers["x-device-token"] = deviceToken;
132
+ return headers;
133
+ }
134
+
135
+ /**
136
+ * Desktop risk token, sent only when the account actually has one.
137
+ *
138
+ * The account's own `auth_state` wins. When it carries none, a file the
139
+ * desktop client writes (`CARTETHYIA_DEVICE_TOKEN_FILE`, default
140
+ * `/app/data/device_token`) is read instead — cached for five minutes, and
141
+ * ignored when it is larger than 1 KB, because a token file that size is not
142
+ * a token. A missing file is the normal case and reads as "no token".
143
+ */
144
+ const DEVICE_TOKEN_FILE_MAX_BYTES = 1024;
145
+ const DEVICE_TOKEN_FILE_TTL_MS = 5 * 60_000;
146
+ let deviceTokenFileCache: { readonly value: string | undefined; readonly readAt: number } | undefined;
147
+
148
+ export function readBuddyDeviceToken(context?: ProviderDispatchContext): string | undefined {
149
+ const state = context?.credential.auth_state;
150
+ const value = state?.["deviceToken"] ?? state?.["device_token"];
151
+ if (typeof value === "string" && value.trim().length > 0) return value.trim();
152
+ return readDeviceTokenFile();
153
+ }
154
+
155
+ function readDeviceTokenFile(): string | undefined {
156
+ const now = Date.now();
157
+ if (deviceTokenFileCache !== undefined && now - deviceTokenFileCache.readAt < DEVICE_TOKEN_FILE_TTL_MS) {
158
+ return deviceTokenFileCache.value;
159
+ }
160
+ const path = process.env["CARTETHYIA_DEVICE_TOKEN_FILE"] ?? "/app/data/device_token";
161
+ let value: string | undefined;
162
+ try {
163
+ const raw = readFileSync(path);
164
+ if (raw.byteLength > 0 && raw.byteLength <= DEVICE_TOKEN_FILE_MAX_BYTES) {
165
+ const text = raw.toString("utf8").trim();
166
+ if (text.length > 0) value = text;
167
+ }
168
+ } catch {
169
+ value = undefined;
170
+ }
171
+ deviceTokenFileCache = { value, readAt: now };
172
+ return value;
173
+ }
174
+
175
+ /**
176
+ * Shared OpenAI-compatible chat-adapter config for WorkBuddy: bearer auth
177
+ * (api_key and oauth both use `Authorization: Bearer`), `/v2/chat/completions`
178
+ * endpoint, per-request desktop-client headers, and the payload hook.
179
+ */
180
+ export function workbuddyAdapterConfig(args: {
181
+ providerId: ProviderId;
182
+ baseUrl: string;
183
+ domain?: string;
184
+ prePayload: (
185
+ payload: Record<string, unknown>,
186
+ request: CanonicalRequest,
187
+ candidate: ProviderDispatchTarget,
188
+ ) => void;
189
+ fetchImpl?: typeof fetch;
190
+ cliIdentity?: boolean;
191
+ }): OpenAICompatibleAdapterConfig {
192
+ return withBearerAuthentication({
193
+ provider_id: args.providerId,
194
+ base_url: args.baseUrl,
195
+ endpoint_paths_by_wire_family: { chat: WORKBUDDY_CHAT_PATH },
196
+ buildExtraHeaders: (context, request) =>
197
+ workbuddyHeaders(context, request, args.domain ?? WORKBUDDY_DOMAIN, undefined, args.cliIdentity),
198
+ prePayload: args.prePayload,
199
+ ...(args.fetchImpl ? { fetchImpl: args.fetchImpl } : {}),
200
+ });
201
+ }
@@ -1,7 +1,6 @@
1
1
  import type { FetchLike, ProviderQuotaResult, ProviderQuotaWindow } from "../../quota/quota-contracts";
2
- import { authCredential, getJson, record, text } from "../../quota/quota-contracts";
2
+ import { authCredential, getJson, record, text, unsupportedQuota } from "../../quota/quota-contracts";
3
3
  import { parseLimitWindows } from "../../quota/quota-limit-windows";
4
- import { probeApiKeyConnectivity } from "../../quota/quota-support";
5
4
  import { getClineCliVersion, getClineSdkVersion } from "../../operations/client-versions";
6
5
  import { clinePlatform, clinePlatformVersion } from "../../operations/cli-platform";
7
6
 
@@ -87,16 +86,14 @@ export async function fetchClineQuota(
87
86
  credential: string,
88
87
  fetcher: FetchLike,
89
88
  ): Promise<ProviderQuotaResult> {
90
- // API-key accounts carry no OAuth envelope and upstream exposes no quota
91
- // surface for them, so an api key must never reach the OAuth-only
92
- // users/me endpoint (a 401 there is not dispatchable either). The account
93
- // test for keys is plain validity via /models.
89
+ // An api_key account has no OAuth envelope, and Cline's quota surface
90
+ // answers 401 for one — the upstream's message asks the operator to
91
+ // re-authenticate, which is a false verdict: the key is fine and the gateway
92
+ // never had a quota to read. Report "not available" and make no upstream
93
+ // call. A probe here also cannot distinguish a dead key from Cline refusing
94
+ // the client, so the only honest answer is that quota is unavailable.
94
95
  if (credential.startsWith(CLINE_API_KEY_PREFIX)) {
95
- return probeApiKeyConnectivity(
96
- CLINE_PROVIDER_ID,
97
- credential.slice(CLINE_API_KEY_PREFIX.length),
98
- fetcher,
99
- );
96
+ return unsupportedQuota(CLINE_PROVIDER_ID);
100
97
  }
101
98
  const fields = authCredential(credential);
102
99
  const access = text(fields.accessToken);