@visual-z/xeno 0.2.1 → 0.4.0

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 (282) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +126 -34
  3. package/docs/app-server.md +84 -0
  4. package/docs/install.md +51 -13
  5. package/docs/providers.md +24 -3
  6. package/package.json +7 -5
  7. package/src/apps/app-server.ts +28 -0
  8. package/src/apps/approve.ts +3 -3
  9. package/src/apps/cli.ts +25 -10
  10. package/src/apps/commands.ts +45 -8
  11. package/src/apps/completions.ts +78 -22
  12. package/src/apps/control-transport.ts +89 -0
  13. package/src/apps/image-draft.ts +46 -0
  14. package/src/apps/model-catalog.ts +2 -1
  15. package/src/apps/project-trust.ts +2 -42
  16. package/src/apps/provider-connect.ts +346 -0
  17. package/src/apps/render.ts +22 -47
  18. package/src/apps/resources.ts +5 -2
  19. package/src/apps/shell.ts +333 -1542
  20. package/src/apps/startup-input.ts +45 -9
  21. package/src/apps/startup.ts +149 -94
  22. package/src/apps/ui/app.ts +1553 -0
  23. package/src/apps/ui/control-model.ts +26 -0
  24. package/src/apps/ui/extension-host.ts +194 -0
  25. package/src/apps/ui/image-cache.ts +78 -0
  26. package/src/apps/ui/palette.ts +88 -0
  27. package/src/apps/ui/project.ts +127 -0
  28. package/src/apps/ui/standalone.ts +146 -0
  29. package/src/apps/ui/status-model.ts +68 -0
  30. package/src/apps/ui/surface.ts +86 -0
  31. package/src/apps/ui/theme.ts +86 -0
  32. package/src/core/acceptance.ts +107 -0
  33. package/src/core/action-defaults.ts +47 -295
  34. package/src/core/benchmark.ts +2 -2
  35. package/src/core/branch-receipt.ts +24 -0
  36. package/src/core/checkpoints.ts +64 -0
  37. package/src/core/churn.ts +2 -0
  38. package/src/core/command-artifacts.ts +94 -0
  39. package/src/core/command-contract.ts +35 -0
  40. package/src/core/completion-action.ts +20 -0
  41. package/src/core/completion-review.ts +37 -21
  42. package/src/core/context-detail.ts +37 -0
  43. package/src/core/context-policy.ts +74 -1
  44. package/src/core/continuation.ts +17 -2
  45. package/src/core/control-protocol.ts +62 -0
  46. package/src/core/control-view.ts +42 -0
  47. package/src/core/derive.ts +29 -3
  48. package/src/core/draft-images.ts +56 -0
  49. package/src/core/environment.ts +145 -0
  50. package/src/core/evaluation-coverage.ts +74 -0
  51. package/src/core/event-validation.ts +81 -6
  52. package/src/core/events.ts +99 -3
  53. package/src/core/evidence-links.ts +7 -0
  54. package/src/core/evidence.ts +13 -3
  55. package/src/core/execution.ts +8 -2
  56. package/src/core/experiment.ts +133 -0
  57. package/src/core/export.ts +9 -0
  58. package/src/core/failure-recovery.ts +173 -0
  59. package/src/core/file-precondition.ts +72 -0
  60. package/src/core/handoff.ts +65 -4
  61. package/src/core/harness-diagnostics.ts +206 -0
  62. package/src/core/history-query.ts +38 -2
  63. package/src/core/history.ts +38 -1
  64. package/src/core/integration-health.ts +34 -0
  65. package/src/core/keymap.ts +8 -20
  66. package/src/core/model-picker.ts +64 -0
  67. package/src/core/native-eval.ts +25 -0
  68. package/src/core/note-guidance.ts +34 -0
  69. package/src/core/oauth-fields.ts +44 -0
  70. package/src/core/observation-view.ts +49 -8
  71. package/src/core/permissions.ts +24 -2
  72. package/src/core/process-receipt.ts +105 -0
  73. package/src/core/process-state.ts +37 -0
  74. package/src/core/program-status.ts +31 -0
  75. package/src/core/project.ts +70 -19
  76. package/src/core/prompt-policy.ts +37 -0
  77. package/src/core/query-eval-contract.ts +14 -0
  78. package/src/core/read-observation.ts +82 -0
  79. package/src/core/recovery-corpus.ts +17 -0
  80. package/src/core/recovery-evaluation.ts +60 -0
  81. package/src/core/recovery-sources.ts +74 -0
  82. package/src/core/request-progress.ts +87 -0
  83. package/src/core/request-state.ts +114 -0
  84. package/src/core/runtime-target.ts +94 -0
  85. package/src/core/search-observation.ts +97 -0
  86. package/src/core/session-tree.ts +147 -0
  87. package/src/core/settings.ts +33 -10
  88. package/src/core/skills.ts +44 -0
  89. package/src/core/sql-query.ts +211 -0
  90. package/src/core/theme.ts +36 -58
  91. package/src/core/tool-contract.ts +51 -2
  92. package/src/core/tool-discovery.ts +87 -0
  93. package/src/core/tool-failure.ts +60 -0
  94. package/src/core/tool-input.ts +6 -0
  95. package/src/core/tool-mode-protocol.ts +25 -0
  96. package/src/core/transcript.ts +19 -4
  97. package/src/core/trial-lifecycle.ts +14 -0
  98. package/src/core/tui-mode.ts +29 -0
  99. package/src/core/update-check.ts +97 -0
  100. package/src/core/version.ts +55 -0
  101. package/src/core/work-state.ts +49 -3
  102. package/src/core/working-set.ts +91 -0
  103. package/src/runtime/auth.ts +11 -4
  104. package/src/runtime/branch-jobs.ts +36 -0
  105. package/src/runtime/catalog.ts +13 -3
  106. package/src/runtime/clipboard.ts +15 -4
  107. package/src/runtime/command-artifacts.ts +64 -0
  108. package/src/runtime/control-connection.ts +104 -0
  109. package/src/runtime/environment-tools.ts +60 -0
  110. package/src/runtime/evidence-notes.ts +4 -2
  111. package/src/runtime/exec-sessions.ts +206 -61
  112. package/src/runtime/execution-control.ts +1 -0
  113. package/src/runtime/extension-ui.ts +22 -30
  114. package/src/runtime/extensions.ts +95 -16
  115. package/src/runtime/harness-comparison.ts +27 -7
  116. package/src/runtime/harness-eval.ts +462 -0
  117. package/src/runtime/history-cache.ts +57 -0
  118. package/src/runtime/history-tools.ts +62 -12
  119. package/src/runtime/integrations.ts +210 -0
  120. package/src/runtime/loop.ts +1088 -282
  121. package/src/runtime/model-discovery.ts +208 -0
  122. package/src/runtime/model.ts +9 -0
  123. package/src/runtime/native-eval-fixtures.ts +33 -0
  124. package/src/runtime/native-eval.ts +146 -0
  125. package/src/runtime/oauth.ts +80 -3
  126. package/src/runtime/pi-provider.ts +169 -12
  127. package/src/runtime/program-status.ts +38 -0
  128. package/src/runtime/provider-connect.ts +109 -0
  129. package/src/runtime/provider-presets.ts +87 -0
  130. package/src/runtime/provider-request.ts +13 -2
  131. package/src/runtime/provider.ts +23 -9
  132. package/src/runtime/query-eval-verifier.ts +28 -0
  133. package/src/runtime/recovery-coverage.ts +71 -0
  134. package/src/runtime/recovery-evidence.ts +76 -0
  135. package/src/runtime/recovery-fixtures.ts +116 -0
  136. package/src/runtime/runtime-target.ts +149 -0
  137. package/src/runtime/session-control.ts +198 -0
  138. package/src/runtime/tool-broker.ts +137 -0
  139. package/src/runtime/tool-discovery.ts +57 -0
  140. package/src/runtime/tool-mode-eval.ts +146 -0
  141. package/src/runtime/tool-queue.ts +77 -0
  142. package/src/runtime/tools.ts +628 -183
  143. package/src/runtime/trial-control.ts +45 -0
  144. package/src/runtime/update-check.ts +47 -0
  145. package/src/runtime/window-trial.ts +2 -1
  146. package/src/runtime/working-notes.ts +33 -3
  147. package/src/runtime/workspace-lifecycle.ts +97 -0
  148. package/src/store/acceptance.ts +15 -0
  149. package/src/store/checkpoint-history.ts +43 -0
  150. package/src/store/command-artifacts.ts +96 -0
  151. package/src/store/command.ts +65 -37
  152. package/src/store/file-checkpoints.ts +104 -0
  153. package/src/store/file-index.ts +13 -1
  154. package/src/store/git-checkpoints.ts +67 -0
  155. package/src/store/observations.ts +61 -20
  156. package/src/store/process-receipts.ts +34 -0
  157. package/src/store/query-source.ts +13 -8
  158. package/src/store/recovery.ts +2 -3
  159. package/src/store/replay.ts +16 -4
  160. package/src/store/runtime-target.ts +64 -0
  161. package/src/store/search.ts +171 -132
  162. package/src/store/session-archive.ts +4 -0
  163. package/src/store/source-cancellation.ts +6 -0
  164. package/src/store/update-cache.ts +36 -0
  165. package/src/store/workspace-observation.ts +113 -0
  166. package/src/tui/ansi-spans.ts +126 -0
  167. package/src/tui/style.ts +19 -16
  168. package/src/tui/ui/atomic-draft.ts +121 -0
  169. package/src/tui/ui/canvas.ts +96 -0
  170. package/src/tui/ui/colors.ts +70 -0
  171. package/src/tui/ui/composer.ts +200 -0
  172. package/src/tui/ui/control-panel.ts +23 -0
  173. package/src/tui/ui/dialog.ts +379 -0
  174. package/src/tui/ui/draft.ts +23 -0
  175. package/src/tui/ui/edge.ts +186 -0
  176. package/src/tui/ui/image-size.ts +40 -0
  177. package/src/tui/ui/ink.ts +210 -0
  178. package/src/tui/ui/items.ts +40 -0
  179. package/src/tui/ui/keys.ts +40 -0
  180. package/src/tui/ui/layout.ts +74 -0
  181. package/src/tui/ui/list-model.ts +86 -0
  182. package/src/tui/ui/list-view.ts +172 -0
  183. package/src/tui/ui/mascot.ts +67 -0
  184. package/src/tui/ui/messages.ts +518 -0
  185. package/src/tui/ui/motion.ts +28 -0
  186. package/src/tui/ui/paths.ts +10 -0
  187. package/src/tui/ui/pictures.ts +78 -0
  188. package/src/tui/ui/screen.ts +421 -0
  189. package/src/tui/ui/status.ts +279 -0
  190. package/src/tui/ui/styled.ts +85 -0
  191. package/src/tui/ui/testing.ts +5 -0
  192. package/src/tui/ui/tool-card.ts +150 -0
  193. package/src/apps/extension-ui.ts +0 -330
  194. package/src/apps/resource-browser.ts +0 -75
  195. package/src/apps/session-browser.ts +0 -113
  196. package/src/apps/session-picker.ts +0 -55
  197. package/src/tui/ansi.ts +0 -67
  198. package/src/tui/autocomplete.ts +0 -29
  199. package/src/tui/chat-viewport.ts +0 -47
  200. package/src/tui/complete.ts +0 -87
  201. package/src/tui/editor.ts +0 -940
  202. package/src/tui/host.ts +0 -264
  203. package/src/tui/input.ts +0 -75
  204. package/src/tui/keys.ts +0 -378
  205. package/src/tui/markdown.ts +0 -17
  206. package/src/tui/render.ts +0 -153
  207. package/src/tui/select.ts +0 -258
  208. package/src/tui/settings.ts +0 -44
  209. package/src/tui/statusline.ts +0 -162
  210. package/src/tui/system-theme.ts +0 -700
  211. package/src/tui/terminal.ts +0 -30
  212. package/src/tui/text.ts +0 -52
  213. package/src/tui/toolview.ts +0 -386
  214. package/src/tui/transcript.ts +0 -123
  215. package/src/tui/tree.ts +0 -471
  216. package/src/vendor/pi-tui/LICENSE.upstream +0 -21
  217. package/src/vendor/pi-tui/UPSTREAM.json +0 -50
  218. package/src/vendor/pi-tui/VENDOR.md +0 -25
  219. package/src/vendor/pi-tui/alt-screen-search.ts +0 -327
  220. package/src/vendor/pi-tui/autocomplete.ts +0 -860
  221. package/src/vendor/pi-tui/colors.ts +0 -367
  222. package/src/vendor/pi-tui/components/alt-screen-flash.ts +0 -51
  223. package/src/vendor/pi-tui/components/box.ts +0 -166
  224. package/src/vendor/pi-tui/components/cancellable-loader.ts +0 -40
  225. package/src/vendor/pi-tui/components/editor.ts +0 -2472
  226. package/src/vendor/pi-tui/components/h-stack.ts +0 -44
  227. package/src/vendor/pi-tui/components/image.ts +0 -127
  228. package/src/vendor/pi-tui/components/input.ts +0 -494
  229. package/src/vendor/pi-tui/components/loader.ts +0 -101
  230. package/src/vendor/pi-tui/components/markdown.ts +0 -1021
  231. package/src/vendor/pi-tui/components/mouse-region.ts +0 -33
  232. package/src/vendor/pi-tui/components/scroll-view.ts +0 -224
  233. package/src/vendor/pi-tui/components/select-list.ts +0 -273
  234. package/src/vendor/pi-tui/components/settings-list.ts +0 -328
  235. package/src/vendor/pi-tui/components/spacer.ts +0 -28
  236. package/src/vendor/pi-tui/components/stack.ts +0 -154
  237. package/src/vendor/pi-tui/components/text.ts +0 -107
  238. package/src/vendor/pi-tui/components/truncated-text.ts +0 -65
  239. package/src/vendor/pi-tui/components/v-stack.ts +0 -33
  240. package/src/vendor/pi-tui/editor-component.ts +0 -74
  241. package/src/vendor/pi-tui/fuzzy.ts +0 -138
  242. package/src/vendor/pi-tui/index.ts +0 -186
  243. package/src/vendor/pi-tui/keybindings.ts +0 -320
  244. package/src/vendor/pi-tui/keys.ts +0 -1401
  245. package/src/vendor/pi-tui/kill-ring.ts +0 -46
  246. package/src/vendor/pi-tui/latex.ts +0 -1506
  247. package/src/vendor/pi-tui/layout-node.ts +0 -51
  248. package/src/vendor/pi-tui/layout.ts +0 -449
  249. package/src/vendor/pi-tui/native/UPSTREAM.json +0 -23
  250. package/src/vendor/pi-tui/native/clipboard.h +0 -208
  251. package/src/vendor/pi-tui/native/darwin/README.md +0 -22
  252. package/src/vendor/pi-tui/native/darwin/build.sh +0 -69
  253. package/src/vendor/pi-tui/native/darwin/prebuilds/darwin-arm64/darwin-platform.node +0 -0
  254. package/src/vendor/pi-tui/native/darwin/prebuilds/darwin-x64/darwin-platform.node +0 -0
  255. package/src/vendor/pi-tui/native/darwin/src/darwin-platform.m +0 -132
  256. package/src/vendor/pi-tui/native/linux/README.md +0 -23
  257. package/src/vendor/pi-tui/native/linux/build.sh +0 -65
  258. package/src/vendor/pi-tui/native/linux/prebuilds/linux-arm64/linux-platform-x11.node +0 -0
  259. package/src/vendor/pi-tui/native/linux/prebuilds/linux-x64/linux-platform-x11.node +0 -0
  260. package/src/vendor/pi-tui/native/linux/src/clipboard-worker.h +0 -70
  261. package/src/vendor/pi-tui/native/linux/src/linux-platform-x11.c +0 -274
  262. package/src/vendor/pi-tui/native/napi.h +0 -91
  263. package/src/vendor/pi-tui/native/win32/README.md +0 -31
  264. package/src/vendor/pi-tui/native/win32/build.mjs +0 -232
  265. package/src/vendor/pi-tui/native/win32/prebuilds/win32-arm64/win32-platform.node +0 -0
  266. package/src/vendor/pi-tui/native/win32/prebuilds/win32-x64/win32-platform.node +0 -0
  267. package/src/vendor/pi-tui/native/win32/src/win32-platform.c +0 -241
  268. package/src/vendor/pi-tui/native-modifiers.ts +0 -13
  269. package/src/vendor/pi-tui/native-module-path.ts +0 -31
  270. package/src/vendor/pi-tui/native-platform.ts +0 -65
  271. package/src/vendor/pi-tui/oklab.ts +0 -233
  272. package/src/vendor/pi-tui/stdin-buffer.ts +0 -444
  273. package/src/vendor/pi-tui/terminal-colors.ts +0 -91
  274. package/src/vendor/pi-tui/terminal-image.ts +0 -731
  275. package/src/vendor/pi-tui/terminal.ts +0 -554
  276. package/src/vendor/pi-tui/tui-alt-screen.ts +0 -1750
  277. package/src/vendor/pi-tui/tui-main-screen.ts +0 -655
  278. package/src/vendor/pi-tui/tui.ts +0 -1493
  279. package/src/vendor/pi-tui/undo-stack.ts +0 -28
  280. package/src/vendor/pi-tui/utils.ts +0 -1385
  281. package/src/vendor/pi-tui/wheel-scroll.ts +0 -82
  282. package/src/vendor/pi-tui/word-navigation.ts +0 -117
