Skip to content

Commit 405ef3f

Browse files
authored
Merge pull request #91 from ndycode/feat/prompt-cache-doctor
feat: add prompt cache diagnostics to codex-doctor
2 parents 5fccac0 + 9fc4001 commit 405ef3f

File tree

3 files changed

+190
-21
lines changed

3 files changed

+190
-21
lines changed

index.ts

Lines changed: 59 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,7 @@ import { paintUiText, formatUiBadge, formatUiHeader, formatUiItem, formatUiKeyVa
158158
import {
159159
buildBeginnerChecklist,
160160
buildBeginnerDoctorFindings,
161+
formatPromptCacheSnapshot,
161162
recommendBeginnerNextAction,
162163
summarizeBeginnerAccounts,
163164
type BeginnerAccountSnapshot,
@@ -278,6 +279,9 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => {
278279
lastRequestAt: number | null;
279280
lastError: string | null;
280281
lastErrorCategory: string | null;
282+
promptCacheEnabledRequests: number;
283+
promptCacheMissingRequests: number;
284+
lastPromptCacheKey: string | null;
281285
lastSelectedAccountIndex: number | null;
282286
lastQuotaKey: string | null;
283287
lastSelectionSnapshot: SelectionSnapshot | null;
@@ -304,6 +308,9 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => {
304308
lastRequestAt: null,
305309
lastError: null,
306310
lastErrorCategory: null,
311+
promptCacheEnabledRequests: 0,
312+
promptCacheMissingRequests: 0,
313+
lastPromptCacheKey: null,
307314
lastSelectedAccountIndex: null,
308315
lastQuotaKey: null,
309316
lastSelectionSnapshot: null,
@@ -1373,6 +1380,9 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => {
13731380
serverErrors: runtimeMetrics.serverErrors,
13741381
networkErrors: runtimeMetrics.networkErrors,
13751382
lastErrorCategory: runtimeMetrics.lastErrorCategory,
1383+
promptCacheEnabledRequests: runtimeMetrics.promptCacheEnabledRequests,
1384+
promptCacheMissingRequests: runtimeMetrics.promptCacheMissingRequests,
1385+
lastPromptCacheKey: runtimeMetrics.lastPromptCacheKey,
13761386
});
13771387

13781388
const formatDoctorSeverity = (
@@ -2025,6 +2035,12 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => {
20252035
threadIdCandidate ? `${threadIdCandidate}:${Date.now()}` : undefined,
20262036
);
20272037
runtimeMetrics.lastRequestAt = Date.now();
2038+
runtimeMetrics.lastPromptCacheKey = promptCacheKey ?? null;
2039+
if (promptCacheKey) {
2040+
runtimeMetrics.promptCacheEnabledRequests++;
2041+
} else {
2042+
runtimeMetrics.promptCacheMissingRequests++;
2043+
}
20282044
const retryBudget = new RetryBudgetTracker(retryBudgetLimits);
20292045
const consumeRetryBudget = (
20302046
bucket: RetryBudgetClass,
@@ -2313,20 +2329,31 @@ while (attempted.size < Math.max(1, accountCount)) {
23132329
: null;
23142330

23152331
if (abortSignal?.aborted) {
2316-
clearTimeout(fetchTimeoutId);
2317-
fetchController.abort(abortSignal.reason ?? new Error("Aborted by user"));
2318-
} else if (abortSignal && onUserAbort) {
2319-
abortSignal.addEventListener("abort", onUserAbort, { once: true });
2320-
}
2332+
clearTimeout(fetchTimeoutId);
2333+
fetchController.abort(abortSignal.reason ?? new Error("Aborted by user"));
2334+
} else if (abortSignal && onUserAbort) {
2335+
abortSignal.addEventListener("abort", onUserAbort, { once: true });
2336+
}
23212337

2322-
try {
2338+
try {
2339+
// Request metrics are tracked at the fetch boundary, so retries and
2340+
// account rotation are counted consistently. These increments are
2341+
// in-memory only and run on Node's single-threaded event loop, so no
2342+
// filesystem locking or token-redaction concerns are introduced here.
23232343
runtimeMetrics.totalRequests++;
23242344
response = await fetch(url, {
23252345
...requestInit,
23262346
headers,
23272347
signal: fetchController.signal,
23282348
});
2329-
} catch (networkError) {
2349+
} catch (networkError) {
2350+
if (abortSignal?.aborted && fetchController.signal.aborted) {
2351+
accountManager.refundToken(account, modelFamily, model);
2352+
if (networkError instanceof Error) {
2353+
throw networkError;
2354+
}
2355+
throw new Error(String(networkError));
2356+
}
23302357
const errorMsg = networkError instanceof Error ? networkError.message : String(networkError);
23312358
logWarn(`Network error for account ${account.index + 1}: ${errorMsg}`);
23322359
if (
@@ -2359,21 +2386,21 @@ while (attempted.size < Math.max(1, accountCount)) {
23592386
accountManager.refundToken(account, modelFamily, model);
23602387
accountManager.recordFailure(account, modelFamily, model);
23612388
break;
2362-
} finally {
2363-
clearTimeout(fetchTimeoutId);
2364-
if (abortSignal && onUserAbort) {
2365-
abortSignal.removeEventListener("abort", onUserAbort);
2366-
}
2389+
} finally {
2390+
clearTimeout(fetchTimeoutId);
2391+
if (abortSignal && onUserAbort) {
2392+
abortSignal.removeEventListener("abort", onUserAbort);
23672393
}
2368-
const fetchLatencyMs = Math.round(performance.now() - fetchStart);
2369-
2370-
logRequest(LOG_STAGES.RESPONSE, {
2371-
status: response.status,
2372-
ok: response.ok,
2373-
statusText: response.statusText,
2374-
latencyMs: fetchLatencyMs,
2375-
headers: Object.fromEntries(response.headers.entries()),
2376-
});
2394+
}
2395+
const fetchLatencyMs = Math.round(performance.now() - fetchStart);
2396+
2397+
logRequest(LOG_STAGES.RESPONSE, {
2398+
status: response.status,
2399+
ok: response.ok,
2400+
statusText: response.statusText,
2401+
latencyMs: fetchLatencyMs,
2402+
headers: Object.fromEntries(response.headers.entries()),
2403+
});
23772404

23782405
if (!response.ok) {
23792406
const contextOverflowResult = await handleContextOverflow(response, model);
@@ -5197,6 +5224,14 @@ while (attempted.size < Math.max(1, accountCount)) {
51975224
"muted",
51985225
),
51995226
);
5227+
lines.push(
5228+
formatUiKeyValue(
5229+
ui,
5230+
"Prompt cache",
5231+
formatPromptCacheSnapshot(runtime),
5232+
"muted",
5233+
),
5234+
);
52005235
}
52015236

52025237
return lines.join("\n");
@@ -5236,6 +5271,9 @@ while (attempted.size < Math.max(1, accountCount)) {
52365271
lines.push(
52375272
` Runtime failures: failed=${runtime.failedRequests}, rateLimited=${runtime.rateLimitedResponses}, authRefreshFailed=${runtime.authRefreshFailures}, server=${runtime.serverErrors}, network=${runtime.networkErrors}`,
52385273
);
5274+
lines.push(
5275+
` Prompt cache: ${formatPromptCacheSnapshot(runtime)}`,
5276+
);
52395277
}
52405278
return lines.join("\n");
52415279
},

lib/ui/beginner.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { createHash } from "node:crypto";
2+
13
export type BeginnerDiagnosticSeverity = "ok" | "warning" | "error";
24

35
export interface BeginnerAccountSnapshot {
@@ -18,6 +20,9 @@ export interface BeginnerRuntimeSnapshot {
1820
serverErrors: number;
1921
networkErrors: number;
2022
lastErrorCategory: string | null;
23+
promptCacheEnabledRequests: number;
24+
promptCacheMissingRequests: number;
25+
lastPromptCacheKey: string | null;
2126
}
2227

2328
export interface BeginnerChecklistItem {
@@ -47,6 +52,20 @@ export interface BeginnerAccountSummary {
4752
unlabeled: number;
4853
}
4954

55+
export function formatPromptCacheKey(value: string | null | undefined): string {
56+
const normalized = value?.trim();
57+
if (!normalized) return "none";
58+
const fingerprint = createHash("sha256").update(normalized).digest("hex").slice(0, 12);
59+
return `masked-${fingerprint}`;
60+
}
61+
62+
export function formatPromptCacheSnapshot(runtime: Pick<
63+
BeginnerRuntimeSnapshot,
64+
"promptCacheEnabledRequests" | "promptCacheMissingRequests" | "lastPromptCacheKey"
65+
>): string {
66+
return `enabled=${runtime.promptCacheEnabledRequests}, missing=${runtime.promptCacheMissingRequests}, lastKey=${formatPromptCacheKey(runtime.lastPromptCacheKey)}`;
67+
}
68+
5069
export function summarizeBeginnerAccounts(
5170
accounts: BeginnerAccountSnapshot[],
5271
now: number,
@@ -241,6 +260,27 @@ export function buildBeginnerDoctorFindings(input: {
241260
});
242261
}
243262

263+
if (input.runtime.totalRequests > 0) {
264+
if (input.runtime.promptCacheEnabledRequests === 0) {
265+
findings.push({
266+
severity: "warning",
267+
code: "prompt-cache-missing",
268+
summary: "Recent requests did not include a prompt cache key.",
269+
action:
270+
"Use a session-backed OpenCode flow that forwards `prompt_cache_key` so Codex prompt caching can engage.",
271+
});
272+
} else if (input.runtime.promptCacheMissingRequests > 0) {
273+
findings.push({
274+
severity: "warning",
275+
code: "prompt-cache-inconsistent",
276+
summary:
277+
"Prompt cache keys were present for some recent requests but missing for others.",
278+
action:
279+
"Keep requests on a stable session/thread path so `prompt_cache_key` stays consistent across turns.",
280+
});
281+
}
282+
}
283+
244284
const failureRate = getFailureRate(input.runtime);
245285
if (input.runtime.totalRequests >= 6 && failureRate >= 0.5) {
246286
findings.push({

test/beginner-ui.test.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
import { describe, it, expect } from "vitest";
2+
import { createHash } from "node:crypto";
3+
24
import {
35
buildBeginnerChecklist,
46
buildBeginnerDoctorFindings,
57
explainRuntimeErrorCategory,
8+
formatPromptCacheKey,
9+
formatPromptCacheSnapshot,
610
recommendBeginnerNextAction,
711
summarizeBeginnerAccounts,
812
type BeginnerAccountSnapshot,
@@ -19,6 +23,9 @@ const healthyRuntime: BeginnerRuntimeSnapshot = {
1923
serverErrors: 0,
2024
networkErrors: 0,
2125
lastErrorCategory: null,
26+
promptCacheEnabledRequests: 12,
27+
promptCacheMissingRequests: 0,
28+
lastPromptCacheKey: "ses_prompt_cache",
2229
};
2330

2431
function buildAccount(
@@ -117,6 +124,55 @@ describe("buildBeginnerDoctorFindings", () => {
117124
expect(findings.some((f) => f.code === "auth-refresh-failures")).toBe(true);
118125
expect(findings.some((f) => f.code === "recent-error-category")).toBe(true);
119126
});
127+
128+
it("flags missing prompt cache keys when recent requests never supplied one", () => {
129+
const findings = buildBeginnerDoctorFindings({
130+
accounts: [buildAccount()],
131+
now,
132+
runtime: {
133+
...healthyRuntime,
134+
totalRequests: 5,
135+
promptCacheEnabledRequests: 0,
136+
promptCacheMissingRequests: 5,
137+
lastPromptCacheKey: null,
138+
},
139+
});
140+
141+
expect(findings.some((f) => f.code === "prompt-cache-missing")).toBe(true);
142+
});
143+
144+
it("flags inconsistent prompt cache usage when only some requests had keys", () => {
145+
const findings = buildBeginnerDoctorFindings({
146+
accounts: [buildAccount()],
147+
now,
148+
runtime: {
149+
...healthyRuntime,
150+
totalRequests: 6,
151+
promptCacheEnabledRequests: 4,
152+
promptCacheMissingRequests: 2,
153+
lastPromptCacheKey: null,
154+
},
155+
});
156+
157+
expect(findings.some((f) => f.code === "prompt-cache-inconsistent")).toBe(true);
158+
});
159+
160+
it("does not flag cache issues when no requests have been made", () => {
161+
const findings = buildBeginnerDoctorFindings({
162+
accounts: [buildAccount()],
163+
now,
164+
runtime: {
165+
...healthyRuntime,
166+
totalRequests: 0,
167+
promptCacheEnabledRequests: 0,
168+
promptCacheMissingRequests: 3,
169+
lastPromptCacheKey: null,
170+
},
171+
});
172+
173+
expect(findings.some((f) => f.code === "prompt-cache-missing")).toBe(false);
174+
expect(findings.some((f) => f.code === "prompt-cache-inconsistent")).toBe(false);
175+
});
120176
});
121177

122178
describe("recommendBeginnerNextAction", () => {
@@ -175,3 +231,38 @@ describe("explainRuntimeErrorCategory", () => {
175231
expect(hint).toContain("codex-doctor");
176232
});
177233
});
234+
235+
describe("formatPromptCacheKey", () => {
236+
it("returns none for empty values", () => {
237+
expect(formatPromptCacheKey(null)).toBe("none");
238+
expect(formatPromptCacheKey(undefined)).toBe("none");
239+
expect(formatPromptCacheKey(" ")).toBe("none");
240+
});
241+
242+
it("redacts short values too", () => {
243+
expect(formatPromptCacheKey("ses_1234")).toBe(
244+
`masked-${createHash("sha256").update("ses_1234").digest("hex").slice(0, 12)}`,
245+
);
246+
});
247+
248+
it("redacts longer values to a stable masked fingerprint", () => {
249+
expect(formatPromptCacheKey("ses_prompt_cache_key_123")).toBe(
250+
`masked-${createHash("sha256").update("ses_prompt_cache_key_123").digest("hex").slice(0, 12)}`,
251+
);
252+
});
253+
});
254+
255+
describe("formatPromptCacheSnapshot", () => {
256+
it("renders a redacted prompt cache snapshot string", () => {
257+
const rendered = formatPromptCacheSnapshot({
258+
promptCacheEnabledRequests: 4,
259+
promptCacheMissingRequests: 1,
260+
lastPromptCacheKey: "ses_prompt_cache_key_123",
261+
});
262+
263+
expect(rendered).toBe(
264+
`enabled=4, missing=1, lastKey=masked-${createHash("sha256").update("ses_prompt_cache_key_123").digest("hex").slice(0, 12)}`,
265+
);
266+
expect(rendered).not.toContain("ses_prompt_cache_key_123");
267+
});
268+
});

0 commit comments

Comments
 (0)