@bitkyc08/opencodex 2.10.0 → 2.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (274) hide show
  1. package/AGENTS_INSTALL.md +77 -0
  2. package/README.md +4 -10
  3. package/bin/ocx.mjs +71 -18
  4. package/gui/dist/assets/index-Cd6_PBKn.css +1 -0
  5. package/gui/dist/assets/index-ChZQsmBY.js +70 -0
  6. package/gui/dist/index.html +2 -2
  7. package/gui/dist/provider-icons/alibaba-color.svg +1 -1
  8. package/gui/dist/provider-icons/antigravity-color.svg +1 -1
  9. package/gui/dist/provider-icons/claude-color.svg +1 -1
  10. package/gui/dist/provider-icons/cline-color.svg +16 -0
  11. package/gui/dist/provider-icons/cloudflare-ai-gateway-color.svg +1 -1
  12. package/gui/dist/provider-icons/copilot-color.svg +1 -1
  13. package/gui/dist/provider-icons/cursor-color.svg +1 -1
  14. package/gui/dist/provider-icons/deepseek-color.svg +1 -1
  15. package/gui/dist/provider-icons/firepass-color.svg +1 -1
  16. package/gui/dist/provider-icons/fireworks-color.svg +1 -1
  17. package/gui/dist/provider-icons/gemini-color.svg +1 -1
  18. package/gui/dist/provider-icons/github-copilot-color.svg +1 -1
  19. package/gui/dist/provider-icons/gitlab-duo-color.svg +1 -1
  20. package/gui/dist/provider-icons/grok.svg +1 -1
  21. package/gui/dist/provider-icons/groq-color.svg +1 -1
  22. package/gui/dist/provider-icons/huggingface-color.svg +1 -1
  23. package/gui/dist/provider-icons/kimi-color.svg +1 -1
  24. package/gui/dist/provider-icons/kiro-color.svg +2 -2
  25. package/gui/dist/provider-icons/lm-studio-color.svg +1 -1
  26. package/gui/dist/provider-icons/mistral-color.svg +1 -1
  27. package/gui/dist/provider-icons/moonshot-color.svg +1 -1
  28. package/gui/dist/provider-icons/nvidia-color.svg +1 -1
  29. package/gui/dist/provider-icons/ollama-color.svg +1 -1
  30. package/gui/dist/provider-icons/openai.svg +1 -1
  31. package/gui/dist/provider-icons/opencode.svg +2 -1
  32. package/gui/dist/provider-icons/openrouter-color.svg +1 -1
  33. package/gui/dist/provider-icons/pi.svg +2 -2
  34. package/gui/dist/provider-icons/qianfan-color.svg +1 -1
  35. package/gui/dist/provider-icons/qwen-portal-color.svg +1 -1
  36. package/gui/dist/provider-icons/vercel-ai-gateway-color.svg +1 -1
  37. package/gui/dist/provider-icons/vllm-color.svg +1 -1
  38. package/gui/dist/provider-icons/xiaomi-color.svg +1 -1
  39. package/package.json +8 -4
  40. package/src/adapters/anthropic.ts +208 -14
  41. package/src/adapters/base.ts +16 -5
  42. package/src/adapters/cursor/effort-map.ts +3 -2
  43. package/src/adapters/cursor/framing.ts +39 -0
  44. package/src/adapters/cursor/live-transport.ts +105 -95
  45. package/src/adapters/cursor/native-exec.ts +32 -6
  46. package/src/adapters/cursor/protobuf-request.ts +20 -15
  47. package/src/adapters/cursor/request-builder.ts +21 -7
  48. package/src/adapters/cursor/types.ts +7 -0
  49. package/src/adapters/google-antigravity-replay.ts +237 -21
  50. package/src/adapters/google-truncation.ts +11 -0
  51. package/src/adapters/google.ts +50 -9
  52. package/src/adapters/identity.ts +39 -6
  53. package/src/adapters/kiro-errors.ts +11 -0
  54. package/src/adapters/kiro-events.ts +19 -1
  55. package/src/adapters/kiro-thinking.ts +10 -2
  56. package/src/adapters/kiro-tools.ts +10 -1
  57. package/src/adapters/kiro.ts +37 -11
  58. package/src/adapters/openai-chat.ts +284 -83
  59. package/src/adapters/openai-responses.ts +182 -24
  60. package/src/bridge.ts +177 -7
  61. package/src/chat/outbound.ts +78 -23
  62. package/src/claude/agents-inject.ts +27 -5
  63. package/src/claude/inbound.ts +11 -1
  64. package/src/claude/model-info.ts +13 -10
  65. package/src/claude/outbound.ts +17 -0
  66. package/src/cli/account-api.ts +24 -0
  67. package/src/cli/account-auth.ts +31 -6
  68. package/src/cli/account-main.ts +317 -0
  69. package/src/cli/account.ts +5 -0
  70. package/src/cli/claude.ts +2 -1
  71. package/src/cli/doctor.ts +93 -22
  72. package/src/cli/export-command.ts +26 -12
  73. package/src/cli/help.ts +8 -6
  74. package/src/cli/index.ts +56 -22
  75. package/src/cli/integrations.ts +84 -1
  76. package/src/cli/observe.ts +54 -1
  77. package/src/cli/opencode.ts +2 -1
  78. package/src/cli/provider-runtime.ts +18 -1
  79. package/src/cli/route-policy.ts +92 -0
  80. package/src/cli/runtime-api.ts +6 -3
  81. package/src/cli/star-prompt.ts +71 -15
  82. package/src/cli/status.ts +1 -1
  83. package/src/cli/v2.ts +36 -9
  84. package/src/clients/config-export.ts +687 -10
  85. package/src/codex/account-lifecycle.ts +30 -5
  86. package/src/codex/account-usability.ts +22 -2
  87. package/src/codex/admission.ts +255 -0
  88. package/src/codex/auth-api.ts +427 -140
  89. package/src/codex/auth-context.ts +155 -30
  90. package/src/codex/autostart-health.ts +8 -1
  91. package/src/codex/catalog/account-models.ts +62 -0
  92. package/src/codex/catalog/aggregation.ts +14 -1
  93. package/src/codex/catalog/bundled.ts +282 -32
  94. package/src/codex/catalog/filesystem-evidence.ts +302 -0
  95. package/src/codex/catalog/metadata.ts +51 -6
  96. package/src/codex/catalog/parsing.ts +6 -3
  97. package/src/codex/catalog/provider-fetch.ts +576 -41
  98. package/src/codex/catalog/sync.ts +505 -66
  99. package/src/codex/catalog-admission.ts +197 -0
  100. package/src/codex/catalog-write-serialization.ts +241 -0
  101. package/src/codex/catalog.ts +2 -1
  102. package/src/codex/codex-write-lock.ts +372 -0
  103. package/src/codex/convergence-types.ts +593 -0
  104. package/src/codex/convergence.ts +441 -0
  105. package/src/codex/desired-state.ts +177 -0
  106. package/src/codex/features.ts +52 -8
  107. package/src/codex/generation.ts +202 -0
  108. package/src/codex/history-job.ts +257 -0
  109. package/src/codex/history-lock.ts +241 -0
  110. package/src/codex/history-migration-guardian.ts +18 -5
  111. package/src/codex/history-provider.ts +9 -2
  112. package/src/codex/history-transition.ts +105 -0
  113. package/src/codex/history-worker.ts +176 -0
  114. package/src/codex/inject-coordination.ts +245 -0
  115. package/src/codex/inject.ts +605 -124
  116. package/src/codex/integration-record.ts +266 -0
  117. package/src/codex/internal/catalog-writer.ts +203 -0
  118. package/src/codex/internal/history-writer.ts +80 -0
  119. package/src/codex/journal.ts +10 -1
  120. package/src/codex/main-account-cache.ts +24 -0
  121. package/src/codex/management-convergence.ts +114 -0
  122. package/src/codex/native-main-admission.ts +47 -0
  123. package/src/codex/native-main-auth-temp.ts +187 -0
  124. package/src/codex/native-main-claim.ts +167 -0
  125. package/src/codex/native-main-lock-file.ts +158 -0
  126. package/src/codex/native-main-owner.ts +315 -0
  127. package/src/codex/native-profile-api.ts +247 -0
  128. package/src/codex/native-profile-manager.ts +1512 -0
  129. package/src/codex/native-profile-processes.ts +121 -0
  130. package/src/codex/native-profile-recovery.ts +99 -0
  131. package/src/codex/native-profile-stage-store.ts +387 -0
  132. package/src/codex/native-profile-startup.ts +340 -0
  133. package/src/codex/native-profile-store.ts +855 -0
  134. package/src/codex/native-profile-types.ts +120 -0
  135. package/src/codex/native-residue.ts +557 -0
  136. package/src/codex/project-config-warnings.ts +18 -4
  137. package/src/codex/prompt-journal.ts +311 -0
  138. package/src/codex/prompt-layers.ts +967 -0
  139. package/src/codex/prompt-lock.ts +143 -0
  140. package/src/codex/quota-rejection.ts +224 -0
  141. package/src/codex/quota.ts +86 -3
  142. package/src/codex/routing.ts +299 -62
  143. package/src/codex/runtime.ts +159 -38
  144. package/src/codex/shim.ts +39 -13
  145. package/src/codex/subagent-model-fallback.ts +73 -12
  146. package/src/codex/transition-state.ts +604 -0
  147. package/src/codex/upstream-host-health.ts +70 -0
  148. package/src/codex/user-identity.ts +266 -0
  149. package/src/codex/write-coordination.ts +114 -0
  150. package/src/config.ts +562 -26
  151. package/src/generated/jawcode-model-metadata.ts +2 -2
  152. package/src/grok/inject.ts +15 -4
  153. package/src/grok/inspect.ts +45 -0
  154. package/src/images/loop.ts +113 -20
  155. package/src/integrations/config-io.ts +151 -0
  156. package/src/integrations/journal.ts +315 -0
  157. package/src/integrations/merge.ts +135 -0
  158. package/src/integrations/native/ownership-preflight.ts +165 -0
  159. package/src/integrations/ownership.ts +111 -0
  160. package/src/integrations/registry.ts +101 -0
  161. package/src/integrations/serialize.ts +235 -0
  162. package/src/integrations/state.ts +290 -0
  163. package/src/integrations/store.ts +103 -0
  164. package/src/integrations/writer.ts +492 -0
  165. package/src/lib/bounded-body.ts +46 -8
  166. package/src/lib/bun-runtime.ts +110 -1
  167. package/src/lib/bun-stream-caps.ts +2 -1
  168. package/src/lib/redact.ts +407 -2
  169. package/src/lib/shadow-call.ts +24 -0
  170. package/src/lib/translator-budget.ts +10 -0
  171. package/src/lib/upstream-reachability.ts +91 -0
  172. package/src/lib/upstream-retry.ts +154 -2
  173. package/src/lib/windows-secret-acl.ts +212 -11
  174. package/src/lib/winsw.ts +9 -3
  175. package/src/oauth/index.ts +61 -3
  176. package/src/oauth/key-providers.ts +4 -0
  177. package/src/oauth/kiro.ts +50 -6
  178. package/src/oauth/store.ts +31 -6
  179. package/src/oauth/token-guardian.ts +9 -3
  180. package/src/providers/codex-capacity.ts +288 -0
  181. package/src/providers/derive.ts +33 -1
  182. package/src/providers/free-directory.ts +3 -1
  183. package/src/providers/key-failover.ts +71 -3
  184. package/src/providers/openai-sidecar.ts +64 -4
  185. package/src/providers/openai-virtual-models.ts +1 -0
  186. package/src/providers/quota.ts +334 -26
  187. package/src/providers/registry.ts +284 -16
  188. package/src/providers/xai-transport.ts +11 -4
  189. package/src/responses/compaction.ts +8 -1
  190. package/src/responses/hosted-tool-policy.ts +9 -0
  191. package/src/responses/parser.ts +44 -2
  192. package/src/responses/reasoning-envelope.ts +9 -1
  193. package/src/responses/reasoning-replay-cache.ts +105 -0
  194. package/src/responses/spill-store.ts +45 -8
  195. package/src/responses/state.ts +161 -17
  196. package/src/router.ts +302 -16
  197. package/src/routing/analytics.ts +377 -0
  198. package/src/routing/capability.ts +204 -0
  199. package/src/routing/cost.ts +77 -0
  200. package/src/routing/evaluator.ts +444 -0
  201. package/src/routing/health.ts +401 -0
  202. package/src/routing/history/cursor.ts +43 -0
  203. package/src/routing/history/indexer.ts +590 -0
  204. package/src/routing/history/schema.ts +72 -0
  205. package/src/routing/profile.ts +423 -0
  206. package/src/routing/quota.ts +124 -0
  207. package/src/routing/request-evidence.ts +45 -0
  208. package/src/routing/trace.ts +686 -0
  209. package/src/server/auth-cors.ts +46 -6
  210. package/src/server/chat-completions.ts +28 -13
  211. package/src/server/claude-messages.ts +23 -15
  212. package/src/server/gui-static.ts +39 -10
  213. package/src/server/images.ts +10 -1
  214. package/src/server/index.ts +238 -52
  215. package/src/server/lifecycle.ts +228 -9
  216. package/src/server/live.ts +14 -3
  217. package/src/server/management/agent-settings-routes.ts +64 -14
  218. package/src/server/management/combo-routes.ts +5 -5
  219. package/src/server/management/config-routes.ts +1 -1
  220. package/src/server/management/context.ts +42 -2
  221. package/src/server/management/integration-routes.ts +538 -0
  222. package/src/server/management/logs-usage-routes.ts +1 -1
  223. package/src/server/management/model-routes.ts +32 -113
  224. package/src/server/management/model-rows.ts +117 -0
  225. package/src/server/management/native-integration-routes.ts +587 -0
  226. package/src/server/management/oauth-account-routes.ts +1 -1
  227. package/src/server/management/provider-routes.ts +218 -117
  228. package/src/server/management/request-history-routes.ts +191 -0
  229. package/src/server/management/routing-analytics-routes.ts +74 -0
  230. package/src/server/management/routing-profile-routes.ts +177 -0
  231. package/src/server/management/shared.ts +2 -2
  232. package/src/server/management/sidebar-routes.ts +47 -31
  233. package/src/server/management/sync-response.ts +69 -0
  234. package/src/server/management/system-restart.ts +276 -24
  235. package/src/server/management/system-routes.ts +4 -0
  236. package/src/server/management-api.ts +84 -9
  237. package/src/server/management-auth.ts +43 -5
  238. package/src/server/relay-eager.ts +82 -42
  239. package/src/server/relay.ts +120 -6
  240. package/src/server/request-log.ts +26 -6
  241. package/src/server/responses/collaboration.ts +63 -8
  242. package/src/server/responses/compact.ts +272 -41
  243. package/src/server/responses/core.ts +730 -132
  244. package/src/server/responses/fetch-helpers.ts +15 -1
  245. package/src/server/responses-item-id-repair.ts +32 -3
  246. package/src/server/responses-json-events.ts +52 -0
  247. package/src/server/responses-snapshot-repair.ts +621 -0
  248. package/src/server/search.ts +51 -6
  249. package/src/server/sse-payload-rewrite.ts +89 -12
  250. package/src/server/startup-health-cache.ts +7 -1
  251. package/src/server/ws-bridge.ts +11 -17
  252. package/src/service-manager-probe.ts +297 -0
  253. package/src/service.ts +222 -32
  254. package/src/tray/windows-tray.ps1 +9 -0
  255. package/src/tray/windows.ts +15 -7
  256. package/src/types.ts +194 -14
  257. package/src/update/index.ts +13 -13
  258. package/src/update/job.ts +24 -21
  259. package/src/update/notify.ts +7 -3
  260. package/src/usage/cost.ts +0 -0
  261. package/src/usage/expected-prices.ts +129 -10
  262. package/src/usage/log.ts +50 -15
  263. package/src/usage/summary.ts +4 -4
  264. package/src/vision/index.ts +6 -1
  265. package/src/web-search/loop.ts +161 -34
  266. package/gui/dist/assets/index-OY43ubAq.css +0 -1
  267. package/gui/dist/assets/index-YwNnKZcL.js +0 -67
  268. package/gui/dist/provider-icons/antigravity.svg +0 -1
  269. package/gui/dist/provider-icons/claude.svg +0 -1
  270. package/gui/dist/provider-icons/copilot.svg +0 -1
  271. package/gui/dist/provider-icons/cursor.svg +0 -2
  272. package/gui/dist/provider-icons/gemini.svg +0 -1
  273. package/gui/dist/provider-icons/grok-color.svg +0 -1
  274. package/gui/dist/provider-icons/kiro.svg +0 -14
