@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.
- package/CHANGELOG.md +18 -0
- package/README.md +126 -34
- package/docs/app-server.md +84 -0
- package/docs/install.md +51 -13
- package/docs/providers.md +24 -3
- package/package.json +7 -5
- package/src/apps/app-server.ts +28 -0
- package/src/apps/approve.ts +3 -3
- package/src/apps/cli.ts +25 -10
- package/src/apps/commands.ts +45 -8
- package/src/apps/completions.ts +78 -22
- package/src/apps/control-transport.ts +89 -0
- package/src/apps/image-draft.ts +46 -0
- package/src/apps/model-catalog.ts +2 -1
- package/src/apps/project-trust.ts +2 -42
- package/src/apps/provider-connect.ts +346 -0
- package/src/apps/render.ts +22 -47
- package/src/apps/resources.ts +5 -2
- package/src/apps/shell.ts +333 -1542
- package/src/apps/startup-input.ts +45 -9
- package/src/apps/startup.ts +149 -94
- package/src/apps/ui/app.ts +1553 -0
- package/src/apps/ui/control-model.ts +26 -0
- package/src/apps/ui/extension-host.ts +194 -0
- package/src/apps/ui/image-cache.ts +78 -0
- package/src/apps/ui/palette.ts +88 -0
- package/src/apps/ui/project.ts +127 -0
- package/src/apps/ui/standalone.ts +146 -0
- package/src/apps/ui/status-model.ts +68 -0
- package/src/apps/ui/surface.ts +86 -0
- package/src/apps/ui/theme.ts +86 -0
- package/src/core/acceptance.ts +107 -0
- package/src/core/action-defaults.ts +47 -295
- package/src/core/benchmark.ts +2 -2
- package/src/core/branch-receipt.ts +24 -0
- package/src/core/checkpoints.ts +64 -0
- package/src/core/churn.ts +2 -0
- package/src/core/command-artifacts.ts +94 -0
- package/src/core/command-contract.ts +35 -0
- package/src/core/completion-action.ts +20 -0
- package/src/core/completion-review.ts +37 -21
- package/src/core/context-detail.ts +37 -0
- package/src/core/context-policy.ts +74 -1
- package/src/core/continuation.ts +17 -2
- package/src/core/control-protocol.ts +62 -0
- package/src/core/control-view.ts +42 -0
- package/src/core/derive.ts +29 -3
- package/src/core/draft-images.ts +56 -0
- package/src/core/environment.ts +145 -0
- package/src/core/evaluation-coverage.ts +74 -0
- package/src/core/event-validation.ts +81 -6
- package/src/core/events.ts +99 -3
- package/src/core/evidence-links.ts +7 -0
- package/src/core/evidence.ts +13 -3
- package/src/core/execution.ts +8 -2
- package/src/core/experiment.ts +133 -0
- package/src/core/export.ts +9 -0
- package/src/core/failure-recovery.ts +173 -0
- package/src/core/file-precondition.ts +72 -0
- package/src/core/handoff.ts +65 -4
- package/src/core/harness-diagnostics.ts +206 -0
- package/src/core/history-query.ts +38 -2
- package/src/core/history.ts +38 -1
- package/src/core/integration-health.ts +34 -0
- package/src/core/keymap.ts +8 -20
- package/src/core/model-picker.ts +64 -0
- package/src/core/native-eval.ts +25 -0
- package/src/core/note-guidance.ts +34 -0
- package/src/core/oauth-fields.ts +44 -0
- package/src/core/observation-view.ts +49 -8
- package/src/core/permissions.ts +24 -2
- package/src/core/process-receipt.ts +105 -0
- package/src/core/process-state.ts +37 -0
- package/src/core/program-status.ts +31 -0
- package/src/core/project.ts +70 -19
- package/src/core/prompt-policy.ts +37 -0
- package/src/core/query-eval-contract.ts +14 -0
- package/src/core/read-observation.ts +82 -0
- package/src/core/recovery-corpus.ts +17 -0
- package/src/core/recovery-evaluation.ts +60 -0
- package/src/core/recovery-sources.ts +74 -0
- package/src/core/request-progress.ts +87 -0
- package/src/core/request-state.ts +114 -0
- package/src/core/runtime-target.ts +94 -0
- package/src/core/search-observation.ts +97 -0
- package/src/core/session-tree.ts +147 -0
- package/src/core/settings.ts +33 -10
- package/src/core/skills.ts +44 -0
- package/src/core/sql-query.ts +211 -0
- package/src/core/theme.ts +36 -58
- package/src/core/tool-contract.ts +51 -2
- package/src/core/tool-discovery.ts +87 -0
- package/src/core/tool-failure.ts +60 -0
- package/src/core/tool-input.ts +6 -0
- package/src/core/tool-mode-protocol.ts +25 -0
- package/src/core/transcript.ts +19 -4
- package/src/core/trial-lifecycle.ts +14 -0
- package/src/core/tui-mode.ts +29 -0
- package/src/core/update-check.ts +97 -0
- package/src/core/version.ts +55 -0
- package/src/core/work-state.ts +49 -3
- package/src/core/working-set.ts +91 -0
- package/src/runtime/auth.ts +11 -4
- package/src/runtime/branch-jobs.ts +36 -0
- package/src/runtime/catalog.ts +13 -3
- package/src/runtime/clipboard.ts +15 -4
- package/src/runtime/command-artifacts.ts +64 -0
- package/src/runtime/control-connection.ts +104 -0
- package/src/runtime/environment-tools.ts +60 -0
- package/src/runtime/evidence-notes.ts +4 -2
- package/src/runtime/exec-sessions.ts +206 -61
- package/src/runtime/execution-control.ts +1 -0
- package/src/runtime/extension-ui.ts +22 -30
- package/src/runtime/extensions.ts +95 -16
- package/src/runtime/harness-comparison.ts +27 -7
- package/src/runtime/harness-eval.ts +462 -0
- package/src/runtime/history-cache.ts +57 -0
- package/src/runtime/history-tools.ts +62 -12
- package/src/runtime/integrations.ts +210 -0
- package/src/runtime/loop.ts +1088 -282
- package/src/runtime/model-discovery.ts +208 -0
- package/src/runtime/model.ts +9 -0
- package/src/runtime/native-eval-fixtures.ts +33 -0
- package/src/runtime/native-eval.ts +146 -0
- package/src/runtime/oauth.ts +80 -3
- package/src/runtime/pi-provider.ts +169 -12
- package/src/runtime/program-status.ts +38 -0
- package/src/runtime/provider-connect.ts +109 -0
- package/src/runtime/provider-presets.ts +87 -0
- package/src/runtime/provider-request.ts +13 -2
- package/src/runtime/provider.ts +23 -9
- package/src/runtime/query-eval-verifier.ts +28 -0
- package/src/runtime/recovery-coverage.ts +71 -0
- package/src/runtime/recovery-evidence.ts +76 -0
- package/src/runtime/recovery-fixtures.ts +116 -0
- package/src/runtime/runtime-target.ts +149 -0
- package/src/runtime/session-control.ts +198 -0
- package/src/runtime/tool-broker.ts +137 -0
- package/src/runtime/tool-discovery.ts +57 -0
- package/src/runtime/tool-mode-eval.ts +146 -0
- package/src/runtime/tool-queue.ts +77 -0
- package/src/runtime/tools.ts +628 -183
- package/src/runtime/trial-control.ts +45 -0
- package/src/runtime/update-check.ts +47 -0
- package/src/runtime/window-trial.ts +2 -1
- package/src/runtime/working-notes.ts +33 -3
- package/src/runtime/workspace-lifecycle.ts +97 -0
- package/src/store/acceptance.ts +15 -0
- package/src/store/checkpoint-history.ts +43 -0
- package/src/store/command-artifacts.ts +96 -0
- package/src/store/command.ts +65 -37
- package/src/store/file-checkpoints.ts +104 -0
- package/src/store/file-index.ts +13 -1
- package/src/store/git-checkpoints.ts +67 -0
- package/src/store/observations.ts +61 -20
- package/src/store/process-receipts.ts +34 -0
- package/src/store/query-source.ts +13 -8
- package/src/store/recovery.ts +2 -3
- package/src/store/replay.ts +16 -4
- package/src/store/runtime-target.ts +64 -0
- package/src/store/search.ts +171 -132
- package/src/store/session-archive.ts +4 -0
- package/src/store/source-cancellation.ts +6 -0
- package/src/store/update-cache.ts +36 -0
- package/src/store/workspace-observation.ts +113 -0
- package/src/tui/ansi-spans.ts +126 -0
- package/src/tui/style.ts +19 -16
- package/src/tui/ui/atomic-draft.ts +121 -0
- package/src/tui/ui/canvas.ts +96 -0
- package/src/tui/ui/colors.ts +70 -0
- package/src/tui/ui/composer.ts +200 -0
- package/src/tui/ui/control-panel.ts +23 -0
- package/src/tui/ui/dialog.ts +379 -0
- package/src/tui/ui/draft.ts +23 -0
- package/src/tui/ui/edge.ts +186 -0
- package/src/tui/ui/image-size.ts +40 -0
- package/src/tui/ui/ink.ts +210 -0
- package/src/tui/ui/items.ts +40 -0
- package/src/tui/ui/keys.ts +40 -0
- package/src/tui/ui/layout.ts +74 -0
- package/src/tui/ui/list-model.ts +86 -0
- package/src/tui/ui/list-view.ts +172 -0
- package/src/tui/ui/mascot.ts +67 -0
- package/src/tui/ui/messages.ts +518 -0
- package/src/tui/ui/motion.ts +28 -0
- package/src/tui/ui/paths.ts +10 -0
- package/src/tui/ui/pictures.ts +78 -0
- package/src/tui/ui/screen.ts +421 -0
- package/src/tui/ui/status.ts +279 -0
- package/src/tui/ui/styled.ts +85 -0
- package/src/tui/ui/testing.ts +5 -0
- package/src/tui/ui/tool-card.ts +150 -0
- package/src/apps/extension-ui.ts +0 -330
- package/src/apps/resource-browser.ts +0 -75
- package/src/apps/session-browser.ts +0 -113
- package/src/apps/session-picker.ts +0 -55
- package/src/tui/ansi.ts +0 -67
- package/src/tui/autocomplete.ts +0 -29
- package/src/tui/chat-viewport.ts +0 -47
- package/src/tui/complete.ts +0 -87
- package/src/tui/editor.ts +0 -940
- package/src/tui/host.ts +0 -264
- package/src/tui/input.ts +0 -75
- package/src/tui/keys.ts +0 -378
- package/src/tui/markdown.ts +0 -17
- package/src/tui/render.ts +0 -153
- package/src/tui/select.ts +0 -258
- package/src/tui/settings.ts +0 -44
- package/src/tui/statusline.ts +0 -162
- package/src/tui/system-theme.ts +0 -700
- package/src/tui/terminal.ts +0 -30
- package/src/tui/text.ts +0 -52
- package/src/tui/toolview.ts +0 -386
- package/src/tui/transcript.ts +0 -123
- package/src/tui/tree.ts +0 -471
- package/src/vendor/pi-tui/LICENSE.upstream +0 -21
- package/src/vendor/pi-tui/UPSTREAM.json +0 -50
- package/src/vendor/pi-tui/VENDOR.md +0 -25
- package/src/vendor/pi-tui/alt-screen-search.ts +0 -327
- package/src/vendor/pi-tui/autocomplete.ts +0 -860
- package/src/vendor/pi-tui/colors.ts +0 -367
- package/src/vendor/pi-tui/components/alt-screen-flash.ts +0 -51
- package/src/vendor/pi-tui/components/box.ts +0 -166
- package/src/vendor/pi-tui/components/cancellable-loader.ts +0 -40
- package/src/vendor/pi-tui/components/editor.ts +0 -2472
- package/src/vendor/pi-tui/components/h-stack.ts +0 -44
- package/src/vendor/pi-tui/components/image.ts +0 -127
- package/src/vendor/pi-tui/components/input.ts +0 -494
- package/src/vendor/pi-tui/components/loader.ts +0 -101
- package/src/vendor/pi-tui/components/markdown.ts +0 -1021
- package/src/vendor/pi-tui/components/mouse-region.ts +0 -33
- package/src/vendor/pi-tui/components/scroll-view.ts +0 -224
- package/src/vendor/pi-tui/components/select-list.ts +0 -273
- package/src/vendor/pi-tui/components/settings-list.ts +0 -328
- package/src/vendor/pi-tui/components/spacer.ts +0 -28
- package/src/vendor/pi-tui/components/stack.ts +0 -154
- package/src/vendor/pi-tui/components/text.ts +0 -107
- package/src/vendor/pi-tui/components/truncated-text.ts +0 -65
- package/src/vendor/pi-tui/components/v-stack.ts +0 -33
- package/src/vendor/pi-tui/editor-component.ts +0 -74
- package/src/vendor/pi-tui/fuzzy.ts +0 -138
- package/src/vendor/pi-tui/index.ts +0 -186
- package/src/vendor/pi-tui/keybindings.ts +0 -320
- package/src/vendor/pi-tui/keys.ts +0 -1401
- package/src/vendor/pi-tui/kill-ring.ts +0 -46
- package/src/vendor/pi-tui/latex.ts +0 -1506
- package/src/vendor/pi-tui/layout-node.ts +0 -51
- package/src/vendor/pi-tui/layout.ts +0 -449
- package/src/vendor/pi-tui/native/UPSTREAM.json +0 -23
- package/src/vendor/pi-tui/native/clipboard.h +0 -208
- package/src/vendor/pi-tui/native/darwin/README.md +0 -22
- package/src/vendor/pi-tui/native/darwin/build.sh +0 -69
- package/src/vendor/pi-tui/native/darwin/prebuilds/darwin-arm64/darwin-platform.node +0 -0
- package/src/vendor/pi-tui/native/darwin/prebuilds/darwin-x64/darwin-platform.node +0 -0
- package/src/vendor/pi-tui/native/darwin/src/darwin-platform.m +0 -132
- package/src/vendor/pi-tui/native/linux/README.md +0 -23
- package/src/vendor/pi-tui/native/linux/build.sh +0 -65
- package/src/vendor/pi-tui/native/linux/prebuilds/linux-arm64/linux-platform-x11.node +0 -0
- package/src/vendor/pi-tui/native/linux/prebuilds/linux-x64/linux-platform-x11.node +0 -0
- package/src/vendor/pi-tui/native/linux/src/clipboard-worker.h +0 -70
- package/src/vendor/pi-tui/native/linux/src/linux-platform-x11.c +0 -274
- package/src/vendor/pi-tui/native/napi.h +0 -91
- package/src/vendor/pi-tui/native/win32/README.md +0 -31
- package/src/vendor/pi-tui/native/win32/build.mjs +0 -232
- package/src/vendor/pi-tui/native/win32/prebuilds/win32-arm64/win32-platform.node +0 -0
- package/src/vendor/pi-tui/native/win32/prebuilds/win32-x64/win32-platform.node +0 -0
- package/src/vendor/pi-tui/native/win32/src/win32-platform.c +0 -241
- package/src/vendor/pi-tui/native-modifiers.ts +0 -13
- package/src/vendor/pi-tui/native-module-path.ts +0 -31
- package/src/vendor/pi-tui/native-platform.ts +0 -65
- package/src/vendor/pi-tui/oklab.ts +0 -233
- package/src/vendor/pi-tui/stdin-buffer.ts +0 -444
- package/src/vendor/pi-tui/terminal-colors.ts +0 -91
- package/src/vendor/pi-tui/terminal-image.ts +0 -731
- package/src/vendor/pi-tui/terminal.ts +0 -554
- package/src/vendor/pi-tui/tui-alt-screen.ts +0 -1750
- package/src/vendor/pi-tui/tui-main-screen.ts +0 -655
- package/src/vendor/pi-tui/tui.ts +0 -1493
- package/src/vendor/pi-tui/undo-stack.ts +0 -28
- package/src/vendor/pi-tui/utils.ts +0 -1385
- package/src/vendor/pi-tui/wheel-scroll.ts +0 -82
- 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
|
+
}
|
package/src/runtime/model.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/runtime/oauth.ts
CHANGED
|
@@ -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.
|
|
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
|
|
177
|
+
return subscriptionById(id) !== undefined;
|
|
106
178
|
}
|
|
107
179
|
|
|
108
180
|
export function subscriptionById(id: string): (typeof SUBSCRIPTION_OPTIONS)[number] | undefined {
|
|
109
|
-
|
|
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)}`);
|