@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.
- package/cordis.patch.yml +5 -1
- package/package.json +3 -2
- package/plugins/code-review/index.mjs +5 -2
- package/plugins/compaction/threshold.mjs +12 -0
- package/plugins/credentials/index.mjs +13 -0
- package/plugins/grok/adapter.mjs +114 -0
- package/plugins/grok/auth.mjs +76 -0
- package/plugins/grok/billing.mjs +146 -0
- package/plugins/grok/index.mjs +118 -0
- package/plugins/grok/models.mjs +129 -0
- package/plugins/grok/status.mjs +28 -0
- package/plugins/grok/wire.mjs +268 -0
- package/plugins/i18n/messages.mjs +6 -6
- package/plugins/providers/catalog.mjs +2 -0
- package/plugins/session-metrics/view.mjs +24 -1
- package/vendor/compaction-basic/index.js +91 -1
- package/vendor/persistent/index.js +10 -9
- package/vendor/terminal/index.js +5 -0
- package/vendor/tui/lib/app.mjs +40 -3
- package/vendor/tui/lib/dscode/chat.mjs +27 -7
- package/vendor/tui/lib/index.mjs +2 -0
- package/vendor/tui/lib/render/projection.mjs +4 -2
package/cordis.patch.yml
CHANGED
|
@@ -820,10 +820,14 @@
|
|
|
820
820
|
|
|
821
821
|
|
|
822
822
|
- id: credentials
|
|
823
|
-
|
|
823
|
+
disabled: true
|
|
824
824
|
- insert:
|
|
825
|
+
- id: dscode-credentials
|
|
826
|
+
name: "@toddzheng024/dscode-bundle/credentials"
|
|
825
827
|
- id: dscode-openrouter
|
|
826
828
|
name: "@toddzheng024/dscode-bundle/openrouter"
|
|
829
|
+
- id: dscode-grok
|
|
830
|
+
name: "@toddzheng024/dscode-bundle/grok"
|
|
827
831
|
- id: dscode-auto-review
|
|
828
832
|
name: "@toddzheng024/dscode-bundle/auto-review"
|
|
829
833
|
config:
|
package/package.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.7.
|
|
2
|
+
"version": "0.7.14",
|
|
3
3
|
"type": "module",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"author": "Todd Zheng",
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
},
|
|
12
12
|
"repository": {
|
|
13
13
|
"type": "git",
|
|
14
|
-
"url": "https://github.com/qiz029/dscode"
|
|
14
|
+
"url": "https://github.com/qiz029/dscode.git"
|
|
15
15
|
},
|
|
16
16
|
"name": "@toddzheng024/dscode-bundle",
|
|
17
17
|
"description": "DSCODE coding harness: minimal persistent shell, Ultra subagents, auto review, Chrome, computer use and session telemetry.",
|
|
@@ -55,6 +55,7 @@
|
|
|
55
55
|
"./auto-review": "./plugins/auto-review/index.mjs",
|
|
56
56
|
"./session-metrics": "./plugins/session-metrics/index.mjs",
|
|
57
57
|
"./openrouter": "./plugins/openrouter/index.mjs",
|
|
58
|
+
"./grok": "./plugins/grok/index.mjs",
|
|
58
59
|
"./tui-tools": "./plugins/tui-tools/index.mjs"
|
|
59
60
|
},
|
|
60
61
|
"dependencies": {
|
|
@@ -79,8 +79,11 @@ export function describeAttempt(tokens, assembler, finish) {
|
|
|
79
79
|
}
|
|
80
80
|
|
|
81
81
|
export function reviewRoute(fallback, env = process.env) {
|
|
82
|
-
// A verdict needs
|
|
83
|
-
|
|
82
|
+
// A verdict needs no deliberation, and a thinking reviewer spends the whole deadline
|
|
83
|
+
// reasoning before it writes one line: ask for thinking off, the only level that turns
|
|
84
|
+
// it off. A model without that level keeps its own default, because `chooseEffort`
|
|
85
|
+
// returns no effort at all when the wanted level is outside its standard levels.
|
|
86
|
+
const effort = typeof env.DSCODE_REVIEW_EFFORT === 'string' && env.DSCODE_REVIEW_EFFORT.trim() ? env.DSCODE_REVIEW_EFFORT.trim() : 'off';
|
|
84
87
|
const wanted = typeof env.DSCODE_REVIEW_MODEL === 'string' ? env.DSCODE_REVIEW_MODEL.trim() : '';
|
|
85
88
|
if (!wanted) return { route: fallback, effort };
|
|
86
89
|
const at = wanted.indexOf('/');
|
|
@@ -16,6 +16,18 @@ export function thresholdForCacheRatio(ratio) {
|
|
|
16
16
|
return rounded < 0.1 ? 0.9 : rounded < 0.5 ? 0.8 : 0.6;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
/**
|
|
20
|
+
* How far below the priced threshold a background prefetch starts, as a share of
|
|
21
|
+
* the context window: at the default 80% threshold the prefetch mark is 70%.
|
|
22
|
+
*/
|
|
23
|
+
export const PREFETCH_LEAD_RATIO = 0.1;
|
|
24
|
+
|
|
25
|
+
/** Token mark where a background prefetch starts; an unknown window leaves the threshold itself. */
|
|
26
|
+
export function prefetchThresholdTokens(thresholdTokens, contextWindow, leadRatio = PREFETCH_LEAD_RATIO) {
|
|
27
|
+
if (!Number.isFinite(thresholdTokens) || !Number.isInteger(contextWindow) || contextWindow <= 0) return thresholdTokens;
|
|
28
|
+
return Math.max(0, thresholdTokens - Math.floor(contextWindow * leadRatio));
|
|
29
|
+
}
|
|
30
|
+
|
|
19
31
|
/** The route's threshold ratio, waiting for the OpenRouter listing when it has not loaded yet. */
|
|
20
32
|
export async function pricedThresholdRatio(provider, model, now = Date.now()) {
|
|
21
33
|
if (provider === 'openrouter') await ensureOpenRouterModels({ home: process.env.DSH_HOME, now });
|
|
@@ -4,6 +4,7 @@ import { LocalCredentialProvider } from '@deepseek-ai/dsh-credentials-local';
|
|
|
4
4
|
import { Context, Service } from '@deepseek-ai/cordis';
|
|
5
5
|
import { launchEnvironmentOf } from '@deepseek-ai/dsh-launch-environment';
|
|
6
6
|
import { PROVIDERS } from '../providers/catalog.mjs';
|
|
7
|
+
import { GROK_TOKEN_REF, grokAuthState } from '../grok/auth.mjs';
|
|
7
8
|
|
|
8
9
|
// The `/provider` keys (DeepSeek, OpenRouter and its management key) live in the shared store.
|
|
9
10
|
const SHARED = new Set(PROVIDERS.flatMap(provider => [provider.credentialRef, provider.managementRef].filter(Boolean)));
|
|
@@ -28,6 +29,12 @@ export default class DscodeCredentials extends LocalCredentialProvider {
|
|
|
28
29
|
yield* super[Service.init]();
|
|
29
30
|
}
|
|
30
31
|
async resolve(ref) {
|
|
32
|
+
// dscode: the Grok subscription rail is the official CLI login, read-only. DSCODE never
|
|
33
|
+
// writes that file: the CLI owns refresh-token rotation, and two writers log the other out.
|
|
34
|
+
if (ref === GROK_TOKEN_REF) {
|
|
35
|
+
const state = grokAuthState();
|
|
36
|
+
return state.kind === 'ready' ? { value: state.credential.token, source: 'file' } : undefined;
|
|
37
|
+
}
|
|
31
38
|
if (SHARED.has(ref)) {
|
|
32
39
|
const stored = await this.shared.resolve(ref);
|
|
33
40
|
if (stored?.source === 'env' || stored?.source === 'file') return stored;
|
|
@@ -35,6 +42,10 @@ export default class DscodeCredentials extends LocalCredentialProvider {
|
|
|
35
42
|
return super.resolve(ref);
|
|
36
43
|
}
|
|
37
44
|
async describe(ref) {
|
|
45
|
+
if (ref === GROK_TOKEN_REF) {
|
|
46
|
+
const state = grokAuthState();
|
|
47
|
+
return state.kind === 'ready' ? { configured: true, source: 'file', writable: false } : { configured: false, writable: false };
|
|
48
|
+
}
|
|
38
49
|
if (SHARED.has(ref)) {
|
|
39
50
|
const facts = await this.shared.describe(ref);
|
|
40
51
|
if (facts.source === 'env' || facts.source === 'file') return facts;
|
|
@@ -42,6 +53,8 @@ export default class DscodeCredentials extends LocalCredentialProvider {
|
|
|
42
53
|
return super.describe(ref);
|
|
43
54
|
}
|
|
44
55
|
set(ref, value) {
|
|
56
|
+
// The CLI file is not a DSCODE store: saving here would go somewhere nothing reads.
|
|
57
|
+
if (ref === GROK_TOKEN_REF) throw new Error('GROK_CLI_TOKEN comes from ~/.grok/auth.json; run grok login instead');
|
|
45
58
|
return SHARED.has(ref) ? this.shared.set(ref, value) : super.set(ref, value);
|
|
46
59
|
}
|
|
47
60
|
async unset(ref) {
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { LlmAdapter, LlmError, ReasoningEffortId } from "@deepseek-ai/dsh-llm";
|
|
2
|
+
import { grokModel, listGrokModels } from "./models.mjs";
|
|
3
|
+
import { PROVIDER, effortInfo, errorCode, errorMessage, requestBody, retryAfterMs, sseData, translate } from "./wire.mjs";
|
|
4
|
+
|
|
5
|
+
export { PROVIDER };
|
|
6
|
+
/** Context assumed for a model the catalog does not size. */
|
|
7
|
+
export const DEFAULT_CONTEXT_WINDOW = 131072;
|
|
8
|
+
/** Output cap materialized when a caller names none. */
|
|
9
|
+
export const DEFAULT_OUTPUT_CAP = 131072;
|
|
10
|
+
|
|
11
|
+
function modelInfo(provider, id, entry) {
|
|
12
|
+
return { provider, id, name: entry?.name ?? id, inputModalities: ["text"] };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* xAI chat completions as a harness adapter: the subscription rail of the official grok
|
|
17
|
+
* CLI, over the model catalog that rail publishes. The connection facts and the token
|
|
18
|
+
* resolve per request, so a fresh `grok login` reaches the next call without a restart.
|
|
19
|
+
*/
|
|
20
|
+
export class GrokAdapter extends LlmAdapter {
|
|
21
|
+
/** @param config - `options()`, `ensureModels()`, `resolveToken()`, optional `fetch`. */
|
|
22
|
+
constructor(config) {
|
|
23
|
+
super();
|
|
24
|
+
this.config = config;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
providerInfo(provider) {
|
|
28
|
+
return { id: provider, name: "Grok" };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
providerRetryPolicy() {
|
|
32
|
+
return this.config.options().retryPolicy;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Models that can drive an agent: the catalog the subscription rail filtered for this account. */
|
|
36
|
+
async listModels(provider) {
|
|
37
|
+
await this.config.ensureModels();
|
|
38
|
+
return listGrokModels().map(([id, entry]) => modelInfo(provider, id, entry));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async resolveModel(provider, model) {
|
|
42
|
+
await this.config.ensureModels();
|
|
43
|
+
const entry = grokModel(model);
|
|
44
|
+
const efforts = entry?.efforts ?? [];
|
|
45
|
+
return {
|
|
46
|
+
...modelInfo(provider, model, entry),
|
|
47
|
+
context: { contextWindow: entry?.contextWindow ?? DEFAULT_CONTEXT_WINDOW },
|
|
48
|
+
...entry?.maxOutput === undefined ? {} : { defaultMaxTokens: Math.min(entry.maxOutput, DEFAULT_OUTPUT_CAP) },
|
|
49
|
+
...efforts.length === 0 ? {} : { reasoning: {
|
|
50
|
+
efforts: efforts.map(id => ({ ...effortInfo(id), id: ReasoningEffortId(id) })),
|
|
51
|
+
...entry?.defaultEffort === undefined ? {} : { defaultEffort: ReasoningEffortId(entry.defaultEffort) },
|
|
52
|
+
} },
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async *stream(options) {
|
|
57
|
+
const connection = this.config.options();
|
|
58
|
+
const idle = new AbortController(), consumer = new AbortController();
|
|
59
|
+
let timer;
|
|
60
|
+
const pulse = () => {
|
|
61
|
+
clearTimeout(timer);
|
|
62
|
+
timer = setTimeout(() => idle.abort(new Error("Grok stream idle")), connection.streamIdleTimeoutMs);
|
|
63
|
+
timer.unref?.();
|
|
64
|
+
};
|
|
65
|
+
const signal = AbortSignal.any([idle.signal, consumer.signal, ...options.signal ? [options.signal] : []]);
|
|
66
|
+
try {
|
|
67
|
+
const token = await this.config.resolveToken();
|
|
68
|
+
await this.config.ensureModels();
|
|
69
|
+
const entry = grokModel(options.model);
|
|
70
|
+
const body = requestBody(options, { entry });
|
|
71
|
+
pulse();
|
|
72
|
+
const fetchImpl = this.config.fetch ?? globalThis.fetch;
|
|
73
|
+
let response;
|
|
74
|
+
try {
|
|
75
|
+
response = await fetchImpl(connection.baseURL + "/chat/completions", {
|
|
76
|
+
method: "POST",
|
|
77
|
+
headers: { authorization: "Bearer " + token, "content-type": "application/json", accept: "text/event-stream" },
|
|
78
|
+
body: JSON.stringify(body),
|
|
79
|
+
signal,
|
|
80
|
+
});
|
|
81
|
+
} catch (error) {
|
|
82
|
+
if (signal.aborted) throw error;
|
|
83
|
+
throw new LlmError(`Grok request to ${connection.baseURL} failed`, "TRANSPORT", { cause: error });
|
|
84
|
+
}
|
|
85
|
+
if (!response.ok || !response.headers.get("content-type")?.includes("text/event-stream")) {
|
|
86
|
+
const raw = await response.text();
|
|
87
|
+
let error;
|
|
88
|
+
try { error = JSON.parse(raw)?.error; } catch { /* not JSON */ }
|
|
89
|
+
if (response.ok && error === undefined) throw new LlmError(`Grok returned a non-stream response: ${raw.slice(0, 120)}`, "MALFORMED_RESPONSE");
|
|
90
|
+
const delay = retryAfterMs(response.headers.get("retry-after"));
|
|
91
|
+
const status = response.ok ? Number.isInteger(error?.code) ? error.code : undefined : response.status;
|
|
92
|
+
throw new LlmError(errorMessage(error, `Grok API error (HTTP ${response.status})`), errorCode(response.ok ? undefined : response.status, error), {
|
|
93
|
+
cause: new Error(raw.length > 0 ? raw : `Grok HTTP ${response.status}`),
|
|
94
|
+
...status === undefined ? {} : { status },
|
|
95
|
+
...delay === undefined ? {} : { providerRetryAfterMs: delay },
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
if (!response.body) throw new LlmError("Grok returned no response body", "EMPTY_RESPONSE");
|
|
99
|
+
for await (const chunk of translate(sseData(response.body, pulse), { model: options.model })) {
|
|
100
|
+
clearTimeout(timer);
|
|
101
|
+
yield chunk;
|
|
102
|
+
pulse();
|
|
103
|
+
}
|
|
104
|
+
} catch (error) {
|
|
105
|
+
if (idle.signal.aborted && !options.signal?.aborted) throw new LlmError(`Grok stream idle timeout after ${connection.streamIdleTimeoutMs}ms`, "TIMEOUT", { cause: error });
|
|
106
|
+
if (options.signal?.aborted) throw new LlmError("Grok request aborted by caller", "ABORTED", { cause: error });
|
|
107
|
+
if (error instanceof LlmError) throw error;
|
|
108
|
+
throw new LlmError(`Grok API stream from ${connection.baseURL} failed`, "TRANSPORT", { cause: error });
|
|
109
|
+
} finally {
|
|
110
|
+
clearTimeout(timer);
|
|
111
|
+
consumer.abort("Grok stream consumer stopped");
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
// dscode: the Grok subscription rail authenticates with the OAuth credentials the
|
|
6
|
+
// official `grok` CLI stores after `grok login`. DSCODE reads that file and never writes
|
|
7
|
+
// it: the CLI owns token rotation, and a refresh token rotated by a second writer would
|
|
8
|
+
// log the CLI out (the same trap the Hub token rotation sets). An expired token is
|
|
9
|
+
// reported with the command that fixes it, never silently refreshed.
|
|
10
|
+
|
|
11
|
+
/** The file the grok CLI writes: one entry per issuer/client, keyed `https://auth.x.ai::<client id>`. */
|
|
12
|
+
export const GROK_AUTH_PATH = join(homedir(), ".grok", "auth.json");
|
|
13
|
+
/** Credential ref the `/provider` machinery reads; resolved read-only from the CLI file. */
|
|
14
|
+
export const GROK_TOKEN_REF = "GROK_CLI_TOKEN";
|
|
15
|
+
|
|
16
|
+
/** Decode a JWT payload without verifying: only `exp`/`sub` are read, and xAI validates the token. */
|
|
17
|
+
function jwtClaims(token) {
|
|
18
|
+
try {
|
|
19
|
+
return JSON.parse(Buffer.from(String(token).split(".")[1], "base64").toString("utf8"));
|
|
20
|
+
} catch {
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* One CLI credential from a parsed auth file: the first issuer entry, whose access token
|
|
27
|
+
* is `key`. Unknown shapes answer undefined instead of throwing.
|
|
28
|
+
* @returns `{ token, refreshToken?, userId?, expiresAt?, issuer?, clientId? }`, or undefined.
|
|
29
|
+
*/
|
|
30
|
+
export function parseGrokAuth(value) {
|
|
31
|
+
if (value === null || typeof value !== "object") return undefined;
|
|
32
|
+
for (const [name, entry] of Object.entries(value)) {
|
|
33
|
+
if (entry === null || typeof entry !== "object") continue;
|
|
34
|
+
if (typeof entry.key !== "string" || entry.key.length === 0) continue;
|
|
35
|
+
const claims = jwtClaims(entry.key);
|
|
36
|
+
const expiresAt = Number.isFinite(claims?.exp) ? claims.exp * 1000 : Date.parse(entry.expires_at ?? "");
|
|
37
|
+
return {
|
|
38
|
+
token: entry.key,
|
|
39
|
+
...typeof entry.refresh_token === "string" && entry.refresh_token.length > 0 ? { refreshToken: entry.refresh_token } : {},
|
|
40
|
+
...typeof entry.user_id === "string" && entry.user_id.length > 0 ? { userId: entry.user_id } : typeof claims?.sub === "string" ? { userId: claims.sub } : {},
|
|
41
|
+
...Number.isFinite(expiresAt) ? { expiresAt } : {},
|
|
42
|
+
...name.includes("::") ? { issuer: name.split("::")[0], clientId: name.split("::")[1] } : {},
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The local Grok login as one fact set, with no I/O of its own beyond the injected read.
|
|
50
|
+
* @returns `{ kind: "ready"|"expired"|"missing"|"malformed", credential? }`; a missing file
|
|
51
|
+
* means the user never ran `grok login`, a malformed one means the CLI changed its shape.
|
|
52
|
+
*/
|
|
53
|
+
export function grokAuthState({ now = Date.now(), read = () => readFileSync(GROK_AUTH_PATH, "utf8") } = {}) {
|
|
54
|
+
let raw;
|
|
55
|
+
try {
|
|
56
|
+
raw = read();
|
|
57
|
+
} catch {
|
|
58
|
+
return { kind: "missing" };
|
|
59
|
+
}
|
|
60
|
+
let parsed;
|
|
61
|
+
try {
|
|
62
|
+
parsed = JSON.parse(raw);
|
|
63
|
+
} catch {
|
|
64
|
+
return { kind: "malformed" };
|
|
65
|
+
}
|
|
66
|
+
const credential = parseGrokAuth(parsed);
|
|
67
|
+
if (credential === undefined) return { kind: "malformed" };
|
|
68
|
+
if (credential.expiresAt !== undefined && credential.expiresAt <= now) return { kind: "expired", credential };
|
|
69
|
+
return { kind: "ready", credential };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Minutes until the access token expires, or undefined when the file carries no expiry. */
|
|
73
|
+
export function minutesLeft(credential, now = Date.now()) {
|
|
74
|
+
if (credential?.expiresAt === undefined) return undefined;
|
|
75
|
+
return Math.max(0, Math.round((credential.expiresAt - now) / 60000));
|
|
76
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
// dscode: what the Grok subscription left, and when it resets. The CLI proxy answers two
|
|
5
|
+
// payloads on the same credential the models come from: /settings names the tier, and
|
|
6
|
+
// /billing?format=credits carries the weekly window plus the used percentage. The
|
|
7
|
+
// percentage is optional (the server omits it when a period has no usage), so the status
|
|
8
|
+
// line has three states: a number, no usage recorded yet, and unreadable.
|
|
9
|
+
//
|
|
10
|
+
// This is the undocumented rail the official CLI itself reads; every field is optional and
|
|
11
|
+
// a shape change must degrade to "usage unavailable", never to a crash or a wrong number.
|
|
12
|
+
|
|
13
|
+
export const GROK_BILLING_URL = "https://cli-chat-proxy.grok.com/v1/billing?format=credits";
|
|
14
|
+
export const GROK_SETTINGS_URL = "https://cli-chat-proxy.grok.com/v1/settings";
|
|
15
|
+
/** The CLI re-reads its subscription every 60s (`subscription_watch_interval_secs`); so does DSCODE. */
|
|
16
|
+
export const GROK_SUBSCRIPTION_TTL_MS = 60_000;
|
|
17
|
+
const FILE = "grok-subscription.json";
|
|
18
|
+
const VERSION = 1;
|
|
19
|
+
let state;
|
|
20
|
+
let fetchedAt = 0;
|
|
21
|
+
let pending;
|
|
22
|
+
let disk;
|
|
23
|
+
|
|
24
|
+
const finite = value => Number.isFinite(Number(value)) ? Number(value) : undefined;
|
|
25
|
+
const percent = value => {
|
|
26
|
+
const number = finite(value);
|
|
27
|
+
return number === undefined ? undefined : Math.min(100, Math.max(0, number));
|
|
28
|
+
};
|
|
29
|
+
const stamp = value => typeof value === "string" && value.length > 0 ? value : undefined;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The weekly credit window from one `/billing?format=credits` body.
|
|
33
|
+
* @returns `{ usedPercent?, periodStart?, periodEnd?, periodType?, onDemandCap?, onDemandUsed?,
|
|
34
|
+
* prepaidBalance?, unified? }` with unknown fields left out.
|
|
35
|
+
*/
|
|
36
|
+
export function parseGrokCredits(body) {
|
|
37
|
+
const config = body?.config ?? {};
|
|
38
|
+
const period = config.currentPeriod ?? {};
|
|
39
|
+
const used = percent(config.creditUsagePercent);
|
|
40
|
+
const onDemandCap = finite(config.onDemandCap?.val);
|
|
41
|
+
const onDemandUsed = finite(config.onDemandUsed?.val);
|
|
42
|
+
const prepaid = finite(config.prepaidBalance?.val);
|
|
43
|
+
const periodEnd = stamp(period.end) ?? stamp(config.billingPeriodEnd);
|
|
44
|
+
const periodStart = stamp(period.start) ?? stamp(config.billingPeriodStart);
|
|
45
|
+
return {
|
|
46
|
+
...used === undefined ? {} : { usedPercent: used },
|
|
47
|
+
...periodStart === undefined ? {} : { periodStart },
|
|
48
|
+
...periodEnd === undefined ? {} : { periodEnd },
|
|
49
|
+
...stamp(period.type) === undefined ? {} : { periodType: stamp(period.type) },
|
|
50
|
+
...onDemandCap === undefined ? {} : { onDemandCap },
|
|
51
|
+
...onDemandUsed === undefined ? {} : { onDemandUsed },
|
|
52
|
+
...prepaid === undefined ? {} : { prepaidBalance: prepaid },
|
|
53
|
+
...config.isUnifiedBillingUser === true ? { unified: true } : {},
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The plan facts from one `/settings` body: the tier label and the access gate. */
|
|
58
|
+
export function parseGrokSettings(body) {
|
|
59
|
+
const tier = stamp(body?.subscription_tier_display);
|
|
60
|
+
const gate = stamp(body?.gate_message);
|
|
61
|
+
const gateUrl = stamp(body?.gate_url);
|
|
62
|
+
return {
|
|
63
|
+
...tier === undefined ? {} : { tier },
|
|
64
|
+
...stamp(body?.default_model) === undefined ? {} : { defaultModel: stamp(body.default_model) },
|
|
65
|
+
...body?.allow_access === false ? { blocked: true } : {},
|
|
66
|
+
...gate === undefined ? {} : { gateMessage: gate },
|
|
67
|
+
...gateUrl === undefined ? {} : { gateUrl },
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Both reads at once; either half may fail without losing the other. */
|
|
72
|
+
export async function fetchGrokSubscription({ token, userId, version = "1.0.34", fetch: fetchImpl = globalThis.fetch, now = Date.now() } = {}) {
|
|
73
|
+
if (typeof fetchImpl !== "function") return undefined;
|
|
74
|
+
const headers = { authorization: "Bearer " + token, accept: "application/json", "x-xai-token-auth": "xai-grok-cli", "x-authenticateresponse": "authenticate-response", "x-grok-client-version": version, ...userId === undefined ? {} : { "x-userid": userId } };
|
|
75
|
+
const read = async url => {
|
|
76
|
+
try {
|
|
77
|
+
const response = await fetchImpl(url, { headers });
|
|
78
|
+
return response.ok ? await response.json() : undefined;
|
|
79
|
+
} catch {
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
const [credits, settings] = await Promise.all([read(GROK_BILLING_URL), read(GROK_SETTINGS_URL)]);
|
|
84
|
+
if (credits === undefined && settings === undefined) return undefined;
|
|
85
|
+
return { ...parseGrokSettings(settings), ...parseGrokCredits(credits), fetchedAt: now };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The state the status line renders: cached for one refresh interval, one request in flight. */
|
|
89
|
+
export async function currentGrokSubscription({ home, ...options } = {}) {
|
|
90
|
+
if (state !== undefined && Date.now() - fetchedAt < GROK_SUBSCRIPTION_TTL_MS) return state;
|
|
91
|
+
if (pending) return pending;
|
|
92
|
+
pending = (async () => {
|
|
93
|
+
const next = await fetchGrokSubscription(options);
|
|
94
|
+
if (next !== undefined) {
|
|
95
|
+
state = next;
|
|
96
|
+
fetchedAt = next.fetchedAt ?? Date.now();
|
|
97
|
+
if (home !== undefined) writeGrokSubscription(home, next);
|
|
98
|
+
}
|
|
99
|
+
return state;
|
|
100
|
+
})().finally(() => { pending = undefined; });
|
|
101
|
+
return pending;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The last written subscription state, or undefined before the first successful read. */
|
|
105
|
+
export function readGrokSubscription(home) {
|
|
106
|
+
try {
|
|
107
|
+
const cached = JSON.parse(readFileSync(join(home, FILE), "utf8"));
|
|
108
|
+
return cached?.version === VERSION && typeof cached === "object" ? cached : undefined;
|
|
109
|
+
} catch {
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Atomic write of one state snapshot; a failure leaves the previous file alone. */
|
|
115
|
+
export function writeGrokSubscription(home, snapshot) {
|
|
116
|
+
try {
|
|
117
|
+
mkdirSync(home, { recursive: true });
|
|
118
|
+
const path = join(home, FILE);
|
|
119
|
+
writeFileSync(path + ".tmp", JSON.stringify({ version: VERSION, ...snapshot }));
|
|
120
|
+
renameSync(path + ".tmp", path);
|
|
121
|
+
} catch { /* the status line simply keeps its previous state */ }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Replace the in-memory state; for tests. */
|
|
125
|
+
export function setGrokSubscription(next, at = Date.now()) {
|
|
126
|
+
state = next;
|
|
127
|
+
fetchedAt = at;
|
|
128
|
+
pending = undefined;
|
|
129
|
+
disk = undefined;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The cached state without waiting for a fetch; the view uses this during a render. The live
|
|
134
|
+
* process wins, then the state file the panel already reads, so a footer drawn before the
|
|
135
|
+
* first refresh — or one drawn while the network is down — still shows the last window
|
|
136
|
+
* instead of falling back to a bare tier name. One file read per refresh window keeps the
|
|
137
|
+
* render path off the disk.
|
|
138
|
+
*/
|
|
139
|
+
export function grokSubscriptionNow(home = process.env.DSH_HOME) {
|
|
140
|
+
if (state !== undefined) return state;
|
|
141
|
+
if (home === undefined) return undefined;
|
|
142
|
+
if (disk !== undefined && disk.home === home && Date.now() - disk.at < GROK_SUBSCRIPTION_TTL_MS) return disk.value;
|
|
143
|
+
const value = readGrokSubscription(home);
|
|
144
|
+
disk = { home, at: Date.now(), value };
|
|
145
|
+
return value;
|
|
146
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import z from "@deepseek-ai/schemastery";
|
|
2
|
+
import { RetryPolicySchema, resolveRetryPolicy } from "@deepseek-ai/dsh-llm";
|
|
3
|
+
import { credentialRef } from "@deepseek-ai/dsh-credentials";
|
|
4
|
+
import { launchEnvironmentOf } from "@deepseek-ai/dsh-launch-environment";
|
|
5
|
+
import { GrokAdapter, PROVIDER } from "./adapter.mjs";
|
|
6
|
+
import { ensureGrokModels } from "./models.mjs";
|
|
7
|
+
import { GROK_SUBSCRIPTION_TTL_MS, currentGrokSubscription } from "./billing.mjs";
|
|
8
|
+
import { GROK_TOKEN_REF, grokAuthState } from "./auth.mjs";
|
|
9
|
+
|
|
10
|
+
// dscode: the `grok` route — the SuperGrok / X Premium+ subscription the official grok CLI
|
|
11
|
+
// already signed in for, driven through xAI chat completions. The token is read (never
|
|
12
|
+
// written) from the CLI credential file, the catalog and the weekly credit window come from
|
|
13
|
+
// the same CLI proxy the official client uses, and the status line reads the cached window
|
|
14
|
+
// instead of a dollar balance: a subscription has credits, not a bill.
|
|
15
|
+
export const name = "dscode-grok";
|
|
16
|
+
export const inject = ["llm"];
|
|
17
|
+
const NS = "llm-grok";
|
|
18
|
+
const DEFAULT_BASE_URL = "https://api.x.ai/v1";
|
|
19
|
+
/** The client version the CLI proxy is asked with; xAI refuses requests without one. */
|
|
20
|
+
const DEFAULT_CLIENT_VERSION = "1.0.34";
|
|
21
|
+
|
|
22
|
+
export const Config = z.object({
|
|
23
|
+
apiKeyEnv: z.string().role("credential-ref").default(GROK_TOKEN_REF),
|
|
24
|
+
baseURL: z.string().default(DEFAULT_BASE_URL),
|
|
25
|
+
clientVersion: z.string().default(DEFAULT_CLIENT_VERSION),
|
|
26
|
+
streamIdleTimeoutMs: z.number().min(1).default(300000),
|
|
27
|
+
retryPolicy: RetryPolicySchema,
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
/** Validated connection facts from one config snapshot. */
|
|
31
|
+
export function resolveOptions(config = {}) {
|
|
32
|
+
const streamIdleTimeoutMs = config.streamIdleTimeoutMs ?? 300000;
|
|
33
|
+
if (!Number.isFinite(streamIdleTimeoutMs) || streamIdleTimeoutMs <= 0) throw new Error(name + ": streamIdleTimeoutMs must be a positive number");
|
|
34
|
+
const baseURL = (config.baseURL || DEFAULT_BASE_URL).replace(/\/+$/, "");
|
|
35
|
+
if (!URL.canParse(baseURL)) throw new Error(name + ": baseURL must be a URL");
|
|
36
|
+
return {
|
|
37
|
+
apiKeyEnv: credentialRef(config.apiKeyEnv || GROK_TOKEN_REF),
|
|
38
|
+
baseURL,
|
|
39
|
+
clientVersion: config.clientVersion || DEFAULT_CLIENT_VERSION,
|
|
40
|
+
streamIdleTimeoutMs,
|
|
41
|
+
retryPolicy: resolveRetryPolicy(config.retryPolicy, name + ": retryPolicy"),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The message a missing or expired CLI login deserves: the fix is a command, not a paste. */
|
|
46
|
+
export function loginHint(state) {
|
|
47
|
+
if (state.kind === 'missing') return name + ': no local grok login found; run "grok login" (DSCODE reads ~/.grok/auth.json read-only)';
|
|
48
|
+
if (state.kind === 'expired') return name + ': the local grok login expired; run "grok login" again (DSCODE never rotates that token)';
|
|
49
|
+
return name + ': the local grok login file was not readable; run "grok login" again';
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function apply(ctx, config = {}) {
|
|
53
|
+
let current = () => config, lastRaw, lastGood;
|
|
54
|
+
const options = () => {
|
|
55
|
+
const raw = current();
|
|
56
|
+
if (raw === lastRaw && lastGood !== undefined) return lastGood;
|
|
57
|
+
try {
|
|
58
|
+
lastGood = resolveOptions(raw);
|
|
59
|
+
lastRaw = raw;
|
|
60
|
+
return lastGood;
|
|
61
|
+
} catch (error) {
|
|
62
|
+
if (lastGood === undefined) throw error;
|
|
63
|
+
lastRaw = raw;
|
|
64
|
+
ctx.logger.error(name + ": keeping the last good configuration after an invalid settings section");
|
|
65
|
+
return lastGood;
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
options();
|
|
69
|
+
const home = process.env.DSH_HOME;
|
|
70
|
+
/** The credential the route calls with, or undefined: the catalog must load without a login. */
|
|
71
|
+
const resolveTokenSafe = async () => {
|
|
72
|
+
const ref = options().apiKeyEnv;
|
|
73
|
+
const credentials = ctx.get("credentials");
|
|
74
|
+
try {
|
|
75
|
+
const stored = credentials === undefined ? launchEnvironmentOf(ctx).get(ref)?.value : (await credentials.resolve(ref))?.value;
|
|
76
|
+
if (stored !== undefined && stored.length > 0) return stored;
|
|
77
|
+
} catch { /* fall through to the local login state for a better message */ }
|
|
78
|
+
const state = grokAuthState();
|
|
79
|
+
return state.kind === "ready" ? state.credential.token : undefined;
|
|
80
|
+
};
|
|
81
|
+
const resolveToken = async () => {
|
|
82
|
+
const token = await resolveTokenSafe();
|
|
83
|
+
if (token !== undefined) return token;
|
|
84
|
+
throw new Error(loginHint(grokAuthState()));
|
|
85
|
+
};
|
|
86
|
+
const ensureModels = async () => {
|
|
87
|
+
const token = await resolveTokenSafe();
|
|
88
|
+
if (token === undefined) return;
|
|
89
|
+
await ensureGrokModels({ home, token, version: options().clientVersion });
|
|
90
|
+
};
|
|
91
|
+
const adapter = new GrokAdapter({ options, ensureModels, resolveToken });
|
|
92
|
+
ctx.llm.registerConfigurableProviders([{ provider: PROVIDER, displayName: "Grok", settingsNs: NS, settingsPath: [] }]);
|
|
93
|
+
const registration = ctx.llm.registerAdapter([PROVIDER], adapter);
|
|
94
|
+
let registeredPolicy = JSON.stringify(options().retryPolicy);
|
|
95
|
+
ctx.inject(["settings"], settingsCtx => {
|
|
96
|
+
settingsCtx.settings.installSection(ctx, NS, Config, config, {
|
|
97
|
+
setSource: source => { current = source; },
|
|
98
|
+
onChange: () => {
|
|
99
|
+
const policy = JSON.stringify(options().retryPolicy);
|
|
100
|
+
if (policy === registeredPolicy) return;
|
|
101
|
+
registration.replace([PROVIDER]);
|
|
102
|
+
registeredPolicy = policy;
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
// Warm the catalog and the weekly window for a user who already ran `grok login`, then keep
|
|
107
|
+
// the window fresh at the interval the official CLI itself watches (subscription_watch_interval_secs).
|
|
108
|
+
const refresh = async () => {
|
|
109
|
+
const state = grokAuthState();
|
|
110
|
+
if (state.kind !== "ready") return;
|
|
111
|
+
await ensureGrokModels({ home, token: state.credential.token, version: options().clientVersion });
|
|
112
|
+
await currentGrokSubscription({ home, token: state.credential.token, userId: state.credential.userId, version: options().clientVersion });
|
|
113
|
+
};
|
|
114
|
+
void refresh().catch(() => {});
|
|
115
|
+
const timer = setInterval(() => { void refresh().catch(() => {}); }, GROK_SUBSCRIPTION_TTL_MS);
|
|
116
|
+
timer.unref?.();
|
|
117
|
+
ctx.effect(() => () => clearInterval(timer));
|
|
118
|
+
}
|