@bitkyc08/opencodex 2.7.27 → 2.7.28
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/README.ko.md +1 -1
- package/README.md +1 -1
- package/README.zh-CN.md +1 -1
- package/gui/dist/assets/index-DyBPh28A.css +1 -0
- package/gui/dist/assets/index-TZysP4q4.js +40 -0
- package/gui/dist/index.html +2 -2
- package/package.json +3 -2
- package/src/adapters/google-antigravity-wire.ts +15 -7
- package/src/bridge.ts +19 -1
- package/src/cli/provider.ts +11 -9
- package/src/codex/auth-api.ts +68 -5
- package/src/codex/auth-collision.ts +2 -0
- package/src/generated/jawcode-model-metadata.ts +40 -13
- package/src/oauth/index.ts +16 -2
- package/src/providers/base-url-choices.ts +36 -0
- package/src/providers/derive.ts +6 -0
- package/src/providers/registry.ts +21 -2
- package/src/responses/parser.ts +4 -2
- package/src/server/claude-messages.ts +2 -1
- package/src/server/index.ts +3 -0
- package/src/server/management-api.ts +144 -13
- package/src/server/relay.ts +41 -0
- package/src/server/request-log.ts +26 -0
- package/src/server/responses.ts +22 -1
- package/src/stall-timeout.ts +20 -0
- package/src/storage/scanner.ts +223 -0
- package/src/types.ts +4 -1
- package/src/usage/cost.ts +0 -0
- package/src/usage/expected-prices.ts +112 -0
- package/src/usage/log.ts +12 -0
- package/src/usage/summary.ts +35 -0
- package/src/web-search/index.ts +2 -3
- package/src/web-search/loop.ts +3 -0
- package/gui/dist/assets/index-BnrJO9Wz.css +0 -1
- package/gui/dist/assets/index-Vcr0pzdO.js +0 -40
|
@@ -30,6 +30,8 @@ import { isCanonicalOpenAiForwardProvider } from "../providers/openai-tiers";
|
|
|
30
30
|
import { clearThreadAccountMap } from "../codex/routing";
|
|
31
31
|
import { primeCodexPoolQuotas } from "../codex/auth-api";
|
|
32
32
|
import { DEFAULT_PROVIDER_CONTEXT_CAP, globalContextCapValue, providerContextCap, providerContextCaps, setAllProviderContextCaps, setGlobalContextCapValue, setProviderContextCap } from "../providers/context-cap";
|
|
33
|
+
import { resolveCodexHomeDir } from "../codex/home";
|
|
34
|
+
import { scanStorage } from "../storage/scanner";
|
|
33
35
|
import { readUsageEntries } from "../usage/log";
|
|
34
36
|
import { getUsageDebugLogEntries } from "../usage/debug";
|
|
35
37
|
import { parseRange, parseUsageSurface, summarizeUsage } from "../usage/summary";
|
|
@@ -46,7 +48,9 @@ import {
|
|
|
46
48
|
} from "../lib/debug-settings";
|
|
47
49
|
import type { OcxClaudeCodeConfig, OcxConfig, OcxProviderConfig } from "../types";
|
|
48
50
|
import { drainAndShutdown } from "./lifecycle";
|
|
49
|
-
import { filterRequestLogs, getRequestLogEntries } from "./request-log";
|
|
51
|
+
import { filterRequestLogs, getRequestLogEntries, type RequestLogEntry } from "./request-log";
|
|
52
|
+
import { estimateComboCost, estimateRequestCost, normalizeCostTokens, tokensPerSecond } from "../usage/cost";
|
|
53
|
+
import type { PersistedUsageAttempt } from "../usage/log";
|
|
50
54
|
import { isAllowedRequestOrigin, jsonResponse, providerManagementConfigError, publicProviderBaseUrl, safeConfigDTO } from "./auth-cors";
|
|
51
55
|
import { applySystemEnvToggle } from "./system-env";
|
|
52
56
|
|
|
@@ -82,6 +86,96 @@ function parseDebugLogQuery(url: URL): { after: number; limit: number } {
|
|
|
82
86
|
};
|
|
83
87
|
}
|
|
84
88
|
|
|
89
|
+
// ---- /api/logs display metrics (devlog/_plan/260720_toks_speed_price_columns/020) ----
|
|
90
|
+
// Derived at response time only; NEVER persisted to the request log or usage.jsonl.
|
|
91
|
+
|
|
92
|
+
type MetricUnavailableReason =
|
|
93
|
+
| "usage_missing" | "usage_unsupported" | "output_missing" | "invalid_duration"
|
|
94
|
+
| "price_unmatched" | "invalid_cache_breakdown"
|
|
95
|
+
| "invalid_usage" | "combo_attempt_unavailable";
|
|
96
|
+
|
|
97
|
+
type TokPerSecondResult =
|
|
98
|
+
| { kind: "value"; value: number; estimated: boolean }
|
|
99
|
+
| { kind: "unavailable"; reason: MetricUnavailableReason };
|
|
100
|
+
|
|
101
|
+
type CostEstimateReason = "usage_estimated" | "cache_detail_missing" | "expected_price_overlay";
|
|
102
|
+
|
|
103
|
+
type CostResult =
|
|
104
|
+
| { kind: "value"; estimate: NonNullable<ReturnType<typeof estimateRequestCost>>; estimateReasons: CostEstimateReason[] }
|
|
105
|
+
| { kind: "unavailable"; reason: MetricUnavailableReason };
|
|
106
|
+
|
|
107
|
+
type MetricSource = Pick<RequestLogEntry, "provider" | "model" | "durationMs" | "usageStatus" | "usage"> & {
|
|
108
|
+
attempts?: readonly PersistedUsageAttempt[];
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
function tokPerSecondResult(entry: Pick<MetricSource, "durationMs" | "usageStatus" | "usage">): TokPerSecondResult {
|
|
112
|
+
if (!entry.usage) return { kind: "unavailable", reason: "usage_missing" };
|
|
113
|
+
if (entry.usageStatus === "unsupported") return { kind: "unavailable", reason: "usage_unsupported" };
|
|
114
|
+
const value = tokensPerSecond(entry.usage.outputTokens, entry.durationMs);
|
|
115
|
+
if (value === null) {
|
|
116
|
+
return {
|
|
117
|
+
kind: "unavailable",
|
|
118
|
+
reason: entry.usage.outputTokens <= 0 ? "output_missing" : "invalid_duration",
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
return { kind: "value", value, estimated: entry.usageStatus === "estimated" || entry.usage.estimated === true };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function unavailableCostReason(entry: MetricSource): MetricUnavailableReason {
|
|
125
|
+
// Normalizer-first classification: the landed normalizer recovers legacy
|
|
126
|
+
// cachedInputTokens=read+write rows via retry, so a raw read+write>input
|
|
127
|
+
// pre-check would misclassify recoverable rows (020 audit blocker #2).
|
|
128
|
+
if (!entry.usage && !entry.attempts?.length) return "usage_missing";
|
|
129
|
+
if (entry.usageStatus === "unsupported") return "usage_unsupported";
|
|
130
|
+
if (entry.attempts?.length) return "combo_attempt_unavailable";
|
|
131
|
+
if (!entry.usage) return "usage_missing";
|
|
132
|
+
if (!normalizeCostTokens(entry.usage)) {
|
|
133
|
+
const effectiveRead = entry.usage.cacheReadInputTokens ?? entry.usage.cachedInputTokens ?? 0;
|
|
134
|
+
const effectiveWrite = entry.usage.cacheCreationInputTokens ?? 0;
|
|
135
|
+
const finite = [entry.usage.inputTokens, entry.usage.outputTokens, effectiveRead, effectiveWrite]
|
|
136
|
+
.every(v => Number.isFinite(v) && v >= 0);
|
|
137
|
+
return finite ? "invalid_cache_breakdown" : "invalid_usage";
|
|
138
|
+
}
|
|
139
|
+
return "price_unmatched";
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function costResult(entry: MetricSource): CostResult {
|
|
143
|
+
const estimate = entry.attempts?.length
|
|
144
|
+
? estimateComboCost(entry.attempts)
|
|
145
|
+
: estimateRequestCost({ provider: entry.provider, model: entry.model, usage: entry.usage, usageStatus: entry.usageStatus });
|
|
146
|
+
if (!estimate) return { kind: "unavailable", reason: unavailableCostReason(entry) };
|
|
147
|
+
const estimateReasons = [
|
|
148
|
+
entry.usageStatus === "estimated" || entry.usage?.estimated ? "usage_estimated" as const : undefined,
|
|
149
|
+
entry.usage && entry.usage.cachedInputTokens === undefined
|
|
150
|
+
&& entry.usage.cacheReadInputTokens === undefined
|
|
151
|
+
&& entry.usage.cacheCreationInputTokens === undefined ? "cache_detail_missing" as const : undefined,
|
|
152
|
+
estimate.price?.source === "expected" || estimate.attempts?.some(a => a.price.source === "expected")
|
|
153
|
+
? "expected_price_overlay" as const : undefined,
|
|
154
|
+
].filter((reason): reason is CostEstimateReason => reason !== undefined);
|
|
155
|
+
return { kind: "value", estimate, estimateReasons };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function requestLogDto(entry: RequestLogEntry): Record<string, unknown> {
|
|
159
|
+
return {
|
|
160
|
+
...entry,
|
|
161
|
+
displayMetrics: {
|
|
162
|
+
tokPerSecond: tokPerSecondResult(entry),
|
|
163
|
+
cost: costResult(entry),
|
|
164
|
+
},
|
|
165
|
+
...(entry.attempts?.length
|
|
166
|
+
? {
|
|
167
|
+
attempts: entry.attempts.map(attempt => ({
|
|
168
|
+
...attempt,
|
|
169
|
+
displayMetrics: {
|
|
170
|
+
tokPerSecond: tokPerSecondResult(attempt),
|
|
171
|
+
cost: costResult({ ...attempt, attempts: undefined }),
|
|
172
|
+
},
|
|
173
|
+
})),
|
|
174
|
+
}
|
|
175
|
+
: {}),
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
85
179
|
export async function handleManagementAPI(req: Request, url: URL, config: OcxConfig, deps: ManagementApiDeps = {}): Promise<Response | null> {
|
|
86
180
|
if (!isAllowedRequestOrigin(req, config)) {
|
|
87
181
|
return jsonResponse({ error: "cross-origin request blocked" }, 403, req, config);
|
|
@@ -311,7 +405,8 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
|
|
|
311
405
|
}
|
|
312
406
|
|
|
313
407
|
if (url.pathname === "/api/logs" && req.method === "GET") {
|
|
314
|
-
|
|
408
|
+
const logs = filterRequestLogs(getRequestLogEntries(), url.searchParams);
|
|
409
|
+
return jsonResponse(logs.map(requestLogDto));
|
|
315
410
|
}
|
|
316
411
|
|
|
317
412
|
if (url.pathname === "/api/debug" && req.method === "GET") {
|
|
@@ -378,6 +473,7 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
|
|
|
378
473
|
generatedAt: now,
|
|
379
474
|
summary: {
|
|
380
475
|
requests: 0,
|
|
476
|
+
attemptCount: 0,
|
|
381
477
|
measuredRequests: 0,
|
|
382
478
|
reportedRequests: 0,
|
|
383
479
|
unreportedRequests: 0,
|
|
@@ -391,6 +487,10 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
|
|
|
391
487
|
reasoningOutputTokens: 0,
|
|
392
488
|
totalTokens: 0,
|
|
393
489
|
coverageRatio: 0,
|
|
490
|
+
estimatedCostUsd: 0,
|
|
491
|
+
pricedRequests: 0,
|
|
492
|
+
unpricedRequests: 0,
|
|
493
|
+
unmeteredRequests: 0,
|
|
394
494
|
},
|
|
395
495
|
days: [],
|
|
396
496
|
models: [],
|
|
@@ -400,6 +500,20 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
|
|
|
400
500
|
}
|
|
401
501
|
}
|
|
402
502
|
|
|
503
|
+
if (url.pathname === "/api/storage" && req.method === "GET") {
|
|
504
|
+
try {
|
|
505
|
+
return jsonResponse(scanStorage());
|
|
506
|
+
} catch {
|
|
507
|
+
return jsonResponse({
|
|
508
|
+
codexHome: resolveCodexHomeDir(),
|
|
509
|
+
generatedAt: Date.now(),
|
|
510
|
+
total: { bytes: 0, fileCount: 0 },
|
|
511
|
+
buckets: [],
|
|
512
|
+
error: "scan_failed",
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
403
517
|
if (url.pathname === "/api/provider-quotas" && req.method === "GET") {
|
|
404
518
|
const forceRefresh = url.searchParams.get("refresh") === "1" || url.searchParams.get("refresh") === "true";
|
|
405
519
|
return jsonResponse(await fetchProviderQuotaReports(config, forceRefresh));
|
|
@@ -543,13 +657,19 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
|
|
|
543
657
|
return jsonResponse({ error: "authMode must be key, forward, oauth, or local" }, 400);
|
|
544
658
|
}
|
|
545
659
|
}
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
660
|
+
if (Object.hasOwn(rawBody, "note")) {
|
|
661
|
+
if (typeof rawBody.note !== "string") return jsonResponse({ error: "note must be a string" }, 400);
|
|
662
|
+
const note = rawBody.note.trim();
|
|
663
|
+
if (note) next.note = note;
|
|
664
|
+
else delete next.note;
|
|
665
|
+
touched = true;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
if (Object.hasOwn(rawBody, "allowPrivateNetwork")) {
|
|
669
|
+
if (typeof rawBody.allowPrivateNetwork !== "boolean") return jsonResponse({ error: "allowPrivateNetwork must be a boolean" }, 400);
|
|
670
|
+
next.allowPrivateNetwork = rawBody.allowPrivateNetwork;
|
|
671
|
+
touched = true;
|
|
672
|
+
}
|
|
553
673
|
|
|
554
674
|
if (!touched) return jsonResponse({ error: "no recognized fields to update" }, 400);
|
|
555
675
|
|
|
@@ -1241,13 +1361,24 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
|
|
|
1241
1361
|
// the provider's loopback callback server (inside this process) captures the redirect in the
|
|
1242
1362
|
// background, then the credential is persisted. The GUI opens the URL and polls /api/oauth/status.
|
|
1243
1363
|
if (url.pathname === "/api/oauth/login" && req.method === "POST") {
|
|
1244
|
-
const body = await req.json().catch(() => ({})) as { provider?: string; addAccount?: boolean };
|
|
1364
|
+
const body = await req.json().catch(() => ({})) as { provider?: string; addAccount?: boolean; accountId?: string; reauth?: boolean };
|
|
1245
1365
|
const provider = (body.provider ?? "").trim().toLowerCase();
|
|
1246
1366
|
if (!isPublicOAuthProvider(provider)) return jsonResponse({ error: "unknown oauth provider" }, 400);
|
|
1367
|
+
const accountId = body.accountId?.trim();
|
|
1368
|
+
const reauth = body.reauth === true || Boolean(accountId);
|
|
1247
1369
|
try {
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1370
|
+
if (accountId) {
|
|
1371
|
+
const { getAccountSet } = await import("../oauth/store");
|
|
1372
|
+
const set = getAccountSet(provider);
|
|
1373
|
+
if (!set?.accounts.some(a => a.id === accountId)) {
|
|
1374
|
+
return jsonResponse({ error: "Unknown account for reauth" }, 404);
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
// addAccount / reauth forces a fresh browser identity (skips local-CLI token import).
|
|
1378
|
+
const { url: authUrl, instructions } = await startLoginFlow(provider, {
|
|
1379
|
+
forceLogin: body.addAccount === true || reauth,
|
|
1380
|
+
...(accountId ? { reauthAccountId: accountId } : {}),
|
|
1381
|
+
});
|
|
1251
1382
|
upsertOAuthProvider(config, provider); // mutate LIVE config — routing sees it without restart
|
|
1252
1383
|
if (authUrl) {
|
|
1253
1384
|
// Open the browser server-side (the proxy runs on the user's machine) — the GUI's
|
package/src/server/relay.ts
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
httpStatusForRequestLogTerminal,
|
|
7
7
|
inspectResponseLogJson,
|
|
8
8
|
inspectResponseLogSsePayload,
|
|
9
|
+
recordFirstOutput,
|
|
9
10
|
type RequestLogContext,
|
|
10
11
|
type RequestLogEntry,
|
|
11
12
|
} from "./request-log";
|
|
@@ -107,6 +108,34 @@ export function sseDataPayload(block: string): string | null {
|
|
|
107
108
|
}
|
|
108
109
|
|
|
109
110
|
export function terminalStatusFromSsePayload(payload: string): ResponsesTerminalStatus | null {
|
|
111
|
+
return terminalStatusFromSsePayloadInner(payload);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** True when a native Responses SSE payload carries the FIRST kind of non-empty model output. */
|
|
115
|
+
export function isFirstOutputSsePayload(payload: string | null): boolean {
|
|
116
|
+
if (!payload || payload === "[DONE]") return false;
|
|
117
|
+
try {
|
|
118
|
+
const event = JSON.parse(payload) as { type?: unknown; delta?: unknown };
|
|
119
|
+
return (event.type === "response.output_text.delta"
|
|
120
|
+
|| event.type === "response.reasoning_summary_text.delta"
|
|
121
|
+
|| event.type === "response.reasoning_text.delta")
|
|
122
|
+
&& typeof event.delta === "string"
|
|
123
|
+
&& event.delta.length > 0;
|
|
124
|
+
} catch {
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function createFirstOutputReporter(onFirstOutput?: () => void): (payload: string | null) => void {
|
|
130
|
+
let reported = false;
|
|
131
|
+
return payload => {
|
|
132
|
+
if (reported || !isFirstOutputSsePayload(payload)) return;
|
|
133
|
+
reported = true;
|
|
134
|
+
try { onFirstOutput?.(); } catch { /* metrics must not break the stream */ }
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function terminalStatusFromSsePayloadInner(payload: string): ResponsesTerminalStatus | null {
|
|
110
139
|
if (payload === "[DONE]") return null;
|
|
111
140
|
try {
|
|
112
141
|
const json = JSON.parse(payload) as { type?: unknown };
|
|
@@ -144,11 +173,13 @@ export function trackSseForRequestLog(
|
|
|
144
173
|
onTerminal: (status: ResponsesTerminalStatus) => void,
|
|
145
174
|
onCancel: () => void,
|
|
146
175
|
logCtx?: RequestLogContext,
|
|
176
|
+
onFirstOutput?: () => void,
|
|
147
177
|
): ReadableStream<Uint8Array> {
|
|
148
178
|
const reader = body.getReader();
|
|
149
179
|
const decoder = new TextDecoder();
|
|
150
180
|
let buffer = "";
|
|
151
181
|
let terminalReported = false;
|
|
182
|
+
const reportFirstOutput = createFirstOutputReporter(onFirstOutput);
|
|
152
183
|
|
|
153
184
|
const reportTerminal = (status: ResponsesTerminalStatus) => {
|
|
154
185
|
if (terminalReported) return;
|
|
@@ -159,6 +190,7 @@ export function trackSseForRequestLog(
|
|
|
159
190
|
const inspectPayload = (payload: string | null) => {
|
|
160
191
|
if (!payload) return;
|
|
161
192
|
if (logCtx) inspectResponseLogSsePayload(logCtx, payload);
|
|
193
|
+
reportFirstOutput(payload);
|
|
162
194
|
const status = terminalStatusFromSsePayload(payload);
|
|
163
195
|
if (status) reportTerminal(status);
|
|
164
196
|
};
|
|
@@ -264,6 +296,7 @@ export function responseWithDeferredRequestLog(
|
|
|
264
296
|
addFinalRequestLog(requestId, start, logCtx, 499, { closeReason: "client_cancel" }, addLog);
|
|
265
297
|
},
|
|
266
298
|
logCtx,
|
|
299
|
+
() => recordFirstOutput(logCtx, start),
|
|
267
300
|
);
|
|
268
301
|
return new Response(body, {
|
|
269
302
|
status: response.status,
|
|
@@ -379,12 +412,14 @@ export function consumeForInspection(
|
|
|
379
412
|
logCtx?: RequestLogContext,
|
|
380
413
|
onCancel?: () => void,
|
|
381
414
|
onCompletedResponse?: (response: { id?: unknown; output?: unknown; status?: unknown }) => void,
|
|
415
|
+
onFirstOutput?: () => void,
|
|
382
416
|
): void {
|
|
383
417
|
const reader = body.getReader();
|
|
384
418
|
const decoder = new TextDecoder();
|
|
385
419
|
let buffer = "";
|
|
386
420
|
let reported = false;
|
|
387
421
|
let cancelled = false;
|
|
422
|
+
const reportFirstOutput = createFirstOutputReporter(onFirstOutput);
|
|
388
423
|
if (signal) {
|
|
389
424
|
if (signal.aborted) {
|
|
390
425
|
// Aborted before we could read anything (Codex disconnects the instant it finishes reading).
|
|
@@ -413,6 +448,7 @@ export function consumeForInspection(
|
|
|
413
448
|
if (buffer.trim() && !reported) {
|
|
414
449
|
const payload = sseDataPayload(buffer);
|
|
415
450
|
if (logCtx) inspectResponseLogSsePayload(logCtx, payload);
|
|
451
|
+
reportFirstOutput(payload);
|
|
416
452
|
if (payload) {
|
|
417
453
|
const status = terminalStatusFromSsePayload(payload);
|
|
418
454
|
if (status) { reported = true; onTerminal(status); }
|
|
@@ -432,6 +468,7 @@ export function consumeForInspection(
|
|
|
432
468
|
if (reported && !onCompletedResponse) continue;
|
|
433
469
|
const payload = sseDataPayload(next.block);
|
|
434
470
|
if (!reported && logCtx) inspectResponseLogSsePayload(logCtx, payload);
|
|
471
|
+
reportFirstOutput(payload);
|
|
435
472
|
if (!payload) continue;
|
|
436
473
|
if (!reported) {
|
|
437
474
|
const status = terminalStatusFromSsePayload(payload);
|
|
@@ -458,10 +495,12 @@ export function consumeForResponseLogMetadata(
|
|
|
458
495
|
signal?: AbortSignal,
|
|
459
496
|
onDone?: () => void,
|
|
460
497
|
onCompletedResponse?: (response: { id?: unknown; output?: unknown; status?: unknown }) => void,
|
|
498
|
+
onFirstOutput?: () => void,
|
|
461
499
|
): void {
|
|
462
500
|
const reader = body.getReader();
|
|
463
501
|
const decoder = new TextDecoder();
|
|
464
502
|
let buffer = "";
|
|
503
|
+
const reportFirstOutput = createFirstOutputReporter(onFirstOutput);
|
|
465
504
|
if (signal) {
|
|
466
505
|
if (signal.aborted) {
|
|
467
506
|
reader.cancel(signal.reason).catch(() => {});
|
|
@@ -481,6 +520,7 @@ export function consumeForResponseLogMetadata(
|
|
|
481
520
|
if (buffer.trim()) {
|
|
482
521
|
const payload = sseDataPayload(buffer);
|
|
483
522
|
inspectResponseLogSsePayload(logCtx, payload);
|
|
523
|
+
reportFirstOutput(payload);
|
|
484
524
|
if (payload && onCompletedResponse) {
|
|
485
525
|
const response = completedResponseFromSsePayload(payload);
|
|
486
526
|
if (response) onCompletedResponse(response);
|
|
@@ -494,6 +534,7 @@ export function consumeForResponseLogMetadata(
|
|
|
494
534
|
buffer = next.rest;
|
|
495
535
|
const payload = sseDataPayload(next.block);
|
|
496
536
|
inspectResponseLogSsePayload(logCtx, payload);
|
|
537
|
+
reportFirstOutput(payload);
|
|
497
538
|
if (payload && onCompletedResponse) {
|
|
498
539
|
const response = completedResponseFromSsePayload(payload);
|
|
499
540
|
if (response) onCompletedResponse(response);
|
|
@@ -29,6 +29,8 @@ import {
|
|
|
29
29
|
export interface RequestLogContext {
|
|
30
30
|
model: string;
|
|
31
31
|
provider: string;
|
|
32
|
+
/** TTFT: ms from request start to the first non-empty model output delta (WP4, devlog 040). */
|
|
33
|
+
firstOutputMs?: number;
|
|
32
34
|
surface?: "claude";
|
|
33
35
|
requestedModel?: string;
|
|
34
36
|
requestedEffort?: string;
|
|
@@ -65,6 +67,8 @@ export interface RequestLogEntry {
|
|
|
65
67
|
timestamp: number;
|
|
66
68
|
model: string;
|
|
67
69
|
provider: string;
|
|
70
|
+
/** TTFT: ms from request start to the first non-empty model output delta; unset for non-streaming/tool-only. */
|
|
71
|
+
firstOutputMs?: number;
|
|
68
72
|
surface?: "claude";
|
|
69
73
|
requestedModel?: string;
|
|
70
74
|
requestedEffort?: string;
|
|
@@ -117,6 +121,7 @@ export function addRequestLog(entry: RequestLogEntry) {
|
|
|
117
121
|
...(entry.requestedModel ? { requestedModel: entry.requestedModel } : {}),
|
|
118
122
|
status: entry.status,
|
|
119
123
|
durationMs: entry.durationMs,
|
|
124
|
+
...(entry.firstOutputMs !== undefined ? { firstOutputMs: entry.firstOutputMs } : {}),
|
|
120
125
|
usageStatus: entry.usageStatus,
|
|
121
126
|
...(entry.usage ? { usage: entry.usage } : {}),
|
|
122
127
|
...(entry.totalTokens !== undefined ? { totalTokens: entry.totalTokens } : {}),
|
|
@@ -133,6 +138,26 @@ export function nextRequestLogId(timestamp = Date.now()): string {
|
|
|
133
138
|
return `ocx-${timestamp.toString(36)}-${requestLogSeq.toString(36)}`;
|
|
134
139
|
}
|
|
135
140
|
|
|
141
|
+
/**
|
|
142
|
+
* One-shot TTFT recorder (WP4). Records the first non-empty model output moment
|
|
143
|
+
* relative to the request start, and — when a combo attempt is in flight —
|
|
144
|
+
* relative to that attempt's start as well. Later calls are no-ops, so both the
|
|
145
|
+
* bridge callback and the deferred SSE tap may fire without double-recording.
|
|
146
|
+
*/
|
|
147
|
+
export function recordFirstOutput(
|
|
148
|
+
logCtx: RequestLogContext,
|
|
149
|
+
requestStartedAt: number,
|
|
150
|
+
now = Date.now(),
|
|
151
|
+
): void {
|
|
152
|
+
if (!Number.isFinite(requestStartedAt) || !Number.isFinite(now)) return;
|
|
153
|
+
const requestElapsed = Math.max(0, now - requestStartedAt);
|
|
154
|
+
if (logCtx.firstOutputMs === undefined) logCtx.firstOutputMs = requestElapsed;
|
|
155
|
+
if (logCtx.activeAttempt && logCtx.activeAttempt.firstOutputMs === undefined) {
|
|
156
|
+
const attemptStartedAt = logCtx.activeAttemptStartedAt ?? requestStartedAt;
|
|
157
|
+
logCtx.activeAttempt.firstOutputMs = Math.max(0, now - attemptStartedAt);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
136
161
|
export function requestLogErrorCode(status: number, upstreamError?: string): string | undefined {
|
|
137
162
|
if (status >= 200 && status < 400) return undefined;
|
|
138
163
|
// Defense in depth: mid-stream web-search aborts used to land as 502 with this message.
|
|
@@ -456,6 +481,7 @@ export function addFinalRequestLog(
|
|
|
456
481
|
...(logCtx.resolvedModel ? { resolvedModel: logCtx.resolvedModel } : {}),
|
|
457
482
|
status: effectiveStatus,
|
|
458
483
|
durationMs: Date.now() - start,
|
|
484
|
+
...(logCtx.firstOutputMs !== undefined ? { firstOutputMs: logCtx.firstOutputMs } : {}),
|
|
459
485
|
...(errorCode ? { errorCode } : {}),
|
|
460
486
|
...(meta?.terminalStatus ? { terminalStatus: meta.terminalStatus } : {}),
|
|
461
487
|
...(closeReason ? { closeReason } : {}),
|
package/src/server/responses.ts
CHANGED
|
@@ -462,6 +462,8 @@ interface ConsumedComboFailure {
|
|
|
462
462
|
interface HandleResponsesOptions {
|
|
463
463
|
forceEmptyResponseId?: boolean;
|
|
464
464
|
abortSignal?: AbortSignal;
|
|
465
|
+
/** One-shot TTFT callback: first non-empty model output observed (WP4). */
|
|
466
|
+
onFirstOutput?: () => void;
|
|
465
467
|
onCodexAuthContextResolved?: (context: CodexAuthContext | undefined) => void;
|
|
466
468
|
recordTerminalOutcomes?: boolean;
|
|
467
469
|
setTerminalOutcomeRecorder?: (recorder: ((status: ResponsesTerminalStatus) => void) | undefined) => void;
|
|
@@ -634,6 +636,14 @@ async function handleComboResponses(
|
|
|
634
636
|
response = await handleResponses(childRequest, config, childLog, {
|
|
635
637
|
...options,
|
|
636
638
|
comboAttempt: true,
|
|
639
|
+
// Attempt-relative TTFT is recorded HERE (not via childLog.firstOutputMs — a later
|
|
640
|
+
// Object.assign(logCtx, childLog) would overwrite the request-relative value).
|
|
641
|
+
onFirstOutput: () => {
|
|
642
|
+
if (attempt.firstOutputMs === undefined) {
|
|
643
|
+
attempt.firstOutputMs = Math.max(0, Date.now() - started);
|
|
644
|
+
}
|
|
645
|
+
options.onFirstOutput?.();
|
|
646
|
+
},
|
|
637
647
|
onCodexAuthContextResolved: value => { resolvedAuth = value; },
|
|
638
648
|
setTerminalOutcomeRecorder: value => { terminalRecorder = value; },
|
|
639
649
|
onConsumedComboFailure: value => { consumedChildFailure = value; },
|
|
@@ -1176,9 +1186,17 @@ export async function handleResponses(
|
|
|
1176
1186
|
logCtx,
|
|
1177
1187
|
() => options.onNativePassthroughCancel?.(),
|
|
1178
1188
|
rememberPassthroughResponse,
|
|
1189
|
+
options.onFirstOutput,
|
|
1179
1190
|
);
|
|
1180
1191
|
} else {
|
|
1181
|
-
consumeForResponseLogMetadata(
|
|
1192
|
+
consumeForResponseLogMetadata(
|
|
1193
|
+
inspectBody,
|
|
1194
|
+
logCtx,
|
|
1195
|
+
turnAc.signal,
|
|
1196
|
+
() => unregisterTurn(turnAc),
|
|
1197
|
+
rememberPassthroughResponse,
|
|
1198
|
+
options.onFirstOutput,
|
|
1199
|
+
);
|
|
1182
1200
|
}
|
|
1183
1201
|
if (!headers.has("content-type")) headers.set("content-type", "text/event-stream");
|
|
1184
1202
|
// win32 must keep the pure native relay (Bun#32111 JS-sink segfault); elsewhere a JS pull
|
|
@@ -1266,6 +1284,7 @@ export async function handleResponses(
|
|
|
1266
1284
|
...(options.forceEmptyResponseId ? { responseId: "" } : {}),
|
|
1267
1285
|
stallTimeoutSec: config.stallTimeoutSec,
|
|
1268
1286
|
hideThinkingSummary: parsed.options.hideThinkingSummary,
|
|
1287
|
+
...(options.onFirstOutput ? { onFirstOutput: options.onFirstOutput } : {}),
|
|
1269
1288
|
...(routedCompaction ? { compaction: true } : {}),
|
|
1270
1289
|
...(routedCompaction ? {} : { onCompletedResponse: (response: Record<string, unknown>) => rememberResponseState(parsed._rawBody, response, parsed._cursorConversationId) }),
|
|
1271
1290
|
},
|
|
@@ -1317,6 +1336,7 @@ export async function handleResponses(
|
|
|
1317
1336
|
maxSearches: wsPlan.maxSearches,
|
|
1318
1337
|
forceEmptyResponseId: true,
|
|
1319
1338
|
abortSignal: options.abortSignal,
|
|
1339
|
+
...(options.onFirstOutput ? { onFirstOutput: options.onFirstOutput } : {}),
|
|
1320
1340
|
recordSidecarOutcome: wsPlan.forwardSidecar?.recordOutcome,
|
|
1321
1341
|
connectTimeoutMs: config.connectTimeoutMs ?? 200_000,
|
|
1322
1342
|
routedModelStallTimeoutMs: wsPlan.routedModelStallTimeoutMs,
|
|
@@ -1529,6 +1549,7 @@ export async function handleResponses(
|
|
|
1529
1549
|
...(options.forceEmptyResponseId ? { responseId: "" } : {}),
|
|
1530
1550
|
stallTimeoutSec: config.stallTimeoutSec,
|
|
1531
1551
|
hideThinkingSummary: parsed.options.hideThinkingSummary,
|
|
1552
|
+
...(options.onFirstOutput ? { onFirstOutput: options.onFirstOutput } : {}),
|
|
1532
1553
|
...(routedCompaction ? { compaction: true } : {}),
|
|
1533
1554
|
// Compaction turns must NOT enter the continuation cache: _rawBody still holds the full
|
|
1534
1555
|
// PRE-compaction history, and a later previous_response_id expansion would rehydrate the
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bridge upstream stall budget: seconds of silence (no adapter events) before the
|
|
3
|
+
* Responses bridge emits `response.incomplete` / `upstream_stall_timeout`.
|
|
4
|
+
*
|
|
5
|
+
* Raised from 90s so long reasoning + large tool writes are not cut mid-turn.
|
|
6
|
+
* Hung streams still die; they just get a more realistic window.
|
|
7
|
+
*/
|
|
8
|
+
export const DEFAULT_STALL_TIMEOUT_SEC = 300;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Resolve the effective bridge stall deadline for a turn.
|
|
12
|
+
* - unset / non-finite config → {@link DEFAULT_STALL_TIMEOUT_SEC}
|
|
13
|
+
* - finite config → ceil, minimum 1
|
|
14
|
+
*/
|
|
15
|
+
export function resolveStallTimeoutSec(configuredSec: number | undefined): number {
|
|
16
|
+
if (typeof configuredSec === "number" && Number.isFinite(configuredSec)) {
|
|
17
|
+
return Math.max(1, Math.ceil(configuredSec));
|
|
18
|
+
}
|
|
19
|
+
return DEFAULT_STALL_TIMEOUT_SEC;
|
|
20
|
+
}
|