clauderipple 0.9.0 → 0.9.2

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 (40) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/README.es.md +22 -11
  3. package/README.ja.md +14 -5
  4. package/README.ko.md +19 -8
  5. package/README.md +22 -10
  6. package/README.pt-BR.md +22 -11
  7. package/README.zh-CN.md +14 -5
  8. package/dist/app/package.json +1 -1
  9. package/dist/cli/src/index.js +2 -1
  10. package/dist/mod/hooks/i18n.ts +36 -12
  11. package/dist/mod/hooks/judge.ts +17 -1
  12. package/dist/mod/hooks/register.tsx +39 -9
  13. package/dist/mod/hooks/session.ts +11 -3
  14. package/dist/mod/hooks/ui/alerts.tsx +21 -5
  15. package/dist/mod/hooks/ui/band.tsx +1 -1
  16. package/dist/mod/hooks/ui/view.ts +4 -1
  17. package/dist/mod/tests/register.test.tsx +53 -0
  18. package/dist/mod/tests/ui.test.tsx +10 -6
  19. package/dist/mod/types/index.d.ts +4 -1
  20. package/dist/router/src/admin.js +72 -2
  21. package/dist/router/src/agents.js +4 -0
  22. package/dist/router/src/config.js +24 -0
  23. package/dist/router/src/ingress/server.js +2 -2
  24. package/dist/router/src/mod-settings.js +2 -1
  25. package/dist/router/src/providers/chatgpt/index.js +19 -6
  26. package/dist/router/src/providers/chatgpt/translate.js +3 -0
  27. package/dist/router/src/providers/google/index.js +4 -3
  28. package/dist/router/src/providers/grok/auth.js +289 -0
  29. package/dist/router/src/providers/grok/catalog.js +52 -0
  30. package/dist/router/src/providers/grok/index.js +94 -0
  31. package/dist/router/src/providers/openai/index.js +71 -17
  32. package/dist/router/src/providers/openai/translate.js +11 -1
  33. package/dist/router/src/proxy.js +91 -21
  34. package/dist/router/src/redact.js +7 -2
  35. package/dist/router/src/requestlog.js +78 -20
  36. package/dist/router/src/version.js +1 -1
  37. package/dist/ui/app.js +105 -0
  38. package/dist/ui/i18n.js +6 -6
  39. package/docs/ARCHITECTURE.md +91 -0
  40. package/package.json +1 -1
@@ -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)
@@ -301,12 +301,15 @@ declare module 'claude-code' {
301
301
  agents: AgentRec[]
302
302
  cache: CacheMemo | null
303
303
  warm: WarmState
304
+ /** Keep-warm for this chat alone, set by the band's flame; null follows Settings. */
305
+ warmHere: boolean | null
304
306
  context: { percent: number; tokens: number; window: number } | null
305
307
  hot: Hot | null
306
308
  hotHidden: number | null
307
309
  saveUntil: number | null
308
310
  swampHidden: number | null
309
311
  coldHidden: boolean
312
+ kept: { pings: number; tokens: number } | null
310
313
  judgeDown: string | null
311
314
  advice: string | null
312
315
  handoff: { stage: HandoffStage; kind: HandoffKind; text?: string } | null
@@ -345,7 +348,7 @@ declare module 'claude-code' {
345
348
  judgeRuns: JudgeRun[]
346
349
  judgeDownNetwork: boolean
347
350
  limit: Hot | null
348
- demo: 'cold' | 'swamp' | 'hot' | 'down' | null
351
+ demo: 'cold' | 'swamp' | 'hot' | 'down' | 'kept' | null
349
352
  }
350
353
  }
351
354
  }
@@ -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;
@@ -157,6 +161,12 @@ export function effortLevels(cfg) {
157
161
  providers[name] = { default: ANTHROPIC_EFFORT_LEVELS };
158
162
  continue;
159
163
  }
164
+ if (provider.type === "grok") {
165
+ // The proxy's listing gives each model its ladder (the probe saves it); a model it has not
166
+ // described gets the common one, which is also what the adapter sends it.
167
+ providers[name] = { default: [...GROK_EFFORT_LEVELS], ...(Object.keys(modelLevels).length ? { models: modelLevels } : {}) };
168
+ continue;
169
+ }
160
170
  const preset = provider.preset ? PRESETS.find((entry) => entry.id === provider.preset) : undefined;