@@ -20,11 +20,37 @@
20
20
  * targeting it is the caller's explicit act.
21
21
  */
22
22
  import { homedir } from "node:os";
23
- import { join } from "node:path";
23
+ import { existsSync } from "node:fs";
24
+ import { isAbsolute, join } from "node:path";
24
25
  import { shouldInjectApiAuthHeader } from "../codex/inject";
26
+ import { FORMAT_MEDIA_TYPE, serializeDocument, type ConfigFormat } from "../integrations/serialize";
25
27
  import { probeHostname } from "../server/proxy-liveness";
26
28
  import type { OcxConfig } from "../types";
27
29
 
30
+ export type { ConfigFormat };
31
+
32
+ /**
33
+ * One entry opencodex owns inside a client's config: the JSON path to it and
34
+ * the value we put there.
35
+ *
36
+ * A path list rather than a single provider key because ownership is not
37
+ * always one entry — Kimi owns its provider block AND one model entry per
38
+ * model, and a writer that only knew about the provider would strand the rest
39
+ * (devlog 260802 006 §2).
40
+ */
41
+ export interface ManagedFragment {
42
+ path: readonly string[];
43
+ value: unknown;
44
+ }
45
+
46
+ /** Everything opencodex contributes to one client's config, as one unit. */
47
+ export interface ManagedContribution {
48
+ clientId: ExportClientId;
49
+ fragments: readonly ManagedFragment[];
50
+ }
51
+
52
+ export type BuildContribution = (ctx: ExportContext) => ManagedContribution;
53
+
28
54
  export interface OpencodeLaunchEnv {
29
55
  [key: string]: string | undefined;
30
56
  }
