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.
Files changed (193) hide show
  1. package/.env.example +4 -2
  2. package/README.md +26 -2
  3. package/dist/dashboard/assets/{BannedPage-B1N603cM.js → BannedPage-DeIbIiBU.js} +1 -1
  4. package/dist/dashboard/assets/BannedPage-DeIbIiBU.js.gz +0 -0
  5. package/dist/dashboard/assets/{CliToolDetailPage-9q4_4NoK.js → CliToolDetailPage-BRWKPQPf.js} +2 -2
  6. package/dist/dashboard/assets/CliToolDetailPage-BRWKPQPf.js.gz +0 -0
  7. package/dist/dashboard/assets/{CliToolsPage-DwshGtK-.js → CliToolsPage-BP-Y5iHN.js} +1 -1
  8. package/dist/dashboard/assets/CliToolsPage-BP-Y5iHN.js.gz +0 -0
  9. package/dist/dashboard/assets/{CombosPage-D762MIkp.js → CombosPage-B7JHozVa.js} +1 -1
  10. package/dist/dashboard/assets/CombosPage-B7JHozVa.js.gz +0 -0
  11. package/dist/dashboard/assets/{ConsoleLogPage-KRYjHZsX.js → ConsoleLogPage-GX7iGGcu.js} +1 -1
  12. package/dist/dashboard/assets/ConsoleLogPage-GX7iGGcu.js.gz +0 -0
  13. package/dist/dashboard/assets/CustomizationPage-HVonbGEa.js +1 -0
  14. package/dist/dashboard/assets/CustomizationPage-HVonbGEa.js.gz +0 -0
  15. package/dist/dashboard/assets/HealthEventsModal-xaut4OJ9.js +1 -0
  16. package/dist/dashboard/assets/HealthEventsModal-xaut4OJ9.js.gz +0 -0
  17. package/dist/dashboard/assets/{InjectSystemPromptPanel-d5v3K_Yc.js → InjectSystemPromptPanel-Cv-2Ol_K.js} +2 -2
  18. package/dist/dashboard/assets/InjectSystemPromptPanel-Cv-2Ol_K.js.gz +0 -0
  19. package/dist/dashboard/assets/{LoginPage-Ccv4tjNS.js → LoginPage-bKfz_b1K.js} +1 -1
  20. package/dist/dashboard/assets/LoginPage-bKfz_b1K.js.gz +0 -0
  21. package/dist/dashboard/assets/{ModelPicker-C6WlpU5E.js → ModelPicker-B-WQxx_H.js} +1 -1
  22. package/dist/dashboard/assets/ModelPicker-B-WQxx_H.js.gz +0 -0
  23. package/dist/dashboard/assets/{OverviewPage-DN9fYNWA.js → OverviewPage-Z9pJZFo8.js} +1 -1
  24. package/dist/dashboard/assets/OverviewPage-Z9pJZFo8.js.gz +0 -0
  25. package/dist/dashboard/assets/ProviderDetailPage-CMeDBxR1.js +7 -0
  26. package/dist/dashboard/assets/ProviderDetailPage-CMeDBxR1.js.gz +0 -0
  27. package/dist/dashboard/assets/ProvidersPage-BXPdOpG4.js +1 -0
  28. package/dist/dashboard/assets/ProvidersPage-BXPdOpG4.js.gz +0 -0
  29. package/dist/dashboard/assets/{ProxyPage-DxjvUzSD.js → ProxyPage-emlTuxz6.js} +1 -1
  30. package/dist/dashboard/assets/ProxyPage-emlTuxz6.js.gz +0 -0
  31. package/dist/dashboard/assets/QuotaPage-CMJKNYWb.js +1 -0
  32. package/dist/dashboard/assets/QuotaPage-CMJKNYWb.js.gz +0 -0
  33. package/dist/dashboard/assets/{SettingsPage-BW4AKj2H.js → SettingsPage-BSy7EwGo.js} +1 -1
  34. package/dist/dashboard/assets/SettingsPage-BSy7EwGo.js.gz +0 -0
  35. package/dist/dashboard/assets/{SetupPage-44BZrxFC.js → SetupPage-CUZrKs9r.js} +1 -1
  36. package/dist/dashboard/assets/SetupPage-CUZrKs9r.js.gz +0 -0
  37. package/dist/dashboard/assets/{StudioPage-DRPavCdU.js → StudioPage-jWmUdDpy.js} +2 -2
  38. package/dist/dashboard/assets/StudioPage-jWmUdDpy.js.gz +0 -0
  39. package/dist/dashboard/assets/{UsagePage-B7M7LvMM.js → UsagePage-5F9tGVGG.js} +2 -2
  40. package/dist/dashboard/assets/UsagePage-5F9tGVGG.js.gz +0 -0
  41. package/dist/dashboard/assets/app-BEsU8hKs.css +2 -0
  42. package/dist/dashboard/assets/app-BEsU8hKs.css.gz +0 -0
  43. package/dist/dashboard/assets/{badge-BaX2GTrn.js → badge-DyI_mJfi.js} +1 -1
  44. package/dist/dashboard/assets/badge-DyI_mJfi.js.gz +0 -0
  45. package/dist/dashboard/assets/{banner-DFH4Bkte.js → banner-B-yXtCqb.js} +1 -1
  46. package/dist/dashboard/assets/banner-B-yXtCqb.js.gz +0 -0
  47. package/dist/dashboard/assets/{card-CxDw0lK6.js → card-_HwAEqFF.js} +1 -1
  48. package/dist/dashboard/assets/card-_HwAEqFF.js.gz +0 -0
  49. package/dist/dashboard/assets/{clipboard-button-DfS1ntiq.js → clipboard-button-ByjCk0o9.js} +1 -1
  50. package/dist/dashboard/assets/clipboard-button-ByjCk0o9.js.gz +0 -0
  51. package/dist/dashboard/assets/{dialog-BK-NRr5u.js → dialog-BZhOlfz-.js} +1 -1
  52. package/dist/dashboard/assets/dialog-BZhOlfz-.js.gz +0 -0
  53. package/dist/dashboard/assets/{drawer-QqPul6sC.js → drawer-BADqIRYP.js} +1 -1
  54. package/dist/dashboard/assets/drawer-BADqIRYP.js.gz +0 -0
  55. package/dist/dashboard/assets/{entry-BMlYJVFj.js → entry-COaC-irN.js} +1 -1
  56. package/dist/dashboard/assets/entry-COaC-irN.js.gz +0 -0
  57. package/dist/dashboard/assets/{entry-D6WvojeD.js → entry-Ca7LSCet.js} +3 -3
  58. package/dist/dashboard/assets/entry-Ca7LSCet.js.gz +0 -0
  59. package/dist/dashboard/assets/{entry-Dmx7gBiN.js → entry-PDd27uCd.js} +1 -1
  60. package/dist/dashboard/assets/entry-PDd27uCd.js.gz +0 -0
  61. package/dist/dashboard/assets/formatters-DcHJyRiu.js +1 -0
  62. package/dist/dashboard/assets/formatters-DcHJyRiu.js.gz +0 -0
  63. package/dist/dashboard/assets/{index-Ko4dLRnV.js → index-CgKmFngv.js} +2 -2
  64. package/dist/dashboard/assets/index-CgKmFngv.js.gz +0 -0
  65. package/dist/dashboard/assets/{inline-CHJQwxLa.js → inline-B2BirKry.js} +1 -1
  66. package/dist/dashboard/assets/inline-B2BirKry.js.gz +0 -0
  67. package/dist/dashboard/assets/{input-BlmlizVV.js → input-D-qV9dKY.js} +1 -1
  68. package/dist/dashboard/assets/input-D-qV9dKY.js.gz +0 -0
  69. package/dist/dashboard/assets/{layout-Bz93I-yE.js → layout-B7tVWhtV.js} +1 -1
  70. package/dist/dashboard/assets/layout-B7tVWhtV.js.gz +0 -0
  71. package/dist/dashboard/assets/{list-row-Ml16KCfu.js → list-row-CtkGE3bF.js} +1 -1
  72. package/dist/dashboard/assets/list-row-CtkGE3bF.js.gz +0 -0
  73. package/dist/dashboard/assets/{live-7_QpnYGu.js → live-DGizF06-.js} +1 -1
  74. package/dist/dashboard/assets/live-DGizF06-.js.gz +0 -0
  75. package/dist/dashboard/assets/{network-CuMDaTzT.js → network-C9UE-bOr.js} +1 -1
  76. package/dist/dashboard/assets/network-C9UE-bOr.js.gz +0 -0
  77. package/dist/dashboard/assets/{numbers-CoqpaDMx.js → numbers-BnAd22a_.js} +1 -1
  78. package/dist/dashboard/assets/numbers-BnAd22a_.js.gz +0 -0
  79. package/dist/dashboard/assets/{pagination-B5aOoweZ.js → pagination-BAywyMzS.js} +1 -1
  80. package/dist/dashboard/assets/pagination-BAywyMzS.js.gz +0 -0
  81. package/dist/dashboard/assets/{select-BHqpd-bg.js → select-BCOcv8WK.js} +1 -1
  82. package/dist/dashboard/assets/select-BCOcv8WK.js.gz +0 -0
  83. package/dist/dashboard/assets/{stack-j7cXjgYX.js → stack-ByKt3Q1g.js} +1 -1
  84. package/dist/dashboard/assets/stack-ByKt3Q1g.js.gz +0 -0
  85. package/dist/dashboard/assets/{state-DHmEnLa2.js → state-DXbGTx7N.js} +1 -1
  86. package/dist/dashboard/assets/state-DXbGTx7N.js.gz +0 -0
  87. package/dist/dashboard/assets/{switch-DmZcyTIC.js → switch-ZeTsW623.js} +1 -1
  88. package/dist/dashboard/assets/switch-ZeTsW623.js.gz +0 -0
  89. package/dist/dashboard/index.html +2 -2
  90. package/dist/dashboard/index.html.gz +0 -0
  91. package/dist/dashboard/precompressed-assets.json +44 -44
  92. package/migrations/full-postgres/0043_add_provider_custom_user_agent.sql +14 -0
  93. package/migrations/lite-sqlite3/0043_add_provider_custom_user_agent.sql +10 -0
  94. package/package.json +3 -1
  95. package/src/config/security.ts +1 -1
  96. package/src/config/spec.ts +13 -6
  97. package/src/console/cli-tools/cartethyia-websearch-plugin.ts +186 -0
  98. package/src/console/cli-tools/injectors/opencode.ts +42 -14
  99. package/src/console/providers/catalog/contracts.ts +4 -0
  100. package/src/console/providers/catalog/routes.ts +1 -0
  101. package/src/console/providers/detail/routes.ts +8 -0
  102. package/src/console/providers/detail/store.ts +4 -0
  103. package/src/console/quota/account-quota-tenant.ts +74 -0
  104. package/src/observability/telemetry-buffer.ts +2 -1
  105. package/src/persistence/boot.ts +13 -4
  106. package/src/persistence/schema.ts +7 -1
  107. package/src/protocol/canonical-model.ts +7 -0
  108. package/src/protocol/request/gemini.ts +20 -1
  109. package/src/protocol/tool-identity.ts +15 -1
  110. package/src/providers/default-registry.ts +8 -2
  111. package/src/providers/discovery/discovery-types.ts +18 -0
  112. package/src/providers/discovery/probe-phases.ts +45 -0
  113. package/src/providers/discovery/probing-service.ts +41 -6
  114. package/src/providers/integrations/antigravity/antigravity-protocol.ts +20 -2
  115. package/src/providers/integrations/antigravity/antigravity.ts +24 -0
  116. package/src/providers/integrations/codex/codex.ts +28 -1
  117. package/src/providers/integrations/qoder/chat-proto.ts +328 -0
  118. package/src/providers/integrations/qoder/claim-qoder-credits.ts +258 -0
  119. package/src/providers/integrations/qoder/qoder-adapter.ts +275 -0
  120. package/src/providers/integrations/qoder/qoder-auth.ts +201 -0
  121. package/src/providers/integrations/qoder/qoder-body.ts +78 -0
  122. package/src/providers/integrations/qoder/qoder-catalog.ts +5 -1
  123. package/src/providers/integrations/qoder/qoder-chat.ts +514 -0
  124. package/src/providers/integrations/qoder/qoder-cosy.ts +162 -0
  125. package/src/providers/integrations/qoder/qoder-grpc.ts +667 -0
  126. package/src/providers/integrations/qoder/qoder-ide.ts +584 -0
  127. package/src/providers/integrations/qoder/qoder-oauth.ts +4 -4
  128. package/src/providers/integrations/qoder/qoder-quota.ts +1 -1
  129. package/src/providers/integrations/qoder/qoder-realm.ts +40 -4
  130. package/src/providers/integrations/qoder/qoder-types.ts +51 -0
  131. package/src/providers/integrations/qoder/qoder-umid.ts +123 -8
  132. package/src/providers/integrations/qoder/qoder.ts +147 -1889
  133. package/src/providers/operations/byok-wire-profile.ts +9 -12
  134. package/src/providers/provider-contracts.ts +7 -0
  135. package/src/providers/provider-usage.ts +9 -6
  136. package/src/security/abuse.ts +5 -5
  137. package/src/transport/dispatch/upstream.ts +4 -2
  138. package/src/transport/routing/route-catalog.ts +8 -0
  139. package/src/transport/routing/route-model.ts +4 -0
  140. package/src/transport/routing/router.ts +8 -117
  141. package/dist/dashboard/assets/BannedPage-B1N603cM.js.gz +0 -0
  142. package/dist/dashboard/assets/CliToolDetailPage-9q4_4NoK.js.gz +0 -0
  143. package/dist/dashboard/assets/CliToolsPage-DwshGtK-.js.gz +0 -0
  144. package/dist/dashboard/assets/CombosPage-D762MIkp.js.gz +0 -0
  145. package/dist/dashboard/assets/ConsoleLogPage-KRYjHZsX.js.gz +0 -0
  146. package/dist/dashboard/assets/CustomizationPage-DXsxk5uM.js +0 -1
  147. package/dist/dashboard/assets/CustomizationPage-DXsxk5uM.js.gz +0 -0
  148. package/dist/dashboard/assets/HealthEventsModal-BEjAjD4F.js +0 -1
  149. package/dist/dashboard/assets/HealthEventsModal-BEjAjD4F.js.gz +0 -0
  150. package/dist/dashboard/assets/InjectSystemPromptPanel-d5v3K_Yc.js.gz +0 -0
  151. package/dist/dashboard/assets/LoginPage-Ccv4tjNS.js.gz +0 -0
  152. package/dist/dashboard/assets/ModelPicker-C6WlpU5E.js.gz +0 -0
  153. package/dist/dashboard/assets/OverviewPage-DN9fYNWA.js.gz +0 -0
  154. package/dist/dashboard/assets/ProviderDetailPage-BY0ieUgz.js +0 -5
  155. package/dist/dashboard/assets/ProviderDetailPage-BY0ieUgz.js.gz +0 -0
  156. package/dist/dashboard/assets/ProvidersPage-DNsbfea2.js +0 -1
  157. package/dist/dashboard/assets/ProvidersPage-DNsbfea2.js.gz +0 -0
  158. package/dist/dashboard/assets/ProxyPage-DxjvUzSD.js.gz +0 -0
  159. package/dist/dashboard/assets/QuotaPage-OaHScjpX.js +0 -1
  160. package/dist/dashboard/assets/QuotaPage-OaHScjpX.js.gz +0 -0
  161. package/dist/dashboard/assets/SettingsPage-BW4AKj2H.js.gz +0 -0
  162. package/dist/dashboard/assets/SetupPage-44BZrxFC.js.gz +0 -0
  163. package/dist/dashboard/assets/StudioPage-DRPavCdU.js.gz +0 -0
  164. package/dist/dashboard/assets/UsagePage-B7M7LvMM.js.gz +0 -0
  165. package/dist/dashboard/assets/app-mweRcsKp.css +0 -2
  166. package/dist/dashboard/assets/app-mweRcsKp.css.gz +0 -0
  167. package/dist/dashboard/assets/badge-BaX2GTrn.js.gz +0 -0
  168. package/dist/dashboard/assets/banner-DFH4Bkte.js.gz +0 -0
  169. package/dist/dashboard/assets/card-CxDw0lK6.js.gz +0 -0
  170. package/dist/dashboard/assets/clipboard-button-DfS1ntiq.js.gz +0 -0
  171. package/dist/dashboard/assets/dialog-BK-NRr5u.js.gz +0 -0
  172. package/dist/dashboard/assets/drawer-QqPul6sC.js.gz +0 -0
  173. package/dist/dashboard/assets/entry-BMlYJVFj.js.gz +0 -0
  174. package/dist/dashboard/assets/entry-D6WvojeD.js.gz +0 -0
  175. package/dist/dashboard/assets/entry-Dmx7gBiN.js.gz +0 -0
  176. package/dist/dashboard/assets/formatters-u7JPB5BX.js +0 -1
  177. package/dist/dashboard/assets/formatters-u7JPB5BX.js.gz +0 -0
  178. package/dist/dashboard/assets/index-Ko4dLRnV.js.gz +0 -0
  179. package/dist/dashboard/assets/inline-CHJQwxLa.js.gz +0 -0
  180. package/dist/dashboard/assets/input-BlmlizVV.js.gz +0 -0
  181. package/dist/dashboard/assets/layout-Bz93I-yE.js.gz +0 -0
  182. package/dist/dashboard/assets/list-row-Ml16KCfu.js.gz +0 -0
  183. package/dist/dashboard/assets/live-7_QpnYGu.js.gz +0 -0
  184. package/dist/dashboard/assets/network-CuMDaTzT.js.gz +0 -0
  185. package/dist/dashboard/assets/numbers-CoqpaDMx.js.gz +0 -0
  186. package/dist/dashboard/assets/pagination-B5aOoweZ.js.gz +0 -0
  187. package/dist/dashboard/assets/select-BHqpd-bg.js.gz +0 -0
  188. package/dist/dashboard/assets/stack-j7cXjgYX.js.gz +0 -0
  189. package/dist/dashboard/assets/state-DHmEnLa2.js.gz +0 -0
  190. package/dist/dashboard/assets/switch-DmZcyTIC.js.gz +0 -0
  191. package/src/providers/integrations/qoder/qoder-text-tools.ts +0 -277
  192. /package/dist/dashboard/assets/{app-DRnQLhsc.js → app-CTxoqyYB.js} +0 -0
  193. /package/dist/dashboard/assets/{app-DRnQLhsc.js.gz → app-CTxoqyYB.js.gz} +0 -0