161
171
  providers[name] = {
162
172
  default: resolveCompatibleCaps(preset ? { effortLevels: preset.effortLevels, thinking: preset.thinking, ...(preset.serverTools ? { serverTools: true } : {}) } : undefined, provider.caps).effortLevels,
@@ -282,6 +292,14 @@ export function chatgptSignedIn(mode) {
282
292
  const borrowed = fs.existsSync(path.join(os.homedir(), ".codex", "auth.json"));
283
293
  return mode === "own" ? own : mode === "borrow-codex" ? borrowed : own || borrowed;
284
294
  }
295
+ /**
296
+ * Whether the Grok CLI's session is there and not yet expired. A session near its end still counts:
297
+ * the router asks the CLI to refresh it before the next turn.
298
+ */
299
+ export function grokSignedIn(p) {
300
+ const token = readGrokToken(grokAuthPath(p.home ?? grokHome()));
301
+ return token !== null && (token.expiresAt === 0 || token.expiresAt > Date.now());
302
+ }
285
303
  /**
286
304
  * An Antigravity provider is only as usable as its accounts: none signed in, or every one waiting on
287
305
  * Google's verification page, answers nothing however reachable the host is.
@@ -409,7 +427,7 @@ async function buildStatus(deps, opts = {}) {
409
427
  // Keyed in the order the config lists them. Assigning inside the Promise.all callbacks ordered
410
428
  // them by whichever TCP check answered first, so the Health list reshuffled on every poll.
411
429
  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;
430
+ 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
431
  let reachable = false;
414
432
  try {
415
433
  const u = new URL(url);
@@ -444,6 +462,7 @@ async function buildStatus(deps, opts = {}) {
444
462
  // Reaching the host says nothing about being able to use it: a chatgpt provider with no
445
463
  // credentials is not "connected", and calling it that sends the user off believing it works.
446
464
  ...(p.type === "chatgpt" ? { needsLogin: !chatgptSignedIn(p.auth) } : {}),
465
+ ...(p.type === "grok" ? { needsLogin: !grokSignedIn(p) } : {}),
447
466
  ...(p.type === "google" && p.auth === "antigravity" ? googleHealth(deps.google?.().accounts?.()[name] ?? []) : {}),
448
467
  ...(p.type === "anthropic"
449
468
  ? {
@@ -843,6 +862,53 @@ async function probeGoogleAntigravity(deps) {
843
862
  const models = named ? await deps.google?.().models?.(named).catch(() => null) ?? null : null;
844
863
  return { ok: true, auth: "ok", models: models ?? staticAntigravityModels() };
845
864
  }
865
+ /**
866
+ * `{type:"grok"}`: whether the Grok CLI's session is usable, and the models its chat proxy lists.
867
+ * The session goes through the same `GrokAuth` the adapter uses — one near its end is refreshed
868
+ * through the CLI here too — and the listing is asked with the headers live traffic sends, since a
869
+ * test that presents another auth set certifies a path nobody uses (§5).
870
+ */
871
+ async function probeGrok(body, log, probeFetch = fetchWithTimeout) {
872
+ const text = (value) => typeof value === "string" && value ? value : undefined;
873
+ const home = text(body.home) ?? grokHome();
874
+ const cli = text(body.cli);
875
+ const clientVersion = text(body.clientVersion);
876
+ const fallback = (result) => ({ ...result, models: GROK_FALLBACK_MODELS, modelsSource: "fallback", warning: GROK_WARNING });
877
+ const versionProblem = clientVersion ? headerValueProblem(clientVersion) : null;
878
+ if (versionProblem)
879
+ return fallback({ ok: false, auth: "unknown", error: `clientVersion cannot be sent as a header: ${versionProblem}` });
880
+ // The adapter's own instance for this session, so a probe joins a refresh a turn started (and the
881
+ // reverse) instead of spawning a second CLI.
882
+ const auth = sharedGrokAuth({ home, ...(cli ? { cli } : {}), log });
883
+ let token;
884
+ try {
885
+ token = await auth.token();
886
+ }
887
+ catch (error) {
888
+ return fallback({ ok: false, auth: "missing", error: errorText(error) });
889
+ }
890
+ const base = (text(body.url) ?? GROK_DEFAULT_URL).replace(/\/+$/, "");
891
+ const { "x-grok-model-override": _route, ...headers } = grokHeaders(token.key, "", await auth.clientVersion(clientVersion));
892
+ let response;
893
+ try {
894
+ response = await probeFetch(`${base}/models`, { headers });
895
+ }
896
+ catch (error) {
897
+ // fetch quotes a header value it refuses, which here is the session token.
898
+ return fallback({ ok: false, auth: "unreachable", error: redactErrorText(errorText(error), [token.key]) });
899
+ }
900
+ if (response.status === 401 || response.status === 403) {
901
+ return fallback({ ok: false, auth: "bad-key", error: `the proxy refused the Grok session (HTTP ${response.status}): ${GROK_AUTH_HINT}` });
902
+ }
903
+ if (!response.ok) {
904
+ const detail = redactErrorText(await response.text().catch(() => ""), [token.key]);
905
+ return fallback({ ok: false, auth: "unknown", error: `HTTP ${response.status}${detail ? `: ${detail.slice(0, 300)}` : ""}` });
906
+ }
907
+ const models = grokModelsFromListing(await response.json().catch(() => null));
908
+ return models.length
909
+ ? { ok: true, auth: "ok", models, modelsSource: "catalog", warning: GROK_WARNING }
910
+ : fallback({ ok: true, auth: "ok" });
911
+ }
846
912
  function chatCompletionsUrl(base) {
847
913
  return `${base.replace(/\/+$/, "")}/chat/completions`;
848
914
  }
@@ -1532,6 +1598,10 @@ export function startAdmin(deps) {
1532
1598
  });