@@ -0,0 +1,208 @@
1
+ /**
2
+ * Live model discovery: ask the provider which models this key can use.
3
+ *
4
+ * Wire vocabulary (URLs, auth headers, response shapes) belongs to runtime,
5
+ * not the SDK-free core (D13). Everything except `discoverModels` is pure so
6
+ * the URL and parsing rules are unit tested without a network.
7
+ *
8
+ * Endpoints, per the vendors' own list APIs and what magpie / CC Switch /
9
+ * Crush converged on (docs/tasks/feat-provider-connect.md):
10
+ * OpenAI-compatible GET {base}/models Authorization: Bearer
11
+ * Anthropic GET {root}/v1/models x-api-key + anthropic-version
12
+ * (paged: has_more / last_id → ?after_id=)
13
+ * Gemini GET {base}/models x-goog-api-key
14
+ * (paged: nextPageToken → ?pageToken=; keep generateContent)
15
+ *
16
+ * A pasted full endpoint (…/chat/completions, …/v1/messages) is cut back to
17
+ * its base, and a base without a version segment also tries `/v1`, because
18
+ * gateways disagree on where the version lives. Anthropic-compatible
19
+ * gateways often want Bearer instead of x-api-key; both are sent (magpie).
20
+ */
21
+ import type { ProviderApi } from "./provider-protocols.ts";
22
+
23
+ export type DiscoveryFamily = "openai" | "anthropic" | "google";
24
+
25
+ export interface DiscoveredModel {
26
+ id: string;
27
+ name?: string;
28
+ contextWindow?: number;
29
+ maxTokens?: number;
30
+ }
31
+
32
+ export type DiscoveryResult =
33
+ | { ok: true; models: DiscoveredModel[]; url: string }
34
+ | { ok: false; reason: string; status?: number };
35
+
36
+ export function discoveryFamily(api: ProviderApi): DiscoveryFamily | undefined {
37
+ switch (api) {
38
+ case "openai-completions":
39
+ case "openai-responses":
40
+ case "mistral-conversations":
41
+ return "openai";
42
+ case "anthropic-messages":
43
+ return "anthropic";
44
+ case "google-generative-ai":
45
+ return "google";
46
+ default:
47
+ // Vertex, Bedrock, Azure, Codex and Pi need cloud auth or have no
48
+ // listing endpoint; their catalog entries stay authoritative.
49
+ return undefined;
50
+ }
51
+ }
52
+
53
+ const ENDPOINT_SUFFIXES = [/\/chat\/completions\/?$/, /\/responses\/?$/, /\/completions\/?$/, /\/messages\/?$/, /\/models\/?$/];
54
+
55
+ /** Strip whitespace, trailing slashes and a pasted operation path. */
56
+ export function normalizeBaseUrl(raw: string): string {
57
+ let url = raw.trim().replace(/\/+$/, "");
58
+ for (const suffix of ENDPOINT_SUFFIXES) url = url.replace(suffix, "");
59
+ return url.replace(/\/+$/, "");
60
+ }
61
+
62
+ const VERSIONED = /\/v\d+(beta\d*|alpha\d*)?$/;
63
+
64
+ export function discoveryUrls(family: DiscoveryFamily, baseUrl: string): string[] {
65
+ const base = normalizeBaseUrl(baseUrl);
66
+ const urls: string[] = [];
67
+ const add = (url: string) => { if (!urls.includes(url)) urls.push(url); };
68
+ switch (family) {
69
+ case "openai":
70
+ add(`${base}/models`);
71
+ if (!VERSIONED.test(base)) add(`${base}/v1/models`);
72
+ break;
73
+ case "anthropic": {
74
+ const root = base.replace(/\/v1$/, "");
75
+ add(`${root}/v1/models`);
76
+ add(`${base}/models`);
77
+ break;
78
+ }
79
+ case "google":
80
+ add(VERSIONED.test(base) ? `${base}/models` : `${base}/v1beta/models`);
81
+ break;
82
+ }
83
+ return urls;
84
+ }
85
+
86
+ export function discoveryHeaders(family: DiscoveryFamily, key: string): Record<string, string> {
87
+ if (key.length === 0) return {};
88
+ switch (family) {
89
+ case "openai": return { authorization: `Bearer ${key}` };
90
+ case "anthropic": return { "x-api-key": key, authorization: `Bearer ${key}`, "anthropic-version": "2023-06-01" };
91
+ case "google": return { "x-goog-api-key": key };
92
+ }
93
+ }
94
+
95
+ function record(value: unknown): value is Record<string, unknown> {
96
+ return value !== null && typeof value === "object" && !Array.isArray(value);
97
+ }
98
+ function positive(value: unknown): number | undefined {
99
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined;
100
+ }
101
+ function text(value: unknown): string | undefined {
102
+ return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
103
+ }
104
+
105
+ /** One page of a list response. `next` is the query to append for the following page. */
106
+ export function parseModelPage(family: DiscoveryFamily, json: unknown): { models: DiscoveredModel[]; next?: string } {
107
+ if (!record(json)) return { models: [] };
108
+ const rows = Array.isArray(json["data"]) ? json["data"] : Array.isArray(json["models"]) ? json["models"] : [];
109
+ const models: DiscoveredModel[] = [];
110
+ for (const row of rows) {
111
+ if (!record(row)) continue;
112
+ if (family === "google") {
113
+ const methods = row["supportedGenerationMethods"];
114
+ if (Array.isArray(methods) && !methods.includes("generateContent")) continue;
115
+ const id = text(row["name"])?.replace(/^models\//, "") ?? text(row["id"]);
116
+ if (!id) continue;
117
+ models.push(compact({ id, name: text(row["displayName"]), contextWindow: positive(row["inputTokenLimit"]), maxTokens: positive(row["outputTokenLimit"]) }));
118
+ continue;
119
+ }
120
+ const id = text(row["id"]) ?? text(row["name"]);
121
+ if (!id) continue;
122
+ // OpenRouter and several gateways publish limits on the listing itself.
123
+ const top = record(row["top_provider"]) ? row["top_provider"] : {};
124
+ models.push(compact({
125
+ id,
126
+ name: text(row["display_name"]) ?? (text(row["name"]) !== id ? text(row["name"]) : undefined),
127
+ contextWindow: positive(row["context_length"]) ?? positive(row["context_window"]) ?? positive(top["context_length"]),
128
+ maxTokens: positive(row["max_output_tokens"]) ?? positive(top["max_completion_tokens"]),
129
+ }));
130
+ }
131
+ if (family === "anthropic" && json["has_more"] === true && text(json["last_id"])) return { models, next: `after_id=${encodeURIComponent(String(json["last_id"]))}` };
132
+ if (family === "google" && text(json["nextPageToken"])) return { models, next: `pageToken=${encodeURIComponent(String(json["nextPageToken"]))}` };
133
+ return { models };
134
+ }
135
+
136
+ function compact(model: { id: string; name: string | undefined; contextWindow: number | undefined; maxTokens: number | undefined }): DiscoveredModel {
137
+ return { id: model.id, ...(model.name !== undefined ? { name: model.name } : {}),
138
+ ...(model.contextWindow !== undefined ? { contextWindow: model.contextWindow } : {}),
139
+ ...(model.maxTokens !== undefined ? { maxTokens: model.maxTokens } : {}) };
140
+ }
141
+
142
+ /**
143
+ * `/models` on OpenAI-style endpoints also lists embeddings, speech, image and
144
+ * moderation models. They can never run a coding turn, so the picker should
145
+ * not offer them. Pattern list, not a guess per model: ids that match are
146
+ * still shown in the wizard under "other", just not preselected.
147
+ */
148
+ const NON_CHAT = /(^|[-_/.:])(embed|embedding|embeddings|tts|whisper|transcribe|dall-e|dalle|moderation|image|imagen|audio|realtime|speech|rerank|reranker|search|veo|sora|davinci|babbage)([-_/.:]|\d|$)/i;
149
+ export function likelyChatModel(id: string): boolean {
150
+ return !NON_CHAT.test(id);
151
+ }
152
+
153
+ /**
154
+ * Network IO. Candidates are tried in order; 404/405 moves on, an auth
155
+ * failure stops (another URL will not make a wrong key right), and any
156
+ * other failure is reported with the URL that produced it.
157
+ * Timeout 10s per request: assumed; long enough for a slow gateway's first
158
+ * response, short enough that a dead host is reported while the user waits.
159
+ */
160
+ const DISCOVERY_TIMEOUT_MS = 10_000;
161
+ /** Pagination bound: Anthropic pages at 20 by default; 25 pages covers every known list. Assumed. */
162
+ const MAX_PAGES = 25;
163
+
164
+ export async function discoverModels(opts: {
165
+ family: DiscoveryFamily;
166
+ baseUrl: string;
167
+ key: string;
168
+ headers?: Record<string, string>;
169
+ fetch: (input: string, init?: RequestInit) => Promise<Response>;
170
+ signal?: AbortSignal;
171
+ timeoutMs?: number;
172
+ }): Promise<DiscoveryResult> {
173
+ const headers = { accept: "application/json", ...discoveryHeaders(opts.family, opts.key), ...opts.headers };
174
+ let last: DiscoveryResult = { ok: false, reason: "no discovery URL" };
175
+ for (const url of discoveryUrls(opts.family, opts.baseUrl)) {
176
+ const models: DiscoveredModel[] = [];
177
+ let query: string | undefined;
178
+ let failed: DiscoveryResult | undefined;
179
+ for (let page = 0; page < MAX_PAGES; page++) {
180
+ const target = query ? `${url}${url.includes("?") ? "&" : "?"}${query}` : url;
181
+ let response: Response;
182
+ try {
183
+ const timeout = AbortSignal.timeout(opts.timeoutMs ?? DISCOVERY_TIMEOUT_MS);
184
+ response = await opts.fetch(target, { headers, signal: opts.signal ? AbortSignal.any([opts.signal, timeout]) : timeout });
185
+ } catch (error) {
186
+ if (opts.signal?.aborted) return { ok: false, reason: "cancelled" };
187
+ failed = { ok: false, reason: `${url}: ${error instanceof Error && error.name === "TimeoutError" ? "timed out" : error instanceof Error ? error.message : String(error)}` };
188
+ break;
189
+ }
190
+ if (response.status === 401 || response.status === 403) {
191
+ return { ok: false, status: response.status, reason: `${url}: authentication failed (HTTP ${response.status}); check the API key` };
192
+ }
193
+ if (!response.ok) { failed = { ok: false, status: response.status, reason: `${url}: HTTP ${response.status}` }; break; }
194
+ let json: unknown;
195
+ try { json = await response.json(); } catch { failed = { ok: false, reason: `${url}: response is not JSON` }; break; }
196
+ const parsed = parseModelPage(opts.family, json);
197
+ models.push(...parsed.models);
198
+ if (!parsed.next) break;
199
+ query = parsed.next;
200
+ }
201
+ if (!failed && models.length > 0) {
202
+ const seen = new Set<string>();
203
+ return { ok: true, url, models: models.filter(model => !seen.has(model.id) && seen.add(model.id)) };
204
+ }
205
+ last = failed ?? { ok: false, reason: `${url}: the provider returned no models` };
206
+ }
207
+ return last;
208
+ }
@@ -19,6 +19,7 @@
19
19
  import type { ProviderApi } from "./provider-protocols.ts";
20
20
  import { SOURCE_MODELS } from "./model-data.ts";
21
21
  import { mergeCatalogs } from "../core/catalog-merge.ts";
22
+ import { GROK_PROVIDER, GROK_BASE_URL } from "./oauth.ts";
22
23
  export type Api = ProviderApi;
23
24
 
24
25
  /** How hard the model should think. Capability and wire vocabulary are mapped per model. */
@@ -440,6 +441,14 @@ export const CURATED_CATALOG: readonly ModelSpec[] = [
440
441
  DEEPSEEK_V4_PRO,
441
442
  DEEPSEEK_FLASH,
442
443
 
444
+ {
445
+ id: "grok-build", name: "Grok Build", provider: GROK_PROVIDER, baseUrl: GROK_BASE_URL,
446
+ api: "openai-completions", driver: "pi", input: ["text"],
447
+ // The official proxy alias has no published effort/price contract. Do not copy API model rates.
448
+ reasoning: false,
449
+ nativeCompat: { supportsDeveloperRole: false, supportsReasoningEffort: false, maxTokensField: "max_tokens" },
450
+ },
451
+
443
452
  completionsModel({
444
453
  id: "grok-4.6",
445
454
  // Input modalities from the existing models.dev catalog source, 2026-09-30.
@@ -0,0 +1,33 @@
1
+ /** Q.20: a synthetic wire peer validates the native path without spending model quota. */
2
+ import { nativeWire } from "./native-eval.ts";
3
+ import { toolModeBackend } from "./tool-mode-eval.ts";
4
+ import type { EvalFixture } from "./harness-eval.ts";
5
+
6
+ export function nativeFixtureFetch(fixture: EvalFixture): typeof fetch {
7
+ const backend = toolModeBackend(fixture);
8
+ return (async (input: RequestInfo | URL, init?: RequestInit) => {
9
+ const request = new Request(input, init), payload = await nativeWire(request);
10
+ const tools = payload["tools"] as { name: string; parameters: unknown }[];
11
+ const source = payload["input"] as Record<string, unknown>[];
12
+ const response = await backend({ tools: tools.map(tool => ({ function: tool })),
13
+ messages: source.filter(item => item["type"] === "function_call_output").map(item => ({ role: "tool", content: item["output"] })) }, request.signal) as {
14
+ id: string; choices: { message: { content: string | null; tool_calls?: { id: string; function: { name: string; arguments: string } }[] } }[] };
15
+ const message = response.choices[0]!.message, events: unknown[] = [], output: unknown[] = [];
16
+ for (const [index, call] of (message.tool_calls ?? []).entries()) {
17
+ const item = { type: "function_call", id: "fc_" + call.id, call_id: call.id, name: call.function.name, arguments: call.function.arguments, status: "completed" };
18
+ events.push({ type: "response.output_item.added", output_index: index, item: { ...item, arguments: "" } },
19
+ { type: "response.function_call_arguments.delta", output_index: index, delta: call.function.arguments },
20
+ { type: "response.output_item.done", output_index: index, item }); output.push(item);
21
+ }
22
+ if (message.content) {
23
+ const item = { type: "message", id: "msg_" + response.id, role: "assistant", content: [{ type: "output_text", text: message.content, annotations: [] }], status: "completed" };
24
+ events.push({ type: "response.output_item.added", output_index: 0, item: { ...item, content: [] } },
25
+ { type: "response.content_part.added", output_index: 0, content_index: 0, part: { type: "output_text", text: "", annotations: [] } },
26
+ { type: "response.output_text.delta", output_index: 0, content_index: 0, delta: message.content },
27
+ { type: "response.output_item.done", output_index: 0, item }); output.push(item);
28
+ }
29
+ events.push({ type: "response.completed", response: { id: response.id, model: "gpt-6-luna", status: "completed", output,
30
+ usage: { input_tokens: 200, output_tokens: 32, input_tokens_details: { cached_tokens: 50, cache_write_tokens: 20 }, total_tokens: 232 } } });
31
+ return new Response(events.map(event => "data: " + JSON.stringify(event) + "\n\n").join(""), { headers: { "content-type": "text/event-stream" } });
32
+ }) as typeof fetch;
33
+ }
@@ -0,0 +1,146 @@
1
+ /** Q.20: meter the production Responses adapter without translating its wire protocol. */
2
+ import { createHash } from "node:crypto";
3
+ import { nativeMeasured, nativeReservation, nativeUsage } from "../core/native-eval.ts";
4
+ import type { BenchmarkPrice } from "../core/benchmark.ts";
5
+ import type { FrozenPrefix, Message } from "../core/project.ts";
6
+ import type { Provider, ProviderConfig } from "./provider.ts";
7
+ import { PiProvider } from "./pi-provider.ts";
8
+ import type { ComparisonLedger, GatewayRecord } from "./harness-comparison.ts";
9
+
10
+ const digest = (body: string) => createHash("sha256").update(body).digest("hex");
11
+ const bytes = (body: string) => new TextEncoder().encode(body);
12
+ const noEffects = { env: () => undefined, run: () => { throw Error("evaluation forbids credential commands"); } };
13
+ // Offline replay only. It has the SDK's required claim shape, no signature or authority.
14
+ export const NATIVE_EVAL_TEST_KEY = "offline." + btoa(JSON.stringify({ "https://api.openai.com/auth": { chatgpt_account_id: "offline-evaluation" } })) + ".unsigned";
15
+
16
+ export async function nativeWire(request: Request): Promise<Record<string, unknown>> {
17
+ const raw = new Uint8Array(await request.clone().arrayBuffer());
18
+ if (raw.length > 8 * 1024 * 1024) throw Error("native eval wire exceeds archive bound");
19
+ const body = request.headers.get("content-encoding") === "zstd" ? Bun.zstdDecompressSync(raw) : raw;
20
+ if (body.length > 8 * 1024 * 1024) throw Error("native eval wire exceeds archive bound");
21
+ return JSON.parse(new TextDecoder().decode(body)) as Record<string, unknown>;
22
+ }
23
+ /** Running the same serializer with a local fetch prevents a second, approximate wire builder. */
24
+ export async function nativePayload(config: ProviderConfig, frozen: FrozenPrefix, messages: Message[], sessionId?: string): Promise<Record<string, unknown>> {
25
+ let payload: Record<string, unknown> | undefined;
26
+ const capture = (async (input: RequestInfo | URL, init?: RequestInit) => {
27
+ payload = await nativeWire(new Request(input, init));
28
+ return new Response(null, { status: 400 });
29
+ }) as typeof fetch;
30
+ const provider = new PiProvider(config, NATIVE_EVAL_TEST_KEY, { oauth: true, effects: noEffects, fetch: capture,
31
+ ...(sessionId ? { sessionId } : {}), retry: { maxRetries: 0, maxRetryDelayMs: 0 } });
32
+ await provider.send(frozen, messages, 0).catch(() => {});
33
+ if (!payload) throw Error("native offline wire was not produced");
34
+ return payload;
35
+ }
36
+ export function nativeInput(payload: Record<string, unknown>): { bytes: Uint8Array; messages: unknown[] } {
37
+ if (!Array.isArray(payload["input"])) throw Error("native input is missing");
38
+ return { bytes: bytes(JSON.stringify([payload["instructions"], payload["tools"] ?? [], payload["input"]])), messages: payload["input"] };
39
+ }
40
+ export interface NativeResponse { model: unknown; usage: unknown; status: unknown; service_tier: unknown }
41
+
42
+ /** Forward unchanged chunks with backpressure; retain only a terminal receipt, never headers. */
43
+ export function nativeResponseTap(body: ReadableStream<Uint8Array>, terminal: (response: NativeResponse) => Promise<void>) {
44
+ const decoder = new TextDecoder(); let pending = "", observed = false;
45
+ const line = async (text: string) => {
46
+ if (!text.startsWith("data:") || text.slice(5).trim() === "[DONE]") return;
47
+ const event = JSON.parse(text.slice(5)) as Record<string, unknown>;
48
+ if (!["response.completed", "response.done", "response.incomplete"].includes(String(event["type"]))) return;
49
+ if (observed) throw Error("duplicate native terminal response");
50
+ observed = true;
51
+ const response = event["response"] as Record<string, unknown> | undefined;
52
+ await terminal({ model: response?.["model"] ?? null, usage: response?.["usage"] ?? null,
53
+ status: response?.["status"] ?? null, service_tier: response?.["service_tier"] ?? null });
54
+ };
55
+ return body.pipeThrough(new TransformStream<Uint8Array, Uint8Array>({
56
+ async transform(chunk, controller) {
57
+ pending += decoder.decode(chunk, { stream: true });
58
+ // The completed event repeats output. Bound this framing buffer separately from the saved receipt.
59
+ if (pending.length > 8 * 1024 * 1024) throw Error("native SSE frame exceeds archive bound");
60
+ let at: number;
61
+ while ((at = pending.indexOf("\n")) >= 0) { await line(pending.slice(0, at).replace(/\r$/, "")); pending = pending.slice(at + 1); }
62
+ controller.enqueue(chunk);
63
+ },
64
+ async flush() { pending += decoder.decode(); if (pending.trim()) await line(pending.trim()); },
65
+ }));
66
+ }
67
+
68
+ export function nativeEvalTransport(options: {
69
+ config: ProviderConfig; key: string; price: BenchmarkPrice; ledger: ComparisonLedger; maxRequests: number;
70
+ fetch?: typeof fetch;
71
+ sessionId?: string;
72
+ record: (row: GatewayRecord) => Promise<void>;
73
+ wire: (payload: Record<string, unknown>, request: number) => Promise<void>;
74
+ response: (receipt: NativeResponse, request: number) => Promise<void>;
75
+ }) {
76
+ const { config, ledger, price } = options;
77
+ if (config.spec.api !== "openai-codex-responses" || !config.spec.maxTokens || config.maxTokens !== config.spec.maxTokens)
78
+ throw Error("native eval must reserve the full subscription model output ceiling");
79
+ const controller = new AbortController();
80
+ let requests = 0, stopped: "budget" | "balance" | "error" | "limit" | null = null;
81
+ let previous: ReturnType<typeof nativeInput> | null = null;
82
+ let current: { row: GatewayRecord; started: number; terminal: NativeResponse | null; httpStatus: number | null } | undefined;
83
+ let active = false, idle = Promise.resolve();
84
+ const observedFetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
85
+ if (!active || current || controller.signal.aborted || ledger.halted || stopped) throw Error("native eval refused an extra request");
86
+ if (requests >= options.maxRequests) { stopped = "limit"; throw Error("native request limit"); }
87
+ const request = new Request(input, init), payload = await nativeWire(request), wire = JSON.stringify(payload);
88
+ if (payload["model"] !== (config.spec.wireId ?? config.spec.id) || payload["store"] !== false || payload["previous_response_id"] !== undefined || payload["stream"] !== true)
89
+ throw Error("native wire differs from frozen stateless policy");
90
+ const reservation = nativeReservation(bytes(wire).length, config.spec.maxTokens!, price);
91
+ if (ledger.budget === null || ledger.charged + reservation > ledger.budget) { stopped = "budget"; throw Error("native eval budget exhausted"); }
92
+ const inputState = nativeInput(payload); let shared = 0;
93
+ if (previous) while (shared < Math.min(previous.bytes.length, inputState.bytes.length) && previous.bytes[shared] === inputState.bytes[shared]) shared++;
94
+ const unchanged = previous === null ? null : JSON.stringify(inputState.messages.slice(0, previous.messages.length)) === JSON.stringify(previous.messages);
95
+ previous = inputState;
96
+ ledger.charged += reservation;
97
+ const row: GatewayRecord = { kind: "reserved", request: ++requests, inputSha256: digest(wire), inputBytes: inputState.bytes.length,
98
+ sharedInputBytes: shared, prefixUnchanged: unchanged, hasTools: Array.isArray(payload["tools"]) && payload["tools"].length > 0,
99
+ reservation, charge: reservation, measured: null, ledgerCharged: ledger.charged, usage: null, failure: null, responseModel: null, wallMs: 0 };
100
+ const state = { row, started: performance.now(), terminal: null as NativeResponse | null, httpStatus: null as number | null };
101
+ current = state;
102
+ await options.record(row); await options.wire(payload, requests);
103
+ const response = await (options.fetch ?? fetch)(new Request(request, { redirect: "error", signal: AbortSignal.any([controller.signal, request.signal]) }));
104
+ state.httpStatus = response.status;
105
+ if (!response.ok || !response.body) { await response.body?.cancel(); throw Error("native upstream response failed"); }
106
+ return new Response(nativeResponseTap(response.body, async terminal => {
107
+ state.terminal = terminal; await options.response(terminal, row.request);
108
+ }), { status: response.status, headers: response.headers });
109
+ }) as typeof fetch;
110
+ const actual = new PiProvider(config, options.key, { oauth: true, effects: noEffects, fetch: observedFetch,
111
+ ...(options.sessionId ? { sessionId: options.sessionId } : {}), retry: { maxRetries: 0, maxRetryDelayMs: 0 } });
112
+ const provider: Provider = { config, async send(frozen, messages, node, signal, onDelta) {
113
+ if (active) throw Error("native eval model requests must be sequential");
114
+ active = true; current = undefined;
115
+ let finish = () => {}; idle = new Promise<void>(resolve => { finish = resolve; });
116
+ let failed = false;
117
+ try {
118
+ const turn = await actual.send(frozen, messages, node, AbortSignal.any([controller.signal, ...(signal ? [signal] : [])]), onDelta);
119
+ const state = current as { terminal: NativeResponse | null } | undefined;
120
+ const observed = nativeUsage(state?.terminal);
121
+ const same = observed && Object.entries(observed).every(([key, count]) => turn.usage[key as keyof typeof observed] === count);
122
+ const model = state?.terminal?.model;
123
+ if (!same || typeof model !== "string" || !(model === config.spec.id || new RegExp("^" + config.spec.id + "-\\d{4}-\\d{2}-\\d{2}$").test(model)))
124
+ throw Error("native model or usage differs from the production adapter");
125
+ return turn;
126
+ } catch {
127
+ failed = true; stopped ??= "error";
128
+ throw Error("native eval request failed; inspect status and retained usage");
129
+ } finally {
130
+ try {
131
+ const state = current as { row: GatewayRecord; started: number; terminal: NativeResponse | null; httpStatus: number | null } | undefined;
132
+ if (state) {
133
+ const usage = nativeUsage(state.terminal), measured = nativeMeasured(state.terminal, price), charge = measured ?? state.row.reservation;
134
+ ledger.charged += charge - state.row.reservation;
135
+ if (failed || measured === null || charge > state.row.reservation) { ledger.halted = true; stopped ??= "error"; }
136
+ await options.record({ ...state.row, kind: "settled", usage, measured, charge, ledgerCharged: ledger.charged,
137
+ responseModel: typeof state.terminal?.model === "string" ? state.terminal.model : null,
138
+ failure: failed ? { kind: [402, 429].includes(state.httpStatus ?? 0) ? "balance" : "provider", status: state.httpStatus } : null,
139
+ wallMs: performance.now() - state.started });
140
+ }
141
+ } catch { ledger.halted = true; throw Error("native eval ledger could not be persisted"); }
142
+ finally { active = false; finish(); }
143
+ }
144
+ } };
145
+ return { provider, get requests() { return requests; }, get stopped() { return stopped; }, async close() { controller.abort(); await idle; } };
146
+ }
@@ -15,9 +15,10 @@
15
15
 
16
16
  import { createServer, type Server } from "node:http";
17
17
  import { pollDeviceCode } from "./oauth-device.ts";
18
+ import { parseDeviceAuthorization, parseOAuthTokens } from "../core/oauth-fields.ts";
18
19
 
19
20
  /** Matches package.json. Sent as User-Agent so the server can see who we are (D18). */
20
- export const XENO_VERSION = "0.2.1";
21
+ export const XENO_VERSION = "0.4.0";
21
22
 
22
23
  /**
23
24
  * Minutes subtracted from server expiry so a request issued at the edge
@@ -33,6 +34,7 @@ export const SUBSCRIPTION_OPTIONS = [
33
34
  { id: "claude-pro", provider: "anthropic", label: "Claude Pro/Max" },
34
35
  { id: "codex", provider: "openai-codex", label: "ChatGPT Plus/Pro (Codex)" },
35
36
  { id: "github-copilot", provider: "github-copilot", label: "GitHub Copilot" },
37
+ { id: "grok", provider: "xai-grok", label: "Grok (official account)" },
36
38
  ] as const;
37
39
 
38
40
  export type SubscriptionId = (typeof SUBSCRIPTION_OPTIONS)[number]["id"];
@@ -101,12 +103,83 @@ export const OPENAI_REDIRECT_URI = `http://localhost:${OPENAI_CALLBACK_PORT}${OP
101
103
  const OPENAI_SCOPE = "openid profile email offline_access";
102
104
  const OPENAI_JWT_CLAIM = "https://api.openai.com/auth";
103
105
 
106
+ // Official Grok Build config.rs / device_code.rs, verified 2026-10-07.
107
+ // This is the public first-party client, with xeno attribution retained (D18).
108
+ const GROK_CLIENT_ID = "b1a00492-073a-47ea-816f-4c329264a828";
109
+ const GROK_AUTH_ORIGIN = "https://auth.x.ai";
110
+ const GROK_TOKEN_URL = `${GROK_AUTH_ORIGIN}/oauth2/token`;
111
+ // Only inference and refresh scopes: xeno does not store remote conversations/workspaces.
112
+ const GROK_SCOPE = "openid profile email offline_access grok-cli:access api:access";
113
+ export const GROK_PROVIDER = "xai-grok";
114
+ export const GROK_BASE_URL = "https://cli-chat-proxy.grok.com/v1";
115
+ /** Official HTTP timeout: issuer requests cannot keep a cancelled or expired grant hanging indefinitely. */
116
+ const GROK_REQUEST_TIMEOUT_MS = 15_000;
117
+
118
+ async function grokPost(url: string, fields: Record<string, string>, effects: Pick<RefreshEffects, "fetch" | "signal">): Promise<{ status: number; json: unknown }> {
119
+ const timeout = AbortSignal.timeout(GROK_REQUEST_TIMEOUT_MS);
120
+ const signal = effects.signal ? AbortSignal.any([effects.signal, timeout]) : timeout;
121
+ try {
122
+ const response = await effects.fetch(url, { method: "POST", redirect: "error", signal,
123
+ headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json", "User-Agent": `xeno/${XENO_VERSION}` },
124
+ body: new URLSearchParams(fields) });
125
+ let json: unknown;
126
+ try { json = await response.json(); } catch { json = undefined; }
127
+ return { status: response.status, json };
128
+ } catch {
129
+ // Network errors can include request details; never expose a credential-bearing body.
130
+ throw new Error(effects.signal?.aborted ? "Login cancelled" : "Grok authentication request failed");
131
+ }
132
+ }
133
+
134
+ export async function loginGrok(interaction: OAuthInteraction, sleep?: Parameters<typeof pollDeviceCode>[0]["sleep"]): Promise<OAuthCredential> {
135
+ const started = await grokPost(`${GROK_AUTH_ORIGIN}/oauth2/device/code`,
136
+ { client_id: GROK_CLIENT_ID, scope: GROK_SCOPE, referrer: "xeno" }, interaction);
137
+ if (started.status < 200 || started.status >= 300) throw new Error(`Grok device code request failed (HTTP ${started.status})`);
138
+ const device = parseDeviceAuthorization(started.json, [GROK_AUTH_ORIGIN, "https://accounts.x.ai"]);
139
+ if (!device) throw new Error("Grok device code response was incomplete or untrusted");
140
+ interaction.signal.throwIfAborted();
141
+ const url = device.verificationUriComplete ?? device.verificationUri;
142
+ interaction.notify(`Open ${url}\nand enter code: ${device.userCode}`);
143
+ tryOpen(url, interaction.openBrowser);
144
+ return pollDeviceCode<OAuthCredential>({
145
+ expiresInSeconds: device.expiresIn, waitBeforeFirstPoll: true, signal: interaction.signal,
146
+ ...(device.interval !== undefined ? { intervalSeconds: device.interval } : {}), ...(sleep ? { sleep } : {}),
147
+ poll: async () => {
148
+ const got = await grokPost(GROK_TOKEN_URL,
149
+ { client_id: GROK_CLIENT_ID, device_code: device.deviceCode, grant_type: "urn:ietf:params:oauth:grant-type:device_code" }, interaction);
150
+ const body = asRecord(got.json);
151
+ // RFC 8628 errors use HTTP 400; they are polling states, never completed logins.
152
+ if (got.status === 400 && body?.["error"] === "authorization_pending") return { status: "pending" };
153
+ if (got.status === 400 && body?.["error"] === "slow_down") return { status: "slow_down" };
154
+ if (got.status < 200 || got.status >= 300) return { status: "failed", message: `Grok device login failed (HTTP ${got.status})` };
155
+ const fields = parseOAuthTokens(got.json);
156
+ if (!fields) return { status: "failed", message: "Grok token response was incomplete" };
157
+ interaction.signal.throwIfAborted();
158
+ return { status: "complete", value: { type: "oauth", access: fields.access, refresh: fields.refresh,
159
+ expires: expiryFromExpiresIn(fields.expiresIn, (interaction.now ?? Date.now)()) } };
160
+ },
161
+ });
162
+ }
163
+
164
+ async function refreshGrok(refresh: string, effects: RefreshEffects): Promise<{ ok: true; credential: OAuthCredential } | { ok: false; reason: string }> {
165
+ try {
166
+ const got = await grokPost(GROK_TOKEN_URL, { client_id: GROK_CLIENT_ID, grant_type: "refresh_token", refresh_token: refresh }, effects);
167
+ if (effects.signal?.aborted) return { ok: false, reason: "Login cancelled" };
168
+ if (got.status < 200 || got.status >= 300) return { ok: false, reason: `HTTP ${got.status}` };
169
+ const fields = parseOAuthTokens(got.json, refresh);
170
+ if (!fields) return { ok: false, reason: "unexpected body" };
171
+ return { ok: true, credential: { type: "oauth", access: fields.access, refresh: fields.refresh,
172
+ expires: expiryFromExpiresIn(fields.expiresIn, effects.now()) } };
173
+ } catch { return { ok: false, reason: "Grok token refresh failed" }; }
174
+ }
175
+
104
176
  export function isSubscriptionLogin(id: string): boolean {
105
- return SUBSCRIPTION_OPTIONS.some((o) => o.id === id);
177
+ return subscriptionById(id) !== undefined;
106
178
  }
107
179
 
108
180
  export function subscriptionById(id: string): (typeof SUBSCRIPTION_OPTIONS)[number] | undefined {
109
- return SUBSCRIPTION_OPTIONS.find((o) => o.id === id);
181
+ // Separate account providers accept their catalog identity too; anthropic still means an API key.
182
+ return SUBSCRIPTION_OPTIONS.find(o => o.id === id || (o.provider === id && o.provider !== "anthropic"));
110
183
  }
111
184
 
112
185
  export function expiryFromExpiresIn(expiresIn: number, now: number): number {
@@ -505,6 +578,8 @@ export async function refreshOAuthCredential(
505
578
  return refreshOpenAI(credential.refresh, opts);
506
579
  case "github-copilot":
507
580
  return refreshCopilot(credential.refresh, opts, credential.enterpriseUrl);
581
+ case GROK_PROVIDER:
582
+ return refreshGrok(credential.refresh, opts);
508
583
  default:
509
584
  return { ok: false, reason: `no oauth refresh for ${provider}` };
510
585
  }
@@ -752,6 +827,8 @@ export async function loginSubscription(
752
827
  return { provider: "openai-codex", credential: await loginOpenAICodex(interaction) };
753
828
  case "github-copilot":
754
829
  return { provider: "github-copilot", credential: await loginGitHubCopilot(interaction) };
830
+ case "grok":
831
+ return { provider: GROK_PROVIDER, credential: await loginGrok(interaction) };
755
832
  default: {
756
833
  const never: never = id;
757
834
  throw new Error(`unknown subscription login: ${String(never)}`);