@@ -87,6 +113,32 @@ export const PI_API_KEY_ENV = "OPENCODEX_API_KEY";
87
113
  /** Pi's reference form for the admission key. Never the value. */
88
114
  export const PI_API_KEY_ENV_REF = `$${PI_API_KEY_ENV}`;
89
115
 
116
+ /**
117
+ * Hermes interpolates `${VAR}` anywhere in config.yaml, so the credential stays
118
+ * in the environment exactly as it does for OpenCode and Pi.
119
+ */
120
+ export const HERMES_API_KEY_ENV = "OPENCODEX_HERMES_API_KEY";
121
+ export const HERMES_API_KEY_ENV_REF = `\${${HERMES_API_KEY_ENV}}`;
122
+
123
+ /** OpenClaw interpolates `${UPPERCASE_VAR}` and fails closed when it is unset. */
124
+ export const OPENCLAW_API_KEY_ENV = "OPENCODEX_OPENCLAW_API_KEY";
125
+ export const OPENCLAW_API_KEY_ENV_REF = `\${${OPENCLAW_API_KEY_ENV}}`;
126
+
127
+ /**
128
+ * Kimi Code reads credentials ONLY from its config file — it never falls back
129
+ * to the shell environment. A loopback bind needs no real admission key, so we
130
+ * emit the same placeholder the Grok managed block uses rather than a user
131
+ * secret; a non-loopback bind is refused by the writer instead of papered over.
132
+ */
133
+ export const KIMI_LOOPBACK_PLACEHOLDER = "opencodex-loopback";
134
+
135
+ /**
136
+ * Gajae's `apiKeyEnv` is env-name-only and fail-closed. Its sibling `apiKey`
137
+ * falls back to treating the literal text as the token when the variable is
138
+ * unset, which would silently ship a bogus credential — so we never emit it.
139
+ */
140
+ export const GAJAE_API_KEY_ENV = "OPENCODEX_GAJAE_API_KEY";
141
+
90
142
  /** Pi's wire-dialect selector for an OpenAI-compatible endpoint. */
91
143
  const PI_API_DIALECT = "openai-completions";
92
144
 
@@ -129,6 +181,164 @@ export function opencodeProxyBaseUrl(port: number, hostname?: string): string {
129
181
  return `http://${probeHostname(hostname)}:${port}/v1`;
130
182
  }
131
183
 
