@bitkyc08/opencodex 2.6.32 → 2.7.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.
Files changed (55) hide show
  1. package/README.ko.md +9 -5
  2. package/README.md +7 -4
  3. package/README.zh-CN.md +8 -4
  4. package/gui/dist/assets/index-BGdxwydf.js +34 -0
  5. package/gui/dist/assets/index-DANCQ2Jt.css +1 -0
  6. package/gui/dist/index.html +2 -2
  7. package/package.json +1 -1
  8. package/src/adapters/anthropic.ts +62 -1
  9. package/src/adapters/cursor/cursor-errors.ts +28 -1
  10. package/src/adapters/cursor/discovery.ts +56 -10
  11. package/src/adapters/cursor/effort-map.ts +35 -7
  12. package/src/adapters/cursor/live-models.ts +3 -0
  13. package/src/adapters/cursor/live-transport.ts +136 -7
  14. package/src/adapters/cursor/protobuf-request.ts +24 -1
  15. package/src/adapters/cursor/request-builder.ts +6 -5
  16. package/src/adapters/cursor/transport-retry.ts +22 -3
  17. package/src/adapters/cursor.ts +2 -1
  18. package/src/adapters/openai-chat.ts +75 -26
  19. package/src/bridge.ts +42 -3
  20. package/src/cli/debug.ts +203 -0
  21. package/src/cli/doctor.ts +11 -0
  22. package/src/cli/help.ts +11 -0
  23. package/src/cli/index.ts +10 -0
  24. package/src/cli/v2.ts +131 -0
  25. package/src/codex/auth-api.ts +7 -3
  26. package/src/codex/catalog.ts +334 -31
  27. package/src/codex/data/upstream-models.json +830 -0
  28. package/src/codex/features.ts +178 -0
  29. package/src/codex/project-config-warnings.ts +388 -0
  30. package/src/codex/sync.ts +8 -0
  31. package/src/codex/warmup.ts +62 -6
  32. package/src/config.ts +7 -5
  33. package/src/lib/debug-log-buffer.ts +42 -0
  34. package/src/lib/debug-settings.ts +84 -0
  35. package/src/lib/debug.ts +18 -9
  36. package/src/lib/errors.ts +104 -1
  37. package/src/oauth/cursor.ts +35 -12
  38. package/src/oauth/store.ts +4 -3
  39. package/src/providers/derive.ts +8 -0
  40. package/src/providers/registry.ts +56 -21
  41. package/src/reasoning-effort.ts +32 -9
  42. package/src/responses/parser.ts +7 -2
  43. package/src/router.ts +5 -0
  44. package/src/server/adapter-resolve.ts +1 -1
  45. package/src/server/index.ts +27 -3
  46. package/src/server/management-api.ts +168 -7
  47. package/src/server/relay.ts +2 -2
  48. package/src/server/request-log.ts +78 -0
  49. package/src/server/responses.ts +209 -0
  50. package/src/types.ts +28 -1
  51. package/src/usage/debug.ts +32 -5
  52. package/src/usage/summary.ts +6 -6
  53. package/src/web-search/index.ts +1 -1
  54. package/gui/dist/assets/index-ByGC8-Bm.css +0 -1
  55. package/gui/dist/assets/index-D_JZzI0r.js +0 -15