1533
1599
  return;
1534
1600
  }
1601
+ if (probe.type === "grok") {
1602
+ sendJson(res, 200, await probeGrok(probe, deps.log, deps.probeFetch));
1603
+ return;
1604
+ }
1535
1605
  if (probe.type === "google") {
1536
1606
  if (probe.auth === "antigravity") {
1537
1607
  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;
@@ -80,7 +80,8 @@ function backupModSettings(file, previousRevision) {
80
80
  const backup = `${file}.bak-${previousRevision}`;
81
81
  fs.copyFileSync(file, backup);
82
82
  fs.chmodSync(backup, 0o600);
83
- const fd = fs.openSync(backup, "r");
83
+ // Windows refuses to flush a read-only handle (EPERM, #53), so the backup is opened for writing.
84
+ const fd = fs.openSync(backup, "r+");
84
85
  try {
85
86
  fs.fsyncSync(fd);
86
87
  }
@@ -1039,8 +1039,9 @@ export class ChatGptAdapter {
1039
1039
  }
1040
1040
  res.writeHead(err.status, { "content-type": "application/json", ...(retryAfterSeconds ? { "retry-after": String(retryAfterSeconds) } : {}) }).end(err.body);
1041
1041
  return { status: err.status, bytes: err.body.length, note,
1042
- ...(sent.kind === "refused" ? { exhaustion: providerExhaustion(sent.status, sent.text, sent.retryAfterSeconds === undefined ? undefined : sent.retryAfterSeconds * 1000) } : {}),
1043
- };
1042
+ ...(sent.kind === "no-account" ? { upstreamCalled: false, refusal: "no-account" } : {}),
1043
+ ...(sent.kind === "refused" && upstreamReq.service_tier !== undefined ? { requestedTier: upstreamReq.service_tier } : {}),
1044
+ ...(sent.kind === "refused" ? { exhaustion: providerExhaustion(sent.status, sent.text, sent.retryAfterSeconds === undefined ? undefined : sent.retryAfterSeconds * 1000) } : {}), };
1044
1045
  }
1045
1046
  const { upstream, credential } = sent;
1046
1047
  const nextTurnState = upstream.headers.get("x-codex-turn-state");
@@ -1058,6 +1059,7 @@ export class ChatGptAdapter {
1058
1059
  let ping = null;
1059
1060
  // Keep early rejection as an HTTP error until an actual content block arrives.
1060
1061
  // A 200 followed by an error loses the status Claude uses for overflow recovery.
1062
+ let messageSent = false;
1061
1063
  const pendingEvents = [];
1062
1064
  const openStream = () => {
1063
1065
  res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache", connection: "keep-alive" });
@@ -1073,8 +1075,12 @@ export class ChatGptAdapter {
1073
1075
  return;
1074
1076
  openStream();
1075
1077
  }
1076
- for (const ev of pendingEvents.splice(0))
1077
- bytes += write(res, formatSse(ev));
1078
+ for (const ev of pendingEvents.splice(0)) {
1079
+ const written = write(res, formatSse(ev));
1080
+ bytes += written;
1081
+ if (ev.event === "message_start" && written > 0)
1082
+ messageSent = true;
1083
+ }
1078
1084
  };
1079
1085
  // The hold waits for upstream's first item, which normally follows the request within a second
1080
1086
  // — and an overflow rejection comes before it. Should it not come, the client must still hear
@@ -1086,8 +1092,12 @@ export class ChatGptAdapter {
1086
1092
  if (res.headersSent || res.writableEnded || mapper.failure)
1087
1093
  return;
1088
1094
  openStream();
1089
- for (const ev of [...pendingEvents.splice(0), ...mapper.start()])
1090
- bytes += write(res, formatSse(ev));
1095
+ for (const ev of [...pendingEvents.splice(0), ...mapper.start()]) {
1096
+ const written = write(res, formatSse(ev));
1097
+ bytes += written;
1098
+ if (ev.event === "message_start" && written > 0)
1099
+ messageSent = true;
1100
+ }
1091
1101
  }, streamHold.ms);
1092
1102
  ping = setInterval(() => {
1093
1103
  if (res.headersSent && !res.writableEnded)
@@ -1170,6 +1180,9 @@ export class ChatGptAdapter {
1170
1180
  output: u.output_tokens,
1171
1181
  },
1172
1182
  stopReason: mapper.stopReason,
1183
+ ...((!wantStream && !failure) || messageSent ? { messageId: mapper.messageId } : {}),
1184
+ ...(upstreamReq.service_tier !== undefined ? { requestedTier: upstreamReq.service_tier } : {}),
1185
+ ...(mapper.servedTier !== undefined ? { servedTier: mapper.servedTier } : {}),
1173
1186
  };
1174
1187
  }
1175
1188
  }