184
+ /**
185
+ * Hermes resolves its home the way cc-switch's writer does: an explicit
186
+ * `HERMES_HOME`, then Windows `%LOCALAPPDATA%\hermes`, then `~/.hermes`.
187
+ */
188
+ export function hermesHomeDir(env: OpencodeLaunchEnv = process.env, home: string = homedir()): string {
189
+ const override = env.HERMES_HOME?.trim();
190
+ if (override) return override;
191
+ if (process.platform === "win32") {
192
+ const local = env.LOCALAPPDATA?.trim();
193
+ return join(local && local.length > 0 ? local : join(home, "AppData", "Local"), "hermes");
194
+ }
195
+ return join(home, ".hermes");
196
+ }
197
+
198
+ export function hermesConfigPath(env: OpencodeLaunchEnv = process.env, home: string = homedir()): string {
199
+ return join(hermesHomeDir(env, home), "config.yaml");
200
+ }
201
+
202
+ /**
203
+ * Expand `~` against the effective home; REFUSE anything still relative.
204
+ *
205
+ * A first attempt at this called `resolve()` and claimed the path would "mean
206
+ * the same thing next time". It does not: `resolve()` only anchors the current
207
+ * invocation, so applying from one directory and disabling from another still
208
+ * resolved two different files — the second reported "not applied" and left
209
+ * the managed block behind with nothing claiming it.
210
+ *
211
+ * There is no honest way to recover the other process's cwd, so a relative
212
+ * selector is refused at the boundary instead of being silently anchored to
213
+ * whichever directory we happened to start in. A `~` path IS stable, because
214
+ * it anchors to the effective home rather than the cwd.
215
+ */
216
+ export class ClientPathError extends Error {}
217
+
218
+ function absoluteClientPath(raw: string, home: string, variable: string): string {
219
+ const trimmed = raw.trim();
220
+ if (trimmed === "~") return home;
221
+ if (trimmed.startsWith("~/") || trimmed.startsWith("~\\")) return join(home, trimmed.slice(2));
222
+ if (!isAbsolute(trimmed)) {
223
+ throw new ClientPathError(
224
+ `${variable} must be an absolute path or start with ~; "${trimmed}" depends on the working directory, `
225
+ + "so opencodex and the client would disagree about which file it names.",
226
+ );
227
+ }
228
+ return trimmed;
229
+ }
230
+
231
+ /**
232
+ * OpenClaw's EFFECTIVE home: `OPENCLAW_HOME` outranks the OS home.
233
+ *
234
+ * Everything below derives from this, which is why it is separate: a profile
235
+ * directory and the default state directory both hang off the effective home,
236
+ * not off `homedir()`.
237
+ */
238
+ function openclawEffectiveHome(env: OpencodeLaunchEnv, home: string): string {
239
+ const override = env.OPENCLAW_HOME?.trim();
240
+ return override ? absoluteClientPath(override, home, "OPENCLAW_HOME") : home;
241
+ }
242
+
243
+ /**
244
+ * OpenClaw's state directory, in the gateway's own precedence order:
245
+ *
246
+ * 1. `OPENCLAW_STATE_DIR` — an explicit relocation wins outright.
247
+ * 2. `OPENCLAW_PROFILE` — a named profile is `.openclaw-<profile>` under the
248
+ * effective home. `default` is the unnamed profile, so it stays
249
+ * `.openclaw`.
250
+ * 3. `.openclaw` under the effective home (`OPENCLAW_HOME` or the OS home).
251
+ *
252
+ * This is also what "is it installed?" detection looks at, so it has to follow
253
+ * the same selectors the gateway does — otherwise an operator running a
254
+ * profile reads as not installed while their gateway runs fine. We honor the
255
+ * ENVIRONMENT selectors only: a `--profile` flag passed to some other process
256
+ * is not something we can observe, and guessing it would be worse than
257
+ * following the same environment the user gave us.
258
+ */
259
+ export function openclawHomeDir(env: OpencodeLaunchEnv = process.env, home: string = homedir()): string {
260
+ const stateDir = env.OPENCLAW_STATE_DIR?.trim();
261
+ const effectiveHome = openclawEffectiveHome(env, home);
262
+ if (stateDir) return absoluteClientPath(stateDir, effectiveHome, "OPENCLAW_STATE_DIR");
263
+ const profile = env.OPENCLAW_PROFILE?.trim();
264
+ // OpenClaw compares the profile name case-insensitively, so `DEFAULT` is
265
+ // still the unnamed profile rather than a `.openclaw-DEFAULT` directory.
266
+ if (profile && profile.toLowerCase() !== "default") return join(effectiveHome, `.openclaw-${profile}`);
267
+ /*
268
+ * `.clawdbot` is not migration debris — OpenClaw still treats it as an
269
+ * active runtime candidate, preferring the modern directory when it exists
270
+ * and otherwise selecting the legacy one. Mirroring that order is not
271
+ * guessing: an install that has not migrated is one this integration would
272
+ * otherwise report as "not installed" while its gateway runs fine.
273
+ */
274
+ const modern = join(effectiveHome, ".openclaw");
275
+ if (existsSync(modern)) return modern;
276
+ const legacy = join(effectiveHome, ".clawdbot");
277
+ if (existsSync(legacy)) return legacy;
278
+ return modern;
279
+ }
280
+
281
+ /**
282
+ * The config file OpenClaw actually reads.
283
+ *
284
+ * An explicit `OPENCLAW_CONFIG_PATH` wins outright — it is a file selector, so
285
+ * it does NOT relocate the state directory that detection looks at. Otherwise
286
+ * `openclaw.json` under the resolved state directory.
287
+ *
288
+ * Ignoring these selectors meant the toggle could report success after writing
289
+ * `~/.openclaw/openclaw.json` while the running gateway read somewhere else —
290
+ * and snapshot the wrong file, so the rollback promise pointed at a file
291
+ * nobody loads.
292
+ */
293
+ export function openclawConfigPath(env: OpencodeLaunchEnv = process.env, home: string = homedir()): string {
294
+ const explicit = env.OPENCLAW_CONFIG_PATH?.trim();
295
+ if (explicit) return absoluteClientPath(explicit, openclawEffectiveHome(env, home), "OPENCLAW_CONFIG_PATH");
296
+ /*
297
+ * OpenClaw searches FILE candidates, not directories: a `.openclaw`
298
+ * directory that exists but holds no config does not beat an actual
299
+ * `.clawdbot/clawdbot.json`. Checking the directory first picked an
300
+ * absent modern file over a real legacy one and wrote where nothing reads.
301
+ *
302
+ * An explicit state dir still scopes the search to that directory, because
303
+ * the operator named it.
304
+ */
305
+ const stateOverride = env.OPENCLAW_STATE_DIR?.trim();
306
+ const effectiveHome = openclawEffectiveHome(env, home);
307
+ const profile = env.OPENCLAW_PROFILE?.trim();
308
+ const scoped = stateOverride !== undefined && stateOverride !== ""
309
+ || (profile !== undefined && profile !== "" && profile.toLowerCase() !== "default");
310
+ const stateDir = openclawHomeDir(env, home);
311
+ const candidates = scoped
312
+ ? [join(stateDir, "openclaw.json"), join(stateDir, "clawdbot.json")]
313
+ : [
314
+ join(effectiveHome, ".openclaw", "openclaw.json"),
315
+ join(effectiveHome, ".openclaw", "clawdbot.json"),
316
+ join(effectiveHome, ".clawdbot", "openclaw.json"),
317
+ join(effectiveHome, ".clawdbot", "clawdbot.json"),
318
+ ];
319
+ for (const candidate of candidates) {
320
+ if (existsSync(candidate)) return candidate;
321
+ }
322
+ return candidates[0]!;
323
+ }
324
+
325
+ export function kimiHomeDir(env: OpencodeLaunchEnv = process.env, home: string = homedir()): string {
326
+ const override = env.KIMI_CODE_HOME?.trim();
327
+ return override && override.length > 0 ? override : join(home, ".kimi-code");
328
+ }
329
+
330
+ export function kimiConfigPath(env: OpencodeLaunchEnv = process.env, home: string = homedir()): string {
331
+ return join(kimiHomeDir(env, home), "config.toml");
332
+ }
333
+
334
+ export function gajaeHomeDir(_env: OpencodeLaunchEnv = process.env, home: string = homedir()): string {
335
+ return join(home, ".gjc");
336
+ }
337
+
338
+ export function gajaeConfigPath(env: OpencodeLaunchEnv = process.env, home: string = homedir()): string {
339
+ return join(gajaeHomeDir(env, home), "agent", "models.yml");
340
+ }
341
+
132
342
  /**
133
343
  * One proxy-routed model destined for a client config. Deliberately narrower than
134
344
  * `CatalogModel` so a serializer cannot reach for a field that does not survive the
@@ -157,7 +367,13 @@ export interface ExportContext {
157
367
  config?: OcxConfig;
158
368
  }
159
369
 
160
- export type ExportClientId = "opencode" | "pi";
370
+ export type ExportClientId =
371
+ | "opencode"
372
+ | "pi"
373
+ | "hermes"
374
+ | "openclaw"
375
+ | "kimi"
376
+ | "gajae";
161
377
 
162
378
  export interface ExportClientSpec {
163
379
  id: ExportClientId;
@@ -170,6 +386,35 @@ export interface ExportClientSpec {
170
386
  /** Shell line the user runs before launching the client. */
