@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
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Meta's subscription-usage SSE frame.
|
|
3
|
+
*
|
|
4
|
+
* Meta publishes no quota endpoint — 17 plausible REST paths were probed and every one
|
|
5
|
+
* 404s, and no `x-ratelimit-*` header appears on any of three measured request shapes
|
|
6
|
+
* (devlog/_plan/260903_muse_spark_plan_oauth/003 §E). The only machine-readable usage
|
|
7
|
+
* Meta emits arrives mid-stream, as one extra event alongside the ordinary
|
|
8
|
+
* `response.*` sequence on a streaming `POST /v1/responses`.
|
|
9
|
+
*
|
|
10
|
+
* That inverts the usual seam: this module is fed by the request path, not by a probe,
|
|
11
|
+
* and nothing can refresh its output on demand — obtaining a newer value would mean
|
|
12
|
+
* spending a real inference turn.
|
|
13
|
+
*
|
|
14
|
+
* Measured payload (2026-09-03):
|
|
15
|
+
*
|
|
16
|
+
* ```json
|
|
17
|
+
* { "type": "response.subscription_usage",
|
|
18
|
+
* "subscription": {
|
|
19
|
+
* "tier": "27681393394859588",
|
|
20
|
+
* "window": { "used_percent": 0, "resets_at": 1788431188, "window_duration_mins": 300 },
|
|
21
|
+
* "weekly": { "used_percent": 0, "resets_at": 1788739200 } } }
|
|
22
|
+
* ```
|
|
23
|
+
*/
|
|
24
|
+
import { asRecord, normalizePercent, normalizeResetAt, toFiniteNumber } from "./quota-wire";
|
|
25
|
+
import type { ProviderQuota, ProviderQuotaWindow } from "./quota-types";
|
|
26
|
+
|
|
27
|
+
/** The SSE frame type Meta emits on streaming turns. */
|
|
28
|
+
export const MUSE_SUBSCRIPTION_USAGE_TYPE = "response.subscription_usage";
|
|
29
|
+
|
|
30
|
+
/** Meta's five-hour window, identified by its declared duration rather than assumed. */
|
|
31
|
+
const FIVE_HOUR_WINDOW_MINS = 300;
|
|
32
|
+
|
|
33
|
+
/** True when a parsed SSE payload is the subscription-usage frame. */
|
|
34
|
+
export function isMuseSubscriptionUsagePayload(payload: unknown): boolean {
|
|
35
|
+
return asRecord(payload)?.type === MUSE_SUBSCRIPTION_USAGE_TYPE;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Translate the frame into a `ProviderQuota`.
|
|
40
|
+
*
|
|
41
|
+
* Returns null — never throws — for anything unrecognizable. This runs inside SSE
|
|
42
|
+
* inspection on a live request, where the only acceptable failure is silence: a parse
|
|
43
|
+
* error must not cost the user their turn.
|
|
44
|
+
*
|
|
45
|
+
* `subscription.tier` is deliberately dropped. It is an opaque numeric id, not the plan
|
|
46
|
+
* label the Muse CLI prints, so surfacing it would show a meaningless number.
|
|
47
|
+
*/
|
|
48
|
+
export function parseMuseSubscriptionUsage(payload: unknown): ProviderQuota | null {
|
|
49
|
+
const subscription = asRecord(asRecord(payload)?.subscription);
|
|
50
|
+
if (!subscription) return null;
|
|
51
|
+
|
|
52
|
+
const quota: ProviderQuota = { updatedAt: Date.now() };
|
|
53
|
+
let sawWindow = false;
|
|
54
|
+
|
|
55
|
+
const window = asRecord(subscription.window);
|
|
56
|
+
if (window) {
|
|
57
|
+
const percent = normalizePercent(window.used_percent);
|
|
58
|
+
const resetAt = normalizeResetAt(window.resets_at);
|
|
59
|
+
const durationMins = toFiniteNumber(window.window_duration_mins);
|
|
60
|
+
if (percent !== undefined) {
|
|
61
|
+
if (durationMins === FIVE_HOUR_WINDOW_MINS) {
|
|
62
|
+
quota.fiveHourPercent = percent;
|
|
63
|
+
if (resetAt !== undefined) quota.fiveHourResetAt = resetAt;
|
|
64
|
+
sawWindow = true;
|
|
65
|
+
} else {
|
|
66
|
+
// A window of some other length is NOT forced into the five-hour slot: filing a
|
|
67
|
+
// ten-hour window there would understate usage by the ratio of the two windows,
|
|
68
|
+
// and would do so with full confidence. Carry it with its real duration instead.
|
|
69
|
+
const custom: ProviderQuotaWindow = {
|
|
70
|
+
label: durationMins === undefined ? "subscription" : `${durationMins}m`,
|
|
71
|
+
percent,
|
|
72
|
+
...(resetAt !== undefined ? { resetAt } : {}),
|
|
73
|
+
};
|
|
74
|
+
quota.customWindows = [...(quota.customWindows ?? []), custom];
|
|
75
|
+
sawWindow = true;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const weekly = asRecord(subscription.weekly);
|
|
81
|
+
if (weekly) {
|
|
82
|
+
const percent = normalizePercent(weekly.used_percent);
|
|
83
|
+
if (percent !== undefined) {
|
|
84
|
+
quota.weeklyPercent = percent;
|
|
85
|
+
const resetAt = normalizeResetAt(weekly.resets_at);
|
|
86
|
+
if (resetAt !== undefined) quota.weeklyResetAt = resetAt;
|
|
87
|
+
sawWindow = true;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Either window may be absent independently, but a payload carrying neither says
|
|
92
|
+
// nothing — returning a bare `updatedAt` would publish an empty row that the GUI
|
|
93
|
+
// would render as a quota with no bars.
|
|
94
|
+
return sawWindow ? quota : null;
|
|
95
|
+
}
|
package/src/providers/quota.ts
CHANGED
|
@@ -1409,6 +1409,27 @@ async function fetchKiroQuota(provider: string): Promise<ProviderQuotaReport | n
|
|
|
1409
1409
|
return report(provider, "kiro:usage-limits", snapshot.quota);
|
|
1410
1410
|
}
|
|
1411
1411
|
|
|
1412
|
+
/**
|
|
1413
|
+
* Provider-level row for a passive provider: the ACTIVE account's last observed
|
|
1414
|
+
* subscription windows, the same shape `fetchAnthropicQuota` and `fetchKiroQuota`
|
|
1415
|
+
* return.
|
|
1416
|
+
*
|
|
1417
|
+
* Cache-only. A dashboard load or `ocx account refresh` must never spend an inference
|
|
1418
|
+
* turn, so `forceRefresh` does not exist on this path — there is nothing to refresh.
|
|
1419
|
+
* `report.updatedAt` is the observation time, which is what both GUI surfaces render
|
|
1420
|
+
* as the relative age of the row.
|
|
1421
|
+
*/
|
|
1422
|
+
async function fetchPassiveProviderQuota(provider: string): Promise<ProviderQuotaReport | null> {
|
|
1423
|
+
const activeId = getAccountSet(provider)?.activeAccountId;
|
|
1424
|
+
if (!activeId) return null;
|
|
1425
|
+
// Idempotent; without it a proxy restart shows nothing until the next streaming turn
|
|
1426
|
+
// even though the last observation is on disk.
|
|
1427
|
+
hydrateAccountQuotaCache();
|
|
1428
|
+
const entry = accountQuotaCache.get(accountCacheKey(provider, activeId));
|
|
1429
|
+
if (!entry?.quota) return null;
|
|
1430
|
+
return report(provider, `${provider}:subscription-observation`, entry.quota);
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1412
1433
|
// ---------------------------------------------------------------------------
|
|
1413
1434
|
// Per-account quota (multiauth)
|
|
1414
1435
|
// ---------------------------------------------------------------------------
|
|
@@ -1505,6 +1526,78 @@ export function setCachedProviderAccountQuotaForTests(
|
|
|
1505
1526
|
accountQuotaCache.set(key, { ts: Date.now(), quota });
|
|
1506
1527
|
}
|
|
1507
1528
|
|
|
1529
|
+
/**
|
|
1530
|
+
* Providers whose per-account quota is OBSERVED in-band, never probed.
|
|
1531
|
+
*
|
|
1532
|
+
* Deliberately separate from `supportsPerAccountQuota` rather than folded into it. That
|
|
1533
|
+
* predicate gates `fetchAccountQuota`, whose fallback branch sends any
|
|
1534
|
+
* non-Kiro/non-Antigravity bearer to Anthropic's usage endpoint — so adding `meta-muse`
|
|
1535
|
+
* there without a dedicated branch would ship a Meta credential to Anthropic. And even
|
|
1536
|
+
* with a branch it would be the wrong predicate: it means "this provider can be probed",
|
|
1537
|
+
* and Meta publishes no quota endpoint to probe.
|
|
1538
|
+
*/
|
|
1539
|
+
export function hasPassiveAccountQuota(provider: string): boolean {
|
|
1540
|
+
return provider === "meta-muse";
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
/**
|
|
1544
|
+
* Record a quota observed in-band on a streaming turn.
|
|
1545
|
+
*
|
|
1546
|
+
* The CALLER captures `writerGeneration` when it resolves the serving credential, not
|
|
1547
|
+
* this function at write time. A streaming turn is a long await, and a generation
|
|
1548
|
+
* captured immediately before the write cannot see a config or account change that
|
|
1549
|
+
* happened EARLIER in the same turn — which is exactly the case the fence exists for.
|
|
1550
|
+
*/
|
|
1551
|
+
export function recordPassiveAccountQuota(
|
|
1552
|
+
provider: string,
|
|
1553
|
+
accountId: string,
|
|
1554
|
+
quota: ProviderQuota,
|
|
1555
|
+
writerGeneration: number,
|
|
1556
|
+
): void {
|
|
1557
|
+
if (!hasPassiveAccountQuota(provider) || !accountId) return;
|
|
1558
|
+
const key = accountCacheKey(provider, accountId);
|
|
1559
|
+
if (!mayCommitAccountQuotaKey(key, writerGeneration)) return;
|
|
1560
|
+
// Hydrate BEFORE writing, not only on the read path. `persistAccountQuotaCache`
|
|
1561
|
+
// serializes the whole in-memory map, so a passive write that lands before anything
|
|
1562
|
+
// has read the cache would persist this one row and erase every other provider's
|
|
1563
|
+
// saved row -- and `diskHydrated` would then stop any later reader from recovering
|
|
1564
|
+
// them. A probe writer cannot hit this because its own read hydrates first; an
|
|
1565
|
+
// observation arrives unprompted, so it must hydrate itself.
|
|
1566
|
+
hydrateAccountQuotaCache();
|
|
1567
|
+
accountQuotaCache.set(key, { ts: Date.now(), quota });
|
|
1568
|
+
// Persisted so a restart keeps the last observation: with no probe to re-establish it,
|
|
1569
|
+
// a forgotten row stays forgotten until the user happens to run another streaming turn.
|
|
1570
|
+
persistAccountQuotaCache();
|
|
1571
|
+
// sweepExpiredOnWrite is deliberately NOT called. Existing probe writers call it
|
|
1572
|
+
// because they run on a poll; this runs on the request path, where a state sweep does
|
|
1573
|
+
// not belong. Passive rows are still reclaimed by generation reconciliation
|
|
1574
|
+
// (reconcileProviderAccountQuotaRows) and by the disk reader's age bound.
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1577
|
+
/**
|
|
1578
|
+
* Cache-only per-account rows for a passive provider. Never probes, never refreshes.
|
|
1579
|
+
*
|
|
1580
|
+
* An account with no observation is OMITTED rather than returned with `quota: null` and
|
|
1581
|
+
* `unavailable`: that pair means "a probe was attempted and failed", and no probe was
|
|
1582
|
+
* ever attempted here. A user who has not yet run a streaming turn simply has no
|
|
1583
|
+
* measurement, which is not an error state.
|
|
1584
|
+
*/
|
|
1585
|
+
export function readPassiveProviderAccountQuotas(provider: string): ProviderAccountQuota[] {
|
|
1586
|
+
if (!hasPassiveAccountQuota(provider)) return [];
|
|
1587
|
+
// Idempotent, and otherwise only reached from probe paths a passive provider never
|
|
1588
|
+
// enters — without it a restart shows nothing until the next streaming turn, even
|
|
1589
|
+
// though the row is sitting on disk.
|
|
1590
|
+
hydrateAccountQuotaCache();
|
|
1591
|
+
const set = getAccountSet(provider);
|
|
1592
|
+
if (!set) return [];
|
|
1593
|
+
const rows: ProviderAccountQuota[] = [];
|
|
1594
|
+
for (const account of set.accounts) {
|
|
1595
|
+
const entry = accountQuotaCache.get(accountCacheKey(provider, account.id));
|
|
1596
|
+
if (entry?.quota) rows.push({ accountId: account.id, quota: entry.quota });
|
|
1597
|
+
}
|
|
1598
|
+
return rows;
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1508
1601
|
export function sweepExpiredProviderAccountQuotaRows(now = Date.now()): number {
|
|
1509
1602
|
let removed = 0;
|
|
1510
1603
|
for (const [key, entry] of accountQuotaCache) {
|
|
@@ -2299,6 +2392,9 @@ async function maybeFetchProviderQuota(
|
|
|
2299
2392
|
if (provider.authMode === "oauth" && name === "cursor") return fetchCursorQuota(name);
|
|
2300
2393
|
if (provider.authMode === "oauth" && name === "google-antigravity") return fetchAntigravityQuota(name, provider);
|
|
2301
2394
|
if (provider.authMode === "oauth" && name === "kiro") return fetchKiroQuota(name);
|
|
2395
|
+
// Passive providers (meta-muse): Meta publishes no quota endpoint, so there is no
|
|
2396
|
+
// probe to run — the row is the active account's last in-band observation.
|
|
2397
|
+
if (provider.authMode === "oauth" && hasPassiveAccountQuota(name)) return fetchPassiveProviderQuota(name);
|
|
2302
2398
|
// Kimi Code `/usages` accepts OAuth or coding-plan API keys, but only on the canonical
|
|
2303
2399
|
// host and only for real key auth — forward/local modes carry no credential of ours.
|
|
2304
2400
|
if (provider.authMode === "oauth" && name === "kimi") return fetchKimiQuota(name, provider);
|
|
@@ -1540,7 +1540,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
|
|
|
1540
1540
|
modelInputModalities: Object.fromEntries(META_MUSE_MODELS.map(id => [id, ["text", "image"] as ["text", "image"]])),
|
|
1541
1541
|
modelReasoningEfforts: Object.fromEntries(META_MUSE_MODELS.map(id => [id, META_MUSE_REASONING_EFFORTS])),
|
|
1542
1542
|
modelReasoningEffortMap: Object.fromEntries(META_MUSE_MODELS.map(id => [id, META_MUSE_REASONING_EFFORT_MAP])),
|
|
1543
|
-
note: "Reuses the API key the Muse Code CLI stores after `muse login` (macOS only; requires the CLI installed and signed in). Meta scopes that credential to the Muse Code CLI, so this is an UNSUPPORTED use: Meta does not authorize subscription coverage outside its own CLI, how these calls settle is not observable from the API, and you should treat every call as billable against your account. The imported key is copied into OpenCodex's auth store. Meta
|
|
1543
|
+
note: "Reuses the API key the Muse Code CLI stores after `muse login` (macOS only; requires the CLI installed and signed in). Meta scopes that credential to the Muse Code CLI, so this is an UNSUPPORTED use: Meta does not authorize subscription coverage outside its own CLI, how these calls settle is not observable from the API, and you should treat every call as billable against your account. The imported key is copied into OpenCodex's auth store. OpenCodex reads Meta's subscription windows from streaming responses and shows the last observed value with its age; there is no endpoint to query them on demand, so refreshing one requires another streaming turn, and translated (non-passthrough) turns report none. Rate limits apply per team, not per key. For a supported path use the meta-model provider with your own key (export it as META_MODEL_API_KEY).",
|
|
1544
1544
|
},
|
|
1545
1545
|
{
|
|
1546
1546
|
id: "umans",
|
package/src/server/index.ts
CHANGED
|
@@ -812,13 +812,21 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
|
|
|
812
812
|
if (path === "/v1/responses/compact") return req.method === "POST";
|
|
813
813
|
if (path === "/v1/alpha/search") return req.method === "POST";
|
|
814
814
|
if (path === "/v1/models") return req.method === "GET";
|
|
815
|
-
//
|
|
816
|
-
//
|
|
817
|
-
//
|
|
818
|
-
//
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
}
|
|
815
|
+
// Realtime voice — a directly-spawned `codex app-server` needs these for desktop voice
|
|
816
|
+
// the same way it needs /v1/responses. Two shapes, same trust model as /v1/responses:
|
|
817
|
+
// - standalone sessions (codex-rs thread/realtime/start, WebSocket transport):
|
|
818
|
+
// WebSocket upgrades on the bare /v1/realtime and /v1/live paths only;
|
|
819
|
+
// - WebRTC calls (desktop v3 voice): POST call-create on /v1/live or
|
|
820
|
+
// /v1/realtime/calls, then the sideband join as a WebSocket upgrade on the keyed
|
|
821
|
+
// /v1/live/{callId}, /v1/realtime/calls/{callId}, or /v1/realtime?call_id= form
|
|
822
|
+
// (the join reaches this listener through the injected
|
|
823
|
+
// experimental_realtime_ws_base_url; openai/codex #35830).
|
|
824
|
+
// Plain HTTP on the upgrade paths stays rejected.
|
|
825
|
+
const isWebSocketUpgrade = req.headers.get("upgrade")?.toLowerCase() === "websocket";
|
|
826
|
+
if (path === "/v1/realtime") return isWebSocketUpgrade;
|
|
827
|
+
if (path === "/v1/live") return isWebSocketUpgrade || req.method === "POST";
|
|
828
|
+
if (path === "/v1/realtime/calls") return req.method === "POST";
|
|
829
|
+
if (/^\/v1\/(?:live|realtime\/calls)\/[^/]+\/?$/.test(path)) return isWebSocketUpgrade;
|
|
822
830
|
return false;
|
|
823
831
|
}
|
|
824
832
|
|
package/src/server/live.ts
CHANGED
|
@@ -147,6 +147,20 @@ function clientProtocolHeaders(reqHeaders: Headers): Record<string, string> {
|
|
|
147
147
|
|
|
148
148
|
const LIVE_CALL_ID_RE = /^[A-Za-z0-9_-]{1,128}$/;
|
|
149
149
|
|
|
150
|
+
/**
|
|
151
|
+
* Decode one path-segment call id. A malformed percent escape (`%ZZ`) makes
|
|
152
|
+
* `decodeURIComponent` throw; that must read as "not a sideband target" (JSON 404),
|
|
153
|
+
* never escape the router as a 500.
|
|
154
|
+
*/
|
|
155
|
+
function decodeLiveCallId(segment: string): string | null {
|
|
156
|
+
try {
|
|
157
|
+
const callId = decodeURIComponent(segment);
|
|
158
|
+
return LIVE_CALL_ID_RE.test(callId) ? callId : null;
|
|
159
|
+
} catch {
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
150
164
|
/**
|
|
151
165
|
* Credential-shaped query keys never forwarded upstream on a standalone realtime
|
|
152
166
|
* relay. Auth on the upstream socket is proxy-owned (headers resolved by
|
|
@@ -238,8 +252,8 @@ function httpsToWss(httpUrl: string): string {
|
|
|
238
252
|
export function parseLiveSidebandTarget(pathname: string, searchParams: URLSearchParams, rawQuery = ""): LiveSidebandTarget | null {
|
|
239
253
|
const liveMatch = pathname.match(/^\/v1\/live\/([^/]+)\/?$/);
|
|
240
254
|
if (liveMatch) {
|
|
241
|
-
const callId =
|
|
242
|
-
if (!
|
|
255
|
+
const callId = decodeLiveCallId(liveMatch[1]!);
|
|
256
|
+
if (!callId) return null;
|
|
243
257
|
return { style: "frameless-path", callId };
|
|
244
258
|
}
|
|
245
259
|
// Standalone Frameless session (no call-create): `GET /v1/live?model=`.
|
|
@@ -248,8 +262,8 @@ export function parseLiveSidebandTarget(pathname: string, searchParams: URLSearc
|
|
|
248
262
|
}
|
|
249
263
|
const callsMatch = pathname.match(/^\/v1\/realtime\/calls\/([^/]+)\/?$/);
|
|
250
264
|
if (callsMatch) {
|
|
251
|
-
const callId =
|
|
252
|
-
if (!
|
|
265
|
+
const callId = decodeLiveCallId(callsMatch[1]!);
|
|
266
|
+
if (!callId) return null;
|
|
253
267
|
return { style: "realtime-calls-path", callId };
|
|
254
268
|
}
|
|
255
269
|
if (pathname === "/v1/realtime" || pathname === "/v1/realtime/") {
|
|
@@ -30,7 +30,7 @@ import { enrichProviderFromCatalog, listKeyLoginProviders } from "../../oauth/ke
|
|
|
30
30
|
import { deriveProviderPresets } from "../../providers/derive";
|
|
31
31
|
import { providerCodexAccountMode } from "../../providers/registry";
|
|
32
32
|
import { routedSlug, slugEquals } from "../../providers/slug-codec";
|
|
33
|
-
import { clearAccountQuotaCache, clearProviderQuotaCache, fetchProviderAccountQuotas, fetchProviderQuotaReports, supportsPerAccountQuota } from "../../providers/quota";
|
|
33
|
+
import { clearAccountQuotaCache, clearProviderQuotaCache, fetchProviderAccountQuotas, fetchProviderQuotaReports, hasPassiveAccountQuota, readPassiveProviderAccountQuotas, supportsPerAccountQuota } from "../../providers/quota";
|
|
34
34
|
import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers";
|
|
35
35
|
import { clearThreadAccountMap } from "../../codex/routing";
|
|
36
36
|
import {
|
|
@@ -282,11 +282,18 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
|
|
|
282
282
|
// account can show its own 5h/weekly bars (not just the active one). Opt-in via ?quota=1
|
|
283
283
|
// so the plain account list stays a cheap local read; ?refresh=1 bypasses the TTL.
|
|
284
284
|
const wantQuota = url.searchParams.get("quota") === "1" && supportsPerAccountQuota(provider);
|
|
285
|
-
|
|
285
|
+
// Meta publishes no quota endpoint: its usage is observed in-band on streaming turns
|
|
286
|
+
// and read back from the cache here. `?refresh=1` is accepted and ignored on this
|
|
287
|
+
// path rather than rejected -- the GUI sends it for every provider on a manual
|
|
288
|
+
// refresh, and a 400 would report an error for what is simply a no-op.
|
|
289
|
+
const passiveQuota = url.searchParams.get("quota") === "1" && hasPassiveAccountQuota(provider);
|
|
290
|
+
if (!wantQuota && !passiveQuota) return jsonResponse(projectAccounts());
|
|
286
291
|
const forceRefresh = url.searchParams.get("refresh") === "1";
|
|
287
292
|
// Probing may refresh the active credential and mark needsReauth — project health
|
|
288
293
|
// from the post-probe store so the response is not stale.
|
|
289
|
-
const rows =
|
|
294
|
+
const rows = passiveQuota
|
|
295
|
+
? readPassiveProviderAccountQuotas(provider)
|
|
296
|
+
: await fetchProviderAccountQuotas(provider, forceRefresh);
|
|
290
297
|
const byId = new Map(rows.map(row => [row.accountId, row]));
|
|
291
298
|
const projected = projectAccounts();
|
|
292
299
|
return jsonResponse({
|
|
@@ -219,6 +219,9 @@ import {
|
|
|
219
219
|
waitForProviderRequestSlot,
|
|
220
220
|
} from "../../providers/request-pacing";
|
|
221
221
|
import { slugsEquivalent } from "../../providers/slug-codec";
|
|
222
|
+
import { isMuseSubscriptionUsagePayload, parseMuseSubscriptionUsage } from "../../providers/muse-subscription-usage";
|
|
223
|
+
import { hasPassiveAccountQuota, recordPassiveAccountQuota } from "../../providers/quota";
|
|
224
|
+
import { captureConfigGeneration } from "../../lib/state-store-sweeper";
|
|
222
225
|
import { applyOpenAiVirtualModel, resolveOpenAiCompactModel } from "../../providers/openai-virtual-models";
|
|
223
226
|
import { isUsageDebugEnabled } from "../../usage/debug";
|
|
224
227
|
import { readJsonRequestBody, DecompressedBodyTooLargeError, UnsupportedContentEncodingError } from "../request-decompress";
|
|
@@ -3308,6 +3311,13 @@ async function handleResponsesInner(
|
|
|
3308
3311
|
// the request actually used, so a concurrent rotation cannot cool an innocent replacement.
|
|
3309
3312
|
let genericFailoverAccountId: string | null = null;
|
|
3310
3313
|
let genericFailovers = 0;
|
|
3314
|
+
/**
|
|
3315
|
+
* Config generation captured where the serving credential is RESOLVED, not where the
|
|
3316
|
+
* quota is written. A streaming turn is a long await, so a generation captured at write
|
|
3317
|
+
* time cannot see a config or account change that happened earlier in the same turn —
|
|
3318
|
+
* the case the fence exists for. Stays 0 for every provider without a passive quota.
|
|
3319
|
+
*/
|
|
3320
|
+
let passiveQuotaWriterGeneration = 0;
|
|
3311
3321
|
/**
|
|
3312
3322
|
* Apply a rotated account's FULL credential snapshot to the live route (#2568d).
|
|
3313
3323
|
*
|
|
@@ -3443,6 +3453,10 @@ async function handleResponsesInner(
|
|
|
3443
3453
|
if (isGenericFailoverProvider(route.providerName, route.provider)) {
|
|
3444
3454
|
genericFailoverAccountId = resolved.accountId;
|
|
3445
3455
|
}
|
|
3456
|
+
// Captured beside the account it fences, so the two can never disagree.
|
|
3457
|
+
if (hasPassiveAccountQuota(route.providerName)) {
|
|
3458
|
+
passiveQuotaWriterGeneration = captureConfigGeneration();
|
|
3459
|
+
}
|
|
3446
3460
|
if (route.providerName === "kiro") {
|
|
3447
3461
|
// `{}` is intentional: this is an account-scoped request with no stored routing metadata.
|
|
3448
3462
|
// Only genuinely accountless adapter calls leave the context undefined and use local/env fallback.
|
|
@@ -3888,7 +3902,27 @@ async function handleResponsesInner(
|
|
|
3888
3902
|
// check sees nothing undeclared, and the refused turn enters continuation state anyway. So the
|
|
3889
3903
|
// rejection is sticky for the whole turn, set from every parsed payload on the inspection side.
|
|
3890
3904
|
let inspectionSawUndeclaredTool = false;
|
|
3905
|
+
const passiveQuotaObserved = hasPassiveAccountQuota(route.providerName)
|
|
3906
|
+
&& route.provider.authMode === "oauth";
|
|
3891
3907
|
const noteInspectedPayload = (payload: unknown) => {
|
|
3908
|
+
// Meta reports subscription usage ONLY as an in-stream event; there is no endpoint
|
|
3909
|
+
// to poll (003 §E probed 17 paths, all 404). Observed here rather than behind a
|
|
3910
|
+
// dedicated inspector handler because onParsedPayload already reaches every
|
|
3911
|
+
// passthrough shape -- eager relay and both tee consumers -- through this one
|
|
3912
|
+
// function.
|
|
3913
|
+
//
|
|
3914
|
+
// Placed BEFORE the undeclared-tool early return below, which is load-bearing: that
|
|
3915
|
+
// guard latches for the rest of the turn once it fires, and a turn that tripped it
|
|
3916
|
+
// still legitimately reports usage.
|
|
3917
|
+
if (passiveQuotaObserved && isMuseSubscriptionUsagePayload(payload)) {
|
|
3918
|
+
const quota = parseMuseSubscriptionUsage(payload);
|
|
3919
|
+
// Read at EVENT time, not at handler construction: failover rebinds this, and the
|
|
3920
|
+
// quota belongs to the account that actually served the turn.
|
|
3921
|
+
const servingAccountId = genericFailoverAccountId;
|
|
3922
|
+
if (quota && servingAccountId) {
|
|
3923
|
+
recordPassiveAccountQuota(route.providerName, servingAccountId, quota, passiveQuotaWriterGeneration);
|
|
3924
|
+
}
|
|
3925
|
+
}
|
|
3892
3926
|
// Gated on the same flag as the guard itself: with no readable catalog (or a forward-auth
|
|
3893
3927
|
// provider) every name looks undeclared, and flipping this would stop recording continuation
|
|
3894
3928
|
// state for exactly the passthrough traffic the guard deliberately stands down for.
|
|
@@ -176,6 +176,9 @@ export function mergeUsage(
|
|
|
176
176
|
const contextTotalTokens = second.contextTotalTokens ?? first.contextTotalTokens;
|
|
177
177
|
const inputTokens = first.inputTokens + second.inputTokens;
|
|
178
178
|
const outputTokens = first.outputTokens + second.outputTokens;
|
|
179
|
+
// The attempt that produced the content owns the raw wire usage (openai/codex#41980);
|
|
180
|
+
// an empty first attempt may still be the only one that saw it.
|
|
181
|
+
const rawUsage = second.rawUsage ?? first.rawUsage;
|
|
179
182
|
return {
|
|
180
183
|
inputTokens,
|
|
181
184
|
outputTokens,
|
|
@@ -186,6 +189,7 @@ export function mergeUsage(
|
|
|
186
189
|
...(cacheCreationInputTokens !== undefined ? { cacheCreationInputTokens } : {}),
|
|
187
190
|
...(reasoningOutputTokens !== undefined ? { reasoningOutputTokens } : {}),
|
|
188
191
|
...(first.estimated || second.estimated ? { estimated: true } : {}),
|
|
192
|
+
...(rawUsage !== undefined ? { rawUsage } : {}),
|
|
189
193
|
};
|
|
190
194
|
}
|
|
191
195
|
|
package/src/types/request.ts
CHANGED
|
@@ -396,4 +396,12 @@ export interface OcxUsage {
|
|
|
396
396
|
cacheCreationInputTokens?: number;
|
|
397
397
|
reasoningOutputTokens?: number;
|
|
398
398
|
estimated?: boolean;
|
|
399
|
+
/**
|
|
400
|
+
* The raw upstream usage object for Responses-shaped upstreams (openai/codex#41980 parity):
|
|
401
|
+
* codex-rs preserves the complete `response.usage` object through its own pipeline, so fields
|
|
402
|
+
* the proxy does not model (subscription metadata, future counters) must survive the bridged /
|
|
403
|
+
* rebuilt `response.completed` too. Accounting paths read only the canonical fields above; the
|
|
404
|
+
* wire rebuild merges this object's unknown keys back under the normalized values.
|
|
405
|
+
*/
|
|
406
|
+
rawUsage?: Record<string, unknown>;
|
|
399
407
|
}
|