@toddzheng024/dscode-bundle 0.7.13 → 0.7.14

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.
@@ -0,0 +1,129 @@
1
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ // dscode: the Grok model catalog. The subscription rail serves its models from the same
5
+ // CLI proxy the official `grok` client uses, and that listing carries what a harness needs:
6
+ // the context window, the reasoning detents (xhigh/high/medium/low, which are DSCODE effort
7
+ // ids already), the backend-search flag and the compaction threshold. One table, kept for a
8
+ // day in DSH_HOME, backs /model, compaction and the status line.
9
+
10
+ export const GROK_MODELS_URL = "https://cli-chat-proxy.grok.com/v1/models";
11
+ /** How long a first model lookup waits for the listing before answering without it. */
12
+ export const GROK_MODELS_TIMEOUT_MS = 10_000;
13
+ /** How long a failed listing (or an unusable cache file) waits before the next attempt. */
14
+ export const GROK_MODELS_RETRY_MS = 10 * 60 * 1000;
15
+ const MAX_AGE_MS = 24 * 60 * 60 * 1000;
16
+ const FILE = "grok-models.json";
17
+ const VERSION = 1;
18
+ /** Reasoning detents DSCODE names; the wire spells them the same way through this route. */
19
+ export const GROK_EFFORTS = Object.freeze(["low", "medium", "high", "xhigh"]);
20
+ /** Media models the chat route must not offer to an agent. */
21
+ const NON_CHAT = /(?:imagine|video|image|audio|vision-gen)/i;
22
+ let table = { fetchedAt: 0, models: {} };
23
+ let cacheReadAt = 0;
24
+ let attemptedAt = 0, pending;
25
+
26
+ const positive = value => Number.isSafeInteger(value) && value > 0 ? value : undefined;
27
+
28
+ /**
29
+ * Models from one `/models` body of the Grok CLI proxy, or of the public api.x.ai listing.
30
+ * @returns `{ [id]: { name, description?, contextWindow?, maxOutput?, efforts?, defaultEffort?,
31
+ * backendSearch?, compactThresholdPercent?, compactionAtTokens? } }`
32
+ */
33
+ export function parseGrokModels(body) {
34
+ const models = {};
35
+ for (const model of Array.isArray(body?.data) ? body.data : []) {
36
+ const id = typeof model?.id === "string" ? model.id : typeof model?.model === "string" ? model.model : undefined;
37
+ if (id === undefined || id.length === 0 || NON_CHAT.test(id)) continue;
38
+ const efforts = (Array.isArray(model.reasoning_efforts) ? model.reasoning_efforts : [])
39
+ .map(entry => typeof entry === "string" ? entry : entry?.id)
40
+ .filter(level => GROK_EFFORTS.includes(level));
41
+ const named = efforts.length > 0 ? GROK_EFFORTS.filter(level => efforts.includes(level)) : undefined;
42
+ const defaultEffort = typeof model.reasoning_effort === "string" && GROK_EFFORTS.includes(model.reasoning_effort) ? model.reasoning_effort : undefined;
43
+ const contextWindow = positive(model.context_window) ?? positive(model.context_length);
44
+ models[id] = {
45
+ name: typeof model.name === "string" && model.name.length > 0 ? model.name : id,
46
+ ...typeof model.description === "string" && model.description.length > 0 ? { description: model.description } : {},
47
+ ...contextWindow === undefined ? {} : { contextWindow },
48
+ ...positive(model.max_completion_tokens) === undefined ? {} : { maxOutput: positive(model.max_completion_tokens) },
49
+ ...named === undefined ? {} : { efforts: named }, ...defaultEffort === undefined ? {} : { defaultEffort },
50
+ ...model.supports_backend_search === true ? { backendSearch: true } : {},
51
+ ...positive(model.auto_compact_threshold_percent) === undefined ? {} : { compactThresholdPercent: positive(model.auto_compact_threshold_percent) },
52
+ ...model.compaction_at_tokens === true ? { compactionAtTokens: true } : {},
53
+ };
54
+ }
55
+ return models;
56
+ }
57
+
58
+ /** One model entry, or undefined when the table does not list it. */
59
+ export function grokModel(id) {
60
+ return Object.hasOwn(table.models, id) ? table.models[id] : undefined;
61
+ }
62
+
63
+ /** Every listed model as `[id, entry]` pairs, in listing order. */
64
+ export function listGrokModels() {
65
+ return Object.entries(table.models);
66
+ }
67
+
68
+ /** Replace the table (and forget the last attempt); for tests and cache loads. */
69
+ export function setGrokModels(models, fetchedAt = Date.now()) {
70
+ table = { fetchedAt, models };
71
+ cacheReadAt = 0;
72
+ attemptedAt = 0;
73
+ }
74
+
75
+ /** The headers the official CLI sends; the proxy answers a request without them with its generic API rail. */
76
+ export function grokCliHeaders(token, version) {
77
+ return { authorization: "Bearer " + token, accept: "application/json", "x-xai-token-auth": "xai-grok-cli", "x-grok-client-version": version };
78
+ }
79
+
80
+ /**
81
+ * Load the cached table, then refetch it once it is a day old. A failure keeps the last
82
+ * table and waits ten minutes before trying again; never throws.
83
+ */
84
+ export async function refreshGrokModels({ home, token, version = "1.0.34", fetch: fetchImpl = globalThis.fetch, now = Date.now() } = {}) {
85
+ const path = home ? join(home, FILE) : undefined;
86
+ if (table.fetchedAt === 0 && path && now - cacheReadAt >= GROK_MODELS_RETRY_MS) {
87
+ cacheReadAt = now;
88
+ try {
89
+ const cached = JSON.parse(readFileSync(path, "utf8"));
90
+ if (cached?.version === VERSION && Number.isFinite(cached.fetchedAt) && cached.models && typeof cached.models === "object") table = { fetchedAt: cached.fetchedAt, models: cached.models };
91
+ } catch { /* no usable cache */ }
92
+ }
93
+ if (now - table.fetchedAt < MAX_AGE_MS || now - attemptedAt < GROK_MODELS_RETRY_MS || token === undefined || typeof fetchImpl !== "function") return table;
94
+ if (pending) return pending;
95
+ attemptedAt = now;
96
+ pending = (async () => {
97
+ try {
98
+ const response = await fetchImpl(GROK_MODELS_URL, { headers: grokCliHeaders(token, version) });
99
+ if (!response.ok) return table;
100
+ const models = parseGrokModels(await response.json());
101
+ if (Object.keys(models).length === 0) return table;
102
+ table = { fetchedAt: now, models };
103
+ if (path) {
104
+ mkdirSync(home, { recursive: true });
105
+ writeFileSync(path + ".tmp", JSON.stringify({ version: VERSION, ...table }));
106
+ renameSync(path + ".tmp", path);
107
+ }
108
+ return table;
109
+ } catch {
110
+ return table;
111
+ } finally {
112
+ pending = undefined;
113
+ }
114
+ })();
115
+ return pending;
116
+ }
117
+
118
+ /**
119
+ * The table before a model lookup: an empty one waits up to `timeoutMs` for the listing,
120
+ * a stale one refreshes in the background.
121
+ */
122
+ export async function ensureGrokModels({ timeoutMs = GROK_MODELS_TIMEOUT_MS, ...options } = {}) {
123
+ const refresh = refreshGrokModels(options);
124
+ if (Object.keys(table.models).length > 0) return table;
125
+ let timer;
126
+ await Promise.race([refresh, new Promise(resolve => { timer = setTimeout(resolve, timeoutMs); timer.unref?.(); })]);
127
+ clearTimeout(timer);
128
+ return table;
129
+ }
@@ -0,0 +1,28 @@
1
+ import { grokAuthState, minutesLeft } from "./auth.mjs";
2
+ import { grokSubscriptionNow, readGrokSubscription } from "./billing.mjs";
3
+
4
+ // dscode: one snapshot for the two surfaces that show the Grok rail: the /login grok panel and
5
+ // the provider list. The live process value wins; a state file written by another process (a
6
+ // second DSCODE on the same Mac) is the fallback, so a panel opened before the first refresh
7
+ // still shows the last known window.
8
+
9
+ /**
10
+ * @returns `{ status: { kind, expiresIn? }, subscription? }`; `kind` is the local login state
11
+ * (`ready`, `expired`, `missing`, `malformed`) and `expiresIn` is minutes on a ready login.
12
+ */
13
+ export function grokStatusSnapshot({ home = process.env.DSH_HOME, now = Date.now() } = {}) {
14
+ const auth = grokAuthState({ now });
15
+ const stored = grokSubscriptionNow() ?? (home === undefined ? undefined : readGrokSubscription(home));
16
+ return {
17
+ status: { kind: auth.kind, ...auth.kind === "ready" ? { expiresIn: minutesLeft(auth.credential, now) } : {} },
18
+ ...stored === undefined ? {} : { subscription: stored },
19
+ };
20
+ }
21
+
22
+ /** One line for the provider list: the login state, not a key name. */
23
+ export function grokStatusText(snapshot = grokStatusSnapshot()) {
24
+ if (snapshot.status.kind === "ready") return "grok login detected";
25
+ if (snapshot.status.kind === "expired") return "grok login expired · run grok login";
26
+ if (snapshot.status.kind === "malformed") return "grok login file unreadable · run grok login";
27
+ return "no grok login · run grok login";
28
+ }
@@ -0,0 +1,268 @@
1
+ import { CONTEXT_WINDOW_EXCEEDED_CODE, EMPTY_RESPONSE_CODE, LlmError, QUOTA_EXCEEDED_CODE, isContextWindowExceededError, isQuotaExceededError, textOnlyImageText } from "@deepseek-ai/dsh-llm";
2
+ import { ultraRequest } from "../ultra/policy.mjs";
3
+ import { GROK_EFFORTS } from "./models.mjs";
4
+
5
+ // dscode: xAI chat completions on the wire. The subscription route speaks plain OpenAI
6
+ // chat completions — structured `tool_calls`, `reasoning_content` deltas and a usage block
7
+ // that reports cache reads and reasoning tokens — so this module is the small, pure half of
8
+ // the adapter: request bodies, SSE payloads and the translation into harness stream chunks.
9
+
10
+ export const PROVIDER = "grok";
11
+ /** `replayState.response.kind` for responses this adapter produced. */
12
+ export const REPLAY_KIND = "dscode-grok";
13
+ /** DeepSeek-style DSCODE detents, spelled the way xAI accepts them. */
14
+ const WIRE_EFFORT = Object.freeze({ off: "none", minimal: "low", low: "low", medium: "medium", high: "high", xhigh: "xhigh", max: "xhigh", ultra: "xhigh" });
15
+ const NAMES = { off: "Off", minimal: "Minimal", low: "Low", medium: "Medium", high: "High", xhigh: "XHigh", max: "Max", ultra: "Ultra" };
16
+ export const ULTRA_DESCRIPTION = "DSCODE: top reasoning plus deliberate subagent collaboration; higher total token use.";
17
+
18
+ /** Display metadata for one harness effort id. */
19
+ export function effortInfo(id) {
20
+ return { id, name: NAMES[id] ?? id, ...id === "ultra" ? { description: ULTRA_DESCRIPTION } : {} };
21
+ }
22
+
23
+ /** The wire spelling for a harness effort, or undefined when the route cannot express it. */
24
+ export function wireEffort(id) {
25
+ return WIRE_EFFORT[id];
26
+ }
27
+
28
+ /** A title needs no deliberation: the lowest detent the model offers. */
29
+ function titleEffort(entry) {
30
+ const levels = entry?.efforts ?? GROK_EFFORTS;
31
+ return GROK_EFFORTS.find(level => levels.includes(level)) ?? levels[0];
32
+ }
33
+
34
+ const textOf = blocks => blocks.filter(block => block.type === "text").map(block => block.text).join("");
35
+
36
+ /** Image blocks this route cannot send as images degrade to their text handle. */
37
+ function contentText(blocks) {
38
+ const parts = [];
39
+ for (const block of blocks) {
40
+ if (block.type === "text" && block.text.length > 0) parts.push(block.text);
41
+ else if (block.type === "image") parts.push(textOnlyImageText(block.attachment));
42
+ }
43
+ return parts.join("\n");
44
+ }
45
+
46
+ function serializeAssistant(message) {
47
+ const text = textOf(message.content);
48
+ const calls = message.content.filter(block => block.type === "tool-call")
49
+ .map(block => ({ id: block.id, type: "function", function: { name: block.name, arguments: block.arguments } }));
50
+ if (text.length === 0 && calls.length === 0) return undefined;
51
+ return { role: "assistant", content: text, ...calls.length > 0 ? { tool_calls: calls } : {} };
52
+ }
53
+
54
+ /**
55
+ * Harness history as chat messages: tool results become `tool` messages, everything else
56
+ * keeps its role. Reasoning is deliberately not replayed — xAI takes the assistant text and
57
+ * its tool calls, and replayed thinking buys nothing here.
58
+ */
59
+ export function serializeMessages(messages, { system } = {}) {
60
+ const wire = [];
61
+ if (typeof system === "string" && system.length > 0) wire.push({ role: "system", content: system });
62
+ for (const message of messages) {
63
+ if (message.role === "system") {
64
+ const text = textOf(message.content);
65
+ if (text.length > 0) wire.push({ role: "system", content: text });
66
+ continue;
67
+ }
68
+ if (message.role === "assistant") {
69
+ const entry = serializeAssistant(message);
70
+ if (entry !== undefined) wire.push(entry);
71
+ continue;
72
+ }
73
+ const results = message.content.filter(block => block.type === "tool-result");
74
+ const parts = contentText(message.content.filter(block => block.type !== "tool-result"));
75
+ if (parts.length > 0 || results.length === 0) wire.push({ role: "user", content: parts });
76
+ for (const result of results) wire.push({ role: "tool", tool_call_id: result.toolCallId, content: contentText(result.content) || "(no output)" });
77
+ }
78
+ return wire;
79
+ }
80
+
81
+ /**
82
+ * The chat-completions body for one harness request.
83
+ * @param options - harness request; @param context - `{ entry }`, the model entry from the catalog.
84
+ */
85
+ export function requestBody(options, { entry } = {}) {
86
+ const effort = options.purpose === "session-title" ? titleEffort(entry) : options.reasoningEffort;
87
+ const spell = effort === undefined ? undefined : wireEffort(effort);
88
+ if (effort !== undefined && spell === undefined) throw new LlmError(`Grok does not offer reasoning effort "${effort}"`, "UNSUPPORTED_REASONING_EFFORT");
89
+ const messages = ultraRequest(options, serializeMessages(options.messages, { system: options.system }));
90
+ const tools = (options.tools ?? []).filter(tool => tool.name !== "workflow" && tool.name !== "ralph")
91
+ .map(tool => ({ type: "function", function: { name: tool.name, description: tool.description, parameters: tool.parameters } }));
92
+ return {
93
+ model: options.model,
94
+ messages,
95
+ stream: true,
96
+ // The usage block of a streamed response arrives on its own final chunk only when asked.
97
+ stream_options: { include_usage: true },
98
+ ...tools.length > 0 ? { tools } : {},
99
+ ...spell === undefined ? {} : { reasoning_effort: spell },
100
+ ...options.maxTokens === undefined ? {} : { max_tokens: options.maxTokens },
101
+ ...options.temperature === undefined ? {} : { temperature: options.temperature },
102
+ ...options.stop === undefined ? {} : { stop: options.stop },
103
+ };
104
+ }
105
+
106
+ /**
107
+ * Harness failure code for an xAI error.
108
+ * @param status - HTTP status of a rejected request; undefined for an error inside a 200 stream.
109
+ * @param error - the provider `error` object (`message`, optional `code`).
110
+ */
111
+ export function errorCode(status, error) {
112
+ const code = Number.isInteger(status) ? status : Number(error?.code);
113
+ const detail = typeof error?.message === "string" ? error.message : "";
114
+ if (isContextWindowExceededError(detail)) return CONTEXT_WINDOW_EXCEEDED_CODE;
115
+ if (isQuotaExceededError(detail)) return QUOTA_EXCEEDED_CODE;
116
+ if (code === 401 || code === 403) return "AUTH";
117
+ if (code === 402) return QUOTA_EXCEEDED_CODE;
118
+ if (code === 429) return "RATE_LIMIT";
119
+ if (code === 408 || code === 504) return "TIMEOUT";
120
+ if (code >= 500) return "SERVER";
121
+ if (code >= 400) return "INVALID_REQUEST";
122
+ return "SERVER";
123
+ }
124
+
125
+ /** Human-readable message for an xAI error object. */
126
+ export function errorMessage(error, fallback) {
127
+ return typeof error?.message === "string" && error.message.length > 0 ? error.message : fallback;
128
+ }
129
+
130
+ /** Retry-After as milliseconds (seconds or an HTTP date), when valid. */
131
+ export function retryAfterMs(value) {
132
+ if (value === null || value === undefined) return undefined;
133
+ const delay = /^\d+$/.test(value) ? Number(value) * 1e3 : Date.parse(value) - Date.now();
134
+ return Number.isFinite(delay) && delay > 0 ? delay : undefined;
135
+ }
136
+
137
+ /** SSE `data:` payloads from a response body, skipping `:` keep-alive comments. */
138
+ export async function* sseData(body, onActivity) {
139
+ const decoder = new TextDecoder();
140
+ let buffer = "", data = [];
141
+ const lines = function* (final) {
142
+ let newline;
143
+ while ((newline = buffer.indexOf("\n")) >= 0 || final && buffer.length > 0) {
144
+ let line = newline >= 0 ? buffer.slice(0, newline) : buffer;
145
+ buffer = newline >= 0 ? buffer.slice(newline + 1) : "";
146
+ if (line.endsWith("\r")) line = line.slice(0, -1);
147
+ if (line === "") {
148
+ const payload = data.join("\n");
149
+ if (payload.trim() !== "") yield payload;
150
+ data = [];
151
+ } else if (line.startsWith("data:")) data.push(line.slice(line.startsWith("data: ") ? 6 : 5));
152
+ }
153
+ };
154
+ for await (const chunk of body) {
155
+ onActivity?.();
156
+ buffer += decoder.decode(chunk, { stream: true });
157
+ yield* lines(false);
158
+ }
159
+ buffer += decoder.decode();
160
+ yield* lines(true);
161
+ const tail = data.join("\n");
162
+ if (tail.trim() !== "") yield tail;
163
+ }
164
+
165
+ /** Map xAI usage to disjoint harness counts (`prompt_tokens` includes the cache reads). */
166
+ export function mapUsage(usage) {
167
+ const valid = value => Number.isSafeInteger(value) && value >= 0;
168
+ const prompt = usage?.prompt_tokens, completion = usage?.completion_tokens;
169
+ if (!valid(prompt) || !valid(completion)) return undefined;
170
+ const read = valid(usage.prompt_tokens_details?.cached_tokens) ? usage.prompt_tokens_details.cached_tokens : 0;
171
+ const reasoning = usage.completion_tokens_details?.reasoning_tokens;
172
+ return {
173
+ inputTokens: Math.max(0, prompt - read),
174
+ outputTokens: completion,
175
+ totalTokens: prompt + completion,
176
+ ...read > 0 ? { cacheReadTokens: read } : {},
177
+ ...valid(reasoning) && reasoning > 0 ? { reasoningTokens: reasoning } : {},
178
+ };
179
+ }
180
+
181
+ function closeBlock(block) {
182
+ if (block.kind === "tool-call") return { type: "tool-call", id: block.callId ?? "", name: block.name ?? "", arguments: block.text };
183
+ return { type: block.kind, text: block.text };
184
+ }
185
+
186
+ function finishReason(reason, blocks) {
187
+ if (reason === "length") return { kind: "max-tokens" };
188
+ if (reason === "content_filter") return { kind: "error", failure: { message: "xAI stopped the response for content filtering", code: "CONTENT_FILTER" } };
189
+ if (reason === "tool_calls" || blocks.some(block => block.kind === "tool-call") && (reason === undefined || reason === "stop")) return { kind: "tool-calls" };
190
+ if (reason === undefined || reason === null || reason === "stop") return blocks.length === 0 ? { kind: "error", failure: { message: "model returned a completed response with no content", code: EMPTY_RESPONSE_CODE } } : { kind: "stop" };
191
+ return { kind: "error", failure: { message: `model stopped: ${reason}`, code: String(reason).toUpperCase() } };
192
+ }
193
+
194
+ /**
195
+ * Translate SSE payloads into harness chunks. Block ends, usage and the finish are held
196
+ * until `[DONE]`; an error inside the stream throws with its routed code.
197
+ */
198
+ export async function* translate(payloads, { model }) {
199
+ let nextIndex = 0, textBlock, reasoningBlock, finish, usage, id;
200
+ const tools = new Map();
201
+ const order = [];
202
+ const open = kind => {
203
+ const block = { index: nextIndex++, kind, text: "" };
204
+ order.push(block);
205
+ return block;
206
+ };
207
+ for await (const payload of payloads) {
208
+ if (payload === "[DONE]") {
209
+ for (const block of order) yield { type: "block-end", index: block.index, block: closeBlock(block) };
210
+ if (usage) yield { type: "usage", usage };
211
+ const reason = finishReason(finish, order);
212
+ const succeeded = reason.kind === "stop" || reason.kind === "tool-calls" || reason.kind === "max-tokens";
213
+ yield {
214
+ type: "finish", reason,
215
+ ...succeeded ? { replayState: { response: { kind: REPLAY_KIND, version: 1, model, ...id === undefined ? {} : { id } }, blocks: order.map(block => ({ type: block.kind })) } } : {},
216
+ };
217
+ return;
218
+ }
219
+ let chunk;
220
+ try {
221
+ chunk = JSON.parse(payload);
222
+ } catch {
223
+ throw new LlmError(`malformed xAI stream payload: ${payload.slice(0, 120)}`, "MALFORMED_RESPONSE");
224
+ }
225
+ if (chunk?.error) {
226
+ const status = Number.isInteger(chunk.error.code) ? chunk.error.code : undefined;
227
+ throw new LlmError(errorMessage(chunk.error, "xAI stream error"), errorCode(undefined, chunk.error), status === undefined ? {} : { status });
228
+ }
229
+ if (typeof chunk?.id === "string") id ??= chunk.id;
230
+ for (const choice of chunk?.choices ?? []) {
231
+ const delta = choice.delta ?? {};
232
+ const reasoning = typeof delta.reasoning_content === "string" ? delta.reasoning_content : typeof delta.reasoning === "string" ? delta.reasoning : "";
233
+ if (reasoning.length > 0) {
234
+ if (!reasoningBlock) {
235
+ reasoningBlock = open("reasoning");
236
+ yield { type: "block-start", index: reasoningBlock.index, blockType: "reasoning" };
237
+ }
238
+ reasoningBlock.text += reasoning;
239
+ yield { type: "reasoning-delta", index: reasoningBlock.index, text: reasoning };
240
+ }
241
+ if (typeof delta.content === "string" && delta.content.length > 0) {
242
+ if (!textBlock) {
243
+ textBlock = open("text");
244
+ yield { type: "block-start", index: textBlock.index, blockType: "text" };
245
+ }
246
+ textBlock.text += delta.content;
247
+ yield { type: "text-delta", index: textBlock.index, text: delta.content };
248
+ }
249
+ for (const call of Array.isArray(delta.tool_calls) ? delta.tool_calls : []) {
250
+ const key = call.index ?? call.id;
251
+ let block = tools.get(key);
252
+ if (!block) {
253
+ block = open("tool-call");
254
+ tools.set(key, block);
255
+ yield { type: "block-start", index: block.index, blockType: "tool-call" };
256
+ }
257
+ if (typeof call.id === "string" && call.id.length > 0) block.callId = call.id;
258
+ if (typeof call.function?.name === "string" && call.function.name.length > 0) block.name = call.function.name;
259
+ const fragment = typeof call.function?.arguments === "string" ? call.function.arguments : "";
260
+ block.text += fragment;
261
+ yield { type: "tool-call-delta", index: block.index, id: block.callId ?? "", ...block.name === undefined ? {} : { name: block.name }, argumentsDelta: fragment };
262
+ }
263
+ if (typeof choice.finish_reason === "string") finish = choice.finish_reason;
264
+ }
265
+ if (chunk?.usage) usage = mapUsage(chunk.usage) ?? usage;
266
+ }
267
+ throw new LlmError("xAI stream ended without [DONE]", "TRANSPORT");
268
+ }
@@ -40,7 +40,7 @@ export const MESSAGES = {
40
40
  'language.set': 'language → {name}', 'language.title': '/language — interface language', 'language.currentMark': 'current', 'language.unknown': 'Unknown language "{value}". Choose en, zh-CN, zh-TW, ja, ko or es.',
41
41
  'language.saveFailed': 'language save failed: {error}',
42
42
  'doctor.logs.new': 'Only warnings/errors since this TUI version started are recorded.',
43
- 'footer.current': 'current', 'footer.average': 'average', 'footer.context': 'context', 'footer.cache': 'cache hit', 'composer.placeholder': 'type a message', 'composer.shellMode': '! shell mode · esc exits',
43
+ 'footer.current': 'current', 'footer.average': 'average', 'footer.context': 'context', 'footer.cache': 'cache hit', 'footer.grokUsed': 'used', 'footer.grokResets': 'resets', 'composer.placeholder': 'type a message', 'composer.shellMode': '! shell mode · esc exits',
44
44
  'compaction.running': 'Compacting context, please wait', 'compaction.confirm.title': 'Switching to {model} will compact the conversation',
45
45
  'compaction.confirm.usage': 'context {used} ≥ compaction threshold {threshold} ({window} × {ratio})', 'compaction.confirm.next': 'the next step compacts older history first',
46
46
  'compaction.confirm.overflow': 'it already exceeds the {window} window: the next request compacts and retries', 'compaction.confirm.hint': 'y switch · n/esc back',
@@ -68,7 +68,7 @@ export const MESSAGES = {
68
68
  'language.set': '语言 → {name}', 'language.title': '/language — 界面语言', 'language.currentMark': '当前', 'language.unknown': '未知语言 "{value}"。可选 en、zh-CN、zh-TW、ja、ko、es。',
69
69
  'language.saveFailed': '语言设置保存失败:{error}',
70
70
  'doctor.logs.new': '仅记录新版 TUI 启动后的 warning/error。',
71
- 'footer.current': '当前', 'footer.average': '平均', 'footer.context': '上下文', 'footer.cache': '缓存命中', 'composer.placeholder': '输入消息', 'composer.shellMode': '! shell 模式 · esc 退出',
71
+ 'footer.current': '当前', 'footer.average': '平均', 'footer.context': '上下文', 'footer.cache': '缓存命中', 'footer.grokUsed': '已用', 'footer.grokResets': '重置', 'composer.placeholder': '输入消息', 'composer.shellMode': '! shell 模式 · esc 退出',
72
72
  'compaction.running': '正在压缩上下文,请稍后', 'compaction.confirm.title': '切换到 {model} 会触发自动压缩',
73
73
  'compaction.confirm.usage': '当前上下文 {used} ≥ 压缩阈值 {threshold}({window} × {ratio})', 'compaction.confirm.next': '下一步开始前会先压缩较早的对话',
74
74
  'compaction.confirm.overflow': '已超出新模型的 {window} 上下文窗口:下一次请求会先压缩再重试', 'compaction.confirm.hint': 'y 确认切换 · n/esc 返回',
@@ -96,7 +96,7 @@ export const MESSAGES = {
96
96
  'language.set': '語言 → {name}', 'language.title': '/language — 介面語言', 'language.currentMark': '目前', 'language.unknown': '未知語言 "{value}"。可選 en、zh-CN、zh-TW、ja、ko、es。',
97
97
  'language.saveFailed': '語言設定儲存失敗:{error}',
98
98
  'doctor.logs.new': '僅記錄新版 TUI 啟動後的 warning/error。',
99
- 'footer.current': '目前', 'footer.average': '平均', 'footer.context': '上下文', 'footer.cache': '快取命中', 'composer.placeholder': '輸入訊息', 'composer.shellMode': '! shell 模式 · esc 離開',
99
+ 'footer.current': '目前', 'footer.average': '平均', 'footer.context': '上下文', 'footer.cache': '快取命中', 'footer.grokUsed': '已用', 'footer.grokResets': '重置', 'composer.placeholder': '輸入訊息', 'composer.shellMode': '! shell 模式 · esc 離開',
100
100
  'compaction.running': '正在壓縮上下文,請稍候', 'compaction.confirm.title': '切換到 {model} 會觸發自動壓縮',
101
101
  'compaction.confirm.usage': '目前上下文 {used} ≥ 壓縮閾值 {threshold}({window} × {ratio})', 'compaction.confirm.next': '下一步開始前會先壓縮較早的對話',
102
102
  'compaction.confirm.overflow': '已超出新模型的 {window} 上下文視窗:下一次請求會先壓縮再重試', 'compaction.confirm.hint': 'y 確認切換 · n/esc 返回',
@@ -124,7 +124,7 @@ export const MESSAGES = {
124
124
  'language.set': '言語 → {name}', 'language.title': '/language — 表示言語', 'language.currentMark': '現在', 'language.unknown': '不明な言語 "{value}"。en、zh-CN、zh-TW、ja、ko、es から選んでください。',
125
125
  'language.saveFailed': '言語設定の保存に失敗しました:{error}',
126
126
  'doctor.logs.new': 'この TUI バージョンの起動以降の warning/error のみ記録されています。',
127
- 'footer.current': '現在', 'footer.average': '平均', 'footer.context': 'コンテキスト', 'footer.cache': 'キャッシュヒット', 'composer.placeholder': 'メッセージを入力', 'composer.shellMode': '! shell モード · esc で終了',
127
+ 'footer.current': '現在', 'footer.average': '平均', 'footer.context': 'コンテキスト', 'footer.cache': 'キャッシュヒット', 'footer.grokUsed': '使用', 'footer.grokResets': 'リセット', 'composer.placeholder': 'メッセージを入力', 'composer.shellMode': '! shell モード · esc で終了',
128
128
  'compaction.running': 'コンテキストを圧縮しています。しばらくお待ちください', 'compaction.confirm.title': '{model} に切り替えると自動圧縮が実行されます',
129
129
  'compaction.confirm.usage': '現在のコンテキスト {used} ≥ 圧縮しきい値 {threshold}({window} × {ratio})', 'compaction.confirm.next': '次のステップの前に古い履歴を圧縮します',
130
130
  'compaction.confirm.overflow': '新しいモデルの {window} ウィンドウを超えています。次のリクエストで圧縮して再試行します', 'compaction.confirm.hint': 'y 切り替え · n/esc 戻る',
@@ -152,7 +152,7 @@ export const MESSAGES = {
152
152
  'language.set': '언어 → {name}', 'language.title': '/language — 인터페이스 언어', 'language.currentMark': '현재', 'language.unknown': '알 수 없는 언어 "{value}". en, zh-CN, zh-TW, ja, ko, es 중에서 선택하세요.',
153
153
  'language.saveFailed': '언어 설정 저장 실패: {error}',
154
154
  'doctor.logs.new': '이 TUI 버전 시작 이후의 warning/error만 기록됩니다.',
155
- 'footer.current': '현재', 'footer.average': '평균', 'footer.context': '컨텍스트', 'footer.cache': '캐시 적중', 'composer.placeholder': '메시지를 입력하세요', 'composer.shellMode': '! shell 모드 · esc 종료',
155
+ 'footer.current': '현재', 'footer.average': '평균', 'footer.context': '컨텍스트', 'footer.cache': '캐시 적중', 'footer.grokUsed': '사용', 'footer.grokResets': '재설정', 'composer.placeholder': '메시지를 입력하세요', 'composer.shellMode': '! shell 모드 · esc 종료',
156
156
  'compaction.running': '컨텍스트를 압축하는 중입니다. 잠시만 기다려 주세요', 'compaction.confirm.title': '{model}(으)로 전환하면 자동 압축이 실행됩니다',
157
157
  'compaction.confirm.usage': '현재 컨텍스트 {used} ≥ 압축 임계값 {threshold} ({window} × {ratio})', 'compaction.confirm.next': '다음 단계 전에 이전 대화를 먼저 압축합니다',
158
158
  'compaction.confirm.overflow': '새 모델의 {window} 창을 이미 넘었습니다. 다음 요청에서 압축한 뒤 재시도합니다', 'compaction.confirm.hint': 'y 전환 · n/esc 뒤로',
@@ -180,7 +180,7 @@ export const MESSAGES = {
180
180
  'language.set': 'idioma → {name}', 'language.title': '/language — idioma de la interfaz', 'language.currentMark': 'actual', 'language.unknown': 'Idioma desconocido "{value}". Elige en, zh-CN, zh-TW, ja, ko o es.',
181
181
  'language.saveFailed': 'no se pudo guardar el idioma: {error}',
182
182
  'doctor.logs.new': 'Solo se registran los warnings/errores desde que arrancó esta versión del TUI.',
183
- 'footer.current': 'actual', 'footer.average': 'promedio', 'footer.context': 'contexto', 'footer.cache': 'éxitos de caché', 'composer.placeholder': 'escribe un mensaje', 'composer.shellMode': '! modo shell · esc sale',
183
+ 'footer.current': 'actual', 'footer.average': 'promedio', 'footer.context': 'contexto', 'footer.cache': 'éxitos de caché', 'footer.grokUsed': 'usado', 'footer.grokResets': 'reinicia', 'composer.placeholder': 'escribe un mensaje', 'composer.shellMode': '! modo shell · esc sale',
184
184
  'compaction.running': 'Compactando el contexto, espera un momento', 'compaction.confirm.title': 'Cambiar a {model} compactará la conversación',
185
185
  'compaction.confirm.usage': 'contexto {used} ≥ umbral de compactación {threshold} ({window} × {ratio})', 'compaction.confirm.next': 'el siguiente paso compacta antes el historial antiguo',
186
186
  'compaction.confirm.overflow': 'ya supera la ventana de {window}: la siguiente petición compacta y reintenta', 'compaction.confirm.hint': 'y cambiar · n/esc volver',
@@ -7,6 +7,8 @@ export const PROVIDERS = Object.freeze([
7
7
  { id: 'deepseek-official', name: 'DeepSeek', aliases: ['deepseek', 'deepseek-official', 'official'], credentialRef: 'DEEPSEEK_API_KEY', defaultModel: 'deepseek-flash' },
8
8
  // The optional management key reads account data only; it cannot call models.
9
9
  { id: 'openrouter', name: 'OpenRouter', aliases: ['openrouter', 'open-router'], credentialRef: 'OPENROUTER_API_KEY', managementRef: 'OPENROUTER_MANAGEMENT_KEY', defaultModel: 'deepseek/deepseek-v4-flash' },
10
+ // The Grok subscription rail: the token is the local 'grok login', read-only (plugins/grok).
11
+ { id: 'grok', name: 'Grok', aliases: ['grok', 'xai', 'x-ai'], credentialRef: 'GROK_CLI_TOKEN', defaultModel: 'grok-4.6' },
10
12
  ]);
11
13
 
12
14
  // The pi-ai adapter served OpenRouter until 0.7.6, from this settings section.
@@ -2,6 +2,7 @@ import { readMetrics } from './store.mjs';
2
2
  import { t } from '../i18n/messages.mjs';
3
3
  import { estimateCost, peakEmoji } from './pricing.mjs';
4
4
  import { balanceNow, trustedNow } from './balance.mjs';
5
+ import { grokSubscriptionNow } from '../grok/billing.mjs';
5
6
  import { sessionAverageTps } from './rate.mjs';
6
7
  import { providerOfHeader } from '../providers/catalog.mjs';
7
8
  let source;
@@ -58,6 +59,28 @@ export function displayWidth(text) {
58
59
  for (const char of text) width += ZERO_WIDTH.test(char) ? 0 : WIDE.test(char) ? 2 : 1;
59
60
  return width;
60
61
  }
62
+ /**
63
+ * The money slot for the Grok subscription rail: a plan has credits and a reset time, not a
64
+ * bill. The percentage is optional (the server omits it for a period without usage), and an
65
+ * unread window shows the tier alone instead of a wrong number.
66
+ */
67
+ export function grokFooterFact(subscription, locale = 'en', now = Date.now()) {
68
+ const parts = [subscription?.tier ?? 'Grok'];
69
+ const used = subscription?.usedPercent;
70
+ if (Number.isFinite(used)) parts.push((Number.isInteger(used) ? String(used) : used.toFixed(1)) + '% ' + t(locale, 'footer.grokUsed'));
71
+ const reset = resetStamp(subscription?.periodEnd, now);
72
+ if (reset !== undefined) parts.push(t(locale, 'footer.grokResets') + ' ' + reset);
73
+ return parts.join(' · ');
74
+ }
75
+
76
+ /** Local `MM-DD HH:MM` for a reset time, or undefined when the window is unknown or past. */
77
+ function resetStamp(iso, now) {
78
+ const at = Date.parse(typeof iso === 'string' ? iso : '');
79
+ if (!Number.isFinite(at) || at <= now) return undefined;
80
+ const pad = value => String(value).padStart(2, '0');
81
+ const date = new Date(at);
82
+ return pad(date.getMonth() + 1) + '-' + pad(date.getDate()) + ' ' + pad(date.getHours()) + ':' + pad(date.getMinutes());
83
+ }
61
84
  export function formatFooter(metrics, context, columns = 80, rates, locale = 'en', header = '', provider = providerOfHeader(header) ?? 'deepseek-official') {
62
85
  const label = key => t(locale, key);
63
86
  const ctx = Number.isFinite(context) ? `${Math.round(context)}%` : '--';
@@ -65,7 +88,7 @@ export function formatFooter(metrics, context, columns = 80, rates, locale = 'en
65
88
  // The balance belongs to the provider the header names; only DeepSeek's official route bills by a peak window.
66
89
  const balance = balanceNow(provider);
67
90
  const spend = metrics.unknown && metrics.cost === 0 ? '--' : `$${metrics.cost.toFixed(2)}${metrics.unknown ? '+' : ''}${metrics.pending ? '…' : ''}`;
68
- const dollars = `${spend} / ${balance === null ? '$--' : '$' + balance.toFixed(2)}${provider === 'deepseek-official' ? ' ' + peakEmoji(trustedNow()) : ''}`;
91
+ const dollars = provider === 'grok' ? grokFooterFact(grokSubscriptionNow(), locale) : `${spend} / ${balance === null ? '$--' : '$' + balance.toFixed(2)}${provider === 'deepseek-official' ? ' ' + peakEmoji(trustedNow()) : ''}`;
69
92
  const base = rates ? [
70
93
  `${label('footer.current')}: ${Number.isFinite(rates.current) ? '~' + rates.current.toFixed(1) : '--'} tps`,
71
94
  `${label('footer.average')}: ${Number.isFinite(rates.average) ? rates.average.toFixed(1) : '--'} tps`,