171
387
  exportHint: string;
172
388
  build: (ctx: ExportContext) => unknown;
389
+ /**
390
+ * Text format of the client's config file. `filename` already carries the
391
+ * extension; this drives serialization and the download media type so no
392
+ * consumer has to infer either from the name.
393
+ */
394
+ format: ConfigFormat;
395
+ /**
396
+ * Count models in THIS client's document shape. Required so a new client
397
+ * cannot be added without teaching the summarizer about it — the old
398
+ * "anything that is not OpenCode must be Pi" branch was a latent bug.
399
+ */
400
+ summarize: (document: unknown) => { modelCount: number; modelsWithoutLimits: number };
401
+ /**
402
+ * The fragments opencodex owns inside this client's config. Only the builder
403
+ * knows where a client keeps our entries, so ownership paths originate here
404
+ * rather than being re-derived by the writer.
405
+ */
406
+ buildContribution: BuildContribution;
407
+ /**
408
+ * True when this client can only reach a loopback bind.
409
+ *
410
+ * `/v1/chat/completions` rejects bearer credentials and requires the
411
+ * dedicated `x-opencodex-api-key` header (AUTH_MATRIX in
412
+ * src/server/auth-cors.ts). A client whose schema has no place to put that
413
+ * header therefore cannot authenticate against a remote bind at all — so we
414
+ * say so rather than exporting a config that 401s. Same reasoning as the
415
+ * Grok managed block's non-loopback refusal.
416
+ */
417
+ loopbackOnly: boolean;
173
418
  }
174
419
 
