@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.
- package/README.ko.md +1 -0
- package/README.md +1 -0
- package/README.zh-CN.md +1 -0
- package/bin/ocx.mjs +41 -3
- package/gui/dist/assets/index-B-UauL1p.css +1 -0
- package/gui/dist/assets/index-DhnOK9c8.js +40 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/adapters/cursor/cursor-errors.ts +7 -2
- package/src/adapters/cursor/request-builder.ts +90 -6
- package/src/adapters/cursor/tool-definitions.ts +23 -6
- package/src/cli/account-api.ts +266 -0
- package/src/cli/account-extended.ts +282 -0
- package/src/cli/account.ts +265 -0
- package/src/cli/claude.ts +3 -1
- package/src/cli/help.ts +15 -0
- package/src/cli/index.ts +58 -17
- package/src/cli/init.ts +11 -1
- package/src/codex/catalog.ts +6 -1
- package/src/lib/errors.ts +4 -0
- package/src/lib/winsw.ts +14 -3
- package/src/oauth/login-cli.ts +15 -4
- package/src/providers/base-url-choices.ts +15 -0
- package/src/providers/registry.ts +113 -2
- package/src/responses/parser.ts +11 -6
- package/src/router.ts +6 -1
- package/src/server/index.ts +5 -0
- package/src/server/ports.ts +20 -0
- package/src/server/request-log.ts +98 -0
- package/src/service.ts +24 -4
- package/src/storage/scanner.ts +18 -6
- package/src/types.ts +2 -0
- package/src/update/index.ts +35 -8
- package/src/update/job.ts +34 -7
- package/src/usage/log.ts +98 -1
- package/gui/dist/assets/index-DyBPh28A.css +0 -1
- package/gui/dist/assets/index-TZysP4q4.js +0 -40
package/gui/dist/index.html
CHANGED
|
@@ -16,8 +16,8 @@
|
|
|
16
16
|
} catch (e) {}
|
|
17
17
|
})();
|
|
18
18
|
</script>
|
|
19
|
-
<script type="module" crossorigin src="/assets/index-
|
|
20
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
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.
|
|
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
|
|
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
|
-
...(
|
|
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 &&
|
|
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 =>
|
|
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
|
|
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 =>
|
|
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 =>
|
|
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
|
+
}
|