@bitkyc08/opencodex 2.41.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/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/package.json +1 -1
- package/src/adapters/cursor/protobuf-request.ts +41 -21
- package/src/adapters/identity.ts +8 -2
- package/src/adapters/openai-responses.ts +43 -3
- 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/observe.ts +3 -1
- package/src/codex/auth-api.ts +102 -9
- package/src/codex/catalog/effort.ts +15 -2
- package/src/codex/catalog/metadata.ts +114 -9
- package/src/codex/catalog/native-models.ts +71 -0
- package/src/codex/catalog/parsing.ts +3 -3
- package/src/codex/catalog/provider-fetch.ts +4 -3
- package/src/codex/catalog.ts +1 -1
- package/src/codex/data/upstream-models.json +169 -0
- package/src/codex/inject.ts +96 -6
- package/src/codex/injected-marker.ts +30 -4
- package/src/codex/journal.ts +14 -0
- package/src/generated/compatibility-version.json +40 -32
- 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/index.ts +13 -3
- package/src/oauth/log.ts +3 -0
- package/src/providers/muse-subscription-usage.ts +95 -0
- package/src/providers/quota.ts +96 -0
- package/src/providers/registry.ts +1 -1
- package/src/server/index.ts +15 -7
- package/src/server/live.ts +18 -4
- package/src/server/management/oauth-account-routes.ts +10 -3
- package/src/server/responses/core.ts +34 -0
- package/src/server/responses/empty-completion-guard.ts +4 -0
- package/src/types/request.ts +8 -0
- package/gui/dist/assets/index-B2YjLA-i.css +0 -1
- package/gui/dist/assets/index-aPup8CKb.js +0 -112
package/src/adapters/identity.ts
CHANGED
|
@@ -22,11 +22,17 @@ export const CODEX_GPT5_IDENTITY_LINE = "You are Codex, a coding agent based on
|
|
|
22
22
|
export const CODEX_GPT5_IDENTITY_LINE_AGENT = "You are Codex, an agent based on GPT-5.";
|
|
23
23
|
|
|
24
24
|
/**
|
|
25
|
-
* Known Codex
|
|
25
|
+
* Known Codex identity sentences. Narrow: only "coding agent" / "an agent" + GPT-<major>(.minor)*.
|
|
26
26
|
* Avoid a broad `You are Codex.*` rewrite that could touch unrelated content.
|
|
27
|
+
*
|
|
28
|
+
* The major version is a wildcard because Codex writes the CURRENT generation into this line and
|
|
29
|
+
* bumps it: `gpt-6-astra` (upstream #42607) ships "You are Codex, an agent based on GPT-6.".
|
|
30
|
+
* Pinning `GPT-5` meant a GPT-6-era prompt routed to a third-party provider kept telling that
|
|
31
|
+
* model it was Codex-on-GPT-6 — the exact misattribution this chokepoint exists to remove, silently
|
|
32
|
+
* reintroduced by a version bump.
|
|
27
33
|
*/
|
|
28
34
|
const CODEX_GPT5_IDENTITY_RE =
|
|
29
|
-
/You are Codex, (?:a coding agent|an agent) based on GPT-
|
|
35
|
+
/You are Codex, (?:a coding agent|an agent) based on GPT-[0-9]+(?:\.[0-9]+)*\./g;
|
|
30
36
|
|
|
31
37
|
/** Proxy-neutral replacement: no "opencodex proxy" mention, just the GPT-5/OpenAI disclaimer. */
|
|
32
38
|
export const NEUTRAL_IDENTITY_LINE = "You are a coding agent. Do not claim to be GPT-5 or to be made by OpenAI.";
|
|
@@ -2075,11 +2075,26 @@ function usageFromResponsesPayload(payload: unknown): OcxUsage | undefined {
|
|
|
2075
2075
|
const usage = payload.usage;
|
|
2076
2076
|
const inputTokens = typeof usage.input_tokens === "number" ? usage.input_tokens : 0;
|
|
2077
2077
|
const outputTokens = typeof usage.output_tokens === "number" ? usage.output_tokens : 0;
|
|
2078
|
-
|
|
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;
|
|
2079
2090
|
return {
|
|
2080
2091
|
inputTokens,
|
|
2081
2092
|
outputTokens,
|
|
2082
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 } } : {}),
|
|
2083
2098
|
};
|
|
2084
2099
|
}
|
|
2085
2100
|
|
|
@@ -2391,7 +2406,26 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
|
|
|
2391
2406
|
reservation.commitRetained();
|
|
2392
2407
|
budget.releaseRetained(previousBytes, { kind: "retained_collectors" });
|
|
2393
2408
|
}
|
|
2394
|
-
|
|
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
|
+
}
|
|
2395
2429
|
break;
|
|
2396
2430
|
}
|
|
2397
2431
|
}
|
|
@@ -2399,7 +2433,13 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
|
|
|
2399
2433
|
// completed snapshot so text is never double-counted.
|
|
2400
2434
|
const text = snapshot || doneText || deltas;
|
|
2401
2435
|
if (text) yield { type: "text_delta", text };
|
|
2402
|
-
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
|
+
);
|
|
2403
2443
|
yield {
|
|
2404
2444
|
type: "done",
|
|
2405
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/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/codex/auth-api.ts
CHANGED
|
@@ -274,6 +274,69 @@ function quotaForPlan<T extends Omit<StoredAccountQuota, "updatedAt"> | StoredAc
|
|
|
274
274
|
} as T;
|
|
275
275
|
}
|
|
276
276
|
|
|
277
|
+
/**
|
|
278
|
+
* Last reset-credit count this process parsed for the main account, tagged with the
|
|
279
|
+
* physical ChatGPT account it was read from.
|
|
280
|
+
*
|
|
281
|
+
* It is deliberately memory-only. The quota store is keyed by the stable `__main__`
|
|
282
|
+
* ALIAS, and `~/.codex/auth.json` can be swapped for another account while the proxy is
|
|
283
|
+
* not running — `reconcileMainCodexAccountRuntimeState` only purges alias-keyed state
|
|
284
|
+
* when it observes the id CHANGE, and its first observation after a restart has nothing
|
|
285
|
+
* to compare against. A disk-hydrated `__main__` entry can therefore belong to the
|
|
286
|
+
* previous login, so filling the DTO from it would show one account's tickets on
|
|
287
|
+
* another's card. Pool accounts have no such hole because their store key IS the account
|
|
288
|
+
* id. Binding the value to `requestAccountId` keeps the fill honest: after a restart the
|
|
289
|
+
* badge simply waits for the first usage response that carries the summary.
|
|
290
|
+
*/
|
|
291
|
+
let mainResetCreditsProvenance: { accountId: string; credits: number } | null = null;
|
|
292
|
+
|
|
293
|
+
function rememberMainResetCredits(accountId: string | null, credits: number | undefined): void {
|
|
294
|
+
if (accountId === null || credits === undefined) return;
|
|
295
|
+
mainResetCreditsProvenance = { accountId, credits };
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** Forget the remembered count when the physical main identity is no longer the same. */
|
|
299
|
+
function mainResetCreditsForCurrentIdentity(): number | undefined {
|
|
300
|
+
if (!mainResetCreditsProvenance) return undefined;
|
|
301
|
+
const currentAccountId = getMainChatgptAccountId();
|
|
302
|
+
if (currentAccountId === null) return undefined;
|
|
303
|
+
if (currentAccountId !== mainResetCreditsProvenance.accountId) {
|
|
304
|
+
mainResetCreditsProvenance = null;
|
|
305
|
+
return undefined;
|
|
306
|
+
}
|
|
307
|
+
return mainResetCreditsProvenance.credits;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* The main account is the only account whose DTO quota comes from the raw WHAM parse
|
|
312
|
+
* result instead of the merged store: `poolAccountDto` serializes what
|
|
313
|
+
* `commitPoolQuotaResponse` read back out of `getAccountQuota()`, while the main DTO
|
|
314
|
+
* spreads `mainInfo.quota` directly. `/wham/usage` carries `rate_limit_reset_credits`
|
|
315
|
+
* only intermittently, and the store exists to bridge that gap
|
|
316
|
+
* (`setAccountQuotaFromParsed` carries an existing `resetCredits` forward when the new
|
|
317
|
+
* snapshot omits it), so the main card lost its ticket badge on every response that
|
|
318
|
+
* happened to omit the summary while pool cards kept theirs.
|
|
319
|
+
*
|
|
320
|
+
* Only `resetCredits` is carried, deliberately, and only from an identity-tagged
|
|
321
|
+
* in-process observation rather than the alias-keyed store. The window fields have
|
|
322
|
+
* *clearing* semantics — a monthly-only snapshot must drop a stale weekly value (#382) —
|
|
323
|
+
* so reinstating the whole stored object would resurrect a window the parse meant to
|
|
324
|
+
* clear whenever the store write was refused by generation gating. A freshly parsed value
|
|
325
|
+
* always wins, including `0`: zero is defined, so it never takes the fill branch.
|
|
326
|
+
*/
|
|
327
|
+
function mainQuotaWithCarriedResetCredits(
|
|
328
|
+
parsed: Omit<StoredAccountQuota, "updatedAt">,
|
|
329
|
+
): StoredAccountQuota {
|
|
330
|
+
const carried = parsed.resetCredits === undefined
|
|
331
|
+
? mainResetCreditsForCurrentIdentity()
|
|
332
|
+
: undefined;
|
|
333
|
+
return {
|
|
334
|
+
...parsed,
|
|
335
|
+
...(carried !== undefined ? { resetCredits: carried } : {}),
|
|
336
|
+
updatedAt: getAccountQuota(MAIN_CODEX_ACCOUNT_ID)?.updatedAt ?? Date.now(),
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
|
|
277
340
|
function poolAccountDto(
|
|
278
341
|
account: CodexAccount,
|
|
279
342
|
quotaResult: PoolQuotaResult,
|
|
@@ -836,6 +899,9 @@ async function fetchMainAccountInfoWhileOwned(
|
|
|
836
899
|
const plan = nonEmptyPlan(data.plan_type) ?? nonEmptyPlan(cached?.plan) ?? nonEmptyPlan(getMainAccountPlan());
|
|
837
900
|
const quota = parseUsageQuota({ ...data, ...(plan ? { plan_type: plan } : {}) });
|
|
838
901
|
const freshResetCredits = quota?.resetCredits;
|
|
902
|
+
// Tag the count with the identity it was read from, so a later response that omits the
|
|
903
|
+
// summary can restore the badge without ever crossing an account boundary.
|
|
904
|
+
rememberMainResetCredits(requestAccountId, freshResetCredits);
|
|
839
905
|
const result = {
|
|
840
906
|
email: data.email ?? null,
|
|
841
907
|
plan,
|
|
@@ -1640,10 +1706,7 @@ export async function listCodexAuthAccountsSnapshot(
|
|
|
1640
1706
|
hasCredential: hasMainCredential,
|
|
1641
1707
|
needsReauth: mainNeedsReauth,
|
|
1642
1708
|
quota: mainInfo.quota ? {
|
|
1643
|
-
...quotaForPlan(
|
|
1644
|
-
...mainInfo.quota,
|
|
1645
|
-
updatedAt: getAccountQuota(MAIN_CODEX_ACCOUNT_ID)?.updatedAt ?? Date.now(),
|
|
1646
|
-
}, mainInfo.plan),
|
|
1709
|
+
...quotaForPlan(mainQuotaWithCarriedResetCredits(mainInfo.quota), mainInfo.plan),
|
|
1647
1710
|
} : null,
|
|
1648
1711
|
...oauthAccountHealthFields("codex", MAIN_CODEX_ACCOUNT_ID, mainHealth),
|
|
1649
1712
|
};
|
|
@@ -2167,7 +2230,15 @@ export async function handleCodexAuthAPI(
|
|
|
2167
2230
|
}
|
|
2168
2231
|
|
|
2169
2232
|
if (url.pathname === "/api/codex-auth/login" && req.method === "POST") {
|
|
2170
|
-
const body = (await req.json().catch(() => ({}))) as {
|
|
2233
|
+
const body = (await req.json().catch(() => ({}))) as {
|
|
2234
|
+
id?: string;
|
|
2235
|
+
reauth?: boolean;
|
|
2236
|
+
openBrowser?: unknown;
|
|
2237
|
+
device?: unknown;
|
|
2238
|
+
};
|
|
2239
|
+
// Device mode: no local browser, no loopback listener. The only way to add
|
|
2240
|
+
// an account to a headless hub (#3366).
|
|
2241
|
+
const useDeviceFlow = body.device === true;
|
|
2171
2242
|
const requestedAccountId = body.id?.trim();
|
|
2172
2243
|
const reauth = body.reauth === true;
|
|
2173
2244
|
if (requestedAccountId && !isValidCodexAccountId(requestedAccountId)) {
|
|
@@ -2197,13 +2268,20 @@ export async function handleCodexAuthAPI(
|
|
|
2197
2268
|
codexAuthLoginState.set(flowId, loginOwner);
|
|
2198
2269
|
try {
|
|
2199
2270
|
const { startLoginFlow, getLoginStatus, publicOAuthAuthenticationErrorMessage } = await import("../oauth");
|
|
2200
|
-
const result = await startLoginFlow("chatgpt", {
|
|
2271
|
+
const result = await startLoginFlow("chatgpt", {
|
|
2272
|
+
forceLogin: true,
|
|
2273
|
+
...(useDeviceFlow ? { flow: "device" as const } : {}),
|
|
2274
|
+
});
|
|
2201
2275
|
|
|
2202
2276
|
// Open the browser server-side (same pattern as /api/oauth/login in management-api.ts).
|
|
2203
2277
|
// The GUI's window.open is popup-blocked because it runs after an await, not a direct click.
|
|
2204
2278
|
// Both login routes share one resolver so this surface cannot drift from the other.
|
|
2205
2279
|
const { shouldOpenBrowserForLogin } = await import("../oauth/open-browser-choice");
|
|
2206
|
-
|
|
2280
|
+
// A device flow's URL is a verification page the user opens on ANOTHER
|
|
2281
|
+
// machine. Opening it on the hub host is useless at best, and on a
|
|
2282
|
+
// headless host it fails. `deviceCode` is the same signal the generic
|
|
2283
|
+
// OAuth login route uses to make this decision.
|
|
2284
|
+
if (result.url && !result.deviceCode && shouldOpenBrowserForLogin(body.openBrowser, runtimeConfig)) {
|
|
2207
2285
|
const { openUrl } = await import("../lib/open-url");
|
|
2208
2286
|
openUrl(result.url);
|
|
2209
2287
|
}
|
|
@@ -2211,7 +2289,14 @@ export async function handleCodexAuthAPI(
|
|
|
2211
2289
|
(async () => {
|
|
2212
2290
|
try {
|
|
2213
2291
|
let completed = false;
|
|
2214
|
-
|
|
2292
|
+
// The device grant lives 15 minutes and the whole point is that the
|
|
2293
|
+
// user walks to another device to enter the code. A 5-minute server
|
|
2294
|
+
// budget would kill the flow at minute five while the grant is still
|
|
2295
|
+
// valid. The extra 30 attempts past 450 are settlement margin: a user
|
|
2296
|
+
// who authorizes in the final seconds still needs the token exchange
|
|
2297
|
+
// and credential write to land before this loop gives up.
|
|
2298
|
+
const pollAttempts = useDeviceFlow ? 480 : 150;
|
|
2299
|
+
for (let i = 0; i < pollAttempts; i++) {
|
|
2215
2300
|
await new Promise(r => setTimeout(r, 2000));
|
|
2216
2301
|
const st = getLoginStatus("chatgpt");
|
|
2217
2302
|
if (st.done && st.loggedIn) {
|
|
@@ -2437,7 +2522,15 @@ export async function handleCodexAuthAPI(
|
|
|
2437
2522
|
})();
|
|
2438
2523
|
|
|
2439
2524
|
setCodexLoginState(flowId, { status: "pending" });
|
|
2440
|
-
return jsonResponse({
|
|
2525
|
+
return jsonResponse({
|
|
2526
|
+
ok: true,
|
|
2527
|
+
flowId,
|
|
2528
|
+
url: result.url,
|
|
2529
|
+
instructions: result.instructions,
|
|
2530
|
+
// Dropped before #3366: every device-code surface renders this field,
|
|
2531
|
+
// so withholding it left the GUI and CLI with no code to show.
|
|
2532
|
+
...(result.deviceCode ? { deviceCode: result.deviceCode } : {}),
|
|
2533
|
+
});
|
|
2441
2534
|
} catch (e) {
|
|
2442
2535
|
if (codexAuthLoginState.get(flowId) === loginOwner) codexAuthLoginState.delete(flowId);
|
|
2443
2536
|
const msg = e instanceof Error ? e.message : String(e);
|
|
@@ -35,7 +35,7 @@ import upstreamModelsSnapshot from "../data/upstream-models.json";
|
|
|
35
35
|
import { generatedModelMetadata, readCatalog, readCodexCatalogPath } from "./parsing";
|
|
36
36
|
import type { CatalogModel, RawEntry } from "./parsing";
|
|
37
37
|
import { UPSTREAM_NATIVE_ENTRIES } from "./metadata";
|
|
38
|
-
import { nativeOpenAiCapabilitySourceSlug } from "./native-models";
|
|
38
|
+
import { nativeOpenAiCapabilitySourceSlug, SELF_DESCRIBED_NATIVE_OPENAI_MODELS } from "./native-models";
|
|
39
39
|
import { loadBundledCodexCatalog } from "./bundled";
|
|
40
40
|
import type { BundledCatalogDeps, ReadonlyRawCatalog } from "./bundled";
|
|
41
41
|
import { deriveEntry } from "./sync";
|
|
@@ -258,8 +258,21 @@ export function applyReasoningLevels(
|
|
|
258
258
|
: efforts.find(effort => effort !== "none" && effort !== "minimal") ?? efforts[0];
|
|
259
259
|
}
|
|
260
260
|
|
|
261
|
+
/**
|
|
262
|
+
* Native slugs entitled to the full GPT-5.6-era ladder (low..ultra, with max restored).
|
|
263
|
+
*
|
|
264
|
+
* The name is historical: membership is about the LADDER, not the model generation. `gpt-6-astra`
|
|
265
|
+
* qualifies because upstream ships it with the same six rungs
|
|
266
|
+
* (`supported_reasoning_levels` low/medium/high/xhigh/max/ultra, #42607). It used to qualify only
|
|
267
|
+
* as a side effect of borrowing Sol's capability source; once it became self-described that
|
|
268
|
+
* accident disappeared, and the sync path's else-branch
|
|
269
|
+
* (`applyReasoningLevels(entry, ["low","medium","high","xhigh"])`) would have truncated the
|
|
270
|
+
* shipped ladder, silently dropping `max` and `ultra`.
|
|
271
|
+
*/
|
|
261
272
|
export function isGpt56NativeSlug(slug: string): boolean {
|
|
262
|
-
|
|
273
|
+
if (slug.includes("/")) return false;
|
|
274
|
+
if (SELF_DESCRIBED_NATIVE_OPENAI_MODELS.has(slug)) return true;
|
|
275
|
+
return nativeOpenAiCapabilitySourceSlug(slug).startsWith("gpt-5.6-");
|
|
263
276
|
}
|
|
264
277
|
|
|
265
278
|
export function ensureGpt56ReasoningLevels(entry: RawEntry): void {
|