175
420
  /**
@@ -188,6 +433,55 @@ function outputBudgetFor(context: number): number {
188
433
  return Math.min(SCHEMA_REQUIRED_OUTPUT_BUDGET, context);
189
434
  }
190
435
 
436
+ /**
437
+ * Modalities a given client's schema will actually accept.
438
+ *
439
+ * Our internal vocabulary is `text | image | audio` (ALLOWED_INPUT_MODALITIES in
440
+ * src/server/management/model-routes.ts). Pi and Gajae accept only
441
+ * `text | image`, and both reject the WHOLE config file over one out-of-enum
442
+ * value — Gajae reports `/providers/opencodex/models/N/input/2: Invalid option`
443
+ * and falls back to its built-in list, Pi returns an empty model config. So a
444
+ * single `audio` model takes every routed model down with it. That is not
445
+ * hypothetical: zenmux/meta-muse-spark-1.1 advertises audio and did exactly
446
+ * this. It is also the same defect the Codex catalog had with `video`, where
447
+ * the app showed zero apps (tests/catalog-input-modality-enum.test.ts).
448
+ *
449
+ * UNKNOWN and INCOMPATIBLE are different inputs, and the Codex fix could
450
+ * conflate them safely only because its enum is wider. A model with nothing
451
+ * declared is unknown, and `text` is the honest floor — every routed model takes
452
+ * prompts. A model declaring `["audio"]` and nothing else is incompatible with a
453
+ * text|image client, and rewriting it to `["text"]` would advertise a capability
454
+ * it does not have. That input is reachable three ways: `ocx models add
455
+ * --modalities audio`, `/api/custom-models`, and provider discovery.
456
+ *
457
+ * So unknown falls back to text and incompatible returns null, which drops the
458
+ * row. Omitting a model costs the user a line in a picker; fabricating `text`
459
+ * costs them a model that fails at call time with no explanation.
460
+ *
461
+ * Deliberately NOT applied in `ExportModel` construction: the management and CLI
462
+ * boundaries carry catalog modalities verbatim on purpose, and stripping `audio`
463
+ * globally would destroy valid metadata before the destination is known.
464
+ */
465
+ const CLIENT_INPUT_MODALITIES: Record<"pi" | "gajae", ReadonlySet<string>> = {
466
+ pi: new Set(["text", "image"]),
467
+ gajae: new Set(["text", "image"]),
468
+ };
469
+
470
+ /** `null` means the model cannot be represented for this client — drop the row. */
471
+ function inputModalitiesForClient(
472
+ client: "pi" | "gajae",
473
+ modalities: readonly string[] | undefined,
474
+ ): string[] | null {
475
+ const declared = modalities ?? [];
476
+ if (declared.length === 0) return ["text"];
477
+ const accepted = CLIENT_INPUT_MODALITIES[client];
478
+ const kept: string[] = [];
479
+ for (const value of declared) {
480
+ if (accepted.has(value) && !kept.includes(value)) kept.push(value);
481
+ }
482
+ return kept.length > 0 ? kept : null;
483
+ }
484
+
191
485
  /**
192
486
  * Label shared by every client: `"<displayName|id> (<native|provider|routed>)"`. The
193
487
  * provider suffix is what makes two same-named models from different upstreams
@@ -306,6 +600,92 @@ export interface PiGeneratedConfig {
306
600
  providers: Record<string, PiProviderBlock>;
307
601
  }
308
602
 
603
+ /**
604
+ * Hermes `~/.hermes/config.yaml`. We emit ONLY the provider entry — never
605
+ * `model.default` — because hijacking the user's main model is not what a
606
+ * connect action asks for.
607
+ */
608
+ export interface HermesProviderBlock {
609
+ api: string;
610
+ api_key: string;
611
+ api_mode: "chat_completions";
612
+ /** We supply the list, so skip their live `/models` probe. */
613
+ discover_models: false;
614
+ models: string[];
615
+ extra_headers?: Record<string, string>;
616
+ }
617
+
618
+ export interface HermesGeneratedConfig {
619
+ providers: Record<string, HermesProviderBlock>;
620
+ }
621
+
622
+ export interface OpenclawModelEntry {
623
+ id: string;
624
+ name: string;
625
+ contextWindow?: number;
626
+ }
627
+
628
+ export interface OpenclawProviderBlock {
629
+ baseUrl: string;
630
+ apiKey: string;
631
+ api: "openai-completions";
632
+ models: OpenclawModelEntry[];
633
+ headers?: Record<string, string>;
634
+ }
635
+
636
+ /** `mode: "merge"` keeps OpenClaw's bundled catalog alongside ours. */
637
+ export interface OpenclawGeneratedConfig {
638
+ models: {
639
+ mode: "merge";
640
+ providers: Record<string, OpenclawProviderBlock>;
641
+ };
642
+ }
643
+
644
+ export interface KimiProviderBlock {
645
+ type: "openai";
646
+ base_url: string;
647
+ api_key: string;
648
+ }
649
+
650
+ /**
651
+ * `max_context_size` is mandatory and must be positive, so a model with no
652
+ * authoritative context window is omitted from the document entirely rather
653
+ * than guessed at. `capabilities` is never emitted: our catalog does not
654
+ * assert them, and Kimi's own inference works off OpenAI-style name prefixes
655
+ * that a routed selector will not match.
656
+ */
657
+ export interface KimiModelBlock {
658
+ provider: string;
659
+ model: string;
660
+ max_context_size: number;
661
+ display_name?: string;
662
+ }
663
+
664
+ export interface KimiGeneratedConfig {
665
+ providers: Record<string, KimiProviderBlock>;
666
+ models: Record<string, KimiModelBlock>;
667
+ }
668
+
669
+ export interface GajaeModelEntry {
670
+ id: string;
671
+ name: string;
672
+ input: string[];
673
+ contextWindow?: number;
674
+ maxTokens?: number;
675
+ }
676
+
677
+ /** Gajae validates strictly: an unknown field fails the whole config. */
678
+ export interface GajaeProviderBlock {
679
+ baseUrl: string;
680
+ apiKeyEnv: string;
681
+ api: "openai-completions";
682
+ models: GajaeModelEntry[];
683
+ }
684
+
685
+ export interface GajaeGeneratedConfig {
686
+ providers: Record<string, GajaeProviderBlock>;
687
+ }
688
+
309
689
  /**
310
690
  * Pi's `~/.pi/agent/models.json` shape. `models` is an ARRAY (identity lives in `id`),
311
691
  * unlike OpenCode's keyed object.
@@ -315,25 +695,34 @@ export interface PiGeneratedConfig {
315
695
  * `reasoning` is a boolean in Pi while our catalog carries an effort list — mapping one
316
696
  * to the other would be a guess.
317
697
  *
318
- * Pi's schema is UNVERIFIED against a real installation (001 §2); this contract is ours,
319
- * not a claim about Pi's acceptance.
698
+ * Pi's input enum IS verified: its documented model configuration accepts only
699
+ * `text` and `image`, and a validation failure yields an EMPTY model config
700
+ * rather than dropping the offending entry — one bad value costs every routed
701
+ * model. The rest of this contract (omitting `cost` and `reasoning`) is still
702
+ * ours rather than a claim about Pi's acceptance.
320
703
  */
