@bitkyc08/opencodex 2.7.28 → 2.7.29-preview.20260721

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.
@@ -16,8 +16,8 @@
16
16
  } catch (e) {}
17
17
  })();
18
18
  </script>
19
- <script type="module" crossorigin src="/assets/index-TZysP4q4.js"></script>
20
- <link rel="stylesheet" crossorigin href="/assets/index-DyBPh28A.css">
19
+ <script type="module" crossorigin src="/assets/index-DhnOK9c8.js"></script>
20
+ <link rel="stylesheet" crossorigin href="/assets/index-B-UauL1p.css">
21
21
  </head>
22
22
  <body>
23
23
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bitkyc08/opencodex",
3
- "version": "2.7.28",
3
+ "version": "2.7.29-preview.20260721",
4
4
  "description": "Universal provider proxy for OpenAI Codex & Claude Code — use any LLM with Codex CLI/App/SDK and Claude Code",
5
5
  "type": "module",
6
6
  "main": "./bin/package-main.mjs",
@@ -48,7 +48,10 @@ export function classifyCursorError(message: string): string {
48
48
 
49
49
  if (
50
50
  lower.includes("resource_exhausted") ||
51
- lower.includes("resource exhausted") ||
51
+ lower.includes("resource exhausted")
52
+ ) return "Cursor resource limit exceeded";
53
+
54
+ if (
52
55
  lower.includes("rate limit") ||
53
56
  lower.includes("rate-limit") ||
54
57
  lower.includes("too many requests") ||
@@ -106,7 +109,9 @@ export function classifyCursorError(message: string): string {
106
109
  * Mirrors `safeKiroErrorMessage` / `safeKiroHttpErrorMessage` in kiro-errors.ts.
107
110
  */
108
111
  export function safeCursorErrorMessage(rawMessage: string): string {
109
- const detail = sanitize(rawMessage).slice(0, 500);
110
112
  const prefix = classifyCursorError(rawMessage);
113
+ const detail = sanitize(rawMessage)
114
+ .replace(/resource[_ ]exhausted/gi, "resource limit exceeded")
115
+ .slice(0, 500);
111
116
  return detail ? `${prefix}: ${detail}` : prefix;
112
117
  }
@@ -6,10 +6,89 @@ import type {
6
6
  OcxToolCall,
7
7
  OcxToolResultMessage,
8
8
  } from "../../types";
9
- import { namespacedToolName } from "../../types";
9
+ import { isAllowedToolChoice, namespacedToolName, toolChoiceAliases, type OcxTool, type OcxToolChoice } from "../../types";
10
10
  import type { CursorRequestMessage, CursorRunRequest } from "./types";
11
11
  import { cursorCodexToWireModelId } from "./discovery";
12
12
  import { cursorEffortSuffix } from "./effort-map";
13
+ import {
14
+ cursorMcpToolEncodedSize,
15
+ cursorMcpToolsEncodedSize,
16
+ cursorToolAllowedByChoice,
17
+ cursorToolWireName,
18
+ cursorToolsForActivePrompt,
19
+ } from "./tool-definitions";
20
+
21
+ /** Probe-verified Cursor Connect boundaries, with byte headroom for the enclosing field. */
22
+ export const CURSOR_TOOL_COUNT_LIMIT = 330;
23
+ export const CURSOR_TOOL_BYTES_LIMIT = 120_000;
24
+
25
+ interface CursorToolBudgetResult {
26
+ tools: OcxTool[];
27
+ omitted: OcxTool[];
28
+ }
29
+
30
+ function explicitlySelectedNames(choice: OcxToolChoice | undefined): Set<string> {
31
+ if (!choice || choice === "auto" || choice === "none" || choice === "required") return new Set();
32
+ return new Set("name" in choice ? [choice.name] : isAllowedToolChoice(choice) ? choice.allowedTools : []);
33
+ }
34
+
35
+ function toolPriority(tool: OcxTool, selectedNames: ReadonlySet<string>): number {
36
+ if (toolChoiceAliases(tool).some(name => selectedNames.has(name))) return 0;
37
+ if (tool.loadedFromToolSearch) return 1;
38
+ if (!tool.namespace) return 2;
39
+ return 3;
40
+ }
41
+
42
+ /**
43
+ * Select one catalog used by both Cursor protobuf registration and call recognition.
44
+ * Actual McpTools serialization is measured after every candidate so descriptions,
45
+ * names, provider identifiers, and schemas all count toward the byte ceiling.
46
+ */
47
+ export function applyCursorToolBudget(
48
+ tools: readonly OcxTool[] | undefined,
49
+ toolChoice: OcxToolChoice | undefined,
50
+ ): CursorToolBudgetResult {
51
+ const eligible = (tools ?? []).filter(tool => cursorToolAllowedByChoice(tool, toolChoice));
52
+ if (
53
+ eligible.length <= CURSOR_TOOL_COUNT_LIMIT
54
+ && cursorMcpToolsEncodedSize(eligible, toolChoice) <= CURSOR_TOOL_BYTES_LIMIT
55
+ ) return { tools: [...eligible], omitted: [] };
56
+
57
+ const selectedNames = explicitlySelectedNames(toolChoice);
58
+ const candidates = eligible
59
+ .map((tool, index) => ({ tool, index, priority: toolPriority(tool, selectedNames) }))
60
+ .sort((a, b) => a.priority - b.priority || a.index - b.index);
61
+ const kept: OcxTool[] = [];
62
+ const keptSet = new Set<OcxTool>();
63
+ let keptBytes = 0;
64
+
65
+ for (const candidate of candidates) {
66
+ if (kept.length >= CURSOR_TOOL_COUNT_LIMIT) continue;
67
+ // Repeated protobuf message fields serialize as concatenated tag/length/value entries,
68
+ // so each one-entry wrapper size is the exact additive contribution to McpTools.
69
+ const candidateBytes = cursorMcpToolEncodedSize(candidate.tool, toolChoice);
70
+ if (keptBytes + candidateBytes > CURSOR_TOOL_BYTES_LIMIT) continue;
71
+ kept.push(candidate.tool);
72
+ keptSet.add(candidate.tool);
73
+ keptBytes += candidateBytes;
74
+ }
75
+
76
+ return {
77
+ tools: eligible.filter(tool => keptSet.has(tool)),
78
+ omitted: eligible.filter(tool => !keptSet.has(tool)),
79
+ };
80
+ }
81
+
82
+ function catalogLimitNote(kept: readonly OcxTool[], omitted: readonly OcxTool[]): string | undefined {
83
+ if (omitted.length === 0) return undefined;
84
+ const recoverable = kept.some(tool => tool.toolSearch || cursorToolWireName(tool) === "tool_search");
85
+ const names = omitted.slice(0, 12).map(cursorToolWireName);
86
+ const remainder = omitted.length - names.length;
87
+ const omittedSummary = `${names.join(", ")}${remainder > 0 ? `, and ${remainder} more` : ""}`;
88
+ return recoverable
89
+ ? `[opencodex] Cursor's transport limit allows ${kept.length} of ${kept.length + omitted.length} client tools this turn. Omitted: ${omittedSummary}. Use tool_search for a needed omitted tool; tools returned by tool_search are prioritized on the next turn.`
90
+ : `[opencodex] Cursor's transport limit allows ${kept.length} of ${kept.length + omitted.length} client tools this turn. Omitted and unavailable this turn: ${omittedSummary}.`;
91
+ }
13
92
 
14
93
  /**
15
94
  * Resolve a `cursor/<model>` selection + Codex reasoning effort to the actual Cursor model id. Cursor
@@ -80,6 +159,13 @@ export function generatedCursorConversationId(): string {
80
159
  }
81
160
 
82
161
  export function createCursorRequest(parsed: OcxParsedRequest): CursorRunRequest {
162
+ const messages = parsed.context.messages
163
+ .map(requestMessage)
164
+ .filter((message): message is CursorRequestMessage => !!message && message.content.length > 0);
165
+ const activeText = [...messages].reverse().find(message => message.role === "user" || message.role === "developer")?.content ?? "";
166
+ const visibleTools = cursorToolsForActivePrompt(parsed.context.tools, activeText, parsed.options.toolChoice);
167
+ const budget = applyCursorToolBudget(visibleTools, parsed.options.toolChoice);
168
+ const limitNote = catalogLimitNote(budget.tools, budget.omitted);
83
169
  return {
84
170
  modelId: normalizeCursorModelId(parsed.modelId, parsed.options.reasoning),
85
171
  // The Cursor conversation id comes ONLY from remembered state (_cursorConversationId). Do NOT fall
@@ -87,12 +173,10 @@ export function createCursorRequest(parsed: OcxParsedRequest): CursorRunRequest
87
173
  // different namespace and would start an unrelated Cursor conversation, breaking tool-result
88
174
  // continuation. If we have no remembered Cursor conversation, start a fresh one.
89
175
  conversationId: parsed._cursorConversationId ?? generatedCursorConversationId(),
90
- system: [...(parsed.context.systemPrompt ?? [])],
91
- messages: parsed.context.messages
92
- .map(requestMessage)
93
- .filter((message): message is CursorRequestMessage => !!message && message.content.length > 0),
176
+ system: [...(parsed.context.systemPrompt ?? []), ...(limitNote ? [limitNote] : [])],
177
+ messages,
94
178
  rawMessages: parsed.context.messages,
95
- ...(parsed.context.tools?.length ? { tools: parsed.context.tools } : {}),
179
+ ...(budget.tools.length ? { tools: budget.tools } : {}),
96
180
  ...(parsed.options.toolChoice ? { toolChoice: parsed.options.toolChoice } : {}),
97
181
  ...(parsed.options.parallelToolCalls !== undefined ? { parallelToolCalls: parsed.options.parallelToolCalls } : {}),
98
182
  };
@@ -2,7 +2,7 @@ import { create, fromJson, toBinary, type JsonValue } from "@bufbuild/protobuf";
2
2
  import { ValueSchema } from "@bufbuild/protobuf/wkt";
3
3
  import type { OcxRequestOptions, OcxTool } from "../../types";
4
4
  import { namespacedToolName } from "../../types";
5
- import { McpToolDefinitionSchema, type McpToolDefinition } from "./gen/agent_pb";
5
+ import { McpToolDefinitionSchema, McpToolsSchema, type McpToolDefinition } from "./gen/agent_pb";
6
6
 
7
7
  export const OCX_RESPONSES_TOOL_PROVIDER = "opencodex-responses";
8
8
  export const CODEX_EXEC_COMMAND_TOOL = "exec_command";
@@ -50,7 +50,7 @@ export function cursorRequestAdvertisesApplyPatch(
50
50
  tools: readonly Pick<OcxTool, "namespace" | "name" | "freeform">[] | undefined,
51
51
  toolChoice?: OcxRequestOptions["toolChoice"],
52
52
  ): boolean {
53
- return tools?.some(tool => !tool.namespace && tool.name === CODEX_APPLY_PATCH_TOOL && tool.freeform === true && toolChoiceAllows(tool, toolChoice)) ?? false;
53
+ return tools?.some(tool => !tool.namespace && tool.name === CODEX_APPLY_PATCH_TOOL && tool.freeform === true && cursorToolAllowedByChoice(tool, toolChoice)) ?? false;
54
54
  }
55
55
 
56
56
  export function cursorToolWireName(tool: Pick<OcxTool, "namespace" | "name">): string {
@@ -176,7 +176,7 @@ export function cursorToolsForActivePrompt<T extends Pick<OcxTool, "namespace" |
176
176
  ): readonly T[] | undefined {
177
177
  if (!shouldUseNativeExecOnlyForGenericToolUse(tools, activeText)) return tools;
178
178
  const execTools = tools?.filter(isBareCodexExecCommandTool);
179
- if (execTools?.length && !execTools.some(tool => toolChoiceAllows(tool, toolChoice))) return tools;
179
+ if (execTools?.length && !execTools.some(tool => cursorToolAllowedByChoice(tool, toolChoice))) return tools;
180
180
  return execTools && execTools.length > 0 ? execTools : tools;
181
181
  }
182
182
 
@@ -199,7 +199,7 @@ export function appendCursorShellAliasHint(
199
199
  return `${text}${text.endsWith("\n") ? "\n" : "\n\n"}${CURSOR_SHELL_ALIAS_USER_HINT}`;
200
200
  }
201
201
 
202
- function toolChoiceAllows(tool: Pick<OcxTool, "namespace" | "name">, toolChoice: OcxRequestOptions["toolChoice"] | undefined): boolean {
202
+ export function cursorToolAllowedByChoice(tool: Pick<OcxTool, "namespace" | "name">, toolChoice: OcxRequestOptions["toolChoice"] | undefined): boolean {
203
203
  if (!toolChoice || toolChoice === "auto" || toolChoice === "required") return true;
204
204
  if (toolChoice === "none") return false;
205
205
  if ("allowedTools" in toolChoice) {
@@ -232,7 +232,7 @@ export function buildCursorToolGuidanceSystemNote(
232
232
  if (!tools?.length) return undefined;
233
233
  const wireNames = [...new Set(
234
234
  tools
235
- .filter(tool => toolChoiceAllows(tool, toolChoice))
235
+ .filter(tool => cursorToolAllowedByChoice(tool, toolChoice))
236
236
  .map(tool => cursorToolWireName(tool)),
237
237
  )];
238
238
  if (wireNames.length === 0) return undefined;
@@ -291,7 +291,7 @@ export function buildCursorToolDefinitions(
291
291
  toolChoice?: OcxRequestOptions["toolChoice"],
292
292
  ): McpToolDefinition[] {
293
293
  if (!tools?.length) return [];
294
- return tools.filter(tool => toolChoiceAllows(tool, toolChoice)).map(tool => {
294
+ return tools.filter(tool => cursorToolAllowedByChoice(tool, toolChoice)).map(tool => {
295
295
  const wireName = cursorToolWireName(tool);
296
296
  return create(McpToolDefinitionSchema, {
297
297
  name: wireName,
@@ -302,3 +302,20 @@ export function buildCursorToolDefinitions(
302
302
  });
303
303
  });
304
304
  }
305
+
306
+ /** Exact byte size of the protobuf field value Cursor receives for client tool registration. */
307
+ export function cursorMcpToolsEncodedSize(
308
+ tools: readonly OcxTool[] | undefined,
309
+ toolChoice?: OcxRequestOptions["toolChoice"],
310
+ ): number {
311
+ const definitions = buildCursorToolDefinitions(tools, toolChoice);
312
+ return toBinary(McpToolsSchema, create(McpToolsSchema, { mcpTools: definitions })).byteLength;
313
+ }
314
+
315
+ /** Exact additive contribution of one repeated McpToolDefinition entry. */
316
+ export function cursorMcpToolEncodedSize(
317
+ tool: OcxTool,
318
+ toolChoice?: OcxRequestOptions["toolChoice"],
319
+ ): number {
320
+ return cursorMcpToolsEncodedSize([tool], toolChoice);
321
+ }
@@ -0,0 +1,266 @@
1
+ /**
2
+ * Data-access layer for `ocx account` (issue #180) — live-proxy HTTP client and
3
+ * per-family account readers. Kept separate from account.ts (command handlers)
4
+ * per the 400-line module budget.
5
+ */
6
+ import { findLiveProxy, probeHostname } from "../server/proxy-liveness";
7
+ import { runningProxyUpdateHeaders } from "../oauth/login-cli";
8
+ import { isPublicOAuthProvider } from "../oauth/index";
9
+ import { getProviderRegistryEntry, providerCodexAccountMode } from "../providers/registry";
10
+ import type { OcxConfig } from "../types";
11
+
12
+ export type AccountType = "codex" | "oauth" | "api-key";
13
+
14
+ export interface AccountRow {
15
+ provider: string;
16
+ type: AccountType;
17
+ id: string;
18
+ label?: string;
19
+ email?: string;
20
+ plan?: string;
21
+ masked?: string;
22
+ active: boolean;
23
+ needsReauth?: boolean;
24
+ quota?: CodexQuotaDto | null;
25
+ }
26
+
27
+ export type ClassifyResult = { type: AccountType } | { error: string };
28
+
29
+ export type AccountStdin = NodeJS.ReadableStream & { isTTY?: boolean };
30
+
31
+ export interface AccountDeps {
32
+ /** Test injection: skip findLiveProxy and call the API at this base URL. */
33
+ baseUrl?: string;
34
+ fetchImpl?: typeof fetch;
35
+ loadConfigImpl?: () => OcxConfig;
36
+ stdinImpl?: AccountStdin;
37
+ stdinTimeoutMs?: number;
38
+ }
39
+
40
+ export function classifyAccount(config: OcxConfig, name: string): ClassifyResult {
41
+ const provider = config.providers?.[name];
42
+ if (providerCodexAccountMode(name, provider)) return { type: "codex" };
43
+ const entry = getProviderRegistryEntry(name);
44
+ if (entry?.authKind === "local") {
45
+ return { error: `provider "${name}" is a local provider and has no credentials` };
46
+ }
47
+ if (provider?.authMode === "forward") {
48
+ return { error: `provider "${name}" uses forward auth and has no switchable credentials` };
49
+ }
50
+ if (provider?.authMode === "key") return { type: "api-key" };
51
+ if (provider && !provider.authMode && (provider.apiKey || (provider.apiKeyPool?.length ?? 0) > 0)) {
52
+ return { type: "api-key" };
53
+ }
54
+ if (isPublicOAuthProvider(name)) return { type: "oauth" };
55
+ if (provider) return { type: "api-key" };
56
+ return { error: `unknown provider "${name}"` };
57
+ }
58
+
59
+ export interface ApiResult {
60
+ /** 0 = network-level failure (proxy unreachable). */
61
+ status: number;
62
+ json: Record<string, unknown>;
63
+ }
64
+
65
+ export async function apiJson(
66
+ deps: AccountDeps,
67
+ baseUrl: string,
68
+ method: "GET" | "PUT" | "POST" | "DELETE",
69
+ path: string,
70
+ body?: unknown,
71
+ ): Promise<ApiResult> {
72
+ const fetchImpl = deps.fetchImpl ?? fetch;
73
+ try {
74
+ const res = await fetchImpl(`${baseUrl}${path}`, {
75
+ method,
76
+ headers: runningProxyUpdateHeaders(),
77
+ body: body === undefined ? undefined : JSON.stringify(body),
78
+ });
79
+ const json = (await res.json().catch(() => ({}))) as Record<string, unknown>;
80
+ return { status: res.status, json };
81
+ } catch {
82
+ return { status: 0, json: {} };
83
+ }
84
+ }
85
+
86
+ export async function resolveBaseUrl(deps: AccountDeps): Promise<string | null> {
87
+ if (deps.baseUrl) return deps.baseUrl;
88
+ const live = await findLiveProxy();
89
+ if (!live) return null;
90
+ return `http://${probeHostname(live.hostname)}:${live.port}`;
91
+ }
92
+
93
+ export function proxyUnreachable(): number {
94
+ console.error("Proxy not reachable. Start it with 'ocx start' or 'ocx ensure'.");
95
+ return 1;
96
+ }
97
+
98
+ export function apiError(json: Record<string, unknown>, fallback: string): number {
99
+ const message = typeof json.error === "string" ? json.error : fallback;
100
+ console.error(`Error: ${message}`);
101
+ return 1;
102
+ }
103
+
104
+ export interface FamilyRows {
105
+ rows: AccountRow[];
106
+ activeId: string | null;
107
+ autoSwitchThreshold?: number;
108
+ /** HTTP status for a completed family read, including failures. */
109
+ status?: number;
110
+ /** Set when the family endpoint returned an error. */
111
+ errorJson?: Record<string, unknown>;
112
+ networkDown?: boolean;
113
+ }
114
+
115
+ export interface CodexQuotaDto {
116
+ weeklyPercent?: number;
117
+ monthlyPercent?: number;
118
+ weeklyResetAt?: number;
119
+ monthlyResetAt?: number;
120
+ }
121
+
122
+ export interface ProviderQuotaWindowDto {
123
+ label: string;
124
+ percent: number;
125
+ resetAt?: number;
126
+ }
127
+
128
+ export interface ProviderQuotaDto extends CodexQuotaDto {
129
+ fiveHourPercent?: number;
130
+ fiveHourResetAt?: number;
131
+ customWindows?: ProviderQuotaWindowDto[];
132
+ updatedAt?: number;
133
+ }
134
+
135
+ export interface ProviderQuotaReportDto {
136
+ provider: string;
137
+ label?: string;
138
+ source?: string;
139
+ quota: ProviderQuotaDto;
140
+ updatedAt?: number;
141
+ reverseEngineered?: boolean;
142
+ }
143
+
144
+ interface CodexAccountDto {
145
+ id: string;
146
+ email?: string;
147
+ plan?: string;
148
+ isMain?: boolean;
149
+ needsReauth?: boolean;
150
+ quota?: CodexQuotaDto | null;
151
+ }
152
+
153
+ function projectQuota(quota: CodexQuotaDto | null | undefined): CodexQuotaDto | null {
154
+ if (!quota) return null;
155
+ const projected: CodexQuotaDto = {};
156
+ for (const key of ["weeklyPercent", "monthlyPercent", "weeklyResetAt", "monthlyResetAt"] as const) {
157
+ if (typeof quota[key] === "number" && Number.isFinite(quota[key])) projected[key] = quota[key];
158
+ }
159
+ return projected;
160
+ }
161
+
162
+ export async function fetchCodexRows(
163
+ deps: AccountDeps,
164
+ baseUrl: string,
165
+ forceRefresh = false,
166
+ ): Promise<FamilyRows> {
167
+ const accountsPath = `/api/codex-auth/accounts${forceRefresh ? "?refresh=1" : ""}`;
168
+ const [accountsRes, activeRes] = await Promise.all([
169
+ apiJson(deps, baseUrl, "GET", accountsPath),
170
+ apiJson(deps, baseUrl, "GET", "/api/codex-auth/active"),
171
+ ]);
172
+ if (accountsRes.status !== 0 && accountsRes.status !== 200) {
173
+ return { rows: [], activeId: null, status: accountsRes.status, errorJson: accountsRes.json };
174
+ }
175
+ if (activeRes.status !== 0 && activeRes.status !== 200) {
176
+ return { rows: [], activeId: null, status: activeRes.status, errorJson: activeRes.json };
177
+ }
178
+ if (accountsRes.status === 0 || activeRes.status === 0) {
179
+ return { rows: [], activeId: null, status: 0, networkDown: true };
180
+ }
181
+ const activeId = typeof activeRes.json.activeCodexAccountId === "string"
182
+ ? activeRes.json.activeCodexAccountId
183
+ : null;
184
+ const autoSwitchThreshold = typeof activeRes.json.autoSwitchThreshold === "number"
185
+ ? activeRes.json.autoSwitchThreshold
186
+ : undefined;
187
+ const accounts = Array.isArray(accountsRes.json.accounts) ? accountsRes.json.accounts as CodexAccountDto[] : [];
188
+ const rows = accounts.map(a => ({
189
+ provider: "openai",
190
+ type: "codex" as const,
191
+ id: a.id,
192
+ label: a.plan ?? a.email,
193
+ email: a.email,
194
+ plan: a.plan,
195
+ active: a.id === activeId,
196
+ needsReauth: a.needsReauth,
197
+ ...(forceRefresh ? { quota: projectQuota(a.quota) } : {}),
198
+ }));
199
+ return { rows, activeId, autoSwitchThreshold, status: 200 };
200
+ }
201
+
202
+ interface OAuthAccountDto {
203
+ id: string;
204
+ email?: string;
205
+ active?: boolean;
206
+ needsReauth?: boolean;
207
+ }
208
+
209
+ async function fetchOAuthRows(deps: AccountDeps, baseUrl: string, name: string): Promise<FamilyRows> {
210
+ const res = await apiJson(deps, baseUrl, "GET", `/api/oauth/accounts?provider=${encodeURIComponent(name)}`);
211
+ if (res.status === 0) return { rows: [], activeId: null, status: 0, networkDown: true };
212
+ if (res.status !== 200) return { rows: [], activeId: null, status: res.status, errorJson: res.json };
213
+ const activeId = typeof res.json.activeAccountId === "string" ? res.json.activeAccountId : null;
214
+ const accounts = Array.isArray(res.json.accounts) ? res.json.accounts as OAuthAccountDto[] : [];
215
+ const rows = accounts.map((a, i) => ({
216
+ provider: name,
217
+ type: "oauth" as const,
218
+ id: a.id,
219
+ label: a.email ?? `Account ${i + 1}`,
220
+ email: a.email,
221
+ active: a.active ?? a.id === activeId,
222
+ needsReauth: a.needsReauth,
223
+ }));
224
+ return { rows, activeId, status: 200 };
225
+ }
226
+
227
+ interface ApiKeyDto {
228
+ id: string;
229
+ label?: string;
230
+ masked?: string;
231
+ active?: boolean;
232
+ }
233
+
234
+ async function fetchKeyRows(deps: AccountDeps, baseUrl: string, name: string): Promise<FamilyRows> {
235
+ const res = await apiJson(deps, baseUrl, "GET", `/api/providers/keys?name=${encodeURIComponent(name)}`);
236
+ if (res.status === 0) return { rows: [], activeId: null, status: 0, networkDown: true };
237
+ if (res.status !== 200) return { rows: [], activeId: null, status: res.status, errorJson: res.json };
238
+ const activeId = typeof res.json.activeId === "string" ? res.json.activeId : null;
239
+ const keys = Array.isArray(res.json.keys) ? res.json.keys as ApiKeyDto[] : [];
240
+ const rows = keys.map(k => ({
241
+ provider: name,
242
+ type: "api-key" as const,
243
+ id: k.id,
244
+ label: k.label ?? k.masked,
245
+ masked: k.masked,
246
+ active: k.active ?? k.id === activeId,
247
+ }));
248
+ return { rows, activeId, status: 200 };
249
+ }
250
+
251
+ export function fetchRows(deps: AccountDeps, baseUrl: string, name: string, type: AccountType): Promise<FamilyRows> {
252
+ if (type === "codex") return fetchCodexRows(deps, baseUrl);
253
+ if (type === "oauth") return fetchOAuthRows(deps, baseUrl, name);
254
+ return fetchKeyRows(deps, baseUrl, name);
255
+ }
256
+
257
+ export async function fetchProviderQuotaReport(
258
+ deps: AccountDeps,
259
+ baseUrl: string,
260
+ name: string,
261
+ ): Promise<{ status: number; report: ProviderQuotaReportDto | null; errorJson?: Record<string, unknown> }> {
262
+ const res = await apiJson(deps, baseUrl, "GET", "/api/provider-quotas?refresh=1");
263
+ if (res.status !== 200) return { status: res.status, report: null, errorJson: res.json };
264
+ const reports = Array.isArray(res.json.reports) ? res.json.reports as ProviderQuotaReportDto[] : [];
265
+ return { status: 200, report: reports.find(report => report?.provider === name) ?? null };
266
+ }