@bitkyc08/opencodex 2.40.0 → 2.42.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -0
- package/gui/dist/assets/index-BU1tE0sr.js +112 -0
- package/gui/dist/assets/index-DL9-iS6J.css +1 -0
- package/gui/dist/index.html +2 -2
- package/gui/dist/provider-icons/meta.svg +1 -0
- package/package.json +4 -3
- package/src/adapters/cursor/catalog.ts +71 -29
- package/src/adapters/cursor/claude-id.ts +76 -0
- package/src/adapters/cursor/discovery.ts +16 -3
- package/src/adapters/cursor/effort-map.ts +27 -12
- package/src/adapters/cursor/protobuf-request.ts +41 -21
- package/src/adapters/google.ts +39 -2
- package/src/adapters/identity.ts +8 -2
- package/src/adapters/openai-responses.ts +57 -4
- package/src/bridge.ts +25 -3
- package/src/cli/account-auth.ts +28 -3
- package/src/cli/account-extended.ts +7 -1
- package/src/cli/capabilities.ts +2 -2
- package/src/cli/claude.ts +11 -2
- package/src/cli/connect.ts +7 -1
- package/src/cli/observe.ts +3 -1
- package/src/cli/registry.ts +1 -1
- package/src/cli/status.ts +19 -4
- package/src/client/connect.ts +5 -1
- package/src/client/hub-client.ts +29 -5
- package/src/clients/config-export.ts +12 -2
- package/src/codex/auth-api.ts +102 -9
- package/src/codex/catalog/aggregation.ts +8 -0
- package/src/codex/catalog/effort.ts +15 -2
- package/src/codex/catalog/metadata.ts +119 -9
- package/src/codex/catalog/native-models.ts +71 -0
- package/src/codex/catalog/parsing.ts +5 -3
- package/src/codex/catalog/provider-fetch.ts +166 -28
- package/src/codex/catalog.ts +1 -1
- package/src/codex/convergence-types.ts +1 -0
- package/src/codex/data/upstream-models.json +169 -0
- package/src/codex/desired-state.ts +18 -11
- package/src/codex/inject.ts +96 -6
- package/src/codex/injected-marker.ts +30 -4
- package/src/codex/journal.ts +14 -0
- package/src/combos/failover.ts +185 -6
- package/src/combos/index.ts +6 -0
- package/src/combos/resolve.ts +43 -6
- package/src/config.ts +5 -1
- package/src/generated/compatibility-version.json +115 -83
- package/src/generated/model-metadata.ts +1 -1
- package/src/grok/sync.ts +10 -2
- package/src/integrations/cursor-effort-table.ts +143 -0
- package/src/integrations/state.ts +1 -1
- package/src/integrations/writer.ts +2 -2
- package/src/lib/app-owned-memory-stores.ts +27 -8
- package/src/lib/bounded-body.ts +16 -1
- package/src/oauth/account-quota-rank.ts +40 -1
- package/src/oauth/chatgpt-device.ts +187 -0
- package/src/oauth/chatgpt.ts +31 -4
- package/src/oauth/generic-account-failover.ts +2 -2
- package/src/oauth/index.ts +24 -3
- package/src/oauth/log.ts +3 -0
- package/src/oauth/meta-muse.ts +235 -0
- package/src/providers/antigravity-models.ts +71 -13
- package/src/providers/command-code-efforts.ts +15 -0
- package/src/providers/free-directory.ts +4 -1
- package/src/providers/muse-subscription-usage.ts +95 -0
- package/src/providers/quota.ts +96 -0
- package/src/providers/registry.ts +116 -8
- package/src/responses/code-mode-helper-compat.ts +4 -1
- package/src/responses/state.ts +5 -4
- package/src/server/auth-cors.ts +241 -56
- package/src/server/chat-completions.ts +11 -2
- package/src/server/chat-native.ts +30 -4
- package/src/server/claude-messages.ts +17 -3
- package/src/server/effort-row.ts +131 -0
- package/src/server/index.ts +82 -45
- package/src/server/live.ts +18 -4
- package/src/server/management/api-key-rotation.ts +2 -1
- package/src/server/management/api-key-usage.ts +97 -43
- package/src/server/management/context.ts +3 -0
- package/src/server/management/cursor-integration-routes.ts +36 -7
- package/src/server/management/logs-usage-routes.ts +64 -87
- package/src/server/management/oauth-account-routes.ts +10 -3
- package/src/server/management/provider-routes.ts +218 -1
- package/src/server/management/route-registry.ts +1 -0
- package/src/server/management/usage-aggregate-cache.ts +464 -0
- package/src/server/management/usage-summary-cache.ts +4 -0
- package/src/server/models-capabilities.ts +60 -5
- package/src/server/responses/core.ts +95 -7
- package/src/server/responses/empty-completion-guard.ts +4 -0
- package/src/types/config.ts +10 -1
- package/src/types/request.ts +8 -0
- package/src/types/tools.ts +12 -9
- package/src/usage/expected-prices.ts +43 -7
- package/src/usage/ledger-scanner.ts +448 -0
- package/src/usage/log.ts +1 -1
- package/src/usage/summary.ts +915 -655
- package/src/web-search/index.ts +1 -1
- package/gui/dist/assets/index-BHe2rl_C.js +0 -112
- package/gui/dist/assets/index-CJSb3HPe.css +0 -1
|
@@ -1965,6 +1965,18 @@ export function stripOpenAiOnlyWebSearchFields(body: unknown): unknown {
|
|
|
1965
1965
|
return changed ? next : body;
|
|
1966
1966
|
}
|
|
1967
1967
|
|
|
1968
|
+
/**
|
|
1969
|
+
* Muse Spark ids whose Responses gateway refuses `search_content_types` on a plain
|
|
1970
|
+
* `web_search` tool. Membership, not equality: 1.3 shipped 2026-09-02 as the
|
|
1971
|
+
* same-shaped successor to 1.2 on the same Zen wire, and an equality check would
|
|
1972
|
+
* have let a Codex-emitted `web_search` + `search_content_types` body reach the
|
|
1973
|
+
* gateway and come back 400 for every request the moment 1.3 was selected.
|
|
1974
|
+
*/
|
|
1975
|
+
const MUSE_SPARK_WEB_SEARCH_STRICT_MODELS = new Set([
|
|
1976
|
+
"muse-spark-1.3-contributor",
|
|
1977
|
+
"muse-spark-1.2-contributor",
|
|
1978
|
+
]);
|
|
1979
|
+
|
|
1968
1980
|
/**
|
|
1969
1981
|
* OpenCode Zen / Go Muse Spark Responses gateway refuses `search_content_types`
|
|
1970
1982
|
* on a plain `web_search` tool (400) but accepts it on `web_search_preview`; a
|
|
@@ -1976,7 +1988,8 @@ export function stripOpenAiOnlyWebSearchFields(body: unknown): unknown {
|
|
|
1976
1988
|
*/
|
|
1977
1989
|
function stripMuseSparkUnsupportedWebSearchFields(body: unknown, modelId: unknown): unknown {
|
|
1978
1990
|
if (!isPlainObject(body)) return body;
|
|
1979
|
-
if (typeof modelId !== "string"
|
|
1991
|
+
if (typeof modelId !== "string") return body;
|
|
1992
|
+
if (!MUSE_SPARK_WEB_SEARCH_STRICT_MODELS.has(modelId.trim().toLowerCase())) return body;
|
|
1980
1993
|
|
|
1981
1994
|
const rewriteTools = (tools: unknown[]): { tools: unknown[]; changed: boolean } => {
|
|
1982
1995
|
let changed = false;
|
|
@@ -2062,11 +2075,26 @@ function usageFromResponsesPayload(payload: unknown): OcxUsage | undefined {
|
|
|
2062
2075
|
const usage = payload.usage;
|
|
2063
2076
|
const inputTokens = typeof usage.input_tokens === "number" ? usage.input_tokens : 0;
|
|
2064
2077
|
const outputTokens = typeof usage.output_tokens === "number" ? usage.output_tokens : 0;
|
|
2065
|
-
|
|
2078
|
+
// openai/codex#41980: the raw usage object is wire data a rebuilt response.completed must keep —
|
|
2079
|
+
// unknown keys (subscription metadata, future counters) ride along even when the token counts
|
|
2080
|
+
// themselves are zero or absent (metadata-only usage).
|
|
2081
|
+
const knownKeys = new Set(["input_tokens", "output_tokens", "total_tokens", "input_tokens_details", "output_tokens_details"]);
|
|
2082
|
+
const hasExtras = Object.keys(usage).some(key => !knownKeys.has(key))
|
|
2083
|
+
|| (isPlainObject(usage.input_tokens_details)
|
|
2084
|
+
&& Object.keys(usage.input_tokens_details).some(key => key !== "cached_tokens" && key !== "cache_write_tokens"))
|
|
2085
|
+
|| (isPlainObject(usage.output_tokens_details)
|
|
2086
|
+
&& Object.keys(usage.output_tokens_details).some(key => key !== "reasoning_tokens"));
|
|
2087
|
+
if (inputTokens === 0 && outputTokens === 0 && !hasExtras) return undefined;
|
|
2088
|
+
const inputDetails = isPlainObject(usage.input_tokens_details) ? usage.input_tokens_details : undefined;
|
|
2089
|
+
const outputDetails = isPlainObject(usage.output_tokens_details) ? usage.output_tokens_details : undefined;
|
|
2066
2090
|
return {
|
|
2067
2091
|
inputTokens,
|
|
2068
2092
|
outputTokens,
|
|
2069
2093
|
...(typeof usage.total_tokens === "number" ? { totalTokens: usage.total_tokens } : {}),
|
|
2094
|
+
...(typeof inputDetails?.cached_tokens === "number" ? { cachedInputTokens: inputDetails.cached_tokens } : {}),
|
|
2095
|
+
...(typeof inputDetails?.cache_write_tokens === "number" ? { cacheCreationInputTokens: inputDetails.cache_write_tokens } : {}),
|
|
2096
|
+
...(typeof outputDetails?.reasoning_tokens === "number" ? { reasoningOutputTokens: outputDetails.reasoning_tokens } : {}),
|
|
2097
|
+
...(hasExtras ? { rawUsage: { ...usage } } : {}),
|
|
2070
2098
|
};
|
|
2071
2099
|
}
|
|
2072
2100
|
|
|
@@ -2378,7 +2406,26 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
|
|
|
2378
2406
|
reservation.commitRetained();
|
|
2379
2407
|
budget.releaseRetained(previousBytes, { kind: "retained_collectors" });
|
|
2380
2408
|
}
|
|
2381
|
-
|
|
2409
|
+
{
|
|
2410
|
+
const nextUsage = usageFromResponsesPayload(payload.response);
|
|
2411
|
+
// The attached raw usage object can be event-sized (unknown keys carry arbitrary
|
|
2412
|
+
// values); it stays reachable until the terminal yields, so charge it like the
|
|
2413
|
+
// adjacent retained collectors or it would defeat the per-request memory cap.
|
|
2414
|
+
const previousRawBytes = usage?.rawUsage === undefined ? 0
|
|
2415
|
+
: budgetEncoder.encode(JSON.stringify(usage.rawUsage)).byteLength;
|
|
2416
|
+
const nextRawBytes = nextUsage?.rawUsage === undefined ? 0
|
|
2417
|
+
: budgetEncoder.encode(JSON.stringify(nextUsage.rawUsage)).byteLength;
|
|
2418
|
+
if (nextRawBytes > 0) {
|
|
2419
|
+
const reservation = budget.reserveTransient(nextRawBytes, { kind: "retained_collectors" });
|
|
2420
|
+
usage = nextUsage;
|
|
2421
|
+
reservation.commitRetained();
|
|
2422
|
+
} else {
|
|
2423
|
+
usage = nextUsage;
|
|
2424
|
+
}
|
|
2425
|
+
if (previousRawBytes > 0) {
|
|
2426
|
+
budget.releaseRetained(previousRawBytes, { kind: "retained_collectors" });
|
|
2427
|
+
}
|
|
2428
|
+
}
|
|
2382
2429
|
break;
|
|
2383
2430
|
}
|
|
2384
2431
|
}
|
|
@@ -2386,7 +2433,13 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
|
|
|
2386
2433
|
// completed snapshot so text is never double-counted.
|
|
2387
2434
|
const text = snapshot || doneText || deltas;
|
|
2388
2435
|
if (text) yield { type: "text_delta", text };
|
|
2389
|
-
budget.releaseRetained(
|
|
2436
|
+
budget.releaseRetained(
|
|
2437
|
+
budgetEncoder.encode(deltas).byteLength
|
|
2438
|
+
+ budgetEncoder.encode(doneText).byteLength
|
|
2439
|
+
+ budgetEncoder.encode(snapshot).byteLength
|
|
2440
|
+
+ (usage?.rawUsage === undefined ? 0 : budgetEncoder.encode(JSON.stringify(usage.rawUsage)).byteLength),
|
|
2441
|
+
{ kind: "retained_collectors" },
|
|
2442
|
+
);
|
|
2390
2443
|
yield {
|
|
2391
2444
|
type: "done",
|
|
2392
2445
|
...(usage ? { usage } : {}),
|
package/src/bridge.ts
CHANGED
|
@@ -59,6 +59,10 @@ function sseEvent(name: string, data: Record<string, unknown>): string {
|
|
|
59
59
|
return `event: ${name}\ndata: ${JSON.stringify(data)}\n\n`;
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
63
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
64
|
+
}
|
|
65
|
+
|
|
62
66
|
function responsesUsage(usage: OcxUsage | undefined): Record<string, unknown> {
|
|
63
67
|
// input_tokens_details / output_tokens_details are ALWAYS emitted (zero defaults):
|
|
64
68
|
// strict Responses clients deserialize them as required fields — grok-build's pinned
|
|
@@ -80,7 +84,24 @@ function responsesUsage(usage: OcxUsage | undefined): Record<string, unknown> {
|
|
|
80
84
|
const inputTokens = usage.contextTotalTokens !== undefined
|
|
81
85
|
? Math.max(0, usage.contextTotalTokens - usage.outputTokens)
|
|
82
86
|
: usage.inputTokens;
|
|
87
|
+
// openai/codex#41980 parity: unknown upstream usage fields (subscription metadata, future
|
|
88
|
+
// counters) pass through the rebuild. Normalized values stay authoritative for the known
|
|
89
|
+
// keys (they are derived from the same raw values, so this never disagrees with upstream).
|
|
90
|
+
const raw: Record<string, unknown> = usage.rawUsage ?? {};
|
|
91
|
+
// cache_write_tokens is a KNOWN key: it is emitted only from the validated normalized
|
|
92
|
+
// value below, never copied through raw (an unknown-shaped value must not leak into the
|
|
93
|
+
// normalized contract).
|
|
94
|
+
const rawInputDetails = isRecord(raw.input_tokens_details)
|
|
95
|
+
? Object.fromEntries(Object.entries(raw.input_tokens_details as Record<string, unknown>)
|
|
96
|
+
.filter(([key]) => key !== "cache_write_tokens"))
|
|
97
|
+
: {} as Record<string, unknown>;
|
|
98
|
+
const rawOutputDetails = isRecord(raw.output_tokens_details)
|
|
99
|
+
? raw.output_tokens_details as Record<string, unknown>
|
|
100
|
+
: {} as Record<string, unknown>;
|
|
83
101
|
const out: Record<string, unknown> = {
|
|
102
|
+
...Object.fromEntries(Object.entries(raw).filter(([key]) =>
|
|
103
|
+
key !== "input_tokens" && key !== "output_tokens" && key !== "total_tokens"
|
|
104
|
+
&& key !== "input_tokens_details" && key !== "output_tokens_details")),
|
|
84
105
|
input_tokens: inputTokens,
|
|
85
106
|
output_tokens: usage.outputTokens,
|
|
86
107
|
total_tokens: usage.contextTotalTokens !== undefined
|
|
@@ -90,18 +111,19 @@ function responsesUsage(usage: OcxUsage | undefined): Record<string, unknown> {
|
|
|
90
111
|
// cached_tokens carries cache READS only, matching OpenAI semantics, and is always present
|
|
91
112
|
// (zero default) for strict clients. Clamp to inputTokens so a provider's absolute
|
|
92
113
|
// checkpoint can never report more cache reads than input.
|
|
93
|
-
const inputDetails: Record<string,
|
|
114
|
+
const inputDetails: Record<string, unknown> = {
|
|
115
|
+
...rawInputDetails,
|
|
94
116
|
cached_tokens: Math.min(usage.cachedInputTokens ?? 0, inputTokens),
|
|
95
117
|
};
|
|
96
118
|
if (usage.cacheCreationInputTokens !== undefined) {
|
|
97
|
-
const cacheRead = inputDetails.cached_tokens
|
|
119
|
+
const cacheRead = typeof inputDetails.cached_tokens === "number" ? inputDetails.cached_tokens : 0;
|
|
98
120
|
inputDetails.cache_write_tokens = Math.min(
|
|
99
121
|
usage.cacheCreationInputTokens,
|
|
100
122
|
Math.max(0, inputTokens - cacheRead),
|
|
101
123
|
);
|
|
102
124
|
}
|
|
103
125
|
out.input_tokens_details = inputDetails;
|
|
104
|
-
out.output_tokens_details = { reasoning_tokens: usage.reasoningOutputTokens ?? 0 };
|
|
126
|
+
out.output_tokens_details = { ...rawOutputDetails, reasoning_tokens: usage.reasoningOutputTokens ?? 0 };
|
|
105
127
|
return out;
|
|
106
128
|
}
|
|
107
129
|
|
package/src/cli/account-auth.ts
CHANGED
|
@@ -31,11 +31,16 @@ function writeStdoutFully(text: string): void {
|
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
const USAGE = `Usage:
|
|
34
|
-
ocx account login <provider> [--id <account-id>] [--reauth] [--code -] [--no-wait] [--json]
|
|
34
|
+
ocx account login <provider> [--id <account-id>] [--reauth] [--device] [--code -] [--no-wait] [--json]
|
|
35
35
|
ocx account code <provider> [--flow <flow-id>] [--json] (reads the code from stdin)
|
|
36
36
|
ocx account cancel <provider> [--flow <flow-id>] [--json]
|
|
37
37
|
ocx account reset-credits <account-id|main> [--consume --yes] [--json]
|
|
38
38
|
|
|
39
|
+
--device runs the OpenAI device-code login instead of the browser callback: use
|
|
40
|
+
it when the proxy has no browser or nothing can reach localhost:1455, such as a
|
|
41
|
+
headless or remote hub. Enter the printed code at the printed URL from any other
|
|
42
|
+
machine.
|
|
43
|
+
|
|
39
44
|
The redirect URL or authorization code is a short-lived credential. Pipe it in
|
|
40
45
|
rather than passing it as an argument, where it lands in shell history and is
|
|
41
46
|
visible to anyone who can run ps:
|
|
@@ -54,6 +59,9 @@ interface LoginStart {
|
|
|
54
59
|
/** `-` means "read it from stdin", the documented way to pass a code silently. */
|
|
55
60
|
const STDIN_SENTINEL = "-";
|
|
56
61
|
|
|
62
|
+
/** Providers whose ONLY login is already a device flow; --device is redundant, not wrong. */
|
|
63
|
+
const DEVICE_NATIVE_PROVIDERS = new Set(["kimi", "nous", "github-copilot"]);
|
|
64
|
+
|
|
57
65
|
const ARGV_WARNING =
|
|
58
66
|
"warning: the authorization code was passed as a command-line argument, so it is now in your shell history and was visible in the process list while this ran. Pipe it on stdin instead, or pass `-` to read from stdin.";
|
|
59
67
|
|
|
@@ -86,10 +94,17 @@ async function login(argv: string[], deps: RuntimeApiDeps): Promise<void> {
|
|
|
86
94
|
const wantsJson = takeFlag(args, "--json");
|
|
87
95
|
const noWait = takeFlag(args, "--no-wait");
|
|
88
96
|
const reauth = takeFlag(args, "--reauth");
|
|
97
|
+
const device = takeFlag(args, "--device");
|
|
89
98
|
const id = takeOption(args, "--id");
|
|
90
99
|
const suppliedCode = takeOptionWithSyntax(args, "--code");
|
|
91
100
|
if (!provider) throw new CliUsageError("provider is required", USAGE);
|
|
92
101
|
rejectArgs(args, USAGE);
|
|
102
|
+
// kimi, nous, and github-copilot are already device flows, so --device is a
|
|
103
|
+
// true statement about them and is accepted as a no-op rather than an error.
|
|
104
|
+
// Anything else has no device grant at all and must fail loudly.
|
|
105
|
+
if (device && !CODEX_NAMES.has(provider) && !DEVICE_NATIVE_PROVIDERS.has(provider)) {
|
|
106
|
+
throw new CliUsageError(`--device is not supported for provider '${provider}'`, USAGE);
|
|
107
|
+
}
|
|
93
108
|
// Only resolve when --code was actually given: a plain `ocx account login`
|
|
94
109
|
// opens the browser flow and polls, and must not block on stdin.
|
|
95
110
|
const code = await resolveCode(suppliedCode, deps, false);
|
|
@@ -97,13 +112,18 @@ async function login(argv: string[], deps: RuntimeApiDeps): Promise<void> {
|
|
|
97
112
|
if (CODEX_NAMES.has(provider)) {
|
|
98
113
|
const start = await runtimeRequest<LoginStart>("/api/codex-auth/login", {
|
|
99
114
|
method: "POST",
|
|
100
|
-
body: JSON.stringify({
|
|
115
|
+
body: JSON.stringify({
|
|
116
|
+
...(id ? { id } : {}),
|
|
117
|
+
...(reauth ? { reauth: true } : {}),
|
|
118
|
+
...(device ? { device: true } : {}),
|
|
119
|
+
}),
|
|
101
120
|
}, deps);
|
|
102
121
|
if (!wantsJson) {
|
|
103
122
|
// One atomic pre-poll block, flushed synchronously so a piped parent
|
|
104
123
|
// reads the URL before the polling window starts (#1007).
|
|
105
124
|
const block = [
|
|
106
125
|
start.url ? `Open this URL to sign in:\n${start.url}` : "",
|
|
126
|
+
start.deviceCode ? `Device code: ${start.deviceCode}` : "",
|
|
107
127
|
start.instructions ?? "",
|
|
108
128
|
start.flowId ? `Flow: ${start.flowId}` : "",
|
|
109
129
|
].filter(line => line !== "").join("\n");
|
|
@@ -120,7 +140,12 @@ async function login(argv: string[], deps: RuntimeApiDeps): Promise<void> {
|
|
|
120
140
|
return;
|
|
121
141
|
}
|
|
122
142
|
if (!start.flowId) throw new CliUsageError("login did not return a flow id");
|
|
123
|
-
|
|
143
|
+
// A device login is deliberately slow: the user leaves this machine to
|
|
144
|
+
// enter the code elsewhere. Match the 15-minute grant instead of giving up
|
|
145
|
+
// at minute five while it is still valid, plus settlement margin for the
|
|
146
|
+
// token exchange and credential write after the final poll.
|
|
147
|
+
const maxAttempts = device ? 480 : 150;
|
|
148
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
124
149
|
await Bun.sleep(2_000);
|
|
125
150
|
const state = await runtimeRequest<Record<string, unknown>>(
|
|
126
151
|
`/api/codex-auth/login-status?flowId=${encodeURIComponent(start.flowId)}${id ? `&accountId=${encodeURIComponent(id)}` : ""}${reauth ? "&reauth=1" : ""}`,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { loadConfig } from "../config";
|
|
2
|
+
import { hasPassiveAccountQuota } from "../providers/quota";
|
|
2
3
|
import { closeSync, openSync, readSync } from "node:fs";
|
|
3
4
|
import {
|
|
4
5
|
MAX_ACCOUNT_PRIORITY,
|
|
@@ -330,7 +331,12 @@ export async function cmdRefresh(args: string[], deps: AccountDeps): Promise<num
|
|
|
330
331
|
if (result.status === 0) return proxyUnreachable(result.transportError);
|
|
331
332
|
if (result.status !== 200) return apiError(result.errorJson ?? {}, `failed to refresh ${name}`, result.status);
|
|
332
333
|
if (wantsJson) console.log(JSON.stringify({ provider: name, report: result.report }, null, 2));
|
|
333
|
-
else
|
|
334
|
+
else if (result.report) console.log(providerQuotaLine(name, result.report));
|
|
335
|
+
// A passive provider has no probe to run, so "no report available" reads as a
|
|
336
|
+
// failure of something that was never attempted. Say what is actually true.
|
|
337
|
+
else if (hasPassiveAccountQuota(name)) {
|
|
338
|
+
console.log(`${name} reports usage only during a streaming response; there is nothing to refresh. Run a request through this provider to update it, then see \`ocx account list ${name}\`.`);
|
|
339
|
+
} else console.log(`no quota report available for ${name}`);
|
|
334
340
|
return 0;
|
|
335
341
|
}
|
|
336
342
|
const result = await fetchCodexRows(deps, baseUrl, true);
|
package/src/cli/capabilities.ts
CHANGED
|
@@ -247,7 +247,7 @@ export const CAPABILITIES: readonly Capability[] = [
|
|
|
247
247
|
"A bare invocation reads and never writes.",
|
|
248
248
|
"The APPLIED value is echoed, not the requested one, so a server-side normalization stays visible.",
|
|
249
249
|
"Values are not re-validated in the CLI: the server owns the strategy names and the 1-100 sticky bound.",
|
|
250
|
-
"`anthropic`
|
|
250
|
+
"`anthropic` owns the full pool contract. Other OAuth providers reach the same endpoint with a generic subset (enabled/strategy/autoSwitchThreshold) whose settings persist but do not yet steer selection; `sticky` and `quotaWindow` are refused for them.",
|
|
251
251
|
],
|
|
252
252
|
},
|
|
253
253
|
{
|
|
@@ -274,7 +274,7 @@ export const CAPABILITIES: readonly Capability[] = [
|
|
|
274
274
|
{ name: "--conversation", value: "string", summary: "Restrict to one conversation id (`--conversationId` is accepted too)." },
|
|
275
275
|
{ name: "--status", value: "string", summary: "An exact code (429) or a class (5xx)." },
|
|
276
276
|
{ name: "--limit", value: "number", summary: "Row cap; defaults to 200." },
|
|
277
|
-
{ name: "--follow", value: "boolean", summary: "
|
|
277
|
+
{ name: "--follow", value: "boolean", summary: "Poll for new rows; add --jsonl to emit JSONL." },
|
|
278
278
|
{ name: "--json", value: "boolean", summary: "Emit the server payload as JSON." },
|
|
279
279
|
{ name: "--jsonl", value: "boolean", summary: "Emit one row per line." },
|
|
280
280
|
],
|
package/src/cli/claude.ts
CHANGED
|
@@ -354,8 +354,17 @@ export function readConnectedClaudeContextWindows(path = DEFAULT_CATALOG_PATH):
|
|
|
354
354
|
}
|
|
355
355
|
}
|
|
356
356
|
|
|
357
|
-
|
|
358
|
-
|
|
357
|
+
export type ClaudeProxyEnsureDeps = {
|
|
358
|
+
findLiveProxy?: typeof findLiveProxy;
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
export async function ensureProxyForClaude(deps: ClaudeProxyEnsureDeps = {}): Promise<number | null> {
|
|
362
|
+
// A proxy that has only just bound can miss a single probe while its event loop
|
|
363
|
+
// is still settling startup work — the same just-started race the stop paths
|
|
364
|
+
// already retry for (#764, SERVICE_STOP_LIVENESS). Only the attempts budget is
|
|
365
|
+
// borrowed here; the probe timeout remains DEFAULT_PROBE_TIMEOUT_MS (750 ms).
|
|
366
|
+
// Without this, `ocx claude` can spawn a second proxy while the first is serving.
|
|
367
|
+
const live = await (deps.findLiveProxy ?? findLiveProxy)({ attempts: 3 });
|
|
359
368
|
if (live) return live.port;
|
|
360
369
|
const cfgPort = loadConfig().port;
|
|
361
370
|
const pinPort = typeof cfgPort === "number" && cfgPort > 0 ? cfgPort : 10100;
|
package/src/cli/connect.ts
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
rejectArgs,
|
|
18
18
|
runCliAction,
|
|
19
19
|
takeFlag,
|
|
20
|
+
takeIntegerOption,
|
|
20
21
|
takeOption,
|
|
21
22
|
type RuntimeApiDeps,
|
|
22
23
|
} from "./runtime-api";
|
|
@@ -25,7 +26,7 @@ export const CONNECT_USAGE = `Usage:
|
|
|
25
26
|
ocx connect <url> [--management-url <url>]
|
|
26
27
|
(--pairing-code-stdin | --admin-token-stdin)
|
|
27
28
|
[--clients codex,claude] [--management-transport direct|relay]
|
|
28
|
-
[--no-sync]
|
|
29
|
+
[--catalog-timeout <seconds>] [--no-sync]
|
|
29
30
|
ocx connect status [--json]
|
|
30
31
|
ocx connect rotate (--pairing-code-stdin | --admin-token-stdin)
|
|
31
32
|
[--json]
|
|
@@ -147,6 +148,10 @@ async function runConnect(argv: string[], deps: RuntimeApiDeps): Promise<void> {
|
|
|
147
148
|
if (!serverUrl || serverUrl.startsWith("--")) throw new CliUsageError("hub URL is required", CONNECT_USAGE);
|
|
148
149
|
const managementUrl = takeOption(args, "--management-url");
|
|
149
150
|
const clients = parseClients(takeOption(args, "--clients"));
|
|
151
|
+
const catalogTimeoutSeconds = takeIntegerOption(args, "--catalog-timeout", { min: 1 });
|
|
152
|
+
if (catalogTimeoutSeconds !== undefined && catalogTimeoutSeconds > 120) {
|
|
153
|
+
throw new CliUsageError("--catalog-timeout must be an integer between 1 and 120", CONNECT_USAGE);
|
|
154
|
+
}
|
|
150
155
|
const managementTransport = takeOption(args, "--management-transport") ?? "direct";
|
|
151
156
|
if (managementTransport !== "direct" && managementTransport !== "relay") {
|
|
152
157
|
throw new CliUsageError("--management-transport must be direct or relay", CONNECT_USAGE);
|
|
@@ -167,6 +172,7 @@ async function runConnect(argv: string[], deps: RuntimeApiDeps): Promise<void> {
|
|
|
167
172
|
selectedClients: clients,
|
|
168
173
|
managementTransport,
|
|
169
174
|
noSync,
|
|
175
|
+
...(catalogTimeoutSeconds === undefined ? {} : { catalogTimeoutMs: catalogTimeoutSeconds * 1_000 }),
|
|
170
176
|
}, { fetchImpl: deps.fetchImpl });
|
|
171
177
|
console.log(`Connected to ${connection.serverUrl} as key ${connection.apiKeyId}.`);
|
|
172
178
|
}
|
package/src/cli/observe.ts
CHANGED
|
@@ -72,7 +72,9 @@ async function logs(argv: string[], deps: RuntimeApiDeps): Promise<void> {
|
|
|
72
72
|
const limit = takeIntegerOption(args, "--limit", { min: 1 }) ?? 200;
|
|
73
73
|
rejectArgs(args, USAGE);
|
|
74
74
|
if (wantsJson && wantsJsonl) throw new CliUsageError("--json and --jsonl cannot be combined", USAGE);
|
|
75
|
-
if (follow && wantsJson)
|
|
75
|
+
if (follow && wantsJson) {
|
|
76
|
+
throw new CliUsageError("--follow cannot be combined with --json; use --jsonl for streaming JSONL", USAGE);
|
|
77
|
+
}
|
|
76
78
|
let seen = new Set<string>();
|
|
77
79
|
do {
|
|
78
80
|
const data = await runtimeRequest(`/api/logs${query({ provider, model, status, conversationId, limit })}`, {}, deps);
|
package/src/cli/registry.ts
CHANGED
|
@@ -88,7 +88,7 @@ export const CLI_COMMANDS: CliCommandEntry[] = [
|
|
|
88
88
|
{ name: "ensure", usage: "ocx ensure", summary: "Ensure the proxy is running and Codex config/cache are current." },
|
|
89
89
|
{
|
|
90
90
|
name: "connect",
|
|
91
|
-
usage: "ocx connect <url> [--management-url <url>] (--pairing-code-stdin | --admin-token-stdin) [--clients codex,claude] [--management-transport direct|relay] [--no-sync]",
|
|
91
|
+
usage: "ocx connect <url> [--management-url <url>] (--pairing-code-stdin | --admin-token-stdin) [--clients codex,claude] [--management-transport direct|relay] [--catalog-timeout <seconds>] [--no-sync]",
|
|
92
92
|
summary: "Connect this machine to a remote OpenCodex hub without persisting the one-time authority.",
|
|
93
93
|
details: [
|
|
94
94
|
"Status: ocx connect status [--json]",
|
package/src/cli/status.ts
CHANGED
|
@@ -120,20 +120,35 @@ export type ListenTarget = {
|
|
|
120
120
|
dashboardUrl: string;
|
|
121
121
|
};
|
|
122
122
|
|
|
123
|
+
type StatusListenConfig = Pick<OcxConfig, "port" | "hostname" | "runtimeRole" | "hub">;
|
|
124
|
+
|
|
125
|
+
function statusDashboardUrl(config: StatusListenConfig, hostname: string | undefined, port: number): string {
|
|
126
|
+
const managementOrigin = config.runtimeRole === "hub" ? config.hub?.managementPublicOrigin : undefined;
|
|
127
|
+
if (managementOrigin) return managementOrigin.endsWith("/") ? managementOrigin : `${managementOrigin}/`;
|
|
128
|
+
|
|
129
|
+
const reachableHostname = probeHostname(hostname);
|
|
130
|
+
const dashboardHostname = reachableHostname === "127.0.0.1"
|
|
131
|
+
|| reachableHostname === "[::1]"
|
|
132
|
+
|| reachableHostname.toLowerCase() === "localhost"
|
|
133
|
+
? "localhost"
|
|
134
|
+
: reachableHostname;
|
|
135
|
+
return `http://${dashboardHostname}:${port}/`;
|
|
136
|
+
}
|
|
137
|
+
|
|
123
138
|
export function selectListenTarget(
|
|
124
|
-
config:
|
|
139
|
+
config: StatusListenConfig,
|
|
125
140
|
pid: number | null,
|
|
126
141
|
runtimePort: RuntimePortState | null,
|
|
127
142
|
): ListenTarget {
|
|
128
143
|
const currentRuntimePort = pid && runtimePort?.pid === pid ? runtimePort : null;
|
|
129
144
|
const port = currentRuntimePort ? currentRuntimePort.port : config.port ?? 10100;
|
|
130
|
-
const hostname = currentRuntimePort
|
|
145
|
+
const hostname = currentRuntimePort?.hostname ?? config.hostname;
|
|
131
146
|
return {
|
|
132
147
|
port,
|
|
133
148
|
hostname,
|
|
134
149
|
source: currentRuntimePort ? "runtime" : "config",
|
|
135
150
|
healthUrl: `http://${probeHostname(hostname)}:${port}/healthz`,
|
|
136
|
-
dashboardUrl:
|
|
151
|
+
dashboardUrl: statusDashboardUrl(config, hostname, port),
|
|
137
152
|
};
|
|
138
153
|
}
|
|
139
154
|
|
|
@@ -341,7 +356,7 @@ export async function collectStatus(): Promise<CliStatusView> {
|
|
|
341
356
|
hostname: live.hostname,
|
|
342
357
|
source: live.source,
|
|
343
358
|
healthUrl: `http://${probeHostname(live.hostname)}:${live.port}/healthz`,
|
|
344
|
-
dashboardUrl:
|
|
359
|
+
dashboardUrl: statusDashboardUrl(config, live.hostname, live.port),
|
|
345
360
|
}
|
|
346
361
|
: selectListenTarget(config, pidFile, pidFile ? readRuntimePort(pidFile) : null);
|
|
347
362
|
// findLiveProxy already identity-probed /healthz; avoid a second fetch that can race.
|
package/src/client/connect.ts
CHANGED
|
@@ -71,6 +71,7 @@ export interface ConnectOptions {
|
|
|
71
71
|
selectedClients: OcxConnectedClientId[];
|
|
72
72
|
managementTransport: "direct" | "relay";
|
|
73
73
|
noSync?: boolean;
|
|
74
|
+
catalogTimeoutMs?: number;
|
|
74
75
|
}
|
|
75
76
|
|
|
76
77
|
export interface ClientConnectDeps {
|
|
@@ -408,7 +409,10 @@ export async function connectClient(
|
|
|
408
409
|
const persisted = writeServiceApiTokenFile(issued.key);
|
|
409
410
|
tokenFingerprint = persisted.fingerprint;
|
|
410
411
|
|
|
411
|
-
const catalog = await downloadClientCatalog(serverUrl, issued.key, {
|
|
412
|
+
const catalog = await downloadClientCatalog(serverUrl, issued.key, {
|
|
413
|
+
fetchImpl: deps.fetchImpl,
|
|
414
|
+
timeoutMs: options.catalogTimeoutMs,
|
|
415
|
+
});
|
|
412
416
|
atomicWriteFile(DEFAULT_CATALOG_PATH, catalog.body);
|
|
413
417
|
writtenCatalogFingerprint = sha256(catalog.body);
|
|
414
418
|
|
package/src/client/hub-client.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { MAX_REMOTE_CATALOG_BYTES } from "../server/catalog-download";
|
|
2
2
|
import { readBoundedResponseBytes } from "../lib/bounded-body";
|
|
3
|
+
import { clearableDeadline } from "../lib/abort";
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* A pairing grant may cross loopback or authenticated HTTPS, and nothing else.
|
|
@@ -84,13 +85,17 @@ async function fetchBounded(
|
|
|
84
85
|
url: string,
|
|
85
86
|
init: RequestInit,
|
|
86
87
|
timeoutMs: number | undefined,
|
|
88
|
+
timeoutScope: "request" | "headers" = "request",
|
|
87
89
|
): Promise<Response> {
|
|
90
|
+
const timeout = safeTimeout(timeoutMs);
|
|
91
|
+
const headerDeadline = timeoutScope === "headers" ? clearableDeadline(timeout) : null;
|
|
88
92
|
try {
|
|
89
93
|
const response = await fetchImpl(url, {
|
|
90
94
|
...init,
|
|
91
95
|
redirect: "manual",
|
|
92
|
-
signal: AbortSignal.timeout(
|
|
96
|
+
signal: headerDeadline?.signal ?? AbortSignal.timeout(timeout),
|
|
93
97
|
});
|
|
98
|
+
headerDeadline?.clear();
|
|
94
99
|
if (response.status >= 300 && response.status < 400 && response.status !== 304) {
|
|
95
100
|
throw new HubClientError("redirect_refused", "Hub request redirect was refused", response.status);
|
|
96
101
|
}
|
|
@@ -98,15 +103,24 @@ async function fetchBounded(
|
|
|
98
103
|
} catch (error) {
|
|
99
104
|
if (error instanceof HubClientError) throw error;
|
|
100
105
|
throw new HubClientError("unreachable", "Hub request did not complete", undefined, { cause: error });
|
|
106
|
+
} finally {
|
|
107
|
+
headerDeadline?.clear();
|
|
101
108
|
}
|
|
102
109
|
}
|
|
103
110
|
|
|
104
|
-
async function boundedText(
|
|
111
|
+
async function boundedText(
|
|
112
|
+
response: Response,
|
|
113
|
+
maxBytes: number,
|
|
114
|
+
options: { inactivityTimeoutMs?: number } = {},
|
|
115
|
+
): Promise<string> {
|
|
105
116
|
const declared = Number(response.headers.get("content-length") ?? "0");
|
|
106
117
|
if (Number.isFinite(declared) && declared > maxBytes) {
|
|
107
118
|
throw new HubClientError("body_too_large", "Hub response exceeded the allowed size", response.status);
|
|
108
119
|
}
|
|
109
|
-
const result = await readBoundedResponseBytes(response, {
|
|
120
|
+
const result = await readBoundedResponseBytes(response, {
|
|
121
|
+
maxBytes,
|
|
122
|
+
...(options.inactivityTimeoutMs === undefined ? {} : { inactivityTimeoutMs: options.inactivityTimeoutMs }),
|
|
123
|
+
});
|
|
110
124
|
if (result.oversized) {
|
|
111
125
|
throw new HubClientError("body_too_large", "Hub response exceeded the allowed size", response.status);
|
|
112
126
|
}
|
|
@@ -422,7 +436,7 @@ export async function downloadClientCatalog(
|
|
|
422
436
|
const response = await fetchBounded(options.fetchImpl ?? fetch, `${origin}/v1/catalog`, {
|
|
423
437
|
method: "GET",
|
|
424
438
|
headers,
|
|
425
|
-
}, options.timeoutMs);
|
|
439
|
+
}, options.timeoutMs, "headers");
|
|
426
440
|
if (response.status === 304) {
|
|
427
441
|
throw new HubClientError("catalog_unexpected_304", "Hub answered 304 to an unconditional catalog request", 304);
|
|
428
442
|
}
|
|
@@ -434,7 +448,17 @@ export async function downloadClientCatalog(
|
|
|
434
448
|
try { await response.body?.cancel(); } catch { /* best effort */ }
|
|
435
449
|
throw new HubClientError("catalog_content_type_invalid", "Hub catalog response was not JSON", response.status);
|
|
436
450
|
}
|
|
437
|
-
|
|
451
|
+
let body: string;
|
|
452
|
+
try {
|
|
453
|
+
body = await boundedText(response, options.maxBytes ?? MAX_REMOTE_CATALOG_BYTES, {
|
|
454
|
+
inactivityTimeoutMs: safeTimeout(options.timeoutMs),
|
|
455
|
+
});
|
|
456
|
+
} catch (error) {
|
|
457
|
+
if (error instanceof DOMException && error.name === "TimeoutError") {
|
|
458
|
+
throw new HubClientError("unreachable", "Hub catalog download stalled", undefined, { cause: error });
|
|
459
|
+
}
|
|
460
|
+
throw error;
|
|
461
|
+
}
|
|
438
462
|
const parsed = parseJson(body, "catalog_invalid");
|
|
439
463
|
validateRemoteCatalog(parsed);
|
|
440
464
|
const keyId = response.headers.get("x-opencodex-key-id")?.trim() || undefined;
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
import { homedir } from "node:os";
|
|
23
23
|
import { existsSync, readFileSync } from "node:fs";
|
|
24
24
|
import { isAbsolute, join, resolve } from "node:path";
|
|
25
|
-
import { shouldInjectApiAuthHeader } from "../codex/inject";
|
|
25
|
+
import { shouldInjectApiAuthHeader, standaloneCodexRoutingTarget } from "../codex/inject";
|
|
26
26
|
import { FORMAT_MEDIA_TYPE, serializeDocument, type ConfigFormat } from "../integrations/serialize";
|
|
27
27
|
import { providerCodexAccountMode } from "../providers/registry";
|
|
28
28
|
import { canonicalizeReasoningEfforts, sanitizeCodexReasoningEfforts } from "../reasoning-effort";
|
|
@@ -301,7 +301,17 @@ export function ompModelsConfigPath(env: OpencodeLaunchEnv = process.env, home:
|
|
|
301
301
|
}
|
|
302
302
|
|
|
303
303
|
/** Compose the OpenAI-compatible proxy base URL from a live probe result. */
|
|
304
|
-
export function opencodeProxyBaseUrl(
|
|
304
|
+
export function opencodeProxyBaseUrl(
|
|
305
|
+
port: number,
|
|
306
|
+
hostname?: string,
|
|
307
|
+
config?: Pick<OcxConfig, "unauthenticatedLoopbackListener">,
|
|
308
|
+
): string {
|
|
309
|
+
if (config?.unauthenticatedLoopbackListener?.enabled) {
|
|
310
|
+
return standaloneCodexRoutingTarget(port, {
|
|
311
|
+
hostname,
|
|
312
|
+
unauthenticatedLoopbackListener: config.unauthenticatedLoopbackListener,
|
|
313
|
+
}).baseUrl;
|
|
314
|
+
}
|
|
305
315
|
return `http://${probeHostname(hostname)}:${port}/v1`;
|
|
306
316
|
}
|
|
307
317
|
|