321
704
  function buildPiClientConfig(ctx: ExportContext): PiGeneratedConfig {
322
- const models: PiModelEntry[] = normalizeExportModels(ctx.models).map(model => {
705
+ const models: PiModelEntry[] = [];
706
+ for (const model of normalizeExportModels(ctx.models)) {
707
+ // Text is the one modality every routed model supports; anything richer must come
708
+ // from the catalog rather than an assumption — and must still be inside the enum
709
+ // Pi accepts, because one rejected value empties the whole config.
710
+ const input = inputModalitiesForClient("pi", model.inputModalities);
711
+ // An audio-only model has no honest representation here; claiming `text`
712
+ // would fail at call time instead, so the row is dropped.
713
+ if (input === null) continue;
323
714
  const entry: PiModelEntry = {
324
715
  id: model.namespaced,
325
716
  name: exportModelLabel(model),
326
- // Text is the one modality every routed model supports; anything richer must come
327
- // from the catalog rather than an assumption.
328
- input: model.inputModalities && model.inputModalities.length > 0 ? [...model.inputModalities] : ["text"],
717
+ input,
329
718
  };
330
719
  const context = authoritativeContextWindow(model.contextWindow);
331
720
  if (context !== undefined) {
332
721
  entry.contextWindow = context;
333
722
  entry.maxTokens = outputBudgetFor(context);
334
723
  }
335
- return entry;
336
- });
724
+ models.push(entry);
725
+ }
337
726
  return {
338
727
  providers: {
339
728
  [OPENCODE_PROVIDER_ID]: {
@@ -346,6 +735,202 @@ function buildPiClientConfig(ctx: ExportContext): PiGeneratedConfig {
346
735
  };
347
736
  }
348
737
 
738
+ /** Extra headers a non-loopback bind needs, or nothing on loopback. */
739
+ function proxyAdmissionHeaders(config: OcxConfig | undefined, envRef: string): Record<string, string> | undefined {
740
+ return shouldInjectApiAuthHeader(config) ? { "x-opencodex-api-key": envRef } : undefined;
741
+ }
742
+
743
+ function buildHermesClientConfig(ctx: ExportContext): HermesGeneratedConfig {
744
+ const models = normalizeExportModels(ctx.models).map(model => model.namespaced);
745
+ const headers = proxyAdmissionHeaders(ctx.config, HERMES_API_KEY_ENV_REF);
746
+ return {
747
+ providers: {
748
+ [OPENCODE_PROVIDER_ID]: {
749
+ api: ctx.baseUrl,
750
+ api_key: HERMES_API_KEY_ENV_REF,
751
+ api_mode: "chat_completions",
752
+ discover_models: false,
753
+ models,
754
+ ...(headers ? { extra_headers: headers } : {}),
755
+ },
756
+ },
757
+ };
758
+ }
759
+
760
+ function buildOpenclawClientConfig(ctx: ExportContext): OpenclawGeneratedConfig {
761
+ const models: OpenclawModelEntry[] = normalizeExportModels(ctx.models).map(model => {
762
+ const context = authoritativeContextWindow(model.contextWindow);
763
+ return {
764
+ id: model.namespaced,
765
+ name: exportModelLabel(model),
766
+ ...(context !== undefined ? { contextWindow: context } : {}),
767
+ };
768
+ });
769
+ const headers = proxyAdmissionHeaders(ctx.config, OPENCLAW_API_KEY_ENV_REF);
770
+ return {
771
+ models: {
772
+ mode: "merge",
773
+ providers: {
774
+ [OPENCODE_PROVIDER_ID]: {
775
+ baseUrl: ctx.baseUrl,
776
+ apiKey: OPENCLAW_API_KEY_ENV_REF,
777
+ api: "openai-completions",
778
+ models,
779
+ ...(headers ? { headers } : {}),
780
+ },
781
+ },
782
+ },
783
+ };
784
+ }
785
+
786
+ /** Kimi's model alias: one key per model, namespaced under our provider id. */
787
+ export function kimiModelAlias(namespaced: string): string {
788
+ return `${OPENCODE_PROVIDER_ID}/${namespaced}`;
789
+ }
790
+
791
+ function buildKimiClientConfig(ctx: ExportContext): KimiGeneratedConfig {
792
+ const models: Record<string, KimiModelBlock> = {};
793
+ for (const model of normalizeExportModels(ctx.models)) {
794
+ const context = authoritativeContextWindow(model.contextWindow);
795
+ // `max_context_size` is mandatory and must be positive. We do not guess it,
796
+ // so a model without an authoritative window is left out rather than
797
+ // shipped with a number we invented.
798
+ if (context === undefined) continue;
799
+ models[kimiModelAlias(model.namespaced)] = {
800
+ provider: OPENCODE_PROVIDER_ID,
801
+ model: model.namespaced,
802
+ max_context_size: context,
803
+ ...(model.displayName ? { display_name: model.displayName } : {}),
804
+ };
805
+ }
806
+ return {
807
+ providers: {
808
+ [OPENCODE_PROVIDER_ID]: {
809
+ type: "openai",
810
+ base_url: ctx.baseUrl,
811
+ api_key: KIMI_LOOPBACK_PLACEHOLDER,
812
+ },
813
+ },
814
+ models,
815
+ };
816
+ }
817
+
818
+ function buildGajaeClientConfig(ctx: ExportContext): GajaeGeneratedConfig {
819
+ const models: GajaeModelEntry[] = [];
820
+ for (const model of normalizeExportModels(ctx.models)) {
821
+ // Gajae's enum is text|image and it rejects the whole file over one bad
822
+ // value, naming the offending index in the error.
823
+ const input = inputModalitiesForClient("gajae", model.inputModalities);
824
+ if (input === null) continue;
825
+ const entry: GajaeModelEntry = {
826
+ id: model.namespaced,
827
+ name: exportModelLabel(model),
828
+ input,
829
+ };
830
+ const context = authoritativeContextWindow(model.contextWindow);
831
+ if (context !== undefined) {
832
+ entry.contextWindow = context;
833
+ entry.maxTokens = outputBudgetFor(context);
834
+ }
835
+ models.push(entry);
836
+ }
837
+ return {
838
+ providers: {
839
+ [OPENCODE_PROVIDER_ID]: {
840
+ baseUrl: ctx.baseUrl,
841
+ apiKeyEnv: GAJAE_API_KEY_ENV,
842
+ api: "openai-completions",
843
+ models,
844
+ },
845
+ },
846
+ };
847
+ }
848
+
849
+ /**
850
+ * Per-client model counts, read back off the SERIALIZED document rather than
851
+ * recomputed from the input rows: `modelsWithoutLimits` drives a GUI line about
852
+ * the bytes the user actually receives, so a parallel reimplementation of the
853
+ * "authoritative context window" rule would be free to drift from it.
854
+ */
855
+ function summarizeOpencode(document: unknown): { modelCount: number; modelsWithoutLimits: number } {
856
+ const models = Object.values((document as OpencodeGeneratedConfig | undefined)?.provider?.[OPENCODE_PROVIDER_ID]?.models ?? {});
857
+ return { modelCount: models.length, modelsWithoutLimits: models.filter(model => !model.limit).length };
858
+ }
859
+
860
+ function summarizePi(document: unknown): { modelCount: number; modelsWithoutLimits: number } {
861
+ const models = (document as PiGeneratedConfig | undefined)?.providers?.[OPENCODE_PROVIDER_ID]?.models ?? [];
862
+ return { modelCount: models.length, modelsWithoutLimits: models.filter(model => model.contextWindow === undefined).length };
863
+ }
864
+
865
+ function summarizeHermes(document: unknown): { modelCount: number; modelsWithoutLimits: number } {
866
+ const models = (document as HermesGeneratedConfig | undefined)?.providers?.[OPENCODE_PROVIDER_ID]?.models ?? [];
867
+ // Hermes carries selectors only; it has no per-model limit to be missing.
868
+ return { modelCount: models.length, modelsWithoutLimits: 0 };
869
+ }
870
+
871
+ function summarizeOpenclaw(document: unknown): { modelCount: number; modelsWithoutLimits: number } {
872
+ const models = (document as OpenclawGeneratedConfig | undefined)?.models?.providers?.[OPENCODE_PROVIDER_ID]?.models ?? [];
873
+ return { modelCount: models.length, modelsWithoutLimits: models.filter(model => model.contextWindow === undefined).length };
874
+ }
875
+
876
+ function summarizeKimi(document: unknown): { modelCount: number; modelsWithoutLimits: number } {
877
+ const models = Object.values((document as KimiGeneratedConfig | undefined)?.models ?? {});
878
+ // A model with no authoritative window is omitted entirely, so every model
879
+ // present carries max_context_size by construction.
880
+ return { modelCount: models.length, modelsWithoutLimits: 0 };
881
+ }
882
+
883
+ function summarizeGajae(document: unknown): { modelCount: number; modelsWithoutLimits: number } {
884
+ const models = (document as GajaeGeneratedConfig | undefined)?.providers?.[OPENCODE_PROVIDER_ID]?.models ?? [];
885
+ return { modelCount: models.length, modelsWithoutLimits: models.filter(model => model.contextWindow === undefined).length };
886
+ }
887
+
888
+ /** One fragment at `path`, built from this client's own document. */
889
+ function singleFragment(clientId: ExportClientId, path: readonly string[], value: unknown): ManagedContribution {
890
+ return { clientId, fragments: [{ path, value }] };
891
+ }
892
+
893
+ function buildOpencodeContribution(ctx: ExportContext): ManagedContribution {
894
+ const doc = buildOpencodeClientConfig(ctx);
895
+ return singleFragment("opencode", ["provider", OPENCODE_PROVIDER_ID], doc.provider[OPENCODE_PROVIDER_ID]);
896
+ }
897
+
898
+ function buildPiContribution(ctx: ExportContext): ManagedContribution {
899
+ const doc = buildPiClientConfig(ctx);
900
+ return singleFragment("pi", ["providers", OPENCODE_PROVIDER_ID], doc.providers[OPENCODE_PROVIDER_ID]);
901
+ }
902
+
903
+ function buildHermesContribution(ctx: ExportContext): ManagedContribution {
904
+ const doc = buildHermesClientConfig(ctx);
905
+ return singleFragment("hermes", ["providers", OPENCODE_PROVIDER_ID], doc.providers[OPENCODE_PROVIDER_ID]);
906
+ }
907
+
908
+ function buildOpenclawContribution(ctx: ExportContext): ManagedContribution {
909
+ const doc = buildOpenclawClientConfig(ctx);
910
+ return singleFragment("openclaw", ["models", "providers", OPENCODE_PROVIDER_ID], doc.models.providers[OPENCODE_PROVIDER_ID]);
911
+ }
912
+
913
+ /**
914
+ * Kimi is why a contribution is a LIST: it owns the provider block AND one
915
+ * `models` entry per model. A writer that only knew about the provider would
916
+ * strand every model entry on disable.
917
+ */
918
+ function buildKimiContribution(ctx: ExportContext): ManagedContribution {
919
+ const doc = buildKimiClientConfig(ctx);
920
+ const fragments: ManagedFragment[] = [
921
+ { path: ["providers", OPENCODE_PROVIDER_ID], value: doc.providers[OPENCODE_PROVIDER_ID] },
922
+ ];
923
+ for (const [alias, block] of Object.entries(doc.models)) {
924
+ fragments.push({ path: ["models", alias], value: block });
925
+ }
926
+ return { clientId: "kimi", fragments };
927
+ }
928
+
929
+ function buildGajaeContribution(ctx: ExportContext): ManagedContribution {
930
+ const doc = buildGajaeClientConfig(ctx);
931
+ return singleFragment("gajae", ["providers", OPENCODE_PROVIDER_ID], doc.providers[OPENCODE_PROVIDER_ID]);
932
+ }
933
+
349
934
  export const EXPORT_CLIENTS: Record<ExportClientId, ExportClientSpec> = {
350
935
  opencode: {
351
936
  id: "opencode",
@@ -354,6 +939,11 @@ export const EXPORT_CLIENTS: Record<ExportClientId, ExportClientSpec> = {
354
939
  apiKeyEnv: OPENCODE_API_KEY_ENV,
355
940
  exportHint: `export ${OPENCODE_API_KEY_ENV}=<your key>`,
356
941
  build: buildOpencodeClientConfig,
942
+ format: "json",
943
+ summarize: summarizeOpencode,
944
+ buildContribution: buildOpencodeContribution,
945
+ // carries the dedicated header in provider options
946
+ loopbackOnly: false,
357
947
  },
358
948
  pi: {
359
949
  id: "pi",
@@ -362,6 +952,68 @@ export const EXPORT_CLIENTS: Record<ExportClientId, ExportClientSpec> = {
362
952
  apiKeyEnv: PI_API_KEY_ENV,
363
953
  exportHint: `export ${PI_API_KEY_ENV}=<your key>`,
364
954
  build: buildPiClientConfig,
955
+ format: "json",
956
+ summarize: summarizePi,
957
+ buildContribution: buildPiContribution,
958
+ // No header field in Pi's provider block (and the schema is unverified
959
+ // against a real install), so there is nowhere to put the dedicated
960
+ // admission header a remote bind requires.
961
+ loopbackOnly: true,
962
+ },
963
+ hermes: {
964
+ id: "hermes",
965
+ filename: "hermes-config.yaml",
966
+ destination: env => hermesConfigPath(env),
967
+ apiKeyEnv: HERMES_API_KEY_ENV,
968
+ exportHint: `export ${HERMES_API_KEY_ENV}=<your key>`,
969
+ build: buildHermesClientConfig,
970
+ format: "yaml",
971
+ summarize: summarizeHermes,
972
+ buildContribution: buildHermesContribution,
973
+ // extra_headers carries the dedicated header
974
+ loopbackOnly: false,
975
+ },
976
+ openclaw: {
977
+ id: "openclaw",
978
+ filename: "openclaw.json5",
979
+ destination: env => openclawConfigPath(env),
980
+ apiKeyEnv: OPENCLAW_API_KEY_ENV,
981
+ exportHint: `export ${OPENCLAW_API_KEY_ENV}=<your key>`,
982
+ build: buildOpenclawClientConfig,
983
+ format: "json5",
984
+ summarize: summarizeOpenclaw,
985
+ buildContribution: buildOpenclawContribution,
986
+ // headers carries the dedicated header
987
+ loopbackOnly: false,
988
+ },
989
+ kimi: {
990
+ id: "kimi",
991
+ filename: "kimi-config.toml",
992
+ destination: env => kimiConfigPath(env),
993
+ // Kimi reads credentials only from its own file, so there is no env var to
994
+ // export: a loopback bind uses the placeholder, and a remote bind is
995
+ // refused rather than handed the user's real key.
996
+ apiKeyEnv: "",
997
+ exportHint: "Kimi Code reads credentials from its config file; loopback needs no key.",
998
+ build: buildKimiClientConfig,
999
+ format: "toml",
1000
+ summarize: summarizeKimi,
1001
+ buildContribution: buildKimiContribution,
1002
+ // no header field, and credentials come only from this file
1003
+ loopbackOnly: true,
1004
+ },
1005
+ gajae: {
1006
+ id: "gajae",
1007
+ filename: "gajae-models.yaml",
1008
+ destination: env => gajaeConfigPath(env),
1009
+ apiKeyEnv: GAJAE_API_KEY_ENV,
1010
+ exportHint: `export ${GAJAE_API_KEY_ENV}=<your key>`,
1011
+ build: buildGajaeClientConfig,
1012
+ format: "yaml",
1013
+ summarize: summarizeGajae,
1014
+ buildContribution: buildGajaeContribution,
1015
+ // strict schema with no header field, so the dedicated header has nowhere to go
1016
+ loopbackOnly: true,
365
1017
  },
366
1018
  };
367
1019
 
@@ -375,3 +1027,28 @@ export function isExportClientId(value: string): value is ExportClientId {
375
1027
  export function buildClientConfig(client: ExportClientId, ctx: ExportContext): unknown {
376
1028
  return EXPORT_CLIENTS[client].build(ctx);
377
1029
  }
1030
+
1031
+ /**
1032
+ * The bytes a user actually receives, plus what they are. One place turns a
1033
+ * client id into text so the CLI, the API and the GUI cannot disagree about
1034
+ * format or media type — and so no consumer has to infer either from a
1035
+ * filename.
1036
+ */
1037
+ export function buildClientConfigText(
1038
+ client: ExportClientId,
1039
+ ctx: ExportContext,
1040
+ ): { document: unknown; text: string; format: ConfigFormat; mediaType: string } {
1041
+ const spec = EXPORT_CLIENTS[client];
1042
+ const document = spec.build(ctx);
1043
+ return {
1044
+ document,
1045
+ text: serializeDocument(document, spec.format),
1046
+ format: spec.format,
1047
+ mediaType: FORMAT_MEDIA_TYPE[spec.format],
1048
+ };
1049
+ }
1050
+
1051
+ /** The fragments opencodex owns in a client's config (writer-side). */
1052
+ export function buildClientContribution(client: ExportClientId, ctx: ExportContext): ManagedContribution {
1053
+ return EXPORT_CLIENTS[client].buildContribution(ctx);
1054
+ }