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.
Files changed (41) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/README.es.md +1 -0
  3. package/README.ja.md +1 -0
  4. package/README.ko.md +1 -0
  5. package/README.md +1 -0
  6. package/README.pt-BR.md +1 -0
  7. package/README.zh-CN.md +1 -0
  8. package/dist/app/package.json +1 -1
  9. package/dist/cli/src/index.js +2 -1
  10. package/dist/mod/hooks/i18n.ts +42 -12
  11. package/dist/mod/hooks/ladder.js +5 -5
  12. package/dist/mod/hooks/ladder.ts +5 -5
  13. package/dist/mod/hooks/register.tsx +58 -15
  14. package/dist/mod/hooks/session.ts +37 -3
  15. package/dist/mod/hooks/settings.js +13 -11
  16. package/dist/mod/hooks/settings.ts +13 -11
  17. package/dist/mod/hooks/ui/alerts.tsx +17 -4
  18. package/dist/mod/hooks/ui/art.ts +2 -2
  19. package/dist/mod/hooks/ui/band.tsx +1 -1
  20. package/dist/mod/hooks/ui/kit.tsx +15 -2
  21. package/dist/mod/hooks/ui/view.ts +4 -1
  22. package/dist/mod/hooks/workers.ts +8 -8
  23. package/dist/mod/tests/register.test.tsx +63 -0
  24. package/dist/mod/tests/ui.test.tsx +10 -6
  25. package/dist/mod/types/index.d.ts +10 -1
  26. package/dist/router/src/admin.js +76 -5
  27. package/dist/router/src/agents.js +4 -0
  28. package/dist/router/src/config.js +24 -0
  29. package/dist/router/src/ingress/server.js +2 -2
  30. package/dist/router/src/providers/grok/auth.js +289 -0
  31. package/dist/router/src/providers/grok/catalog.js +52 -0
  32. package/dist/router/src/providers/grok/index.js +94 -0
  33. package/dist/router/src/providers/openai/index.js +69 -17
  34. package/dist/router/src/providers/openai/translate.js +6 -1
  35. package/dist/router/src/proxy.js +43 -0
  36. package/dist/router/src/redact.js +7 -2
  37. package/dist/router/src/version.js +1 -1
  38. package/dist/ui/app.js +115 -6
  39. package/dist/ui/i18n.js +6 -6
  40. package/docs/ARCHITECTURE.md +78 -0
  41. 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, 'hot', (el, surface) => renderAlert(el, frameOf(surface), ALERTS[2]!, actions))
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[10]!, actions))
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 compacted share the pleased face, compactNote and handoffChoice the asking
358
- // one; every other state has its own.
359
- expect(new Set(sources).size).toBe(ALERTS.length - 2)
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
  }
@@ -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
- const ANTHROPIC_EFFORT_LEVELS = ["low", "medium", "high", "max"];
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;