@bitkyc08/opencodex 2.1.8 → 2.1.9

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.
@@ -2,6 +2,7 @@ import type { OcxProviderConfig } from "../types";
2
2
  import { FORWARD_HEADERS } from "../adapters/openai-responses";
3
3
  import { signalWithTimeout } from "../abort";
4
4
  import { parseSidecarSSE } from "../web-search/parse";
5
+ import type { SidecarOutcomeRecorder } from "../web-search/executor";
5
6
 
6
7
  export interface VisionSettings {
7
8
  model: string;
@@ -39,7 +40,7 @@ function validateImageUrl(url: string): string | null {
39
40
 
40
41
  /**
41
42
  * Describe ONE image via a gpt vision model through the ChatGPT forward backend — the path that has
42
- * native image input. Reuses the caller's forwarded OAuth headers. The user's own request text is
43
+ * native image input. Reuses selected forwarded OAuth headers. The user's own request text is
43
44
  * passed as context so the description is focused. Never throws — returns `{error}` on failure.
44
45
  */
45
46
  export async function describeImage(
@@ -47,9 +48,10 @@ export async function describeImage(
47
48
  detail: string | undefined,
48
49
  contextText: string,
49
50
  forwardProvider: OcxProviderConfig,
50
- incomingHeaders: Headers,
51
+ selectedForwardHeaders: Headers,
51
52
  settings: VisionSettings,
52
53
  abortSignal?: AbortSignal,
54
+ recordOutcome?: SidecarOutcomeRecorder,
53
55
  ): Promise<DescribeOutcome> {
54
56
  const invalid = validateImageUrl(imageUrl);
55
57
  if (invalid) return { text: "", error: invalid };
@@ -57,7 +59,7 @@ export async function describeImage(
57
59
  const headers: Record<string, string> = { "Content-Type": "application/json" };
58
60
  if (forwardProvider.headers) Object.assign(headers, forwardProvider.headers);
59
61
  for (const h of FORWARD_HEADERS) {
60
- const v = incomingHeaders.get(h);
62
+ const v = selectedForwardHeaders.get(h);
61
63
  if (v) headers[h] = v;
62
64
  }
63
65
  const content: unknown[] = [];
@@ -86,6 +88,7 @@ export async function describeImage(
86
88
  body: JSON.stringify(body),
87
89
  signal: linkedSignal.signal,
88
90
  });
91
+ recordOutcome?.(res.status);
89
92
  if (!res.ok) {
90
93
  const t = await res.text().catch(() => "");
91
94
  return { text: "", error: `vision sidecar HTTP ${res.status}: ${t.slice(0, 200)}` };
@@ -96,6 +99,7 @@ export async function describeImage(
96
99
  if (!parsed.text.trim() && parsed.error) return { text: "", error: parsed.error };
97
100
  return { text: parsed.text };
98
101
  } catch (e) {
102
+ recordOutcome?.(e instanceof Error && e.name === "TimeoutError" ? "timeout" : "connect_error");
99
103
  return { text: "", error: e instanceof Error ? e.message : String(e) };
100
104
  } finally {
101
105
  linkedSignal.cleanup();
@@ -1,6 +1,8 @@
1
1
  import type { OcxConfig, OcxContentPart, OcxMessage, OcxParsedRequest, OcxProviderConfig, OcxTextContent } from "../types";
2
2
  import { modelInList } from "../types";
3
3
  import { describeImage, type VisionSettings } from "./describe";
4
+ import type { CodexAuthContext } from "../codex-auth-context";
5
+ import type { SidecarOutcomeRecorder } from "../web-search/executor";
4
6
 
5
7
  export { describeImage } from "./describe";
6
8
 
@@ -66,12 +68,13 @@ export function planVisionSidecar(
66
68
  modelId: string,
67
69
  parsed: OcxParsedRequest,
68
70
  incomingHeaders: Headers,
71
+ authContext: CodexAuthContext = { kind: "main", accountId: null },
69
72
  ): VisionPlan | undefined {
70
73
  if (!modelInList(provider.noVisionModels, modelId)) return undefined;
71
74
  if (!messagesHaveImage(parsed)) return undefined;
72
75
  const cfg = config.visionSidecar ?? {};
73
76
  if (cfg.enabled === false) return undefined;
74
- if (!incomingHeaders.get("authorization")) return undefined;
77
+ if (authContext.kind === "main" && !incomingHeaders.get("authorization")) return undefined;
75
78
  const forwardProvider = findForwardProvider(config);
76
79
  if (!forwardProvider) return undefined;
77
80
  return {
@@ -105,9 +108,10 @@ function renderDescription(out: { text: string; error?: string }): OcxTextConten
105
108
  export async function describeImagesInPlace(
106
109
  parsed: OcxParsedRequest,
107
110
  forwardProvider: OcxProviderConfig,
108
- incomingHeaders: Headers,
111
+ selectedForwardHeaders: Headers,
109
112
  settings: VisionSettings,
110
113
  abortSignal?: AbortSignal,
114
+ recordSidecarOutcome?: SidecarOutcomeRecorder,
111
115
  ): Promise<void> {
112
116
  // 1. Gather every image part across messages, each with its own message's text as context.
113
117
  const jobs: ImageJob[] = [];
@@ -130,7 +134,7 @@ export async function describeImagesInPlace(
130
134
 
131
135
  // 2. Describe all images with bounded concurrency (order preserved).
132
136
  const outcomes = await runBounded(jobs, VISION_CONCURRENCY, j =>
133
- describeImage(j.imageUrl, j.detail, j.contextText, forwardProvider, incomingHeaders, settings, abortSignal));
137
+ describeImage(j.imageUrl, j.detail, j.contextText, forwardProvider, selectedForwardHeaders, settings, abortSignal, recordSidecarOutcome));
134
138
 
135
139
  // 3. Rebuild each message, replacing image parts with their descriptions in order.
136
140
  let oi = 0;
@@ -2,6 +2,7 @@ import type { OcxProviderConfig } from "../types";
2
2
  import { FORWARD_HEADERS } from "../adapters/openai-responses";
3
3
  import { signalWithTimeout } from "../abort";
4
4
  import { parseSidecarSSE, type WebSearchResult } from "./parse";
5
+ import type { CodexUpstreamOutcome } from "../codex-routing";
5
6
 
6
7
  export interface SidecarSettings {
7
8
  model: string;
@@ -24,10 +25,11 @@ const IMAGE_INSTRUCTION =
24
25
 
25
26
  /** A search result, or an `error` string when the search couldn't run (surfaced as a tool result). */
26
27
  export type SidecarOutcome = WebSearchResult & { error?: string };
28
+ export type SidecarOutcomeRecorder = (outcome: CodexUpstreamOutcome) => void;
27
29
 
28
30
  /**
29
31
  * Execute ONE web search via the gpt-mini sidecar through the ChatGPT forward backend — the only path
30
- * with a real server-side web_search. Reuses the caller's forwarded OAuth headers (the forward adapter
32
+ * with a real server-side web_search. Reuses selected forwarded OAuth headers (the forward adapter
31
33
  * has no key of its own), replays the hosted web_search tool config verbatim, and runs the mini at
32
34
  * minimal reasoning. Never throws — returns `{error}` so the caller injects a graceful tool result.
33
35
  */
@@ -35,14 +37,15 @@ export async function runWebSearch(
35
37
  query: string,
36
38
  hostedTool: Record<string, unknown>,
37
39
  forwardProvider: OcxProviderConfig,
38
- incomingHeaders: Headers,
40
+ selectedForwardHeaders: Headers,
39
41
  settings: SidecarSettings,
40
42
  abortSignal?: AbortSignal,
43
+ recordOutcome?: SidecarOutcomeRecorder,
41
44
  ): Promise<SidecarOutcome> {
42
45
  const headers: Record<string, string> = { "Content-Type": "application/json" };
43
46
  if (forwardProvider.headers) Object.assign(headers, forwardProvider.headers);
44
47
  for (const h of FORWARD_HEADERS) {
45
- const v = incomingHeaders.get(h);
48
+ const v = selectedForwardHeaders.get(h);
46
49
  if (v) headers[h] = v;
47
50
  }
48
51
  const body = {
@@ -67,12 +70,14 @@ export async function runWebSearch(
67
70
  body: JSON.stringify(body),
68
71
  signal: linkedSignal.signal,
69
72
  });
73
+ recordOutcome?.(res.status);
70
74
  if (!res.ok) {
71
75
  const t = await res.text().catch(() => "");
72
76
  return { text: "", sources: [], error: `sidecar HTTP ${res.status}: ${t.slice(0, 200)}` };
73
77
  }
74
78
  return await parseSidecarSSE(res);
75
79
  } catch (e) {
80
+ recordOutcome?.(e instanceof Error && e.name === "TimeoutError" ? "timeout" : "connect_error");
76
81
  return { text: "", sources: [], error: e instanceof Error ? e.message : String(e) };
77
82
  } finally {
78
83
  linkedSignal.cleanup();
@@ -1,6 +1,7 @@
1
1
  import type { OcxConfig, OcxParsedRequest, OcxProviderConfig } from "../types";
2
2
  import { modelInList } from "../types";
3
3
  import type { SidecarSettings } from "./executor";
4
+ import type { CodexAuthContext } from "../codex-auth-context";
4
5
 
5
6
  export { runWithWebSearch } from "./loop";
6
7
  export { buildWebSearchTool, extractHostedWebSearch, WEB_SEARCH_TOOL_NAME } from "./synthetic-tool";
@@ -40,11 +41,12 @@ export function planWebSearch(
40
41
  incomingHeaders: Headers,
41
42
  provider: OcxProviderConfig,
42
43
  modelId: string,
44
+ authContext: CodexAuthContext = { kind: "main", accountId: null },
43
45
  ): SidecarPlan | undefined {
44
46
  if (!parsed._webSearch || isPassthrough) return undefined;
45
47
  const cfg = config.webSearchSidecar ?? {};
46
48
  if (cfg.enabled === false) return undefined;
47
- if (!incomingHeaders.get("authorization")) return undefined; // not logged into ChatGPT → sidecar can't run
49
+ if (authContext.kind === "main" && !incomingHeaders.get("authorization")) return undefined; // not logged into ChatGPT → sidecar can't run
48
50
  const forwardProvider = findForwardProvider(config);
49
51
  if (!forwardProvider) return undefined;
50
52
  return {
@@ -2,7 +2,7 @@ import type { ProviderAdapter } from "../adapters/base";
2
2
  import type { AdapterEvent, OcxMessage, OcxParsedRequest, OcxProviderConfig } from "../types";
3
3
  import { namespacedToolName } from "../types";
4
4
  import { bridgeToResponsesSSE } from "../bridge";
5
- import { runWebSearch, type SidecarSettings } from "./executor";
5
+ import { runWebSearch, type SidecarOutcomeRecorder, type SidecarSettings } from "./executor";
6
6
  import { formatWebSearchResult } from "./format-result";
7
7
  import { WEB_SEARCH_TOOL_NAME } from "./synthetic-tool";
8
8
 
@@ -92,11 +92,12 @@ export interface WebSearchLoopDeps {
92
92
  adapter: ProviderAdapter;
93
93
  forwardProvider: OcxProviderConfig;
94
94
  hostedTool: Record<string, unknown>;
95
- incomingHeaders: Headers;
95
+ selectedForwardHeaders: Headers;
96
96
  settings: SidecarSettings;
97
97
  maxSearches: number;
98
98
  forceEmptyResponseId?: boolean;
99
99
  abortSignal?: AbortSignal;
100
+ recordSidecarOutcome?: SidecarOutcomeRecorder;
100
101
  }
101
102
 
102
103
  /**
@@ -106,7 +107,7 @@ export interface WebSearchLoopDeps {
106
107
  * streamed Responses SSE. web_search calls are executed internally and never relayed to Codex.
107
108
  */
108
109
  export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Response> {
109
- const { parsed, adapter, incomingHeaders, forwardProvider, hostedTool, settings, maxSearches, abortSignal } = deps;
110
+ const { parsed, adapter, selectedForwardHeaders, forwardProvider, hostedTool, settings, maxSearches, abortSignal, recordSidecarOutcome } = deps;
110
111
  if (!adapter.parseResponse) return jsonError(500, "web-search sidecar requires a non-streaming adapter");
111
112
 
112
113
  const messages: OcxMessage[] = [...parsed.context.messages];
@@ -128,7 +129,7 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
128
129
  ...parsed, stream: false,
129
130
  context: { ...parsed.context, messages, tools: forceAnswer ? toolsNoWebSearch : allTools },
130
131
  };
131
- const request = adapter.buildRequest(iterParsed, { headers: incomingHeaders });
132
+ const request = adapter.buildRequest(iterParsed, { headers: selectedForwardHeaders });
132
133
  let resp: Response;
133
134
  try {
134
135
  resp = await fetch(request.url, {
@@ -166,7 +167,7 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
166
167
  outcome = { text: "", sources: [], error: "the model called web_search with an empty query" };
167
168
  searchesExecuted++;
168
169
  } else {
169
- outcome = await runWebSearch(call.query, hostedTool, forwardProvider, incomingHeaders, settings, abortSignal);
170
+ outcome = await runWebSearch(call.query, hostedTool, forwardProvider, selectedForwardHeaders, settings, abortSignal, recordSidecarOutcome);
170
171
  searchesExecuted++;
171
172
  if (outcome.error) failedQueries.add(normalizeQuery(call.query));
172
173
  }
package/src/ws-bridge.ts CHANGED
@@ -1,8 +1,11 @@
1
1
  import type { ServerWebSocket } from "bun";
2
2
  import { FORWARD_HEADERS } from "./adapters/openai-responses";
3
+ import type { CodexAuthContext } from "./codex-auth-context";
4
+ import { headersForCodexAuthContext } from "./codex-auth-context";
5
+ import type { ResponsesTerminalStatus } from "./bridge";
3
6
 
4
7
  const OPEN = 1;
5
- const TERMINAL_TYPES = new Set(["response.completed", "response.failed", "response.incomplete"]);
8
+ type ResponsesTerminalReporter = (status: ResponsesTerminalStatus) => void;
6
9
  const SAFE_RESPONSE_HEADER_EXACT = new Set([
7
10
  "retry-after",
8
11
  "x-request-id",
@@ -15,6 +18,7 @@ const SAFE_RESPONSE_HEADER_EXACT = new Set([
15
18
 
16
19
  export interface WsData {
17
20
  headers?: Headers; // selected inbound upgrade headers only; never store full cookies/handshake internals
21
+ authContext?: CodexAuthContext; // immutable account decision made at upgrade time
18
22
  cancel?: () => void; // cancels the in-flight stream reader/fetch
19
23
  turnId?: number; // monotonically increasing per socket; prevents stale frames after replacement turns
20
24
  }
@@ -25,15 +29,26 @@ export class WsSendDroppedError extends Error {
25
29
  }
26
30
  }
27
31
 
28
- export function selectForwardHeaders(headers: Headers): Headers {
32
+ export function selectForwardHeaders(
33
+ headers: Headers,
34
+ codexOverride?: { accessToken: string; chatgptAccountId: string },
35
+ ): Headers {
29
36
  const selected = new Headers();
30
37
  for (const name of FORWARD_HEADERS) {
31
38
  const value = headers.get(name);
32
39
  if (value) selected.set(name, value);
33
40
  }
41
+ if (codexOverride) {
42
+ selected.set("authorization", `Bearer ${codexOverride.accessToken}`);
43
+ selected.set("chatgpt-account-id", codexOverride.chatgptAccountId);
44
+ }
34
45
  return selected;
35
46
  }
36
47
 
48
+ export function selectForwardHeadersForAuthContext(headers: Headers, ctx: CodexAuthContext): Headers {
49
+ return headersForCodexAuthContext(headers, ctx);
50
+ }
51
+
37
52
  export function safeResponseHeaders(headers: Headers): Record<string, string> {
38
53
  const out: Record<string, string> = {};
39
54
  for (const [name, value] of headers) {
@@ -41,7 +56,7 @@ export function safeResponseHeaders(headers: Headers): Record<string, string> {
41
56
  if (
42
57
  SAFE_RESPONSE_HEADER_EXACT.has(lower) ||
43
58
  lower.startsWith("x-ratelimit-") ||
44
- /^x-codex(?:-[a-z0-9-]+)?-(primary|secondary)-(used-percent|window-minutes|reset-at)$/.test(lower) ||
59
+ /^x-codex(?:-[a-z0-9-]+)?-(primary|secondary|tertiary)-(used-percent|window-minutes|reset-at)$/.test(lower) ||
45
60
  /^x-codex(?:-[a-z0-9-]+)?-limit-name$/.test(lower)
46
61
  ) {
47
62
  out[lower] = value;
@@ -126,6 +141,19 @@ function payloadType(payload: string): string | null {
126
141
  }
127
142
  }
128
143
 
144
+ function terminalStatusFromType(type: string): ResponsesTerminalStatus | null {
145
+ switch (type) {
146
+ case "response.completed":
147
+ return "completed";
148
+ case "response.failed":
149
+ return "failed";
150
+ case "response.incomplete":
151
+ return "incomplete";
152
+ default:
153
+ return null;
154
+ }
155
+ }
156
+
129
157
  function protocolError(message: string): Record<string, unknown> {
130
158
  return {
131
159
  type: "protocol_error",
@@ -141,11 +169,19 @@ function sendProtocolError(ws: ServerWebSocket<WsData>, status: number, message:
141
169
  export async function pumpResponsesSseToWebSocket(
142
170
  ws: ServerWebSocket<WsData>,
143
171
  sseStream: ReadableStream<Uint8Array>,
144
- options: { isCurrent?: () => boolean } = {},
172
+ options: { isCurrent?: () => boolean; onTerminal?: ResponsesTerminalReporter } = {},
145
173
  ): Promise<void> {
146
174
  const reader = sseStream.getReader();
147
175
  const isCurrent = options.isCurrent ?? (() => true);
176
+ let clientCancelled = false;
177
+ let terminalReported = false;
178
+ const reportTerminal = (status: ResponsesTerminalStatus) => {
179
+ if (terminalReported || clientCancelled || !isCurrent()) return;
180
+ terminalReported = true;
181
+ options.onTerminal?.(status);
182
+ };
148
183
  const cancel = () => {
184
+ clientCancelled = true;
149
185
  void reader.cancel().catch(() => {});
150
186
  };
151
187
  ws.data.cancel = cancel;
@@ -159,6 +195,7 @@ export async function pumpResponsesSseToWebSocket(
159
195
  if (payload === "[DONE]") return false;
160
196
  const type = payloadType(payload);
161
197
  if (!type) {
198
+ reportTerminal("incomplete");
162
199
  sendProtocolError(ws, 502, "Invalid JSON payload in upstream SSE frame");
163
200
  terminalSeen = true;
164
201
  void reader.cancel().catch(() => {});
@@ -166,7 +203,9 @@ export async function pumpResponsesSseToWebSocket(
166
203
  }
167
204
  if (terminalSeen) return true;
168
205
  sendTextFrame(ws, payload);
169
- if (TERMINAL_TYPES.has(type)) {
206
+ const terminalStatus = terminalStatusFromType(type);
207
+ if (terminalStatus) {
208
+ reportTerminal(terminalStatus);
170
209
  terminalSeen = true;
171
210
  void reader.cancel().catch(() => {});
172
211
  return true;
@@ -191,11 +230,13 @@ export async function pumpResponsesSseToWebSocket(
191
230
  const payload = parseSseBlock(buffer);
192
231
  if (payload) handlePayload(payload);
193
232
  }
194
- if (!terminalSeen && isCurrent()) {
233
+ if (!terminalSeen && isCurrent() && !clientCancelled) {
234
+ reportTerminal("incomplete");
195
235
  sendProtocolError(ws, 502, "Upstream stream ended before response terminal event");
196
236
  }
197
237
  } catch (err) {
198
238
  if (!terminalSeen && isCurrent() && ws.readyState === OPEN) {
239
+ if (!(err instanceof WsSendDroppedError)) reportTerminal("incomplete");
199
240
  sendProtocolError(ws, 502, err instanceof Error ? err.message : String(err));
200
241
  }
201
242
  } finally {
@@ -206,6 +247,7 @@ export async function pumpResponsesSseToWebSocket(
206
247
  export function sendResponsesJsonAsEvents(
207
248
  ws: ServerWebSocket<WsData>,
208
249
  response: Record<string, unknown>,
250
+ onTerminal?: ResponsesTerminalReporter,
209
251
  ): void {
210
252
  const output = Array.isArray(response.output) ? response.output : [];
211
253
  sendJsonFrame(ws, {
@@ -226,6 +268,7 @@ export function sendResponsesJsonAsEvents(
226
268
  type: `response.${finalStatus}` as "response.completed" | "response.failed" | "response.incomplete",
227
269
  response: { ...response, status: finalStatus },
228
270
  });
271
+ onTerminal?.(finalStatus);
229
272
  }
230
273
 
231
274
  function errorPayloadFromText(text: string): Record<string, unknown> {
@@ -247,6 +290,7 @@ export async function sendResponseToWebSocket(
247
290
  ws: ServerWebSocket<WsData>,
248
291
  response: Response,
249
292
  isCurrent: () => boolean,
293
+ options: { onTerminal?: ResponsesTerminalReporter } = {},
250
294
  ): Promise<void> {
251
295
  if (!isCurrent()) {
252
296
  await response.body?.cancel().catch(() => {});
@@ -262,6 +306,7 @@ export async function sendResponseToWebSocket(
262
306
 
263
307
  const contentType = response.headers.get("content-type")?.toLowerCase() ?? "";
264
308
  if (!response.body) {
309
+ options.onTerminal?.("incomplete");
265
310
  sendJsonFrame(ws, buildWsErrorFrame(502, {
266
311
  type: "protocol_error",
267
312
  code: "websocket_protocol_error",
@@ -271,7 +316,7 @@ export async function sendResponseToWebSocket(
271
316
  }
272
317
 
273
318
  if (contentType.includes("text/event-stream")) {
274
- await pumpResponsesSseToWebSocket(ws, response.body, { isCurrent });
319
+ await pumpResponsesSseToWebSocket(ws, response.body, { isCurrent, onTerminal: options.onTerminal });
275
320
  return;
276
321
  }
277
322
 
@@ -279,7 +324,7 @@ export async function sendResponseToWebSocket(
279
324
  const text = await response.text();
280
325
  if (!isCurrent()) return;
281
326
  const json = JSON.parse(text) as Record<string, unknown>;
282
- sendResponsesJsonAsEvents(ws, json);
327
+ sendResponsesJsonAsEvents(ws, json, options.onTerminal);
283
328
  return;
284
329
  }
285
330
 
@@ -289,7 +334,7 @@ export async function sendResponseToWebSocket(
289
334
  return;
290
335
  }
291
336
  if (looksLikeSse(prefix)) {
292
- await pumpResponsesSseToWebSocket(ws, stream, { isCurrent });
337
+ await pumpResponsesSseToWebSocket(ws, stream, { isCurrent, onTerminal: options.onTerminal });
293
338
  return;
294
339
  }
295
340
 
@@ -298,10 +343,11 @@ export async function sendResponseToWebSocket(
298
343
  const trimmed = text.trim();
299
344
  if (trimmed.startsWith("{")) {
300
345
  const json = JSON.parse(trimmed) as Record<string, unknown>;
301
- sendResponsesJsonAsEvents(ws, json);
346
+ sendResponsesJsonAsEvents(ws, json, options.onTerminal);
302
347
  return;
303
348
  }
304
349
 
350
+ options.onTerminal?.("incomplete");
305
351
  sendJsonFrame(ws, buildWsErrorFrame(502, {
306
352
  type: "protocol_error",
307
353
  code: "websocket_protocol_error",
@@ -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}.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{padding:5px}.card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius)}.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}.stat-row{grid-template-columns:repeat(4,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:.05em;align-items:center;gap:6px;margin-bottom:9px;font-size:11px;font-weight:600;display:flex}.stat .label svg{width:14px;height:14px}.stat .value{letter-spacing:-.02em;font-size:24px;font-weight:700;line-height:1.1}.stat .value.mono{font-family:var(--mono);font-size:19px}.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-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)}.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}.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}.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{justify-content:space-between;align-items:flex-start;gap:12px;padding:15px 16px;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}@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{overscroll-behavior-x:contain;border-top:1px solid var(--border-soft);scrollbar-width:none;flex-direction:row;flex:100%;order:3;gap:2px;min-width:0;margin:0;padding:4px 0 8px;display:flex;overflow-x:auto}.sidebar nav::-webkit-scrollbar{display:none}.nav-item{white-space:nowrap;width:auto;min-height:44px;padding:9px 14px;font-size:14px}.main-inner{padding:22px 18px 48px}.stat-row{grid-template-columns:repeat(2,minmax(0,1fr))}.tbl{min-width:460px}}