clauderipple 0.9.1 → 0.9.3
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 +42 -0
- package/README.es.md +1 -0
- package/README.ja.md +1 -0
- package/README.ko.md +1 -0
- package/README.md +1 -0
- package/README.pt-BR.md +1 -0
- package/README.zh-CN.md +1 -0
- package/dist/app/package.json +1 -1
- package/dist/cli/src/index.js +2 -1
- package/dist/mod/hooks/i18n.ts +42 -12
- package/dist/mod/hooks/ladder.js +5 -5
- package/dist/mod/hooks/ladder.ts +5 -5
- package/dist/mod/hooks/register.tsx +58 -15
- package/dist/mod/hooks/session.ts +37 -3
- package/dist/mod/hooks/settings.js +13 -11
- package/dist/mod/hooks/settings.ts +13 -11
- package/dist/mod/hooks/ui/alerts.tsx +17 -4
- package/dist/mod/hooks/ui/art.ts +2 -2
- package/dist/mod/hooks/ui/band.tsx +1 -1
- package/dist/mod/hooks/ui/kit.tsx +15 -2
- package/dist/mod/hooks/ui/view.ts +4 -1
- package/dist/mod/hooks/workers.ts +8 -8
- package/dist/mod/tests/register.test.tsx +63 -0
- package/dist/mod/tests/ui.test.tsx +10 -6
- package/dist/mod/types/index.d.ts +10 -1
- package/dist/router/src/admin.js +76 -5
- package/dist/router/src/agents.js +4 -0
- package/dist/router/src/config.js +24 -0
- package/dist/router/src/ingress/server.js +2 -2
- package/dist/router/src/providers/grok/auth.js +289 -0
- package/dist/router/src/providers/grok/catalog.js +52 -0
- package/dist/router/src/providers/grok/index.js +94 -0
- package/dist/router/src/providers/openai/index.js +69 -17
- package/dist/router/src/providers/openai/translate.js +6 -1
- package/dist/router/src/proxy.js +43 -0
- package/dist/router/src/redact.js +7 -2
- package/dist/router/src/version.js +1 -1
- package/dist/ui/app.js +115 -6
- package/dist/ui/i18n.js +6 -6
- package/docs/ARCHITECTURE.md +78 -0
- package/package.json +1 -1
|
@@ -918,6 +918,42 @@ test('keep-warm forks near expiry, refreshes the memo and stops once on a null f
|
|
|
918
918
|
expect(stats.text).toContain('Warm: 1 pings')
|
|
919
919
|
})
|
|
920
920
|
|
|
921
|
+
test('a cache-reading ping keeps the card, a real reply and /clear drop it, and the demo draws it', async ($, on) => {
|
|
922
|
+
const b = boundaries(on, { keepWarm: true })
|
|
923
|
+
http(on)
|
|
924
|
+
judgeStub(on)
|
|
925
|
+
stepStub(on)
|
|
926
|
+
on('model.fork', () => ({ value: { isAnswered: true, text: 'ok', usage: USAGE } }))
|
|
927
|
+
await start($)
|
|
928
|
+
await step($)
|
|
929
|
+
await complete($)
|
|
930
|
+
await b.clock.advance(3375000)
|
|
931
|
+
// The ping read 40000 cache tokens: the card tells it, with the memo's minutes left.
|
|
932
|
+
const ui = await band($)
|
|
933
|
+
expect(await ui.find({ type: 'Text', text: /held 40k tokens in cache/ })).toBeDefined()
|
|
934
|
+
await ui.unmount()
|
|
935
|
+
// A prompt counts as having seen the card; the next is a real reply, which clears the count.
|
|
936
|
+
await submit($)
|
|
937
|
+
await step($)
|
|
938
|
+
await complete($)
|
|
939
|
+
const after = await band($)
|
|
940
|
+
expect(await after.find({ type: 'Text', text: /held 40k tokens in cache/ })).toBeUndefined()
|
|
941
|
+
await after.unmount()
|
|
942
|
+
await b.clock.advance(3375000)
|
|
943
|
+
await b.clock.settle()
|
|
944
|
+
const again = await band($)
|
|
945
|
+
expect(await again.find({ type: 'Text', text: /held 40k tokens in cache/ })).toBeDefined()
|
|
946
|
+
await again.unmount()
|
|
947
|
+
await $.classic.SessionStart({ source: 'clear' })
|
|
948
|
+
const cleared = await band($)
|
|
949
|
+
expect(await cleared.find({ type: 'Text', text: /held 40k tokens in cache/ })).toBeUndefined()
|
|
950
|
+
await cleared.unmount()
|
|
951
|
+
// The demo shows the card with sample values, whatever the state.
|
|
952
|
+
await command($, 'kept')
|
|
953
|
+
const demo = await band($)
|
|
954
|
+
expect(await demo.find({ type: 'Text', text: /held 312k tokens in cache/ })).toBeDefined()
|
|
955
|
+
})
|
|
956
|
+
|
|
921
957
|
test('fork turn.step is not effort-steered, busy or main-request cost', async ($, on) => {
|
|
922
958
|
const b = boundaries(on, { keepWarm: true })
|
|
923
959
|
http(on)
|
|
@@ -941,6 +977,23 @@ test('fork turn.step is not effort-steered, busy or main-request cost', async ($
|
|
|
941
977
|
expect(stats.text).toContain('Warm: 1 pings')
|
|
942
978
|
})
|
|
943
979
|
|
|
980
|
+
test('the flame turns keep-warm off for this chat only; Settings stay as they are', async ($, on) => {
|
|
981
|
+
const b = boundaries(on, { keepWarm: true })
|
|
982
|
+
http(on)
|
|
983
|
+
judgeStub(on, '{"effort":"high","why":"scope"}')
|
|
984
|
+
stepStub(on)
|
|
985
|
+
let forks = 0
|
|
986
|
+
on('model.fork', async () => { forks++; return { value: { isAnswered: true, text: 'ok', usage: USAGE } } })
|
|
987
|
+
await start($)
|
|
988
|
+
await submit($)
|
|
989
|
+
await step($)
|
|
990
|
+
await complete($)
|
|
991
|
+
await $.command.run({ command: 'ripple', origin: { kind: 'composer' }, presentation: { isFullscreen: true, columns: 140 }, args: 'warm' })
|
|
992
|
+
await b.clock.advance(3375000)
|
|
993
|
+
expect(forks).toBe(0)
|
|
994
|
+
expect(b.router().settings.keepWarm).toBe(true)
|
|
995
|
+
})
|
|
996
|
+
|
|
944
997
|
test('Haiku compaction returns replacement messages and forwards the keep note', async ($, on) => {
|
|
945
998
|
boundaries(on)
|
|
946
999
|
http(on)
|
|
@@ -1131,6 +1184,16 @@ for (const surface of ['terminal', 'desktop'] as const) test('quick controls sav
|
|
|
1131
1184
|
await ui.unmount()
|
|
1132
1185
|
})
|
|
1133
1186
|
|
|
1187
|
+
test('ripple-bar says where the line is when the layout is minimal', async ($, on) => {
|
|
1188
|
+
const b = boundaries(on, { layout: 'minimal' })
|
|
1189
|
+
http(on)
|
|
1190
|
+
await start($)
|
|
1191
|
+
const run = () => $.command.run({ command: 'ripple-bar', origin: { kind: 'composer' }, presentation: { isFullscreen: true, columns: 140 }, args: '' })
|
|
1192
|
+
expect((await run()).text).toContain('hidden')
|
|
1193
|
+
expect(b.saved.get('bandHidden')).toBe(true)
|
|
1194
|
+
expect((await run()).text).toContain('Minimal')
|
|
1195
|
+
})
|
|
1196
|
+
|
|
1134
1197
|
for (const surface of ['terminal', 'desktop'] as const) test('quick settings select a saved mode, never edit rungs, on ' + surface, async ($, on) => {
|
|
1135
1198
|
const b = boundaries(on, { modes: BURN })
|
|
1136
1199
|
http(on)
|
|
@@ -265,6 +265,7 @@ test('a ko frame resolves temporary Korean text in labels and hover cards', asyn
|
|
|
265
265
|
|
|
266
266
|
const ALERTS: AlertView[] = [
|
|
267
267
|
{ kind: 'cold', tokens: 182_000, idleMinutes: 74 },
|
|
268
|
+
{ kind: 'kept', pings: 2, tokens: 312_000, minutesLeft: 47 },
|
|
268
269
|
{ kind: 'swamp', percent: 63, tokens: 126_000 },
|
|
269
270
|
{ kind: 'hot', limitKind: 'five_hour', percent: 86, resetsAt: '2026-10-10T15:40:00', save: false },
|
|
270
271
|
{ kind: 'judgeDown', judge: 'jev', reason: 'key rejected (401)' },
|
|
@@ -297,14 +298,17 @@ test('alert choices reach their callbacks', async ($, on) => {
|
|
|
297
298
|
setHandoffKind: fn('kind'), setHandoffAfter: fn('after'), go: fn('go'), copy: fn('copy'), openNew: fn('open-new'),
|
|
298
299
|
}
|
|
299
300
|
drawPane(on, 'cold', (el, surface) => renderAlert(el, frameOf(surface), ALERTS[0]!, actions))
|
|
300
|
-
drawPane(on, '
|
|
301
|
+
drawPane(on, 'kept', (el, surface) => renderAlert(el, frameOf(surface), ALERTS[1]!, actions))
|
|
302
|
+
drawPane(on, 'hot', (el, surface) => renderAlert(el, frameOf(surface), ALERTS[3]!, actions))
|
|
301
303
|
drawPane(on, 'note', (el, surface) => renderAlert(el, frameOf(surface), { kind: 'compactNote', draft: '' }, actions))
|
|
302
|
-
drawPane(on, 'choice', (el, surface) => renderAlert(el, frameOf(surface), ALERTS[
|
|
304
|
+
drawPane(on, 'choice', (el, surface) => renderAlert(el, frameOf(surface), ALERTS[11]!, actions))
|
|
303
305
|
for (const surface of SURFACES) {
|
|
304
306
|
const cold = await mount($, 'cold', surface)
|
|
305
307
|
await cold.press({ key: 'compact' })
|
|
306
308
|
await cold.press({ key: 'handoff' })
|
|
307
309
|
await cold.press({ key: 'dismiss' })
|
|
310
|
+
const kept = await mount($, 'kept', surface)
|
|
311
|
+
await kept.press({ key: 'dismiss' })
|
|
308
312
|
const hot = await mount($, 'hot', surface)
|
|
309
313
|
await hot.press({ key: 'save' })
|
|
310
314
|
const note = await mount($, 'note', surface)
|
|
@@ -314,7 +318,7 @@ test('alert choices reach their callbacks', async ($, on) => {
|
|
|
314
318
|
await choice.press({ key: 'after-copy' })
|
|
315
319
|
await choice.press({ key: 'go' })
|
|
316
320
|
}
|
|
317
|
-
const once = ['compact', 'handoff', 'dismiss', 'save', 'compact:["keep the API decision"]', 'kind:["full"]', 'after:["copy"]', 'go']
|
|
321
|
+
const once = ['compact', 'handoff', 'dismiss', 'dismiss', 'save', 'compact:["keep the API decision"]', 'kind:["full"]', 'after:["copy"]', 'go']
|
|
318
322
|
expect(calls).toEqual([...once, ...once])
|
|
319
323
|
})
|
|
320
324
|
|
|
@@ -354,9 +358,9 @@ test('each alert shows 리플이 with the face of its state, its gauge in 5% ste
|
|
|
354
358
|
expect(mascots(await terminal.drawn()).length).toBe(0)
|
|
355
359
|
await terminal.unmount()
|
|
356
360
|
}
|
|
357
|
-
// handoffDone and
|
|
358
|
-
// one; every other state has its own.
|
|
359
|
-
expect(new Set(sources).size).toBe(ALERTS.length -
|
|
361
|
+
// handoffDone, compacted and kept share the pleased face, compactNote and handoffChoice the
|
|
362
|
+
// asking one; every other state has its own.
|
|
363
|
+
expect(new Set(sources).size).toBe(ALERTS.length - 3)
|
|
360
364
|
const source = async (id: string) => {
|
|
361
365
|
const ui = await mount($, id, 'desktop')
|
|
362
366
|
const s = String(mascots(await ui.drawn())[0]?.props?.source)
|
|
@@ -149,6 +149,12 @@ export type Settings = {
|
|
|
149
149
|
* picks, only among these.
|
|
150
150
|
*/
|
|
151
151
|
allow: string[]
|
|
152
|
+
/**
|
|
153
|
+
* The allow-list keys of every worker that existed when `allow` was last written (what
|
|
154
|
+
* `candidateKey` returns). A key absent from this non-empty list counts as ticked on; empty means
|
|
155
|
+
* either every worker is ticked (`allow` empty) or all writers predate the field (#56).
|
|
156
|
+
*/
|
|
157
|
+
allowKnown: string[]
|
|
152
158
|
/** The user's operating modes; none is built in. */
|
|
153
159
|
modes: Mode[]
|
|
154
160
|
/** The mode that is on, by id; absent when none is. */
|
|
@@ -301,12 +307,15 @@ declare module 'claude-code' {
|
|
|
301
307
|
agents: AgentRec[]
|
|
302
308
|
cache: CacheMemo | null
|
|
303
309
|
warm: WarmState
|
|
310
|
+
/** Keep-warm for this chat alone, set by the band's flame; null follows Settings. */
|
|
311
|
+
warmHere: boolean | null
|
|
304
312
|
context: { percent: number; tokens: number; window: number } | null
|
|
305
313
|
hot: Hot | null
|
|
306
314
|
hotHidden: number | null
|
|
307
315
|
saveUntil: number | null
|
|
308
316
|
swampHidden: number | null
|
|
309
317
|
coldHidden: boolean
|
|
318
|
+
kept: { pings: number; tokens: number } | null
|
|
310
319
|
judgeDown: string | null
|
|
311
320
|
advice: string | null
|
|
312
321
|
handoff: { stage: HandoffStage; kind: HandoffKind; text?: string } | null
|
|
@@ -345,7 +354,7 @@ declare module 'claude-code' {
|
|
|
345
354
|
judgeRuns: JudgeRun[]
|
|
346
355
|
judgeDownNetwork: boolean
|
|
347
356
|
limit: Hot | null
|
|
348
|
-
demo: 'cold' | 'swamp' | 'hot' | 'down' | null
|
|
357
|
+
demo: 'cold' | 'swamp' | 'hot' | 'down' | 'kept' | null
|
|
349
358
|
}
|
|
350
359
|
}
|
|
351
360
|
}
|
package/dist/router/src/admin.js
CHANGED
|
@@ -23,7 +23,7 @@ import path from "node:path";
|
|
|
23
23
|
import http from "node:http";
|
|
24
24
|
import { execFile, spawn } from "node:child_process";
|
|
25
25
|
import { fileURLToPath } from "node:url";
|
|
26
|
-
import { adminPort, homeDir, searchProviderFor, validate } from "./config.js";
|
|
26
|
+
import { adminPort, headerValueProblem, homeDir, searchProviderFor, validate } from "./config.js";
|
|
27
27
|
export { adminPort } from "./config.js";
|
|
28
28
|
import { generatedAgents } from "./agents.js";
|
|
29
29
|
import { isJudgeOnlyModel } from "./model-policy.js";
|
|
@@ -33,6 +33,7 @@ import { normalizeSettings } from "../../mod/hooks/settings.js";
|
|
|
33
33
|
import { chatPickerModels } from "./picker.js";
|
|
34
34
|
import { PRESETS } from "./presets.js";
|
|
35
35
|
import { resolveCompatibleCaps } from "./compat.js";
|
|
36
|
+
import { redactErrorText } from "./redact.js";
|
|
36
37
|
import { CHATGPT_FALLBACK_MODELS } from "./providers/chatgpt/catalog.js";
|
|
37
38
|
import { measureModel, refusedByPlan } from "./capabilities.js";
|
|
38
39
|
import { catalogEntry } from "./catalog.js";
|
|
@@ -53,6 +54,9 @@ import { exhaustedForMs, IMAGE_ASPECTS, IMAGE_BACKGROUNDS, IMAGE_FORMATS } from
|
|
|
53
54
|
import { ANTIGRAVITY_WARNING, staticAntigravityModels } from "./providers/google/antigravity.js";
|
|
54
55
|
import { readGoogleAccounts, removeGoogleAccount, summarize as summarizeGoogleAccount, updateGoogleAccount } from "./providers/google/accounts.js";
|
|
55
56
|
import { GoogleOAuthSession } from "./providers/google/login.js";
|
|
57
|
+
import { GROK_AUTH_HINT, grokAuthPath, grokHome, readGrokToken, sharedGrokAuth } from "./providers/grok/auth.js";
|
|
58
|
+
import { GROK_DEFAULT_URL, GROK_EFFORT_LEVELS, GROK_FALLBACK_MODELS, grokModelsFromListing } from "./providers/grok/catalog.js";
|
|
59
|
+
import { GROK_WARNING, grokHeaders } from "./providers/grok/index.js";
|
|
56
60
|
const MAX_BODY = 1024 * 1024;
|
|
57
61
|
/** `/api/image` carries reference images inline, so it gets more room than a config save. */
|
|
58
62
|
const MAX_IMAGE_BODY = 32 * 1024 * 1024;
|
|
@@ -96,7 +100,8 @@ async function claudeAccountUsage(deps) {
|
|
|
96
100
|
}
|
|
97
101
|
return rows;
|
|
98
102
|
}
|
|
99
|
-
|
|
103
|
+
// The CLI's `--effort` takes the same five; Opus, Sonnet and Haiku 5.5 answered `xhigh` (requests.jsonl, 2026-10-14, #56).
|
|
104
|
+
const ANTHROPIC_EFFORT_LEVELS = ["low", "medium", "high", "xhigh", "max"];
|
|
100
105
|
const CHATGPT_DEFAULT_EFFORT_LEVELS = ["low", "medium", "high", "xhigh", "max"];
|
|
101
106
|
let chatgptLogin = { running: false };
|
|
102
107
|
/** The Claude subscription sign-in in progress (or the last one), for GET /api/claude-oauth. */
|
|
@@ -157,6 +162,12 @@ export function effortLevels(cfg) {
|
|
|
157
162
|
providers[name] = { default: ANTHROPIC_EFFORT_LEVELS };
|
|
158
163
|
continue;
|
|
159
164
|
}
|
|
165
|
+
if (provider.type === "grok") {
|
|
166
|
+
// The proxy's listing gives each model its ladder (the probe saves it); a model it has not
|
|
167
|
+
// described gets the common one, which is also what the adapter sends it.
|
|
168
|
+
providers[name] = { default: [...GROK_EFFORT_LEVELS], ...(Object.keys(modelLevels).length ? { models: modelLevels } : {}) };
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
160
171
|
const preset = provider.preset ? PRESETS.find((entry) => entry.id === provider.preset) : undefined;
|
|
161
172
|
providers[name] = {
|
|
162
173
|
default: resolveCompatibleCaps(preset ? { effortLevels: preset.effortLevels, thinking: preset.thinking, ...(preset.serverTools ? { serverTools: true } : {}) } : undefined, provider.caps).effortLevels,
|
|
@@ -282,6 +293,14 @@ export function chatgptSignedIn(mode) {
|
|
|
282
293
|
const borrowed = fs.existsSync(path.join(os.homedir(), ".codex", "auth.json"));
|
|
283
294
|
return mode === "own" ? own : mode === "borrow-codex" ? borrowed : own || borrowed;
|
|
284
295
|
}
|
|
296
|
+
/**
|
|
297
|
+
* Whether the Grok CLI's session is there and not yet expired. A session near its end still counts:
|
|
298
|
+
* the router asks the CLI to refresh it before the next turn.
|
|
299
|
+
*/
|
|
300
|
+
export function grokSignedIn(p) {
|
|
301
|
+
const token = readGrokToken(grokAuthPath(p.home ?? grokHome()));
|
|
302
|
+
return token !== null && (token.expiresAt === 0 || token.expiresAt > Date.now());
|
|
303
|
+
}
|
|
285
304
|
/**
|
|
286
305
|
* An Antigravity provider is only as usable as its accounts: none signed in, or every one waiting on
|
|
287
306
|
* Google's verification page, answers nothing however reachable the host is.
|
|
@@ -393,8 +412,8 @@ async function withFilledLadder(deps, scores, settings) {
|
|
|
393
412
|
return settings;
|
|
394
413
|
try {
|
|
395
414
|
const choices = ladderChoices(await modOptions(deps, scores));
|
|
396
|
-
const { rungs } = fillLadder(general.rungs, settings.allow, choices);
|
|
397
|
-
const modes = settings.modes.map(mode => ({ ...mode, rungs: fillLadder(mode.rungs, settings.allow, choices).rungs }));
|
|
415
|
+
const { rungs } = fillLadder(general.rungs, settings.allow, choices, settings.allowKnown);
|
|
416
|
+
const modes = settings.modes.map(mode => ({ ...mode, rungs: fillLadder(mode.rungs, settings.allow, choices, settings.allowKnown).rungs }));
|
|
398
417
|
return { ...settings, rules: { general: { mode: "ladder", rungs } }, modes };
|
|
399
418
|
}
|
|
400
419
|
catch (error) {
|
|
@@ -409,7 +428,7 @@ async function buildStatus(deps, opts = {}) {
|
|
|
409
428
|
// Keyed in the order the config lists them. Assigning inside the Promise.all callbacks ordered
|
|
410
429
|
// them by whichever TCP check answered first, so the Health list reshuffled on every poll.
|
|
411
430
|
const checked = await Promise.all(Object.entries(cfg.providers).map(async ([name, p]) => {
|
|
412
|
-
const url = p.type === "anthropic" ? "https://api.anthropic.com" : p.type === "chatgpt" ? (p.url ?? "https://chatgpt.com/backend-api") : p.type === "google" ? (p.url ?? "https://generativelanguage.googleapis.com") : p.url;
|
|
431
|
+
const url = p.type === "anthropic" ? "https://api.anthropic.com" : p.type === "chatgpt" ? (p.url ?? "https://chatgpt.com/backend-api") : p.type === "google" ? (p.url ?? "https://generativelanguage.googleapis.com") : p.type === "grok" ? (p.url ?? GROK_DEFAULT_URL) : p.url;
|
|
413
432
|
let reachable = false;
|
|
414
433
|
try {
|
|
415
434
|
const u = new URL(url);
|
|
@@ -444,6 +463,7 @@ async function buildStatus(deps, opts = {}) {
|
|
|
444
463
|
// Reaching the host says nothing about being able to use it: a chatgpt provider with no
|
|
445
464
|
// credentials is not "connected", and calling it that sends the user off believing it works.
|
|
446
465
|
...(p.type === "chatgpt" ? { needsLogin: !chatgptSignedIn(p.auth) } : {}),
|
|
466
|
+
...(p.type === "grok" ? { needsLogin: !grokSignedIn(p) } : {}),
|
|
447
467
|
...(p.type === "google" && p.auth === "antigravity" ? googleHealth(deps.google?.().accounts?.()[name] ?? []) : {}),
|
|
448
468
|
...(p.type === "anthropic"
|
|
449
469
|
? {
|
|
@@ -843,6 +863,53 @@ async function probeGoogleAntigravity(deps) {
|
|
|
843
863
|
const models = named ? await deps.google?.().models?.(named).catch(() => null) ?? null : null;
|
|
844
864
|
return { ok: true, auth: "ok", models: models ?? staticAntigravityModels() };
|
|
845
865
|
}
|
|
866
|
+
/**
|
|
867
|
+
* `{type:"grok"}`: whether the Grok CLI's session is usable, and the models its chat proxy lists.
|
|
868
|
+
* The session goes through the same `GrokAuth` the adapter uses — one near its end is refreshed
|
|
869
|
+
* through the CLI here too — and the listing is asked with the headers live traffic sends, since a
|
|
870
|
+
* test that presents another auth set certifies a path nobody uses (§5).
|
|
871
|
+
*/
|
|
872
|
+
async function probeGrok(body, log, probeFetch = fetchWithTimeout) {
|
|
873
|
+
const text = (value) => typeof value === "string" && value ? value : undefined;
|
|
874
|
+
const home = text(body.home) ?? grokHome();
|
|
875
|
+
const cli = text(body.cli);
|
|
876
|
+
const clientVersion = text(body.clientVersion);
|
|
877
|
+
const fallback = (result) => ({ ...result, models: GROK_FALLBACK_MODELS, modelsSource: "fallback", warning: GROK_WARNING });
|
|
878
|
+
const versionProblem = clientVersion ? headerValueProblem(clientVersion) : null;
|
|
879
|
+
if (versionProblem)
|
|
880
|
+
return fallback({ ok: false, auth: "unknown", error: `clientVersion cannot be sent as a header: ${versionProblem}` });
|
|
881
|
+
// The adapter's own instance for this session, so a probe joins a refresh a turn started (and the
|
|
882
|
+
// reverse) instead of spawning a second CLI.
|
|
883
|
+
const auth = sharedGrokAuth({ home, ...(cli ? { cli } : {}), log });
|
|
884
|
+
let token;
|
|
885
|
+
try {
|
|
886
|
+
token = await auth.token();
|
|
887
|
+
}
|
|
888
|
+
catch (error) {
|
|
889
|
+
return fallback({ ok: false, auth: "missing", error: errorText(error) });
|
|
890
|
+
}
|
|
891
|
+
const base = (text(body.url) ?? GROK_DEFAULT_URL).replace(/\/+$/, "");
|
|
892
|
+
const { "x-grok-model-override": _route, ...headers } = grokHeaders(token.key, "", await auth.clientVersion(clientVersion));
|
|
893
|
+
let response;
|
|
894
|
+
try {
|
|
895
|
+
response = await probeFetch(`${base}/models`, { headers });
|
|
896
|
+
}
|
|
897
|
+
catch (error) {
|
|
898
|
+
// fetch quotes a header value it refuses, which here is the session token.
|
|
899
|
+
return fallback({ ok: false, auth: "unreachable", error: redactErrorText(errorText(error), [token.key]) });
|
|
900
|
+
}
|
|
901
|
+
if (response.status === 401 || response.status === 403) {
|
|
902
|
+
return fallback({ ok: false, auth: "bad-key", error: `the proxy refused the Grok session (HTTP ${response.status}): ${GROK_AUTH_HINT}` });
|
|
903
|
+
}
|
|
904
|
+
if (!response.ok) {
|
|
905
|
+
const detail = redactErrorText(await response.text().catch(() => ""), [token.key]);
|
|
906
|
+
return fallback({ ok: false, auth: "unknown", error: `HTTP ${response.status}${detail ? `: ${detail.slice(0, 300)}` : ""}` });
|
|
907
|
+
}
|
|
908
|
+
const models = grokModelsFromListing(await response.json().catch(() => null));
|
|
909
|
+
return models.length
|
|
910
|
+
? { ok: true, auth: "ok", models, modelsSource: "catalog", warning: GROK_WARNING }
|
|
911
|
+
: fallback({ ok: true, auth: "ok" });
|
|
912
|
+
}
|
|
846
913
|
function chatCompletionsUrl(base) {
|
|
847
914
|
return `${base.replace(/\/+$/, "")}/chat/completions`;
|
|
848
915
|
}
|
|
@@ -1532,6 +1599,10 @@ export function startAdmin(deps) {
|
|
|
1532
1599
|
});
|
|
1533
1600
|
return;
|
|
1534
1601
|
}
|
|
1602
|
+
if (probe.type === "grok") {
|
|
1603
|
+
sendJson(res, 200, await probeGrok(probe, deps.log, deps.probeFetch));
|
|
1604
|
+
return;
|
|
1605
|
+
}
|
|
1535
1606
|
if (probe.type === "google") {
|
|
1536
1607
|
if (probe.auth === "antigravity") {
|
|
1537
1608
|
sendJson(res, 200, await probeGoogleAntigravity(deps));
|
|
@@ -15,6 +15,7 @@ import path from "node:path";
|
|
|
15
15
|
import { adminPort, homeDir } from "./config.js";
|
|
16
16
|
import { declaredBy } from "./routing.js";
|
|
17
17
|
import { isJudgeOnlyModel } from "./model-policy.js";
|
|
18
|
+
import { GROK_EFFORT_LEVELS } from "./providers/grok/catalog.js";
|
|
18
19
|
import { shortName } from "../../cli/src/hooks/agent-title.js";
|
|
19
20
|
/** Where Claude Code's Agent tool reads its worker definitions. */
|
|
20
21
|
export function defaultAgentDir() {
|
|
@@ -145,6 +146,9 @@ export function agentNameFor(modelId) {
|
|
|
145
146
|
return modelId.replace(/[^a-z0-9-]/g, "-");
|
|
146
147
|
}
|
|
147
148
|
function capsEffortLevels(provider) {
|
|
149
|
+
// A grok provider has no caps: its adapter sends the common ladder to a model the listing left out.
|
|
150
|
+
if (provider.type === "grok")
|
|
151
|
+
return [...GROK_EFFORT_LEVELS];
|
|
148
152
|
return provider.caps?.effortLevels;
|
|
149
153
|
}
|
|
150
154
|
/**
|
|
@@ -153,6 +153,9 @@ export function headerValueProblem(value) {
|
|
|
153
153
|
// A newline would split the header; a stray one usually means a copied line rather than a key.
|
|
154
154
|
if (code === 0x0a || code === 0x0d)
|
|
155
155
|
return `there is a line break at character ${i + 1} — paste the key on its own, without the line around it`;
|
|
156
|
+
// fetch refuses a NUL too, so a value that passes here would only fail when sent.
|
|
157
|
+
if (code === 0x00)
|
|
158
|
+
return `there is a NUL character at character ${i + 1} — paste the key on its own`;
|
|
156
159
|
}
|
|
157
160
|
return null;
|
|
158
161
|
}
|
|
@@ -429,6 +432,27 @@ export function validate(c) {
|
|
|
429
432
|
errors.push(`provider ${name}: ${MODELS_SHAPE}`);
|
|
430
433
|
}
|
|
431
434
|
}
|
|
435
|
+
else if (p.type === "grok") {
|
|
436
|
+
for (const field of ["home", "cli", "clientVersion"]) {
|
|
437
|
+
if (p[field] !== undefined && (typeof p[field] !== "string" || !p[field]))
|
|
438
|
+
errors.push(`provider ${name}: ${field} must be a non-empty string`);
|
|
439
|
+
}
|
|
440
|
+
// Sent as `x-grok-client-version`, so it has to survive being a header value.
|
|
441
|
+
const versionProblem = typeof p.clientVersion === "string" && p.clientVersion ? headerValueProblem(p.clientVersion) : null;
|
|
442
|
+
if (versionProblem)
|
|
443
|
+
errors.push(`provider ${name}: clientVersion cannot be sent as a header: ${versionProblem}`);
|
|
444
|
+
if (p.url !== undefined && !/^https?:\/\//.test(p.url))
|
|
445
|
+
errors.push(`provider ${name}: url must start with http:// or https://`);
|
|
446
|
+
if (p.models !== undefined) {
|
|
447
|
+
if (!validModels(p.models))
|
|
448
|
+
errors.push(`provider ${name}: ${MODELS_SHAPE}`);
|
|
449
|
+
// `providerFor` turns a model with its own wire, url or auth header into an openai-compatible
|
|
450
|
+
// provider, which would go out without the session headers this one exists to add.
|
|
451
|
+
else if (p.models.some((m) => m.wire !== undefined || m.url !== undefined || m.authHeader !== undefined)) {
|
|
452
|
+
errors.push(`provider ${name}: a grok model cannot set wire, url or authHeader`);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
}
|
|
432
456
|
else {
|
|
433
457
|
errors.push(`provider ${name}: unknown type "${p.type}"`);
|
|
434
458
|
}
|
|
@@ -332,8 +332,8 @@ export class OpenAiIngress {
|
|
|
332
332
|
// is an OpenAI wire, which the refusal below would otherwise turn away on the provider's type
|
|
333
333
|
// alone. One subscription is one provider, so the model decides.
|
|
334
334
|
const provider = providerFor(configured, route.model);
|
|
335
|
-
if (provider.type === "chatgpt" || provider.type === "openai-compatible" || provider.type === "google") {
|
|
336
|
-
const label = provider.type === "chatgpt" ? "ChatGPT" : provider.type === "google" ? "Google" : "OpenAI-compatible";
|
|
335
|
+
if (provider.type === "chatgpt" || provider.type === "openai-compatible" || provider.type === "google" || provider.type === "grok") {
|
|
336
|
+
const label = provider.type === "chatgpt" ? "ChatGPT" : provider.type === "google" ? "Google" : provider.type === "grok" ? "Grok" : "OpenAI-compatible";
|
|
337
337
|
const bytes = sendJson(res, 400, openAiError(`${label} provider is not available through OpenAI ingress`, "invalid_request_error", "unsupported_provider"));
|
|
338
338
|
finish(400, bytes, { note: `model ${requested} -> ${provider.type} provider unsupported` });
|
|
339
339
|
return;
|