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.
- package/CHANGELOG.md +49 -0
- package/README.es.md +22 -11
- package/README.ja.md +14 -5
- package/README.ko.md +19 -8
- package/README.md +22 -10
- package/README.pt-BR.md +22 -11
- package/README.zh-CN.md +14 -5
- package/dist/app/package.json +1 -1
- package/dist/cli/src/index.js +2 -1
- package/dist/mod/hooks/i18n.ts +36 -12
- package/dist/mod/hooks/judge.ts +17 -1
- package/dist/mod/hooks/register.tsx +39 -9
- package/dist/mod/hooks/session.ts +11 -3
- package/dist/mod/hooks/ui/alerts.tsx +21 -5
- package/dist/mod/hooks/ui/band.tsx +1 -1
- package/dist/mod/hooks/ui/view.ts +4 -1
- package/dist/mod/tests/register.test.tsx +53 -0
- package/dist/mod/tests/ui.test.tsx +10 -6
- package/dist/mod/types/index.d.ts +4 -1
- package/dist/router/src/admin.js +72 -2
- 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/mod-settings.js +2 -1
- package/dist/router/src/providers/chatgpt/index.js +19 -6
- package/dist/router/src/providers/chatgpt/translate.js +3 -0
- package/dist/router/src/providers/google/index.js +4 -3
- 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 +71 -17
- package/dist/router/src/providers/openai/translate.js +11 -1
- package/dist/router/src/proxy.js +91 -21
- package/dist/router/src/redact.js +7 -2
- package/dist/router/src/requestlog.js +78 -20
- package/dist/router/src/version.js +1 -1
- package/dist/ui/app.js +105 -0
- package/dist/ui/i18n.js +6 -6
- package/docs/ARCHITECTURE.md +91 -0
- 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, '
|
|
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)
|
|
@@ -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
|
}
|
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;
|
|
@@ -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
|
-
|
|
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 === "
|
|
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
|
-
|
|
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
|
-
|
|
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 === "
|
|
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
|
/**
|