@@ -352,6 +352,7 @@ export class StreamMapper {
352
352
  sawToolCall = false;
353
353
  finished = false;
354
354
  messageId;
355
+ servedTier;
355
356
  model;
356
357
  usage = { input_tokens: 0, output_tokens: 0, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 };
357
358
  /** Latest `codex.rate_limits` payload, if the backend sent one. */
@@ -493,6 +494,8 @@ export class StreamMapper {
493
494
  case "response.completed":
494
495
  case "response.incomplete": {
495
496
  const r = ev.response;
497
+ if (typeof r?.service_tier === "string")
498
+ this.servedTier = r.service_tier;
496
499
  const u = r?.usage;
497
500
  if (u) {
498
501
  const cached = u.input_tokens_details?.cached_tokens ?? 0;
@@ -281,7 +281,7 @@ export class GoogleAdapter {
281
281
  if (!apiKey) {
282
282
  const out = anthropicError(401, "authentication_error", "Google provider: no API key; set apiKey or GEMINI_API_KEY");
283
283
  res.writeHead(out.status, { "content-type": "application/json", "content-length": String(Buffer.byteLength(out.body)) }).end(out.body);
284
- return { status: out.status, bytes: Buffer.byteLength(out.body), note: "no api key" };
284
+ return { status: out.status, bytes: Buffer.byteLength(out.body), note: "no api key", upstreamCalled: false, refusal: "no-account" };
285
285
  }
286
286
  let built;
287
287
  try {
@@ -386,8 +386,8 @@ export class GoogleAdapter {
386
386
  }
387
387
  res.writeHead(err.status, { "content-type": "application/json", ...(retryAfterSeconds ? { "retry-after": String(retryAfterSeconds) } : {}) }).end(err.body);
388
388
  return { status: err.status, bytes: Buffer.byteLength(err.body), note,
389
- ...(sent.kind === "refused" ? { exhaustion: providerExhaustion(sent.status, sent.text, sent.retryAfterSeconds === undefined ? undefined : sent.retryAfterSeconds * 1000) } : {}),
390
- };
389
+ ...(sent.kind === "no-account" ? { upstreamCalled: false, refusal: "no-account" } : {}),
390
+ ...(sent.kind === "refused" ? { exhaustion: providerExhaustion(sent.status, sent.text, sent.retryAfterSeconds === undefined ? undefined : sent.retryAfterSeconds * 1000) } : {}), };
391
391
  }
392
392
  return this.streamBack(res, sent.upstream, transport, json, model, wantStream, key, startInput, controller, onClose);
393
393
  }
@@ -494,6 +494,7 @@ export class GoogleAdapter {
494
494
  : `in=${usage.input_tokens} cached=${usage.cache_read_input_tokens} out=${usage.output_tokens} stop=${mapper.stopReason}`,
495
495
  usage: { input: usage.input_tokens, cached: usage.cache_read_input_tokens, output: usage.output_tokens },
496
496
  stopReason: mapper.stopReason,
497
+ ...(wantStream || !failure ? { messageId: mapper.messageId } : {}),
497
498
  };
498
499
  }
499
500
  /**