@bitkyc08/opencodex 2.76.0 → 2.77.0
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/gui/dist/assets/App-COdEeqSj.css +1 -0
- package/gui/dist/assets/App-DEpCJEP_.js +54 -0
- package/gui/dist/assets/{Tray-DjNwwrWS.js → Tray-gloIYNaW.js} +1 -1
- package/gui/dist/assets/index-Cd7ittMv.css +1 -0
- package/gui/dist/assets/index-DwJKFJm5.js +94 -0
- package/gui/dist/assets/quota-summary-DGcEJU3h.js +1 -0
- package/gui/dist/index.html +4 -3
- package/package.json +1 -1
- package/src/adapters/antigravity-refusal.ts +28 -0
- package/src/adapters/cursor/protobuf-request.ts +6 -0
- package/src/adapters/cursor/request-builder.ts +3 -0
- package/src/adapters/cursor/structured-output.ts +13 -0
- package/src/adapters/cursor/types.ts +2 -0
- package/src/adapters/ollama-native.ts +29 -2
- package/src/adapters/openai-responses/passthrough.ts +133 -103
- package/src/adapters/openai-responses/request-strips.ts +10 -4
- package/src/adapters/zed.ts +26 -4
- package/src/bridge/response-json.ts +14 -14
- package/src/bridge/sse.ts +15 -5
- package/src/chatgpt/app-server-shim/bundle-trust.ts +40 -10
- package/src/chatgpt/app-server-shim/gate-rewrite.ts +3 -0
- package/src/claude/agents-inject.ts +4 -43
- package/src/claude/gateway-cache.ts +16 -13
- package/src/claude/inbound-model-options.ts +9 -2
- package/src/claude/intercept/cli-catalog.ts +27 -16
- package/src/claude/intercept/picker-bootstrap.ts +37 -16
- package/src/claude/intercept/picker-budget.ts +41 -0
- package/src/claude/outbound.ts +2 -0
- package/src/claude/subagent-force-status.ts +53 -0
- package/src/claude/subagent-model.ts +93 -0
- package/src/cli/account-api.ts +3 -0
- package/src/cli/account.ts +3 -1
- package/src/cli/agent.ts +10 -0
- package/src/cli/capabilities.ts +9 -0
- package/src/cli/chatgpt-command.ts +10 -7
- package/src/cli/claude.ts +22 -6
- package/src/cli/codex-shim-readiness.ts +89 -0
- package/src/cli/connect.ts +19 -0
- package/src/cli/dispatch.ts +3 -3
- package/src/cli/export-command.ts +20 -9
- package/src/cli/gui.ts +8 -3
- package/src/cli/help-catalog.ts +84 -0
- package/src/cli/help-models-context.ts +17 -0
- package/src/cli/help-navigation.ts +50 -0
- package/src/cli/help-recovery.ts +66 -0
- package/src/cli/help.ts +52 -8
- package/src/cli/models-runtime.ts +2 -1
- package/src/cli/opencode.ts +54 -10
- package/src/cli/provider.ts +3 -29
- package/src/cli/registry.ts +29 -5
- package/src/cli/root.ts +30 -9
- package/src/cli/runtime-api.ts +1 -1
- package/src/cli/tray-proxy.ts +3 -2
- package/src/clients/config-export/contracts.ts +100 -0
- package/src/clients/config-export/droid.ts +54 -2
- package/src/clients/config-export/kilo.ts +12 -2
- package/src/clients/config-export/model-metadata.ts +10 -4
- package/src/clients/config-export/reasoning-metadata.ts +47 -0
- package/src/clients/config-export.ts +46 -66
- package/src/clients/omo-role-models.ts +17 -3
- package/src/codex/account-credit-use.ts +4 -2
- package/src/codex/account-runtime-state.ts +21 -0
- package/src/codex/account-store.ts +11 -47
- package/src/codex/account-usability.ts +11 -8
- package/src/codex/auth-api/account-list.ts +73 -63
- package/src/codex/auth-api/main-account-probe.ts +25 -12
- package/src/codex/auth-api/pool-mode-gate.ts +6 -9
- package/src/codex/auth-api/pool-quota-probe.ts +2 -4
- package/src/codex/auth-api/reset-credit-service.ts +3 -2
- package/src/codex/auth-context.ts +30 -11
- package/src/codex/catalog/aggregation.ts +3 -0
- package/src/codex/catalog/model-hints.ts +2 -1
- package/src/codex/catalog/model-visibility.ts +6 -3
- package/src/codex/catalog/parsing.ts +2 -0
- package/src/codex/catalog/provider-models.ts +13 -4
- package/src/codex/catalog/retained-sync.ts +2 -1
- package/src/codex/chatgpt-refresh-failure.ts +56 -0
- package/src/codex/convergence.ts +2 -1
- package/src/codex/main-account.ts +144 -11
- package/src/codex/quota-refresh-outcome.ts +17 -2
- package/src/codex/quota-types.ts +33 -1
- package/src/codex/quota.ts +56 -15
- package/src/codex/routing/cooldown-math.ts +10 -4
- package/src/codex/routing/selection.ts +11 -4
- package/src/codex/routing.ts +9 -9
- package/src/codex/shim-path-resolution.ts +133 -0
- package/src/codex/shim.ts +39 -39
- package/src/codex/subagent-model-fallback.ts +2 -1
- package/src/combos/failover.ts +82 -1
- package/src/combos/jev.ts +13 -3
- package/src/config/diagnostics.ts +4 -0
- package/src/config/load-degrade.ts +4 -0
- package/src/config/provider-validation.ts +3 -0
- package/src/config/schema/compaction-triggers.ts +8 -0
- package/src/config/schema/config-schema.ts +11 -0
- package/src/config/schema/leaf-validators.ts +5 -1
- package/src/config/subagent-models.ts +12 -0
- package/src/generated/compatibility-version.json +245 -153
- package/src/grok/session-identity.ts +33 -0
- package/src/integrations/catalog-refresh.ts +1 -1
- package/src/integrations/mutation-plan.ts +4 -2
- package/src/integrations/state.ts +68 -6
- package/src/integrations/writer.ts +18 -4
- package/src/lib/gui-pair-capability.ts +17 -0
- package/src/lib/provider-client-headers.ts +62 -0
- package/src/oauth/anthropic-continuity.ts +65 -0
- package/src/oauth/anthropic-identity.ts +75 -0
- package/src/oauth/anthropic-routing.ts +3 -3
- package/src/oauth/anthropic.ts +7 -1
- package/src/oauth/generic-account-failover.ts +21 -0
- package/src/oauth/health.ts +32 -5
- package/src/oauth/index.ts +36 -27
- package/src/oauth/local-token-detect.ts +7 -0
- package/src/oauth/refresh-policy.ts +2 -0
- package/src/oauth/store.ts +55 -6
- package/src/oauth/types.ts +9 -0
- package/src/protocols/encoders/messages.ts +4 -3
- package/src/providers/antigravity-effort-families.ts +75 -0
- package/src/providers/antigravity-models.ts +54 -16
- package/src/providers/antigravity-wire-snapshot.ts +66 -0
- package/src/providers/derive.ts +2 -0
- package/src/providers/model-rename-fields.ts +3 -0
- package/src/providers/new-model-policy.ts +48 -10
- package/src/providers/registry/entries-extended.ts +3 -0
- package/src/providers/registry/model-ids.ts +2 -0
- package/src/providers/registry/model-seeds.ts +5 -0
- package/src/providers/registry/types.ts +4 -0
- package/src/responses/compaction.ts +19 -0
- package/src/router.ts +6 -0
- package/src/server/auth-cors.ts +9 -0
- package/src/server/chat-completions.ts +10 -0
- package/src/server/claude-messages.ts +18 -11
- package/src/server/droid-reasoning-default.ts +50 -0
- package/src/server/github-copilot-responses-repair.ts +3 -6
- package/src/server/grok-responses-control-frame.ts +2 -2
- package/src/server/grok-responses-snapshot-repair.ts +4 -5
- package/src/server/gui-session.ts +33 -11
- package/src/server/index/claude-intercept-lifecycle.ts +4 -0
- package/src/server/index/serve-options.ts +2 -1
- package/src/server/management/agent-settings-routes.ts +6 -129
- package/src/server/management/config-routes.ts +1 -1
- package/src/server/management/integration-routes.ts +64 -6
- package/src/server/management/link-routes.ts +20 -13
- package/src/server/management/model-row-export-metadata.ts +150 -0
- package/src/server/management/model-rows.ts +148 -9
- package/src/server/management/route-registry.ts +4 -3
- package/src/server/management/shared.ts +19 -5
- package/src/server/management/subagent-model-routes.ts +214 -0
- package/src/server/relay.ts +14 -44
- package/src/server/request-log-generation-window.ts +38 -0
- package/src/server/request-log.ts +13 -0
- package/src/server/responses/adapter-dispatch.ts +100 -1
- package/src/server/responses/codex-auth-error.ts +10 -2
- package/src/server/responses/combo-stream-preflight.ts +29 -3
- package/src/server/responses/compaction-routing.ts +10 -1
- package/src/server/responses/core-auth.ts +57 -26
- package/src/server/responses/core-codex-account.ts +6 -3
- package/src/server/responses/core-combo-failure.ts +5 -1
- package/src/server/responses/core-combo-native.ts +7 -1
- package/src/server/responses/core-combo.ts +18 -1
- package/src/server/responses/core-normalize.ts +7 -0
- package/src/server/responses/core-options.ts +5 -0
- package/src/server/responses/fetch-helpers.ts +9 -0
- package/src/server/responses/passthrough-delivery.ts +1 -1
- package/src/server/responses/policy-fallback.ts +1 -1
- package/src/server/responses/request-prepare.ts +5 -0
- package/src/server/responses/request-spend.ts +30 -30
- package/src/server/responses-custom-tool-repair.ts +2 -2
- package/src/server/responses-hosted-image-display.ts +70 -9
- package/src/server/responses-terminal-repair.ts +15 -2
- package/src/server/sse-frame-buffer.ts +38 -36
- package/src/server/sse-payload-rewrite.ts +78 -49
- package/src/server/ws-bridge.ts +1 -11
- package/src/service/windows-taskxml.ts +1 -2
- package/src/service-manager-probe.ts +36 -2
- package/src/types/config.ts +4 -0
- package/src/types/provider.ts +18 -0
- package/src/update/npm-invocation.mjs +35 -3
- package/src/update/proxy-liveness-probe.mjs +38 -8
- package/src/usage/expected-prices.ts +9 -0
- package/src/usage/log.ts +7 -0
- package/src/usage/summary.ts +82 -1
- package/src/web-search/loop.ts +11 -8
- package/src/web-search/run-turn-loop.ts +10 -7
- package/gui/dist/assets/App-CddorHdA.js +0 -51
- package/gui/dist/assets/App-DMudtISW.css +0 -1
- package/gui/dist/assets/index-Btze1I3T.css +0 -1
- package/gui/dist/assets/index-CvnY7E6i.js +0 -86
- package/gui/dist/assets/quota-summary-B_AWpHfP.js +0 -1
|
@@ -147,7 +147,6 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [
|
|
|
147
147
|
{ method: "GET", path: "/api/injection-model", module: "server/management/agent-settings-routes", mutates: false },
|
|
148
148
|
{ method: "POST", path: "/api/injection-model/suggest", module: "server/management/agent-settings-routes", mutates: false },
|
|
149
149
|
{ method: "GET", path: "/api/subagent-model-fallback", module: "server/management/agent-settings-routes", mutates: false },
|
|
150
|
-
{ method: "GET", path: "/api/subagent-models", module: "server/management/agent-settings-routes", mutates: false },
|
|
151
150
|
{ method: "GET", path: "/api/v2", module: "server/management/agent-settings-routes", mutates: false },
|
|
152
151
|
{ method: "POST", path: "/api/claude-desktop/apply", module: "server/management/agent-settings-routes", mutates: true },
|
|
153
152
|
{ method: "POST", path: "/api/grok/apply", module: "server/management/agent-settings-routes", mutates: true },
|
|
@@ -169,8 +168,10 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [
|
|
|
169
168
|
{ method: "PUT", path: "/api/grok/selection", module: "server/management/agent-settings-routes", mutates: true },
|
|
170
169
|
{ method: "PUT", path: "/api/injection-model", module: "server/management/agent-settings-routes", mutates: true },
|
|
171
170
|
{ method: "PUT", path: "/api/subagent-model-fallback", module: "server/management/agent-settings-routes", mutates: true },
|
|
172
|
-
{ method: "PUT", path: "/api/subagent-models", module: "server/management/agent-settings-routes", mutates: true },
|
|
173
171
|
{ method: "PUT", path: "/api/v2", module: "server/management/agent-settings-routes", mutates: true },
|
|
172
|
+
// server/management/subagent-model-routes
|
|
173
|
+
{ method: "GET", path: "/api/subagent-models", module: "server/management/subagent-model-routes", mutates: false },
|
|
174
|
+
{ method: "PUT", path: "/api/subagent-models", module: "server/management/subagent-model-routes", mutates: true },
|
|
174
175
|
// server/management/codex-agent-role-routes
|
|
175
176
|
{ method: "GET", path: "/api/codex-agent-roles", module: "server/management/codex-agent-role-routes", mutates: false },
|
|
176
177
|
{ method: "POST", path: "/api/codex-agent-roles/auto-assign", module: "server/management/codex-agent-role-routes", mutates: false, mechanism: "path-constant" },
|
|
@@ -388,7 +389,7 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [
|
|
|
388
389
|
{ method: "GET", path: "/api/link/candidates", module: "server/management/link-routes", mutates: false, exempt: { reason: "session-only", why: "SSH candidates are a dashboard surface: a paired session, or on a standalone runtime the current loopback dashboard session on trusted loopback ingress; admin-token and Tailscale identity sessions are refused." } },
|
|
389
390
|
{ method: "POST", path: "/api/link/probe", module: "server/management/link-routes", mutates: true, exempt: { reason: "session-only", why: "SSH probing and host-key presentation are part of the interactive dashboard consent flow (paired, or standalone loopback dashboard on trusted loopback ingress)." } },
|
|
390
391
|
{ method: "POST", path: "/api/link/confirm-host", module: "server/management/link-routes", mutates: true, exempt: { reason: "session-only", why: "Persisting a host key requires the dashboard session that saw the fingerprint (paired, or standalone loopback dashboard on trusted loopback ingress)." } },
|
|
391
|
-
{ method: "POST", path: "/api/link/join", module: "server/management/link-routes", mutates: true, exempt: { reason: "session-only", why: "Joining a confirmed Home issues a link credential and restarts this standalone runtime as a client on its configured port, which briefly interrupts running Codex turns. It
|
|
392
|
+
{ method: "POST", path: "/api/link/join", module: "server/management/link-routes", mutates: true, exempt: { reason: "session-only", why: "Joining a confirmed Home issues a link credential and restarts this standalone runtime as a client on its configured port, which briefly interrupts running Codex turns. It requires an operator-paired dashboard session and a pre-join notice; unpaired standalone loopback, admin-token and Tailscale identity sessions are refused." } },
|
|
392
393
|
{ method: "POST", path: "/api/link/apply", module: "server/management/link-routes", mutates: true, exempt: { reason: "session-only", why: "Applying a link issues a data key and starts a remote tunnel, so it requires a dashboard session (paired, or standalone loopback dashboard on trusted loopback ingress)." } },
|
|
393
394
|
{ method: "DELETE", path: "/api/link/{id}", module: "server/management/link-routes", mutates: true, mechanism: "regex" },
|
|
394
395
|
{ method: "POST", path: "/api/link/issue", module: "server/management/link-routes", mutates: true },
|
|
@@ -100,7 +100,7 @@ export type CostResult =
|
|
|
100
100
|
| { kind: "value"; estimate: NonNullable<ReturnType<typeof estimateRequestCost>>; estimateReasons: CostEstimateReason[] }
|
|
101
101
|
| { kind: "unavailable"; reason: MetricUnavailableReason };
|
|
102
102
|
|
|
103
|
-
export type MetricSource = Pick<RequestLogEntry, "provider" | "model" | "durationMs" | "firstOutputMs" | "usageStatus" | "usage" | "requestedServiceTier" | "configuredServiceTier" | "responseServiceTier" | "tierOutcome" | "routeDecision" | "cacheProvenance"> & {
|
|
103
|
+
export type MetricSource = Pick<RequestLogEntry, "provider" | "model" | "durationMs" | "firstOutputMs" | "genStartMs" | "lastOutputMs" | "usageStatus" | "usage" | "requestedServiceTier" | "configuredServiceTier" | "responseServiceTier" | "tierOutcome" | "routeDecision" | "cacheProvenance"> & {
|
|
104
104
|
attempts?: readonly PersistedUsageAttempt[];
|
|
105
105
|
};
|
|
106
106
|
|
|
@@ -129,10 +129,11 @@ export function tokPerSecondResult(entry: Pick<MetricSource, "durationMs" | "usa
|
|
|
129
129
|
export const MIN_DECODE_WINDOW_MS = 1_000;
|
|
130
130
|
|
|
131
131
|
/**
|
|
132
|
-
* Estimated DECODE throughput: output tokens over the window
|
|
132
|
+
* Estimated DECODE throughput: output tokens over the measured output window (#4038): the generation window when
|
|
133
|
+
* recorded, otherwise the window after the first token.
|
|
133
134
|
*
|
|
134
|
-
* Strictly additive. `tokensPerSecond
|
|
135
|
-
*
|
|
135
|
+
* Strictly additive. `tokensPerSecond` and `tokPerSecondResult` are untouched, rows only gain the
|
|
136
|
+
* optional window pair, and the end-to-end rate beside it keeps meaning exactly what it
|
|
136
137
|
* has always meant — it is documented as end-to-end, so this is a missing metric rather than a
|
|
137
138
|
* miscalculated one.
|
|
138
139
|
*
|
|
@@ -140,13 +141,26 @@ export const MIN_DECODE_WINDOW_MS = 1_000;
|
|
|
140
141
|
* not the provider's own generation start, so this can never be more than an estimate no matter
|
|
141
142
|
* how long the window is. Saying so in the payload is the honest half of the answer to #4040;
|
|
142
143
|
* MIN_DECODE_WINDOW_MS is the other half.
|
|
144
|
+
*
|
|
145
|
+
* When the row carries the generation window (`genStartMs` / `lastOutputMs`, #6309) that window is
|
|
146
|
+
* used instead. `firstOutputMs` is the first VISIBLE delta, so on a reasoning model the post-TTFT
|
|
147
|
+
* window leaves out the reasoning phase while `outputTokens` still counts its tokens; the
|
|
148
|
+
* generation window spans both and stops at the last delta rather than at stream close.
|
|
143
149
|
*/
|
|
144
150
|
export function decodeTokPerSecondResult(
|
|
145
|
-
entry: Pick<MetricSource, "durationMs" | "firstOutputMs" | "usageStatus" | "usage">,
|
|
151
|
+
entry: Pick<MetricSource, "durationMs" | "firstOutputMs" | "genStartMs" | "lastOutputMs" | "usageStatus" | "usage">,
|
|
146
152
|
): TokPerSecondResult {
|
|
147
153
|
if (!entry.usage) return { kind: "unavailable", reason: "usage_missing" };
|
|
148
154
|
if (entry.usageStatus === "unsupported") return { kind: "unavailable", reason: "usage_unsupported" };
|
|
149
155
|
if (entry.usage.outputTokens <= 0) return { kind: "unavailable", reason: "output_missing" };
|
|
156
|
+
const { genStartMs, lastOutputMs } = entry;
|
|
157
|
+
if (genStartMs !== undefined && lastOutputMs !== undefined && Number.isFinite(genStartMs) && Number.isFinite(lastOutputMs)) {
|
|
158
|
+
const generationMs = lastOutputMs - genStartMs;
|
|
159
|
+
if (generationMs <= 0) return { kind: "unavailable", reason: "invalid_duration" };
|
|
160
|
+
if (generationMs < MIN_DECODE_WINDOW_MS) return { kind: "unavailable", reason: "decode_window_too_short" };
|
|
161
|
+
const value = tokensPerSecond(entry.usage.outputTokens, generationMs);
|
|
162
|
+
return value === null ? { kind: "unavailable", reason: "invalid_duration" } : { kind: "value", value, estimated: true };
|
|
163
|
+
}
|
|
150
164
|
// A row that predates TTFT capture, or a non-streaming turn that never recorded one, has no
|
|
151
165
|
// window to measure. That is a different fact from a bad duration, so it gets its own reason.
|
|
152
166
|
if (entry.firstOutputMs === undefined) return { kind: "unavailable", reason: "ttft_missing" };
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import type { ManagementContext } from "./context";
|
|
2
|
+
import { catalogModelSlug, filterCatalogVisibleModels, nativeModelRows, listCatalogNativeSlugs } from "../../codex/catalog";
|
|
3
|
+
import { subagentSelectableModels } from "../../codex/subagent-selectable-models";
|
|
4
|
+
import { saveConfigPreservingClaudeCode, deleteConfigTopLevelKey, mutatePersistedConfig, adoptPersistedClaudeCode, initializePersistedConfigIfMissing, observeInitialConfigState } from "../../config";
|
|
5
|
+
import { captureConfigTopLevelRollback, parsedConfigRebaseDeletionKeys, projectConfigRebaseProvenance, deleteConfigObjectChildKey } from "../../config/rebase-provenance";
|
|
6
|
+
import { InitialConfigPublicationError } from "../../config/initialize";
|
|
7
|
+
import { ConfigWritePublishedError } from "../../config/persist-unlocked";
|
|
8
|
+
import { commitClaudeCodeBlock } from "../../claude/claude-code-block";
|
|
9
|
+
import { resolveSubagentForceModel, SAFE_AGENT_MODEL_ID } from "../../claude/subagent-model";
|
|
10
|
+
import { inspectSubagentForceStatus } from "../../claude/subagent-force-status";
|
|
11
|
+
import { jsonResponse } from "../auth-cors";
|
|
12
|
+
import { isPlainRecord, fetchInitializedModels as fetchAllModels } from "./shared";
|
|
13
|
+
import { readManagementJsonBody, rethrowManagementBodyTooLarge } from "./body";
|
|
14
|
+
|
|
15
|
+
export async function handleSubagentModelRoutes(ctx: ManagementContext, autoApplyDesktopBestEffort: () => Promise<void>): Promise<Response | null> {
|
|
16
|
+
const { req, url, config, deps, convergeCodexCatalog, syncClaudeAgentDefsBestEffort } = ctx;
|
|
17
|
+
// Featured roster and saved picker order are separate settings. Native Codex advertises
|
|
18
|
+
// the first five eligible visible rows by display priority; OCX guidance uses natural ranks.
|
|
19
|
+
if (url.pathname === "/api/subagent-models" && req.method === "GET") {
|
|
20
|
+
const models = await (deps.fetchAllModels ?? fetchAllModels)(config);
|
|
21
|
+
// Native gpt (passthrough) are also valid subagent picks — they're picker-visible models in the
|
|
22
|
+
// catalog, just buried by priority. List them first so the user can feature them over routed.
|
|
23
|
+
const chosen = config.subagentModels ?? [];
|
|
24
|
+
// A saved roster slot must stay representable even after its model is disabled
|
|
25
|
+
// elsewhere (Models page, provider allowlist, a provider row going away). The
|
|
26
|
+
// dashboard treats `available` as the set of rows it can render, so a chosen id
|
|
27
|
+
// missing from it disappears from the roster UI and the next Save — which PUTs
|
|
28
|
+
// exactly what the UI holds — silently truncates the persisted list. Losing a
|
|
29
|
+
// deliberate 5-model roster to an unrelated visibility toggle is data loss, not a
|
|
30
|
+
// filter. Same reasoning as `fetchGrokCandidateModels`, which deliberately lists a
|
|
31
|
+
// model the user already excluded so its switch remains reachable.
|
|
32
|
+
const available = subagentSelectableModels(config, models, listCatalogNativeSlugs());
|
|
33
|
+
// #857: let CLI/GUI show when a running Codex app-server keeps an older
|
|
34
|
+
// in-memory catalog than the one on disk. Bounded request-path read: the synchronous
|
|
35
|
+
// collector blocked the event loop for the whole Windows CIM walk (4-7s measured).
|
|
36
|
+
const {
|
|
37
|
+
collectCodexAppServerCatalogStateWithin,
|
|
38
|
+
DASHBOARD_CATALOG_STATE_DEADLINE_MS,
|
|
39
|
+
} = await import("../../codex/app-server-processes");
|
|
40
|
+
const catalogState = await collectCodexAppServerCatalogStateWithin(DASHBOARD_CATALOG_STATE_DEADLINE_MS);
|
|
41
|
+
const forceAvailable = subagentSelectableModels({ ...config, subagentModels: [] }, filterCatalogVisibleModels(models, config), listCatalogNativeSlugs());
|
|
42
|
+
return jsonResponse({
|
|
43
|
+
chosen, available, catalogState,
|
|
44
|
+
force: config.claudeCode?.subagentModelForce ?? null,
|
|
45
|
+
forceAvailable,
|
|
46
|
+
forceStatus: config.claudeCode?.subagentModelForce === undefined ? null : await inspectSubagentForceStatus(
|
|
47
|
+
resolveSubagentForceModel(config, {}, { entries: forceAvailable }) !== null,
|
|
48
|
+
),
|
|
49
|
+
pickerAvailable: [...new Set(filterCatalogVisibleModels(models, config).map(catalogModelSlug).filter(slug => slug.includes("/")))],
|
|
50
|
+
pickerOrder: config.modelPickerOrder ?? [],
|
|
51
|
+
pickerOrderMode: config.modelPickerOrderMode ?? null,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
if (url.pathname === "/api/subagent-models" && req.method === "PUT") {
|
|
55
|
+
// Observe before body/discovery awaits: disappearance of an existing config
|
|
56
|
+
// must never turn a scoped update into first-run initialization.
|
|
57
|
+
const initialConfigState = deps.saveConfigPreservingClaudeCode ? undefined : observeInitialConfigState();
|
|
58
|
+
let rawBody: unknown;
|
|
59
|
+
try { rawBody = await readManagementJsonBody(req); } catch (error) { rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid JSON body" }, 400); }
|
|
60
|
+
if (!isPlainRecord(rawBody)) return jsonResponse({ error: "JSON body must be an object" }, 400);
|
|
61
|
+
const body = rawBody as { models?: unknown; pickerOrder?: unknown; pickerOrderMode?: unknown; force?: unknown };
|
|
62
|
+
const updatesForce = Object.hasOwn(body, "force");
|
|
63
|
+
const updatesRoster = body.models !== undefined;
|
|
64
|
+
const updatesPicker = body.pickerOrder !== undefined;
|
|
65
|
+
if (!updatesRoster && !updatesPicker && !updatesForce) return jsonResponse({ error: "models, pickerOrder or force is required" }, 400);
|
|
66
|
+
let chosen: string[] | undefined;
|
|
67
|
+
if (updatesRoster) {
|
|
68
|
+
if (!Array.isArray(body.models) || body.models.some(model => typeof model !== "string")) {
|
|
69
|
+
return jsonResponse({ error: "models must be an array of strings" }, 400);
|
|
70
|
+
}
|
|
71
|
+
// Keep the original valid roster contract: no discovery validation, trimming or deduping.
|
|
72
|
+
chosen = body.models.slice(0, 5);
|
|
73
|
+
}
|
|
74
|
+
const mode = body.pickerOrderMode;
|
|
75
|
+
if (mode !== undefined && (!updatesPicker || (mode !== null
|
|
76
|
+
&& mode !== "alphabetical" && mode !== "provider" && mode !== "most-used"))) {
|
|
77
|
+
return jsonResponse({ error: "pickerOrderMode requires pickerOrder and must be alphabetical, provider, most-used, or null" }, 400);
|
|
78
|
+
}
|
|
79
|
+
let pickerOrder: string[] | undefined;
|
|
80
|
+
if (updatesPicker) {
|
|
81
|
+
if (body.pickerOrder !== null && (!Array.isArray(body.pickerOrder)
|
|
82
|
+
|| body.pickerOrder.some(model => typeof model !== "string" || model.trim() === ""))) {
|
|
83
|
+
return jsonResponse({ error: "pickerOrder must be an array of non-empty routed model ids, or null" }, 400);
|
|
84
|
+
}
|
|
85
|
+
pickerOrder = body.pickerOrder === null ? [] : (body.pickerOrder as string[]).map(model => model.trim());
|
|
86
|
+
if (new Set(pickerOrder).size !== pickerOrder.length) {
|
|
87
|
+
return jsonResponse({ error: "pickerOrder must not contain duplicate ids" }, 400);
|
|
88
|
+
}
|
|
89
|
+
if (pickerOrder.length > 0) {
|
|
90
|
+
const models = await (deps.fetchAllModels ?? fetchAllModels)(config);
|
|
91
|
+
// Evaluate visibility AFTER discovery: a concurrent visibility write may have completed.
|
|
92
|
+
const visible = new Set(filterCatalogVisibleModels(models, config).map(catalogModelSlug).filter(slug => slug.includes("/")));
|
|
93
|
+
// A bare native id orders the complete picker (docs: guides/model-ordering.md).
|
|
94
|
+
for (const row of nativeModelRows(config)) if (!row.disabled) visible.add(row.slug);
|
|
95
|
+
if (pickerOrder.some(model => !visible.has(model))) {
|
|
96
|
+
return jsonResponse({ error: "pickerOrder must contain visible routed or native model ids, each at most once" }, 400);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (updatesForce && body.force !== null) {
|
|
102
|
+
if (typeof body.force !== "string" || !SAFE_AGENT_MODEL_ID.test(body.force)) {
|
|
103
|
+
return jsonResponse({ error: "force must be a safe exposed model id or null" }, 400);
|
|
104
|
+
}
|
|
105
|
+
const models = await (deps.fetchAllModels ?? fetchAllModels)(config);
|
|
106
|
+
const exposed = subagentSelectableModels({ ...config, subagentModels: [] }, filterCatalogVisibleModels(models, config), listCatalogNativeSlugs());
|
|
107
|
+
if (!resolveSubagentForceModel({ ...config, claudeCode: { ...config.claudeCode, subagentModelForce: body.force } }, {}, { entries: exposed })) {
|
|
108
|
+
return jsonResponse({ error: "force must resolve to an exposed model" }, 400);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (updatesForce && initialConfigState === "invalid") {
|
|
113
|
+
return jsonResponse({ error: "force settings could not be persisted" }, 409);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Everything above can await. From this snapshot through persistence there is no yield.
|
|
117
|
+
// Stage deletion intent before adopting the touched fields through the canonical
|
|
118
|
+
// live deletion owner. A failed save restores both fields and pending intent.
|
|
119
|
+
if ((updatesPicker || updatesForce) && config.configRebaseProvenance !== undefined
|
|
120
|
+
&& parsedConfigRebaseDeletionKeys(config) === null) {
|
|
121
|
+
// A newer provenance format must not silently discard this clear's intent on rebase.
|
|
122
|
+
return jsonResponse({ error: "unsupported config deletion provenance" }, 409);
|
|
123
|
+
}
|
|
124
|
+
const draft = { ...projectConfigRebaseProvenance(config) };
|
|
125
|
+
if (chosen !== undefined) draft.subagentModels = chosen;
|
|
126
|
+
if (updatesForce) {
|
|
127
|
+
commitClaudeCodeBlock(draft, { ...config.claudeCode });
|
|
128
|
+
if (body.force === null) deleteConfigObjectChildKey(draft, "claudeCode", "subagentModelForce");
|
|
129
|
+
else draft.claudeCode!.subagentModelForce = body.force as string;
|
|
130
|
+
}
|
|
131
|
+
if (pickerOrder !== undefined) {
|
|
132
|
+
if (pickerOrder.length === 0) {
|
|
133
|
+
deleteConfigTopLevelKey(draft, "modelPickerOrder");
|
|
134
|
+
deleteConfigTopLevelKey(draft, "modelPickerOrderMode");
|
|
135
|
+
} else {
|
|
136
|
+
draft.modelPickerOrder = pickerOrder;
|
|
137
|
+
if (mode === "alphabetical" || mode === "provider" || mode === "most-used") draft.modelPickerOrderMode = mode;
|
|
138
|
+
else deleteConfigTopLevelKey(draft, "modelPickerOrderMode");
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
const projected = projectConfigRebaseProvenance(draft);
|
|
142
|
+
const touched = [
|
|
143
|
+
...(updatesRoster ? ["subagentModels" as const] : []),
|
|
144
|
+
...(updatesForce ? ["claudeCode" as const] : []),
|
|
145
|
+
...(updatesPicker ? ["modelPickerOrder" as const, "modelPickerOrderMode" as const] : []),
|
|
146
|
+
"configRebaseProvenance" as const,
|
|
147
|
+
];
|
|
148
|
+
const rollback = captureConfigTopLevelRollback(config, touched);
|
|
149
|
+
try {
|
|
150
|
+
for (const key of touched) {
|
|
151
|
+
if (Object.hasOwn(projected, key)) Object.defineProperty(config, key, {
|
|
152
|
+
value: projected[key], writable: true, enumerable: true, configurable: true,
|
|
153
|
+
});
|
|
154
|
+
else deleteConfigTopLevelKey(config, key);
|
|
155
|
+
}
|
|
156
|
+
if (updatesForce && body.force === null) deleteConfigObjectChildKey(config, "claudeCode", "subagentModelForce");
|
|
157
|
+
if (updatesForce && initialConfigState === "missing") {
|
|
158
|
+
// Create-only publication rechecks under the canonical lock and never
|
|
159
|
+
// replaces a file another writer supplied while discovery was pending.
|
|
160
|
+
if (initializePersistedConfigIfMissing(config) !== "created") {
|
|
161
|
+
rollback();
|
|
162
|
+
return jsonResponse({ error: "force settings could not be persisted" }, 409);
|
|
163
|
+
}
|
|
164
|
+
adoptPersistedClaudeCode(config, structuredClone(config.claudeCode));
|
|
165
|
+
} else if (updatesForce && !deps.saveConfigPreservingClaudeCode) {
|
|
166
|
+
const outcome = mutatePersistedConfig(persisted => {
|
|
167
|
+
commitClaudeCodeBlock(persisted, { ...persisted.claudeCode });
|
|
168
|
+
if (body.force === null) delete persisted.claudeCode!.subagentModelForce;
|
|
169
|
+
else persisted.claudeCode!.subagentModelForce = body.force as string;
|
|
170
|
+
if (updatesRoster) persisted.subagentModels = chosen!;
|
|
171
|
+
if (updatesPicker) {
|
|
172
|
+
if (pickerOrder!.length === 0) {
|
|
173
|
+
deleteConfigTopLevelKey(persisted, "modelPickerOrder");
|
|
174
|
+
deleteConfigTopLevelKey(persisted, "modelPickerOrderMode");
|
|
175
|
+
} else {
|
|
176
|
+
persisted.modelPickerOrder = pickerOrder;
|
|
177
|
+
if (mode === "alphabetical" || mode === "provider" || mode === "most-used") persisted.modelPickerOrderMode = mode;
|
|
178
|
+
else deleteConfigTopLevelKey(persisted, "modelPickerOrderMode");
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return { changed: true, value: structuredClone(persisted.claudeCode) };
|
|
182
|
+
});
|
|
183
|
+
if (outcome.status === "unavailable") {
|
|
184
|
+
rollback();
|
|
185
|
+
return jsonResponse({ error: "force settings could not be persisted" }, 409);
|
|
186
|
+
}
|
|
187
|
+
adoptPersistedClaudeCode(config, outcome.value);
|
|
188
|
+
} else {
|
|
189
|
+
(deps.saveConfigPreservingClaudeCode ?? saveConfigPreservingClaudeCode)(config);
|
|
190
|
+
}
|
|
191
|
+
} catch (error) {
|
|
192
|
+
// Once publication succeeded (or cannot be ruled out), restoring only the
|
|
193
|
+
// live object would falsely report the old setting while disk may hold new bytes.
|
|
194
|
+
if (!(error instanceof ConfigWritePublishedError)
|
|
195
|
+
&& !(error instanceof InitialConfigPublicationError && error.publication !== "not-published")) rollback();
|
|
196
|
+
throw error;
|
|
197
|
+
}
|
|
198
|
+
// Capture the result before convergence yields to another settings mutation.
|
|
199
|
+
const saved = {
|
|
200
|
+
applied: [...(config.subagentModels ?? [])],
|
|
201
|
+
force: config.claudeCode?.subagentModelForce ?? null,
|
|
202
|
+
pickerOrder: [...(config.modelPickerOrder ?? [])],
|
|
203
|
+
pickerOrderMode: config.modelPickerOrderMode ?? null,
|
|
204
|
+
};
|
|
205
|
+
const catalogRefresh = updatesRoster || updatesPicker ? await convergeCodexCatalog() : undefined;
|
|
206
|
+
if (updatesRoster) {
|
|
207
|
+
await syncClaudeAgentDefsBestEffort();
|
|
208
|
+
await autoApplyDesktopBestEffort();
|
|
209
|
+
}
|
|
210
|
+
return jsonResponse({ ok: true, ...saved, catalogRefresh });
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return null;
|
|
214
|
+
}
|
package/src/server/relay.ts
CHANGED
|
@@ -30,8 +30,9 @@ import {
|
|
|
30
30
|
joinSseFrameBytes,
|
|
31
31
|
MAX_CLIENT_SSE_FRAME_BYTES,
|
|
32
32
|
SseFrameCountLimitError,
|
|
33
|
+
sseDelimiterLengthAt,
|
|
33
34
|
} from "./sse-frame-buffer";
|
|
34
|
-
import { replaceSseDataPayload, sseDataPayload } from "./sse-payload-rewrite";
|
|
35
|
+
import { replaceSseDataPayload, splitSseBlock, sseDataPayload } from "./sse-payload-rewrite";
|
|
35
36
|
import { createBoundedResponseLogBody } from "./response-log-body";
|
|
36
37
|
import { clientWireLogOf } from "./inference/client-wire";
|
|
37
38
|
import { recordClientWireRequestLog } from "./inference/client-wire-log";
|
|
@@ -355,10 +356,11 @@ export function createSseTerminalOutputBoundary(
|
|
|
355
356
|
frames: ReturnType<BoundedSseFrameBuffer["feed"]>,
|
|
356
357
|
): Uint8Array => {
|
|
357
358
|
if (disposed || terminal || frames.length === 0) return EMPTY_BYTES;
|
|
358
|
-
|
|
359
|
+
const completedFrames = frames.reduce((count, frame) => count + (frame.continuation ? 0 : 1), 0);
|
|
360
|
+
if (maxFrames !== undefined && completedFrames > maxFrames - framesSeen) {
|
|
359
361
|
throw new SseFrameCountLimitError(maxFrames);
|
|
360
362
|
}
|
|
361
|
-
framesSeen +=
|
|
363
|
+
framesSeen += completedFrames;
|
|
362
364
|
const output: Uint8Array[] = [];
|
|
363
365
|
const appendOutput = (...parts: Uint8Array[]): void => {
|
|
364
366
|
for (const part of parts) {
|
|
@@ -596,17 +598,7 @@ export function relaySseWithFailedTail(
|
|
|
596
598
|
});
|
|
597
599
|
}
|
|
598
600
|
|
|
599
|
-
export
|
|
600
|
-
const match = buffer.match(/\r?\n\r?\n/);
|
|
601
|
-
if (!match || match.index === undefined) return null;
|
|
602
|
-
return {
|
|
603
|
-
block: buffer.slice(0, match.index),
|
|
604
|
-
delimiter: match[0],
|
|
605
|
-
rest: buffer.slice(match.index + match[0].length),
|
|
606
|
-
};
|
|
607
|
-
}
|
|
608
|
-
|
|
609
|
-
export { sseDataPayload } from "./sse-payload-rewrite";
|
|
601
|
+
export { nextSseBlock, sseDataPayload } from "./sse-payload-rewrite";
|
|
610
602
|
|
|
611
603
|
type JsonRecord = Record<string, unknown>;
|
|
612
604
|
|
|
@@ -698,9 +690,8 @@ function stripCodexSafetyBufferingField(block: string, parsed: unknown): string
|
|
|
698
690
|
}
|
|
699
691
|
|
|
700
692
|
function rewritePolicyTerminalBlock(block: string, payload: string): string {
|
|
701
|
-
const newline = block.includes("\r\n") ? "\r\n" : "\n";
|
|
702
693
|
const rewritten = replaceSseDataPayload(block, payload);
|
|
703
|
-
const lines = rewritten
|
|
694
|
+
const { newline, lines } = splitSseBlock(rewritten);
|
|
704
695
|
let eventRewritten = false;
|
|
705
696
|
const withEvent = lines.map(line => {
|
|
706
697
|
if (!eventRewritten && line.startsWith("event:")) {
|
|
@@ -1156,31 +1147,6 @@ function outputBearingSseEvent(event: { type?: unknown; output_index?: unknown }
|
|
|
1156
1147
|
.test(event.type);
|
|
1157
1148
|
}
|
|
1158
1149
|
|
|
1159
|
-
function delimiterLengthAt(
|
|
1160
|
-
index: number,
|
|
1161
|
-
length: number,
|
|
1162
|
-
byteAt: (index: number) => number,
|
|
1163
|
-
): number | 0 | undefined {
|
|
1164
|
-
const first = byteAt(index);
|
|
1165
|
-
if (first === 10) {
|
|
1166
|
-
if (index + 1 >= length) return undefined;
|
|
1167
|
-
const second = byteAt(index + 1);
|
|
1168
|
-
if (second === 10) return 2;
|
|
1169
|
-
if (second !== 13) return 0;
|
|
1170
|
-
if (index + 2 >= length) return undefined;
|
|
1171
|
-
return byteAt(index + 2) === 10 ? 3 : 0;
|
|
1172
|
-
}
|
|
1173
|
-
if (first !== 13) return 0;
|
|
1174
|
-
if (index + 1 >= length) return undefined;
|
|
1175
|
-
if (byteAt(index + 1) !== 10) return 0;
|
|
1176
|
-
if (index + 2 >= length) return undefined;
|
|
1177
|
-
const third = byteAt(index + 2);
|
|
1178
|
-
if (third === 10) return 3;
|
|
1179
|
-
if (third !== 13) return 0;
|
|
1180
|
-
if (index + 3 >= length) return undefined;
|
|
1181
|
-
return byteAt(index + 3) === 10 ? 4 : 0;
|
|
1182
|
-
}
|
|
1183
|
-
|
|
1184
1150
|
/**
|
|
1185
1151
|
* Per-chunk SSE inspection state machine shared by consumeForInspection,
|
|
1186
1152
|
* consumeForResponseLogMetadata, and the eager bounded relay (relay-eager.ts).
|
|
@@ -1202,6 +1168,7 @@ export function createSseInspector(handlers: SseInspectorHandlers): SseInspector
|
|
|
1202
1168
|
let sawTerminal = false;
|
|
1203
1169
|
let disposed = false;
|
|
1204
1170
|
let delimiterTail: Uint8Array = EMPTY_BYTES;
|
|
1171
|
+
let pendingLineFeed = false;
|
|
1205
1172
|
let candidate: Uint8Array = EMPTY_BYTES;
|
|
1206
1173
|
let candidateBytes = 0;
|
|
1207
1174
|
let discardingOversizedFrame = false;
|
|
@@ -1228,6 +1195,7 @@ export function createSseInspector(handlers: SseInspectorHandlers): SseInspector
|
|
|
1228
1195
|
|
|
1229
1196
|
const clearFrameState = (): void => {
|
|
1230
1197
|
delimiterTail = EMPTY_BYTES;
|
|
1198
|
+
pendingLineFeed = false;
|
|
1231
1199
|
candidate = EMPTY_BYTES;
|
|
1232
1200
|
candidateBytes = 0;
|
|
1233
1201
|
discardingOversizedFrame = false;
|
|
@@ -1504,15 +1472,17 @@ export function createSseInspector(handlers: SseInspectorHandlers): SseInspector
|
|
|
1504
1472
|
retainCandidateSlice(chunk.subarray(Math.max(0, start - tailLength), end - tailLength));
|
|
1505
1473
|
}
|
|
1506
1474
|
};
|
|
1507
|
-
let index = 0;
|
|
1508
|
-
|
|
1475
|
+
let index = pendingLineFeed && byteAt(0) === 10 ? 1 : 0;
|
|
1476
|
+
if (totalLength > 0) pendingLineFeed = false;
|
|
1477
|
+
let retainedThrough = index;
|
|
1509
1478
|
while (index < totalLength) {
|
|
1510
|
-
const delimiterLength =
|
|
1479
|
+
const delimiterLength = sseDelimiterLengthAt(index, totalLength, byteAt);
|
|
1511
1480
|
if (delimiterLength === undefined) break;
|
|
1512
1481
|
if (delimiterLength > 0) {
|
|
1513
1482
|
retainRange(retainedThrough, index);
|
|
1514
1483
|
completeCandidate();
|
|
1515
1484
|
index += delimiterLength;
|
|
1485
|
+
pendingLineFeed = index === totalLength && byteAt(index - 1) === 13;
|
|
1516
1486
|
retainedThrough = index;
|
|
1517
1487
|
continue;
|
|
1518
1488
|
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { RequestLogContext } from "./request-log";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Generation window for the decode-rate estimate (#4038, #6309).
|
|
5
|
+
*
|
|
6
|
+
* `firstOutputMs` marks the first VISIBLE output delta. For a reasoning model that is the end of
|
|
7
|
+
* the reasoning phase, yet `outputTokens` includes the reasoning tokens, so
|
|
8
|
+
* `outputTokens / (durationMs - firstOutputMs)` divides the whole output by only part of the time
|
|
9
|
+
* that produced it. The window recorded here starts when the model starts producing ANY output
|
|
10
|
+
* item (reasoning included) and ends at the last output delta, so trailing stream bookkeeping
|
|
11
|
+
* after the final token does not count either.
|
|
12
|
+
*
|
|
13
|
+
* Event vocabulary: Anthropic `content_block_start` / `content_block_delta`, and Responses
|
|
14
|
+
* `response.output_item.added` / `response.*.delta`. Anything else leaves the window untouched.
|
|
15
|
+
*/
|
|
16
|
+
export function recordGenerationEvent(logCtx: RequestLogContext, type: unknown, now = Date.now()): void {
|
|
17
|
+
if (typeof type !== "string" || !Number.isFinite(now)) return;
|
|
18
|
+
if (type === "content_block_start" || type === "response.output_item.added") {
|
|
19
|
+
logCtx.generationStartedAt ??= now;
|
|
20
|
+
} else if (type === "content_block_delta" || (type.startsWith("response.") && type.endsWith(".delta"))) {
|
|
21
|
+
logCtx.generationStartedAt ??= now;
|
|
22
|
+
logCtx.lastOutputAt = now;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Request-relative `genStartMs` / `lastOutputMs`, present only when both ends were observed. */
|
|
27
|
+
export function generationWindowFields(
|
|
28
|
+
logCtx: Pick<RequestLogContext, "generationStartedAt" | "lastOutputAt">,
|
|
29
|
+
requestStartedAt: number,
|
|
30
|
+
): { genStartMs?: number; lastOutputMs?: number } {
|
|
31
|
+
const startedAt = logCtx.generationStartedAt;
|
|
32
|
+
const lastAt = logCtx.lastOutputAt;
|
|
33
|
+
if (startedAt === undefined || lastAt === undefined || !Number.isFinite(requestStartedAt)) return {};
|
|
34
|
+
return {
|
|
35
|
+
genStartMs: Math.max(0, startedAt - requestStartedAt),
|
|
36
|
+
lastOutputMs: Math.max(0, lastAt - requestStartedAt),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
@@ -85,6 +85,7 @@ import {
|
|
|
85
85
|
normalizePersistedJevDecision,
|
|
86
86
|
type PersistedJevDecisionV1,
|
|
87
87
|
} from "../usage/jev-stats";
|
|
88
|
+
import { generationWindowFields, recordGenerationEvent } from "./request-log-generation-window";
|
|
88
89
|
import type { RequestMetricsRecorder } from "./request-metrics";
|
|
89
90
|
import type {
|
|
90
91
|
CacheDiagnosticDraft,
|
|
@@ -136,6 +137,9 @@ export interface RequestLogContext {
|
|
|
136
137
|
affinityMoveReasons?: CodexAffinityReason[];
|
|
137
138
|
/** TTFT: ms from request start to the first non-empty model output delta (WP4, devlog 040). */
|
|
138
139
|
firstOutputMs?: number;
|
|
140
|
+
/** Epoch ms of the first output item and of the last output delta; see request-log-generation-window.ts. */
|
|
141
|
+
generationStartedAt?: number;
|
|
142
|
+
lastOutputAt?: number;
|
|
139
143
|
/** Best-effort chat/session correlation for Logs grouping (#330). Opaque; omit when unknown. */
|
|
140
144
|
conversationId?: string;
|
|
141
145
|
surface?: "claude" | "claude-desktop" | "grok";
|
|
@@ -282,6 +286,9 @@ export interface RequestLogEntry {
|
|
|
282
286
|
provider: string;
|
|
283
287
|
/** TTFT: ms from request start to the first non-empty model output delta; unset for non-streaming/tool-only. */
|
|
284
288
|
firstOutputMs?: number;
|
|
289
|
+
/** Request-relative generation window (#6309): first output item of any kind, then the last output delta. */
|
|
290
|
+
genStartMs?: number;
|
|
291
|
+
lastOutputMs?: number;
|
|
285
292
|
surface?: "claude" | "claude-desktop" | "grok";
|
|
286
293
|
/**
|
|
287
294
|
* Set when the proxy answered this turn locally and sent nothing upstream. Without it a zero-send
|
|
@@ -453,6 +460,8 @@ export function requestLogEntryFromPersistedUsage(entry: PersistedUsageEntry): R
|
|
|
453
460
|
model: entry.model,
|
|
454
461
|
provider: entry.provider,
|
|
455
462
|
...(entry.firstOutputMs !== undefined ? { firstOutputMs: entry.firstOutputMs } : {}),
|
|
463
|
+
...(entry.genStartMs !== undefined ? { genStartMs: entry.genStartMs } : {}),
|
|
464
|
+
...(entry.lastOutputMs !== undefined ? { lastOutputMs: entry.lastOutputMs } : {}),
|
|
456
465
|
...(isKnownUsageSurface(entry.surface) ? { surface: entry.surface } : {}),
|
|
457
466
|
...(entry.conversationId ? { conversationId: entry.conversationId } : {}),
|
|
458
467
|
...(isCodexUsageAccountLogLabel(entry.accountLogLabel)
|
|
@@ -668,6 +677,8 @@ export function addRequestLog(entry: RequestLogEntry) {
|
|
|
668
677
|
status: entry.status,
|
|
669
678
|
durationMs: entry.durationMs,
|
|
670
679
|
...(entry.firstOutputMs !== undefined ? { firstOutputMs: entry.firstOutputMs } : {}),
|
|
680
|
+
...(entry.genStartMs !== undefined ? { genStartMs: entry.genStartMs } : {}),
|
|
681
|
+
...(entry.lastOutputMs !== undefined ? { lastOutputMs: entry.lastOutputMs } : {}),
|
|
671
682
|
usageStatus: entry.usageStatus,
|
|
672
683
|
...(entry.usage ? { usage: entry.usage } : {}),
|
|
673
684
|
...(entry.totalTokens !== undefined ? { totalTokens: entry.totalTokens } : {}),
|
|
@@ -1056,6 +1067,7 @@ export function inspectResponseLogSsePayloadParsed(
|
|
|
1056
1067
|
if (!payload || payload.trim() === "[DONE]") return;
|
|
1057
1068
|
const debugEnabled = isUsageDebugEnabled();
|
|
1058
1069
|
const sseAlreadyMarked = logCtx.usageDebugBodyKind === "sse";
|
|
1070
|
+
if (parsed !== undefined) recordGenerationEvent(logCtx, (parsed as { type?: unknown } | null)?.type);
|
|
1059
1071
|
if (parsed !== undefined) applyResponseLogMetadata(logCtx, parsed);
|
|
1060
1072
|
else logCtx.activeTierMetadata?.markResponseUnparseable();
|
|
1061
1073
|
captureUpstreamErrorParsed(logCtx, payload, parsed);
|
|
@@ -1571,6 +1583,7 @@ export function addFinalRequestLog(
|
|
|
1571
1583
|
status: effectiveStatus,
|
|
1572
1584
|
durationMs,
|
|
1573
1585
|
...(logCtx.firstOutputMs !== undefined ? { firstOutputMs: logCtx.firstOutputMs } : {}),
|
|
1586
|
+
...generationWindowFields(logCtx, start),
|
|
1574
1587
|
...(errorCode ? { errorCode } : {}),
|
|
1575
1588
|
...(meta?.terminalStatus ? { terminalStatus: meta.terminalStatus } : {}),
|
|
1576
1589
|
...(closeReason ? { closeReason } : {}),
|
|
@@ -46,9 +46,10 @@ import type { OpaqueBlobRecoveryGuard } from "./core-opaque-recovery";
|
|
|
46
46
|
import type { AttemptRecoveryKind } from "../../usage/log";
|
|
47
47
|
import type { OAuthAccessSnapshot } from "../../oauth";
|
|
48
48
|
import { OAuthAccountPausedError, OAuthLoginRequiredError, publicOAuthAuthenticationErrorMessage } from "../../oauth";
|
|
49
|
-
import { getAccountSet } from "../../oauth/store";
|
|
49
|
+
import { getAccountSet, markAccountNeedsReauthIfGeneration } from "../../oauth/store";
|
|
50
50
|
import { tryKiroAlternateAfterTerminalRefresh } from "../../oauth/kiro-terminal-failover";
|
|
51
51
|
import { classifyKiroRefusal } from "../../adapters/kiro-refusal";
|
|
52
|
+
import { classifyAntigravityRefusal } from "../../adapters/antigravity-refusal";
|
|
52
53
|
import { normalizeFinalKiroHttpError } from "../../adapters/kiro-retry";
|
|
53
54
|
import { noteKiroMonthlyRefusal } from "../../providers/kiro-usage";
|
|
54
55
|
import { persistKiroAccountState } from "../../providers/kiro-account-state-disk";
|
|
@@ -1243,6 +1244,104 @@ export async function prepareAdapterExchange(
|
|
|
1243
1244
|
break;
|
|
1244
1245
|
}
|
|
1245
1246
|
}
|
|
1247
|
+
// Antigravity verify-account quarantine: a 403 demanding account verification
|
|
1248
|
+
// is terminal for THAT account — its grant still refreshes, so only a manual
|
|
1249
|
+
// verification followed by a re-login clears it. Mark it needsReauth (durable,
|
|
1250
|
+
// shown as `needs-reauth(verify)` in `ocx account list` with a `verify_account`
|
|
1251
|
+
// health reason in the management API, excluded from the pool until re-login)
|
|
1252
|
+
// and replay the same request on the next eligible account so the pool keeps
|
|
1253
|
+
// serving.
|
|
1254
|
+
while (
|
|
1255
|
+
upstreamResponse.status === 403
|
|
1256
|
+
&& route.providerName === "google-antigravity"
|
|
1257
|
+
&& transportState.genericFailoverAccountId
|
|
1258
|
+
) {
|
|
1259
|
+
const refusal = classifyAntigravityRefusal(upstreamResponse.status,
|
|
1260
|
+
await readDisplaySafeErrorText(upstreamResponse.clone(), upstream.signal, ""));
|
|
1261
|
+
if (refusal.kind === "other") break;
|
|
1262
|
+
const sent = transportState.sentOAuthSnapshot;
|
|
1263
|
+
const failedAccountId = transportState.genericFailoverAccountId;
|
|
1264
|
+
// Fenced write: a 403 arriving after the credential rotated (refresh or
|
|
1265
|
+
// re-login) must not quarantine the new login. Without the matching sent
|
|
1266
|
+
// snapshot there is no owner for this write, so skip marking.
|
|
1267
|
+
if (!sent || sent.accountId !== failedAccountId) break;
|
|
1268
|
+
try {
|
|
1269
|
+
await markAccountNeedsReauthIfGeneration(route.providerName, failedAccountId, sent.generation, undefined, "verify_account");
|
|
1270
|
+
} catch {
|
|
1271
|
+
// A failed quarantine write must preserve the refusal, not authorize another send.
|
|
1272
|
+
break recovery;
|
|
1273
|
+
}
|
|
1274
|
+
if (!antigravityPoolActivated || transportState.genericFailovers >= transportState.genericFailoverLimit) break;
|
|
1275
|
+
const adapterOwnsDispatch = transportState.activeAdapter.fetchResponse !== undefined;
|
|
1276
|
+
const hop = reserveCredentialHop(
|
|
1277
|
+
"auth-recovery",
|
|
1278
|
+
`${route.providerName}|${route.modelId}|adapter-recovery-oauth-verify`,
|
|
1279
|
+
!adapterOwnsDispatch && transientRetryPolicyFor(route.provider) !== null,
|
|
1280
|
+
);
|
|
1281
|
+
if (!hop.allowed) break;
|
|
1282
|
+
// Auth-refusal rotation, not the rate-limit one: a verification refusal
|
|
1283
|
+
// must not record rate-limit cooldown semantics against the account.
|
|
1284
|
+
const nextAccountId = rotateAntigravityAccountOnAuthRefusal(
|
|
1285
|
+
antigravityPoolActivated,
|
|
1286
|
+
failedAccountId,
|
|
1287
|
+
sent.generation,
|
|
1288
|
+
route.modelId,
|
|
1289
|
+
);
|
|
1290
|
+
if (!nextAccountId) {
|
|
1291
|
+
hop.permit?.release();
|
|
1292
|
+
break;
|
|
1293
|
+
}
|
|
1294
|
+
// The refusal body stays alive until the replacement owns the outcome: it
|
|
1295
|
+
// is also the preserveFailureResponse below, so cancelling it early would
|
|
1296
|
+
// hand back a 403 with a destroyed body when the rebuild is refused.
|
|
1297
|
+
const failedResponse = upstreamResponse;
|
|
1298
|
+
try {
|
|
1299
|
+
// The FULL snapshot, not just the bearer: Antigravity pairs an
|
|
1300
|
+
// account-matched projectId with its token, so a token-only swap would mix
|
|
1301
|
+
// one account's credential with another's routing data.
|
|
1302
|
+
const snapshot = await failoverAccountSnapshot(route.providerName, nextAccountId);
|
|
1303
|
+
if (!await applyFailoverSnapshot(snapshot)) {
|
|
1304
|
+
hop.permit?.release();
|
|
1305
|
+
break;
|
|
1306
|
+
}
|
|
1307
|
+
invalidateSameTargetRequest();
|
|
1308
|
+
transportState.activeAdapter = resolveSelectionAdapter(
|
|
1309
|
+
resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire, route.staticPolicy),
|
|
1310
|
+
config.cacheRetention,
|
|
1311
|
+
);
|
|
1312
|
+
bindRouteReasoningReplayScope({
|
|
1313
|
+
parsed,
|
|
1314
|
+
providerName: route.providerName,
|
|
1315
|
+
provider: route.provider,
|
|
1316
|
+
adapterName: transportState.activeAdapter.name,
|
|
1317
|
+
oauthCredentialSnapshot: transportState.replayOAuthCredentialSnapshot,
|
|
1318
|
+
});
|
|
1319
|
+
sealRequestAttemptIdentity(logCtx.activeAttempt, logCtx.provider, transportState.activeAdapter.name, logCtx.accountLogLabel);
|
|
1320
|
+
recordAttemptCredentialSource(logCtx.activeAttempt, route.providerName, route.provider, transportState.activeAdapter.name);
|
|
1321
|
+
sendBudgetState.pendingHopPermit = hop.permit;
|
|
1322
|
+
let result: Response | { failed: Response };
|
|
1323
|
+
try {
|
|
1324
|
+
result = await rebuildAndRefetch("oauth-account-403", () => {
|
|
1325
|
+
if (!adapterOwnsDispatch) hop.permit?.use();
|
|
1326
|
+
}, failedResponse);
|
|
1327
|
+
} finally {
|
|
1328
|
+
sendBudgetState.pendingHopPermit = undefined;
|
|
1329
|
+
}
|
|
1330
|
+
if ("failed" in result) {
|
|
1331
|
+
hop.permit?.release();
|
|
1332
|
+
if (result.failed !== failedResponse) return result.failed;
|
|
1333
|
+
// Preserve common redaction and combo consumption without another recovery send.
|
|
1334
|
+
break recovery;
|
|
1335
|
+
}
|
|
1336
|
+
transportState.genericFailovers += 1;
|
|
1337
|
+
try { void failedResponse.body?.cancel().catch(() => {}); } catch { /* already consumed/closed */ }
|
|
1338
|
+
upstreamResponse = result;
|
|
1339
|
+
if (isNonReplayableResponse(upstreamResponse)) continue recovery;
|
|
1340
|
+
} catch {
|
|
1341
|
+
hop.permit?.release();
|
|
1342
|
+
break;
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1246
1345
|
}
|
|
1247
1346
|
// Unknown provenance is deliberately fail-soft in pre-flight: after a restart, TTL expiry,
|
|
1248
1347
|
// or LRU eviction, a valid same-backend blob must survive. A decoder's own 4xx identity is
|