@@ -0,0 +1,514 @@
1
+ /**
2
+ * Qoder legacy COSY chat builder & transport.
3
+ *
4
+ * `buildQoderRequest` translates one canonical request into the legacy
5
+ * `/algo/.../agent_chat_generation` body (and, by reuse, the messages/tools
6
+ * shared by the IDE and gRPC surfaces). `callQoder` signs and sends it over
7
+ * the COSY surface with the real client's headers (Bun UA, machine identity,
8
+ * `x-model-key/source`, SSE accept).
9
+ */
10
+ import { createHash, randomUUID } from "node:crypto";
11
+ import type {
12
+ CanonicalRequest,
13
+ ContentPart,
14
+ } from "../../../protocol/canonical-model";
15
+ import { toolResultParts, toolCallParts } from "../../../protocol/canonical-model";
16
+ import { reasoningEffortFromIntent } from "../../../protocol/reasoning";
17
+ import { resolveImageSource } from "../../../protocol/codecs";
18
+ import { getQoderVersion } from "../../operations/client-versions";
19
+ import { qoderDiscoveredMaxContexts, QODER_CN_MODEL_CONFIGS, QODER_INTL_MODEL_CONFIGS } from "./qoder-catalog";
20
+ import { transformQoderBody, qoderBuildCosyHeaders } from "./qoder-cosy";
21
+ import { qoderSessionId } from "./qoder-auth";
22
+ import { flattenCanonicalContent, qoderToolChoice, qoderTools } from "./qoder-body";
23
+ import type { QoderRealm } from "./qoder-realm";
24
+ import type { QoderAuth, QoderModelConfig } from "./qoder-types";
25
+
26
+ export interface QoderModeProfile {
27
+ readonly businessProduct: string;
28
+ readonly businessType: string;
29
+ readonly cosyScene: string;
30
+ readonly mirrorTopLevelSystem: boolean;
31
+ readonly sendBusinessHeaders: boolean;
32
+ readonly sendModelSourceHeaders: boolean;
33
+ readonly emptyAliyunUserType: boolean;
34
+ }
35
+
36
+ export const MODERN_PROFILE: QoderModeProfile = {
37
+ businessProduct: "cli",
38
+ businessType: "agent",
39
+ cosyScene: "assistant",
40
+ mirrorTopLevelSystem: true,
41
+ sendBusinessHeaders: true,
42
+ sendModelSourceHeaders: true,
43
+ emptyAliyunUserType: true,
44
+ };
45
+
46
+ function qoderStableRecordId(modelId: string, messages: CanonicalRequest["messages"], maxTokens: number): string {
47
+ const h = createHash("sha256");
48
+ h.update("qoder-record\0");
49
+ h.update(modelId);
50
+ for (const m of messages) {
51
+ h.update("\0");
52
+ h.update(m.role);
53
+ const text = flattenCanonicalContent(m.content);
54
+ if (text) {
55
+ h.update("\0");
56
+ h.update(text);
57
+ }
58
+ }
59
+ h.update(`\0mt=${maxTokens}`);
60
+ return h.digest("hex").slice(0, 16);
61
+ }
62
+
63
+ /**
64
+ * The widest selectable context window for one model key.
65
+ *
66
+ * The live list's `context_config` is the authority when discovery has run:
67
+ * it names every window the account can switch to, and the widest one is what
68
+ * a dispatch should send (`model_config.max_input_tokens`) so a conversation is
69
+ * never capped at the *currently active* window (often 180k) when the account
70
+ * can use 1M. Falls back to the static roster's stated window otherwise.
71
+ */
72
+ function qoderMaxContextFor(modelId: string, modelConfig: QoderModelConfig, realm: QoderRealm): number {
73
+ const discovered = qoderDiscoveredMaxContexts(realm).get(modelId);
74
+ if (discovered !== undefined) return discovered;
75
+ return modelConfig.max_input_tokens;
76
+ }
77
+
78
+ function qoderRequestedMaxTokens(
79
+ request: CanonicalRequest,
80
+ modelConfig: QoderModelConfig,
81
+ realm: QoderRealm,
82
+ ): number {
83
+ /**
84
+ * The ceiling is the roster's stated one. A fixed 32k fallback would clamp
85
+ * every model alike, so a request asking for more would be silently lowered
86
+ * no matter which model it named. A discovered row carries no
87
+ * `max_output_tokens` (upstream's model list reports none), so consult the
88
+ * static roster for the key before the 65_536 last resort — and keep that
89
+ * last resort only when neither the caller nor the roster states a limit.
90
+ */
91
+ const configured =
92
+ typeof modelConfig.max_output_tokens === "number"
93
+ ? modelConfig.max_output_tokens
94
+ : qoderRosterMaxOutput(modelConfig.id, realm) ?? 65_536;
95
+ const controls = request.generation_controls as Record<string, unknown>;
96
+ const requested =
97
+ typeof controls["max_tokens"] === "number"
98
+ ? (controls["max_tokens"] as number)
99
+ : typeof controls["max_output_tokens"] === "number"
100
+ ? (controls["max_output_tokens"] as number)
101
+ : null;
102
+ return typeof requested === "number" && requested > 0 ? Math.min(requested, configured) : configured;
103
+ }
104
+
105
+ /**
106
+ * The static roster's stated output ceiling for one key, when the roster states
107
+ * one. Dispatch builds a live-discovered model's config without an output limit
108
+ * (upstream's model list reports none); this lookup restores the roster's
109
+ * ceiling so such a model is not capped at the 65_536 last resort.
110
+ */
111
+ function qoderRosterMaxOutput(modelId: string, realm: QoderRealm): number | undefined {
112
+ const configs = realm === "cn" ? QODER_CN_MODEL_CONFIGS : QODER_INTL_MODEL_CONFIGS;
113
+ return configs[modelId]?.max_output_tokens;
114
+ }
115
+
116
+ /**
117
+ * Whether this model needs the prior turn's reasoning echoed back.
118
+ *
119
+ * The DeepSeek family ties multi-turn consistency to `reasoning_content`: an
120
+ * assistant turn that thought must be replayed with that field or the upstream
121
+ * refuses the whole history. The upstream reference bridge keeps it for exactly
122
+ * these models and drops it as noise for every other one
123
+ * (`keep_reasoning=is_deepseek_model(...)`, and `reasoning_content` in
124
+ * `NOISE_KEYS` otherwise), and notes the symptom when it is missed: occasional
125
+ * failures that sometimes pass on retry.
126
+ *
127
+ * Matched by the upstream key and by the display name, because a caller may
128
+ * name either — the reference warns that matching only the "deepseek" prefix
129
+ * missed every request that used the key.
130
+ */
131
+ function qoderKeepsReasoning(modelId: string): boolean {
132
+ const key = modelId.trim().toLowerCase();
133
+ if (key === "dmodel" || key === "dfmodel") return true;
134
+ return key.startsWith("deepseek");
135
+ }
136
+
137
+ /**
138
+ * The `reasoning_content` field for an assistant turn, when one is needed.
139
+ *
140
+ * Presence, not length, decides: a client that already knows the rule sends
141
+ * `""` on a thinking turn whose text is not readable back, and dropping that
142
+ * empty string re-triggers the complaint it was sent to avoid.
143
+ *
144
+ * DeepSeek multi-turn consistency also backfills the field on every assistant
145
+ * turn once the conversation carries a reasoning trace at all — the reference
146
+ * bridge does the same (`backfill_reasoning_content`), and notes the symptom
147
+ * when it is missed: occasional failures that sometimes pass on retry. An
148
+ * assistant turn with no reasoning and no trace gains nothing, so nothing is
149
+ * fabricated.
150
+ */
151
+ function qoderReasoningEcho(
152
+ message: { content: readonly ContentPart[] },
153
+ modelId: string,
154
+ hasReasoningTrace = false,
155
+ ): { reasoning_content: string } | Record<string, never> {
156
+ if (!qoderKeepsReasoning(modelId)) return {};
157
+ const reasoning = message.content.filter((part) => part.kind === "reasoning");
158
+ if (reasoning.length === 0) {
159
+ return hasReasoningTrace ? { reasoning_content: "" } : {};
160
+ }
161
+ const text = reasoning
162
+ .map((part) => (part.kind === "reasoning" ? (part.summary ?? part.payload ?? "") : ""))
163
+ .join("");
164
+ return { reasoning_content: text };
165
+ }
166
+
167
+ /**
168
+ * Whether this request sends its tool history as structured turns.
169
+ *
170
+ * Qoder's wire is OpenAI-compatible `tool_calls` deltas on every realm; the
171
+ * model is fed the same structured turns in both INTL and CN. Writing a past
172
+ * call as prose (`[assistant 请求调用工具] …`) contaminates the conversation
173
+ * history with a text echo the model starts imitating — the origin of the
174
+ * text-leak feedback loop. Structured history keeps the model seeing only real
175
+ * tool calls, so it never learns the prose marker.
176
+ */
177
+ function qoderStructuredTools(_realm: QoderRealm): boolean {
178
+ return true;
179
+ }
180
+
181
+ export function buildQoderRequest(
182
+ modelId: string,
183
+ request: CanonicalRequest,
184
+ modelConfig: QoderModelConfig,
185
+ auth: QoderAuth,
186
+ realm: QoderRealm = "intl",
187
+ ): Record<string, unknown> {
188
+ const allMessages = request.messages;
189
+ const systemParts: string[] = [];
190
+ if (request.system) {
191
+ for (const p of request.system) if (p.kind === "text") systemParts.push(p.text);
192
+ }
193
+ if (request.instructions) {
194
+ for (const p of request.instructions) if (p.kind === "text") systemParts.push(p.text);
195
+ }
196
+ for (const m of allMessages) {
197
+ if (m.role === "system" || m.role === "developer") {
198
+ const t = flattenCanonicalContent(m.content);
199
+ if (t) systemParts.push(t);
200
+ }
201
+ }
202
+ const systemPrompt = systemParts.join("\n\n").trim();
203
+ const qoderMessages: Array<Record<string, unknown>> = [];
204
+ const structuredTools = qoderStructuredTools(realm);
205
+ // DeepSeek multi-turn: once any turn in the conversation carries a reasoning
206
+ // trace, every assistant message must replay `reasoning_content` (see
207
+ // `qoderReasoningEcho`).
208
+ const hasReasoningTrace = allMessages.some((message) =>
209
+ message.content.some((part) => part.kind === "reasoning"),
210
+ );
211
+ if (MODERN_PROFILE.mirrorTopLevelSystem && systemPrompt.length > 0) {
212
+ qoderMessages.push({
213
+ role: "system",
214
+ content: systemPrompt,
215
+ });
216
+ }
217
+ // Every tool result across the whole conversation, keyed by call id. An
218
+ // assistant `tool_calls` turn is only sent structured when every call has a
219
+ // matching result somewhere in the history — otherwise the upstream rejects
220
+ // the transcript ("An assistant message with 'tool_calls' must be followed by
221
+ // tool messages responding to each 'tool_call_id'"). Calls without a result
222
+ // are left out of the structured turn instead of poisoning it.
223
+ const toolResultsById = new Map<string, string>();
224
+ for (const msg of allMessages) {
225
+ for (const part of toolResultParts(msg)) {
226
+ if (typeof part.call_id === "string" && part.call_id.length > 0) {
227
+ toolResultsById.set(part.call_id, part.call_id);
228
+ }
229
+ }
230
+ }
231
+ // The tool calls that actually survive onto the wire. A call only makes it
232
+ // when every call on its assistant turn carries an id AND a matching result
233
+ // exists in the history. This is the single source of truth for both sides
234
+ // of the transcript: the assistant `tool_calls` emit and the `tool` result
235
+ // turns. If a result's call is not here, its `tool` turn is dropped too —
236
+ // leaving it would emit an orphan `tool` turn with no preceding call, which
237
+ // the upstream rejects ("Messages with role 'tool' must be a response to a
238
+ // preceding message with 'tool_calls'"). This is what a cancelled or
239
+ // compacted parallel call looks like, and it is the "insufficient tool
240
+ // messages" failure the client sees on the turn after.
241
+ const emittableCallIds = new Set<string>();
242
+ for (const msg of allMessages) {
243
+ if (msg.role !== "assistant") continue;
244
+ const calls = toolCallParts(msg);
245
+ if (calls.length === 0) continue;
246
+ const idsOk = calls.every(
247
+ (part) => typeof part.call_id === "string" && part.call_id.length > 0,
248
+ );
249
+ const everyCallHasResult = calls.every((part) =>
250
+ typeof part.call_id === "string" ? toolResultsById.has(part.call_id) : false,
251
+ );
252
+ if (idsOk && everyCallHasResult) {
253
+ for (const part of calls) emittableCallIds.add(part.call_id as string);
254
+ }
255
+ }
256
+ for (const m of allMessages) {
257
+ if (m.role === "system" || m.role === "developer") continue;
258
+ // Answers may live in a `user` turn (the Messages ledger re-homes them),
259
+ // so the result scan is role-agnostic. Each result becomes its own `tool`
260
+ // turn carrying its `tool_call_id`; any non-result content on the same turn
261
+ // is emitted after, so nothing is duplicated or lost.
262
+ const results = structuredTools ? toolResultParts(m) : [];
263
+ if (results.length > 0) {
264
+ let emittedAnyTool = false;
265
+ for (const block of results) {
266
+ // Drop orphan results: their assistant `tool_calls` turn was filtered
267
+ // out (a sibling call has no result), and emitting the `tool` turn
268
+ // anyway would make the upstream reject the whole transcript.
269
+ if (!emittableCallIds.has(block.call_id)) continue;
270
+ emittedAnyTool = true;
271
+ const text = flattenCanonicalContent(
272
+ typeof block.content === "string"
273
+ ? [{ kind: "text", text: block.content }]
274
+ : block.content,
275
+ );
276
+ qoderMessages.push({
277
+ role: "tool",
278
+ content: text,
279
+ tool_call_id: block.call_id,
280
+ });
281
+ }
282
+ // Text that shared the turn with a result is emitted after it, minus the
283
+ // result itself. Re-emitting the result's own text here was doubling
284
+ // every tool answer: the `tool` turn above and a `user` turn with the
285
+ // same content, which doubled the payload for no reason.
286
+ const remaining = m.content.filter((part) => part.kind !== "toolResult");
287
+ const flat = flattenCanonicalContent(remaining);
288
+ if (flat.length === 0 && !emittedAnyTool) continue;
289
+ if (flat.length > 0) {
290
+ qoderMessages.push({
291
+ role: "user",
292
+ content: flat,
293
+ });
294
+ }
295
+ continue;
296
+ }
297
+ // An assistant turn that called tools must carry the calls, not just its
298
+ // text. Falling through to the text-only emit dropped them, so the payload
299
+ // arrived as a `tool` result with no preceding call and the upstream
300
+ // rejected the whole transcript: "Messages with role 'tool' must be a
301
+ // response to a preceding message with 'tool_calls'".
302
+ const allCalls = m.content.filter((part) => part.kind === "toolCall");
303
+ const reasoningEcho = qoderReasoningEcho(m, modelId, hasReasoningTrace);
304
+ // Structured tool turns are only safe when every call carries an id AND a
305
+ // matching result exists in the history — the upstream rejects a transcript
306
+ // whose `tool_calls` is not fully answered by `tool` turns. The same set
307
+ // that gates the `tool` emit above decides this, so the two halves of the
308
+ // transcript can never disagree.
309
+ const calls =
310
+ structuredTools && allCalls.every((part) => emittableCallIds.has(part.call_id))
311
+ ? allCalls
312
+ : [];
313
+ if (calls.length > 0) {
314
+ const textParts = m.content.filter((part) => part.kind !== "toolCall");
315
+ const flat = flattenCanonicalContent(textParts);
316
+ qoderMessages.push({
317
+ role: "assistant",
318
+ content: flat,
319
+ ...reasoningEcho,
320
+ tool_calls: calls.map((part) => ({
321
+ id: part.call_id,
322
+ type: "function",
323
+ function: {
324
+ name: part.name,
325
+ arguments:
326
+ typeof part.arguments === "string"
327
+ ? part.arguments
328
+ : part.arguments !== undefined && part.arguments !== null
329
+ ? JSON.stringify(part.arguments)
330
+ : "",
331
+ },
332
+ })),
333
+ });
334
+ continue;
335
+ }
336
+ const role = m.role === "assistant" ? "assistant" : "user";
337
+ // Images must travel as structured OpenAI parts inside the message content —
338
+ // the upstream reads `image_url` parts there and not the top-level
339
+ // `image_urls` (mirroring `flatten_messages` in the reference bridge). A
340
+ // bare file_id reference degrades to a text note instead of being dropped.
341
+ const hasImages = m.content.some((part) => part.kind === "image");
342
+ if (hasImages) {
343
+ const parts: Array<Record<string, unknown>> = [];
344
+ for (const part of m.content) {
345
+ if (part.kind === "text") {
346
+ parts.push({ type: "text", text: part.text });
347
+ } else if (part.kind === "image") {
348
+ const source = resolveImageSource(part.payload);
349
+ if (source?.url !== undefined) {
350
+ parts.push({ type: "image_url", image_url: { url: source.url } });
351
+ } else {
352
+ parts.push({
353
+ type: "text",
354
+ text: source?.fileId !== undefined ? `[image: ${source.fileId}]` : "[image: unsupported source]",
355
+ });
356
+ }
357
+ }
358
+ }
359
+ qoderMessages.push({
360
+ role,
361
+ content: parts,
362
+ ...(role === "assistant" ? qoderReasoningEcho(m, modelId, hasReasoningTrace) : {}),
363
+ });
364
+ continue;
365
+ }
366
+ const flat = flattenCanonicalContent(m.content);
367
+ qoderMessages.push({
368
+ role,
369
+ content: flat,
370
+ ...(role === "assistant" ? qoderReasoningEcho(m, modelId, hasReasoningTrace) : {}),
371
+ });
372
+ }
373
+ const latestUserMessage = [...qoderMessages].reverse().find((m) => m["role"] === "user");
374
+ const latestUserText = typeof latestUserMessage?.["content"] === "string" ? (latestUserMessage["content"] as string) : "";
375
+ const maxTokens = qoderRequestedMaxTokens(request, modelConfig, realm);
376
+ const reasoning = modelConfig.is_reasoning === true;
377
+ // 1:1 with the IDE worker's `FTn` legacy builder, which sends reasoning
378
+ // effort as a top-level camelCase field (`reasoningEffort`), derived from
379
+ // the canonical intent when one is present. Omitted entirely when the
380
+ // caller asked for no reasoning, matching the reference's conditional send.
381
+ // When the caller sends no intent but the model needs the push to think
382
+ // (verified live: Sonus/Cantus stream no reasoning without it), use the
383
+ // roster's default effort.
384
+ //
385
+ // Only a reasoning-capable model carries the field. A non-reasoning model
386
+ // (e.g. `lite`) cannot think, so an effort is meaningless there; sending it
387
+ // would also assert a capability the model does not advertise. The upstream
388
+ // tolerates it today, but declaring reasoning on a model that has none is a
389
+ // latent contract violation, so the field is kept off non-reasoning models.
390
+ const reasoningEffort =
391
+ reasoning
392
+ ? (reasoningEffortFromIntent(request.reasoning) ?? modelConfig.default_reasoning_effort)
393
+ : undefined;
394
+ const sessionId = qoderSessionId(auth.userId, modelId);
395
+ const recordId = qoderStableRecordId(modelId, allMessages, maxTokens);
396
+ return {
397
+ // 1:1 with the IDE worker's `FTn` legacy request builder, which always
398
+ // sends a top-level `model` equal to the resolved roster key (falling
399
+ // back to "auto" only when no key is known). The upstream honours
400
+ // `model_config.key` for actual dispatch and echoes a fixed "auto" in
401
+ // stream chunks, but the field is part of the wire shape the IDE sends.
402
+ model: modelId,
403
+ request_id: randomUUID(),
404
+ request_set_id: recordId,
405
+ chat_record_id: recordId,
406
+ session_id: sessionId,
407
+ source_session_id: "",
408
+ stream: true,
409
+ ...(reasoningEffort === undefined ? {} : { reasoningEffort }),
410
+ ...(MODERN_PROFILE.emptyAliyunUserType ? { aliyun_user_type: "" } : {}),
411
+ chat_task: "FREE_INPUT",
412
+ is_reply: true,
413
+ is_retry: false,
414
+ source: 1,
415
+ version: "3",
416
+ session_type: "qodercli",
417
+ agent_id: "agent_common",
418
+ task_id: "common",
419
+ code_language: "",
420
+ chat_prompt: MODERN_PROFILE.mirrorTopLevelSystem ? systemPrompt : "",
421
+ image_urls: null,
422
+ chat_context: {
423
+ chatPrompt: MODERN_PROFILE.mirrorTopLevelSystem ? systemPrompt : "",
424
+ imageUrls: null,
425
+ extra: { context: [], modelConfig: { key: modelId, is_reasoning: reasoning }, originalContent: { text: latestUserText } },
426
+ features: [],
427
+ // An object, as the reference sends it: `cc["text"]["text"] = prompt`.
428
+ text: { text: latestUserText },
429
+ },
430
+ model_config: {
431
+ key: modelId,
432
+ display_name: modelConfig.display_name,
433
+ // Declares the wire dialect for the tool calls. Without it the upstream
434
+ // does not read the OpenAI-shaped `tools` array the same way, and the
435
+ // model then emits a call with no arguments filled in.
436
+ format: "openai",
437
+ model: "",
438
+ api_key: "",
439
+ url: "",
440
+ is_vl: modelConfig.is_vl,
441
+ is_reasoning: modelConfig.is_reasoning,
442
+ // Send the widest selectable window from the live list's `context_config`
443
+ // (falling back to the static roster's stated window). Without this, the
444
+ // request would declare the *currently active* window — often 180k — even
445
+ // when the account can use 1M, capping the conversation the upstream sees.
446
+ max_input_tokens: qoderMaxContextFor(modelId, modelConfig, realm),
447
+ // Mirrors the roster's stated ceiling. A fixed 32k fallback would be sent
448
+ // for every model alike, so the request would declare a cap the catalog
449
+ // does not publish. A discovered model's config carries no output limit,
450
+ // so consult the roster for the key before the 65_536 last resort.
451
+ max_output_tokens: modelConfig.max_output_tokens ?? qoderRosterMaxOutput(modelId, realm) ?? 65_536,
452
+ source: modelConfig.source ?? "system",
453
+ },
454
+ messages: qoderMessages,
455
+ max_tokens: maxTokens,
456
+ // Always present, even when empty. Omitting the field makes Qoder fall back
457
+ // to the desktop agent's own toolset (Bash/Edit/...), which then leaks to a
458
+ // caller that declared none — the reference bridge sends `[]` for exactly
459
+ // that reason. Parallel calls are stated explicitly rather than left to the
460
+ // upstream default, so a caller expecting them is not served serial
461
+ // behaviour.
462
+ tools: qoderTools(request.tools),
463
+ parallel_tool_calls: true,
464
+ ...(() => {
465
+ const choice = qoderToolChoice(request.tool_choice);
466
+ return choice === undefined ? {} : { tool_choice: choice };
467
+ })(),
468
+ // Passthrough knobs the IDE surface reads from `parameters` (worker `vyl`):
469
+ // parallel_tool_calls, tool_choice, top_p, seed, user, response_format, …
470
+ // Only include what the caller actually set.
471
+ parameters: (() => {
472
+ const out: Record<string, unknown> = {};
473
+ const choice = qoderToolChoice(request.tool_choice);
474
+ if (choice !== undefined) out["tool_choice"] = choice;
475
+ const parallel = request.generation_controls.parallel_tool_calls;
476
+ if (parallel !== undefined) out["parallel_tool_calls"] = parallel;
477
+ return Object.keys(out).length > 0 ? out : undefined;
478
+ })(),
479
+ };
480
+ }
481
+
482
+ type QoderFetcher = (url: string, init: RequestInit) => Promise<Response>;
483
+
484
+ /**
485
+ * Signs and sends one legacy COSY chat request. The body is alphabet-scrambled
486
+ * before signing; headers come from `qoderBuildCosyHeaders` plus the modern
487
+ * `x-model-key`/`x-model-source` and SSE accepts.
488
+ */
489
+ export async function callQoder(
490
+ url: string,
491
+ body: Record<string, unknown>,
492
+ modelId: string,
493
+ auth: QoderAuth,
494
+ signal: AbortSignal,
495
+ fetcher: QoderFetcher,
496
+ version = getQoderVersion(),
497
+ ): Promise<Response> {
498
+ const encoded = transformQoderBody(JSON.stringify(body));
499
+ const modelConfig = (body["model_config"] as Record<string, unknown> | null) ?? null;
500
+ return fetcher(url, {
501
+ method: "POST",
502
+ headers: {
503
+ ...qoderBuildCosyHeaders(encoded, url, auth, version),
504
+ ...(MODERN_PROFILE.sendModelSourceHeaders
505
+ ? { "x-model-key": modelId, "x-model-source": typeof modelConfig?.["source"] === "string" ? (modelConfig["source"] as string) : "system" }
506
+ : {}),
507
+ Accept: "text/event-stream",
508
+ "Cache-Control": "no-cache",
509
+ "Accept-Encoding": "identity",
510
+ },
511
+ body: encoded as unknown as BodyInit,
512
+ signal,
513
+ });
514
+ }
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Qoder COSY signing — AES-128-CBC identity encryption + RSA PKCS1 key +
3
+ * md5 chain, and the alphabet-scrambled body transform. This is the donor of
4
+ * the WASM `prepareInferRequest` output of the real client, and the only chat
5
+ * surface a free/lite account can use.
6
+ *
7
+ * Header values mirror live captures of the real client (v1.1.83,
8
+ * 2026-11-05): `User-Agent: Bun/1.4.2`, machine identity headers from the
9
+ * UMID (`cosy-machineid/token/type/code/os/hostname`), business headers,
10
+ * and the static `signature` md5 chain.
11
+ */
12
+ import { constants, createCipheriv, createHash, publicEncrypt, randomUUID } from "node:crypto";
13
+ import { getQoderVersion } from "../../operations/client-versions";
14
+ import { qoderMachineHostname, qoderMachineOs } from "../../operations/cli-platform";
15
+ import { QODER_UA_CLI } from "./qoder-realm";
16
+ import type { QoderAuth } from "./qoder-types";
17
+
18
+ const QODER_APPCODE = "cosy";
19
+ const QODER_SIG_SECRET = "d2FyLCB3YXIgbmV2ZXIgY2hhbmdlcw==";
20
+ const QODER_STANDARD_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
21
+ const QODER_ALPHABET = "_doRTgHZBKcGVjlvpC,@aFSx#DPuNJme&i*MzLOEn)sUrthbf%Y^w.(kIQyXqWA!";
22
+ const QODER_RSA_PUBLIC_KEY = `-----BEGIN PUBLIC KEY-----
23
+ MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDA8iMH5c02LilrsERw9t6Pv5Nc
24
+ 4k6Pz1EaDicBMpdpxKduSZu5OANqUq8er4GM95omAGIOPOh+Nx0spthYA2BqGz+l
25
+ 6HRkPJ7S236FZz73In/KVuLnwI8JJ2CbuJap8kvheCCZpmAWpb/cPx/3Vr/J6I17
26
+ XcW+ML9FoCI6AOvOzwIDAQAB
27
+ -----END PUBLIC KEY-----`;
28
+
29
+ function qoderMd5(value: string | Uint8Array): string {
30
+ return createHash("md5").update(value).digest("hex");
31
+ }
32
+
33
+ /**
34
+ * Canonical compact JSON used by Qoder's COSY signing: keys sorted
35
+ * lexicographically, no whitespace, null/undefined rendered as empty string.
36
+ * The upstream verifies the signature against this exact serialization, so a
37
+ * payload built with plain `JSON.stringify` (insertion order) fails with
38
+ * `code 101 / Signature invalid`.
39
+ */
40
+ function qoderJsonSortedCompact(mapping: Record<string, unknown>): string {
41
+ const parts: string[] = [];
42
+ for (const key of Object.keys(mapping).sort()) {
43
+ const value = mapping[key] === null || mapping[key] === undefined ? "" : mapping[key];
44
+ parts.push(`${JSON.stringify(key)}:${JSON.stringify(value)}`);
45
+ }
46
+ return `{${parts.join(",")}}`;
47
+ }
48
+
49
+ function qoderPathSignature(url: string): string {
50
+ const path = new URL(url).pathname;
51
+ return path.startsWith("/algo") ? path.slice(5) : path;
52
+ }
53
+
54
+ /**
55
+ * Qoder's own base64 alphabet transform for a request body: base64, rotate
56
+ * thirds, then map each character through the custom alphabet (`=` → `$`).
57
+ */
58
+ export function transformQoderBody(plaintext: string): Uint8Array {
59
+ const base64 = Buffer.from(plaintext).toString("base64");
60
+ const third = Math.floor(base64.length / 3);
61
+ const reordered = base64.slice(base64.length - third) + base64.slice(third, base64.length - third) + base64.slice(0, third);
62
+ let encoded = "";
63
+ for (const char of reordered) {
64
+ if (char === "=") encoded += "$";
65
+ else encoded += QODER_ALPHABET[QODER_STANDARD_ALPHABET.indexOf(char)] ?? char;
66
+ }
67
+ return Buffer.from(encoded, "latin1");
68
+ }
69
+
70
+ /**
71
+ * Builds the COSY Bearer headers for one signed request. The identity payload
72
+ * is encrypted AES-128-CBC (random 16-byte key, IV = key), the key is RSA
73
+ * PKCS1-encrypted, and the signature is md5 over
74
+ * `payload\ncosyKey\ndate\nbody\nsigPath`.
75
+ *
76
+ * Machine identity headers mirror the live 1.1.83 capture: `cosy-machineid`
77
+ * (the account's machine id), `cosy-machinetoken/type/code` when a native
78
+ * UMID identity is bound (otherwise they fall back to the machine id / `5`),
79
+ * real `cosy-machineos`, and the sanitized hostname.
80
+ */
81
+ export function qoderBuildCosyHeaders(
82
+ body: Uint8Array,
83
+ url: string,
84
+ auth: QoderAuth,
85
+ version = getQoderVersion(),
86
+ ): Record<string, string> {
87
+ const key = randomUUID().replaceAll("-", "").slice(0, 16);
88
+ const identity = qoderJsonSortedCompact({
89
+ name: auth.userName,
90
+ aid: auth.userId,
91
+ uid: auth.userId,
92
+ yx_uid: "",
93
+ organization_id: "",
94
+ organization_name: "",
95
+ user_type: auth.userType,
96
+ security_oauth_token: auth.securityOauthToken,
97
+ refresh_token: auth.refreshToken,
98
+ });
99
+ const cipher = createCipheriv("aes-128-cbc", Buffer.from(key), Buffer.from(key));
100
+ const info = Buffer.concat([cipher.update(identity), cipher.final()]).toString("base64");
101
+ const cosyKey = publicEncrypt({ key: QODER_RSA_PUBLIC_KEY, padding: constants.RSA_PKCS1_PADDING }, Buffer.from(key)).toString("base64");
102
+ const payload = Buffer.from(
103
+ qoderJsonSortedCompact({ version: "v1", requestId: randomUUID(), info, cosyVersion: version, ideVersion: "" }),
104
+ ).toString("base64");
105
+ const date = String(Math.floor(Date.now() / 1000));
106
+ const signature = qoderMd5(`${payload}\n${cosyKey}\n${date}\n${Buffer.from(body).toString("latin1")}\n${qoderPathSignature(url)}`);
107
+ return {
108
+ authorization: `Bearer COSY.${payload}.${signature}`,
109
+ "cosy-data-policy": "agree",
110
+ "cosy-machinetype": auth.machineType ?? "5",
111
+ "cosy-machineos": qoderMachineOs(),
112
+ "cosy-clienttype": "5",
113
+ "cosy-date": date,
114
+ "cosy-user": auth.userId,
115
+ "cosy-key": cosyKey,
116
+ "cosy-bodyhash": qoderMd5(body),
117
+ "cosy-bodylength": String(body.byteLength),
118
+ "cosy-sigpath": qoderPathSignature(url),
119
+ "cosy-machineid": auth.machineId,
120
+ ...(auth.machineToken !== undefined && auth.machineToken.length > 0 ? { "cosy-machinetoken": auth.machineToken } : {}),
121
+ ...(auth.machineCode !== undefined && auth.machineCode.length > 0 ? { "cosy-machinecode": auth.machineCode } : {}),
122
+ "cosy-machinehostname": qoderMachineHostname(),
123
+ "cosy-organization-id": "",
124
+ "cosy-organization-tags": "",
125
+ "x-request-id": randomUUID(),
126
+ "cosy-business-product": "cli",
127
+ "cosy-business-type": "agent",
128
+ "cosy-business-version": version,
129
+ "cosy-scene": "assistant",
130
+ "cosy-version": version,
131
+ "login-version": "v2",
132
+ appcode: QODER_APPCODE,
133
+ accept: "application/json",
134
+ "accept-encoding": "identity",
135
+ date: new Date().toUTCString(),
136
+ signature: qoderMd5(`${QODER_APPCODE}&${QODER_SIG_SECRET}&${new Date().toUTCString()}`),
137
+ "content-type": "application/json",
138
+ "user-agent": QODER_UA_CLI,
139
+ };
140
+ }
141
+
142
+ /**
143
+ * A COSY signer for one already-resolved account identity.
144
+ *
145
+ * Discovery needs signed headers but lives in `qoder-catalog.ts`, which cannot
146
+ * import this module (the adapter imports its rosters — that would be a cycle).
147
+ * Exporting the signer keeps one signing implementation and lets discovery
148
+ * stay pure and injectable.
149
+ */
150
+ export function qoderSignerFor(
151
+ auth: QoderAuth,
152
+ version = getQoderVersion(),
153
+ ): (url: string, body: string) => Record<string, string> {
154
+ return (url: string, body: string): Record<string, string> =>
155
+ qoderBuildCosyHeaders(Buffer.from(body, "latin1"), url, auth, version);
156
+ }
157
+
158
+ /** Qoder's own base64 alphabet transform, for a request body. */
159
+ export function qoderEncodeBody(plaintext: Uint8Array | string): string {
160
+ const text = typeof plaintext === "string" ? plaintext : new TextDecoder().decode(plaintext);
161
+ return Buffer.from(transformQoderBody(text)).toString("latin1");
162
+ }