@@ -3,7 +3,7 @@ import { isUsageDebugEnabled } from "../usage/debug";
3
3
  import {
4
4
  addRequestLog,
5
5
  addFinalRequestLog,
6
- httpStatusForTerminalStatus,
6
+ httpStatusForRequestLogTerminal,
7
7
  inspectResponseLogJson,
8
8
  inspectResponseLogSsePayload,
9
9
  type RequestLogContext,
@@ -249,7 +249,7 @@ export function responseWithDeferredRequestLog(
249
249
  status => {
250
250
  if (logged) return;
251
251
  logged = true;
252
- addFinalRequestLog(requestId, start, logCtx, httpStatusForTerminalStatus(status), {
252
+ addFinalRequestLog(requestId, start, logCtx, httpStatusForRequestLogTerminal(status, logCtx), {
253
253
  terminalStatus: status,
254
254
  closeReason: "terminal",
255
255
  }, addLog);
@@ -1,8 +1,10 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
  import type { ResponsesTerminalStatus } from "../bridge";
3
+ import { httpStatusFromTerminalError as httpStatusFromClassifiedTerminalError } from "../lib/errors";
3
4
  import { CODEX_CONFIG_PATH, readRootTomlString } from "../codex/paths";
4
5
  import { readCodexCatalogPath } from "../codex/catalog";
5
6
  import type { OcxUsage } from "../types";
7
+ import { redactSecretString } from "../lib/redact";
6
8
  import {
7
9
  appendUsageEntry,
8
10
  usageForFinalLog,
@@ -35,6 +37,12 @@ export interface RequestLogContext {
35
37
  usageDebugBodyKind?: UsageDebugBodyKind;
36
38
  usageDebugBodySample?: string;
37
39
  usageDebugContentType?: string;
40
+ /** Secret-redacted upstream error reason (e.g. the granular Cursor "rate limit exceeded…"
41
+ * message) extracted from a `response.failed` SSE payload or non-streaming error body, so the
42
+ * request log / GUI shows the actual upstream failure rather than only the HTTP-mapped code. */
43
+ upstreamError?: string;
44
+ /** HTTP status derived from a terminal `response.failed` SSE payload (429/401/503/etc.). */
45
+ terminalHttpStatus?: number;
38
46
  }
39
47
 
40
48
  export interface RequestLogEntry {
@@ -56,6 +64,8 @@ export interface RequestLogEntry {
56
64
  errorCode?: string;
57
65
  terminalStatus?: ResponsesTerminalStatus;
58
66
  closeReason?: "terminal" | "client_cancel" | "non_stream";
67
+ /** Secret-redacted upstream error reason, surfaced in /api/logs and the GUI detail modal. */
68
+ upstreamError?: string;
59
69
  usageStatus: UsageStatus;
60
70
  usage?: OcxUsage;
61
71
  totalTokens?: number;
@@ -208,6 +218,7 @@ export function inspectResponseLogJson(logCtx: RequestLogContext, text: string):
208
218
  } catch {
209
219
  /* body may not be JSON; request log metadata is best-effort only */
210
220
  }
221
+ captureUpstreamError(logCtx, text);
211
222
  if (isUsageDebugEnabled() && logCtx.usageDebugBodyKind === undefined) {
212
223
  logCtx.usageDebugBodyKind = "json";
213
224
  logCtx.usageDebugBodySample = truncateForDebug(text);
@@ -223,6 +234,7 @@ export function inspectResponseLogSsePayload(logCtx: RequestLogContext, payload:
223
234
  } catch {
224
235
  /* SSE block payload may not be JSON; metadata inspection is best-effort */
225
236
  }
237
+ captureUpstreamError(logCtx, payload);
226
238
  if (debugEnabled) {
227
239
  if (!sseAlreadyMarked) {
228
240
  logCtx.usageDebugBodyKind = "sse";
@@ -235,10 +247,75 @@ export function inspectResponseLogSsePayload(logCtx: RequestLogContext, payload:
235
247
  }
236
248
  }
237
249
 
250
+ /**
251
+ * Capture the upstream error reason into the request log context. Codex/consumer surfaces only
252
+ * see an HTTP-mapped error code (502 → upstream_server_error); the granular reason lives inside
253
+ * a `response.failed` SSE payload's `error.message` (the adapter's redacted upstream message) or
254
+ * a non-streaming JSON error body. We keep the FIRST non-empty reason (the original failure) and
255
+ * run it through redactSecretString so secrets never reach /api/logs. Pure; safe on any text.
256
+ */
257
+ function captureUpstreamError(logCtx: RequestLogContext, text: string | null): void {
258
+ if (!text || logCtx.upstreamError) return;
259
+ try {
260
+ const json = JSON.parse(text) as {
261
+ type?: unknown;
262
+ error?: { message?: unknown };
263
+ last_error?: { message?: unknown };
264
+ response?: { error?: { type?: unknown; code?: unknown; message?: unknown } };
265
+ };
266
+ captureTerminalHttpStatus(logCtx, json);
267
+ const message = json?.error?.message
268
+ ?? json?.last_error?.message
269
+ ?? json?.response?.error?.message;
270
+ if (typeof message === "string" && message.trim()) {
271
+ logCtx.upstreamError = redactSecretString(message).slice(0, 500);
272
+ }
273
+ } catch {
274
+ /* not JSON; nothing to capture */
275
+ }
276
+ }
277
+
278
+ function captureTerminalHttpStatus(
279
+ logCtx: RequestLogContext,
280
+ json: {
281
+ type?: unknown;
282
+ response?: { error?: { type?: unknown; code?: unknown; message?: unknown } };
283
+ },
284
+ ): void {
285
+ if (logCtx.terminalHttpStatus !== undefined) return;
286
+ if (json.type !== "response.failed") return;
287
+ const error = json.response?.error;
288
+ if (!error || typeof error !== "object") return;
289
+ logCtx.terminalHttpStatus = httpStatusFromTerminalError({
290
+ type: typeof error.type === "string" ? error.type : undefined,
291
+ code: error.code === null || typeof error.code === "string" ? error.code : undefined,
292
+ message: typeof error.message === "string" ? error.message : undefined,
293
+ });
294
+ }
295
+
296
+ /** Map a terminal Responses error object to the HTTP status we record in /api/logs. */
297
+ export function httpStatusFromTerminalError(error: {
298
+ type?: string;
299
+ code?: string | null;
300
+ message?: string;
301
+ } | undefined): number {
302
+ return httpStatusFromClassifiedTerminalError(error);
303
+ }
304
+
238
305
  export function httpStatusForTerminalStatus(status: ResponsesTerminalStatus): number {
239
306
  return status === "completed" ? 200 : 502;
240
307
  }
241
308
 
309
+ export function httpStatusForRequestLogTerminal(
310
+ status: ResponsesTerminalStatus,
311
+ logCtx?: RequestLogContext,
312
+ ): number {
313
+ if (status === "failed" && logCtx?.terminalHttpStatus !== undefined) {
314
+ return logCtx.terminalHttpStatus;
315
+ }
316
+ return httpStatusForTerminalStatus(status);
317
+ }
318
+
242
319
  export function addFinalRequestLog(
243
320
  requestId: string,
244
321
  start: number,
@@ -276,6 +353,7 @@ export function addFinalRequestLog(
276
353
  ...(errorCode ? { errorCode } : {}),
277
354
  ...(meta?.terminalStatus ? { terminalStatus: meta.terminalStatus } : {}),
278
355
  ...(meta?.closeReason ? { closeReason: meta.closeReason } : {}),
356
+ ...(logCtx.upstreamError ? { upstreamError: logCtx.upstreamError } : {}),
279
357
  usageStatus,
280
358
  ...(loggedUsage ? { usage: loggedUsage } : {}),
281
359
  ...(totalTokens !== undefined ? { totalTokens } : {}),
@@ -74,6 +74,174 @@ export function buildToolBridgeMaps(parsed: OcxParsedRequest): {
74
74
  return { toolNsMap, freeformToolNames, toolSearchToolNames };
75
75
  }
76
76
 
77
+ /** Verbatim upstream Proactive text (codex-rs core/src/context/multi_agent_mode_instructions.rs). */
78
+ const PROACTIVE_MULTI_AGENT_MODE_TEXT = [
79
+ "Proactive multi-agent delegation is active.",
80
+ "Any earlier instruction requiring an explicit user request before spawning sub-agents no longer applies.",
81
+ "Delegate independent sub-tasks to sub-agents whenever parallel work would materially improve speed or quality — do not serialize work that can run concurrently.",
82
+ "Each sub-agent runs in its own context and can use all available tools; prefer spawning specialists over doing everything yourself.",
83
+ "This mode remains active until a later multi-agent mode developer message changes it.",
84
+ ].join(" ");
85
+
86
+ /**
87
+ * True when this turn runs the v1 collab surface, judged from the request's own tool list
88
+ * (codex registers exactly one surface per thread, core/src/tools/spec_plan.rs): v1 ships
89
+ * spawn_agent inside a namespace plus v1-only names (send_input/close_agent); v2 ships a
90
+ * flat spawn_agent. A flat spawn_agent vetoes so an ambiguous mix never counts as v1.
91
+ */
92
+ export function isV1CollabSurface(parsed: OcxParsedRequest): boolean {
93
+ let namespacedSpawn = false;
94
+ let flatSpawn = false;
95
+ let v1Only = false;
96
+ for (const t of parsed.context.tools ?? []) {
97
+ if (t.name === "spawn_agent") {
98
+ if (t.namespace) namespacedSpawn = true;
99
+ else flatSpawn = true;
100
+ } else if (t.name === "send_input" || t.name === "close_agent") v1Only = true;
101
+ }
102
+ return (namespacedSpawn || v1Only) && !flatSpawn;
103
+ }
104
+
105
+ /**
106
+ * Multi-agent guidance for this turn, or null when nothing applies.
107
+ *
108
+ * codex-rs only emits its Proactive delegation developer message on the v2 surface,
109
+ * so when a v1-surface turn arrives at the synthetic top tier (codex converts
110
+ * ultra -> max on the wire, so max arrival means the user picked the top rung) the
111
+ * proxy supplies the same one-liner, wrapped in codex's own <multi_agent_mode> tags
112
+ * (v1 turns never carry that fragment, so there is nothing to collide with).
113
+ * Ultra is always advertised, so the guidance fires regardless of the multi_agent_v2
114
+ * toggle.
115
+ *
116
+ * Dynamic model injection: when the user has configured a specific injectionModel,
117
+ * the prompt names it so the agent knows which routed model to delegate to.
118
+ *
119
+ * Effort gate relaxation: when an injectionModel is set, the prompt fires at every
120
+ * effort level, not just max/ultra — the user opted into delegation.
121
+ */
122
+ export async function multiAgentGuidanceText(parsed: OcxParsedRequest, injectionModel?: string): Promise<string | null> {
123
+ if (!isV1CollabSurface(parsed)) return null;
124
+ const effort = parsed.options.reasoning;
125
+ // When the user has selected a specific injection model, fire the delegation prompt
126
+ // at ANY effort level. Otherwise preserve the original gate: top tier only (max/ultra).
127
+ if (!injectionModel && effort !== "max" && effort !== "ultra") return null;
128
+
129
+ let text = PROACTIVE_MULTI_AGENT_MODE_TEXT;
130
+
131
+ // Append the selected model when the user has configured a specific injection target.
132
+ if (injectionModel) {
133
+ text += `\n\nA preferred sub-agent model is configured: "${injectionModel}". `
134
+ + `When delegating, call spawn_agent and set its model argument to exactly "${injectionModel}". `
135
+ + "Use it for independent sub-tasks unless the user explicitly asks for another model.";
136
+ }
137
+
138
+ return `<multi_agent_mode>${text}</multi_agent_mode>`;
139
+ }
140
+
141
+ /**
142
+ * Append a developer message to BOTH request shapes: parsed.context.messages feeds the
143
+ * routed adapters, while the ChatGPT passthrough serializes _rawBody verbatim (same
144
+ * dual-write contract as the mock-max clamp in handleResponses).
145
+ */
146
+ export function injectDeveloperMessage(parsed: OcxParsedRequest, text: string): void {
147
+ parsed.context.messages.push({ role: "developer", content: text, timestamp: Date.now() });
148
+ const raw = parsed._rawBody as { input?: unknown } | undefined;
149
+ if (raw && Array.isArray(raw.input)) {
150
+ const devItem = { type: "message", role: "developer", content: [{ type: "input_text", text }] };
151
+ // compaction_trigger must remain the final input item (codex-rs + ChatGPT backend both
152
+ // validate this). Insert the developer message BEFORE the trigger when present.
153
+ const last = raw.input[raw.input.length - 1];
154
+ if (last && typeof last === "object" && (last as { type?: string }).type === "compaction_trigger") {
155
+ raw.input.splice(raw.input.length - 1, 0, devItem);
156
+ } else {
157
+ raw.input.push(devItem);
158
+ }
159
+ }
160
+ }
161
+
162
+ /**
163
+ * True when an encrypted_content payload plausibly came from the ChatGPT backend
164
+ * (opaque base64-ish blob). codex-rs's `InterAgentCommunication::new_encrypted` performs
165
+ * NO local crypto — it just parks plaintext in the encrypted slot and relies on the
166
+ * backend to swap in real ciphertext. Under a routed (ocx-served) parent the backend
167
+ * never sees the parent turn, so the slot still holds plaintext when a native child
168
+ * replays it — and the backend then fails the turn with "Encrypted function output
169
+ * content could not be decrypted or decoded" (observed 260709 as 502 retry loops).
170
+ */
171
+ function looksLikeBackendCiphertext(payload: string): boolean {
172
+ return payload.length >= 64 && /^[A-Za-z0-9+/=_-]+$/.test(payload);
173
+ }
174
+
175
+ /**
176
+ * Backend-minted ciphertext runs are Fernet tokens (base64url, version byte 0x80 ->
177
+ * literal "gAAAA" prefix). Used to carve embedded blobs out of MIXED slots: plugin
178
+ * hooks (e.g. codexclaw's leaf guard) prepend plaintext preambles to spawn messages
179
+ * whose task body is already backend-encrypted, producing a slot that is neither
180
+ * decryptable (backend) nor readable (model) as a whole.
181
+ */
182
+ const FERNET_TOKEN_RUN = /gAAAA[A-Za-z0-9_-]{60,}={0,2}/g;
183
+
184
+ /**
185
+ * Split a non-ciphertext encrypted slot into ordered parts: prose becomes input_text,
186
+ * embedded Fernet blobs stay encrypted_content so the backend can still decrypt the
187
+ * real task body. A slot with no embedded blob degrades to a single input_text part.
188
+ */
189
+ function encryptedSlotParts(payload: string): Array<Record<string, string>> {
190
+ const parts: Array<Record<string, string>> = [];
191
+ let last = 0;
192
+ for (const match of payload.matchAll(FERNET_TOKEN_RUN)) {
193
+ const index = match.index ?? 0;
194
+ const before = payload.slice(last, index);
195
+ if (before.trim().length > 0) parts.push({ type: "input_text", text: before });
196
+ parts.push({ type: "encrypted_content", encrypted_content: match[0] });
197
+ last = index + match[0].length;
198
+ }
199
+ const rest = payload.slice(last);
200
+ if (rest.trim().length > 0) parts.push({ type: "input_text", text: rest });
201
+ return parts.length > 0 ? parts : [{ type: "input_text", text: payload }];
202
+ }
203
+
204
+ /**
205
+ * Rewrite non-ciphertext `{type:"encrypted_content"}` parts into `{type:"input_text"}`
206
+ * throughout a native-bound request's input items (message content and
207
+ * function_call_output content arrays share the part shape, codex-rs protocol/models.rs).
208
+ * Genuine backend blobs are left byte-identical so replay/cache semantics survive, and
209
+ * MIXED slots (plaintext preamble + embedded Fernet task body) are split so the backend
210
+ * decrypts the blob while the prose passes as text. Returns the number of parts rewritten.
211
+ */
212
+ export function sanitizeEncryptedContentInPlace(input: unknown): number {
213
+ if (!Array.isArray(input)) return 0;
214
+ let rewritten = 0;
215
+ const visit = (node: unknown): void => {
216
+ if (Array.isArray(node)) {
217
+ for (let i = 0; i < node.length; i += 1) {
218
+ const child = node[i] as unknown;
219
+ if (
220
+ child && typeof child === "object"
221
+ && (child as { type?: unknown }).type === "encrypted_content"
222
+ && typeof (child as { encrypted_content?: unknown }).encrypted_content === "string"
223
+ ) {
224
+ const payload = (child as { encrypted_content: string }).encrypted_content;
225
+ if (!looksLikeBackendCiphertext(payload)) {
226
+ const parts = encryptedSlotParts(payload);
227
+ node.splice(i, 1, ...parts);
228
+ i += parts.length - 1;
229
+ rewritten += 1;
230
+ continue;
231
+ }
232
+ }
233
+ visit(child);
234
+ }
235
+ return;
236
+ }
237
+ if (node && typeof node === "object") {
238
+ for (const value of Object.values(node)) visit(value);
239
+ }
240
+ };
241
+ visit(input);
242
+ return rewritten;
243
+ }
244
+
77
245
  export function sidecarOutcomeRecorder(config: OcxConfig, authCtx: CodexAuthContext): ((outcome: CodexUpstreamOutcome) => void) | undefined {
78
246
  return authCtx.kind === "pool" || authCtx.kind === "main-pool"
79
247
  ? outcome => recordCodexUpstreamOutcome(config, authCtx.accountId, outcome)
@@ -163,6 +331,47 @@ export async function handleResponses(
163
331
  }
164
332
  logCtx.model = route.modelId;
165
333
  logCtx.provider = route.providerName;
334
+
335
+ // Multi-agent guidance shim: codex-rs emits its Proactive delegation developer
336
+ // message only on the v2 surface. If the request's own tool list proves this
337
+ // is a v1 collab surface, preserve that top-tier behavior for legacy/v1 threads.
338
+ // Runs BEFORE the mock-max clamp below so the synthetic top tier (ultra arrives
339
+ // as max on the codex wire) is still visible. Both request shapes are rewritten.
340
+ {
341
+ const requestedModelId = logCtx.requestedModel ?? route.modelId;
342
+ // Cross-provider spawn poison fix: native-bound requests may carry plaintext parked in
343
+ // encrypted_content slots (spawn messages minted under a routed parent). Rewrite them
344
+ // to input_text before the passthrough serializes _rawBody verbatim.
345
+ if (!requestedModelId.includes("/")) {
346
+ const raw = parsed._rawBody as { input?: unknown } | undefined;
347
+ const rewritten = sanitizeEncryptedContentInPlace(raw?.input);
348
+ if (rewritten > 0) console.warn(`[opencodex] ${route.modelId}: rewrote ${rewritten} plaintext encrypted_content part(s) to input_text (routed-parent spawn compatibility)`);
349
+ }
350
+ const guidance = await multiAgentGuidanceText(parsed, config.injectionModel);
351
+ if (guidance) injectDeveloperMessage(parsed, guidance);
352
+ }
353
+
354
+ // Mock-max clamp: native models whose real ladder stops below max (gpt-5.5/5.4/…)
355
+ // receive `max` when the user picks Ultra (codex converts ultra->max client-side).
356
+ // Clamp to the model's highest real effort BEFORE any adapter — the ChatGPT
357
+ // passthrough serializes _rawBody verbatim, so both shapes must be rewritten.
358
+ // GUARD: judge nativeness by the ORIGINALLY REQUESTED id (logCtx.requestedModel),
359
+ // never by route.modelId — routing strips the "<provider>/" namespace, so a routed
360
+ // model (anthropic/claude-opus-4-6, real max) would masquerade as an off-snapshot
361
+ // bare native and get wrongly clamped. Routed efforts belong to their adapters.
362
+ {
363
+ const requestedModelId = logCtx.requestedModel ?? route.modelId;
364
+ const { nativeEffortClamp } = await import("../codex/catalog");
365
+ const clamped = requestedModelId.includes("/")
366
+ ? null
367
+ : nativeEffortClamp(route.modelId, parsed.options.reasoning);
368
+ if (clamped) {
369
+ parsed.options.reasoning = clamped;
370
+ const raw = parsed._rawBody as { reasoning?: { effort?: string } } | undefined;
371
+ if (raw?.reasoning && typeof raw.reasoning === "object") raw.reasoning.effort = clamped;
372
+ logCtx.requestedEffort = `${logCtx.requestedEffort ?? "max"}->${clamped}`;
373
+ }
374
+ }
166
375
  logCtx.modelSupportsServiceTier = catalogModelSupportsServiceTier(
167
376
  route.modelId,
168
377
  logCtx.requestedServiceTier ?? logCtx.configuredServiceTier,
package/src/types.ts CHANGED
@@ -244,8 +244,21 @@ export interface OcxConfig {
244
244
  * Codex's spawn_agent only advertises the first 5 routed models, so this picks which 5 appear.
245
245
  */
246
246
  subagentModels?: string[];
247
- /** Routed model ids ("<provider>/<model>") hidden from Codex (excluded from the catalog + /v1/models). */
247
+ injectionModel?: string;
248
+ /**
249
+ * Models hidden from Codex. Routed ids are namespaced ("<provider>/<model>") and are excluded
250
+ * from the catalog + /v1/models entirely. BARE ids (no "/") are native GPT passthrough slugs:
251
+ * their catalog entries flip to visibility "hide" (entry preserved, picker-hidden) and they
252
+ * are omitted from the bare /v1/models list.
253
+ */
248
254
  disabledModels?: string[];
255
+ /**
256
+ * 3-state multi-agent surface override:
257
+ * - "v1": force ALL models to v1 surface (override upstream pins)
258
+ * - "default" | undefined: respect upstream model pins (sol/terra=v2, luna=v1, rest=codex flag)
259
+ * - "v2": force ALL models to v2 surface (override upstream pins)
260
+ */
261
+ multiAgentMode?: "v1" | "default" | "v2";
249
262
  /** Provider-level Codex-visible context caps. Values only lower known model context windows. */
250
263
  providerContextCaps?: Record<string, number>;
251
264
  /** Global Codex-visible context cap value (tokens). Falls back to DEFAULT_PROVIDER_CONTEXT_CAP. */
@@ -423,6 +436,14 @@ export interface OcxProviderConfig {
423
436
  noTopPModels?: string[];
424
437
  /** Model ids that reject caller-specified presence/frequency penalty values. */
425
438
  noPenaltyModels?: string[];
439
+ /**
440
+ * Allow multiple tool calls per completion. DEFAULT-ON for openai-chat providers (the
441
+ * buffered stream parser assembles interleaved/fragmented multi-call turns safely);
442
+ * set `false` to force `parallel_tool_calls:false` upstream and drop the catalog's
443
+ * `supports_parallel_tool_calls` bit for that provider. Non-chat adapters advertise
444
+ * only on explicit `true`. See devlog/_plan/260709_parallel_tool_calls.
445
+ */
446
+ parallelToolCalls?: boolean;
426
447
  /** Model ids whose tool_choice only accepts `auto` or `none`; forced/named choices are downgraded. */
427
448
  autoToolChoiceOnlyModels?: string[];
428
449
  /** Model ids that expect prior assistant `reasoning_content` to be preserved in chat history. */
@@ -433,6 +454,12 @@ export interface OcxProviderConfig {
433
454
  * The openai-chat adapter translates the mapped effort into the thinking toggle for these.
434
455
  */
435
456
  thinkingToggleModels?: string[];
457
+ /**
458
+ * Model ids whose reasoning is a `thinking_budget` integer on the chat-completions wire
459
+ * (Qwen3.x style), NOT an OpenAI `reasoning_effort` ladder. The openai-chat adapter maps the
460
+ * Codex effort to a budget fraction.
461
+ */
462
+ thinkingBudgetModels?: string[];
436
463
  /** Anthropic-compatible gateways that need custom tool names escaped on the wire. */
437
464
  escapeBuiltinToolNames?: boolean;
438
465
  /**
@@ -1,10 +1,15 @@
1
+ /** Usage-shape diagnostic JSONL. Enable with `ocx debug usage on` or OPENCODEX_USAGE_DEBUG=1. */
2
+
1
3
  import { appendFileSync, chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
4
  import { join } from "node:path";
3
5
  import { getConfigDir } from "../config";
6
+ import { DEBUG_ENV } from "../lib/debug-settings";
7
+ import type { DebugLogEntry } from "../lib/debug-log-buffer";
4
8
  import { redactSecretString, redactSecrets } from "../lib/redact";
5
9
  import type { OcxUsage } from "../types";
6
10
 
7
- export const USAGE_DEBUG_ENV = "OPENCODEX_USAGE_DEBUG";
11
+ export const USAGE_DEBUG_ENV = DEBUG_ENV.usage;
12
+ export { isUsageDebugEnabled } from "../lib/debug-settings";
8
13
  export const USAGE_DEBUG_BODY_SAMPLE_BYTES = 2048;
9
14
  export const USAGE_DEBUG_MAX_LINES = 200;
10
15
  export const USAGE_DEBUG_KEEP_LINES = 100;
@@ -23,10 +28,6 @@ export interface UsageDebugRecord {
23
28
  extractedUsage: OcxUsage | null;
24
29
  }
25
30
 
26
- export function isUsageDebugEnabled(): boolean {
27
- return process.env[USAGE_DEBUG_ENV] === "1";
28
- }
29
-
30
31
  export function usageDebugPath(): string {
31
32
  return join(getConfigDir(), "usage-debug.jsonl");
32
33
  }
@@ -66,3 +67,29 @@ export function appendUsageDebug(record: UsageDebugRecord): void {
66
67
  /* debug capture must never break the proxy */
67
68
  }
68
69
  }
70
+
71
+ /** Tail usage-debug.jsonl for GUI / management API (same shape as provider debug buffer). */
72
+ export function getUsageDebugLogEntries(options?: { after?: number; limit?: number }): DebugLogEntry[] {
73
+ const after = options?.after ?? 0;
74
+ const limit = options?.limit ?? 500;
75
+ try {
76
+ const content = readFileSync(usageDebugPath(), "utf8");
77
+ const entries: DebugLogEntry[] = [];
78
+ let seq = 0;
79
+ for (const line of content.split(/\r?\n/).filter(Boolean)) {
80
+ seq += 1;
81
+ if (after > 0 && seq <= after) continue;
82
+ try {
83
+ const record = JSON.parse(line) as UsageDebugRecord;
84
+ const at = typeof record.ts === "number" ? record.ts : 0;
85
+ entries.push({ seq, at, line });
86
+ } catch {
87
+ entries.push({ seq, at: 0, line });
88
+ }
89
+ }
90
+ if (entries.length <= limit) return entries;
91
+ return entries.slice(-limit);
92
+ } catch {
93
+ return [];
94
+ }
95
+ }
@@ -183,7 +183,7 @@ function buildDayGrid(range: UsageRange, since: number | null, now: number, entr
183
183
  return out;
184
184
  }
185
185
 
186
- function buildModels(entries: PersistedUsageEntry[], totalRequests: number): UsageModel[] {
186
+ function buildModels(entries: PersistedUsageEntry[], totalTokens: number): UsageModel[] {
187
187
  const byKey = new Map<string, UsageModel>();
188
188
  for (const entry of entries) {
189
189
  const providerKey = baseProviderLabel(entry.provider);
@@ -219,11 +219,11 @@ function buildModels(entries: PersistedUsageEntry[], totalRequests: number): Usa
219
219
  }
220
220
  }
221
221
  const models = [...byKey.values()];
222
- for (const m of models) m.shareRatio = totalRequests === 0 ? 0 : m.requests / totalRequests;
222
+ for (const m of models) m.shareRatio = totalTokens === 0 ? 0 : m.totalTokens / totalTokens;
223
223
  return models.sort((a, b) => b.requests - a.requests);
224
224
  }
225
225
 
226
- function buildProviders(entries: PersistedUsageEntry[], totalRequests: number): UsageProvider[] {
226
+ function buildProviders(entries: PersistedUsageEntry[], totalTokens: number): UsageProvider[] {
227
227
  const byKey = new Map<string, UsageProvider>();
228
228
  for (const entry of entries) {
229
229
  const providerKey = baseProviderLabel(entry.provider);
@@ -249,7 +249,7 @@ function buildProviders(entries: PersistedUsageEntry[], totalRequests: number):
249
249
  }
250
250
  }
251
251
  const providers = [...byKey.values()];
252
- for (const p of providers) p.shareRatio = totalRequests === 0 ? 0 : p.requests / totalRequests;
252
+ for (const p of providers) p.shareRatio = totalTokens === 0 ? 0 : p.totalTokens / totalTokens;
253
253
  return providers.sort((a, b) => b.requests - a.requests);
254
254
  }
255
255
 
@@ -268,7 +268,7 @@ export function summarizeUsage(entries: PersistedUsageEntry[], range: UsageRange
268
268
  generatedAt: now,
269
269
  summary: totals,
270
270
  days: buildDayGrid(range, since, now, inRange),
271
- models: buildModels(inRange, totals.requests),
272
- providers: buildProviders(inRange, totals.requests),
271
+ models: buildModels(inRange, totals.totalTokens),
272
+ providers: buildProviders(inRange, totals.totalTokens),
273
273
  };
274
274
  }
@@ -6,7 +6,7 @@ import type { CodexAuthContext } from "../codex/auth-context";
6
6
  export { runWithWebSearch } from "./loop";
7
7
  export { buildWebSearchTool, extractHostedWebSearch, WEB_SEARCH_TOOL_NAME } from "./synthetic-tool";
8
8
 
9
- const DEFAULT_SIDECAR_MODEL = "gpt-5.4-mini";
9
+ const DEFAULT_SIDECAR_MODEL = "gpt-5.6-luna";
10
10
  // "low" is the lightest effort the ChatGPT backend allows with web_search ("minimal" is rejected:
11
11
  // "tools cannot be used with reasoning.effort 'minimal'") — keeps the sidecar fast/cheap.
12
12
  const DEFAULT_SIDECAR_REASONING = "low";
@@ -1 +0,0 @@
1
- :root{--lightningcss-light:initial;--lightningcss-dark: ;color-scheme:light dark;--bg:var(--lightningcss-light,#f6f7f9)var(--lightningcss-dark,#0c0d11);--rail:var(--lightningcss-light,#fff)var(--lightningcss-dark,#101218);--surface:var(--lightningcss-light,#fff)var(--lightningcss-dark,#15171d);--raised:var(--lightningcss-light,#f1f2f5)var(--lightningcss-dark,#1c1f27);--raised-hover:var(--lightningcss-light,#e8eaee)var(--lightningcss-dark,#242833);--border:var(--lightningcss-light,#e2e4e9)var(--lightningcss-dark,#2a2e39);--border-soft:var(--lightningcss-light,#ededf1)var(--lightningcss-dark,#20242d);--hover:var(--lightningcss-light,#11131c09)var(--lightningcss-dark,#ffffff06);--text:var(--lightningcss-light,#16181d)var(--lightningcss-dark,#edeef2);--muted:var(--lightningcss-light,#5b6270)var(--lightningcss-dark,#a3a9b5);--faint:var(--lightningcss-light,#868d9b)var(--lightningcss-dark,#6b7280);--accent:var(--lightningcss-light,#4f46e5)var(--lightningcss-dark,#6366f1);--accent-hover:var(--lightningcss-light,#4338ca)var(--lightningcss-dark,#818cf8);--accent-ink:#fff;--accent-soft:var(--lightningcss-light,#4f46e51a)var(--lightningcss-dark,#6366f129);--accent-ring:var(--lightningcss-light,#4f46e566)var(--lightningcss-dark,#6366f180);--green:var(--lightningcss-light,#047857)var(--lightningcss-dark,#34d399);--green-soft:var(--lightningcss-light,#0596691a)var(--lightningcss-dark,#34d39921);--red:var(--lightningcss-light,#b91c1c)var(--lightningcss-dark,#f87171);--red-soft:var(--lightningcss-light,#b91c1c17)var(--lightningcss-dark,#f8717121);--amber:var(--lightningcss-light,#b45309)var(--lightningcss-dark,#fbbf24);--amber-soft:var(--lightningcss-light,#b453091a)var(--lightningcss-dark,#fbbf2421);--radius:8px;--radius-sm:5px;--radius-xs:4px;--font:-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, system-ui, "Helvetica Neue", sans-serif;--mono:ui-monospace, "SF Mono", "JetBrains Mono", "Cascadia Code", Menlo, Consolas, monospace;--shadow:0 1px 2px var(--lightningcss-light,#1018280f)var(--lightningcss-dark,#00000080), 0 10px 28px var(--lightningcss-light,#10182812)var(--lightningcss-dark,#0000004d);--shadow-sm:0 1px 2px var(--lightningcss-light,#1018280f)var(--lightningcss-dark,#0006)}@media (prefers-color-scheme:dark){:root{--lightningcss-light: ;--lightningcss-dark:initial}}:root[data-theme=light]{--lightningcss-light:initial;--lightningcss-dark: ;color-scheme:light}:root[data-theme=dark]{--lightningcss-light: ;--lightningcss-dark:initial;color-scheme:dark}*{box-sizing:border-box}html,body,#root{height:100%}body{background:var(--bg);color:var(--text);font-family:var(--font);-webkit-font-smoothing:antialiased;text-rendering:optimizelegibility;margin:0;font-size:14px;line-height:1.5}a{color:var(--accent-hover);text-decoration:none}a:hover{text-decoration:underline}code,.mono{font-family:var(--mono);font-size:.92em}h1,h2,h3,h4{letter-spacing:-.01em;margin:0;font-weight:650}::selection{background:var(--accent-soft)}input[type=checkbox],input[type=radio]{accent-color:var(--accent)}::-webkit-scrollbar{width:10px;height:10px}::-webkit-scrollbar-thumb{background:var(--border);border:2px solid var(--bg);border-radius:99px}::-webkit-scrollbar-thumb:hover{background:var(--faint)}:focus-visible{outline:2px solid var(--accent-ring);outline-offset:2px;border-radius:4px}.app{grid-template-columns:232px 1fr;min-height:100dvh;display:grid}.sidebar{border-right:1px solid var(--border);background:var(--rail);flex-direction:column;align-self:start;gap:4px;height:100dvh;padding:18px 14px;display:flex;position:sticky;top:0}.brand{align-items:center;gap:10px;padding:6px 8px 14px;display:flex}.brand-logo{background:var(--text);flex-shrink:0;width:26px;height:26px;-webkit-mask:url(/logo.png) 50%/contain no-repeat;mask:url(/logo.png) 50%/contain no-repeat}.brand .name{letter-spacing:-.02em;font-size:15px;font-weight:700;line-height:26px}.brand .ver{font-family:var(--mono);color:var(--muted);background:var(--raised);border:1px solid var(--border);border-radius:99px;align-self:center;padding:2px 6px;font-size:10px;line-height:1}.nav-item{border-radius:var(--radius-sm);text-align:left;cursor:pointer;width:100%;color:var(--muted);font:inherit;background:0 0;border:none;align-items:center;gap:10px;padding:8px 10px;font-size:13.5px;font-weight:500;transition:background .12s,color .12s;display:flex}.nav-item:hover{background:var(--raised);color:var(--text)}.nav-item.active{background:var(--accent-soft);color:var(--text)}.nav-item svg{width:17px;height:17px;color:var(--faint);flex-shrink:0}.nav-item.active svg{color:var(--accent)}.sidebar-foot{flex-direction:column;gap:2px;margin-top:auto;padding-top:12px;display:flex}.sidebar-link{color:var(--muted);border-radius:var(--radius-sm);align-items:center;gap:9px;padding:8px 10px;font-size:13px;display:flex}.sidebar-link:hover{background:var(--raised);color:var(--text);text-decoration:none}.sidebar-link svg{width:16px;height:16px}.theme-toggle{text-align:left;cursor:pointer;width:100%;color:var(--muted);font:inherit;border-radius:var(--radius-sm);background:0 0;border:none;align-items:center;gap:9px;padding:8px 10px;font-size:13px;transition:background .12s,color .12s;display:flex}.theme-toggle:hover{background:var(--raised);color:var(--text)}.theme-toggle svg{flex-shrink:0;width:16px;height:16px}.theme-toggle .mode{text-transform:capitalize}.stop-toggle{color:var(--red)}.stop-toggle:hover{background:var(--red-soft);color:var(--red)}.stop-toggle:disabled{opacity:.5;cursor:default}.main{min-width:0}.main-inner{max-width:980px;margin:0 auto;padding:32px 36px 64px}.page-head{justify-content:space-between;align-items:center;gap:16px;margin-bottom:6px;display:flex}.page-head h2{font-size:19px}.page-sub{color:var(--muted);max-width:70ch;margin:4px 0 22px;font-size:13.5px}.page-sub b{color:var(--text);font-weight:600}.api-page h2 svg{vertical-align:-.16em;width:1em;height:1em}.api-page .page-sub code{color:var(--text);background:var(--raised);border:1px solid var(--border-soft);border-radius:var(--radius-xs);padding:1px 4px;font-size:.92em}.btn{border-radius:var(--radius-sm);font:inherit;cursor:pointer;white-space:nowrap;border:1px solid #0000;justify-content:center;align-items:center;gap:7px;padding:7px 14px;font-size:13px;font-weight:550;transition:background .12s,border-color .12s,opacity .12s;display:inline-flex}.btn svg{width:15px;height:15px}.btn:disabled{opacity:.55;cursor:default}.btn-primary{background:var(--accent);color:var(--accent-ink)}.btn-primary:hover:not(:disabled){background:var(--accent-hover)}.btn-ghost{background:var(--raised);color:var(--text);border-color:var(--border)}.btn-ghost:hover:not(:disabled){background:var(--raised-hover)}.btn-danger{color:var(--red);background:0 0;border-color:#f871714d}.btn-danger:hover:not(:disabled){background:var(--red-soft)}.btn-sm{border-radius:var(--radius-xs);padding:4px 9px;font-size:12px}.btn-icon{color:var(--muted);border-radius:var(--radius-xs);padding:5px;transition:background .12s,color .12s}.btn-icon:hover{background:var(--raised-hover);color:var(--text)}.card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);min-width:0}.panel{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:18px}.panel-accent{background:linear-gradient(180deg, var(--accent-soft), transparent 120%), var(--surface);border-color:#7c5cff47}.api-panel{flex-direction:column;gap:10px;display:flex;overflow:hidden}.api-panel .panel-title,.api-panel .muted{margin:0}.api-form-row{align-items:center;gap:8px;min-width:0;display:flex}.api-form-row .input{flex:1;min-width:0}.api-code{min-width:0;color:var(--text);background:var(--raised);border:1px solid var(--border-soft);border-radius:var(--radius-sm);font-family:var(--mono);white-space:pre;margin:0;padding:9px 11px;font-size:12.5px;line-height:1.55;display:block;overflow-x:auto}.api-code-inline{white-space:nowrap}.api-actions{justify-content:flex-end;align-items:center;gap:4px;display:flex}.maintenance-head{flex-wrap:wrap;align-items:flex-start}.maintenance-actions{flex-wrap:wrap;justify-content:flex-end;align-items:center;gap:8px;display:flex}.maintenance-notice{align-items:flex-start;margin:14px 0 0}.update-row{justify-content:space-between;align-items:center;gap:12px;margin-bottom:14px;display:flex}.update-row .field-label{margin:0}.update-empty{margin-bottom:14px;padding:18px}.update-box{flex-direction:column;gap:12px;display:flex}.update-command{flex-wrap:wrap;align-items:center;gap:6px;font-size:12px;display:flex}.update-restart{border-top:1px solid var(--border-soft);padding-top:12px}.stat-row{grid-template-columns:repeat(5,1fr);gap:12px;margin-bottom:28px;display:grid}.stat{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:14px 16px;transition:border-color .12s}.stat:hover{border-color:var(--accent-ring)}.stat .label{color:var(--muted);text-transform:uppercase;letter-spacing:.03em;align-items:center;gap:6px;margin-bottom:6px;font-size:12px;font-weight:600;display:flex}.stat .label svg{width:14px;height:14px}.stat .value{letter-spacing:-.02em;font-size:20px;font-weight:700;line-height:1.15}.stat .value.mono{font-family:var(--mono);font-size:17px}.model-group-head{color:var(--muted);text-transform:uppercase;letter-spacing:.04em;align-items:baseline;gap:8px;margin:0 0 8px;font-size:12px;font-weight:600;display:flex}.model-group-head .count{font-family:var(--mono);text-transform:none;letter-spacing:0;color:var(--faint);font-weight:500}.model-grid{grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:8px;display:grid}.model-card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-sm);padding:10px 12px;transition:border-color .12s,background .12s}.model-card:hover{border-color:var(--accent-ring);background:var(--hover)}.model-card .id{font-family:var(--mono);letter-spacing:-.01em;color:var(--text);font-size:13px;font-weight:600}.badge{font-size:11px;font-weight:600;font-family:var(--mono);letter-spacing:.01em;border:1px solid #0000;border-radius:99px;align-items:center;gap:5px;padding:2px 8px;display:inline-flex}.badge-accent{background:var(--accent-soft);color:var(--accent-hover)}.badge-green{background:var(--green-soft);color:var(--green)}.badge-amber{background:var(--amber-soft);color:var(--amber)}.badge-muted{background:var(--raised);color:var(--muted);border:1px solid var(--border)}.badge-clickable{cursor:pointer;appearance:none;transition:filter .12s}.badge-clickable:hover{filter:brightness(1.1)}.badge-disabled{opacity:.5;cursor:default}.card-badges{flex-wrap:wrap;align-items:center;gap:8px;min-width:0;display:inline-flex}.card-badges .badge{flex-shrink:0}.confirm-icon{background:var(--amber-soft);width:44px;height:44px;color:var(--amber);border-radius:50%;justify-content:center;align-items:center;margin:0 auto 12px;display:flex}.credit-list{flex-direction:column;gap:6px;display:flex}.credit-item{border:1px solid var(--border);border-radius:var(--radius-sm);background:var(--raised);padding:8px 10px;transition:border-color .12s}.credit-next{border-color:var(--amber);background:var(--amber-soft)}.credit-item-head{color:var(--text);align-items:center;gap:6px;margin-bottom:3px;font-size:12px;font-weight:600;display:flex}.credit-item-head svg{color:var(--amber);flex-shrink:0}.credit-item-dates{font-size:11px;font-family:var(--mono);color:var(--muted);justify-content:space-between;display:flex}.credit-urgent{color:var(--red);font-weight:600}.dot{border-radius:50%;flex-shrink:0;width:7px;height:7px}.dot-green{background:var(--green);box-shadow:0 0 0 3px var(--green-soft)}.dot-red{background:var(--red);box-shadow:0 0 0 3px var(--red-soft)}.tbl{border-collapse:collapse;width:100%;font-size:13px}.tbl thead th{text-align:left;color:var(--muted);text-transform:uppercase;letter-spacing:.04em;border-bottom:1px solid var(--border);padding:9px 12px;font-size:11.5px;font-weight:600}.tbl tbody td{border-bottom:1px solid var(--border-soft);padding:10px 12px}.tbl tbody tr:last-child td{border-bottom:none}.tbl tbody tr:hover td{background:var(--hover)}.tbl .num{text-align:right;font-family:var(--mono)}.tbl-wrap{border:1px solid var(--border);border-radius:var(--radius);overflow-x:auto}.input,textarea.input{border-radius:var(--radius-sm);background:var(--raised);border:1px solid var(--border);width:100%;color:var(--text);font:inherit;padding:8px 11px;font-size:13px;transition:border-color .12s}.input::placeholder{color:var(--faint)}.input:focus{border-color:var(--accent);outline:none}textarea.input{resize:vertical;font-family:var(--mono);line-height:1.55}.field-label{color:var(--muted);margin-bottom:5px;font-size:12px;font-weight:500;display:block}select.input{appearance:none}.select-sm{border-radius:var(--radius-sm);background:var(--raised);border:1px solid var(--border);color:var(--text);font:inherit;cursor:pointer;padding:5px 8px;font-size:13px;transition:border-color .12s}.select-sm:focus{border-color:var(--accent);outline:none}.select-sm:disabled{opacity:.5;cursor:default}.switch{cursor:pointer;background:var(--lightningcss-light,#c5c9d2)var(--lightningcss-dark,#3a3f4b);border:none;border-radius:99px;flex-shrink:0;width:34px;height:19px;padding:0;transition:background .15s;position:relative}.switch.on{background:var(--accent)}.switch:disabled{opacity:.6;cursor:default}.switch .knob{background:#fff;border-radius:50%;width:15px;height:15px;transition:left .15s;position:absolute;top:2px;left:2px;box-shadow:0 1px 2px #1018284d}.switch.on .knob{left:17px}.muted{color:var(--muted)}.faint{color:var(--faint)}.row{align-items:center;gap:10px;display:flex}.spread{justify-content:space-between;align-items:center;gap:12px;display:flex}.setting-hint{margin-top:3px;font-size:13px}.stack{flex-direction:column;display:flex}.chip{font-family:var(--mono);background:var(--raised);border:1px solid var(--border);border-radius:var(--radius-xs);color:var(--text);padding:1px 7px;font-size:12px}.empty{text-align:center;border:1px dashed var(--border);border-radius:var(--radius);color:var(--muted);padding:56px 20px}.empty svg{width:30px;height:30px;color:var(--faint);margin-bottom:12px}.empty .title{color:var(--text);margin-bottom:6px;font-weight:600}.notice{border-radius:var(--radius-sm);align-items:center;gap:8px;margin-bottom:14px;padding:9px 12px;font-size:13px;display:flex}.notice svg{flex-shrink:0;width:15px;height:15px}.notice-ok{background:var(--green-soft);color:var(--green)}.notice-err{background:var(--red-soft);color:var(--red)}.h-section{color:var(--text);align-items:center;gap:8px;margin:30px 0 12px;font-size:13px;font-weight:600;display:flex}.h-section .count{color:var(--muted);font-weight:500;font-family:var(--mono);font-size:12px}.spin{border:2px solid var(--border);border-top-color:var(--accent);border-radius:50%;width:14px;height:14px;animation:.7s linear infinite spin;display:inline-block}@keyframes spin{to{transform:rotate(360deg)}}@media (prefers-reduced-motion:reduce){*{transition:none!important;animation:none!important}}.modal-overlay{-webkit-backdrop-filter:blur(2px);backdrop-filter:blur(2px);z-index:50;background:var(--lightningcss-light,#11131c73)var(--lightningcss-dark,#0009);justify-content:center;align-items:flex-start;padding:8vh 16px;display:flex;position:fixed;inset:0}.modal-card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);width:100%;max-width:520px;box-shadow:var(--shadow);max-height:84vh;padding:20px;overflow-y:auto}.modal-head{justify-content:space-between;align-items:center;margin-bottom:16px;display:flex}.modal-head h3{font-size:16px}.card-head{flex-wrap:wrap;align-items:center;gap:8px;min-width:0;padding:14px 16px 6px;display:flex}.card-head strong{overflow-wrap:anywhere;min-width:0}.card-sub{color:var(--muted);overflow-wrap:anywhere;min-width:0;padding:0 16px 10px;font-size:12px}.card-active{border-color:var(--accent-ring)}.card-right{color:var(--faint);align-items:center;gap:4px;margin-left:auto;font-size:11px;display:flex}.btn-icon-danger.card-right{appearance:none;color:var(--red);cursor:pointer;background:0 0;border:1px solid #0000;justify-content:center}.btn-icon-danger.card-right:hover{background:var(--red-soft);border-color:var(--red-soft);color:var(--red)}.card-row{justify-content:space-between;align-items:center;padding:14px 16px;display:flex}.badge-primary{background:var(--accent-soft);color:var(--accent-hover)}.dot-blue{background:var(--accent);box-shadow:0 0 0 3px var(--accent-soft)}.dot-muted{background:var(--muted)}.dot-amber{background:var(--amber);box-shadow:0 0 0 3px var(--amber-soft)}.section-sep{align-items:center;gap:10px;margin:20px 0 12px;display:flex}.section-label{color:var(--muted);text-transform:uppercase;letter-spacing:.04em;white-space:nowrap;font-size:12px;font-weight:600}.sep-line{background:var(--border);flex:1;height:1px}.quota-compact{gap:4px;padding:2px 16px 12px;display:grid}.quota-row{grid-template-columns:minmax(34px,max-content) 34px minmax(34px,44px) minmax(38px,42px) minmax(58px,1fr) minmax(30px,max-content);align-items:center;gap:8px;min-width:0;display:grid}.quota-label{color:var(--muted);font-size:11px;font-weight:600}.quota-val{font-size:11px;font-family:var(--mono);color:var(--text);text-align:right}.quota-reset-label{text-overflow:ellipsis;white-space:nowrap;color:var(--faint);font-size:11px;overflow:hidden}.quota-reset-day,.quota-reset-time{color:var(--muted);white-space:nowrap;font-size:11px}.quota-reset-time{font-family:var(--mono)}.bar{background:var(--raised);border-radius:3px;min-width:0;height:5px;overflow:hidden}.bar-fill{border-radius:3px;height:100%;transition:width .3s}.bar-green{background:var(--green)}.bar-amber{background:linear-gradient(90deg, var(--green), var(--amber))}.toggle{background:var(--raised);border:1px solid var(--border);cursor:pointer;border-radius:10px;flex-shrink:0;width:36px;height:20px;transition:background .15s;position:relative}.toggle.on{background:var(--accent);border-color:var(--accent)}.toggle-knob{background:#fff;border-radius:50%;width:16px;height:16px;transition:left .15s;position:absolute;top:1px;left:1px}.toggle.on .toggle-knob{left:17px}.notice-warn{border-radius:var(--radius-sm);background:var(--amber-soft);color:var(--amber);align-items:center;gap:6px;margin-bottom:12px;padding:8px 10px;font-size:12px;display:flex}.notice-warn svg{flex-shrink:0;width:14px;height:14px}.modal-desc{color:var(--muted);margin-bottom:14px;font-size:13px}.modal-actions{gap:8px;margin-top:16px;display:flex}.modal-actions .btn{flex:1}.log-reqid{-webkit-line-clamp:2;word-break:break-all;-webkit-box-orient:vertical;max-width:14ch;display:-webkit-box;overflow:hidden}.log-col-model{overflow-wrap:break-word;word-break:normal;max-width:18ch}.log-col-tokens{min-width:10ch}.log-status-cell{flex-direction:column;align-items:flex-start;gap:2px;display:inline-flex}.log-detail-btn{cursor:pointer;color:var(--accent-hover);font:inherit;white-space:nowrap;background:0 0;border:none;padding:0;font-size:11px;text-decoration:underline}.log-detail-grid{grid-template-columns:max-content minmax(0,1fr);gap:6px 14px;font-size:13px;display:grid}.log-detail-break{word-break:break-all}.log-detail-json{background:var(--raised);border:1px solid var(--border-soft);border-radius:var(--radius-sm);font-family:var(--mono);white-space:pre-wrap;word-break:break-all;max-height:40vh;margin:0;padding:10px 12px;font-size:11.5px;line-height:1.5;overflow:auto}.setup-guide{border:1px solid var(--border);border-radius:var(--radius-sm);margin-bottom:4px;padding:8px 12px;font-size:13px}.setup-guide summary{cursor:pointer;color:var(--accent-hover);font-weight:500}.setup-guide summary:hover{text-decoration:underline}.setup-guide a{color:var(--accent-hover)}.list-row{text-align:left;border-radius:var(--radius-sm);border:1px solid var(--border);background:var(--raised);cursor:pointer;width:100%;color:var(--text);font:inherit;justify-content:space-between;align-items:center;gap:10px;padding:11px 13px;transition:background .12s,border-color .12s;display:flex}.list-row:hover{background:var(--raised-hover);border-color:var(--accent-ring)}.list-row .title{font-size:14px;font-weight:600}.list-row .sub{color:var(--muted);margin-top:2px;font-size:12px}.prov-card{overflow:hidden}.prov-card-main{flex-wrap:wrap;justify-content:space-between;align-items:flex-start;gap:12px;padding:15px 16px;display:flex}.prov-card-disabled{opacity:.62}.prov-card-info{flex:360px;align-items:flex-start;gap:11px;min-width:0;display:flex}.prov-card-copy{flex:1;min-width:0}.prov-title{flex-wrap:wrap;align-items:center;gap:8px;min-width:0;margin-bottom:5px;display:flex}.provider-icon{background:var(--raised);border:1px solid var(--border-soft);width:31px;height:31px;color:var(--text);border-radius:7px;flex:none;justify-content:center;align-items:center;display:inline-flex}.provider-icon img{object-fit:contain;width:19px;height:19px;display:block}.prov-meta{flex-wrap:wrap;align-items:center;gap:5px;min-width:0;display:flex}.prov-meta>span{text-overflow:ellipsis;white-space:nowrap;min-width:0;max-width:100%;overflow:hidden}.provider-quota{padding-left:58px}.provider-actions{flex-shrink:0;align-items:center;gap:8px;margin-left:auto;display:flex}.link-btn{color:var(--accent-hover);font:inherit;cursor:pointer;background:0 0;border:none;padding:6px 2px;font-size:13px;text-decoration:underline}.prov-accounts-toggle{border:none;border-top:1px solid var(--border-soft);width:100%;color:var(--muted);font:inherit;cursor:pointer;background:0 0;justify-content:center;align-items:center;gap:6px;min-height:24px;padding:4px 0;font-size:12px;display:flex}.prov-accounts-toggle:hover{color:var(--text);background:var(--raised)}.prov-accounts-toggle .chev{transition:transform .15s;display:inline-flex}.prov-accounts-toggle .chev svg{width:12px;height:12px;transform:rotate(90deg)}.prov-accounts-toggle.open .chev svg{transform:rotate(-90deg)}.prov-accounts-list{border-top:1px solid var(--border-soft);flex-direction:column;gap:2px;padding:6px 16px 10px;display:flex}.prov-account-row{text-align:left;border-radius:var(--radius-xs);width:100%;color:var(--text);font:inherit;cursor:pointer;background:0 0;border:none;align-items:center;gap:8px;min-height:32px;padding:6px 8px;font-size:13px;display:flex}.prov-account-row:hover{background:var(--raised)}.prov-account-row.active{cursor:default}.prov-account-row .prov-account-email{text-overflow:ellipsis;white-space:nowrap;flex:auto;min-width:0;overflow:hidden}.prov-account-row .badge{flex:none}.prov-account-remove{color:var(--muted);cursor:pointer;border-radius:var(--radius-xs);background:0 0;border:none;flex:none;justify-content:center;align-items:center;padding:4px;display:inline-flex}.prov-account-remove:hover{color:var(--red);background:var(--red-soft)}.prov-account-add{color:var(--muted);font-size:12.5px}.prov-account-add:hover{color:var(--accent-hover)}.prov-account-keyform{cursor:default;gap:6px}.prov-account-keyform:hover{background:0 0}.prov-account-keyform .input-sm{flex:auto;min-width:0;height:28px;padding:4px 8px;font-size:12.5px}.oauth-grid{grid-template-columns:minmax(140px,max-content) minmax(0,1fr) max-content;align-items:center;gap:10px 16px;display:grid}.oauth-row{grid-column:1/-1;grid-template-columns:subgrid;align-items:center;min-height:30px;display:grid}.oauth-name{align-items:center;gap:8px;min-width:0;font-size:13px;font-weight:600;display:inline-flex}.oauth-name-text{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.provider-icon-sm{border-radius:6px;width:24px;height:24px}.provider-icon-sm img{width:15px;height:15px}.oauth-status{align-items:center;gap:7px;min-width:0;font-size:13px;display:inline-flex}.oauth-email{text-overflow:ellipsis;white-space:nowrap;min-width:0;overflow:hidden}.oauth-actions{justify-content:flex-end;align-items:center;gap:8px;min-width:0;display:inline-flex}.oauth-login-hint{flex-wrap:wrap;grid-column:1/-1;align-items:center;gap:8px;font-size:12px;display:inline-flex}@media (width<=760px){.app{grid-template-columns:1fr}.sidebar{z-index:20;border-right:none;border-bottom:1px solid var(--border);background:var(--rail);flex-flow:wrap;align-items:center;gap:0;min-width:0;height:auto;padding:0 10px;position:sticky;top:0}.brand{flex:auto;order:1;width:auto;padding:10px 4px}.sidebar-foot{flex-direction:row;flex:none;order:2;gap:4px;margin:0;padding:0}.sidebar-foot .sidebar-link{display:none}.theme-toggle{justify-content:center;min-width:44px;min-height:44px;padding:8px}.theme-toggle .mode{display:none}.sidebar nav{border-top:1px solid var(--border-soft);flex:100%;order:3;grid-template-columns:repeat(4,minmax(0,1fr));gap:2px;min-width:0;margin:0;padding:4px 0 8px;display:grid}.nav-item{text-align:center;text-overflow:ellipsis;white-space:nowrap;flex-direction:column;justify-content:center;gap:3px;width:auto;min-height:44px;padding:6px 4px;font-size:12px;overflow:hidden}.nav-item svg{width:16px;height:16px}.main-inner{padding:22px 18px 48px}.setting-row{flex-wrap:wrap}.setting-row .setting-copy{flex:100%!important}.setting-row .setting-controls{width:100%}.setting-row .setting-controls .select-sm{flex:1 1 0;min-width:0}.setting-row>.select-sm{width:100%}.api-form-row{flex-direction:column;align-items:stretch}.api-form-row .btn{width:100%;min-height:40px}.stat-row{grid-template-columns:repeat(2,minmax(0,1fr))}.tbl{min-width:460px}.usage-cards{grid-template-columns:repeat(2,minmax(0,1fr))!important}.provider-quota{padding-left:16px}.prov-meta{grid-template-columns:max-content minmax(0,1fr);width:100%;display:grid}.prov-meta .chip~span:not(:last-child){display:none}.oauth-grid{grid-template-columns:minmax(0,auto) minmax(0,1fr) max-content;column-gap:8px}}.usage-cards{grid-template-columns:repeat(3,minmax(0,1fr));gap:12px;margin-top:8px;display:grid}.usage-cards .stat-value{margin-top:4px;font-size:20px;font-weight:600}.usage-range{border:1px solid var(--border);background:var(--surface);border-radius:999px;gap:2px;padding:2px;display:inline-flex}.usage-range-btn{color:var(--muted);cursor:pointer;font:inherit;background:0 0;border:none;border-radius:999px;padding:4px 12px}.usage-range-btn.active{background:var(--raised);color:var(--text);font-weight:600}.panel-title{color:var(--text);margin:0 0 12px;font-size:14px;font-weight:600}.panel-head{justify-content:space-between;align-items:center;gap:12px;margin-bottom:12px;display:flex}.panel-head .panel-title{margin:0}.panel-head .input{max-width:220px}.heatmap{--hm-cell:11px;--hm-gap:3px;flex-direction:column;gap:6px;padding-bottom:4px;display:flex;overflow-x:auto}.heatmap-months{color:var(--muted);width:max-content;margin-bottom:-2px;font-size:11px;display:grid}.heatmap-day-spacer{grid-column:1}.heatmap-month{white-space:nowrap}.heatmap-body{gap:var(--hm-gap);width:max-content;display:flex}.heatmap-days{grid-template-rows:repeat(7, var(--hm-cell));row-gap:var(--hm-gap);color:var(--muted);background:var(--surface);z-index:1;flex-shrink:0;align-items:center;width:25px;font-size:10px;display:grid;position:sticky;left:0}.heatmap-grid{gap:var(--hm-gap);display:grid}.heatmap-week{grid-template-rows:repeat(7, var(--hm-cell));gap:var(--hm-gap);display:grid}.heatmap-cell{width:var(--hm-cell);height:var(--hm-cell);background:var(--border);border-radius:2px}.heatmap-cell-0{background:var(--border)}.heatmap-cell-1{background:color-mix(in oklch, var(--green) 25%, var(--surface))}.heatmap-cell-2{background:color-mix(in oklch, var(--green) 50%, var(--surface))}.heatmap-cell-3{background:color-mix(in oklch, var(--green) 75%, var(--surface))}.heatmap-cell-4{background:var(--green)}.heatmap-legend{align-self:flex-end;align-items:center;gap:4px;font-size:12px;display:inline-flex;position:sticky;right:0}.heatmap-legend .heatmap-cell{width:10px;height:10px}.usage-bar{background:var(--border);border-radius:999px;width:100%;min-width:60px;height:6px;overflow:hidden}.usage-bar-fill{background:var(--green);border-radius:999px;height:100%}.daybars{grid-template-columns:repeat(7,1fr);align-items:end;gap:10px;height:180px;padding-top:8px;display:grid}.daybar{flex-direction:column;justify-content:flex-end;align-items:center;gap:6px;height:100%;display:flex;position:relative}.daybar-track{background:var(--border);border-radius:6px;flex:1;align-items:flex-end;width:100%;max-width:48px;display:flex;overflow:hidden}.daybar-stack{border-radius:6px 6px 0 0;flex-direction:column-reverse;width:100%;min-height:2px;transition:height .2s;display:flex;overflow:hidden}.daybar-seg{width:100%;min-height:1px}.daybar-count{color:var(--text);font-size:12px;font-weight:600}.daybar-label{white-space:nowrap;font-size:11px}.daybar:hover .daybar-track{outline:1px solid var(--border)}.daybar-tip{z-index:5;background:var(--surface);border:1px solid var(--border);pointer-events:none;border-radius:8px;min-width:160px;padding:8px 10px;position:absolute;bottom:calc(100% + 6px);left:50%;transform:translate(-50%);box-shadow:0 6px 20px #00000059}.daybar-tip-date{color:var(--text);white-space:nowrap;margin-bottom:6px;font-size:12px;font-weight:600}.daybar-tip-row{align-items:center;gap:8px;font-size:12px;line-height:1.7;display:flex}.daybar-tip-swatch{border-radius:2px;flex-shrink:0;width:10px;height:10px}.daybar-tip-name{color:var(--text);white-space:nowrap;text-overflow:ellipsis;flex:1;max-width:160px;overflow:hidden}.daybar-tip-val{color:var(--muted);font-variant-numeric:tabular-nums}.usage-scroll{max-height:360px;overflow-y:auto}.usage-scroll thead th{z-index:1;background:var(--surface);position:sticky;top:0}